Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,23 @@ bunny registries add --name "GitHub" --username myorg
bunny registries remove <registry-id>
```

### `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://<registry-host>

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.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,6 +35,7 @@ const commands: CommandModule[] = [
const experimentalCommands: CommandModule[] = [
appsNamespace,
registriesNamespace,
registryNamespace,
dnsNamespace,
];

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/apps/APPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sha>-<timestamp>` 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 `<image>`/`--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. |
Expand Down
96 changes: 94 additions & 2 deletions packages/cli/src/commands/apps/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -124,6 +129,7 @@ async function applyPostPushSuggestions(
}

import {
BUNNY_REGISTRY_ID,
buildImage,
buildImageRef,
dockerLogin,
Expand Down Expand Up @@ -207,7 +213,8 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
})
.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",
Expand Down Expand Up @@ -459,6 +466,7 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
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);
Expand All @@ -469,7 +477,84 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
}
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...");
Expand Down Expand Up @@ -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}", {
Expand Down
136 changes: 26 additions & 110 deletions packages/cli/src/commands/apps/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createMcClient>;
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<void> {
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.
Expand Down Expand Up @@ -334,56 +307,6 @@ export async function buildImage(
}
}

/**
* Push a Docker image to a registry.
*/
export async function pushImage(tag: string): Promise<void> {
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 ?? "<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<void> {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -774,6 +676,20 @@ export async function createRegistry(
export async function promptRegistry(
client: McClient,
): Promise<ResolvedRegistry | null> {
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();

Expand Down
Loading