Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/better-baboons-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(scripts): manage custom domains for Edge Scripts (`bunny scripts domains`, with `hostnames` as a hidden alias)
27 changes: 25 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ bunny-cli/
│ │ ├── errors.ts # Re-exports UserError/ApiError from @bunny.net/openapi-client + ConfigError
│ │ ├── format.ts # Shared table/key-value rendering (text, table, csv, markdown)
│ │ ├── format.test.ts # Tests for format utilities
│ │ ├── hostnames/ # Reusable pull-zone hostname feature (mounted by scripts; apps next)
│ │ │ ├── index.ts # Re-exports client helpers + createHostnamesCommands
│ │ │ ├── client.ts # hostnameUrl(), fetchPullZoneHostnames(), enableSsl() + Hostname/ResolvedPullZone types
│ │ │ ├── client.test.ts # Tests for hostnameUrl() scheme logic
│ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver
│ │ ├── logger.ts # Chalk-based structured logger
│ │ ├── manifest.ts # .bunny/ context file resolution (load, save, resolveManifestId)
│ │ ├── types.ts # GlobalArgs, OutputFormat, and shared type definitions
Expand Down Expand Up @@ -268,15 +273,18 @@ bunny-cli/
│ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands
│ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS
│ │ ├── create.ts # Create a remote Edge Script (exports shared `createScript` helper)
│ │ ├── delete.ts # Delete an Edge Script (double confirmation or --force)
│ │ ├── deploy.ts # Deploy code to an Edge Script (publishes by default)
│ │ ├── docs.ts # Open Edge Script documentation in browser
│ │ ├── init.ts # Scaffold a new Edge Script project from a template (calls `createScript`)
│ │ ├── link.ts # Link directory to a remote Edge Script (.bunny/script.json)
│ │ ├── list.ts # List all Edge Scripts (Standalone + Middleware)
│ │ ├── show.ts # Show Edge Script details (supports manifest fallback)
│ │ ├── show.ts # Show Edge Script details + hostnames (supports manifest fallback)
│ │ ├── deployments/
│ │ │ ├── index.ts # defineNamespace("deployments", ...)
│ │ │ └── list.ts # List deployments for an Edge Script
│ │ ├── hostnames/
│ │ │ └── index.ts # Mounts core/hostnames factory: script pull-zone resolver + --id/--pull-zone, visible as "domains" with hidden "hostnames" alias
│ │ └── env/
│ │ ├── index.ts # defineNamespace("env", ...)
│ │ ├── list.ts # List environment variables for a script
Expand All @@ -301,10 +309,16 @@ bunny-cli/
- **Namespaces are directories** with an `index.ts` that calls `defineNamespace()`.
- **Leaf commands** are individual `.ts` files that call `defineCommand()`.
- **Top-level commands** (`login`, `logout`, `whoami`) are registered directly in `cli.ts` without a namespace.
- **Shared internal code lives in `packages/cli/src/core/`** — command factories, errors, logger, format utilities, UI helpers, and shared types. Keep this flat (no nested subdirectories).
- **Shared internal code lives in `packages/cli/src/core/`** — command factories, errors, logger, format utilities, UI helpers, and shared types. Keep this mostly flat; a cohesive, reusable feature spanning several files may use a subdirectory (e.g. `core/hostnames/` — the pull-zone hostname helpers + the `createHostnamesCommands` factory mounted by both `scripts` and, in future, `apps`).
- **Config logic lives in `packages/cli/src/config/`** — schema, file resolution, and profile management.
- **Error classes are split.** `UserError` and `ApiError` live in `@bunny.net/openapi-client` (the SDK needs them). `ConfigError` lives in the CLI and extends `UserError`. The CLI's `errors.ts` re-exports `UserError` and `ApiError` from `@bunny.net/openapi-client`.
- **Import API clients from `@bunny.net/openapi-client`**, not relative paths. Import generated types from `@bunny.net/openapi-client/generated/<spec>.d.ts`.
- **Pull-zone settings are exposed via "Hybrid D" across surfaces.** Scripts and apps are backed by a pull zone, which has a large settings surface (hostnames, caching, edge rules, origin, security, purge, CORS, optimizer, logging, …). To keep each owner's help legible:
- **Flatten only first-class groups** directly into the owner — picked by user mental model, kept to one or two. `scripts domains` is the flattened group (a custom domain is "my site's address," not a CDN setting).
- **Group the long tail** under a `pullzone` sub-namespace within the owner (e.g. `scripts pullzone <setting>`), so the owner's top-level help gains one line, not ten. Curate per owner — don't expose settings that don't apply (a script _is_ its pull zone's origin, so no origin-URL command under `scripts`).
- **A standalone `bunny pullzone` command** (planned) is the canonical full surface for pull zones not backing a script/app, targeted by `--id`.
- Each setting-area is a **mountable factory** like `createHostnamesCommands` (`core/hostnames/`): one `{ commandPath, target, resolve(args) => { pullZoneId, coreClient }, hiddenAliases }` mounted into the root `pullzone` (resolve from `--id`), `scripts` (resolve from the linked manifest), and `apps` (resolve from the CDN endpoint). The resolver is the only per-surface difference.
- Canonical term is `pullzone` (matches the bunny.net dashboard/API); `pz` is a hidden alias (`defineNamespace(alias, false, …)`), the same pattern as `domains`'s hidden `hostnames` alias.

