Skip to content
7 changes: 7 additions & 0 deletions .changeset/silver-domains-wait.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/tame-selectors-share.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/witty-falcons-route.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 35 additions & 24 deletions AGENTS.md

Large diffs are not rendered by default.

23 changes: 19 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand All @@ -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`

Expand Down Expand Up @@ -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 <script-id>` to target another, and `--pull-zone <id>` 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 <script-id>` flag) to target another, and `--pull-zone <id>` 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`
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/dns/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/dns/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
}
68 changes: 65 additions & 3 deletions packages/cli/src/commands/dns/interactive.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<DnsManifest>(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<void> {
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<void> {
const existing = loadManifest<DnsManifest>(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<DnsZoneModel> {
if (ref) {
const spin = spinner("Resolving zone...");
Expand All @@ -33,6 +75,20 @@ export async function resolveZoneInteractive(
}
}

// Fall back to a directory linked with `bunny dns zones link` before prompting.
const manifest = loadManifest<DnsManifest>(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[];
Expand All @@ -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 <domain>".',
'Create one with "bunny dns zones add <domain>".',
);
}

Expand All @@ -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;
}

/**
Expand Down
36 changes: 6 additions & 30 deletions packages/cli/src/commands/dns/record-types.ts
Original file line number Diff line number Diff line change
@@ -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<string, DnsRecordTypes>;

const TYPE_LABELS: Record<number, string> = 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;
Expand Down
15 changes: 9 additions & 6 deletions packages/cli/src/commands/dns/record/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,20 +201,20 @@ export const dnsAddCommand = defineCommand<AddArgs>({
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) =>
Expand Down Expand Up @@ -256,7 +256,10 @@ export const dnsAddCommand = defineCommand<AddArgs>({
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) {
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/src/commands/dns/record/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ export const dnsExportCommand = defineCommand<ExportArgs>({
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) =>
Expand All @@ -41,7 +41,10 @@ export const dnsExportCommand = defineCommand<ExportArgs>({
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();
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/dns/record/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const dnsImportCommand = defineCommand<ImportArgs>({
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",
],
],
Expand All @@ -35,7 +35,10 @@ export const dnsImportCommand = defineCommand<ImportArgs>({
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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/dns/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,5 +17,5 @@ export const dnsRecordNamespace = defineNamespace(
dnsImportCommand,
dnsExportCommand,
],
["records", "rec"],
["record", "rec"],
);
9 changes: 6 additions & 3 deletions packages/cli/src/commands/dns/record/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const dnsRecordListCommand = defineCommand<ListArgs>({
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) =>
Expand All @@ -34,7 +34,10 @@ export const dnsRecordListCommand = defineCommand<ListArgs>({
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)),
Expand Down
Loading
Loading