From 2e14d9ee6d7f1bac6a9d7945e4067cfb33e2c68a Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 19:58:56 +0100 Subject: [PATCH 1/4] feat(scripts): add deployments publish for rollbacks --- .changeset/giant-phones-divide.md | 5 + AGENTS.md | 8 +- packages/cli/README.md | 14 ++ packages/cli/src/commands/scripts/api.ts | 38 +++++ packages/cli/src/commands/scripts/deploy.ts | 58 +------ .../src/commands/scripts/deployments/index.ts | 3 +- .../src/commands/scripts/deployments/list.ts | 16 +- .../commands/scripts/deployments/publish.ts | 158 ++++++++++++++++++ packages/cli/src/commands/scripts/show.ts | 22 +-- packages/cli/src/core/hostnames/client.ts | 37 ++++ packages/cli/src/core/hostnames/index.ts | 2 + 11 files changed, 280 insertions(+), 81 deletions(-) create mode 100644 .changeset/giant-phones-divide.md create mode 100644 packages/cli/src/commands/scripts/deployments/publish.ts diff --git a/.changeset/giant-phones-divide.md b/.changeset/giant-phones-divide.md new file mode 100644 index 0000000..8757226 --- /dev/null +++ b/.changeset/giant-phones-divide.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(scripts): add deployments publish for rollbacks diff --git a/AGENTS.md b/AGENTS.md index a29b65b..67bfdf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) @@ -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/ @@ -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 [id] [--force] +│ │ Publish (roll back to) a past deployment by release ID │ ├── docs Open Edge Script documentation in browser │ ├── domains (hidden alias: hostnames) │ │ ├── add [--ssl] [--no-force-ssl] [--id] [--pull-zone] diff --git a/packages/cli/README.md b/packages/cli/README.md index a581de8..3407bdf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -549,6 +549,20 @@ bunny scripts deployments list 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 +bunny scripts deployments publish +bunny scripts deployments publish --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 ` to target another. diff --git a/packages/cli/src/commands/scripts/api.ts b/packages/cli/src/commands/scripts/api.ts index 0ad4d21..4a28ecf 100644 --- a/packages/cli/src/commands/scripts/api.ts +++ b/packages/cli/src/commands/scripts/api.ts @@ -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"; @@ -76,6 +82,38 @@ export async function fetchEnvEntries( ].sort((a, b) => a.name.localeCompare(b.name)); } +/** 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 { + 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}`); + return; + } + const fallback = script.LinkedPullZones?.[0]?.DefaultHostname; + if (fallback) logger.info(`Live at: ${fallback}`); +} + /** Prompt to open a script's hostname in the browser, with a deploy hint otherwise. */ export async function promptOpenInBrowser(hostname: string): Promise { const shouldOpen = await confirm("Open script in browser?"); diff --git a/packages/cli/src/commands/scripts/deploy.ts b/packages/cli/src/commands/scripts/deploy.ts index 5be284c..324d439 100644 --- a/packages/cli/src/commands/scripts/deploy.ts +++ b/packages/cli/src/commands/scripts/deploy.ts @@ -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 [id]"; @@ -136,55 +132,9 @@ export const scriptsDeployCommand = defineCommand({ 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); }, }); diff --git a/packages/cli/src/commands/scripts/deployments/index.ts b/packages/cli/src/commands/scripts/deployments/index.ts index 6686924..a079476 100644 --- a/packages/cli/src/commands/scripts/deployments/index.ts +++ b/packages/cli/src/commands/scripts/deployments/index.ts @@ -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], ); diff --git a/packages/cli/src/commands/scripts/deployments/list.ts b/packages/cli/src/commands/scripts/deployments/list.ts index df0e336..706c17f 100644 --- a/packages/cli/src/commands/scripts/deployments/list.ts +++ b/packages/cli/src/commands/scripts/deployments/list.ts @@ -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"; @@ -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"]; @@ -75,7 +79,8 @@ export const scriptsDeploymentsListCommand = defineCommand({ 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(); @@ -106,7 +111,6 @@ export const scriptsDeploymentsListCommand = defineCommand({ } const script = scriptResult.data; - const hostname = script?.LinkedPullZones?.[0]?.DefaultHostname ?? undefined; if (script?.Name) { logger.info(`Deployments for ${script.Name}:`); @@ -130,11 +134,13 @@ export const scriptsDeploymentsListCommand = defineCommand({ ); 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); } }, }); diff --git a/packages/cli/src/commands/scripts/deployments/publish.ts b/packages/cli/src/commands/scripts/deployments/publish.ts new file mode 100644 index 0000000..0f15b4a --- /dev/null +++ b/packages/cli/src/commands/scripts/deployments/publish.ts @@ -0,0 +1,158 @@ +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"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveManifestId } from "../../../core/manifest.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { fetchScript, fetchScriptHostnames, logLiveHostnames } from "../api.ts"; +import { SCRIPT_MANIFEST } from "../constants.ts"; + +type EdgeScript = components["schemas"]["EdgeScriptModel"]; +type EdgeScriptRelease = components["schemas"]["EdgeScriptReleaseModel"]; + +const COMMAND = "publish [id]"; +const DESCRIPTION = "Publish (roll back to) a past Edge Script deployment."; + +const ARG_RELEASE = "release"; +const ARG_RELEASE_DESCRIPTION = + "Release ID to publish (see `deployments list`)"; +const ARG_ID = "id"; +const ARG_ID_DESCRIPTION = "Edge Script ID (uses linked script if omitted)"; +const ARG_FORCE = "force"; +const ARG_FORCE_DESCRIPTION = "Skip the confirmation prompt"; + +interface PublishArgs { + [ARG_RELEASE]: number; + [ARG_ID]?: EdgeScript["Id"]; + [ARG_FORCE]?: boolean; +} + +/** + * Publish a past release as the live deployment — i.e. roll back. + * + * `scripts deploy` already uploads and publishes in one step; this command + * re-publishes an earlier release (by the ID shown in `deployments list`) + * without touching the current code. Falls back to the linked script ID from + * the local manifest when no explicit ID is provided. + * + * @example + * ```bash + * # Roll back the linked script to release 42 + * bunny scripts deployments publish 42 + * + * # Roll back a specific script + * bunny scripts deployments publish 42 12345 + * + * # Skip the confirmation prompt + * bunny scripts deployments publish 42 --force + * ``` + */ +export const scriptsDeploymentsPublishCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + [ + "$0 scripts deployments publish 42", + "Roll back linked script to a release", + ], + ["$0 scripts deployments publish 42 12345", "Roll back a specific script"], + [ + "$0 scripts deployments publish 42 --force", + "Skip the confirmation prompt", + ], + ], + + builder: (yargs) => + yargs + .positional(ARG_RELEASE, { + type: "number", + describe: ARG_RELEASE_DESCRIPTION, + demandOption: true, + }) + .positional(ARG_ID, { + type: "number", + describe: ARG_ID_DESCRIPTION, + }) + .option(ARG_FORCE, { + type: "boolean", + alias: "f", + describe: ARG_FORCE_DESCRIPTION, + }), + + handler: async ({ + [ARG_RELEASE]: releaseId, + [ARG_ID]: rawId, + [ARG_FORCE]: force, + profile, + output, + verbose, + apiKey, + }) => { + const id = resolveManifestId(SCRIPT_MANIFEST, rawId, "script"); + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const client = createComputeClient(options); + + const spin = spinner("Fetching deployments..."); + spin.start(); + + const { data } = await client.GET("/compute/script/{id}/releases", { + params: { path: { id } }, + }); + + spin.stop(); + + const release = (data?.Items ?? []) + .filter((r: EdgeScriptRelease) => !r.Deleted) + .find((r: EdgeScriptRelease) => r.Id === releaseId); + + if (!release) { + throw new UserError( + `Release ${releaseId} not found for script ${id}.`, + "Run `bunny scripts deployments list` to see available releases.", + ); + } + if (!release.Uuid) { + throw new UserError(`Release ${releaseId} cannot be published.`); + } + + const proceed = await confirm( + `Publish release ${releaseId} as the live deployment?`, + { force: force || output === "json" }, + ); + if (!proceed) { + logger.info("Aborted."); + return; + } + + const pubSpin = spinner("Publishing..."); + pubSpin.start(); + + await client.POST("/compute/script/{id}/publish/{uuid}", { + params: { path: { id, uuid: release.Uuid } }, + body: {}, + }); + + pubSpin.stop(); + + if (output === "json") { + logger.log( + JSON.stringify({ id, release: releaseId, published: true }, null, 2), + ); + return; + } + + logger.success(`Release ${releaseId} published.`); + + const script = await fetchScript(client, id); + const coreClient = createCoreClient(options); + const hostnames = await fetchScriptHostnames(coreClient, script, verbose); + logLiveHostnames(script, hostnames); + }, +}); diff --git a/packages/cli/src/commands/scripts/show.ts b/packages/cli/src/commands/scripts/show.ts index 35bfc73..38579eb 100644 --- a/packages/cli/src/commands/scripts/show.ts +++ b/packages/cli/src/commands/scripts/show.ts @@ -7,16 +7,11 @@ import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { formatKeyValue, formatTable } from "../../core/format.ts"; -import { - fetchPullZoneHostnames, - type Hostname, - hostnameUrl, - toSafeHostname, -} from "../../core/hostnames/index.ts"; +import { hostnameUrl, toSafeHostname } 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 } from "./api.ts"; +import { fetchScript, fetchScriptHostnames } from "./api.ts"; import { SCRIPT_MANIFEST, scriptTypeLabel } from "./constants.ts"; type EdgeScript = components["schemas"]["EdgeScriptModel"]; @@ -78,18 +73,7 @@ export const scriptsShowCommand = defineCommand({ // Pull each linked pull zone's hostnames (incl. custom domains + SSL state). const coreClient = createCoreClient(options); - const hostnames: Hostname[] = []; - for (const zone of script.LinkedPullZones ?? []) { - 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, - ); - } - } + const hostnames = await fetchScriptHostnames(coreClient, script, verbose); spin.stop(); diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index 1cee184..aae76da 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -64,6 +64,43 @@ export async function fetchPullZoneHostnames( }); } +/** Fetch hostnames across several pull zones in parallel, tolerating per-zone failures. */ +export async function fetchHostnamesForZones( + client: CoreClient, + zoneIds: number[], + onError?: (zoneId: number, err: unknown) => void, +): Promise { + const results = await Promise.all( + zoneIds.map(async (zoneId) => { + try { + return await fetchPullZoneHostnames(client, zoneId); + } catch (err) { + onError?.(zoneId, err); + return [] as Hostname[]; + } + }), + ); + return results.flat(); +} + +/** Pick the system-preferred live URL plus any custom-domain URLs from a hostname list. */ +export function liveHostnames(hostnames: Hostname[]): { + primary?: string; + customs: string[]; +} { + if (hostnames.length === 0) return { customs: [] }; + const primaryHost = hostnames.find((h) => h.IsSystemHostname) ?? hostnames[0]; + const toUrl = (h: Hostname) => + hostnameUrl(h.Value ?? "", { + hasCertificate: h.HasCertificate, + forceSSL: h.ForceSSL, + }); + return { + primary: primaryHost?.Value ? toUrl(primaryHost) : undefined, + customs: hostnames.filter((h) => h !== primaryHost && h.Value).map(toUrl), + }; +} + /** Issue a free SSL certificate for a hostname on a pull zone, then set its Force SSL state. */ export async function enableSsl( client: CoreClient, diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index 3ed610e..cea08a1 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -1,9 +1,11 @@ export { type CoreClient, enableSsl, + fetchHostnamesForZones, fetchPullZoneHostnames, type Hostname, hostnameUrl, + liveHostnames, type ResolvedPullZone, type SafeHostname, toSafeHostname, From cb58ee1e0777823b10b876d9543cb425be12568a Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 20:02:01 +0100 Subject: [PATCH 2/4] refactor --- packages/cli/src/commands/scripts/deployments/publish.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/scripts/deployments/publish.ts b/packages/cli/src/commands/scripts/deployments/publish.ts index 0f15b4a..f2df028 100644 --- a/packages/cli/src/commands/scripts/deployments/publish.ts +++ b/packages/cli/src/commands/scripts/deployments/publish.ts @@ -143,7 +143,11 @@ export const scriptsDeploymentsPublishCommand = defineCommand({ if (output === "json") { logger.log( - JSON.stringify({ id, release: releaseId, published: true }, null, 2), + JSON.stringify( + { id, release: releaseId, uuid: release.Uuid, published: true }, + null, + 2, + ), ); return; } From d70e0b1fe956553406b1c6a4667d70281545ccd0 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 20:13:49 +0100 Subject: [PATCH 3/4] refactor --- packages/cli/src/commands/scripts/api.test.ts | 37 ++++++++++++++++ packages/cli/src/commands/scripts/api.ts | 43 +++++++++++++++++++ .../commands/scripts/deployments/publish.ts | 20 ++++----- 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/commands/scripts/api.test.ts diff --git a/packages/cli/src/commands/scripts/api.test.ts b/packages/cli/src/commands/scripts/api.test.ts new file mode 100644 index 0000000..b2016bc --- /dev/null +++ b/packages/cli/src/commands/scripts/api.test.ts @@ -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(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([[]], true); + expect(await findAcrossPages(p.fetchPage, () => true)).toBeUndefined(); + expect(p.calls()).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/scripts/api.ts b/packages/cli/src/commands/scripts/api.ts index 4a28ecf..63b60d0 100644 --- a/packages/cli/src/commands/scripts/api.ts +++ b/packages/cli/src/commands/scripts/api.ts @@ -15,6 +15,9 @@ type ComputeClient = ReturnType; 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; @@ -82,6 +85,46 @@ 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( + fetchPage: (page: number) => Promise<{ items: T[]; hasMore: boolean }>, + match: (item: T) => boolean, +): Promise { + 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 { + 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, diff --git a/packages/cli/src/commands/scripts/deployments/publish.ts b/packages/cli/src/commands/scripts/deployments/publish.ts index f2df028..aa47313 100644 --- a/packages/cli/src/commands/scripts/deployments/publish.ts +++ b/packages/cli/src/commands/scripts/deployments/publish.ts @@ -10,11 +10,15 @@ import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { resolveManifestId } from "../../../core/manifest.ts"; import { confirm, spinner } from "../../../core/ui.ts"; -import { fetchScript, fetchScriptHostnames, logLiveHostnames } from "../api.ts"; +import { + fetchScript, + fetchScriptHostnames, + findRelease, + logLiveHostnames, +} from "../api.ts"; import { SCRIPT_MANIFEST } from "../constants.ts"; type EdgeScript = components["schemas"]["EdgeScriptModel"]; -type EdgeScriptRelease = components["schemas"]["EdgeScriptReleaseModel"]; const COMMAND = "publish [id]"; const DESCRIPTION = "Publish (roll back to) a past Edge Script deployment."; @@ -102,16 +106,10 @@ export const scriptsDeploymentsPublishCommand = defineCommand({ const spin = spinner("Fetching deployments..."); spin.start(); - const { data } = await client.GET("/compute/script/{id}/releases", { - params: { path: { id } }, - }); + const release = await findRelease(client, id, releaseId); spin.stop(); - const release = (data?.Items ?? []) - .filter((r: EdgeScriptRelease) => !r.Deleted) - .find((r: EdgeScriptRelease) => r.Id === releaseId); - if (!release) { throw new UserError( `Release ${releaseId} not found for script ${id}.`, @@ -124,10 +122,10 @@ export const scriptsDeploymentsPublishCommand = defineCommand({ const proceed = await confirm( `Publish release ${releaseId} as the live deployment?`, - { force: force || output === "json" }, + { force }, ); if (!proceed) { - logger.info("Aborted."); + logger.log("Cancelled."); return; } From 856415a9165dda4c3f0c906e4fb6385ae47c8096 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 4 Jun 2026 20:16:54 +0100 Subject: [PATCH 4/4] further refactoring --- packages/cli/src/commands/scripts/api.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/scripts/api.ts b/packages/cli/src/commands/scripts/api.ts index 63b60d0..a6ae88d 100644 --- a/packages/cli/src/commands/scripts/api.ts +++ b/packages/cli/src/commands/scripts/api.ts @@ -148,13 +148,15 @@ export function logLiveHostnames( hostnames: Hostname[], ): void { const { primary, customs } = liveHostnames(hostnames); - if (primary) { - logger.info(`Live at: ${primary}`); - for (const url of customs) logger.log(` ${url}`); - return; + 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}`); } - const fallback = script.LinkedPullZones?.[0]?.DefaultHostname; - if (fallback) logger.info(`Live at: ${fallback}`); } /** Prompt to open a script's hostname in the browser, with a deploy hint otherwise. */