---

Expand Down Expand Up @@ -827,9 +841,18 @@ bunny
│ │ Create a remote Edge Script (use after init when --deploy was skipped)
│ ├── deploy <file> [id] [--skip-publish]
│ │ Deploy code to an Edge Script (publishes by default)
│ ├── delete [id] [--force] Delete an Edge Script (double confirmation or --force)
│ ├── deployments
│ │ └── list [id] (alias: ls) List deployments for an Edge Script
│ ├── docs Open Edge Script documentation in browser
│ ├── domains (hidden alias: hostnames)
│ │ ├── add <domain> [--ssl] [--no-force-ssl] [--id] [--pull-zone]
│ │ │ Add a custom domain (SSL opt-in; HTTPS forced by default)
│ │ ├── ssl <domain> [--no-force-ssl] [--id] [--pull-zone]
│ │ │ Issue a free SSL certificate (HTTPS forced by default)
│ │ ├── list (alias: ls) [--id] [--pull-zone] List pull zone domains
│ │ └── remove <domain> (alias: rm) [--force] [--id] [--pull-zone]
│ │ Remove a custom domain
│ ├── env
│ │ ├── list [id] List environment variables
│ │ ├── set <key> <value> [id] Set environment variable
Expand Down
163 changes: 162 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ bunny open --print
bunny open --print --output json
```

### `bunny docs`

Open the bunny.net documentation in your default browser.

```bash
bunny docs
```

### `bunny config`

Manage CLI configuration and profiles.
Expand Down Expand Up @@ -477,6 +485,8 @@ bunny scripts deploy dist/index.js 12345
| ---------------- | ------------------------------ |
| `--skip-publish` | Upload code without publishing |

After publishing, the live URL and any custom domains are printed.

> **Note:** `bunny scripts deploy` works regardless of how the script was created or whether GitHub Actions is configured. The last deployment always wins — whether triggered by a GitHub Action or a manual CLI deploy.

#### `bunny scripts link`
Expand All @@ -503,13 +513,156 @@ bunny scripts list --output json

#### `bunny scripts show`

Show details for an Edge Script. Uses the linked script from `.bunny/script.json` if no ID is provided.
Show details for an Edge Script. Uses the linked script from `.bunny/script.json` if no ID is provided. Output includes the script's hostnames (system and custom) with their SSL status.

```bash
bunny scripts show <script-id>
bunny scripts show
```

#### `bunny scripts delete`

Delete an Edge Script. Uses the linked script if no ID is provided. Requires double confirmation (or `--force` to skip).

```bash
bunny scripts delete <script-id>
bunny scripts delete
bunny scripts delete <script-id> --force
```

| Flag | Description |
| --------- | ------------------------- |
| `--force` | Skip confirmation prompts |

#### `bunny scripts deployments`

Manage Edge Script deployments.

##### `bunny scripts deployments list`

List deployments for an Edge Script. Uses the linked script if no ID is provided.

```bash
bunny scripts deployments list
bunny scripts deployments ls
bunny scripts deployments list <script-id>
bunny scripts deployments list --output json
```

#### `bunny scripts env`

Manage environment variables and secrets for an Edge Script. All subcommands default to the linked script; pass `--id <script-id>` to target another.

##### `bunny scripts env list`

List environment variables and secrets.

```bash
bunny scripts env list
bunny scripts env ls
bunny scripts env list --output json
```

##### `bunny scripts env set`

Set an environment variable or secret. Runs interactively when arguments are omitted. The variable name is uppercased.

```bash
bunny scripts env set MY_VAR value
bunny scripts env set # interactive
bunny scripts env set API_KEY secret-value --secret
```

| Flag | Description |
| ---------- | --------------------------------------- |
| `--secret` | Store as an encrypted secret |
| `--id` | Edge Script ID (uses linked if omitted) |

##### `bunny scripts env remove`

Remove an environment variable or secret. Shows an interactive picker when no name is given; prompts for confirmation unless `--force`.

```bash
bunny scripts env remove MY_VAR
bunny scripts env rm MY_VAR -f
```

##### `bunny scripts env pull`

Pull environment variables to a local `.env` file.

```bash
bunny scripts env pull
bunny scripts env pull <script-id>
bunny scripts env pull --force
```

| Flag | Description |
| --------- | ---------------------------------------------- |
| `--force` | Overwrite an existing `.env` without prompting |

#### `bunny scripts domains`

Manage custom domains for an Edge Script. A script's domains live on its linked pull zone, so these commands operate on that pull zone. All subcommands default to the linked script; pass `--id <script-id>` to target another, and `--pull-zone <id>` when a script has more than one linked pull zone. (`bunny scripts hostnames` is kept as a hidden alias.)

##### `bunny scripts domains add`

Add a custom domain. SSL is **not** requested by default — a free certificate can only be issued once your DNS points at bunny.net, so the command prints the `CNAME` record to create and the follow-up command to enable HTTPS. Pass `--ssl` to issue a certificate immediately; HTTP is redirected to HTTPS by default (opt out with `--no-force-ssl`).

```bash
# Add a domain and get DNS instructions
bunny scripts domains add shop.example.com

