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
5 changes: 5 additions & 0 deletions .changeset/giant-phones-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

feat(scripts): add deployments publish for rollbacks
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ bunny-cli/
│ │ └── scripts/
│ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands
│ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS
│ │ ├── api.ts # Shared: fetchScript(s), fetchEnvEntries, fetchScriptHostnames, logLiveHostnames, promptOpenInBrowser
│ │ ├── 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)
Expand All @@ -285,7 +286,8 @@ bunny-cli/
│ │ ├── show.ts # Show Edge Script details + hostnames (supports manifest fallback)
│ │ ├── deployments/
│ │ │ ├── index.ts # defineNamespace("deployments", ...)
│ │ │ └── list.ts # List deployments for an Edge Script
│ │ │ ├── list.ts # List deployments for an Edge Script
│ │ │ └── publish.ts # Publish (roll back to) a past deployment by release ID
│ │ ├── hostnames/
│ │ │ └── index.ts # Mounts core/hostnames factory: script pull-zone resolver + --id/--pull-zone, visible as "domains" with hidden "hostnames" alias
│ │ └── env/
Expand Down Expand Up @@ -846,7 +848,9 @@ bunny
│ │ 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
│ │ ├── list [id] (alias: ls) List deployments for an Edge Script
│ │ └── publish <release> [id] [--force]
│ │ Publish (roll back to) a past deployment by release ID
│ ├── docs Open Edge Script documentation in browser
│ ├── domains (hidden alias: hostnames)
│ │ ├── add <domain> [--ssl] [--no-force-ssl] [--id] [--pull-zone]
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,20 @@ bunny scripts deployments list <script-id>
bunny scripts deployments list --output json
```

##### `bunny scripts deployments publish`

Publish (roll back to) a past deployment by its release ID, as shown in `deployments list`. `bunny scripts deploy` already uploads and publishes in one step; use this to re-publish an earlier release without touching the current code. Uses the linked script if no ID is provided.

```bash
bunny scripts deployments publish <release-id>
bunny scripts deployments publish <release-id> <script-id>
bunny scripts deployments publish <release-id> --force
```

| Flag | Description |
| --------- | ---------------------------- |
| `--force` | Skip the confirmation prompt |

#### `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.
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/commands/scripts/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { findAcrossPages } from "./api.ts";

/** Serve `pages` one at a time; `moreOnLast` keeps hasMore true past the end. */
function pager<T>(pages: T[][], moreOnLast = false) {
let calls = 0;
const fetchPage = async (page: number) => {
calls += 1;
return {
items: pages[page - 1] ?? [],
hasMore: moreOnLast || page < pages.length,
};
};
return { fetchPage, calls: () => calls };
}

describe("findAcrossPages", () => {
test("finds across pages, stopping as soon as it matches", async () => {
const p = pager([[1, 2], [3], [4]]);
expect(await findAcrossPages(p.fetchPage, (n) => n === 3)).toBe(3);
expect(p.calls()).toBe(2);
});

test("returns undefined when no page matches", async () => {
const p = pager([[1], [2]]);
expect(
await findAcrossPages(p.fetchPage, (n) => n === 999),
).toBeUndefined();
expect(p.calls()).toBe(2);
});

test("stops on an empty page even if hasMore stays true", async () => {
const p = pager<number>([[]], true);
expect(await findAcrossPages(p.fetchPage, () => true)).toBeUndefined();
expect(p.calls()).toBe(1);
});
});
83 changes: 83 additions & 0 deletions packages/cli/src/commands/scripts/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
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 {
type CoreClient,
fetchHostnamesForZones,
type Hostname,
liveHostnames,
} from "../../core/hostnames/index.ts";
import { logger } from "../../core/logger.ts";
import { confirm, openBrowser } from "../../core/ui.ts";
import { SCRIPT_TYPE_MIDDLEWARE, SCRIPT_TYPE_STANDALONE } from "./constants.ts";
Expand All @@ -9,6 +15,9 @@ type ComputeClient = ReturnType<typeof createComputeClient>;
type EdgeScript = components["schemas"]["EdgeScriptModel"];
type EdgeScriptVariable = components["schemas"]["EdgeScriptVariableModel"];
type EdgeScriptSecret = components["schemas"]["EdgeScriptSecretModel"];
type EdgeScriptRelease = components["schemas"]["EdgeScriptReleaseModel"];

const RELEASES_PER_PAGE = 1000;

export interface EnvEntry {
id: number;
Expand Down Expand Up @@ -76,6 +85,80 @@ export async function fetchEnvEntries(
].sort((a, b) => a.name.localeCompare(b.name));
}

/** Walk paginated results one page at a time, short-circuiting as soon as `match` hits. */
export async function findAcrossPages<T>(
fetchPage: (page: number) => Promise<{ items: T[]; hasMore: boolean }>,
match: (item: T) => boolean,
): Promise<T | undefined> {
let page = 1;
while (true) {
const { items, hasMore } = await fetchPage(page);
const found = items.find(match);
if (found) return found;

// Stop on the last page, or if a page comes back empty (guards a bad flag).
if (!hasMore || items.length === 0) return undefined;
page += 1;
}
}

