From 43bb65c20d7fc3c7a671e54394eaa4091d618165 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 16:05:06 +0100 Subject: [PATCH 1/5] first pass --- AGENTS.md | 49 +++ packages/cli/README.md | 74 ++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/dns/add.ts | 325 ++++++++++++++++++ packages/cli/src/commands/dns/api.ts | 64 ++++ packages/cli/src/commands/dns/export.ts | 73 ++++ packages/cli/src/commands/dns/import.ts | 88 +++++ packages/cli/src/commands/dns/index.ts | 26 ++ packages/cli/src/commands/dns/interactive.ts | 98 ++++++ packages/cli/src/commands/dns/list.ts | 104 ++++++ packages/cli/src/commands/dns/nameservers.ts | 85 +++++ .../cli/src/commands/dns/record-types.test.ts | 81 +++++ packages/cli/src/commands/dns/record-types.ts | 67 ++++ packages/cli/src/commands/dns/remove.ts | 78 +++++ packages/cli/src/commands/dns/stats.ts | 132 +++++++ packages/cli/src/commands/dns/update.ts | 136 ++++++++ packages/cli/src/commands/dns/zone/add.ts | 54 +++ .../src/commands/dns/zone/dnssec/disable.ts | 68 ++++ .../src/commands/dns/zone/dnssec/enable.ts | 64 ++++ .../cli/src/commands/dns/zone/dnssec/index.ts | 9 + packages/cli/src/commands/dns/zone/index.ts | 14 + .../src/commands/dns/zone/logging/disable.ts | 62 ++++ .../src/commands/dns/zone/logging/enable.ts | 83 +++++ .../src/commands/dns/zone/logging/index.ts | 9 + packages/cli/src/commands/dns/zone/remove.ts | 69 ++++ packages/cli/src/commands/dns/zone/show.ts | 66 ++++ .../openapi-client/src/middleware.test.ts | 22 ++ packages/openapi-client/src/middleware.ts | 19 +- 28 files changed, 2020 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/dns/add.ts create mode 100644 packages/cli/src/commands/dns/api.ts create mode 100644 packages/cli/src/commands/dns/export.ts create mode 100644 packages/cli/src/commands/dns/import.ts create mode 100644 packages/cli/src/commands/dns/index.ts create mode 100644 packages/cli/src/commands/dns/interactive.ts create mode 100644 packages/cli/src/commands/dns/list.ts create mode 100644 packages/cli/src/commands/dns/nameservers.ts create mode 100644 packages/cli/src/commands/dns/record-types.test.ts create mode 100644 packages/cli/src/commands/dns/record-types.ts create mode 100644 packages/cli/src/commands/dns/remove.ts create mode 100644 packages/cli/src/commands/dns/stats.ts create mode 100644 packages/cli/src/commands/dns/update.ts create mode 100644 packages/cli/src/commands/dns/zone/add.ts create mode 100644 packages/cli/src/commands/dns/zone/dnssec/disable.ts create mode 100644 packages/cli/src/commands/dns/zone/dnssec/enable.ts create mode 100644 packages/cli/src/commands/dns/zone/dnssec/index.ts create mode 100644 packages/cli/src/commands/dns/zone/index.ts create mode 100644 packages/cli/src/commands/dns/zone/logging/disable.ts create mode 100644 packages/cli/src/commands/dns/zone/logging/enable.ts create mode 100644 packages/cli/src/commands/dns/zone/logging/index.ts create mode 100644 packages/cli/src/commands/dns/zone/remove.ts create mode 100644 packages/cli/src/commands/dns/zone/show.ts diff --git a/AGENTS.md b/AGENTS.md index 6ea5301..af5ca79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,32 @@ 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 +│ │ │ ├── index.ts # defineNamespace("dns", ...) — registers all DNS commands +│ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone) +│ │ │ ├── interactive.ts # resolveZoneInteractive (zone picker) + resolveRecordInteractive (record picker) fallbacks +│ │ │ ├── record-types.ts # Record type name⇄integer map, parseRecordType, recordTypeLabel, formatRecordValue +│ │ │ ├── list.ts # List DNS zones, or records within a zone (alias: ls) +│ │ │ ├── add.ts # Add a DNS record (positional grammar per type, or interactive wizard; --pull-zone/--script) +│ │ │ ├── update.ts # Update a DNS record (alias: edit; prompts to pick zone+record when omitted) +│ │ │ ├── remove.ts # Remove a DNS record (alias: rm; prompts to pick zone+record when omitted) +│ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted) +│ │ │ ├── export.ts # Export a zone as a BIND zone file (stdout, --file , or --save → .zone) +│ │ │ ├── stats.ts # Show DNS query statistics for a zone (TotalQueriesServed, by-type breakdown) +│ │ │ ├── nameservers.ts # Show registrar nameservers (alias: ns; custom if set, else kiki/coco.bunny.net) +│ │ │ └── zone/ +│ │ │ ├── index.ts # defineNamespace("zone", ...) +│ │ │ ├── add.ts # Create a DNS zone +│ │ │ ├── show.ts # Show zone details (nameservers, SOA, DNSSEC, logging, record count) +│ │ │ ├── remove.ts # Delete a DNS zone and its records (alias: rm) +│ │ │ ├── dnssec/ +│ │ │ │ ├── index.ts # defineNamespace("dnssec", ...) +│ │ │ │ ├── enable.ts # Enable DNSSEC, print DS record for the registrar +│ │ │ │ └── disable.ts # Disable DNSSEC (with confirmation) +│ │ │ └── logging/ +│ │ │ ├── index.ts # defineNamespace("logging", ...) +│ │ │ ├── enable.ts # Enable DNS query logging (optional IP anonymization) +│ │ │ └── disable.ts # Disable DNS query logging (with confirmation) │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -794,6 +820,29 @@ bunny │ ├── update [--name] [--username] [--password] │ │ Update registry name and/or rotate credentials │ └── remove Remove registry +├── dns (experimental — hidden from help and landing page) +│ │ Every [domain] is optional — omit it to pick a zone interactively (resolveZoneInteractive) +│ ├── list [domain] (alias: ls) List DNS zones, or records within a zone +│ ├── add [domain] [name] [type] [values..] [--ttl] [--comment] [--pull-zone] [--script] +│ │ Add a DNS record (interactive wizard when args omitted; MX/SRV/CAA use positional values; PullZone/Script use --pull-zone/--script) +│ ├── update [domain] [id] [--name] [--value] [--type] [--ttl] [--priority] [--weight] [--port] [--flags] [--tag] [--comment] [--disabled] [--pull-zone] [--script] +│ │ 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) +│ ├── 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) +│ ├── stats [domain] [--from] [--to] Show DNS query statistics for a zone (defaults to last 30 days) +│ ├── nameservers [domain] (alias: ns) Show the nameservers to set at the registrar (custom if enabled, else bunny.net defaults) +│ └── zone +│ ├── add Create a DNS zone +│ ├── show [domain] Show zone details (nameservers, SOA, DNSSEC, logging, record count) +│ ├── remove [domain] [--force] Delete a DNS zone and its records (alias: rm) +│ ├── dnssec +│ │ ├── enable [domain] Enable DNSSEC and print the DS record for the registrar +│ │ └── disable [domain] [--force] Disable DNSSEC +│ └── logging +│ ├── enable [domain] [--anonymize-ip] [--anonymization onedigit|drop] +│ │ Enable DNS query logging +│ └── disable [domain] [--force] Disable DNS query logging ├── db │ ├── create [--name] [--primary] [--replicas] [--storage-region] │ │ Create a new database diff --git a/packages/cli/README.md b/packages/cli/README.md index e30c3aa..4f14b77 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -394,6 +394,80 @@ bunny registries add --name "GitHub" --username myorg bunny registries remove ``` +### `bunny dns` + +> **Experimental** — hidden from `--help` and the landing page while it stabilizes. + +Manage DNS zones and records. 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. `update` and `remove` likewise prompt you to pick a record when the ID is omitted. + +```bash +# List all DNS zones +bunny dns list +bunny dns ls + +# List records within a zone +bunny dns list example.com + +# Add records (use '@' for the zone apex) +bunny dns add example.com api A 198.51.100.1 +bunny dns add example.com '@' MX mail.example.com 10 +bunny dns add example.com '@' SRV 10 0 389 sip.example.com +bunny dns add example.com '@' CAA '0 issue "letsencrypt.org"' + +# Link a record to a pull zone or Edge Script +bunny dns add example.com cdn PullZone --pull-zone 12345 +bunny dns add example.com fn Script --script 67890 + +# Interactive wizard — omit the record type (or all args) to be prompted +bunny dns add +bunny dns add example.com + +# Update / remove a record by its ID +bunny dns update example.com 123 --value 198.51.100.2 --ttl 3600 +bunny dns remove example.com 123 + +# Import / export a BIND zone file +bunny dns import example.com ./zonefile.txt +bunny dns export example.com # print to stdout +bunny dns export example.com --file ./my.zone # write to a path +bunny dns export example.com --save # write to ./example.com.zone + +# Query statistics (defaults to the last 30 days) +bunny dns stats example.com +bunny dns stats example.com --from 2026-05-01 --to 2026-05-31 + +# Nameservers to set at your registrar (custom if enabled, else bunny.net defaults) +bunny dns nameservers example.com +bunny dns ns example.com + +# Manage zones +bunny dns zone add example.com +bunny dns zone show example.com +bunny dns zone remove example.com + +# DNSSEC — enable prints the DS record to register at your domain registrar +bunny dns zone dnssec enable example.com +bunny dns zone dnssec disable example.com + +# DNS query logging — enable to start collecting logs (optionally anonymize IPs) +bunny dns zone logging enable example.com +bunny dns zone logging enable example.com --anonymize-ip --anonymization drop +bunny dns zone logging disable example.com +``` + +Positional value ordering follows the record type: `A`/`AAAA`/`CNAME`/`TXT`/`NS` take a single value, `MX` takes ` `, `SRV` takes ` `, and `CAA` takes a single quoted `' ""'` string. `PullZone` and `Script` records take no positional value — pass `--pull-zone ` or `--script ` instead. Omit the record type (or all arguments) to run an interactive wizard that prompts for the zone, type, and per-type values. + +| Flag | Commands | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- | +| `--ttl` | `add`, `update` | Time to live in seconds | +| `--comment` | `add`, `update` | Optional comment for the record | +| `--pull-zone`, `--script` | `add`, `update` | Link a `PullZone` / `Script` record by ID | +| `--name`, `--value`, `--type`, `--priority`, `--weight`, `--port`, `--flags`, `--tag`, `--disabled` | `update` | Edit individual record fields (see `bunny dns update --help`) | +| `--file`, `--save` | `export` | Write to a path, or to `.zone` in the current directory | +| `--from`, `--to` | `stats` | Date range (defaults to the last 30 days) | +| `--anonymize-ip`, `--anonymization` | `zone logging enable` | Anonymize client IPs in logs (`onedigit` \| `drop`) | +| `--force` | `remove`, `zone remove`, `zone dnssec disable`, `zone logging disable` | Skip the confirmation prompt | + ### `bunny scripts` Manage Edge Scripts. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 16e0279..784bbdf 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,6 +8,7 @@ import { authLoginCommand } from "./commands/auth/login.ts"; import { authLogoutCommand } from "./commands/auth/logout.ts"; import { configNamespace } from "./commands/config/index.ts"; import { dbNamespace } from "./commands/db/index.ts"; +import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; @@ -33,6 +34,7 @@ const commands: CommandModule[] = [ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, + dnsNamespace, ]; let instance = yargs(hideBin(process.argv)) diff --git a/packages/cli/src/commands/dns/add.ts b/packages/cli/src/commands/dns/add.ts new file mode 100644 index 0000000..cdd04b4 --- /dev/null +++ b/packages/cli/src/commands/dns/add.ts @@ -0,0 +1,325 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import prompts from "prompts"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { resolveZoneInteractive } from "./interactive.ts"; +import { + parseRecordType, + RECORD_TYPES, + recordName, + recordTypeLabel, +} from "./record-types.ts"; + +type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; + +interface AddArgs { + domain?: string; + name?: string; + type?: string; + values?: string[]; + ttl?: number; + comment?: string; + "pull-zone"?: number; + script?: number; +} + +interface RecordOpts { + pullZoneId?: number; + scriptId?: number; +} + +/** Build the request body from positional values, honouring per-type grammar. */ +function buildRecord( + type: number, + name: string, + values: string[], + opts: RecordOpts, +): AddDnsRecordModel { + const record: AddDnsRecordModel = { + Type: type as never, + Name: name === "@" ? "" : name, + }; + + if (type === RECORD_TYPES.PULLZONE) { + if (opts.pullZoneId === undefined) + throw new UserError("PullZone records require --pull-zone ."); + record.PullZoneId = opts.pullZoneId; + return record; + } + + if (type === RECORD_TYPES.SCRIPT) { + if (opts.scriptId === undefined) + throw new UserError("Script records require --script ."); + record.ScriptId = opts.scriptId; + return record; + } + + if (type === RECORD_TYPES.MX) { + const [value, priority] = values; + if (!value) throw new UserError("MX records require ."); + record.Value = value; + record.Priority = Number(priority ?? 0); + return record; + } + + if (type === RECORD_TYPES.SRV) { + const [priority, weight, port, target] = values; + if (!target) + throw new UserError( + "SRV records require .", + ); + record.Priority = Number(priority ?? 0); + record.Weight = Number(weight ?? 0); + record.Port = Number(port ?? 0); + record.Value = target; + return record; + } + + if (type === RECORD_TYPES.CAA) { + let flags: string | undefined; + let tag: string | undefined; + let value: string | undefined; + if (values.length === 1) { + const match = values[0]?.match(/^(\d+)\s+(\S+)\s+"?([^"]*)"?$/); + if (!match) + throw new UserError( + "CAA value must be like: '0 issue \"example.com\"'.", + ); + [, flags, tag, value] = match; + } else { + [flags, tag, value] = values; + } + if (!value) + throw new UserError("CAA records require ."); + record.Flags = Number(flags ?? 0); + record.Tag = tag; + record.Value = value; + return record; + } + + const [value] = values; + if (!value) throw new UserError("A record value is required."); + record.Value = value; + return record; +} + +/** Throw if the user aborted a prompt (Esc / Ctrl-C yields undefined). */ +function required(value: T | undefined, label: string): T { + if (value === undefined || value === "") { + throw new UserError(`${label} is required.`); + } + return value; +} + +/** Interactively gather a record when positional args were omitted. */ +async function promptRecord( + type: number, + name: string, +): Promise { + const record: AddDnsRecordModel = { + Type: type as never, + Name: name === "@" ? "" : name, + }; + + if (type === RECORD_TYPES.PULLZONE) { + const { id } = await prompts({ + type: "number", + name: "id", + message: "Pull zone ID:", + }); + record.PullZoneId = required(id, "Pull zone ID"); + return record; + } + + if (type === RECORD_TYPES.SCRIPT) { + const { id } = await prompts({ + type: "number", + name: "id", + message: "Script ID:", + }); + record.ScriptId = required(id, "Script ID"); + return record; + } + + if (type === RECORD_TYPES.MX) { + const { value, priority } = await prompts([ + { type: "text", name: "value", message: "Mail server:" }, + { type: "number", name: "priority", message: "Priority:", initial: 10 }, + ]); + record.Value = required(value, "Mail server"); + record.Priority = priority ?? 10; + return record; + } + + if (type === RECORD_TYPES.SRV) { + const res = await prompts([ + { type: "number", name: "priority", message: "Priority:", initial: 10 }, + { type: "number", name: "weight", message: "Weight:", initial: 0 }, + { type: "number", name: "port", message: "Port:" }, + { type: "text", name: "target", message: "Target:" }, + ]); + record.Priority = res.priority ?? 10; + record.Weight = res.weight ?? 0; + record.Port = required(res.port, "Port"); + record.Value = required(res.target, "Target"); + return record; + } + + if (type === RECORD_TYPES.CAA) { + const res = await prompts([ + { type: "number", name: "flags", message: "Flags:", initial: 0 }, + { + type: "select", + name: "tag", + message: "Tag:", + choices: [ + { title: "issue", value: "issue" }, + { title: "issuewild", value: "issuewild" }, + { title: "iodef", value: "iodef" }, + ], + }, + { type: "text", name: "value", message: "Value:" }, + ]); + record.Flags = res.flags ?? 0; + record.Tag = required(res.tag, "Tag"); + record.Value = required(res.value, "Value"); + return record; + } + + const { value } = await prompts({ + type: "text", + name: "value", + message: "Value:", + }); + record.Value = required(value, "Value"); + return record; +} + +export const dnsAddCommand = defineCommand({ + command: "add [domain] [name] [type] [values..]", + describe: "Add a DNS record to a zone (interactive when args are omitted).", + examples: [ + ["$0 dns add example.com api A 198.51.100.1", "Add an A record"], + ["$0 dns add example.com '@' MX mail.example.com 10", "Add an MX record"], + [ + "$0 dns add example.com '@' SRV 10 0 389 sip.example.com", + "Add an SRV record", + ], + [ + "$0 dns add example.com '@' CAA '0 issue \"letsencrypt.org\"'", + "Add a CAA record", + ], + ["$0 dns add", "Interactive wizard"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("name", { + type: "string", + describe: "Record name (use '@' for the zone apex)", + }) + .positional("type", { + type: "string", + describe: "Record type (A, AAAA, CNAME, TXT, MX, SRV, CAA, NS, ...)", + }) + .positional("values", { + type: "string", + array: true, + describe: "Record value(s) — see examples for per-type ordering", + }) + .option("ttl", { type: "number", describe: "Time to live in seconds" }) + .option("comment", { + type: "string", + describe: "Optional comment for the record", + }) + .option("pull-zone", { + type: "number", + describe: "Pull zone ID (for PullZone records)", + }) + .option("script", { + type: "number", + describe: "Edge Script ID (for Script records)", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // Interactive when the record type wasn't given positionally. + const interactive = !args.type; + + // Resolve the target zone (prompt with a picker when no domain given). + const zone = await resolveZoneInteractive(client, args.domain); + + let record: AddDnsRecordModel; + if (interactive) { + 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 ?? "@"; + } + + record = await promptRecord(type, name ?? "@"); + + 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, + }); + } + + if (args.ttl !== undefined) record.Ttl = args.ttl; + if (args.comment !== undefined) record.Comment = args.comment; + + const spin = spinner("Adding record..."); + spin.start(); + const { data } = await client.PUT("/dnszone/{zoneId}/records", { + params: { path: { zoneId: zone.Id as number } }, + body: record, + }); + 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} (ID: ${data?.Id}).`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/api.ts b/packages/cli/src/commands/dns/api.ts new file mode 100644 index 0000000..667dbb6 --- /dev/null +++ b/packages/cli/src/commands/dns/api.ts @@ -0,0 +1,64 @@ +import type { createCoreClient } 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"]; + +/** Fetch all DNS zones on the account, paginated and sorted by domain. */ +export async function fetchZones(client: CoreClient): Promise { + const zones: DnsZoneModel[] = []; + let page = 1; + for (;;) { + const { data } = await client.GET("/dnszone", { + params: { query: { page, perPage: 1000 } }, + }); + zones.push(...(data?.Items ?? [])); + if (!data?.HasMoreItems) break; + page++; + } + return zones.sort((a, b) => (a.Domain ?? "").localeCompare(b.Domain ?? "")); +} + +/** Fetch a single DNS zone (including its records) by ID. */ +export async function fetchZone( + client: CoreClient, + id: number, +): Promise { + const { data } = await client.GET("/dnszone/{id}", { + params: { path: { id } }, + }); + if (!data) throw new UserError(`DNS zone ${id} not found.`); + return data; +} + +/** + * Resolve a zone reference (numeric ID or domain name) to a full zone. + * + * Numeric input is treated as a zone ID; anything else is matched against + * the account's zones by domain name. + */ +export async function resolveZone( + client: CoreClient, + domainOrId: string, +): Promise { + const ref = domainOrId.trim(); + if (!ref) throw new UserError("A domain or zone ID is required."); + + if (/^\d+$/.test(ref)) return fetchZone(client, Number(ref)); + + const { data } = await client.GET("/dnszone", { + params: { query: { search: ref, perPage: 1000 } }, + }); + const match = (data?.Items ?? []).find( + (z) => (z.Domain ?? "").toLowerCase() === ref.toLowerCase(), + ); + if (!match?.Id) { + throw new UserError( + `No DNS zone found for "${domainOrId}".`, + 'Run "bunny dns list" to see your zones.', + ); + } + return fetchZone(client, match.Id); +} diff --git a/packages/cli/src/commands/dns/export.ts b/packages/cli/src/commands/dns/export.ts new file mode 100644 index 0000000..9d8b78f --- /dev/null +++ b/packages/cli/src/commands/dns/export.ts @@ -0,0 +1,73 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +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"; + +interface ExportArgs { + domain?: string; + file?: string; + save?: boolean; +} + +export const dnsExportCommand = defineCommand({ + command: "export [domain]", + describe: "Export a zone's records as a BIND zone file.", + examples: [ + ["$0 dns export example.com", "Print the zone file to stdout"], + ["$0 dns export example.com --file ./example.zone", "Write to a path"], + ["$0 dns export example.com --save", "Write to ./example.com.zone"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("file", { + type: "string", + describe: "Write the zone file to this path instead of stdout", + }) + .option("save", { + type: "boolean", + default: false, + describe: "Write to .zone in the current directory", + }), + + handler: async ({ domain, file, save, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const spin = spinner("Exporting zone..."); + spin.start(); + const { data } = await client.GET("/dnszone/{id}/export", { + params: { path: { id: zone.Id as number } }, + parseAs: "text", + }); + spin.stop(); + + const zonefile = (data as string) ?? ""; + + const outPath = file ?? (save ? `${zone.Domain}.zone` : undefined); + if (outPath) { + await Bun.write(outPath, zonefile); + if (output === "json") { + logger.log( + JSON.stringify({ domain: zone.Domain, file: outPath }, null, 2), + ); + return; + } + logger.success(`Exported ${zone.Domain} to ${outPath}.`); + return; + } + + if (output === "json") { + logger.log(JSON.stringify({ domain: zone.Domain, zonefile }, null, 2)); + return; + } + + logger.log(zonefile); + }, +}); diff --git a/packages/cli/src/commands/dns/import.ts b/packages/cli/src/commands/dns/import.ts new file mode 100644 index 0000000..f0cb15d --- /dev/null +++ b/packages/cli/src/commands/dns/import.ts @@ -0,0 +1,88 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { resolveZoneInteractive } from "./interactive.ts"; + +interface ImportArgs { + domain?: string; + file?: string; +} + +export const dnsImportCommand = defineCommand({ + command: "import [domain] [file]", + describe: "Import DNS records into a zone from a BIND zone file.", + examples: [ + [ + "$0 dns import example.com ./zonefile.txt", + "Import records from a zone file", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("file", { + type: "string", + describe: "Path to the BIND zone file", + }), + + handler: async ({ domain, file, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + 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(); + + // The import endpoint takes the raw zone file as the request body, + // which the generated spec does not model — pass it through directly. + const { 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); + + 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.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/index.ts b/packages/cli/src/commands/dns/index.ts new file mode 100644 index 0000000..a11a237 --- /dev/null +++ b/packages/cli/src/commands/dns/index.ts @@ -0,0 +1,26 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { dnsAddCommand } from "./add.ts"; +import { dnsExportCommand } from "./export.ts"; +import { dnsImportCommand } from "./import.ts"; +import { dnsListCommand } from "./list.ts"; +import { dnsNameserversCommand } from "./nameservers.ts"; +import { dnsRemoveCommand } from "./remove.ts"; +import { dnsStatsCommand } from "./stats.ts"; +import { dnsUpdateCommand } from "./update.ts"; +import { dnsZoneNamespace } from "./zone/index.ts"; + +export const dnsNamespace = defineNamespace( + "dns", + "Manage DNS zones and records.", + [ + dnsListCommand, + dnsAddCommand, + dnsUpdateCommand, + dnsRemoveCommand, + dnsImportCommand, + dnsExportCommand, + dnsStatsCommand, + dnsNameserversCommand, + dnsZoneNamespace, + ], +); diff --git a/packages/cli/src/commands/dns/interactive.ts b/packages/cli/src/commands/dns/interactive.ts new file mode 100644 index 0000000..e4f751a --- /dev/null +++ b/packages/cli/src/commands/dns/interactive.ts @@ -0,0 +1,98 @@ +import prompts from "prompts"; +import { UserError } from "../../core/errors.ts"; +import { spinner } from "../../core/ui.ts"; +import { + type CoreClient, + type DnsRecordModel, + type DnsZoneModel, + fetchZone, + fetchZones, + resolveZone, +} from "./api.ts"; +import { + formatRecordValue, + recordName, + recordTypeLabel, +} from "./record-types.ts"; + +/** + * Resolve a zone by name/ID, or prompt the user to pick one when no + * reference is given. Manages its own spinner so it never spins over a prompt. + */ +export async function resolveZoneInteractive( + client: CoreClient, + ref: string | undefined, +): Promise { + if (ref) { + const spin = spinner("Resolving zone..."); + spin.start(); + try { + return await resolveZone(client, ref); + } finally { + spin.stop(); + } + } + + const spin = spinner("Fetching zones..."); + spin.start(); + const zones = await fetchZones(client); + spin.stop(); + + if (zones.length === 0) { + throw new UserError( + "No DNS zones found.", + 'Create one with "bunny dns zone add ".', + ); + } + + const { id } = await prompts({ + type: "select", + name: "id", + message: "Zone:", + choices: zones.map((z) => ({ title: z.Domain ?? "", value: z.Id })), + }); + if (id === undefined) throw new UserError("A zone is required."); + + const resolveSpin = spinner("Loading zone..."); + resolveSpin.start(); + try { + return await fetchZone(client, id); + } finally { + resolveSpin.stop(); + } +} + +/** + * Return the record matching `id`, or prompt the user to pick one from the + * zone when no ID is given. + */ +export async function resolveRecordInteractive( + zone: DnsZoneModel, + id: number | undefined, + action: string, +): Promise { + const records = zone.Records ?? []; + + if (id !== undefined) { + const match = records.find((r) => r.Id === id); + if (!match) + throw new UserError(`Record ${id} not found in ${zone.Domain}.`); + return match; + } + + if (records.length === 0) { + throw new UserError(`No records in ${zone.Domain}.`); + } + + const { record } = await prompts({ + type: "select", + name: "record", + message: `Record to ${action}:`, + choices: records.map((r) => ({ + title: `${recordTypeLabel(r.Type)} ${recordName(r.Name)} → ${formatRecordValue(r)}`, + value: r, + })), + }); + if (!record) throw new UserError("A record is required."); + return record; +} diff --git a/packages/cli/src/commands/dns/list.ts b/packages/cli/src/commands/dns/list.ts new file mode 100644 index 0000000..6d2207b --- /dev/null +++ b/packages/cli/src/commands/dns/list.ts @@ -0,0 +1,104 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { fetchZones, resolveZone } from "./api.ts"; +import { + formatRecordValue, + recordName, + recordTypeLabel, +} from "./record-types.ts"; + +interface ListArgs { + domain?: string; +} + +export const dnsListCommand = defineCommand({ + command: "list [domain]", + aliases: ["ls"], + describe: "List DNS zones, or the records within a zone.", + examples: [ + ["$0 dns list", "List all DNS zones"], + ["$0 dns list example.com", "List records in a zone"], + ["$0 dns list example.com --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID (omit to list all zones)", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + if (domain) { + const spin = spinner("Fetching DNS records..."); + spin.start(); + const zone = await resolveZone(client, domain); + spin.stop(); + + const records = (zone.Records ?? []).sort((a, b) => + recordName(a.Name).localeCompare(recordName(b.Name)), + ); + + if (output === "json") { + logger.log(JSON.stringify(zone, null, 2)); + return; + } + + if (records.length === 0) { + logger.info(`No records found in ${zone.Domain}.`); + return; + } + + logger.log( + formatTable( + ["ID", "Name", "Type", "Value", "TTL"], + records.map((r) => [ + String(r.Id ?? ""), + recordName(r.Name), + recordTypeLabel(r.Type), + formatRecordValue(r), + String(r.Ttl ?? ""), + ]), + output, + ), + ); + return; + } + + const spin = spinner("Fetching DNS zones..."); + spin.start(); + const zones = await fetchZones(client); + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(zones, null, 2)); + return; + } + + if (zones.length === 0) { + logger.info("No DNS zones found."); + return; + } + + logger.log( + formatTable( + ["ID", "Domain", "Records", "DNSSEC", "Nameservers"], + zones.map((z) => [ + String(z.Id ?? ""), + z.Domain ?? "", + String((z.Records ?? []).length), + z.DnsSecEnabled ? "Yes" : "No", + z.NameserversDetected ? "Detected" : "Pending", + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/dns/nameservers.ts b/packages/cli/src/commands/dns/nameservers.ts new file mode 100644 index 0000000..2595d08 --- /dev/null +++ b/packages/cli/src/commands/dns/nameservers.ts @@ -0,0 +1,85 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { formatKeyValue, formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { resolveZoneInteractive } from "./interactive.ts"; + +interface NameserversArgs { + domain?: string; +} + +// bunny.net delegates every zone to the same two anycast nameservers. +const BUNNY_DEFAULT_NAMESERVERS = ["kiki.bunny.net", "coco.bunny.net"]; + +export const dnsNameserversCommand = defineCommand({ + command: "nameservers [domain]", + aliases: ["ns"], + describe: "Show the nameservers to set at your registrar for a zone.", + examples: [ + ["$0 dns nameservers example.com", "Show the zone's nameservers"], + ["$0 dns ns example.com --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const custom = + zone.CustomNameserversEnabled === true && + Boolean(zone.Nameserver1 || zone.Nameserver2); + const nameservers = custom + ? [zone.Nameserver1, zone.Nameserver2].filter((ns): ns is string => + Boolean(ns), + ) + : BUNNY_DEFAULT_NAMESERVERS; + const detected = zone.NameserversDetected === true; + + if (output === "json") { + logger.log( + JSON.stringify( + { domain: zone.Domain, custom, detected, nameservers }, + null, + 2, + ), + ); + return; + } + + logger.log( + formatKeyValue( + [ + { key: "Zone", value: zone.Domain ?? "" }, + { key: "Type", value: custom ? "Custom" : "Default (bunny.net)" }, + { key: "Detected at registrar", value: detected ? "Yes" : "No" }, + ], + output, + ), + ); + + logger.log(""); + logger.log( + formatTable( + ["Nameserver"], + nameservers.map((ns) => [ns]), + output, + ), + ); + + if (!detected) { + logger.log(""); + logger.dim( + `Point ${zone.Domain}'s nameservers at the above to delegate it to bunny.net.`, + ); + } + }, +}); diff --git a/packages/cli/src/commands/dns/record-types.test.ts b/packages/cli/src/commands/dns/record-types.test.ts new file mode 100644 index 0000000..fcdbf9c --- /dev/null +++ b/packages/cli/src/commands/dns/record-types.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { + formatRecordValue, + parseRecordType, + RECORD_TYPES, + recordName, + recordTypeLabel, +} from "./record-types.ts"; + +describe("parseRecordType", () => { + test("parses known types case-insensitively", () => { + expect(parseRecordType("A")).toBe(RECORD_TYPES.A); + expect(parseRecordType("cname")).toBe(RECORD_TYPES.CNAME); + expect(parseRecordType(" Mx ")).toBe(RECORD_TYPES.MX); + }); + + test("throws on an unknown type", () => { + expect(() => parseRecordType("BOGUS")).toThrow(/Unknown record type/); + }); +}); + +describe("recordTypeLabel", () => { + test("maps enum values back to names", () => { + expect(recordTypeLabel(RECORD_TYPES.A)).toBe("A"); + expect(recordTypeLabel(RECORD_TYPES.CAA)).toBe("CAA"); + }); + + test("falls back to UNKNOWN", () => { + expect(recordTypeLabel(999)).toBe("UNKNOWN"); + expect(recordTypeLabel(null)).toBe("UNKNOWN"); + }); +}); + +describe("recordName", () => { + test("shows @ for the apex", () => { + expect(recordName("")).toBe("@"); + expect(recordName(null)).toBe("@"); + expect(recordName("api")).toBe("api"); + }); +}); + +describe("formatRecordValue", () => { + test("plain value for A", () => { + expect( + formatRecordValue({ Type: RECORD_TYPES.A, Value: "198.51.100.1" }), + ).toBe("198.51.100.1"); + }); + + test("priority prefix for MX", () => { + expect( + formatRecordValue({ + Type: RECORD_TYPES.MX, + Value: "mail.example.com", + Priority: 10, + }), + ).toBe("10 mail.example.com"); + }); + + test("priority/weight/port for SRV", () => { + expect( + formatRecordValue({ + Type: RECORD_TYPES.SRV, + Value: "sip.example.com", + Priority: 10, + Weight: 0, + Port: 389, + }), + ).toBe("10 0 389 sip.example.com"); + }); + + test("flags/tag/quoted value for CAA", () => { + expect( + formatRecordValue({ + Type: RECORD_TYPES.CAA, + Value: "letsencrypt.org", + Flags: 0, + Tag: "issue", + }), + ).toBe('0 issue "letsencrypt.org"'); + }); +}); diff --git a/packages/cli/src/commands/dns/record-types.ts b/packages/cli/src/commands/dns/record-types.ts new file mode 100644 index 0000000..bae1a3b --- /dev/null +++ b/packages/cli/src/commands/dns/record-types.ts @@ -0,0 +1,67 @@ +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { UserError } from "../../core/errors.ts"; + +export type DnsRecordTypes = components["schemas"]["DnsRecordTypes"]; +export type DnsRecordModel = components["schemas"]["DnsRecordModel"]; + +/** Record type name → bunny.net integer enum value. */ +export const RECORD_TYPES = { + A: 0, + AAAA: 1, + CNAME: 2, + TXT: 3, + MX: 4, + REDIRECT: 5, + FLATTEN: 6, + PULLZONE: 7, + SRV: 8, + CAA: 9, + PTR: 10, + SCRIPT: 11, + NS: 12, + SVCB: 13, + HTTPS: 14, + TLSA: 15, +} as const satisfies Record; + +const TYPE_LABELS: Record = Object.fromEntries( + Object.entries(RECORD_TYPES).map(([name, value]) => [value, name]), +); + +/** Human label for a record type integer, falling back to "UNKNOWN". */ +export function recordTypeLabel(type: number | null | undefined): string { + return TYPE_LABELS[type ?? -1] ?? "UNKNOWN"; +} + +/** Parse a record type name (e.g. "A", "cname") to its enum value, or throw. */ +export function parseRecordType(value: string): DnsRecordTypes { + const key = value.trim().toUpperCase() as keyof typeof RECORD_TYPES; + const parsed = RECORD_TYPES[key]; + if (parsed === undefined) { + throw new UserError( + `Unknown record type "${value}".`, + `Valid types: ${Object.keys(RECORD_TYPES).join(", ")}`, + ); + } + return parsed; +} + +/** Display the record name, showing "@" for the zone apex. */ +export function recordName(name: string | null | undefined): string { + return name && name.length > 0 ? name : "@"; +} + +/** Render a record's value for display, including type-specific fields. */ +export function formatRecordValue(record: DnsRecordModel): string { + const value = record.Value ?? ""; + switch (record.Type) { + case RECORD_TYPES.MX: + return `${record.Priority ?? 0} ${value}`; + case RECORD_TYPES.SRV: + return `${record.Priority ?? 0} ${record.Weight ?? 0} ${record.Port ?? 0} ${value}`; + case RECORD_TYPES.CAA: + return `${record.Flags ?? 0} ${record.Tag ?? ""} "${value}"`; + default: + return value; + } +} diff --git a/packages/cli/src/commands/dns/remove.ts b/packages/cli/src/commands/dns/remove.ts new file mode 100644 index 0000000..7844ef0 --- /dev/null +++ b/packages/cli/src/commands/dns/remove.ts @@ -0,0 +1,78 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +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 { confirm, spinner } from "../../core/ui.ts"; +import { + resolveRecordInteractive, + resolveZoneInteractive, +} from "./interactive.ts"; +import { + formatRecordValue, + recordName, + recordTypeLabel, +} from "./record-types.ts"; + +interface RemoveArgs { + domain?: string; + id?: number; + force?: boolean; +} + +export const dnsRemoveCommand = defineCommand({ + command: "remove [domain] [id]", + aliases: ["rm"], + describe: "Remove a DNS record from a zone (prompts when args are omitted).", + examples: [ + ["$0 dns remove example.com 123", "Remove a record by ID"], + ["$0 dns remove example.com 123 --force", "Skip confirmation"], + ["$0 dns remove", "Pick a zone and record interactively"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("id", { type: "number", describe: "Record ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, id, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + const record = await resolveRecordInteractive(zone, id, "remove"); + + const label = `${recordTypeLabel(record.Type)} ${recordName(record.Name)} → ${formatRecordValue(record)}`; + const confirmed = await confirm(`Remove ${label}?`, { force }); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const removeSpin = spinner("Removing record..."); + removeSpin.start(); + await client.DELETE("/dnszone/{zoneId}/records/{id}", { + params: { path: { zoneId: zone.Id as number, id: record.Id as number } }, + }); + removeSpin.stop(); + + if (output === "json") { + logger.log( + JSON.stringify( + { zoneId: zone.Id, id: record.Id, removed: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Removed record ${record.Id} from ${zone.Domain}.`); + }, +}); diff --git a/packages/cli/src/commands/dns/stats.ts b/packages/cli/src/commands/dns/stats.ts new file mode 100644 index 0000000..128dd88 --- /dev/null +++ b/packages/cli/src/commands/dns/stats.ts @@ -0,0 +1,132 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import chalk from "chalk"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { bunny } from "../../core/colors.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { formatKeyValue, formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { resolveZoneInteractive } from "./interactive.ts"; + +interface StatsArgs { + domain?: string; + from?: string; + to?: string; +} + +const BAR_WIDTH = 24; + +/** Sum the values of a date-keyed chart map. */ +function sumChart(chart: { [key: string]: number } | null | undefined): number { + return Object.values(chart ?? {}).reduce((acc, n) => acc + n, 0); +} + +/** Render a horizontal bar chart of query counts per record type. */ +function renderTypeChart(byType: [string, number][]): string { + const max = Math.max(...byType.map(([, n]) => n), 1); + const labelWidth = Math.max(...byType.map(([t]) => t.length)); + const numWidth = Math.max( + ...byType.map(([, n]) => n.toLocaleString().length), + ); + return byType + .map(([type, count]) => { + const filled = + count > 0 ? Math.max(1, Math.round((count / max) * BAR_WIDTH)) : 0; + const bar = + bunny("█".repeat(filled)) + chalk.gray("░".repeat(BAR_WIDTH - filled)); + const label = type.padEnd(labelWidth); + const value = count.toLocaleString().padStart(numWidth); + return ` ${label} ${bar} ${value}`; + }) + .join("\n"); +} + +export const dnsStatsCommand = defineCommand({ + command: "stats [domain]", + describe: "Show DNS query statistics for a zone.", + examples: [ + ["$0 dns stats example.com", "Statistics for the last 30 days"], + [ + "$0 dns stats example.com --from 2026-05-01 --to 2026-05-31", + "Statistics for a date range", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("from", { + type: "string", + describe: "Start date (YYYY-MM-DD); defaults to 30 days ago", + }) + .option("to", { + type: "string", + describe: "End date (YYYY-MM-DD); defaults to today", + }), + + handler: async ({ domain, from, to, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const spin = spinner("Fetching statistics..."); + spin.start(); + const { data } = await client.GET("/dnszone/{id}/statistics", { + params: { + path: { id: zone.Id as number }, + query: { dateFrom: from, dateTo: to }, + }, + }); + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(data ?? {}, null, 2)); + return; + } + + const period = + from || to ? `${from ?? "…"} → ${to ?? "today"}` : "last 30 days"; + logger.log( + formatKeyValue( + [ + { key: "Zone", value: zone.Domain ?? "" }, + { key: "Period", value: period }, + { + key: "Total queries", + value: (data?.TotalQueriesServed ?? 0).toLocaleString(), + }, + { + key: "Normal queries", + value: sumChart(data?.NormalQueriesServedChart).toLocaleString(), + }, + { + key: "Smart queries", + value: sumChart(data?.SmartQueriesServedChart).toLocaleString(), + }, + ], + output, + ), + ); + + const byType = Object.entries(data?.QueriesByTypeChart ?? {}).sort( + (a, b) => b[1] - a[1], + ); + if (byType.length > 0) { + logger.log(""); + logger.dim(" Queries by type"); + if (output === "text") { + logger.log(renderTypeChart(byType)); + } else { + logger.log( + formatTable( + ["Type", "Queries"], + byType.map(([type, count]) => [type, String(count)]), + output, + ), + ); + } + } + }, +}); diff --git a/packages/cli/src/commands/dns/update.ts b/packages/cli/src/commands/dns/update.ts new file mode 100644 index 0000000..8a1618a --- /dev/null +++ b/packages/cli/src/commands/dns/update.ts @@ -0,0 +1,136 @@ +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 { + resolveRecordInteractive, + resolveZoneInteractive, +} from "./interactive.ts"; +import { parseRecordType, recordName } from "./record-types.ts"; + +type UpdateDnsRecordModel = components["schemas"]["UpdateDnsRecordModel"]; + +interface UpdateArgs { + domain?: string; + id?: number; + name?: string; + value?: string; + type?: string; + ttl?: number; + priority?: number; + weight?: number; + port?: number; + flags?: number; + tag?: string; + comment?: string; + disabled?: boolean; + "pull-zone"?: number; + script?: number; +} + +export const dnsUpdateCommand = defineCommand({ + command: "update [domain] [id]", + aliases: ["edit"], + describe: "Update an existing DNS record (prompts when args are omitted).", + examples: [ + [ + "$0 dns update example.com 123 --value 198.51.100.2", + "Change a record value", + ], + ["$0 dns update example.com 123 --ttl 3600", "Change the TTL"], + ["$0 dns update example.com 123 --disabled", "Disable a record"], + [ + "$0 dns update example.com --value 198.51.100.2", + "Pick the record interactively", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .positional("id", { type: "number", describe: "Record ID" }) + .option("name", { + type: "string", + describe: "Record name ('@' for apex)", + }) + .option("value", { type: "string", describe: "Record value" }) + .option("type", { type: "string", describe: "Record type" }) + .option("ttl", { type: "number", describe: "Time to live in seconds" }) + .option("priority", { type: "number", describe: "Priority (MX/SRV)" }) + .option("weight", { type: "number", describe: "Weight (SRV)" }) + .option("port", { type: "number", describe: "Port (SRV)" }) + .option("flags", { type: "number", describe: "Flags (CAA)" }) + .option("tag", { type: "string", describe: "Tag (CAA)" }) + .option("comment", { type: "string", describe: "Comment for the record" }) + .option("disabled", { type: "boolean", describe: "Disable the record" }) + .option("pull-zone", { + type: "number", + describe: "Pull zone ID (for PullZone records)", + }) + .option("script", { + type: "number", + describe: "Edge Script ID (for Script records)", + }), + + handler: async (args) => { + const { domain, id, profile, output, verbose, apiKey } = args; + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + const existing = await resolveRecordInteractive(zone, id, "update"); + const recordId = existing.Id as number; + + // Seed from the existing record so unspecified fields are preserved. + const body: UpdateDnsRecordModel = { + Type: existing.Type ?? null, + Ttl: existing.Ttl ?? null, + Value: existing.Value ?? null, + Name: existing.Name ?? null, + Weight: existing.Weight ?? null, + Priority: existing.Priority ?? null, + Flags: existing.Flags ?? null, + Tag: existing.Tag ?? null, + Port: existing.Port ?? null, + Disabled: existing.Disabled ?? null, + Comment: existing.Comment ?? null, + }; + + if (args.name !== undefined) body.Name = args.name === "@" ? "" : args.name; + if (args.value !== undefined) body.Value = args.value; + if (args.type !== undefined) body.Type = parseRecordType(args.type); + if (args.ttl !== undefined) body.Ttl = args.ttl; + if (args.priority !== undefined) body.Priority = args.priority; + if (args.weight !== undefined) body.Weight = args.weight; + if (args.port !== undefined) body.Port = args.port; + if (args.flags !== undefined) body.Flags = args.flags; + if (args.tag !== undefined) body.Tag = args.tag; + if (args.comment !== undefined) body.Comment = args.comment; + if (args.disabled !== undefined) body.Disabled = args.disabled; + if (args["pull-zone"] !== undefined) body.PullZoneId = args["pull-zone"]; + if (args.script !== undefined) body.ScriptId = args.script; + + const spin = spinner("Updating record..."); + spin.start(); + await client.POST("/dnszone/{zoneId}/records/{id}", { + params: { path: { zoneId: zone.Id as number, id: recordId } }, + body, + }); + spin.stop(); + + if (output === "json") { + logger.log( + JSON.stringify({ zoneId: zone.Id, id: recordId, ...body }, null, 2), + ); + return; + } + + logger.success( + `Updated record ${recordName(body.Name)} (ID: ${recordId}) in ${zone.Domain}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts new file mode 100644 index 0000000..746dc28 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -0,0 +1,54 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +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"; + +interface ZoneAddArgs { + domain: string; +} + +export const dnsZoneAddCommand = defineCommand({ + command: "add ", + describe: "Create a new DNS zone.", + examples: [["$0 dns zone add example.com", "Create a zone for example.com"]], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain to create the zone for", + demandOption: true, + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const spin = spinner("Creating DNS zone..."); + spin.start(); + + await client.POST("/dnszone", { body: { Domain: domain } }); + + // The add endpoint returns no body — look the new zone up to report its ID. + const { data } = await client.GET("/dnszone", { + params: { query: { search: domain, perPage: 1000 } }, + }); + const created = (data?.Items ?? []).find( + (z) => (z.Domain ?? "").toLowerCase() === domain.toLowerCase(), + ); + + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(created ?? { Domain: domain }, null, 2)); + return; + } + + logger.success( + created?.Id + ? `Created DNS zone ${domain} (ID: ${created.Id}).` + : `Created DNS zone ${domain}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/dnssec/disable.ts b/packages/cli/src/commands/dns/zone/dnssec/disable.ts new file mode 100644 index 0000000..1e64b89 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/dnssec/disable.ts @@ -0,0 +1,68 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +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 { confirm, spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +interface DisableArgs { + domain?: string; + force?: boolean; +} + +export const dnsZoneDnssecDisableCommand = defineCommand({ + command: "disable [domain]", + describe: "Disable DNSSEC for a zone.", + examples: [ + ["$0 dns zone dnssec disable example.com", "Disable DNSSEC"], + ["$0 dns zone dnssec disable example.com --force", "Skip confirmation"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const confirmed = await confirm(`Disable DNSSEC for ${zone.Domain}?`, { + force, + }); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const disableSpin = spinner("Disabling DNSSEC..."); + disableSpin.start(); + await client.DELETE("/dnszone/{id}/dnssec", { + params: { path: { id: zone.Id as number } }, + }); + disableSpin.stop(); + + if (output === "json") { + logger.log( + JSON.stringify( + { id: zone.Id, domain: zone.Domain, dnssec: false }, + null, + 2, + ), + ); + return; + } + + logger.warn( + `DNSSEC disabled for ${zone.Domain}. Remove the DS record at your registrar.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/dnssec/enable.ts b/packages/cli/src/commands/dns/zone/dnssec/enable.ts new file mode 100644 index 0000000..c1e43ba --- /dev/null +++ b/packages/cli/src/commands/dns/zone/dnssec/enable.ts @@ -0,0 +1,64 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../../config/index.ts"; +import { clientOptions } from "../../../../core/client-options.ts"; +import { defineCommand } from "../../../../core/define-command.ts"; +import { formatKeyValue } from "../../../../core/format.ts"; +import { logger } from "../../../../core/logger.ts"; +import { spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +interface EnableArgs { + domain?: string; +} + +export const dnsZoneDnssecEnableCommand = defineCommand({ + command: "enable [domain]", + describe: "Enable DNSSEC for a zone and print its DS record.", + examples: [["$0 dns zone dnssec enable example.com", "Enable DNSSEC"]], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const spin = spinner("Enabling DNSSEC..."); + spin.start(); + const { data } = await client.POST("/dnszone/{id}/dnssec", { + params: { path: { id: zone.Id as number } }, + }); + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success(`DNSSEC enabled for ${zone.Domain}.`); + if (data?.DsRecord) { + logger.log( + formatKeyValue( + [ + { key: "DS Record", value: data.DsRecord ?? "" }, + { key: "Digest", value: data.Digest ?? "" }, + { key: "Digest Type", value: data.DigestType ?? "" }, + { key: "Algorithm", value: String(data.Algorithm ?? "") }, + { key: "Key Tag", value: String(data.KeyTag ?? "") }, + { key: "Flags", value: String(data.Flags ?? "") }, + { key: "Public Key", value: data.PublicKey ?? "" }, + ], + output, + ), + ); + } + logger.dim( + "Add the DS record above at your domain registrar to complete DNSSEC setup.", + ); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/dnssec/index.ts b/packages/cli/src/commands/dns/zone/dnssec/index.ts new file mode 100644 index 0000000..67ed696 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/dnssec/index.ts @@ -0,0 +1,9 @@ +import { defineNamespace } from "../../../../core/define-namespace.ts"; +import { dnsZoneDnssecDisableCommand } from "./disable.ts"; +import { dnsZoneDnssecEnableCommand } from "./enable.ts"; + +export const dnsZoneDnssecNamespace = defineNamespace( + "dnssec", + "Manage DNSSEC for a zone.", + [dnsZoneDnssecEnableCommand, dnsZoneDnssecDisableCommand], +); diff --git a/packages/cli/src/commands/dns/zone/index.ts b/packages/cli/src/commands/dns/zone/index.ts new file mode 100644 index 0000000..92a5a5b --- /dev/null +++ b/packages/cli/src/commands/dns/zone/index.ts @@ -0,0 +1,14 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { dnsZoneAddCommand } from "./add.ts"; +import { dnsZoneDnssecNamespace } from "./dnssec/index.ts"; +import { dnsZoneLoggingNamespace } from "./logging/index.ts"; +import { dnsZoneRemoveCommand } from "./remove.ts"; +import { dnsZoneShowCommand } from "./show.ts"; + +export const dnsZoneNamespace = defineNamespace("zone", "Manage DNS zones.", [ + dnsZoneAddCommand, + dnsZoneShowCommand, + dnsZoneRemoveCommand, + dnsZoneDnssecNamespace, + dnsZoneLoggingNamespace, +]); diff --git a/packages/cli/src/commands/dns/zone/logging/disable.ts b/packages/cli/src/commands/dns/zone/logging/disable.ts new file mode 100644 index 0000000..62dadb8 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/logging/disable.ts @@ -0,0 +1,62 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +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 { confirm, spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +interface DisableArgs { + domain?: string; + force?: boolean; +} + +export const dnsZoneLoggingDisableCommand = defineCommand({ + command: "disable [domain]", + describe: "Disable DNS query logging for a zone.", + examples: [ + ["$0 dns zone logging disable example.com", "Stop collecting query logs"], + ["$0 dns zone logging disable example.com --force", "Skip confirmation"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const confirmed = await confirm( + `Disable DNS query logging for ${zone.Domain}?`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const disableSpin = spinner("Disabling logging..."); + disableSpin.start(); + const { data } = await client.POST("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + body: { LoggingEnabled: false }, + }); + disableSpin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success(`DNS query logging disabled for ${zone.Domain}.`); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/logging/enable.ts b/packages/cli/src/commands/dns/zone/logging/enable.ts new file mode 100644 index 0000000..c60abdc --- /dev/null +++ b/packages/cli/src/commands/dns/zone/logging/enable.ts @@ -0,0 +1,83 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../../config/index.ts"; +import { clientOptions } from "../../../../core/client-options.ts"; +import { defineCommand } from "../../../../core/define-command.ts"; +import { UserError } from "../../../../core/errors.ts"; +import { logger } from "../../../../core/logger.ts"; +import { spinner } from "../../../../core/ui.ts"; +import { resolveZoneInteractive } from "../../interactive.ts"; + +interface EnableArgs { + domain?: string; + "anonymize-ip"?: boolean; + anonymization?: string; +} + +// LogAnonymizationType: 0 = OneDigit, 1 = Drop +const ANONYMIZATION: Record = { onedigit: 0, drop: 1 }; + +export const dnsZoneLoggingEnableCommand = defineCommand({ + command: "enable [domain]", + describe: "Enable DNS query logging for a zone.", + examples: [ + ["$0 dns zone logging enable example.com", "Start collecting query logs"], + [ + "$0 dns zone logging enable example.com --anonymize-ip --anonymization drop", + "Enable with IP anonymization", + ], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("anonymize-ip", { + type: "boolean", + describe: "Anonymize client IPs in the logs", + }) + .option("anonymization", { + type: "string", + choices: ["onedigit", "drop"], + describe: "IP anonymization strategy (default: onedigit)", + }), + + handler: async (args) => { + const { domain, profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const body: { + LoggingEnabled: boolean; + LoggingIPAnonymizationEnabled?: boolean; + LogAnonymizationType?: 0 | 1; + } = { LoggingEnabled: true }; + + if (args["anonymize-ip"] !== undefined) { + body.LoggingIPAnonymizationEnabled = args["anonymize-ip"]; + } + if (args.anonymization !== undefined) { + const type = ANONYMIZATION[args.anonymization]; + if (type === undefined) { + throw new UserError("Anonymization must be 'onedigit' or 'drop'."); + } + body.LogAnonymizationType = type; + } + + const spin = spinner("Enabling logging..."); + spin.start(); + const { data } = await client.POST("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + body, + }); + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(data, null, 2)); + return; + } + + logger.success(`DNS query logging enabled for ${zone.Domain}.`); + logger.dim("Logs start collecting now — allow a few minutes for data."); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/logging/index.ts b/packages/cli/src/commands/dns/zone/logging/index.ts new file mode 100644 index 0000000..795feaf --- /dev/null +++ b/packages/cli/src/commands/dns/zone/logging/index.ts @@ -0,0 +1,9 @@ +import { defineNamespace } from "../../../../core/define-namespace.ts"; +import { dnsZoneLoggingDisableCommand } from "./disable.ts"; +import { dnsZoneLoggingEnableCommand } from "./enable.ts"; + +export const dnsZoneLoggingNamespace = defineNamespace( + "logging", + "Manage DNS query logging for a zone.", + [dnsZoneLoggingEnableCommand, dnsZoneLoggingDisableCommand], +); diff --git a/packages/cli/src/commands/dns/zone/remove.ts b/packages/cli/src/commands/dns/zone/remove.ts new file mode 100644 index 0000000..06f2cc1 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/remove.ts @@ -0,0 +1,69 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +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 { confirm, spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; + +interface ZoneRemoveArgs { + domain?: string; + force?: boolean; +} + +export const dnsZoneRemoveCommand = defineCommand({ + command: "remove [domain]", + aliases: ["rm"], + describe: "Delete a DNS zone and all of its records.", + examples: [ + ["$0 dns zone remove example.com", "Delete a zone"], + ["$0 dns zone remove example.com --force", "Skip confirmation"], + ["$0 dns zone remove", "Pick a zone interactively"], + ], + + builder: (yargs) => + yargs + .positional("domain", { type: "string", describe: "Domain or zone ID" }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ domain, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const confirmed = await confirm( + `Delete zone ${zone.Domain} and all ${(zone.Records ?? []).length} record(s)?`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const removeSpin = spinner("Deleting zone..."); + removeSpin.start(); + await client.DELETE("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + }); + removeSpin.stop(); + + if (output === "json") { + logger.log( + JSON.stringify( + { id: zone.Id, domain: zone.Domain, removed: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Deleted DNS zone ${zone.Domain}.`); + }, +}); diff --git a/packages/cli/src/commands/dns/zone/show.ts b/packages/cli/src/commands/dns/zone/show.ts new file mode 100644 index 0000000..3509f33 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/show.ts @@ -0,0 +1,66 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatDateTime, formatKeyValue } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; + +interface ShowArgs { + domain?: string; +} + +export const dnsZoneShowCommand = defineCommand({ + command: "show [domain]", + describe: "Show details for a DNS zone.", + examples: [ + ["$0 dns zone show example.com", "Show zone details"], + ["$0 dns zone show example.com --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + if (output === "json") { + logger.log(JSON.stringify(zone, null, 2)); + return; + } + + const nameservers = zone.CustomNameserversEnabled + ? [zone.Nameserver1, zone.Nameserver2].filter(Boolean).join(", ") + : "bunny.net (default)"; + + logger.log( + formatKeyValue( + [ + { key: "ID", value: String(zone.Id ?? "") }, + { key: "Domain", value: zone.Domain ?? "" }, + { key: "Records", value: String((zone.Records ?? []).length) }, + { + key: "Nameservers", + value: zone.NameserversDetected ? "Detected" : "Pending", + }, + { key: "Nameserver config", value: nameservers }, + { key: "SOA email", value: zone.SoaEmail ?? "—" }, + { key: "DNSSEC", value: zone.DnsSecEnabled ? "Enabled" : "Disabled" }, + { + key: "Logging", + value: zone.LoggingEnabled ? "Enabled" : "Disabled", + }, + { key: "Created", value: formatDateTime(zone.DateCreated) }, + { key: "Modified", value: formatDateTime(zone.DateModified) }, + ], + output, + ), + ); + }, +}); diff --git a/packages/openapi-client/src/middleware.test.ts b/packages/openapi-client/src/middleware.test.ts index d85e5c6..c750df0 100644 --- a/packages/openapi-client/src/middleware.test.ts +++ b/packages/openapi-client/src/middleware.test.ts @@ -139,4 +139,26 @@ describe("authMiddleware onResponse", () => { ); expect(result).toBeUndefined(); }); + + test("allows an OK text/plain download body (e.g. DNS zone-file export)", async () => { + const result = await runResponse( + { apiKey: "k" }, + new Response("$ORIGIN example.com.\nwww IN CNAME example.b-cdn.net.", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ); + expect(result).toBeUndefined(); + }); + + test("allows an OK application/octet-stream download body", async () => { + const result = await runResponse( + { apiKey: "k" }, + new Response("binary-ish payload", { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }), + ); + expect(result).toBeUndefined(); + }); }); diff --git a/packages/openapi-client/src/middleware.ts b/packages/openapi-client/src/middleware.ts index 90c435f..71d851b 100644 --- a/packages/openapi-client/src/middleware.ts +++ b/packages/openapi-client/src/middleware.ts @@ -26,6 +26,20 @@ function looksLikeJson(contentType: string): boolean { ); } +/** + * Content types that are legitimately non-JSON payloads (e.g. DNS zone-file + * exports, downloads) rather than CDN/proxy HTML interception. Callers fetch + * these with `parseAs: "text"`, so they must not trip the non-JSON guard. + */ +function isDownloadableNonJson(contentType: string): boolean { + const lower = contentType.toLowerCase(); + return ( + lower.includes("text/plain") || + lower.includes("application/octet-stream") || + lower.includes("text/csv") + ); +} + const STATUS_MESSAGES: Record = { 401: "Unauthorized. Check your API key.", 403: "Forbidden. You don't have permission for this action.", @@ -125,7 +139,10 @@ export function authMiddleware(options: ClientOptions): Middleware { // HTML error page with a 200 status code. if (response.ok) { const contentType = response.headers.get("content-type") ?? ""; - if (!looksLikeJson(contentType)) { + if ( + !looksLikeJson(contentType) && + !isDownloadableNonJson(contentType) + ) { const text = await response.clone().text(); if (text.trim().length > 0) { const preview = text.length > 200 ? `${text.slice(0, 200)}…` : text; From 7f18945ad4718ca4af7db5401cc7cb4268c0d2f1 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 16:13:28 +0100 Subject: [PATCH 2/5] updated structure from previous iteration --- .changeset/thirty-parrots-agree.md | 5 + .changeset/twelve-peaches-remain.md | 5 + AGENTS.md | 52 +++++---- packages/cli/README.md | 82 +++++++------- packages/cli/src/commands/dns/index.ts | 23 +--- packages/cli/src/commands/dns/list.ts | 104 ------------------ .../cli/src/commands/dns/{ => record}/add.ts | 29 ++--- .../src/commands/dns/{ => record}/export.ts | 21 ++-- .../src/commands/dns/{ => record}/import.ts | 16 +-- packages/cli/src/commands/dns/record/index.ts | 21 ++++ packages/cli/src/commands/dns/record/list.ts | 67 +++++++++++ .../src/commands/dns/{ => record}/remove.ts | 20 ++-- .../src/commands/dns/{ => record}/update.ts | 22 ++-- packages/cli/src/commands/dns/zone/index.ts | 23 +++- packages/cli/src/commands/dns/zone/list.ts | 52 +++++++++ .../commands/dns/{ => zone}/nameservers.ts | 16 +-- .../cli/src/commands/dns/{ => zone}/stats.ts | 20 ++-- packages/cli/src/core/define-namespace.ts | 7 +- 18 files changed, 324 insertions(+), 261 deletions(-) create mode 100644 .changeset/thirty-parrots-agree.md create mode 100644 .changeset/twelve-peaches-remain.md delete mode 100644 packages/cli/src/commands/dns/list.ts rename packages/cli/src/commands/dns/{ => record}/add.ts (91%) rename packages/cli/src/commands/dns/{ => record}/export.ts (74%) rename packages/cli/src/commands/dns/{ => record}/import.ts (83%) create mode 100644 packages/cli/src/commands/dns/record/index.ts create mode 100644 packages/cli/src/commands/dns/record/list.ts rename packages/cli/src/commands/dns/{ => record}/remove.ts (77%) rename packages/cli/src/commands/dns/{ => record}/update.ts (87%) create mode 100644 packages/cli/src/commands/dns/zone/list.ts rename packages/cli/src/commands/dns/{ => zone}/nameservers.ts (79%) rename packages/cli/src/commands/dns/{ => zone}/stats.ts (85%) diff --git a/.changeset/thirty-parrots-agree.md b/.changeset/thirty-parrots-agree.md new file mode 100644 index 0000000..ccaa762 --- /dev/null +++ b/.changeset/thirty-parrots-agree.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/openapi-client": patch +--- + +fix: let legitimate non-JSON 200 responses pass the proxy-interception guard diff --git a/.changeset/twelve-peaches-remain.md b/.changeset/twelve-peaches-remain.md new file mode 100644 index 0000000..a6f345b --- /dev/null +++ b/.changeset/twelve-peaches-remain.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(dns): add experimental `bunny dns` commands for managing DNS zones and records diff --git a/AGENTS.md b/AGENTS.md index af5ca79..08bb89b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -257,23 +257,26 @@ bunny-cli/ │ │ │ ├── create.ts # Generate an auth token (read-only/full-access, optional expiry) │ │ │ └── invalidate.ts # Invalidate all tokens for a database (with confirmation) │ │ ├── dns/ # Experimental — hidden from help and landing page -│ │ │ ├── index.ts # defineNamespace("dns", ...) — registers all DNS commands +│ │ │ ├── index.ts # defineNamespace("dns", ...) — registers the record + zone groups (+ hidden domain aliases) │ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone) │ │ │ ├── interactive.ts # resolveZoneInteractive (zone picker) + resolveRecordInteractive (record picker) fallbacks │ │ │ ├── record-types.ts # Record type name⇄integer map, parseRecordType, recordTypeLabel, formatRecordValue -│ │ │ ├── list.ts # List DNS zones, or records within a zone (alias: ls) -│ │ │ ├── add.ts # Add a DNS record (positional grammar per type, or interactive wizard; --pull-zone/--script) -│ │ │ ├── update.ts # Update a DNS record (alias: edit; prompts to pick zone+record when omitted) -│ │ │ ├── remove.ts # Remove a DNS record (alias: rm; prompts to pick zone+record when omitted) -│ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted) -│ │ │ ├── export.ts # Export a zone as a BIND zone file (stdout, --file , or --save → .zone) -│ │ │ ├── stats.ts # Show DNS query statistics for a zone (TotalQueriesServed, by-type breakdown) -│ │ │ ├── nameservers.ts # Show registrar nameservers (alias: ns; custom if set, else kiki/coco.bunny.net) -│ │ │ └── zone/ -│ │ │ ├── index.ts # defineNamespace("zone", ...) +│ │ │ ├── record/ # `dns record` — entries within a zone (aliases: records, rec) +│ │ │ │ ├── index.ts # defineNamespace("record", ...) +│ │ │ │ ├── list.ts # List records in a zone (alias: ls) +│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script) +│ │ │ │ ├── update.ts # Update a record (alias: edit; prompts to pick zone+record when omitted) +│ │ │ │ ├── remove.ts # Remove a record (alias: rm; prompts to pick zone+record when omitted) +│ │ │ │ ├── import.ts # Import records from a BIND zone file (prompts for zone/file when omitted) +│ │ │ │ └── export.ts # Export records as a BIND zone file (stdout, --file , or --save → .zone) +│ │ │ └── zone/ # `dns zone` — the zone itself (aliases: zones; hidden: domain, domains) +│ │ │ ├── index.ts # defineNamespace("zone", ...) + dnsZoneHiddenAliases (domain/domains) +│ │ │ ├── list.ts # List all DNS zones (alias: ls) │ │ │ ├── add.ts # Create a DNS zone │ │ │ ├── show.ts # Show zone details (nameservers, SOA, DNSSEC, logging, record count) │ │ │ ├── remove.ts # Delete a DNS zone and its records (alias: rm) +│ │ │ ├── stats.ts # Show DNS query statistics (TotalQueriesServed, by-type bar chart in text mode) +│ │ │ ├── nameservers.ts # Show registrar nameservers (alias: ns; custom if set, else kiki/coco.bunny.net) │ │ │ ├── dnssec/ │ │ │ │ ├── index.ts # defineNamespace("dnssec", ...) │ │ │ │ ├── enable.ts # Enable DNSSEC, print DS record for the registrar @@ -821,21 +824,24 @@ bunny │ │ Update registry name and/or rotate credentials │ └── remove Remove registry ├── dns (experimental — hidden from help and landing page) -│ │ Every [domain] is optional — omit it to pick a zone interactively (resolveZoneInteractive) -│ ├── list [domain] (alias: ls) List DNS zones, or records within a zone -│ ├── add [domain] [name] [type] [values..] [--ttl] [--comment] [--pull-zone] [--script] -│ │ Add a DNS record (interactive wizard when args omitted; MX/SRV/CAA use positional values; PullZone/Script use --pull-zone/--script) -│ ├── update [domain] [id] [--name] [--value] [--type] [--ttl] [--priority] [--weight] [--port] [--flags] [--tag] [--comment] [--disabled] [--pull-zone] [--script] -│ │ 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) -│ ├── 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) -│ ├── stats [domain] [--from] [--to] Show DNS query statistics for a zone (defaults to last 30 days) -│ ├── nameservers [domain] (alias: ns) Show the nameservers to set at the registrar (custom if enabled, else bunny.net defaults) -│ └── zone +│ │ Two resource groups: `record` (entries in a zone) and `zone` (the zone itself). +│ │ Every [domain] is optional — omit it to pick a zone interactively (resolveZoneInteractive). +│ ├── record (aliases: records, rec) +│ │ ├── list [domain] (alias: ls) List the records within a zone +│ │ ├── add [domain] [name] [type] [values..] [--ttl] [--comment] [--pull-zone] [--script] +│ │ │ Add a DNS record (interactive wizard when args omitted; MX/SRV/CAA use positional values; PullZone/Script use --pull-zone/--script) +│ │ ├── update [domain] [id] [--name] [--value] [--type] [--ttl] [--priority] [--weight] [--port] [--flags] [--tag] [--comment] [--disabled] [--pull-zone] [--script] +│ │ │ 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) +│ │ ├── import [domain] [file] Import records from a BIND zone file (prompts for zone/file when omitted) +│ │ └── export [domain] [--file] [--save] Export a zone as a BIND zone file (stdout, --file , or --save → .zone) +│ └── zone (aliases: zones; hidden: domain, domains) +│ ├── list List all DNS zones (alias: ls) │ ├── add Create a DNS zone │ ├── show [domain] Show zone details (nameservers, SOA, DNSSEC, logging, record count) │ ├── remove [domain] [--force] Delete a DNS zone and its records (alias: rm) +│ ├── stats [domain] [--from] [--to] Show DNS query statistics for a zone (defaults to last 30 days; text mode renders a bar chart) +│ ├── nameservers [domain] (alias: ns) Show the nameservers to set at the registrar (custom if enabled, else bunny.net defaults) │ ├── dnssec │ │ ├── enable [domain] Enable DNSSEC and print the DS record for the registrar │ │ └── disable [domain] [--force] Disable DNSSEC diff --git a/packages/cli/README.md b/packages/cli/README.md index 4f14b77..112d9ac 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -398,53 +398,51 @@ bunny registries remove > **Experimental** — hidden from `--help` and the landing page while it stabilizes. -Manage DNS zones and records. 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. `update` and `remove` likewise prompt you to pick a record when the ID is omitted. +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 -# List all DNS zones -bunny dns list -bunny dns ls - -# List records within a zone -bunny dns list example.com +# Records — list within a zone +bunny dns record list example.com +bunny dns rec ls example.com # Add records (use '@' for the zone apex) -bunny dns add example.com api A 198.51.100.1 -bunny dns add example.com '@' MX mail.example.com 10 -bunny dns add example.com '@' SRV 10 0 389 sip.example.com -bunny dns add example.com '@' CAA '0 issue "letsencrypt.org"' +bunny dns record add example.com api A 198.51.100.1 +bunny dns record add example.com '@' MX mail.example.com 10 +bunny dns record add example.com '@' SRV 10 0 389 sip.example.com +bunny dns record add example.com '@' CAA '0 issue "letsencrypt.org"' # Link a record to a pull zone or Edge Script -bunny dns add example.com cdn PullZone --pull-zone 12345 -bunny dns add example.com fn Script --script 67890 +bunny dns record add example.com cdn PullZone --pull-zone 12345 +bunny dns record add example.com fn Script --script 67890 # Interactive wizard — omit the record type (or all args) to be prompted -bunny dns add -bunny dns add example.com +bunny dns record add +bunny dns record add example.com # Update / remove a record by its ID -bunny dns update example.com 123 --value 198.51.100.2 --ttl 3600 -bunny dns remove example.com 123 +bunny dns record update example.com 123 --value 198.51.100.2 --ttl 3600 +bunny dns record remove example.com 123 # Import / export a BIND zone file -bunny dns import example.com ./zonefile.txt -bunny dns export example.com # print to stdout -bunny dns export example.com --file ./my.zone # write to a path -bunny dns export example.com --save # write to ./example.com.zone - -# Query statistics (defaults to the last 30 days) -bunny dns stats example.com -bunny dns stats example.com --from 2026-05-01 --to 2026-05-31 - -# Nameservers to set at your registrar (custom if enabled, else bunny.net defaults) -bunny dns nameservers example.com -bunny dns ns example.com +bunny dns record import example.com ./zonefile.txt +bunny dns record export example.com # print to stdout +bunny dns record export example.com --file ./my.zone # write to a path +bunny dns record export example.com --save # write to ./example.com.zone -# Manage zones +# Zones — lifecycle +bunny dns zone list bunny dns zone add example.com bunny dns zone show example.com bunny dns zone remove example.com +# Query statistics (defaults to the last 30 days; text mode draws a bar chart) +bunny dns zone stats example.com +bunny dns zone stats example.com --from 2026-05-01 --to 2026-05-31 + +# Nameservers to set at your registrar (custom if enabled, else bunny.net defaults) +bunny dns zone nameservers example.com +bunny dns zone ns example.com + # DNSSEC — enable prints the DS record to register at your domain registrar bunny dns zone dnssec enable example.com bunny dns zone dnssec disable example.com @@ -455,18 +453,18 @@ bunny dns zone logging enable example.com --anonymize-ip --anonymization drop bunny dns zone logging disable example.com ``` -Positional value ordering follows the record type: `A`/`AAAA`/`CNAME`/`TXT`/`NS` take a single value, `MX` takes ` `, `SRV` takes ` `, and `CAA` takes a single quoted `' ""'` string. `PullZone` and `Script` records take no positional value — pass `--pull-zone ` or `--script ` instead. Omit the record type (or all arguments) to run an interactive wizard that prompts for the zone, type, and per-type values. - -| Flag | Commands | Description | -| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- | -| `--ttl` | `add`, `update` | Time to live in seconds | -| `--comment` | `add`, `update` | Optional comment for the record | -| `--pull-zone`, `--script` | `add`, `update` | Link a `PullZone` / `Script` record by ID | -| `--name`, `--value`, `--type`, `--priority`, `--weight`, `--port`, `--flags`, `--tag`, `--disabled` | `update` | Edit individual record fields (see `bunny dns update --help`) | -| `--file`, `--save` | `export` | Write to a path, or to `.zone` in the current directory | -| `--from`, `--to` | `stats` | Date range (defaults to the last 30 days) | -| `--anonymize-ip`, `--anonymization` | `zone logging enable` | Anonymize client IPs in logs (`onedigit` \| `drop`) | -| `--force` | `remove`, `zone remove`, `zone dnssec disable`, `zone logging disable` | Skip the confirmation prompt | +Positional value ordering for `record add` follows the record type: `A`/`AAAA`/`CNAME`/`TXT`/`NS` take a single value, `MX` takes ` `, `SRV` takes ` `, and `CAA` takes a single quoted `' ""'` string. `PullZone` and `Script` records take no positional value — pass `--pull-zone ` or `--script ` instead. Omit the record type (or all arguments) to run an interactive wizard that prompts for the zone, type, and per-type values. + +| Flag | Commands | Description | +| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `--ttl` | `record add`, `record update` | Time to live in seconds | +| `--comment` | `record add`, `record update` | Optional comment for the record | +| `--pull-zone`, `--script` | `record add`, `record update` | Link a `PullZone` / `Script` record by ID | +| `--name`, `--value`, `--type`, `--priority`, `--weight`, `--port`, `--flags`, `--tag`, `--disabled` | `record update` | Edit individual record fields (see `bunny dns record update --help`) | +| `--file`, `--save` | `record export` | Write to a path, or to `.zone` in the current directory | +| `--from`, `--to` | `zone stats` | Date range (defaults to the last 30 days) | +| `--anonymize-ip`, `--anonymization` | `zone logging enable` | Anonymize client IPs in logs (`onedigit` \| `drop`) | +| `--force` | `record remove`, `zone remove`, `zone dnssec disable`, `zone logging disable` | Skip the confirmation prompt | ### `bunny scripts` diff --git a/packages/cli/src/commands/dns/index.ts b/packages/cli/src/commands/dns/index.ts index a11a237..d3ed052 100644 --- a/packages/cli/src/commands/dns/index.ts +++ b/packages/cli/src/commands/dns/index.ts @@ -1,26 +1,9 @@ import { defineNamespace } from "../../core/define-namespace.ts"; -import { dnsAddCommand } from "./add.ts"; -import { dnsExportCommand } from "./export.ts"; -import { dnsImportCommand } from "./import.ts"; -import { dnsListCommand } from "./list.ts"; -import { dnsNameserversCommand } from "./nameservers.ts"; -import { dnsRemoveCommand } from "./remove.ts"; -import { dnsStatsCommand } from "./stats.ts"; -import { dnsUpdateCommand } from "./update.ts"; -import { dnsZoneNamespace } from "./zone/index.ts"; +import { dnsRecordNamespace } from "./record/index.ts"; +import { dnsZoneHiddenAliases, dnsZoneNamespace } from "./zone/index.ts"; export const dnsNamespace = defineNamespace( "dns", "Manage DNS zones and records.", - [ - dnsListCommand, - dnsAddCommand, - dnsUpdateCommand, - dnsRemoveCommand, - dnsImportCommand, - dnsExportCommand, - dnsStatsCommand, - dnsNameserversCommand, - dnsZoneNamespace, - ], + [dnsRecordNamespace, dnsZoneNamespace, ...dnsZoneHiddenAliases], ); diff --git a/packages/cli/src/commands/dns/list.ts b/packages/cli/src/commands/dns/list.ts deleted file mode 100644 index 6d2207b..0000000 --- a/packages/cli/src/commands/dns/list.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createCoreClient } from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { formatTable } from "../../core/format.ts"; -import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; -import { fetchZones, resolveZone } from "./api.ts"; -import { - formatRecordValue, - recordName, - recordTypeLabel, -} from "./record-types.ts"; - -interface ListArgs { - domain?: string; -} - -export const dnsListCommand = defineCommand({ - command: "list [domain]", - aliases: ["ls"], - describe: "List DNS zones, or the records within a zone.", - examples: [ - ["$0 dns list", "List all DNS zones"], - ["$0 dns list example.com", "List records in a zone"], - ["$0 dns list example.com --output json", "JSON output"], - ], - - builder: (yargs) => - yargs.positional("domain", { - type: "string", - describe: "Domain or zone ID (omit to list all zones)", - }), - - handler: async ({ domain, profile, output, verbose, apiKey }) => { - const config = resolveConfig(profile, apiKey, verbose); - const client = createCoreClient(clientOptions(config, verbose)); - - if (domain) { - const spin = spinner("Fetching DNS records..."); - spin.start(); - const zone = await resolveZone(client, domain); - spin.stop(); - - const records = (zone.Records ?? []).sort((a, b) => - recordName(a.Name).localeCompare(recordName(b.Name)), - ); - - if (output === "json") { - logger.log(JSON.stringify(zone, null, 2)); - return; - } - - if (records.length === 0) { - logger.info(`No records found in ${zone.Domain}.`); - return; - } - - logger.log( - formatTable( - ["ID", "Name", "Type", "Value", "TTL"], - records.map((r) => [ - String(r.Id ?? ""), - recordName(r.Name), - recordTypeLabel(r.Type), - formatRecordValue(r), - String(r.Ttl ?? ""), - ]), - output, - ), - ); - return; - } - - const spin = spinner("Fetching DNS zones..."); - spin.start(); - const zones = await fetchZones(client); - spin.stop(); - - if (output === "json") { - logger.log(JSON.stringify(zones, null, 2)); - return; - } - - if (zones.length === 0) { - logger.info("No DNS zones found."); - return; - } - - logger.log( - formatTable( - ["ID", "Domain", "Records", "DNSSEC", "Nameservers"], - zones.map((z) => [ - String(z.Id ?? ""), - z.Domain ?? "", - String((z.Records ?? []).length), - z.DnsSecEnabled ? "Yes" : "No", - z.NameserversDetected ? "Detected" : "Pending", - ]), - output, - ), - ); - }, -}); diff --git a/packages/cli/src/commands/dns/add.ts b/packages/cli/src/commands/dns/record/add.ts similarity index 91% rename from packages/cli/src/commands/dns/add.ts rename to packages/cli/src/commands/dns/record/add.ts index cdd04b4..1e98bf6 100644 --- a/packages/cli/src/commands/dns/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -1,19 +1,19 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; import prompts from "prompts"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { UserError } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; -import { resolveZoneInteractive } from "./interactive.ts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; import { parseRecordType, RECORD_TYPES, recordName, recordTypeLabel, -} from "./record-types.ts"; +} from "../record-types.ts"; type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; @@ -204,17 +204,20 @@ export const dnsAddCommand = defineCommand({ command: "add [domain] [name] [type] [values..]", describe: "Add a DNS record to a zone (interactive when args are omitted).", examples: [ - ["$0 dns add example.com api A 198.51.100.1", "Add an A record"], - ["$0 dns add example.com '@' MX mail.example.com 10", "Add an MX record"], + ["$0 dns record add example.com api A 198.51.100.1", "Add an A record"], [ - "$0 dns add example.com '@' SRV 10 0 389 sip.example.com", + "$0 dns record add example.com '@' MX mail.example.com 10", + "Add an MX record", + ], + [ + "$0 dns record add example.com '@' SRV 10 0 389 sip.example.com", "Add an SRV record", ], [ - "$0 dns add example.com '@' CAA '0 issue \"letsencrypt.org\"'", + "$0 dns record add example.com '@' CAA '0 issue \"letsencrypt.org\"'", "Add a CAA record", ], - ["$0 dns add", "Interactive wizard"], + ["$0 dns record add", "Interactive wizard"], ], builder: (yargs) => diff --git a/packages/cli/src/commands/dns/export.ts b/packages/cli/src/commands/dns/record/export.ts similarity index 74% rename from packages/cli/src/commands/dns/export.ts rename to packages/cli/src/commands/dns/record/export.ts index 9d8b78f..3e10976 100644 --- a/packages/cli/src/commands/dns/export.ts +++ b/packages/cli/src/commands/dns/record/export.ts @@ -1,10 +1,10 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -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 { 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"; interface ExportArgs { domain?: string; @@ -16,9 +16,12 @@ export const dnsExportCommand = defineCommand({ command: "export [domain]", describe: "Export a zone's records as a BIND zone file.", examples: [ - ["$0 dns export example.com", "Print the zone file to stdout"], - ["$0 dns export example.com --file ./example.zone", "Write to a path"], - ["$0 dns export example.com --save", "Write to ./example.com.zone"], + ["$0 dns record export example.com", "Print the zone file to stdout"], + [ + "$0 dns record export example.com --file ./example.zone", + "Write to a path", + ], + ["$0 dns record export example.com --save", "Write to ./example.com.zone"], ], builder: (yargs) => diff --git a/packages/cli/src/commands/dns/import.ts b/packages/cli/src/commands/dns/record/import.ts similarity index 83% rename from packages/cli/src/commands/dns/import.ts rename to packages/cli/src/commands/dns/record/import.ts index f0cb15d..ac4f9ab 100644 --- a/packages/cli/src/commands/dns/import.ts +++ b/packages/cli/src/commands/dns/record/import.ts @@ -1,12 +1,12 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import prompts from "prompts"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { UserError } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; -import { resolveZoneInteractive } from "./interactive.ts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; interface ImportArgs { domain?: string; @@ -18,7 +18,7 @@ export const dnsImportCommand = defineCommand({ describe: "Import DNS records into a zone from a BIND zone file.", examples: [ [ - "$0 dns import example.com ./zonefile.txt", + "$0 dns record import example.com ./zonefile.txt", "Import records from a zone file", ], ], diff --git a/packages/cli/src/commands/dns/record/index.ts b/packages/cli/src/commands/dns/record/index.ts new file mode 100644 index 0000000..a3cc7bf --- /dev/null +++ b/packages/cli/src/commands/dns/record/index.ts @@ -0,0 +1,21 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { dnsAddCommand } from "./add.ts"; +import { dnsExportCommand } from "./export.ts"; +import { dnsImportCommand } from "./import.ts"; +import { dnsRecordListCommand } from "./list.ts"; +import { dnsRemoveCommand } from "./remove.ts"; +import { dnsUpdateCommand } from "./update.ts"; + +export const dnsRecordNamespace = defineNamespace( + "record", + "Manage the DNS records within a zone.", + [ + dnsRecordListCommand, + dnsAddCommand, + dnsUpdateCommand, + dnsRemoveCommand, + dnsImportCommand, + dnsExportCommand, + ], + ["records", "rec"], +); diff --git a/packages/cli/src/commands/dns/record/list.ts b/packages/cli/src/commands/dns/record/list.ts new file mode 100644 index 0000000..88356e4 --- /dev/null +++ b/packages/cli/src/commands/dns/record/list.ts @@ -0,0 +1,67 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; +import { + formatRecordValue, + recordName, + recordTypeLabel, +} from "../record-types.ts"; + +interface ListArgs { + domain?: string; +} + +export const dnsRecordListCommand = defineCommand({ + command: "list [domain]", + aliases: ["ls"], + describe: "List the records within a zone.", + examples: [ + ["$0 dns record list example.com", "List records in a zone"], + ["$0 dns record list example.com --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("domain", { + type: "string", + describe: "Domain or zone ID", + }), + + handler: async ({ domain, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const zone = await resolveZoneInteractive(client, domain); + + const records = (zone.Records ?? []).sort((a, b) => + recordName(a.Name).localeCompare(recordName(b.Name)), + ); + + if (output === "json") { + logger.log(JSON.stringify(zone, null, 2)); + return; + } + + if (records.length === 0) { + logger.info(`No records found in ${zone.Domain}.`); + return; + } + + logger.log( + formatTable( + ["ID", "Name", "Type", "Value", "TTL"], + records.map((r) => [ + String(r.Id ?? ""), + recordName(r.Name), + recordTypeLabel(r.Type), + formatRecordValue(r), + String(r.Ttl ?? ""), + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/dns/remove.ts b/packages/cli/src/commands/dns/record/remove.ts similarity index 77% rename from packages/cli/src/commands/dns/remove.ts rename to packages/cli/src/commands/dns/record/remove.ts index 7844ef0..0d84d16 100644 --- a/packages/cli/src/commands/dns/remove.ts +++ b/packages/cli/src/commands/dns/record/remove.ts @@ -1,18 +1,18 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -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 { confirm, spinner } from "../../core/ui.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 { confirm, spinner } from "../../../core/ui.ts"; import { resolveRecordInteractive, resolveZoneInteractive, -} from "./interactive.ts"; +} from "../interactive.ts"; import { formatRecordValue, recordName, recordTypeLabel, -} from "./record-types.ts"; +} from "../record-types.ts"; interface RemoveArgs { domain?: string; @@ -25,9 +25,9 @@ export const dnsRemoveCommand = defineCommand({ aliases: ["rm"], describe: "Remove a DNS record from a zone (prompts when args are omitted).", examples: [ - ["$0 dns remove example.com 123", "Remove a record by ID"], - ["$0 dns remove example.com 123 --force", "Skip confirmation"], - ["$0 dns remove", "Pick a zone and record interactively"], + ["$0 dns record remove example.com 123", "Remove a record by ID"], + ["$0 dns record remove example.com 123 --force", "Skip confirmation"], + ["$0 dns record remove", "Pick a zone and record interactively"], ], builder: (yargs) => diff --git a/packages/cli/src/commands/dns/update.ts b/packages/cli/src/commands/dns/record/update.ts similarity index 87% rename from packages/cli/src/commands/dns/update.ts rename to packages/cli/src/commands/dns/record/update.ts index 8a1618a..23296ac 100644 --- a/packages/cli/src/commands/dns/update.ts +++ b/packages/cli/src/commands/dns/record/update.ts @@ -1,15 +1,15 @@ 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 { 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 { resolveRecordInteractive, resolveZoneInteractive, -} from "./interactive.ts"; -import { parseRecordType, recordName } from "./record-types.ts"; +} from "../interactive.ts"; +import { parseRecordType, recordName } from "../record-types.ts"; type UpdateDnsRecordModel = components["schemas"]["UpdateDnsRecordModel"]; @@ -37,13 +37,13 @@ export const dnsUpdateCommand = defineCommand({ describe: "Update an existing DNS record (prompts when args are omitted).", examples: [ [ - "$0 dns update example.com 123 --value 198.51.100.2", + "$0 dns record update example.com 123 --value 198.51.100.2", "Change a record value", ], - ["$0 dns update example.com 123 --ttl 3600", "Change the TTL"], - ["$0 dns update example.com 123 --disabled", "Disable a record"], + ["$0 dns record update example.com 123 --ttl 3600", "Change the TTL"], + ["$0 dns record update example.com 123 --disabled", "Disable a record"], [ - "$0 dns update example.com --value 198.51.100.2", + "$0 dns record update example.com --value 198.51.100.2", "Pick the record interactively", ], ], diff --git a/packages/cli/src/commands/dns/zone/index.ts b/packages/cli/src/commands/dns/zone/index.ts index 92a5a5b..f3900dd 100644 --- a/packages/cli/src/commands/dns/zone/index.ts +++ b/packages/cli/src/commands/dns/zone/index.ts @@ -1,14 +1,33 @@ +import type { CommandModule } from "yargs"; import { defineNamespace } from "../../../core/define-namespace.ts"; import { dnsZoneAddCommand } from "./add.ts"; import { dnsZoneDnssecNamespace } from "./dnssec/index.ts"; +import { dnsZoneListCommand } from "./list.ts"; import { dnsZoneLoggingNamespace } from "./logging/index.ts"; +import { dnsNameserversCommand } from "./nameservers.ts"; import { dnsZoneRemoveCommand } from "./remove.ts"; import { dnsZoneShowCommand } from "./show.ts"; +import { dnsStatsCommand } from "./stats.ts"; -export const dnsZoneNamespace = defineNamespace("zone", "Manage DNS zones.", [ +const subcommands: CommandModule[] = [ + dnsZoneListCommand, dnsZoneAddCommand, dnsZoneShowCommand, dnsZoneRemoveCommand, + dnsStatsCommand, + dnsNameserversCommand, dnsZoneDnssecNamespace, dnsZoneLoggingNamespace, -]); +]; + +export const dnsZoneNamespace = defineNamespace( + "zone", + "Manage DNS zones — settings, DNSSEC, logging, stats, nameservers.", + subcommands, + ["zones"], +); + +// Hidden aliases so `bunny dns domain …` works without cluttering help. +export const dnsZoneHiddenAliases: CommandModule[] = ["domain", "domains"].map( + (name) => defineNamespace(name, false, subcommands), +); diff --git a/packages/cli/src/commands/dns/zone/list.ts b/packages/cli/src/commands/dns/zone/list.ts new file mode 100644 index 0000000..26457c2 --- /dev/null +++ b/packages/cli/src/commands/dns/zone/list.ts @@ -0,0 +1,52 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { fetchZones } from "../api.ts"; + +export const dnsZoneListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List all DNS zones.", + examples: [ + ["$0 dns zone list", "List all DNS zones"], + ["$0 dns zone list --output json", "JSON output"], + ], + + handler: async ({ profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching DNS zones..."); + spin.start(); + const zones = await fetchZones(client); + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(zones, null, 2)); + return; + } + + if (zones.length === 0) { + logger.info("No DNS zones found."); + return; + } + + logger.log( + formatTable( + ["ID", "Domain", "Records", "DNSSEC", "Nameservers"], + zones.map((z) => [ + String(z.Id ?? ""), + z.Domain ?? "", + String((z.Records ?? []).length), + z.DnsSecEnabled ? "Yes" : "No", + z.NameserversDetected ? "Detected" : "Pending", + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/dns/nameservers.ts b/packages/cli/src/commands/dns/zone/nameservers.ts similarity index 79% rename from packages/cli/src/commands/dns/nameservers.ts rename to packages/cli/src/commands/dns/zone/nameservers.ts index 2595d08..420cde8 100644 --- a/packages/cli/src/commands/dns/nameservers.ts +++ b/packages/cli/src/commands/dns/zone/nameservers.ts @@ -1,10 +1,10 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { formatKeyValue, formatTable } from "../../core/format.ts"; -import { logger } from "../../core/logger.ts"; -import { resolveZoneInteractive } from "./interactive.ts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatKeyValue, formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; interface NameserversArgs { domain?: string; @@ -18,8 +18,8 @@ export const dnsNameserversCommand = defineCommand({ aliases: ["ns"], describe: "Show the nameservers to set at your registrar for a zone.", examples: [ - ["$0 dns nameservers example.com", "Show the zone's nameservers"], - ["$0 dns ns example.com --output json", "JSON output"], + ["$0 dns zone nameservers example.com", "Show the zone's nameservers"], + ["$0 dns zone ns example.com --output json", "JSON output"], ], builder: (yargs) => diff --git a/packages/cli/src/commands/dns/stats.ts b/packages/cli/src/commands/dns/zone/stats.ts similarity index 85% rename from packages/cli/src/commands/dns/stats.ts rename to packages/cli/src/commands/dns/zone/stats.ts index 128dd88..6c42334 100644 --- a/packages/cli/src/commands/dns/stats.ts +++ b/packages/cli/src/commands/dns/zone/stats.ts @@ -1,13 +1,13 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import chalk from "chalk"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { bunny } from "../../core/colors.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { formatKeyValue, formatTable } from "../../core/format.ts"; -import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; -import { resolveZoneInteractive } from "./interactive.ts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { bunny } from "../../../core/colors.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatKeyValue, formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; interface StatsArgs { domain?: string; @@ -46,9 +46,9 @@ export const dnsStatsCommand = defineCommand({ command: "stats [domain]", describe: "Show DNS query statistics for a zone.", examples: [ - ["$0 dns stats example.com", "Statistics for the last 30 days"], + ["$0 dns zone stats example.com", "Statistics for the last 30 days"], [ - "$0 dns stats example.com --from 2026-05-01 --to 2026-05-31", + "$0 dns zone stats example.com --from 2026-05-01 --to 2026-05-31", "Statistics for a date range", ], ], diff --git a/packages/cli/src/core/define-namespace.ts b/packages/cli/src/core/define-namespace.ts index f515eab..ccf9c1a 100644 --- a/packages/cli/src/core/define-namespace.ts +++ b/packages/cli/src/core/define-namespace.ts @@ -4,6 +4,9 @@ import type { Argv, CommandModule } from "yargs"; * Groups subcommands under a parent namespace. Running the namespace * without a subcommand shows help. * + * Pass `describe: false` to hide the namespace from help (e.g. a hidden + * alias). Pass `aliases` to expose alternative names shown in help. + * * @example * ```ts * export const authNamespace = defineNamespace( @@ -15,12 +18,14 @@ import type { Argv, CommandModule } from "yargs"; */ export function defineNamespace( command: string, - describe: string, + describe: string | false, subcommands: CommandModule[], + aliases?: string[], ): CommandModule { let yRef: Argv; return { command, + aliases, describe, builder: (yargs) => { yRef = yargs; From 3f669330fbd98b5cdd5d34b4b9d4c6b90d87d8ee Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 16:19:40 +0100 Subject: [PATCH 3/5] fix logging and stats ui --- packages/cli/src/commands/dns/query-types.ts | 66 +++++++++++++++++++ packages/cli/src/commands/dns/record/add.ts | 29 ++++---- .../src/commands/dns/zone/logging/enable.ts | 18 +++-- packages/cli/src/commands/dns/zone/stats.ts | 7 +- 4 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/commands/dns/query-types.ts diff --git a/packages/cli/src/commands/dns/query-types.ts b/packages/cli/src/commands/dns/query-types.ts new file mode 100644 index 0000000..84f2486 --- /dev/null +++ b/packages/cli/src/commands/dns/query-types.ts @@ -0,0 +1,66 @@ +// IANA DNS resource-record (RR) TYPE codes — the on-the-wire query types +// surfaced by DnsZoneStatisticsModel.QueriesByTypeChart. These are the +// standard registry values, NOT the bunny DnsRecordTypes management enum +// (0–15): https://www.iana.org/assignments/dns-parameters +const DNS_QUERY_TYPES: Record = { + 1: "A", + 2: "NS", + 5: "CNAME", + 6: "SOA", + 12: "PTR", + 13: "HINFO", + 15: "MX", + 16: "TXT", + 17: "RP", + 18: "AFSDB", + 24: "SIG", + 25: "KEY", + 28: "AAAA", + 29: "LOC", + 33: "SRV", + 35: "NAPTR", + 36: "KX", + 37: "CERT", + 39: "DNAME", + 43: "DS", + 44: "SSHFP", + 45: "IPSECKEY", + 46: "RRSIG", + 47: "NSEC", + 48: "DNSKEY", + 49: "DHCID", + 50: "NSEC3", + 51: "NSEC3PARAM", + 52: "TLSA", + 53: "SMIMEA", + 55: "HIP", + 59: "CDS", + 60: "CDNSKEY", + 61: "OPENPGPKEY", + 62: "CSYNC", + 63: "ZONEMD", + 64: "SVCB", + 65: "HTTPS", + 99: "SPF", + 108: "EUI48", + 109: "EUI64", + 249: "TKEY", + 250: "TSIG", + 251: "IXFR", + 252: "AXFR", + 255: "ANY", + 256: "URI", + 257: "CAA", + 32768: "TA", + 32769: "DLV", +}; + +/** + * Human label for an IANA DNS query-type code. Unknown codes fall back to the + * RFC 3597 `TYPEn` presentation (e.g. `TYPE42`). + */ +export function queryTypeLabel(code: string | number): string { + const n = typeof code === "number" ? code : Number.parseInt(code, 10); + if (Number.isNaN(n)) return String(code); + return DNS_QUERY_TYPES[n] ?? `TYPE${n}`; +} diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index 1e98bf6..3e0b437 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -9,6 +9,7 @@ import { logger } from "../../../core/logger.ts"; import { spinner } from "../../../core/ui.ts"; import { resolveZoneInteractive } from "../interactive.ts"; import { + type DnsRecordTypes, parseRecordType, RECORD_TYPES, recordName, @@ -16,6 +17,7 @@ import { } from "../record-types.ts"; type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; +type RecordLinks = Pick; interface AddArgs { domain?: string; @@ -28,34 +30,29 @@ interface AddArgs { script?: number; } -interface RecordOpts { - pullZoneId?: number; - scriptId?: number; -} - /** Build the request body from positional values, honouring per-type grammar. */ function buildRecord( - type: number, + type: DnsRecordTypes, name: string, values: string[], - opts: RecordOpts, + links: RecordLinks, ): AddDnsRecordModel { const record: AddDnsRecordModel = { - Type: type as never, + Type: type, Name: name === "@" ? "" : name, }; if (type === RECORD_TYPES.PULLZONE) { - if (opts.pullZoneId === undefined) + if (links.PullZoneId == null) throw new UserError("PullZone records require --pull-zone ."); - record.PullZoneId = opts.pullZoneId; + record.PullZoneId = links.PullZoneId; return record; } if (type === RECORD_TYPES.SCRIPT) { - if (opts.scriptId === undefined) + if (links.ScriptId == null) throw new UserError("Script records require --script ."); - record.ScriptId = opts.scriptId; + record.ScriptId = links.ScriptId; return record; } @@ -118,11 +115,11 @@ function required(value: T | undefined, label: string): T { /** Interactively gather a record when positional args were omitted. */ async function promptRecord( - type: number, + type: DnsRecordTypes, name: string, ): Promise { const record: AddDnsRecordModel = { - Type: type as never, + Type: type, Name: name === "@" ? "" : name, }; @@ -300,8 +297,8 @@ export const dnsAddCommand = defineCommand({ const name = args.name ?? "@"; const values = (args.values ?? []).map((v) => String(v)); record = buildRecord(type, name, values, { - pullZoneId: args["pull-zone"], - scriptId: args.script, + PullZoneId: args["pull-zone"], + ScriptId: args.script, }); } diff --git a/packages/cli/src/commands/dns/zone/logging/enable.ts b/packages/cli/src/commands/dns/zone/logging/enable.ts index c60abdc..c76cbd5 100644 --- a/packages/cli/src/commands/dns/zone/logging/enable.ts +++ b/packages/cli/src/commands/dns/zone/logging/enable.ts @@ -1,4 +1,5 @@ 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"; @@ -7,6 +8,12 @@ import { logger } from "../../../../core/logger.ts"; import { spinner } from "../../../../core/ui.ts"; import { resolveZoneInteractive } from "../../interactive.ts"; +type LogAnonymizationType = components["schemas"]["LogAnonymizationType"]; +type LoggingUpdate = Pick< + components["schemas"]["UpdateDnsZoneModel"], + "LoggingEnabled" | "LoggingIPAnonymizationEnabled" | "LogAnonymizationType" +>; + interface EnableArgs { domain?: string; "anonymize-ip"?: boolean; @@ -14,7 +21,10 @@ interface EnableArgs { } // LogAnonymizationType: 0 = OneDigit, 1 = Drop -const ANONYMIZATION: Record = { onedigit: 0, drop: 1 }; +const ANONYMIZATION: Record = { + onedigit: 0, + drop: 1, +}; export const dnsZoneLoggingEnableCommand = defineCommand({ command: "enable [domain]", @@ -47,11 +57,7 @@ export const dnsZoneLoggingEnableCommand = defineCommand({ const zone = await resolveZoneInteractive(client, domain); - const body: { - LoggingEnabled: boolean; - LoggingIPAnonymizationEnabled?: boolean; - LogAnonymizationType?: 0 | 1; - } = { LoggingEnabled: true }; + const body: LoggingUpdate = { LoggingEnabled: true }; if (args["anonymize-ip"] !== undefined) { body.LoggingIPAnonymizationEnabled = args["anonymize-ip"]; diff --git a/packages/cli/src/commands/dns/zone/stats.ts b/packages/cli/src/commands/dns/zone/stats.ts index 6c42334..7bc105f 100644 --- a/packages/cli/src/commands/dns/zone/stats.ts +++ b/packages/cli/src/commands/dns/zone/stats.ts @@ -8,6 +8,7 @@ import { formatKeyValue, formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; import { spinner } from "../../../core/ui.ts"; import { resolveZoneInteractive } from "../interactive.ts"; +import { queryTypeLabel } from "../query-types.ts"; interface StatsArgs { domain?: string; @@ -110,9 +111,9 @@ export const dnsStatsCommand = defineCommand({ ), ); - const byType = Object.entries(data?.QueriesByTypeChart ?? {}).sort( - (a, b) => b[1] - a[1], - ); + const byType = Object.entries(data?.QueriesByTypeChart ?? {}) + .map(([code, count]): [string, number] => [queryTypeLabel(code), count]) + .sort((a, b) => b[1] - a[1]); if (byType.length > 0) { logger.log(""); logger.dim(" Queries by type"); From 48697c5654b1ec3b8310107b6548f19b667c904c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 20:45:22 +0100 Subject: [PATCH 4/5] apply code suggestions --- packages/cli/src/commands/dns/api.ts | 2 +- packages/cli/src/commands/dns/index.ts | 11 ++++--- packages/cli/src/commands/dns/interactive.ts | 8 +++-- packages/cli/src/commands/dns/record/add.ts | 16 +++++---- .../cli/src/commands/dns/record/export.ts | 14 +++++--- .../cli/src/commands/dns/record/import.ts | 23 +++++++------ packages/cli/src/commands/dns/record/list.ts | 2 +- .../cli/src/commands/dns/record/remove.ts | 13 +++++--- .../cli/src/commands/dns/record/update.ts | 24 ++++++++++---- packages/cli/src/commands/dns/zone/add.ts | 28 +++++++++------- .../src/commands/dns/zone/dnssec/enable.ts | 13 +++++--- packages/cli/src/commands/dns/zone/list.ts | 10 ++++-- .../src/commands/dns/zone/logging/enable.ts | 18 +++++++--- packages/cli/src/commands/dns/zone/remove.ts | 11 ++++--- .../openapi-client/src/middleware.test.ts | 25 ++++++++++++-- packages/openapi-client/src/middleware.ts | 33 +++++-------------- 16 files changed, 157 insertions(+), 94 deletions(-) diff --git a/packages/cli/src/commands/dns/api.ts b/packages/cli/src/commands/dns/api.ts index 667dbb6..97d59f7 100644 --- a/packages/cli/src/commands/dns/api.ts +++ b/packages/cli/src/commands/dns/api.ts @@ -57,7 +57,7 @@ export async function resolveZone( if (!match?.Id) { throw new UserError( `No DNS zone found for "${domainOrId}".`, - 'Run "bunny dns list" to see your zones.', + 'Run "bunny dns zone list" to see your zones.', ); } return fetchZone(client, match.Id); diff --git a/packages/cli/src/commands/dns/index.ts b/packages/cli/src/commands/dns/index.ts index d3ed052..131b52a 100644 --- a/packages/cli/src/commands/dns/index.ts +++ b/packages/cli/src/commands/dns/index.ts @@ -2,8 +2,9 @@ import { defineNamespace } from "../../core/define-namespace.ts"; import { dnsRecordNamespace } from "./record/index.ts"; import { dnsZoneHiddenAliases, dnsZoneNamespace } from "./zone/index.ts"; -export const dnsNamespace = defineNamespace( - "dns", - "Manage DNS zones and records.", - [dnsRecordNamespace, dnsZoneNamespace, ...dnsZoneHiddenAliases], -); +// Hidden from help while experimental, matching the apps and registries namespaces. +export const dnsNamespace = defineNamespace("dns", false, [ + dnsRecordNamespace, + dnsZoneNamespace, + ...dnsZoneHiddenAliases, +]); diff --git a/packages/cli/src/commands/dns/interactive.ts b/packages/cli/src/commands/dns/interactive.ts index e4f751a..edb9952 100644 --- a/packages/cli/src/commands/dns/interactive.ts +++ b/packages/cli/src/commands/dns/interactive.ts @@ -35,8 +35,12 @@ export async function resolveZoneInteractive( const spin = spinner("Fetching zones..."); spin.start(); - const zones = await fetchZones(client); - spin.stop(); + let zones: DnsZoneModel[]; + try { + zones = await fetchZones(client); + } finally { + spin.stop(); + } if (zones.length === 0) { throw new UserError( diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index 3e0b437..d931eba 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -307,11 +307,15 @@ export const dnsAddCommand = defineCommand({ const spin = spinner("Adding record..."); spin.start(); - const { data } = await client.PUT("/dnszone/{zoneId}/records", { - params: { path: { zoneId: zone.Id as number } }, - body: record, - }); - spin.stop(); + 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)); @@ -319,7 +323,7 @@ export const dnsAddCommand = defineCommand({ } logger.success( - `Added ${recordTypeLabel(record.Type as number)} record ${recordName(record.Name)} to ${zone.Domain} (ID: ${data?.Id}).`, + `Added ${recordTypeLabel(record.Type as number)} record ${recordName(record.Name)} to ${zone.Domain}${data?.Id != null ? ` (ID: ${data.Id})` : ""}.`, ); }, }); diff --git a/packages/cli/src/commands/dns/record/export.ts b/packages/cli/src/commands/dns/record/export.ts index 3e10976..5cea03a 100644 --- a/packages/cli/src/commands/dns/record/export.ts +++ b/packages/cli/src/commands/dns/record/export.ts @@ -45,11 +45,15 @@ export const dnsExportCommand = defineCommand({ const spin = spinner("Exporting zone..."); spin.start(); - const { data } = await client.GET("/dnszone/{id}/export", { - params: { path: { id: zone.Id as number } }, - parseAs: "text", - }); - spin.stop(); + let data: unknown; + try { + ({ data } = await client.GET("/dnszone/{id}/export", { + params: { path: { id: zone.Id as number } }, + parseAs: "text", + })); + } finally { + spin.stop(); + } const zonefile = (data as string) ?? ""; diff --git a/packages/cli/src/commands/dns/record/import.ts b/packages/cli/src/commands/dns/record/import.ts index ac4f9ab..d6c9672 100644 --- a/packages/cli/src/commands/dns/record/import.ts +++ b/packages/cli/src/commands/dns/record/import.ts @@ -57,16 +57,19 @@ export const dnsImportCommand = defineCommand({ const spin = spinner("Importing records..."); spin.start(); - // The import endpoint takes the raw zone file as the request body, - // which the generated spec does not model — pass it through directly. - const { 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); - - spin.stop(); + 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 | { diff --git a/packages/cli/src/commands/dns/record/list.ts b/packages/cli/src/commands/dns/record/list.ts index 88356e4..60ff0b2 100644 --- a/packages/cli/src/commands/dns/record/list.ts +++ b/packages/cli/src/commands/dns/record/list.ts @@ -41,7 +41,7 @@ export const dnsRecordListCommand = defineCommand({ ); if (output === "json") { - logger.log(JSON.stringify(zone, null, 2)); + logger.log(JSON.stringify(records, null, 2)); return; } diff --git a/packages/cli/src/commands/dns/record/remove.ts b/packages/cli/src/commands/dns/record/remove.ts index 0d84d16..0811f64 100644 --- a/packages/cli/src/commands/dns/record/remove.ts +++ b/packages/cli/src/commands/dns/record/remove.ts @@ -57,10 +57,15 @@ export const dnsRemoveCommand = defineCommand({ const removeSpin = spinner("Removing record..."); removeSpin.start(); - await client.DELETE("/dnszone/{zoneId}/records/{id}", { - params: { path: { zoneId: zone.Id as number, id: record.Id as number } }, - }); - removeSpin.stop(); + try { + await client.DELETE("/dnszone/{zoneId}/records/{id}", { + params: { + path: { zoneId: zone.Id as number, id: record.Id as number }, + }, + }); + } finally { + removeSpin.stop(); + } if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/dns/record/update.ts b/packages/cli/src/commands/dns/record/update.ts index 23296ac..960ff59 100644 --- a/packages/cli/src/commands/dns/record/update.ts +++ b/packages/cli/src/commands/dns/record/update.ts @@ -85,7 +85,7 @@ export const dnsUpdateCommand = defineCommand({ const existing = await resolveRecordInteractive(zone, id, "update"); const recordId = existing.Id as number; - // Seed from the existing record so unspecified fields are preserved. + // Seed from the existing record so unspecified fields (including advanced settings) are preserved. const body: UpdateDnsRecordModel = { Type: existing.Type ?? null, Ttl: existing.Ttl ?? null, @@ -98,6 +98,15 @@ export const dnsUpdateCommand = defineCommand({ Port: existing.Port ?? null, Disabled: existing.Disabled ?? null, Comment: existing.Comment ?? null, + Accelerated: existing.Accelerated ?? null, + PullZoneId: existing.AcceleratedPullZoneId ?? null, + MonitorType: existing.MonitorType ?? null, + GeolocationLatitude: existing.GeolocationLatitude ?? null, + GeolocationLongitude: existing.GeolocationLongitude ?? null, + LatencyZone: existing.LatencyZone ?? null, + SmartRoutingType: existing.SmartRoutingType ?? null, + EnviromentalVariables: existing.EnviromentalVariables ?? null, + AutoSslIssuance: existing.AutoSslIssuance ?? null, }; if (args.name !== undefined) body.Name = args.name === "@" ? "" : args.name; @@ -116,11 +125,14 @@ export const dnsUpdateCommand = defineCommand({ const spin = spinner("Updating record..."); spin.start(); - await client.POST("/dnszone/{zoneId}/records/{id}", { - params: { path: { zoneId: zone.Id as number, id: recordId } }, - body, - }); - spin.stop(); + try { + await client.POST("/dnszone/{zoneId}/records/{id}", { + params: { path: { zoneId: zone.Id as number, id: recordId } }, + body, + }); + } finally { + spin.stop(); + } if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts index 746dc28..b1c3f46 100644 --- a/packages/cli/src/commands/dns/zone/add.ts +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -4,6 +4,7 @@ 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 type { DnsZoneModel } from "../api.ts"; interface ZoneAddArgs { domain: string; @@ -28,17 +29,22 @@ export const dnsZoneAddCommand = defineCommand({ const spin = spinner("Creating DNS zone..."); spin.start(); - await client.POST("/dnszone", { body: { Domain: domain } }); - - // The add endpoint returns no body — look the new zone up to report its ID. - const { data } = await client.GET("/dnszone", { - params: { query: { search: domain, perPage: 1000 } }, - }); - const created = (data?.Items ?? []).find( - (z) => (z.Domain ?? "").toLowerCase() === domain.toLowerCase(), - ); - - spin.stop(); + let created: DnsZoneModel | undefined; + try { + await client.POST("/dnszone", { body: { Domain: domain } }); + + // Look the new zone up to report its ID; a lookup failure must not mask the created zone. + try { + const { data } = await client.GET("/dnszone", { + params: { query: { search: domain, perPage: 1000 } }, + }); + created = (data?.Items ?? []).find( + (z) => (z.Domain ?? "").toLowerCase() === domain.toLowerCase(), + ); + } catch {} + } finally { + spin.stop(); + } if (output === "json") { logger.log(JSON.stringify(created ?? { Domain: domain }, null, 2)); diff --git a/packages/cli/src/commands/dns/zone/dnssec/enable.ts b/packages/cli/src/commands/dns/zone/dnssec/enable.ts index c1e43ba..aeb6e09 100644 --- a/packages/cli/src/commands/dns/zone/dnssec/enable.ts +++ b/packages/cli/src/commands/dns/zone/dnssec/enable.ts @@ -1,4 +1,5 @@ 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"; @@ -30,10 +31,14 @@ export const dnsZoneDnssecEnableCommand = defineCommand({ const spin = spinner("Enabling DNSSEC..."); spin.start(); - const { data } = await client.POST("/dnszone/{id}/dnssec", { - params: { path: { id: zone.Id as number } }, - }); - spin.stop(); + let data: components["schemas"]["DnsSecDsRecordModel"] | undefined; + try { + ({ data } = await client.POST("/dnszone/{id}/dnssec", { + params: { path: { id: zone.Id as number } }, + })); + } finally { + spin.stop(); + } if (output === "json") { logger.log(JSON.stringify(data, null, 2)); diff --git a/packages/cli/src/commands/dns/zone/list.ts b/packages/cli/src/commands/dns/zone/list.ts index 26457c2..1896853 100644 --- a/packages/cli/src/commands/dns/zone/list.ts +++ b/packages/cli/src/commands/dns/zone/list.ts @@ -5,7 +5,7 @@ import { defineCommand } from "../../../core/define-command.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; import { spinner } from "../../../core/ui.ts"; -import { fetchZones } from "../api.ts"; +import { type DnsZoneModel, fetchZones } from "../api.ts"; export const dnsZoneListCommand = defineCommand({ command: "list", @@ -22,8 +22,12 @@ export const dnsZoneListCommand = defineCommand({ const spin = spinner("Fetching DNS zones..."); spin.start(); - const zones = await fetchZones(client); - spin.stop(); + let zones: DnsZoneModel[]; + try { + zones = await fetchZones(client); + } finally { + spin.stop(); + } if (output === "json") { logger.log(JSON.stringify(zones, null, 2)); diff --git a/packages/cli/src/commands/dns/zone/logging/enable.ts b/packages/cli/src/commands/dns/zone/logging/enable.ts index c76cbd5..3a8683b 100644 --- a/packages/cli/src/commands/dns/zone/logging/enable.ts +++ b/packages/cli/src/commands/dns/zone/logging/enable.ts @@ -68,15 +68,23 @@ export const dnsZoneLoggingEnableCommand = defineCommand({ throw new UserError("Anonymization must be 'onedigit' or 'drop'."); } body.LogAnonymizationType = type; + // Choosing a strategy implies enabling anonymization unless explicitly disabled. + if (args["anonymize-ip"] === undefined) { + body.LoggingIPAnonymizationEnabled = true; + } } const spin = spinner("Enabling logging..."); spin.start(); - const { data } = await client.POST("/dnszone/{id}", { - params: { path: { id: zone.Id as number } }, - body, - }); - spin.stop(); + let data: components["schemas"]["DnsZoneModel"] | undefined; + try { + ({ data } = await client.POST("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + body, + })); + } finally { + spin.stop(); + } if (output === "json") { logger.log(JSON.stringify(data, null, 2)); diff --git a/packages/cli/src/commands/dns/zone/remove.ts b/packages/cli/src/commands/dns/zone/remove.ts index 06f2cc1..2bb9aa1 100644 --- a/packages/cli/src/commands/dns/zone/remove.ts +++ b/packages/cli/src/commands/dns/zone/remove.ts @@ -48,10 +48,13 @@ export const dnsZoneRemoveCommand = defineCommand({ const removeSpin = spinner("Deleting zone..."); removeSpin.start(); - await client.DELETE("/dnszone/{id}", { - params: { path: { id: zone.Id as number } }, - }); - removeSpin.stop(); + try { + await client.DELETE("/dnszone/{id}", { + params: { path: { id: zone.Id as number } }, + }); + } finally { + removeSpin.stop(); + } if (output === "json") { logger.log( diff --git a/packages/openapi-client/src/middleware.test.ts b/packages/openapi-client/src/middleware.test.ts index c750df0..4d9773c 100644 --- a/packages/openapi-client/src/middleware.test.ts +++ b/packages/openapi-client/src/middleware.test.ts @@ -11,9 +11,12 @@ function runRequest(options: ClientOptions, request: Request) { function runResponse( options: ClientOptions, response: Response, + parseAs: "json" | "text" = "json", ): Promise { const mw = authMiddleware(options); - return Promise.resolve(mw.onResponse!({ response } as never)); + return Promise.resolve( + mw.onResponse!({ response, options: { parseAs } } as never), + ); } describe("authMiddleware onRequest", () => { @@ -140,25 +143,41 @@ describe("authMiddleware onResponse", () => { expect(result).toBeUndefined(); }); - test("allows an OK text/plain download body (e.g. DNS zone-file export)", async () => { + test("allows an OK text/plain download body when parseAs is text (e.g. DNS zone-file export)", async () => { const result = await runResponse( { apiKey: "k" }, new Response("$ORIGIN example.com.\nwww IN CNAME example.b-cdn.net.", { status: 200, headers: { "content-type": "text/plain" }, }), + "text", ); expect(result).toBeUndefined(); }); - test("allows an OK application/octet-stream download body", async () => { + test("allows an OK application/octet-stream download body when parseAs is text", async () => { const result = await runResponse( { apiKey: "k" }, new Response("binary-ish payload", { status: 200, headers: { "content-type": "application/octet-stream" }, }), + "text", ); expect(result).toBeUndefined(); }); + + test("throws on a non-JSON body when parseAs is json (proxy serving text/plain to a JSON call)", async () => { + const error = (await captureError( + runResponse( + { apiKey: "k" }, + new Response("upstream connect error", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ), + )) as ApiError; + expect(error).toBeInstanceOf(ApiError); + expect(error.message).toContain("non-JSON"); + }); }); diff --git a/packages/openapi-client/src/middleware.ts b/packages/openapi-client/src/middleware.ts index 71d851b..6b25f20 100644 --- a/packages/openapi-client/src/middleware.ts +++ b/packages/openapi-client/src/middleware.ts @@ -26,20 +26,6 @@ function looksLikeJson(contentType: string): boolean { ); } -/** - * Content types that are legitimately non-JSON payloads (e.g. DNS zone-file - * exports, downloads) rather than CDN/proxy HTML interception. Callers fetch - * these with `parseAs: "text"`, so they must not trip the non-JSON guard. - */ -function isDownloadableNonJson(contentType: string): boolean { - const lower = contentType.toLowerCase(); - return ( - lower.includes("text/plain") || - lower.includes("application/octet-stream") || - lower.includes("text/csv") - ); -} - const STATUS_MESSAGES: Record = { 401: "Unauthorized. Check your API key.", 403: "Forbidden. You don't have permission for this action.", @@ -111,7 +97,7 @@ export function authMiddleware(options: ClientOptions): Middleware { return request; }, - async onResponse({ response }) { + async onResponse({ response, options }) { if (debug) { const cloned = response.clone(); debug(`← ${response.status} ${response.statusText}`); @@ -132,17 +118,16 @@ export function authMiddleware(options: ClientOptions): Middleware { } } - // OK responses with a non-JSON body would otherwise crash - // openapi-fetch when it tries to JSON.parse the bytes. Detect - // that here and translate it into a clearer ApiError. This - // commonly happens when a CDN / proxy / captive portal serves an - // HTML error page with a 200 status code. + // openapi-fetch only JSON.parses the body when parseAs is "json" (its + // default). Callers that fetch downloads opt out via parseAs: "text" + // (etc.), so a non-JSON body is expected there and passes through. A + // non-JSON body on a JSON call is almost always a CDN / proxy / captive + // portal serving an HTML error page with a 200 status — surface that as + // a clear ApiError instead of letting openapi-fetch crash on JSON.parse. if (response.ok) { + const parseAs = options?.parseAs ?? "json"; const contentType = response.headers.get("content-type") ?? ""; - if ( - !looksLikeJson(contentType) && - !isDownloadableNonJson(contentType) - ) { + if (parseAs === "json" && !looksLikeJson(contentType)) { const text = await response.clone().text(); if (text.trim().length > 0) { const preview = text.length > 200 ? `${text.slice(0, 200)}…` : text; From 012303bc770804c5b70bf65d6fecf226c854921f Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 5 Jun 2026 08:30:02 +0100 Subject: [PATCH 5/5] fix AcceleratedPullZoneId --- packages/cli/src/commands/dns/record/update.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/dns/record/update.ts b/packages/cli/src/commands/dns/record/update.ts index 960ff59..50abd4b 100644 --- a/packages/cli/src/commands/dns/record/update.ts +++ b/packages/cli/src/commands/dns/record/update.ts @@ -99,7 +99,6 @@ export const dnsUpdateCommand = defineCommand({ Disabled: existing.Disabled ?? null, Comment: existing.Comment ?? null, Accelerated: existing.Accelerated ?? null, - PullZoneId: existing.AcceleratedPullZoneId ?? null, MonitorType: existing.MonitorType ?? null, GeolocationLatitude: existing.GeolocationLatitude ?? null, GeolocationLongitude: existing.GeolocationLongitude ?? null, @@ -109,6 +108,11 @@ export const dnsUpdateCommand = defineCommand({ AutoSslIssuance: existing.AutoSslIssuance ?? null, }; + // AcceleratedPullZoneId is the CDN-acceleration pull zone, not a PullZone-type record's link — only seed it when actually accelerated. + if (existing.Accelerated && existing.AcceleratedPullZoneId != null) { + body.PullZoneId = existing.AcceleratedPullZoneId; + } + if (args.name !== undefined) body.Name = args.name === "@" ? "" : args.name; if (args.value !== undefined) body.Value = args.value; if (args.type !== undefined) body.Type = parseRecordType(args.type);