# Add and request SSL now (DNS must already be pointed at bunny.net) — HTTPS forced
bunny scripts domains add shop.example.com --ssl

# Add and request SSL without forcing HTTPS
bunny scripts domains add shop.example.com --ssl --no-force-ssl
```

| Flag | Description |
| ---------------- | ----------------------------------------------------------------------- |
| `--ssl` | Issue a free SSL certificate now and force HTTPS (requires DNS pointed) |
| `--no-force-ssl` | When issuing SSL, keep serving HTTP instead of redirecting to HTTPS |
| `--id` | Edge Script ID (uses linked script if omitted) |
| `--pull-zone` | Pull zone ID (required if the script has multiple linked zones) |

##### `bunny scripts domains ssl`

Request a free SSL certificate for a custom domain. Run this after the domain's DNS points at bunny.net (see the `CNAME` printed by `domains add`). HTTP is redirected to HTTPS by default; pass `--no-force-ssl` to keep plain HTTP.

```bash
bunny scripts domains ssl shop.example.com
bunny scripts domains ssl shop.example.com --no-force-ssl
```

##### `bunny scripts domains list`

List the domains on a script's pull zone, with SSL and Force SSL status.

```bash
bunny scripts domains list
bunny scripts domains ls
bunny scripts domains list --output json
```

##### `bunny scripts domains remove`

Remove a custom domain. System hostnames controlled by bunny.net cannot be removed.

```bash
bunny scripts domains remove shop.example.com
bunny scripts domains remove shop.example.com --force
```

#### `bunny scripts docs`

Open the Edge Scripts documentation in your browser.

```bash
bunny scripts docs
```

### `bunny api`

Make a raw authenticated HTTP request to any bunny.net API endpoint. Auth is handled automatically via your configured API key.
Expand Down Expand Up @@ -543,6 +696,14 @@ bunny api GET /pullzone --verbose

The method is case-insensitive (`get` and `GET` both work). Paths are relative to `https://api.bunny.net` — use `/database/...` for the Database API and `/mc/...` for Magic Containers.