/** Find a non-deleted release by ID, paging through the script's releases until found or exhausted. */
export function findRelease(
client: ComputeClient,
scriptId: number,
releaseId: number,
): Promise<EdgeScriptRelease | undefined> {
return findAcrossPages(
async (page) => {
const { data } = await client.GET("/compute/script/{id}/releases", {
params: {
path: { id: scriptId },
query: { page, perPage: RELEASES_PER_PAGE },
},
});
return {
items: (data?.Items ?? []) as EdgeScriptRelease[],
hasMore: data?.HasMoreItems ?? false,
};
},
(r) => !r.Deleted && r.Id === releaseId,
);
}

/** Fetch every hostname across a script's linked pull zones (parallel; per-zone errors logged). */
export async function fetchScriptHostnames(
coreClient: CoreClient,
script: EdgeScript,
verbose: boolean,
): Promise<Hostname[]> {
const zoneIds = (script.LinkedPullZones ?? [])
.map((zone) => zone.Id)
.filter((id): id is number => id != null);
return fetchHostnamesForZones(coreClient, zoneIds, (zoneId, err) =>
logger.debug(
`Failed to fetch hostnames for pull zone ${zoneId}: ${err}`,
verbose,
),
);
}

/** Print where a script is live, listing custom domains, falling back to the zone default. */
export function logLiveHostnames(
script: EdgeScript,
hostnames: Hostname[],
): void {
const { primary, customs } = liveHostnames(hostnames);
if (primary) logger.info(`Live at: ${primary}`);
for (const url of customs) logger.log(` ${url}`);

// Only fall back to the zone default when nothing resolved from the API —
// a valueless primary must not discard the custom domains we did find.
if (!primary && customs.length === 0) {
const fallback = script.LinkedPullZones?.[0]?.DefaultHostname;
if (fallback) logger.info(`Live at: ${fallback}`);
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/** Prompt to open a script's hostname in the browser, with a deploy hint otherwise. */
export async function promptOpenInBrowser(hostname: string): Promise<void> {
const shouldOpen = await confirm("Open script in browser?");
Expand Down
58 changes: 4 additions & 54 deletions packages/cli/src/commands/scripts/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,10 @@ 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";
import { fetchScript, fetchScriptHostnames, logLiveHostnames } from "./api.ts";
import { SCRIPT_MANIFEST } from "./constants.ts";

const COMMAND = "deploy <file> [id]";
Expand Down Expand Up @@ -136,55 +132,9 @@ export const scriptsDeployCommand = defineCommand<DeployArgs>({

if (!published) return;

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

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 script = await fetchScript(client, id);
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;
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,
})}`,
);
}
const hostnames = await fetchScriptHostnames(coreClient, script, verbose);
logLiveHostnames(script, hostnames);
},
});
3 changes: 2 additions & 1 deletion packages/cli/src/commands/scripts/deployments/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { defineNamespace } from "../../../core/define-namespace.ts";
import { scriptsDeploymentsListCommand } from "./list.ts";
import { scriptsDeploymentsPublishCommand } from "./publish.ts";

export const scriptsDeploymentsNamespace = defineNamespace(
"deployments",
"Manage Edge Script deployments.",
[scriptsDeploymentsListCommand],
[scriptsDeploymentsListCommand, scriptsDeploymentsPublishCommand],
);
16 changes: 11 additions & 5 deletions packages/cli/src/commands/scripts/deployments/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createComputeClient } from "@bunny.net/openapi-client";
import {
createComputeClient,
createCoreClient,
} from "@bunny.net/openapi-client";
import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts";
import { resolveConfig } from "../../../config/index.ts";
import { clientOptions } from "../../../core/client-options.ts";
Expand All @@ -7,6 +10,7 @@ import { formatDateTime, formatTable } from "../../../core/format.ts";
import { logger } from "../../../core/logger.ts";
import { resolveManifestId } from "../../../core/manifest.ts";
import { spinner } from "../../../core/ui.ts";
import { fetchScriptHostnames, logLiveHostnames } from "../api.ts";
import { SCRIPT_MANIFEST } from "../constants.ts";

type EdgeScript = components["schemas"]["EdgeScriptModel"];
Expand Down Expand Up @@ -75,7 +79,8 @@ export const scriptsDeploymentsListCommand = defineCommand<ListArgs>({
handler: async ({ [ARG_ID]: rawId, profile, output, verbose, apiKey }) => {
const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script");
const config = resolveConfig(profile, apiKey, verbose);
const client = createComputeClient(clientOptions(config, verbose));
const options = clientOptions(config, verbose);
const client = createComputeClient(options);

const spin = spinner("Fetching deployments...");
spin.start();
Expand Down Expand Up @@ -106,7 +111,6 @@ export const scriptsDeploymentsListCommand = defineCommand<ListArgs>({
}

const script = scriptResult.data;
const hostname = script?.LinkedPullZones?.[0]?.DefaultHostname ?? undefined;

if (script?.Name) {
logger.info(`Deployments for ${script.Name}:`);
Expand All @@ -130,11 +134,13 @@ export const scriptsDeploymentsListCommand = defineCommand<ListArgs>({
);

if (
hostname &&
script &&
releases.some((r: EdgeScriptRelease) => r.Status === RELEASE_STATUS_LIVE)
) {
const coreClient = createCoreClient(options);
const hostnames = await fetchScriptHostnames(coreClient, script, verbose);
logger.log();
logger.info(`Live at: ${hostname}`);
logLiveHostnames(script, hostnames);
}
},
});
Loading