Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dns-main-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(dns): promote dns out of experimental and onto the main command menu
5 changes: 5 additions & 0 deletions .changeset/dns-zone-add-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(dns): zones add asks how to add records (scan existing / upload a BIND zone file / add manually) instead of auto-scanning
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ bunny-cli/
│ │ │ ├── index.ts # defineNamespace("tokens", ...) — registers token commands
│ │ │ ├── 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
│ │ ├── dns/
│ │ │ ├── index.ts # defineNamespace("dns", ...): registers the records + zones + scripts groups (+ hidden domain aliases)
│ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone), scanZoneRecords (trigger + poll bunny's server-side record scan via /dnszone/records/scan; matches the triggered JobId, falling back to "differs from the prior job" when the trigger omits one; returns corrected DnsDiscoveredRecord[] with Flags/Tag; uses DnsRecordScanStatus enum)
│ │ │ ├── constants.ts # DNS_MANIFEST (".bunny/dns.json") + DnsManifest type, written by `dns zones link`
Expand All @@ -320,7 +320,7 @@ bunny-cli/
│ │ │ └── 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); Nameservers column from a live per-zone NS lookup, not bunny's NameserversDetected flag
│ │ │ ├── add.ts # Create a DNS zone, auto-scan for the domain's existing records (discoverImportableRecords + reviewAndApply; --import imports all without prompting and surfaces failures as a JSON ImportError + nonzero exit, --no-import skips scan+menu), then offerNextSteps menu (upload a zone file via importZoneFile / add records manually via addRecordInteractive / continue), then print the bunny nameservers (naming the registrar via core/registrar.ts when RDAP resolves it). Default scan+menu are TTY-gated so `zones add <domain>` stays scriptable
│ │ │ ├── add.ts # Create a DNS zone, then offerNextSteps menu (scan for existing records via scanAndImport: discoverImportableRecords + reviewAndApply / upload a zone file via importZoneFile / add records manually via addRecordInteractive / continue); --import scans and imports all without prompting and surfaces failures as a JSON ImportError + nonzero exit, --no-import skips the menu; then print the bunny nameservers (naming the registrar via core/registrar.ts when RDAP resolves it). Menu is TTY-gated so `zones add <domain>` stays scriptable
│ │ │ ├── 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)
Expand Down Expand Up @@ -953,7 +953,7 @@ bunny
│ ├── update <id> [--name] [--username] [--password]
│ │ Update registry name and/or rotate credentials
│ └── remove <id> Remove registry
├── dns (experimental — hidden from help and landing page)
├── dns Manage DNS zones and records
│ │ 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)
Expand All @@ -969,7 +969,7 @@ bunny
│ │ └── export [domain] [--file] [--save] Export a zone as a BIND zone file (stdout, --file <path>, or --save → <domain>.zone)
│ └── zones (canonical; aliases: zone; hidden: domain, domains)
│ ├── list List all DNS zones (alias: ls)
│ ├── add <domain> [--import] Create a DNS zone: with a TTY, auto-scan for existing records → import them → next-steps menu (upload a zone file / add records manually / continue); then print the bunny nameservers to set (naming the registrar via RDAP when detectable). --import imports scanned records without prompting (also under --output json, surfacing failures); --no-import (and non-TTY default) skips the scan and menu
│ ├── add <domain> [--import] Create a DNS zone: with a TTY, prompt how to add records (scan for existing records / upload a zone file / add records manually / continue); then print the bunny nameservers to set (naming the registrar via RDAP when detectable). --import scans and imports all existing records without prompting (also under --output json, surfacing failures); --no-import (and non-TTY default) skips the menu
│ ├── 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ bun ny apps deploy # first run? Imports docker-compose.
bun ny apps link # interactive: pick from existing apps on the account
bun ny apps link <app-id> # link a specific app to this directory (writes .bunny/app.json)
bun ny apps unlink # remove .bunny/app.json
bun ny dns zones add example.com # create a zone; auto-scans for existing records, then offers to import/upload/add before registrar setup steps
bun ny dns zones add example.com # create a zone, then choose how to add records (scan existing / upload a BIND zone file / add manually) before registrar setup steps
bun ny dns zones nameservers example.com # live-check whether the registrar delegates to bunny
bun ny dns records scan example.com # scan for the domain's existing records and import them
bun ny dns records preset list # list DNS record presets (email providers, verification, security)
Expand Down
2 changes: 0 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,6 @@ bunny registries remove <registry-id>

### `bunny dns`

> **Experimental** — hidden from `--help` and the landing page while it stabilizes.

Manage DNS through two resource groups: **`bunny dns record`** (the entries within a zone) and **`bunny dns zone`** (the zone itself — settings, DNSSEC, logging, stats, nameservers). The `[domain]` argument accepts either the zone's domain name or its numeric zone ID, and is optional everywhere — omit it and you'll be prompted to pick a zone. `record update`/`record remove` likewise prompt you to pick a record when the ID is omitted. `record` aliases to `records`/`rec`; `zone` aliases to `zones` (and `domain`/`domains`).

