diff --git a/.changeset/sites-optimizer.md b/.changeset/sites-optimizer.md
new file mode 100644
index 00000000..fe02265c
--- /dev/null
+++ b/.changeset/sites-optimizer.md
@@ -0,0 +1,5 @@
+---
+"@bunny.net/cli": patch
+---
+
+`bunny optimizer enable|disable|status` turns Bunny Optimizer on for any pull zone or site, with `bunny sites optimizer` and `sites.optimizer` in bunny.jsonc for sites
diff --git a/AGENTS.md b/AGENTS.md
index 3488d94d..8e235a7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -550,6 +550,8 @@ Scripts, apps, and sites are each backed by a pull zone, which has a large setti
- **A root `bunny pullzone`** (planned) is the canonical full surface for zones not backing anything, targeted by `--id`.
- **Each setting area is a mountable factory** like `createHostnamesCommands` in `core/hostnames/`, taking `{ commandPath, target, targetPositional, resolve(args), hiddenAliases }`. The resolver is the only per-surface difference: `--id` at the root, the linked manifest for scripts, the CDN endpoint for apps. `targetPositional` appends an optional trailing positional so each mount matches its namespace's convention.
- Canonical term is `pullzone` (matching the dashboard and API); `pz` is a hidden alias.
+- **Separately billed CDN products get a root command** (`bunny optimizer`) as well as an owner mount, so they work on any pull zone. Root mounts resolve through `resolvePullZoneTarget` in `commands/pz/target.ts`: an explicit ID or name, then the first linked `PullZoneSource` (each owner exports one, e.g. `sitePullZoneSource`), then a picker that offers `pz link`.
+- **Owner policy travels with the target**, not the mount: a site target carries minify-off and the `sites.optimizer` sync, so `bunny optimizer` in a site directory behaves like `bunny sites optimizer`. Tools stay owner-neutral and only ever add to a zone's existing settings (Optimizer adds its image parameters to the cache key; it never narrows it).
---
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 0f665dbf..6aa4b99e 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -1077,6 +1077,12 @@ bunny sites show # resources, domains, SSL
bunny sites open --print
bunny sites ssl --no-force-ssl
+# Bunny Optimizer: images served as WebP and sized for each device ($9.50/month per site)
+bunny sites optimizer status
+bunny sites optimizer enable # shows the price, confirms, configures the pull zone, purges
+bunny sites optimizer enable my-site --yes # accept the price non-interactively
+bunny sites optimizer disable
+
# CI, linking, maintenance
bunny sites ci init # GitHub Actions: push to main goes live
bunny sites ci init --framework astro
@@ -1087,7 +1093,9 @@ bunny sites delete my-site --keep-storage # typed-name confirmation;
**Client-side routing and 404s.** Single-page apps serve `index.html` for extensionless paths that aren't files (a refresh on `/products/42` returns the app with a 200), while missing assets still 404. This is on automatically when the detected framework is a single-page one (Vite, Create React App, React Router, Angular, Vue CLI, Ember, Preact, Blazor WebAssembly) and the output has a root `index.html` and no `404.html` (a built not-found page wins over the framework heuristic); set `sites.spa` to `true` or `false` in `bunny.jsonc`, or pass `--spa`/`--no-spa` on the deploy, to decide yourself. When neither applies but the output looks client-routed (a single root `index.html` plus scripts), an interactive deploy asks once and saves the answer to `sites.spa`; the flags answer it in scripts. Otherwise a root `404.html` in the output (as Astro, Eleventy, Hugo, and most static generators emit) becomes the site's not-found page, served with a 404 status. The choice is recorded per deploy and follows rollbacks; `sites show` prints the live one.
-Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) and a deploy needs no arguments: `bunny sites deploy --build`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, a Blazor WebAssembly `.csproj`, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos.
+**Bunny Optimizer.** With Optimizer on, a plain `` is served as WebP and scaled down for mobile, with no code changes. `sites optimizer enable` turns it on with automatic optimization, WebP and the Dynamic Image API (`?width=`, `?quality=`, ... for framework image loaders), keys the cache on those image parameters only (so `utm_source` and friends don't split it), turns minification off (frameworks already minify), and purges the cache so old full-size files stop being served. It is billed monthly per site, so it asks before turning on; `--yes` accepts the price in scripts. `disable` turns it off and restores a cache key that ignores query strings. To keep it managed from the repo, set `sites.optimizer` to `true` or `false` in `bunny.jsonc`: every `sites deploy` (including CI from `sites ci init`) brings the pull zone in line before publishing. The first time it turns Optimizer on, an interactive deploy asks; unattended, the config value is the consent. Leaving it unset leaves the pull zone alone, and an interactive deploy with over 1 MB of PNG, JPEG, GIF or WebP images prints a one-line suggestion (set `sites.optimizer: false` to hide it). `sites create` offers it next to the custom domain and saves the answer.
+
+Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`, `optimizer`) and a deploy needs no arguments: `bunny sites deploy --build`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, a Blazor WebAssembly `.csproj`, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos.
**Importing an existing zone.** `sites create --from-zone ` turns a storage zone you already serve through a pull zone (for example one deployed by a community storage action) into a site, so custom hostnames, certificates, and DNS stay as they are. The import only writes the site state and the edge rules that block `/_bunny/` and `/deploys/`; nothing else visitors see changes until the first `sites deploy`, which adds the rest of the site's rules and cache settings and goes live on the existing hostnames (a wildcard hostname never becomes the production domain). Files already at the zone root stay in storage but stop being served after that deploy; delete them yourself once you're happy. The edge blocks `_bunny/` and `deploys/` at any depth, so a nested path such as `docs/_bunny/` or `assets/deploys/` stops being served at import. The import refuses a zone that already has a root `deploys` or `_bunny` entry, a pull zone running an edge script, or anything but exactly one storage-backed pull zone; `--region`, `--tier` and `--domain` don't apply. Other edge rules on the pull zone are left in place. `sites delete` treats an imported site like any other and deletes both zones with everything in them.
@@ -1107,6 +1115,7 @@ Every deploy publishes: the files land in an immutable `deploys//` directory
| `--keep` | `deployments prune` | Number of recent deploys to keep (default 5; live and previous are always kept) |
| `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait up to 10 minutes for DNS then issue it; `--no-force-ssl` keeps HTTP working |
| `--force-ssl` | `ssl` | Force HTTP→HTTPS on the system host; `--no-force-ssl` allows plain HTTP |
+| `--yes`, `-y` | `optimizer enable` | Accept the Optimizer price without the confirmation prompt (alias of `--force`) |
| `--framework` | `ci init` | Framework preset for the workflow's build steps (default: detected) |
| `--force` | `ci init` | Overwrite an existing workflow file |
| `--print` | `open` | Print the URL instead of opening a browser |
@@ -1114,6 +1123,24 @@ Every deploy publishes: the files land in an immutable `deploys//` directory
| `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files |
| `--force`, `-f` | `create --from-zone`, `deployments publish`/`prune`/`delete`, `domains remove`, `delete` | Skip the confirmation prompts |
+### `bunny optimizer`
+
+Turn Bunny Optimizer on or off for any pull zone: images are served as WebP and sized for each device ($9.50/month per pull zone). Turning it on also enables the Dynamic Image API (`?width=`, `?quality=`, ...), adds those parameters to the cache key (other query parameters cache exactly as before), and purges the cache. Minification is left as it is; turning Optimizer off removes the image parameters from the cache key only when Optimizer added them.
+
+The pull zone is an optional positional (ID or name). When omitted it resolves from the directory's linked pull zone (`.bunny/pullzone.json`), then a linked site, then an interactive picker that offers to link the directory. A site, whichever command reaches it, also gets minification turned off and `sites.optimizer` in `bunny.jsonc` kept in step; `bunny sites optimizer` takes a site name instead of a pull zone.
+
+```bash
+bunny optimizer status # linked pull zone or site
+bunny optimizer status 12345 --output json
+bunny optimizer enable my-zone # shows the price, confirms, configures the pull zone, purges
+bunny optimizer enable 12345 --yes # accept the price non-interactively
+bunny optimizer disable
+```
+
+| Flag | Commands | Description |
+| -------------- | -------- | ------------------------------------------------------------------------------ |
+| `--yes`, `-y` | `enable` | Accept the Optimizer price without the confirmation prompt (alias of `--force`) |
+
### `bunny stream`
> **Experimental**: hidden from `--help` and the landing page while it stabilizes.
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index c5555329..ba434f53 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -12,6 +12,7 @@ import { dbNamespace } from "./commands/db/index.ts";
import { dnsNamespace } from "./commands/dns/index.ts";
import { docsCommand } from "./commands/docs.ts";
import { openCommand } from "./commands/open.ts";
+import { optimizerNamespace } from "./commands/optimizer/index.ts";
import { pzNamespace } from "./commands/pz/index.ts";
import { registryNamespace } from "./commands/registry/index.ts";
import { sandboxNamespace } from "./commands/sandbox/index.ts";
@@ -37,6 +38,7 @@ const commands: CommandModule[] = [
sandboxNamespace,
storageNamespace,
sitesNamespace,
+ optimizerNamespace,
configNamespace,
skillsNamespace,
docsCommand,
diff --git a/packages/cli/src/commands/optimizer/index.ts b/packages/cli/src/commands/optimizer/index.ts
new file mode 100644
index 00000000..fca59ec1
--- /dev/null
+++ b/packages/cli/src/commands/optimizer/index.ts
@@ -0,0 +1,22 @@
+import {
+ linkedPullZoneSource,
+ resolvePullZoneTarget,
+} from "@/commands/pz/target.ts";
+import { sitePullZoneSource } from "@/commands/sites/optimizer/shared.ts";
+import { createOptimizerCommands } from "@/core/optimizer/index.ts";
+
+// Resources whose directory links lead to a pull zone; the first linked one wins.
+const sources = [linkedPullZoneSource, sitePullZoneSource];
+
+export const optimizerNamespace = createOptimizerCommands({
+ commandPath: "optimizer",
+ describe:
+ "Serve images as WebP sized for each device on any pull zone (Bunny Optimizer).",
+ positional: {
+ name: "pull-zone",
+ describe:
+ "Pull zone ID or name (uses the linked pull zone or site if omitted)",
+ },
+ resolve: (ref, args, ctx) =>
+ resolvePullZoneTarget(ctx, ref, args.output, sources),
+});
diff --git a/packages/cli/src/commands/pz/target.test.ts b/packages/cli/src/commands/pz/target.test.ts
new file mode 100644
index 00000000..7a28d957
--- /dev/null
+++ b/packages/cli/src/commands/pz/target.test.ts
@@ -0,0 +1,29 @@
+import { expect, test } from "bun:test";
+import { type CoreClient, createToolContext } from "@bunny.net/tools";
+import { type PullZoneSource, resolvePullZoneTarget } from "./target.ts";
+
+const ctx = createToolContext({
+ clients: {
+ core: {
+ GET: () =>
+ Promise.resolve({ data: [{ Id: 9, Name: "cdn", Enabled: true }] }),
+ } as unknown as CoreClient,
+ },
+});
+
+const source = (linked: boolean, name: string): PullZoneSource => ({
+ linked: () => linked,
+ resolve: async () => ({ pullZone: 1, name, label: "Site" }),
+});
+
+test("an explicit pull zone beats links, and the first linked source wins", async () => {
+ const sources = [source(false, "a"), source(true, "b"), source(true, "c")];
+ expect(
+ await resolvePullZoneTarget(ctx, undefined, "json", sources),
+ ).toMatchObject({ name: "b" });
+ expect(await resolvePullZoneTarget(ctx, "cdn", "json", sources)).toEqual({
+ pullZone: 9,
+ name: "cdn",
+ label: "Pull zone",
+ });
+});
diff --git a/packages/cli/src/commands/pz/target.ts b/packages/cli/src/commands/pz/target.ts
new file mode 100644
index 00000000..272e077e
--- /dev/null
+++ b/packages/cli/src/commands/pz/target.ts
@@ -0,0 +1,103 @@
+import type { ToolContext } from "@bunny.net/tools";
+import { type PullZone, pullZonesList } from "@bunny.net/tools/pullzones";
+import { UserError } from "@/core/errors.ts";
+import { loadManifest, saveManifest } from "@/core/manifest.ts";
+import type { OutputFormat } from "@/core/types.ts";
+import { confirm, isInteractive, prompts, withSpinner } from "@/core/ui.ts";
+import { PULL_ZONE_MANIFEST, type PullZoneManifest } from "./constants.ts";
+
+/** A pull zone a command acts on, named the way its owner (a site, a script, a bare zone) is known. */
+export interface PullZoneTarget {
+ pullZone: number;
+ name: string;
+ /** What `name` names, for output rows, e.g. "Site" or "Pull zone". */
+ label: string;
+}
+
+/** A kind of directory link that leads to a pull zone; resources register one so pull-zone commands work from their directories. */
+export interface PullZoneSource {
+ /** Whether the current directory is linked to this kind of resource. */
+ linked(): boolean;
+ resolve(ctx: ToolContext, output: OutputFormat): Promise;
+}
+
+const toTarget = (zone: PullZone): PullZoneTarget => ({
+ pullZone: zone.id,
+ name: zone.name,
+ label: "Pull zone",
+});
+
+async function listZones(ctx: ToolContext): Promise {
+ return withSpinner("Fetching pull zones...", () =>
+ pullZonesList.invoke(ctx, {}),
+ );
+}
+
+function findZone(zones: PullZone[], ref: string): PullZone {
+ const zone = /^\d+$/.test(ref)
+ ? zones.find((z) => z.id === Number(ref))
+ : zones.find((z) => z.name === ref);
+ if (!zone) {
+ throw new UserError(
+ `No pull zone found for "${ref}".`,
+ "Pass a pull zone ID or name (see `bunny pz list`).",
+ );
+ }
+ return zone;
+}
+
+/** `.bunny/pullzone.json`, written by `bunny pz link`. */
+export const linkedPullZoneSource: PullZoneSource = {
+ linked: () => !!loadManifest(PULL_ZONE_MANIFEST).id,
+ resolve: async (ctx) => {
+ const { id } = loadManifest(PULL_ZONE_MANIFEST);
+ return toTarget(findZone(await listZones(ctx), String(id)));
+ },
+};
+
+/** An explicit ID or name, then the first linked source, then a picker that offers to link the directory. */
+export async function resolvePullZoneTarget(
+ ctx: ToolContext,
+ ref: string | undefined,
+ output: OutputFormat,
+ sources: PullZoneSource[],
+): Promise {
+ if (ref) return toTarget(findZone(await listZones(ctx), ref));
+
+ const source = sources.find((s) => s.linked());
+ if (source) return source.resolve(ctx, output);
+
+ if (!isInteractive(output)) {
+ throw new UserError(
+ "No pull zone specified and none linked.",
+ "Pass a pull zone ID or name, or run `bunny pz link`.",
+ );
+ }
+ const zones = await listZones(ctx);
+ if (zones.length === 0) {
+ throw new UserError(
+ "No pull zones found.",
+ 'Run "bunny pz create" to create one.',
+ );
+ }
+ const { selected } = await prompts({
+ type: "select",
+ name: "selected",
+ message: "Select a pull zone:",
+ choices: zones.map((z) => ({ title: `${z.name} (${z.id})`, value: z })),
+ });
+ if (!selected) throw new UserError("A pull zone is required.");
+ const zone = selected as PullZone;
+ if (
+ await confirm(`Link this directory to ${zone.name}?`, {
+ initial: true,
+ optional: true,
+ })
+ ) {
+ saveManifest(PULL_ZONE_MANIFEST, {
+ id: zone.id,
+ name: zone.name,
+ });
+ }
+ return toTarget(zone);
+}
diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts
index 7a6a239d..6763cce8 100644
--- a/packages/cli/src/commands/sites/create.ts
+++ b/packages/cli/src/commands/sites/create.ts
@@ -13,6 +13,7 @@ import { errorMessage, UserError } from "@/core/errors.ts";
import { formatKeyValue } from "@/core/format.ts";
import { normalizeHostname } from "@/core/hostnames/index.ts";
import { logger } from "@/core/logger.ts";
+import { toolContext } from "@/core/tool-context.ts";
import type { OutputFormat } from "@/core/types.ts";
import {
confirm,
@@ -33,6 +34,7 @@ import { loadSiteConfig } from "./config.ts";
import { isValidSiteName } from "./constants.ts";
import { setupSiteDomain } from "./domains/index.ts";
import { saveSiteLink } from "./interactive.ts";
+import { offerOptimizerOnCreate } from "./optimizer/sync.ts";
import { createSiteWithProgress, promptSiteName } from "./provision.ts";
interface CreateArgs {
@@ -382,6 +384,16 @@ export const sitesCreateCommand = defineCommand({
}
}
+ // Optimizer is billed, so it's only offered, defaulting to no; an answer is saved so deploys keep the zone in line with it.
+ if (interactive && siteConfig?.optimizer === undefined) {
+ logger.log();
+ await offerOptimizerOnCreate({
+ ctx: toolContext(config, { verbose }),
+ pullZone: result.state.pullZoneId,
+ site: name,
+ });
+ }
+
// GitHub deployments: offer the workflow scaffold when this is a GitHub repo.
if (interactive) {
const root = await gitTopLevel(process.cwd());
diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts
index 96293a4e..098c43a6 100644
--- a/packages/cli/src/commands/sites/deploy.ts
+++ b/packages/cli/src/commands/sites/deploy.ts
@@ -9,6 +9,7 @@ import { errorMessage, UserError } from "@/core/errors.ts";
import { formatBytes } from "@/core/format.ts";
import { normalizeHostname } from "@/core/hostnames/index.ts";
import { logger } from "@/core/logger.ts";
+import { toolContext } from "@/core/tool-context.ts";
import {
confirm,
isInteractive,
@@ -48,6 +49,7 @@ import {
siteLinkOption,
siteOptionBuilder,
} from "./interactive.ts";
+import { offerOptimizer, syncOptimizer } from "./optimizer/sync.ts";
import { createLinkedSite, promptSiteName } from "./provision.ts";
import { collectFiles, hashFiles, uploadDeploy } from "./uploader.ts";
@@ -459,6 +461,19 @@ export const sitesDeployCommand = defineCommand({
: await fetchSystemHostname(coreClient, state.pullZoneId);
const production = productionUrl(state, systemHost);
+ // Before publishing, so the publish's purge also drops files cached under the old settings.
+ const tools = toolContext(config, { verbose });
+ const optimizer = siteConfig?.config.optimizer;
+ if (optimizer !== undefined) {
+ await syncOptimizer({
+ ctx: tools,
+ pullZone: state.pullZoneId,
+ site: state.name,
+ enabled: optimizer,
+ output,
+ });
+ }
+
if (skipUpload && alreadyLive) {
await withSpinner("Checking routing...", () =>
promoteDeploy({ coreClient, state, deployId }),
@@ -579,6 +594,10 @@ export const sitesDeployCommand = defineCommand({
if (production) logger.info(`Production: ${production}`);
if (notFoundNote) logger.info(notFoundNote);
+ if (optimizer === undefined && isInteractive(output)) {
+ await offerOptimizer({ ctx: tools, pullZone: state.pullZoneId, files });
+ }
+
// Domainless sites: the first deploy offers a custom production domain, later ones just hint.
if (!state.domain) {
logger.log();
diff --git a/packages/cli/src/commands/sites/index.ts b/packages/cli/src/commands/sites/index.ts
index cd5978d4..86c706cc 100644
--- a/packages/cli/src/commands/sites/index.ts
+++ b/packages/cli/src/commands/sites/index.ts
@@ -9,6 +9,7 @@ import { sitesLinkCommand } from "./link.ts";
import { sitesListCommand } from "./list.ts";
import { sitesMigrateCommand } from "./migrate.ts";
import { sitesOpenCommand } from "./open.ts";
+import { sitesOptimizerNamespace } from "./optimizer/index.ts";
import { sitesShowCommand } from "./show.ts";
import { sitesSslCommand } from "./ssl.ts";
import { sitesUnlinkCommand } from "./unlink.ts";
@@ -25,6 +26,7 @@ export const sitesNamespace = defineNamespace(
sitesDeploymentsNamespace,
...sitesDomainsCommands,
sitesSslCommand,
+ sitesOptimizerNamespace,
sitesCiNamespace,
sitesLinkCommand,
sitesUnlinkCommand,
diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts
index 80bbb0a2..b04f8506 100644
--- a/packages/cli/src/commands/sites/interactive.ts
+++ b/packages/cli/src/commands/sites/interactive.ts
@@ -13,7 +13,7 @@ import { fetchSites, type SiteContext, siteContextFromZone } from "./api.ts";
import { loadSiteConfig } from "./config.ts";
import { SITES_MANIFEST, type SiteManifest } from "./constants.ts";
-const ARG_SITE_DESCRIPTION =
+export const ARG_SITE_DESCRIPTION =
"Site name or storage zone ID (uses the linked site if omitted)";
const ARG_LINK_DESCRIPTION =
"Link this directory to the site (prompted when picking one; --no-link never links)";
diff --git a/packages/cli/src/commands/sites/optimizer/index.ts b/packages/cli/src/commands/sites/optimizer/index.ts
new file mode 100644
index 00000000..ec0356ee
--- /dev/null
+++ b/packages/cli/src/commands/sites/optimizer/index.ts
@@ -0,0 +1,15 @@
+import { createOptimizerCommands } from "@/core/optimizer/index.ts";
+import { ARG_SITE_DESCRIPTION } from "../interactive.ts";
+import { siteOptimizerTarget } from "./shared.ts";
+import { resolveSitePullZone } from "./site.ts";
+
+export const sitesOptimizerNamespace = createOptimizerCommands({
+ commandPath: "sites optimizer",
+ describe:
+ "Serve a site's images as WebP sized for each device (Bunny Optimizer).",
+ positional: { name: "site", describe: ARG_SITE_DESCRIPTION },
+ resolve: async (site, args, ctx) =>
+ siteOptimizerTarget(
+ await resolveSitePullZone(ctx, { site, output: args.output }),
+ ),
+});
diff --git a/packages/cli/src/commands/sites/optimizer/shared.ts b/packages/cli/src/commands/sites/optimizer/shared.ts
new file mode 100644
index 00000000..f88d4afa
--- /dev/null
+++ b/packages/cli/src/commands/sites/optimizer/shared.ts
@@ -0,0 +1,55 @@
+import type { PullZoneSource } from "@/commands/pz/target.ts";
+import { logger } from "@/core/logger.ts";
+import { loadManifest } from "@/core/manifest.ts";
+import type { OptimizerTarget } from "@/core/optimizer/index.ts";
+import type { OutputFormat } from "@/core/types.ts";
+import { loadSiteConfig, saveSiteConfig } from "../config.ts";
+import { SITES_MANIFEST, type SiteManifest } from "../constants.ts";
+import { resolveSitePullZone, type SitePullZone } from "./site.ts";
+
+/** Sites keep minification off: every framework already minifies its build. */
+export const SITE_OPTIMIZER_MINIFY = false;
+
+/** Keep `sites.optimizer` in bunny.jsonc from undoing a change on the next deploy; only touches a value that is already set, for the site this directory points at. */
+export function syncOptimizerConfig(
+ site: SitePullZone,
+ enabled: boolean,
+ output: OutputFormat,
+): void {
+ if (!site.fromDirectory) return;
+ const configured = loadSiteConfig()?.config.optimizer;
+ if (configured === undefined || configured === enabled) return;
+ const path = saveSiteConfig({ optimizer: enabled });
+ if (output !== "json") {
+ logger.dim(` Updated sites.optimizer to ${enabled} in ${path}`);
+ }
+}
+
+/** A site as an Optimizer target, carrying the sites policy to whichever command reached it. */
+export function siteOptimizerTarget(site: SitePullZone): OptimizerTarget {
+ return {
+ pullZone: site.pullZone,
+ name: site.name,
+ label: "Site",
+ optimizer: {
+ minify: SITE_OPTIMIZER_MINIFY,
+ onChanged: (enabled, output) =>
+ syncOptimizerConfig(site, enabled, output),
+ enableHint: () =>
+ site.fromDirectory && loadSiteConfig()?.config.optimizer === undefined
+ ? "Keep deploys in line with it: set sites.optimizer to true in bunny.jsonc"
+ : undefined,
+ },
+ };
+}
+
+/** A directory linked to a site (`.bunny/site.json` or `sites.name`) resolves to the site's pull zone. */
+export const sitePullZoneSource: PullZoneSource = {
+ linked: () =>
+ !!(
+ loadManifest(SITES_MANIFEST).id ||
+ loadSiteConfig()?.config.name
+ ),
+ resolve: async (ctx, output) =>
+ siteOptimizerTarget(await resolveSitePullZone(ctx, { output })),
+};
diff --git a/packages/cli/src/commands/sites/optimizer/site.test.ts b/packages/cli/src/commands/sites/optimizer/site.test.ts
new file mode 100644
index 00000000..f1517af0
--- /dev/null
+++ b/packages/cli/src/commands/sites/optimizer/site.test.ts
@@ -0,0 +1,37 @@
+import { expect, test } from "bun:test";
+import { type CoreClient, createToolContext } from "@bunny.net/tools";
+import { resolveSitePullZone } from "./site.ts";
+
+const zones = [
+ { Id: 1, Name: "sites-blog-abc123", StorageZoneId: 10, Enabled: true },
+ { Id: 2, Name: "sites-shop-xyz789", StorageZoneId: 20, Enabled: true },
+ // An imported site keeps its own pull zone name.
+ { Id: 3, Name: "legacy-cdn", StorageZoneId: 30, Enabled: true },
+ { Id: 4, Name: "api", StorageZoneId: null, Enabled: true },
+];
+
+const ctx = createToolContext({
+ clients: {
+ core: {
+ GET: () => Promise.resolve({ data: zones }),
+ } as unknown as CoreClient,
+ },
+});
+
+test("resolves a site name to its pull zone", async () => {
+ expect(
+ await resolveSitePullZone(ctx, { site: "shop", output: "json" }),
+ ).toEqual({ name: "shop", pullZone: 2, fromDirectory: false });
+});
+
+test("resolves a storage zone ID, including an imported site's", async () => {
+ expect(
+ await resolveSitePullZone(ctx, { site: "30", output: "json" }),
+ ).toMatchObject({ pullZone: 3 });
+});
+
+test("an unknown site names the fix", async () => {
+ await expect(
+ resolveSitePullZone(ctx, { site: "nope", output: "json" }),
+ ).rejects.toThrow('No site found for "nope".');
+});
diff --git a/packages/cli/src/commands/sites/optimizer/site.ts b/packages/cli/src/commands/sites/optimizer/site.ts
new file mode 100644
index 00000000..ebf57c99
--- /dev/null
+++ b/packages/cli/src/commands/sites/optimizer/site.ts
@@ -0,0 +1,128 @@
+import type { ToolContext } from "@bunny.net/tools";
+import { type PullZone, pullZonesList } from "@bunny.net/tools/pullzones";
+import { UserError } from "@/core/errors.ts";
+import { loadManifest } from "@/core/manifest.ts";
+import type { OutputFormat } from "@/core/types.ts";
+import { isInteractive, prompts, withSpinner } from "@/core/ui.ts";
+import { loadSiteConfig } from "../config.ts";
+import {
+ SITES_MANIFEST,
+ type SiteManifest,
+ siteResourcePattern,
+} from "../constants.ts";
+
+export interface SitePullZone {
+ name: string;
+ pullZone: number;
+ /** Resolved from `.bunny/site.json` or `sites.name`, so bunny.jsonc describes this site. */
+ fromDirectory: boolean;
+}
+
+// Created sites name their zones `sites-{name}-{suffix}`.
+const CREATED_SITE_ZONE = /^sites-(.+)-[a-z0-9]{6}$/i;
+
+function displayName(zone: PullZone): string {
+ return CREATED_SITE_ZONE.exec(zone.name)?.[1] ?? zone.name;
+}
+
+function pick(
+ zones: PullZone[],
+ name: string | undefined,
+): PullZone | undefined {
+ if (zones.length <= 1) return zones[0];
+ // An imported storage zone can have other pull zones in front of it; the site's own carries its name.
+ const pattern = name ? siteResourcePattern(name) : CREATED_SITE_ZONE;
+ const named = zones.filter((z) => pattern.test(z.name));
+ return named.length === 1 ? named[0] : undefined;
+}
+
+function byStorageZone(
+ zones: PullZone[],
+ id: number,
+ name?: string,
+): PullZone | undefined {
+ return pick(
+ zones.filter((z) => z.storageZoneId === id),
+ name,
+ );
+}
+
+function byName(zones: PullZone[], name: string): PullZone | undefined {
+ const pattern = siteResourcePattern(name);
+ const created = zones.filter((z) => pattern.test(z.name));
+ if (created.length > 1) {
+ throw new UserError(
+ `Multiple sites are named "${name}".`,
+ "Pass the site's storage zone ID instead (see `bunny sites list`).",
+ );
+ }
+ return created[0] ?? zones.find((z) => z.name === name);
+}
+
+/**
+ * Resolve a site to its pull zone through the pull zone list, in the same order as every sites command: explicit ref, `.bunny/site.json`, `sites.name`, then a picker.
+ *
+ * Sites are storage-backed pull zones, so this needs no storage credentials; an imported site whose pull zone kept its own name resolves by its storage zone ID.
+ */
+export async function resolveSitePullZone(
+ ctx: ToolContext,
+ args: { site?: string; output: OutputFormat },
+): Promise {
+ const zones = await withSpinner("Resolving site...", async () =>
+ (await pullZonesList.invoke(ctx, {})).filter(
+ (z) => z.storageZoneId !== null,
+ ),
+ );
+ const found = (
+ zone: PullZone | undefined,
+ label: string,
+ fromDirectory = false,
+ ): SitePullZone => {
+ if (!zone) {
+ throw new UserError(
+ `No site found for "${label}".`,
+ "Run `bunny sites list` to see your sites, and pass a site name or storage zone ID.",
+ );
+ }
+ return { name: displayName(zone), pullZone: zone.id, fromDirectory };
+ };
+
+ const ref = args.site;
+ if (ref) {
+ const zone = /^\d+$/.test(ref)
+ ? byStorageZone(zones, Number(ref))
+ : byName(zones, ref);
+ return found(zone, ref);
+ }
+
+ const manifest = loadManifest(SITES_MANIFEST);
+ if (manifest.id) {
+ return found(
+ byStorageZone(zones, manifest.id, manifest.name),
+ manifest.name ?? String(manifest.id),
+ true,
+ );
+ }
+
+ const configured = loadSiteConfig()?.config.name;
+ if (configured) return found(byName(zones, configured), configured, true);
+
+ const sites = zones.filter((z) => CREATED_SITE_ZONE.test(z.name));
+ if (!isInteractive(args.output) || sites.length === 0) {
+ throw new UserError(
+ "No site specified and no linked site found.",
+ "Pass a site name or run `bunny sites link`.",
+ );
+ }
+ const { selected } = await prompts({
+ type: "select",
+ name: "selected",
+ message: "Select a site:",
+ choices: sites.map((z) => ({
+ title: `${displayName(z)} (${z.storageZoneId})`,
+ value: z,
+ })),
+ });
+ if (!selected) throw new UserError("A site is required.");
+ return found(selected as PullZone, "");
+}
diff --git a/packages/cli/src/commands/sites/optimizer/sync.test.ts b/packages/cli/src/commands/sites/optimizer/sync.test.ts
new file mode 100644
index 00000000..ec28a55e
--- /dev/null
+++ b/packages/cli/src/commands/sites/optimizer/sync.test.ts
@@ -0,0 +1,14 @@
+import { expect, test } from "bun:test";
+import { optimizableImages } from "./sync.ts";
+
+test("optimizableImages counts only formats Optimizer converts", () => {
+ expect(
+ optimizableImages([
+ { path: "images/hero.png", size: 500 },
+ { path: "a.JPG", size: 100 },
+ { path: "logo.svg", size: 50 },
+ { path: "index.html", size: 10 },
+ { path: "photo.webp", size: 20 },
+ ]),
+ ).toEqual({ count: 3, bytes: 620 });
+});
diff --git a/packages/cli/src/commands/sites/optimizer/sync.ts b/packages/cli/src/commands/sites/optimizer/sync.ts
new file mode 100644
index 00000000..c8245411
--- /dev/null
+++ b/packages/cli/src/commands/sites/optimizer/sync.ts
@@ -0,0 +1,148 @@
+import type { ToolContext } from "@bunny.net/tools";
+import {
+ OPTIMIZER_MONTHLY_PRICE,
+ pullZonesOptimizerGet,
+ pullZonesOptimizerSet,
+} from "@bunny.net/tools/pullzones";
+import { errorMessage } from "@/core/errors.ts";
+import { formatBytes } from "@/core/format.ts";
+import { logger } from "@/core/logger.ts";
+import { formatPrice } from "@/core/optimizer/index.ts";
+import type { OutputFormat } from "@/core/types.ts";
+import { confirm, isInteractive, withSpinner } from "@/core/ui.ts";
+import { saveSiteConfig } from "../config.ts";
+import { SITE_OPTIMIZER_MINIFY } from "./shared.ts";
+
+/**
+ * Bring the site's pull zone in line with `sites.optimizer` before a deploy publishes (the publish purges, so no separate purge here).
+ *
+ * Turning it on costs money, so the first time asks in a terminal; unattended, the config value is the consent. Turning it off never asks.
+ */
+export async function syncOptimizer(opts: {
+ ctx: ToolContext;
+ pullZone: number;
+ site: string;
+ enabled: boolean;
+ output: OutputFormat;
+}): Promise {
+ const { ctx, pullZone, site, enabled, output } = opts;
+ const quiet = output === "json";
+ const status = await withSpinner("Checking Bunny Optimizer...", () =>
+ pullZonesOptimizerGet.invoke(ctx, { pullZone }),
+ );
+ if (status.enabled === enabled && status.configured) return;
+
+ const price = formatPrice(status.monthlyPrice);
+ if (enabled && !status.enabled) {
+ if (isInteractive(output)) {
+ const accepted = await confirm(
+ `sites.optimizer is true in bunny.jsonc. Turn on Bunny Optimizer for ${site}? It costs ${price}.`,
+ { initial: true, optional: true },
+ );
+ if (!accepted) {
+ logger.warn("Bunny Optimizer stays off for this deploy.");
+ logger.dim(
+ " Set sites.optimizer to false in bunny.jsonc to stop this prompt.",
+ );
+ return;
+ }
+ } else if (!quiet) {
+ logger.info(
+ `Turning on Bunny Optimizer (${price}) because sites.optimizer is true in bunny.jsonc.`,
+ );
+ }
+ }
+
+ await withSpinner(
+ enabled
+ ? "Turning on Bunny Optimizer..."
+ : "Turning off Bunny Optimizer...",
+ () =>
+ pullZonesOptimizerSet.invoke(ctx, {
+ pullZone,
+ enabled,
+ minify: SITE_OPTIMIZER_MINIFY,
+ purge: false,
+ }),
+ );
+ if (!quiet) {
+ logger.success(
+ enabled
+ ? status.enabled
+ ? "Re-applied the Bunny Optimizer settings."
+ : `Bunny Optimizer is on for ${site}.`
+ : `Bunny Optimizer is off for ${site}.`,
+ );
+ }
+}
+
+// Formats Optimizer converts and resizes.
+const OPTIMIZABLE_IMAGE = /\.(png|jpe?g|gif|webp)$/i;
+
+/** Below this, the saving isn't worth a line of output. */
+export const OPTIMIZER_HINT_MIN_BYTES = 1024 * 1024;
+
+export function optimizableImages(files: { path: string; size: number }[]): {
+ count: number;
+ bytes: number;
+} {
+ const images = files.filter((f) => OPTIMIZABLE_IMAGE.test(f.path));
+ return {
+ count: images.length,
+ bytes: images.reduce((sum, f) => sum + f.size, 0),
+ };
+}
+
+/** One line after a deploy whose images Optimizer would shrink; never in CI, and gone once `sites.optimizer` is set either way. */
+export async function offerOptimizer(opts: {
+ ctx: ToolContext;
+ pullZone: number;
+ files: { path: string; size: number }[];
+}): Promise {
+ const { count, bytes } = optimizableImages(opts.files);
+ if (bytes < OPTIMIZER_HINT_MIN_BYTES) return;
+ try {
+ const status = await pullZonesOptimizerGet.invoke(opts.ctx, {
+ pullZone: opts.pullZone,
+ });
+ if (status.enabled) return;
+ logger.log();
+ logger.info(
+ `Your build has ${count} images (${formatBytes(bytes)}). Bunny Optimizer serves them as WebP sized for each device, for ${formatPrice(status.monthlyPrice)}: bunny sites optimizer enable`,
+ );
+ logger.dim(" Hide this: set sites.optimizer to false in bunny.jsonc");
+ } catch {
+ // A hint never fails a deploy.
+ }
+}
+
+/** The create flow's offer, next to the custom domain one. Never fails the create: the site already exists. */
+export async function offerOptimizerOnCreate(opts: {
+ ctx: ToolContext;
+ pullZone: number;
+ site: string;
+}): Promise {
+ const price = formatPrice(OPTIMIZER_MONTHLY_PRICE);
+ const accepted = await confirm(
+ `Turn on Bunny Optimizer? Images are served as WebP sized for each device, for ${price}.`,
+ { initial: false, optional: true },
+ );
+ if (!accepted) return;
+ try {
+ // A new site has nothing cached yet, so there's nothing to purge.
+ await withSpinner("Turning on Bunny Optimizer...", () =>
+ pullZonesOptimizerSet.invoke(opts.ctx, {
+ pullZone: opts.pullZone,
+ enabled: true,
+ minify: SITE_OPTIMIZER_MINIFY,
+ purge: false,
+ }),
+ );
+ const path = saveSiteConfig({ optimizer: true });
+ logger.success(`Bunny Optimizer is on for ${opts.site}.`);
+ logger.dim(` Saved sites.optimizer: true to ${path}`);
+ } catch (err) {
+ logger.warn(`Couldn't turn on Bunny Optimizer: ${errorMessage(err)}`);
+ logger.dim(` Retry later: bunny sites optimizer enable ${opts.site}`);
+ }
+}
diff --git a/packages/cli/src/core/optimizer/commands.ts b/packages/cli/src/core/optimizer/commands.ts
new file mode 100644
index 00000000..51735300
--- /dev/null
+++ b/packages/cli/src/core/optimizer/commands.ts
@@ -0,0 +1,246 @@
+import type { ToolContext } from "@bunny.net/tools";
+import {
+ type OptimizerStatus,
+ pullZonesOptimizerGet,
+ pullZonesOptimizerSet,
+} from "@bunny.net/tools/pullzones";
+import type { Argv, CommandModule } from "yargs";
+import { defineNamespace } from "@/core/define-namespace.ts";
+import { DONE, defineToolCommand } from "@/core/define-tool-command.ts";
+import { formatKeyValue } from "@/core/format.ts";
+import { logger } from "@/core/logger.ts";
+import type { GlobalArgs, OutputFormat } from "@/core/types.ts";
+import { confirm, requireConfirmable, withSpinner } from "@/core/ui.ts";
+
+/** How the resource that owns a pull zone wants Optimizer run; it travels with the target so every command that reaches the resource applies it. */
+export interface OptimizerPolicy {
+ /** Set CSS/JS minification on enable; omitted leaves the zone's setting alone. */
+ minify?: boolean;
+ /** Runs after Optimizer is turned on or off, e.g. to keep a config file in line. */
+ onChanged?: (enabled: boolean, output: OutputFormat) => void;
+ /** Extra line printed after a successful enable. */
+ enableHint?: () => string | undefined;
+}
+
+/** The pull zone Optimizer acts on, named the way its owner is known. */
+export interface OptimizerTarget {
+ pullZone: number;
+ name: string;
+ /** What `name` names, e.g. "Site" or "Pull zone". */
+ label: string;
+ optimizer?: OptimizerPolicy;
+}
+
+export interface OptimizerMountOptions {
+ /** Command breadcrumb used in examples and hints, e.g. "sites optimizer". */
+ commandPath: string;
+ describe: string;
+ /** Trailing positional that names the target, e.g. `site` or `pull-zone`. */
+ positional: { name: string; describe: string };
+ /** Resolve the positional (or the linked directory) to a pull zone. */
+ resolve: (
+ ref: string | undefined,
+ args: GlobalArgs,
+ ctx: ToolContext,
+ ) => Promise;
+}
+
+export function formatPrice(monthly: number): string {
+ return `$${monthly.toFixed(2)}/month`;
+}
+
+export function renderOptimizerStatus(
+ label: string,
+ name: string,
+ status: OptimizerStatus,
+ output: OutputFormat,
+): void {
+ const onOff = (on: boolean) => (on ? "on" : "off");
+ const cacheKey =
+ status.cacheKeyParameters === null
+ ? "all query parameters"
+ : status.cacheKeyParameters.length === 0
+ ? "query strings ignored"
+ : status.cacheKeyParameters.join(", ");
+ logger.log(
+ formatKeyValue(
+ [
+ { key: label, value: name },
+ { key: "Optimizer", value: onOff(status.enabled) },
+ { key: "WebP", value: onOff(status.webp) },
+ { key: "Dynamic images", value: onOff(status.dynamicImages) },
+ {
+ key: "Minify CSS/JS",
+ value: `${onOff(status.minify.css)}/${onOff(status.minify.js)}`,
+ },
+ { key: "Cache key", value: cacheKey },
+ { key: "Price", value: formatPrice(status.monthlyPrice) },
+ ],
+ output,
+ ),
+ );
+}
+
+/** The `optimizer` namespace (status, enable, disable), mounted wherever a pull zone can be resolved. */
+export function createOptimizerCommands(
+ opts: OptimizerMountOptions,
+): CommandModule {
+ const { commandPath, positional } = opts;
+ const targets = new WeakMap