From 46f4aa2927272cf511eb9c1feb3c788b599fc564 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 25 Jun 2026 13:44:11 +0100 Subject: [PATCH 1/4] feat(dns): manage Scriptable DNS scripts with ambient runtime types --- .changeset/dry-crabs-help.md | 6 + AGENTS.md | 21 +- README.md | 1 + packages/cli/src/commands/dns/index.ts | 2 + packages/cli/src/commands/dns/record/add.ts | 45 +++- packages/cli/src/commands/dns/scripts/api.ts | 74 ++++++ .../cli/src/commands/dns/scripts/connect.ts | 156 +++++++++++ .../cli/src/commands/dns/scripts/constants.ts | 137 ++++++++++ .../cli/src/commands/dns/scripts/create.ts | 139 ++++++++++ .../cli/src/commands/dns/scripts/index.ts | 21 ++ packages/cli/src/commands/dns/scripts/init.ts | 248 ++++++++++++++++++ .../src/commands/dns/scripts/interactive.ts | 64 +++++ packages/cli/src/commands/dns/scripts/list.ts | 59 +++++ .../cli/src/commands/dns/scripts/publish.ts | 77 ++++++ packages/cli/src/commands/dns/scripts/save.ts | 132 ++++++++++ packages/scriptable-dns-types/README.md | 38 +++ packages/scriptable-dns-types/index.d.ts | 146 +++++++++++ packages/scriptable-dns-types/package.json | 24 ++ 18 files changed, 1386 insertions(+), 4 deletions(-) create mode 100644 .changeset/dry-crabs-help.md create mode 100644 packages/cli/src/commands/dns/scripts/api.ts create mode 100644 packages/cli/src/commands/dns/scripts/connect.ts create mode 100644 packages/cli/src/commands/dns/scripts/constants.ts create mode 100644 packages/cli/src/commands/dns/scripts/create.ts create mode 100644 packages/cli/src/commands/dns/scripts/index.ts create mode 100644 packages/cli/src/commands/dns/scripts/init.ts create mode 100644 packages/cli/src/commands/dns/scripts/interactive.ts create mode 100644 packages/cli/src/commands/dns/scripts/list.ts create mode 100644 packages/cli/src/commands/dns/scripts/publish.ts create mode 100644 packages/cli/src/commands/dns/scripts/save.ts create mode 100644 packages/scriptable-dns-types/README.md create mode 100644 packages/scriptable-dns-types/index.d.ts create mode 100644 packages/scriptable-dns-types/package.json diff --git a/.changeset/dry-crabs-help.md b/.changeset/dry-crabs-help.md new file mode 100644 index 0000000..1d172f3 --- /dev/null +++ b/.changeset/dry-crabs-help.md @@ -0,0 +1,6 @@ +--- +"@bunny.net/cli": patch +"@bunny.net/scriptable-dns-types": patch +--- + +feat(dns): manage Scriptable DNS scripts (`bunny dns scripts` init/create/save/publish/connect/list) with ambient runtime types in `@bunny.net/scriptable-dns-types` diff --git a/AGENTS.md b/AGENTS.md index 8048223..0e15e16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,7 @@ This is a Bun workspace monorepo with four packages: - **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. - **`@bunny.net/app-config`** (`packages/app-config/`) — Shared app configuration schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. Used by the CLI and potentially other tools. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). +- **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`) — Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. - **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, and `@bunny.net/database-shell`. ``` @@ -129,6 +130,11 @@ bunny-cli/ │ │ ├── convert.ts # API ↔ config conversion (apiToConfig, configToAddRequest, configToPatchRequest) │ │ └── parse-image-ref.ts # Docker image reference parser (parseImageRef) │ │ +│ ├── scriptable-dns-types/ # @bunny.net/scriptable-dns-types package +│ │ ├── package.json # types-only; exports "./index.d.ts" under the "types" condition +│ │ ├── index.d.ts # Ambient globals: ARecord/AaaaRecord/CnameRecord/TxtRecord/PullZoneRecord/Server, Monitoring/GeoDatabase/GeoDistance/RoutingEngine, DnsRequest/DnsQuery/GeoLocation +│ │ └── README.md +│ │ │ ├── database-shell/ # @bunny.net/database-shell package │ │ ├── package.json # bin: { "bsql": "./src/cli.ts" } │ │ ├── tsconfig.json @@ -273,7 +279,7 @@ 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 the records + zones groups (+ hidden domain aliases) +│ │ │ ├── index.ts # defineNamespace("dns", ...) — registers the records + zones + scripts groups (+ hidden domain aliases) │ │ │ ├── api.ts # CoreClient type, fetchZones/fetchZone, resolveZone (domain-or-ID → zone) │ │ │ ├── constants.ts # DNS_MANIFEST (".bunny/dns.json") + DnsManifest type, written by `dns zones link` │ │ │ ├── interactive.ts # resolveZoneInteractive (arg → .bunny/dns.json manifest → zone picker; offerLink prompts to link a picked zone, skipped under --output json) + resolveRecordInteractive; autoLinkDnsZone (link a zone found in another flow — silent write, confirm before relinking a different zone) reused by scripts custom-domain setup @@ -281,7 +287,7 @@ bunny-cli/ │ │ │ ├── record/ # `dns records` — entries within a zone (canonical: records; aliases: record, rec) │ │ │ │ ├── index.ts # defineNamespace("records", ...) │ │ │ │ ├── list.ts # List records in a zone (alias: ls) -│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script) +│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script; SCRIPT type lists DNS scripts as a picker interactively) │ │ │ │ ├── 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) @@ -304,6 +310,17 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("logging", ...) │ │ │ ├── enable.ts # Enable DNS query logging (optional IP anonymization) │ │ │ └── disable.ts # Disable DNS query logging (with confirmation) +│ │ │ └── scripts/ # `dns scripts` — Scriptable DNS scripts (canonical: scripts; alias: script). Reuses the compute API (EdgeScriptType 0); separate from `bunny scripts` (different runtime, no pull zone) +│ │ │ ├── index.ts # defineNamespace("scripts", ...) — init/create/save/publish/connect/list +│ │ │ ├── constants.ts # DNS_SCRIPT_MANIFEST (".bunny/dns-script.json") + DnsScriptManifest; SCRIPT_TYPE_DNS; inline EXAMPLES (empty/geo/closest/weighted/failover/pullzone); TSCONFIG + TYPES_PACKAGE scaffold strings +│ │ │ ├── api.ts # ComputeClient helpers: fetchDnsScripts/fetchDnsScript (type 0), createDnsScript (no pull zone), uploadCode, publishScript +│ │ │ ├── interactive.ts # resolveDnsScriptId (arg → .bunny/dns-script.json → DNS-script picker) +│ │ │ ├── init.ts # Scaffold a project: example chooser, handleQuery.js + tsconfig + package.json (devDep on @bunny.net/scriptable-dns-types) + manifest; optional --deploy +│ │ │ ├── create.ts # Create the remote DNS script (no pull zone), link manifest +│ │ │ ├── save.ts # Upload code (single file, no build); --publish to publish in one step +│ │ │ ├── publish.ts # Publish the latest uploaded code as the live release +│ │ │ ├── connect.ts # Bridge: add a SCRIPT record on a zone pointing at the script (confirms before the DNS write) +│ │ │ └── list.ts # List DNS scripts (alias: ls) │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries diff --git a/README.md b/README.md index 0dcb152..eef06f3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Monorepo for the [bunny.net](https://bunny.net) CLI and supporting packages. | [`packages/database-openapi/`](packages/database-openapi/) | `@bunny.net/database-openapi` | Generate OpenAPI 3.0 specs from a database schema | | [`packages/database-rest/`](packages/database-rest/) | `@bunny.net/database-rest` | PostgREST-like REST API handler (database-agnostic) | | [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | Bunny Database adapter for database-rest | +| [`packages/scriptable-dns-types/`](packages/scriptable-dns-types/) | `@bunny.net/scriptable-dns-types` | Ambient TypeScript types for the Scriptable DNS runtime | See each package's README for usage and API documentation. diff --git a/packages/cli/src/commands/dns/index.ts b/packages/cli/src/commands/dns/index.ts index 131b52a..1e30073 100644 --- a/packages/cli/src/commands/dns/index.ts +++ b/packages/cli/src/commands/dns/index.ts @@ -1,10 +1,12 @@ import { defineNamespace } from "../../core/define-namespace.ts"; import { dnsRecordNamespace } from "./record/index.ts"; +import { dnsScriptsNamespace } from "./scripts/index.ts"; import { dnsZoneHiddenAliases, dnsZoneNamespace } from "./zone/index.ts"; // Hidden from help while experimental, matching the apps and registries namespaces. export const dnsNamespace = defineNamespace("dns", false, [ dnsRecordNamespace, dnsZoneNamespace, + dnsScriptsNamespace, ...dnsZoneHiddenAliases, ]); diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index 18c2811..b9a516c 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -1,4 +1,7 @@ -import { createCoreClient } from "@bunny.net/openapi-client"; +import { + createComputeClient, + 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"; @@ -15,6 +18,7 @@ import { recordName, recordTypeLabel, } from "../record-types.ts"; +import { fetchDnsScripts } from "../scripts/api.ts"; type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; type RecordLinks = Pick; @@ -117,6 +121,7 @@ function required(value: T | undefined, label: string): T { async function promptRecord( type: DnsRecordTypes, name: string, + dnsScripts: Array<{ id: number; name: string }> = [], ): Promise { const record: AddDnsRecordModel = { Type: type, @@ -134,6 +139,25 @@ async function promptRecord( } if (type === RECORD_TYPES.SCRIPT) { + // Offer the account's DNS scripts as a picker; fall back to a manual ID. + if (dnsScripts.length > 0) { + const { id } = await prompts({ + type: "select", + name: "id", + message: "DNS script:", + choices: [ + ...dnsScripts.map((s) => ({ + title: `${s.name} (${s.id})`, + value: s.id, + })), + { title: "Enter a script ID manually", value: -1 }, + ], + }); + if (id !== undefined && id !== -1) { + record.ScriptId = id; + return record; + } + } const { id } = await prompts({ type: "number", name: "id", @@ -285,7 +309,24 @@ export const dnsAddCommand = defineCommand({ name = res.name ?? "@"; } - record = await promptRecord(type, name ?? "@"); + // For SCRIPT records, offer the account's DNS scripts as a picker. + let dnsScripts: Array<{ id: number; name: string }> = []; + if (type === RECORD_TYPES.SCRIPT) { + const scriptSpin = spinner("Fetching DNS scripts..."); + scriptSpin.start(); + try { + const computeClient = createComputeClient( + clientOptions(config, verbose), + ); + dnsScripts = (await fetchDnsScripts(computeClient)) + .filter((s): s is typeof s & { Id: number } => s.Id != null) + .map((s) => ({ id: s.Id, name: s.Name ?? "(unnamed)" })); + } finally { + scriptSpin.stop(); + } + } + + record = await promptRecord(type, name ?? "@", dnsScripts); if (args.ttl === undefined) { const { ttl } = await prompts({ diff --git a/packages/cli/src/commands/dns/scripts/api.ts b/packages/cli/src/commands/dns/scripts/api.ts new file mode 100644 index 0000000..ac75c82 --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/api.ts @@ -0,0 +1,74 @@ +import type { createComputeClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; +import { UserError } from "../../../core/errors.ts"; +import { SCRIPT_TYPE_DNS } from "./constants.ts"; + +export type ComputeClient = ReturnType; +export type EdgeScript = components["schemas"]["EdgeScriptModel"]; + +/** Fetch a single script by ID, throwing a UserError if it doesn't exist. */ +export async function fetchDnsScript( + client: ComputeClient, + id: number, +): Promise { + const { data } = await client.GET("/compute/script/{id}", { + params: { path: { id } }, + }); + if (!data) throw new UserError(`DNS script ${id} not found.`); + return data; +} + +/** Fetch all DNS scripts on the account, sorted by name. */ +export async function fetchDnsScripts( + client: ComputeClient, +): Promise { + const { data } = await client.GET("/compute/script", { + params: { query: { type: [SCRIPT_TYPE_DNS] } }, + }); + + return (data?.Items ?? []).sort((a, b) => + (a.Name ?? "").localeCompare(b.Name ?? ""), + ); +} + +/** Create a DNS script (no linked pull zone), returning its ID and name. */ +export async function createDnsScript( + client: ComputeClient, + name: string, +): Promise<{ id: number; name: string }> { + const { data } = await client.POST("/compute/script", { + body: { + Name: name, + ScriptType: SCRIPT_TYPE_DNS, + CreateLinkedPullZone: false, + }, + }); + + if (!data || data.Id == null) { + throw new UserError("Failed to create DNS script."); + } + return { id: data.Id, name: data.Name ?? name }; +} + +/** Upload code to a DNS script, creating an unpublished deployment. */ +export async function uploadCode( + client: ComputeClient, + id: number, + code: string, +): Promise { + await client.POST("/compute/script/{id}/code", { + params: { path: { id } }, + body: { Code: code }, + }); +} + +/** Publish the latest uploaded code as the live release. */ +export async function publishScript( + client: ComputeClient, + id: number, +): Promise { + await client.POST("/compute/script/{id}/publish", { + params: { path: { id, uuid: null } }, + body: {}, + }); +} diff --git a/packages/cli/src/commands/dns/scripts/connect.ts b/packages/cli/src/commands/dns/scripts/connect.ts new file mode 100644 index 0000000..48fe2d4 --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/connect.ts @@ -0,0 +1,156 @@ +import { + createComputeClient, + 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 { confirm, spinner } from "../../../core/ui.ts"; +import { resolveZoneInteractive } from "../interactive.ts"; +import { RECORD_TYPES, recordName } from "../record-types.ts"; +import { fetchDnsScript } from "./api.ts"; +import { resolveDnsScriptId } from "./interactive.ts"; + +type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; + +const COMMAND = "connect [domain] [name] [id]"; +const DESCRIPTION = "Point a DNS record at a Scriptable DNS script."; + +const ARG_DOMAIN = "domain"; +const ARG_DOMAIN_DESCRIPTION = "Domain or zone ID (prompted when omitted)"; +const ARG_NAME = "name"; +const ARG_NAME_DESCRIPTION = "Record name (use '@' for the zone apex)"; +const ARG_ID = "id"; +const ARG_ID_DESCRIPTION = "DNS script ID (uses the linked script if omitted)"; +const ARG_TTL = "ttl"; +const ARG_TTL_DESCRIPTION = "Time to live in seconds"; + +interface ConnectArgs { + [ARG_DOMAIN]?: string; + [ARG_NAME]?: string; + [ARG_ID]?: number; + [ARG_TTL]?: number; +} + +/** + * Connect a Scriptable DNS script to a zone by adding a SCRIPT record that + * routes the chosen name to the script. + * + * This is the bridge between `bunny dns scripts` and the zone's records: a + * SCRIPT record (`bunny dns records add ... SCRIPT --script `) surfaced + * from the script's side. + * + * @example + * ```bash + * # Interactive: pick a zone and the linked script + * bunny dns scripts connect + * + * # Route api.example.com at script 12345 + * bunny dns scripts connect example.com api 12345 + * ``` + */ +export const dnsScriptsConnectCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 dns scripts connect", "Pick a zone and the linked script"], + [ + "$0 dns scripts connect example.com api 12345", + "Route api.example.com at script 12345", + ], + ], + + builder: (yargs) => + yargs + .positional(ARG_DOMAIN, { + type: "string", + describe: ARG_DOMAIN_DESCRIPTION, + }) + .positional(ARG_NAME, { + type: "string", + describe: ARG_NAME_DESCRIPTION, + }) + .positional(ARG_ID, { + type: "number", + describe: ARG_ID_DESCRIPTION, + }) + .option(ARG_TTL, { + type: "number", + describe: ARG_TTL_DESCRIPTION, + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const isInteractive = output !== "json" && process.stdout.isTTY; + + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const computeClient = createComputeClient(options); + const coreClient = createCoreClient(options); + + const scriptId = await resolveDnsScriptId( + computeClient, + args[ARG_ID], + "connect", + isInteractive, + ); + const script = await fetchDnsScript(computeClient, scriptId); + + const zone = await resolveZoneInteractive(coreClient, args[ARG_DOMAIN], { + output, + offerLink: true, + }); + + const name = (args[ARG_NAME] ?? "@").trim(); + const record: AddDnsRecordModel = { + Type: RECORD_TYPES.SCRIPT, + Name: name === "@" ? "" : name, + ScriptId: scriptId, + }; + if (args[ARG_TTL] !== undefined) record.Ttl = args[ARG_TTL]; + + if (isInteractive) { + const ok = await confirm( + `Add SCRIPT record ${recordName(record.Name)}.${zone.Domain} -> ${script.Name ?? scriptId}?`, + ); + if (!ok) { + logger.info("Cancelled."); + return; + } + } + + const spin = spinner("Connecting..."); + spin.start(); + let data: { Id?: number } | undefined; + try { + ({ data } = await coreClient.PUT("/dnszone/{zoneId}/records", { + params: { path: { zoneId: zone.Id as number } }, + body: record, + })); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { + zoneId: zone.Id, + recordId: data?.Id ?? null, + scriptId, + name: recordName(record.Name), + }, + null, + 2, + ), + ); + return; + } + + logger.success( + `Connected ${recordName(record.Name)}.${zone.Domain} to DNS script ${script.Name ?? scriptId}${data?.Id != null ? ` (record ${data.Id})` : ""}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/dns/scripts/constants.ts b/packages/cli/src/commands/dns/scripts/constants.ts new file mode 100644 index 0000000..5bb39df --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/constants.ts @@ -0,0 +1,137 @@ +import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; + +export type EdgeScriptTypes = components["schemas"]["EdgeScriptTypes"]; + +/** Scriptable DNS scripts are EdgeScriptType 0 on the compute API. */ +export const SCRIPT_TYPE_DNS: EdgeScriptTypes = 0; + +/** Local manifest written by `bunny dns scripts` commands. */ +export const DNS_SCRIPT_MANIFEST = "dns-script.json"; + +/** Default entry file for a scaffolded DNS script. */ +export const DEFAULT_ENTRY = "handleQuery.js"; + +/** Package providing ambient types for the Scriptable DNS runtime. */ +export const TYPES_PACKAGE = "@bunny.net/scriptable-dns-types"; +export const TYPES_PACKAGE_VERSION = "^0.1.0"; + +export interface DnsScriptManifest { + id?: number; + name?: string; + scriptType?: EdgeScriptTypes; + entry?: string; +} + +export interface DnsScriptExample { + key: string; + title: string; + code: string; +} + +const REFERENCE = `/// `; + +/** Starter examples offered by `bunny dns scripts init`, keyed by intent. */ +export const EXAMPLES: DnsScriptExample[] = [ + { + key: "empty", + title: "Empty: return a single A record", + code: `${REFERENCE} + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + return new ARecord("203.0.113.10", 30); +} +`, + }, + { + key: "geo", + title: "Geo routing: answer by client country", + code: `${REFERENCE} + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + if (query.request.geoLocation.country === "DE") { + return new ARecord("203.0.113.20", 30); + } + return new ARecord("203.0.113.10", 30); +} +`, + }, + { + key: "closest", + title: "Closest server: route to the nearest location", + code: `${REFERENCE} + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + const servers = [ + new Server("203.0.113.10", 40.69, -74.18), + new Server("203.0.113.11", 52.31, 4.76), + new Server("203.0.113.12", -37.67, 144.85), + ]; + return RoutingEngine.getClosestServer(servers, query.request.geoLocation, true); +} +`, + }, + { + key: "weighted", + title: "Weighted round robin: spread load by weight", + code: `${REFERENCE} + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + const servers = [ + new Server("203.0.113.10", 40.69, -74.18, 100), + new Server("203.0.113.11", 52.31, 4.76, 50), + ]; + return RoutingEngine.getWeightedRandom(servers, true); +} +`, + }, + { + key: "failover", + title: "Failover: answer with a healthy IP", + code: `${REFERENCE} + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + if (Monitoring.getStatus("203.0.113.10").isOnline) { + return new ARecord("203.0.113.10", 30); + } + return new ARecord("203.0.113.11", 30); +} +`, + }, + { + key: "pullzone", + title: "Pull zone: map the answer to a pull zone", + code: `${REFERENCE} + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + return new PullZoneRecord("my-pull-zone"); +} +`, + }, +]; + +/** tsconfig that typechecks a DNS script against the ambient runtime types. */ +export const TSCONFIG = `${JSON.stringify( + { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + allowJs: true, + checkJs: true, + noEmit: true, + strict: true, + skipLibCheck: true, + types: [TYPES_PACKAGE], + }, + include: ["*.js"], + }, + null, + 2, +)} +`; diff --git a/packages/cli/src/commands/dns/scripts/create.ts b/packages/cli/src/commands/dns/scripts/create.ts new file mode 100644 index 0000000..bce91ce --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/create.ts @@ -0,0 +1,139 @@ +import { basename, resolve } from "node:path"; +import { createComputeClient } 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 { formatKeyValue } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { loadManifest, saveManifest } from "../../../core/manifest.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { createDnsScript } from "./api.ts"; +import { + DNS_SCRIPT_MANIFEST, + type DnsScriptManifest, + SCRIPT_TYPE_DNS, +} from "./constants.ts"; + +const COMMAND = "create [name]"; +const DESCRIPTION = "Create a Scriptable DNS script on bunny.net."; + +const ARG_NAME = "name"; +const ARG_NAME_DESCRIPTION = "Script name (defaults to current directory name)"; +const ARG_LINK = "link"; +const ARG_LINK_DESCRIPTION = + "Link this directory to the new script (default: true). Use --no-link to skip."; + +interface CreateArgs { + [ARG_NAME]?: string; + [ARG_LINK]?: boolean; +} + +/** + * Create a Scriptable DNS script on bunny.net (without scaffolding files). + * + * Unlike Edge Scripts, DNS scripts have no linked pull zone: they are + * attached to a zone later with `bunny dns scripts connect`. + * + * @example + * ```bash + * # Create using the current directory name + * bunny dns scripts create + * + * # Explicit name, without linking the directory + * bunny dns scripts create geo-router --no-link + * ``` + */ +export const dnsScriptsCreateCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 dns scripts create", "Create using current directory name"], + ["$0 dns scripts create geo-router", "Create with an explicit name"], + ], + + builder: (yargs) => + yargs + .positional(ARG_NAME, { + type: "string", + describe: ARG_NAME_DESCRIPTION, + }) + .option(ARG_LINK, { + type: "boolean", + describe: ARG_LINK_DESCRIPTION, + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const isInteractive = output !== "json" && process.stdout.isTTY; + + const name = args[ARG_NAME] ?? basename(resolve(process.cwd())); + if (!name) throw new UserError("Script name is required."); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + + const spin = spinner(`Creating DNS script "${name}"...`); + spin.start(); + let created: { id: number; name: string }; + try { + created = await createDnsScript(client, name); + } finally { + spin.stop(); + } + + const manifest = loadManifest(DNS_SCRIPT_MANIFEST); + const linkArg = args[ARG_LINK]; + let shouldLink: boolean; + if (linkArg !== undefined) { + shouldLink = linkArg; + } else if (isInteractive && manifest.id && manifest.id !== created.id) { + shouldLink = await confirm( + `Replace existing link to ${manifest.name ?? manifest.id}?`, + ); + } else { + shouldLink = true; + } + + if (shouldLink) { + saveManifest(DNS_SCRIPT_MANIFEST, { + ...manifest, + id: created.id, + name: created.name, + scriptType: SCRIPT_TYPE_DNS, + }); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { id: created.id, name: created.name, linked: shouldLink }, + null, + 2, + ), + ); + return; + } + + logger.success(`Created DNS script "${created.name}" (${created.id}).`); + logger.log(); + logger.log( + formatKeyValue( + [ + { key: "ID", value: String(created.id) }, + { key: "Name", value: created.name }, + ], + output, + ), + ); + + if (shouldLink) { + logger.log(); + logger.success(`Linked .bunny/${DNS_SCRIPT_MANIFEST} -> ${created.id}.`); + } + + logger.log(); + logger.dim(" Save code: bunny dns scripts save"); + logger.dim(" Connect: bunny dns scripts connect"); + }, +}); diff --git a/packages/cli/src/commands/dns/scripts/index.ts b/packages/cli/src/commands/dns/scripts/index.ts new file mode 100644 index 0000000..01ffd4d --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/index.ts @@ -0,0 +1,21 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { dnsScriptsConnectCommand } from "./connect.ts"; +import { dnsScriptsCreateCommand } from "./create.ts"; +import { dnsScriptsInitCommand } from "./init.ts"; +import { dnsScriptsListCommand } from "./list.ts"; +import { dnsScriptsPublishCommand } from "./publish.ts"; +import { dnsScriptsSaveCommand } from "./save.ts"; + +export const dnsScriptsNamespace = defineNamespace( + "scripts", + "Manage Scriptable DNS scripts.", + [ + dnsScriptsInitCommand, + dnsScriptsCreateCommand, + dnsScriptsSaveCommand, + dnsScriptsPublishCommand, + dnsScriptsConnectCommand, + dnsScriptsListCommand, + ], + ["script"], +); diff --git a/packages/cli/src/commands/dns/scripts/init.ts b/packages/cli/src/commands/dns/scripts/init.ts new file mode 100644 index 0000000..cc4dbf0 --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/init.ts @@ -0,0 +1,248 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { createComputeClient } 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 { saveManifestAt } from "../../../core/manifest.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { createDnsScript } from "./api.ts"; +import { + DEFAULT_ENTRY, + DNS_SCRIPT_MANIFEST, + type DnsScriptExample, + type DnsScriptManifest, + EXAMPLES, + SCRIPT_TYPE_DNS, + TSCONFIG, + TYPES_PACKAGE, + TYPES_PACKAGE_VERSION, +} from "./constants.ts"; + +const COMMAND = "init [name]"; +const DESCRIPTION = "Scaffold a new Scriptable DNS script project."; + +const ARG_NAME = "name"; +const ARG_NAME_DESCRIPTION = "Project directory name"; +const ARG_EXAMPLE = "example"; +const ARG_EXAMPLE_DESCRIPTION = `Starter example: ${EXAMPLES.map((e) => e.key).join(", ")}`; +const ARG_DEPLOY = "deploy"; +const ARG_DEPLOY_DESCRIPTION = + "Create the script on bunny.net after scaffolding"; +const ARG_SKIP_INSTALL = "skip-install"; +const ARG_SKIP_INSTALL_DESCRIPTION = "Skip installing editor type dependencies"; + +interface InitArgs { + [ARG_NAME]?: string; + [ARG_EXAMPLE]?: string; + [ARG_DEPLOY]?: boolean; + [ARG_SKIP_INSTALL]?: boolean; +} + +function packageJson(name: string): string { + return `${JSON.stringify( + { + name, + private: true, + type: "module", + scripts: { typecheck: "tsc --noEmit" }, + devDependencies: { + [TYPES_PACKAGE]: TYPES_PACKAGE_VERSION, + typescript: "^5", + }, + }, + null, + 2, + )}\n`; +} + +/** + * Scaffold a Scriptable DNS script project. + * + * Writes a `handleQuery` entry file from a chosen example, a tsconfig and + * package.json wired to the ambient runtime types for editor autocomplete, + * and a `.bunny/dns-script.json` manifest. Optionally creates the script on + * bunny.net so it is ready for `bunny dns scripts save`. + * + * @example + * ```bash + * # Interactive wizard + * bunny dns scripts init + * + * # Non-interactive: geo example, create on bunny.net + * bunny dns scripts init geo-router --example geo --deploy + * ``` + */ +export const dnsScriptsInitCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 dns scripts init", "Interactive wizard"], + [ + "$0 dns scripts init geo-router --example geo --deploy", + "Scaffold the geo example and create the script", + ], + ], + + builder: (yargs) => + yargs + .positional(ARG_NAME, { + type: "string", + describe: ARG_NAME_DESCRIPTION, + }) + .option(ARG_EXAMPLE, { + type: "string", + choices: EXAMPLES.map((e) => e.key), + describe: ARG_EXAMPLE_DESCRIPTION, + }) + .option(ARG_DEPLOY, { + type: "boolean", + describe: ARG_DEPLOY_DESCRIPTION, + }) + .option(ARG_SKIP_INSTALL, { + type: "boolean", + describe: ARG_SKIP_INSTALL_DESCRIPTION, + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const interactive = output !== "json" && process.stdout.isTTY; + + let dirName = args[ARG_NAME]; + if (!dirName && interactive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Project directory name:", + initial: "my-dns-script", + }); + dirName = value; + } + if (!dirName) throw new UserError("Directory name is required."); + + const dirPath = resolve(dirName); + if (existsSync(dirPath)) { + throw new UserError(`Directory "${dirName}" already exists.`); + } + + let example: DnsScriptExample | undefined = args[ARG_EXAMPLE] + ? EXAMPLES.find((e) => e.key === args[ARG_EXAMPLE]) + : undefined; + if (!example && interactive) { + const { value } = await prompts({ + type: "select", + name: "value", + message: "What should this script do?", + choices: EXAMPLES.map((e) => ({ title: e.title, value: e })), + }); + example = value; + } + example ??= EXAMPLES[0]; + if (!example) throw new UserError("An example is required."); + + mkdirSync(dirPath, { recursive: true }); + await Bun.write(`${dirPath}/${DEFAULT_ENTRY}`, example.code); + await Bun.write(`${dirPath}/tsconfig.json`, TSCONFIG); + await Bun.write(`${dirPath}/package.json`, packageJson(basename(dirPath))); + await Bun.write(`${dirPath}/.gitignore`, ".bunny/\nnode_modules/\n"); + saveManifestAt(dirPath, DNS_SCRIPT_MANIFEST, { + scriptType: SCRIPT_TYPE_DNS, + entry: DEFAULT_ENTRY, + }); + + logger.success(`Scaffolded "${example.title}" in ${dirName}.`); + + if (args[ARG_SKIP_INSTALL] !== true) { + const install = interactive + ? await confirm("Install editor type dependencies with bun?") + : false; + if (install) { + const spin = spinner("Installing dependencies (bun)..."); + spin.start(); + let code = 1; + try { + const proc = Bun.spawn(["bun", "install"], { + cwd: dirPath, + stdout: "ignore", + stderr: "ignore", + }); + code = await proc.exited; + } catch { + // bun missing or vanished; warn below. + } + spin.stop(); + if (code === 0) { + logger.success("Dependencies installed."); + } else { + logger.warn( + "Could not install dependencies. Run `bun install` later.", + ); + } + } + } + + let created: { id: number; name: string } | undefined; + const shouldDeploy = + args[ARG_DEPLOY] !== undefined + ? args[ARG_DEPLOY] + : interactive + ? await confirm("Create the DNS script on bunny.net?") + : false; + + if (shouldDeploy) { + try { + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + const spin = spinner("Creating DNS script..."); + spin.start(); + try { + created = await createDnsScript(client, basename(dirPath)); + } finally { + spin.stop(); + } + saveManifestAt(dirPath, DNS_SCRIPT_MANIFEST, { + id: created.id, + name: created.name, + scriptType: SCRIPT_TYPE_DNS, + entry: DEFAULT_ENTRY, + }); + logger.success(`Created DNS script "${created.name}" (${created.id}).`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ""; + logger.warn( + message + ? `Could not create the script: ${message}` + : "Could not create the script.", + ); + logger.dim( + " Run `bunny dns scripts create` from the project to retry.", + ); + } + } + + if (output === "json") { + logger.log( + JSON.stringify( + { + directory: dirName, + example: example.key, + entry: DEFAULT_ENTRY, + ...(created && { script: created }), + }, + null, + 2, + ), + ); + return; + } + + logger.log(); + logger.dim(` cd ${dirName}`); + if (!created) logger.dim(" Create: bunny dns scripts create"); + logger.dim(" Save: bunny dns scripts save --publish"); + logger.dim(" Connect: bunny dns scripts connect"); + }, +}); diff --git a/packages/cli/src/commands/dns/scripts/interactive.ts b/packages/cli/src/commands/dns/scripts/interactive.ts new file mode 100644 index 0000000..42b5170 --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/interactive.ts @@ -0,0 +1,64 @@ +import prompts from "prompts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { loadManifest } from "../../../core/manifest.ts"; +import { spinner } from "../../../core/ui.ts"; +import { type ComputeClient, fetchDnsScripts } from "./api.ts"; +import { DNS_SCRIPT_MANIFEST, type DnsScriptManifest } from "./constants.ts"; + +/** + * Resolve a DNS script ID from an explicit value, the linked manifest, or a + * picker over the account's DNS scripts. + * + * Resolution order: explicit `id` -> `.bunny/dns-script.json` -> prompt. + * Throws when none is available (non-interactive with nothing linked). + */ +export async function resolveDnsScriptId( + client: ComputeClient, + id: number | undefined, + action: string, + interactive: boolean, +): Promise { + if (id) return id; + + const manifest = loadManifest(DNS_SCRIPT_MANIFEST); + if (manifest.id) { + logger.dim(`Using linked DNS script ${manifest.name ?? manifest.id}.`); + return manifest.id; + } + + if (!interactive) { + throw new UserError( + "No DNS script ID provided and none linked.", + "Pass an ID, or run from a directory created by `bunny dns scripts init`.", + ); + } + + const spin = spinner("Fetching DNS scripts..."); + spin.start(); + let scripts: Awaited>; + try { + scripts = await fetchDnsScripts(client); + } finally { + spin.stop(); + } + + if (scripts.length === 0) { + throw new UserError( + "No DNS scripts found.", + "Create one with `bunny dns scripts create`.", + ); + } + + const { value } = await prompts({ + type: "select", + name: "value", + message: `DNS script to ${action}:`, + choices: scripts.map((s) => ({ + title: `${s.Name ?? "(unnamed)"} (${s.Id})`, + value: s.Id, + })), + }); + if (value === undefined) throw new UserError("A DNS script is required."); + return value; +} diff --git a/packages/cli/src/commands/dns/scripts/list.ts b/packages/cli/src/commands/dns/scripts/list.ts new file mode 100644 index 0000000..530078b --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/list.ts @@ -0,0 +1,59 @@ +import { createComputeClient } 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 { fetchDnsScripts } from "./api.ts"; + +const COMMAND = "list"; +const DESCRIPTION = "List Scriptable DNS scripts."; + +/** + * List all Scriptable DNS scripts in the account. + * + * @example + * ```bash + * bunny dns scripts list + * + * bunny dns scripts list --output json + * ``` + */ +export const dnsScriptsListCommand = defineCommand({ + command: COMMAND, + aliases: ["ls"], + describe: DESCRIPTION, + examples: [ + ["$0 dns scripts list", "List DNS scripts"], + ["$0 dns scripts list --output json", "JSON output"], + ], + + handler: async ({ profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching DNS scripts..."); + spin.start(); + const scripts = await fetchDnsScripts(client); + spin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(scripts, null, 2)); + return; + } + + if (scripts.length === 0) { + logger.info("No DNS scripts found."); + return; + } + + logger.log( + formatTable( + ["ID", "Name"], + scripts.map((script) => [String(script.Id ?? ""), script.Name ?? ""]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/dns/scripts/publish.ts b/packages/cli/src/commands/dns/scripts/publish.ts new file mode 100644 index 0000000..3d25aba --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/publish.ts @@ -0,0 +1,77 @@ +import { createComputeClient } 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 { publishScript } from "./api.ts"; +import { resolveDnsScriptId } from "./interactive.ts"; + +const COMMAND = "publish [id]"; +const DESCRIPTION = "Publish the latest uploaded code as the live release."; + +const ARG_ID = "id"; +const ARG_ID_DESCRIPTION = "DNS script ID (uses the linked script if omitted)"; + +interface PublishArgs { + [ARG_ID]?: number; +} + +/** + * Publish a DNS script's latest uploaded code as the live release. + * + * Run this after `bunny dns scripts save` to make the saved code live. + * + * @example + * ```bash + * # Publish the linked script + * bunny dns scripts publish + * + * # Publish a specific script + * bunny dns scripts publish 12345 + * ``` + */ +export const dnsScriptsPublishCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 dns scripts publish", "Publish the linked script"], + ["$0 dns scripts publish 12345", "Publish a specific script"], + ], + + builder: (yargs) => + yargs.positional(ARG_ID, { + type: "number", + describe: ARG_ID_DESCRIPTION, + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const isInteractive = output !== "json" && process.stdout.isTTY; + + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + + const id = await resolveDnsScriptId( + client, + args[ARG_ID], + "publish", + isInteractive, + ); + + const spin = spinner("Publishing..."); + spin.start(); + try { + await publishScript(client, id); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify({ id, published: true }, null, 2)); + return; + } + + logger.success(`Published DNS script ${id}.`); + }, +}); diff --git a/packages/cli/src/commands/dns/scripts/save.ts b/packages/cli/src/commands/dns/scripts/save.ts new file mode 100644 index 0000000..ba118a1 --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/save.ts @@ -0,0 +1,132 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { createComputeClient } 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 { loadManifest } from "../../../core/manifest.ts"; +import { spinner } from "../../../core/ui.ts"; +import { publishScript, uploadCode } from "./api.ts"; +import { + DEFAULT_ENTRY, + DNS_SCRIPT_MANIFEST, + type DnsScriptManifest, +} from "./constants.ts"; +import { resolveDnsScriptId } from "./interactive.ts"; + +const COMMAND = "save [file] [id]"; +const DESCRIPTION = "Upload DNS script code (without publishing)."; + +const ARG_FILE = "file"; +const ARG_FILE_DESCRIPTION = + "Path to the script file (defaults to the entry file)"; +const ARG_ID = "id"; +const ARG_ID_DESCRIPTION = "DNS script ID (uses the linked script if omitted)"; +const ARG_PUBLISH = "publish"; +const ARG_PUBLISH_DESCRIPTION = "Publish the uploaded code as the live release"; + +interface SaveArgs { + [ARG_FILE]?: string; + [ARG_ID]?: number; + [ARG_PUBLISH]?: boolean; +} + +/** + * Upload DNS script code, creating an unpublished deployment. + * + * The file is uploaded as-is: DNS scripts are a single `handleQuery` + * source file, so there is no build step. Pass `--publish` to promote + * the upload to the live release in one step, or run + * `bunny dns scripts publish` afterwards. + * + * @example + * ```bash + * # Save the entry file from the linked script's directory + * bunny dns scripts save + * + * # Save and publish a specific file to a specific script + * bunny dns scripts save handleQuery.js 12345 --publish + * ``` + */ +export const dnsScriptsSaveCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 dns scripts save", "Upload the entry file"], + ["$0 dns scripts save --publish", "Upload and publish"], + ], + + builder: (yargs) => + yargs + .positional(ARG_FILE, { + type: "string", + describe: ARG_FILE_DESCRIPTION, + }) + .positional(ARG_ID, { + type: "number", + describe: ARG_ID_DESCRIPTION, + }) + .option(ARG_PUBLISH, { + type: "boolean", + describe: ARG_PUBLISH_DESCRIPTION, + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const isInteractive = output !== "json" && process.stdout.isTTY; + + const manifest = loadManifest(DNS_SCRIPT_MANIFEST); + const file = args[ARG_FILE] ?? manifest.entry ?? DEFAULT_ENTRY; + const absPath = resolve(file); + if (!existsSync(absPath)) { + throw new UserError( + `File not found: ${file}`, + "Pass a file path, or run from a directory created by `bunny dns scripts init`.", + ); + } + const code = await Bun.file(absPath).text(); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + + const id = await resolveDnsScriptId( + client, + args[ARG_ID], + "save to", + isInteractive, + ); + + const spin = spinner("Uploading code..."); + spin.start(); + try { + await uploadCode(client, id, code); + } finally { + spin.stop(); + } + logger.success("Code uploaded."); + + const published = args[ARG_PUBLISH] === true; + if (published) { + const pubSpin = spinner("Publishing..."); + pubSpin.start(); + try { + await publishScript(client, id); + } finally { + pubSpin.stop(); + } + logger.success("Deployment published."); + } + + if (output === "json") { + logger.log(JSON.stringify({ id, file, published }, null, 2)); + return; + } + + if (!published) { + logger.log(); + logger.dim(" Publish: bunny dns scripts publish"); + } + }, +}); diff --git a/packages/scriptable-dns-types/README.md b/packages/scriptable-dns-types/README.md new file mode 100644 index 0000000..cf0d885 --- /dev/null +++ b/packages/scriptable-dns-types/README.md @@ -0,0 +1,38 @@ +# @bunny.net/scriptable-dns-types + +Ambient TypeScript types for the [bunny.net Scriptable DNS](https://docs.bunny.net/docs/dns-scripting) runtime. + +Scriptable DNS scripts run with a set of globals injected by the runtime (`ARecord`, `Monitoring`, `RoutingEngine`, and so on). Because the runtime does not support `import`, these are provided as ambient declarations: they power editor autocomplete and an optional typecheck step, but are never imported at runtime. + +## Usage + +Add the package as a dev dependency, then reference it from your script or your `tsconfig.json`. + +Reference from the script file: + +```js +/// + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + if (query.request.geoLocation.country === "DE") { + return new ARecord("203.0.113.20", 30); + } + return new ARecord("203.0.113.10", 30); +} +``` + +Or wire it up globally in `tsconfig.json`: + +```jsonc +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "types": ["@bunny.net/scriptable-dns-types"], + }, +} +``` + +The CLI scaffolds all of this for you with `bunny dns scripts init`. diff --git a/packages/scriptable-dns-types/index.d.ts b/packages/scriptable-dns-types/index.d.ts new file mode 100644 index 0000000..01e7d90 --- /dev/null +++ b/packages/scriptable-dns-types/index.d.ts @@ -0,0 +1,146 @@ +// Ambient declarations for the bunny.net Scriptable DNS runtime. The runtime injects these as globals; scripts use them without importing. + +/** The DNS request passed to the `handleQuery` entry function. */ +interface DnsRequest { + /** The DNS query that contains the details about the request. */ + request: DnsQuery; +} + +/** The details of an incoming DNS query. */ +interface DnsQuery { + /** The hostname that is being queried. */ + hostname: string; + /** The IP of the remote client that sent the DNS query. */ + clientIP: string; + /** The query question type (A, AAAA, TXT). */ + queryType: string; + /** The EDNS0 IP attached by the client. */ + ednsIP: string; + /** The geo location of the client. */ + geoLocation: GeoLocation; + /** The server zone of the DNS server that received the query (DE, UK, SG). */ + serverZone: string; +} + +/** A geographic location resolved for a client or an IP. */ +interface GeoLocation { + /** The latitude of the location. */ + latitude: number; + /** The longitude of the location. */ + longitude: number; + /** The two letter ISO country code. */ + country: string; + /** The detected ASN number. */ + asn: number; +} + +/** An A record answer. */ +declare class ARecord { + constructor(ip: string, ttl?: number); + /** The IP of the A record. */ + ip: string; + /** The TTL of the answer. */ + ttl: number; +} + +/** An AAAA record answer. */ +declare class AaaaRecord { + constructor(ip: string, ttl?: number); + /** The IP of the AAAA record. */ + ip: string; + /** The TTL of the answer. */ + ttl: number; +} + +/** A CNAME record answer. */ +declare class CnameRecord { + constructor(hostname: string, ttl?: number); + /** The hostname of the CNAME record. */ + hostname: string; + /** The TTL of the answer. */ + ttl: number; +} + +/** A TXT record answer. */ +declare class TxtRecord { + constructor(value: string, ttl?: number); + /** The value of the TXT record. */ + value: string; + /** The TTL of the answer. */ + ttl: number; +} + +/** Maps the response to a pull zone, resolving to its A or AAAA records. */ +declare class PullZoneRecord { + constructor(pullzone: string); + /** The name of the pull zone. */ + pullzone: string; +} + +/** A server passed to or returned from the routing helpers. */ +declare class Server { + constructor( + ip: string, + latitude?: number, + longitude?: number, + weight?: number, + online?: boolean, + ); + /** The IP of the server. */ + ip: string; + /** The geographical latitude of the server. */ + latitude: number; + /** The geographical longitude of the server. */ + longitude: number; + /** The routing weight of the server in a weighted routing scenario. */ + weight: number; +} + +/** The uptime status returned by `Monitoring.getStatus`. */ +interface MonitoringStatus { + /** Whether the IP is currently online. */ + isOnline: boolean; + /** The last measured latency from the DNS server to this IP. */ + latency: number; +} + +/** Checks and monitors the uptime status of an IP in the background. */ +declare const Monitoring: { + getStatus(ip: string): MonitoringStatus; +}; + +/** Looks up the geo location of an IP address using a GeoDNS database. */ +declare const GeoDatabase: { + resolve(ip: string): GeoLocation; +}; + +/** Calculates the distance between two geographical points. */ +declare const GeoDistance: { + calculate(lat1: number, lon1: number, lat2: number, lon2: number): number; + calculate(loc1: GeoLocation, loc2: GeoLocation): number; + calculate(server: Server, location: GeoLocation): number; +}; + +/** Dynamic geographic routing, weight calculation, and round robin logic. */ +declare const RoutingEngine: { + getWeightedRandom( + servers: Server[], + onlineOnly?: boolean, + applyWeight?: boolean, + ): Server; + getClosestServer( + servers: Server[], + location: GeoLocation, + onlineOnly?: boolean, + applyWeight?: boolean, + ): Server; +}; + +/** Any value the `handleQuery` entry function may return. */ +type DnsAnswer = + | ARecord + | AaaaRecord + | CnameRecord + | TxtRecord + | PullZoneRecord + | Server; diff --git a/packages/scriptable-dns-types/package.json b/packages/scriptable-dns-types/package.json new file mode 100644 index 0000000..d509b92 --- /dev/null +++ b/packages/scriptable-dns-types/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bunny.net/scriptable-dns-types", + "version": "0.1.0", + "description": "Ambient TypeScript types for the bunny.net Scriptable DNS runtime.", + "license": "MIT", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts" + } + }, + "files": [ + "index.d.ts" + ], + "keywords": [ + "bunny", + "dns", + "scriptable-dns", + "types" + ], + "publishConfig": { + "access": "public" + } +} From 9171b922fdaba4e0e40198c4ce9bf22d275ec613 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 25 Jun 2026 15:06:56 +0100 Subject: [PATCH 2/4] updates --- .changeset/dry-crabs-help.md | 2 +- AGENTS.md | 7 +- bun.lock | 22 ++++-- .../dns/scripts/{connect.ts => attach.ts} | 37 +++++---- .../cli/src/commands/dns/scripts/create.ts | 18 ++++- .../dns/scripts/{save.ts => deploy.ts} | 57 ++++++++------ .../cli/src/commands/dns/scripts/index.ts | 10 +-- packages/cli/src/commands/dns/scripts/init.ts | 6 +- .../cli/src/commands/dns/scripts/publish.ts | 77 ------------------- 9 files changed, 98 insertions(+), 138 deletions(-) rename packages/cli/src/commands/dns/scripts/{connect.ts => attach.ts} (78%) rename packages/cli/src/commands/dns/scripts/{save.ts => deploy.ts} (65%) delete mode 100644 packages/cli/src/commands/dns/scripts/publish.ts diff --git a/.changeset/dry-crabs-help.md b/.changeset/dry-crabs-help.md index 1d172f3..acac03a 100644 --- a/.changeset/dry-crabs-help.md +++ b/.changeset/dry-crabs-help.md @@ -3,4 +3,4 @@ "@bunny.net/scriptable-dns-types": patch --- -feat(dns): manage Scriptable DNS scripts (`bunny dns scripts` init/create/save/publish/connect/list) with ambient runtime types in `@bunny.net/scriptable-dns-types` +feat(dns): manage Scriptable DNS scripts (`bunny dns scripts` init/create/deploy/attach/list) with ambient runtime types in `@bunny.net/scriptable-dns-types` diff --git a/AGENTS.md b/AGENTS.md index 0e15e16..e75c85a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -311,15 +311,14 @@ bunny-cli/ │ │ │ ├── enable.ts # Enable DNS query logging (optional IP anonymization) │ │ │ └── disable.ts # Disable DNS query logging (with confirmation) │ │ │ └── scripts/ # `dns scripts` — Scriptable DNS scripts (canonical: scripts; alias: script). Reuses the compute API (EdgeScriptType 0); separate from `bunny scripts` (different runtime, no pull zone) -│ │ │ ├── index.ts # defineNamespace("scripts", ...) — init/create/save/publish/connect/list +│ │ │ ├── index.ts # defineNamespace("scripts", ...) — init/create/deploy/attach/list │ │ │ ├── constants.ts # DNS_SCRIPT_MANIFEST (".bunny/dns-script.json") + DnsScriptManifest; SCRIPT_TYPE_DNS; inline EXAMPLES (empty/geo/closest/weighted/failover/pullzone); TSCONFIG + TYPES_PACKAGE scaffold strings │ │ │ ├── api.ts # ComputeClient helpers: fetchDnsScripts/fetchDnsScript (type 0), createDnsScript (no pull zone), uploadCode, publishScript │ │ │ ├── interactive.ts # resolveDnsScriptId (arg → .bunny/dns-script.json → DNS-script picker) │ │ │ ├── init.ts # Scaffold a project: example chooser, handleQuery.js + tsconfig + package.json (devDep on @bunny.net/scriptable-dns-types) + manifest; optional --deploy │ │ │ ├── create.ts # Create the remote DNS script (no pull zone), link manifest -│ │ │ ├── save.ts # Upload code (single file, no build); --publish to publish in one step -│ │ │ ├── publish.ts # Publish the latest uploaded code as the live release -│ │ │ ├── connect.ts # Bridge: add a SCRIPT record on a zone pointing at the script (confirms before the DNS write) +│ │ │ ├── deploy.ts # Upload code (single file, no build) + publish; prompts for the file when omitted, --skip-publish to stage +│ │ │ ├── attach.ts # Bridge: add a SCRIPT record on a zone pointing at the script (confirms before the DNS write) │ │ │ └── list.ts # List DNS scripts (alias: ls) │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list diff --git a/bun.lock b/bun.lock index 10474be..9a6dde9 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "packages/app-config": { "name": "@bunny.net/app-config", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "zod": "^4.3.6", @@ -24,7 +24,7 @@ }, "packages/cli": { "name": "@bunny.net/cli", - "version": "0.5.3", + "version": "0.7.0", "bin": { "bunny": "./bin/bunny.cjs", }, @@ -55,23 +55,23 @@ }, "packages/cli-darwin-arm64": { "name": "@bunny.net/cli-darwin-arm64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-darwin-x64": { "name": "@bunny.net/cli-darwin-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-linux-arm64": { "name": "@bunny.net/cli-linux-arm64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-linux-x64": { "name": "@bunny.net/cli-linux-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/cli-windows-x64": { "name": "@bunny.net/cli-windows-x64", - "version": "0.5.3", + "version": "0.7.0", }, "packages/database-adapter-libsql": { "name": "@bunny.net/database-adapter-libsql", @@ -172,7 +172,7 @@ }, "packages/openapi-client": { "name": "@bunny.net/openapi-client", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "openapi-fetch": "^0.17.0", }, @@ -181,6 +181,10 @@ "typescript": "^5", }, }, + "packages/scriptable-dns-types": { + "name": "@bunny.net/scriptable-dns-types", + "version": "0.1.0", + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -277,6 +281,8 @@ "@bunny.net/openapi-client": ["@bunny.net/openapi-client@workspace:packages/openapi-client"], + "@bunny.net/scriptable-dns-types": ["@bunny.net/scriptable-dns-types@workspace:packages/scriptable-dns-types"], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], diff --git a/packages/cli/src/commands/dns/scripts/connect.ts b/packages/cli/src/commands/dns/scripts/attach.ts similarity index 78% rename from packages/cli/src/commands/dns/scripts/connect.ts rename to packages/cli/src/commands/dns/scripts/attach.ts index 48fe2d4..4906565 100644 --- a/packages/cli/src/commands/dns/scripts/connect.ts +++ b/packages/cli/src/commands/dns/scripts/attach.ts @@ -3,6 +3,7 @@ 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"; @@ -15,8 +16,8 @@ import { resolveDnsScriptId } from "./interactive.ts"; type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; -const COMMAND = "connect [domain] [name] [id]"; -const DESCRIPTION = "Point a DNS record at a Scriptable DNS script."; +const COMMAND = "attach [domain] [name] [id]"; +const DESCRIPTION = "Attach a Scriptable DNS script to a domain."; const ARG_DOMAIN = "domain"; const ARG_DOMAIN_DESCRIPTION = "Domain or zone ID (prompted when omitted)"; @@ -27,7 +28,7 @@ const ARG_ID_DESCRIPTION = "DNS script ID (uses the linked script if omitted)"; const ARG_TTL = "ttl"; const ARG_TTL_DESCRIPTION = "Time to live in seconds"; -interface ConnectArgs { +interface AttachArgs { [ARG_DOMAIN]?: string; [ARG_NAME]?: string; [ARG_ID]?: number; @@ -35,7 +36,7 @@ interface ConnectArgs { } /** - * Connect a Scriptable DNS script to a zone by adding a SCRIPT record that + * Attach a Scriptable DNS script to a domain by adding a SCRIPT record that * routes the chosen name to the script. * * This is the bridge between `bunny dns scripts` and the zone's records: a @@ -45,19 +46,19 @@ interface ConnectArgs { * @example * ```bash * # Interactive: pick a zone and the linked script - * bunny dns scripts connect + * bunny dns scripts attach * * # Route api.example.com at script 12345 - * bunny dns scripts connect example.com api 12345 + * bunny dns scripts attach example.com api 12345 * ``` */ -export const dnsScriptsConnectCommand = defineCommand({ +export const dnsScriptsAttachCommand = defineCommand({ command: COMMAND, describe: DESCRIPTION, examples: [ - ["$0 dns scripts connect", "Pick a zone and the linked script"], + ["$0 dns scripts attach", "Pick a zone and the linked script"], [ - "$0 dns scripts connect example.com api 12345", + "$0 dns scripts attach example.com api 12345", "Route api.example.com at script 12345", ], ], @@ -93,7 +94,7 @@ export const dnsScriptsConnectCommand = defineCommand({ const scriptId = await resolveDnsScriptId( computeClient, args[ARG_ID], - "connect", + "attach", isInteractive, ); const script = await fetchDnsScript(computeClient, scriptId); @@ -103,7 +104,17 @@ export const dnsScriptsConnectCommand = defineCommand({ offerLink: true, }); - const name = (args[ARG_NAME] ?? "@").trim(); + let nameInput = args[ARG_NAME]; + if (nameInput === undefined && isInteractive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Record name ('@' for apex):", + initial: "@", + }); + nameInput = value ?? "@"; + } + const name = (nameInput ?? "@").trim(); const record: AddDnsRecordModel = { Type: RECORD_TYPES.SCRIPT, Name: name === "@" ? "" : name, @@ -121,7 +132,7 @@ export const dnsScriptsConnectCommand = defineCommand({ } } - const spin = spinner("Connecting..."); + const spin = spinner("Attaching..."); spin.start(); let data: { Id?: number } | undefined; try { @@ -150,7 +161,7 @@ export const dnsScriptsConnectCommand = defineCommand({ } logger.success( - `Connected ${recordName(record.Name)}.${zone.Domain} to DNS script ${script.Name ?? scriptId}${data?.Id != null ? ` (record ${data.Id})` : ""}.`, + `Attached DNS script ${script.Name ?? scriptId} to ${recordName(record.Name)}.${zone.Domain}${data?.Id != null ? ` (record ${data.Id})` : ""}.`, ); }, }); diff --git a/packages/cli/src/commands/dns/scripts/create.ts b/packages/cli/src/commands/dns/scripts/create.ts index bce91ce..20f4694 100644 --- a/packages/cli/src/commands/dns/scripts/create.ts +++ b/packages/cli/src/commands/dns/scripts/create.ts @@ -1,5 +1,6 @@ import { basename, resolve } from "node:path"; import { createComputeClient } 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"; @@ -67,7 +68,18 @@ export const dnsScriptsCreateCommand = defineCommand({ const { profile, output, verbose, apiKey } = args; const isInteractive = output !== "json" && process.stdout.isTTY; - const name = args[ARG_NAME] ?? basename(resolve(process.cwd())); + const dirName = basename(resolve(process.cwd())); + let name = args[ARG_NAME]; + if (!name && isInteractive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Script name:", + initial: dirName, + }); + name = value; + } + name ??= dirName; if (!name) throw new UserError("Script name is required."); const config = resolveConfig(profile, apiKey, verbose); @@ -133,7 +145,7 @@ export const dnsScriptsCreateCommand = defineCommand({ } logger.log(); - logger.dim(" Save code: bunny dns scripts save"); - logger.dim(" Connect: bunny dns scripts connect"); + logger.dim(" Deploy: bunny dns scripts deploy"); + logger.dim(" Attach: bunny dns scripts attach"); }, }); diff --git a/packages/cli/src/commands/dns/scripts/save.ts b/packages/cli/src/commands/dns/scripts/deploy.ts similarity index 65% rename from packages/cli/src/commands/dns/scripts/save.ts rename to packages/cli/src/commands/dns/scripts/deploy.ts index ba118a1..575f645 100644 --- a/packages/cli/src/commands/dns/scripts/save.ts +++ b/packages/cli/src/commands/dns/scripts/deploy.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { createComputeClient } 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"; @@ -16,46 +17,45 @@ import { } from "./constants.ts"; import { resolveDnsScriptId } from "./interactive.ts"; -const COMMAND = "save [file] [id]"; -const DESCRIPTION = "Upload DNS script code (without publishing)."; +const COMMAND = "deploy [file] [id]"; +const DESCRIPTION = "Upload DNS script code and publish it."; const ARG_FILE = "file"; const ARG_FILE_DESCRIPTION = "Path to the script file (defaults to the entry file)"; const ARG_ID = "id"; const ARG_ID_DESCRIPTION = "DNS script ID (uses the linked script if omitted)"; -const ARG_PUBLISH = "publish"; -const ARG_PUBLISH_DESCRIPTION = "Publish the uploaded code as the live release"; +const ARG_SKIP_PUBLISH = "skip-publish"; +const ARG_SKIP_PUBLISH_DESCRIPTION = "Upload code without publishing"; -interface SaveArgs { +interface DeployArgs { [ARG_FILE]?: string; [ARG_ID]?: number; - [ARG_PUBLISH]?: boolean; + [ARG_SKIP_PUBLISH]?: boolean; } /** - * Upload DNS script code, creating an unpublished deployment. + * Upload DNS script code and publish it as the live release. * * The file is uploaded as-is: DNS scripts are a single `handleQuery` - * source file, so there is no build step. Pass `--publish` to promote - * the upload to the live release in one step, or run - * `bunny dns scripts publish` afterwards. + * source file, so there is no build step. Publishes by default; pass + * `--skip-publish` to stage the upload without making it live. * * @example * ```bash - * # Save the entry file from the linked script's directory - * bunny dns scripts save + * # Deploy the entry file from the linked script's directory + * bunny dns scripts deploy * - * # Save and publish a specific file to a specific script - * bunny dns scripts save handleQuery.js 12345 --publish + * # Stage a specific file without publishing + * bunny dns scripts deploy handleQuery.js 12345 --skip-publish * ``` */ -export const dnsScriptsSaveCommand = defineCommand({ +export const dnsScriptsDeployCommand = defineCommand({ command: COMMAND, describe: DESCRIPTION, examples: [ - ["$0 dns scripts save", "Upload the entry file"], - ["$0 dns scripts save --publish", "Upload and publish"], + ["$0 dns scripts deploy", "Upload and publish the entry file"], + ["$0 dns scripts deploy --skip-publish", "Stage without publishing"], ], builder: (yargs) => @@ -68,9 +68,9 @@ export const dnsScriptsSaveCommand = defineCommand({ type: "number", describe: ARG_ID_DESCRIPTION, }) - .option(ARG_PUBLISH, { + .option(ARG_SKIP_PUBLISH, { type: "boolean", - describe: ARG_PUBLISH_DESCRIPTION, + describe: ARG_SKIP_PUBLISH_DESCRIPTION, }), handler: async (args) => { @@ -78,7 +78,18 @@ export const dnsScriptsSaveCommand = defineCommand({ const isInteractive = output !== "json" && process.stdout.isTTY; const manifest = loadManifest(DNS_SCRIPT_MANIFEST); - const file = args[ARG_FILE] ?? manifest.entry ?? DEFAULT_ENTRY; + const defaultFile = manifest.entry ?? DEFAULT_ENTRY; + let file = args[ARG_FILE]; + if (file === undefined && isInteractive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "File to upload:", + initial: defaultFile, + }); + file = value; + } + file ??= defaultFile; const absPath = resolve(file); if (!existsSync(absPath)) { throw new UserError( @@ -94,7 +105,7 @@ export const dnsScriptsSaveCommand = defineCommand({ const id = await resolveDnsScriptId( client, args[ARG_ID], - "save to", + "deploy to", isInteractive, ); @@ -107,7 +118,7 @@ export const dnsScriptsSaveCommand = defineCommand({ } logger.success("Code uploaded."); - const published = args[ARG_PUBLISH] === true; + const published = args[ARG_SKIP_PUBLISH] !== true; if (published) { const pubSpin = spinner("Publishing..."); pubSpin.start(); @@ -126,7 +137,7 @@ export const dnsScriptsSaveCommand = defineCommand({ if (!published) { logger.log(); - logger.dim(" Publish: bunny dns scripts publish"); + logger.dim(" Publish later: bunny dns scripts deploy"); } }, }); diff --git a/packages/cli/src/commands/dns/scripts/index.ts b/packages/cli/src/commands/dns/scripts/index.ts index 01ffd4d..71bc2bf 100644 --- a/packages/cli/src/commands/dns/scripts/index.ts +++ b/packages/cli/src/commands/dns/scripts/index.ts @@ -1,10 +1,9 @@ import { defineNamespace } from "../../../core/define-namespace.ts"; -import { dnsScriptsConnectCommand } from "./connect.ts"; +import { dnsScriptsAttachCommand } from "./attach.ts"; import { dnsScriptsCreateCommand } from "./create.ts"; +import { dnsScriptsDeployCommand } from "./deploy.ts"; import { dnsScriptsInitCommand } from "./init.ts"; import { dnsScriptsListCommand } from "./list.ts"; -import { dnsScriptsPublishCommand } from "./publish.ts"; -import { dnsScriptsSaveCommand } from "./save.ts"; export const dnsScriptsNamespace = defineNamespace( "scripts", @@ -12,9 +11,8 @@ export const dnsScriptsNamespace = defineNamespace( [ dnsScriptsInitCommand, dnsScriptsCreateCommand, - dnsScriptsSaveCommand, - dnsScriptsPublishCommand, - dnsScriptsConnectCommand, + dnsScriptsDeployCommand, + dnsScriptsAttachCommand, dnsScriptsListCommand, ], ["script"], diff --git a/packages/cli/src/commands/dns/scripts/init.ts b/packages/cli/src/commands/dns/scripts/init.ts index cc4dbf0..0e492fa 100644 --- a/packages/cli/src/commands/dns/scripts/init.ts +++ b/packages/cli/src/commands/dns/scripts/init.ts @@ -65,7 +65,7 @@ function packageJson(name: string): string { * Writes a `handleQuery` entry file from a chosen example, a tsconfig and * package.json wired to the ambient runtime types for editor autocomplete, * and a `.bunny/dns-script.json` manifest. Optionally creates the script on - * bunny.net so it is ready for `bunny dns scripts save`. + * bunny.net so it is ready for `bunny dns scripts deploy`. * * @example * ```bash @@ -242,7 +242,7 @@ export const dnsScriptsInitCommand = defineCommand({ logger.log(); logger.dim(` cd ${dirName}`); if (!created) logger.dim(" Create: bunny dns scripts create"); - logger.dim(" Save: bunny dns scripts save --publish"); - logger.dim(" Connect: bunny dns scripts connect"); + logger.dim(" Deploy: bunny dns scripts deploy"); + logger.dim(" Attach: bunny dns scripts attach"); }, }); diff --git a/packages/cli/src/commands/dns/scripts/publish.ts b/packages/cli/src/commands/dns/scripts/publish.ts deleted file mode 100644 index 3d25aba..0000000 --- a/packages/cli/src/commands/dns/scripts/publish.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createComputeClient } 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 { publishScript } from "./api.ts"; -import { resolveDnsScriptId } from "./interactive.ts"; - -const COMMAND = "publish [id]"; -const DESCRIPTION = "Publish the latest uploaded code as the live release."; - -const ARG_ID = "id"; -const ARG_ID_DESCRIPTION = "DNS script ID (uses the linked script if omitted)"; - -interface PublishArgs { - [ARG_ID]?: number; -} - -/** - * Publish a DNS script's latest uploaded code as the live release. - * - * Run this after `bunny dns scripts save` to make the saved code live. - * - * @example - * ```bash - * # Publish the linked script - * bunny dns scripts publish - * - * # Publish a specific script - * bunny dns scripts publish 12345 - * ``` - */ -export const dnsScriptsPublishCommand = defineCommand({ - command: COMMAND, - describe: DESCRIPTION, - examples: [ - ["$0 dns scripts publish", "Publish the linked script"], - ["$0 dns scripts publish 12345", "Publish a specific script"], - ], - - builder: (yargs) => - yargs.positional(ARG_ID, { - type: "number", - describe: ARG_ID_DESCRIPTION, - }), - - handler: async (args) => { - const { profile, output, verbose, apiKey } = args; - const isInteractive = output !== "json" && process.stdout.isTTY; - - const config = resolveConfig(profile, apiKey, verbose); - const client = createComputeClient(clientOptions(config, verbose)); - - const id = await resolveDnsScriptId( - client, - args[ARG_ID], - "publish", - isInteractive, - ); - - const spin = spinner("Publishing..."); - spin.start(); - try { - await publishScript(client, id); - } finally { - spin.stop(); - } - - if (output === "json") { - logger.log(JSON.stringify({ id, published: true }, null, 2)); - return; - } - - logger.success(`Published DNS script ${id}.`); - }, -}); From 57e43e48f58cef7f985dc188a89215058b59e9c6 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 25 Jun 2026 15:29:10 +0100 Subject: [PATCH 3/4] link dns script --- .changeset/dry-crabs-help.md | 2 +- AGENTS.md | 3 +- .../cli/src/commands/dns/scripts/index.ts | 2 + .../src/commands/dns/scripts/interactive.ts | 2 +- packages/cli/src/commands/dns/scripts/link.ts | 118 ++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/dns/scripts/link.ts diff --git a/.changeset/dry-crabs-help.md b/.changeset/dry-crabs-help.md index acac03a..f28d68d 100644 --- a/.changeset/dry-crabs-help.md +++ b/.changeset/dry-crabs-help.md @@ -3,4 +3,4 @@ "@bunny.net/scriptable-dns-types": patch --- -feat(dns): manage Scriptable DNS scripts (`bunny dns scripts` init/create/deploy/attach/list) with ambient runtime types in `@bunny.net/scriptable-dns-types` +feat(dns): manage Scriptable DNS scripts (`bunny dns scripts` init/create/deploy/attach/link/list) with ambient runtime types in `@bunny.net/scriptable-dns-types` diff --git a/AGENTS.md b/AGENTS.md index e75c85a..c733a5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -311,7 +311,7 @@ bunny-cli/ │ │ │ ├── enable.ts # Enable DNS query logging (optional IP anonymization) │ │ │ └── disable.ts # Disable DNS query logging (with confirmation) │ │ │ └── scripts/ # `dns scripts` — Scriptable DNS scripts (canonical: scripts; alias: script). Reuses the compute API (EdgeScriptType 0); separate from `bunny scripts` (different runtime, no pull zone) -│ │ │ ├── index.ts # defineNamespace("scripts", ...) — init/create/deploy/attach/list +│ │ │ ├── index.ts # defineNamespace("scripts", ...) — init/create/deploy/attach/link/list │ │ │ ├── constants.ts # DNS_SCRIPT_MANIFEST (".bunny/dns-script.json") + DnsScriptManifest; SCRIPT_TYPE_DNS; inline EXAMPLES (empty/geo/closest/weighted/failover/pullzone); TSCONFIG + TYPES_PACKAGE scaffold strings │ │ │ ├── api.ts # ComputeClient helpers: fetchDnsScripts/fetchDnsScript (type 0), createDnsScript (no pull zone), uploadCode, publishScript │ │ │ ├── interactive.ts # resolveDnsScriptId (arg → .bunny/dns-script.json → DNS-script picker) @@ -319,6 +319,7 @@ bunny-cli/ │ │ │ ├── create.ts # Create the remote DNS script (no pull zone), link manifest │ │ │ ├── deploy.ts # Upload code (single file, no build) + publish; prompts for the file when omitted, --skip-publish to stage │ │ │ ├── attach.ts # Bridge: add a SCRIPT record on a zone pointing at the script (confirms before the DNS write) +│ │ │ ├── link.ts # Link this directory to an existing DNS script → .bunny/dns-script.json (positional id, else pick interactively; preserves entry) │ │ │ └── list.ts # List DNS scripts (alias: ls) │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list diff --git a/packages/cli/src/commands/dns/scripts/index.ts b/packages/cli/src/commands/dns/scripts/index.ts index 71bc2bf..637a823 100644 --- a/packages/cli/src/commands/dns/scripts/index.ts +++ b/packages/cli/src/commands/dns/scripts/index.ts @@ -3,6 +3,7 @@ import { dnsScriptsAttachCommand } from "./attach.ts"; import { dnsScriptsCreateCommand } from "./create.ts"; import { dnsScriptsDeployCommand } from "./deploy.ts"; import { dnsScriptsInitCommand } from "./init.ts"; +import { dnsScriptsLinkCommand } from "./link.ts"; import { dnsScriptsListCommand } from "./list.ts"; export const dnsScriptsNamespace = defineNamespace( @@ -13,6 +14,7 @@ export const dnsScriptsNamespace = defineNamespace( dnsScriptsCreateCommand, dnsScriptsDeployCommand, dnsScriptsAttachCommand, + dnsScriptsLinkCommand, dnsScriptsListCommand, ], ["script"], diff --git a/packages/cli/src/commands/dns/scripts/interactive.ts b/packages/cli/src/commands/dns/scripts/interactive.ts index 42b5170..df2faeb 100644 --- a/packages/cli/src/commands/dns/scripts/interactive.ts +++ b/packages/cli/src/commands/dns/scripts/interactive.ts @@ -30,7 +30,7 @@ export async function resolveDnsScriptId( if (!interactive) { throw new UserError( "No DNS script ID provided and none linked.", - "Pass an ID, or run from a directory created by `bunny dns scripts init`.", + "Pass an ID, or link this directory with `bunny dns scripts link`.", ); } diff --git a/packages/cli/src/commands/dns/scripts/link.ts b/packages/cli/src/commands/dns/scripts/link.ts new file mode 100644 index 0000000..7e6a40d --- /dev/null +++ b/packages/cli/src/commands/dns/scripts/link.ts @@ -0,0 +1,118 @@ +import { createComputeClient } 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 { loadManifest, saveManifest } from "../../../core/manifest.ts"; +import { spinner } from "../../../core/ui.ts"; +import { fetchDnsScript, fetchDnsScripts } from "./api.ts"; +import { + DEFAULT_ENTRY, + DNS_SCRIPT_MANIFEST, + type DnsScriptManifest, + SCRIPT_TYPE_DNS, +} from "./constants.ts"; + +const COMMAND = "link [id]"; +const DESCRIPTION = "Link the current directory to a Scriptable DNS script."; + +const ARG_ID = "id"; +const ARG_ID_DESCRIPTION = "DNS script ID (skips the interactive prompt)"; + +interface LinkArgs { + [ARG_ID]?: number; +} + +/** + * Link the current directory to an existing Scriptable DNS script. + * + * Writes the script ID into `.bunny/dns-script.json` so `deploy` and + * `attach` resolve it without an explicit ID. Use this when a directory + * wasn't created by `init`/`create` (e.g. a cloned repo). A pre-existing + * manifest's entry file is preserved. + * + * @example + * ```bash + * # Interactive selection + * bunny dns scripts link + * + * # Direct link by ID + * bunny dns scripts link 12345 + * ``` + */ +export const dnsScriptsLinkCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 dns scripts link", "Interactive selection"], + ["$0 dns scripts link 12345", "Direct link by ID"], + ], + + builder: (yargs) => + yargs.positional(ARG_ID, { + type: "number", + describe: ARG_ID_DESCRIPTION, + }), + + handler: async ({ [ARG_ID]: id, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createComputeClient(clientOptions(config, verbose)); + const existing = loadManifest(DNS_SCRIPT_MANIFEST); + + let selected: Awaited>; + if (id != null) { + const spin = spinner("Fetching DNS script..."); + spin.start(); + try { + selected = await fetchDnsScript(client, id); + } finally { + spin.stop(); + } + } else { + const spin = spinner("Fetching DNS scripts..."); + spin.start(); + let scripts: Awaited>; + try { + scripts = await fetchDnsScripts(client); + } finally { + spin.stop(); + } + + if (scripts.length === 0) { + throw new UserError( + "No DNS scripts found.", + "Create one with `bunny dns scripts create`.", + ); + } + + const { value } = await prompts({ + type: "select", + name: "value", + message: "DNS script to link:", + choices: scripts.map((s) => ({ + title: `${s.Name ?? "(unnamed)"} (${s.Id})`, + value: s, + })), + }); + if (!value) throw new UserError("Link cancelled."); + selected = value; + } + + saveManifest(DNS_SCRIPT_MANIFEST, { + ...existing, + id: selected.Id, + name: selected.Name ?? undefined, + scriptType: SCRIPT_TYPE_DNS, + entry: existing.entry ?? DEFAULT_ENTRY, + }); + + if (output === "json") { + logger.log(JSON.stringify({ id: selected.Id, name: selected.Name })); + return; + } + + logger.success(`Linked to ${selected.Name} (${selected.Id}).`); + }, +}); From 64643338489447ceece177c888fa638b6ab32d0f Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 26 Jun 2026 14:27:20 +0100 Subject: [PATCH 4/4] static-vs-scriptable --- .changeset/dry-crabs-help.md | 2 +- AGENTS.md | 2 +- packages/cli/src/commands/dns/record/add.ts | 83 +++++++++++-------- .../cli/src/commands/dns/scripts/constants.ts | 22 +++++ .../src/commands/dns/scripts/interactive.ts | 83 ++++++++++++++++++- 5 files changed, 152 insertions(+), 40 deletions(-) diff --git a/.changeset/dry-crabs-help.md b/.changeset/dry-crabs-help.md index f28d68d..cb05552 100644 --- a/.changeset/dry-crabs-help.md +++ b/.changeset/dry-crabs-help.md @@ -3,4 +3,4 @@ "@bunny.net/scriptable-dns-types": patch --- -feat(dns): manage Scriptable DNS scripts (`bunny dns scripts` init/create/deploy/attach/link/list) with ambient runtime types in `@bunny.net/scriptable-dns-types` +feat(dns): manage Scriptable DNS scripts (`bunny dns scripts` init/create/deploy/attach/link/list) with ambient runtime types in `@bunny.net/scriptable-dns-types`; `dns records add` offers a static or script-computed answer for A/AAAA/CNAME/TXT diff --git a/AGENTS.md b/AGENTS.md index c733a5f..828f48e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,7 +287,7 @@ bunny-cli/ │ │ │ ├── record/ # `dns records` — entries within a zone (canonical: records; aliases: record, rec) │ │ │ │ ├── index.ts # defineNamespace("records", ...) │ │ │ │ ├── list.ts # List records in a zone (alias: ls) -│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script; SCRIPT type lists DNS scripts as a picker interactively) +│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script). Interactively, A/AAAA/CNAME/TXT offer static vs script-computed (Scriptable DNS) via pickOrCreateDnsScript — pick or create+seed a DNS script and write a SCRIPT record │ │ │ │ ├── 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) diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index b9a516c..5c8c9c3 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -18,7 +18,8 @@ import { recordName, recordTypeLabel, } from "../record-types.ts"; -import { fetchDnsScripts } from "../scripts/api.ts"; +import type { AnswerKind } from "../scripts/constants.ts"; +import { pickOrCreateDnsScript } from "../scripts/interactive.ts"; type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"]; type RecordLinks = Pick; @@ -121,7 +122,6 @@ function required(value: T | undefined, label: string): T { async function promptRecord( type: DnsRecordTypes, name: string, - dnsScripts: Array<{ id: number; name: string }> = [], ): Promise { const record: AddDnsRecordModel = { Type: type, @@ -139,25 +139,6 @@ async function promptRecord( } if (type === RECORD_TYPES.SCRIPT) { - // Offer the account's DNS scripts as a picker; fall back to a manual ID. - if (dnsScripts.length > 0) { - const { id } = await prompts({ - type: "select", - name: "id", - message: "DNS script:", - choices: [ - ...dnsScripts.map((s) => ({ - title: `${s.name} (${s.id})`, - value: s.id, - })), - { title: "Enter a script ID manually", value: -1 }, - ], - }); - if (id !== undefined && id !== -1) { - record.ScriptId = id; - return record; - } - } const { id } = await prompts({ type: "number", name: "id", @@ -309,24 +290,54 @@ export const dnsAddCommand = defineCommand({ name = res.name ?? "@"; } - // For SCRIPT records, offer the account's DNS scripts as a picker. - let dnsScripts: Array<{ id: number; name: string }> = []; - if (type === RECORD_TYPES.SCRIPT) { - const scriptSpin = spinner("Fetching DNS scripts..."); - scriptSpin.start(); - try { - const computeClient = createComputeClient( - clientOptions(config, verbose), - ); - dnsScripts = (await fetchDnsScripts(computeClient)) - .filter((s): s is typeof s & { Id: number } => s.Id != null) - .map((s) => ({ id: s.Id, name: s.Name ?? "(unnamed)" })); - } finally { - scriptSpin.stop(); + const recName = name ?? "@"; + + // Offer a script-computed answer for types a DNS script can return. + const SCRIPTABLE_ANSWERS = new Set([ + RECORD_TYPES.A, + RECORD_TYPES.AAAA, + RECORD_TYPES.CNAME, + RECORD_TYPES.TXT, + ]); + let useScript = type === RECORD_TYPES.SCRIPT; + let starterKind: AnswerKind | undefined; + if (!useScript && SCRIPTABLE_ANSWERS.has(type)) { + const { source } = await prompts({ + type: "select", + name: "source", + message: "Value source:", + choices: [ + { title: "Static value", value: "static" }, + { + title: "Computed by a script (Scriptable DNS)", + value: "script", + }, + ], + }); + if (source === undefined) + throw new UserError("A value source is required."); + if (source === "script") { + useScript = true; + starterKind = recordTypeLabel(type) as AnswerKind; } } - record = await promptRecord(type, name ?? "@", dnsScripts); + if (useScript) { + const computeClient = createComputeClient( + clientOptions(config, verbose), + ); + const picked = await pickOrCreateDnsScript(computeClient, { + starterKind, + defaultName: `${zone.Domain ?? "dns"}-script`, + }); + record = { + Type: RECORD_TYPES.SCRIPT, + Name: recName === "@" ? "" : recName, + ScriptId: picked.id, + }; + } else { + record = await promptRecord(type, recName); + } if (args.ttl === undefined) { const { ttl } = await prompts({ diff --git a/packages/cli/src/commands/dns/scripts/constants.ts b/packages/cli/src/commands/dns/scripts/constants.ts index 5bb39df..3c66e46 100644 --- a/packages/cli/src/commands/dns/scripts/constants.ts +++ b/packages/cli/src/commands/dns/scripts/constants.ts @@ -115,6 +115,28 @@ export default function handleQuery(query) { }, ]; +/** Answer types a DNS script can return that also have a static record form. */ +export type AnswerKind = "A" | "AAAA" | "CNAME" | "TXT"; + +const ANSWER_CTOR: Record = { + A: 'new ARecord("203.0.113.10", 30)', + AAAA: 'new AaaaRecord("2001:db8::1", 30)', + CNAME: 'new CnameRecord("example.com", 30)', + TXT: 'new TxtRecord("hello world", 30)', +}; + +/** Starter `handleQuery` source returning the given answer type (A by default). */ +export function dnsScriptStarter(kind?: AnswerKind): string { + const answer = kind ? ANSWER_CTOR[kind] : ANSWER_CTOR.A; + return `${REFERENCE} + +/** @param {DnsRequest} query */ +export default function handleQuery(query) { + return ${answer}; +} +`; +} + /** tsconfig that typechecks a DNS script against the ambient runtime types. */ export const TSCONFIG = `${JSON.stringify( { diff --git a/packages/cli/src/commands/dns/scripts/interactive.ts b/packages/cli/src/commands/dns/scripts/interactive.ts index df2faeb..aa36d58 100644 --- a/packages/cli/src/commands/dns/scripts/interactive.ts +++ b/packages/cli/src/commands/dns/scripts/interactive.ts @@ -3,8 +3,19 @@ import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { loadManifest } from "../../../core/manifest.ts"; import { spinner } from "../../../core/ui.ts"; -import { type ComputeClient, fetchDnsScripts } from "./api.ts"; -import { DNS_SCRIPT_MANIFEST, type DnsScriptManifest } from "./constants.ts"; +import { + type ComputeClient, + createDnsScript, + fetchDnsScripts, + publishScript, + uploadCode, +} from "./api.ts"; +import { + type AnswerKind, + DNS_SCRIPT_MANIFEST, + type DnsScriptManifest, + dnsScriptStarter, +} from "./constants.ts"; /** * Resolve a DNS script ID from an explicit value, the linked manifest, or a @@ -62,3 +73,71 @@ export async function resolveDnsScriptId( if (value === undefined) throw new UserError("A DNS script is required."); return value; } + +const CREATE_NEW = -1; + +/** + * Pick an existing DNS script, or create a new one seeded with starter code so + * the record it backs is never dead. Returns the chosen script's id and name. + * + * Used by `dns records add` when the user opts for a script-computed answer. + * A newly created script is uploaded and published with a `handleQuery` that + * returns `starterKind` (an A record when omitted). + */ +export async function pickOrCreateDnsScript( + client: ComputeClient, + opts: { starterKind?: AnswerKind; defaultName?: string } = {}, +): Promise<{ id: number; name: string }> { + const spin = spinner("Fetching DNS scripts..."); + spin.start(); + let scripts: Awaited>; + try { + scripts = await fetchDnsScripts(client); + } finally { + spin.stop(); + } + + const { value } = await prompts({ + type: "select", + name: "value", + message: "DNS script:", + choices: [ + ...scripts + .filter((s) => s.Id != null) + .map((s) => ({ + title: `${s.Name ?? "(unnamed)"} (${s.Id})`, + value: s.Id as number, + })), + { title: "+ Create a new DNS script", value: CREATE_NEW }, + ], + }); + if (value === undefined) throw new UserError("A DNS script is required."); + + if (value !== CREATE_NEW) { + const found = scripts.find((s) => s.Id === value); + return { id: value, name: found?.Name ?? String(value) }; + } + + const { name } = await prompts({ + type: "text", + name: "name", + message: "New script name:", + initial: opts.defaultName ?? "dns-script", + }); + if (!name) throw new UserError("A script name is required."); + + const createSpin = spinner(`Creating DNS script "${name}"...`); + createSpin.start(); + let created: { id: number; name: string }; + try { + created = await createDnsScript(client, name); + await uploadCode(client, created.id, dnsScriptStarter(opts.starterKind)); + await publishScript(client, created.id); + } finally { + createSpin.stop(); + } + + logger.success(`Created DNS script "${created.name}" (${created.id}).`); + logger.dim(` Edit locally: bunny dns scripts link ${created.id}`); + return created; +}