diff --git a/.changeset/sites-ci-init-respects-config.md b/.changeset/sites-ci-init-respects-config.md new file mode 100644 index 0000000..1815261 --- /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; 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/.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/.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/.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/.changeset/sites-link-flag-scope.md b/.changeset/sites-link-flag-scope.md new file mode 100644 index 0000000..889af79 --- /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 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/.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/.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 7d088b9..2487c3c 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/ @@ -383,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]) + 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 @@ -393,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 @@ -403,8 +404,8 @@ 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 -│ │ │ ├── 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 +│ │ │ ├── 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/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list @@ -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`). @@ -1100,19 +1105,19 @@ 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). +│ │ 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] 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) -│ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy) -│ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5) +│ │ ├── 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 [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 @@ -1120,11 +1125,11 @@ 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 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] 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) +│ ├── 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) ├── --profile, -p Profile to use (default: "default") 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 131afcd..abf7a9a 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -4,9 +4,11 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; import { isInteractive } from "../../../core/ui.ts"; +import { loadSiteConfig } from "../config.ts"; import { type SiteSelectorArgs, selectSite, + siteLinkOption, siteOptionBuilder, } from "../interactive.ts"; import { FRAMEWORK_PRESETS } from "./frameworks.ts"; @@ -36,7 +38,7 @@ export const sitesCiInitCommand = defineCommand({ ], builder: (yargs) => - siteOptionBuilder(yargs) + siteLinkOption(siteOptionBuilder(yargs)) .option("framework", { type: "string", choices: FRAMEWORK_PRESETS.map((p) => p.id), @@ -68,12 +70,17 @@ export const sitesCiInitCommand = defineCommand({ ); } + // `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them, relative to the bunny.jsonc directory they resolve against. + const siteConfig = loadSiteConfig(); const result = await scaffoldSitesWorkflow({ site: name, root, + projectRoot: siteConfig?.root, frameworkId: args.framework, interactive, force: args.force, + dir: siteConfig?.config.dir, + build: siteConfig?.config.build, }); if (output === "json") { @@ -84,6 +91,7 @@ export const sitesCiInitCommand = defineCommand({ path: result.path, framework: result.preset.id, packageManager: result.packageManager, + directory: result.dir, }, null, 2, @@ -98,7 +106,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.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 766acbd..683ed0e 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"; @@ -12,8 +12,13 @@ import { type FrameworkPreset, findPreset, type PackageManager, + readPackageJson, } 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,6 +35,86 @@ export interface ScaffoldResult { path: string; preset: FrameworkPreset; packageManager: PackageManager; + /** Directory the workflow deploys, relative to the repo root: `sites.dir` when configured, else the preset's, prefixed when the project sits below the root. */ + dir: string; +} + +// 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, + }; +} + +// 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. */ @@ -37,6 +122,7 @@ async function resolvePreset( root: string, frameworkId: string | undefined, interactive: boolean, + dir: string | undefined, ): Promise { if (frameworkId) { const preset = findPreset(frameworkId); @@ -51,7 +137,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 +158,34 @@ 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; `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, + settings.dir, ); - const packageManager = await detectPackageManager(opts.root); + const packageManager = await detectPackageManager(settings.projectRoot); const content = renderSitesWorkflow({ site: opts.site, preset, packageManager, + dir: settings.dir, + build: settings.build, + workingDirectory: settings.prefix || undefined, + cacheDependencyPath: settings.cacheDependencyPath, + installDeps: await needsJsInstall(preset, settings), }); const target = join(opts.root, SITES_WORKFLOW_PATH); @@ -109,21 +205,45 @@ 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: workflowPath(settings.prefix, settings.dir ?? preset.dir), + }; } /** Print the workflow and setup steps for users who declined the scaffold. */ export async function printWorkflowInstructions( site: string, root: 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(); - logger.log(renderSitesWorkflow({ site, preset, packageManager })); + logger.log( + renderSitesWorkflow({ + site, + preset, + packageManager, + dir: settings.dir, + 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 470a279..51ed287 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -94,6 +94,122 @@ 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"'); +}); + +// 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('run: "npm run build: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", + preset: preset("static"), + packageManager: "npm", + build: "./build.sh", + }); + 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. +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(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", () => { 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..c097870 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -11,55 +11,88 @@ 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 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(preset: FrameworkPreset, pm: PackageManager): string[] { - const build = presetBuildCommand(preset, pm) ?? `${pm} run build`; - return [...JS_SETUP[pm], ` - run: ${build}`]; +function jsSteps( + preset: FrameworkPreset, + pm: PackageManager, + build: string | undefined, + cacheDependencyPath: string | undefined, +): string[] { + return [ + ...jsSetup(pm, cacheDependencyPath), + runStep(build, presetBuildCommand(preset, pm) ?? `${pm} run build`), + ]; } function buildSteps( preset: FrameworkPreset, packageManager: PackageManager, + build: string | undefined, + cacheDependencyPath: string | undefined, + installDeps: boolean | undefined, ): string[] { switch (preset.toolchain) { case "js": - return jsSteps(preset, packageManager); + return jsSteps(preset, packageManager, build, cacheDependencyPath); 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 +102,7 @@ function buildSteps( " with:", ' hugo-version: "latest"', " extended: true", - ` - run: ${preset.build}`, + runStep(build, preset.build), ]; case "python": return [ @@ -77,34 +110,57 @@ 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."]; + 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)]; } } -// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. +/** 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, `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; packageManager: PackageManager; + dir?: string; + build?: string; + workingDirectory?: string; + cacheDependencyPath?: string; + installDeps?: boolean; }): 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:", @@ -120,6 +176,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:", @@ -128,13 +185,19 @@ export function renderSitesWorkflow(opts: { " steps:", " - uses: actions/checkout@v4", "", - ...buildSteps(preset, packageManager), + ...buildSteps( + preset, + packageManager, + opts.build, + opts.cacheDependencyPath, + opts.installDeps, + ), "", ` - 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(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 2c44160..2091ade 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"; @@ -63,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", @@ -80,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", @@ -102,7 +106,17 @@ 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 loadedConfig = loadSiteConfig(); + const siteConfig = loadedConfig?.config; + const configRoot = loadedConfig?.root; + 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; @@ -217,11 +231,14 @@ export const sitesCreateCommand = defineCommand({ const scaffold = await scaffoldSitesWorkflow({ site: name, root, + projectRoot: configRoot, 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 +247,10 @@ export const sitesCreateCommand = defineCommand({ }); } } else { - await printWorkflowInstructions(name, root); + await printWorkflowInstructions(name, root, { + root: configRoot, + ...siteConfig, + }); } } } diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index ac42e96..0f2dc33 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 { @@ -59,12 +64,18 @@ export const sitesDeleteCommand = defineCommand({ site: args.site, link: false, output, + force, }); const { state } = site; 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/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 8eb40b7..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; @@ -157,6 +161,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/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/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 d28e92b..7fa9cfe 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -2,9 +2,9 @@ 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, withSpinner } from "../../../core/ui.ts"; +import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; import { deleteDeployFiles, writeRemoteState } from "../api.ts"; import { DEFAULT_KEEP_DEPLOYS, @@ -14,7 +14,7 @@ import { import { type SiteSelectorArgs, selectSite, - siteOptionBuilder, + sitePositionalBuilder, } from "../interactive.ts"; interface PruneArgs extends SiteSelectorArgs { @@ -22,8 +22,20 @@ 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", + command: "prune [site]", describe: "Delete old deploys, keeping the most recent ones.", examples: [ [ @@ -31,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, @@ -50,6 +63,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)); @@ -57,12 +72,13 @@ export const sitesDeploymentsPruneCommand = defineCommand({ site: args.site, link: false, output, + force: args.force, }); const { state, connection, etag } = site; const victims = pruneVictims( state.deploys, - args.keep ?? DEFAULT_KEEP_DEPLOYS, + keep, state.current, state.previous, ); @@ -76,6 +92,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..a15477a 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -7,12 +7,13 @@ 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 { 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; @@ -61,6 +64,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ site: args.site, link: args.link, output, + force: args.force, }); const { state, connection, etag } = site; @@ -112,6 +116,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/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..a4d4845 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,28 +96,45 @@ 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. + // 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; } -// 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. +function linkDirectory(site: SiteContext, output: OutputFormat): void { + saveManifest(SITES_MANIFEST, { + id: site.state.storageZoneId, + name: site.state.name, + }); + // 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. export async function selectSite( client: CoreClient, args: SiteSelectorArgs & { output: OutputFormat; + force?: boolean; offerCreate?: () => Promise; }, ): Promise { const noLink = async () => {}; + // 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) { 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 linked(site); } const manifest = loadManifest(SITES_MANIFEST); @@ -133,16 +154,15 @@ 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 linked(site); } - 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`.", @@ -196,21 +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) return; - saveManifest(SITES_MANIFEST, { - id: context.state.storageZoneId, - name: context.state.name, - }); - logger.success( - `Linked to ${context.state.name} (${context.state.storageZoneId}).`, - ); + if (await confirm(`Link this directory to ${context.state.name}?`)) { + linkDirectory(context, args.output); + } }, }; } 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/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..8dd38f3 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -7,7 +7,9 @@ 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) + +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 @@ -42,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 @@ -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. @@ -94,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. @@ -121,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. 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. --- @@ -154,6 +158,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 the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json`. +- Pass `--force` on anything with a confirmation (publish, prune, remove, delete); without a TTY they error with a hint rather than waiting on a prompt. +- Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json` and by `--force`, so `sites delete --force` with nothing linked errors instead of prompting. - `--output json` on every command emits machine-readable results (deploy prints `{ id, production, preview, promoted }`).