diff --git a/.changeset/dns-record-scan-import.md b/.changeset/dns-record-scan-import.md new file mode 100644 index 00000000..71502ba5 --- /dev/null +++ b/.changeset/dns-record-scan-import.md @@ -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` diff --git a/.changeset/dns-record-type-labels.md b/.changeset/dns-record-type-labels.md new file mode 100644 index 00000000..3c06268b --- /dev/null +++ b/.changeset/dns-record-type-labels.md @@ -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 diff --git a/.changeset/dns-zone-add-next-steps.md b/.changeset/dns-zone-add-next-steps.md new file mode 100644 index 00000000..3d6eeada --- /dev/null +++ b/.changeset/dns-zone-add-next-steps.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index cbc3b008..f584904c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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; 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` │ │ │ ├── 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 apex SOA/NS but keep delegated subdomain NS, dedupe vs existing records by type + normalized name/value (trailing-dot/case) + type-specific fields (priority/weight/port/flags/tag) +│ │ │ │ ├── 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 , or --save → .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 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 ` 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) @@ -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 , or --save → .zone) │ └── zones (canonical; aliases: zone; hidden: domain, domains) │ ├── list List all DNS zones (alias: ls) -│ ├── add Create a DNS zone (then prints the bunny nameservers to set, naming the registrar via RDAP when detectable) +│ ├── add [--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 │ ├── 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) diff --git a/README.md b/README.md index 117a6eaf..615e8fb7 100644 --- a/README.md +++ b/README.md @@ -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 # 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 diff --git a/packages/cli/src/commands/dns/api.ts b/packages/cli/src/commands/dns/api.ts index 6924eae2..dd81dcb8 100644 --- a/packages/cli/src/commands/dns/api.ts +++ b/packages/cli/src/commands/dns/api.ts @@ -1,10 +1,71 @@ -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; 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 { + const { timeoutMs = 45000, intervalMs = 1500 } = opts; + // Remember the prior job so a fresh result is identifiable even if the trigger omits a JobId. + let priorJobId: string | undefined; + try { + const { data } = await client.GET("/dnszone/{zoneId}/records/scan", { + params: { path: { zoneId } }, + }); + priorJobId = (data as DnsRecordScanJob | undefined)?.JobId ?? undefined; + } catch {} + + 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; + // Match the triggered job by id; without one, accept only a job that differs from the prior result. + const isOurJob = jobId + ? data?.JobId === jobId + : data?.JobId != null && data.JobId !== priorJobId; + if (data && isOurJob) { + 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, + ); + } + } + 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 { diff --git a/packages/cli/src/commands/dns/record-types.test.ts b/packages/cli/src/commands/dns/record-types.test.ts index fcdbf9cc..7fbd2f94 100644 --- a/packages/cli/src/commands/dns/record-types.test.ts +++ b/packages/cli/src/commands/dns/record-types.test.ts @@ -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/); }); @@ -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"); diff --git a/packages/cli/src/commands/dns/record-types.ts b/packages/cli/src/commands/dns/record-types.ts index c38edb30..96ff80fa 100644 --- a/packages/cli/src/commands/dns/record-types.ts +++ b/packages/cli/src/commands/dns/record-types.ts @@ -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; diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index e5d7b8e3..22d24eff 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -10,10 +10,12 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { spinner } from "../../../core/ui.ts"; +import type { CoreClient, DnsZoneModel } from "../api.ts"; import { resolveZoneInteractive } from "../interactive.ts"; import { type DnsRecordTypes, parseRecordType, + RECORD_TYPE_META, RECORD_TYPES, recordName, recordTypeLabel, @@ -203,6 +205,133 @@ async function promptRecord( return record; } +/** Write a single record and report the result (json or a success line). */ +async function writeAndReport( + client: CoreClient, + zone: DnsZoneModel, + record: AddDnsRecordModel, + output: string, +): Promise { + const spin = spinner("Adding record..."); + spin.start(); + let data: { Id?: number } | undefined; + try { + ({ data } = await client.PUT("/dnszone/{zoneId}/records", { + params: { path: { zoneId: zone.Id as number } }, + body: record, + })); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success( + `Added ${recordTypeLabel(record.Type as number)} record ${recordName(record.Name)} to ${zone.Domain}${data?.Id != null ? ` (ID: ${data.Id})` : ""}.`, + ); +} + +/** + * Interactively build and add one record to a zone: pick a type, gather its + * fields (with the Scriptable DNS option for A/AAAA/CNAME/TXT), then write it. + * Reused by the records `add` wizard and the `zones add` post-scan menu. + */ +export async function addRecordInteractive(opts: { + client: CoreClient; + config: ReturnType; + verbose: boolean; + zone: DnsZoneModel; + output: string; + name?: string; + ttl?: number; + comment?: string; +}): Promise { + const { client, config, verbose, zone, output } = opts; + + const { typeValue } = await prompts({ + type: "select", + name: "typeValue", + message: "Record type:", + choices: RECORD_TYPE_META.map((m) => ({ + title: m.group === "Bunny" ? `${m.name} ${m.group}` : m.name, + value: m.value, + })), + }); + const type = required(typeValue, "Record type"); + + let name = opts.name; + if (name === undefined) { + const res = await prompts({ + type: "text", + name: "name", + message: "Record name ('@' for apex):", + initial: "@", + }); + name = res.name ?? "@"; + } + const recName = name ?? "@"; + + // Offer a script-computed answer for types a DNS script can return. + const SCRIPTABLE_ANSWERS = new Set([ + RECORD_TYPES.A, + RECORD_TYPES.AAAA, + RECORD_TYPES.CNAME, + RECORD_TYPES.TXT, + ]); + let useScript = type === RECORD_TYPES.SCRIPT; + let starterKind: AnswerKind | undefined; + if (!useScript && SCRIPTABLE_ANSWERS.has(type)) { + const { source } = await prompts({ + type: "select", + name: "source", + message: "Value source:", + choices: [ + { title: "Static value", value: "static" }, + { title: "Computed by a script (Scriptable DNS)", value: "script" }, + ], + }); + if (source === undefined) + throw new UserError("A value source is required."); + if (source === "script") { + useScript = true; + starterKind = recordTypeLabel(type) as AnswerKind; + } + } + + let record: AddDnsRecordModel; + if (useScript) { + const computeClient = createComputeClient(clientOptions(config, verbose)); + const picked = await pickOrCreateDnsScript(computeClient, { + starterKind, + defaultName: `${zone.Domain ?? "dns"}-script`, + }); + record = { + Type: RECORD_TYPES.SCRIPT, + Name: recName === "@" ? "" : recName, + ScriptId: picked.id, + }; + } else { + record = await promptRecord(type, recName); + } + + if (opts.ttl === undefined) { + const { ttl } = await prompts({ + type: "number", + name: "ttl", + message: "TTL (seconds, blank for default):", + }); + if (ttl !== undefined) record.Ttl = ttl; + } else { + record.Ttl = opts.ttl; + } + if (opts.comment !== undefined) record.Comment = opts.comment; + + await writeAndReport(client, zone, record, output); +} + export const dnsAddCommand = defineCommand({ command: "add [domain] [name] [type] [values..]", describe: "Add a DNS record to a zone (interactive when args are omitted).", @@ -237,7 +366,7 @@ export const dnsAddCommand = defineCommand({ .positional("values", { type: "string", array: true, - describe: "Record value(s) — see examples for per-type ordering", + describe: "Record value(s); see examples for per-type ordering", }) .option("ttl", { type: "number", describe: "Time to live in seconds" }) .option("comment", { @@ -267,7 +396,6 @@ export const dnsAddCommand = defineCommand({ offerLink: true, }); - let record: AddDnsRecordModel; if (interactive) { // Offer a ready-made preset before falling back to building a single record by hand. const { mode } = await prompts({ @@ -288,117 +416,29 @@ export const dnsAddCommand = defineCommand({ return; } - const { typeValue } = await prompts({ - type: "select", - name: "typeValue", - message: "Record type:", - choices: Object.values(RECORD_TYPES).map((value) => ({ - title: recordTypeLabel(value), - value, - })), - }); - const type = required(typeValue, "Record type"); - - let name = args.name; - if (name === undefined) { - const res = await prompts({ - type: "text", - name: "name", - message: "Record name ('@' for apex):", - initial: "@", - }); - name = res.name ?? "@"; - } - - const recName = name ?? "@"; - - // Offer a script-computed answer for types a DNS script can return. - const SCRIPTABLE_ANSWERS = new Set([ - RECORD_TYPES.A, - RECORD_TYPES.AAAA, - RECORD_TYPES.CNAME, - RECORD_TYPES.TXT, - ]); - let useScript = type === RECORD_TYPES.SCRIPT; - let starterKind: AnswerKind | undefined; - if (!useScript && SCRIPTABLE_ANSWERS.has(type)) { - const { source } = await prompts({ - type: "select", - name: "source", - message: "Value source:", - choices: [ - { title: "Static value", value: "static" }, - { - title: "Computed by a script (Scriptable DNS)", - value: "script", - }, - ], - }); - if (source === undefined) - throw new UserError("A value source is required."); - if (source === "script") { - useScript = true; - starterKind = recordTypeLabel(type) as AnswerKind; - } - } - - if (useScript) { - const computeClient = createComputeClient( - clientOptions(config, verbose), - ); - const picked = await pickOrCreateDnsScript(computeClient, { - starterKind, - defaultName: `${zone.Domain ?? "dns"}-script`, - }); - record = { - Type: RECORD_TYPES.SCRIPT, - Name: recName === "@" ? "" : recName, - ScriptId: picked.id, - }; - } else { - record = await promptRecord(type, recName); - } - - if (args.ttl === undefined) { - const { ttl } = await prompts({ - type: "number", - name: "ttl", - message: "TTL (seconds, blank for default):", - }); - if (ttl !== undefined) record.Ttl = ttl; - } - } else { - const type = parseRecordType(args.type as string); - const name = args.name ?? "@"; - const values = (args.values ?? []).map((v) => String(v)); - record = buildRecord(type, name, values, { - PullZoneId: args["pull-zone"], - ScriptId: args.script, + await addRecordInteractive({ + client, + config, + verbose, + zone, + output, + name: args.name, + ttl: args.ttl, + comment: args.comment, }); + return; } + const type = parseRecordType(args.type as string); + const name = args.name ?? "@"; + const values = (args.values ?? []).map((v) => String(v)); + const record = buildRecord(type, name, values, { + PullZoneId: args["pull-zone"], + ScriptId: args.script, + }); if (args.ttl !== undefined) record.Ttl = args.ttl; if (args.comment !== undefined) record.Comment = args.comment; - const spin = spinner("Adding record..."); - spin.start(); - let data: { Id?: number } | undefined; - try { - ({ data } = await client.PUT("/dnszone/{zoneId}/records", { - params: { path: { zoneId: zone.Id as number } }, - body: record, - })); - } finally { - spin.stop(); - } - - if (output === "json") { - logger.log(JSON.stringify(data, null, 2)); - return; - } - - logger.success( - `Added ${recordTypeLabel(record.Type as number)} record ${recordName(record.Name)} to ${zone.Domain}${data?.Id != null ? ` (ID: ${data.Id})` : ""}.`, - ); + await writeAndReport(client, zone, record, output); }, }); diff --git a/packages/cli/src/commands/dns/record/import.ts b/packages/cli/src/commands/dns/record/import.ts index ceea30a3..ecf17c4d 100644 --- a/packages/cli/src/commands/dns/record/import.ts +++ b/packages/cli/src/commands/dns/record/import.ts @@ -6,6 +6,7 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { spinner } from "../../../core/ui.ts"; +import type { CoreClient, DnsZoneModel } from "../api.ts"; import { resolveZoneInteractive } from "../interactive.ts"; interface ImportArgs { @@ -13,6 +14,64 @@ interface ImportArgs { file?: string; } +/** Read a BIND zone file (prompting for the path when omitted) and import it into a zone. */ +export async function importZoneFile(opts: { + client: CoreClient; + zone: DnsZoneModel; + output: string; + file?: string; +}): Promise { + const { client, zone, output } = opts; + let path = opts.file; + if (!path) { + const res = await prompts({ + type: "text", + name: "file", + message: "Path to the BIND zone file:", + }); + path = res.file; + } + if (!path) throw new UserError("A zone file path is required."); + + const handle = Bun.file(path); + if (!(await handle.exists())) { + throw new UserError(`File not found: ${path}`); + } + const contents = await handle.text(); + + const spin = spinner("Importing records..."); + spin.start(); + let data: unknown; + try { + // The import endpoint takes the raw zone file as its body, which the generated spec doesn't model. + ({ data } = await client.POST("/dnszone/{zoneId}/import", { + params: { path: { zoneId: zone.Id as number } }, + body: contents as never, + bodySerializer: (body: string) => body, + headers: { "Content-Type": "text/plain" }, + } as never)); + } finally { + spin.stop(); + } + + const result = data as + | { + RecordsSuccessful?: number; + RecordsFailed?: number; + RecordsSkipped?: number; + } + | undefined; + + if (output === "json") { + logger.log(JSON.stringify(result ?? {}, null, 2)); + return; + } + + logger.success( + `Imported into ${zone.Domain}: ${result?.RecordsSuccessful ?? 0} added, ${result?.RecordsSkipped ?? 0} skipped, ${result?.RecordsFailed ?? 0} failed.`, + ); +} + export const dnsImportCommand = defineCommand({ command: "import [domain] [file]", describe: "Import DNS records into a zone from a BIND zone file.", @@ -40,55 +99,6 @@ export const dnsImportCommand = defineCommand({ offerLink: true, }); - let path = file; - if (!path) { - const res = await prompts({ - type: "text", - name: "file", - message: "Path to the BIND zone file:", - }); - path = res.file; - } - if (!path) throw new UserError("A zone file path is required."); - - const handle = Bun.file(path); - if (!(await handle.exists())) { - throw new UserError(`File not found: ${path}`); - } - const contents = await handle.text(); - - const spin = spinner("Importing records..."); - spin.start(); - - let data: unknown; - try { - // The import endpoint takes the raw zone file as the request body, - // which the generated spec does not model — pass it through directly. - ({ data } = await client.POST("/dnszone/{zoneId}/import", { - params: { path: { zoneId: zone.Id as number } }, - body: contents as never, - bodySerializer: (body: string) => body, - headers: { "Content-Type": "text/plain" }, - } as never)); - } finally { - spin.stop(); - } - - const result = data as - | { - RecordsSuccessful?: number; - RecordsFailed?: number; - RecordsSkipped?: number; - } - | undefined; - - if (output === "json") { - logger.log(JSON.stringify(result ?? {}, null, 2)); - return; - } - - logger.success( - `Imported into ${zone.Domain}: ${result?.RecordsSuccessful ?? 0} added, ${result?.RecordsSkipped ?? 0} skipped, ${result?.RecordsFailed ?? 0} failed.`, - ); + await importZoneFile({ client, zone, output, file }); }, }); diff --git a/packages/cli/src/commands/dns/record/index.ts b/packages/cli/src/commands/dns/record/index.ts index 15fb51ed..58f74ee0 100644 --- a/packages/cli/src/commands/dns/record/index.ts +++ b/packages/cli/src/commands/dns/record/index.ts @@ -5,6 +5,7 @@ import { dnsImportCommand } from "./import.ts"; import { dnsRecordListCommand } from "./list.ts"; import { dnsPresetCommand } from "./preset.ts"; import { dnsRemoveCommand } from "./remove.ts"; +import { dnsScanCommand } from "./scan.ts"; import { dnsUpdateCommand } from "./update.ts"; export const dnsRecordNamespace = defineNamespace( @@ -16,6 +17,7 @@ export const dnsRecordNamespace = defineNamespace( dnsUpdateCommand, dnsRemoveCommand, dnsPresetCommand, + dnsScanCommand, dnsImportCommand, dnsExportCommand, ], diff --git a/packages/cli/src/commands/dns/record/preset.ts b/packages/cli/src/commands/dns/record/preset.ts index a4809d4c..cd683aee 100644 --- a/packages/cli/src/commands/dns/record/preset.ts +++ b/packages/cli/src/commands/dns/record/preset.ts @@ -6,11 +6,10 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; import type { CoreClient, DnsZoneModel } from "../api.ts"; import { resolveZoneInteractive } from "../interactive.ts"; -import { recordName, recordTypeLabel } from "../record-types.ts"; import { type DnsPreset, findPreset, PRESETS } from "./presets.ts"; +import { reviewAndApply } from "./write.ts"; interface PresetArgs { name?: string; @@ -90,70 +89,23 @@ export async function applyPreset(opts: { }): Promise { const { client, zone, preset, output, provided = {} } = opts; // Only plain text drives the interactive prompts; json/csv/... must come fully specified. - const interactive = output === "text"; - const params = await gatherParams(preset, provided, interactive); + const params = await gatherParams(preset, provided, output === "text"); const records = preset.build({ domain: zone.Domain ?? "", params }); if (records.length === 0) { throw new UserError("This preset produced no records to add."); } - if (interactive) { - logger.log(`This will add ${records.length} record(s) to ${zone.Domain}:`); - logger.log(""); - logger.log( - formatTable( - ["Type", "Name", "Value", "Priority"], - records.map((r) => [ - recordTypeLabel(r.Type), - recordName(r.Name), - r.Value ?? "", - r.Priority != null ? String(r.Priority) : "", - ]), - "text", - ), - ); - logger.log(""); - - if ( - !(await confirm(`Add these ${records.length} record(s)?`, { - initial: true, - })) - ) { - logger.info("Cancelled."); - return; - } - } - - const spin = interactive ? spinner(`Applying ${preset.title}...`) : null; - spin?.start(); - let applied = 0; - try { - for (const record of records) { - await client.PUT("/dnszone/{zoneId}/records", { - params: { path: { zoneId: zone.Id as number } }, - body: record, - }); - applied++; - } - } catch (err) { - // Records are written one by one with no rollback; tell the user how far it got so they can inspect the zone. - throw new UserError( - `Applied ${applied} of ${records.length} record(s) before failing; ${zone.Domain} may be partially configured.`, - err instanceof Error ? err.message : String(err), - ); - } finally { - spin?.stop(); - } - - if (output === "json") { - logger.log(JSON.stringify(records, null, 2)); - return; - } - - logger.success( - `Applied ${preset.title} to ${zone.Domain}: ${records.length} record(s) added.`, - ); + await reviewAndApply({ + client, + zone, + records, + output, + selectMessage: `Select records to add to ${zone.Domain}:`, + spinnerLabel: `Applying ${preset.title}...`, + successFor: (n) => + `Applied ${preset.title} to ${zone.Domain}: ${n} record(s) added.`, + }); } /** Interactive picker grouped by category; returns undefined if the user aborts. */ diff --git a/packages/cli/src/commands/dns/record/scan-records.test.ts b/packages/cli/src/commands/dns/record/scan-records.test.ts new file mode 100644 index 00000000..150c5baf --- /dev/null +++ b/packages/cli/src/commands/dns/record/scan-records.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, mock, test } from "bun:test"; +import { RECORD_TYPES } from "../../../core/dns-record-types.ts"; +import type { DnsDiscoveredRecord } from "../api.ts"; + +let discovered: DnsDiscoveredRecord[] = []; +mock.module("../api.ts", () => ({ + scanZoneRecords: async () => discovered, +})); + +const { discoverImportableRecords } = await import("./scan-records.ts"); + +const SOA = 16; +const zone = { + Id: 1, + Domain: "example.com", + Records: [{ Type: RECORD_TYPES.A, Name: "dup", Value: "1.2.3.4" }], +} as never; + +describe("discoverImportableRecords", () => { + test("maps apex, drops SOA/NS, and skips records that already exist", async () => { + discovered = [ + { Type: RECORD_TYPES.A, Name: "@", Value: "1.1.1.1", Ttl: 300 }, + { Type: RECORD_TYPES.CNAME, Name: "www", Value: "example.com" }, + { + Type: RECORD_TYPES.MX, + Name: "@", + Value: "mail.example.com", + Priority: 10, + }, + { Type: SOA, Name: "@", Value: "ns root soa" }, + { Type: RECORD_TYPES.NS, Name: "@", Value: "ns1.other.com" }, + { Type: RECORD_TYPES.A, Name: "dup", Value: "1.2.3.4" }, + ]; + + const records = await discoverImportableRecords({} as never, zone); + + // SOA, NS, and the already-present "dup" record are filtered out. + expect(records).toHaveLength(3); + // Apex "@" is rewritten to the API's empty name. + const apex = records.find((r) => r.Type === RECORD_TYPES.A); + expect(apex?.Name).toBe(""); + expect(apex?.Ttl).toBe(300); + expect(records.find((r) => r.Type === RECORD_TYPES.MX)?.Priority).toBe(10); + expect(records.some((r) => r.Type === RECORD_TYPES.NS)).toBe(false); + expect(records.some((r) => r.Value === "ns root soa")).toBe(false); + expect(records.some((r) => r.Name === "dup")).toBe(false); + }); + + test("passes through CAA Flags/Tag the scan already broke out", async () => { + discovered = [ + { + Type: RECORD_TYPES.CAA, + Name: "@", + Value: "letsencrypt.org", + Flags: 0, + Tag: "issue", + }, + ]; + const [caa] = await discoverImportableRecords({} as never, zone); + expect(caa?.Flags).toBe(0); + expect(caa?.Tag).toBe("issue"); + expect(caa?.Value).toBe("letsencrypt.org"); + }); + + test("reconstructs CAA Flags/Tag from rdata when the scan didn't break them out", async () => { + discovered = [ + { Type: RECORD_TYPES.CAA, Name: "@", Value: '0 issue "letsencrypt.org"' }, + ]; + const [caa] = await discoverImportableRecords({} as never, zone); + expect(caa?.Flags).toBe(0); + expect(caa?.Tag).toBe("issue"); + expect(caa?.Value).toBe("letsencrypt.org"); + }); + + test("keeps delegated subdomain NS records but drops the apex NS", async () => { + discovered = [ + { Type: RECORD_TYPES.NS, Name: "@", Value: "kiki.bunny.net" }, + { Type: RECORD_TYPES.NS, Name: "dev", Value: "ns1.vendor.com" }, + ]; + const records = await discoverImportableRecords({} as never, zone); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + Type: RECORD_TYPES.NS, + Name: "dev", + Value: "ns1.vendor.com", + }); + }); + + test("treats records differing only by a type-specific field as distinct", async () => { + const mxZone = { + Id: 1, + Domain: "example.com", + Records: [ + { + Type: RECORD_TYPES.MX, + Name: "", + Value: "mail.example.com", + Priority: 10, + }, + ], + } as never; + discovered = [ + { + Type: RECORD_TYPES.MX, + Name: "@", + Value: "mail.example.com", + Priority: 10, + }, + { + Type: RECORD_TYPES.MX, + Name: "@", + Value: "mail.example.com", + Priority: 20, + }, + ]; + const records = await discoverImportableRecords({} as never, mxZone); + expect(records).toHaveLength(1); + expect(records[0]?.Priority).toBe(20); + }); + + test("dedupes values that differ only by a trailing dot", async () => { + const cnameZone = { + Id: 1, + Domain: "example.com", + Records: [ + { Type: RECORD_TYPES.CNAME, Name: "www", Value: "example.com" }, + ], + } as never; + discovered = [ + { Type: RECORD_TYPES.CNAME, Name: "www", Value: "example.com." }, + ]; + expect( + await discoverImportableRecords({} as never, cnameZone), + ).toHaveLength(0); + }); + + test("reconstructs CAA records with non-standard tags from rdata", async () => { + discovered = [ + { Type: RECORD_TYPES.CAA, Name: "@", Value: '0 issuevmc "example.com"' }, + ]; + const [caa] = await discoverImportableRecords({} as never, zone); + expect(caa?.Tag).toBe("issuevmc"); + expect(caa?.Value).toBe("example.com"); + }); + + test("returns nothing when the scan only finds existing/zone-managed records", async () => { + discovered = [ + { Type: RECORD_TYPES.NS, Name: "@", Value: "ns1.other.com" }, + { Type: SOA, Name: "@", Value: "soa" }, + { Type: RECORD_TYPES.A, Name: "dup", Value: "1.2.3.4" }, + ]; + expect(await discoverImportableRecords({} as never, zone)).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/commands/dns/record/scan-records.ts b/packages/cli/src/commands/dns/record/scan-records.ts new file mode 100644 index 00000000..60834c96 --- /dev/null +++ b/packages/cli/src/commands/dns/record/scan-records.ts @@ -0,0 +1,90 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { RECORD_TYPES } from "../../../core/dns-record-types.ts"; +import { type CoreClient, type DnsZoneModel, scanZoneRecords } from "../api.ts"; + +type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; +type DnsRecordTypes = components["schemas"]["DnsRecordTypes"]; + +const SOA_TYPE = 16; + +// Fallback for a CAA whose flags/tag weren't broken out: split the rdata ("0 issue letsencrypt.org"). +const CAA_RDATA = /^\s*(\d{1,3})\s+([A-Za-z0-9]+)\s+"?(.*?)"?\s*$/; +function caaFields( + value: string, +): Pick | null { + const match = value.match(CAA_RDATA); + if (!match) return null; + const [, flags = "0", tag = "issue", caaValue = ""] = match; + return { Flags: Number(flags), Tag: tag.toLowerCase(), Value: caaValue }; +} + +function normalizeName(name: string | null | undefined): string { + return (name ?? "").replace(/^@$/, "").toLowerCase().replace(/\.$/, ""); +} + +/** The zone manages its own apex SOA and apex NS delegation; subdomain NS are real child delegations. */ +function isZoneManaged(type: number, name: string): boolean { + if (type === SOA_TYPE) return true; + return type === RECORD_TYPES.NS && normalizeName(name) === ""; +} + +/** A comparable key so an already-present record isn't offered again. */ +function recordKey(r: { + Type?: number | null; + Name?: string | null; + Value?: string | null; + Priority?: number | null; + Weight?: number | null; + Port?: number | null; + Flags?: number | null; + Tag?: string | null; +}): string { + const name = normalizeName(r.Name); + const value = (r.Value ?? "").trim().toLowerCase().replace(/\.$/, ""); + // Type-specific fields keep otherwise-identical MX/SRV/CAA records distinct. + const extra = [ + r.Priority ?? "", + r.Weight ?? "", + r.Port ?? "", + r.Flags ?? "", + (r.Tag ?? "").toLowerCase(), + ].join(":"); + return `${r.Type ?? ""}|${name}|${value}|${extra}`; +} + +/** Scan a zone for pre-existing records and return the ones worth importing. */ +export async function discoverImportableRecords( + client: CoreClient, + zone: DnsZoneModel, +): Promise { + const discovered = await scanZoneRecords(client, zone.Id as number); + const existing = new Set((zone.Records ?? []).map(recordKey)); + const out: AddDnsRecordModel[] = []; + for (const d of discovered) { + const name = d.Name === "@" ? "" : (d.Name ?? ""); + if (d.Type == null || isZoneManaged(d.Type, name)) continue; + const record: AddDnsRecordModel = { + Type: d.Type as DnsRecordTypes, + Name: name, + Value: d.Value ?? "", + Ttl: d.Ttl ?? undefined, + Priority: d.Priority ?? undefined, + Weight: d.Weight ?? undefined, + Port: d.Port ?? undefined, + Flags: d.Flags ?? undefined, + Tag: d.Tag ?? undefined, + }; + // The scan carries Flags/Tag for CAA; only reconstruct from the rdata when it didn't. + if ( + record.Type === RECORD_TYPES.CAA && + record.Tag == null && + record.Value + ) { + const caa = caaFields(record.Value); + if (caa) Object.assign(record, caa); + } + if (existing.has(recordKey(record))) continue; + out.push(record); + } + return out; +} diff --git a/packages/cli/src/commands/dns/record/scan.ts b/packages/cli/src/commands/dns/record/scan.ts new file mode 100644 index 00000000..24243f8c --- /dev/null +++ b/packages/cli/src/commands/dns/record/scan.ts @@ -0,0 +1,77 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +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 { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; +import { discoverImportableRecords } from "./scan-records.ts"; +import { reviewAndApply } from "./write.ts"; + +type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; + +interface ScanArgs { + domain?: string; + yes?: boolean; +} + +export const dnsScanCommand = defineCommand({ + command: "scan [domain]", + describe: "Scan for a domain's existing DNS records and import them.", + examples: [ + ["$0 dns records scan example.com", "Discover and import existing records"], + ["$0 dns records scan example.com --yes", "Import without confirming"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("yes", { + type: "boolean", + alias: "y", + describe: "Skip the import confirmation", + }), + + handler: async ({ domain, yes, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain, { + output, + offerLink: true, + }); + + const spin = + output === "text" + ? spinner("Scanning for existing DNS records...") + : null; + spin?.start(); + let records: AddDnsRecordModel[]; + try { + records = await discoverImportableRecords(client, zone); + } finally { + spin?.stop(); + } + + if (records.length === 0) { + if (output === "json") { + logger.log(JSON.stringify({ applied: [], failures: [] }, null, 2)); + return; + } + logger.info(`No new records discovered for ${zone.Domain}.`); + return; + } + + await reviewAndApply({ + client, + zone, + records, + output, + selectMessage: `Found ${records.length} existing record(s) for ${zone.Domain}. Select which to import:`, + spinnerLabel: "Importing records...", + successFor: (n) => `Imported ${n} record(s) into ${zone.Domain}.`, + assumeYes: yes, + }); + }, +}); diff --git a/packages/cli/src/commands/dns/record/write.test.ts b/packages/cli/src/commands/dns/record/write.test.ts new file mode 100644 index 00000000..0323c0f5 --- /dev/null +++ b/packages/cli/src/commands/dns/record/write.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { reviewAndApply, writeRecords } from "./write.ts"; + +const zone = { Id: 1, Domain: "example.com" } as never; + +describe("writeRecords", () => { + test("keeps going after a failure and reports both sides", async () => { + const client = { + PUT: async (_path: string, opts: { body: { Value?: string | null } }) => { + if (opts.body.Value === "bad") + throw new Error("A tag can be a maximum of 50 characters."); + return {}; + }, + } as never; + + const result = await writeRecords(client, zone, [ + { Value: "ok-1" }, + { Value: "bad" }, + { Value: "ok-2" }, + ] as never); + + expect(result.applied).toHaveLength(2); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.message).toContain("maximum of 50 characters"); + expect(result.failures[0]?.record.Value).toBe("bad"); + }); +}); + +describe("reviewAndApply", () => { + test("a json run throws (nonzero exit) when every record fails", async () => { + const client = { + PUT: async () => { + throw new Error("boom"); + }, + } as never; + + await expect( + reviewAndApply({ + client, + zone, + records: [{ Value: "x" }] as never, + output: "json", + selectMessage: "", + spinnerLabel: "", + successFor: () => "", + }), + ).rejects.toThrow(/None of the/); + }); +}); diff --git a/packages/cli/src/commands/dns/record/write.ts b/packages/cli/src/commands/dns/record/write.ts new file mode 100644 index 00000000..9325095e --- /dev/null +++ b/packages/cli/src/commands/dns/record/write.ts @@ -0,0 +1,139 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import prompts from "prompts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import type { CoreClient, DnsZoneModel } from "../api.ts"; +import { recordName, recordTypeLabel } from "../record-types.ts"; + +type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; + +export interface RecordWriteFailure { + record: AddDnsRecordModel; + message: string; +} +export interface WriteRecordsResult { + applied: AddDnsRecordModel[]; + failures: RecordWriteFailure[]; +} + +function describeRecord(r: AddDnsRecordModel): string { + const head = `${recordTypeLabel(r.Type)} ${recordName(r.Name) || "@"}`; + const value = r.Value ? ` ${r.Value}` : ""; + const priority = r.Priority != null ? ` (priority ${r.Priority})` : ""; + return `${head}${value}${priority}`; +} + +/** + * Write records one at a time, recording each outcome. A failed record never + * aborts the rest: callers get every success and every failure so one bad + * record can't strand a migration half-applied. + */ +export async function writeRecords( + client: CoreClient, + zone: DnsZoneModel, + records: AddDnsRecordModel[], +): Promise { + const applied: AddDnsRecordModel[] = []; + const failures: RecordWriteFailure[] = []; + for (const record of records) { + try { + await client.PUT("/dnszone/{zoneId}/records", { + params: { path: { zoneId: zone.Id as number } }, + body: record, + }); + applied.push(record); + } catch (err) { + failures.push({ + record, + message: err instanceof Error ? err.message : String(err), + }); + } + } + return { applied, failures }; +} + +/** + * Let the user pick which records to write (text mode; all pre-selected), + * write them, then report. json serializes the applied/failed split; partial + * failures warn per record; a total failure throws. + */ +export async function reviewAndApply(opts: { + client: CoreClient; + zone: DnsZoneModel; + records: AddDnsRecordModel[]; + output: string; + selectMessage: string; + spinnerLabel: string; + successFor: (added: number) => string; + assumeYes?: boolean; +}): Promise { + const { client, zone, output } = opts; + const interactive = output === "text"; + + let chosen = opts.records; + if (interactive && !opts.assumeYes) { + const { picked } = await prompts({ + type: "multiselect", + name: "picked", + message: opts.selectMessage, + choices: opts.records.map((r, i) => ({ + title: describeRecord(r), + value: i, + selected: true, + })), + hint: "Space to toggle, A to toggle all, Enter to confirm", + instructions: false, + }); + if (!picked || picked.length === 0) { + logger.info("Nothing selected."); + return; + } + chosen = (picked as number[]) + .map((i) => opts.records[i]) + .filter((r): r is AddDnsRecordModel => r != null); + } + + const spin = interactive ? spinner(opts.spinnerLabel) : null; + spin?.start(); + let result: WriteRecordsResult; + try { + result = await writeRecords(client, zone, chosen); + } finally { + spin?.stop(); + } + const { applied, failures } = result; + const allFailed = applied.length === 0 && failures.length > 0; + + if (output === "json") { + logger.log( + JSON.stringify( + { + applied, + failures: failures.map((f) => ({ + record: f.record, + error: f.message, + })), + }, + null, + 2, + ), + ); + } else { + if (applied.length > 0) logger.success(opts.successFor(applied.length)); + if (failures.length > 0) { + logger.warn(`${failures.length} record(s) couldn't be added:`); + for (const f of failures) { + logger.warn(` ${describeRecord(f.record)}: ${f.message}`); + } + } + } + + // Exit nonzero (in both text and json) when nothing could be written. + if (allFailed) { + throw new UserError( + `None of the ${failures.length} record(s) could be added to ${zone.Domain}.`, + failures[0]?.message, + ); + } +} diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts index e1368494..dec76d77 100644 --- a/packages/cli/src/commands/dns/zone/add.ts +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -1,4 +1,5 @@ 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"; @@ -6,28 +7,98 @@ import { checkDelegation, expectedNameservers, } from "../../../core/dns-nameservers.ts"; +import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { detectRegistrar } from "../../../core/registrar.ts"; import { spinner } from "../../../core/ui.ts"; -import type { DnsZoneModel } from "../api.ts"; +import type { CoreClient, DnsZoneModel } from "../api.ts"; +import { addRecordInteractive } from "../record/add.ts"; +import { importZoneFile } from "../record/import.ts"; +import { discoverImportableRecords } from "../record/scan-records.ts"; +import { reviewAndApply, writeRecords } from "../record/write.ts"; interface ZoneAddArgs { domain: string; + import?: boolean; +} + +/** After the scan, let the user keep populating the zone until they continue to nameserver setup. */ +async function offerNextSteps(opts: { + client: CoreClient; + config: ReturnType; + verbose: boolean; + zone: DnsZoneModel; + output: string; +}): Promise { + for (;;) { + const { next } = await prompts({ + type: "select", + name: "next", + message: "What next?", + choices: [ + { 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") { + await importZoneFile({ + client: opts.client, + zone: opts.zone, + output: opts.output, + }); + } else if (next === "manual") { + await addRecordInteractive({ + client: opts.client, + config: opts.config, + verbose: opts.verbose, + zone: opts.zone, + output: opts.output, + }); + } + } catch (err) { + // Keep the menu alive when a sub-step is cancelled or fails validation. + if (err instanceof UserError) logger.warn(err.message); + else throw err; + } + logger.log(""); + } } export const dnsZoneAddCommand = defineCommand({ command: "add ", describe: "Create a new DNS zone.", - examples: [["$0 dns zones add example.com", "Create a zone for example.com"]], + examples: [ + ["$0 dns zones add example.com", "Create a zone for example.com"], + [ + "$0 dns zones add example.com --import", + "Create the zone and import existing records without prompting", + ], + ], builder: (yargs) => - yargs.positional("domain", { - type: "string", - describe: "Domain to create the zone for", - demandOption: true, - }), + yargs + .positional("domain", { + type: "string", + describe: "Domain to create the zone for", + demandOption: true, + }) + .option("import", { + type: "boolean", + describe: + "Import all scanned records without prompting (--no-import skips the scan and next-steps menu)", + }), - handler: async ({ domain, profile, output, verbose, apiKey }) => { + handler: async ({ + domain, + import: doImport, + profile, + output, + verbose, + apiKey, + }) => { const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); @@ -52,7 +123,48 @@ export const dnsZoneAddCommand = defineCommand({ } if (output === "json") { - logger.log(JSON.stringify(created ?? { Domain: domain }, null, 2)); + let importedRecords: number | undefined; + let failedRecords: number | undefined; + let importError: string | undefined; + if (doImport === true && created?.Id != null) { + try { + const records = await discoverImportableRecords(client, created); + const { applied, failures } = records.length + ? await writeRecords(client, created, records) + : { applied: [], failures: [] }; + importedRecords = applied.length; + failedRecords = failures.length; + } catch (err) { + importError = err instanceof Error ? err.message : String(err); + } + } + logger.log( + JSON.stringify( + { + ...(created ?? { Domain: domain }), + ...(importedRecords != null + ? { ImportedRecords: importedRecords } + : {}), + ...(failedRecords ? { FailedRecords: failedRecords } : {}), + ...(importError ? { ImportError: importError } : {}), + }, + null, + 2, + ), + ); + // --import is an explicit migration action; fail loudly when it couldn't run. + if (importError) { + throw new UserError( + `Importing records into ${domain} failed.`, + importError, + ); + } + + if (importedRecords === 0 && failedRecords) { + throw new UserError( + `Importing records into ${domain} failed: none of the ${failedRecords} record(s) could be added.`, + ); + } return; } @@ -62,6 +174,51 @@ export const dnsZoneAddCommand = defineCommand({ : `Created DNS zone ${domain}.`, ); + // Default scan+menu only run with a TTY so `zones add ` stays scriptable; --import forces it. + const interactive = Boolean(process.stdin.isTTY); + + if ( + created?.Id != null && + (doImport === true || (doImport === undefined && interactive)) + ) { + let discovered: Awaited> = + []; + 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}.`); + } + } + + if (created?.Id != null && doImport === undefined && interactive) { + await offerNextSteps({ client, config, verbose, zone: created, output }); + } + // Savvy users often point the registrar at bunny before creating the zone; skip the setup steps when it's already delegated. const checkSpin = spinner("Checking nameserver delegation..."); checkSpin.start(); diff --git a/packages/cli/src/core/dns-record-types.ts b/packages/cli/src/core/dns-record-types.ts index c7816f0f..6311440d 100644 --- a/packages/cli/src/core/dns-record-types.ts +++ b/packages/cli/src/core/dns-record-types.ts @@ -22,11 +22,87 @@ export const RECORD_TYPES = { TLSA: 15, } as const satisfies Record; -const TYPE_LABELS: Record = Object.fromEntries( - Object.entries(RECORD_TYPES).map(([name, value]) => [value, name]), +export type RecordTypeGroup = "Standard" | "Bunny"; + +interface RecordTypeMeta { + value: DnsRecordTypes; + /** Short label shown in tables (matches the bunny dashboard). */ + label: string; + /** Friendlier name shown in pickers. */ + name: string; + group: RecordTypeGroup; +} + +/** Per-type metadata mirroring the bunny dashboard taxonomy (Standard first, then Bunny). */ +export const RECORD_TYPE_META: RecordTypeMeta[] = [ + { value: RECORD_TYPES.A, label: "A", name: "A", group: "Standard" }, + { value: RECORD_TYPES.AAAA, label: "AAAA", name: "AAAA", group: "Standard" }, + { + value: RECORD_TYPES.CNAME, + label: "CNAME", + name: "CNAME", + group: "Standard", + }, + { value: RECORD_TYPES.TXT, label: "TXT", name: "TXT", group: "Standard" }, + { value: RECORD_TYPES.MX, label: "MX", name: "MX", group: "Standard" }, + { value: RECORD_TYPES.SRV, label: "SRV", name: "SRV", group: "Standard" }, + { value: RECORD_TYPES.CAA, label: "CAA", name: "CAA", group: "Standard" }, + { value: RECORD_TYPES.PTR, label: "PTR", name: "PTR", group: "Standard" }, + { value: RECORD_TYPES.NS, label: "NS", name: "NS", group: "Standard" }, + { value: RECORD_TYPES.SVCB, label: "SVCB", name: "SVCB", group: "Standard" }, + { + value: RECORD_TYPES.HTTPS, + label: "HTTPS", + name: "HTTPS", + group: "Standard", + }, + { value: RECORD_TYPES.TLSA, label: "TLSA", name: "TLSA", group: "Standard" }, + { + value: RECORD_TYPES.PULLZONE, + label: "PZ", + name: "Pull Zone (PZ)", + group: "Bunny", + }, + { + value: RECORD_TYPES.REDIRECT, + label: "RDR", + name: "Redirect (RDR)", + group: "Bunny", + }, + { + value: RECORD_TYPES.SCRIPT, + label: "SCR", + name: "Script (SCR)", + group: "Bunny", + }, + { + value: RECORD_TYPES.FLATTEN, + label: "Flatten", + name: "Flatten", + group: "Bunny", + }, +]; + +const LABEL_BY_VALUE = new Map( + RECORD_TYPE_META.map((m) => [m.value, m.label]), ); /** 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"; + return LABEL_BY_VALUE.get(type ?? -1) ?? "UNKNOWN"; } + +// Resolve from the canonical label (PZ, RDR, SCR, ...) or the spelled-out enum key (PULLZONE, ...). +const VALUE_BY_LABEL = new Map(); +for (const m of RECORD_TYPE_META) + VALUE_BY_LABEL.set(m.label.toUpperCase(), m.value); +for (const [name, value] of Object.entries(RECORD_TYPES)) + VALUE_BY_LABEL.set(name, value as DnsRecordTypes); + +/** Resolve a type label or enum-key name to its value, or undefined when unknown. */ +export function recordTypeFromLabel(label: string): DnsRecordTypes | undefined { + return VALUE_BY_LABEL.get(label.trim().toUpperCase()); +} + +/** Canonical labels in display order, for "valid types" hints. */ +export const RECORD_TYPE_LABELS = RECORD_TYPE_META.map((m) => m.label); diff --git a/packages/openapi-client/src/dns.ts b/packages/openapi-client/src/dns.ts new file mode 100644 index 00000000..3f4c8293 --- /dev/null +++ b/packages/openapi-client/src/dns.ts @@ -0,0 +1,45 @@ +import type { components } from "./generated/core.d.ts"; + +/** + * Hand-authored corrections for the DNS record-scan endpoints. The generator + * models a scan's `Records` as `DnsZoneDiscoveredRecordModel`, which is lossy: + * the runtime serializes the full record shape, so `Flags`/`Tag` (needed to + * recreate CAA records) are in the JSON but absent from the generated type. + * These types restore them and name the scan-status enum the spec leaves as a + * bare `0 | 1 | 2 | 3`. + */ + +/** Status of a background DNS record scan (spec: DnsZoneScanJobStatus). */ +export enum DnsRecordScanStatus { + Pending = 0, + InProgress = 1, + Completed = 2, + Failed = 3, +} + +/** + * A record returned by a scan: the generated discovered model plus the writable + * fields the runtime includes but the generator dropped (`Flags`/`Tag`). + */ +export type DnsDiscoveredRecord = + components["schemas"]["DnsZoneDiscoveredRecordModel"] & + Pick; + +/** A scan job result with the corrected `Records` and a named `Status`. */ +export interface DnsRecordScanJob + extends Omit< + components["schemas"]["DnsZoneRecordScanJobResponse"], + "Records" | "Status" + > { + Status?: DnsRecordScanStatus | null; + Records?: DnsDiscoveredRecord[] | null; +} + +/** The response from triggering a scan, with a named `Status`. */ +export interface DnsRecordScanTrigger + extends Omit< + components["schemas"]["DnsZoneRecordScanTriggerResponse"], + "Status" + > { + Status?: DnsRecordScanStatus | null; +} diff --git a/packages/openapi-client/src/index.ts b/packages/openapi-client/src/index.ts index f5d49985..bd32d5f0 100644 --- a/packages/openapi-client/src/index.ts +++ b/packages/openapi-client/src/index.ts @@ -1,6 +1,12 @@ export { createComputeClient } from "./compute-client.ts"; export { createCoreClient } from "./core-client.ts"; export { createDbClient } from "./db-client.ts"; +export type { + DnsDiscoveredRecord, + DnsRecordScanJob, + DnsRecordScanTrigger, +} from "./dns.ts"; +export { DnsRecordScanStatus } from "./dns.ts"; export { ApiError, UserError } from "./errors.ts"; export { createMcClient } from "./mc-client.ts"; export type { ClientOptions } from "./middleware.ts";