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
6 changes: 6 additions & 0 deletions .changeset/dns-record-scan-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bunny.net/openapi-client": patch
"@bunny.net/cli": patch
---

feat(dns): import a domain's existing records when moving to bunny: new `dns records scan [domain]` (server-side record scan, multiselect, bulk-write) and `dns zones add --import` offer the same migration right after creating the zone; a bad record is reported rather than stranding the batch, and CAA flags/tag survive via corrected `DnsDiscoveredRecord` types in `@bunny.net/openapi-client`
5 changes: 5 additions & 0 deletions .changeset/dns-record-type-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(dns): label the bunny-specific record types with bunny's canonical codes (Pull Zone as PZ, Redirect as RDR, Script as SCR) across listings and pickers, group them under "Bunny" in the interactive type picker, and accept those codes (plus the spelled-out names) when parsing a record type
5 changes: 5 additions & 0 deletions .changeset/dns-zone-add-next-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

feat(dns): `dns zones add` now scans for existing records automatically, then offers a next-steps menu (upload a zone file, add records manually, or continue to nameserver setup) so you can fully populate the zone before delegating; `--import` imports scanned records without prompting and `--no-import` skips the scan and menu
22 changes: 14 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ bunny-cli/
│ │ ├── scripts/
│ │ │ └── update-specs.ts # Downloads latest specs from bunny.net endpoints
│ │ └── src/
│ │ ├── index.ts # Barrel export: clients, errors, ClientOptions type
│ │ ├── index.ts # Barrel export: clients, errors, ClientOptions type, DNS scan type corrections
│ │ ├── middleware.ts # authMiddleware(options) — dependency-inverted (no CLI imports)
│ │ ├── errors.ts # UserError, ApiError classes
│ │ ├── dns.ts # Hand-authored corrections for lossy generated DNS types: DnsDiscoveredRecord (adds Flags/Tag the scan returns but generation drops), DnsRecordScanJob/Trigger, DnsRecordScanStatus enum. Pattern for enriching generated types.
│ │ ├── core-client.ts # createCoreClient(options) — Core API
│ │ ├── compute-client.ts # createComputeClient(options) — Edge Scripting
│ │ ├── db-client.ts # createDbClient(options) — Database
Expand Down Expand Up @@ -176,7 +177,7 @@ bunny-cli/
│ │ ├── define-command.ts # Command factory (see "Command Pattern" below)
│ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees
│ │ ├── dns-nameservers.ts # BUNNY_NAMESERVERS + expectedNameservers(zone) + checkDelegation()/checkDelegations(): reads the parent zone's NS referral (raw UDP query of the registry, not the recursive answer a child host could spoof; falls back to dns.resolveNs when the referral is unreadable), matches the full expected set both ways, ground truth over bunny's NameserversDetected flag which defaults true on a fresh zone; checkDelegations is bounded-concurrency for the zone list
│ │ ├── dns-record-types.ts # Canonical DNS record-type name⇄integer map (RECORD_TYPES) + recordTypeLabel(); shared by commands/dns + core/hostnames
│ │ ├── dns-record-types.ts # Canonical DNS record-type name⇄integer map (RECORD_TYPES) + RECORD_TYPE_META (dashboard taxonomy: short label, friendly name, Standard/Bunny group) + recordTypeLabel() (bunny's canonical labels: PZ/RDR/SCR/Flatten for the bunny-specific types) + recordTypeFromLabel() (parses canonical labels and enum-key names); 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
Expand Down Expand Up @@ -297,25 +298,29 @@ bunny-cli/
│ │ │ └── invalidate.ts # Invalidate all tokens for a database (with confirmation)
│ │ ├── dns/ # Experimental — hidden from help and landing page
│ │ │ ├── index.ts # defineNamespace("dns", ...): registers the records + zones + scripts groups (+ hidden domain aliases)
│ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone)
│ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone), scanZoneRecords (trigger + poll bunny's server-side record scan via /dnszone/records/scan, returns corrected DnsDiscoveredRecord[] with Flags/Tag; uses DnsRecordScanStatus enum)
│ │ │ ├── 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-types.ts # Re-exports RECORD_TYPES/RECORD_TYPE_META/recordTypeLabel from core/dns-record-types.ts; adds parseRecordType (accepts canonical labels + enum-key names), 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). Interactive wizard first offers "single record" vs a preset (pickAndApplyPreset); A/AAAA/CNAME/TXT offer static vs script-computed (Scriptable DNS) via pickOrCreateDnsScript: pick or create+seed a DNS script and write a SCRIPT record
│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script). Interactive wizard first offers "single record" vs a preset (pickAndApplyPreset); A/AAAA/CNAME/TXT offer static vs script-computed (Scriptable DNS) via pickOrCreateDnsScript: pick or create+seed a DNS script and write a SCRIPT record. Exports addRecordInteractive (the single-record wizard) reused by zone/add.ts's next-steps menu
│ │ │ │ ├── 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)
│ │ │ │ ├── preset.ts # `records preset [name] [domain]` (`list` lists; `--param key=value` repeatable for non-interactive runs): pick/apply a preset, gather params (flags then prompts in text mode), summarize, confirm, bulk-write. `--output json` writes then serializes the result; mid-sequence failures report how many records landed. Exports pickAndApplyPreset reused by add.ts
│ │ │ │ ├── presets.ts # Preset catalog (data + pure build fns): google-workspace, microsoft365/outlook, zoho, mailgun, resend, proton, bluesky, dmarc, caa, no-email. findPreset(id|alias)
│ │ │ │ ├── presets.test.ts # Tests for the pure preset build fns + alias resolution
│ │ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted)
│ │ │ │ ├── write.ts # Shared writeRecords (per-record PUT, resilient: collects {applied, failures} so one bad record can't strand the batch) + reviewAndApply (multiselect pre-checked records in text, write, report partial failures, serialize on json); used by preset.ts, scan.ts, zone/add.ts
│ │ │ │ ├── scan-records.ts # discoverImportableRecords: scanZoneRecords + map discovered → AddDnsRecordModel (carries Flags/Tag, reconstructs CAA flags/tag from rdata as a fallback), drop SOA/NS, dedupe vs existing records
│ │ │ │ ├── scan-records.test.ts # Tests for the discovered-record mapping/filter/dedupe
│ │ │ │ ├── scan.ts # `records scan [domain]` (--yes): discover the domain's existing records (server-side scan) and reviewAndApply them; reused at zone creation
│ │ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted). Exports importZoneFile reused by zone/add.ts's next-steps menu
│ │ │ │ └── export.ts # Export records as a BIND zone file (stdout, --file <path>, or --save → <domain>.zone)
│ │ │ └── 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, then print the bunny nameservers to set (naming the registrar via core/registrar.ts when RDAP resolves it)
│ │ │ ├── add.ts # Create a DNS zone, auto-scan for the domain's existing records (discoverImportableRecords + reviewAndApply; --import imports all without prompting, --no-import skips scan+menu, best-effort), then offerNextSteps menu (upload a zone file via importZoneFile / add records manually via addRecordInteractive / continue), then print the bunny nameservers to set (naming the registrar via core/registrar.ts when RDAP resolves it)
│ │ │ ├── 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 @@ -903,11 +908,12 @@ bunny
│ │ │ Update a DNS record (alias: edit; prompts to pick zone+record when omitted)
│ │ ├── remove [domain] [id] [--force] Remove a DNS record (alias: rm; prompts to pick zone+record when omitted)
│ │ ├── preset [name] [domain] [--param key=value] Apply a preset record set (`preset list` lists; email providers, verification, security; --param repeatable for non-interactive runs)
│ │ ├── scan [domain] [--yes] Scan for the domain's existing records (bunny server-side scan) and import them (--yes skips the confirm)
│ │ ├── 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 <path>, or --save → <domain>.zone)
│ └── zones (canonical; aliases: zone; hidden: domain, domains)
│ ├── list List all DNS zones (alias: ls)
│ ├── add <domain> Create a DNS zone (then prints the bunny nameservers to set, naming the registrar via RDAP when detectable)
│ ├── add <domain> [--import] Create a DNS zone: 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); --no-import skips the scan and 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ 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; prints registrar-aware setup steps (skipped if already delegated)
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 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)
bun ny dns records preset google-workspace example.com # apply a preset record set
bun ny dns records preset bluesky example.com --param did=did:plc:abc123 # apply a preset non-interactively
Expand Down
51 changes: 50 additions & 1 deletion packages/cli/src/commands/dns/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,59 @@
import type { createCoreClient } from "@bunny.net/openapi-client";
import {
type createCoreClient,
type DnsDiscoveredRecord,
type DnsRecordScanJob,
DnsRecordScanStatus,
} from "@bunny.net/openapi-client";
import type { components } from "@bunny.net/openapi-client/generated/core.d.ts";
import { UserError } from "../../core/errors.ts";