### `bunny completion`

Generate a shell completion script. Add the output to your shell profile to enable tab completion.

```bash
bunny completion >> ~/.zshrc
```

## Global Options

| Flag | Alias | Description | Default |
Expand Down
63 changes: 58 additions & 5 deletions packages/cli/src/commands/scripts/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { createComputeClient } from "@bunny.net/openapi-client";
import {
createComputeClient,
createCoreClient,
} from "@bunny.net/openapi-client";
import { resolveConfig } from "../../config/index.ts";
import { clientOptions } from "../../core/client-options.ts";
import { defineCommand } from "../../core/define-command.ts";
import { UserError } from "../../core/errors.ts";
import {
fetchPullZoneHostnames,
type Hostname,
hostnameUrl,
} from "../../core/hostnames/index.ts";
import { logger } from "../../core/logger.ts";
import { resolveManifestId } from "../../core/manifest.ts";
import { spinner } from "../../core/ui.ts";
Expand Down Expand Up @@ -92,7 +100,8 @@ export const scriptsDeployCommand = defineCommand<DeployArgs>({
const code = await Bun.file(absPath).text();

const config = resolveConfig(profile, apiKey, verbose);
const client = createComputeClient(clientOptions(config, verbose));
const options = clientOptions(config, verbose);
const client = createComputeClient(options);

const spin = spinner("Uploading code...");
spin.start();
Expand Down Expand Up @@ -125,13 +134,57 @@ export const scriptsDeployCommand = defineCommand<DeployArgs>({
return;
}

if (!published) return;

const { data: script } = await client.GET("/compute/script/{id}", {
params: { path: { id } },
});

const hostname = script?.LinkedPullZones?.[0]?.DefaultHostname ?? undefined;
if (hostname && published) {
logger.info(`Live at: ${hostname}`);
const zones = script?.LinkedPullZones ?? [];

// Pull the full hostname list (incl. custom domains) from the core API;
// fall back to the script's system hostname if that lookup fails.
const coreClient = createCoreClient(options);
const hostnames: Hostname[] = [];
for (const zone of zones) {
if (zone.Id == null) continue;
try {
hostnames.push(...(await fetchPullZoneHostnames(coreClient, zone.Id)));
} catch (err) {
logger.debug(
`Failed to fetch hostnames for pull zone ${zone.Id}: ${err}`,
verbose,
);
}
}

if (hostnames.length === 0) {
const fallback = zones[0]?.DefaultHostname;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (fallback) logger.info(`Live at: ${fallback}`);
return;
}

const system = hostnames.find((h) => h.IsSystemHostname);
const primary = system ?? hostnames[0];
const customs = hostnames.filter((h) => h !== primary);

if (primary?.Value) {
logger.info(
`Live at: ${hostnameUrl(primary.Value, {
hasCertificate: primary.HasCertificate,
forceSSL: primary.ForceSSL,
})}`,
);
}

for (const custom of customs) {
if (!custom.Value) continue;
logger.log(
` ${hostnameUrl(custom.Value, {
hasCertificate: custom.HasCertificate,
forceSSL: custom.ForceSSL,
})}`,
);
}
},
});
Loading