diff --git a/.changeset/better-baboons-attend.md b/.changeset/better-baboons-attend.md new file mode 100644 index 0000000..86506f4 --- /dev/null +++ b/.changeset/better-baboons-attend.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(scripts): manage custom domains for Edge Scripts (`bunny scripts domains`, with `hostnames` as a hidden alias) diff --git a/AGENTS.md b/AGENTS.md index 6ea5301..a379341 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,6 +158,11 @@ bunny-cli/ │ │ ├── errors.ts # Re-exports UserError/ApiError from @bunny.net/openapi-client + ConfigError │ │ ├── format.ts # Shared table/key-value rendering (text, table, csv, markdown) │ │ ├── format.test.ts # Tests for format utilities +│ │ ├── hostnames/ # Reusable pull-zone hostname feature (mounted by scripts; apps next) +│ │ │ ├── index.ts # Re-exports client helpers + createHostnamesCommands +│ │ │ ├── client.ts # hostnameUrl(), fetchPullZoneHostnames(), enableSsl() + Hostname/ResolvedPullZone types +│ │ │ ├── client.test.ts # Tests for hostnameUrl() scheme logic +│ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver │ │ ├── logger.ts # Chalk-based structured logger │ │ ├── manifest.ts # .bunny/ context file resolution (load, save, resolveManifestId) │ │ ├── types.ts # GlobalArgs, OutputFormat, and shared type definitions @@ -268,15 +273,18 @@ bunny-cli/ │ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands │ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS │ │ ├── create.ts # Create a remote Edge Script (exports shared `createScript` helper) +│ │ ├── delete.ts # Delete an Edge Script (double confirmation or --force) │ │ ├── deploy.ts # Deploy code to an Edge Script (publishes by default) │ │ ├── docs.ts # Open Edge Script documentation in browser │ │ ├── init.ts # Scaffold a new Edge Script project from a template (calls `createScript`) │ │ ├── link.ts # Link directory to a remote Edge Script (.bunny/script.json) │ │ ├── list.ts # List all Edge Scripts (Standalone + Middleware) -│ │ ├── show.ts # Show Edge Script details (supports manifest fallback) +│ │ ├── show.ts # Show Edge Script details + hostnames (supports manifest fallback) │ │ ├── deployments/ │ │ │ ├── index.ts # defineNamespace("deployments", ...) │ │ │ └── list.ts # List deployments for an Edge Script +│ │ ├── hostnames/ +│ │ │ └── index.ts # Mounts core/hostnames factory: script pull-zone resolver + --id/--pull-zone, visible as "domains" with hidden "hostnames" alias │ │ └── env/ │ │ ├── index.ts # defineNamespace("env", ...) │ │ ├── list.ts # List environment variables for a script @@ -301,10 +309,16 @@ bunny-cli/ - **Namespaces are directories** with an `index.ts` that calls `defineNamespace()`. - **Leaf commands** are individual `.ts` files that call `defineCommand()`. - **Top-level commands** (`login`, `logout`, `whoami`) are registered directly in `cli.ts` without a namespace. -- **Shared internal code lives in `packages/cli/src/core/`** — command factories, errors, logger, format utilities, UI helpers, and shared types. Keep this flat (no nested subdirectories). +- **Shared internal code lives in `packages/cli/src/core/`** — command factories, errors, logger, format utilities, UI helpers, and shared types. Keep this mostly flat; a cohesive, reusable feature spanning several files may use a subdirectory (e.g. `core/hostnames/` — the pull-zone hostname helpers + the `createHostnamesCommands` factory mounted by both `scripts` and, in future, `apps`). - **Config logic lives in `packages/cli/src/config/`** — schema, file resolution, and profile management. - **Error classes are split.** `UserError` and `ApiError` live in `@bunny.net/openapi-client` (the SDK needs them). `ConfigError` lives in the CLI and extends `UserError`. The CLI's `errors.ts` re-exports `UserError` and `ApiError` from `@bunny.net/openapi-client`. - **Import API clients from `@bunny.net/openapi-client`**, not relative paths. Import generated types from `@bunny.net/openapi-client/generated/.d.ts`. +- **Pull-zone settings are exposed via "Hybrid D" across surfaces.** Scripts and apps are backed by a pull zone, which has a large settings surface (hostnames, caching, edge rules, origin, security, purge, CORS, optimizer, logging, …). To keep each owner's help legible: + - **Flatten only first-class groups** directly into the owner — picked by user mental model, kept to one or two. `scripts domains` is the flattened group (a custom domain is "my site's address," not a CDN setting). + - **Group the long tail** under a `pullzone` sub-namespace within the owner (e.g. `scripts pullzone `), so the owner's top-level help gains one line, not ten. Curate per owner — don't expose settings that don't apply (a script _is_ its pull zone's origin, so no origin-URL command under `scripts`). + - **A standalone `bunny pullzone` command** (planned) is the canonical full surface for pull zones not backing a script/app, targeted by `--id`. + - Each setting-area is a **mountable factory** like `createHostnamesCommands` (`core/hostnames/`): one `{ commandPath, target, resolve(args) => { pullZoneId, coreClient }, hiddenAliases }` mounted into the root `pullzone` (resolve from `--id`), `scripts` (resolve from the linked manifest), and `apps` (resolve from the CDN endpoint). The resolver is the only per-surface difference. + - Canonical term is `pullzone` (matches the bunny.net dashboard/API); `pz` is a hidden alias (`defineNamespace(alias, false, …)`), the same pattern as `domains`'s hidden `hostnames` alias. --- @@ -827,9 +841,18 @@ bunny │ │ Create a remote Edge Script (use after init when --deploy was skipped) │ ├── deploy [id] [--skip-publish] │ │ Deploy code to an Edge Script (publishes by default) +│ ├── delete [id] [--force] Delete an Edge Script (double confirmation or --force) │ ├── deployments │ │ └── list [id] (alias: ls) List deployments for an Edge Script │ ├── docs Open Edge Script documentation in browser +│ ├── domains (hidden alias: hostnames) +│ │ ├── add [--ssl] [--no-force-ssl] [--id] [--pull-zone] +│ │ │ Add a custom domain (SSL opt-in; HTTPS forced by default) +│ │ ├── ssl [--no-force-ssl] [--id] [--pull-zone] +│ │ │ Issue a free SSL certificate (HTTPS forced by default) +│ │ ├── list (alias: ls) [--id] [--pull-zone] List pull zone domains +│ │ └── remove (alias: rm) [--force] [--id] [--pull-zone] +│ │ Remove a custom domain │ ├── env │ │ ├── list [id] List environment variables │ │ ├── set [id] Set environment variable diff --git a/packages/cli/README.md b/packages/cli/README.md index e30c3aa..a581de8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -81,6 +81,14 @@ bunny open --print bunny open --print --output json ``` +### `bunny docs` + +Open the bunny.net documentation in your default browser. + +```bash +bunny docs +``` + ### `bunny config` Manage CLI configuration and profiles. @@ -477,6 +485,8 @@ bunny scripts deploy dist/index.js 12345 | ---------------- | ------------------------------ | | `--skip-publish` | Upload code without publishing | +After publishing, the live URL and any custom domains are printed. + > **Note:** `bunny scripts deploy` works regardless of how the script was created or whether GitHub Actions is configured. The last deployment always wins — whether triggered by a GitHub Action or a manual CLI deploy. #### `bunny scripts link` @@ -503,13 +513,156 @@ bunny scripts list --output json #### `bunny scripts show` -Show details for an Edge Script. Uses the linked script from `.bunny/script.json` if no ID is provided. +Show details for an Edge Script. Uses the linked script from `.bunny/script.json` if no ID is provided. Output includes the script's hostnames (system and custom) with their SSL status. ```bash bunny scripts show bunny scripts show ``` +#### `bunny scripts delete` + +Delete an Edge Script. Uses the linked script if no ID is provided. Requires double confirmation (or `--force` to skip). + +```bash +bunny scripts delete +bunny scripts delete +bunny scripts delete --force +``` + +| Flag | Description | +| --------- | ------------------------- | +| `--force` | Skip confirmation prompts | + +#### `bunny scripts deployments` + +Manage Edge Script deployments. + +##### `bunny scripts deployments list` + +List deployments for an Edge Script. Uses the linked script if no ID is provided. + +```bash +bunny scripts deployments list +bunny scripts deployments ls +bunny scripts deployments list +bunny scripts deployments list --output json +``` + +#### `bunny scripts env` + +Manage environment variables and secrets for an Edge Script. All subcommands default to the linked script; pass `--id ` to target another. + +##### `bunny scripts env list` + +List environment variables and secrets. + +```bash +bunny scripts env list +bunny scripts env ls +bunny scripts env list --output json +``` + +##### `bunny scripts env set` + +Set an environment variable or secret. Runs interactively when arguments are omitted. The variable name is uppercased. + +```bash +bunny scripts env set MY_VAR value +bunny scripts env set # interactive +bunny scripts env set API_KEY secret-value --secret +``` + +| Flag | Description | +| ---------- | --------------------------------------- | +| `--secret` | Store as an encrypted secret | +| `--id` | Edge Script ID (uses linked if omitted) | + +##### `bunny scripts env remove` + +Remove an environment variable or secret. Shows an interactive picker when no name is given; prompts for confirmation unless `--force`. + +```bash +bunny scripts env remove MY_VAR +bunny scripts env rm MY_VAR -f +``` + +##### `bunny scripts env pull` + +Pull environment variables to a local `.env` file. + +```bash +bunny scripts env pull +bunny scripts env pull +bunny scripts env pull --force +``` + +| Flag | Description | +| --------- | ---------------------------------------------- | +| `--force` | Overwrite an existing `.env` without prompting | + +#### `bunny scripts domains` + +Manage custom domains for an Edge Script. A script's domains live on its linked pull zone, so these commands operate on that pull zone. All subcommands default to the linked script; pass `--id ` to target another, and `--pull-zone ` when a script has more than one linked pull zone. (`bunny scripts hostnames` is kept as a hidden alias.) + +##### `bunny scripts domains add` + +Add a custom domain. SSL is **not** requested by default — a free certificate can only be issued once your DNS points at bunny.net, so the command prints the `CNAME` record to create and the follow-up command to enable HTTPS. Pass `--ssl` to issue a certificate immediately; HTTP is redirected to HTTPS by default (opt out with `--no-force-ssl`). + +```bash +# Add a domain and get DNS instructions +bunny scripts domains add shop.example.com + +# Add and request SSL now (DNS must already be pointed at bunny.net) — HTTPS forced +bunny scripts domains add shop.example.com --ssl + +# Add and request SSL without forcing HTTPS +bunny scripts domains add shop.example.com --ssl --no-force-ssl +``` + +| Flag | Description | +| ---------------- | ----------------------------------------------------------------------- | +| `--ssl` | Issue a free SSL certificate now and force HTTPS (requires DNS pointed) | +| `--no-force-ssl` | When issuing SSL, keep serving HTTP instead of redirecting to HTTPS | +| `--id` | Edge Script ID (uses linked script if omitted) | +| `--pull-zone` | Pull zone ID (required if the script has multiple linked zones) | + +##### `bunny scripts domains ssl` + +Request a free SSL certificate for a custom domain. Run this after the domain's DNS points at bunny.net (see the `CNAME` printed by `domains add`). HTTP is redirected to HTTPS by default; pass `--no-force-ssl` to keep plain HTTP. + +```bash +bunny scripts domains ssl shop.example.com +bunny scripts domains ssl shop.example.com --no-force-ssl +``` + +##### `bunny scripts domains list` + +List the domains on a script's pull zone, with SSL and Force SSL status. + +```bash +bunny scripts domains list +bunny scripts domains ls +bunny scripts domains list --output json +``` + +##### `bunny scripts domains remove` + +Remove a custom domain. System hostnames controlled by bunny.net cannot be removed. + +```bash +bunny scripts domains remove shop.example.com +bunny scripts domains remove shop.example.com --force +``` + +#### `bunny scripts docs` + +Open the Edge Scripts documentation in your browser. + +```bash +bunny scripts docs +``` + ### `bunny api` Make a raw authenticated HTTP request to any bunny.net API endpoint. Auth is handled automatically via your configured API key. @@ -543,6 +696,14 @@ bunny api GET /pullzone --verbose The method is case-insensitive (`get` and `GET` both work). Paths are relative to `https://api.bunny.net` — use `/database/...` for the Database API and `/mc/...` for Magic Containers. +### `bunny completion` + +Generate a shell completion script. Add the output to your shell profile to enable tab completion. + +```bash +bunny completion >> ~/.zshrc +``` + ## Global Options | Flag | Alias | Description | Default | diff --git a/packages/cli/src/commands/scripts/deploy.ts b/packages/cli/src/commands/scripts/deploy.ts index 4a244c9..5be284c 100644 --- a/packages/cli/src/commands/scripts/deploy.ts +++ b/packages/cli/src/commands/scripts/deploy.ts @@ -1,10 +1,18 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; -import { createComputeClient } from "@bunny.net/openapi-client"; +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; +import { + fetchPullZoneHostnames, + type Hostname, + hostnameUrl, +} from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { resolveManifestId } from "../../core/manifest.ts"; import { spinner } from "../../core/ui.ts"; @@ -92,7 +100,8 @@ export const scriptsDeployCommand = defineCommand({ const code = await Bun.file(absPath).text(); const config = resolveConfig(profile, apiKey, verbose); - const client = createComputeClient(clientOptions(config, verbose)); + const options = clientOptions(config, verbose); + const client = createComputeClient(options); const spin = spinner("Uploading code..."); spin.start(); @@ -125,13 +134,57 @@ export const scriptsDeployCommand = defineCommand({ return; } + if (!published) return; + const { data: script } = await client.GET("/compute/script/{id}", { params: { path: { id } }, }); - const hostname = script?.LinkedPullZones?.[0]?.DefaultHostname ?? undefined; - if (hostname && published) { - logger.info(`Live at: ${hostname}`); + const zones = script?.LinkedPullZones ?? []; + + // Pull the full hostname list (incl. custom domains) from the core API; + // fall back to the script's system hostname if that lookup fails. + const coreClient = createCoreClient(options); + const hostnames: Hostname[] = []; + for (const zone of zones) { + if (zone.Id == null) continue; + try { + hostnames.push(...(await fetchPullZoneHostnames(coreClient, zone.Id))); + } catch (err) { + logger.debug( + `Failed to fetch hostnames for pull zone ${zone.Id}: ${err}`, + verbose, + ); + } + } + + if (hostnames.length === 0) { + const fallback = zones[0]?.DefaultHostname; + if (fallback) logger.info(`Live at: ${fallback}`); + return; + } + + const system = hostnames.find((h) => h.IsSystemHostname); + const primary = system ?? hostnames[0]; + const customs = hostnames.filter((h) => h !== primary); + + if (primary?.Value) { + logger.info( + `Live at: ${hostnameUrl(primary.Value, { + hasCertificate: primary.HasCertificate, + forceSSL: primary.ForceSSL, + })}`, + ); + } + + for (const custom of customs) { + if (!custom.Value) continue; + logger.log( + ` ${hostnameUrl(custom.Value, { + hasCertificate: custom.HasCertificate, + forceSSL: custom.ForceSSL, + })}`, + ); } }, }); diff --git a/packages/cli/src/commands/scripts/hostnames/index.ts b/packages/cli/src/commands/scripts/hostnames/index.ts new file mode 100644 index 0000000..e394465 --- /dev/null +++ b/packages/cli/src/commands/scripts/hostnames/index.ts @@ -0,0 +1,103 @@ +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { UserError } from "../../../core/errors.ts"; +import { + createHostnamesCommands, + type ResolvedPullZone, +} from "../../../core/hostnames/index.ts"; +import { resolveManifestId } from "../../../core/manifest.ts"; +import { fetchScript } from "../api.ts"; +import { SCRIPT_MANIFEST } from "../constants.ts"; + +type EdgeScript = components["schemas"]["EdgeScriptModel"]; + +/** + * Resolve which linked pull zone to operate on. + * + * Defaults to the script's only linked pull zone. When several exist, + * requires `--pull-zone` to disambiguate. + */ +function resolvePullZoneId(script: EdgeScript, flag?: number): number { + const zones = script.LinkedPullZones ?? []; + + if (flag != null) { + const match = zones.find((z) => z.Id === flag); + if (!match) { + throw new UserError( + `Pull zone ${flag} is not linked to script ${script.Id}.`, + ); + } + return flag; + } + + if (zones.length === 0) { + throw new UserError( + `Script ${script.Id} has no linked pull zone.`, + "Custom hostnames require a linked pull zone.", + ); + } + + if (zones.length > 1) { + const list = zones.map((z) => `${z.Id} (${z.PullZoneName})`).join(", "); + throw new UserError( + "Script has multiple linked pull zones.", + `Pass --pull-zone to choose one: ${list}`, + ); + } + + const id = zones[0]?.Id; + if (id == null) throw new UserError("Linked pull zone has no ID."); + return id; +} + +/** Resolve a script's pull zone (from manifest/--id) plus a core client. */ +async function resolveScriptPullZone(args: { + profile: string; + apiKey?: string; + verbose: boolean; + id?: number; + "pull-zone"?: number; +}): Promise { + const config = resolveConfig(args.profile, args.apiKey, args.verbose); + const options = clientOptions(config, args.verbose); + const computeClient = createComputeClient(options); + const coreClient = createCoreClient(options); + + const scriptId = resolveManifestId(SCRIPT_MANIFEST, args.id, "script"); + const script = await fetchScript(computeClient, scriptId); + const pullZoneId = resolvePullZoneId(script, args["pull-zone"]); + + return { pullZoneId, coreClient }; +} + +/** The `domains` namespace + hidden `hostnames` alias, ready to spread into `scripts`. */ +export const scriptsHostnamesCommands = createHostnamesCommands({ + commandPath: "scripts domains", + namespace: "domains", + describe: "Manage custom domains for an Edge Script.", + hiddenAliases: ["hostnames"], + target: (yargs) => + yargs + .option("id", { + type: "number", + describe: "Edge Script ID (uses linked script if omitted)", + }) + .option("pull-zone", { + type: "number", + describe: + "Pull zone ID (required if the script has multiple linked zones)", + }), + resolve: (args) => + resolveScriptPullZone({ + profile: args.profile, + apiKey: args.apiKey, + verbose: args.verbose, + id: args.id as number | undefined, + "pull-zone": args["pull-zone"] as number | undefined, + }), +}); diff --git a/packages/cli/src/commands/scripts/index.ts b/packages/cli/src/commands/scripts/index.ts index 5f5bf50..1811db3 100644 --- a/packages/cli/src/commands/scripts/index.ts +++ b/packages/cli/src/commands/scripts/index.ts @@ -5,6 +5,7 @@ import { scriptsDeployCommand } from "./deploy.ts"; import { scriptsDeploymentsNamespace } from "./deployments/index.ts"; import { scriptsDocsCommand } from "./docs.ts"; import { scriptsEnvNamespace } from "./env/index.ts"; +import { scriptsHostnamesCommands } from "./hostnames/index.ts"; import { scriptsInitCommand } from "./init.ts"; import { scriptsLinkCommand } from "./link.ts"; import { scriptsListCommand } from "./list.ts"; @@ -20,6 +21,7 @@ export const scriptsNamespace = defineNamespace( scriptsDeploymentsNamespace, scriptsDocsCommand, scriptsEnvNamespace, + ...scriptsHostnamesCommands, scriptsInitCommand, scriptsLinkCommand, scriptsListCommand, diff --git a/packages/cli/src/commands/scripts/show.ts b/packages/cli/src/commands/scripts/show.ts index b478f2a..35bfc73 100644 --- a/packages/cli/src/commands/scripts/show.ts +++ b/packages/cli/src/commands/scripts/show.ts @@ -1,9 +1,18 @@ -import { createComputeClient } from "@bunny.net/openapi-client"; +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { formatKeyValue, formatTable } from "../../core/format.ts"; +import { + fetchPullZoneHostnames, + type Hostname, + hostnameUrl, + toSafeHostname, +} from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { resolveManifestId } from "../../core/manifest.ts"; import { spinner } from "../../core/ui.ts"; @@ -59,17 +68,39 @@ export const scriptsShowCommand = defineCommand({ handler: async ({ [ARG_ID]: rawId, profile, output, verbose, apiKey }) => { const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); const config = resolveConfig(profile, apiKey, verbose); - const client = createComputeClient(clientOptions(config, verbose)); + const options = clientOptions(config, verbose); + const client = createComputeClient(options); const spin = spinner("Fetching Edge Script..."); spin.start(); const script = await fetchScript(client, id); + // Pull each linked pull zone's hostnames (incl. custom domains + SSL state). + const coreClient = createCoreClient(options); + const hostnames: Hostname[] = []; + for (const zone of script.LinkedPullZones ?? []) { + if (zone.Id == null) continue; + try { + hostnames.push(...(await fetchPullZoneHostnames(coreClient, zone.Id))); + } catch (err) { + logger.debug( + `Failed to fetch hostnames for pull zone ${zone.Id}: ${err}`, + verbose, + ); + } + } + spin.stop(); if (output === "json") { - logger.log(JSON.stringify(script, null, 2)); + logger.log( + JSON.stringify( + { ...script, Hostnames: hostnames.map(toSafeHostname) }, + null, + 2, + ), + ); return; } @@ -120,6 +151,26 @@ export const scriptsShowCommand = defineCommand({ ); } + if (hostnames.length > 0) { + logger.log(); + logger.log("Hostnames:"); + logger.log( + formatTable( + ["Hostname", "Type", "SSL", "Force SSL"], + hostnames.map((h) => [ + hostnameUrl(h.Value ?? "", { + hasCertificate: h.HasCertificate, + forceSSL: h.ForceSSL, + }), + h.IsSystemHostname ? "System" : "Custom", + h.HasCertificate ? "Yes" : "No", + h.ForceSSL ? "Yes" : "No", + ]), + output, + ), + ); + } + const variables = script.EdgeScriptVariables ?? []; if (variables.length > 0) { logger.log(); diff --git a/packages/cli/src/core/define-namespace.ts b/packages/cli/src/core/define-namespace.ts index f515eab..b3c814a 100644 --- a/packages/cli/src/core/define-namespace.ts +++ b/packages/cli/src/core/define-namespace.ts @@ -15,7 +15,7 @@ import type { Argv, CommandModule } from "yargs"; */ export function defineNamespace( command: string, - describe: string, + describe: string | false, subcommands: CommandModule[], ): CommandModule { let yRef: Argv; diff --git a/packages/cli/src/core/hostnames/client.test.ts b/packages/cli/src/core/hostnames/client.test.ts new file mode 100644 index 0000000..726d365 --- /dev/null +++ b/packages/cli/src/core/hostnames/client.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { type Hostname, hostnameUrl, toSafeHostname } from "./client.ts"; + +describe("hostnameUrl", () => { + test("respects an existing scheme", () => { + expect(hostnameUrl("https://x.bunny.run")).toBe("https://x.bunny.run"); + expect(hostnameUrl("http://x.bunny.run")).toBe("http://x.bunny.run"); + }); + + test("defaults bare hostnames to http when SSL state is unknown", () => { + expect(hostnameUrl("shop.example.com")).toBe("http://shop.example.com"); + }); + + test("derives https from a certificate", () => { + expect(hostnameUrl("shop.example.com", { hasCertificate: true })).toBe( + "https://shop.example.com", + ); + }); + + test("derives https from force SSL when no certificate flag is set", () => { + expect(hostnameUrl("shop.example.com", { forceSSL: true })).toBe( + "https://shop.example.com", + ); + }); + + test("a present certificate flag wins over forceSSL", () => { + expect( + hostnameUrl("shop.example.com", { + hasCertificate: false, + forceSSL: true, + }), + ).toBe("http://shop.example.com"); + }); +}); + +describe("toSafeHostname", () => { + const raw: Hostname = { + Id: 1, + Value: "shop.example.com", + ForceSSL: true, + IsSystemHostname: false, + IsManagedHostname: false, + HasCertificate: true, + Certificate: "BASE64-CERT", + CertificateKey: "BASE64-PRIVATE-KEY", + }; + + test("drops Certificate and CertificateKey", () => { + const safe = toSafeHostname(raw); + expect("Certificate" in safe).toBe(false); + expect("CertificateKey" in safe).toBe(false); + expect(JSON.stringify(safe)).not.toContain("PRIVATE-KEY"); + }); + + test("keeps the non-sensitive display fields", () => { + expect(toSafeHostname(raw)).toMatchObject({ + Id: 1, + Value: "shop.example.com", + ForceSSL: true, + IsSystemHostname: false, + HasCertificate: true, + }); + }); +}); diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts new file mode 100644 index 0000000..1cee184 --- /dev/null +++ b/packages/cli/src/core/hostnames/client.ts @@ -0,0 +1,97 @@ +import type { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { UserError } from "../errors.ts"; + +export type CoreClient = ReturnType; +export type Hostname = components["schemas"]["HostnameModel"]; + +/** Hostname fields safe to serialize — excludes Certificate/CertificateKey private-key material. */ +export type SafeHostname = Pick< + Hostname, + | "Id" + | "Value" + | "ForceSSL" + | "IsSystemHostname" + | "IsManagedHostname" + | "HasCertificate" + | "CertificateProvisionType" + | "CertificateKeyType" +>; + +/** Drop certificate/private-key material so hostnames can be safely written to logs/JSON. */ +export function toSafeHostname(h: Hostname): SafeHostname { + return { + Id: h.Id, + Value: h.Value, + ForceSSL: h.ForceSSL, + IsSystemHostname: h.IsSystemHostname, + IsManagedHostname: h.IsManagedHostname, + HasCertificate: h.HasCertificate, + CertificateProvisionType: h.CertificateProvisionType, + CertificateKeyType: h.CertificateKeyType, + }; +} + +/** A resolved pull zone plus a core client, returned by a resource's resolver. */ +export interface ResolvedPullZone { + pullZoneId: number; + coreClient: CoreClient; +} + +/** Build a URL from a hostname, respecting an existing scheme; else derive it from SSL state. */ +export function hostnameUrl( + host: string, + opts?: { hasCertificate?: boolean | null; forceSSL?: boolean | null }, +): string { + if (/^https?:\/\//i.test(host)) return host; + const secure = opts?.hasCertificate ?? opts?.forceSSL ?? false; + return `${secure ? "https" : "http"}://${host}`; +} + +/** Fetch a pull zone's hostnames from the core API, sorted system-first then by value. */ +export async function fetchPullZoneHostnames( + client: CoreClient, + pullZoneId: number, +): Promise { + const { data } = await client.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + return (data?.Hostnames ?? []).sort((a, b) => { + if (a.IsSystemHostname !== b.IsSystemHostname) { + return a.IsSystemHostname ? -1 : 1; + } + return (a.Value ?? "").localeCompare(b.Value ?? ""); + }); +} + +/** Issue a free SSL certificate for a hostname on a pull zone, then set its Force SSL state. */ +export async function enableSsl( + client: CoreClient, + pullZoneId: number, + hostname: string, + forceSSL: boolean, + knownHostnames?: Hostname[], +): Promise { + // loadFreeCertificate is account-wide (keyed only by hostname), so confirm the + // hostname lives on this pull zone before issuing — never touch another zone's. + const hostnames = + knownHostnames ?? (await fetchPullZoneHostnames(client, pullZoneId)); + const onZone = hostnames.some( + (h) => (h.Value ?? "").toLowerCase() === hostname.toLowerCase(), + ); + if (!onZone) { + throw new UserError( + `"${hostname}" is not on pull zone ${pullZoneId}.`, + "Add it first, then request a certificate.", + ); + } + + await client.GET("/pullzone/loadFreeCertificate", { + params: { query: { hostname } }, + }); + // Always set Force SSL to the requested value so --no-force-ssl can also turn it off. + await client.POST("/pullzone/{id}/setForceSSL", { + params: { path: { id: pullZoneId } }, + body: { Hostname: hostname, ForceSSL: forceSSL }, + }); +} diff --git a/packages/cli/src/core/hostnames/commands.ts b/packages/cli/src/core/hostnames/commands.ts new file mode 100644 index 0000000..fc67ddd --- /dev/null +++ b/packages/cli/src/core/hostnames/commands.ts @@ -0,0 +1,423 @@ +import type { Argv, CommandModule } from "yargs"; +import { defineCommand } from "../define-command.ts"; +import { defineNamespace } from "../define-namespace.ts"; +import { UserError } from "../errors.ts"; +import { formatTable } from "../format.ts"; +import { logger } from "../logger.ts"; +import type { GlobalArgs } from "../types.ts"; +import { confirm, spinner } from "../ui.ts"; +import { + enableSsl, + fetchPullZoneHostnames, + hostnameUrl, + type ResolvedPullZone, + toSafeHostname, +} from "./client.ts"; + +/** Resolves the pull zone (and a core client) for the resource being targeted. */ +export type HostnameResolver = ( + args: GlobalArgs & Record, +) => Promise; + +export interface HostnamesMountOptions { + /** Command breadcrumb used in examples and follow-up hints, e.g. "scripts domains". */ + commandPath: string; + /** Visible namespace name shown in help (defaults to "domains"). */ + namespace?: string; + /** Resolve the pull zone + core client from the parsed args. */ + resolve: HostnameResolver; + /** Adds resource-targeting flags (e.g. --id, --pull-zone) shared by every subcommand. */ + target?: (yargs: Argv) => Argv; + /** Namespace description shown in help. */ + describe?: string; + /** Hidden namespace aliases (e.g. ["hostnames"]) — they work but stay out of help. */ + hiddenAliases?: string[]; +} + +/** Strip any scheme and trailing slash from a user-supplied hostname. */ +function normalizeHostname(value: string): string { + return value.replace(/^https?:\/\//i, "").replace(/\/+$/, ""); +} + +/** Echo back the targeting flags the user passed so copy-paste follow-up hints keep the same scope. */ +function targetSuffix(args: Record): string { + const parts: string[] = []; + if (args.id != null) parts.push(`--id ${args.id}`); + if (args["pull-zone"] != null) parts.push(`--pull-zone ${args["pull-zone"]}`); + return parts.length ? ` ${parts.join(" ")}` : ""; +} + +/** + * Build a reusable `domains` command namespace for any resource backed by a + * pull zone (Edge Scripts today, Magic Containers apps next). The caller + * supplies a {@link HostnameResolver} that maps the parsed args to a pull + * zone; the add/ssl/list/remove behavior is identical across resources. + * + * Returns the visible namespace followed by any hidden alias namespaces, ready + * to spread into a parent namespace's subcommand list. + */ +export function createHostnamesCommands( + opts: HostnamesMountOptions, +): CommandModule[] { + const { commandPath, resolve } = opts; + // Generic passthrough so each subcommand's inferred arg type is preserved. + const target = (yargs: Argv): Argv => + (opts.target ? opts.target(yargs as Argv) : yargs) as Argv; + const resolveArgs = (args: GlobalArgs) => + resolve(args as GlobalArgs & Record); + + const addCommand = defineCommand<{ + domain: string; + ssl?: boolean; + "force-ssl"?: boolean; + }>({ + command: "add ", + describe: "Add a custom domain to a pull zone.", + examples: [ + [`$0 ${commandPath} add shop.example.com`, "Add a domain (no SSL)"], + [ + `$0 ${commandPath} add shop.example.com --ssl`, + "Add and request SSL now", + ], + ], + builder: (yargs) => + target( + yargs + .positional("domain", { + type: "string", + describe: "Custom domain to add (e.g. shop.example.com)", + demandOption: true, + }) + .option("ssl", { + type: "boolean", + describe: + "Issue a free SSL certificate now and force HTTPS (requires DNS to already point at bunny.net)", + }) + .option("force-ssl", { + type: "boolean", + default: true, + describe: + "Force HTTP→HTTPS when issuing SSL (default: true). Use --no-force-ssl to keep HTTP.", + }), + ), + handler: async (args) => { + const hostname = normalizeHostname(args.domain ?? ""); + if (!hostname) throw new UserError("A domain is required."); + + const requestSsl = args.ssl === true; + const force = args["force-ssl"] !== false; + + const { pullZoneId, coreClient } = await resolveArgs(args); + + const spin = spinner(`Adding ${hostname}...`); + spin.start(); + + await coreClient.POST("/pullzone/{id}/addHostname", { + params: { path: { id: pullZoneId } }, + body: { Hostname: hostname }, + }); + + const hostnames = await fetchPullZoneHostnames(coreClient, pullZoneId); + const systemHostname = hostnames + .find((h) => h.IsSystemHostname) + ?.Value?.replace(/^https?:\/\//i, ""); + + spin.stop(); + + let sslIssued = false; + let sslError: string | undefined; + if (requestSsl) { + const sslSpin = spinner("Requesting free SSL certificate..."); + sslSpin.start(); + try { + await enableSsl(coreClient, pullZoneId, hostname, force, hostnames); + sslIssued = true; + } catch (err) { + sslError = err instanceof Error ? err.message : String(err); + } + sslSpin.stop(); + } + + const sslHint = `bunny ${commandPath} ssl ${hostname}${targetSuffix( + args as unknown as Record, + )}`; + + // A requested certificate that failed to issue is a command error, like `ssl`. + const sslFailed = requestSsl && sslError != null; + + if (args.output === "json") { + logger.log( + JSON.stringify( + { + hostname, + pullZoneId, + cnameTarget: systemHostname ?? null, + ssl: sslIssued, + forceSSL: sslIssued && force, + sslError: sslError ?? null, + }, + null, + 2, + ), + ); + // Emit the full result for agents/CI, then signal failure with a non-zero exit. + if (sslFailed) process.exit(1); + return; + } + + logger.success(`Added ${hostname} to pull zone ${pullZoneId}.`); + + if (sslIssued) { + logger.log(); + logger.success( + force + ? "SSL certificate issued and HTTPS forced." + : "SSL certificate issued.", + ); + logger.log( + ` Live at: ${hostnameUrl(hostname, { hasCertificate: true })}`, + ); + return; + } + + if (systemHostname) { + logger.log(); + logger.log("Point your DNS at bunny.net to activate it:"); + logger.dim(` CNAME ${hostname} → ${systemHostname}`); + } + + logger.log(); + + if (sslFailed) { + throw new UserError( + `Couldn't issue a certificate for ${hostname} yet: ${sslError}`, + `This is normal until DNS propagates. Once it's live, run: ${sslHint}`, + ); + } + + logger.log("Then enable HTTPS once DNS is live:"); + logger.dim(` ${sslHint}`); + }, + }); + + const sslCommand = defineCommand<{ + domain: string; + "force-ssl"?: boolean; + }>({ + command: "ssl ", + describe: "Request a free SSL certificate for a custom domain.", + examples: [ + [ + `$0 ${commandPath} ssl shop.example.com`, + "Issue a free certificate and force HTTPS", + ], + [ + `$0 ${commandPath} ssl shop.example.com --no-force-ssl`, + "Issue without forcing HTTPS", + ], + ], + builder: (yargs) => + target( + yargs + .positional("domain", { + type: "string", + describe: "Custom domain to secure (e.g. shop.example.com)", + demandOption: true, + }) + .option("force-ssl", { + type: "boolean", + default: true, + describe: + "Force HTTP→HTTPS after issuing the certificate (default: true). Use --no-force-ssl to keep HTTP.", + }), + ), + handler: async (args) => { + const hostname = normalizeHostname(args.domain ?? ""); + if (!hostname) throw new UserError("A domain is required."); + + const force = args["force-ssl"] !== false; + + const { pullZoneId, coreClient } = await resolveArgs(args); + + const spin = spinner("Requesting free SSL certificate..."); + spin.start(); + + try { + await enableSsl(coreClient, pullZoneId, hostname, force); + } catch (err) { + spin.stop(); + if (err instanceof UserError) throw err; + const message = err instanceof Error ? err.message : String(err); + throw new UserError( + `Couldn't issue a certificate for ${hostname}: ${message}`, + "Make sure the domain's DNS points at bunny.net, then try again.", + ); + } + + spin.stop(); + + if (args.output === "json") { + logger.log( + JSON.stringify( + { hostname, pullZoneId, ssl: true, forceSSL: force }, + null, + 2, + ), + ); + return; + } + + logger.success( + force + ? `SSL certificate issued for ${hostname} and HTTPS forced.` + : `SSL certificate issued for ${hostname}.`, + ); + logger.log( + ` Live at: ${hostnameUrl(hostname, { hasCertificate: true })}`, + ); + }, + }); + + const listCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List the domains on a pull zone.", + examples: [ + [`$0 ${commandPath} list`, "List domains"], + [`$0 ${commandPath} list --output json`, "JSON output"], + ], + builder: (yargs) => target(yargs), + handler: async (args) => { + const { pullZoneId, coreClient } = await resolveArgs(args); + + const spin = spinner("Fetching hostnames..."); + spin.start(); + + const hostnames = await fetchPullZoneHostnames(coreClient, pullZoneId); + + spin.stop(); + + if (args.output === "json") { + logger.log(JSON.stringify(hostnames.map(toSafeHostname), null, 2)); + return; + } + + if (hostnames.length === 0) { + logger.info("No domains found."); + return; + } + + logger.log( + formatTable( + ["Domain", "Type", "SSL", "Force SSL"], + hostnames.map((h) => [ + hostnameUrl(h.Value ?? "", { + hasCertificate: h.HasCertificate, + forceSSL: h.ForceSSL, + }), + h.IsSystemHostname ? "System" : "Custom", + h.HasCertificate ? "Yes" : "No", + h.ForceSSL ? "Yes" : "No", + ]), + args.output, + ), + ); + }, + }); + + const removeCommand = defineCommand<{ + domain: string; + force?: boolean; + }>({ + command: "remove ", + aliases: ["rm"], + describe: "Remove a custom domain from a pull zone.", + examples: [ + [`$0 ${commandPath} remove shop.example.com`, "Remove a custom domain"], + [ + `$0 ${commandPath} remove shop.example.com --force`, + "Skip confirmation", + ], + ], + builder: (yargs) => + target( + yargs + .positional("domain", { + type: "string", + describe: "Custom domain to remove", + demandOption: true, + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + ), + handler: async (args) => { + const hostname = normalizeHostname(args.domain ?? ""); + if (!hostname) throw new UserError("A domain is required."); + + const { pullZoneId, coreClient } = await resolveArgs(args); + + const spin = spinner("Fetching hostnames..."); + spin.start(); + + const hostnames = await fetchPullZoneHostnames(coreClient, pullZoneId); + + spin.stop(); + + const match = hostnames.find( + (h) => (h.Value ?? "").toLowerCase() === hostname.toLowerCase(), + ); + if (!match) { + throw new UserError( + `Domain "${hostname}" is not on pull zone ${pullZoneId}.`, + ); + } + if (match.IsSystemHostname) { + throw new UserError( + `"${hostname}" is a bunny.net system hostname and cannot be removed.`, + ); + } + + const confirmed = await confirm(`Remove ${hostname}?`, { + force: args.force, + }); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const removeSpin = spinner(`Removing ${hostname}...`); + removeSpin.start(); + + await coreClient.DELETE("/pullzone/{id}/removeHostname", { + params: { path: { id: pullZoneId } }, + body: { Hostname: hostname }, + }); + + removeSpin.stop(); + + if (args.output === "json") { + logger.log( + JSON.stringify({ hostname, pullZoneId, removed: true }, null, 2), + ); + return; + } + + logger.success(`Removed ${hostname}.`); + }, + }); + + const subcommands = [addCommand, sslCommand, listCommand, removeCommand]; + const describe = opts.describe ?? "Manage custom domains."; + const namespace = opts.namespace ?? "domains"; + + return [ + defineNamespace(namespace, describe, subcommands), + ...(opts.hiddenAliases ?? []).map((alias) => + defineNamespace(alias, false, subcommands), + ), + ]; +} diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts new file mode 100644 index 0000000..3ed610e --- /dev/null +++ b/packages/cli/src/core/hostnames/index.ts @@ -0,0 +1,15 @@ +export { + type CoreClient, + enableSsl, + fetchPullZoneHostnames, + type Hostname, + hostnameUrl, + type ResolvedPullZone, + type SafeHostname, + toSafeHostname, +} from "./client.ts"; +export { + createHostnamesCommands, + type HostnameResolver, + type HostnamesMountOptions, +} from "./commands.ts";