Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dry-crabs-help.md
Original file line number Diff line number Diff line change
@@ -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/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
21 changes: 19 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

```
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -273,15 +279,15 @@ 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
│ │ │ ├── record-types.ts # Re-exports RECORD_TYPES/recordTypeLabel from core/dns-record-types.ts; adds parseRecordType, recordName, formatRecordValue
│ │ │ ├── record/ # `dns records` — entries within a zone (canonical: records; aliases: record, rec)
│ │ │ │ ├── index.ts # defineNamespace("records", ...)
│ │ │ │ ├── list.ts # List records in a zone (alias: ls)
│ │ │ │ ├── add.ts # Add a record (positional grammar per type, or interactive wizard; --pull-zone/--script)
│ │ │ │ ├── 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)
Expand All @@ -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/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)
│ │ │ ├── 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
│ │ │ ├── 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
│ │ │ ├── list.ts # List container registries
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 14 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/src/commands/dns/index.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
56 changes: 54 additions & 2 deletions packages/cli/src/commands/dns/record/add.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +18,8 @@ import {
recordName,
recordTypeLabel,
} from "../record-types.ts";
import type { AnswerKind } from "../scripts/constants.ts";
import { pickOrCreateDnsScript } from "../scripts/interactive.ts";

type AddDnsRecordModel = components["schemas"]["AddDnsRecordModel"];
type RecordLinks = Pick<AddDnsRecordModel, "PullZoneId" | "ScriptId">;
Expand Down Expand Up @@ -285,7 +290,54 @@ export const dnsAddCommand = defineCommand<AddArgs>({
name = res.name ?? "@";
}

record = await promptRecord(type, name ?? "@");
const recName = name ?? "@";

// Offer a script-computed answer for types a DNS script can return.
const SCRIPTABLE_ANSWERS = new Set<number>([
RECORD_TYPES.A,
RECORD_TYPES.AAAA,
RECORD_TYPES.CNAME,
RECORD_TYPES.TXT,
]);
let useScript = type === RECORD_TYPES.SCRIPT;
let starterKind: AnswerKind | undefined;
if (!useScript && SCRIPTABLE_ANSWERS.has(type)) {
const { source } = await prompts({
type: "select",
name: "source",
message: "Value source:",
choices: [
{ title: "Static value", value: "static" },
{
title: "Computed by a script (Scriptable DNS)",
value: "script",
},
],
});
if (source === undefined)
throw new UserError("A value source is required.");
if (source === "script") {
useScript = true;
starterKind = recordTypeLabel(type) as AnswerKind;
}
}

if (useScript) {
const computeClient = createComputeClient(
clientOptions(config, verbose),
);
const picked = await pickOrCreateDnsScript(computeClient, {
starterKind,
defaultName: `${zone.Domain ?? "dns"}-script`,
});
record = {
Type: RECORD_TYPES.SCRIPT,
Name: recName === "@" ? "" : recName,
ScriptId: picked.id,
};
} else {
record = await promptRecord(type, recName);
}

if (args.ttl === undefined) {
const { ttl } = await prompts({
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/commands/dns/scripts/api.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createComputeClient>;
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<EdgeScript> {
const { data } = await client.GET("/compute/script/{id}", {
params: { path: { id } },
});
if (!data) throw new UserError(`DNS script ${id} not found.`);
return data;
Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Validate Script Type

fetchDnsScript only checks that the compute script exists. dns scripts link <id> can therefore write a regular Edge Script id into .bunny/dns-script.json, and later dns scripts deploy will upload DNS code to that non-DNS script while dns scripts attach can create a SCRIPT record pointing at it. The helper should reject scripts whose ScriptType is not SCRIPT_TYPE_DNS before callers treat the id as a Scriptable DNS script.

Fix in Claude Code

}

/** Fetch all DNS scripts on the account, sorted by name. */
export async function fetchDnsScripts(
client: ComputeClient,
): Promise<EdgeScript[]> {
const { data } = await client.GET("/compute/script", {
params: { query: { type: [SCRIPT_TYPE_DNS] } },
});
Comment on lines +25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Script List Drops Later Pages

The compute script list endpoint is paginated, but this call only reads the first page. Accounts past the API page size will see incomplete dns scripts list output, and interactive deploy or attach pickers will omit valid DNS scripts unless the id is entered manually.

Context Used: CLAUDE.md (source)

Fix in Claude Code

Comment on lines +25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Page through all DNS scripts

The compute list endpoint is paginated, but this helper only issues one GET with the DNS type filter. Accounts with more scripts than the first page will have dns scripts list, the attach picker, and the interactive SCRIPT-record picker silently omit valid scripts, so loop until HasMoreItems is false as the DNS zone helper does.

Useful? React with 👍 / 👎.


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<void> {
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<void> {
await client.POST("/compute/script/{id}/publish", {
params: { path: { id, uuid: null } },
body: {},
});
}
Loading