From 840698b947f16a5b1467be4e0f0d5bef94c8f4e3 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 18 Jun 2026 15:06:24 +0100 Subject: [PATCH 1/3] feat(registry): push and inspect images on the bunny.net registry --- packages/cli/README.md | 17 +++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/apps/docker.ts | 120 ++-------------- packages/cli/src/commands/registry/client.ts | 95 +++++++++++++ packages/cli/src/commands/registry/index.ts | 13 ++ packages/cli/src/commands/registry/list.ts | 65 +++++++++ packages/cli/src/commands/registry/push.ts | 97 +++++++++++++ .../cli/src/commands/registry/ref.test.ts | 52 +++++++ packages/cli/src/commands/registry/ref.ts | 57 ++++++++ packages/cli/src/commands/registry/tags.ts | 78 +++++++++++ packages/cli/src/core/docker.ts | 132 ++++++++++++++++++ 11 files changed, 618 insertions(+), 110 deletions(-) create mode 100644 packages/cli/src/commands/registry/client.ts create mode 100644 packages/cli/src/commands/registry/index.ts create mode 100644 packages/cli/src/commands/registry/list.ts create mode 100644 packages/cli/src/commands/registry/push.ts create mode 100644 packages/cli/src/commands/registry/ref.test.ts create mode 100644 packages/cli/src/commands/registry/ref.ts create mode 100644 packages/cli/src/commands/registry/tags.ts create mode 100644 packages/cli/src/core/docker.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index bb07432..9649559 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -402,6 +402,23 @@ bunny registries add --name "GitHub" --username myorg bunny registries remove ``` +### `bunny registry` + +> **Experimental** internal use only + +Push and inspect images on the bunny.net OCI registry. The endpoint is read from the `BUNNYNET_REGISTRY_URL` environment variable. + +```bash +export BUNNYNET_REGISTRY_URL=https:// + +bunny registry push myapp:latest # push, deriving repository/tag from the image +bunny registry push myapp:dev --repository team/myapp --tag v1 +bunny registry list # list repositories (alias: ls) +bunny registry tags team/myapp # list tags for a repository +``` + +`push` currently uses Docker to read the local image; `list` and `tags` talk to the registry directly. + ### `bunny dns` > **Experimental** — hidden from `--help` and the landing page while it stabilizes. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 784bbdf..31adc8f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,6 +12,7 @@ import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; +import { registryNamespace } from "./commands/registry/index.ts"; import { scriptsNamespace } from "./commands/scripts/index.ts"; import { whoamiCommand } from "./commands/whoami.ts"; import { bunny } from "./core/colors.ts"; @@ -34,6 +35,7 @@ const commands: CommandModule[] = [ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, + registryNamespace, dnsNamespace, ]; diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index a60e4ed..0db7df2 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -4,51 +4,25 @@ import { basename, isAbsolute, join, resolve } from "node:path"; import type { createMcClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import prompts from "prompts"; +import { dockerLogin, imageHostname } from "../../core/docker.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; +// Generic Docker primitives live in core/docker.ts; re-exported here so +// existing app-command imports keep resolving from this module. +export { + dockerLogin, + ensureDockerAvailable, + imageHostname, + pushImage, +} from "../../core/docker.ts"; + export type McClient = ReturnType; export type ContainerRegistry = components["schemas"]["ContainerRegistry"]; export type ConfigSuggestions = components["schemas"]["ContainerConfigSuggestions"]; -/** - * Ensure the Docker CLI is available on the system. - * - * Two failure modes need to map to the same friendly error: - * - `docker` not on PATH → `Bun.spawn` throws ENOENT synchronously - * (unlike Node's child_process, which emits an 'error' event). - * - `docker` on PATH but daemon not running / version probe fails → - * non-zero exit code. - * - * Without the try/catch the first case escapes as a raw spawn error - * and lands on the generic "An unexpected error occurred." branch in - * `defineCommand`, which hides the install link. - */ -export async function ensureDockerAvailable(): Promise { - let exitCode: number; - try { - const proc = Bun.spawn( - ["docker", "version", "--format", "{{.Client.Version}}"], - { - stdout: "pipe", - stderr: "pipe", - }, - ); - exitCode = await proc.exited; - } catch { - exitCode = 1; - } - - if (exitCode !== 0) { - throw new UserError( - "Docker is not installed or not running.", - "Install Docker from https://docs.docker.com/get-docker/", - ); - } -} - /** * Get a short git SHA for tagging images. * Returns the first 7 characters of HEAD, or null if not in a git repo. @@ -334,56 +308,6 @@ export async function buildImage( } } -/** - * Push a Docker image to a registry. - */ -export async function pushImage(tag: string): Promise { - const proc = Bun.spawn(["docker", "push", tag], { - stdout: "inherit", - stderr: "inherit", - }); - - const exitCode = await proc.exited; - - if (exitCode !== 0) { - const hostname = imageHostname(tag); - const hint = - hostname === "ghcr.io" - ? "If you saw `permission_denied: write_package`, your token is missing the `write:packages` scope. Run `gh auth refresh -h github.com -s write:packages` then `gh auth token | docker login ghcr.io -u $(gh api user --jq .login) --password-stdin` and try again." - : `Run \`docker login ${hostname ?? ""}\` and try again. Check that your token has push permission.`; - throw new UserError(`Docker push failed (exit code ${exitCode}).`, hint); - } -} - -/** - * Log in to a Docker registry. Pipes the password through stdin so it - * never appears in the process list. - */ -export async function dockerLogin( - hostname: string, - username: string, - password: string, -): Promise { - const proc = Bun.spawn( - ["docker", "login", hostname, "-u", username, "--password-stdin"], - { - stdin: new Response(password), - stdout: "pipe", - stderr: "pipe", - }, - ); - - const exitCode = await proc.exited; - - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - throw new UserError( - "Docker login failed.", - stderr.trim() || "Check your registry credentials.", - ); - } -} - /** * Check `~/.docker/config.json` for an existing credential record for * `hostname`. Looks at both the `auths` map (where Docker Desktop leaves @@ -635,30 +559,6 @@ export function buildImageRef( return ns ? `${hostName}/${ns}/${name}:${tag}` : `${hostName}/${name}:${tag}`; } -/** - * Extract the registry hostname from a Docker image reference. - * - * Returns null if the reference has no explicit hostname (i.e. it's a - * Docker Hub library or user image like `nginx:latest` or `library/redis`). - * - * A hostname only exists when the ref has a `/`. Otherwise the first - * segment is just `name[:tag]`, not `host[:port]`. Without that check, - * `nginx:1.27` would be mis-read as the hostname `nginx:1.27`. - */ -export function imageHostname(ref: string): string | null { - if (!ref.includes("/")) return null; - const firstSegment = ref.split("/")[0]; - if (!firstSegment) return null; - if ( - firstSegment.includes(".") || - firstSegment.includes(":") || - firstSegment === "localhost" - ) { - return firstSegment; - } - return null; -} - const ADD_NEW_REGISTRY = "__add_new__"; /** diff --git a/packages/cli/src/commands/registry/client.ts b/packages/cli/src/commands/registry/client.ts new file mode 100644 index 0000000..6ed5f56 --- /dev/null +++ b/packages/cli/src/commands/registry/client.ts @@ -0,0 +1,95 @@ +import { UserError } from "../../core/errors.ts"; + +/** Env var holding the OCI registry endpoint. Intentionally undocumented. */ +export const REGISTRY_URL_ENV = "BUNNYNET_REGISTRY_URL"; + +/** Basic-auth username for the registry. The API token is the password. */ +export const REGISTRY_USERNAME = "token"; + +export interface RegistryEndpoint { + /** Normalised base URL with no trailing slash (e.g. `https://host`). */ + baseUrl: string; + /** Host[:port] used for `docker login` / image refs. */ + host: string; +} + +/** + * Normalise a raw registry URL into a base URL and host, defaulting the + * scheme to https when omitted. Pure so it's testable without env access. + */ +export function parseRegistryUrl(raw: string): RegistryEndpoint { + const trimmed = raw.trim(); + if (!trimmed) throw new UserError("Registry URL is empty."); + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + + let url: URL; + try { + url = new URL(withScheme); + } catch { + throw new UserError(`Invalid registry URL: ${raw}`); + } + + return { baseUrl: url.origin, host: url.host }; +} + +/** + * Resolve the registry endpoint from the environment. The URL is kept out + * of help output and config so it isn't casually discoverable; an unset + * env var is an expected, friendly error rather than a crash. + */ +export function resolveRegistryEndpoint(): RegistryEndpoint { + const raw = process.env[REGISTRY_URL_ENV]; + if (!raw) { + throw new UserError( + "Registry endpoint is not configured.", + `Set ${REGISTRY_URL_ENV} to the registry URL and try again.`, + ); + } + return parseRegistryUrl(raw); +} + +/** Build the HTTP Basic auth header from the resolved API token. */ +export function basicAuthHeader(apiKey: string): string { + const encoded = Buffer.from(`${REGISTRY_USERNAME}:${apiKey}`).toString( + "base64", + ); + return `Basic ${encoded}`; +} + +/** + * Perform an authenticated request against an OCI distribution path + * (e.g. `/v2/_catalog`). Maps auth failures to a friendly error and + * returns the parsed JSON body for everything else. + */ +export async function registryRequest( + endpoint: RegistryEndpoint, + apiKey: string, + path: string, + init?: RequestInit, +): Promise { + const res = await fetch(`${endpoint.baseUrl}${path}`, { + ...init, + headers: { + Accept: "application/json", + Authorization: basicAuthHeader(apiKey), + ...init?.headers, + }, + }); + + if (res.status === 401 || res.status === 403) { + throw new UserError( + "Registry authentication failed.", + "Check that your API token is valid (`bunny whoami`).", + ); + } + if (!res.ok) { + throw new UserError( + `Registry request failed (HTTP ${res.status}).`, + (await res.text().catch(() => "")).trim() || undefined, + ); + } + + return (await res.json()) as T; +} diff --git a/packages/cli/src/commands/registry/index.ts b/packages/cli/src/commands/registry/index.ts new file mode 100644 index 0000000..f6012ce --- /dev/null +++ b/packages/cli/src/commands/registry/index.ts @@ -0,0 +1,13 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { registryListCommand } from "./list.ts"; +import { registryPushCommand } from "./push.ts"; +import { registryTagsCommand } from "./tags.ts"; + +// Hidden namespace: the registry endpoint is configured out-of-band via +// BUNNYNET_REGISTRY_URL and not advertised in help output. Auth is handled +// per-request with the API token — there's no separate login step. +export const registryNamespace = defineNamespace("registry", false, [ + registryPushCommand, + registryListCommand, + registryTagsCommand, +]); diff --git a/packages/cli/src/commands/registry/list.ts b/packages/cli/src/commands/registry/list.ts new file mode 100644 index 0000000..447b665 --- /dev/null +++ b/packages/cli/src/commands/registry/list.ts @@ -0,0 +1,65 @@ +import { resolveConfig } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { registryRequest, resolveRegistryEndpoint } from "./client.ts"; + +const COMMAND = "list"; +const DESCRIPTION = "List repositories in the bunny.net registry."; + +interface Catalog { + repositories?: string[]; +} + +export const registryListCommand = defineCommand({ + command: COMMAND, + aliases: ["ls"], + describe: DESCRIPTION, + + handler: async ({ profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + if (!config.apiKey) { + throw new UserError( + "Not logged in.", + 'Run "bunny login" to authenticate.', + ); + } + + const endpoint = resolveRegistryEndpoint(); + + const spin = spinner("Fetching repositories..."); + spin.start(); + let catalog: Catalog; + try { + catalog = await registryRequest( + endpoint, + config.apiKey, + "/v2/_catalog", + ); + } finally { + spin.stop(); + } + + const repositories = catalog.repositories ?? []; + + if (output === "json") { + logger.log(JSON.stringify({ repositories }, null, 2)); + return; + } + + if (repositories.length === 0) { + logger.info("No repositories found."); + return; + } + + logger.log( + formatTable( + ["Repository"], + repositories.map((r) => [r]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/registry/push.ts b/packages/cli/src/commands/registry/push.ts new file mode 100644 index 0000000..0d0dedd --- /dev/null +++ b/packages/cli/src/commands/registry/push.ts @@ -0,0 +1,97 @@ +import { resolveConfig } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { + dockerLogin, + ensureDockerAvailable, + pushImage, + tagImage, +} from "../../core/docker.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { REGISTRY_USERNAME, resolveRegistryEndpoint } from "./client.ts"; +import { buildTargetRef } from "./ref.ts"; + +const COMMAND = "push "; +const DESCRIPTION = "Push a local image to the bunny.net registry."; + +interface PushArgs { + image: string; + repository?: string; + tag?: string; +} + +export const registryPushCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 registry push myapp:latest", "Push as myapp:latest"], + [ + "$0 registry push myapp:dev --repository team/myapp --tag v1", + "Push under an explicit repository and tag", + ], + ], + + builder: (yargs) => + yargs + .positional("image", { + type: "string", + describe: "Local image to push (e.g. myapp:latest)", + demandOption: true, + }) + .option("repository", { + type: "string", + describe: "Target repository (defaults to the source image name)", + }) + .option("tag", { + type: "string", + describe: "Target tag (defaults to the source image tag)", + }), + + handler: async ({ + image, + repository, + tag, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + if (!config.apiKey) { + throw new UserError( + "Not logged in.", + 'Run "bunny login" to authenticate.', + ); + } + + const endpoint = resolveRegistryEndpoint(); + const target = buildTargetRef(endpoint.host, image, repository, tag); + + await ensureDockerAvailable(); + + const loginSpin = spinner(`Logging in to ${endpoint.host}...`); + loginSpin.start(); + try { + await dockerLogin(endpoint.host, REGISTRY_USERNAME, config.apiKey); + } finally { + loginSpin.stop(); + } + + await tagImage(image, target.reference); + await pushImage(target.reference); + + if (output === "json") { + logger.log( + JSON.stringify({ + reference: target.reference, + repository: target.repository, + tag: target.tag, + }), + ); + return; + } + + logger.success(`Pushed ${target.reference}`); + }, +}); diff --git a/packages/cli/src/commands/registry/ref.test.ts b/packages/cli/src/commands/registry/ref.test.ts new file mode 100644 index 0000000..049f0fd --- /dev/null +++ b/packages/cli/src/commands/registry/ref.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { parseRegistryUrl } from "./client.ts"; +import { buildTargetRef, parseImageRef } from "./ref.ts"; + +describe("parseImageRef", () => { + test.each([ + ["alpine:3.20", { name: "alpine", tag: "3.20" }], + ["alpine", { name: "alpine", tag: "latest" }], + ["org/app:1.0", { name: "org/app", tag: "1.0" }], + ["ghcr.io/org/app:1.0", { name: "org/app", tag: "1.0" }], + ["registry:5000/app:dev", { name: "app", tag: "dev" }], + ["MyApp:Latest", { name: "myapp", tag: "Latest" }], + ])("parseImageRef(%j)", (input, expected) => { + expect(parseImageRef(input)).toEqual(expected); + }); +}); + +describe("buildTargetRef", () => { + test("defaults repository and tag from the source", () => { + expect(buildTargetRef("host.example", "alpine:3.20")).toEqual({ + reference: "host.example/alpine:3.20", + repository: "alpine", + tag: "3.20", + }); + }); + + test("overrides repository and tag", () => { + expect( + buildTargetRef("host.example", "alpine:3.20", "Team/Alpine", "v1"), + ).toEqual({ + reference: "host.example/team/alpine:v1", + repository: "team/alpine", + tag: "v1", + }); + }); +}); + +describe("parseRegistryUrl", () => { + test("defaults scheme to https and strips trailing slash", () => { + expect(parseRegistryUrl("example.bunny.run/")).toEqual({ + baseUrl: "https://example.bunny.run", + host: "example.bunny.run", + }); + }); + + test("keeps an explicit scheme and port", () => { + expect(parseRegistryUrl("http://localhost:5000")).toEqual({ + baseUrl: "http://localhost:5000", + host: "localhost:5000", + }); + }); +}); diff --git a/packages/cli/src/commands/registry/ref.ts b/packages/cli/src/commands/registry/ref.ts new file mode 100644 index 0000000..6465281 --- /dev/null +++ b/packages/cli/src/commands/registry/ref.ts @@ -0,0 +1,57 @@ +import { imageHostname } from "../../core/docker.ts"; + +export interface ParsedImageRef { + /** Repository path with any host and tag stripped (e.g. `org/app`). */ + name: string; + /** Tag, defaulting to `latest` when the source omits one. */ + tag: string; +} + +/** + * Split a Docker image reference into its repository name and tag, + * dropping any leading registry host. The tag is the last `:`-separated + * segment that falls after the last `/`, so a `host:port` prefix isn't + * mistaken for a tag. + */ +export function parseImageRef(ref: string): ParsedImageRef { + const lastSlash = ref.lastIndexOf("/"); + const lastColon = ref.lastIndexOf(":"); + + let nameAndHost = ref; + let tag = "latest"; + if (lastColon > lastSlash) { + nameAndHost = ref.slice(0, lastColon); + tag = ref.slice(lastColon + 1); + } + + const host = imageHostname(nameAndHost); + const name = host ? nameAndHost.slice(host.length + 1) : nameAndHost; + return { name: name.toLowerCase(), tag }; +} + +export interface TargetRef { + reference: string; + repository: string; + tag: string; +} + +/** + * Build the fully-qualified target reference for pushing `source` to the + * registry `host`. Repository and tag default to the source image's, and + * either can be overridden explicitly. + */ +export function buildTargetRef( + host: string, + source: string, + repository?: string, + tag?: string, +): TargetRef { + const parsed = parseImageRef(source); + const repo = (repository ?? parsed.name).toLowerCase(); + const finalTag = tag ?? parsed.tag; + return { + reference: `${host}/${repo}:${finalTag}`, + repository: repo, + tag: finalTag, + }; +} diff --git a/packages/cli/src/commands/registry/tags.ts b/packages/cli/src/commands/registry/tags.ts new file mode 100644 index 0000000..0159758 --- /dev/null +++ b/packages/cli/src/commands/registry/tags.ts @@ -0,0 +1,78 @@ +import { resolveConfig } from "../../config/index.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { registryRequest, resolveRegistryEndpoint } from "./client.ts"; + +const COMMAND = "tags "; +const DESCRIPTION = "List tags for a repository in the bunny.net registry."; + +interface TagList { + name?: string; + tags?: string[]; +} + +interface TagsArgs { + repository: string; +} + +export const registryTagsCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [["$0 registry tags team/myapp", "List tags for team/myapp"]], + + builder: (yargs) => + yargs.positional("repository", { + type: "string", + describe: "Repository to list tags for (e.g. team/myapp)", + demandOption: true, + }), + + handler: async ({ repository, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + if (!config.apiKey) { + throw new UserError( + "Not logged in.", + 'Run "bunny login" to authenticate.', + ); + } + + const endpoint = resolveRegistryEndpoint(); + const repo = repository.replace(/^\/+|\/+$/g, ""); + + const spin = spinner(`Fetching tags for ${repo}...`); + spin.start(); + let result: TagList; + try { + result = await registryRequest( + endpoint, + config.apiKey, + `/v2/${repo}/tags/list`, + ); + } finally { + spin.stop(); + } + + const tags = result.tags ?? []; + + if (output === "json") { + logger.log(JSON.stringify({ repository: repo, tags }, null, 2)); + return; + } + + if (tags.length === 0) { + logger.info(`No tags found for ${repo}.`); + return; + } + + logger.log( + formatTable( + ["Tag"], + tags.map((t) => [t]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/core/docker.ts b/packages/cli/src/core/docker.ts new file mode 100644 index 0000000..76f9ef3 --- /dev/null +++ b/packages/cli/src/core/docker.ts @@ -0,0 +1,132 @@ +import { UserError } from "./errors.ts"; + +/** + * Generic Docker CLI primitives shared across commands. Registry- or + * app-specific helpers (image-ref construction, MC platform targeting) + * stay in their own command modules and build on top of these. + */ + +/** + * Ensure the Docker CLI is available on the system. + * + * Two failure modes need to map to the same friendly error: + * - `docker` not on PATH → `Bun.spawn` throws ENOENT synchronously + * (unlike Node's child_process, which emits an 'error' event). + * - `docker` on PATH but daemon not running / version probe fails → + * non-zero exit code. + */ +export async function ensureDockerAvailable(): Promise { + let exitCode: number; + try { + const proc = Bun.spawn( + ["docker", "version", "--format", "{{.Client.Version}}"], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + exitCode = await proc.exited; + } catch { + exitCode = 1; + } + + if (exitCode !== 0) { + throw new UserError( + "Docker is not installed or not running.", + "Install Docker from https://docs.docker.com/get-docker/", + ); + } +} + +/** + * Extract the registry hostname from a Docker image reference. + * + * Returns null if the reference has no explicit hostname (i.e. it's a + * Docker Hub library or user image like `nginx:latest` or `library/redis`). + * + * A hostname only exists when the ref has a `/`. Otherwise the first + * segment is just `name[:tag]`, not `host[:port]`. + */ +export function imageHostname(ref: string): string | null { + if (!ref.includes("/")) return null; + const firstSegment = ref.split("/")[0]; + if (!firstSegment) return null; + if ( + firstSegment.includes(".") || + firstSegment.includes(":") || + firstSegment === "localhost" + ) { + return firstSegment; + } + return null; +} + +/** + * Re-tag a local image under a new reference (`docker tag `). + */ +export async function tagImage(source: string, target: string): Promise { + const proc = Bun.spawn(["docker", "tag", source, target], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new UserError( + `Could not tag image "${source}".`, + stderr.trim() || "Check that the source image exists locally.", + ); + } +} + +/** + * Push a Docker image to a registry. + */ +export async function pushImage(tag: string): Promise { + const proc = Bun.spawn(["docker", "push", tag], { + stdout: "inherit", + stderr: "inherit", + }); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const hostname = imageHostname(tag); + const hint = + hostname === "ghcr.io" + ? "If you saw `permission_denied: write_package`, your token is missing the `write:packages` scope. Run `gh auth refresh -h github.com -s write:packages` then `gh auth token | docker login ghcr.io -u $(gh api user --jq .login) --password-stdin` and try again." + : `Run \`docker login ${hostname ?? ""}\` and try again. Check that your token has push permission.`; + throw new UserError(`Docker push failed (exit code ${exitCode}).`, hint); + } +} + +/** + * Log in to a Docker registry. Pipes the password through stdin so it + * never appears in the process list. + */ +export async function dockerLogin( + hostname: string, + username: string, + password: string, +): Promise { + const proc = Bun.spawn( + ["docker", "login", hostname, "-u", username, "--password-stdin"], + { + stdin: new Response(password), + stdout: "pipe", + stderr: "pipe", + }, + ); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new UserError( + "Docker login failed.", + stderr.trim() || "Check your registry credentials.", + ); + } +} From 250c247adc85b7a5aef430ab2ab9891b630b3b5a Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 18 Jun 2026 15:44:07 +0100 Subject: [PATCH 2/3] update apps --- packages/cli/src/commands/apps/APPS.md | 2 +- packages/cli/src/commands/apps/deploy.ts | 94 +++++++++++++++++++- packages/cli/src/commands/apps/docker.ts | 17 +++- packages/cli/src/commands/registry/client.ts | 67 +++----------- packages/cli/src/commands/registry/index.ts | 3 - packages/cli/src/core/bunny-registry.ts | 67 ++++++++++++++ packages/cli/src/core/docker.ts | 6 -- 7 files changed, 184 insertions(+), 72 deletions(-) create mode 100644 packages/cli/src/core/bunny-registry.ts diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index c073da8..69ce296 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -92,7 +92,7 @@ For compose files that have just one service, the import overlaps with the Docke | `--dockerfile` | Build from a Dockerfile, then deploy. Pass a path or use the bare flag for `./Dockerfile`. | | `--context` | Docker build context directory. Defaults to the directory of the Dockerfile. | | `--tag` | Override the auto-generated `-` image tag. | -| `--registry` | bunny.net registry ID to push to. Overrides the value stored in `bunny.jsonc`. | +| `--registry` | Registry ID to push to, or `bunny` for the bunny.net registry (requires `BUNNYNET_REGISTRY_URL`). Overrides `bunny.jsonc`. | | `--container` | Name of the container to update. Required when `bunny.jsonc` has multiple containers and you pass ``/`--dockerfile`. | | `--port` | Override the container port. Retargets any endpoints written to `bunny.jsonc`. | | `--command` | Override the container `CMD`. Passed as a single string, split on whitespace. | diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index ab50533..b87c245 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -3,6 +3,11 @@ import { dirname, isAbsolute, resolve } from "node:path"; import type { RegistryMap } from "@bunny.net/app-config"; import { createMcClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; +import { + REGISTRY_URL_ENV, + REGISTRY_USERNAME, + tryResolveRegistryEndpoint, +} from "../../core/bunny-registry.ts"; import { clientOptions } from "../../core/client-options.ts"; import { bunny } from "../../core/colors.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -124,6 +129,7 @@ async function applyPostPushSuggestions( } import { + BUNNY_REGISTRY_ID, buildImage, buildImageRef, dockerLogin, @@ -207,7 +213,8 @@ export const appsDeployCommand = defineCommand({ }) .option("registry", { type: "string", - describe: "bunny.net registry ID to push to (overrides bunny.jsonc)", + describe: + 'Registry ID to push to, or "bunny" for the bunny.net registry (overrides bunny.jsonc)', }) .option("container", { type: "string", @@ -459,9 +466,15 @@ export const appsDeployCommand = defineCommand({ if (mode.kind === "build") { assertDockerfileExists(mode.dockerfile, targetName); + const bunnyEndpoint = tryResolveRegistryEndpoint(); + // `--registry bunny` is a friendly alias for the internal sentinel. + if (registryId === "bunny") registryId = BUNNY_REGISTRY_ID; + // Ensure a registry is selected before we build (we need its hostname). if (!registryId) { - const resolved = await promptRegistry(client); + const resolved = await promptRegistry(client, { + bunnyEndpoint: bunnyEndpoint ?? undefined, + }); if (!resolved) { throw new UserError( "A registry is required to build and push images.", @@ -469,7 +482,82 @@ export const appsDeployCommand = defineCommand({ } registryId = resolved.id; freshCreds = resolved.freshCredentials; - setContainerRegistry(targetName, registryId); + // TODO: The bunny registry has no account record — don't persist a sentinel AT THE MOMENT + if (!resolved.bunny) setContainerRegistry(targetName, registryId); + } + + // TODO: bunny.net registry (env stub): build + push with the API token, then skip deploy — MC can't pull from it until `/registries` exposes a `bunny` record we can deploy by id. + if (registryId === BUNNY_REGISTRY_ID) { + if (!bunnyEndpoint) { + throw new UserError( + "The bunny.net registry endpoint is not configured.", + `Set ${REGISTRY_URL_ENV} to the registry URL and try again.`, + ); + } + if (!cfg.apiKey) { + throw new UserError( + "Not logged in.", + 'Run "bunny login" to authenticate.', + ); + } + + const tag = args.tag ?? (await generateTag()); + const imageRef = buildImageRef( + bunnyEndpoint.host, + undefined, + toml.app.name, + tag, + ); + const buildCwd = resolveBuildContext(mode.dockerfile, args.context); + + logger.info(`Building ${imageRef}...`); + await buildImage(mode.dockerfile, imageRef, buildCwd); + + if (noPush) { + logger.success(`Image built: ${imageRef}`); + logger.dim("Skipping push and deploy (--no-push)."); + if (output === "json") { + logger.log( + JSON.stringify({ + built: true, + image: imageRef, + pushed: false, + deployed: false, + }), + ); + } + return; + } + + const loginSpin = spinner(`Logging in to ${bunnyEndpoint.host}...`); + loginSpin.start(); + try { + await dockerLogin(bunnyEndpoint.host, REGISTRY_USERNAME, cfg.apiKey); + } finally { + loginSpin.stop(); + } + + logger.info(`Pushing ${imageRef}...`); + await pushImage(imageRef); + + if (output === "json") { + logger.log( + JSON.stringify({ + built: true, + image: imageRef, + pushed: true, + deployed: false, + reason: "mc-pull-unsupported", + }), + ); + return; + } + + logger.success(`Pushed ${imageRef}`); + logger.warn( + "Magic Containers can't deploy from the bunny.net registry yet — image pushed, deploy skipped.", + ); + return; } const regSpin = spinner("Fetching registry..."); diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index 0db7df2..dd39c22 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -9,8 +9,6 @@ import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; -// Generic Docker primitives live in core/docker.ts; re-exported here so -// existing app-command imports keep resolving from this module. export { dockerLogin, ensureDockerAvailable, @@ -561,6 +559,9 @@ export function buildImageRef( const ADD_NEW_REGISTRY = "__add_new__"; +/** Stand-in id for the bunny.net registry; swap for the real id once `/registries` returns it as a `bunny` record. */ +export const BUNNY_REGISTRY_ID = "bunny"; + /** * Result of resolving a registry — the ID plus, if the user just entered * credentials in this session, those credentials so the caller can run @@ -673,6 +674,7 @@ export async function createRegistry( */ export async function promptRegistry( client: McClient, + opts: { bunnyEndpoint?: { host: string } } = {}, ): Promise { const regSpin = spinner("Fetching registries..."); regSpin.start(); @@ -685,6 +687,14 @@ export async function promptRegistry( const pushable = registries.filter((r) => r.userName); const choices = [ + ...(opts.bunnyEndpoint + ? [ + { + title: `bunny.net registry (${opts.bunnyEndpoint.host})`, + value: BUNNY_REGISTRY_ID, + }, + ] + : []), ...pushable.map((r) => ({ title: `${r.displayName} (${r.hostName} — ${r.userName})`, value: String(r.id ?? ""), @@ -700,6 +710,9 @@ export async function promptRegistry( }); if (choice === undefined) return null; + if (choice === BUNNY_REGISTRY_ID) { + return { id: BUNNY_REGISTRY_ID, hostName: opts.bunnyEndpoint?.host }; + } if (choice !== ADD_NEW_REGISTRY) { const existing = pushable.find((r) => String(r.id) === String(choice)); return { id: String(choice), hostName: existing?.hostName }; diff --git a/packages/cli/src/commands/registry/client.ts b/packages/cli/src/commands/registry/client.ts index 6ed5f56..9470883 100644 --- a/packages/cli/src/commands/registry/client.ts +++ b/packages/cli/src/commands/registry/client.ts @@ -1,62 +1,15 @@ +import { + basicAuthHeader, + type RegistryEndpoint, +} from "../../core/bunny-registry.ts"; import { UserError } from "../../core/errors.ts"; -/** Env var holding the OCI registry endpoint. Intentionally undocumented. */ -export const REGISTRY_URL_ENV = "BUNNYNET_REGISTRY_URL"; - -/** Basic-auth username for the registry. The API token is the password. */ -export const REGISTRY_USERNAME = "token"; - -export interface RegistryEndpoint { - /** Normalised base URL with no trailing slash (e.g. `https://host`). */ - baseUrl: string; - /** Host[:port] used for `docker login` / image refs. */ - host: string; -} - -/** - * Normalise a raw registry URL into a base URL and host, defaulting the - * scheme to https when omitted. Pure so it's testable without env access. - */ -export function parseRegistryUrl(raw: string): RegistryEndpoint { - const trimmed = raw.trim(); - if (!trimmed) throw new UserError("Registry URL is empty."); - const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) - ? trimmed - : `https://${trimmed}`; - - let url: URL; - try { - url = new URL(withScheme); - } catch { - throw new UserError(`Invalid registry URL: ${raw}`); - } - - return { baseUrl: url.origin, host: url.host }; -} - -/** - * Resolve the registry endpoint from the environment. The URL is kept out - * of help output and config so it isn't casually discoverable; an unset - * env var is an expected, friendly error rather than a crash. - */ -export function resolveRegistryEndpoint(): RegistryEndpoint { - const raw = process.env[REGISTRY_URL_ENV]; - if (!raw) { - throw new UserError( - "Registry endpoint is not configured.", - `Set ${REGISTRY_URL_ENV} to the registry URL and try again.`, - ); - } - return parseRegistryUrl(raw); -} - -/** Build the HTTP Basic auth header from the resolved API token. */ -export function basicAuthHeader(apiKey: string): string { - const encoded = Buffer.from(`${REGISTRY_USERNAME}:${apiKey}`).toString( - "base64", - ); - return `Basic ${encoded}`; -} +export { + parseRegistryUrl, + REGISTRY_USERNAME, + type RegistryEndpoint, + resolveRegistryEndpoint, +} from "../../core/bunny-registry.ts"; /** * Perform an authenticated request against an OCI distribution path diff --git a/packages/cli/src/commands/registry/index.ts b/packages/cli/src/commands/registry/index.ts index f6012ce..11a1b12 100644 --- a/packages/cli/src/commands/registry/index.ts +++ b/packages/cli/src/commands/registry/index.ts @@ -3,9 +3,6 @@ import { registryListCommand } from "./list.ts"; import { registryPushCommand } from "./push.ts"; import { registryTagsCommand } from "./tags.ts"; -// Hidden namespace: the registry endpoint is configured out-of-band via -// BUNNYNET_REGISTRY_URL and not advertised in help output. Auth is handled -// per-request with the API token — there's no separate login step. export const registryNamespace = defineNamespace("registry", false, [ registryPushCommand, registryListCommand, diff --git a/packages/cli/src/core/bunny-registry.ts b/packages/cli/src/core/bunny-registry.ts new file mode 100644 index 0000000..dcc89e8 --- /dev/null +++ b/packages/cli/src/core/bunny-registry.ts @@ -0,0 +1,67 @@ +import { UserError } from "./errors.ts"; + +/** Endpoint and auth resolution for the bunny.net OCI registry, shared by the registry commands and apps deploy. */ + +/** Env var holding the OCI registry endpoint — a stub until `/registries` returns the bunny registry (host included) directly. */ +export const REGISTRY_URL_ENV = "BUNNYNET_REGISTRY_URL"; + +/** Basic-auth username for the registry. The API token is the password. */ +export const REGISTRY_USERNAME = "token"; + +export interface RegistryEndpoint { + /** Normalised base URL with no trailing slash (e.g. `https://host`). */ + baseUrl: string; + /** Host[:port] used for `docker login` / image refs. */ + host: string; +} + +/** + * Normalise a raw registry URL into a base URL and host, defaulting the + * scheme to https when omitted. Pure so it's testable without env access. + */ +export function parseRegistryUrl(raw: string): RegistryEndpoint { + const trimmed = raw.trim(); + if (!trimmed) throw new UserError("Registry URL is empty."); + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + + let url: URL; + try { + url = new URL(withScheme); + } catch { + throw new UserError(`Invalid registry URL: ${raw}`); + } + + return { baseUrl: url.origin, host: url.host }; +} + +/** Resolve the endpoint from the environment, or null when unset. */ +export function tryResolveRegistryEndpoint(): RegistryEndpoint | null { + const raw = process.env[REGISTRY_URL_ENV]; + return raw ? parseRegistryUrl(raw) : null; +} + +/** + * Resolve the registry endpoint from the environment. The URL is kept out + * of help output and config so it isn't casually discoverable; an unset + * env var is an expected, friendly error rather than a crash. + */ +export function resolveRegistryEndpoint(): RegistryEndpoint { + const endpoint = tryResolveRegistryEndpoint(); + if (!endpoint) { + throw new UserError( + "Registry endpoint is not configured.", + `Set ${REGISTRY_URL_ENV} to the registry URL and try again.`, + ); + } + return endpoint; +} + +/** Build the HTTP Basic auth header from the resolved API token. */ +export function basicAuthHeader(apiKey: string): string { + const encoded = Buffer.from(`${REGISTRY_USERNAME}:${apiKey}`).toString( + "base64", + ); + return `Basic ${encoded}`; +} diff --git a/packages/cli/src/core/docker.ts b/packages/cli/src/core/docker.ts index 76f9ef3..60e16d9 100644 --- a/packages/cli/src/core/docker.ts +++ b/packages/cli/src/core/docker.ts @@ -1,11 +1,5 @@ import { UserError } from "./errors.ts"; -/** - * Generic Docker CLI primitives shared across commands. Registry- or - * app-specific helpers (image-ref construction, MC platform targeting) - * stay in their own command modules and build on top of these. - */ - /** * Ensure the Docker CLI is available on the system. * From 9a6ed79b630b0344951d54fbd954e35b7c15c3e2 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 18 Jun 2026 15:59:50 +0100 Subject: [PATCH 3/3] default to bunny registry --- packages/cli/src/commands/apps/deploy.ts | 20 +++++++++++------- packages/cli/src/commands/apps/docker.ts | 27 +++++++++++++----------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index b87c245..2e829c0 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -467,14 +467,9 @@ export const appsDeployCommand = defineCommand({ assertDockerfileExists(mode.dockerfile, targetName); const bunnyEndpoint = tryResolveRegistryEndpoint(); - // `--registry bunny` is a friendly alias for the internal sentinel. - if (registryId === "bunny") registryId = BUNNY_REGISTRY_ID; - // Ensure a registry is selected before we build (we need its hostname). if (!registryId) { - const resolved = await promptRegistry(client, { - bunnyEndpoint: bunnyEndpoint ?? undefined, - }); + const resolved = await promptRegistry(client); if (!resolved) { throw new UserError( "A registry is required to build and push images.", @@ -482,8 +477,10 @@ export const appsDeployCommand = defineCommand({ } registryId = resolved.id; freshCreds = resolved.freshCredentials; - // TODO: The bunny registry has no account record — don't persist a sentinel AT THE MOMENT - if (!resolved.bunny) setContainerRegistry(targetName, registryId); + // The bunny registry has no account record — don't persist its id. + if (registryId !== BUNNY_REGISTRY_ID) { + setContainerRegistry(targetName, registryId); + } } // TODO: bunny.net registry (env stub): build + push with the API token, then skip deploy — MC can't pull from it until `/registries` exposes a `bunny` record we can deploy by id. @@ -1107,6 +1104,13 @@ async function buildAndPushContainer( opts.onRegistryResolved(name, registryId); } + if (registryId === BUNNY_REGISTRY_ID) { + throw new UserError( + "The bunny.net registry isn't supported for multi-container apps yet.", + "Push images individually with `bunny registry push`, or use a single-container app.", + ); + } + const regSpin = spinner(`Fetching registry for ${name}...`); regSpin.start(); const { data: reg } = await client.GET("/registries/{registryId}", { diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index dd39c22..1191989 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -4,6 +4,7 @@ import { basename, isAbsolute, join, resolve } from "node:path"; import type { createMcClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import prompts from "prompts"; +import { tryResolveRegistryEndpoint } from "../../core/bunny-registry.ts"; import { dockerLogin, imageHostname } from "../../core/docker.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; @@ -674,8 +675,21 @@ export async function createRegistry( */ export async function promptRegistry( client: McClient, - opts: { bunnyEndpoint?: { host: string } } = {}, ): Promise { + const bunnyEndpoint = tryResolveRegistryEndpoint(); + if (bunnyEndpoint) { + const { value: useBunny } = await prompts({ + type: "confirm", + name: "value", + message: "Push to the bunny.net registry?", + initial: true, + }); + if (useBunny === undefined) return null; + if (useBunny) { + return { id: BUNNY_REGISTRY_ID, hostName: bunnyEndpoint.host }; + } + } + const regSpin = spinner("Fetching registries..."); regSpin.start(); @@ -687,14 +701,6 @@ export async function promptRegistry( const pushable = registries.filter((r) => r.userName); const choices = [ - ...(opts.bunnyEndpoint - ? [ - { - title: `bunny.net registry (${opts.bunnyEndpoint.host})`, - value: BUNNY_REGISTRY_ID, - }, - ] - : []), ...pushable.map((r) => ({ title: `${r.displayName} (${r.hostName} — ${r.userName})`, value: String(r.id ?? ""), @@ -710,9 +716,6 @@ export async function promptRegistry( }); if (choice === undefined) return null; - if (choice === BUNNY_REGISTRY_ID) { - return { id: BUNNY_REGISTRY_ID, hostName: opts.bunnyEndpoint?.host }; - } if (choice !== ADD_NEW_REGISTRY) { const existing = pushable.find((r) => String(r.id) === String(choice)); return { id: String(choice), hostName: existing?.hostName };