export type CoreClient = ReturnType<typeof createCoreClient>;
export type DnsZoneModel = components["schemas"]["DnsZoneModel"];
export type DnsRecordModel = components["schemas"]["DnsRecordModel"];
export type { DnsDiscoveredRecord } from "@bunny.net/openapi-client";

/**
* Trigger bunny's server-side scan for a zone's pre-existing records and poll
* until it finishes, returning the discovered records. Throws on scan failure
* or timeout.
*/
export async function scanZoneRecords(
client: CoreClient,
zoneId: number,
opts: { timeoutMs?: number; intervalMs?: number } = {},
): Promise<DnsDiscoveredRecord[]> {
const { timeoutMs = 45000, intervalMs = 1500 } = opts;
const { data: trigger } = await client.POST("/dnszone/records/scan", {
body: { ZoneId: zoneId },
});
const jobId = trigger?.JobId;
const deadline = Date.now() + timeoutMs;
for (;;) {
const { data: raw } = await client.GET("/dnszone/{zoneId}/records/scan", {
params: { path: { zoneId } },
});
// The generated Records type is lossy (drops Flags/Tag); read the corrected shape.
const data = raw as DnsRecordScanJob | undefined;
// Only trust the result once it matches the job we just triggered.
if (data && (!jobId || data.JobId === jobId)) {
if (data.Status === DnsRecordScanStatus.Completed)
return data.Records ?? [];
if (data.Status === DnsRecordScanStatus.Failed) {
throw new UserError(
`DNS record scan failed for zone ${zoneId}.`,
data.Error ?? undefined,
);
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (Date.now() > deadline) {
throw new UserError(
"Timed out waiting for the DNS record scan to finish.",
'Try again, or import records manually with "bunny dns records import".',
);
}
await Bun.sleep(intervalMs);
}
}

/** Fetch all DNS zones on the account, paginated and sorted by domain. */
export async function fetchZones(client: CoreClient): Promise<DnsZoneModel[]> {
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/dns/record-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ describe("parseRecordType", () => {
expect(parseRecordType(" Mx ")).toBe(RECORD_TYPES.MX);
});

test("accepts bunny's canonical labels and the spelled-out names", () => {
expect(parseRecordType("PZ")).toBe(RECORD_TYPES.PULLZONE);
expect(parseRecordType("pullzone")).toBe(RECORD_TYPES.PULLZONE);
expect(parseRecordType("RDR")).toBe(RECORD_TYPES.REDIRECT);
expect(parseRecordType("scr")).toBe(RECORD_TYPES.SCRIPT);
});

test("throws on an unknown type", () => {
expect(() => parseRecordType("BOGUS")).toThrow(/Unknown record type/);
});
Expand All @@ -25,6 +32,13 @@ describe("recordTypeLabel", () => {
expect(recordTypeLabel(RECORD_TYPES.CAA)).toBe("CAA");
});

test("uses bunny's canonical short labels for bunny-specific types", () => {
expect(recordTypeLabel(RECORD_TYPES.PULLZONE)).toBe("PZ");
expect(recordTypeLabel(RECORD_TYPES.REDIRECT)).toBe("RDR");
expect(recordTypeLabel(RECORD_TYPES.SCRIPT)).toBe("SCR");
expect(recordTypeLabel(RECORD_TYPES.FLATTEN)).toBe("Flatten");
});

test("falls back to UNKNOWN", () => {
expect(recordTypeLabel(999)).toBe("UNKNOWN");
expect(recordTypeLabel(null)).toBe("UNKNOWN");
Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/commands/dns/record-types.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import type { components } from "@bunny.net/openapi-client/generated/core.d.ts";
import {
type DnsRecordTypes,
RECORD_TYPE_LABELS,
RECORD_TYPE_META,
RECORD_TYPES,
recordTypeFromLabel,
recordTypeLabel,
} from "../../core/dns-record-types.ts";
import { UserError } from "../../core/errors.ts";

export { type DnsRecordTypes, RECORD_TYPES, recordTypeLabel };
export { type DnsRecordTypes, RECORD_TYPE_META, RECORD_TYPES, recordTypeLabel };
export type DnsRecordModel = components["schemas"]["DnsRecordModel"];

/** Parse a record type name (e.g. "A", "cname") to its enum value, or throw. */
/** Parse a record type label (e.g. "A", "cname", "PZ") to its enum value, or throw. */
export function parseRecordType(value: string): DnsRecordTypes {
const key = value.trim().toUpperCase() as keyof typeof RECORD_TYPES;
const parsed = RECORD_TYPES[key];
const parsed = recordTypeFromLabel(value);
if (parsed === undefined) {
throw new UserError(
`Unknown record type "${value}".`,
`Valid types: ${Object.keys(RECORD_TYPES).join(", ")}`,
`Valid types: ${RECORD_TYPE_LABELS.join(", ")}`,
);
}
return parsed;
Expand Down
Loading