diff --git a/.changeset/silver-domains-wait.md b/.changeset/silver-domains-wait.md new file mode 100644 index 0000000..4b93d7b --- /dev/null +++ b/.changeset/silver-domains-wait.md @@ -0,0 +1,7 @@ +--- +"@bunny.net/cli": minor +--- + +feat(scripts): wait for DNS and enable HTTPS automatically when adding a custom domain + +`scripts domains add` gains `--wait` to poll DNS (up to 10 minutes) and issue the free SSL certificate once the domain points at bunny.net; interactive runs offer the same. `scripts create` and `scripts init` now offer a custom domain as part of the flow. diff --git a/.changeset/tame-selectors-share.md b/.changeset/tame-selectors-share.md new file mode 100644 index 0000000..302745b --- /dev/null +++ b/.changeset/tame-selectors-share.md @@ -0,0 +1,7 @@ +--- +"@bunny.net/cli": patch +--- + +refactor(scripts): share a common script selector across subcommands + +`scripts` subcommands (env, deployments, show, stats) now use a shared selector, so they consistently accept the optional `[id]` positional and `--link` flag for targeting and linking a script. diff --git a/.changeset/witty-falcons-route.md b/.changeset/witty-falcons-route.md new file mode 100644 index 0000000..5b582a0 --- /dev/null +++ b/.changeset/witty-falcons-route.md @@ -0,0 +1,7 @@ +--- +"@bunny.net/cli": minor +--- + +feat(scripts): point a custom domain at the pull zone via Bunny DNS automatically + +When a custom domain added in `scripts create`/`init` belongs to one of your Bunny DNS zones, the CLI offers to add (or repoint) the DNS record for you — always after confirmation — then issues SSL immediately since the record is already live on bunny's resolvers. diff --git a/AGENTS.md b/AGENTS.md index fd69c95..8048223 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,13 +155,19 @@ bunny-cli/ │ │ ├── client-options.ts # clientOptions() helper — builds ClientOptions from ResolvedConfig │ │ ├── define-command.ts # Command factory (see "Command Pattern" below) │ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees +│ │ ├── dns-record-types.ts # Canonical DNS record-type name⇄integer map (RECORD_TYPES) + recordTypeLabel(); shared by commands/dns + core/hostnames │ │ ├── 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 +│ │ │ ├── index.ts # Re-exports client helpers, DNS/flow helpers + createHostnamesCommands +│ │ │ ├── client.ts # hostnameUrl(), normalizeHostname(), addHostname(), fetchPullZoneHostnames(), enableSsl() + Hostname/ResolvedPullZone types │ │ │ ├── client.test.ts # Tests for hostnameUrl() scheme logic +│ │ │ ├── dns.ts # dnsPointsAt()/anyResolverPointsAt(): DNS checks (CNAME or flattened A records) via system + public (1.1.1.1/8.8.8.8) resolvers, injectable for tests +│ │ │ ├── dns.test.ts # Tests for DNS matching + multi-resolver checks with fake resolvers +│ │ │ ├── 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) +│ │ │ ├── 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 │ │ ├── logger.ts # Chalk-based structured logger │ │ ├── manifest.ts # .bunny/ context file resolution (load, save, resolveManifestId) @@ -267,22 +273,25 @@ bunny-cli/ │ │ │ ├── create.ts # Generate an auth token (read-only/full-access, optional expiry) │ │ │ └── invalidate.ts # Invalidate all tokens for a database (with confirmation) │ │ ├── dns/ # Experimental — hidden from help and landing page -│ │ │ ├── index.ts # defineNamespace("dns", ...) — registers the record + zone groups (+ hidden domain aliases) +│ │ │ ├── index.ts # defineNamespace("dns", ...) — registers the records + zones groups (+ hidden domain aliases) │ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone) -│ │ │ ├── interactive.ts # resolveZoneInteractive (zone picker) + resolveRecordInteractive (record picker) fallbacks -│ │ │ ├── record-types.ts # Record type name⇄integer map, parseRecordType, recordTypeLabel, formatRecordValue -│ │ │ ├── record/ # `dns record` — entries within a zone (aliases: records, rec) -│ │ │ │ ├── index.ts # defineNamespace("record", ...) +│ │ │ ├── constants.ts # DNS_MANIFEST (".bunny/dns.json") + DnsManifest type, written by `dns zones link` +│ │ │ ├── interactive.ts # resolveZoneInteractive (arg → .bunny/dns.json manifest → zone picker; offerLink prompts to link a picked zone, skipped under --output json) + resolveRecordInteractive; autoLinkDnsZone (link a zone found in another flow — silent write, confirm before relinking a different zone) reused by scripts custom-domain setup +│ │ │ ├── record-types.ts # Re-exports RECORD_TYPES/recordTypeLabel from core/dns-record-types.ts; adds parseRecordType, recordName, formatRecordValue +│ │ │ ├── record/ # `dns records` — entries within a zone (canonical: records; aliases: record, rec) +│ │ │ │ ├── index.ts # defineNamespace("records", ...) │ │ │ │ ├── list.ts # List records in a zone (alias: ls) │ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script) │ │ │ │ ├── update.ts # Update a record (alias: edit; prompts to pick zone+record when omitted) │ │ │ │ ├── remove.ts # Remove a record (alias: rm; prompts to pick zone+record when omitted) │ │ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted) │ │ │ │ └── export.ts # Export records as a BIND zone file (stdout, --file , or --save → .zone) -│ │ │ └── zone/ # `dns zone` — the zone itself (aliases: zones; hidden: domain, domains) -│ │ │ ├── index.ts # defineNamespace("zone", ...) + dnsZoneHiddenAliases (domain/domains) +│ │ │ └── zone/ # `dns zones` — the zone itself (canonical: zones; aliases: zone; hidden: domain, domains) +│ │ │ ├── index.ts # defineNamespace("zones", ...) + dnsZoneHiddenAliases (domain/domains) │ │ │ ├── list.ts # List all DNS zones (alias: ls) │ │ │ ├── add.ts # Create a DNS zone +│ │ │ ├── link.ts # Link this directory to a zone → .bunny/dns.json (arg, else pick interactively) +│ │ │ ├── unlink.ts # Remove .bunny/dns.json (alias-free; --force skips confirm) │ │ │ ├── show.ts # Show zone details (nameservers, SOA, DNSSEC, logging, record count) │ │ │ ├── remove.ts # Delete a DNS zone and its records (alias: rm) │ │ │ ├── stats.ts # Show DNS query statistics (TotalQueriesServed, by-type bar chart in text mode) @@ -307,11 +316,11 @@ bunny-cli/ │ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands │ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS │ │ ├── api.ts # Shared: fetchScript(s), fetchEnvEntries, fetchScriptHostnames, logLiveHostnames, promptOpenInBrowser -│ │ ├── create.ts # Create a remote Edge Script (exports shared `createScript` helper) +│ │ ├── create.ts # Create a remote Edge Script (exports shared `createScript` + `setupCustomDomain`; for a linked script, setupCustomDomain auto-links the dir to the domain's Bunny DNS zone via autoLinkDnsZone) │ │ ├── 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`) +│ │ ├── init.ts # Scaffold a new Edge Script project from a template (calls `createScript` + `setupCustomDomain`) │ │ ├── interactive.ts # resolveScriptInteractive(): explicit ID → linked manifest → picker (offers to link; skipped for JSON output) │ │ ├── link.ts # Link directory to a remote Edge Script (.bunny/script.json) │ │ ├── list.ts # List all Edge Scripts (Standalone + Middleware) @@ -322,7 +331,7 @@ bunny-cli/ │ │ │ ├── list.ts # List deployments for an Edge Script │ │ │ └── publish.ts # Publish (roll back to) a past deployment by release ID │ │ ├── hostnames/ -│ │ │ └── index.ts # Mounts core/hostnames factory: script pull-zone resolver + --id/--pull-zone, visible as "domains" with hidden "hostnames" alias +│ │ │ └── index.ts # Mounts core/hostnames factory: script pull-zone resolver + [id] positional (--id also accepted)/--pull-zone, visible as "domains" with hidden "hostnames" alias │ │ └── env/ │ │ ├── index.ts # defineNamespace("env", ...) │ │ ├── list.ts # List environment variables for a script @@ -355,7 +364,7 @@ bunny-cli/ - **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. + - Each setting-area is a **mountable factory** like `createHostnamesCommands` (`core/hostnames/`): one `{ commandPath, target, targetPositional, 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. `targetPositional` appends an optional trailing positional (e.g. `[id]`) to every subcommand so mounts can match their namespace's positional-ID convention; the flag form of the same key keeps working. - 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. --- @@ -847,9 +856,9 @@ bunny │ │ Update registry name and/or rotate credentials │ └── remove Remove registry ├── dns (experimental — hidden from help and landing page) -│ │ Two resource groups: `record` (entries in a zone) and `zone` (the zone itself). -│ │ Every [domain] is optional — omit it to pick a zone interactively (resolveZoneInteractive). -│ ├── record (aliases: records, rec) +│ │ Two resource groups: `records` (entries in a zone) and `zones` (the zone itself). +│ │ Every [domain] is optional — omit it to use the linked zone (`dns zones link` → .bunny/dns.json), else pick interactively (resolveZoneInteractive). Picking a zone interactively offers to link the directory (skipped under --output json; `zones remove` never offers). +│ ├── records (canonical; aliases: record, rec) │ │ ├── list [domain] (alias: ls) List the records within a zone │ │ ├── add [domain] [name] [type] [values..] [--ttl] [--comment] [--pull-zone] [--script] │ │ │ Add a DNS record (interactive wizard when args omitted; MX/SRV/CAA use positional values; PullZone/Script use --pull-zone/--script) @@ -858,9 +867,11 @@ bunny │ │ ├── remove [domain] [id] [--force] Remove a DNS record (alias: rm; prompts to pick zone+record when omitted) │ │ ├── import [domain] [file] Import records from a BIND zone file (prompts for zone/file when omitted) │ │ └── export [domain] [--file] [--save] Export a zone as a BIND zone file (stdout, --file , or --save → .zone) -│ └── zone (aliases: zones; hidden: domain, domains) +│ └── zones (canonical; aliases: zone; hidden: domain, domains) │ ├── list List all DNS zones (alias: ls) │ ├── add Create a DNS zone +│ ├── link [domain] Link this directory to a zone → .bunny/dns.json (pick interactively when omitted) +│ ├── unlink [--force] Remove .bunny/dns.json, unlinking this directory │ ├── show [domain] Show zone details (nameservers, SOA, DNSSEC, logging, record count) │ ├── remove [domain] [--force] Delete a DNS zone and its records (alias: rm) │ ├── stats [domain] [--from] [--to] Show DNS query statistics for a zone (defaults to last 30 days; text mode renders a bar chart) @@ -901,7 +912,7 @@ bunny ├── scripts │ ├── init [--name] [--type] [--template] [--github-actions] [--deploy] [--skip-git] [--skip-install] │ │ Create a new Edge Script project from a template -│ ├── create [name] [--type] [--pull-zone] [--pull-zone-name] [--link] +│ ├── create [name] [--type] [--pull-zone] [--pull-zone-name] [--link] [--domain] │ │ 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) @@ -912,13 +923,13 @@ bunny │ │ Publish (roll back to) a past deployment by release ID │ ├── 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] +│ │ ├── add [id] [--ssl] [--wait] [--no-force-ssl] [--pull-zone] +│ │ │ Add a custom domain (SSL opt-in; HTTPS forced by default; --wait polls DNS then issues SSL) +│ │ ├── ssl [id] [--no-force-ssl] [--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 +│ │ ├── list [id] (alias: ls) [--pull-zone] List pull zone domains +│ │ └── remove [id] (alias: rm) [--force] [--pull-zone] +│ │ Remove a custom domain (script [id] also accepted as --id on all domains subcommands) │ ├── env │ │ ├── list [id] List environment variables │ │ ├── set [id] Set environment variable diff --git a/packages/cli/README.md b/packages/cli/README.md index a954104..bb07432 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -512,6 +512,8 @@ bunny scripts init --template-repo https://github.com/owner/my-template When `--repo` / `--template-repo` is given without `--type`, the script type defaults to `standalone`. +After creating the script on bunny.net, the interactive wizard also asks for an optional custom domain — the same DNS + HTTPS flow as `bunny scripts create` and `bunny scripts domains add`, including the offer to wire up the DNS record for you (after confirmation) when the domain is on Bunny DNS. + With `--github-actions`, git is initialized automatically, the template's `.github/` workflow is kept, and after creating the script you'll be shown the `SCRIPT_ID` to add as a GitHub repo secret. With `--no-github-actions`, the `.github/` directory is removed and git init is prompted (or skipped via `--skip-git`). The `.changeset/` directory is always removed from the template — bunny scripts don't use it. @@ -529,6 +531,9 @@ bunny scripts create my-script --type middleware # Skip pull zone creation and directory linking bunny scripts create my-script --no-pull-zone --no-link + +# Create and attach a custom domain +bunny scripts create my-script --domain shop.example.com ``` | Flag | Description | @@ -537,6 +542,9 @@ bunny scripts create my-script --no-pull-zone --no-link | `--pull-zone` | Create a linked pull zone (default: true). Use `--no-pull-zone` to skip. | | `--pull-zone-name` | Name for the linked pull zone | | `--link` | Link this directory to the new script (default: true). Use `--no-link` to skip. | +| `--domain` | Add a custom domain to the new script's pull zone (prompted when interactive) | + +When run interactively, `create` also asks for an optional custom domain. If that domain is already in one of your Bunny DNS zones, it offers to add (or repoint) the DNS record for you — declining, or any record it would overwrite, always prompts first, and nothing is changed without confirmation. With the record in place, DNS is live on bunny's resolvers immediately, so it skips straight to issuing the free SSL certificate. Otherwise it prints the `CNAME` record to create and offers to wait while DNS propagates, issuing the certificate automatically once the domain points at bunny.net — the same flow as `bunny scripts domains add --wait`. #### `bunny scripts deploy` @@ -712,28 +720,35 @@ bunny scripts env pull --force #### `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.) +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 a trailing `[id]` positional (or the equivalent `--id ` flag) 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`). +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. When run interactively it then offers to wait while DNS propagates (checking every few seconds, up to 10 minutes) and issues the certificate automatically once the domain is live; pass `--wait` to do that without the prompt, or `--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 +# Add a domain, get DNS instructions, and optionally wait for DNS + HTTPS bunny scripts domains add shop.example.com +# Add, wait for DNS to propagate, then enable HTTPS — no prompts +bunny scripts domains add shop.example.com --wait + # 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 + +# Target a script other than the linked one +bunny scripts domains add shop.example.com 12345 ``` | Flag | Description | | ---------------- | ----------------------------------------------------------------------- | | `--ssl` | Issue a free SSL certificate now and force HTTPS (requires DNS pointed) | +| `--wait` | Wait for DNS to point at bunny.net (up to 10 minutes), then issue SSL | | `--no-force-ssl` | When issuing SSL, keep serving HTTP instead of redirecting to HTTPS | -| `--id` | Edge Script ID (uses linked script if omitted) | +| `[id]` / `--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` diff --git a/packages/cli/src/commands/dns/api.ts b/packages/cli/src/commands/dns/api.ts index 97d59f7..6924eae 100644 --- a/packages/cli/src/commands/dns/api.ts +++ b/packages/cli/src/commands/dns/api.ts @@ -57,7 +57,7 @@ export async function resolveZone( if (!match?.Id) { throw new UserError( `No DNS zone found for "${domainOrId}".`, - 'Run "bunny dns zone list" to see your zones.', + 'Run "bunny dns zones list" to see your zones.', ); } return fetchZone(client, match.Id); diff --git a/packages/cli/src/commands/dns/constants.ts b/packages/cli/src/commands/dns/constants.ts new file mode 100644 index 0000000..f9f771e --- /dev/null +++ b/packages/cli/src/commands/dns/constants.ts @@ -0,0 +1,7 @@ +/** Local manifest written by `bunny dns zones link`, read when a [domain] is omitted. */ +export const DNS_MANIFEST = "dns.json"; + +export interface DnsManifest { + id: number; + domain?: string; +} diff --git a/packages/cli/src/commands/dns/interactive.ts b/packages/cli/src/commands/dns/interactive.ts index edb9952..927970b 100644 --- a/packages/cli/src/commands/dns/interactive.ts +++ b/packages/cli/src/commands/dns/interactive.ts @@ -1,6 +1,9 @@ import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; -import { spinner } from "../../core/ui.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, saveManifest } from "../../core/manifest.ts"; +import type { OutputFormat } from "../../core/types.ts"; +import { confirm, spinner } from "../../core/ui.ts"; import { type CoreClient, type DnsRecordModel, @@ -9,19 +12,58 @@ import { fetchZones, resolveZone, } from "./api.ts"; +import { DNS_MANIFEST, type DnsManifest } from "./constants.ts"; import { formatRecordValue, recordName, recordTypeLabel, } from "./record-types.ts"; +function writeDnsManifest(id: number, domain: string | undefined): void { + saveManifest(DNS_MANIFEST, { id, domain }); + logger.success(`Linked this directory to DNS zone ${domain ?? id}.`); +} + +/** Offer to remember a zone picked from the prompt; a no-op if the user declines. */ +async function maybeLinkZone(zone: DnsZoneModel): Promise { + if (!(await confirm(`Link this directory to ${zone.Domain}?`))) return; + writeDnsManifest(zone.Id as number, zone.Domain ?? undefined); +} + +/** + * Link a directory to a zone discovered during another flow (e.g. setting up a + * script's custom domain). Writes silently when nothing is linked yet, confirms + * before replacing a different linked zone, and no-ops when already linked here. + */ +export async function autoLinkDnsZone(zone: { + id: number; + domain?: string; +}): Promise { + const existing = loadManifest(DNS_MANIFEST); + if (existing.id === zone.id) return; + if ( + existing.id && + !(await confirm( + `This directory is linked to DNS zone ${existing.domain ?? existing.id}. Relink to ${zone.domain ?? zone.id}?`, + )) + ) { + return; + } + writeDnsManifest(zone.id, zone.domain); +} + /** * Resolve a zone by name/ID, or prompt the user to pick one when no * reference is given. Manages its own spinner so it never spins over a prompt. + * + * When `offerLink` is set and the zone is chosen via the picker (not an + * explicit ref or the existing manifest), offer to link the directory to it. + * The offer is skipped under `--output json` so machine output stays clean. */ export async function resolveZoneInteractive( client: CoreClient, ref: string | undefined, + opts: { output?: OutputFormat; offerLink?: boolean } = {}, ): Promise { if (ref) { const spin = spinner("Resolving zone..."); @@ -33,6 +75,20 @@ export async function resolveZoneInteractive( } } + // Fall back to a directory linked with `bunny dns zones link` before prompting. + const manifest = loadManifest(DNS_MANIFEST); + if (manifest.id) { + const spin = spinner("Loading linked zone..."); + spin.start(); + try { + const zone = await fetchZone(client, manifest.id); + logger.dim(`Using linked zone ${zone.Domain}.`); + return zone; + } finally { + spin.stop(); + } + } + const spin = spinner("Fetching zones..."); spin.start(); let zones: DnsZoneModel[]; @@ -45,7 +101,7 @@ export async function resolveZoneInteractive( if (zones.length === 0) { throw new UserError( "No DNS zones found.", - 'Create one with "bunny dns zone add ".', + 'Create one with "bunny dns zones add ".', ); } @@ -59,11 +115,17 @@ export async function resolveZoneInteractive( const resolveSpin = spinner("Loading zone..."); resolveSpin.start(); + let zone: DnsZoneModel; try { - return await fetchZone(client, id); + zone = await fetchZone(client, id); } finally { resolveSpin.stop(); } + + if (opts.offerLink && opts.output !== "json") { + await maybeLinkZone(zone); + } + return zone; } /** diff --git a/packages/cli/src/commands/dns/record-types.ts b/packages/cli/src/commands/dns/record-types.ts index bae1a3b..c38edb3 100644 --- a/packages/cli/src/commands/dns/record-types.ts +++ b/packages/cli/src/commands/dns/record-types.ts @@ -1,38 +1,14 @@ import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { + type DnsRecordTypes, + RECORD_TYPES, + recordTypeLabel, +} from "../../core/dns-record-types.ts"; import { UserError } from "../../core/errors.ts"; -export type DnsRecordTypes = components["schemas"]["DnsRecordTypes"]; +export { type DnsRecordTypes, RECORD_TYPES, recordTypeLabel }; export type DnsRecordModel = components["schemas"]["DnsRecordModel"]; -/** Record type name → bunny.net integer enum value. */ -export const RECORD_TYPES = { - A: 0, - AAAA: 1, - CNAME: 2, - TXT: 3, - MX: 4, - REDIRECT: 5, - FLATTEN: 6, - PULLZONE: 7, - SRV: 8, - CAA: 9, - PTR: 10, - SCRIPT: 11, - NS: 12, - SVCB: 13, - HTTPS: 14, - TLSA: 15, -} as const satisfies Record; - -const TYPE_LABELS: Record = Object.fromEntries( - Object.entries(RECORD_TYPES).map(([name, value]) => [value, name]), -); - -/** Human label for a record type integer, falling back to "UNKNOWN". */ -export function recordTypeLabel(type: number | null | undefined): string { - return TYPE_LABELS[type ?? -1] ?? "UNKNOWN"; -} - /** Parse a record type name (e.g. "A", "cname") to its enum value, or throw. */ export function parseRecordType(value: string): DnsRecordTypes { const key = value.trim().toUpperCase() as keyof typeof RECORD_TYPES; diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index d931eba..18c2811 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -201,20 +201,20 @@ export const dnsAddCommand = defineCommand({ command: "add [domain] [name] [type] [values..]", describe: "Add a DNS record to a zone (interactive when args are omitted).", examples: [ - ["$0 dns record add example.com api A 198.51.100.1", "Add an A record"], + ["$0 dns records add example.com api A 198.51.100.1", "Add an A record"], [ - "$0 dns record add example.com '@' MX mail.example.com 10", + "$0 dns records add example.com '@' MX mail.example.com 10", "Add an MX record", ], [ - "$0 dns record add example.com '@' SRV 10 0 389 sip.example.com", + "$0 dns records add example.com '@' SRV 10 0 389 sip.example.com", "Add an SRV record", ], [ - "$0 dns record add example.com '@' CAA '0 issue \"letsencrypt.org\"'", + "$0 dns records add example.com '@' CAA '0 issue \"letsencrypt.org\"'", "Add a CAA record", ], - ["$0 dns record add", "Interactive wizard"], + ["$0 dns records add", "Interactive wizard"], ], builder: (yargs) => @@ -256,7 +256,10 @@ export const dnsAddCommand = defineCommand({ const interactive = !args.type; // Resolve the target zone (prompt with a picker when no domain given). - const zone = await resolveZoneInteractive(client, args.domain); + const zone = await resolveZoneInteractive(client, args.domain, { + output, + offerLink: true, + }); let record: AddDnsRecordModel; if (interactive) { diff --git a/packages/cli/src/commands/dns/record/export.ts b/packages/cli/src/commands/dns/record/export.ts index 5cea03a..aad6180 100644 --- a/packages/cli/src/commands/dns/record/export.ts +++ b/packages/cli/src/commands/dns/record/export.ts @@ -16,12 +16,12 @@ export const dnsExportCommand = defineCommand({ command: "export [domain]", describe: "Export a zone's records as a BIND zone file.", examples: [ - ["$0 dns record export example.com", "Print the zone file to stdout"], + ["$0 dns records export example.com", "Print the zone file to stdout"], [ - "$0 dns record export example.com --file ./example.zone", + "$0 dns records export example.com --file ./example.zone", "Write to a path", ], - ["$0 dns record export example.com --save", "Write to ./example.com.zone"], + ["$0 dns records export example.com --save", "Write to ./example.com.zone"], ], builder: (yargs) => @@ -41,7 +41,10 @@ export const dnsExportCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const spin = spinner("Exporting zone..."); spin.start(); diff --git a/packages/cli/src/commands/dns/record/import.ts b/packages/cli/src/commands/dns/record/import.ts index d6c9672..ceea30a 100644 --- a/packages/cli/src/commands/dns/record/import.ts +++ b/packages/cli/src/commands/dns/record/import.ts @@ -18,7 +18,7 @@ export const dnsImportCommand = defineCommand({ describe: "Import DNS records into a zone from a BIND zone file.", examples: [ [ - "$0 dns record import example.com ./zonefile.txt", + "$0 dns records import example.com ./zonefile.txt", "Import records from a zone file", ], ], @@ -35,7 +35,10 @@ export const dnsImportCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); let path = file; if (!path) { diff --git a/packages/cli/src/commands/dns/record/index.ts b/packages/cli/src/commands/dns/record/index.ts index a3cc7bf..34d1b5c 100644 --- a/packages/cli/src/commands/dns/record/index.ts +++ b/packages/cli/src/commands/dns/record/index.ts @@ -7,7 +7,7 @@ import { dnsRemoveCommand } from "./remove.ts"; import { dnsUpdateCommand } from "./update.ts"; export const dnsRecordNamespace = defineNamespace( - "record", + "records", "Manage the DNS records within a zone.", [ dnsRecordListCommand, @@ -17,5 +17,5 @@ export const dnsRecordNamespace = defineNamespace( dnsImportCommand, dnsExportCommand, ], - ["records", "rec"], + ["record", "rec"], ); diff --git a/packages/cli/src/commands/dns/record/list.ts b/packages/cli/src/commands/dns/record/list.ts index 60ff0b2..b72ee19 100644 --- a/packages/cli/src/commands/dns/record/list.ts +++ b/packages/cli/src/commands/dns/record/list.ts @@ -20,8 +20,8 @@ export const dnsRecordListCommand = defineCommand({ aliases: ["ls"], describe: "List the records within a zone.", examples: [ - ["$0 dns record list example.com", "List records in a zone"], - ["$0 dns record list example.com --output json", "JSON output"], + ["$0 dns records list example.com", "List records in a zone"], + ["$0 dns records list example.com --output json", "JSON output"], ], builder: (yargs) => @@ -34,7 +34,10 @@ export const dnsRecordListCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const records = (zone.Records ?? []).sort((a, b) => recordName(a.Name).localeCompare(recordName(b.Name)), diff --git a/packages/cli/src/commands/dns/record/remove.ts b/packages/cli/src/commands/dns/record/remove.ts index 0811f64..a106699 100644 --- a/packages/cli/src/commands/dns/record/remove.ts +++ b/packages/cli/src/commands/dns/record/remove.ts @@ -25,9 +25,9 @@ export const dnsRemoveCommand = defineCommand({ aliases: ["rm"], describe: "Remove a DNS record from a zone (prompts when args are omitted).", examples: [ - ["$0 dns record remove example.com 123", "Remove a record by ID"], - ["$0 dns record remove example.com 123 --force", "Skip confirmation"], - ["$0 dns record remove", "Pick a zone and record interactively"], + ["$0 dns records remove example.com 123", "Remove a record by ID"], + ["$0 dns records remove example.com 123 --force", "Skip confirmation"], + ["$0 dns records remove", "Pick a zone and record interactively"], ], builder: (yargs) => @@ -45,7 +45,10 @@ export const dnsRemoveCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const record = await resolveRecordInteractive(zone, id, "remove"); const label = `${recordTypeLabel(record.Type)} ${recordName(record.Name)} → ${formatRecordValue(record)}`; diff --git a/packages/cli/src/commands/dns/record/update.ts b/packages/cli/src/commands/dns/record/update.ts index 50abd4b..4294528 100644 --- a/packages/cli/src/commands/dns/record/update.ts +++ b/packages/cli/src/commands/dns/record/update.ts @@ -37,13 +37,13 @@ export const dnsUpdateCommand = defineCommand({ describe: "Update an existing DNS record (prompts when args are omitted).", examples: [ [ - "$0 dns record update example.com 123 --value 198.51.100.2", + "$0 dns records update example.com 123 --value 198.51.100.2", "Change a record value", ], - ["$0 dns record update example.com 123 --ttl 3600", "Change the TTL"], - ["$0 dns record update example.com 123 --disabled", "Disable a record"], + ["$0 dns records update example.com 123 --ttl 3600", "Change the TTL"], + ["$0 dns records update example.com 123 --disabled", "Disable a record"], [ - "$0 dns record update example.com --value 198.51.100.2", + "$0 dns records update example.com --value 198.51.100.2", "Pick the record interactively", ], ], @@ -81,7 +81,10 @@ export const dnsUpdateCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const existing = await resolveRecordInteractive(zone, id, "update"); const recordId = existing.Id as number; diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts index b1c3f46..b409aa0 100644 --- a/packages/cli/src/commands/dns/zone/add.ts +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -13,7 +13,7 @@ interface ZoneAddArgs { export const dnsZoneAddCommand = defineCommand({ command: "add ", describe: "Create a new DNS zone.", - examples: [["$0 dns zone add example.com", "Create a zone for example.com"]], + examples: [["$0 dns zones add example.com", "Create a zone for example.com"]], builder: (yargs) => yargs.positional("domain", { diff --git a/packages/cli/src/commands/dns/zone/dnssec/disable.ts b/packages/cli/src/commands/dns/zone/dnssec/disable.ts index 1e64b89..acdd891 100644 --- a/packages/cli/src/commands/dns/zone/dnssec/disable.ts +++ b/packages/cli/src/commands/dns/zone/dnssec/disable.ts @@ -15,8 +15,8 @@ export const dnsZoneDnssecDisableCommand = defineCommand({ command: "disable [domain]", describe: "Disable DNSSEC for a zone.", examples: [ - ["$0 dns zone dnssec disable example.com", "Disable DNSSEC"], - ["$0 dns zone dnssec disable example.com --force", "Skip confirmation"], + ["$0 dns zones dnssec disable example.com", "Disable DNSSEC"], + ["$0 dns zones dnssec disable example.com --force", "Skip confirmation"], ], builder: (yargs) => @@ -33,7 +33,10 @@ export const dnsZoneDnssecDisableCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const confirmed = await confirm(`Disable DNSSEC for ${zone.Domain}?`, { force, diff --git a/packages/cli/src/commands/dns/zone/dnssec/enable.ts b/packages/cli/src/commands/dns/zone/dnssec/enable.ts index aeb6e09..2afbc8f 100644 --- a/packages/cli/src/commands/dns/zone/dnssec/enable.ts +++ b/packages/cli/src/commands/dns/zone/dnssec/enable.ts @@ -15,7 +15,7 @@ interface EnableArgs { export const dnsZoneDnssecEnableCommand = defineCommand({ command: "enable [domain]", describe: "Enable DNSSEC for a zone and print its DS record.", - examples: [["$0 dns zone dnssec enable example.com", "Enable DNSSEC"]], + examples: [["$0 dns zones dnssec enable example.com", "Enable DNSSEC"]], builder: (yargs) => yargs.positional("domain", { @@ -27,7 +27,10 @@ export const dnsZoneDnssecEnableCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const spin = spinner("Enabling DNSSEC..."); spin.start(); diff --git a/packages/cli/src/commands/dns/zone/index.ts b/packages/cli/src/commands/dns/zone/index.ts index f3900dd..ef20d84 100644 --- a/packages/cli/src/commands/dns/zone/index.ts +++ b/packages/cli/src/commands/dns/zone/index.ts @@ -2,12 +2,14 @@ import type { CommandModule } from "yargs"; import { defineNamespace } from "../../../core/define-namespace.ts"; import { dnsZoneAddCommand } from "./add.ts"; import { dnsZoneDnssecNamespace } from "./dnssec/index.ts"; +import { dnsZoneLinkCommand } from "./link.ts"; import { dnsZoneListCommand } from "./list.ts"; import { dnsZoneLoggingNamespace } from "./logging/index.ts"; import { dnsNameserversCommand } from "./nameservers.ts"; import { dnsZoneRemoveCommand } from "./remove.ts"; import { dnsZoneShowCommand } from "./show.ts"; import { dnsStatsCommand } from "./stats.ts"; +import { dnsZoneUnlinkCommand } from "./unlink.ts"; const subcommands: CommandModule[] = [ dnsZoneListCommand, @@ -16,15 +18,17 @@ const subcommands: CommandModule[] = [ dnsZoneRemoveCommand, dnsStatsCommand, dnsNameserversCommand, + dnsZoneLinkCommand, + dnsZoneUnlinkCommand, dnsZoneDnssecNamespace, dnsZoneLoggingNamespace, ]; export const dnsZoneNamespace = defineNamespace( - "zone", + "zones", "Manage DNS zones — settings, DNSSEC, logging, stats, nameservers.", subcommands, - ["zones"], + ["zone"], ); // Hidden aliases so `bunny dns domain …` works without cluttering help. diff --git a/packages/cli/src/commands/dns/zone/link.ts b/packages/cli/src/commands/dns/zone/link.ts new file mode 100644 index 0000000..77f5bc9 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/link.ts @@ -0,0 +1,86 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; +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 { logger } from "../../../core/logger.ts"; +import { saveManifest } from "../../../core/manifest.ts"; +import { spinner } from "../../../core/ui.ts"; +import { fetchZones, resolveZone } from "../api.ts"; +import { DNS_MANIFEST } from "../constants.ts"; + +interface LinkArgs { + domain?: string; +} + +export const dnsZoneLinkCommand = defineCommand({ + command: "link [domain]", + describe: `Link this directory to a DNS zone (writes .bunny/${DNS_MANIFEST}).`, + examples: [ + ["$0 dns zones link example.com", "Link by domain or zone ID"], + ["$0 dns zones link", "Pick a zone interactively"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // Resolve an explicit reference, otherwise prompt over every zone — never + // reuse the manifest here, since linking is how the manifest is (re)set. + const zone = await (async () => { + if (domain) { + const spin = spinner("Resolving zone..."); + spin.start(); + try { + return await resolveZone(client, domain); + } finally { + spin.stop(); + } + } + + const spin = spinner("Fetching zones..."); + spin.start(); + let zones: Awaited>; + try { + zones = await fetchZones(client); + } finally { + spin.stop(); + } + + if (zones.length === 0) { + throw new UserError( + "No DNS zones found.", + 'Create one with "bunny dns zones add ".', + ); + } + + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Zone to link:", + choices: zones.map((z) => ({ title: z.Domain ?? "", value: z })), + }); + if (!selected) throw new UserError("Link cancelled."); + return selected; + })(); + + saveManifest(DNS_MANIFEST, { + id: zone.Id, + domain: zone.Domain ?? undefined, + }); + + if (output === "json") { + logger.log(JSON.stringify({ id: zone.Id, domain: zone.Domain })); + return; + } + + logger.success(`Linked to ${zone.Domain} (${zone.Id}).`); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/list.ts b/packages/cli/src/commands/dns/zone/list.ts index 1896853..102cbe8 100644 --- a/packages/cli/src/commands/dns/zone/list.ts +++ b/packages/cli/src/commands/dns/zone/list.ts @@ -12,8 +12,8 @@ export const dnsZoneListCommand = defineCommand({ aliases: ["ls"], describe: "List all DNS zones.", examples: [ - ["$0 dns zone list", "List all DNS zones"], - ["$0 dns zone list --output json", "JSON output"], + ["$0 dns zones list", "List all DNS zones"], + ["$0 dns zones list --output json", "JSON output"], ], handler: async ({ profile, output, verbose, apiKey }) => { diff --git a/packages/cli/src/commands/dns/zone/logging/disable.ts b/packages/cli/src/commands/dns/zone/logging/disable.ts index 62dadb8..6e0f9f5 100644 --- a/packages/cli/src/commands/dns/zone/logging/disable.ts +++ b/packages/cli/src/commands/dns/zone/logging/disable.ts @@ -15,8 +15,8 @@ export const dnsZoneLoggingDisableCommand = defineCommand({ command: "disable [domain]", describe: "Disable DNS query logging for a zone.", examples: [ - ["$0 dns zone logging disable example.com", "Stop collecting query logs"], - ["$0 dns zone logging disable example.com --force", "Skip confirmation"], + ["$0 dns zones logging disable example.com", "Stop collecting query logs"], + ["$0 dns zones logging disable example.com --force", "Skip confirmation"], ], builder: (yargs) => @@ -33,7 +33,10 @@ export const dnsZoneLoggingDisableCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const confirmed = await confirm( `Disable DNS query logging for ${zone.Domain}?`, diff --git a/packages/cli/src/commands/dns/zone/logging/enable.ts b/packages/cli/src/commands/dns/zone/logging/enable.ts index 3a8683b..ab2669f 100644 --- a/packages/cli/src/commands/dns/zone/logging/enable.ts +++ b/packages/cli/src/commands/dns/zone/logging/enable.ts @@ -30,9 +30,9 @@ export const dnsZoneLoggingEnableCommand = defineCommand({ command: "enable [domain]", describe: "Enable DNS query logging for a zone.", examples: [ - ["$0 dns zone logging enable example.com", "Start collecting query logs"], + ["$0 dns zones logging enable example.com", "Start collecting query logs"], [ - "$0 dns zone logging enable example.com --anonymize-ip --anonymization drop", + "$0 dns zones logging enable example.com --anonymize-ip --anonymization drop", "Enable with IP anonymization", ], ], @@ -55,7 +55,10 @@ export const dnsZoneLoggingEnableCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const body: LoggingUpdate = { LoggingEnabled: true }; diff --git a/packages/cli/src/commands/dns/zone/nameservers.ts b/packages/cli/src/commands/dns/zone/nameservers.ts index 420cde8..43df5fa 100644 --- a/packages/cli/src/commands/dns/zone/nameservers.ts +++ b/packages/cli/src/commands/dns/zone/nameservers.ts @@ -18,8 +18,8 @@ export const dnsNameserversCommand = defineCommand({ aliases: ["ns"], describe: "Show the nameservers to set at your registrar for a zone.", examples: [ - ["$0 dns zone nameservers example.com", "Show the zone's nameservers"], - ["$0 dns zone ns example.com --output json", "JSON output"], + ["$0 dns zones nameservers example.com", "Show the zone's nameservers"], + ["$0 dns zones ns example.com --output json", "JSON output"], ], builder: (yargs) => @@ -32,7 +32,10 @@ export const dnsNameserversCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const custom = zone.CustomNameserversEnabled === true && diff --git a/packages/cli/src/commands/dns/zone/remove.ts b/packages/cli/src/commands/dns/zone/remove.ts index 2bb9aa1..2b6d62e 100644 --- a/packages/cli/src/commands/dns/zone/remove.ts +++ b/packages/cli/src/commands/dns/zone/remove.ts @@ -16,9 +16,9 @@ export const dnsZoneRemoveCommand = defineCommand({ aliases: ["rm"], describe: "Delete a DNS zone and all of its records.", examples: [ - ["$0 dns zone remove example.com", "Delete a zone"], - ["$0 dns zone remove example.com --force", "Skip confirmation"], - ["$0 dns zone remove", "Pick a zone interactively"], + ["$0 dns zones remove example.com", "Delete a zone"], + ["$0 dns zones remove example.com --force", "Skip confirmation"], + ["$0 dns zones remove", "Pick a zone interactively"], ], builder: (yargs) => diff --git a/packages/cli/src/commands/dns/zone/show.ts b/packages/cli/src/commands/dns/zone/show.ts index 3509f33..5c820dd 100644 --- a/packages/cli/src/commands/dns/zone/show.ts +++ b/packages/cli/src/commands/dns/zone/show.ts @@ -14,8 +14,8 @@ export const dnsZoneShowCommand = defineCommand({ command: "show [domain]", describe: "Show details for a DNS zone.", examples: [ - ["$0 dns zone show example.com", "Show zone details"], - ["$0 dns zone show example.com --output json", "JSON output"], + ["$0 dns zones show example.com", "Show zone details"], + ["$0 dns zones show example.com --output json", "JSON output"], ], builder: (yargs) => @@ -28,7 +28,10 @@ export const dnsZoneShowCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); if (output === "json") { logger.log(JSON.stringify(zone, null, 2)); diff --git a/packages/cli/src/commands/dns/zone/stats.ts b/packages/cli/src/commands/dns/zone/stats.ts index 7308c29..307d80a 100644 --- a/packages/cli/src/commands/dns/zone/stats.ts +++ b/packages/cli/src/commands/dns/zone/stats.ts @@ -19,9 +19,9 @@ export const dnsStatsCommand = defineCommand({ command: "stats [domain]", describe: "Show DNS query statistics for a zone.", examples: [ - ["$0 dns zone stats example.com", "Statistics for the last 30 days"], + ["$0 dns zones stats example.com", "Statistics for the last 30 days"], [ - "$0 dns zone stats example.com --from 2026-05-01 --to 2026-05-31", + "$0 dns zones stats example.com --from 2026-05-01 --to 2026-05-31", "Statistics for a date range", ], ], @@ -42,7 +42,10 @@ export const dnsStatsCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveZoneInteractive(client, domain); + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); const spin = spinner("Fetching statistics..."); spin.start(); diff --git a/packages/cli/src/commands/dns/zone/unlink.ts b/packages/cli/src/commands/dns/zone/unlink.ts new file mode 100644 index 0000000..00dc6b3 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/unlink.ts @@ -0,0 +1,52 @@ +import { defineCommand } from "../../../core/define-command.ts"; +import { logger } from "../../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../../core/manifest.ts"; +import { confirm } from "../../../core/ui.ts"; +import { DNS_MANIFEST, type DnsManifest } from "../constants.ts"; + +interface UnlinkArgs { + force?: boolean; +} + +export const dnsZoneUnlinkCommand = defineCommand({ + command: "unlink", + describe: `Remove .bunny/${DNS_MANIFEST}, unlinking this directory from its zone.`, + + builder: (yargs) => + yargs.option("force", { + alias: "f", + type: "boolean", + describe: "Skip the confirmation prompt", + }), + + handler: async ({ force, output }) => { + const existing = loadManifest(DNS_MANIFEST); + + if (!existing.id) { + if (output === "json") { + logger.log(JSON.stringify({ unlinked: false, reason: "no-manifest" })); + return; + } + logger.log(`Nothing to unlink: no .bunny/${DNS_MANIFEST} in this tree.`); + return; + } + + if (!force) { + const confirmed = await confirm( + `Unlink from ${existing.domain ?? existing.id}?`, + ); + if (!confirmed) { + logger.log("Unlink cancelled."); + return; + } + } + + removeManifest(DNS_MANIFEST); + + if (output === "json") { + logger.log(JSON.stringify({ unlinked: true, id: existing.id })); + return; + } + logger.success("Unlinked."); + }, +}); diff --git a/packages/cli/src/commands/scripts/create.ts b/packages/cli/src/commands/scripts/create.ts index 68c4e73..be940da 100644 --- a/packages/cli/src/commands/scripts/create.ts +++ b/packages/cli/src/commands/scripts/create.ts @@ -1,14 +1,25 @@ import { basename, resolve } from "node:path"; -import { createComputeClient } from "@bunny.net/openapi-client"; +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; import prompts from "prompts"; 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 { formatKeyValue } from "../../core/format.ts"; +import { + addHostname, + normalizeHostname, + offerBunnyDnsThenSsl, + offerDnsWaitAndSsl, + printSslHint, +} from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; import { confirm, spinner } from "../../core/ui.ts"; +import { autoLinkDnsZone } from "../dns/interactive.ts"; import { promptOpenInBrowser } from "./api.ts"; import { type EdgeScriptTypes, @@ -34,6 +45,9 @@ const ARG_PULL_ZONE_NAME_DESCRIPTION = "Name for the linked pull zone"; const ARG_LINK = "link"; const ARG_LINK_DESCRIPTION = "Link this directory to the new script (default: true). Use --no-link to skip."; +const ARG_DOMAIN = "domain"; +const ARG_DOMAIN_DESCRIPTION = + "Add a custom domain to the new script's pull zone (prompted when interactive)"; interface CreateArgs { [ARG_NAME]?: string; @@ -41,6 +55,7 @@ interface CreateArgs { [ARG_PULL_ZONE]?: boolean; [ARG_PULL_ZONE_NAME]?: string; [ARG_LINK]?: boolean; + [ARG_DOMAIN]?: string; } interface CreatedScript { @@ -48,6 +63,7 @@ interface CreatedScript { name: string; scriptType: EdgeScriptTypes; hostname?: string; + pullZoneId?: number; } /** @@ -94,9 +110,93 @@ export async function createScript(opts: { name: script.Name ?? opts.name, scriptType: opts.scriptType, hostname: script.LinkedPullZones?.[0]?.DefaultHostname ?? undefined, + pullZoneId: script.LinkedPullZones?.[0]?.Id ?? undefined, }; } +/** + * Attach a custom domain to the new script's pull zone, print the DNS + * instructions, and (when interactive) offer to wait for DNS and enable + * HTTPS. A failure to add the domain is a warning, not an error — the + * script itself was already created. + * + * Shared between `scripts create` and `scripts init`. Returns true when + * an SSL certificate was issued for the domain. + */ +export async function setupCustomDomain(opts: { + profile: string; + apiKey?: string; + verbose: boolean; + pullZoneId: number; + domain: string; + scriptId: number; + linked: boolean; + interactive: boolean; +}): Promise { + const config = resolveConfig(opts.profile, opts.apiKey, opts.verbose); + const coreClient = createCoreClient(clientOptions(config, opts.verbose)); + const idSuffix = opts.linked ? "" : ` --id ${opts.scriptId}`; + const sslHint = `bunny scripts domains ssl ${opts.domain}${idSuffix}`; + + const spin = spinner(`Adding ${opts.domain}...`); + spin.start(); + + let cnameTarget: string | undefined; + try { + ({ cnameTarget } = await addHostname( + coreClient, + opts.pullZoneId, + opts.domain, + )); + } catch (err) { + spin.stop(); + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Couldn't add ${opts.domain}: ${message}`); + logger.dim(` Retry: bunny scripts domains add ${opts.domain}${idSuffix}`); + return false; + } + + spin.stop(); + logger.success(`Added ${opts.domain} to pull zone ${opts.pullZoneId}.`); + + if (!cnameTarget) return false; + + // If the domain is on Bunny DNS, offer to add the record (prompted) so SSL can issue right away. + if (opts.interactive) { + const issued = await offerBunnyDnsThenSsl({ + coreClient, + hostname: opts.domain, + pullZoneId: opts.pullZoneId, + cnameTarget, + forceSsl: true, + sslHint, + verbose: opts.verbose, + // Only link the directory when this is a linked script project. + onBunnyDnsZone: opts.linked ? autoLinkDnsZone : undefined, + }); + if (issued !== null) return issued; + } + + logger.log(); + logger.log("Point your DNS at bunny.net to activate it:"); + logger.accent(` CNAME ${opts.domain} → ${cnameTarget}`); + logger.log(); + + if (!opts.interactive) { + printSslHint(sslHint); + return false; + } + + return offerDnsWaitAndSsl({ + coreClient, + pullZoneId: opts.pullZoneId, + hostname: opts.domain, + cnameTarget, + forceSsl: true, + sslHint, + }); +} + /** * Create a new Edge Script on bunny.net (without scaffolding a project). * @@ -133,6 +233,10 @@ export const scriptsCreateCommand = defineCommand({ "$0 scripts create my-script --no-pull-zone --no-link", "Skip pull zone creation and directory linking", ], + [ + "$0 scripts create my-script --domain shop.example.com", + "Create and attach a custom domain", + ], ], builder: (yargs) => @@ -157,6 +261,10 @@ export const scriptsCreateCommand = defineCommand({ .option(ARG_LINK, { type: "boolean", describe: ARG_LINK_DESCRIPTION, + }) + .option(ARG_DOMAIN, { + type: "string", + describe: ARG_DOMAIN_DESCRIPTION, }), handler: async (args) => { @@ -166,6 +274,16 @@ export const scriptsCreateCommand = defineCommand({ const name = args[ARG_NAME] ?? basename(resolve(process.cwd())); if (!name) throw new UserError("Script name is required."); + const domainFlag = args[ARG_DOMAIN] + ? normalizeHostname(args[ARG_DOMAIN]) + : undefined; + if (domainFlag && args[ARG_PULL_ZONE] === false) { + throw new UserError( + "--domain requires a linked pull zone.", + "Drop --no-pull-zone to attach a custom domain.", + ); + } + const manifest = loadManifest(SCRIPT_MANIFEST); // Resolve script type: explicit flag → manifest → prompt → error. @@ -237,6 +355,30 @@ export const scriptsCreateCommand = defineCommand({ } if (output === "json") { + // --domain is added non-interactively; a failure still reports the created script. + let customDomain: { + hostname: string; + cnameTarget: string | null; + } | null = null; + let customDomainError: string | undefined; + if (domainFlag && created.pullZoneId != null) { + const config = resolveConfig(profile, apiKey, verbose); + const coreClient = createCoreClient(clientOptions(config, verbose)); + try { + const { cnameTarget } = await addHostname( + coreClient, + created.pullZoneId, + domainFlag, + ); + customDomain = { + hostname: domainFlag, + cnameTarget: cnameTarget ?? null, + }; + } catch (err) { + customDomainError = err instanceof Error ? err.message : String(err); + } + } + logger.log( JSON.stringify( { @@ -245,11 +387,14 @@ export const scriptsCreateCommand = defineCommand({ scriptType: created.scriptType, hostname: created.hostname ?? null, linked: shouldLink, + customDomain, + ...(customDomainError ? { customDomainError } : {}), }, null, 2, ), ); + if (customDomainError) process.exit(1); return; } @@ -276,8 +421,53 @@ export const scriptsCreateCommand = defineCommand({ logger.log(); - if (created.hostname && isInteractive) { - await promptOpenInBrowser(created.hostname); + // Custom domain: --domain flag, or offer one interactively when a pull zone exists. + let openTarget = created.hostname; + if (created.pullZoneId != null) { + let domain = domainFlag; + if (!domain && isInteractive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Custom domain (leave blank to skip):", + }); + domain = normalizeHostname(value ?? ""); + } + + if (domain) { + // A domain failure mustn't fail the command — the script already exists. + try { + const sslIssued = await setupCustomDomain({ + profile, + apiKey, + verbose, + pullZoneId: created.pullZoneId, + domain, + scriptId: created.id, + linked: shouldLink, + interactive: isInteractive, + }); + if (sslIssued) openTarget = domain; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ""; + const idSuffix = shouldLink ? "" : ` --id ${created.id}`; + logger.warn( + message + ? `Couldn't finish setting up ${domain}: ${message}` + : `Couldn't finish setting up ${domain}.`, + ); + logger.dim( + ` Retry later: bunny scripts domains add ${domain}${idSuffix} --wait`, + ); + } + logger.log(); + } + } else if (domainFlag) { + logger.warn("No linked pull zone — can't attach a custom domain."); + } + + if (openTarget && isInteractive) { + await promptOpenInBrowser(openTarget); } else { logger.dim(` Deploy: bunny scripts deploy `); } diff --git a/packages/cli/src/commands/scripts/deployments/list.ts b/packages/cli/src/commands/scripts/deployments/list.ts index 706c17f..dc6d407 100644 --- a/packages/cli/src/commands/scripts/deployments/list.ts +++ b/packages/cli/src/commands/scripts/deployments/list.ts @@ -8,12 +8,14 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { formatDateTime, formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; -import { resolveManifestId } from "../../../core/manifest.ts"; import { spinner } from "../../../core/ui.ts"; import { fetchScriptHostnames, logLiveHostnames } from "../api.ts"; -import { SCRIPT_MANIFEST } from "../constants.ts"; +import { + type ScriptSelectorArgs, + scriptSelectorBuilder, + selectScript, +} from "../interactive.ts"; -type EdgeScript = components["schemas"]["EdgeScriptModel"]; type EdgeScriptRelease = components["schemas"]["EdgeScriptReleaseModel"]; type EdgeScriptReleaseStatus = components["schemas"]["EdgeScriptReleaseStatus"]; @@ -21,9 +23,6 @@ const COMMAND = "list [id]"; const ALIASES = ["ls"] as const; const DESCRIPTION = "List deployments for an Edge Script."; -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; - const RELEASE_STATUS_LIVE: EdgeScriptReleaseStatus = 1; const STATUS_LABELS: Record = { @@ -31,9 +30,7 @@ const STATUS_LABELS: Record = { 1: "Live", }; -interface ListArgs { - [ARG_ID]?: EdgeScript["Id"]; -} +type ListArgs = ScriptSelectorArgs; /** * List all deployments (releases) for an Edge Script. @@ -42,8 +39,9 @@ interface ListArgs { * in a table. Deleted releases are excluded. If a release is currently live * and the script has a linked pull zone, the hostname is printed at the end. * - * Falls back to the linked script ID from the local manifest when no - * explicit ID is provided. + * When no ID is given it falls back to the linked script from the local + * manifest, then to an interactive picker (offering to link the directory + * for next time). * * @example * ```bash @@ -70,33 +68,29 @@ export const scriptsDeploymentsListCommand = defineCommand({ ["$0 scripts deployments list --output json", "JSON output"], ], - builder: (yargs) => - yargs.positional(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }), + builder: (yargs) => scriptSelectorBuilder(yargs), - handler: async ({ [ARG_ID]: rawId, profile, output, verbose, apiKey }) => { - const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); + handler: async ({ id: rawId, link, profile, output, verbose, apiKey }) => { const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const client = createComputeClient(options); + const { script, id, offerLink } = await selectScript(client, { + id: rawId, + link, + output, + }); + const spin = spinner("Fetching deployments..."); spin.start(); - const [releasesResult, scriptResult] = await Promise.all([ - client.GET("/compute/script/{id}/releases", { - params: { path: { id } }, - }), - client.GET("/compute/script/{id}", { + const { data } = await client + .GET("/compute/script/{id}/releases", { params: { path: { id } }, - }), - ]); + }) + .finally(() => spin.stop()); - spin.stop(); - - const releases = (releasesResult.data?.Items ?? []).filter( + const releases = (data?.Items ?? []).filter( (r: EdgeScriptRelease) => !r.Deleted, ); @@ -107,12 +101,11 @@ export const scriptsDeploymentsListCommand = defineCommand({ if (releases.length === 0) { logger.info("No deployments found for this script."); + await offerLink(); return; } - const script = scriptResult.data; - - if (script?.Name) { + if (script.Name) { logger.info(`Deployments for ${script.Name}:`); logger.log(); } @@ -134,7 +127,6 @@ export const scriptsDeploymentsListCommand = defineCommand({ ); if ( - script && releases.some((r: EdgeScriptRelease) => r.Status === RELEASE_STATUS_LIVE) ) { const coreClient = createCoreClient(options); @@ -142,5 +134,7 @@ export const scriptsDeploymentsListCommand = defineCommand({ logger.log(); logLiveHostnames(script, hostnames); } + + await offerLink(); }, }); diff --git a/packages/cli/src/commands/scripts/deployments/publish.ts b/packages/cli/src/commands/scripts/deployments/publish.ts index aa47313..903b9d1 100644 --- a/packages/cli/src/commands/scripts/deployments/publish.ts +++ b/packages/cli/src/commands/scripts/deployments/publish.ts @@ -2,23 +2,18 @@ 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 { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { resolveManifestId } from "../../../core/manifest.ts"; import { confirm, spinner } from "../../../core/ui.ts"; +import { fetchScriptHostnames, findRelease, logLiveHostnames } from "../api.ts"; import { - fetchScript, - fetchScriptHostnames, - findRelease, - logLiveHostnames, -} from "../api.ts"; -import { SCRIPT_MANIFEST } from "../constants.ts"; - -type EdgeScript = components["schemas"]["EdgeScriptModel"]; + type ScriptSelectorArgs, + scriptSelectorBuilder, + selectScript, +} from "../interactive.ts"; const COMMAND = "publish [id]"; const DESCRIPTION = "Publish (roll back to) a past Edge Script deployment."; @@ -26,14 +21,11 @@ const DESCRIPTION = "Publish (roll back to) a past Edge Script deployment."; const ARG_RELEASE = "release"; const ARG_RELEASE_DESCRIPTION = "Release ID to publish (see `deployments list`)"; -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; const ARG_FORCE = "force"; const ARG_FORCE_DESCRIPTION = "Skip the confirmation prompt"; -interface PublishArgs { +interface PublishArgs extends ScriptSelectorArgs { [ARG_RELEASE]: number; - [ARG_ID]?: EdgeScript["Id"]; [ARG_FORCE]?: boolean; } @@ -42,8 +34,9 @@ interface PublishArgs { * * `scripts deploy` already uploads and publishes in one step; this command * re-publishes an earlier release (by the ID shown in `deployments list`) - * without touching the current code. Falls back to the linked script ID from - * the local manifest when no explicit ID is provided. + * without touching the current code. When no ID is given it falls back to the + * linked script from the local manifest, then to an interactive picker + * (offering to link the directory for next time). * * @example * ```bash @@ -73,36 +66,38 @@ export const scriptsDeploymentsPublishCommand = defineCommand({ ], builder: (yargs) => - yargs - .positional(ARG_RELEASE, { + scriptSelectorBuilder( + yargs.positional(ARG_RELEASE, { type: "number", describe: ARG_RELEASE_DESCRIPTION, demandOption: true, - }) - .positional(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }) - .option(ARG_FORCE, { - type: "boolean", - alias: "f", - describe: ARG_FORCE_DESCRIPTION, }), + ).option(ARG_FORCE, { + type: "boolean", + alias: "f", + describe: ARG_FORCE_DESCRIPTION, + }), handler: async ({ [ARG_RELEASE]: releaseId, - [ARG_ID]: rawId, + id: rawId, [ARG_FORCE]: force, + link, profile, output, verbose, apiKey, }) => { - const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const client = createComputeClient(options); + const { script, id, offerLink } = await selectScript(client, { + id: rawId, + link, + output, + }); + const spin = spinner("Fetching deployments..."); spin.start(); @@ -152,9 +147,10 @@ export const scriptsDeploymentsPublishCommand = defineCommand({ logger.success(`Release ${releaseId} published.`); - const script = await fetchScript(client, id); const coreClient = createCoreClient(options); const hostnames = await fetchScriptHostnames(coreClient, script, verbose); logLiveHostnames(script, hostnames); + + await offerLink(); }, }); diff --git a/packages/cli/src/commands/scripts/env/list.ts b/packages/cli/src/commands/scripts/env/list.ts index 9b75658..240221f 100644 --- a/packages/cli/src/commands/scripts/env/list.ts +++ b/packages/cli/src/commands/scripts/env/list.ts @@ -4,22 +4,20 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; -import { resolveManifestId } from "../../../core/manifest.ts"; import { spinner } from "../../../core/ui.ts"; import { fetchEnvEntries } from "../api.ts"; -import { SCRIPT_MANIFEST } from "../constants.ts"; +import { + type ScriptSelectorArgs, + scriptSelectorBuilder, + selectScript, +} from "../interactive.ts"; const COMMAND = "list [id]"; const ALIASES = ["ls"] as const; const DESCRIPTION = "List environment variables and secrets for an Edge Script."; -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; - -interface ListArgs { - [ARG_ID]?: number; -} +type ListArgs = ScriptSelectorArgs; /** * List all environment variables and secrets for an Edge Script. @@ -52,17 +50,18 @@ export const scriptsEnvListCommand = defineCommand({ ["$0 scripts env list --output json", "JSON output"], ], - builder: (yargs) => - yargs.positional(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }), + builder: (yargs) => scriptSelectorBuilder(yargs), - handler: async ({ [ARG_ID]: rawId, profile, output, verbose, apiKey }) => { - const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); + handler: async ({ id: rawId, link, profile, output, verbose, apiKey }) => { const config = resolveConfig(profile, apiKey, verbose); const client = createComputeClient(clientOptions(config, verbose)); + const { id, offerLink } = await selectScript(client, { + id: rawId, + link, + output, + }); + const spin = spinner("Fetching environment variables..."); spin.start(); @@ -77,6 +76,7 @@ export const scriptsEnvListCommand = defineCommand({ if (entries.length === 0) { logger.info("No environment variables or secrets found."); + await offerLink(); return; } @@ -92,5 +92,7 @@ export const scriptsEnvListCommand = defineCommand({ output, ), ); + + await offerLink(); }, }); diff --git a/packages/cli/src/commands/scripts/env/pull.ts b/packages/cli/src/commands/scripts/env/pull.ts index c442396..684e90b 100644 --- a/packages/cli/src/commands/scripts/env/pull.ts +++ b/packages/cli/src/commands/scripts/env/pull.ts @@ -6,23 +6,25 @@ import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; -import { manifestDir, resolveManifestId } from "../../../core/manifest.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; +import { manifestDir } from "../../../core/manifest.ts"; +import { confirm } from "../../../core/ui.ts"; import { SCRIPT_MANIFEST } from "../constants.ts"; +import { + type ScriptSelectorArgs, + scriptSelectorBuilder, + selectScript, +} from "../interactive.ts"; type EdgeScriptVariable = components["schemas"]["EdgeScriptVariableModel"]; const COMMAND = "pull [id]"; const DESCRIPTION = "Pull environment variables to a local .env file."; -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; const ARG_FORCE = "force"; const ARG_FORCE_ALIAS = "f"; const ARG_FORCE_DESCRIPTION = "Overwrite existing .env file without prompting"; -interface PullArgs { - [ARG_ID]?: number; +interface PullArgs extends ScriptSelectorArgs { [ARG_FORCE]?: boolean; } @@ -54,40 +56,32 @@ export const scriptsEnvPullCommand = defineCommand({ ], builder: (yargs) => - yargs - .positional(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }) - .option(ARG_FORCE, { - alias: ARG_FORCE_ALIAS, - type: "boolean", - default: false, - describe: ARG_FORCE_DESCRIPTION, - }), + scriptSelectorBuilder(yargs).option(ARG_FORCE, { + alias: ARG_FORCE_ALIAS, + type: "boolean", + default: false, + describe: ARG_FORCE_DESCRIPTION, + }), handler: async ({ - [ARG_ID]: rawId, + id: rawId, [ARG_FORCE]: force, + link, profile, output, verbose, apiKey, }) => { - const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); const config = resolveConfig(profile, apiKey, verbose); const client = createComputeClient(clientOptions(config, verbose)); - const spin = spinner("Fetching environment variables..."); - spin.start(); - - const { data: script } = await client.GET("/compute/script/{id}", { - params: { path: { id } }, + const { script, offerLink } = await selectScript(client, { + id: rawId, + link, + output, }); - spin.stop(); - - const variables = script?.EdgeScriptVariables ?? []; + const variables = script.EdgeScriptVariables ?? []; if (output === "json") { logger.log( @@ -108,6 +102,7 @@ export const scriptsEnvPullCommand = defineCommand({ logger.warn( "Secrets are not included — their values cannot be read from the API.", ); + await offerLink(); return; } @@ -136,5 +131,7 @@ export const scriptsEnvPullCommand = defineCommand({ logger.warn( "Secrets are not included — their values cannot be read from the API.", ); + + await offerLink(); }, }); diff --git a/packages/cli/src/commands/scripts/env/remove.ts b/packages/cli/src/commands/scripts/env/remove.ts index a6a8118..36566d7 100644 --- a/packages/cli/src/commands/scripts/env/remove.ts +++ b/packages/cli/src/commands/scripts/env/remove.ts @@ -5,10 +5,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 { resolveManifestId } from "../../../core/manifest.ts"; import { confirm, spinner } from "../../../core/ui.ts"; import { fetchEnvEntries } from "../api.ts"; -import { SCRIPT_MANIFEST } from "../constants.ts"; +import { + type ScriptSelectorArgs, + scriptIdOptionBuilder, + selectScript, +} from "../interactive.ts"; const COMMAND = "remove [name]"; const ALIASES = ["rm"] as const; @@ -17,15 +20,12 @@ const DESCRIPTION = const ARG_NAME = "name"; const ARG_NAME_DESCRIPTION = "Variable or secret name to remove"; -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; const ARG_FORCE = "force"; const ARG_FORCE_ALIAS = "f"; const ARG_FORCE_DESCRIPTION = "Skip confirmation prompt"; -interface RemoveArgs { +interface RemoveArgs extends ScriptSelectorArgs { [ARG_NAME]?: string; - [ARG_ID]?: number; [ARG_FORCE]?: boolean; } @@ -64,35 +64,37 @@ export const scriptsEnvRemoveCommand = defineCommand({ ], builder: (yargs) => - yargs - .positional(ARG_NAME, { + scriptIdOptionBuilder( + yargs.positional(ARG_NAME, { type: "string", describe: ARG_NAME_DESCRIPTION, - }) - .option(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }) - .option(ARG_FORCE, { - alias: ARG_FORCE_ALIAS, - type: "boolean", - default: false, - describe: ARG_FORCE_DESCRIPTION, }), + ).option(ARG_FORCE, { + alias: ARG_FORCE_ALIAS, + type: "boolean", + default: false, + describe: ARG_FORCE_DESCRIPTION, + }), handler: async ({ [ARG_NAME]: rawName, - [ARG_ID]: rawId, + id: rawId, [ARG_FORCE]: force, + link, profile, output, verbose, apiKey, }) => { - const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); const config = resolveConfig(profile, apiKey, verbose); const client = createComputeClient(clientOptions(config, verbose)); + const { id, offerLink } = await selectScript(client, { + id: rawId, + link, + output, + }); + const spin = spinner("Fetching environment variables..."); spin.start(); @@ -102,6 +104,7 @@ export const scriptsEnvRemoveCommand = defineCommand({ if (entries.length === 0) { logger.info("No environment variables or secrets found."); + await offerLink(); return; } @@ -163,5 +166,7 @@ export const scriptsEnvRemoveCommand = defineCommand({ logger.success( `Removed ${entry.secret ? "secret" : "variable"} "${entry.name}".`, ); + + await offerLink(); }, }); diff --git a/packages/cli/src/commands/scripts/env/set.ts b/packages/cli/src/commands/scripts/env/set.ts index 54c07b3..e6bd2ce 100644 --- a/packages/cli/src/commands/scripts/env/set.ts +++ b/packages/cli/src/commands/scripts/env/set.ts @@ -5,10 +5,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 { resolveManifestId } from "../../../core/manifest.ts"; import { spinner } from "../../../core/ui.ts"; import { fetchEnvEntries } from "../api.ts"; -import { SCRIPT_MANIFEST } from "../constants.ts"; +import { + type ScriptSelectorArgs, + scriptIdOptionBuilder, + selectScript, +} from "../interactive.ts"; const COMMAND = "set [name] [value]"; const DESCRIPTION = "Set an environment variable or secret for an Edge Script."; @@ -17,15 +20,12 @@ const ARG_NAME = "name"; const ARG_NAME_DESCRIPTION = "Variable name (will be uppercased)"; const ARG_VALUE = "value"; const ARG_VALUE_DESCRIPTION = "Variable value"; -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; const ARG_SECRET = "secret"; const ARG_SECRET_DESCRIPTION = "Store as an encrypted secret"; -interface SetArgs { +interface SetArgs extends ScriptSelectorArgs { [ARG_NAME]?: string; [ARG_VALUE]?: string; - [ARG_ID]?: number; [ARG_SECRET]?: boolean; } @@ -62,35 +62,40 @@ export const scriptsEnvSetCommand = defineCommand({ ], builder: (yargs) => - yargs - .positional(ARG_NAME, { - type: "string", - describe: ARG_NAME_DESCRIPTION, - }) - .positional(ARG_VALUE, { - type: "string", - describe: ARG_VALUE_DESCRIPTION, - }) - .option(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }) - .option(ARG_SECRET, { - type: "boolean", - describe: ARG_SECRET_DESCRIPTION, - }), + scriptIdOptionBuilder( + yargs + .positional(ARG_NAME, { + type: "string", + describe: ARG_NAME_DESCRIPTION, + }) + .positional(ARG_VALUE, { + type: "string", + describe: ARG_VALUE_DESCRIPTION, + }), + ).option(ARG_SECRET, { + type: "boolean", + describe: ARG_SECRET_DESCRIPTION, + }), handler: async ({ [ARG_NAME]: rawName, [ARG_VALUE]: rawValue, - [ARG_ID]: rawId, + id: rawId, [ARG_SECRET]: secret, + link, profile, output, verbose, apiKey, }) => { - const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + + const { id, offerLink } = await selectScript(client, { + id: rawId, + link, + output, + }); const interactive = !rawName; let name = rawName; @@ -132,9 +137,6 @@ export const scriptsEnvSetCommand = defineCommand({ name = name.toUpperCase(); - const config = resolveConfig(profile, apiKey, verbose); - const client = createComputeClient(clientOptions(config, verbose)); - const spin = spinner("Checking for conflicts..."); spin.start(); @@ -177,5 +179,7 @@ export const scriptsEnvSetCommand = defineCommand({ ? `Secret "${name}" set successfully.` : `Variable "${name}" set to "${value}".`, ); + + await offerLink(); }, }); diff --git a/packages/cli/src/commands/scripts/hostnames/index.ts b/packages/cli/src/commands/scripts/hostnames/index.ts index e394465..7a712ed 100644 --- a/packages/cli/src/commands/scripts/hostnames/index.ts +++ b/packages/cli/src/commands/scripts/hostnames/index.ts @@ -55,7 +55,7 @@ function resolvePullZoneId(script: EdgeScript, flag?: number): number { return id; } -/** Resolve a script's pull zone (from manifest/--id) plus a core client. */ +/** Resolve a script's pull zone (from manifest/[id]/--id) plus a core client. */ async function resolveScriptPullZone(args: { profile: string; apiKey?: string; @@ -81,17 +81,16 @@ export const scriptsHostnamesCommands = createHostnamesCommands({ namespace: "domains", describe: "Manage custom domains for an Edge Script.", hiddenAliases: ["hostnames"], + targetPositional: { + name: "id", + describe: "Edge Script ID (uses linked script if omitted)", + }, 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)", - }), + yargs.option("pull-zone", { + type: "number", + describe: + "Pull zone ID (required if the script has multiple linked zones)", + }), resolve: (args) => resolveScriptPullZone({ profile: args.profile, diff --git a/packages/cli/src/commands/scripts/init.ts b/packages/cli/src/commands/scripts/init.ts index 253f12d..072fa55 100644 --- a/packages/cli/src/commands/scripts/init.ts +++ b/packages/cli/src/commands/scripts/init.ts @@ -3,6 +3,7 @@ import { basename, resolve } from "node:path"; import prompts from "prompts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; +import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { saveManifestAt } from "../../core/manifest.ts"; import { confirm, spinner } from "../../core/ui.ts"; @@ -16,7 +17,7 @@ import { TEMPLATES, type Template, } from "./constants.ts"; -import { createScript } from "./create.ts"; +import { createScript, setupCustomDomain } from "./create.ts"; import { detectFromLockfile, pickPackageManager } from "./package-manager.ts"; const COMMAND = "init"; @@ -416,12 +417,49 @@ export const scriptsInitCommand = defineCommand({ hostname: created.hostname, }; - if ( - deployResult.hostname && - output !== "json" && - process.stdout.isTTY - ) { - await promptOpenInBrowser(deployResult.hostname); + const isInteractive = output !== "json" && process.stdout.isTTY; + + // Offer a custom domain; on SSL success the browser prompt opens it instead. + let openTarget = deployResult.hostname; + if (created.pullZoneId != null && isInteractive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Custom domain (leave blank to skip):", + }); + const domain = normalizeHostname(value ?? ""); + if (domain) { + // A domain failure mustn't trip the script-creation catch — the script already exists. + try { + // The user is still outside the project directory, so hints must carry --id. + const sslIssued = await setupCustomDomain({ + profile, + apiKey, + verbose, + pullZoneId: created.pullZoneId, + domain, + scriptId: created.id, + linked: false, + interactive: true, + }); + if (sslIssued) openTarget = domain; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ""; + logger.warn( + message + ? `Couldn't finish setting up ${domain}: ${message}` + : `Couldn't finish setting up ${domain}.`, + ); + logger.dim( + ` Retry later: bunny scripts domains add ${domain} --id ${created.id} --wait`, + ); + } + logger.log(); + } + } + + if (openTarget && isInteractive) { + await promptOpenInBrowser(openTarget); } else if (deployResult.hostname) { logger.dim(` URL: ${deployResult.hostname}`); } diff --git a/packages/cli/src/commands/scripts/interactive.ts b/packages/cli/src/commands/scripts/interactive.ts index d57f314..ad4595b 100644 --- a/packages/cli/src/commands/scripts/interactive.ts +++ b/packages/cli/src/commands/scripts/interactive.ts @@ -1,6 +1,7 @@ import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; import prompts from "prompts"; +import type { Argv } from "yargs"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; @@ -26,7 +27,7 @@ interface ResolveResult { * In non-interactive output modes (`--output json`) the picker is skipped and * a UserError points the caller at `bunny scripts link`. */ -export async function resolveScriptInteractive( +async function resolveScriptInteractive( client: ComputeClient, id: number | undefined, opts: { output: OutputFormat }, @@ -73,11 +74,15 @@ export async function resolveScriptInteractive( }); if (!selected) throw new UserError("A script is required."); - return { script: selected, picked: true }; + // The list endpoint returns a trimmed model — re-fetch by ID for the full shape. + return { + script: await fetchScript(client, selected.Id as number), + picked: true, + }; } /** Offer to link the directory to a picked script: `link` forces the choice, otherwise prompt. */ -export async function maybeLinkScript( +async function maybeLinkScript( script: EdgeScript, link: boolean | undefined, ): Promise { @@ -94,3 +99,86 @@ export async function maybeLinkScript( }); logger.success(`Linked to ${script.Name} (${script.Id}).`); } + +const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; +const ARG_LINK_DESCRIPTION = + "Link the directory to the picked script (use --no-link to skip the prompt)"; + +/** Args contributed by {@link scriptSelectorBuilder}. Extend a command's args with this. */ +export interface ScriptSelectorArgs { + id?: number; + link?: boolean; +} + +/** + * Shared yargs fragment for commands that act on a single Edge Script: the + * optional `[id]` positional and the `--link` flag. Chainable, so a command + * can add its own options afterwards. + * + * Pair it with the `[id]` positional in the command string, e.g. + * `command: "show [id]"`. + */ +export function scriptSelectorBuilder( + yargs: Argv, +): Argv { + return yargs + .positional("id", { type: "number", describe: ARG_ID_DESCRIPTION }) + .option("link", { + type: "boolean", + describe: ARG_LINK_DESCRIPTION, + }) as Argv; +} + +/** + * Like {@link scriptSelectorBuilder}, but exposes the script ID as an `--id` + * option instead of a positional — for commands that already use positionals + * for their own arguments (e.g. `env set [name] [value]`). + */ +export function scriptIdOptionBuilder( + yargs: Argv, +): Argv { + return yargs + .option("id", { type: "number", describe: ARG_ID_DESCRIPTION }) + .option("link", { + type: "boolean", + describe: ARG_LINK_DESCRIPTION, + }) as Argv; +} + +interface SelectedScript { + script: EdgeScript; + id: number; + /** + * Offer to link the directory to the script — but only when it was chosen + * via the interactive picker. A no-op otherwise, so commands can always call + * it at the end of the handler without branching on how the script was found. + */ + offerLink: () => Promise; +} + +/** + * Resolve the Edge Script a command should act on — from an explicit ID, the + * linked manifest, or an interactive picker — and return a deferred `offerLink` + * to run once the command's own output is shown. + * + * This is the entry point for `[id]`-style script commands; pair it with + * {@link scriptSelectorBuilder} and {@link ScriptSelectorArgs}. + */ +export async function selectScript( + client: ComputeClient, + args: ScriptSelectorArgs & { output: OutputFormat }, +): Promise { + const { script, picked } = await resolveScriptInteractive(client, args.id, { + output: args.output, + }); + + return { + script, + id: script.Id as number, + offerLink: async () => { + if (!picked) return; + logger.log(); + await maybeLinkScript(script, args.link); + }, + }; +} diff --git a/packages/cli/src/commands/scripts/show.ts b/packages/cli/src/commands/scripts/show.ts index 38579eb..8e7aea7 100644 --- a/packages/cli/src/commands/scripts/show.ts +++ b/packages/cli/src/commands/scripts/show.ts @@ -2,29 +2,25 @@ 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 { 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"; -import { fetchScript, fetchScriptHostnames } from "./api.ts"; -import { SCRIPT_MANIFEST, scriptTypeLabel } from "./constants.ts"; - -type EdgeScript = components["schemas"]["EdgeScriptModel"]; +import { fetchScriptHostnames } from "./api.ts"; +import { scriptTypeLabel } from "./constants.ts"; +import { + type ScriptSelectorArgs, + scriptSelectorBuilder, + selectScript, +} from "./interactive.ts"; const COMMAND = "show [id]"; const DESCRIPTION = "Show details of an Edge Script."; -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; - -interface ShowArgs { - [ARG_ID]?: EdgeScript["Id"]; -} +type ShowArgs = ScriptSelectorArgs; /** * Show details of an Edge Script. @@ -54,22 +50,21 @@ export const scriptsShowCommand = defineCommand({ ["$0 scripts show --output json", "JSON output"], ], - builder: (yargs) => - yargs.positional(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }), + builder: (yargs) => scriptSelectorBuilder(yargs), - handler: async ({ [ARG_ID]: rawId, profile, output, verbose, apiKey }) => { - const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); + handler: async ({ id: rawId, link, profile, output, verbose, apiKey }) => { const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const client = createComputeClient(options); - const spin = spinner("Fetching Edge Script..."); - spin.start(); + const { script, offerLink } = await selectScript(client, { + id: rawId, + link, + output, + }); - const script = await fetchScript(client, id); + const spin = spinner("Fetching hostnames..."); + spin.start(); // Pull each linked pull zone's hostnames (incl. custom domains + SSL state). const coreClient = createCoreClient(options); @@ -172,5 +167,7 @@ export const scriptsShowCommand = defineCommand({ ), ); } + + await offerLink(); }, }); diff --git a/packages/cli/src/commands/scripts/stats.ts b/packages/cli/src/commands/scripts/stats.ts index 1f06511..be7f7c8 100644 --- a/packages/cli/src/commands/scripts/stats.ts +++ b/packages/cli/src/commands/scripts/stats.ts @@ -6,14 +6,16 @@ import { formatKeyValue, formatTable } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; import { formatBucketLabel, renderBarChart } from "../../core/stats.ts"; import { spinner } from "../../core/ui.ts"; -import { maybeLinkScript, resolveScriptInteractive } from "./interactive.ts"; +import { + type ScriptSelectorArgs, + scriptSelectorBuilder, + selectScript, +} from "./interactive.ts"; -interface StatsArgs { - id?: number; +interface StatsArgs extends ScriptSelectorArgs { from?: string; to?: string; hourly?: boolean; - link?: boolean; } /** @@ -49,11 +51,7 @@ export const scriptsStatsCommand = defineCommand({ ], builder: (yargs) => - yargs - .positional("id", { - type: "number", - describe: "Edge Script ID (uses linked script if omitted)", - }) + scriptSelectorBuilder(yargs) .option("from", { type: "string", describe: "Start date (YYYY-MM-DD); defaults to 30 days ago", @@ -65,11 +63,6 @@ export const scriptsStatsCommand = defineCommand({ .option("hourly", { type: "boolean", describe: "Group statistics by hour instead of by day", - }) - .option("link", { - type: "boolean", - describe: - "Link the directory to the picked script (use --no-link to skip the prompt)", }), handler: async ({ @@ -86,10 +79,11 @@ export const scriptsStatsCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createComputeClient(clientOptions(config, verbose)); - const { script, picked } = await resolveScriptInteractive(client, rawId, { + const { script, id, offerLink } = await selectScript(client, { + id: rawId, + link, output, }); - const id = script.Id as number; const spin = spinner("Fetching statistics..."); spin.start(); @@ -162,9 +156,6 @@ export const scriptsStatsCommand = defineCommand({ } } - if (picked) { - logger.log(""); - await maybeLinkScript(script, link); - } + await offerLink(); }, }); diff --git a/packages/cli/src/core/dns-record-types.ts b/packages/cli/src/core/dns-record-types.ts new file mode 100644 index 0000000..c7816f0 --- /dev/null +++ b/packages/cli/src/core/dns-record-types.ts @@ -0,0 +1,32 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; + +export type DnsRecordTypes = components["schemas"]["DnsRecordTypes"]; + +/** Record type name → bunny.net integer enum value. */ +export const RECORD_TYPES = { + A: 0, + AAAA: 1, + CNAME: 2, + TXT: 3, + MX: 4, + REDIRECT: 5, + FLATTEN: 6, + PULLZONE: 7, + SRV: 8, + CAA: 9, + PTR: 10, + SCRIPT: 11, + NS: 12, + SVCB: 13, + HTTPS: 14, + TLSA: 15, +} as const satisfies Record; + +const TYPE_LABELS: Record = Object.fromEntries( + Object.entries(RECORD_TYPES).map(([name, value]) => [value, name]), +); + +/** Human label for a record type integer, falling back to "UNKNOWN". */ +export function recordTypeLabel(type: number | null | undefined): string { + return TYPE_LABELS[type ?? -1] ?? "UNKNOWN"; +} diff --git a/packages/cli/src/core/hostnames/bunny-dns.test.ts b/packages/cli/src/core/hostnames/bunny-dns.test.ts new file mode 100644 index 0000000..51bcdac --- /dev/null +++ b/packages/cli/src/core/hostnames/bunny-dns.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test } from "bun:test"; +import prompts from "prompts"; +import { findBunnyDnsZone, offerBunnyDnsRecord } from "./bunny-dns.ts"; +import type { CoreClient } from "./client.ts"; +import { offerBunnyDnsThenSsl } from "./flow.ts"; + +type Zone = { Id: number; Domain: string; NameserversDetected?: boolean }; +type Rec = { Id?: number; Type?: number; Name?: string; Value?: string }; + +/** A core client stubbed to serve a fixed set of zones and per-zone records. */ +function fakeClient( + zones: Zone[], + recordsByZone: Record = {}, +): CoreClient { + return { + GET: async (path: string, opts: { params: { path?: { id: number } } }) => { + if (path === "/dnszone") { + return { data: { Items: zones, HasMoreItems: false } }; + } + if (path === "/dnszone/{id}") { + const id = opts.params.path?.id as number; + const zone = zones.find((z) => z.Id === id); + return { + data: { + Records: recordsByZone[id] ?? [], + NameserversDetected: zone?.NameserversDetected, + }, + }; + } + throw new Error(`unexpected GET ${path}`); + }, + } as unknown as CoreClient; +} + +describe("findBunnyDnsZone", () => { + test("returns null when no zone owns the hostname", async () => { + const client = fakeClient([{ Id: 1, Domain: "other.net" }]); + expect(await findBunnyDnsZone(client, "shop.example.com")).toBeNull(); + }); + + test("matches a subdomain to its zone and derives the record name", async () => { + const client = fakeClient([{ Id: 7, Domain: "example.com" }]); + const match = await findBunnyDnsZone(client, "shop.example.com"); + expect(match).toMatchObject({ + zoneId: 7, + zoneDomain: "example.com", + recordName: "shop", + existing: null, + }); + }); + + test("matches the apex with an empty record name", async () => { + const client = fakeClient([{ Id: 7, Domain: "example.com" }]); + const match = await findBunnyDnsZone(client, "example.com"); + expect(match?.recordName).toBe(""); + }); + + test("prefers the longest matching zone suffix", async () => { + const client = fakeClient([ + { Id: 1, Domain: "example.com" }, + { Id: 2, Domain: "uk.example.com" }, + ]); + const match = await findBunnyDnsZone(client, "shop.uk.example.com"); + expect(match).toMatchObject({ zoneId: 2, recordName: "shop" }); + }); + + test("ignores case and trailing dots in the hostname", async () => { + const client = fakeClient([{ Id: 7, Domain: "example.com" }]); + const match = await findBunnyDnsZone(client, "Shop.Example.COM."); + expect(match).toMatchObject({ zoneId: 7, recordName: "shop" }); + }); + + test("surfaces the record already sitting at that name", async () => { + const client = fakeClient([{ Id: 7, Domain: "example.com" }], { + 7: [{ Id: 99, Type: 7, Name: "shop", Value: "12345" }], + }); + const match = await findBunnyDnsZone(client, "shop.example.com"); + expect(match?.existing).toMatchObject({ Id: 99, Type: 7, Value: "12345" }); + }); + + test("leaves existing null when no record matches the name", async () => { + const client = fakeClient([{ Id: 7, Domain: "example.com" }], { + 7: [{ Id: 99, Type: 0, Name: "www", Value: "1.2.3.4" }], + }); + const match = await findBunnyDnsZone(client, "shop.example.com"); + expect(match?.existing).toBeNull(); + }); + + test("reports delegated:true only when nameservers are detected", async () => { + const delegated = fakeClient([ + { Id: 7, Domain: "example.com", NameserversDetected: true }, + ]); + expect( + (await findBunnyDnsZone(delegated, "shop.example.com"))?.delegated, + ).toBe(true); + + // A zone in the account but not yet delegated at the registrar isn't live. + const undelegated = fakeClient([ + { Id: 7, Domain: "example.com", NameserversDetected: false }, + ]); + expect( + (await findBunnyDnsZone(undelegated, "shop.example.com"))?.delegated, + ).toBe(false); + }); +}); + +describe("offerBunnyDnsRecord", () => { + test("throws instead of repointing a record that has no ID", async () => { + // A record missing an Id would otherwise produce a `.../records/undefined` request. + prompts.inject([true]); + const client = { + POST: async () => { + throw new Error("repoint must not be attempted without a record ID"); + }, + } as unknown as CoreClient; + + await expect( + offerBunnyDnsRecord({ + client, + hostname: "shop.example.com", + pullZoneId: 12345, + match: { + zoneId: 7, + zoneDomain: "example.com", + recordName: "shop", + existing: { Type: 0, Name: "shop", Value: "1.2.3.4" }, + delegated: true, + }, + }), + ).rejects.toThrow(/has no ID/); + }); +}); + +describe("offerBunnyDnsThenSsl", () => { + test("surfaces a post-confirmation error instead of swallowing it as a detection hiccup", async () => { + // Zone detection succeeds, but the matched record has no Id — once the user + // confirms the repoint, the failure must propagate, not fall back to manual DNS. + prompts.inject([true]); + const client = fakeClient( + [{ Id: 7, Domain: "example.com", NameserversDetected: true }], + { 7: [{ Type: 0, Name: "shop", Value: "1.2.3.4" }] }, + ); + + await expect( + offerBunnyDnsThenSsl({ + coreClient: client, + hostname: "shop.example.com", + pullZoneId: 12345, + cnameTarget: "shop.b-cdn.net", + forceSsl: true, + sslHint: "bunny scripts domains ssl shop.example.com", + verbose: false, + }), + ).rejects.toThrow(/has no ID/); + }); + + test("adds the record but skips the poll when the zone isn't delegated", async () => { + // A PULLZONE record on an undelegated zone never resolves publicly — short-circuit + // rather than entering offerDnsWaitAndSsl, which would poll for the full 10 minutes. + prompts.inject([true]); + let putCalled = false; + const client = { + GET: async (path: string) => { + if (path === "/dnszone") { + return { + data: { + Items: [ + { Id: 7, Domain: "example.com", NameserversDetected: false }, + ], + HasMoreItems: false, + }, + }; + } + if (path === "/dnszone/{id}") { + return { data: { Records: [], NameserversDetected: false } }; + } + throw new Error(`unexpected GET ${path}`); + }, + PUT: async () => { + putCalled = true; + return {}; + }, + } as unknown as CoreClient; + + const issued = await offerBunnyDnsThenSsl({ + coreClient: client, + hostname: "shop.example.com", + pullZoneId: 12345, + cnameTarget: "shop.b-cdn.net", + forceSsl: true, + sslHint: "bunny scripts domains ssl shop.example.com", + verbose: false, + }); + + expect(putCalled).toBe(true); // the record was added + expect(issued).toBe(false); // but no certificate / poll — short-circuited on delegation + }); +}); diff --git a/packages/cli/src/core/hostnames/bunny-dns.ts b/packages/cli/src/core/hostnames/bunny-dns.ts new file mode 100644 index 0000000..8e42229 --- /dev/null +++ b/packages/cli/src/core/hostnames/bunny-dns.ts @@ -0,0 +1,199 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { RECORD_TYPES, recordTypeLabel } from "../dns-record-types.ts"; +import { UserError } from "../errors.ts"; +import { logger } from "../logger.ts"; +import { confirm, spinner } from "../ui.ts"; +import type { CoreClient } from "./client.ts"; + +type DnsZoneModel = components["schemas"]["DnsZoneModel"]; +type DnsRecordModel = components["schemas"]["DnsRecordModel"]; + +/** Lowercase and drop a trailing dot for comparison. */ +function normalize(value: string): string { + return value.trim().toLowerCase().replace(/\.$/, ""); +} + +/** Fetch every DNS zone on the account, paginated. */ +async function listAllZones(client: CoreClient): Promise { + const zones: DnsZoneModel[] = []; + let page = 1; + for (;;) { + const { data } = await client.GET("/dnszone", { + params: { query: { page, perPage: 1000 } }, + }); + zones.push(...(data?.Items ?? [])); + if (!data?.HasMoreItems) break; + page++; + } + return zones; +} + +/** A hostname that falls inside an account-managed Bunny DNS zone. */ +export interface BunnyDnsMatch { + zoneId: number; + zoneDomain: string; + /** The record name within the zone — "" for the apex. */ + recordName: string; + existing: DnsRecordModel | null; + /** True when the registrar delegates to bunny's nameservers — if false, records here aren't publicly resolvable yet. */ + delegated: boolean; +} + +/** + * Find the Bunny DNS zone that owns `hostname`, if any. Matches the + * longest zone-domain suffix (so `shop.example.com` resolves to zone + * `example.com`, record name `shop`), then returns the record already + * sitting at that name. Returns null when the domain isn't on Bunny DNS. + */ +export async function findBunnyDnsZone( + client: CoreClient, + hostname: string, +): Promise { + const host = normalize(hostname); + + let best: DnsZoneModel | undefined; + for (const zone of await listAllZones(client)) { + const domain = normalize(zone.Domain ?? ""); + if (!domain || !zone.Id) continue; + if (host !== domain && !host.endsWith(`.${domain}`)) continue; + if (!best || domain.length > normalize(best.Domain ?? "").length) + best = zone; + } + if (!best?.Id) return null; + + const domain = normalize(best.Domain ?? ""); + const recordName = host === domain ? "" : host.slice(0, -domain.length - 1); + + // The list endpoint omits records — fetch the full zone for them. + const { data } = await client.GET("/dnszone/{id}", { + params: { path: { id: best.Id } }, + }); + const existing = + (data?.Records ?? []).find((r) => normalize(r.Name ?? "") === recordName) ?? + null; + + return { + zoneId: best.Id, + zoneDomain: best.Domain ?? domain, + recordName, + existing, + delegated: data?.NameserversDetected === true, + }; +} + +/** True when the record already points at this pull zone. */ +function routesHere(record: DnsRecordModel, pullZoneId: number): boolean { + return ( + record.Type === RECORD_TYPES.PULLZONE && Number(record.Value) === pullZoneId + ); +} + +async function addPullZoneRecord( + client: CoreClient, + zoneId: number, + name: string, + pullZoneId: number, +): Promise { + const spin = spinner("Adding DNS record..."); + spin.start(); + try { + // A PullZone record routes the name straight at the pull zone and works at the apex (where CNAMEs can't). + await client.PUT("/dnszone/{zoneId}/records", { + params: { path: { zoneId } }, + body: { Type: RECORD_TYPES.PULLZONE, Name: name, PullZoneId: pullZoneId }, + }); + } finally { + spin.stop(); + } +} + +async function repointPullZoneRecord( + client: CoreClient, + zoneId: number, + recordId: number, + name: string, + pullZoneId: number, +): Promise { + const spin = spinner("Updating DNS record..."); + spin.start(); + try { + await client.POST("/dnszone/{zoneId}/records/{id}", { + params: { path: { zoneId, id: recordId } }, + body: { + Type: RECORD_TYPES.PULLZONE, + Name: name, + PullZoneId: pullZoneId, + Value: null, + }, + }); + } finally { + spin.stop(); + } +} + +export type BunnyDnsResult = "created" | "updated" | "exists" | "declined"; + +/** + * Offer to point a Bunny DNS record at the pull zone. Every write is confirmed + * first; the one exception is a record that already routes here. The result + * lets the caller skip the manual-DNS steps and propagation wait once a record + * is in place. + */ +export async function offerBunnyDnsRecord(opts: { + client: CoreClient; + hostname: string; + pullZoneId: number; + match: BunnyDnsMatch; +}): Promise { + const { client, hostname, pullZoneId, match } = opts; + const { zoneId, zoneDomain, recordName, existing } = match; + + if (existing && routesHere(existing, pullZoneId)) { + logger.success(`${hostname} already routes here via Bunny DNS.`); + return "exists"; + } + + logger.log(); + + if (!existing) { + logger.success(`Found ${zoneDomain} in your Bunny DNS.`); + if ( + !(await confirm(`Point ${hostname} at this pull zone now?`, { + initial: true, + })) + ) { + return "declined"; + } + await addPullZoneRecord(client, zoneId, recordName, pullZoneId); + logger.success(`Pointed ${hostname} here via Bunny DNS.`); + return "created"; + } + + const isBunnyRoute = + existing.Type === RECORD_TYPES.PULLZONE || + existing.Type === RECORD_TYPES.SCRIPT; + const detail = isBunnyRoute + ? "points at another bunny resource" + : `has a ${recordTypeLabel(existing.Type)} record`; + logger.warn( + `${hostname} already ${detail} in your Bunny DNS (${zoneDomain}).`, + ); + if (!(await confirm("Repoint it at this pull zone?", { initial: false }))) { + return "declined"; + } + if (existing.Id == null) { + throw new UserError( + `DNS record for "${hostname}" has no ID — cannot repoint it.`, + "Update the record manually in the Bunny DNS dashboard.", + ); + } + await repointPullZoneRecord( + client, + zoneId, + existing.Id, + recordName, + pullZoneId, + ); + logger.success(`Repointed ${hostname} here via Bunny DNS.`); + return "updated"; +} diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index aae76da..6609abc 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -38,6 +38,14 @@ export interface ResolvedPullZone { coreClient: CoreClient; } +/** Strip any scheme and trailing slash from a user-supplied hostname. */ +export function normalizeHostname(value: string): string { + return value + .trim() + .replace(/^https?:\/\//i, "") + .replace(/\/+$/, ""); +} + /** Build a URL from a hostname, respecting an existing scheme; else derive it from SSL state. */ export function hostnameUrl( host: string, @@ -101,6 +109,23 @@ export function liveHostnames(hostnames: Hostname[]): { }; } +/** Add a hostname to a pull zone, returning the zone's hostnames and the CNAME target to point DNS at. */ +export async function addHostname( + client: CoreClient, + pullZoneId: number, + hostname: string, +): Promise<{ hostnames: Hostname[]; cnameTarget?: string }> { + await client.POST("/pullzone/{id}/addHostname", { + params: { path: { id: pullZoneId } }, + body: { Hostname: hostname }, + }); + const hostnames = await fetchPullZoneHostnames(client, pullZoneId); + const cnameTarget = hostnames + .find((h) => h.IsSystemHostname) + ?.Value?.replace(/^https?:\/\//i, ""); + return { hostnames, cnameTarget }; +} + /** Issue a free SSL certificate for a hostname on a pull zone, then set its Force SSL state. */ export async function enableSsl( client: CoreClient, diff --git a/packages/cli/src/core/hostnames/commands.ts b/packages/cli/src/core/hostnames/commands.ts index fc67ddd..5823a68 100644 --- a/packages/cli/src/core/hostnames/commands.ts +++ b/packages/cli/src/core/hostnames/commands.ts @@ -7,12 +7,19 @@ import { logger } from "../logger.ts"; import type { GlobalArgs } from "../types.ts"; import { confirm, spinner } from "../ui.ts"; import { + addHostname, enableSsl, fetchPullZoneHostnames, hostnameUrl, + normalizeHostname, type ResolvedPullZone, toSafeHostname, } from "./client.ts"; +import { + offerBunnyDnsThenSsl, + offerDnsWaitAndSsl, + printSslHint, +} from "./flow.ts"; /** Resolves the pull zone (and a core client) for the resource being targeted. */ export type HostnameResolver = ( @@ -28,17 +35,14 @@ export interface HostnamesMountOptions { resolve: HostnameResolver; /** Adds resource-targeting flags (e.g. --id, --pull-zone) shared by every subcommand. */ target?: (yargs: Argv) => Argv; + /** Optional trailing positional (e.g. `[id]`) appended to every subcommand for targeting the resource. */ + targetPositional?: { name: string; describe: string }; /** 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[] = []; @@ -59,10 +63,22 @@ function targetSuffix(args: Record): string { export function createHostnamesCommands( opts: HostnamesMountOptions, ): CommandModule[] { - const { commandPath, resolve } = opts; + const { commandPath, resolve, targetPositional } = opts; + // Append the targeting positional (e.g. "[id]") to a subcommand's command string. + const command = (base: string) => + targetPositional ? `${base} [${targetPositional.name}]` : base; // 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 target = (yargs: Argv): Argv => { + const withPositional = targetPositional + ? yargs.positional(targetPositional.name, { + type: "number", + describe: targetPositional.describe, + }) + : yargs; + return ( + opts.target ? opts.target(withPositional as Argv) : withPositional + ) as Argv; + }; const resolveArgs = (args: GlobalArgs) => resolve(args as GlobalArgs & Record); @@ -70,8 +86,9 @@ export function createHostnamesCommands( domain: string; ssl?: boolean; "force-ssl"?: boolean; + wait?: boolean; }>({ - command: "add ", + command: command("add "), describe: "Add a custom domain to a pull zone.", examples: [ [`$0 ${commandPath} add shop.example.com`, "Add a domain (no SSL)"], @@ -79,6 +96,18 @@ export function createHostnamesCommands( `$0 ${commandPath} add shop.example.com --ssl`, "Add and request SSL now", ], + [ + `$0 ${commandPath} add shop.example.com --wait`, + "Add, wait for DNS, then enable HTTPS", + ], + ...(targetPositional + ? ([ + [ + `$0 ${commandPath} add shop.example.com 12345`, + `Target an explicit ${targetPositional.name}`, + ], + ] as const) + : []), ], builder: (yargs) => target( @@ -98,6 +127,11 @@ export function createHostnamesCommands( default: true, describe: "Force HTTP→HTTPS when issuing SSL (default: true). Use --no-force-ssl to keep HTTP.", + }) + .option("wait", { + type: "boolean", + describe: + "Wait for DNS to point at bunny.net (up to 10 minutes), then issue SSL automatically", }), ), handler: async (args) => { @@ -106,21 +140,18 @@ export function createHostnamesCommands( const requestSsl = args.ssl === true; const force = args["force-ssl"] !== false; + const interactive = args.output !== "json" && process.stdout.isTTY; 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, ""); + const { hostnames, cnameTarget: systemHostname } = await addHostname( + coreClient, + pullZoneId, + hostname, + ); spin.stop(); @@ -146,6 +177,25 @@ export function createHostnamesCommands( const sslFailed = requestSsl && sslError != null; if (args.output === "json") { + // With --wait, finish the DNS/SSL flow first so the JSON reflects the real certificate outcome. + if (systemHostname && args.wait === true && !sslIssued) { + try { + sslIssued = await offerDnsWaitAndSsl({ + coreClient, + pullZoneId, + hostname, + cnameTarget: systemHostname, + forceSsl: force, + sslHint, + assumeYes: true, + json: true, + }); + if (sslIssued) sslError = undefined; + } catch (err) { + sslError = err instanceof Error ? err.message : String(err); + } + } + logger.log( JSON.stringify( { @@ -161,7 +211,9 @@ export function createHostnamesCommands( ), ); // Emit the full result for agents/CI, then signal failure with a non-zero exit. - if (sslFailed) process.exit(1); + const sslWanted = + requestSsl || (args.wait === true && systemHostname != null); + if (sslWanted && !sslIssued) process.exit(1); return; } @@ -180,14 +232,50 @@ export function createHostnamesCommands( return; } + // Offer to wait for DNS and finish HTTPS in one go (or just do it with --wait). + const offerWait = + systemHostname != null && (args.wait === true || interactive); + + if (sslFailed && offerWait) { + logger.warn(`Couldn't issue a certificate yet: ${sslError}`); + logger.dim(" This is normal until DNS propagates."); + } + + // If the domain is on Bunny DNS, offer to add the record (prompted) so SSL can issue right away. + if (systemHostname && interactive) { + const issued = await offerBunnyDnsThenSsl({ + coreClient, + hostname, + pullZoneId, + cnameTarget: systemHostname, + forceSsl: force, + sslHint, + verbose: args.verbose, + }); + if (issued !== null) return; + } + if (systemHostname) { logger.log(); logger.log("Point your DNS at bunny.net to activate it:"); - logger.dim(` CNAME ${hostname} → ${systemHostname}`); + logger.accent(` CNAME ${hostname} → ${systemHostname}`); } logger.log(); + if (systemHostname && offerWait) { + await offerDnsWaitAndSsl({ + coreClient, + pullZoneId, + hostname, + cnameTarget: systemHostname, + forceSsl: force, + sslHint, + assumeYes: args.wait === true, + }); + return; + } + if (sslFailed) { throw new UserError( `Couldn't issue a certificate for ${hostname} yet: ${sslError}`, @@ -195,8 +283,7 @@ export function createHostnamesCommands( ); } - logger.log("Then enable HTTPS once DNS is live:"); - logger.dim(` ${sslHint}`); + printSslHint(sslHint); }, }); @@ -204,7 +291,7 @@ export function createHostnamesCommands( domain: string; "force-ssl"?: boolean; }>({ - command: "ssl ", + command: command("ssl "), describe: "Request a free SSL certificate for a custom domain.", examples: [ [ @@ -279,7 +366,7 @@ export function createHostnamesCommands( }); const listCommand = defineCommand({ - command: "list", + command: command("list"), aliases: ["ls"], describe: "List the domains on a pull zone.", examples: [ @@ -329,7 +416,7 @@ export function createHostnamesCommands( domain: string; force?: boolean; }>({ - command: "remove ", + command: command("remove "), aliases: ["rm"], describe: "Remove a custom domain from a pull zone.", examples: [ diff --git a/packages/cli/src/core/hostnames/dns.test.ts b/packages/cli/src/core/hostnames/dns.test.ts new file mode 100644 index 0000000..b3ad812 --- /dev/null +++ b/packages/cli/src/core/hostnames/dns.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { anyResolverPointsAt, type DnsResolver, dnsPointsAt } from "./dns.ts"; + +const noCname = () => Promise.reject(new Error("ENODATA")); +const noA = () => Promise.reject(new Error("ENOTFOUND")); + +function resolver(overrides: Partial): DnsResolver { + return { resolveCname: noCname, resolve4: noA, ...overrides }; +} + +describe("dnsPointsAt", () => { + test("matches a CNAME pointing at the target", async () => { + const r = resolver({ + resolveCname: async () => ["my-script-e9g0r.b-cdn.net"], + }); + expect( + await dnsPointsAt("shop.example.com", "my-script-e9g0r.b-cdn.net", r), + ).toBe(true); + }); + + test("ignores case and trailing dots in CNAME answers", async () => { + const r = resolver({ + resolveCname: async () => ["My-Script-E9G0R.B-CDN.NET."], + }); + expect( + await dnsPointsAt("shop.example.com", "my-script-e9g0r.b-cdn.net", r), + ).toBe(true); + }); + + test("a CNAME to somewhere else is not a match by itself", async () => { + const r = resolver({ + resolveCname: async () => ["other.example.net"], + resolve4: async (host) => + host === "my-script-e9g0r.b-cdn.net" ? ["1.1.1.1"] : ["9.9.9.9"], + }); + expect( + await dnsPointsAt("shop.example.com", "my-script-e9g0r.b-cdn.net", r), + ).toBe(false); + }); + + test("falls back to shared A records when no CNAME exists (flattened DNS)", async () => { + const r = resolver({ + resolve4: async (host) => + host === "shop.example.com" ? ["1.1.1.1", "2.2.2.2"] : ["2.2.2.2"], + }); + expect( + await dnsPointsAt("shop.example.com", "my-script-e9g0r.b-cdn.net", r), + ).toBe(true); + }); + + test("returns false when nothing resolves yet", async () => { + expect( + await dnsPointsAt( + "shop.example.com", + "my-script-e9g0r.b-cdn.net", + resolver({}), + ), + ).toBe(false); + }); + + test("returns false when A records differ", async () => { + const r = resolver({ + resolve4: async (host) => + host === "shop.example.com" ? ["3.3.3.3"] : ["4.4.4.4"], + }); + expect( + await dnsPointsAt("shop.example.com", "my-script-e9g0r.b-cdn.net", r), + ).toBe(false); + }); +}); + +describe("anyResolverPointsAt", () => { + const live = resolver({ resolveCname: async () => ["target.b-cdn.net"] }); + const stale = resolver({}); + + test("true when any resolver sees the record", async () => { + expect( + await anyResolverPointsAt("shop.example.com", "target.b-cdn.net", [ + stale, + live, + ]), + ).toBe(true); + }); + + test("false when no resolver sees the record", async () => { + expect( + await anyResolverPointsAt("shop.example.com", "target.b-cdn.net", [ + stale, + stale, + ]), + ).toBe(false); + }); + + test("a resolver that errors entirely doesn't block the others", async () => { + const broken: DnsResolver = { + resolveCname: () => Promise.reject(new Error("ETIMEOUT")), + resolve4: () => Promise.reject(new Error("ETIMEOUT")), + }; + expect( + await anyResolverPointsAt("shop.example.com", "target.b-cdn.net", [ + broken, + live, + ]), + ).toBe(true); + }); +}); diff --git a/packages/cli/src/core/hostnames/dns.ts b/packages/cli/src/core/hostnames/dns.ts new file mode 100644 index 0000000..328cf6c --- /dev/null +++ b/packages/cli/src/core/hostnames/dns.ts @@ -0,0 +1,72 @@ +import { Resolver, resolve4, resolveCname } from "node:dns/promises"; + +/** Minimal resolver surface, injectable for tests. */ +export interface DnsResolver { + resolveCname(hostname: string): Promise; + resolve4(hostname: string): Promise; +} + +const systemResolver: DnsResolver = { resolveCname, resolve4 }; + +/** Public resolvers dodge stale negative caches in the OS/ISP resolver after a record is added. */ +function publicResolver(): DnsResolver { + const resolver = new Resolver({ timeout: 2_000, tries: 1 }); + resolver.setServers(["1.1.1.1", "8.8.8.8"]); + return { + resolveCname: (hostname) => resolver.resolveCname(hostname), + resolve4: (hostname) => resolver.resolve4(hostname), + }; +} + +/** The resolvers consulted by default: the system's, plus a public fallback. */ +export function defaultResolvers(): DnsResolver[] { + return [systemResolver, publicResolver()]; +} + +/** Lowercase and strip the trailing dot so DNS answers compare cleanly. */ +function normalizeDnsName(value: string): string { + return value.trim().toLowerCase().replace(/\.$/, ""); +} + +/** + * Check whether `hostname` points at `target`: a CNAME to the target, or + * (for providers that flatten CNAMEs at the apex) a shared A record. + * Resolution errors (NXDOMAIN, no records yet) mean "not live yet". + */ +export async function dnsPointsAt( + hostname: string, + target: string, + resolver: DnsResolver = systemResolver, +): Promise { + const want = normalizeDnsName(target); + + try { + const cnames = await resolver.resolveCname(hostname); + if (cnames.some((c) => normalizeDnsName(c) === want)) return true; + } catch { + // No CNAME record — fall through to the A-record comparison. + } + + try { + const [hostIps, targetIps] = await Promise.all([ + resolver.resolve4(hostname), + resolver.resolve4(target), + ]); + const targetSet = new Set(targetIps); + return hostIps.some((ip) => targetSet.has(ip)); + } catch { + return false; + } +} + +/** True when any of the given resolvers sees `hostname` pointing at `target`. */ +export async function anyResolverPointsAt( + hostname: string, + target: string, + resolvers: DnsResolver[] = defaultResolvers(), +): Promise { + const results = await Promise.all( + resolvers.map((resolver) => dnsPointsAt(hostname, target, resolver)), + ); + return results.some(Boolean); +} diff --git a/packages/cli/src/core/hostnames/flow.ts b/packages/cli/src/core/hostnames/flow.ts new file mode 100644 index 0000000..fe43ed6 --- /dev/null +++ b/packages/cli/src/core/hostnames/flow.ts @@ -0,0 +1,242 @@ +import { UserError } from "../errors.ts"; +import { logger } from "../logger.ts"; +import { confirm, spinner } from "../ui.ts"; +import { + type BunnyDnsMatch, + findBunnyDnsZone, + offerBunnyDnsRecord, +} from "./bunny-dns.ts"; +import { type CoreClient, enableSsl, hostnameUrl } from "./client.ts"; +import { anyResolverPointsAt, defaultResolvers } from "./dns.ts"; + +const DNS_TIMEOUT_MS = 10 * 60 * 1000; +const POLL_INTERVAL_MS = 5_000; +// Try issuing every ~30s even before DNS looks live here — bunny's resolvers may see the record first. +const OPPORTUNISTIC_EVERY_TICKS = 6; +const SSL_ATTEMPTS = 3; +const SSL_RETRY_DELAY_MS = 10_000; + +function formatElapsed(ms: number): string { + const s = Math.round(ms / 1000); + return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`; +} + +export interface DnsSslFlowOptions { + coreClient: CoreClient; + pullZoneId: number; + hostname: string; + /** The system hostname the user's CNAME should point at. */ + cnameTarget: string; + /** Force HTTP→HTTPS once the certificate is issued. */ + forceSsl: boolean; + /** Copy-paste command for issuing SSL later, shown when declining or giving up. */ + sslHint: string; + /** Skip the confirmation prompt and start waiting immediately (e.g. --wait). */ + assumeYes?: boolean; + /** Skip the wait prompt and DNS poll, issuing SSL right away (record already live on bunny's resolvers). */ + dnsAlreadyLive?: boolean; + /** Suppress the human-readable summary on stdout (the caller emits JSON instead). */ + json?: boolean; +} + +/** Print the manual follow-up for enabling HTTPS once DNS propagates. */ +export function printSslHint(sslHint: string): void { + logger.log("Then enable HTTPS once DNS is live:"); + logger.accent(` ${sslHint}`); +} + +/** + * Offer to wait for the domain's DNS to point at bunny.net, then issue a + * free SSL certificate automatically. Polls DNS every few seconds (system + * and public resolvers) for up to 10 minutes, and periodically attempts + * issuance outright — bunny's resolvers decide validation, and may see the + * record before or after this machine's caches do. + * + * Returns `true` when a certificate was issued, `false` when the user + * declined to wait. Throws a UserError on timeout or issuance failure. + */ +export async function offerDnsWaitAndSsl( + opts: DnsSslFlowOptions, +): Promise { + const shouldWait = + opts.dnsAlreadyLive || + opts.assumeYes || + (await confirm("Wait for DNS and enable HTTPS now?", { initial: true })); + + if (!shouldWait) { + printSslHint(opts.sslHint); + return false; + } + + if (!opts.dnsAlreadyLive) { + logger.dim(" Checking every 5s — Ctrl+C to stop and finish later."); + } + + const waitText = `Waiting for DNS (${opts.hostname} → ${opts.cnameTarget})...`; + const spin = spinner( + opts.dnsAlreadyLive ? "Requesting free SSL certificate..." : waitText, + ); + spin.start(); + + const tryIssue = () => + enableSsl(opts.coreClient, opts.pullZoneId, opts.hostname, opts.forceSsl); + + const resolvers = defaultResolvers(); + const startedAt = Date.now(); + let dnsLive = opts.dnsAlreadyLive ?? false; + let issued = false; + let lastError: unknown; + + for ( + let tick = 0; + !dnsLive && Date.now() - startedAt < DNS_TIMEOUT_MS; + tick++ + ) { + if (await anyResolverPointsAt(opts.hostname, opts.cnameTarget, resolvers)) { + dnsLive = true; + break; + } + + if (tick > 0 && tick % OPPORTUNISTIC_EVERY_TICKS === 0) { + try { + await tryIssue(); + issued = true; + break; + } catch (err) { + lastError = err; + } + } + + spin.text = `${waitText} ${formatElapsed(Date.now() - startedAt)}`; + await Bun.sleep(POLL_INTERVAL_MS); + } + + if (!dnsLive && !issued) { + spin.stop(); + throw new UserError( + `DNS for ${opts.hostname} hasn't propagated yet (gave up after 10 minutes).`, + `Once it's live, run: ${opts.sslHint}`, + ); + } + + if (!issued) { + spin.text = "DNS is live. Requesting free SSL certificate..."; + + for (let attempt = 1; attempt <= SSL_ATTEMPTS; attempt++) { + try { + await tryIssue(); + issued = true; + lastError = undefined; + break; + } catch (err) { + lastError = err; + if (attempt < SSL_ATTEMPTS) { + spin.text = `Certificate not ready yet (attempt ${attempt}/${SSL_ATTEMPTS}) — retrying...`; + await Bun.sleep(SSL_RETRY_DELAY_MS); + } + } + } + } + + spin.stop(); + + if (!issued) { + const message = + lastError instanceof Error ? lastError.message : String(lastError); + throw new UserError( + `DNS is live, but the certificate couldn't be issued: ${message}`, + `Try again in a minute: ${opts.sslHint}`, + ); + } + + if (!opts.json) { + logger.success("DNS is live."); + logger.success( + opts.forceSsl + ? `SSL certificate issued for ${opts.hostname} and HTTPS forced.` + : `SSL certificate issued for ${opts.hostname}.`, + ); + logger.log( + ` Live at: ${hostnameUrl(opts.hostname, { hasCertificate: true })}`, + ); + } + return true; +} + +/** + * When the domain is on Bunny DNS, offer (prompted) to point a record at the + * pull zone, then run the wait/SSL flow. Returns whether a certificate was + * issued, or null when the domain isn't on Bunny DNS or the user declined — in + * which case the caller should fall back to manual CNAME instructions. + * + * Only zone detection is wrapped: an unreachable Bunny DNS API degrades to + * manual setup. Once a zone matches, the record offer and wait/SSL flow run + * outside the catch, so a write the user just confirmed (or a propagation + * timeout) surfaces instead of being mistaken for a detection hiccup. + * + * When the matched zone isn't delegated, the just-added PULLZONE record can't + * resolve publicly (only bunny's nameservers serve it), so we point the user at + * their registrar instead of starting a poll that would time out after 10 min. + */ +export async function offerBunnyDnsThenSsl(opts: { + coreClient: CoreClient; + hostname: string; + pullZoneId: number; + cnameTarget: string; + forceSsl: boolean; + sslHint: string; + verbose: boolean; + /** Invoked with the owning zone when the hostname is on Bunny DNS — lets the caller link the directory. */ + onBunnyDnsZone?: (zone: { + id: number; + domain: string; + }) => void | Promise; +}): Promise { + let match: BunnyDnsMatch | null; + try { + match = await findBunnyDnsZone(opts.coreClient, opts.hostname); + } catch (err) { + // Detecting the zone failed (API unreachable, etc.) — fall back to manual DNS. + if (opts.verbose) { + const message = err instanceof Error ? err.message : String(err); + logger.dim(` Bunny DNS check skipped: ${message}`); + } + return null; + } + if (!match) return null; + + await opts.onBunnyDnsZone?.({ id: match.zoneId, domain: match.zoneDomain }); + + const dnsResult = await offerBunnyDnsRecord({ + client: opts.coreClient, + hostname: opts.hostname, + pullZoneId: opts.pullZoneId, + match, + }); + if (dnsResult === "declined") return null; + + // An undelegated zone's PULLZONE record never reaches public resolvers, so + // skip the doomed poll and tell the user to delegate at their registrar. + if (!match.delegated) { + logger.log(); + logger.warn( + `${match.zoneDomain} isn't delegated to bunny.net's nameservers yet.`, + ); + logger.dim( + " The record is set, but won't resolve (or get a certificate) until you point your registrar at bunny.net.", + ); + printSslHint(opts.sslHint); + return false; + } + + logger.log(); + return offerDnsWaitAndSsl({ + coreClient: opts.coreClient, + pullZoneId: opts.pullZoneId, + hostname: opts.hostname, + cnameTarget: opts.cnameTarget, + forceSsl: opts.forceSsl, + sslHint: opts.sslHint, + dnsAlreadyLive: true, + }); +} diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index cea08a1..9d50dac 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -1,4 +1,11 @@ export { + type BunnyDnsMatch, + type BunnyDnsResult, + findBunnyDnsZone, + offerBunnyDnsRecord, +} from "./bunny-dns.ts"; +export { + addHostname, type CoreClient, enableSsl, fetchHostnamesForZones, @@ -6,6 +13,7 @@ export { type Hostname, hostnameUrl, liveHostnames, + normalizeHostname, type ResolvedPullZone, type SafeHostname, toSafeHostname, @@ -15,3 +23,15 @@ export { type HostnameResolver, type HostnamesMountOptions, } from "./commands.ts"; +export { + anyResolverPointsAt, + type DnsResolver, + defaultResolvers, + dnsPointsAt, +} from "./dns.ts"; +export { + type DnsSslFlowOptions, + offerBunnyDnsThenSsl, + offerDnsWaitAndSsl, + printSslHint, +} from "./flow.ts"; diff --git a/packages/cli/src/core/logger.ts b/packages/cli/src/core/logger.ts index 318cc3c..622dcb6 100644 --- a/packages/cli/src/core/logger.ts +++ b/packages/cli/src/core/logger.ts @@ -9,6 +9,7 @@ export const logger = { warn: (msg: string) => console.error(chalk.yellow("⚠"), msg), error: (msg: string) => console.error(chalk.red("✖"), msg), dim: (msg: string) => console.error(chalk.gray(msg)), + accent: (msg: string) => console.error(bunny(msg)), debug: (msg: string, verbose: boolean) => { if (verbose) console.error(chalk.gray("[debug]"), msg); }, diff --git a/packages/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index 2311198..44c2235 100644 --- a/packages/cli/src/core/ui.ts +++ b/packages/cli/src/core/ui.ts @@ -25,14 +25,14 @@ export async function readPassword(message: string): Promise { */ export async function confirm( message: string, - opts?: { force?: boolean }, + opts?: { force?: boolean; initial?: boolean }, ): Promise { if (opts?.force) return true; const { confirmed } = await prompts({ type: "confirm", name: "confirmed", message, - initial: false, + initial: opts?.initial ?? false, }); return confirmed ?? false; } diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index bb3c895..c014e82 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: bunny-cli -description: Manage bunny.net resources from the command line (databases, authentication, and raw API requests). Use when working with bunny.net (pullzones, databases, storage, Magic Containers), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. +description: Manage bunny.net resources from the command line (databases, Edge Scripts, authentication, and raw API requests). Use when working with bunny.net (pullzones, databases, storage, Edge Scripts, Magic Containers), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. --- # bunny.net CLI Skill @@ -34,6 +34,11 @@ bunny api GET /user bunny db create bunny db list bunny db shell + +# manage Edge Scripts +bunny scripts init +bunny scripts deploy dist/index.js +bunny scripts list ``` ## Decision Tree @@ -42,6 +47,7 @@ Use this to route to the correct reference file: - **Authenticate or switch profiles** -> `references/auth.md` - **Database management (create, list, show, link, delete, shell, studio, regions, tokens)** -> `references/database.md` +- **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` - **Make raw API requests** -> `references/api.md` - **CLI doesn't have a command for it** -> use `bunny api` as a fallback (see `references/api.md`) diff --git a/skills/bunny-cli/references/scripts.md b/skills/bunny-cli/references/scripts.md new file mode 100644 index 0000000..78a5010 --- /dev/null +++ b/skills/bunny-cli/references/scripts.md @@ -0,0 +1,264 @@ +# Edge Scripts Commands + +All Edge Script commands live under `bunny scripts`. Most accept an optional `SCRIPT_ID`. When omitted, the ID is resolved in this order: + +1. Explicit `SCRIPT_ID` — a trailing `[id]` positional on most commands, or the `--id` flag on `env set` / `env remove` (which already use positionals for the variable name/value) +2. `.bunny/script.json` manifest (written by `bunny scripts link`, `bunny scripts create`, or `bunny scripts init --deploy`) +3. Interactive prompt (suppressed in `--output json` mode — pass an ID or link the directory in CI) + +When the script is chosen via the interactive prompt, these commands (`show`, `stats`, `env`, `deployments`) offer to link the directory to it. Pass `--link` to link without the prompt, or `--no-link` to skip it. + +## Typical workflows + +```bash +# New project from scratch: scaffold, create remote script, deploy +bunny scripts init --name my-script --type standalone --template Empty --no-github-actions --deploy +cd my-script +bunny scripts deploy dist/index.js + +# Existing project: create the remote script, then deploy +bunny scripts create # links .bunny/script.json + creates a pull zone +bunny scripts deploy dist/index.js + +# Existing remote script: link the directory first +bunny scripts link +bunny scripts deploy dist/index.js +``` + +--- + +## `bunny scripts init` — Scaffold a project from a template + +```bash +bunny scripts init # interactive wizard +bunny scripts init --name my-script --type standalone --template Empty --no-github-actions --deploy +bunny scripts init --name my-script --type standalone --template Empty --github-actions --deploy +bunny scripts init --repo owner/my-template # custom template (shorthand) +bunny scripts init --template-repo https://github.com/owner/my-template # custom template (full URL) +``` + +| Flag | Description | +| --------------------------- | ------------------------------------------------------------------------------------ | +| `--name` | Project directory name | +| `--type` | Script type: `standalone` or `middleware` | +| `--template` | Template name | +| `--template-repo`, `--repo` | Git repository URL or GitHub `owner/repo` shorthand to use as template | +| `--github-actions` | Keep the template's GitHub Actions workflow (use `--no-github-actions` to remove it) | +| `--deploy` | Create script on bunny.net after scaffolding | +| `--skip-git` | Skip git initialization | +| `--skip-install` | Skip dependency installation | + +- `--repo`/`--template-repo` without `--type` defaults to `standalone`. +- With `--github-actions`, git is initialized automatically and the `SCRIPT_ID` to add as a GitHub repo secret is printed after script creation. +- The interactive wizard offers an optional custom domain after creating the script (same DNS + HTTPS flow as `domains add`). If the domain is in one of the account's Bunny DNS zones, it offers — after confirmation — to add or repoint the DNS record, then issues SSL straight away since DNS is already live on bunny's resolvers. + +--- + +## `bunny scripts create` — Create a remote Edge Script + +Creates the script on bunny.net without scaffolding a project. Use when a local project already exists and needs a remote script before `deploy`. + +```bash +bunny scripts create # current directory name + link +bunny scripts create my-script --type middleware +bunny scripts create my-script --no-pull-zone --no-link +bunny scripts create my-script --domain shop.example.com +``` + +| Flag | Description | +| ------------------ | ---------------------------------------------------------------------------------------- | +| `--type` | Script type: `standalone` or `middleware` (defaults to manifest, prompts if interactive) | +| `--pull-zone` | Create a linked pull zone (default: true). Use `--no-pull-zone` to skip. | +| `--pull-zone-name` | Name for the linked pull zone | +| `--link` | Link this directory to the new script (default: true). Use `--no-link` to skip. | +| `--domain` | Add a custom domain to the new script's pull zone (prompted when interactive) | + +--- + +## `bunny scripts deploy` — Upload and publish code + +```bash +bunny scripts deploy dist/index.js # deploy and publish +bunny scripts deploy dist/index.js --skip-publish # upload without publishing +bunny scripts deploy dist/index.js 12345 # target a specific script +``` + +| Flag | Description | +| ---------------- | ------------------------------ | +| `--skip-publish` | Upload code without publishing | + +After publishing, the live URL and any custom domains are printed. The last deployment always wins, whether triggered by GitHub Actions or a manual CLI deploy. + +--- + +## `bunny scripts link` — Link the current directory + +Writes `.bunny/script.json` so subsequent commands resolve the script without an ID. + +```bash +bunny scripts link # interactive selection +bunny scripts link --id # non-interactive +``` + +--- + +## `bunny scripts list` / `show` + +```bash +bunny scripts list # all scripts (alias: ls) +bunny scripts list --output json +bunny scripts show # details incl. hostnames + SSL status +bunny scripts show # linked script +``` + +--- + +## `bunny scripts stats` — Usage statistics + +Request, CPU, and cost totals plus a per-bucket requests-served bar chart (text mode). Defaults to the last 30 days. With no ID and no link, prompts to pick a script and offers to link the directory; in `--output json` mode it errors instead. + +```bash +bunny scripts stats +bunny scripts stats 12345 --from 2026-05-01 --to 2026-05-31 +bunny scripts stats 12345 --hourly +bunny scripts stats 12345 --output json +bunny scripts stats --no-link # interactive pick without the link prompt +``` + +| Flag | Description | +| ---------- | ---------------------------------------------------------------------------------- | +| `--from` | Start date (YYYY-MM-DD); defaults to 30 days ago | +| `--to` | End date (YYYY-MM-DD); defaults to today | +| `--hourly` | Group statistics by hour instead of by day | +| `--link` | After an interactive pick, link the directory (use `--no-link` to skip the prompt) | + +--- + +## `bunny scripts delete` — Delete a script + +Requires double confirmation (or `--force`). + +```bash +bunny scripts delete +bunny scripts delete # linked script +bunny scripts delete --force +``` + +--- + +## Deployments + +### `bunny scripts deployments list` + +```bash +bunny scripts deployments list # linked script (alias: ls) +bunny scripts deployments list +bunny scripts deployments list --output json +``` + +### `bunny scripts deployments publish` — Roll back to a past release + +Re-publishes an earlier release by its release ID (from `deployments list`) without touching the current code. + +```bash +bunny scripts deployments publish +bunny scripts deployments publish +bunny scripts deployments publish --force +``` + +| Flag | Description | +| --------- | ----------------------------------------------------------- | +| `--force` | Skip the confirmation prompt | +| `--link` | After an interactive pick, link the directory to the script | + +--- + +## Environment variables + +All `env` subcommands default to the linked script. `env list` and `env pull` take a trailing `[id]`; `env set` and `env remove` use `--id ` (their positionals are the variable name and value). All accept `--link` (see the resolution note above). + +### `bunny scripts env list` + +```bash +bunny scripts env list # alias: ls +bunny scripts env list --output json +``` + +### `bunny scripts env set` + +Variable names are uppercased. Runs interactively when arguments are omitted. + +```bash +bunny scripts env set MY_VAR value +bunny scripts env set API_KEY secret-value --secret # encrypted secret +``` + +### `bunny scripts env remove` + +Interactive picker when no name is given; confirms 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 --force # overwrite existing .env without prompting +``` + +--- + +## Custom domains + +A script's domains live on its linked pull zone — these commands operate on that pull zone. Pass a trailing `[id]` (or `--id`) to target a non-linked script, and `--pull-zone ` when a script has multiple linked zones. (`bunny scripts hostnames` is a hidden alias.) + +### `bunny scripts domains add` + +SSL is **not** requested by default — a free certificate can only be issued once DNS points at bunny.net, so the command prints the `CNAME` record to create. Interactively it offers to wait for DNS propagation (up to 10 minutes) and issues the certificate automatically. + +```bash +bunny scripts domains add shop.example.com # print CNAME, optionally wait +bunny scripts domains add shop.example.com --wait # wait for DNS, then enable HTTPS — no prompts +bunny scripts domains add shop.example.com --ssl # request SSL now (DNS must already point at bunny.net) +bunny scripts domains add shop.example.com --ssl --no-force-ssl +bunny scripts domains add shop.example.com 12345 # non-linked script +``` + +| Flag | Description | +| ---------------- | ----------------------------------------------------------------------- | +| `--ssl` | Issue a free SSL certificate now and force HTTPS (requires DNS pointed) | +| `--wait` | Wait for DNS to point at bunny.net (up to 10 minutes), then issue SSL | +| `--no-force-ssl` | When issuing SSL, keep serving HTTP instead of redirecting to HTTPS | +| `--pull-zone` | Pull zone ID (required if the script has multiple linked zones) | + +### `bunny scripts domains ssl` + +Request a free SSL certificate after DNS points at bunny.net. HTTP redirects to HTTPS by default. + +```bash +bunny scripts domains ssl shop.example.com +bunny scripts domains ssl shop.example.com --no-force-ssl +``` + +### `bunny scripts domains list` / `remove` + +```bash +bunny scripts domains list # domains with SSL + Force SSL status (alias: ls) +bunny scripts domains remove shop.example.com # system hostnames cannot be removed +bunny scripts domains remove shop.example.com --force +``` + +--- + +## `bunny scripts docs` + +Open the Edge Scripts documentation in your browser. + +```bash +bunny scripts docs +```