```bash
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const commands: CommandModule[] = [
authLogoutCommand,
whoamiCommand,
dbNamespace,
dnsNamespace,
scriptsNamespace,
configNamespace,
docsCommand,
Expand All @@ -36,7 +37,6 @@ const commands: CommandModule[] = [
const experimentalCommands: CommandModule[] = [
appsNamespace,
registriesNamespace,
dnsNamespace,
sandboxNamespace,
storageNamespace,
];
Expand Down
17 changes: 10 additions & 7 deletions packages/cli/src/commands/dns/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { dnsRecordNamespace } from "./record/index.ts";
import { dnsScriptsNamespace } from "./scripts/index.ts";
import { dnsZoneHiddenAliases, dnsZoneNamespace } from "./zone/index.ts";

// Hidden from help while experimental, matching the apps and registries namespaces.
export const dnsNamespace = defineNamespace("dns", false, [
dnsRecordNamespace,
dnsZoneNamespace,
dnsScriptsNamespace,
...dnsZoneHiddenAliases,
]);
export const dnsNamespace = defineNamespace(
"dns",
"Manage DNS zones and records.",
[
dnsRecordNamespace,
dnsZoneNamespace,
dnsScriptsNamespace,
...dnsZoneHiddenAliases,
],
);
119 changes: 75 additions & 44 deletions packages/cli/src/commands/dns/zone/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,53 @@ interface ZoneAddArgs {
import?: boolean;
}

/** After the scan, let the user keep populating the zone until they continue to nameserver setup. */
async function scanAndImport(opts: {
client: CoreClient;
zone: DnsZoneModel;
domain: string;
output: string;
assumeYes: boolean;
}): Promise<void> {
let discovered: Awaited<ReturnType<typeof discoverImportableRecords>> = [];
let scanError: unknown;
const scanSpin = spinner("Scanning for existing DNS records...");
scanSpin.start();
try {
discovered = await discoverImportableRecords(opts.client, opts.zone);
} catch (err) {
scanError = err;
} finally {
scanSpin.stop();
}

logger.log("");
if (scanError) {
logger.warn(
`Couldn't scan for existing records: ${scanError instanceof Error ? scanError.message : String(scanError)}`,
);
} else if (discovered.length) {
await reviewAndApply({
client: opts.client,
zone: opts.zone,
records: discovered,
output: opts.output,
selectMessage: `Found ${discovered.length} existing record(s) for ${opts.domain} at your current provider. Select which to import:`,
spinnerLabel: "Importing records...",
successFor: (n) => `Imported ${n} record(s) into ${opts.domain}.`,
assumeYes: opts.assumeYes,
});
} else {
logger.info(`No existing records found for ${opts.domain}.`);
}
}

/** After creating the zone, let the user populate it (scan/upload/manual) until they continue to nameserver setup. */
async function offerNextSteps(opts: {
client: CoreClient;
config: ReturnType<typeof resolveConfig>;
verbose: boolean;
zone: DnsZoneModel;
domain: string;
output: string;
}): Promise<void> {
for (;;) {
Expand All @@ -36,14 +77,26 @@ async function offerNextSteps(opts: {
name: "next",
message: "What next?",
choices: [
{
title: "Scan for existing records at your current provider",
value: "scan",
},
{ title: "Upload a zone file (BIND)", value: "import" },
{ title: "Add records manually", value: "manual" },
{ title: "Continue to nameserver setup", value: "continue" },
],
});
if (next === undefined || next === "continue") return;
try {
if (next === "import") {
if (next === "scan") {
await scanAndImport({
client: opts.client,
zone: opts.zone,
Comment thread
jamie-at-bunny marked this conversation as resolved.
domain: opts.domain,
output: opts.output,
Comment thread
jamie-at-bunny marked this conversation as resolved.
Outdated
assumeYes: false,
});
} else if (next === "import") {
await importZoneFile({
client: opts.client,
zone: opts.zone,
Expand Down Expand Up @@ -88,7 +141,7 @@ export const dnsZoneAddCommand = defineCommand<ZoneAddArgs>({
.option("import", {
type: "boolean",
describe:
"Import all scanned records without prompting (--no-import skips the scan and next-steps menu)",
"Scan and import all existing records without prompting (--no-import skips the records menu)",
}),

handler: async ({
Expand Down Expand Up @@ -174,49 +227,27 @@ export const dnsZoneAddCommand = defineCommand<ZoneAddArgs>({
: `Created DNS zone ${domain}.`,
);

// Default scan+menu only run with a TTY so `zones add <domain>` stays scriptable; --import forces it.
const interactive = Boolean(process.stdin.isTTY);

if (
created?.Id != null &&
(doImport === true || (doImport === undefined && interactive))
) {
let discovered: Awaited<ReturnType<typeof discoverImportableRecords>> =
[];
let scanError: unknown;
const scanSpin = spinner("Scanning for existing DNS records...");
scanSpin.start();
try {
discovered = await discoverImportableRecords(client, created);
} catch (err) {
scanError = err;
} finally {
scanSpin.stop();
}

logger.log("");
if (scanError) {
logger.warn(
`Couldn't scan for existing records: ${scanError instanceof Error ? scanError.message : String(scanError)}`,
);
} else if (discovered.length) {
await reviewAndApply({
client,
zone: created,
records: discovered,
output,
selectMessage: `Found ${discovered.length} existing record(s) for ${domain} at your current provider. Select which to import:`,
spinnerLabel: "Importing records...",
successFor: (n) => `Imported ${n} record(s) into ${domain}.`,
assumeYes: doImport === true,
});
} else {
logger.info(`No existing records found for ${domain}.`);
}
// --import is an explicit migration action: scan and import everything without prompting.
if (created?.Id != null && doImport === true) {
await scanAndImport({
client,
zone: created,
domain,
output,
assumeYes: true,
});
}

if (created?.Id != null && doImport === undefined && interactive) {
await offerNextSteps({ client, config, verbose, zone: created, output });
// The records menu only runs with a TTY so `zones add <domain>` stays scriptable.
if (created?.Id != null && doImport === undefined && process.stdin.isTTY) {
await offerNextSteps({
client,
config,
verbose,
zone: created,
domain,
output,
});
}

// Savvy users often point the registrar at bunny before creating the zone; skip the setup steps when it's already delegated.
Expand Down