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/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..2e829c0 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,6 +466,7 @@ export const appsDeployCommand = defineCommand({ if (mode.kind === "build") { assertDockerfileExists(mode.dockerfile, targetName); + const bunnyEndpoint = tryResolveRegistryEndpoint(); // Ensure a registry is selected before we build (we need its hostname). if (!registryId) { const resolved = await promptRegistry(client); @@ -469,7 +477,84 @@ export const appsDeployCommand = defineCommand({ } registryId = resolved.id; freshCreds = resolved.freshCredentials; - 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. + 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..."); @@ -1019,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 a60e4ed..1191989 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -4,51 +4,24 @@ 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"; import { spinner } from "../../core/ui.ts"; +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 +307,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,32 +558,11 @@ 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__"; +/** 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 @@ -774,6 +676,20 @@ export async function createRegistry( export async function promptRegistry( client: McClient, ): 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(); diff --git a/packages/cli/src/commands/registry/client.ts b/packages/cli/src/commands/registry/client.ts new file mode 100644 index 0000000..9470883 --- /dev/null +++ b/packages/cli/src/commands/registry/client.ts @@ -0,0 +1,48 @@ +import { + basicAuthHeader, + type RegistryEndpoint, +} from "../../core/bunny-registry.ts"; +import { UserError } from "../../core/errors.ts"; + +export { + parseRegistryUrl, + REGISTRY_USERNAME, + type RegistryEndpoint, + resolveRegistryEndpoint, +} from "../../core/bunny-registry.ts"; + +/** + * 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..11a1b12 --- /dev/null +++ b/packages/cli/src/commands/registry/index.ts @@ -0,0 +1,10 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { registryListCommand } from "./list.ts"; +import { registryPushCommand } from "./push.ts"; +import { registryTagsCommand } from "./tags.ts"; + +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/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 new file mode 100644 index 0000000..60e16d9 --- /dev/null +++ b/packages/cli/src/core/docker.ts @@ -0,0 +1,126 @@ +import { UserError } from "./errors.ts"; + +/** + * 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.", + ); + } +}