From 1ee2d881b711d97c5202e1a519590c3ccfd28ccf Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 11 May 2026 19:34:45 +0100 Subject: [PATCH 01/11] feat(apps): rework deploy flow and version bunny.jsonc --- AGENTS.md | 14 +- README.md | 3 +- bun.lock | 3 - packages/app-config/generated/schema.json | 39 +- packages/app-config/src/convert.ts | 43 +- packages/app-config/src/index.ts | 6 + packages/app-config/src/migrate.ts | 55 ++ packages/app-config/src/schema.ts | 69 ++- packages/cli/package.json | 1 - packages/cli/src/commands/apps/APPS.md | 162 +++++- packages/cli/src/commands/apps/config.ts | 47 +- packages/cli/src/commands/apps/deploy.ts | 619 ++++++++++++++++++++-- packages/cli/src/commands/apps/docker.ts | 253 +++++++-- packages/cli/src/commands/apps/init.ts | 17 +- 14 files changed, 1137 insertions(+), 194 deletions(-) create mode 100644 packages/app-config/src/migrate.ts diff --git a/AGENTS.md b/AGENTS.md index d44b230..5d44925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,6 @@ Bun replaces the entire Node.js toolchain. There are no separate tools for trans | `@libsql/client` | libSQL database client (used by `db shell`) | | `openapi-fetch` | Type-safe HTTP client generated from OpenAPI specs | | `jsonc-parser` | JSONC parser for `bunny.jsonc` config files | -| `smol-toml` | TOML v1 parser (legacy `bunny.toml` fallback only) | ### Dev dependencies @@ -164,11 +163,11 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("apps", false) — hidden, registers all app commands │ │ │ ├── constants.ts # Status label maps │ │ │ ├── config.ts # bunny.jsonc file I/O, re-exports from @bunny.net/app-config (resolveAppId, resolveContainerId) -│ │ │ ├── docker.ts # Docker helpers (build, push, login, generateTag, promptRegistry) +│ │ │ ├── docker.ts # Docker + registry helpers (build, push, dockerLogin, generateTag, promptRegistry, resolveRegistryForImage, getConfigSuggestions, imageHostname) │ │ │ ├── init.ts # Scaffold bunny.jsonc (detects Dockerfile, prompts for registry) │ │ │ ├── list.ts # List all apps │ │ │ ├── show.ts # Show app details and overview -│ │ │ ├── deploy.ts # Deploy app (build from Dockerfile or use --image) +│ │ │ ├── deploy.ts # Deploy app — positional , --dockerfile, --container , or manifest; first-run walkthrough │ │ │ ├── undeploy.ts # Undeploy app │ │ │ ├── restart.ts # Restart app │ │ │ ├── delete.ts # Delete app @@ -707,7 +706,8 @@ bunny │ │ Scaffold bunny.jsonc (detects Dockerfile) │ ├── list (alias: ls) List all apps │ ├── show [--id] Show app details and overview -│ ├── deploy [--image] Build + deploy (or deploy pre-built image) +│ ├── deploy [image] [--dockerfile] [--context] [--tag] [--registry] [--container] [--no-push] +│ │ Deploy a pre-built image, build from a Dockerfile, or re-deploy from bunny.jsonc │ ├── undeploy [--id] [--force] Undeploy an app │ ├── restart [--id] Restart an app │ ├── delete [--id] [--force] Delete an app @@ -1031,8 +1031,10 @@ The `.bunny/` manifest and `bunny.jsonc` serve different purposes: ```jsonc { "$schema": "./node_modules/@bunny.net/app-config/generated/schema.json", + "version": "2026-05-11", "app": { "name": "my-app", + "regions": ["sfo"], "containers": { "web": { "image": "nginx:latest" }, }, @@ -1040,9 +1042,9 @@ The `.bunny/` manifest and `bunny.jsonc` serve different purposes: } ``` -Schemas and types are defined in `@bunny.net/app-config` using Zod. The CLI's `config.ts` handles file I/O (parsing JSONC, validating with Zod, writing with `$schema` injection) and resolution helpers (`resolveAppId`, `resolveContainerId`). +`version` is an ISO date string. When the CLI loads an older config, `migrate()` in `@bunny.net/app-config` transforms it in memory; the next `saveConfig()` writes the upgraded shape. Each shape change adds a new entry to the `MIGRATIONS` table in `packages/app-config/src/migrate.ts`. -Legacy `bunny.toml` files are still loadable with a deprecation warning. +Schemas and types are defined in `@bunny.net/app-config` using Zod. The CLI's `config.ts` handles file I/O (parsing JSONC, running migrations, validating with Zod, writing with `$schema` + `version` injection) and resolution helpers (`resolveAppId`, `resolveContainerId`). --- diff --git a/README.md b/README.md index 272a08b..bf21586 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ bun ny # Examples bun ny login bun ny db list -bun ny apps deploy +bun ny apps deploy ghcr.io/me/api:v1.2 # deploy a pre-built image +bun ny apps deploy --dockerfile # build ./Dockerfile and deploy ``` ### Available Scripts diff --git a/bun.lock b/bun.lock index cb68bff..781d155 100644 --- a/bun.lock +++ b/bun.lock @@ -42,7 +42,6 @@ "jsonc-parser": "^3.3.1", "ora": "^9.3.0", "prompts": "^2.4.2", - "smol-toml": "^1.6.0", "yargs": "^18.0.0", "zod": "^4.3.6", }, @@ -942,8 +941,6 @@ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], diff --git a/packages/app-config/generated/schema.json b/packages/app-config/generated/schema.json index d2e6786..2a0e4e4 100644 --- a/packages/app-config/generated/schema.json +++ b/packages/app-config/generated/schema.json @@ -5,6 +5,10 @@ "$schema": { "type": "string" }, + "version": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, "app": { "type": "object", "properties": { @@ -28,22 +32,32 @@ "additionalProperties": false }, "regions": { - "type": "object", - "properties": { - "allowed": { + "anyOf": [ + { "type": "array", "items": { "type": "string" } }, - "required": { - "type": "array", - "items": { - "type": "string" - } + { + "type": "object", + "properties": { + "allowed": { + "type": "array", + "items": { + "type": "string" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false } - }, - "additionalProperties": false + ] }, "containers": { "type": "object", @@ -59,6 +73,9 @@ "dockerfile": { "type": "string" }, + "context": { + "type": "string" + }, "command": { "type": "array", "items": { @@ -195,6 +212,6 @@ "additionalProperties": false } }, - "required": ["app"], + "required": ["version", "app"], "additionalProperties": false } diff --git a/packages/app-config/src/convert.ts b/packages/app-config/src/convert.ts index 9175369..40933a7 100644 --- a/packages/app-config/src/convert.ts +++ b/packages/app-config/src/convert.ts @@ -1,9 +1,11 @@ import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import { parseImageRef } from "./parse-image-ref.ts"; -import type { - BunnyAppConfig, - ContainerConfig, - EndpointConfig, +import { + type BunnyAppConfig, + type ContainerConfig, + CURRENT_VERSION, + type EndpointConfig, + normalizeRegions, } from "./schema.ts"; type Application = components["schemas"]["Application"]; @@ -76,6 +78,7 @@ export function apiToConfig(app: Application): BunnyAppConfig { } const config: BunnyAppConfig = { + version: CURRENT_VERSION, app: { id: app.id, name: app.name, @@ -90,14 +93,14 @@ export function apiToConfig(app: Application): BunnyAppConfig { }; } - if ( - app.regionSettings.allowedRegionIds.length > 0 || - app.regionSettings.requiredRegionIds.length > 0 - ) { - config.app.regions = { - allowed: app.regionSettings.allowedRegionIds, - required: app.regionSettings.requiredRegionIds, - }; + const allowed = app.regionSettings.allowedRegionIds; + const required = app.regionSettings.requiredRegionIds; + if (allowed.length > 0 || required.length > 0) { + // Collapse to the simple array form when allowed === required. + const same = + allowed.length === required.length && + allowed.every((id) => required.includes(id)); + config.app.regions = same ? [...allowed] : { allowed, required }; } return config; @@ -204,10 +207,10 @@ export function configToAddRequest( name: config.app.name, runtimeType: "shared", autoScaling: config.app.scaling ?? { min: 1, max: 1 }, - regionSettings: { - allowedRegionIds: config.app.regions?.allowed ?? [], - requiredRegionIds: config.app.regions?.required ?? [], - }, + regionSettings: (() => { + const { allowed, required } = normalizeRegions(config.app.regions); + return { allowedRegionIds: allowed, requiredRegionIds: required }; + })(), containerTemplates: containers, volumes, }; @@ -238,10 +241,10 @@ export function configToPatchRequest( name: config.app.name, runtimeType: "shared", autoScaling: config.app.scaling ?? { min: 1, max: 1 }, - regionSettings: { - allowedRegionIds: config.app.regions?.allowed ?? [], - requiredRegionIds: config.app.regions?.required ?? [], - }, + regionSettings: (() => { + const { allowed, required } = normalizeRegions(config.app.regions); + return { allowedRegionIds: allowed, requiredRegionIds: required }; + })(), containerTemplates: containers, volumes, }; diff --git a/packages/app-config/src/index.ts b/packages/app-config/src/index.ts index 6cddae2..444e058 100644 --- a/packages/app-config/src/index.ts +++ b/packages/app-config/src/index.ts @@ -6,6 +6,8 @@ export { configToAddRequest, configToPatchRequest, } from "./convert.ts"; +// Migrations +export { migrate } from "./migrate.ts"; // Utilities export { parseImageRef } from "./parse-image-ref.ts"; // Types @@ -14,12 +16,16 @@ export type { ContainerConfig, EndpointConfig, ProbeConfig, + RegionsConfig, VolumeConfig, } from "./schema.ts"; export { BunnyAppConfigSchema, ContainerConfigSchema, + CURRENT_VERSION, EndpointConfigSchema, + normalizeRegions, ProbeConfigSchema, + RegionsConfigSchema, VolumeConfigSchema, } from "./schema.ts"; diff --git a/packages/app-config/src/migrate.ts b/packages/app-config/src/migrate.ts new file mode 100644 index 0000000..ea5bfa8 --- /dev/null +++ b/packages/app-config/src/migrate.ts @@ -0,0 +1,55 @@ +import { CURRENT_VERSION } from "./schema.ts"; + +/** + * Migration functions transform a raw config object (post-JSONC parse, + * pre-Zod validation) from one version to the next. + * + * Add a new entry whenever the shape changes. Each migration receives + * the previous shape and returns the next. The runner applies them + * in order until the config reaches `CURRENT_VERSION`. + */ +type Migration = { + from: string; + to: string; + apply: (raw: any) => any; +}; + +const MIGRATIONS: Migration[] = [ + { + // Pre-versioned configs (no `version` field) → tag as the first version. + // The pre-versioned shape had no breaking differences from the first + // versioned one, so this is a no-op tag. + from: "0", + to: "2026-05-11", + apply: (raw) => ({ version: "2026-05-11", ...raw }), + }, +]; + +/** + * Migrate a raw parsed config to the current version. Returns a tuple of + * `[migrated, migratedFrom]` so callers can log when a migration happened. + */ +export function migrate(raw: any): { + config: any; + migratedFrom: string | null; +} { + if (typeof raw !== "object" || raw === null) { + return { config: raw, migratedFrom: null }; + } + + const originalVersion: string = raw.version ?? "0"; + let cfg = raw; + let current = originalVersion; + + while (current !== CURRENT_VERSION) { + const next = MIGRATIONS.find((m) => m.from === current); + if (!next) break; // No migration path — let Zod surface the validation error. + cfg = next.apply(cfg); + current = next.to; + } + + return { + config: cfg, + migratedFrom: originalVersion === CURRENT_VERSION ? null : originalVersion, + }; +} diff --git a/packages/app-config/src/schema.ts b/packages/app-config/src/schema.ts index 0650b0d..a37b839 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/app-config/src/schema.ts @@ -1,5 +1,21 @@ import { z } from "zod"; +// ─── Schema version ────────────────────────────────────────────────── +// +// The config file carries a `version` field so we can evolve the shape +// without breaking existing files. Versions are ISO date strings +// (lexicographically sortable) — when something changes, bump this and +// add a migration in `migrate.ts`. + +export const CURRENT_VERSION = "2026-05-11"; + +const VersionSchema = z + .string() + .regex( + /^\d{4}-\d{2}-\d{2}$/, + "version must be an ISO date string (YYYY-MM-DD)", + ); + // ─── Sub-schemas ───────────────────────────────────────────────────── export const ProbeConfigSchema = z.object({ @@ -25,6 +41,7 @@ export const VolumeConfigSchema = z.object({ export const ContainerConfigSchema = z.object({ image: z.string().optional(), dockerfile: z.string().optional(), + context: z.string().optional(), command: z.array(z.string()).optional(), registry: z.string().optional(), env: z.record(z.string(), z.string()).optional(), @@ -39,20 +56,36 @@ export const ContainerConfigSchema = z.object({ volumes: z.array(VolumeConfigSchema).optional(), }); +/** + * Regions can be specified two ways: + * + * "regions": ["sfo", "lhr"] + * + * or, for the rare case where you need to distinguish "regions Bunny + * is allowed to use" from "regions Bunny must always have running": + * + * "regions": { "allowed": ["sfo", "lhr", "nyc"], "required": ["sfo"] } + * + * Both forms accept the same downstream conversion via `normalizeRegions`. + */ +export const RegionsConfigSchema = z.union([ + z.array(z.string()), + z.object({ + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + }), +]); + // ─── Root schema ───────────────────────────────────────────────────── export const BunnyAppConfigSchema = z.object({ $schema: z.string().optional(), + version: VersionSchema, app: z.object({ id: z.string().optional(), name: z.string(), scaling: z.object({ min: z.number(), max: z.number() }).optional(), - regions: z - .object({ - allowed: z.array(z.string()).optional(), - required: z.array(z.string()).optional(), - }) - .optional(), + regions: RegionsConfigSchema.optional(), containers: z.record(z.string(), ContainerConfigSchema), }), }); @@ -64,3 +97,27 @@ export type ContainerConfig = z.infer; export type EndpointConfig = z.infer; export type VolumeConfig = z.infer; export type ProbeConfig = z.infer; +export type RegionsConfig = z.infer; + +// ─── Helpers ───────────────────────────────────────────────────────── + +/** + * Normalize either regions shape into `{ allowed, required }` for the API. + * + * - Array form → both `allowed` and `required` are the same list (the user's + * intent is "deploy to these regions, and they are required"). + * - Object form → passed through, with empty arrays as defaults. + */ +export function normalizeRegions(regions: RegionsConfig | undefined): { + allowed: string[]; + required: string[]; +} { + if (!regions) return { allowed: [], required: [] }; + if (Array.isArray(regions)) { + return { allowed: [...regions], required: [...regions] }; + } + return { + allowed: regions.allowed ?? [], + required: regions.required ?? [], + }; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 547aa29..5322972 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,6 @@ "jsonc-parser": "^3.3.1", "ora": "^9.3.0", "prompts": "^2.4.2", - "smol-toml": "^1.6.0", "yargs": "^18.0.0", "zod": "^4.3.6" }, diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index f0fdc1c..69ebb19 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -3,14 +3,14 @@ Manage apps (Magic Containers). Apps are multi-container deployments where all containers share a localhost network. Configuration is stored in a `bunny.jsonc` file which is committed to your repo. The app ID is written back to the config on first deploy, so cloning the repo gives you everything you need. The JSONC format supports a `$schema` property for editor autocompletion. ```bash -# Scaffold a new bunny.jsonc (interactive) -bunny apps init +# Deploy a pre-built image (first run walks through setup) +bunny apps deploy ghcr.io/myorg/api:v1.2 -# Deploy (creates the app on first run, builds from Dockerfile if configured) -bunny apps deploy +# Build the local Dockerfile and deploy +bunny apps deploy --dockerfile -# Deploy a pre-built image -bunny apps deploy --image ghcr.io/myorg/api:v1.2 +# Re-deploy whatever is in bunny.jsonc +bunny apps deploy # Sync remote config to local bunny.jsonc bunny apps pull @@ -19,9 +19,64 @@ bunny apps pull bunny apps push ``` +## `bunny apps deploy` + +Deploy an app. Three modes, chosen by what you pass: + +| You pass… | What happens | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `` positional | Skip build. Resolve a registry record for the image hostname (prompting if needed) and deploy the ref. | +| `--dockerfile [path]` | Build from the Dockerfile (defaults to `./Dockerfile`), push to a registry, then deploy. | +| Neither | Consult `bunny.jsonc`. If `dockerfile` is set on the container, build; otherwise re-deploy the pinned `image`. | + +`` and `--dockerfile` are mutually exclusive. + +If no `bunny.jsonc` exists, the first run launches a walkthrough that: + +1. Resolves the registry (prompts "is this image public, or do you need credentials?" for new hostnames). +2. Calls bunny.net's image-suggestions endpoint to pre-fill app name, endpoints, and required environment variables for known public images. +3. Prompts for regions. +4. Writes `bunny.jsonc`, creates the app, and deploys. + +| Flag | Description | +| ---------------- | ----------------------------------------------------------------------------------------------------------------- | +| `` | Container image reference to deploy (e.g. `ghcr.io/me/api:v1.2`). Skips build. | +| `--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 registry ID to push to. Overrides the value stored in `bunny.jsonc`. | +| `--container` | Name of the container to update. Required when `bunny.jsonc` has multiple containers and you pass ``/`--dockerfile`. | +| `--no-push` | Build only — skip pushing the image and skip the deploy. | + +```bash +# Deploy a pre-built image +bunny apps deploy ghcr.io/myorg/api:v1.2 + +# Build ./Dockerfile and deploy +bunny apps deploy --dockerfile + +# Build a Dockerfile in a subdirectory with explicit context +bunny apps deploy --dockerfile apps/api/Dockerfile --context apps/api + +# Tag the build explicitly (CI workflow with $GITHUB_SHA) +bunny apps deploy --dockerfile --tag ${GITHUB_SHA} + +# Build locally for verification but don't push or deploy +bunny apps deploy --dockerfile --no-push +``` + +### Registries during deploy + +Every container on Magic Containers is tied to a registry record on bunny.net — even for public images. When you pass `` for the first time, the CLI parses the hostname and tries to match it to an existing registry on your account: + +- **Match found** → uses it and saves the registry ID to `bunny.jsonc`. +- **No match** → prompts: "Is this image public, or do you need credentials?" Selecting public creates a credential-less registry record; selecting private prompts for username + token and creates the registry. + +Credentials entered during this flow are also passed through to `docker login` (for the build path) so the very next `docker push` succeeds without a separate manual login step. + ## `bunny apps init` -Scaffold a new `bunny.jsonc` config file. Prompts for name and regions. If a `Dockerfile` is detected in the current directory, offers to use it for build-and-deploy and prompts for a container registry. Otherwise prompts for a container image. +Scaffold a new `bunny.jsonc` without deploying. Useful if you want to edit the config before the first deploy. Most users can skip this and run `bunny apps deploy` straight away — it will walk you through setup the first time. ```bash bunny apps init @@ -33,6 +88,8 @@ bunny apps init --name my-api --image nginx:latest | `--name` | App name (defaults to directory name) | | `--image` | Primary container image | +If a `Dockerfile` is detected in the current directory, `init` offers to wire it up for build-and-deploy. + ## `bunny apps list` List all apps. @@ -51,22 +108,6 @@ bunny apps show bunny apps show --id ``` -## `bunny apps deploy` - -Deploy an app. If `bunny.jsonc` has no `id`, the app is created on Bunny first. If `dockerfile` is set in the primary container config (first entry in `containers`), the image is built and pushed automatically (prompts for a registry if not configured). Use `--image` to skip the build and deploy a pre-built image. - -```bash -# Build from Dockerfile + deploy -bunny apps deploy - -# Deploy a pre-built image -bunny apps deploy --image ghcr.io/myorg/api:v1.2 -``` - -| Flag | Description | -| --------- | -------------------------------------------------- | -| `--image` | Container image to deploy (skips Dockerfile build) | - ## `bunny apps pull` / `bunny apps push` Sync configuration between the remote API and local `bunny.jsonc`. @@ -76,7 +117,7 @@ Sync configuration between the remote API and local `bunny.jsonc`. bunny apps pull bunny apps pull --force -# Push local bunny.jsonc to remote +# Push local bunny.jsonc to remote (config only — does not deploy) bunny apps push bunny apps push --dry-run ``` @@ -130,3 +171,76 @@ View available regions and app region settings. bunny apps regions list bunny apps regions show ``` + +## `bunny.jsonc` schema + +The config carries a `version` field so we can evolve the shape without breaking existing files. Versions are ISO date strings — when the CLI loads an older config it migrates it in memory and writes the upgraded form on the next save. + +A single-container app: + +```jsonc +{ + "$schema": "./node_modules/@bunny.net/app-config/generated/schema.json", + "version": "2026-05-11", + "app": { + "id": "app_xxx", // written by the CLI on first deploy + "name": "my-api", + "regions": ["sfo", "lhr"], // simple array + "scaling": { "min": 1, "max": 3 }, + "containers": { + "api": { + "image": "ghcr.io/me/api:v1", // last deployed; rewritten each deploy + "registry": "12345", // bunny registry id + "dockerfile": "Dockerfile", // optional — enables the build path + "context": ".", // optional — Docker build context + "env": { "PORT": "3000" }, + "endpoints": [ + { "type": "cdn", "ssl": true, "ports": [{ "public": 443, "container": 3000 }] } + ] + } + } + } +} +``` + +A multi-container app — every container is its own entry in `app.containers`. `bunny apps deploy ` and `bunny apps deploy --dockerfile` require `--container ` to disambiguate which one to update; `bunny apps deploy` with no args re-triggers a deploy of the whole app at its current state. + +```jsonc +{ + "version": "2026-05-11", + "app": { + "name": "my-stack", + "regions": ["sfo"], + "containers": { + "api": { + "image": "ghcr.io/me/api:v1", + "registry": "12345", + "env": { "DB_URL": "postgres://db:5432/app" } + }, + "db": { + "image": "postgres:16", + "env": { "POSTGRES_PASSWORD": "..." } + } + } + } +} +``` + +### Regions: simple form vs advanced + +Most users want a single list of regions that the app should run in: + +```jsonc +"regions": ["sfo", "lhr"] +``` + +For the rare case where you need to distinguish "regions Bunny is allowed to use" from "regions Bunny must always have running", use the object form: + +```jsonc +"regions": { + "allowed": ["sfo", "lhr", "nyc"], + "required": ["sfo"] +} +``` + +The array form sets both `allowed` and `required` to the same list under the hood. diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index 6d7c9ee..d280de8 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path"; import { type BunnyAppConfig, BunnyAppConfigSchema, + migrate, } from "@bunny.net/app-config"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import { parse as parseJsonc } from "jsonc-parser"; @@ -12,14 +13,19 @@ import { logger } from "../../core/logger.ts"; type Application = components["schemas"]["Application"]; const CONFIG_FILENAME = "bunny.jsonc"; -const LEGACY_FILENAME = "bunny.toml"; // Re-export types and conversion functions for convenience -export type { BunnyAppConfig, ContainerConfig } from "@bunny.net/app-config"; +export type { + BunnyAppConfig, + ContainerConfig, + RegionsConfig, +} from "@bunny.net/app-config"; export { apiToConfig, + CURRENT_VERSION, configToAddRequest, configToPatchRequest, + normalizeRegions, parseImageRef, } from "@bunny.net/app-config"; @@ -30,35 +36,34 @@ function findConfigRoot(): string { while (true) { if (existsSync(join(dir, CONFIG_FILENAME))) return dir; - if (existsSync(join(dir, LEGACY_FILENAME))) return dir; const parent = dirname(dir); if (parent === dir) return process.cwd(); dir = parent; } } -/** Load and parse bunny.jsonc from cwd or nearest ancestor. Falls back to legacy bunny.toml. */ +/** Load and parse bunny.jsonc from cwd or nearest ancestor. */ export function loadConfig(): BunnyAppConfig { const root = findConfigRoot(); const jsoncPath = join(root, CONFIG_FILENAME); - const tomlPath = join(root, LEGACY_FILENAME); if (existsSync(jsoncPath)) { const raw = readFileSync(jsoncPath, "utf-8"); - return BunnyAppConfigSchema.parse(parseJsonc(raw)); + return parseAndMigrate(parseJsonc(raw)); } - if (existsSync(tomlPath)) { - const { parse: parseToml } = require("smol-toml"); - logger.warn( - "bunny.toml is deprecated. Run `bunny apps pull` to regenerate as bunny.jsonc.", - ); - return BunnyAppConfigSchema.parse( - parseToml(readFileSync(tomlPath, "utf-8")), + throw new UserError("No bunny.jsonc found.", "Run `bunny apps init` first."); +} + +/** Apply schema migrations then validate. Logs when a migration ran. */ +function parseAndMigrate(raw: unknown): BunnyAppConfig { + const { config, migratedFrom } = migrate(raw); + if (migratedFrom) { + logger.dim( + `Migrated bunny.jsonc from version "${migratedFrom}". Run \`bunny apps push\` to save the new format.`, ); } - - throw new UserError("No bunny.jsonc found.", "Run `bunny apps init` first."); + return BunnyAppConfigSchema.parse(config); } /** Write bunny.jsonc to the given directory (or cwd). */ @@ -66,23 +71,21 @@ export function saveConfig(data: BunnyAppConfig, dir?: string): void { const target = dir ?? process.cwd(); const path = join(target, CONFIG_FILENAME); - // Strip $schema from data before re-inserting to ensure it's always first - const { $schema: _, ...rest } = data; + // Re-key the object so the file always starts with $schema → version → app. + const { $schema: _schema, version, ...rest } = data; const output = { $schema: "./node_modules/@bunny.net/app-config/generated/schema.json", + version, ...rest, }; writeFileSync(path, `${JSON.stringify(output, null, 2)}\n`); } -/** Check if bunny.jsonc (or legacy bunny.toml) exists in cwd or ancestor. */ +/** Check if bunny.jsonc exists in cwd or ancestor. */ export function configExists(): boolean { const root = findConfigRoot(); - return ( - existsSync(join(root, CONFIG_FILENAME)) || - existsSync(join(root, LEGACY_FILENAME)) - ); + return existsSync(join(root, CONFIG_FILENAME)); } // ─── Resolution helpers ───────────────────────────────────────────── diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 5c708ae..874eeca 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -1,4 +1,8 @@ +import { existsSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { createMcClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; +import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -6,6 +10,10 @@ import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; import { + type BunnyAppConfig, + type ContainerConfig, + CURRENT_VERSION, + configExists, configToAddRequest, configToPatchRequest, loadConfig, @@ -14,93 +22,226 @@ import { } from "./config.ts"; import { buildImage, + dockerLogin, ensureDockerAvailable, generateTag, + getConfigSuggestions, + type McClient, promptRegistry, pushImage, + type ResolvedRegistry, + resolveRegistryForImage, } from "./docker.ts"; -const COMMAND = "deploy"; +type EndpointRequest = components["schemas"]["EndpointRequest"]; + +const COMMAND = "deploy [image]"; const DESCRIPTION = "Deploy an app."; +const DEFAULT_DOCKERFILE = "Dockerfile"; interface DeployArgs { image?: string; + dockerfile?: string | boolean; + context?: string; + tag?: string; + registry?: string; + container?: string; + "no-push"?: boolean; } export const appsDeployCommand = defineCommand({ command: COMMAND, describe: DESCRIPTION, + examples: [ + ["$0 apps deploy ghcr.io/me/api:v1.2", "Deploy a pre-built image"], + ["$0 apps deploy --dockerfile", "Build ./Dockerfile and deploy"], + [ + "$0 apps deploy --dockerfile apps/api/Dockerfile --context apps/api", + "Build with explicit context", + ], + ["$0 apps deploy", "Re-deploy using config from bunny.jsonc"], + ], builder: (yargs) => - yargs.option("image", { - type: "string", - describe: "Container image to deploy (skips build if dockerfile is set)", - }), + yargs + .positional("image", { + type: "string", + describe: + "Container image reference to deploy (e.g. ghcr.io/me/api:v1)", + }) + .option("dockerfile", { + type: "string", + describe: + "Build from Dockerfile, then deploy. Pass a path or use bare flag for ./Dockerfile.", + }) + .option("context", { + type: "string", + describe: + "Docker build context directory (defaults to dirname of Dockerfile)", + }) + .option("tag", { + type: "string", + describe: "Override the auto-generated image tag", + }) + .option("registry", { + type: "string", + describe: "Bunny registry ID to push to (overrides bunny.jsonc)", + }) + .option("container", { + type: "string", + describe: + "Target container by name (required when bunny.jsonc has multiple containers)", + }) + .option("no-push", { + type: "boolean", + describe: "Build only — skip push and deploy", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const positionalImage = args.image; + const dockerfileFlag = normalizeDockerfileFlag(args.dockerfile); + const noPush = args["no-push"] === true; + + if (positionalImage && dockerfileFlag) { + throw new UserError( + "Cannot use both and --dockerfile at the same time.", + "Pass an image to deploy a pre-built ref, or --dockerfile to build locally.", + ); + } - handler: async ({ image, profile, output, verbose, apiKey }) => { - const toml = loadConfig(); - const config = resolveConfig(profile, apiKey); - const client = createMcClient(clientOptions(config, verbose)); + const cfg = resolveConfig(profile, apiKey); + const client = createMcClient(clientOptions(cfg, verbose)); + + // ─── First-run walkthrough ─────────────────────────────────── + let toml: BunnyAppConfig; + if (!configExists()) { + toml = await firstRunWalkthrough(client, { + positionalImage, + dockerfileFlag, + contextFlag: args.context, + registryFlag: args.registry, + }); + } else { + toml = loadConfig(); + } - let appId = toml.app.id; - let deployImage = image; + const [targetName, targetContainer] = resolveTargetContainer(toml, { + explicit: args.container, + hasImageOrBuild: Boolean(positionalImage || dockerfileFlag), + }); - // Get the primary (first) container from the containers map - const containerEntries = Object.entries(toml.app.containers); - const primaryContainer = containerEntries[0]?.[1]; + // ─── Decide build vs deploy mode ───────────────────────────── + const mode = resolveMode({ + positionalImage, + dockerfileFlag, + container: targetContainer, + }); - // Build from Dockerfile if configured and no --image override - const dockerfile = primaryContainer?.dockerfile; - let registry = primaryContainer?.registry; + let deployImage: string | undefined; + let registryId: string | undefined = + args.registry ?? targetContainer.registry; + let freshCreds: ResolvedRegistry["freshCredentials"]; - if (dockerfile && !image) { + // ─── Build path ────────────────────────────────────────────── + if (mode.kind === "build") { await ensureDockerAvailable(); - // Prompt for registry if not set - if (!registry) { - const registryId = await promptRegistry(client); - if (!registryId) { + // Ensure a registry is selected before we build (we need its hostname). + if (!registryId) { + const resolved = await promptRegistry(client); + if (!resolved) { throw new UserError( "A registry is required to build and push images.", ); } - registry = registryId; - if (primaryContainer) { - primaryContainer.registry = registry; - } + registryId = resolved.id; + freshCreds = resolved.freshCredentials; + targetContainer.registry = registryId; saveConfig(toml); } - // Fetch registry details to get hostname const regSpin = spinner("Fetching registry..."); regSpin.start(); - const { data: reg } = await client.GET("/registries/{registryId}", { - params: { path: { registryId: Number(registry) } }, + params: { path: { registryId: Number(registryId) } }, }); - regSpin.stop(); if (!reg?.hostName) { throw new UserError( - `Registry ${registry} not found or has no hostname.`, - "Use `bunny registry list` to check your registries.", + `Registry ${registryId} not found or has no hostname.`, + "Use `bunny registries list` to check your registries.", ); } - const tag = await generateTag(); + const tag = args.tag ?? (await generateTag()); const imageRef = `${reg.hostName}/${toml.app.name}:${tag}`; + const buildCwd = resolveBuildContext(mode.dockerfile, args.context); logger.info(`Building ${imageRef}...`); - await buildImage(dockerfile, 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 }), + ); + } + return; + } + + if (freshCreds && reg.hostName) { + const loginSpin = spinner(`Logging in to ${reg.hostName}...`); + loginSpin.start(); + try { + await dockerLogin( + reg.hostName, + freshCreds.userName, + freshCreds.password, + ); + loginSpin.stop(); + } catch (err) { + loginSpin.stop(); + throw err; + } + } logger.info(`Pushing ${imageRef}...`); await pushImage(imageRef); deployImage = imageRef; + targetContainer.image = imageRef; + // Persist dockerfile/context if they came from flags so the manifest stays the source of truth. + if (!targetContainer.dockerfile) { + targetContainer.dockerfile = mode.dockerfile; + } + if (args.context && !targetContainer.context) { + targetContainer.context = args.context; + } + saveConfig(toml); + } + + // ─── Deploy path (pre-built image) ─────────────────────────── + if (mode.kind === "image") { + const resolved = await resolveRegistryForImage(client, mode.image); + if (!resolved) { + throw new UserError( + "A registry is required to deploy this image.", + "Bunny needs a registry record for the image hostname so it can pull the image.", + ); + } + registryId = resolved.id; + deployImage = mode.image; + targetContainer.image = mode.image; + targetContainer.registry = registryId; + saveConfig(toml); } - // If no id, create the app on MC first + // ─── App create-or-update ──────────────────────────────────── + let appId = toml.app.id; if (!appId) { const createSpin = spinner("Creating app..."); createSpin.start(); @@ -108,7 +249,6 @@ export const appsDeployCommand = defineCommand({ const { data: result } = await client.POST("/apps", { body: configToAddRequest(toml), }); - createSpin.stop(); if (!result?.id) { @@ -121,7 +261,6 @@ export const appsDeployCommand = defineCommand({ logger.success(`App "${toml.app.name}" created (${appId}).`); } else { - // Existing app — push config changes before deploying const pushSpin = spinner("Pushing config..."); pushSpin.start(); @@ -138,32 +277,39 @@ export const appsDeployCommand = defineCommand({ params: { path: { appId } }, body: configToPatchRequest(toml, existingApp), }); - pushSpin.stop(); } - // If we have an image to deploy (from build or --image), update the primary container + // ─── Update container image (if changed) ───────────────────── if (deployImage) { const fetchSpin = spinner("Fetching app..."); fetchSpin.start(); - const { data: app } = await client.GET("/apps/{appId}", { params: { path: { appId } }, }); - fetchSpin.stop(); - const containerId = app?.containerTemplates?.[0]?.id; - if (!containerId) { - throw new UserError("App has no containers."); + // Find the remote container template matching our target by name — + // falls back to the primary if there's only one. + const templates = app?.containerTemplates ?? []; + const match = + templates.find( + (t) => t.name.toLowerCase() === targetName.toLowerCase(), + ) ?? (templates.length === 1 ? templates[0] : undefined); + + if (!match) { + throw new UserError( + `Container "${targetName}" not found on the remote app.`, + `Remote containers: ${templates.map((t) => t.name).join(", ") || "(none)"}`, + ); } + const containerId = match.id; const { imageName, imageNamespace, imageTag } = parseImageRef(deployImage); const updateSpin = spinner("Updating container image..."); updateSpin.start(); - await client.PATCH("/apps/{appId}/containers/{containerId}", { params: { path: { appId, containerId } }, body: { @@ -171,21 +317,19 @@ export const appsDeployCommand = defineCommand({ imageName, imageNamespace, imageTag, - imageRegistryId: registry ?? "", + imageRegistryId: registryId ?? "", }, }); - updateSpin.stop(); logger.success(`Image updated to ${deployImage}.`); } + // ─── Trigger deploy ────────────────────────────────────────── const deploySpin = spinner("Deploying..."); deploySpin.start(); - await client.POST("/apps/{appId}/deploy", { params: { path: { appId } }, }); - deploySpin.stop(); if (output === "json") { @@ -198,3 +342,380 @@ export const appsDeployCommand = defineCommand({ logger.success("App deployed."); }, }); + +// ─── Helpers ───────────────────────────────────────────────────────── + +/** + * yargs returns `--dockerfile` (bare) as an empty string and `--dockerfile foo` + * as the path. Normalize to a path-or-undefined. + */ +function normalizeDockerfileFlag( + value: string | boolean | undefined, +): string | undefined { + if (value === undefined) return undefined; + if (value === true || value === "") return DEFAULT_DOCKERFILE; + if (value === false) return undefined; + return value; +} + +/** + * Pick the container in `bunny.jsonc` that this deploy targets. + * + * - Explicit `--container ` → must match a key in `app.containers`. + * - One container in the manifest → that one, always. + * - Multiple containers + the user passed `` or `--dockerfile` → + * require `--container ` so we don't guess which one to swap. + * - Multiple containers + no image-or-build flag → use the first one + * (it doesn't matter — we're only triggering a redeploy of current state). + */ +function resolveTargetContainer( + toml: BunnyAppConfig, + opts: { explicit?: string; hasImageOrBuild: boolean }, +): [string, ContainerConfig] { + const entries = Object.entries(toml.app.containers); + if (entries.length === 0) { + throw new UserError( + "bunny.jsonc has no containers configured.", + "Add a container under `app.containers` and try again.", + ); + } + + if (opts.explicit) { + const found = entries.find( + ([name]) => name.toLowerCase() === opts.explicit?.toLowerCase(), + ); + if (!found) { + throw new UserError( + `Container "${opts.explicit}" not found in bunny.jsonc.`, + `Available containers: ${entries.map(([n]) => n).join(", ")}`, + ); + } + return found; + } + + if (entries.length > 1 && opts.hasImageOrBuild) { + throw new UserError( + "bunny.jsonc has multiple containers — pass --container .", + `Available containers: ${entries.map(([n]) => n).join(", ")}`, + ); + } + + const first = entries[0]; + if (!first) { + // Unreachable — the length === 0 branch above already handled this. + throw new UserError("bunny.jsonc has no containers configured."); + } + return first; +} + +type DeployMode = + | { kind: "build"; dockerfile: string } + | { kind: "image"; image: string } + | { kind: "redeploy"; image: string }; + +function resolveMode(args: { + positionalImage?: string; + dockerfileFlag?: string; + container: ContainerConfig; +}): DeployMode { + if (args.positionalImage) { + return { kind: "image", image: args.positionalImage }; + } + if (args.dockerfileFlag) { + return { kind: "build", dockerfile: args.dockerfileFlag }; + } + if (args.container.dockerfile) { + return { kind: "build", dockerfile: args.container.dockerfile }; + } + if (args.container.image) { + return { kind: "redeploy", image: args.container.image }; + } + throw new UserError( + "Nothing to deploy.", + "Pass , use --dockerfile, or set `image`/`dockerfile` on the container in bunny.jsonc.", + ); +} + +function resolveBuildContext( + dockerfile: string, + contextOverride: string | undefined, +): string { + if (contextOverride) { + return isAbsolute(contextOverride) + ? contextOverride + : resolve(process.cwd(), contextOverride); + } + const absDockerfile = isAbsolute(dockerfile) + ? dockerfile + : resolve(process.cwd(), dockerfile); + return dirname(absDockerfile); +} + +// ─── First-run walkthrough ─────────────────────────────────────────── + +interface WalkthroughInput { + positionalImage?: string; + dockerfileFlag?: string; + contextFlag?: string; + registryFlag?: string; +} + +async function firstRunWalkthrough( + client: McClient, + input: WalkthroughInput, +): Promise { + logger.info("No bunny.jsonc found — setting up this app."); + + // Decide build vs deploy if neither flag was passed. + let imageRef: string | undefined = input.positionalImage; + let dockerfilePath: string | undefined = input.dockerfileFlag; + + if (!imageRef && !dockerfilePath) { + const hasDockerfile = existsSync(join(process.cwd(), DEFAULT_DOCKERFILE)); + const { value } = await prompts({ + type: "select", + name: "value", + message: "How do you want to deploy?", + choices: [ + ...(hasDockerfile + ? [ + { + title: "Build from ./Dockerfile", + value: "dockerfile", + }, + ] + : []), + { title: "Deploy a pre-built image", value: "image" }, + ], + }); + if (!value) throw new UserError("Setup cancelled."); + if (value === "dockerfile") { + dockerfilePath = DEFAULT_DOCKERFILE; + } else { + const { value: ref } = await prompts({ + type: "text", + name: "value", + message: "Image ref (e.g. ghcr.io/me/api:v1):", + }); + if (!ref) throw new UserError("Image ref is required."); + imageRef = ref; + } + } + + const mode: "build" | "image" = dockerfilePath ? "build" : "image"; + + // ─── Resolve the registry first — we need it for the rest of the walkthrough. + let registry: ResolvedRegistry | null; + if (input.registryFlag) { + registry = { id: input.registryFlag }; + } else if (mode === "image" && imageRef) { + registry = await resolveRegistryForImage(client, imageRef); + } else { + logger.info("Pick a registry to push your image to."); + registry = await promptRegistry(client); + } + if (!registry) throw new UserError("A registry is required."); + + // ─── Ask for suggestions (only meaningful when we already know the image). + let suggestions: Awaited> | null = + null; + if (mode === "image" && imageRef) { + const parsed = parseImageRef(imageRef); + suggestions = await getConfigSuggestions(client, registry.id, parsed); + if (suggestions?.instructions) { + logger.log(); + logger.dim(suggestions.instructions); + logger.log(); + } + } + + // ─── App name. + const defaultName = + suggestions?.appName?.trim() || basename(resolve(process.cwd())); + const { value: name } = await prompts({ + type: "text", + name: "value", + message: "App name:", + initial: defaultName, + }); + if (!name) throw new UserError("App name is required."); + + // ─── Regions. + const regionsSpin = spinner("Fetching regions..."); + regionsSpin.start(); + const { data: regionsResult } = await client.GET("/regions"); + regionsSpin.stop(); + + const regionsWithCapacity = (regionsResult?.items ?? []).filter( + (r): r is typeof r & { id: string } => + r.hasCapacity === true && typeof r.id === "string", + ); + + if (regionsWithCapacity.length === 0) { + throw new UserError("No regions with capacity are available right now."); + } + + const { value: selectedRegions } = await prompts({ + type: "multiselect", + name: "value", + message: "Select regions:", + choices: regionsWithCapacity.map((r) => ({ + title: `${r.name} (${r.id})`, + value: r.id, + })), + min: 1, + }); + + const regions: string[] = selectedRegions ?? []; + if (regions.length === 0) { + throw new UserError("At least one region must be selected."); + } + + // ─── Build the container config. + const container: ContainerConfig = { + registry: registry.id, + }; + + if (mode === "build" && dockerfilePath) { + container.dockerfile = dockerfilePath; + if (input.contextFlag) container.context = input.contextFlag; + } + if (imageRef) { + container.image = imageRef; + } + + // Apply suggested endpoints. + if (suggestions?.endpointSuggestions?.length) { + const accepted = await confirmEndpointSuggestions( + suggestions.endpointSuggestions, + ); + if (accepted.length > 0) { + container.endpoints = accepted.map(endpointRequestToConfig); + } + } + + // Prompt for suggested env vars (required first; offer optional ones). + if (suggestions?.environmentVariablesSuggestions?.length) { + const env = await promptSuggestedEnv( + suggestions.environmentVariablesSuggestions, + ); + if (Object.keys(env).length > 0) { + container.env = env; + } + } + + const toml: BunnyAppConfig = { + version: CURRENT_VERSION, + app: { + name, + scaling: { min: 1, max: 1 }, + regions, + containers: { [name]: container }, + }, + }; + + saveConfig(toml); + logger.success("Wrote bunny.jsonc."); + return toml; +} + +async function confirmEndpointSuggestions( + endpoints: EndpointRequest[], +): Promise { + const accepted: EndpointRequest[] = []; + for (const ep of endpoints) { + const label = describeEndpoint(ep); + const { value } = await prompts({ + type: "confirm", + name: "value", + message: `Add suggested endpoint: ${label}?`, + initial: true, + }); + if (value) accepted.push(ep); + } + return accepted; +} + +function describeEndpoint(ep: EndpointRequest): string { + if (ep.cdn) { + const ports = ep.cdn.portMappings + ?.map((p) => `${p.exposedPort}→${p.containerPort}`) + .join(", "); + return `CDN (${ports ?? "default port"})${ep.cdn.isSslEnabled ? " + SSL" : ""}`; + } + if (ep.anycast) { + const ports = ep.anycast.portMappings + .map((p) => `${p.exposedPort}→${p.containerPort}`) + .join(", "); + return `Anycast (${ports})`; + } + return ep.displayName ?? "endpoint"; +} + +function endpointRequestToConfig( + ep: EndpointRequest, +): NonNullable[number] { + if (ep.cdn) { + return { + type: "cdn", + ssl: ep.cdn.isSslEnabled, + ports: + ep.cdn.portMappings?.map((p) => ({ + public: p.exposedPort ?? p.containerPort, + container: p.containerPort, + })) ?? [], + }; + } + if (ep.anycast) { + return { + type: "anycast", + ports: ep.anycast.portMappings.map((p) => ({ + public: p.exposedPort ?? p.containerPort, + container: p.containerPort, + })), + }; + } + return { type: "cdn" }; +} + +async function promptSuggestedEnv( + suggestions: components["schemas"]["EnvironmentVariableSuggestion"][], +): Promise> { + const env: Record = {}; + const required = suggestions.filter((s) => s.required); + const optional = suggestions.filter((s) => !s.required); + + for (const item of required) { + if (!item.name) continue; + const { value } = await prompts({ + type: "text", + name: "value", + message: `${item.name}${item.description ? ` — ${item.description}` : ""}:`, + initial: item.defaultValue ?? "", + }); + if (value !== undefined && value !== "") env[item.name] = String(value); + } + + if (optional.length > 0) { + const { value: confirm } = await prompts({ + type: "confirm", + name: "value", + message: `Configure ${optional.length} optional env var${optional.length === 1 ? "" : "s"} now?`, + initial: false, + }); + if (confirm) { + for (const item of optional) { + if (!item.name) continue; + const { value } = await prompts({ + type: "text", + name: "value", + message: `${item.name}${item.description ? ` — ${item.description}` : ""}:`, + initial: item.defaultValue ?? "", + }); + if (value !== undefined && value !== "") env[item.name] = String(value); + } + } + } + + return env; +} diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index 9b583bc..83828ce 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -1,10 +1,14 @@ 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 { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.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. @@ -94,13 +98,14 @@ export async function pushImage(tag: string): Promise { if (exitCode !== 0) { throw new UserError( `Docker push failed (exit code ${exitCode}).`, - "Ensure you are logged in to the registry (`docker login`).", + "Ensure you are logged in to the registry (`docker login `).", ); } } /** - * Log in to a Docker registry using credentials from the MC API. + * 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, @@ -127,23 +132,153 @@ export async function dockerLogin( } } +// ─── Image ref helpers ─────────────────────────────────────────────── + +/** + * 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`). + */ +export function imageHostname(ref: string): string | null { + const firstSegment = ref.split("/")[0]; + if (!firstSegment) return null; + // A hostname segment must contain a dot, a colon (port), or be "localhost". + if ( + firstSegment.includes(".") || + firstSegment.includes(":") || + firstSegment === "localhost" + ) { + return firstSegment; + } + return null; +} + +// ─── Registry resolution ───────────────────────────────────────────── + const ADD_NEW_REGISTRY = "__add_new__"; /** - * Prompt the user to select a container registry (or add a new one). - * Returns the registry ID as a string, or null if cancelled. + * Result of resolving a registry — the ID plus, if the user just entered + * credentials in this session, those credentials so the caller can run + * `docker login` without prompting again. + */ +export interface ResolvedRegistry { + id: string; + hostName?: string; + freshCredentials?: { userName: string; password: string }; +} + +/** + * Fetch all registries on the account. + */ +export async function listRegistries( + client: McClient, +): Promise { + const { data } = await client.GET("/registries"); + return data?.items ?? []; +} + +/** + * Find an existing registry that matches the given hostname. + * Falls back to undefined if no match is found. + */ +export function findRegistryByHostname( + registries: ContainerRegistry[], + hostname: string, +): ContainerRegistry | undefined { + const normalized = hostname.toLowerCase(); + return registries.find((r) => r.hostName?.toLowerCase() === normalized); +} + +/** + * Inline "add new registry" flow. Prompts for display name + credentials, + * creates the registry on bunny.net, and returns its ID along with the + * credentials so the caller can `docker login` without re-prompting. + * + * If `hostname` is given it is used as the suggested display name. + * + * If `isPublic` is true, the registry is created with no credentials + * (suitable for pulling public images that don't require auth). + */ +export async function createRegistry( + client: McClient, + opts: { displayName?: string; isPublic?: boolean } = {}, +): Promise { + let displayName = opts.displayName; + if (!displayName) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Registry display name:", + }); + displayName = value; + } + if (!displayName) return null; + + let userName: string | undefined; + let password: string | undefined; + + if (!opts.isPublic) { + const { value: rawUser } = await prompts({ + type: "text", + name: "value", + message: "Username:", + }); + userName = rawUser; + if (!userName) return null; + + const { value: rawPass } = await prompts({ + type: "password", + name: "value", + message: "Password/Token:", + }); + password = rawPass; + if (!password) return null; + } + + const addSpin = spinner("Adding registry..."); + addSpin.start(); + + const { data: result } = await client.POST("/registries", { + body: { + displayName, + ...(userName && password + ? { passwordCredentials: { userName, password } } + : {}), + }, + }); + + addSpin.stop(); + + if (result?.status !== "saved" || !result.id) { + logger.error(`Failed to add registry: ${result?.error ?? "unknown error"}`); + return null; + } + + logger.success(`Registry "${displayName}" added (ID: ${result.id}).`); + + return { + id: String(result.id), + freshCredentials: userName && password ? { userName, password } : undefined, + }; +} + +/** + * Interactive registry selection for "where do I push my image?". + * Returns the registry ID, or null if cancelled. */ -export async function promptRegistry(client: McClient): Promise { +export async function promptRegistry( + client: McClient, +): Promise { const regSpin = spinner("Fetching registries..."); regSpin.start(); - const { data } = await client.GET("/registries"); + const registries = await listRegistries(client); regSpin.stop(); - const registries = data?.items ?? []; - - // Only show registries the user can push to (have a username) + // Only show registries the user can push to (have a username). const pushable = registries.filter((r) => r.userName); const choices = [ @@ -154,56 +289,92 @@ export async function promptRegistry(client: McClient): Promise { { title: "Add new registry", value: ADD_NEW_REGISTRY }, ]; - const { value: registryId } = await prompts({ + const { value: choice } = await prompts({ type: "select", name: "value", message: "Container registry:", choices, }); - if (registryId === undefined) return null; + if (choice === undefined) return null; + if (choice !== ADD_NEW_REGISTRY) { + const existing = pushable.find((r) => String(r.id) === String(choice)); + return { id: String(choice), hostName: existing?.hostName }; + } - if (registryId !== ADD_NEW_REGISTRY) return registryId; + return createRegistry(client); +} - // Inline "add new registry" flow - const { value: displayName } = await prompts({ - type: "text", - name: "value", - message: "Registry display name:", - }); - if (!displayName) return null; +/** + * Resolve a registry to use when pulling an image. If the hostname matches + * an existing registry, returns it. Otherwise prompts the user to either + * add it as a public registry or provide credentials. + * + * Returns null if the user cancels. + */ +export async function resolveRegistryForImage( + client: McClient, + imageRef: string, +): Promise { + const hostname = imageHostname(imageRef) ?? "docker.io"; + + const fetchSpin = spinner("Looking up registry..."); + fetchSpin.start(); + const registries = await listRegistries(client); + fetchSpin.stop(); + + const existing = findRegistryByHostname(registries, hostname); + if (existing?.id) { + return { id: String(existing.id), hostName: existing.hostName }; + } + + logger.info(`No registry connected for ${hostname}.`); - const { value: userName } = await prompts({ - type: "text", + const { value: kind } = await prompts({ + type: "select", name: "value", - message: "Username:", + message: `Is ${hostname} public, or do you need credentials?`, + choices: [ + { title: "Public — no credentials needed", value: "public" }, + { title: "Private — I have credentials", value: "private" }, + { title: "Cancel", value: "cancel" }, + ], }); - if (!userName) return null; - const { value: password } = await prompts({ - type: "password", - name: "value", - message: "Password/Token:", + if (!kind || kind === "cancel") return null; + + return createRegistry(client, { + displayName: hostname, + isPublic: kind === "public", }); - if (!password) return null; +} - const addSpin = spinner("Adding registry..."); - addSpin.start(); +// ─── Config suggestions ────────────────────────────────────────────── - const { data: result } = await client.POST("/registries", { +/** + * Ask bunny.net for recommended configuration for a given image + * (endpoints, environment variables, app name suggestion). Returns null + * if no suggestions are available — common for custom user images. + */ +export async function getConfigSuggestions( + client: McClient, + registryId: string, + imageRef: { imageName: string; imageNamespace: string; imageTag: string }, +): Promise { + const spin = spinner("Looking up image defaults..."); + spin.start(); + + const { data, error } = await client.POST("/registries/config-suggestions", { body: { - displayName, - passwordCredentials: { userName, password }, + registryId, + imageName: imageRef.imageName, + imageNamespace: imageRef.imageNamespace, + tag: imageRef.imageTag, }, }); - addSpin.stop(); + spin.stop(); - if (result?.status !== "saved" || !result.id) { - logger.error(`Failed to add registry: ${result?.error ?? "unknown error"}`); - return null; - } - - logger.success(`Registry "${displayName}" added (ID: ${result.id}).`); - return String(result.id); + if (error || !data) return null; + return data; } diff --git a/packages/cli/src/commands/apps/init.ts b/packages/cli/src/commands/apps/init.ts index b75fbe6..9323a08 100644 --- a/packages/cli/src/commands/apps/init.ts +++ b/packages/cli/src/commands/apps/init.ts @@ -9,7 +9,7 @@ import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; import type { BunnyAppConfig, ContainerConfig } from "./config.ts"; -import { configExists, saveConfig } from "./config.ts"; +import { CURRENT_VERSION, configExists, saveConfig } from "./config.ts"; import { promptRegistry } from "./docker.ts"; const COMMAND = "init"; @@ -83,12 +83,12 @@ export const appsInitCommand = defineCommand({ }); if (useDockerfile) { - const registryId = await promptRegistry(client); - if (!registryId) + const resolved = await promptRegistry(client); + if (!resolved) throw new UserError( "A registry is required to build and push images.", ); - container = { dockerfile: "Dockerfile", registry: registryId }; + container = { dockerfile: "Dockerfile", registry: resolved.id }; } else { const { value } = await prompts({ type: "text", @@ -139,19 +139,16 @@ export const appsInitCommand = defineCommand({ selectedRegions = value ?? []; } - const [primaryRegion] = selectedRegions; - if (!primaryRegion) { + if (selectedRegions.length === 0) { throw new UserError("At least one region must be selected."); } const toml: BunnyAppConfig = { + version: CURRENT_VERSION, app: { name, scaling: { min: 1, max: 1 }, - regions: { - allowed: selectedRegions, - required: [primaryRegion], - }, + regions: selectedRegions, containers: { [name]: container }, }, }; From b5ecbfecfcb6620e2531415c9ea885d912334313 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 11 May 2026 19:35:40 +0100 Subject: [PATCH 02/11] bunny apps deploy changeset --- .changeset/swift-ideas-grin.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/swift-ideas-grin.md diff --git a/.changeset/swift-ideas-grin.md b/.changeset/swift-ideas-grin.md new file mode 100644 index 0000000..c18f243 --- /dev/null +++ b/.changeset/swift-ideas-grin.md @@ -0,0 +1,6 @@ +--- +"@bunny.net/app-config": minor +"@bunny.net/cli": minor +--- + +Rework bunny apps deploy command From 9a9cdfebb138d0b34a7f12afff3673bf2aa95ce7 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 11 May 2026 19:51:54 +0100 Subject: [PATCH 03/11] formatting --- packages/app-config/src/convert.test.ts | 492 ++++++++++++++++++ packages/app-config/src/convert.ts | 4 - .../app-config/src/parse-image-ref.test.ts | 89 ++++ packages/app-config/src/schema.ts | 10 - packages/cli/src/commands/apps/config.ts | 4 - packages/cli/src/commands/apps/deploy.ts | 16 - packages/cli/src/commands/apps/docker.ts | 6 - packages/database-shell/src/format.ts | 8 - 8 files changed, 581 insertions(+), 48 deletions(-) create mode 100644 packages/app-config/src/convert.test.ts create mode 100644 packages/app-config/src/parse-image-ref.test.ts diff --git a/packages/app-config/src/convert.test.ts b/packages/app-config/src/convert.test.ts new file mode 100644 index 0000000..e7fa0b3 --- /dev/null +++ b/packages/app-config/src/convert.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, test } from "bun:test"; +import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; +import { + apiToConfig, + configToAddRequest, + configToPatchRequest, +} from "./convert.ts"; +import { CURRENT_VERSION } from "./schema.ts"; + +type Application = components["schemas"]["Application"]; +type ContainerTemplate = components["schemas"]["ContainerTemplate"]; + +// ─── Test fixtures ─────────────────────────────────────────────────── +// +// Building real `Application` objects requires every nested required field; +// for unit tests we lean on `as Application` after constructing the subset +// the conversion code actually reads. + +function template(overrides: Partial): ContainerTemplate { + return { + id: "ct_1", + name: "api", + packageId: "pkg_1", + image: "ghcr.io/me/api:v1", + imageName: "api", + imageNamespace: "me", + imageTag: "v1", + imageRegistryId: "12345", + imageDigest: "", + environmentVariables: [], + endpoints: [], + volumeMounts: [], + ...overrides, + } as ContainerTemplate; +} + +function app(overrides: Partial): Application { + return { + id: "app_1", + name: "my-app", + containerTemplates: [template({})], + containerInstances: [], + volumes: [], + regionSettings: { allowedRegionIds: [], requiredRegionIds: [] }, + ...overrides, + } as Application; +} + +// ─── apiToConfig ───────────────────────────────────────────────────── + +describe("apiToConfig", () => { + test("emits CURRENT_VERSION and basic app fields", () => { + const result = apiToConfig(app({ id: "app_42", name: "my-api" })); + expect(result.version).toBe(CURRENT_VERSION); + expect(result.app.id).toBe("app_42"); + expect(result.app.name).toBe("my-api"); + }); + + test("maps container image", () => { + const result = apiToConfig( + app({ + containerTemplates: [template({ name: "web", image: "nginx:1.27" })], + }), + ); + expect(result.app.containers.web?.image).toBe("nginx:1.27"); + }); + + test("prefers commandArray over command when both are present", () => { + const result = apiToConfig( + app({ + containerTemplates: [ + template({ + entryPoint: { + command: "/start.sh", + commandArray: ["bash", "-c", "/start.sh"], + }, + }), + ], + }), + ); + expect(result.app.containers.api?.command).toEqual([ + "bash", + "-c", + "/start.sh", + ]); + }); + + test("wraps single command string in a one-element array", () => { + const result = apiToConfig( + app({ + containerTemplates: [ + template({ + entryPoint: { command: "/start.sh" }, + }), + ], + }), + ); + expect(result.app.containers.api?.command).toEqual(["/start.sh"]); + }); + + test("populates env from environmentVariables", () => { + const result = apiToConfig( + app({ + containerTemplates: [ + template({ + environmentVariables: [ + { name: "PORT", value: "3000" }, + { name: "DEBUG", value: "true" }, + { name: "EMPTY", value: undefined } as unknown as { + name: string; + value: string; + }, + ], + }), + ], + }), + ); + expect(result.app.containers.api?.env).toEqual({ + PORT: "3000", + DEBUG: "true", + EMPTY: "", + }); + }); + + test("maps endpoints to lowercase type with public/container port pairs", () => { + const result = apiToConfig( + app({ + containerTemplates: [ + template({ + endpoints: [ + { + displayName: "web", + publicHost: "example.com", + type: "cdn", + isSslEnabled: true, + pullZoneId: "pz_1", + portMappings: [ + { + exposedPort: 443, + containerPort: 3000, + protocols: ["tcp"], + }, + ], + }, + ], + }), + ], + }), + ); + expect(result.app.containers.api?.endpoints).toEqual([ + { + type: "cdn", + ssl: true, + ports: [{ public: 443, container: 3000 }], + }, + ]); + }); + + test("resolves volume sizes from app.volumes", () => { + const result = apiToConfig( + app({ + volumes: [ + { name: "data", size: 50 }, + { name: "cache", size: 10 }, + ], + containerTemplates: [ + template({ + volumeMounts: [ + { name: "data", mountPath: "/data" }, + { name: "unknown", mountPath: "/unknown" }, + ], + }), + ], + }), + ); + expect(result.app.containers.api?.volumes).toEqual([ + { name: "data", mount: "/data", size: 50 }, + { name: "unknown", mount: "/unknown", size: 0 }, + ]); + }); + + test("includes autoScaling when set", () => { + const result = apiToConfig(app({ autoScaling: { min: 2, max: 5 } })); + expect(result.app.scaling).toEqual({ min: 2, max: 5 }); + }); + + test("collapses regions to array when allowed === required", () => { + const result = apiToConfig( + app({ + regionSettings: { + allowedRegionIds: ["sfo", "lhr"], + requiredRegionIds: ["sfo", "lhr"], + }, + }), + ); + expect(result.app.regions).toEqual(["sfo", "lhr"]); + }); + + test("emits regions as object when allowed and required differ", () => { + const result = apiToConfig( + app({ + regionSettings: { + allowedRegionIds: ["sfo", "lhr", "nyc"], + requiredRegionIds: ["sfo"], + }, + }), + ); + expect(result.app.regions).toEqual({ + allowed: ["sfo", "lhr", "nyc"], + required: ["sfo"], + }); + }); + + test("omits regions when both are empty", () => { + const result = apiToConfig(app({})); + expect(result.app.regions).toBeUndefined(); + }); + + test("multiple containers keyed by name", () => { + const result = apiToConfig( + app({ + containerTemplates: [ + template({ id: "ct_1", name: "api", image: "ghcr.io/me/api:v1" }), + template({ id: "ct_2", name: "db", image: "postgres:16" }), + ], + }), + ); + expect(Object.keys(result.app.containers)).toEqual(["api", "db"]); + expect(result.app.containers.db?.image).toBe("postgres:16"); + }); +}); + +// ─── configToAddRequest ────────────────────────────────────────────── + +describe("configToAddRequest", () => { + test("parses image ref into name/namespace/tag", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { image: "ghcr.io/me/api:v1.2", registry: "12345" }, + }, + }, + }); + const c = req.containerTemplates?.[0]; + expect(c?.imageName).toBe("api"); + expect(c?.imageNamespace).toBe("me"); + expect(c?.imageTag).toBe("v1.2"); + expect(c?.imageRegistryId).toBe("12345"); + }); + + test("regions array fills both allowedRegionIds and requiredRegionIds", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + regions: ["sfo", "lhr"], + containers: { api: { image: "nginx" } }, + }, + }); + expect(req.regionSettings.allowedRegionIds).toEqual(["sfo", "lhr"]); + expect(req.regionSettings.requiredRegionIds).toEqual(["sfo", "lhr"]); + }); + + test("regions object form is passed through", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + regions: { allowed: ["sfo", "lhr"], required: ["sfo"] }, + containers: { api: { image: "nginx" } }, + }, + }); + expect(req.regionSettings.allowedRegionIds).toEqual(["sfo", "lhr"]); + expect(req.regionSettings.requiredRegionIds).toEqual(["sfo"]); + }); + + test("missing regions yields empty arrays", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { name: "x", containers: { api: { image: "nginx" } } }, + }); + expect(req.regionSettings.allowedRegionIds).toEqual([]); + expect(req.regionSettings.requiredRegionIds).toEqual([]); + }); + + test("defaults autoScaling to { min: 1, max: 1 } when missing", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { name: "x", containers: { api: { image: "nginx" } } }, + }); + expect(req.autoScaling).toEqual({ min: 1, max: 1 }); + }); + + test("passes scaling through when set", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + scaling: { min: 2, max: 5 }, + containers: { api: { image: "nginx" } }, + }, + }); + expect(req.autoScaling).toEqual({ min: 2, max: 5 }); + }); + + test("env record becomes environmentVariables array", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { image: "nginx", env: { PORT: "3000", DEBUG: "true" } }, + }, + }, + }); + const c = req.containerTemplates?.[0]; + expect(c?.environmentVariables).toEqual([ + { name: "PORT", value: "3000" }, + { name: "DEBUG", value: "true" }, + ]); + }); + + test("cdn endpoint defaults ssl to true and maps port pairs", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { + image: "nginx", + endpoints: [ + { type: "cdn", ports: [{ public: 443, container: 3000 }] }, + ], + }, + }, + }, + }); + const ep = req.containerTemplates?.[0]?.endpoints?.[0]; + expect(ep?.displayName).toBe("cdn"); + expect(ep?.cdn?.isSslEnabled).toBe(true); + expect(ep?.cdn?.portMappings).toEqual([ + { containerPort: 3000, exposedPort: 443 }, + ]); + }); + + test("anycast endpoint stamps type 'iPv4'", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { + image: "nginx", + endpoints: [ + { type: "anycast", ports: [{ public: 80, container: 80 }] }, + ], + }, + }, + }, + }); + const ep = req.containerTemplates?.[0]?.endpoints?.[0]; + expect(ep?.anycast?.type).toBe("iPv4"); + expect(ep?.anycast?.portMappings).toEqual([ + { containerPort: 80, exposedPort: 80 }, + ]); + }); + + test("deduplicates volumes across containers", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { + image: "nginx", + volumes: [{ name: "data", mount: "/data", size: 10 }], + }, + worker: { + image: "nginx", + volumes: [{ name: "data", mount: "/data", size: 10 }], + }, + }, + }, + }); + expect(req.volumes).toEqual([{ name: "data", size: 10 }]); + // Volume *mounts* still appear on every container that references the volume. + expect(req.containerTemplates?.[0]?.volumeMounts).toEqual([ + { name: "data", mountPath: "/data" }, + ]); + expect(req.containerTemplates?.[1]?.volumeMounts).toEqual([ + { name: "data", mountPath: "/data" }, + ]); + }); + + test("missing image becomes empty string with empty parsed fields", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + containers: { api: {} }, + }, + }); + const c = req.containerTemplates?.[0]; + expect(c?.image).toBe(""); + expect(c?.imageName).toBe(""); + expect(c?.imageNamespace).toBe(""); + expect(c?.imageRegistryId).toBe(""); + }); + + test("command becomes entryPoint.commandArray", () => { + const req = configToAddRequest({ + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { image: "nginx", command: ["bash", "-c", "exit 0"] }, + }, + }, + }); + expect(req.containerTemplates?.[0]?.entryPoint).toEqual({ + commandArray: ["bash", "-c", "exit 0"], + }); + }); +}); + +// ─── configToPatchRequest ──────────────────────────────────────────── + +describe("configToPatchRequest", () => { + test("first local container inherits id from first remote template even if names differ", () => { + const existing = app({ + containerTemplates: [template({ id: "ct_remote_1", name: "api-old" })], + }); + const req = configToPatchRequest( + { + version: CURRENT_VERSION, + app: { + name: "x", + containers: { "api-new": { image: "nginx" } }, + }, + }, + existing, + ); + expect(req.containerTemplates?.[0]?.id).toBe("ct_remote_1"); + expect(req.containerTemplates?.[0]?.name).toBe("api-new"); + }); + + test("non-first containers match remote templates by name", () => { + const existing = app({ + containerTemplates: [ + template({ id: "ct_remote_1", name: "api" }), + template({ id: "ct_remote_2", name: "db" }), + ], + }); + const req = configToPatchRequest( + { + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { image: "ghcr.io/me/api:v2" }, + db: { image: "postgres:17" }, + }, + }, + }, + existing, + ); + expect(req.containerTemplates?.[0]?.id).toBe("ct_remote_1"); + expect(req.containerTemplates?.[1]?.id).toBe("ct_remote_2"); + }); + + test("non-first container with no remote match gets no id", () => { + const existing = app({ + containerTemplates: [template({ id: "ct_remote_1", name: "api" })], + }); + const req = configToPatchRequest( + { + version: CURRENT_VERSION, + app: { + name: "x", + containers: { + api: { image: "nginx" }, + sidecar: { image: "busybox" }, + }, + }, + }, + existing, + ); + expect(req.containerTemplates?.[1]?.id).toBeUndefined(); + }); +}); diff --git a/packages/app-config/src/convert.ts b/packages/app-config/src/convert.ts index 40933a7..4745312 100644 --- a/packages/app-config/src/convert.ts +++ b/packages/app-config/src/convert.ts @@ -16,8 +16,6 @@ type PatchApplicationRequest = components["schemas"]["PatchApplicationRequest"]; type EndpointRequest = components["schemas"]["EndpointRequest"]; type VolumeRequest = components["schemas"]["VolumeRequest"]; -// ─── API → Config conversion ──────────────────────────────────────── - function containerTemplateToConfig(ct: ContainerTemplate): ContainerConfig { const config: ContainerConfig = {}; @@ -106,8 +104,6 @@ export function apiToConfig(app: Application): BunnyAppConfig { return config; } -// ─── Config → API conversion ──────────────────────────────────────── - function containerConfigToRequest( name: string, config: ContainerConfig, diff --git a/packages/app-config/src/parse-image-ref.test.ts b/packages/app-config/src/parse-image-ref.test.ts new file mode 100644 index 0000000..a62721a --- /dev/null +++ b/packages/app-config/src/parse-image-ref.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { parseImageRef } from "./parse-image-ref.ts"; + +describe("parseImageRef", () => { + test("bare image name defaults tag to 'latest'", () => { + expect(parseImageRef("nginx")).toEqual({ + imageName: "nginx", + imageNamespace: "", + imageTag: "latest", + }); + }); + + test("namespaced image without tag defaults to 'latest'", () => { + expect(parseImageRef("library/redis")).toEqual({ + imageName: "redis", + imageNamespace: "library", + imageTag: "latest", + }); + }); + + test("bare image with simple tag", () => { + expect(parseImageRef("nginx:1.27")).toEqual({ + imageName: "nginx", + imageNamespace: "", + imageTag: "1.27", + }); + }); + + test("tag containing dots is preserved", () => { + expect(parseImageRef("nginx:1.27.0-alpine")).toEqual({ + imageName: "nginx", + imageNamespace: "", + imageTag: "1.27.0-alpine", + }); + }); + + test("two-segment namespace and tag", () => { + expect(parseImageRef("me/api:v1")).toEqual({ + imageName: "api", + imageNamespace: "me", + imageTag: "v1", + }); + }); + + test("hostname + single-segment namespace + tag", () => { + expect(parseImageRef("ghcr.io/me/api:v1.2")).toEqual({ + imageName: "api", + imageNamespace: "me", + imageTag: "v1.2", + }); + }); + + test("hostname + multi-segment namespace joins with /", () => { + expect(parseImageRef("ghcr.io/org/team/api:v1")).toEqual({ + imageName: "api", + imageNamespace: "org/team", + imageTag: "v1", + }); + }); + + test("hostname-only path treats hostname as namespace", () => { + // Two-segment refs can't distinguish "hostname/image" from "user/image"; + // the parser keeps the first segment as the namespace either way. + expect(parseImageRef("ghcr.io/api")).toEqual({ + imageName: "api", + imageNamespace: "ghcr.io", + imageTag: "latest", + }); + }); + + test("hostname with port and tag — colon in port does not confuse tag detection", () => { + expect(parseImageRef("ghcr.io:5000/me/api:v1")).toEqual({ + imageName: "api", + imageNamespace: "me", + imageTag: "v1", + }); + }); + + test("hostname with port and no tag", () => { + // The trailing ":5000/api" segment contains a slash, so the parser + // correctly treats the colon as a port separator (not a tag separator) + // and the tag falls back to "latest". + expect(parseImageRef("registry.local:5000/api")).toEqual({ + imageName: "api", + imageNamespace: "registry.local:5000", + imageTag: "latest", + }); + }); +}); diff --git a/packages/app-config/src/schema.ts b/packages/app-config/src/schema.ts index a37b839..32a7a96 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/app-config/src/schema.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -// ─── Schema version ────────────────────────────────────────────────── -// // The config file carries a `version` field so we can evolve the shape // without breaking existing files. Versions are ISO date strings // (lexicographically sortable) — when something changes, bump this and @@ -16,8 +14,6 @@ const VersionSchema = z "version must be an ISO date string (YYYY-MM-DD)", ); -// ─── Sub-schemas ───────────────────────────────────────────────────── - export const ProbeConfigSchema = z.object({ type: z.enum(["http", "tcp", "grpc"]), path: z.string().optional(), @@ -76,8 +72,6 @@ export const RegionsConfigSchema = z.union([ }), ]); -// ─── Root schema ───────────────────────────────────────────────────── - export const BunnyAppConfigSchema = z.object({ $schema: z.string().optional(), version: VersionSchema, @@ -90,8 +84,6 @@ export const BunnyAppConfigSchema = z.object({ }), }); -// ─── Inferred types ────────────────────────────────────────────────── - export type BunnyAppConfig = z.infer; export type ContainerConfig = z.infer; export type EndpointConfig = z.infer; @@ -99,8 +91,6 @@ export type VolumeConfig = z.infer; export type ProbeConfig = z.infer; export type RegionsConfig = z.infer; -// ─── Helpers ───────────────────────────────────────────────────────── - /** * Normalize either regions shape into `{ allowed, required }` for the API. * diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index d280de8..4391811 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -29,8 +29,6 @@ export { parseImageRef, } from "@bunny.net/app-config"; -// ─── File I/O ──────────────────────────────────────────────────────── - function findConfigRoot(): string { let dir = resolve(process.cwd()); @@ -88,8 +86,6 @@ export function configExists(): boolean { return existsSync(join(root, CONFIG_FILENAME)); } -// ─── Resolution helpers ───────────────────────────────────────────── - /** * Resolve an app ID from an explicit value or from bunny.jsonc. * Throws if neither source provides an ID. diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 874eeca..f77caea 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -113,7 +113,6 @@ export const appsDeployCommand = defineCommand({ const cfg = resolveConfig(profile, apiKey); const client = createMcClient(clientOptions(cfg, verbose)); - // ─── First-run walkthrough ─────────────────────────────────── let toml: BunnyAppConfig; if (!configExists()) { toml = await firstRunWalkthrough(client, { @@ -131,7 +130,6 @@ export const appsDeployCommand = defineCommand({ hasImageOrBuild: Boolean(positionalImage || dockerfileFlag), }); - // ─── Decide build vs deploy mode ───────────────────────────── const mode = resolveMode({ positionalImage, dockerfileFlag, @@ -143,7 +141,6 @@ export const appsDeployCommand = defineCommand({ args.registry ?? targetContainer.registry; let freshCreds: ResolvedRegistry["freshCredentials"]; - // ─── Build path ────────────────────────────────────────────── if (mode.kind === "build") { await ensureDockerAvailable(); @@ -224,7 +221,6 @@ export const appsDeployCommand = defineCommand({ saveConfig(toml); } - // ─── Deploy path (pre-built image) ─────────────────────────── if (mode.kind === "image") { const resolved = await resolveRegistryForImage(client, mode.image); if (!resolved) { @@ -240,7 +236,6 @@ export const appsDeployCommand = defineCommand({ saveConfig(toml); } - // ─── App create-or-update ──────────────────────────────────── let appId = toml.app.id; if (!appId) { const createSpin = spinner("Creating app..."); @@ -280,7 +275,6 @@ export const appsDeployCommand = defineCommand({ pushSpin.stop(); } - // ─── Update container image (if changed) ───────────────────── if (deployImage) { const fetchSpin = spinner("Fetching app..."); fetchSpin.start(); @@ -324,7 +318,6 @@ export const appsDeployCommand = defineCommand({ logger.success(`Image updated to ${deployImage}.`); } - // ─── Trigger deploy ────────────────────────────────────────── const deploySpin = spinner("Deploying..."); deploySpin.start(); await client.POST("/apps/{appId}/deploy", { @@ -343,8 +336,6 @@ export const appsDeployCommand = defineCommand({ }, }); -// ─── Helpers ───────────────────────────────────────────────────────── - /** * yargs returns `--dockerfile` (bare) as an empty string and `--dockerfile foo` * as the path. Normalize to a path-or-undefined. @@ -451,8 +442,6 @@ function resolveBuildContext( return dirname(absDockerfile); } -// ─── First-run walkthrough ─────────────────────────────────────────── - interface WalkthroughInput { positionalImage?: string; dockerfileFlag?: string; @@ -504,7 +493,6 @@ async function firstRunWalkthrough( const mode: "build" | "image" = dockerfilePath ? "build" : "image"; - // ─── Resolve the registry first — we need it for the rest of the walkthrough. let registry: ResolvedRegistry | null; if (input.registryFlag) { registry = { id: input.registryFlag }; @@ -516,7 +504,6 @@ async function firstRunWalkthrough( } if (!registry) throw new UserError("A registry is required."); - // ─── Ask for suggestions (only meaningful when we already know the image). let suggestions: Awaited> | null = null; if (mode === "image" && imageRef) { @@ -529,7 +516,6 @@ async function firstRunWalkthrough( } } - // ─── App name. const defaultName = suggestions?.appName?.trim() || basename(resolve(process.cwd())); const { value: name } = await prompts({ @@ -540,7 +526,6 @@ async function firstRunWalkthrough( }); if (!name) throw new UserError("App name is required."); - // ─── Regions. const regionsSpin = spinner("Fetching regions..."); regionsSpin.start(); const { data: regionsResult } = await client.GET("/regions"); @@ -571,7 +556,6 @@ async function firstRunWalkthrough( throw new UserError("At least one region must be selected."); } - // ─── Build the container config. const container: ContainerConfig = { registry: registry.id, }; diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index 83828ce..7ee6cde 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -132,8 +132,6 @@ export async function dockerLogin( } } -// ─── Image ref helpers ─────────────────────────────────────────────── - /** * Extract the registry hostname from a Docker image reference. * @@ -154,8 +152,6 @@ export function imageHostname(ref: string): string | null { return null; } -// ─── Registry resolution ───────────────────────────────────────────── - const ADD_NEW_REGISTRY = "__add_new__"; /** @@ -349,8 +345,6 @@ export async function resolveRegistryForImage( }); } -// ─── Config suggestions ────────────────────────────────────────────── - /** * Ask bunny.net for recommended configuration for a given image * (endpoints, environment variables, app name suggestion). Returns null diff --git a/packages/database-shell/src/format.ts b/packages/database-shell/src/format.ts index 74e1e84..45cb9c0 100644 --- a/packages/database-shell/src/format.ts +++ b/packages/database-shell/src/format.ts @@ -3,8 +3,6 @@ import chalk from "chalk"; import Table from "cli-table3"; import type { PrintMode, ShellLogger } from "./types.ts"; -// ─── Sensitive column masking ──────────────────────────────────────── - export const SENSITIVE_SUBSTRINGS = [ "password", "passwd", @@ -51,8 +49,6 @@ export function maskEmail(value: string): string { return `${local[0]}••••${local[local.length - 1]}${domain}`; } -// ─── Value formatting ──────────────────────────────────────────────── - /** Format a value for styled terminal output (NULL shown dimmed). */ export function formatValue(val: unknown): string { if (val === null) return chalk.dim("NULL"); @@ -65,8 +61,6 @@ export function formatValueRaw(val: unknown): string { return String(val); } -// ─── CSV escape ────────────────────────────────────────────────────── - /** Escape a value for CSV output (handles commas, quotes, newlines). */ export function csvEscape(value: string): string { if (value.includes(",") || value.includes('"') || value.includes("\n")) { @@ -75,8 +69,6 @@ export function csvEscape(value: string): string { return value; } -// ─── Result set printing ───────────────────────────────────────────── - /** Print a result set in the given mode, optionally masking sensitive columns. */ export function printResultSet( result: ResultSet, From 34038a19068010a189ada9d287f56756fce6a1ec Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 11 May 2026 19:52:10 +0100 Subject: [PATCH 04/11] tidt dividers --- packages/app-config/src/convert.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/app-config/src/convert.test.ts b/packages/app-config/src/convert.test.ts index e7fa0b3..baf5a40 100644 --- a/packages/app-config/src/convert.test.ts +++ b/packages/app-config/src/convert.test.ts @@ -10,8 +10,6 @@ import { CURRENT_VERSION } from "./schema.ts"; type Application = components["schemas"]["Application"]; type ContainerTemplate = components["schemas"]["ContainerTemplate"]; -// ─── Test fixtures ─────────────────────────────────────────────────── -// // Building real `Application` objects requires every nested required field; // for unit tests we lean on `as Application` after constructing the subset // the conversion code actually reads. @@ -46,8 +44,6 @@ function app(overrides: Partial): Application { } as Application; } -// ─── apiToConfig ───────────────────────────────────────────────────── - describe("apiToConfig", () => { test("emits CURRENT_VERSION and basic app fields", () => { const result = apiToConfig(app({ id: "app_42", name: "my-api" })); @@ -230,8 +226,6 @@ describe("apiToConfig", () => { }); }); -// ─── configToAddRequest ────────────────────────────────────────────── - describe("configToAddRequest", () => { test("parses image ref into name/namespace/tag", () => { const req = configToAddRequest({ @@ -425,8 +419,6 @@ describe("configToAddRequest", () => { }); }); -// ─── configToPatchRequest ──────────────────────────────────────────── - describe("configToPatchRequest", () => { test("first local container inherits id from first remote template even if names differ", () => { const existing = app({ From 92247d6e24b590ba7f4980d1f810fa9b9f03b05c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 11 May 2026 19:53:38 +0100 Subject: [PATCH 05/11] limit skillscheck --- .github/workflows/skillscheck.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/skillscheck.yml b/.github/workflows/skillscheck.yml index f8ea93d..957b879 100644 --- a/.github/workflows/skillscheck.yml +++ b/.github/workflows/skillscheck.yml @@ -10,8 +10,14 @@ concurrency: on: push: branches: [main] + paths: + - "skills/**" + - ".github/workflows/skillscheck.yml" pull_request: branches: [main] + paths: + - "skills/**" + - ".github/workflows/skillscheck.yml" jobs: skillscheck: From ac7b65f324a3565736a810624f93b4172d06edd4 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 11 May 2026 21:12:50 +0100 Subject: [PATCH 06/11] refactor config for schema --- AGENTS.md | 4 +- packages/app-config/src/index.ts | 2 - packages/app-config/src/migrate.ts | 55 ------ packages/app-config/src/schema.test.ts | 224 +++++++++++++++++++++++ packages/cli/src/commands/apps/APPS.md | 2 +- packages/cli/src/commands/apps/config.ts | 26 ++- 6 files changed, 238 insertions(+), 75 deletions(-) delete mode 100644 packages/app-config/src/migrate.ts create mode 100644 packages/app-config/src/schema.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5d44925..26eb650 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1042,9 +1042,9 @@ The `.bunny/` manifest and `bunny.jsonc` serve different purposes: } ``` -`version` is an ISO date string. When the CLI loads an older config, `migrate()` in `@bunny.net/app-config` transforms it in memory; the next `saveConfig()` writes the upgraded shape. Each shape change adds a new entry to the `MIGRATIONS` table in `packages/app-config/src/migrate.ts`. +`version` is an ISO date string. The CLI requires it on load — if a config is missing `version`, `loadConfig` throws a `UserError` with a hint to regenerate via `bunny apps pull`. There is no migration runner yet; when the first breaking shape change ships, that PR introduces one alongside its transform. -Schemas and types are defined in `@bunny.net/app-config` using Zod. The CLI's `config.ts` handles file I/O (parsing JSONC, running migrations, validating with Zod, writing with `$schema` + `version` injection) and resolution helpers (`resolveAppId`, `resolveContainerId`). +Schemas and types are defined in `@bunny.net/app-config` using Zod. The CLI's `config.ts` handles file I/O (parsing JSONC, validating with Zod, writing with `$schema` + `version` injection) and resolution helpers (`resolveAppId`, `resolveContainerId`). --- diff --git a/packages/app-config/src/index.ts b/packages/app-config/src/index.ts index 444e058..ed34814 100644 --- a/packages/app-config/src/index.ts +++ b/packages/app-config/src/index.ts @@ -6,8 +6,6 @@ export { configToAddRequest, configToPatchRequest, } from "./convert.ts"; -// Migrations -export { migrate } from "./migrate.ts"; // Utilities export { parseImageRef } from "./parse-image-ref.ts"; // Types diff --git a/packages/app-config/src/migrate.ts b/packages/app-config/src/migrate.ts deleted file mode 100644 index ea5bfa8..0000000 --- a/packages/app-config/src/migrate.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { CURRENT_VERSION } from "./schema.ts"; - -/** - * Migration functions transform a raw config object (post-JSONC parse, - * pre-Zod validation) from one version to the next. - * - * Add a new entry whenever the shape changes. Each migration receives - * the previous shape and returns the next. The runner applies them - * in order until the config reaches `CURRENT_VERSION`. - */ -type Migration = { - from: string; - to: string; - apply: (raw: any) => any; -}; - -const MIGRATIONS: Migration[] = [ - { - // Pre-versioned configs (no `version` field) → tag as the first version. - // The pre-versioned shape had no breaking differences from the first - // versioned one, so this is a no-op tag. - from: "0", - to: "2026-05-11", - apply: (raw) => ({ version: "2026-05-11", ...raw }), - }, -]; - -/** - * Migrate a raw parsed config to the current version. Returns a tuple of - * `[migrated, migratedFrom]` so callers can log when a migration happened. - */ -export function migrate(raw: any): { - config: any; - migratedFrom: string | null; -} { - if (typeof raw !== "object" || raw === null) { - return { config: raw, migratedFrom: null }; - } - - const originalVersion: string = raw.version ?? "0"; - let cfg = raw; - let current = originalVersion; - - while (current !== CURRENT_VERSION) { - const next = MIGRATIONS.find((m) => m.from === current); - if (!next) break; // No migration path — let Zod surface the validation error. - cfg = next.apply(cfg); - current = next.to; - } - - return { - config: cfg, - migratedFrom: originalVersion === CURRENT_VERSION ? null : originalVersion, - }; -} diff --git a/packages/app-config/src/schema.test.ts b/packages/app-config/src/schema.test.ts new file mode 100644 index 0000000..971bcfc --- /dev/null +++ b/packages/app-config/src/schema.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "bun:test"; +import { + BunnyAppConfigSchema, + ContainerConfigSchema, + CURRENT_VERSION, + normalizeRegions, +} from "./schema.ts"; + +function valid>(overrides: T = {} as T) { + return { + version: CURRENT_VERSION, + app: { + name: "my-app", + containers: { api: { image: "nginx" } }, + }, + ...overrides, + }; +} + +describe("BunnyAppConfigSchema", () => { + test("minimal valid config parses", () => { + const result = BunnyAppConfigSchema.safeParse(valid()); + expect(result.success).toBe(true); + }); + + test("missing version is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + app: { name: "x", containers: { api: {} } }, + }); + expect(result.success).toBe(false); + }); + + test("non-padded version is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + version: "2026-1-1", + }); + expect(result.success).toBe(false); + }); + + test("non-date-string version is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + version: "v1", + }); + expect(result.success).toBe(false); + }); + + test("numeric version is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + version: 20260511, + }); + expect(result.success).toBe(false); + }); + + test("missing app.name is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + version: CURRENT_VERSION, + app: { containers: { api: {} } }, + }); + expect(result.success).toBe(false); + }); + + test("missing app.containers is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + version: CURRENT_VERSION, + app: { name: "x" }, + }); + expect(result.success).toBe(false); + }); + + test("$schema is preserved when present", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + $schema: "https://example.com/schema.json", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.$schema).toBe("https://example.com/schema.json"); + } + }); + + test("app.id is optional", () => { + const withId = BunnyAppConfigSchema.safeParse({ + version: CURRENT_VERSION, + app: { id: "app_42", name: "x", containers: { api: {} } }, + }); + const withoutId = BunnyAppConfigSchema.safeParse(valid()); + expect(withId.success).toBe(true); + expect(withoutId.success).toBe(true); + }); +}); + +describe("regions field", () => { + test("array form parses", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + app: { ...valid().app, regions: ["sfo", "lhr"] }, + }); + expect(result.success).toBe(true); + }); + + test("object form parses", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + app: { + ...valid().app, + regions: { allowed: ["sfo", "lhr"], required: ["sfo"] }, + }, + }); + expect(result.success).toBe(true); + }); + + test("empty object form parses (both subfields optional)", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + app: { ...valid().app, regions: {} }, + }); + expect(result.success).toBe(true); + }); + + test("string instead of array/object is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + app: { ...valid().app, regions: "sfo" }, + }); + expect(result.success).toBe(false); + }); + + test("array of non-strings is rejected", () => { + const result = BunnyAppConfigSchema.safeParse({ + ...valid(), + app: { ...valid().app, regions: [1, 2] }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("ContainerConfigSchema", () => { + test("empty container parses", () => { + expect(ContainerConfigSchema.safeParse({}).success).toBe(true); + }); + + test("container with every optional field parses", () => { + const result = ContainerConfigSchema.safeParse({ + image: "ghcr.io/me/api:v1", + dockerfile: "Dockerfile", + context: ".", + command: ["bash", "-c", "/start"], + registry: "12345", + env: { PORT: "3000" }, + probes: { + readiness: { type: "http", path: "/healthz", port: 3000 }, + liveness: { type: "tcp", port: 3000 }, + }, + endpoints: [ + { + type: "cdn", + ssl: true, + ports: [{ public: 443, container: 3000 }], + }, + ], + volumes: [{ name: "data", mount: "/data", size: 10 }], + }); + expect(result.success).toBe(true); + }); + + test("invalid endpoint type is rejected", () => { + const result = ContainerConfigSchema.safeParse({ + endpoints: [{ type: "tcp" }], + }); + expect(result.success).toBe(false); + }); +}); + +describe("normalizeRegions", () => { + test("undefined yields empty arrays", () => { + expect(normalizeRegions(undefined)).toEqual({ + allowed: [], + required: [], + }); + }); + + test("array form fills both subfields with the same list", () => { + expect(normalizeRegions(["sfo", "lhr"])).toEqual({ + allowed: ["sfo", "lhr"], + required: ["sfo", "lhr"], + }); + }); + + test("object form with only allowed defaults required to []", () => { + expect(normalizeRegions({ allowed: ["sfo"] })).toEqual({ + allowed: ["sfo"], + required: [], + }); + }); + + test("object form with only required defaults allowed to []", () => { + expect(normalizeRegions({ required: ["sfo"] })).toEqual({ + allowed: [], + required: ["sfo"], + }); + }); + + test("empty object yields empty arrays", () => { + expect(normalizeRegions({})).toEqual({ allowed: [], required: [] }); + }); + + test("array result doesn't share references with input (defensive copy)", () => { + const input = ["sfo", "lhr"]; + const result = normalizeRegions(input); + result.allowed.push("nyc"); + expect(input).toEqual(["sfo", "lhr"]); + expect(result.allowed).not.toBe(result.required); + }); +}); + +describe("CURRENT_VERSION", () => { + test("matches the schema's version regex (no typos in the constant)", () => { + const result = BunnyAppConfigSchema.safeParse(valid()); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 69ebb19..7e7dc38 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -174,7 +174,7 @@ bunny apps regions show ## `bunny.jsonc` schema -The config carries a `version` field so we can evolve the shape without breaking existing files. Versions are ISO date strings — when the CLI loads an older config it migrates it in memory and writes the upgraded form on the next save. +The config carries a `version` field so we can detect and reject older shapes when they no longer match what the CLI expects. Versions are ISO date strings; the CLI throws a clear error if `version` is missing and asks you to regenerate the file via `bunny apps pull`. A single-container app: diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index 4391811..c02b633 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -3,12 +3,10 @@ import { dirname, join, resolve } from "node:path"; import { type BunnyAppConfig, BunnyAppConfigSchema, - migrate, } from "@bunny.net/app-config"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import { parse as parseJsonc } from "jsonc-parser"; import { UserError } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; type Application = components["schemas"]["Application"]; @@ -45,23 +43,21 @@ export function loadConfig(): BunnyAppConfig { const root = findConfigRoot(); const jsoncPath = join(root, CONFIG_FILENAME); - if (existsSync(jsoncPath)) { - const raw = readFileSync(jsoncPath, "utf-8"); - return parseAndMigrate(parseJsonc(raw)); + if (!existsSync(jsoncPath)) { + throw new UserError( + "No bunny.jsonc found.", + "Run `bunny apps init` first.", + ); } - throw new UserError("No bunny.jsonc found.", "Run `bunny apps init` first."); -} - -/** Apply schema migrations then validate. Logs when a migration ran. */ -function parseAndMigrate(raw: unknown): BunnyAppConfig { - const { config, migratedFrom } = migrate(raw); - if (migratedFrom) { - logger.dim( - `Migrated bunny.jsonc from version "${migratedFrom}". Run \`bunny apps push\` to save the new format.`, + const raw = parseJsonc(readFileSync(jsoncPath, "utf-8")); + if (raw && typeof raw === "object" && !("version" in raw)) { + throw new UserError( + "bunny.jsonc is missing the `version` field.", + "Run `bunny apps pull` to regenerate it from the remote app.", ); } - return BunnyAppConfigSchema.parse(config); + return BunnyAppConfigSchema.parse(raw); } /** Write bunny.jsonc to the given directory (or cwd). */ From ff265e9f6feb01df0bab4d0c5d6a786f11c49dc0 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 12 May 2026 08:11:48 +0100 Subject: [PATCH 07/11] Create bunfig.toml --- bunfig.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 bunfig.toml diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..cd80577 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +[install] +# Only install package versions published at least 3 days ago +minimumReleaseAge = 259200 From 2bc4ba7691f35164b8c649f627707facf5183af8 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 20 May 2026 16:07:34 +0100 Subject: [PATCH 08/11] progress --- AGENTS.md | 41 +- README.md | 3 +- bun.lock | 31 +- packages/app-config/src/schema.ts | 4 +- packages/cli/package.json | 1 + packages/cli/src/commands/apps/APPS.md | 151 +++++- .../cli/src/commands/apps/compose/find.ts | 22 + .../cli/src/commands/apps/compose/index.ts | 48 ++ .../src/commands/apps/compose/load.test.ts | 69 +++ .../src/commands/apps/compose/ports.test.ts | 35 ++ .../cli/src/commands/apps/compose/ports.ts | 71 +++ .../cli/src/commands/apps/compose/schema.ts | 84 ++++ .../commands/apps/compose/translate.test.ts | 284 +++++++++++ .../src/commands/apps/compose/translate.ts | 348 ++++++++++++++ packages/cli/src/commands/apps/config.test.ts | 70 +++ packages/cli/src/commands/apps/config.ts | 43 +- packages/cli/src/commands/apps/deploy.ts | 365 ++++----------- packages/cli/src/commands/apps/docker.test.ts | 59 +++ packages/cli/src/commands/apps/docker.ts | 160 ++++++- packages/cli/src/commands/apps/env/index.ts | 2 + .../cli/src/commands/apps/env/parse.test.ts | 49 ++ packages/cli/src/commands/apps/env/parse.ts | 50 ++ packages/cli/src/commands/apps/env/push.ts | 167 +++++++ packages/cli/src/commands/apps/init.ts | 203 ++++---- packages/cli/src/commands/apps/walkthrough.ts | 441 ++++++++++++++++++ packages/cli/src/commands/whoami.ts | 2 +- packages/cli/src/test-utils/temp-dir.ts | 16 + packages/database-adapter-libsql/README.md | 2 +- packages/database-rest/README.md | 2 +- packages/database-shell/README.md | 2 +- packages/openapi-client/src/compute-client.ts | 2 +- packages/openapi-client/src/core-client.ts | 2 +- packages/openapi-client/src/db-client.ts | 2 +- packages/openapi-client/src/errors.ts | 2 +- packages/openapi-client/src/mc-client.ts | 2 +- packages/openapi-client/src/middleware.ts | 4 +- skills/bunny-cli/SKILL.md | 6 +- 37 files changed, 2367 insertions(+), 478 deletions(-) create mode 100644 packages/cli/src/commands/apps/compose/find.ts create mode 100644 packages/cli/src/commands/apps/compose/index.ts create mode 100644 packages/cli/src/commands/apps/compose/load.test.ts create mode 100644 packages/cli/src/commands/apps/compose/ports.test.ts create mode 100644 packages/cli/src/commands/apps/compose/ports.ts create mode 100644 packages/cli/src/commands/apps/compose/schema.ts create mode 100644 packages/cli/src/commands/apps/compose/translate.test.ts create mode 100644 packages/cli/src/commands/apps/compose/translate.ts create mode 100644 packages/cli/src/commands/apps/config.test.ts create mode 100644 packages/cli/src/commands/apps/docker.test.ts create mode 100644 packages/cli/src/commands/apps/env/parse.test.ts create mode 100644 packages/cli/src/commands/apps/env/parse.ts create mode 100644 packages/cli/src/commands/apps/env/push.ts create mode 100644 packages/cli/src/commands/apps/walkthrough.ts create mode 100644 packages/cli/src/test-utils/temp-dir.ts diff --git a/AGENTS.md b/AGENTS.md index 26eb650..9eee7a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ -# AGENTS.md — Bunny CLI +# AGENTS.md: bunny.net CLI -This document describes the architecture, conventions, and implementation details for the Bunny CLI. It serves as the canonical reference for AI agents and contributors working on this codebase. +This document describes the architecture, conventions, and implementation details for the bunny.net CLI. It serves as the canonical reference for AI agents and contributors working on this codebase. --- ## Overview -The Bunny CLI (`bunny`) is a command-line interface for interacting with bunny.net services (magic containers, edge scripting, databases). It is written in TypeScript, runs on the Bun runtime, and follows patterns inspired by Cobra (Go). +The bunny.net CLI (`bunny`) is a command-line interface for interacting with bunny.net services (Magic Containers, Edge Scripting, databases). It is written in TypeScript, runs on the Bun runtime, and follows patterns inspired by Cobra (Go). The CLI supports profile-based authentication, browser-based login, and a modular command structure built on `yargs`. @@ -163,22 +163,33 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("apps", false) — hidden, registers all app commands │ │ │ ├── constants.ts # Status label maps │ │ │ ├── config.ts # bunny.jsonc file I/O, re-exports from @bunny.net/app-config (resolveAppId, resolveContainerId) -│ │ │ ├── docker.ts # Docker + registry helpers (build, push, dockerLogin, generateTag, promptRegistry, resolveRegistryForImage, getConfigSuggestions, imageHostname) +│ │ │ ├── docker.ts # Docker + registry helpers (build, push, dockerLogin, ensureRegistryLogin, dockerHasCredentials, ghDockerLogin, generateTag, promptRegistry, resolveRegistryForImage, getConfigSuggestions, imageHostname) │ │ │ ├── init.ts # Scaffold bunny.jsonc (detects Dockerfile, prompts for registry) │ │ │ ├── list.ts # List all apps │ │ │ ├── show.ts # Show app details and overview -│ │ │ ├── deploy.ts # Deploy app — positional , --dockerfile, --container , or manifest; first-run walkthrough +│ │ │ ├── deploy.ts # Deploy app: positional , --dockerfile, --container, --port, --command, --dry-run, --no-push │ │ │ ├── undeploy.ts # Undeploy app │ │ │ ├── restart.ts # Restart app │ │ │ ├── delete.ts # Delete app │ │ │ ├── pull.ts # Sync API → bunny.jsonc │ │ │ ├── push.ts # Sync bunny.jsonc → API +│ │ │ ├── walkthrough.ts # Shared new-app walkthrough used by init and deploy (runWalkthrough, runComposeImport) +│ │ │ ├── compose/ # docker-compose import: parse, translate, validate +│ │ │ │ ├── index.ts # findComposeFile, loadComposeFile, composeToConfig +│ │ │ │ ├── schema.ts # Zod schema for compose subset +│ │ │ │ ├── find.ts # locate compose.yml / docker-compose.yml +│ │ │ │ ├── ports.ts # parsePortMapping (short/long/IP-prefixed forms) +│ │ │ │ ├── translate.ts # service → ContainerConfig, warnings, hard errors +│ │ │ │ └── *.test.ts # ports, translate, load │ │ │ ├── env/ │ │ │ │ ├── index.ts # defineNamespace("env", ...) │ │ │ │ ├── list.ts # List env vars per container │ │ │ │ ├── set.ts # Set env var (read-modify-write) │ │ │ │ ├── remove.ts # Remove env var -│ │ │ │ └── pull.ts # Pull env vars to .env file +│ │ │ │ ├── pull.ts # Pull env vars to .env file +│ │ │ │ ├── push.ts # Bulk import .env file to remote (merge or --replace, --dry-run) +│ │ │ │ ├── parse.ts # Minimal dotenv parser (no shell expansion) +│ │ │ │ └── parse.test.ts # parser unit tests │ │ │ ├── endpoints/ │ │ │ │ ├── index.ts # defineNamespace("endpoints", ...) │ │ │ │ ├── list.ts # List endpoints per container @@ -507,7 +518,7 @@ Creates an `ora` spinner. Automatically silenced in non-TTY environments (`isSil ### API error normalization -The Bunny APIs use two different error response formats. The shared `authMiddleware()` in `packages/openapi-client/src/middleware.ts` normalizes both into `ApiError` via an `onResponse` handler, so command code never deals with raw HTTP errors. +The bunny.net APIs use two different error response formats. The shared `authMiddleware()` in `packages/openapi-client/src/middleware.ts` normalizes both into `ApiError` via an `onResponse` handler, so command code never deals with raw HTTP errors. | API | Error schema | Fields | | ---------------- | ------------------------- | ----------------------------------------------------------------------------------- | @@ -702,12 +713,14 @@ bunny │ ├── create (alias: add) Create a named profile with API key │ └── delete Delete a named profile ├── apps (experimental — hidden from help and landing page) -│ ├── init [--name] [--image] -│ │ Scaffold bunny.jsonc (detects Dockerfile) +│ ├── init [image] [--name] [--dockerfile] [--registry] [--port] [--command] [--config] +│ │ Scaffold bunny.jsonc via shared walkthrough (no deploy); --config writes to a specific path │ ├── list (alias: ls) List all apps │ ├── show [--id] Show app details and overview -│ ├── deploy [image] [--dockerfile] [--context] [--tag] [--registry] [--container] [--no-push] -│ │ Deploy a pre-built image, build from a Dockerfile, or re-deploy from bunny.jsonc +│ ├── deploy [image] [--name] [--dockerfile] [--context] [--tag] [--registry] [--container] [--port] [--command] [--config] [--dry-run] [--no-push] +│ │ Deploy a pre-built image, build from a Dockerfile, or re-deploy from bunny.jsonc. +│ │ First-run, in order: imports compose.yml if present, else asks for Dockerfile / pre-built image. +│ │ --config uses that file as the source of truth (CI / agent flows); --dry-run skips writes and API. │ ├── undeploy [--id] [--force] Undeploy an app │ ├── restart [--id] Restart an app │ ├── delete [--id] [--force] Delete an app @@ -717,7 +730,9 @@ bunny │ │ ├── list [--container] List environment variables │ │ ├── set [--container] Set environment variable │ │ ├── remove [--container] Remove environment variable -│ │ └── pull [--container] [--force] Pull env vars to .env +│ │ ├── pull [--container] [--force] Pull env vars to .env +│ │ └── push [file] [--container] [--replace] [--dry-run] +│ │ Bulk import a .env file (merge by default; --replace drops vars not in file) │ ├── endpoints │ │ ├── list [--container] List endpoints │ │ ├── add [--container] [--type] [--ssl] Add endpoint @@ -823,7 +838,7 @@ The CLI provides a `clientOptions()` helper (`packages/cli/src/core/client-optio ### Undocumented endpoints (`CustomPaths`) -Some Bunny API endpoints are not included in the public OpenAPI specs. These are typed manually via a `CustomPaths` type in `packages/openapi-client/src/core-client.ts`, which is intersected with the generated `paths`: +Some bunny.net API endpoints are not included in the public OpenAPI specs. These are typed manually via a `CustomPaths` type in `packages/openapi-client/src/core-client.ts`, which is intersected with the generated `paths`: ```typescript const client = createClient({ baseUrl }); diff --git a/README.md b/README.md index bf21586..3fe05ff 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Monorepo for the [bunny.net](https://bunny.net) CLI and supporting packages. | [`packages/database-shell/`](packages/database-shell/) | `@bunny.net/database-shell` | Standalone interactive SQL shell for libSQL databases | | [`packages/database-openapi/`](packages/database-openapi/) | `@bunny.net/database-openapi` | Generate OpenAPI 3.0 specs from a database schema | | [`packages/database-rest/`](packages/database-rest/) | `@bunny.net/database-rest` | PostgREST-like REST API handler (database-agnostic) | -| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | Bunny Database adapter for database-rest | +| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | bunny.net Database adapter for database-rest | See each package's README for usage and API documentation. @@ -43,6 +43,7 @@ bun ny login bun ny db list bun ny apps deploy ghcr.io/me/api:v1.2 # deploy a pre-built image bun ny apps deploy --dockerfile # build ./Dockerfile and deploy +bun ny apps deploy # first run? Imports docker-compose.yml if present, otherwise walks through Dockerfile or pre-built image setup. ``` ### Available Scripts diff --git a/bun.lock b/bun.lock index 781d155..387a5e8 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "packages/app-config": { "name": "@bunny.net/app-config", - "version": "0.0.0", + "version": "0.0.1", "dependencies": { "zod": "^4.3.6", }, @@ -25,7 +25,7 @@ }, "packages/cli": { "name": "@bunny.net/cli", - "version": "0.4.0", + "version": "0.4.1", "bin": { "bunny": "./bin/bunny.cjs", }, @@ -42,36 +42,37 @@ "jsonc-parser": "^3.3.1", "ora": "^9.3.0", "prompts": "^2.4.2", + "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.3.6", }, "optionalDependencies": { - "@bunny.net/cli-darwin-arm64": "0.4.0", - "@bunny.net/cli-darwin-x64": "0.4.0", - "@bunny.net/cli-linux-arm64": "0.4.0", - "@bunny.net/cli-linux-x64": "0.4.0", - "@bunny.net/cli-windows-x64": "0.4.0", + "@bunny.net/cli-darwin-arm64": "0.4.1", + "@bunny.net/cli-darwin-x64": "0.4.1", + "@bunny.net/cli-linux-arm64": "0.4.1", + "@bunny.net/cli-linux-x64": "0.4.1", + "@bunny.net/cli-windows-x64": "0.4.1", }, }, "packages/cli-darwin-arm64": { "name": "@bunny.net/cli-darwin-arm64", - "version": "0.4.0", + "version": "0.4.1", }, "packages/cli-darwin-x64": { "name": "@bunny.net/cli-darwin-x64", - "version": "0.4.0", + "version": "0.4.1", }, "packages/cli-linux-arm64": { "name": "@bunny.net/cli-linux-arm64", - "version": "0.4.0", + "version": "0.4.1", }, "packages/cli-linux-x64": { "name": "@bunny.net/cli-linux-x64", - "version": "0.4.0", + "version": "0.4.1", }, "packages/cli-windows-x64": { "name": "@bunny.net/cli-windows-x64", - "version": "0.4.0", + "version": "0.4.1", }, "packages/database-adapter-libsql": { "name": "@bunny.net/database-adapter-libsql", @@ -171,7 +172,7 @@ }, "packages/openapi-client": { "name": "@bunny.net/openapi-client", - "version": "0.0.0", + "version": "0.0.1", "dependencies": { "openapi-fetch": "^0.17.0", }, @@ -1009,7 +1010,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], @@ -1045,6 +1046,8 @@ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@seriousme/openapi-schema-validator/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], diff --git a/packages/app-config/src/schema.ts b/packages/app-config/src/schema.ts index 32a7a96..5aa394a 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/app-config/src/schema.ts @@ -57,8 +57,8 @@ export const ContainerConfigSchema = z.object({ * * "regions": ["sfo", "lhr"] * - * or, for the rare case where you need to distinguish "regions Bunny - * is allowed to use" from "regions Bunny must always have running": + * or, for the rare case where you need to distinguish "regions bunny.net + * is allowed to use" from "regions bunny.net must always have running": * * "regions": { "allowed": ["sfo", "lhr", "nyc"], "required": ["sfo"] } * diff --git a/packages/cli/package.json b/packages/cli/package.json index 36bc9a1..1a6b22b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,7 @@ "jsonc-parser": "^3.3.1", "ora": "^9.3.0", "prompts": "^2.4.2", + "yaml": "^2.9.0", "yargs": "^18.0.0", "zod": "^4.3.6" }, diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 7e7dc38..46dacb8 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -33,20 +33,72 @@ Deploy an app. Three modes, chosen by what you pass: If no `bunny.jsonc` exists, the first run launches a walkthrough that: -1. Resolves the registry (prompts "is this image public, or do you need credentials?" for new hostnames). -2. Calls bunny.net's image-suggestions endpoint to pre-fill app name, endpoints, and required environment variables for known public images. -3. Prompts for regions. -4. Writes `bunny.jsonc`, creates the app, and deploys. +1. Looks for a `compose.yml` / `docker-compose.yml` in the working directory and offers to import the services from it. +2. Otherwise offers to build from a `Dockerfile` (if one exists) or to deploy a pre-built image ref. +3. Resolves the registry (prompts "is this image public, or do you need credentials?" for new hostnames). +4. Calls bunny.net's image-suggestions endpoint to pre-fill app name, endpoints, and required environment variables for known public images. +5. Prompts for a single region. Multi-region apps can be configured by editing the resulting `bunny.jsonc`: the schema accepts an array, the walkthrough just keeps the initial setup focused. +6. Writes `bunny.jsonc`, creates the app, and deploys. + +After a successful deploy that replaced a running image, the CLI prints the previous image reference and a one-line rollback command: + +``` +Previous image: ghcr.io/me/api:abc-123 +To rollback: bunny apps deploy ghcr.io/me/api:abc-123 +``` + +### Importing from docker-compose + +When you run `bunny apps deploy` in a project containing a `compose.yml` (or `compose.yaml`, `docker-compose.yml`, `docker-compose.yaml`), the walkthrough offers to translate it into `bunny.jsonc`. Each compose `service` becomes a container; the user still picks an app name, region, and registry. + +What translates cleanly: + +| Compose | bunny.jsonc | +| --------------------------------------- | ------------------------------------------------------------ | +| `services.` | `app.containers.` | +| `image:` / `build:` | `container.image` / `container.dockerfile` + `container.context` | +| `command:` (string or array) | `container.command` (array form) | +| `environment:` (map or array) | `container.env` | +| `env_file:` (one or many) | parsed and merged into `container.env` (env vars in `environment:` override) | +| `ports:` (`"3000:80"` / object / IP-prefixed) | `container.endpoints[0]` (one CDN endpoint, multiple port mappings) | +| `volumes:` (named only) | `container.volumes` | +| `healthcheck:` | `container.probes.liveness` (HTTP probe if `test` mentions a URL; TCP otherwise) | + +What's ignored (warning is printed, translation proceeds): + +- `depends_on:`, `networks:`, `links:`, `external_links:` (MC containers share `localhost` and start together) +- `restart:` (MC restarts automatically) +- `labels:` (use bunny.jsonc endpoints for routing) +- `deploy.replicas:` (MC scales at the app level via `app.scaling`, not per-container) +- `profiles:` (not relevant on MC) + +What's refused (translation fails with a clear error): + +- **Bind mounts** (`./data:/data` or any source starting with `.` / `/`): MC only supports named volumes +- **Anonymous volumes** (`/data` with no source name): ambiguous, give the volume a name in top-level `volumes:` +- `extends:`: flatten the service inline +- `secrets:`: use `bunny apps env push` to store secrets +- `configs:`: bake config into the image or pass via env +- Port ranges (`"3000-3005:3000-3005"`): use individual mappings + +For compose files that have just one service, the import overlaps with the Dockerfile build path, but with the user's chosen ports, env, and command already filled in. For multi-service files, this is the canonical entry point. + +### Deploy flags | Flag | Description | | ---------------- | ----------------------------------------------------------------------------------------------------------------- | | `` | Container image reference to deploy (e.g. `ghcr.io/me/api:v1.2`). Skips build. | +| `--name` | App name. Used during the first-run walkthrough; skips the interactive prompt. | | `--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 registry ID to push to. Overrides the value stored in `bunny.jsonc`. | +| `--registry` | bunny.net registry ID to push to. Overrides the value stored in `bunny.jsonc`. | | `--container` | Name of the container to update. Required when `bunny.jsonc` has multiple containers and you pass ``/`--dockerfile`. | -| `--no-push` | Build only — skip pushing the image and skip the deploy. | +| `--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. | +| `--config` | Use this file as the app config instead of cwd's `bunny.jsonc`. Useful in CI / agent flows. | +| `--dry-run` | Run the walkthrough and print the would-be `bunny.jsonc` without writing anything or contacting the API. | +| `--no-push` | Build only. Skip pushing the image and skip the deploy. | ```bash # Deploy a pre-built image @@ -63,32 +115,81 @@ bunny apps deploy --dockerfile --tag ${GITHUB_SHA} # Build locally for verification but don't push or deploy bunny apps deploy --dockerfile --no-push + +# Preview the would-be config without writing or deploying +bunny apps deploy --dry-run + +# Override the container port and start command +bunny apps deploy --port 8000 --command "uvicorn app:app --host 0.0.0.0 --port 8000" + +# CI / agent flow: use a config file outside the repo +bunny apps deploy --config /tmp/agent-task-abc.jsonc --output json +``` + +### Using `--config ` for CI and agent workflows + +When `--config ` is set, that file is treated as the source of truth instead of walking up from cwd looking for `bunny.jsonc`. Semantics: + +- **Path exists** → load it (no walkthrough). `app.id` and any other changes are persisted back to the same file. +- **Path doesn't exist** → run the walkthrough as usual, but write the result to the given path instead of `./bunny.jsonc`. +- **`--config` not set + `bunny.jsonc` not detected in cwd** → run the walkthrough and write `./bunny.jsonc` (current behaviour). +- **`--config` not set + `bunny.jsonc` detected in cwd** → use it (current behaviour). + +This avoids the "agent has to create a file in cwd" problem for CI environments and ephemeral agent runs. Common pattern: + +```bash +# Generate the config at runtime (e.g. with jq, envsubst, or echo) +echo "$CONFIG_JSON" > /tmp/agent-task.jsonc + +# Deploy reads + persists to that path +bunny apps deploy --config /tmp/agent-task.jsonc --output json + +# ... do work via Tailscale / your container's own endpoints ... + +# Clean up +bunny apps delete --force --id $(jq -r .app.id /tmp/agent-task.jsonc) +rm /tmp/agent-task.jsonc ``` ### Registries during deploy -Every container on Magic Containers is tied to a registry record on bunny.net — even for public images. When you pass `` for the first time, the CLI parses the hostname and tries to match it to an existing registry on your account: +Every container on Magic Containers is tied to a registry record on bunny.net, even for public images. When you pass `` for the first time, the CLI parses the hostname and tries to match it to an existing registry on your account: - **Match found** → uses it and saves the registry ID to `bunny.jsonc`. - **No match** → prompts: "Is this image public, or do you need credentials?" Selecting public creates a credential-less registry record; selecting private prompts for username + token and creates the registry. Credentials entered during this flow are also passed through to `docker login` (for the build path) so the very next `docker push` succeeds without a separate manual login step. +### Docker login pre-flight + +Before pushing, the CLI checks `~/.docker/config.json` for an existing credential entry for the registry hostname. If none is found: + +- **`ghcr.io`**: if the `gh` CLI is installed and authenticated, you'll be offered a one-click login. The CLI runs `gh api user` + `gh auth token` and pipes them through `docker login` for you. +- **Any other host**: you'll be prompted for a username and token, then logged in. + +Either path persists in `~/.docker/config.json`, so subsequent deploys skip this step. + ## `bunny apps init` -Scaffold a new `bunny.jsonc` without deploying. Useful if you want to edit the config before the first deploy. Most users can skip this and run `bunny apps deploy` straight away — it will walk you through setup the first time. +Scaffold a new `bunny.jsonc` without deploying. Runs exactly the same walkthrough as `deploy` (compose import, Dockerfile, or pre-built image). It just stops at "config written" instead of continuing on to build/push/deploy. Use this when you want to inspect or edit the generated config before the first deploy. ```bash bunny apps init -bunny apps init --name my-api --image nginx:latest +bunny apps init ghcr.io/me/api:v1 +bunny apps init --dockerfile --port 8080 ``` -| Flag | Description | -| --------- | ------------------------------------- | -| `--name` | App name (defaults to directory name) | -| `--image` | Primary container image | +| Flag | Description | +| -------------- | -------------------------------------------------------------------------------------------------------- | +| `` | Pre-built image reference. Skips the build-vs-image prompt. | +| `--name` | App name (skips the interactive name prompt). | +| `--dockerfile` | Build from a Dockerfile. Pass a path or use the bare flag for `./Dockerfile`. | +| `--registry` | bunny.net registry ID to push to. | +| `--port` | Override the container port (affects the generated Dockerfile and the CDN endpoint). | +| `--command` | Override the container CMD (passed as a single string, split on whitespace). | +| `--config` | Write the config to this path instead of `./bunny.jsonc`. | -If a `Dockerfile` is detected in the current directory, `init` offers to wire it up for build-and-deploy. +Most users can skip `init` entirely and run `bunny apps deploy` straight away. Both share the same walkthrough. ## `bunny apps list` @@ -130,7 +231,7 @@ Manage environment variables per container. # List vars (primary container) bunny apps env list -# Set a variable on a specific container +# Set a single variable bunny apps env set DATABASE_URL postgres://localhost:5432/mydb --container postgres # Remove a variable @@ -138,11 +239,23 @@ bunny apps env remove OLD_VAR # Pull remote vars to .env bunny apps env pull + +# Push (bulk import) variables from a .env file. Default file is ./.env. +bunny apps env push +bunny apps env push .env.prod + +# Replace all remote variables with the file's contents. +bunny apps env push .env.prod --replace + +# Preview the diff without writing. +bunny apps env push --dry-run ``` -| Flag | Description | -| ------------- | ----------------------------------- | -| `--container` | Target container (default: primary) | +| Flag | Description | +| ------------- | ---------------------------------------------------------------------------------------------------- | +| `--container` | Target container (default: primary) | +| `--replace` | (`push` only) Drop remote variables that aren't in the file. Default is merge (file overrides remote). | +| `--dry-run` | (`push` only) Print the diff (`+ added`, `~ changed`, `- removed`) without writing. | ## `bunny apps endpoints` @@ -234,7 +347,7 @@ Most users want a single list of regions that the app should run in: "regions": ["sfo", "lhr"] ``` -For the rare case where you need to distinguish "regions Bunny is allowed to use" from "regions Bunny must always have running", use the object form: +For the rare case where you need to distinguish "regions bunny.net is allowed to use" from "regions bunny.net must always have running", use the object form: ```jsonc "regions": { diff --git a/packages/cli/src/commands/apps/compose/find.ts b/packages/cli/src/commands/apps/compose/find.ts new file mode 100644 index 0000000..afd248a --- /dev/null +++ b/packages/cli/src/commands/apps/compose/find.ts @@ -0,0 +1,22 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +// In priority order. `compose.yml` is the modern canonical name. +const CANDIDATES = [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", +]; + +/** + * Find a compose file at the given directory. Returns the absolute + * path of the first match, or null if none exist. + */ +export function findComposeFile(dir: string): string | null { + for (const candidate of CANDIDATES) { + const path = join(dir, candidate); + if (existsSync(path)) return path; + } + return null; +} diff --git a/packages/cli/src/commands/apps/compose/index.ts b/packages/cli/src/commands/apps/compose/index.ts new file mode 100644 index 0000000..6c8d563 --- /dev/null +++ b/packages/cli/src/commands/apps/compose/index.ts @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; +import { parse as parseYaml } from "yaml"; +import { UserError } from "../../../core/errors.ts"; +import { ComposeFileSchema } from "./schema.ts"; + +export { findComposeFile } from "./find.ts"; +export type { ComposeFile, ComposeService } from "./schema.ts"; +export type { TranslateResult } from "./translate.ts"; +export { composeToConfig } from "./translate.ts"; + +/** + * Read and validate a compose file. Throws `UserError` with a clear + * message on parse/validation failure. + */ +export function loadComposeFile( + path: string, +): ReturnType { + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch (err) { + throw new UserError( + `Could not read compose file at ${path}.`, + err instanceof Error ? err.message : String(err), + ); + } + + let parsed: unknown; + try { + parsed = parseYaml(raw); + } catch (err) { + throw new UserError( + `Could not parse compose YAML at ${path}.`, + err instanceof Error ? err.message : String(err), + ); + } + + const result = ComposeFileSchema.safeParse(parsed); + if (!result.success) { + throw new UserError( + `Compose file at ${path} is not valid.`, + result.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("\n"), + ); + } + return result.data; +} diff --git a/packages/cli/src/commands/apps/compose/load.test.ts b/packages/cli/src/commands/apps/compose/load.test.ts new file mode 100644 index 0000000..902a397 --- /dev/null +++ b/packages/cli/src/commands/apps/compose/load.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../../test-utils/temp-dir.ts"; +import { findComposeFile, loadComposeFile } from "./index.ts"; + +const tempDir = useTempDir("bunny-compose-load-"); + +describe("findComposeFile", () => { + test("returns null when no compose file exists", () => { + expect(findComposeFile(tempDir())).toBeNull(); + }); + + test("prefers compose.yml over docker-compose.yml", () => { + writeFileSync(join(tempDir(), "compose.yml"), "services: {}"); + writeFileSync(join(tempDir(), "docker-compose.yml"), "services: {}"); + expect(findComposeFile(tempDir())?.endsWith("/compose.yml")).toBe(true); + }); + + test("accepts docker-compose.yaml", () => { + writeFileSync(join(tempDir(), "docker-compose.yaml"), "services: {}"); + expect(findComposeFile(tempDir())?.endsWith(".yaml")).toBe(true); + }); +}); + +describe("loadComposeFile", () => { + test("parses and validates a simple compose file", () => { + const path = join(tempDir(), "compose.yml"); + writeFileSync( + path, + `services: + api: + image: nginx + ports: + - "3000:80" +`, + ); + const result = loadComposeFile(path); + expect(result.services.api?.image).toBe("nginx"); + expect(result.services.api?.ports).toEqual(["3000:80"]); + }); + + test("preserves unknown top-level keys via passthrough", () => { + const path = join(tempDir(), "compose.yml"); + writeFileSync( + path, + `version: "3.9" +services: + api: + image: nginx +networks: + default: {} +`, + ); + expect(() => loadComposeFile(path)).not.toThrow(); + }); + + test("malformed YAML throws", () => { + const path = join(tempDir(), "compose.yml"); + writeFileSync(path, "services:\n api:\n image: [unclosed\n"); + expect(() => loadComposeFile(path)).toThrow(); + }); + + test("missing file throws", () => { + expect(() => loadComposeFile(join(tempDir(), "nope.yml"))).toThrow( + /Could not read/, + ); + }); +}); diff --git a/packages/cli/src/commands/apps/compose/ports.test.ts b/packages/cli/src/commands/apps/compose/ports.test.ts new file mode 100644 index 0000000..fb78ef9 --- /dev/null +++ b/packages/cli/src/commands/apps/compose/ports.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { parsePortMapping } from "./ports.ts"; + +test.each([ + ["3000", { public: 3000, container: 3000 }], + ["3000:80", { public: 3000, container: 80 }], + ["127.0.0.1:3000:80", { public: 3000, container: 80 }], + ["3000:80/tcp", { public: 3000, container: 80 }], +])("parsePortMapping(%j)", (input, expected) => { + expect(parsePortMapping(input)).toEqual(expected); +}); + +test.each([ + [ + { target: 80, published: 3000 }, + { public: 3000, container: 80 }, + ], + [{ target: 80 }, { public: 80, container: 80 }], + [ + { target: 80, published: "3000" }, + { public: 3000, container: 80 }, + ], +])("parsePortMapping(%j) object form", (input, expected) => { + expect(parsePortMapping(input)).toEqual(expected); +}); + +test("rejects port ranges", () => { + expect(() => parsePortMapping("3000-3005:3000-3005")).toThrow( + /not supported/, + ); +}); + +test("rejects garbage strings", () => { + expect(() => parsePortMapping("not:a:port:mapping")).toThrow(); +}); diff --git a/packages/cli/src/commands/apps/compose/ports.ts b/packages/cli/src/commands/apps/compose/ports.ts new file mode 100644 index 0000000..7913fc9 --- /dev/null +++ b/packages/cli/src/commands/apps/compose/ports.ts @@ -0,0 +1,71 @@ +export interface PortMapping { + public: number; + container: number; +} + +/** + * Parse a compose `ports:` entry into `{ public, container }`. + * + * Accepts: + * - `"3000"` → `{ public: 3000, container: 3000 }` + * - `"3000:80"` → `{ public: 3000, container: 80 }` + * - `"127.0.0.1:3000:80"` → `{ public: 3000, container: 80 }` (host IP stripped) + * - object form → `{ public: published ?? target, container: target }` + * + * Strips `/tcp` / `/udp` protocol suffixes (we always map TCP). + * Throws on ranges (`"3000-3005:3000-3005"`); too ambiguous for one endpoint. + */ +export function parsePortMapping( + entry: string | { target: number; published?: number | string }, +): PortMapping { + if (typeof entry === "object") { + const target = entry.target; + const publishedRaw = entry.published ?? target; + const published = + typeof publishedRaw === "string" + ? Number.parseInt(publishedRaw, 10) + : publishedRaw; + if (!Number.isFinite(published) || !Number.isFinite(target)) { + throw new Error(`Invalid port mapping: ${JSON.stringify(entry)}`); + } + return { public: published, container: target }; + } + + // String form. Strip protocol suffix and any host IP prefix. + const noProto = entry.split("/")[0] ?? entry; + if (noProto.includes("-")) { + throw new Error( + `Port range ${entry} is not supported. Use individual port mappings.`, + ); + } + + const parts = noProto.split(":"); + + let publicStr: string; + let containerStr: string; + + if (parts.length === 1) { + // "3000" + publicStr = parts[0] ?? ""; + containerStr = parts[0] ?? ""; + } else if (parts.length === 2) { + // "3000:80" + publicStr = parts[0] ?? ""; + containerStr = parts[1] ?? ""; + } else if (parts.length === 3) { + // "127.0.0.1:3000:80" (drop the IP) + publicStr = parts[1] ?? ""; + containerStr = parts[2] ?? ""; + } else { + throw new Error(`Cannot parse port mapping: ${entry}`); + } + + const publicPort = Number.parseInt(publicStr, 10); + const containerPort = Number.parseInt(containerStr, 10); + + if (!Number.isFinite(publicPort) || !Number.isFinite(containerPort)) { + throw new Error(`Cannot parse port mapping: ${entry}`); + } + + return { public: publicPort, container: containerPort }; +} diff --git a/packages/cli/src/commands/apps/compose/schema.ts b/packages/cli/src/commands/apps/compose/schema.ts new file mode 100644 index 0000000..125561d --- /dev/null +++ b/packages/cli/src/commands/apps/compose/schema.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +/** + * Zod schema for the subset of `docker-compose.yml` we translate. + * + * Uses `.loose()` everywhere so unknown fields don't fail validation. + * We read them downstream to emit standardised warnings + * (e.g. "Ignored `depends_on`") rather than rejecting the whole file. + */ + +const BuildSchema = z.union([ + z.string(), + z + .object({ + context: z.string().optional(), + dockerfile: z.string().optional(), + args: z.record(z.string(), z.string()).optional(), + }) + .loose(), +]); + +const PortSchema = z.union([ + z.string(), + z + .object({ + target: z.number(), + published: z.union([z.number(), z.string()]).optional(), + protocol: z.string().optional(), + }) + .loose(), +]); + +const VolumeMountSchema = z.union([ + z.string(), + z + .object({ + type: z.string(), + source: z.string().optional(), + target: z.string(), + read_only: z.boolean().optional(), + }) + .loose(), +]); + +const EnvSchema = z.union([ + z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]), + ), + z.array(z.string()), +]); + +const HealthcheckSchema = z + .object({ + test: z.union([z.string(), z.array(z.string())]).optional(), + interval: z.string().optional(), + timeout: z.string().optional(), + retries: z.number().optional(), + disable: z.boolean().optional(), + }) + .loose(); + +export const ComposeServiceSchema = z + .object({ + image: z.string().optional(), + build: BuildSchema.optional(), + command: z.union([z.string(), z.array(z.string())]).optional(), + environment: EnvSchema.optional(), + env_file: z.union([z.string(), z.array(z.string())]).optional(), + ports: z.array(PortSchema).optional(), + volumes: z.array(VolumeMountSchema).optional(), + healthcheck: HealthcheckSchema.optional(), + }) + .loose(); + +export const ComposeFileSchema = z + .object({ + services: z.record(z.string(), ComposeServiceSchema), + volumes: z.record(z.string(), z.unknown()).optional(), + }) + .loose(); + +export type ComposeFile = z.infer; +export type ComposeService = z.infer; diff --git a/packages/cli/src/commands/apps/compose/translate.test.ts b/packages/cli/src/commands/apps/compose/translate.test.ts new file mode 100644 index 0000000..74fd3e5 --- /dev/null +++ b/packages/cli/src/commands/apps/compose/translate.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../../test-utils/temp-dir.ts"; +import type { ComposeFile } from "./schema.ts"; +import { composeToConfig } from "./translate.ts"; + +const tempDir = useTempDir("bunny-compose-"); + +function callTranslate(file: ComposeFile, overrideOpts?: object) { + return composeToConfig(file, { + composeFilePath: join(tempDir(), "compose.yml"), + appName: "myapp", + regions: ["sfo"], + defaultRegistryId: "12345", + ...overrideOpts, + }); +} + +describe("composeToConfig", () => { + test("single image-only service", () => { + const result = callTranslate({ + services: { api: { image: "nginx:1.27" } }, + }); + expect(result.config.app.containers).toEqual({ + api: { image: "nginx:1.27", registry: "12345" }, + }); + expect(result.warnings).toEqual([]); + }); + + test("service with string build form", () => { + const result = callTranslate({ + services: { api: { build: "." } }, + }); + expect(result.config.app.containers.api).toMatchObject({ + context: ".", + dockerfile: "Dockerfile", + registry: "12345", + }); + }); + + test("service with object build form", () => { + const result = callTranslate({ + services: { + api: { + build: { context: "./api", dockerfile: "Dockerfile.prod" }, + }, + }, + }); + expect(result.config.app.containers.api).toMatchObject({ + context: "./api", + dockerfile: "Dockerfile.prod", + }); + }); + + test("service with neither image nor build throws", () => { + expect(() => + callTranslate({ + services: { api: {} }, + }), + ).toThrow(/has neither `image` nor `build`/); + }); + + test("environment as map → container.env", () => { + const result = callTranslate({ + services: { + api: { + image: "x", + environment: { PORT: "3000", DEBUG: true, FOO: 42 }, + }, + }, + }); + expect(result.config.app.containers.api?.env).toEqual({ + PORT: "3000", + DEBUG: "true", + FOO: "42", + }); + }); + + test("environment as array → container.env", () => { + const result = callTranslate({ + services: { + api: { image: "x", environment: ["KEY=value", "OTHER=2"] }, + }, + }); + expect(result.config.app.containers.api?.env).toEqual({ + KEY: "value", + OTHER: "2", + }); + }); + + test("env_file merges with environment (environment overrides)", () => { + writeFileSync(join(tempDir(), "shared.env"), "PORT=3000\nDB_URL=local\n"); + const result = callTranslate({ + services: { + api: { + image: "x", + env_file: "shared.env", + environment: { DB_URL: "prod" }, + }, + }, + }); + expect(result.config.app.containers.api?.env).toEqual({ + PORT: "3000", + DB_URL: "prod", + }); + }); + + test("env_file missing file throws", () => { + expect(() => + callTranslate({ + services: { api: { image: "x", env_file: "missing.env" } }, + }), + ).toThrow(/env_file.*not found/); + }); + + test("ports → one CDN endpoint with multiple mappings", () => { + const result = callTranslate({ + services: { + api: { image: "x", ports: ["3000:80", "9229:9229"] }, + }, + }); + const endpoints = result.config.app.containers.api?.endpoints; + expect(endpoints).toHaveLength(1); + expect(endpoints?.[0]).toMatchObject({ + type: "cdn", + ssl: true, + ports: [ + { public: 3000, container: 80 }, + { public: 9229, container: 9229 }, + ], + }); + }); + + test("command (string) splits on whitespace", () => { + const result = callTranslate({ + services: { api: { image: "x", command: "node ./server.js --port 80" } }, + }); + expect(result.config.app.containers.api?.command).toEqual([ + "node", + "./server.js", + "--port", + "80", + ]); + }); + + test("command (array) passes through", () => { + const result = callTranslate({ + services: { api: { image: "x", command: ["node", "server.js"] } }, + }); + expect(result.config.app.containers.api?.command).toEqual([ + "node", + "server.js", + ]); + }); + + test("named volume → container.volumes with default size", () => { + const result = callTranslate({ + services: { + api: { image: "x", volumes: ["data:/var/lib/data"] }, + }, + volumes: { data: {} }, + }); + expect(result.config.app.containers.api?.volumes).toEqual([ + { name: "data", mount: "/var/lib/data", size: 1024 }, + ]); + }); + + test("bind mount (string form) throws", () => { + expect(() => + callTranslate({ + services: { api: { image: "x", volumes: ["./src:/app/src"] } }, + }), + ).toThrow(/bind mount/); + }); + + test("bind mount (object form) throws", () => { + expect(() => + callTranslate({ + services: { + api: { + image: "x", + volumes: [{ type: "bind", source: "/host", target: "/c" }], + }, + }, + }), + ).toThrow(/bind mount/); + }); + + test("anonymous volume (single segment) throws", () => { + expect(() => + callTranslate({ + services: { api: { image: "x", volumes: ["/data"] } }, + }), + ).toThrow(/anonymous volume/); + }); + + test("healthcheck with HTTP URL → http probe with path", () => { + const result = callTranslate({ + services: { + api: { + image: "x", + healthcheck: { + test: ["CMD", "curl", "-f", "http://localhost:3000/health"], + }, + }, + }, + }); + expect(result.config.app.containers.api?.probes?.liveness).toEqual({ + type: "http", + path: "/health", + port: 3000, + }); + }); + + test("healthcheck without URL falls back to tcp probe", () => { + const result = callTranslate({ + services: { + api: { + image: "x", + healthcheck: { test: ["CMD", "pg_isready", "-U", "postgres"] }, + }, + }, + }); + expect(result.config.app.containers.api?.probes?.liveness).toEqual({ + type: "tcp", + }); + }); + + test("depends_on and networks emit warnings, not errors", () => { + const result = callTranslate({ + services: { + api: { + image: "x", + depends_on: ["db"], + networks: ["app"], + }, + db: { image: "postgres" }, + }, + }); + expect(result.warnings.some((w) => w.includes("depends_on"))).toBe(true); + expect(result.warnings.some((w) => w.includes("networks"))).toBe(true); + expect(result.config.app.containers.api?.image).toBe("x"); + }); + + test("extends throws (hard refused)", () => { + expect(() => + callTranslate({ + services: { api: { image: "x", extends: { service: "base" } } }, + }), + ).toThrow(/extends/); + }); + + test("secrets throws", () => { + expect(() => + callTranslate({ + services: { api: { image: "x", secrets: ["db-password"] } }, + }), + ).toThrow(/secrets/); + }); + + test("multi-service translates every entry", () => { + const result = callTranslate({ + services: { + api: { image: "ghcr.io/me/api:v1", ports: ["3000:3000"] }, + db: { image: "postgres:16", volumes: ["pgdata:/var/lib/postgresql"] }, + redis: { image: "redis:7" }, + }, + volumes: { pgdata: {} }, + }); + expect(Object.keys(result.config.app.containers)).toEqual([ + "api", + "db", + "redis", + ]); + expect(result.config.app.containers.api?.endpoints).toHaveLength(1); + expect(result.config.app.containers.db?.volumes).toHaveLength(1); + expect(result.config.app.containers.redis?.image).toBe("redis:7"); + }); + + test("empty services throws", () => { + expect(() => callTranslate({ services: {} })).toThrow(/no services/i); + }); +}); diff --git a/packages/cli/src/commands/apps/compose/translate.ts b/packages/cli/src/commands/apps/compose/translate.ts new file mode 100644 index 0000000..da2bb9c --- /dev/null +++ b/packages/cli/src/commands/apps/compose/translate.ts @@ -0,0 +1,348 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import type { + BunnyAppConfig, + ContainerConfig, + EndpointConfig, + ProbeConfig, + VolumeConfig, +} from "@bunny.net/app-config"; +import { CURRENT_VERSION } from "@bunny.net/app-config"; +import { UserError } from "../../../core/errors.ts"; +import { parseDotenv } from "../env/parse.ts"; +import { parsePortMapping } from "./ports.ts"; +import type { ComposeFile, ComposeService } from "./schema.ts"; + +export interface TranslateOptions { + /** Absolute path of the compose file. Used to resolve env_file and build context paths. */ + composeFilePath: string; + /** App name (compose has no app-level name, so the caller supplies it). */ + appName: string; + /** Regions (caller supplies; compose has nothing region-aware). */ + regions: string[]; + /** Registry to assign to every container. Caller picks one in the walkthrough. */ + defaultRegistryId?: string; +} + +export interface TranslateResult { + config: BunnyAppConfig; + /** Non-fatal warnings to surface, usually about ignored compose fields. */ + warnings: string[]; +} + +// Compose fields that have no meaning on Magic Containers. We emit a +// warning and skip them. Anything else passes through unchanged. +const IGNORED_FIELDS: Record = { + depends_on: "MC starts all containers together", + networks: "MC containers share localhost within an app", + links: "use service names (links are deprecated in compose anyway)", + external_links: "MC does not link to external Docker containers", + labels: + "use bunny.jsonc endpoints for routing instead of Caddy/Traefik labels", + restart: "MC restarts containers automatically", + profiles: "compose profiles do not apply on MC", + deploy: + "use app.scaling in bunny.jsonc. Compose deploy.replicas is per-service; MC scales the whole app", +}; + +// Fields we refuse to translate. +const REFUSED_FIELDS: Record = { + extends: "compose `extends:` is not supported. Flatten the service inline.", + secrets: + "compose `secrets:` is not supported. Store secrets via `bunny apps env push` instead.", + configs: + "compose `configs:` is not supported. Bake config into the image or pass via env.", +}; + +/** + * Translate a parsed compose file into a `bunny.jsonc` shape. + * + * Returns `{ config, warnings }`. Hard errors (bind mounts, `extends:`, + * `secrets:`, multi-port ranges) throw `UserError`. + */ +export function composeToConfig( + compose: ComposeFile, + opts: TranslateOptions, +): TranslateResult { + const warnings: string[] = []; + const containers: Record = {}; + + const composeDir = dirname(opts.composeFilePath); + const topLevelVolumes = compose.volumes ?? {}; + + for (const [name, service] of Object.entries(compose.services)) { + const container = translateService(service, name, { + composeDir, + defaultRegistryId: opts.defaultRegistryId, + topLevelVolumes, + warnings, + }); + containers[name] = container; + } + + if (Object.keys(containers).length === 0) { + throw new UserError( + "Compose file has no services.", + "Add at least one service under `services:`.", + ); + } + + return { + config: { + version: CURRENT_VERSION, + app: { + name: opts.appName, + scaling: { min: 1, max: 1 }, + regions: opts.regions, + containers, + }, + }, + warnings, + }; +} + +interface ServiceCtx { + composeDir: string; + defaultRegistryId?: string; + topLevelVolumes: Record; + warnings: string[]; +} + +function translateService( + service: ComposeService, + name: string, + ctx: ServiceCtx, +): ContainerConfig { + const container: ContainerConfig = {}; + + // Catch refused fields first, so we fail before partially translating. + for (const [field, reason] of Object.entries(REFUSED_FIELDS)) { + if ((service as Record)[field] !== undefined) { + throw new UserError( + `Service "${name}" uses unsupported compose field \`${field}\`.`, + reason, + ); + } + } + + // Image / build. + if (service.image) { + container.image = service.image; + } + if (service.build !== undefined) { + if (typeof service.build === "string") { + container.context = service.build; + container.dockerfile = "Dockerfile"; + } else { + if (service.build.context) container.context = service.build.context; + container.dockerfile = service.build.dockerfile ?? "Dockerfile"; + if (service.build.args && Object.keys(service.build.args).length > 0) { + ctx.warnings.push( + `Service "${name}": \`build.args\` are not propagated through the deploy flow yet. Bake required ARGs into the Dockerfile or expose them as env.`, + ); + } + } + } + if (!container.image && !container.dockerfile) { + throw new UserError( + `Service "${name}" has neither \`image\` nor \`build\`.`, + "Add one or the other.", + ); + } + + // Registry default. + if (ctx.defaultRegistryId) { + container.registry = ctx.defaultRegistryId; + } + + // Command. + if (service.command !== undefined) { + container.command = + typeof service.command === "string" + ? service.command.trim().split(/\s+/) + : service.command; + } + + // Environment: env_file first, then `environment:` overrides per the spec. + const env: Record = {}; + if (service.env_file !== undefined) { + const files = Array.isArray(service.env_file) + ? service.env_file + : [service.env_file]; + for (const file of files) { + const path = isAbsolute(file) ? file : resolve(ctx.composeDir, file); + if (!existsSync(path)) { + throw new UserError( + `Service "${name}": env_file \`${file}\` not found at ${path}.`, + ); + } + Object.assign(env, parseDotenv(readFileSync(path, "utf-8"))); + } + } + if (service.environment !== undefined) { + Object.assign(env, normalizeEnvironment(service.environment)); + } + if (Object.keys(env).length > 0) container.env = env; + + // Ports → endpoints. Combine all into one CDN endpoint with multiple + // mappings, matching how MC's CDN endpoint model accepts them. + if (service.ports && service.ports.length > 0) { + const mappings = service.ports.map((p) => + parsePortMapping( + p as string | { target: number; published?: number | string }, + ), + ); + const endpoint: EndpointConfig = { + type: "cdn", + ssl: true, + ports: mappings, + }; + container.endpoints = [endpoint]; + } + + // Volumes: named volumes only. Bind mounts are a hard error so users + // notice rather than silently losing data. + if (service.volumes && service.volumes.length > 0) { + const volumes: VolumeConfig[] = []; + for (const entry of service.volumes) { + const parsed = parseVolumeEntry(entry, name, ctx.topLevelVolumes); + if (parsed) volumes.push(parsed); + } + if (volumes.length > 0) container.volumes = volumes; + } + + // Healthcheck → liveness probe. + if (service.healthcheck && !service.healthcheck.disable) { + const probe = translateHealthcheck(service.healthcheck); + if (probe) container.probes = { liveness: probe }; + } + + // Standard ignored-field warnings. + for (const [field, reason] of Object.entries(IGNORED_FIELDS)) { + if ((service as Record)[field] !== undefined) { + ctx.warnings.push(`Service "${name}": ignored \`${field}\` (${reason}).`); + } + } + + return container; +} + +function normalizeEnvironment( + env: Record | string[], +): Record { + const out: Record = {}; + if (Array.isArray(env)) { + // "KEY=value" or bare "KEY" (inherit from host: not portable, skip) + for (const line of env) { + const eq = line.indexOf("="); + if (eq < 1) continue; + const key = line.slice(0, eq); + out[key] = line.slice(eq + 1); + } + } else { + for (const [key, value] of Object.entries(env)) { + if (value === null) continue; + out[key] = String(value); + } + } + return out; +} + +function parseVolumeEntry( + entry: string | { type: string; source?: string; target: string }, + serviceName: string, + topLevelVolumes: Record, +): VolumeConfig | null { + if (typeof entry === "object") { + if (entry.type === "bind") { + throw new UserError( + `Service "${serviceName}": bind mount to \`${entry.source}\` is not supported on MC.`, + "Convert to a named volume (declared under top-level `volumes:`) or remove the mount.", + ); + } + if (entry.type === "volume" && entry.source) { + return { + name: entry.source, + mount: entry.target, + size: sizeForVolume(entry.source, topLevelVolumes), + }; + } + throw new UserError( + `Service "${serviceName}": cannot translate volume entry ${JSON.stringify(entry)}.`, + ); + } + + // String form: "source:target[:mode]" + const parts = entry.split(":"); + if (parts.length < 2) { + // Anonymous volume like "/data" is ambiguous; refuse. + throw new UserError( + `Service "${serviceName}": anonymous volume \`${entry}\` is not supported.`, + "Give the volume a name in top-level `volumes:` and reference it as `name:/path`.", + ); + } + + const source = parts[0] ?? ""; + const target = parts[1] ?? ""; + + // Bind mount heuristic: anything starting with `.` or `/` (or a Windows + // drive letter, ignored here) is treated as a host path. + if (source.startsWith(".") || source.startsWith("/")) { + throw new UserError( + `Service "${serviceName}": bind mount \`${entry}\` is not supported on MC.`, + "Convert to a named volume (declared under top-level `volumes:`) or remove the mount.", + ); + } + + return { + name: source, + mount: target, + size: sizeForVolume(source, topLevelVolumes), + }; +} + +/** + * Compose doesn't carry a size for volumes; they're created on demand + * by the local docker daemon at whatever size. MC requires a number. + * Default to 1 GiB and let users edit `bunny.jsonc` if they want more. + */ +const DEFAULT_VOLUME_SIZE = 1024; +function sizeForVolume( + name: string, + topLevelVolumes: Record, +): number { + // Future hook: read driver_opts.size or similar if anyone uses them. + void topLevelVolumes; + void name; + return DEFAULT_VOLUME_SIZE; +} + +function translateHealthcheck( + hc: NonNullable, +): ProbeConfig | null { + const test = hc.test; + if (!test) return null; + + // `test` is either a string (run in a shell) or array form ["CMD", ...] / ["CMD-SHELL", ...]. + const args = Array.isArray(test) ? test : ["CMD-SHELL", test]; + const isCmd = args[0] === "CMD" || args[0] === "CMD-SHELL"; + const body = isCmd ? args.slice(1).join(" ") : args.join(" "); + + // Heuristic: if the command looks like a curl/wget probe of an HTTP URL, + // emit an http probe with the path extracted. Otherwise fall back to tcp. + const urlMatch = body.match(/https?:\/\/[^\s]+/); + if (urlMatch?.[0]) { + try { + const url = new URL(urlMatch[0]); + const portFromUrl = url.port ? Number.parseInt(url.port, 10) : undefined; + return { + type: "http", + path: url.pathname || "/", + ...(portFromUrl ? { port: portFromUrl } : {}), + }; + } catch { + // fall through + } + } + return { type: "tcp" }; +} diff --git a/packages/cli/src/commands/apps/config.test.ts b/packages/cli/src/commands/apps/config.test.ts new file mode 100644 index 0000000..a93e30f --- /dev/null +++ b/packages/cli/src/commands/apps/config.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../test-utils/temp-dir.ts"; +import { configExists, loadConfig, saveConfig } from "./config.ts"; + +const tempDir = useTempDir("bunny-config-"); + +test("configExists(path) checks that exact file", () => { + const path = join(tempDir(), "custom.jsonc"); + expect(configExists(path)).toBe(false); + writeFileSync( + path, + JSON.stringify({ + version: "2026-05-11", + app: { name: "x", containers: {} }, + }), + ); + expect(configExists(path)).toBe(true); +}); + +test("loadConfig(path) reads from that exact file", () => { + const path = join(tempDir(), "elsewhere.jsonc"); + writeFileSync( + path, + JSON.stringify({ + version: "2026-05-11", + app: { name: "my-app", containers: { api: { image: "nginx" } } }, + }), + ); + const cfg = loadConfig(path); + expect(cfg.app.name).toBe("my-app"); + expect(cfg.app.containers.api?.image).toBe("nginx"); +}); + +test("loadConfig(missing) throws", () => { + expect(() => loadConfig(join(tempDir(), "nope.jsonc"))).toThrow( + /No config file found/, + ); +}); + +test("saveConfig(data, path) writes there, not to cwd", () => { + const path = join(tempDir(), "out.jsonc"); + saveConfig( + { + version: "2026-05-11", + app: { name: "demo", containers: { api: { image: "x" } } }, + }, + path, + ); + const parsed = JSON.parse(readFileSync(path, "utf-8")); + expect(parsed.version).toBe("2026-05-11"); + expect(parsed.app.name).toBe("demo"); + expect(Object.keys(parsed)[0]).toBe("$schema"); +}); + +test("load → save → reload round-trip through an explicit path", () => { + const path = join(tempDir(), "rt.jsonc"); + writeFileSync( + path, + JSON.stringify({ + version: "2026-05-11", + app: { name: "rt", containers: { api: { image: "nginx" } } }, + }), + ); + const original = loadConfig(path); + original.app.id = "app_abc123"; + saveConfig(original, path); + expect(loadConfig(path).app.id).toBe("app_abc123"); +}); diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index c02b633..2b88d04 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -38,32 +38,43 @@ function findConfigRoot(): string { } } -/** Load and parse bunny.jsonc from cwd or nearest ancestor. */ -export function loadConfig(): BunnyAppConfig { - const root = findConfigRoot(); - const jsoncPath = join(root, CONFIG_FILENAME); +/** + * Load and parse the app config. + * + * When `explicitPath` is given (e.g. from `--config `), that file + * is loaded verbatim. Otherwise we walk up from cwd looking for + * `bunny.jsonc`. + */ +export function loadConfig(explicitPath?: string): BunnyAppConfig { + const jsoncPath = explicitPath ?? join(findConfigRoot(), CONFIG_FILENAME); if (!existsSync(jsoncPath)) { throw new UserError( - "No bunny.jsonc found.", - "Run `bunny apps init` first.", + `No config file found at ${jsoncPath}.`, + "Run `bunny apps init` first, or pass --config .", ); } const raw = parseJsonc(readFileSync(jsoncPath, "utf-8")); if (raw && typeof raw === "object" && !("version" in raw)) { throw new UserError( - "bunny.jsonc is missing the `version` field.", + `${jsoncPath} is missing the \`version\` field.`, "Run `bunny apps pull` to regenerate it from the remote app.", ); } return BunnyAppConfigSchema.parse(raw); } -/** Write bunny.jsonc to the given directory (or cwd). */ -export function saveConfig(data: BunnyAppConfig, dir?: string): void { - const target = dir ?? process.cwd(); - const path = join(target, CONFIG_FILENAME); +/** + * Write the app config. + * + * When `explicitPath` is given the file is written exactly there; + * otherwise we write to `./bunny.jsonc` in the current working + * directory. The `--config ` flow uses the explicit form so that + * deploys can persist `app.id` back to whatever file the caller chose. + */ +export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void { + const path = explicitPath ?? join(process.cwd(), CONFIG_FILENAME); // Re-key the object so the file always starts with $schema → version → app. const { $schema: _schema, version, ...rest } = data; @@ -76,8 +87,14 @@ export function saveConfig(data: BunnyAppConfig, dir?: string): void { writeFileSync(path, `${JSON.stringify(output, null, 2)}\n`); } -/** Check if bunny.jsonc exists in cwd or ancestor. */ -export function configExists(): boolean { +/** + * Check whether an app config exists. + * + * When `explicitPath` is given we check that exact file; otherwise we + * walk up from cwd looking for `bunny.jsonc`. + */ +export function configExists(explicitPath?: string): boolean { + if (explicitPath) return existsSync(explicitPath); const root = findConfigRoot(); return existsSync(join(root, CONFIG_FILENAME)); } diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index f77caea..2e000d3 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -1,8 +1,5 @@ -import { existsSync } from "node:fs"; -import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, resolve } from "node:path"; import { createMcClient } from "@bunny.net/openapi-client"; -import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -12,7 +9,6 @@ import { spinner } from "../../core/ui.ts"; import { type BunnyAppConfig, type ContainerConfig, - CURRENT_VERSION, configExists, configToAddRequest, configToPatchRequest, @@ -24,16 +20,14 @@ import { buildImage, dockerLogin, ensureDockerAvailable, + ensureRegistryLogin, generateTag, - getConfigSuggestions, - type McClient, promptRegistry, pushImage, type ResolvedRegistry, resolveRegistryForImage, } from "./docker.ts"; - -type EndpointRequest = components["schemas"]["EndpointRequest"]; +import { runWalkthrough } from "./walkthrough.ts"; const COMMAND = "deploy [image]"; const DESCRIPTION = "Deploy an app."; @@ -46,6 +40,11 @@ interface DeployArgs { tag?: string; registry?: string; container?: string; + name?: string; + port?: number; + command?: string; + config?: string; + "dry-run"?: boolean; "no-push"?: boolean; } @@ -59,6 +58,7 @@ export const appsDeployCommand = defineCommand({ "$0 apps deploy --dockerfile apps/api/Dockerfile --context apps/api", "Build with explicit context", ], + ["$0 apps deploy --dry-run", "Preview the would-be config without writing"], ["$0 apps deploy", "Re-deploy using config from bunny.jsonc"], ], @@ -85,13 +85,38 @@ export const appsDeployCommand = defineCommand({ }) .option("registry", { type: "string", - describe: "Bunny registry ID to push to (overrides bunny.jsonc)", + describe: "bunny.net registry ID to push to (overrides bunny.jsonc)", }) .option("container", { type: "string", describe: "Target container by name (required when bunny.jsonc has multiple containers)", }) + .option("name", { + type: "string", + describe: + "App name (used during first-run walkthrough; skips the interactive prompt)", + }) + .option("port", { + type: "number", + describe: + "Override the container port (affects generated Dockerfile and endpoint)", + }) + .option("command", { + type: "string", + describe: + "Override the container CMD (passed as a single string, split on whitespace)", + }) + .option("config", { + type: "string", + describe: + "Use this file as the app config (overrides cwd-detected bunny.jsonc). Useful in CI / agent flows where no bunny.jsonc is checked in.", + }) + .option("dry-run", { + type: "boolean", + describe: + "Print the would-be bunny.jsonc and Dockerfile without writing anything or contacting the API to deploy", + }) .option("no-push", { type: "boolean", describe: "Build only — skip push and deploy", @@ -102,6 +127,14 @@ export const appsDeployCommand = defineCommand({ const positionalImage = args.image; const dockerfileFlag = normalizeDockerfileFlag(args.dockerfile); const noPush = args["no-push"] === true; + const dryRun = args["dry-run"] === true; + + // --config takes precedence over the cwd-walk for bunny.jsonc. When + // set, we read/write *that exact* path. Useful for agents and CI + // that generate ephemeral configs without checking anything in. + const configPath = args.config + ? resolve(process.cwd(), args.config) + : undefined; if (positionalImage && dockerfileFlag) { throw new UserError( @@ -114,15 +147,31 @@ export const appsDeployCommand = defineCommand({ const client = createMcClient(clientOptions(cfg, verbose)); let toml: BunnyAppConfig; - if (!configExists()) { - toml = await firstRunWalkthrough(client, { + if (!configExists(configPath)) { + toml = await runWalkthrough(client, { positionalImage, dockerfileFlag, contextFlag: args.context, registryFlag: args.registry, + portOverride: args.port, + commandOverride: args.command, + nameOverride: args.name, + configPath, + dryRun, }); } else { - toml = loadConfig(); + toml = loadConfig(configPath); + } + + if (dryRun) { + logger.log(); + logger.dim("--- bunny.jsonc (preview) ---"); + logger.log(JSON.stringify(toml, null, 2)); + logger.dim("--- end preview ---"); + logger.dim( + "Dry run complete. No files were written and no API calls were made to deploy.", + ); + return; } const [targetName, targetContainer] = resolveTargetContainer(toml, { @@ -155,7 +204,7 @@ export const appsDeployCommand = defineCommand({ registryId = resolved.id; freshCreds = resolved.freshCredentials; targetContainer.registry = registryId; - saveConfig(toml); + saveConfig(toml, configPath); } const regSpin = spinner("Fetching registry..."); @@ -204,6 +253,10 @@ export const appsDeployCommand = defineCommand({ loginSpin.stop(); throw err; } + } else if (reg.hostName) { + // No just-entered credentials, so make sure docker is logged in + // before we attempt the push, prompting if not. + await ensureRegistryLogin(reg.hostName); } logger.info(`Pushing ${imageRef}...`); @@ -226,7 +279,7 @@ export const appsDeployCommand = defineCommand({ if (!resolved) { throw new UserError( "A registry is required to deploy this image.", - "Bunny needs a registry record for the image hostname so it can pull the image.", + "bunny.net needs a registry record for the image hostname so it can pull the image.", ); } registryId = resolved.id; @@ -275,6 +328,9 @@ export const appsDeployCommand = defineCommand({ pushSpin.stop(); } + // Captured pre-deploy so we can surface a rollback hint at the end. + let previousImage: string | undefined; + if (deployImage) { const fetchSpin = spinner("Fetching app..."); fetchSpin.start(); @@ -298,6 +354,8 @@ export const appsDeployCommand = defineCommand({ ); } + previousImage = match.image ?? undefined; + const containerId = match.id; const { imageName, imageNamespace, imageTag } = parseImageRef(deployImage); @@ -327,12 +385,25 @@ export const appsDeployCommand = defineCommand({ if (output === "json") { logger.log( - JSON.stringify({ id: appId, deployed: true, image: deployImage }), + JSON.stringify({ + id: appId, + deployed: true, + image: deployImage, + previousImage, + }), ); return; } logger.success("App deployed."); + + // Rollback hint, only meaningful when there was a previous image + // and we just replaced it with a different one. + if (previousImage && previousImage !== deployImage) { + logger.log(); + logger.dim(`Previous image: ${previousImage}`); + logger.dim(`To rollback: bunny apps deploy ${previousImage}`); + } }, }); @@ -441,265 +512,3 @@ function resolveBuildContext( : resolve(process.cwd(), dockerfile); return dirname(absDockerfile); } - -interface WalkthroughInput { - positionalImage?: string; - dockerfileFlag?: string; - contextFlag?: string; - registryFlag?: string; -} - -async function firstRunWalkthrough( - client: McClient, - input: WalkthroughInput, -): Promise { - logger.info("No bunny.jsonc found — setting up this app."); - - // Decide build vs deploy if neither flag was passed. - let imageRef: string | undefined = input.positionalImage; - let dockerfilePath: string | undefined = input.dockerfileFlag; - - if (!imageRef && !dockerfilePath) { - const hasDockerfile = existsSync(join(process.cwd(), DEFAULT_DOCKERFILE)); - const { value } = await prompts({ - type: "select", - name: "value", - message: "How do you want to deploy?", - choices: [ - ...(hasDockerfile - ? [ - { - title: "Build from ./Dockerfile", - value: "dockerfile", - }, - ] - : []), - { title: "Deploy a pre-built image", value: "image" }, - ], - }); - if (!value) throw new UserError("Setup cancelled."); - if (value === "dockerfile") { - dockerfilePath = DEFAULT_DOCKERFILE; - } else { - const { value: ref } = await prompts({ - type: "text", - name: "value", - message: "Image ref (e.g. ghcr.io/me/api:v1):", - }); - if (!ref) throw new UserError("Image ref is required."); - imageRef = ref; - } - } - - const mode: "build" | "image" = dockerfilePath ? "build" : "image"; - - let registry: ResolvedRegistry | null; - if (input.registryFlag) { - registry = { id: input.registryFlag }; - } else if (mode === "image" && imageRef) { - registry = await resolveRegistryForImage(client, imageRef); - } else { - logger.info("Pick a registry to push your image to."); - registry = await promptRegistry(client); - } - if (!registry) throw new UserError("A registry is required."); - - let suggestions: Awaited> | null = - null; - if (mode === "image" && imageRef) { - const parsed = parseImageRef(imageRef); - suggestions = await getConfigSuggestions(client, registry.id, parsed); - if (suggestions?.instructions) { - logger.log(); - logger.dim(suggestions.instructions); - logger.log(); - } - } - - const defaultName = - suggestions?.appName?.trim() || basename(resolve(process.cwd())); - const { value: name } = await prompts({ - type: "text", - name: "value", - message: "App name:", - initial: defaultName, - }); - if (!name) throw new UserError("App name is required."); - - const regionsSpin = spinner("Fetching regions..."); - regionsSpin.start(); - const { data: regionsResult } = await client.GET("/regions"); - regionsSpin.stop(); - - const regionsWithCapacity = (regionsResult?.items ?? []).filter( - (r): r is typeof r & { id: string } => - r.hasCapacity === true && typeof r.id === "string", - ); - - if (regionsWithCapacity.length === 0) { - throw new UserError("No regions with capacity are available right now."); - } - - const { value: selectedRegions } = await prompts({ - type: "multiselect", - name: "value", - message: "Select regions:", - choices: regionsWithCapacity.map((r) => ({ - title: `${r.name} (${r.id})`, - value: r.id, - })), - min: 1, - }); - - const regions: string[] = selectedRegions ?? []; - if (regions.length === 0) { - throw new UserError("At least one region must be selected."); - } - - const container: ContainerConfig = { - registry: registry.id, - }; - - if (mode === "build" && dockerfilePath) { - container.dockerfile = dockerfilePath; - if (input.contextFlag) container.context = input.contextFlag; - } - if (imageRef) { - container.image = imageRef; - } - - // Apply suggested endpoints. - if (suggestions?.endpointSuggestions?.length) { - const accepted = await confirmEndpointSuggestions( - suggestions.endpointSuggestions, - ); - if (accepted.length > 0) { - container.endpoints = accepted.map(endpointRequestToConfig); - } - } - - // Prompt for suggested env vars (required first; offer optional ones). - if (suggestions?.environmentVariablesSuggestions?.length) { - const env = await promptSuggestedEnv( - suggestions.environmentVariablesSuggestions, - ); - if (Object.keys(env).length > 0) { - container.env = env; - } - } - - const toml: BunnyAppConfig = { - version: CURRENT_VERSION, - app: { - name, - scaling: { min: 1, max: 1 }, - regions, - containers: { [name]: container }, - }, - }; - - saveConfig(toml); - logger.success("Wrote bunny.jsonc."); - return toml; -} - -async function confirmEndpointSuggestions( - endpoints: EndpointRequest[], -): Promise { - const accepted: EndpointRequest[] = []; - for (const ep of endpoints) { - const label = describeEndpoint(ep); - const { value } = await prompts({ - type: "confirm", - name: "value", - message: `Add suggested endpoint: ${label}?`, - initial: true, - }); - if (value) accepted.push(ep); - } - return accepted; -} - -function describeEndpoint(ep: EndpointRequest): string { - if (ep.cdn) { - const ports = ep.cdn.portMappings - ?.map((p) => `${p.exposedPort}→${p.containerPort}`) - .join(", "); - return `CDN (${ports ?? "default port"})${ep.cdn.isSslEnabled ? " + SSL" : ""}`; - } - if (ep.anycast) { - const ports = ep.anycast.portMappings - .map((p) => `${p.exposedPort}→${p.containerPort}`) - .join(", "); - return `Anycast (${ports})`; - } - return ep.displayName ?? "endpoint"; -} - -function endpointRequestToConfig( - ep: EndpointRequest, -): NonNullable[number] { - if (ep.cdn) { - return { - type: "cdn", - ssl: ep.cdn.isSslEnabled, - ports: - ep.cdn.portMappings?.map((p) => ({ - public: p.exposedPort ?? p.containerPort, - container: p.containerPort, - })) ?? [], - }; - } - if (ep.anycast) { - return { - type: "anycast", - ports: ep.anycast.portMappings.map((p) => ({ - public: p.exposedPort ?? p.containerPort, - container: p.containerPort, - })), - }; - } - return { type: "cdn" }; -} - -async function promptSuggestedEnv( - suggestions: components["schemas"]["EnvironmentVariableSuggestion"][], -): Promise> { - const env: Record = {}; - const required = suggestions.filter((s) => s.required); - const optional = suggestions.filter((s) => !s.required); - - for (const item of required) { - if (!item.name) continue; - const { value } = await prompts({ - type: "text", - name: "value", - message: `${item.name}${item.description ? ` — ${item.description}` : ""}:`, - initial: item.defaultValue ?? "", - }); - if (value !== undefined && value !== "") env[item.name] = String(value); - } - - if (optional.length > 0) { - const { value: confirm } = await prompts({ - type: "confirm", - name: "value", - message: `Configure ${optional.length} optional env var${optional.length === 1 ? "" : "s"} now?`, - initial: false, - }); - if (confirm) { - for (const item of optional) { - if (!item.name) continue; - const { value } = await prompts({ - type: "text", - name: "value", - message: `${item.name}${item.description ? ` — ${item.description}` : ""}:`, - initial: item.defaultValue ?? "", - }); - if (value !== undefined && value !== "") env[item.name] = String(value); - } - } - } - - return env; -} diff --git a/packages/cli/src/commands/apps/docker.test.ts b/packages/cli/src/commands/apps/docker.test.ts new file mode 100644 index 0000000..4ef445c --- /dev/null +++ b/packages/cli/src/commands/apps/docker.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../test-utils/temp-dir.ts"; +import { dockerHasCredentials, imageHostname } from "./docker.ts"; + +describe("imageHostname", () => { + test.each([ + ["nginx", null], + ["nginx:1.27", null], + ["library/redis", null], + ["ghcr.io/me/api:v1", "ghcr.io"], + ["registry.example.com/foo/bar", "registry.example.com"], + ["localhost/foo:1", "localhost"], + ["localhost:5000/foo:1", "localhost:5000"], + ])("imageHostname(%j) → %j", (input, expected) => { + expect(imageHostname(input)).toBe(expected); + }); +}); + +describe("dockerHasCredentials", () => { + const tempDir = useTempDir("bunny-docker-"); + const configFile = () => join(tempDir(), "config.json"); + + test("returns false when the config file does not exist", () => { + expect(dockerHasCredentials("ghcr.io", configFile())).toBe(false); + }); + + test("returns false when the config has no matching entry", () => { + writeFileSync(configFile(), JSON.stringify({ auths: { "docker.io": {} } })); + expect(dockerHasCredentials("ghcr.io", configFile())).toBe(false); + }); + + test("returns true when auths has the hostname (even with empty value)", () => { + writeFileSync(configFile(), JSON.stringify({ auths: { "ghcr.io": {} } })); + expect(dockerHasCredentials("ghcr.io", configFile())).toBe(true); + }); + + test("returns true when auths has the hostname with an auth string", () => { + writeFileSync( + configFile(), + JSON.stringify({ auths: { "ghcr.io": { auth: "dXNlcjpwYXNz" } } }), + ); + expect(dockerHasCredentials("ghcr.io", configFile())).toBe(true); + }); + + test("returns true when credHelpers has the hostname (Docker Desktop pattern)", () => { + writeFileSync( + configFile(), + JSON.stringify({ credHelpers: { "ghcr.io": "osxkeychain" } }), + ); + expect(dockerHasCredentials("ghcr.io", configFile())).toBe(true); + }); + + test("returns false on malformed JSON", () => { + writeFileSync(configFile(), "{not json"); + expect(dockerHasCredentials("ghcr.io", configFile())).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index 7ee6cde..a738aa3 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } 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"; @@ -96,10 +99,12 @@ export async function pushImage(tag: string): Promise { const exitCode = await proc.exited; if (exitCode !== 0) { - throw new UserError( - `Docker push failed (exit code ${exitCode}).`, - "Ensure you are logged in to the registry (`docker login `).", - ); + const hostname = imageHostname(tag); + const hint = + hostname === "ghcr.io" + ? "Log in with `gh auth token | docker login ghcr.io -u --password-stdin`, or ensure your token has write:packages scope." + : `Run \`docker login ${hostname ?? ""}\` and try again. Check that your token has push permission.`; + throw new UserError(`Docker push failed (exit code ${exitCode}).`, hint); } } @@ -132,16 +137,161 @@ export async function dockerLogin( } } +/** + * Check `~/.docker/config.json` for an existing credential record for + * `hostname`. Looks at both the `auths` map (where Docker Desktop leaves + * a `{}` marker even when the real cred lives in a credsStore) and the + * `credHelpers` map (per-host helpers). + * + * Not a guarantee the cred is still valid (tokens expire), but a + * strong signal that `docker login` has been run here. + * + * Takes an optional `configPath` to make the function testable. + */ +export function dockerHasCredentials( + hostname: string, + configPath?: string, +): boolean { + const path = configPath ?? join(homedir(), ".docker", "config.json"); + if (!existsSync(path)) return false; + try { + const config = JSON.parse(readFileSync(path, "utf-8")) as { + auths?: Record; + credHelpers?: Record; + }; + if (config.auths && hostname in config.auths) return true; + if (config.credHelpers && hostname in config.credHelpers) return true; + return false; + } catch { + return false; + } +} + +/** + * Check whether `gh` is on PATH and the user is authenticated. + * Used to offer a one-click docker-login flow for ghcr.io. + */ +export async function ghIsAuthenticated(): Promise { + try { + const proc = Bun.spawn(["gh", "auth", "status"], { + stdout: "pipe", + stderr: "pipe", + }); + return (await proc.exited) === 0; + } catch { + return false; + } +} + +/** + * Log in to a registry using the local `gh` CLI for credentials. + * + * The user is already authenticated to GitHub, so we read their username + * and token via `gh api user` / `gh auth token`, then pipe them through + * the normal `docker login` flow. Token never touches stdout/stderr. + */ +export async function ghDockerLogin(hostname: string): Promise { + const userProc = Bun.spawn(["gh", "api", "user", "--jq", ".login"], { + stdout: "pipe", + stderr: "pipe", + }); + if ((await userProc.exited) !== 0) { + throw new UserError( + "`gh api user` failed.", + "Ensure the GitHub CLI is authenticated (`gh auth status`).", + ); + } + const username = (await new Response(userProc.stdout).text()).trim(); + + const tokenProc = Bun.spawn(["gh", "auth", "token"], { + stdout: "pipe", + stderr: "pipe", + }); + if ((await tokenProc.exited) !== 0) { + throw new UserError("`gh auth token` failed."); + } + const token = (await new Response(tokenProc.stdout).text()).trim(); + + await dockerLogin(hostname, username, token); +} + +/** + * Ensure docker is logged in to `hostname` before a push. If not, offer + * a login flow: + * + * - `ghcr.io` + `gh` authenticated → one-click via GitHub CLI + * - any host → fall back to manual username / password prompts + * + * On success, credentials persist in `~/.docker/config.json` so future + * deploys skip this step. + */ +export async function ensureRegistryLogin(hostname: string): Promise { + if (dockerHasCredentials(hostname)) return; + + logger.warn(`Not logged in to ${hostname}.`); + + if (hostname === "ghcr.io" && (await ghIsAuthenticated())) { + const { value } = await prompts({ + type: "confirm", + name: "value", + message: "Use the GitHub CLI (`gh`) to log in to ghcr.io?", + initial: true, + }); + if (value) { + const spin = spinner("Logging in via gh..."); + spin.start(); + try { + await ghDockerLogin(hostname); + spin.stop(); + logger.success(`Logged in to ${hostname}.`); + return; + } catch (err) { + spin.stop(); + throw err; + } + } + } + + const { value: username } = await prompts({ + type: "text", + name: "value", + message: `Username for ${hostname}:`, + }); + if (!username) throw new UserError(`Login to ${hostname} required.`); + + const { value: password } = await prompts({ + type: "password", + name: "value", + message: `Password/Token for ${hostname}:`, + }); + if (!password) throw new UserError(`Login to ${hostname} required.`); + + const spin = spinner(`Logging in to ${hostname}...`); + spin.start(); + try { + await dockerLogin(hostname, username, password); + spin.stop(); + logger.success(`Logged in to ${hostname}.`); + } catch (err) { + spin.stop(); + throw err; + } +} + /** * 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; - // A hostname segment must contain a dot, a colon (port), or be "localhost". if ( firstSegment.includes(".") || firstSegment.includes(":") || diff --git a/packages/cli/src/commands/apps/env/index.ts b/packages/cli/src/commands/apps/env/index.ts index fed90c6..7f34261 100644 --- a/packages/cli/src/commands/apps/env/index.ts +++ b/packages/cli/src/commands/apps/env/index.ts @@ -1,6 +1,7 @@ import { defineNamespace } from "../../../core/define-namespace.ts"; import { appsEnvListCommand } from "./list.ts"; import { appsEnvPullCommand } from "./pull.ts"; +import { appsEnvPushCommand } from "./push.ts"; import { appsEnvRemoveCommand } from "./remove.ts"; import { appsEnvSetCommand } from "./set.ts"; @@ -10,6 +11,7 @@ export const appsEnvNamespace = defineNamespace( [ appsEnvListCommand, appsEnvPullCommand, + appsEnvPushCommand, appsEnvRemoveCommand, appsEnvSetCommand, ], diff --git a/packages/cli/src/commands/apps/env/parse.test.ts b/packages/cli/src/commands/apps/env/parse.test.ts new file mode 100644 index 0000000..9ba3e53 --- /dev/null +++ b/packages/cli/src/commands/apps/env/parse.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { parseDotenv } from "./parse.ts"; + +test.each<[string, string, Record]>([ + [ + "simple KEY=value", + "PORT=3000\nDATABASE_URL=postgres://x", + { + PORT: "3000", + DATABASE_URL: "postgres://x", + }, + ], + [ + "blank lines and comments", + "\n# header\nFOO=1\n\n# another\nBAR=2\n", + { + FOO: "1", + BAR: "2", + }, + ], + [ + "double and single quotes stripped", + `A="hello world"\nB='single quoted'`, + { + A: "hello world", + B: "single quoted", + }, + ], + [ + "trailing inline comment on unquoted value", + "PORT=3000 # the http port", + { + PORT: "3000", + }, + ], + [ + "'#' preserved inside quoted values", + 'TOKEN="abc#def"', + { + TOKEN: "abc#def", + }, + ], + ["leading 'export'", "export FOO=bar", { FOO: "bar" }], + ["invalid identifiers rejected", "1BAD=x\nGOOD=y\n-foo=z", { GOOD: "y" }], + ["lines without = ignored", "not_a_kvp\nFOO=bar", { FOO: "bar" }], + ["later keys overwrite earlier", "X=1\nX=2", { X: "2" }], +])("parseDotenv: %s", (_name, input, expected) => { + expect(parseDotenv(input)).toEqual(expected); +}); diff --git a/packages/cli/src/commands/apps/env/parse.ts b/packages/cli/src/commands/apps/env/parse.ts new file mode 100644 index 0000000..7c249ee --- /dev/null +++ b/packages/cli/src/commands/apps/env/parse.ts @@ -0,0 +1,50 @@ +/** + * Minimal `.env` parser. + * + * Handles: + * - `KEY=value` + * - `KEY="quoted value"` / `KEY='quoted value'` (quotes stripped) + * - leading `export KEY=...` (the line still parses as KEY=...) + * - comments starting with `#` + * - blank lines + * + * Deliberately does *not* expand variable references (`$OTHER`), since + * we don't run user-supplied shell semantics inside our config layer. + */ +export function parseDotenv(input: string): Record { + const result: Record = {}; + + for (const rawLine of input.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + + const stripped = line.startsWith("export ") ? line.slice(7).trim() : line; + + const eq = stripped.indexOf("="); + if (eq < 1) continue; + + const key = stripped.slice(0, eq).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + + let value = stripped.slice(eq + 1).trim(); + + // Strip a trailing inline comment when the value is unquoted. + if (!isQuoted(value)) { + const hash = value.indexOf(" #"); + if (hash >= 0) value = value.slice(0, hash).trim(); + } else { + value = value.slice(1, -1); + } + + result[key] = value; + } + + return result; +} + +function isQuoted(value: string): boolean { + if (value.length < 2) return false; + const first = value[0]; + const last = value[value.length - 1]; + return (first === '"' && last === '"') || (first === "'" && last === "'"); +} diff --git a/packages/cli/src/commands/apps/env/push.ts b/packages/cli/src/commands/apps/env/push.ts new file mode 100644 index 0000000..a31cdc1 --- /dev/null +++ b/packages/cli/src/commands/apps/env/push.ts @@ -0,0 +1,167 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createMcClient } from "@bunny.net/openapi-client"; +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 { spinner } from "../../../core/ui.ts"; +import { resolveAppId, resolveContainerId } from "../config.ts"; +import { parseDotenv } from "./parse.ts"; + +const COMMAND = "push [file]"; +const DESCRIPTION = "Bulk-set environment variables from a .env file."; + +interface PushArgs { + file?: string; + id?: string; + container?: string; + replace?: boolean; + "dry-run"?: boolean; +} + +export const appsEnvPushCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 apps env push", "Push variables from ./.env, merging with remote"], + ["$0 apps env push .env.prod", "Push from a specific file"], + [ + "$0 apps env push .env.prod --replace", + "Replace the remote env with the file (drops vars not in the file)", + ], + ["$0 apps env push --dry-run", "Show what would change without writing"], + ], + + builder: (yargs) => + yargs + .positional("file", { + type: "string", + describe: "Path to the .env file (defaults to ./.env)", + }) + .option("id", { + type: "string", + describe: "App ID (overrides bunny.jsonc)", + }) + .option("container", { + type: "string", + describe: "Container name (defaults to primary)", + }) + .option("replace", { + type: "boolean", + describe: + "Replace all remote variables with the file's contents (default: merge)", + }) + .option("dry-run", { + type: "boolean", + describe: "Show the diff without writing", + }), + + handler: async ({ + file, + id: rawId, + container: containerName, + replace, + "dry-run": dryRun, + profile, + output, + verbose, + apiKey, + }) => { + const filePath = resolve(process.cwd(), file ?? ".env"); + if (!existsSync(filePath)) { + throw new UserError(`File not found: ${filePath}`); + } + + const fileVars = parseDotenv(readFileSync(filePath, "utf-8")); + const fileKeys = Object.keys(fileVars); + if (fileKeys.length === 0) { + throw new UserError(`No variables found in ${filePath}.`); + } + + const appId = resolveAppId(rawId); + const config = resolveConfig(profile, apiKey); + const client = createMcClient(clientOptions(config, verbose)); + + const fetchSpin = spinner("Fetching current variables..."); + fetchSpin.start(); + + const { data: app } = await client.GET("/apps/{appId}", { + params: { path: { appId } }, + }); + + fetchSpin.stop(); + + if (!app) { + throw new UserError(`App ${appId} not found.`); + } + + const containerId = resolveContainerId(app, containerName); + const container = app.containerTemplates.find((c) => c.id === containerId); + const existing = container?.environmentVariables ?? []; + + const existingMap: Record = {}; + for (const v of existing) existingMap[v.name] = v.value ?? ""; + + // Compute the post-push map. + const next: Record = replace + ? { ...fileVars } + : { ...existingMap, ...fileVars }; + + // Diff for reporting. + const added: string[] = []; + const changed: string[] = []; + const removed: string[] = []; + + for (const key of Object.keys(next)) { + if (!(key in existingMap)) added.push(key); + else if (existingMap[key] !== next[key]) changed.push(key); + } + if (replace) { + for (const key of Object.keys(existingMap)) { + if (!(key in next)) removed.push(key); + } + } + + if (added.length + changed.length + removed.length === 0) { + logger.info("No changes. Remote env matches the file."); + return; + } + + if (output !== "json") { + logger.log(`From ${filePath} → container "${container?.name}":`); + for (const k of added) logger.success(` + ${k}`); + for (const k of changed) logger.info(` ~ ${k}`); + for (const k of removed) logger.warn(` - ${k}`); + } + + if (dryRun) { + if (output === "json") { + logger.log( + JSON.stringify({ added, changed, removed, dryRun: true }, null, 2), + ); + } else { + logger.dim("Dry run. No changes written."); + } + return; + } + + const writeSpin = spinner("Updating variables..."); + writeSpin.start(); + await client.PUT("/apps/{appId}/containers/{containerId}/env", { + params: { path: { appId, containerId } }, + body: next, + }); + writeSpin.stop(); + + if (output === "json") { + logger.log(JSON.stringify({ added, changed, removed }, null, 2)); + return; + } + + logger.success( + `${added.length} added, ${changed.length} updated${replace ? `, ${removed.length} removed` : ""}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/apps/init.ts b/packages/cli/src/commands/apps/init.ts index 9323a08..9bf41a4 100644 --- a/packages/cli/src/commands/apps/init.ts +++ b/packages/cli/src/commands/apps/init.ts @@ -1,166 +1,131 @@ -import { existsSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { resolve } from "node:path"; import { createMcClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; 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 { spinner } from "../../core/ui.ts"; -import type { BunnyAppConfig, ContainerConfig } from "./config.ts"; -import { CURRENT_VERSION, configExists, saveConfig } from "./config.ts"; -import { promptRegistry } from "./docker.ts"; +import { configExists } from "./config.ts"; +import { runWalkthrough } from "./walkthrough.ts"; const COMMAND = "init"; -const DESCRIPTION = "Initialize a new app config."; +const DESCRIPTION = "Initialize a new app config (no deploy)."; interface InitArgs { name?: string; image?: string; + dockerfile?: string | boolean; + registry?: string; + port?: number; + command?: string; + config?: string; +} + +const DEFAULT_DOCKERFILE = "Dockerfile"; + +function normalizeDockerfileFlag( + value: string | boolean | undefined, +): string | undefined { + if (value === undefined) return undefined; + if (value === true || value === "") return DEFAULT_DOCKERFILE; + if (value === false) return undefined; + return value; } export const appsInitCommand = defineCommand({ command: COMMAND, describe: DESCRIPTION, + examples: [ + ["$0 apps init", "Walk through and scaffold bunny.jsonc"], + [ + "$0 apps init ghcr.io/me/api:v1", + "Scaffold bunny.jsonc pointed at a pre-built image", + ], + [ + "$0 apps init --dockerfile", + "Scaffold bunny.jsonc for a Dockerfile-based build", + ], + ], builder: (yargs) => yargs + .positional("image", { + type: "string", + describe: + "Container image reference (e.g. ghcr.io/me/api:v1). Skips the build-vs-image prompt.", + }) .option("name", { type: "string", - describe: "App name", + describe: "App name (skips the interactive prompt)", + }) + .option("dockerfile", { + type: "string", + describe: + "Build from Dockerfile. Pass a path or use bare flag for ./Dockerfile.", }) - .option("image", { + .option("registry", { type: "string", - describe: "Primary container image", + describe: "bunny.net registry ID to push to", + }) + .option("port", { + type: "number", + describe: + "Override the container port (affects generated Dockerfile and endpoint)", + }) + .option("command", { + type: "string", + describe: + "Override the container CMD (passed as a single string, split on whitespace)", + }) + .option("config", { + type: "string", + describe: + "Write the config to this path instead of ./bunny.jsonc (useful in CI / agent flows)", }), handler: async ({ - name: rawName, - image: rawImage, + name, + image, + dockerfile, + registry, + port, + command, + config: configArg, profile, output, verbose, apiKey, }) => { - if (configExists()) { + const configPath = configArg + ? resolve(process.cwd(), configArg) + : undefined; + + if (configExists(configPath)) { + const where = configPath ?? "this directory"; throw new UserError( - "A bunny.jsonc already exists in this directory.", + `A config file already exists at ${where}.`, "Use `bunny apps push` to sync changes or delete it first.", ); } - // Default app name to current directory name - let name = rawName; - if (!name) { - const defaultName = basename(resolve(process.cwd())); - const { value } = await prompts({ - type: "text", - name: "value", - message: "App name:", - initial: defaultName, - }); - name = value; - } - if (!name) throw new UserError("App name is required."); - - const config = resolveConfig(profile, apiKey); - const client = createMcClient(clientOptions(config, verbose)); - - // Detect Dockerfile in cwd - const dockerfilePath = join(process.cwd(), "Dockerfile"); - const hasDockerfile = existsSync(dockerfilePath); - - let container: ContainerConfig; - - if (hasDockerfile && !rawImage) { - logger.info("Detected Dockerfile in current directory."); - const { useDockerfile } = await prompts({ - type: "confirm", - name: "useDockerfile", - message: "Use this Dockerfile to build and deploy?", - initial: true, - }); - - if (useDockerfile) { - const resolved = await promptRegistry(client); - if (!resolved) - throw new UserError( - "A registry is required to build and push images.", - ); - container = { dockerfile: "Dockerfile", registry: resolved.id }; - } else { - const { value } = await prompts({ - type: "text", - name: "value", - message: "Primary container image (e.g. nginx:latest):", - }); - if (!value) throw new UserError("Container image is required."); - container = { image: value }; - } - } else if (rawImage) { - container = { image: rawImage }; - } else { - const { value } = await prompts({ - type: "text", - name: "value", - message: "Primary container image (e.g. nginx:latest):", - }); - if (!value) throw new UserError("Container image is required."); - container = { image: value }; - } - - // Fetch available regions for selection - const spin = spinner("Fetching regions..."); - spin.start(); - - const regionsResult = await client.GET("/regions"); - - spin.stop(); - - const availableRegions = regionsResult.data?.items ?? []; - const regionsWithCapacity = availableRegions.filter( - (r): r is typeof r & { id: string } => - r.hasCapacity === true && typeof r.id === "string", - ); - - let selectedRegions: string[] = []; - if (regionsWithCapacity.length > 0) { - const { value } = await prompts({ - type: "multiselect", - name: "value", - message: "Select regions:", - choices: regionsWithCapacity.map((r) => ({ - title: `${r.name} (${r.id})`, - value: r.id, - })), - min: 1, - }); - selectedRegions = value ?? []; - } - - if (selectedRegions.length === 0) { - throw new UserError("At least one region must be selected."); - } - - const toml: BunnyAppConfig = { - version: CURRENT_VERSION, - app: { - name, - scaling: { min: 1, max: 1 }, - regions: selectedRegions, - containers: { [name]: container }, - }, - }; + const cfg = resolveConfig(profile, apiKey); + const client = createMcClient(clientOptions(cfg, verbose)); - saveConfig(toml); + const toml = await runWalkthrough(client, { + positionalImage: image, + dockerfileFlag: normalizeDockerfileFlag(dockerfile), + registryFlag: registry, + portOverride: port, + commandOverride: command, + nameOverride: name, + configPath, + }); if (output === "json") { logger.log(JSON.stringify(toml, null, 2)); return; } - logger.success("Config written to bunny.jsonc."); logger.dim("Run `bunny apps deploy` to create and deploy the app."); }, }); diff --git a/packages/cli/src/commands/apps/walkthrough.ts b/packages/cli/src/commands/apps/walkthrough.ts new file mode 100644 index 0000000..ea1cd16 --- /dev/null +++ b/packages/cli/src/commands/apps/walkthrough.ts @@ -0,0 +1,441 @@ +import { existsSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; +import prompts from "prompts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { + composeToConfig, + findComposeFile, + loadComposeFile, +} from "./compose/index.ts"; +import { + type BunnyAppConfig, + type ContainerConfig, + CURRENT_VERSION, + parseImageRef, + saveConfig, +} from "./config.ts"; +import { + getConfigSuggestions, + type McClient, + promptRegistry, + type ResolvedRegistry, + resolveRegistryForImage, +} from "./docker.ts"; + +type EndpointRequest = components["schemas"]["EndpointRequest"]; + +const DEFAULT_DOCKERFILE = "Dockerfile"; + +/** + * Inputs to the shared walkthrough. Flags passed from `apps deploy`, + * `apps init`, etc. land here. + */ +export interface WalkthroughInput { + /** Pre-built image ref the user passed positionally. */ + positionalImage?: string; + /** Dockerfile path the user passed via --dockerfile. */ + dockerfileFlag?: string; + /** Build context the user passed via --context. */ + contextFlag?: string; + /** Registry ID the user passed via --registry. */ + registryFlag?: string; + /** Port override (--port). Retargets any endpoints written to bunny.jsonc. */ + portOverride?: number; + /** Container CMD override (--command). Stored as container.command. */ + commandOverride?: string; + /** App name (--name). When set, skips the interactive name prompt. */ + nameOverride?: string; + /** + * If true, skip every write side-effect: don't generate files to + * disk, don't save bunny.jsonc. The walkthrough still prompts and + * still returns a valid config; the caller decides what to do + * with it. + */ + dryRun?: boolean; + /** + * Absolute path the resulting config should be written to. When + * unset, the walkthrough writes `./bunny.jsonc` in cwd (current + * default). Set this when `--config ` is used so the persisted + * `app.id` lands in the caller's chosen file. + */ + configPath?: string; +} + +/** + * Run the new-app walkthrough and return the resulting `bunny.jsonc` shape. + * + * Used by both `apps deploy` (which then proceeds to create + build + + * deploy) and `apps init` (which stops at "config written"). Sharing the + * function means both commands generate identical configs. + * + * Side effects (saving bunny.jsonc) are skipped when `input.dryRun` is true. + */ +export async function runWalkthrough( + client: McClient, + input: WalkthroughInput, +): Promise { + logger.info("No bunny.jsonc found. Setting up this app."); + + let imageRef: string | undefined = input.positionalImage; + let dockerfilePath: string | undefined = input.dockerfileFlag; + + if (!imageRef && !dockerfilePath) { + const composeFile = findComposeFile(process.cwd()); + const hasDockerfile = existsSync(join(process.cwd(), DEFAULT_DOCKERFILE)); + + const composeServicesCount = composeFile + ? Object.keys(loadComposeFile(composeFile).services).length + : 0; + + const { value } = await prompts({ + type: "select", + name: "value", + message: "How do you want to deploy?", + choices: [ + ...(composeFile + ? [ + { + title: `Import ${composeServicesCount} service${composeServicesCount === 1 ? "" : "s"} from ${basename(composeFile)}`, + value: "compose", + }, + ] + : []), + ...(hasDockerfile + ? [{ title: "Build from ./Dockerfile", value: "dockerfile" }] + : []), + { title: "Deploy a pre-built image", value: "image" }, + ], + }); + if (!value) throw new UserError("Setup cancelled."); + + if (value === "compose" && composeFile) { + return runComposeImport(client, composeFile, input); + } + + if (value === "dockerfile") { + dockerfilePath = DEFAULT_DOCKERFILE; + } else { + const { value: ref } = await prompts({ + type: "text", + name: "value", + message: "Image ref (e.g. ghcr.io/me/api:v1):", + }); + if (!ref) throw new UserError("Image ref is required."); + imageRef = ref; + } + } + + const mode: "build" | "image" = dockerfilePath ? "build" : "image"; + + let registry: ResolvedRegistry | null; + if (input.registryFlag) { + registry = { id: input.registryFlag }; + } else if (mode === "image" && imageRef) { + registry = await resolveRegistryForImage(client, imageRef); + } else { + logger.info("Pick a registry to push your image to."); + registry = await promptRegistry(client); + } + if (!registry) throw new UserError("A registry is required."); + + let suggestions: Awaited> | null = + null; + if (mode === "image" && imageRef) { + const parsed = parseImageRef(imageRef); + suggestions = await getConfigSuggestions(client, registry.id, parsed); + if (suggestions?.instructions) { + logger.log(); + logger.dim(suggestions.instructions); + logger.log(); + } + } + + const name = + input.nameOverride ?? + (await promptAppName(suggestions?.appName ?? undefined)); + + const regions = await pickRegions(client); + + const container: ContainerConfig = { + registry: registry.id, + }; + + if (mode === "build" && dockerfilePath) { + container.dockerfile = dockerfilePath; + if (input.contextFlag) container.context = input.contextFlag; + } + if (imageRef) { + container.image = imageRef; + } + + if (suggestions?.endpointSuggestions?.length) { + const accepted = await confirmEndpointSuggestions( + suggestions.endpointSuggestions, + ); + if (accepted.length > 0) { + container.endpoints = accepted.map(endpointRequestToConfig); + } + } + + if (suggestions?.environmentVariablesSuggestions?.length) { + const env = await promptSuggestedEnv( + suggestions.environmentVariablesSuggestions, + ); + if (Object.keys(env).length > 0) { + container.env = env; + } + } + + // --port: retarget any endpoints in the container to the chosen port. + const portOverride = input.portOverride; + if (portOverride && container.endpoints) { + container.endpoints = container.endpoints.map((ep) => ({ + ...ep, + ports: ep.ports?.map((p) => ({ ...p, container: portOverride })), + })); + } + + // --command: split on whitespace and assign as exec-form CMD. + if (input.commandOverride) { + container.command = input.commandOverride.trim().split(/\s+/); + } + + const toml: BunnyAppConfig = { + version: CURRENT_VERSION, + app: { + name, + scaling: { min: 1, max: 1 }, + regions, + containers: { [name]: container }, + }, + }; + + if (!input.dryRun) { + saveConfig(toml, input.configPath); + logger.success("Wrote bunny.jsonc."); + } else { + logger.dim("Would write bunny.jsonc (--dry-run)."); + } + + return toml; +} + +/** + * Compose import path: translate `compose.yml` → `bunny.jsonc` and write. + * + * Compose carries the container topology, env, ports, and volumes. + * The user still has to choose: + * - app name (compose has no app-level name) + * - region (compose isn't region-aware) + * - default registry for all containers (assigned to each in translation) + */ +async function runComposeImport( + client: McClient, + composeFilePath: string, + input: WalkthroughInput, +): Promise { + const compose = loadComposeFile(composeFilePath); + + const serviceNames = Object.keys(compose.services); + logger.info( + `Importing ${serviceNames.length} service${serviceNames.length === 1 ? "" : "s"} from ${basename(composeFilePath)}: ${serviceNames.join(", ")}.`, + ); + + const name = input.nameOverride ?? (await promptAppName()); + + const regions = await pickRegions(client); + + let registry: ResolvedRegistry | null; + if (input.registryFlag) { + registry = { id: input.registryFlag }; + } else { + logger.info("Pick a registry to use for all containers."); + registry = await promptRegistry(client); + } + if (!registry) throw new UserError("A registry is required."); + + const { config, warnings } = composeToConfig(compose, { + composeFilePath, + appName: name, + regions, + defaultRegistryId: registry.id, + }); + + // --command override: if a single service exists, apply it; otherwise + // warn, because `--command` is ambiguous with multi-service compose files. + if (input.commandOverride) { + if (serviceNames.length === 1) { + const onlyName = serviceNames[0]; + if (onlyName) { + const primary = config.app.containers[onlyName]; + if (primary) { + primary.command = input.commandOverride.trim().split(/\s+/); + } + } + } else { + warnings.push( + "--command was ignored: pass it via the compose file when multiple services are present.", + ); + } + } + + for (const warning of warnings) logger.warn(warning); + + if (!input.dryRun) { + saveConfig(config, input.configPath); + logger.success("Wrote bunny.jsonc."); + } else { + logger.dim("Would write bunny.jsonc (--dry-run)."); + } + + return config; +} + +async function promptAppName(suggested?: string): Promise { + const initial = suggested?.trim() || basename(resolve(process.cwd())); + const { value } = await prompts({ + type: "text", + name: "value", + message: "App name:", + initial, + }); + if (!value) throw new UserError("App name is required."); + return value; +} + +async function pickRegions(client: McClient): Promise { + const spin = spinner("Fetching regions..."); + spin.start(); + const { data: regionsResult } = await client.GET("/regions"); + spin.stop(); + + const regionsWithCapacity = (regionsResult?.items ?? []).filter( + (r): r is typeof r & { id: string } => + r.hasCapacity === true && typeof r.id === "string", + ); + + if (regionsWithCapacity.length === 0) { + throw new UserError("No regions with capacity are available right now."); + } + + // MVP scope: single-region deploys. Users can edit `bunny.jsonc` to + // add more regions later: the schema already supports an array. + const { value: selectedRegion } = await prompts({ + type: "select", + name: "value", + message: "Region:", + choices: regionsWithCapacity.map((r) => ({ + title: `${r.name} (${r.id})`, + value: r.id, + })), + }); + + if (!selectedRegion) { + throw new UserError("A region must be selected."); + } + return [selectedRegion]; +} + +async function confirmEndpointSuggestions( + endpoints: EndpointRequest[], +): Promise { + const accepted: EndpointRequest[] = []; + for (const ep of endpoints) { + const label = describeEndpoint(ep); + const { value } = await prompts({ + type: "confirm", + name: "value", + message: `Add suggested endpoint: ${label}?`, + initial: true, + }); + if (value) accepted.push(ep); + } + return accepted; +} + +function describeEndpoint(ep: EndpointRequest): string { + if (ep.cdn) { + const ports = ep.cdn.portMappings + ?.map((p) => `${p.exposedPort}→${p.containerPort}`) + .join(", "); + return `CDN (${ports ?? "default port"})${ep.cdn.isSslEnabled ? " + SSL" : ""}`; + } + if (ep.anycast) { + const ports = ep.anycast.portMappings + .map((p) => `${p.exposedPort}→${p.containerPort}`) + .join(", "); + return `Anycast (${ports})`; + } + return ep.displayName ?? "endpoint"; +} + +function endpointRequestToConfig( + ep: EndpointRequest, +): NonNullable[number] { + if (ep.cdn) { + return { + type: "cdn", + ssl: ep.cdn.isSslEnabled, + ports: + ep.cdn.portMappings?.map((p) => ({ + public: p.exposedPort ?? p.containerPort, + container: p.containerPort, + })) ?? [], + }; + } + if (ep.anycast) { + return { + type: "anycast", + ports: ep.anycast.portMappings.map((p) => ({ + public: p.exposedPort ?? p.containerPort, + container: p.containerPort, + })), + }; + } + return { type: "cdn" }; +} + +async function promptSuggestedEnv( + suggestions: components["schemas"]["EnvironmentVariableSuggestion"][], +): Promise> { + const env: Record = {}; + const required = suggestions.filter((s) => s.required); + const optional = suggestions.filter((s) => !s.required); + + for (const item of required) { + if (!item.name) continue; + const { value } = await prompts({ + type: "text", + name: "value", + message: `${item.name}${item.description ? ` (${item.description})` : ""}:`, + initial: item.defaultValue ?? "", + }); + if (value !== undefined && value !== "") env[item.name] = String(value); + } + + if (optional.length > 0) { + const { value: confirm } = await prompts({ + type: "confirm", + name: "value", + message: `Configure ${optional.length} optional env var${optional.length === 1 ? "" : "s"} now?`, + initial: false, + }); + if (confirm) { + for (const item of optional) { + if (!item.name) continue; + const { value } = await prompts({ + type: "text", + name: "value", + message: `${item.name}${item.description ? ` (${item.description})` : ""}:`, + initial: item.defaultValue ?? "", + }); + if (value !== undefined && value !== "") env[item.name] = String(value); + } + } + } + + return env; +} diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 7aaa53b..2736c88 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -12,7 +12,7 @@ const DESCRIPTION = "Show the currently authenticated account."; /** * Display information about the currently authenticated account. * - * Verifies the configured API key against the Bunny API and shows the + * Verifies the configured API key against the bunny.net API and shows the * active profile, authentication source, and key roles. * * @example diff --git a/packages/cli/src/test-utils/temp-dir.ts b/packages/cli/src/test-utils/temp-dir.ts new file mode 100644 index 0000000..23cb9db --- /dev/null +++ b/packages/cli/src/test-utils/temp-dir.ts @@ -0,0 +1,16 @@ +import { afterEach, beforeEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export function useTempDir(prefix = "bunny-test-"): () => string { + let dir = ""; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), prefix)); + }); + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = ""; + }); + return () => dir; +} diff --git a/packages/database-adapter-libsql/README.md b/packages/database-adapter-libsql/README.md index 7071e32..24d68be 100644 --- a/packages/database-adapter-libsql/README.md +++ b/packages/database-adapter-libsql/README.md @@ -1,6 +1,6 @@ # @bunny.net/database-adapter-libsql -Bunny Database adapter for `@bunny.net/database-rest`. Provides a `DatabaseExecutor` implementation and database introspection for libSQL databases. +bunny.net Database adapter for `@bunny.net/database-rest`. Provides a `DatabaseExecutor` implementation and database introspection for libSQL databases. ## Install diff --git a/packages/database-rest/README.md b/packages/database-rest/README.md index 727bf0b..b7aa535 100644 --- a/packages/database-rest/README.md +++ b/packages/database-rest/README.md @@ -48,7 +48,7 @@ Requests must then carry `Authorization: Bearer `. See ``` database-openapi - schema types + OpenAPI spec generation database-rest - routing, query parsing, SQL building, handler -database-adapter-libsql - executor + introspection for Bunny Database (libSQL) +database-adapter-libsql - executor + introspection for bunny.net Database (libSQL) ``` The `DatabaseExecutor` interface is simple: diff --git a/packages/database-shell/README.md b/packages/database-shell/README.md index 04f55fa..aad6d39 100644 --- a/packages/database-shell/README.md +++ b/packages/database-shell/README.md @@ -2,7 +2,7 @@ Standalone, framework-agnostic interactive SQL shell for libSQL databases. Provides a readline-based REPL, dot-commands, multiple output formats, sensitive column masking, and persistent history. -Also powers `bunny db shell` in the [Bunny CLI](https://www.npmjs.com/package/@bunny.net/cli). +Also powers `bunny db shell` in the [bunny.net CLI](https://www.npmjs.com/package/@bunny.net/cli). ## Quick Start diff --git a/packages/openapi-client/src/compute-client.ts b/packages/openapi-client/src/compute-client.ts index 47e9fca..d479161 100644 --- a/packages/openapi-client/src/compute-client.ts +++ b/packages/openapi-client/src/compute-client.ts @@ -4,7 +4,7 @@ import { authMiddleware, type ClientOptions } from "./middleware.ts"; const COMPUTE_BASE_URL = "https://api.bunny.net"; -/** Create a type-safe client for the Bunny Edge Scripting (Compute) API. */ +/** Create a type-safe client for the Edge Scripting (Compute) API. */ export function createComputeClient(options: ClientOptions) { const client = createClient({ baseUrl: options.baseUrl ?? COMPUTE_BASE_URL, diff --git a/packages/openapi-client/src/core-client.ts b/packages/openapi-client/src/core-client.ts index ed5167a..46a20bd 100644 --- a/packages/openapi-client/src/core-client.ts +++ b/packages/openapi-client/src/core-client.ts @@ -27,7 +27,7 @@ type CustomPaths = { const CORE_BASE_URL = "https://api.bunny.net"; -/** Create a type-safe client for the Bunny Core API (CDN, DNS, storage zones, billing). */ +/** Create a type-safe client for the bunny.net Core API (CDN, DNS, storage zones, billing). */ export function createCoreClient(options: ClientOptions) { const client = createClient({ baseUrl: options.baseUrl ?? CORE_BASE_URL, diff --git a/packages/openapi-client/src/db-client.ts b/packages/openapi-client/src/db-client.ts index e1fcd57..36bdf14 100644 --- a/packages/openapi-client/src/db-client.ts +++ b/packages/openapi-client/src/db-client.ts @@ -4,7 +4,7 @@ import { authMiddleware, type ClientOptions } from "./middleware.ts"; const DB_BASE_URL = "https://api.bunny.net/database"; -/** Create a type-safe client for the Bunny Database API. */ +/** Create a type-safe client for the bunny.net Database API. */ export function createDbClient(options: ClientOptions) { const client = createClient({ baseUrl: options.baseUrl ?? DB_BASE_URL, diff --git a/packages/openapi-client/src/errors.ts b/packages/openapi-client/src/errors.ts index 620361e..063831c 100644 --- a/packages/openapi-client/src/errors.ts +++ b/packages/openapi-client/src/errors.ts @@ -17,7 +17,7 @@ export class UserError extends Error { } /** - * Error from a Bunny API HTTP response. Thrown automatically by + * Error from a bunny.net API HTTP response. Thrown automatically by * {@link authMiddleware} when a response has a non-OK status. * * Normalizes two different API error formats: diff --git a/packages/openapi-client/src/mc-client.ts b/packages/openapi-client/src/mc-client.ts index 981dd58..a42d89f 100644 --- a/packages/openapi-client/src/mc-client.ts +++ b/packages/openapi-client/src/mc-client.ts @@ -4,7 +4,7 @@ import { authMiddleware, type ClientOptions } from "./middleware.ts"; const MC_BASE_URL = "https://api.bunny.net/mc"; -/** Create a type-safe client for the Bunny Magic Containers API. */ +/** Create a type-safe client for the Magic Containers API. */ export function createMcClient(options: ClientOptions) { const client = createClient({ baseUrl: options.baseUrl ?? MC_BASE_URL, diff --git a/packages/openapi-client/src/middleware.ts b/packages/openapi-client/src/middleware.ts index 35e60b4..6650741 100644 --- a/packages/openapi-client/src/middleware.ts +++ b/packages/openapi-client/src/middleware.ts @@ -40,12 +40,12 @@ const extractors: Array< ]; /** - * Shared openapi-fetch middleware for all Bunny API clients. + * Shared openapi-fetch middleware for all bunny.net API clients. * * **Request**: Injects `AccessKey` and `User-Agent` headers. * * **Response**: Intercepts non-OK responses and throws {@link ApiError}, - * normalizing the two different error formats used across Bunny APIs: + * normalizing the two different error formats used across bunny.net APIs: * * - **Core / Compute** use `ApiErrorData` (`{ ErrorKey, Field, Message }`). * Only 400 responses have a JSON body; 401/404/500 are empty. diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index b6996ee..bb3c895 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -1,11 +1,11 @@ --- name: bunny-cli -description: Manage bunny.net resources from the command line — databases, authentication, and raw API requests. Use when working with bunny.net (pullzones, databases, storage, magic containers), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. +description: Manage bunny.net resources from the command line (databases, authentication, and raw API requests). Use when working with bunny.net (pullzones, databases, storage, Magic Containers), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. --- -# Bunny CLI Skill +# bunny.net CLI Skill -The Bunny CLI (`bunny`) manages bunny.net resources from the command line. Use `bunny --help` for full flag details on any command. +The bunny.net CLI (`bunny`) manages bunny.net resources from the command line. Use `bunny --help` for full flag details on any command. ## Critical: Authentication From 76dd1735146d791b641b7c85681e3f81806d114e Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 21 May 2026 09:39:56 +0100 Subject: [PATCH 09/11] feat(apps): resolve env values from .env via pointer references --- packages/app-config/src/convert.ts | 1 + .../app-config/src/parse-image-ref.test.ts | 18 +- packages/app-config/src/parse-image-ref.ts | 18 +- .../commands/apps/compose/translate.test.ts | 9 +- .../src/commands/apps/compose/translate.ts | 14 +- packages/cli/src/commands/apps/deploy.ts | 196 +++++++++++++++++- .../cli/src/commands/apps/env/resolve.test.ts | 125 +++++++++++ packages/cli/src/commands/apps/env/resolve.ts | 48 +++++ 8 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/commands/apps/env/resolve.test.ts create mode 100644 packages/cli/src/commands/apps/env/resolve.ts diff --git a/packages/app-config/src/convert.ts b/packages/app-config/src/convert.ts index 4745312..34245b6 100644 --- a/packages/app-config/src/convert.ts +++ b/packages/app-config/src/convert.ts @@ -120,6 +120,7 @@ function containerConfigToRequest( imageNamespace, imageTag, imageRegistryId: config.registry ?? "", + imagePullPolicy: "ifNotPresent", }; if (config.command) { diff --git a/packages/app-config/src/parse-image-ref.test.ts b/packages/app-config/src/parse-image-ref.test.ts index a62721a..9e73a70 100644 --- a/packages/app-config/src/parse-image-ref.test.ts +++ b/packages/app-config/src/parse-image-ref.test.ts @@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test"; import { parseImageRef } from "./parse-image-ref.ts"; describe("parseImageRef", () => { - test("bare image name defaults tag to 'latest'", () => { + test("bare image name defaults tag to 'latest' and namespace to 'library'", () => { expect(parseImageRef("nginx")).toEqual({ imageName: "nginx", - imageNamespace: "", + imageNamespace: "library", imageTag: "latest", }); }); @@ -18,10 +18,10 @@ describe("parseImageRef", () => { }); }); - test("bare image with simple tag", () => { + test("bare image with simple tag defaults namespace to 'library'", () => { expect(parseImageRef("nginx:1.27")).toEqual({ imageName: "nginx", - imageNamespace: "", + imageNamespace: "library", imageTag: "1.27", }); }); @@ -29,11 +29,19 @@ describe("parseImageRef", () => { test("tag containing dots is preserved", () => { expect(parseImageRef("nginx:1.27.0-alpine")).toEqual({ imageName: "nginx", - imageNamespace: "", + imageNamespace: "library", imageTag: "1.27.0-alpine", }); }); + test("empty ref returns all empty fields", () => { + expect(parseImageRef("")).toEqual({ + imageName: "", + imageNamespace: "", + imageTag: "", + }); + }); + test("two-segment namespace and tag", () => { expect(parseImageRef("me/api:v1")).toEqual({ imageName: "api", diff --git a/packages/app-config/src/parse-image-ref.ts b/packages/app-config/src/parse-image-ref.ts index a31f34e..ff27e53 100644 --- a/packages/app-config/src/parse-image-ref.ts +++ b/packages/app-config/src/parse-image-ref.ts @@ -4,6 +4,10 @@ export function parseImageRef(ref: string): { imageNamespace: string; imageTag: string; } { + if (!ref) { + return { imageName: "", imageNamespace: "", imageTag: "" }; + } + let tag = "latest"; let imagePath = ref; @@ -15,8 +19,18 @@ export function parseImageRef(ref: string): { const parts = imagePath.split("/"); const name = parts.pop() ?? imagePath; - const namespace = - parts.length > 1 ? parts.slice(1).join("/") : (parts[0] ?? ""); + + // Bare names (no slash) refer to Docker Hub's `library` namespace + // (e.g. `postgres` → `library/postgres`). The MC API rejects empty + // imageNamespace, so make the convention explicit. + let namespace: string; + if (parts.length === 0) { + namespace = "library"; + } else if (parts.length > 1) { + namespace = parts.slice(1).join("/"); + } else { + namespace = parts[0] ?? ""; + } return { imageName: name, imageNamespace: namespace, imageTag: tag }; } diff --git a/packages/cli/src/commands/apps/compose/translate.test.ts b/packages/cli/src/commands/apps/compose/translate.test.ts index 74fd3e5..75db9bf 100644 --- a/packages/cli/src/commands/apps/compose/translate.test.ts +++ b/packages/cli/src/commands/apps/compose/translate.test.ts @@ -89,7 +89,10 @@ describe("composeToConfig", () => { }); }); - test("env_file merges with environment (environment overrides)", () => { + test("env_file keys become pointers; environment literals override", () => { + // env_file values stay in .env (gitignored). bunny.jsonc only carries + // the *keys* as pointers ("K": "K") that get resolved at deploy time. + // environment: literals copy as-is and win over env_file pointers. writeFileSync(join(tempDir(), "shared.env"), "PORT=3000\nDB_URL=local\n"); const result = callTranslate({ services: { @@ -101,7 +104,7 @@ describe("composeToConfig", () => { }, }); expect(result.config.app.containers.api?.env).toEqual({ - PORT: "3000", + PORT: "PORT", DB_URL: "prod", }); }); @@ -162,7 +165,7 @@ describe("composeToConfig", () => { volumes: { data: {} }, }); expect(result.config.app.containers.api?.volumes).toEqual([ - { name: "data", mount: "/var/lib/data", size: 1024 }, + { name: "data", mount: "/var/lib/data", size: 1 }, ]); }); diff --git a/packages/cli/src/commands/apps/compose/translate.ts b/packages/cli/src/commands/apps/compose/translate.ts index da2bb9c..452a448 100644 --- a/packages/cli/src/commands/apps/compose/translate.ts +++ b/packages/cli/src/commands/apps/compose/translate.ts @@ -164,6 +164,11 @@ function translateService( } // Environment: env_file first, then `environment:` overrides per the spec. + // + // env_file entries become pointers — only the *keys* go into bunny.jsonc + // (`"K": "K"`), so the values stay in `.env` (which is gitignored) and + // get resolved at deploy time. `environment:` values are inline literals + // and copy as-is; if a key appears in both, the literal wins. const env: Record = {}; if (service.env_file !== undefined) { const files = Array.isArray(service.env_file) @@ -176,7 +181,10 @@ function translateService( `Service "${name}": env_file \`${file}\` not found at ${path}.`, ); } - Object.assign(env, parseDotenv(readFileSync(path, "utf-8"))); + const fileVars = parseDotenv(readFileSync(path, "utf-8")); + for (const key of Object.keys(fileVars)) { + env[key] = key; + } } } if (service.environment !== undefined) { @@ -303,10 +311,10 @@ function parseVolumeEntry( /** * Compose doesn't carry a size for volumes; they're created on demand - * by the local docker daemon at whatever size. MC requires a number. + * by the local docker daemon at whatever size. MC requires GiB in [1, 100]. * Default to 1 GiB and let users edit `bunny.jsonc` if they want more. */ -const DEFAULT_VOLUME_SIZE = 1024; +const DEFAULT_VOLUME_SIZE = 1; function sizeForVolume( name: string, topLevelVolumes: Record, diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 2e000d3..5851635 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -27,6 +27,7 @@ import { type ResolvedRegistry, resolveRegistryForImage, } from "./docker.ts"; +import { resolveContainerEnv } from "./env/resolve.ts"; import { runWalkthrough } from "./walkthrough.ts"; const COMMAND = "deploy [image]"; @@ -136,6 +137,13 @@ export const appsDeployCommand = defineCommand({ ? resolve(process.cwd(), args.config) : undefined; + // `.env` lives next to bunny.jsonc. Container env values that match a + // key in this file are resolved at deploy time; everything else is + // sent literally. See resolveContainerEnv for the full rule. + const dotenvPath = configPath + ? resolve(dirname(configPath), ".env") + : resolve(process.cwd(), ".env"); + if (positionalImage && dockerfileFlag) { throw new UserError( "Cannot use both and --dockerfile at the same time.", @@ -174,6 +182,55 @@ export const appsDeployCommand = defineCommand({ return; } + // First-time deploy of a multi-container app (e.g. compose import). + // The single-container flow below can only build/push one image, so + // for multi-container creates we iterate every container, build any + // with a `dockerfile`, resolve a registry for any with a pre-built + // `image`, then create the app in one POST. + const containerEntries = Object.entries(toml.app.containers); + if ( + !toml.app.id && + containerEntries.length > 1 && + !positionalImage && + !dockerfileFlag && + !args.container + ) { + await prepareContainersForCreate(client, toml, configPath, { + tag: args.tag, + contextOverride: args.context, + }); + + const createSpin = spinner("Creating app..."); + createSpin.start(); + const { data: result } = await client.POST("/apps", { + body: configToAddRequest(resolveContainerEnv(toml, dotenvPath)), + }); + createSpin.stop(); + + if (!result?.id) { + throw new UserError("Failed to create app — no ID returned."); + } + + toml.app.id = result.id; + saveConfig(toml, configPath); + logger.success(`App "${toml.app.name}" created (${result.id}).`); + + const deploySpin = spinner("Deploying..."); + deploySpin.start(); + await client.POST("/apps/{appId}/deploy", { + params: { path: { appId: result.id } }, + }); + deploySpin.stop(); + + if (output === "json") { + logger.log(JSON.stringify({ id: result.id, deployed: true })); + return; + } + + logger.success("App deployed."); + return; + } + const [targetName, targetContainer] = resolveTargetContainer(toml, { explicit: args.container, hasImageOrBuild: Boolean(positionalImage || dockerfileFlag), @@ -295,7 +352,7 @@ export const appsDeployCommand = defineCommand({ createSpin.start(); const { data: result } = await client.POST("/apps", { - body: configToAddRequest(toml), + body: configToAddRequest(resolveContainerEnv(toml, dotenvPath)), }); createSpin.stop(); @@ -323,7 +380,10 @@ export const appsDeployCommand = defineCommand({ await client.PATCH("/apps/{appId}", { params: { path: { appId } }, - body: configToPatchRequest(toml, existingApp), + body: configToPatchRequest( + resolveContainerEnv(toml, dotenvPath), + existingApp, + ), }); pushSpin.stop(); } @@ -512,3 +572,135 @@ function resolveBuildContext( : resolve(process.cwd(), dockerfile); return dirname(absDockerfile); } + +/** + * Walk every container in bunny.jsonc and prepare it for `POST /apps`: + * + * - `dockerfile`-only entries → build + push to the configured registry, + * then write the resulting image ref back onto the container. + * - Pre-built `image` entries → resolve the matching registry record on + * the user's account (compose import assigns the user's push registry + * to every container by default, which is wrong for public images like + * `postgres:17-alpine`). + * + * The toml is mutated in place and re-saved after every container so + * partial progress survives a mid-loop failure. + */ +async function prepareContainersForCreate( + client: ReturnType, + toml: BunnyAppConfig, + configPath: string | undefined, + opts: { tag?: string; contextOverride?: string }, +): Promise { + const entries = Object.entries(toml.app.containers); + const hasAnyBuild = entries.some(([, c]) => c.dockerfile); + if (hasAnyBuild) { + await ensureDockerAvailable(); + } + + // One shared tag for every build in this deploy so co-deployed images + // are easy to correlate later (same git sha + timestamp). + const sharedTag = opts.tag ?? (await generateTag()); + + for (const [name, container] of entries) { + if (container.dockerfile) { + await buildAndPushContainer(client, toml, name, container, { + tag: sharedTag, + contextOverride: opts.contextOverride, + }); + } else if (container.image) { + await resolveContainerRegistry(client, name, container); + } else { + throw new UserError( + `Container "${name}" has neither \`image\` nor \`dockerfile\`.`, + "Add one or the other in bunny.jsonc.", + ); + } + saveConfig(toml, configPath); + } +} + +async function buildAndPushContainer( + client: ReturnType, + toml: BunnyAppConfig, + name: string, + container: ContainerConfig, + opts: { tag: string; contextOverride?: string }, +): Promise { + if (!container.dockerfile) return; + + let freshCreds: ResolvedRegistry["freshCredentials"]; + if (!container.registry) { + logger.info(`Pick a registry to push the "${name}" image to.`); + const resolved = await promptRegistry(client); + if (!resolved) { + throw new UserError( + `A registry is required to build and push "${name}".`, + ); + } + container.registry = resolved.id; + freshCreds = resolved.freshCredentials; + } + + const regSpin = spinner(`Fetching registry for ${name}...`); + regSpin.start(); + const { data: reg } = await client.GET("/registries/{registryId}", { + params: { path: { registryId: Number(container.registry) } }, + }); + regSpin.stop(); + + if (!reg?.hostName) { + throw new UserError( + `Registry ${container.registry} not found or has no hostname.`, + "Use `bunny registries list` to check your registries.", + ); + } + + const imageRef = `${reg.hostName}/${toml.app.name}-${name}:${opts.tag}`; + const buildCwd = resolveBuildContext( + container.dockerfile, + container.context ?? opts.contextOverride, + ); + + logger.info(`Building ${imageRef}...`); + await buildImage(container.dockerfile, imageRef, buildCwd); + + if (freshCreds && reg.hostName) { + const loginSpin = spinner(`Logging in to ${reg.hostName}...`); + loginSpin.start(); + try { + await dockerLogin(reg.hostName, freshCreds.userName, freshCreds.password); + loginSpin.stop(); + } catch (err) { + loginSpin.stop(); + throw err; + } + } else if (reg.hostName) { + await ensureRegistryLogin(reg.hostName); + } + + logger.info(`Pushing ${imageRef}...`); + await pushImage(imageRef); + + container.image = imageRef; +} + +async function resolveContainerRegistry( + client: ReturnType, + name: string, + container: ContainerConfig, +): Promise { + if (!container.image) return; + + // Always resolve by hostname for pre-built images: the registry + // currently set on the container may be the user's push registry, + // which is wrong for images from other hosts (Docker Hub, etc.). + const resolved = await resolveRegistryForImage(client, container.image); + if (!resolved) { + throw new UserError( + `A registry is required for "${name}" (image: ${container.image}).`, + "bunny.net needs a registry record for the image hostname so it can pull the image.", + ); + } + container.registry = resolved.id; +} diff --git a/packages/cli/src/commands/apps/env/resolve.test.ts b/packages/cli/src/commands/apps/env/resolve.test.ts new file mode 100644 index 0000000..e06397d --- /dev/null +++ b/packages/cli/src/commands/apps/env/resolve.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { CURRENT_VERSION } from "@bunny.net/app-config"; +import { useTempDir } from "../../../test-utils/temp-dir.ts"; +import type { BunnyAppConfig } from "../config.ts"; +import { resolveContainerEnv } from "./resolve.ts"; + +const tempDir = useTempDir("bunny-env-resolve-"); + +function makeConfig(env: Record): BunnyAppConfig { + return { + version: CURRENT_VERSION, + app: { + name: "x", + containers: { api: { image: "nginx", env } }, + }, + }; +} + +describe("resolveContainerEnv", () => { + test("resolves values that match a key in .env", () => { + const dotenv = join(tempDir(), ".env"); + writeFileSync(dotenv, "BETTER_AUTH_SECRET=topsecret\n"); + + const resolved = resolveContainerEnv( + makeConfig({ BETTER_AUTH_SECRET: "BETTER_AUTH_SECRET" }), + dotenv, + ); + + expect(resolved.app.containers.api?.env).toEqual({ + BETTER_AUTH_SECRET: "topsecret", + }); + }); + + test("passes literal values through unchanged when no key matches", () => { + const dotenv = join(tempDir(), ".env"); + writeFileSync(dotenv, "OTHER=x\n"); + + const resolved = resolveContainerEnv( + makeConfig({ POSTGRES_DB: "orbit", PORT: "3000" }), + dotenv, + ); + + expect(resolved.app.containers.api?.env).toEqual({ + POSTGRES_DB: "orbit", + PORT: "3000", + }); + }); + + test("supports renaming: LHS is container key, RHS is .env key", () => { + const dotenv = join(tempDir(), ".env"); + writeFileSync(dotenv, "PROD_DATABASE_URL=postgres://prod/db\n"); + + const resolved = resolveContainerEnv( + makeConfig({ DATABASE_URL: "PROD_DATABASE_URL" }), + dotenv, + ); + + expect(resolved.app.containers.api?.env).toEqual({ + DATABASE_URL: "postgres://prod/db", + }); + }); + + test("missing .env file leaves all values literal", () => { + const resolved = resolveContainerEnv( + makeConfig({ POSTGRES_DB: "orbit", BETTER_AUTH_SECRET: "secret" }), + join(tempDir(), ".env"), + ); + + expect(resolved.app.containers.api?.env).toEqual({ + POSTGRES_DB: "orbit", + BETTER_AUTH_SECRET: "secret", + }); + }); + + test("does not mutate the input config", () => { + const dotenv = join(tempDir(), ".env"); + writeFileSync(dotenv, "BETTER_AUTH_SECRET=topsecret\n"); + + const input = makeConfig({ BETTER_AUTH_SECRET: "BETTER_AUTH_SECRET" }); + resolveContainerEnv(input, dotenv); + + // Original config still carries the pointer, not the resolved value. + expect(input.app.containers.api?.env).toEqual({ + BETTER_AUTH_SECRET: "BETTER_AUTH_SECRET", + }); + }); + + test("mixes literals and pointers in the same env block", () => { + const dotenv = join(tempDir(), ".env"); + writeFileSync(dotenv, "BETTER_AUTH_SECRET=topsecret\nDATABASE_URL=pgurl\n"); + + const resolved = resolveContainerEnv( + makeConfig({ + POSTGRES_DB: "orbit", + BETTER_AUTH_SECRET: "BETTER_AUTH_SECRET", + DATABASE_URL: "DATABASE_URL", + }), + dotenv, + ); + + expect(resolved.app.containers.api?.env).toEqual({ + POSTGRES_DB: "orbit", + BETTER_AUTH_SECRET: "topsecret", + DATABASE_URL: "pgurl", + }); + }); + + test("containers without env are left untouched", () => { + const dotenv = join(tempDir(), ".env"); + writeFileSync(dotenv, "FOO=bar\n"); + + const input: BunnyAppConfig = { + version: CURRENT_VERSION, + app: { + name: "x", + containers: { api: { image: "nginx" } }, + }, + }; + + const resolved = resolveContainerEnv(input, dotenv); + expect(resolved.app.containers.api?.env).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/apps/env/resolve.ts b/packages/cli/src/commands/apps/env/resolve.ts new file mode 100644 index 0000000..46006c1 --- /dev/null +++ b/packages/cli/src/commands/apps/env/resolve.ts @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from "node:fs"; +import type { BunnyAppConfig } from "../config.ts"; +import { parseDotenv } from "./parse.ts"; + +/** + * Resolution rule for `env` values in `bunny.jsonc`: + * + * For each [key, value] in a container's env map, if `value` is the + * name of a key in the local `.env` file, send the resolved value to + * the API. Otherwise, send `value` literally. + * + * This lets `bunny.jsonc` carry pointers like: + * + * "env": { + * "POSTGRES_DB": "orbit", // literal + * "BETTER_AUTH_SECRET": "BETTER_AUTH_SECRET", // resolved from .env + * "DATABASE_URL": "PROD_DATABASE_URL" // resolved + renamed + * } + * + * with no syntax to learn. Collisions (literal value happening to match a + * `.env` key) are rare in practice because literals tend to be short, + * non-identifier-shaped strings. + * + * Process env is intentionally not consulted — it carries unrelated + * shell vars (PATH, HOME, …) that would silently rewrite literals. + */ +export function resolveContainerEnv( + toml: BunnyAppConfig, + dotenvPath: string, +): BunnyAppConfig { + const dotenv = existsSync(dotenvPath) + ? parseDotenv(readFileSync(dotenvPath, "utf-8")) + : {}; + + // Deep-clone so the resolved values never get written back to bunny.jsonc. + const copy: BunnyAppConfig = structuredClone(toml); + + for (const container of Object.values(copy.app.containers)) { + if (!container.env) continue; + for (const [key, value] of Object.entries(container.env)) { + if (value in dotenv) { + container.env[key] = dotenv[value] ?? ""; + } + } + } + + return copy; +} From 2e017a45d31959bcb4c45bec1b90b066e91e7a21 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 21 May 2026 15:37:29 +0100 Subject: [PATCH 10/11] Bunny Database --- AGENTS.md | 2 +- README.md | 2 +- packages/app-config/src/schema.ts | 5 - packages/cli/src/commands/apps/APPS.md | 116 +++++++++--------- packages/database-adapter-libsql/README.md | 2 +- packages/database-rest/README.md | 2 +- .../database-shell-darwin-arm64/package.json | 2 +- .../database-shell-darwin-x64/package.json | 2 +- .../database-shell-linux-arm64/package.json | 2 +- .../database-shell-linux-x64/package.json | 2 +- .../database-shell-windows-x64/package.json | 2 +- packages/openapi-client/src/db-client.ts | 2 +- 12 files changed, 68 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c36563..863f836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1104,7 +1104,7 @@ Schemas and types are defined in `@bunny.net/app-config` using Zod. The CLI's `c ### Overview -The database shell is an interactive SQL REPL that connects to a bunny.net database via `@libsql/client`. It supports both interactive mode (readline-based REPL) and non-interactive mode (execute a query and exit). +The database shell is an interactive SQL REPL that connects to a Bunny Database via `@libsql/client`. It supports both interactive mode (readline-based REPL) and non-interactive mode (execute a query and exit). ### Architecture diff --git a/README.md b/README.md index 3fe05ff..01071db 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Monorepo for the [bunny.net](https://bunny.net) CLI and supporting packages. | [`packages/database-shell/`](packages/database-shell/) | `@bunny.net/database-shell` | Standalone interactive SQL shell for libSQL databases | | [`packages/database-openapi/`](packages/database-openapi/) | `@bunny.net/database-openapi` | Generate OpenAPI 3.0 specs from a database schema | | [`packages/database-rest/`](packages/database-rest/) | `@bunny.net/database-rest` | PostgREST-like REST API handler (database-agnostic) | -| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | bunny.net Database adapter for database-rest | +| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | Bunny Database adapter for database-rest | See each package's README for usage and API documentation. diff --git a/packages/app-config/src/schema.ts b/packages/app-config/src/schema.ts index 5aa394a..7c7421d 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/app-config/src/schema.ts @@ -1,10 +1,5 @@ import { z } from "zod"; -// The config file carries a `version` field so we can evolve the shape -// without breaking existing files. Versions are ISO date strings -// (lexicographically sortable) — when something changes, bump this and -// add a migration in `migrate.ts`. - export const CURRENT_VERSION = "2026-05-11"; const VersionSchema = z diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 46dacb8..c073da8 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -23,11 +23,11 @@ bunny apps push Deploy an app. Three modes, chosen by what you pass: -| You pass… | What happens | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | -| `` positional | Skip build. Resolve a registry record for the image hostname (prompting if needed) and deploy the ref. | -| `--dockerfile [path]` | Build from the Dockerfile (defaults to `./Dockerfile`), push to a registry, then deploy. | -| Neither | Consult `bunny.jsonc`. If `dockerfile` is set on the container, build; otherwise re-deploy the pinned `image`. | +| You pass… | What happens | +| --------------------- | -------------------------------------------------------------------------------------------------------------- | +| `` positional | Skip build. Resolve a registry record for the image hostname (prompting if needed) and deploy the ref. | +| `--dockerfile [path]` | Build from the Dockerfile (defaults to `./Dockerfile`), push to a registry, then deploy. | +| Neither | Consult `bunny.jsonc`. If `dockerfile` is set on the container, build; otherwise re-deploy the pinned `image`. | `` and `--dockerfile` are mutually exclusive. @@ -53,16 +53,16 @@ When you run `bunny apps deploy` in a project containing a `compose.yml` (or `co What translates cleanly: -| Compose | bunny.jsonc | -| --------------------------------------- | ------------------------------------------------------------ | -| `services.` | `app.containers.` | -| `image:` / `build:` | `container.image` / `container.dockerfile` + `container.context` | -| `command:` (string or array) | `container.command` (array form) | -| `environment:` (map or array) | `container.env` | -| `env_file:` (one or many) | parsed and merged into `container.env` (env vars in `environment:` override) | -| `ports:` (`"3000:80"` / object / IP-prefixed) | `container.endpoints[0]` (one CDN endpoint, multiple port mappings) | -| `volumes:` (named only) | `container.volumes` | -| `healthcheck:` | `container.probes.liveness` (HTTP probe if `test` mentions a URL; TCP otherwise) | +| Compose | bunny.jsonc | +| --------------------------------------------- | -------------------------------------------------------------------------------- | +| `services.` | `app.containers.` | +| `image:` / `build:` | `container.image` / `container.dockerfile` + `container.context` | +| `command:` (string or array) | `container.command` (array form) | +| `environment:` (map or array) | `container.env` | +| `env_file:` (one or many) | parsed and merged into `container.env` (env vars in `environment:` override) | +| `ports:` (`"3000:80"` / object / IP-prefixed) | `container.endpoints[0]` (one CDN endpoint, multiple port mappings) | +| `volumes:` (named only) | `container.volumes` | +| `healthcheck:` | `container.probes.liveness` (HTTP probe if `test` mentions a URL; TCP otherwise) | What's ignored (warning is printed, translation proceeds): @@ -85,20 +85,20 @@ For compose files that have just one service, the import overlaps with the Docke ### Deploy flags -| Flag | Description | -| ---------------- | ----------------------------------------------------------------------------------------------------------------- | -| `` | Container image reference to deploy (e.g. `ghcr.io/me/api:v1.2`). Skips build. | -| `--name` | App name. Used during the first-run walkthrough; skips the interactive prompt. | -| `--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`. | -| `--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. | -| `--config` | Use this file as the app config instead of cwd's `bunny.jsonc`. Useful in CI / agent flows. | -| `--dry-run` | Run the walkthrough and print the would-be `bunny.jsonc` without writing anything or contacting the API. | -| `--no-push` | Build only. Skip pushing the image and skip the deploy. | +| Flag | Description | +| -------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `` | Container image reference to deploy (e.g. `ghcr.io/me/api:v1.2`). Skips build. | +| `--name` | App name. Used during the first-run walkthrough; skips the interactive prompt. | +| `--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`. | +| `--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. | +| `--config` | Use this file as the app config instead of cwd's `bunny.jsonc`. Useful in CI / agent flows. | +| `--dry-run` | Run the walkthrough and print the would-be `bunny.jsonc` without writing anything or contacting the API. | +| `--no-push` | Build only. Skip pushing the image and skip the deploy. | ```bash # Deploy a pre-built image @@ -179,15 +179,15 @@ bunny apps init ghcr.io/me/api:v1 bunny apps init --dockerfile --port 8080 ``` -| Flag | Description | -| -------------- | -------------------------------------------------------------------------------------------------------- | -| `` | Pre-built image reference. Skips the build-vs-image prompt. | -| `--name` | App name (skips the interactive name prompt). | -| `--dockerfile` | Build from a Dockerfile. Pass a path or use the bare flag for `./Dockerfile`. | -| `--registry` | bunny.net registry ID to push to. | -| `--port` | Override the container port (affects the generated Dockerfile and the CDN endpoint). | -| `--command` | Override the container CMD (passed as a single string, split on whitespace). | -| `--config` | Write the config to this path instead of `./bunny.jsonc`. | +| Flag | Description | +| -------------- | ------------------------------------------------------------------------------------ | +| `` | Pre-built image reference. Skips the build-vs-image prompt. | +| `--name` | App name (skips the interactive name prompt). | +| `--dockerfile` | Build from a Dockerfile. Pass a path or use the bare flag for `./Dockerfile`. | +| `--registry` | bunny.net registry ID to push to. | +| `--port` | Override the container port (affects the generated Dockerfile and the CDN endpoint). | +| `--command` | Override the container CMD (passed as a single string, split on whitespace). | +| `--config` | Write the config to this path instead of `./bunny.jsonc`. | Most users can skip `init` entirely and run `bunny apps deploy` straight away. Both share the same walkthrough. @@ -251,11 +251,11 @@ bunny apps env push .env.prod --replace bunny apps env push --dry-run ``` -| Flag | Description | -| ------------- | ---------------------------------------------------------------------------------------------------- | -| `--container` | Target container (default: primary) | +| Flag | Description | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| `--container` | Target container (default: primary) | | `--replace` | (`push` only) Drop remote variables that aren't in the file. Default is merge (file overrides remote). | -| `--dry-run` | (`push` only) Print the diff (`+ added`, `~ changed`, `- removed`) without writing. | +| `--dry-run` | (`push` only) Print the diff (`+ added`, `~ changed`, `- removed`) without writing. | ## `bunny apps endpoints` @@ -296,23 +296,23 @@ A single-container app: "$schema": "./node_modules/@bunny.net/app-config/generated/schema.json", "version": "2026-05-11", "app": { - "id": "app_xxx", // written by the CLI on first deploy + "id": "app_xxx", // written by the CLI on first deploy "name": "my-api", - "regions": ["sfo", "lhr"], // simple array + "regions": ["sfo", "lhr"], // simple array "scaling": { "min": 1, "max": 3 }, "containers": { "api": { - "image": "ghcr.io/me/api:v1", // last deployed; rewritten each deploy - "registry": "12345", // bunny registry id - "dockerfile": "Dockerfile", // optional — enables the build path - "context": ".", // optional — Docker build context + "image": "ghcr.io/me/api:v1", // last deployed; rewritten each deploy + "registry": "12345", // bunny registry id + "dockerfile": "Dockerfile", // optional — enables the build path + "context": ".", // optional — Docker build context "env": { "PORT": "3000" }, "endpoints": [ - { "type": "cdn", "ssl": true, "ports": [{ "public": 443, "container": 3000 }] } - ] - } - } - } + { "type": "cdn", "ssl": true, "ports": [{ "public": 443, "container": 3000 }] }, + ], + }, + }, + }, } ``` @@ -328,14 +328,14 @@ A multi-container app — every container is its own entry in `app.containers`. "api": { "image": "ghcr.io/me/api:v1", "registry": "12345", - "env": { "DB_URL": "postgres://db:5432/app" } + "env": { "DB_URL": "postgres://db:5432/app" }, }, "db": { "image": "postgres:16", - "env": { "POSTGRES_PASSWORD": "..." } - } - } - } + "env": { "POSTGRES_PASSWORD": "..." }, + }, + }, + }, } ``` diff --git a/packages/database-adapter-libsql/README.md b/packages/database-adapter-libsql/README.md index 46c59a1..783e6d9 100644 --- a/packages/database-adapter-libsql/README.md +++ b/packages/database-adapter-libsql/README.md @@ -1,6 +1,6 @@ # @bunny.net/database-adapter-libsql -bunny.net Database adapter for `@bunny.net/database-rest`. Provides a `DatabaseExecutor` implementation and database introspection for libSQL databases. +Bunny Database adapter for `@bunny.net/database-rest`. Provides a `DatabaseExecutor` implementation and database introspection for libSQL databases. ## Install diff --git a/packages/database-rest/README.md b/packages/database-rest/README.md index b2382a6..dabadb7 100644 --- a/packages/database-rest/README.md +++ b/packages/database-rest/README.md @@ -52,7 +52,7 @@ Requests must then carry `Authorization: Bearer `. See ``` database-openapi - schema types + OpenAPI spec generation database-rest - routing, query parsing, SQL building, handler -database-adapter-libsql - executor + introspection for bunny.net Database (libSQL) +database-adapter-libsql - executor + introspection for Bunny Database (libSQL) ``` The `DatabaseExecutor` interface is simple: diff --git a/packages/database-shell-darwin-arm64/package.json b/packages/database-shell-darwin-arm64/package.json index ba496eb..71c118f 100644 --- a/packages/database-shell-darwin-arm64/package.json +++ b/packages/database-shell-darwin-arm64/package.json @@ -1,7 +1,7 @@ { "name": "@bunny.net/database-shell-darwin-arm64", "version": "0.2.3", - "description": "bunny.net database shell binary for macOS arm64", + "description": "Bunny Database shell binary for macOS arm64", "os": [ "darwin" ], diff --git a/packages/database-shell-darwin-x64/package.json b/packages/database-shell-darwin-x64/package.json index 510827f..d9cc641 100644 --- a/packages/database-shell-darwin-x64/package.json +++ b/packages/database-shell-darwin-x64/package.json @@ -1,7 +1,7 @@ { "name": "@bunny.net/database-shell-darwin-x64", "version": "0.2.3", - "description": "bunny.net database shell binary for macOS x64", + "description": "Bunny Database shell binary for macOS x64", "os": [ "darwin" ], diff --git a/packages/database-shell-linux-arm64/package.json b/packages/database-shell-linux-arm64/package.json index c1dd567..c99afe4 100644 --- a/packages/database-shell-linux-arm64/package.json +++ b/packages/database-shell-linux-arm64/package.json @@ -1,7 +1,7 @@ { "name": "@bunny.net/database-shell-linux-arm64", "version": "0.2.3", - "description": "bunny.net database shell binary for Linux arm64", + "description": "Bunny Database shell binary for Linux arm64", "os": [ "linux" ], diff --git a/packages/database-shell-linux-x64/package.json b/packages/database-shell-linux-x64/package.json index 289dba6..5a7a06b 100644 --- a/packages/database-shell-linux-x64/package.json +++ b/packages/database-shell-linux-x64/package.json @@ -1,7 +1,7 @@ { "name": "@bunny.net/database-shell-linux-x64", "version": "0.2.3", - "description": "bunny.net database shell binary for Linux x64", + "description": "Bunny Database shell binary for Linux x64", "os": [ "linux" ], diff --git a/packages/database-shell-windows-x64/package.json b/packages/database-shell-windows-x64/package.json index b4e314b..7bb35ea 100644 --- a/packages/database-shell-windows-x64/package.json +++ b/packages/database-shell-windows-x64/package.json @@ -1,7 +1,7 @@ { "name": "@bunny.net/database-shell-windows-x64", "version": "0.2.3", - "description": "bunny.net database shell binary for Windows x64", + "description": "Bunny Database shell binary for Windows x64", "os": [ "win32" ], diff --git a/packages/openapi-client/src/db-client.ts b/packages/openapi-client/src/db-client.ts index 36bdf14..e1fcd57 100644 --- a/packages/openapi-client/src/db-client.ts +++ b/packages/openapi-client/src/db-client.ts @@ -4,7 +4,7 @@ import { authMiddleware, type ClientOptions } from "./middleware.ts"; const DB_BASE_URL = "https://api.bunny.net/database"; -/** Create a type-safe client for the bunny.net Database API. */ +/** Create a type-safe client for the Bunny Database API. */ export function createDbClient(options: ClientOptions) { const client = createClient({ baseUrl: options.baseUrl ?? DB_BASE_URL, From 967349b326be66321ef051285f4acc124f3079f9 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 21 May 2026 18:11:41 +0100 Subject: [PATCH 11/11] updates to app deploy link and manifest --- AGENTS.md | 40 ++- README.md | 3 + packages/app-config/src/convert.ts | 32 +- packages/app-config/src/index.ts | 1 + packages/cli/src/commands/apps/config.test.ts | 128 +++++++- packages/cli/src/commands/apps/config.ts | 96 +++++- packages/cli/src/commands/apps/constants.ts | 33 ++ packages/cli/src/commands/apps/delete.ts | 8 + packages/cli/src/commands/apps/deploy.ts | 308 ++++++++++++++++-- packages/cli/src/commands/apps/docker.test.ts | 145 ++++++++- packages/cli/src/commands/apps/docker.ts | 188 ++++++++++- packages/cli/src/commands/apps/index.ts | 4 + packages/cli/src/commands/apps/init.ts | 2 +- packages/cli/src/commands/apps/link.ts | 182 +++++++++++ packages/cli/src/commands/apps/pull.ts | 22 ++ packages/cli/src/commands/apps/push.ts | 20 +- .../cli/src/commands/apps/suggestions.test.ts | 132 ++++++++ packages/cli/src/commands/apps/suggestions.ts | 180 ++++++++++ packages/cli/src/commands/apps/unlink.ts | 53 +++ packages/cli/src/commands/apps/walkthrough.ts | 168 ++++------ packages/cli/src/commands/registries/index.ts | 2 + .../cli/src/commands/registries/update.ts | 182 +++++++++++ packages/openapi-client/src/middleware.ts | 56 +++- 23 files changed, 1808 insertions(+), 177 deletions(-) create mode 100644 packages/cli/src/commands/apps/link.ts create mode 100644 packages/cli/src/commands/apps/suggestions.test.ts create mode 100644 packages/cli/src/commands/apps/suggestions.ts create mode 100644 packages/cli/src/commands/apps/unlink.ts create mode 100644 packages/cli/src/commands/registries/update.ts diff --git a/AGENTS.md b/AGENTS.md index 863f836..85ffec6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,19 +173,22 @@ bunny-cli/ │ │ ├── apps/ # Experimental — hidden from help and landing page │ │ │ ├── APPS.md # Apps documentation (while experimental) │ │ │ ├── index.ts # defineNamespace("apps", false) — hidden, registers all app commands -│ │ │ ├── constants.ts # Status label maps -│ │ │ ├── config.ts # bunny.jsonc file I/O, re-exports from @bunny.net/app-config (resolveAppId, resolveContainerId) -│ │ │ ├── docker.ts # Docker + registry helpers (build, push, dockerLogin, ensureRegistryLogin, dockerHasCredentials, ghDockerLogin, generateTag, promptRegistry, resolveRegistryForImage, getConfigSuggestions, imageHostname) +│ │ │ ├── constants.ts # Status label maps + APP_MANIFEST filename + AppManifest interface (consumed via core/manifest.ts) +│ │ │ ├── config.ts # bunny.jsonc file I/O (saveConfig strips transient `image`/`registry`/`app.id` via stripTransientFields), re-exports from @bunny.net/app-config; provides resolveAppId, resolveContainerId, resolveContainerRegistry +│ │ │ ├── docker.ts # Docker + registry helpers (build, push, dockerLogin, ensureRegistryLogin, dockerHasCredentials, ghDockerLogin, generateTag, promptRegistry, resolveRegistryForImage, getConfigSuggestions, imageHostname, parseDockerfileExposedPorts/readDockerfileExposedPorts) +│ │ │ ├── suggestions.ts # Shared endpoint/env-var suggestion prompting (confirmEndpointSuggestions, endpointRequestToConfig, promptSuggestedEnv, filterNewEndpointSuggestions, filterNewEnvSuggestions) - used by walkthrough.ts and deploy.ts (post-push) │ │ │ ├── init.ts # Scaffold bunny.jsonc (detects Dockerfile, prompts for registry) +│ │ │ ├── link.ts # Link this directory to an existing MC app (writes .bunny/app.json) +│ │ │ ├── unlink.ts # Remove .bunny/app.json │ │ │ ├── list.ts # List all apps │ │ │ ├── show.ts # Show app details and overview │ │ │ ├── deploy.ts # Deploy app: positional , --dockerfile, --container, --port, --command, --dry-run, --no-push │ │ │ ├── undeploy.ts # Undeploy app │ │ │ ├── restart.ts # Restart app -│ │ │ ├── delete.ts # Delete app -│ │ │ ├── pull.ts # Sync API → bunny.jsonc -│ │ │ ├── push.ts # Sync bunny.jsonc → API -│ │ │ ├── walkthrough.ts # Shared new-app walkthrough used by init and deploy (runWalkthrough, runComposeImport) +│ │ │ ├── delete.ts # Delete app (also drops .bunny/app.json if it pointed at this app) +│ │ │ ├── pull.ts # Sync API → bunny.jsonc + .bunny/app.json +│ │ │ ├── push.ts # Sync bunny.jsonc → API (uses manifest for registry IDs) +│ │ │ ├── walkthrough.ts # Shared new-app walkthrough used by init and deploy (runWalkthrough, runComposeImport) - returns { config, registries } so the caller can write the manifest after creating the app │ │ │ ├── compose/ # docker-compose import: parse, translate, validate │ │ │ │ ├── index.ts # findComposeFile, loadComposeFile, composeToConfig │ │ │ │ ├── schema.ts # Zod schema for compose subset @@ -255,6 +258,7 @@ bunny-cli/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries │ │ │ ├── add.ts # Add registry with credentials +│ │ │ ├── update.ts # Update registry display name and/or rotate credentials │ │ │ └── remove.ts # Remove registry │ │ ├── docs.ts # Open bunny.net documentation in browser (top-level: bunny docs) │ │ ├── open.ts # Open bunny.net dashboard in browser (top-level: bunny open) @@ -750,9 +754,11 @@ bunny │ │ --config uses that file as the source of truth (CI / agent flows); --dry-run skips writes and API. │ ├── undeploy [--id] [--force] Undeploy an app │ ├── restart [--id] Restart an app -│ ├── delete [--id] [--force] Delete an app -│ ├── pull [--id] [--force] Sync remote config to bunny.jsonc -│ ├── push [--id] [--dry-run] Apply bunny.jsonc to remote +│ ├── delete [--id] [--force] Delete an app (drops the manifest if it pointed at this app) +│ ├── link [app-id] [--force] Link this directory to an existing app (omit ID for interactive selection; writes .bunny/app.json) +│ ├── unlink [--force] Remove .bunny/app.json +│ ├── pull [--id] [--force] Sync remote config to bunny.jsonc + .bunny/app.json +│ ├── push [--id] [--dry-run] Apply bunny.jsonc to remote (uses .bunny/app.json for registry IDs) │ ├── env │ │ ├── list [--container] List environment variables │ │ ├── set [--container] Set environment variable @@ -773,6 +779,8 @@ bunny ├── registries List container registries (default: list) │ ├── list (alias: ls) List container registries │ ├── add [--name] [--username] Add registry +│ ├── update [--name] [--username] [--password] +│ │ Update registry name and/or rotate credentials │ └── remove Remove registry ├── db │ ├── create [--name] [--primary] [--replicas] [--storage-region] @@ -1098,6 +1106,18 @@ The `.bunny/` manifest and `bunny.jsonc` serve different purposes: Schemas and types are defined in `@bunny.net/app-config` using Zod. The CLI's `config.ts` handles file I/O (parsing JSONC, validating with Zod, writing with `$schema` + `version` injection) and resolution helpers (`resolveAppId`, `resolveContainerId`). +**Persistence model.** Three layers, with strict roles: + +- **`bunny.jsonc`** - committable deploy _intent_. App name, container shapes (dockerfile/image-pin/env/endpoints/volumes/command), scaling, regions. No account-scoped identities, no per-deploy artifacts. +- **`.bunny/app.json`** - per-user _identity_ state (gitignored). `id` (app ID), container-template IDs, account-scoped registry IDs, the CLI profile this link was made under. Stored via the shared `core/manifest.ts` helpers (`loadManifest`, `saveManifest`, `removeManifest`) - the same generic pattern used by `db/` (`database.json`) and `scripts/` (`script.json`). The filename `app.json` and the `AppManifest` interface live in `apps/constants.ts`. Created by `bun-ny apps link `, mutated during `apps deploy` and `apps pull`, dropped by `apps unlink` or `apps delete`. +- **MC API** - source of truth for _deployed state_. The currently-running image digest and the live config. + +To keep these consistent and stop file churn on every deploy, `saveConfig` (`apps/config.ts`) calls `stripTransientFields` before writing - it removes `app.id`, per-container `registry`, and any `image` field on a container that has `dockerfile` set. Pre-built `image:` refs (e.g. `nginx:1.27`) are preserved because they're universally resolvable upstream identifiers, not account-scoped or build-time artifacts. + +`resolveAppId` and `resolveContainerRegistry` in `apps/config.ts` read from the manifest first, then fall back to legacy `app.id` / `container.registry` in `bunny.jsonc` with a one-time deprecation warning. Existing pre-manifest configs continue to work; the next save naturally migrates them by stripping the legacy fields once the manifest carries the same data. + +In-memory mutations during a deploy run (`targetContainer.image = imageRef`) are still required so `configToAddRequest` / `configToPatchRequest` can read the ref within the same run - they just don't make it to disk. Registry IDs for the API body are supplied via the new `RegistryMap` argument to `configToAddRequest` / `configToPatchRequest` (`@bunny.net/app-config`), sourced from the manifest at the call site. + --- ## Database Shell (`bunny db shell`) diff --git a/README.md b/README.md index 01071db..cad9a50 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ bun ny db list bun ny apps deploy ghcr.io/me/api:v1.2 # deploy a pre-built image bun ny apps deploy --dockerfile # build ./Dockerfile and deploy bun ny apps deploy # first run? Imports docker-compose.yml if present, otherwise walks through Dockerfile or pre-built image setup. +bun ny apps link # interactive: pick from existing apps on the account +bun ny apps link # link a specific app to this directory (writes .bunny/app.json) +bun ny apps unlink # remove .bunny/app.json ``` ### Available Scripts diff --git a/packages/app-config/src/convert.ts b/packages/app-config/src/convert.ts index 34245b6..4ecd8cb 100644 --- a/packages/app-config/src/convert.ts +++ b/packages/app-config/src/convert.ts @@ -187,17 +187,39 @@ function collectVolumes( } } +/** + * Per-container registry ID overrides supplied at request-build time. + * + * Registry IDs are account-scoped (the same image can be reached via + * different account-side registry records) and so don't live in + * `bunny.jsonc`. Callers pass them in here from whatever per-user state + * they keep - the CLI uses `.bunny/manifest.json`. + */ +export type RegistryMap = Record; + +function mergeRegistry( + name: string, + c: ContainerConfig, + registries: RegistryMap | undefined, +): ContainerConfig { + const override = registries?.[name]; + if (!override) return c; + return { ...c, registry: override }; +} + /** Convert BunnyAppConfig to an AddApplicationRequest for creating a new app. */ export function configToAddRequest( config: BunnyAppConfig, + registries?: RegistryMap, ): AddApplicationRequest { const containers: ContainerRequest[] = []; const volumes: VolumeRequest[] = []; const seenVolumes = new Set(); for (const [name, c] of Object.entries(config.app.containers)) { - containers.push(containerConfigToRequest(name, c)); - collectVolumes(c, volumes, seenVolumes); + const merged = mergeRegistry(name, c, registries); + containers.push(containerConfigToRequest(name, merged)); + collectVolumes(merged, volumes, seenVolumes); } return { @@ -217,6 +239,7 @@ export function configToAddRequest( export function configToPatchRequest( config: BunnyAppConfig, existingApp: Application, + registries?: RegistryMap, ): PatchApplicationRequest { const containers: ContainerRequest[] = []; const volumes: VolumeRequest[] = []; @@ -230,8 +253,9 @@ export function configToPatchRequest( i === 0 ? existingApp.containerTemplates[0] : existingApp.containerTemplates.find((ct) => ct.name === name); - containers.push(containerConfigToRequest(name, c, existing?.id)); - collectVolumes(c, volumes, seenVolumes); + const merged = mergeRegistry(name, c, registries); + containers.push(containerConfigToRequest(name, merged, existing?.id)); + collectVolumes(merged, volumes, seenVolumes); } return { diff --git a/packages/app-config/src/index.ts b/packages/app-config/src/index.ts index ed34814..d06704a 100644 --- a/packages/app-config/src/index.ts +++ b/packages/app-config/src/index.ts @@ -5,6 +5,7 @@ export { apiToConfig, configToAddRequest, configToPatchRequest, + type RegistryMap, } from "./convert.ts"; // Utilities export { parseImageRef } from "./parse-image-ref.ts"; diff --git a/packages/cli/src/commands/apps/config.test.ts b/packages/cli/src/commands/apps/config.test.ts index a93e30f..7aaa8cb 100644 --- a/packages/cli/src/commands/apps/config.test.ts +++ b/packages/cli/src/commands/apps/config.test.ts @@ -2,7 +2,12 @@ import { expect, test } from "bun:test"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { useTempDir } from "../../test-utils/temp-dir.ts"; -import { configExists, loadConfig, saveConfig } from "./config.ts"; +import { + configExists, + loadConfig, + saveConfig, + stripTransientFields, +} from "./config.ts"; const tempDir = useTempDir("bunny-config-"); @@ -54,17 +59,132 @@ test("saveConfig(data, path) writes there, not to cwd", () => { expect(Object.keys(parsed)[0]).toBe("$schema"); }); -test("load → save → reload round-trip through an explicit path", () => { +test("stripTransientFields removes image+registry when dockerfile is set", () => { + const stripped = stripTransientFields({ + version: "2026-05-11", + app: { + name: "demo", + containers: { + api: { + dockerfile: "Dockerfile", + image: "ghcr.io/me/api:abc123", + registry: "7545", + }, + }, + }, + }); + + expect(stripped.app.containers.api).toEqual({ + dockerfile: "Dockerfile", + }); +}); + +test("stripTransientFields keeps image, drops registry when dockerfile is absent", () => { + const stripped = stripTransientFields({ + version: "2026-05-11", + app: { + name: "demo", + containers: { + api: { image: "nginx:1.27", registry: "7545" }, + }, + }, + }); + + expect(stripped.app.containers.api).toEqual({ + image: "nginx:1.27", + }); +}); + +test("stripTransientFields removes app.id (it lives in .bunny/app.json)", () => { + const stripped = stripTransientFields({ + version: "2026-05-11", + app: { + id: "app_abc123", + name: "demo", + containers: {}, + }, + }); + + expect(stripped.app.id).toBeUndefined(); + expect(stripped.app.name).toBe("demo"); +}); + +test("stripTransientFields handles mixed containers in one app", () => { + const stripped = stripTransientFields({ + version: "2026-05-11", + app: { + name: "stack", + containers: { + web: { + dockerfile: "Dockerfile", + image: "ghcr.io/me/web:abc", + registry: "7545", + }, + db: { image: "postgres:17-alpine", registry: "1155" }, + }, + }, + }); + + expect(stripped.app.containers.web).toEqual({ dockerfile: "Dockerfile" }); + expect(stripped.app.containers.db).toEqual({ image: "postgres:17-alpine" }); +}); + +test("saveConfig strips id/registry/transient-image on disk", () => { + const path = join(tempDir(), "transient.jsonc"); + saveConfig( + { + version: "2026-05-11", + app: { + id: "app_abc123", + name: "demo", + containers: { + api: { + dockerfile: "Dockerfile", + image: "ghcr.io/me/api:abc123", + registry: "7545", + }, + }, + }, + }, + path, + ); + + const parsed = JSON.parse(readFileSync(path, "utf-8")); + expect(parsed.app.containers.api.dockerfile).toBe("Dockerfile"); + expect(parsed.app.containers.api.registry).toBeUndefined(); + expect(parsed.app.containers.api.image).toBeUndefined(); + expect(parsed.app.id).toBeUndefined(); +}); + +test("load → save → reload preserves intent fields", () => { const path = join(tempDir(), "rt.jsonc"); writeFileSync( path, JSON.stringify({ version: "2026-05-11", - app: { name: "rt", containers: { api: { image: "nginx" } } }, + app: { + name: "rt", + scaling: { min: 2, max: 5 }, + containers: { + api: { image: "nginx", command: ["nginx", "-g", "daemon off;"] }, + }, + }, }), ); const original = loadConfig(path); + // app.id mutations on the in-memory object are NOT round-tripped + // through the file - identity lives in .bunny/app.json now. original.app.id = "app_abc123"; saveConfig(original, path); - expect(loadConfig(path).app.id).toBe("app_abc123"); + + const reloaded = loadConfig(path); + expect(reloaded.app.id).toBeUndefined(); + expect(reloaded.app.name).toBe("rt"); + expect(reloaded.app.scaling).toEqual({ min: 2, max: 5 }); + expect(reloaded.app.containers.api?.image).toBe("nginx"); + expect(reloaded.app.containers.api?.command).toEqual([ + "nginx", + "-g", + "daemon off;", + ]); }); diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index 2b88d04..0baa3bb 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -7,6 +7,9 @@ import { import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import { parse as parseJsonc } from "jsonc-parser"; import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest } from "../../core/manifest.ts"; +import { APP_MANIFEST, type AppManifest } from "./constants.ts"; type Application = components["schemas"]["Application"]; @@ -65,6 +68,43 @@ export function loadConfig(explicitPath?: string): BunnyAppConfig { return BunnyAppConfigSchema.parse(raw); } +/** + * Strip fields that should never be persisted to `bunny.jsonc`. + * + * `bunny.jsonc` stores deploy *intent* - name, containers, env, regions, + * scaling. Anything that's account-scoped identity or per-build artifact + * lives in `.bunny/app.json` (see `apps/constants.ts` + `core/manifest.ts`) instead, so the + * config file stays committable and stable across a team: + * + * - `app.id` - MC app ID is per-account. + * - `containers[name].registry` - registry record IDs are per-account. + * - `containers[name].image` when the container builds from a `dockerfile` + * - the tag changes every build and the MC API is the source of truth. + * + * For containers with only `image` (a pre-built ref the user pinned + * intentionally, e.g. `nginx:1.27`), `image` is preserved - it's a + * universally resolvable upstream reference. + * + * Exposed for testing; production callers should use {@link saveConfig}. + */ +export function stripTransientFields(data: BunnyAppConfig): BunnyAppConfig { + const containers: BunnyAppConfig["app"]["containers"] = {}; + for (const [name, c] of Object.entries(data.app.containers)) { + const { registry: _registry, ...withoutRegistry } = c; + if (c.dockerfile) { + const { image: _image, ...rest } = withoutRegistry; + containers[name] = rest; + } else { + containers[name] = withoutRegistry; + } + } + const { id: _id, ...appWithoutId } = data.app; + return { + ...data, + app: { ...appWithoutId, containers }, + }; +} + /** * Write the app config. * @@ -72,12 +112,17 @@ export function loadConfig(explicitPath?: string): BunnyAppConfig { * otherwise we write to `./bunny.jsonc` in the current working * directory. The `--config ` flow uses the explicit form so that * deploys can persist `app.id` back to whatever file the caller chose. + * + * Transient fields (see {@link stripTransientFields}) are removed before + * write - callers can freely mutate the in-memory `image` field during a + * deploy without polluting the on-disk config. */ export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void { const path = explicitPath ?? join(process.cwd(), CONFIG_FILENAME); + const cleaned = stripTransientFields(data); // Re-key the object so the file always starts with $schema → version → app. - const { $schema: _schema, version, ...rest } = data; + const { $schema: _schema, version, ...rest } = cleaned; const output = { $schema: "./node_modules/@bunny.net/app-config/generated/schema.json", version, @@ -100,21 +145,58 @@ export function configExists(explicitPath?: string): boolean { } /** - * Resolve an app ID from an explicit value or from bunny.jsonc. - * Throws if neither source provides an ID. + * Resolve the active app ID. + * + * Precedence: explicit flag → `.bunny/app.json` → legacy `app.id` + * in `bunny.jsonc` (deprecation-warned). Throws if nothing resolves so + * callers don't have to repeat the "no linked app" branch everywhere. */ export function resolveAppId(explicit?: string): string { if (explicit) return explicit; - const config = loadConfig(); - if (config.app.id) return config.app.id; + const manifest = loadManifest(APP_MANIFEST); + if (manifest.id) return manifest.id; + + if (configExists()) { + const config = loadConfig(); + if (config.app.id) { + logger.warn( + `\`app.id\` in bunny.jsonc is deprecated and will be removed in a future release. Run \`bunny apps link ${config.app.id}\` to migrate to .bunny/${APP_MANIFEST}.`, + ); + return config.app.id; + } + } throw new UserError( - "No app ID found in bunny.jsonc.", - "Run `bunny apps deploy` to create the app first, or pass --id explicitly.", + "No linked app.", + "Run `bunny apps link ` to link this directory, or `bunny apps deploy` to create a new app.", ); } +/** + * Resolve the registry record ID for a given container. + * + * Precedence: manifest entry → legacy `container.registry` in bunny.jsonc + * (deprecation-warned). Returns undefined when neither source has it - + * callers that need a registry should then prompt or otherwise resolve. + */ +export function resolveContainerRegistry( + containerName: string, + legacyContainer?: { registry?: string }, +): string | undefined { + const manifest = loadManifest(APP_MANIFEST); + const fromManifest = manifest.containers?.[containerName]?.registry; + if (fromManifest) return fromManifest; + + if (legacyContainer?.registry) { + logger.warn( + `\`containers.${containerName}.registry\` in bunny.jsonc is deprecated. It will move to .bunny/${APP_MANIFEST} on the next deploy.`, + ); + return legacyContainer.registry; + } + return undefined; +} + /** * Resolve a container template ID by name. * Defaults to the first container (primary) if no name is given. diff --git a/packages/cli/src/commands/apps/constants.ts b/packages/cli/src/commands/apps/constants.ts index 8ef721d..ecb4ef1 100644 --- a/packages/cli/src/commands/apps/constants.ts +++ b/packages/cli/src/commands/apps/constants.ts @@ -10,3 +10,36 @@ export const STATUS_LABELS: Record = { failing: "Failing", suspended: "Suspended", }; + +/** Filename for the linked-app manifest stored under `.bunny/`. */ +export const APP_MANIFEST = "app.json"; + +/** + * Shape of `.bunny/app.json`. + * + * Per-user, per-clone state that pairs a working directory with a + * specific MC app on someone's account. `bunny.jsonc` is *intent*; this + * manifest is *identity* - which app ID this directory points at, which + * MC container template IDs map to which user-facing names, and which + * account-scoped registry record each container pushes/pulls through. + */ +export interface AppManifest { + /** MC application ID this directory is linked to. */ + id: string; + /** + * CLI profile used when this link was created. Advisory - surfaced as + * a warning when commands run under a different profile, to catch + * "wrong account, wrong day" mistakes. + */ + profile?: string; + /** + * User-facing container name (matches the key in + * `bunny.jsonc.app.containers`) → resolved API identity: + * - `id`: MC container template ID (lets us PATCH the right container + * without a full re-fetch every deploy). + * - `registry`: the user's MC registry record this container pushes + * to / pulls from. Registries are account-scoped so this can't live + * in shared config. + */ + containers: Record; +} diff --git a/packages/cli/src/commands/apps/delete.ts b/packages/cli/src/commands/apps/delete.ts index a371d93..cc90c20 100644 --- a/packages/cli/src/commands/apps/delete.ts +++ b/packages/cli/src/commands/apps/delete.ts @@ -3,8 +3,10 @@ import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { logger } from "../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../core/manifest.ts"; import { confirm, spinner } from "../../core/ui.ts"; import { resolveAppId } from "./config.ts"; +import { APP_MANIFEST, type AppManifest } from "./constants.ts"; const COMMAND = "delete"; const DESCRIPTION = "Delete an app."; @@ -54,6 +56,12 @@ export const appsDeleteCommand = defineCommand({ spin.stop(); + // If this delete matches the manifest-linked app, drop the manifest + // so the next deploy doesn't try to PATCH a now-deleted app ID. + if (loadManifest(APP_MANIFEST).id === appId) { + removeManifest(APP_MANIFEST); + } + if (output === "json") { logger.log(JSON.stringify({ id: appId, deleted: true })); return; diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 5851635..9c62f78 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -1,10 +1,12 @@ 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 { 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 { loadManifest, saveManifest } from "../../core/manifest.ts"; import { spinner } from "../../core/ui.ts"; import { type BunnyAppConfig, @@ -14,20 +16,131 @@ import { configToPatchRequest, loadConfig, parseImageRef, + resolveContainerRegistry, saveConfig, } from "./config.ts"; +import { APP_MANIFEST, type AppManifest } from "./constants.ts"; + +/** + * Extract the per-container registry IDs we have so far into the + * registry-override map expected by `configToAddRequest` / + * `configToPatchRequest`. Empty entries are omitted so they don't + * accidentally clear a registry on the server. + */ +function draftRegistries( + containers: AppManifest["containers"], +): RegistryMap | undefined { + const map: RegistryMap = {}; + let any = false; + for (const [name, entry] of Object.entries(containers)) { + if (entry.registry) { + map[name] = entry.registry; + any = true; + } + } + return any ? map : undefined; +} + +/** + * Ask bunny.net for endpoint/env suggestions for an image we just + * pushed, and prompt the user to merge them into `targetContainer`. + * + * The API call is the source of truth - it can see the image manifest + * and apply well-known patterns (Postgres → 5432, Node images → 3000, + * etc.). When it returns nothing useful (network error, registry + * doesn't expose the manifest), we fall back to parsing `EXPOSE` out + * of the local Dockerfile so the user isn't left with no endpoints. + * + * Suggestions already covered by `targetContainer.endpoints` (by + * container port) or `targetContainer.env` (by name) are filtered out + * before prompting - avoids re-asking about ports the walkthrough + * already added. + */ +async function applyPostPushSuggestions( + client: ReturnType, + registryId: string, + imageRef: string, + _mode: { kind: "build"; dockerfile: string }, + opts: { targetContainer: ContainerConfig; dockerfilePath: string }, +): Promise { + const parsed = parseImageRef(imageRef); + const suggestions = await getConfigSuggestions(client, registryId, parsed); + + let endpointSuggestions = suggestions?.endpointSuggestions ?? []; + const envSuggestions = suggestions?.environmentVariablesSuggestions ?? []; + + // Fallback: if the API didn't return endpoint suggestions, derive + // them from the Dockerfile's EXPOSE directives. Only triggers when + // the API call genuinely returned nothing - otherwise we trust the + // backend's view. + if (endpointSuggestions.length === 0) { + const dockerfileAbs = resolve(process.cwd(), opts.dockerfilePath); + const exposedPorts = await readDockerfileExposedPorts(dockerfileAbs); + if (exposedPorts.length > 0) { + endpointSuggestions = exposedPorts.map((port) => ({ + displayName: "cdn", + cdn: { + isSslEnabled: true, + portMappings: [{ exposedPort: 443, containerPort: port }], + }, + })); + } + } + + if (suggestions?.instructions) { + logger.log(); + logger.dim(suggestions.instructions); + logger.log(); + } + + const newEndpoints = filterNewEndpointSuggestions( + endpointSuggestions, + opts.targetContainer, + ); + if (newEndpoints.length > 0) { + const accepted = await confirmEndpointSuggestions(newEndpoints); + if (accepted.length > 0) { + opts.targetContainer.endpoints = [ + ...(opts.targetContainer.endpoints ?? []), + ...accepted.map(endpointRequestToConfig), + ]; + } + } + + const newEnvs = filterNewEnvSuggestions(envSuggestions, opts.targetContainer); + if (newEnvs.length > 0) { + const env = await promptSuggestedEnv(newEnvs); + if (Object.keys(env).length > 0) { + opts.targetContainer.env = { + ...env, + ...(opts.targetContainer.env ?? {}), + }; + } + } +} + import { buildImage, + buildImageRef, dockerLogin, ensureDockerAvailable, ensureRegistryLogin, generateTag, + getConfigSuggestions, promptRegistry, pushImage, type ResolvedRegistry, + readDockerfileExposedPorts, resolveRegistryForImage, } from "./docker.ts"; import { resolveContainerEnv } from "./env/resolve.ts"; +import { + confirmEndpointSuggestions, + endpointRequestToConfig, + filterNewEndpointSuggestions, + filterNewEnvSuggestions, + promptSuggestedEnv, +} from "./suggestions.ts"; import { runWalkthrough } from "./walkthrough.ts"; const COMMAND = "deploy [image]"; @@ -155,8 +268,10 @@ export const appsDeployCommand = defineCommand({ const client = createMcClient(clientOptions(cfg, verbose)); let toml: BunnyAppConfig; + /** Registries the walkthrough resolved, to seed the manifest draft below. */ + let walkthroughRegistries: Record = {}; if (!configExists(configPath)) { - toml = await runWalkthrough(client, { + const result = await runWalkthrough(client, { positionalImage, dockerfileFlag, contextFlag: args.context, @@ -167,10 +282,54 @@ export const appsDeployCommand = defineCommand({ configPath, dryRun, }); + toml = result.config; + walkthroughRegistries = result.registries; } else { toml = loadConfig(configPath); } + // Build a writable draft of the manifest for this deploy run. We + // save eagerly after each piece of state we resolve (app ID, + // per-container registry, per-container template ID) so a mid-deploy + // crash doesn't lose what we've learned. + const existingManifest = loadManifest(APP_MANIFEST); + const draft: { + id?: string; + profile?: string; + containers: AppManifest["containers"]; + } = { + id: existingManifest.id ?? toml.app.id, + profile: existingManifest.profile ?? profile, + containers: { ...(existingManifest.containers ?? {}) }, + }; + // Seed with any registry picks the walkthrough just resolved so + // we don't re-prompt below for the same registry. + for (const [name, registryId] of Object.entries(walkthroughRegistries)) { + draft.containers[name] = { + ...draft.containers[name], + registry: registryId, + }; + } + const persistDraft = () => { + if (!draft.id) return; + saveManifest(APP_MANIFEST, { + id: draft.id, + profile: draft.profile, + containers: draft.containers, + }); + }; + const setContainerRegistry = (name: string, registryId: string) => { + draft.containers[name] = { + ...draft.containers[name], + registry: registryId, + }; + persistDraft(); + }; + const setContainerTemplateId = (name: string, templateId: string) => { + draft.containers[name] = { ...draft.containers[name], id: templateId }; + persistDraft(); + }; + if (dryRun) { logger.log(); logger.dim("--- bunny.jsonc (preview) ---"); @@ -189,7 +348,7 @@ export const appsDeployCommand = defineCommand({ // `image`, then create the app in one POST. const containerEntries = Object.entries(toml.app.containers); if ( - !toml.app.id && + !draft.id && containerEntries.length > 1 && !positionalImage && !dockerfileFlag && @@ -198,12 +357,17 @@ export const appsDeployCommand = defineCommand({ await prepareContainersForCreate(client, toml, configPath, { tag: args.tag, contextOverride: args.context, + draftContainers: draft.containers, + onRegistryResolved: setContainerRegistry, }); const createSpin = spinner("Creating app..."); createSpin.start(); const { data: result } = await client.POST("/apps", { - body: configToAddRequest(resolveContainerEnv(toml, dotenvPath)), + body: configToAddRequest( + resolveContainerEnv(toml, dotenvPath), + draftRegistries(draft.containers), + ), }); createSpin.stop(); @@ -211,10 +375,23 @@ export const appsDeployCommand = defineCommand({ throw new UserError("Failed to create app — no ID returned."); } - toml.app.id = result.id; - saveConfig(toml, configPath); + draft.id = result.id; + persistDraft(); logger.success(`App "${toml.app.name}" created (${result.id}).`); + // POST /apps returns only the new app ID. Fetch the full app to + // capture container template IDs so subsequent commands can target + // each container without re-fetching. + const fetchSpin = spinner("Recording container IDs..."); + fetchSpin.start(); + const { data: createdApp } = await client.GET("/apps/{appId}", { + params: { path: { appId: result.id } }, + }); + fetchSpin.stop(); + for (const ct of createdApp?.containerTemplates ?? []) { + setContainerTemplateId(ct.name, ct.id); + } + const deploySpin = spinner("Deploying..."); deploySpin.start(); await client.POST("/apps/{appId}/deploy", { @@ -244,7 +421,7 @@ export const appsDeployCommand = defineCommand({ let deployImage: string | undefined; let registryId: string | undefined = - args.registry ?? targetContainer.registry; + args.registry ?? resolveContainerRegistry(targetName, targetContainer); let freshCreds: ResolvedRegistry["freshCredentials"]; if (mode.kind === "build") { @@ -260,8 +437,7 @@ export const appsDeployCommand = defineCommand({ } registryId = resolved.id; freshCreds = resolved.freshCredentials; - targetContainer.registry = registryId; - saveConfig(toml, configPath); + setContainerRegistry(targetName, registryId); } const regSpin = spinner("Fetching registry..."); @@ -279,7 +455,12 @@ export const appsDeployCommand = defineCommand({ } const tag = args.tag ?? (await generateTag()); - const imageRef = `${reg.hostName}/${toml.app.name}:${tag}`; + const imageRef = buildImageRef( + reg.hostName, + reg.userName, + toml.app.name, + tag, + ); const buildCwd = resolveBuildContext(mode.dockerfile, args.context); logger.info(`Building ${imageRef}...`); @@ -320,6 +501,11 @@ export const appsDeployCommand = defineCommand({ await pushImage(imageRef); deployImage = imageRef; + // In-memory only - `saveConfig` strips `image` for dockerfile + // containers via `stripTransientFields`. The MC API is the source + // of truth for what's deployed; `bunny.jsonc` stores intent only. + // The mutation is still needed for `configToAddRequest` / + // `configToPatchRequest` to read the ref in the same deploy run. targetContainer.image = imageRef; // Persist dockerfile/context if they came from flags so the manifest stays the source of truth. if (!targetContainer.dockerfile) { @@ -328,7 +514,21 @@ export const appsDeployCommand = defineCommand({ if (args.context && !targetContainer.context) { targetContainer.context = args.context; } - saveConfig(toml); + + // Now that the image is live in the registry, ask bunny.net to + // analyze it. This is the same `getConfigSuggestions` call we + // make for pre-built images during the walkthrough - it returns + // endpoint and env-var hints derived from the image manifest / + // known-image patterns. For a fresh first deploy we usually have + // no endpoints yet, so this is what populates them. + if (registryId) { + await applyPostPushSuggestions(client, registryId, imageRef, mode, { + targetContainer, + dockerfilePath: mode.dockerfile, + }); + } + + saveConfig(toml, configPath); } if (mode.kind === "image") { @@ -341,18 +541,23 @@ export const appsDeployCommand = defineCommand({ } registryId = resolved.id; deployImage = mode.image; + // `image` is a pinned upstream ref - keep it in bunny.jsonc (intent). + // Registry is account-scoped - record in the manifest instead. targetContainer.image = mode.image; - targetContainer.registry = registryId; - saveConfig(toml); + setContainerRegistry(targetName, registryId); + saveConfig(toml, configPath); } - let appId = toml.app.id; + let appId = draft.id; if (!appId) { const createSpin = spinner("Creating app..."); createSpin.start(); const { data: result } = await client.POST("/apps", { - body: configToAddRequest(resolveContainerEnv(toml, dotenvPath)), + body: configToAddRequest( + resolveContainerEnv(toml, dotenvPath), + draftRegistries(draft.containers), + ), }); createSpin.stop(); @@ -361,8 +566,8 @@ export const appsDeployCommand = defineCommand({ } appId = result.id; - toml.app.id = appId; - saveConfig(toml); + draft.id = appId; + persistDraft(); logger.success(`App "${toml.app.name}" created (${appId}).`); } else { @@ -383,6 +588,7 @@ export const appsDeployCommand = defineCommand({ body: configToPatchRequest( resolveContainerEnv(toml, dotenvPath), existingApp, + draftRegistries(draft.containers), ), }); pushSpin.stop(); @@ -417,6 +623,9 @@ export const appsDeployCommand = defineCommand({ previousImage = match.image ?? undefined; const containerId = match.id; + // Cache the resolved template ID in the manifest so the next + // deploy/restart/env-push doesn't have to re-resolve by name. + setContainerTemplateId(targetName, containerId); const { imageName, imageNamespace, imageTag } = parseImageRef(deployImage); @@ -590,7 +799,18 @@ async function prepareContainersForCreate( client: ReturnType, toml: BunnyAppConfig, configPath: string | undefined, - opts: { tag?: string; contextOverride?: string }, + opts: { + tag?: string; + contextOverride?: string; + /** + * Manifest's container map at the start of the deploy. Read-only + * here - registry IDs we already know come from this. New + * registries discovered in this loop are reported via + * {@link onRegistryResolved} so the parent can persist eagerly. + */ + draftContainers: AppManifest["containers"]; + onRegistryResolved: (name: string, registryId: string) => void; + }, ): Promise { const entries = Object.entries(toml.app.containers); const hasAnyBuild = entries.some(([, c]) => c.dockerfile); @@ -607,9 +827,13 @@ async function prepareContainersForCreate( await buildAndPushContainer(client, toml, name, container, { tag: sharedTag, contextOverride: opts.contextOverride, + draftContainers: opts.draftContainers, + onRegistryResolved: opts.onRegistryResolved, }); } else if (container.image) { - await resolveContainerRegistry(client, name, container); + await resolveRegistryForPrebuiltImage(client, name, container, { + onRegistryResolved: opts.onRegistryResolved, + }); } else { throw new UserError( `Container "${name}" has neither \`image\` nor \`dockerfile\`.`, @@ -625,12 +849,21 @@ async function buildAndPushContainer( toml: BunnyAppConfig, name: string, container: ContainerConfig, - opts: { tag: string; contextOverride?: string }, + opts: { + tag: string; + contextOverride?: string; + draftContainers: AppManifest["containers"]; + onRegistryResolved: (name: string, registryId: string) => void; + }, ): Promise { if (!container.dockerfile) return; let freshCreds: ResolvedRegistry["freshCredentials"]; - if (!container.registry) { + let registryId = + opts.draftContainers[name]?.registry ?? + resolveContainerRegistry(name, container); + + if (!registryId) { logger.info(`Pick a registry to push the "${name}" image to.`); const resolved = await promptRegistry(client); if (!resolved) { @@ -638,25 +871,31 @@ async function buildAndPushContainer( `A registry is required to build and push "${name}".`, ); } - container.registry = resolved.id; + registryId = resolved.id; freshCreds = resolved.freshCredentials; + opts.onRegistryResolved(name, registryId); } const regSpin = spinner(`Fetching registry for ${name}...`); regSpin.start(); const { data: reg } = await client.GET("/registries/{registryId}", { - params: { path: { registryId: Number(container.registry) } }, + params: { path: { registryId: Number(registryId) } }, }); regSpin.stop(); if (!reg?.hostName) { throw new UserError( - `Registry ${container.registry} not found or has no hostname.`, + `Registry ${registryId} not found or has no hostname.`, "Use `bunny registries list` to check your registries.", ); } - const imageRef = `${reg.hostName}/${toml.app.name}-${name}:${opts.tag}`; + const imageRef = buildImageRef( + reg.hostName, + reg.userName, + `${toml.app.name}-${name}`, + opts.tag, + ); const buildCwd = resolveBuildContext( container.dockerfile, container.context ?? opts.contextOverride, @@ -682,19 +921,30 @@ async function buildAndPushContainer( logger.info(`Pushing ${imageRef}...`); await pushImage(imageRef); + // In-memory only - `saveConfig` strips this for dockerfile containers. + // Needed here so `configToAddRequest` picks up the ref on first create. container.image = imageRef; + + await applyPostPushSuggestions( + client, + registryId, + imageRef, + { kind: "build", dockerfile: container.dockerfile }, + { targetContainer: container, dockerfilePath: container.dockerfile }, + ); } -async function resolveContainerRegistry( +async function resolveRegistryForPrebuiltImage( client: ReturnType, name: string, container: ContainerConfig, + opts: { onRegistryResolved: (name: string, registryId: string) => void }, ): Promise { if (!container.image) return; - // Always resolve by hostname for pre-built images: the registry - // currently set on the container may be the user's push registry, - // which is wrong for images from other hosts (Docker Hub, etc.). + // Always resolve by hostname for pre-built images: any registry + // currently associated with this container may be the user's push + // registry, which is wrong for images from other hosts (Docker Hub, etc.). const resolved = await resolveRegistryForImage(client, container.image); if (!resolved) { throw new UserError( @@ -702,5 +952,5 @@ async function resolveContainerRegistry( "bunny.net needs a registry record for the image hostname so it can pull the image.", ); } - container.registry = resolved.id; + opts.onRegistryResolved(name, resolved.id); } diff --git a/packages/cli/src/commands/apps/docker.test.ts b/packages/cli/src/commands/apps/docker.test.ts index 4ef445c..f1f30b3 100644 --- a/packages/cli/src/commands/apps/docker.test.ts +++ b/packages/cli/src/commands/apps/docker.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { useTempDir } from "../../test-utils/temp-dir.ts"; -import { dockerHasCredentials, imageHostname } from "./docker.ts"; +import { + buildImageRef, + dockerHasCredentials, + imageHostname, + parseDockerfileExposedPorts, + parseOauthScopes, +} from "./docker.ts"; describe("imageHostname", () => { test.each([ @@ -18,6 +24,143 @@ describe("imageHostname", () => { }); }); +describe("buildImageRef", () => { + test("includes userName as the namespace segment for ghcr.io", () => { + expect( + buildImageRef("ghcr.io", "notrab", "go-magic-containers", "v1"), + ).toBe("ghcr.io/notrab/go-magic-containers:v1"); + }); + + test("lowercases mixed-case usernames and image names", () => { + expect( + buildImageRef("ghcr.io", "Bunny-Net", "Go-Magic-Containers", "v1"), + ).toBe("ghcr.io/bunny-net/go-magic-containers:v1"); + }); + + test("trims whitespace from userName", () => { + expect(buildImageRef("ghcr.io", " notrab ", "api", "v1")).toBe( + "ghcr.io/notrab/api:v1", + ); + }); + + test("omits the namespace when userName is null", () => { + expect(buildImageRef("registry.example.com", null, "api", "v1")).toBe( + "registry.example.com/api:v1", + ); + }); + + test("omits the namespace when userName is undefined", () => { + expect(buildImageRef("registry.example.com", undefined, "api", "v1")).toBe( + "registry.example.com/api:v1", + ); + }); + + test("omits the namespace when userName is empty/whitespace", () => { + expect(buildImageRef("registry.example.com", " ", "api", "v1")).toBe( + "registry.example.com/api:v1", + ); + }); +}); + +describe("parseDockerfileExposedPorts", () => { + test("extracts a single port from `EXPOSE 8080`", () => { + expect(parseDockerfileExposedPorts("EXPOSE 8080")).toEqual([8080]); + }); + + test("extracts multiple ports from a single EXPOSE line", () => { + expect(parseDockerfileExposedPorts("EXPOSE 8080 443 8443")).toEqual([ + 8080, 443, 8443, + ]); + }); + + test("handles EXPOSE/tcp explicit protocol", () => { + expect(parseDockerfileExposedPorts("EXPOSE 8080/tcp")).toEqual([8080]); + }); + + test("skips udp ports - bunny endpoints are TCP", () => { + expect(parseDockerfileExposedPorts("EXPOSE 53/udp 443/tcp")).toEqual([443]); + }); + + test("dedupes repeated ports across multiple EXPOSE lines", () => { + const dockerfile = [ + "FROM golang:1.25-alpine", + "EXPOSE 8080", + "RUN go build .", + "EXPOSE 8080", + "EXPOSE 9090", + ].join("\n"); + expect(parseDockerfileExposedPorts(dockerfile)).toEqual([8080, 9090]); + }); + + test("is case-insensitive on the directive", () => { + expect(parseDockerfileExposedPorts("expose 8080")).toEqual([8080]); + }); + + test("ignores invalid port numbers", () => { + expect(parseDockerfileExposedPorts("EXPOSE 0 99999 foo 443")).toEqual([ + 443, + ]); + }); + + test("strips inline comments", () => { + expect(parseDockerfileExposedPorts("EXPOSE 8080 # listen here")).toEqual([ + 8080, + ]); + }); + + test("returns empty list when no EXPOSE directives are present", () => { + const dockerfile = [ + "FROM alpine", + "RUN apk add curl", + 'CMD ["sleep", "infinity"]', + ].join("\n"); + expect(parseDockerfileExposedPorts(dockerfile)).toEqual([]); + }); + + test("ignores commented-out EXPOSE lines", () => { + expect(parseDockerfileExposedPorts("# EXPOSE 8080")).toEqual([]); + }); +}); + +describe("parseOauthScopes", () => { + test("parses scopes from the X-Oauth-Scopes header", () => { + const headers = [ + "HTTP/2.0 200 OK", + "X-Oauth-Scopes: gist, read:org, repo, workflow, write:packages", + "Content-Type: application/json", + "", + "{}", + ].join("\r\n"); + expect(parseOauthScopes(headers)).toEqual([ + "gist", + "read:org", + "repo", + "workflow", + "write:packages", + ]); + }); + + test("returns an empty list when the header is absent", () => { + expect(parseOauthScopes("HTTP/2.0 200 OK\r\n\r\n{}")).toEqual([]); + }); + + test("returns an empty list when the header is present but empty", () => { + expect(parseOauthScopes("X-Oauth-Scopes: ")).toEqual([]); + }); + + test("is case-insensitive on the header name", () => { + expect(parseOauthScopes("x-oauth-scopes: write:packages")).toEqual([ + "write:packages", + ]); + }); + + test("trims whitespace around each scope", () => { + expect( + parseOauthScopes("X-Oauth-Scopes: repo , write:packages "), + ).toEqual(["repo", "write:packages"]); + }); +}); + describe("dockerHasCredentials", () => { const tempDir = useTempDir("bunny-docker-"); const configFile = () => join(tempDir(), "config.json"); diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index a738aa3..a34bcda 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -62,14 +62,86 @@ export async function generateTag(): Promise { } /** - * Build a Docker image from a Dockerfile. + * Extract container ports from `EXPOSE` directives in a Dockerfile. + * + * Handles each documented form: + * EXPOSE 8080 + * EXPOSE 8080 443 + * EXPOSE 8080/tcp + * EXPOSE 80/udp (skipped - bunny CDN/Anycast endpoints are TCP) + * + * Returns a deduped list in source order. Pure string parsing - no I/O - + * so it's trivially testable without a Dockerfile on disk. + */ +export function parseDockerfileExposedPorts(content: string): number[] { + const ports: number[] = []; + const seen = new Set(); + for (const rawLine of content.split("\n")) { + // Strip inline comments and trim. Dockerfile comments start with `#` + // and run to end of line; they're not valid mid-instruction in real + // Dockerfiles, but stripping is harmless for the EXPOSE-line case. + const line = rawLine.replace(/#.*$/, "").trim(); + if (!line) continue; + const match = line.match(/^EXPOSE\s+(.+)$/i); + if (!match?.[1]) continue; + for (const token of match[1].trim().split(/\s+/)) { + const [portStr, proto] = token.split("/"); + if (proto && proto.toLowerCase() !== "tcp") continue; + const port = Number(portStr); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; + if (seen.has(port)) continue; + seen.add(port); + ports.push(port); + } + } + return ports; +} + +/** + * Read a Dockerfile from disk and return its EXPOSE'd ports. Returns + * an empty list when the file doesn't exist or can't be read - callers + * treat "no exposed ports" the same as "couldn't determine ports". + */ +export async function readDockerfileExposedPorts( + dockerfilePath: string, +): Promise { + try { + const content = await Bun.file(dockerfilePath).text(); + return parseDockerfileExposedPorts(content); + } catch { + return []; + } +} + +/** + * Magic Containers only supports `linux/amd64` images. On arm64 hosts + * (any Apple Silicon Mac) Docker's default build target is the host arch, + * which silently breaks pulls on MC's side with a generic 500. Hard-coded + * here rather than configurable because it's a platform requirement, not + * a per-app choice. + */ +const MC_TARGET_PLATFORM = "linux/amd64"; + +/** + * Build a Docker image from a Dockerfile, targeting `linux/amd64` so the + * resulting image is actually deployable to Magic Containers. */ export async function buildImage( dockerfile: string, tag: string, cwd?: string, ): Promise { - const args = ["docker", "build", "-f", dockerfile, "-t", tag, "."]; + const args = [ + "docker", + "build", + "--platform", + MC_TARGET_PLATFORM, + "-f", + dockerfile, + "-t", + tag, + ".", + ]; const proc = Bun.spawn(args, { cwd: cwd ?? process.cwd(), @@ -102,7 +174,7 @@ export async function pushImage(tag: string): Promise { const hostname = imageHostname(tag); const hint = hostname === "ghcr.io" - ? "Log in with `gh auth token | docker login ghcr.io -u --password-stdin`, or ensure your token has write:packages scope." + ? "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); } @@ -183,6 +255,87 @@ export async function ghIsAuthenticated(): Promise { } } +/** + * Parse the `X-Oauth-Scopes` response header from `gh api user -i` output. + * + * GitHub returns the active scopes for the token on every authenticated + * request, which is the only reliable way to verify scopes - `gh auth + * status` reads cached/configured scopes, not what the token actually + * carries server-side. + * + * Returns `[]` if the header is absent (older gh, or no auth). + */ +export function parseOauthScopes(headerOutput: string): string[] { + const match = headerOutput.match(/^X-Oauth-Scopes:\s*(.+)$/im); + if (!match?.[1]) return []; + return match[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Fetch the OAuth scopes attached to the current `gh` token, as reported + * by GitHub. Returns `[]` if `gh` is unauthenticated or the call fails. + */ +export async function ghTokenScopes(): Promise { + const proc = Bun.spawn(["gh", "api", "user", "-i"], { + stdout: "pipe", + stderr: "pipe", + }); + if ((await proc.exited) !== 0) return []; + const out = await new Response(proc.stdout).text(); + return parseOauthScopes(out); +} + +/** + * Ensure the local `gh` token has `write:packages` scope before we hand it + * to `docker login`. Without this scope, login succeeds but `docker push` + * fails with `permission_denied: write_package`, which is confusing + * because the failure happens minutes later, after a full image build. + * + * If the scope is missing, offer to run `gh auth refresh` interactively. + * The user has to complete the browser flow themselves; we just kick it off. + */ +export async function ghEnsureWritePackagesScope(): Promise { + const scopes = await ghTokenScopes(); + if (scopes.includes("write:packages")) return; + + logger.warn( + "Your GitHub CLI token is missing the `write:packages` scope, which ghcr.io requires for pushes.", + ); + + const { value } = await prompts({ + type: "confirm", + name: "value", + message: "Run `gh auth refresh -h github.com -s write:packages` to add it?", + initial: true, + }); + + if (!value) { + throw new UserError( + "`write:packages` scope is required to push to ghcr.io.", + "Run `gh auth refresh -h github.com -s write:packages` and try again.", + ); + } + + const proc = Bun.spawn( + ["gh", "auth", "refresh", "-h", "github.com", "-s", "write:packages"], + { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ); + + if ((await proc.exited) !== 0) { + throw new UserError( + "`gh auth refresh` failed.", + "Run `gh auth refresh -h github.com -s write:packages` manually and try again.", + ); + } +} + /** * Log in to a registry using the local `gh` CLI for credentials. * @@ -191,6 +344,10 @@ export async function ghIsAuthenticated(): Promise { * the normal `docker login` flow. Token never touches stdout/stderr. */ export async function ghDockerLogin(hostname: string): Promise { + if (hostname === "ghcr.io") { + await ghEnsureWritePackagesScope(); + } + const userProc = Bun.spawn(["gh", "api", "user", "--jq", ".login"], { stdout: "pipe", stderr: "pipe", @@ -278,6 +435,31 @@ export async function ensureRegistryLogin(hostname: string): Promise { } } +/** + * Build a fully-qualified image ref for pushing to a registry. + * + * Most registries (ghcr.io, Docker Hub) require the namespace segment - + * `host//:tag`. Without it, ghcr.io interprets the first + * segment as the owner and rejects the blob upload with 400 Bad Request. + * + * `userName` from the registry record is the namespace for the user's + * own pushes. When it's missing (public registry, or self-hosted setup + * that doesn't need a namespace), fall back to `host/image:tag`. + * + * Both the namespace and the image name are lowercased because GHCR and + * Docker Hub reject mixed-case path segments. + */ +export function buildImageRef( + hostName: string, + userName: string | null | undefined, + imageName: string, + tag: string, +): string { + const ns = userName?.trim().toLowerCase(); + const name = imageName.toLowerCase(); + return ns ? `${hostName}/${ns}/${name}:${tag}` : `${hostName}/${name}:${tag}`; +} + /** * Extract the registry hostname from a Docker image reference. * diff --git a/packages/cli/src/commands/apps/index.ts b/packages/cli/src/commands/apps/index.ts index cbd3859..934f67a 100644 --- a/packages/cli/src/commands/apps/index.ts +++ b/packages/cli/src/commands/apps/index.ts @@ -4,6 +4,7 @@ import { appsDeployCommand } from "./deploy.ts"; import { appsEndpointsNamespace } from "./endpoints/index.ts"; import { appsEnvNamespace } from "./env/index.ts"; import { appsInitCommand } from "./init.ts"; +import { appsLinkCommand } from "./link.ts"; import { appsListCommand } from "./list.ts"; import { appsPullCommand } from "./pull.ts"; import { appsPushCommand } from "./push.ts"; @@ -11,6 +12,7 @@ import { appsRegionsNamespace } from "./regions/index.ts"; import { appsRestartCommand } from "./restart.ts"; import { appsShowCommand } from "./show.ts"; import { appsUndeployCommand } from "./undeploy.ts"; +import { appsUnlinkCommand } from "./unlink.ts"; import { appsVolumesNamespace } from "./volumes/index.ts"; export const appsNamespace = defineNamespace("apps", false as never, [ @@ -19,6 +21,7 @@ export const appsNamespace = defineNamespace("apps", false as never, [ appsEndpointsNamespace, appsEnvNamespace, appsInitCommand, + appsLinkCommand, appsListCommand, appsPullCommand, appsPushCommand, @@ -26,5 +29,6 @@ export const appsNamespace = defineNamespace("apps", false as never, [ appsRestartCommand, appsShowCommand, appsUndeployCommand, + appsUnlinkCommand, appsVolumesNamespace, ]); diff --git a/packages/cli/src/commands/apps/init.ts b/packages/cli/src/commands/apps/init.ts index 9bf41a4..90e9092 100644 --- a/packages/cli/src/commands/apps/init.ts +++ b/packages/cli/src/commands/apps/init.ts @@ -111,7 +111,7 @@ export const appsInitCommand = defineCommand({ const cfg = resolveConfig(profile, apiKey); const client = createMcClient(clientOptions(cfg, verbose)); - const toml = await runWalkthrough(client, { + const { config: toml } = await runWalkthrough(client, { positionalImage: image, dockerfileFlag: normalizeDockerfileFlag(dockerfile), registryFlag: registry, diff --git a/packages/cli/src/commands/apps/link.ts b/packages/cli/src/commands/apps/link.ts new file mode 100644 index 0000000..11f3b13 --- /dev/null +++ b/packages/cli/src/commands/apps/link.ts @@ -0,0 +1,182 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; +import prompts from "prompts"; +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 { loadManifest, saveManifest } from "../../core/manifest.ts"; +import { confirm, spinner } from "../../core/ui.ts"; +import { APP_MANIFEST, type AppManifest } from "./constants.ts"; + +type Application = components["schemas"]["Application"]; + +const COMMAND = "link [app-id]"; +const DESCRIPTION = `Link this directory to an existing MC app (writes .bunny/${APP_MANIFEST}).`; + +interface LinkArgs { + "app-id"?: string; + force?: boolean; +} + +export const appsLinkCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + ["$0 apps link", "Interactive selection from all apps on your account"], + ["$0 apps link app_abc123", "Link directly by ID"], + [ + "$0 apps link app_abc123 --force", + "Replace an existing link without prompting", + ], + ], + + builder: (yargs) => + yargs + .positional("app-id", { + type: "string", + describe: + "App ID to link this directory to (omit for interactive selection)", + }) + .option("force", { + alias: "f", + type: "boolean", + describe: `Overwrite an existing .bunny/${APP_MANIFEST} without prompting`, + }), + + handler: async ({ + "app-id": appIdArg, + force, + profile, + output, + verbose, + apiKey, + }) => { + const existing = loadManifest(APP_MANIFEST); + if (existing.id && !force) { + const confirmed = await confirm( + `Already linked to ${existing.id}. Replace?`, + ); + if (!confirmed) { + logger.log("Link cancelled."); + return; + } + } + + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const app = appIdArg + ? await fetchAppById(client, appIdArg) + : await pickAppInteractively(client); + + // Carry forward any registry pointers the previous manifest had; + // re-linking shouldn't lose work the user did via interactive + // walkthroughs. Container template IDs always come fresh from the + // API (they're authoritative there). + const previousContainers = existing.containers ?? {}; + + const containers: AppManifest["containers"] = {}; + for (const ct of app.containerTemplates) { + const previousEntry = previousContainers[ct.name]; + containers[ct.name] = { + id: ct.id, + registry: + ct.imageRegistryId && ct.imageRegistryId !== "0" + ? ct.imageRegistryId + : previousEntry?.registry, + }; + } + + const manifest: AppManifest = { + id: app.id, + profile: profile ?? "default", + containers, + }; + + saveManifest(APP_MANIFEST, manifest); + + if (output === "json") { + logger.log(JSON.stringify(manifest, null, 2)); + return; + } + + logger.success(`Linked to ${app.id} → .bunny/${APP_MANIFEST}`); + const names = Object.keys(containers); + if (names.length > 0) { + logger.dim(`Containers: ${names.join(", ")}`); + } + logger.dim("Tip: add `.bunny/` to .gitignore - manifest is per-user."); + }, +}); + +async function fetchAppById( + client: ReturnType, + appId: string, +): Promise { + const spin = spinner("Fetching app..."); + spin.start(); + const { data: app } = await client.GET("/apps/{appId}", { + params: { path: { appId } }, + }); + spin.stop(); + + if (!app) { + throw new UserError( + `App ${appId} not found.`, + "Check the ID is correct and you're authenticated to the right account.", + ); + } + return app; +} + +/** + * Fetch every app on the account and let the user pick one. Mirrors + * `db/link.ts` and `scripts/link.ts` - same affordance for the + * fresh-clone case where the user knows the app name but not its ID. + * + * Returns the full {@link Application} (not just the ID) so the caller + * can populate the manifest with container template IDs without a + * second round-trip. + */ +async function pickAppInteractively( + client: ReturnType, +): Promise { + const spin = spinner("Fetching apps..."); + spin.start(); + const { data } = await client.GET("/apps"); + spin.stop(); + + const apps = (data?.items ?? []).filter( + (a): a is typeof a & { id: string; name: string } => + typeof a.id === "string" && typeof a.name === "string", + ); + + if (apps.length === 0) { + throw new UserError( + "No apps found on this account.", + "Run `bunny apps deploy` to create one, or pass an explicit app ID.", + ); + } + + apps.sort((a, b) => a.name.localeCompare(b.name)); + + const { value: selectedId } = await prompts({ + type: "select", + name: "value", + message: "Select an app to link:", + choices: apps.map((a) => ({ + title: `${a.name} (${a.id})`, + value: a.id, + })), + }); + + if (!selectedId) { + throw new UserError("Link cancelled."); + } + + // The list endpoint returns a slim shape - fetch the full app so + // we have its `containerTemplates` for the manifest. + return fetchAppById(client, selectedId); +} diff --git a/packages/cli/src/commands/apps/pull.ts b/packages/cli/src/commands/apps/pull.ts index c1a7e17..7878180 100644 --- a/packages/cli/src/commands/apps/pull.ts +++ b/packages/cli/src/commands/apps/pull.ts @@ -3,6 +3,7 @@ import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { logger } from "../../core/logger.ts"; +import { saveManifest } from "../../core/manifest.ts"; import { confirm, spinner } from "../../core/ui.ts"; import { apiToConfig, @@ -10,6 +11,7 @@ import { resolveAppId, saveConfig, } from "./config.ts"; +import { APP_MANIFEST, type AppManifest } from "./constants.ts"; const COMMAND = "pull"; const DESCRIPTION = "Sync remote app config to local bunny.jsonc."; @@ -67,11 +69,31 @@ export const appsPullCommand = defineCommand({ const toml = apiToConfig(app); saveConfig(toml); + // Mirror identity into `.bunny/app.json` so subsequent commands + // don't fall back to legacy `app.id`/`container.registry` reads from + // bunny.jsonc (those fields are stripped on the next save). + const manifestContainers: AppManifest["containers"] = {}; + for (const ct of app.containerTemplates) { + manifestContainers[ct.name] = { + id: ct.id, + registry: + ct.imageRegistryId && ct.imageRegistryId !== "0" + ? ct.imageRegistryId + : undefined, + }; + } + saveManifest(APP_MANIFEST, { + id: appId, + profile: profile ?? "default", + containers: manifestContainers, + }); + if (output === "json") { logger.log(JSON.stringify(toml, null, 2)); return; } logger.success("bunny.jsonc updated from remote."); + logger.dim(`Linked to ${appId} → .bunny/${APP_MANIFEST}`); }, }); diff --git a/packages/cli/src/commands/apps/push.ts b/packages/cli/src/commands/apps/push.ts index 456e699..a76a5ff 100644 --- a/packages/cli/src/commands/apps/push.ts +++ b/packages/cli/src/commands/apps/push.ts @@ -1,10 +1,13 @@ +import type { RegistryMap } from "@bunny.net/app-config"; import { createMcClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { logger } from "../../core/logger.ts"; +import { loadManifest } from "../../core/manifest.ts"; import { spinner } from "../../core/ui.ts"; import { configToPatchRequest, loadConfig, resolveAppId } from "./config.ts"; +import { APP_MANIFEST, type AppManifest } from "./constants.ts"; const COMMAND = "push"; const DESCRIPTION = "Apply local bunny.jsonc config to remote app."; @@ -43,7 +46,22 @@ export const appsPushCommand = defineCommand({ process.exit(1); } - const patchRequest = configToPatchRequest(toml, existingApp); + // Pull registry IDs from the manifest so the patch body still + // carries `imageRegistryId` per container - these don't live in + // bunny.jsonc anymore. Fall back to whatever the existing template + // already had (no-op change) when the manifest doesn't know. + const manifest = loadManifest(APP_MANIFEST); + const registries: RegistryMap = {}; + for (const ct of existingApp.containerTemplates) { + const fromManifest = manifest.containers?.[ct.name]?.registry; + registries[ct.name] = + fromManifest ?? + (ct.imageRegistryId && ct.imageRegistryId !== "0" + ? ct.imageRegistryId + : undefined); + } + + const patchRequest = configToPatchRequest(toml, existingApp, registries); if (dryRun) { if (output === "json") { diff --git a/packages/cli/src/commands/apps/suggestions.test.ts b/packages/cli/src/commands/apps/suggestions.test.ts new file mode 100644 index 0000000..56875eb --- /dev/null +++ b/packages/cli/src/commands/apps/suggestions.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; +import { + containerPortsInConfig, + endpointContainerPorts, + endpointRequestToConfig, + filterNewEndpointSuggestions, + filterNewEnvSuggestions, +} from "./suggestions.ts"; + +type EndpointRequest = components["schemas"]["EndpointRequest"]; + +const cdn = (...ports: Array<[number, number]>): EndpointRequest => ({ + displayName: "cdn", + cdn: { + isSslEnabled: true, + portMappings: ports.map(([exposed, container]) => ({ + exposedPort: exposed, + containerPort: container, + })), + }, +}); + +describe("endpointContainerPorts", () => { + test("extracts container ports from a CDN endpoint", () => { + expect(endpointContainerPorts(cdn([443, 8080], [80, 8080]))).toEqual([ + 8080, 8080, + ]); + }); + + test("returns [] for an endpoint with no portMappings", () => { + expect(endpointContainerPorts({ displayName: "weird" })).toEqual([]); + }); +}); + +describe("containerPortsInConfig", () => { + test("collects container ports across all endpoints", () => { + const ports = containerPortsInConfig({ + endpoints: [ + { type: "cdn", ports: [{ public: 443, container: 8080 }] }, + { type: "anycast", ports: [{ public: 9090, container: 9090 }] }, + ], + }); + expect([...ports]).toEqual([8080, 9090]); + }); + + test("returns empty Set when no endpoints", () => { + expect(containerPortsInConfig({}).size).toBe(0); + }); +}); + +describe("filterNewEndpointSuggestions", () => { + test("drops suggestions whose container port is already configured", () => { + const out = filterNewEndpointSuggestions( + [cdn([443, 8080]), cdn([443, 9090])], + { + endpoints: [{ type: "cdn", ports: [{ public: 443, container: 8080 }] }], + }, + ); + expect(out).toHaveLength(1); + expect(out[0]?.cdn?.portMappings?.[0]?.containerPort).toBe(9090); + }); + + test("keeps all suggestions when the config has no endpoints", () => { + const suggestions = [cdn([443, 8080]), cdn([80, 3000])]; + const out = filterNewEndpointSuggestions(suggestions, {}); + expect(out).toHaveLength(2); + }); + + test("keeps suggestions targeting at least one unconfigured port", () => { + // suggestion publishes both 8080 (already configured) and 9090 (new) + const out = filterNewEndpointSuggestions([cdn([443, 8080], [80, 9090])], { + endpoints: [{ type: "cdn", ports: [{ public: 443, container: 8080 }] }], + }); + expect(out).toHaveLength(1); + }); +}); + +describe("filterNewEnvSuggestions", () => { + test("drops suggestions whose name is already set in config.env", () => { + const out = filterNewEnvSuggestions( + [ + { name: "PORT", required: true }, + { name: "LOG_LEVEL", required: false }, + ], + { env: { PORT: "8080" } }, + ); + expect(out).toEqual([{ name: "LOG_LEVEL", required: false }]); + }); + + test("keeps everything when config.env is empty", () => { + const sugg = [ + { name: "FOO", required: true }, + { name: "BAR", required: false }, + ]; + expect(filterNewEnvSuggestions(sugg, {})).toEqual(sugg); + }); + + test("drops nameless suggestions", () => { + expect(filterNewEnvSuggestions([{ required: true }], {})).toEqual([]); + }); +}); + +describe("endpointRequestToConfig", () => { + test("converts a CDN request to ContainerConfig endpoint shape", () => { + expect(endpointRequestToConfig(cdn([443, 8080]))).toEqual({ + type: "cdn", + ssl: true, + ports: [{ public: 443, container: 8080 }], + }); + }); + + test("converts an Anycast request", () => { + const ep: EndpointRequest = { + displayName: "anycast", + anycast: { + type: "iPv4", + portMappings: [{ exposedPort: 9090, containerPort: 9090 }], + }, + }; + expect(endpointRequestToConfig(ep)).toEqual({ + type: "anycast", + ports: [{ public: 9090, container: 9090 }], + }); + }); + + test("falls back to an empty CDN endpoint for malformed input", () => { + expect(endpointRequestToConfig({ displayName: "weird" })).toEqual({ + type: "cdn", + }); + }); +}); diff --git a/packages/cli/src/commands/apps/suggestions.ts b/packages/cli/src/commands/apps/suggestions.ts new file mode 100644 index 0000000..9bcce8c --- /dev/null +++ b/packages/cli/src/commands/apps/suggestions.ts @@ -0,0 +1,180 @@ +import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; +import prompts from "prompts"; +import type { ContainerConfig } from "./config.ts"; + +type EndpointRequest = components["schemas"]["EndpointRequest"]; +type EnvironmentVariableSuggestion = + components["schemas"]["EnvironmentVariableSuggestion"]; + +/** + * Prompt the user to accept/reject each suggested endpoint. + * + * Suggestions come from two places: + * - `getConfigSuggestions` (`POST /registries/config-suggestions`) for + * pre-built and just-pushed images - bunny.net's own analysis, + * richer than what we can derive locally. + * - The Dockerfile EXPOSE parser in `docker.ts`, used as a fallback + * when the API doesn't return suggestions (e.g. `--no-push` runs). + */ +export async function confirmEndpointSuggestions( + endpoints: EndpointRequest[], +): Promise { + const accepted: EndpointRequest[] = []; + for (const ep of endpoints) { + const label = describeEndpoint(ep); + const { value } = await prompts({ + type: "confirm", + name: "value", + message: `Add suggested endpoint: ${label}?`, + initial: true, + }); + if (value) accepted.push(ep); + } + return accepted; +} + +export function describeEndpoint(ep: EndpointRequest): string { + if (ep.cdn) { + const ports = ep.cdn.portMappings + ?.map((p) => `${p.exposedPort}→${p.containerPort}`) + .join(", "); + return `CDN (${ports ?? "default port"})${ep.cdn.isSslEnabled ? " + SSL" : ""}`; + } + if (ep.anycast) { + const ports = ep.anycast.portMappings + .map((p) => `${p.exposedPort}→${p.containerPort}`) + .join(", "); + return `Anycast (${ports})`; + } + return ep.displayName ?? "endpoint"; +} + +export function endpointRequestToConfig( + ep: EndpointRequest, +): NonNullable[number] { + if (ep.cdn) { + return { + type: "cdn", + ssl: ep.cdn.isSslEnabled, + ports: + ep.cdn.portMappings?.map((p) => ({ + public: p.exposedPort ?? p.containerPort, + container: p.containerPort, + })) ?? [], + }; + } + if (ep.anycast) { + return { + type: "anycast", + ports: ep.anycast.portMappings.map((p) => ({ + public: p.exposedPort ?? p.containerPort, + container: p.containerPort, + })), + }; + } + return { type: "cdn" }; +} + +/** + * Container ports already present in a `ContainerConfig`'s endpoint + * list. Used to filter API-suggested endpoints down to "ports the user + * hasn't already configured" so we don't double-prompt. + */ +export function containerPortsInConfig( + container: ContainerConfig, +): Set { + const ports = new Set(); + for (const ep of container.endpoints ?? []) { + for (const p of ep.ports ?? []) { + ports.add(p.container); + } + } + return ports; +} + +/** Container ports a suggested EndpointRequest targets. */ +export function endpointContainerPorts(ep: EndpointRequest): number[] { + if (ep.cdn) { + return (ep.cdn.portMappings ?? []).map((p) => p.containerPort); + } + if (ep.anycast) { + return ep.anycast.portMappings.map((p) => p.containerPort); + } + return []; +} + +/** + * Filter endpoint suggestions to only those targeting container ports + * not already configured. Suggestions hitting an already-configured + * port are dropped silently - the user's choice stands. + */ +export function filterNewEndpointSuggestions( + suggestions: EndpointRequest[], + container: ContainerConfig, +): EndpointRequest[] { + const configured = containerPortsInConfig(container); + return suggestions.filter((ep) => { + const ports = endpointContainerPorts(ep); + return ports.length === 0 || ports.some((p) => !configured.has(p)); + }); +} + +/** + * Prompt the user for the values of suggested env vars. Required + * suggestions always prompt; optional ones are gated behind a + * "configure now?" confirmation so a long list doesn't ambush users. + */ +export async function promptSuggestedEnv( + suggestions: EnvironmentVariableSuggestion[], +): Promise> { + const env: Record = {}; + const required = suggestions.filter((s) => s.required); + const optional = suggestions.filter((s) => !s.required); + + for (const item of required) { + if (!item.name) continue; + const { value } = await prompts({ + type: "text", + name: "value", + message: `${item.name}${item.description ? ` (${item.description})` : ""}:`, + initial: item.defaultValue ?? "", + }); + if (value !== undefined && value !== "") env[item.name] = String(value); + } + + if (optional.length > 0) { + const { value: confirm } = await prompts({ + type: "confirm", + name: "value", + message: `Configure ${optional.length} optional env var${optional.length === 1 ? "" : "s"} now?`, + initial: false, + }); + if (confirm) { + for (const item of optional) { + if (!item.name) continue; + const { value } = await prompts({ + type: "text", + name: "value", + message: `${item.name}${item.description ? ` (${item.description})` : ""}:`, + initial: item.defaultValue ?? "", + }); + if (value !== undefined && value !== "") env[item.name] = String(value); + } + } + } + + return env; +} + +/** + * Filter env suggestions to only those whose names aren't already set + * in the container config. Avoids prompting for values the user + * already wrote into bunny.jsonc. + */ +export function filterNewEnvSuggestions( + suggestions: EnvironmentVariableSuggestion[], + container: ContainerConfig, +): EnvironmentVariableSuggestion[] { + const existing = new Set(Object.keys(container.env ?? {})); + return suggestions.filter((s) => s.name && !existing.has(s.name)); +} diff --git a/packages/cli/src/commands/apps/unlink.ts b/packages/cli/src/commands/apps/unlink.ts new file mode 100644 index 0000000..234c349 --- /dev/null +++ b/packages/cli/src/commands/apps/unlink.ts @@ -0,0 +1,53 @@ +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../core/manifest.ts"; +import { confirm } from "../../core/ui.ts"; +import { APP_MANIFEST, type AppManifest } from "./constants.ts"; + +const COMMAND = "unlink"; +const DESCRIPTION = `Remove .bunny/${APP_MANIFEST}, unlinking this directory.`; + +interface UnlinkArgs { + force?: boolean; +} + +export const appsUnlinkCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + + builder: (yargs) => + yargs.option("force", { + alias: "f", + type: "boolean", + describe: "Skip the confirmation prompt", + }), + + handler: async ({ force, output }) => { + const existing = loadManifest(APP_MANIFEST); + + if (!existing.id) { + if (output === "json") { + logger.log(JSON.stringify({ unlinked: false, reason: "no-manifest" })); + return; + } + logger.log(`Nothing to unlink: no .bunny/${APP_MANIFEST} in this tree.`); + return; + } + + if (!force) { + const confirmed = await confirm(`Unlink from ${existing.id}?`); + if (!confirmed) { + logger.log("Unlink cancelled."); + return; + } + } + + removeManifest(APP_MANIFEST); + + if (output === "json") { + logger.log(JSON.stringify({ unlinked: true, appId: existing.id })); + return; + } + logger.success("Unlinked."); + }, +}); diff --git a/packages/cli/src/commands/apps/walkthrough.ts b/packages/cli/src/commands/apps/walkthrough.ts index ea1cd16..c81810b 100644 --- a/packages/cli/src/commands/apps/walkthrough.ts +++ b/packages/cli/src/commands/apps/walkthrough.ts @@ -1,6 +1,5 @@ import { existsSync } from "node:fs"; import { basename, join, resolve } from "node:path"; -import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; @@ -22,10 +21,14 @@ import { type McClient, promptRegistry, type ResolvedRegistry, + readDockerfileExposedPorts, resolveRegistryForImage, } from "./docker.ts"; - -type EndpointRequest = components["schemas"]["EndpointRequest"]; +import { + confirmEndpointSuggestions, + endpointRequestToConfig, + promptSuggestedEnv, +} from "./suggestions.ts"; const DEFAULT_DOCKERFILE = "Dockerfile"; @@ -59,11 +62,28 @@ export interface WalkthroughInput { * Absolute path the resulting config should be written to. When * unset, the walkthrough writes `./bunny.jsonc` in cwd (current * default). Set this when `--config ` is used so the persisted - * `app.id` lands in the caller's chosen file. + * config lands in the caller's chosen file. */ configPath?: string; } +/** + * Result of the walkthrough. + * + * `config` is the new `bunny.jsonc` (intent only). `registries` is the + * per-container registry mapping the user picked or that we inferred - + * it doesn't go in `bunny.jsonc` (account-scoped), but the deploy flow + * that immediately runs after the walkthrough needs it to call the API. + * Whichever command runs the walkthrough is responsible for persisting + * these into `.bunny/app.json` once it has the app ID to attach them + * to. `apps init` just discards them - the next deploy will re-prompt + * or re-resolve. + */ +export interface WalkthroughResult { + config: BunnyAppConfig; + registries: Record; +} + /** * Run the new-app walkthrough and return the resulting `bunny.jsonc` shape. * @@ -76,7 +96,7 @@ export interface WalkthroughInput { export async function runWalkthrough( client: McClient, input: WalkthroughInput, -): Promise { +): Promise { logger.info("No bunny.jsonc found. Setting up this app."); let imageRef: string | undefined = input.positionalImage; @@ -151,6 +171,24 @@ export async function runWalkthrough( logger.dim(suggestions.instructions); logger.log(); } + } else if (mode === "build" && dockerfilePath) { + // Read EXPOSE directives from the Dockerfile to seed endpoint + // suggestions. bunny's getConfigSuggestions only works for pre-built + // images, so build mode needs a local equivalent - otherwise users + // end up with an app that has no way to reach the container. + const dockerfileAbs = resolve(process.cwd(), dockerfilePath); + const exposedPorts = await readDockerfileExposedPorts(dockerfileAbs); + if (exposedPorts.length > 0) { + suggestions = { + endpointSuggestions: exposedPorts.map((port) => ({ + displayName: "cdn", + cdn: { + isSslEnabled: true, + portMappings: [{ exposedPort: 443, containerPort: port }], + }, + })), + }; + } } const name = @@ -159,9 +197,11 @@ export async function runWalkthrough( const regions = await pickRegions(client); - const container: ContainerConfig = { - registry: registry.id, - }; + // `registry` is account-scoped - it lives in the manifest, not the + // shared config. We hold it in memory long enough for the deploy run + // that immediately follows this walkthrough, then `saveConfig` strips + // it from disk via `stripTransientFields`. + const container: ContainerConfig = {}; if (mode === "build" && dockerfilePath) { container.dockerfile = dockerfilePath; @@ -220,7 +260,7 @@ export async function runWalkthrough( logger.dim("Would write bunny.jsonc (--dry-run)."); } - return toml; + return { config: toml, registries: { [name]: registry.id } }; } /** @@ -236,7 +276,7 @@ async function runComposeImport( client: McClient, composeFilePath: string, input: WalkthroughInput, -): Promise { +): Promise { const compose = loadComposeFile(composeFilePath); const serviceNames = Object.keys(compose.services); @@ -291,7 +331,12 @@ async function runComposeImport( logger.dim("Would write bunny.jsonc (--dry-run)."); } - return config; + // Every service in a compose import shares the same registry by default. + const registries: Record = {}; + for (const serviceName of Object.keys(config.app.containers)) { + registries[serviceName] = registry.id; + } + return { config, registries }; } async function promptAppName(suggested?: string): Promise { @@ -338,104 +383,3 @@ async function pickRegions(client: McClient): Promise { } return [selectedRegion]; } - -async function confirmEndpointSuggestions( - endpoints: EndpointRequest[], -): Promise { - const accepted: EndpointRequest[] = []; - for (const ep of endpoints) { - const label = describeEndpoint(ep); - const { value } = await prompts({ - type: "confirm", - name: "value", - message: `Add suggested endpoint: ${label}?`, - initial: true, - }); - if (value) accepted.push(ep); - } - return accepted; -} - -function describeEndpoint(ep: EndpointRequest): string { - if (ep.cdn) { - const ports = ep.cdn.portMappings - ?.map((p) => `${p.exposedPort}→${p.containerPort}`) - .join(", "); - return `CDN (${ports ?? "default port"})${ep.cdn.isSslEnabled ? " + SSL" : ""}`; - } - if (ep.anycast) { - const ports = ep.anycast.portMappings - .map((p) => `${p.exposedPort}→${p.containerPort}`) - .join(", "); - return `Anycast (${ports})`; - } - return ep.displayName ?? "endpoint"; -} - -function endpointRequestToConfig( - ep: EndpointRequest, -): NonNullable[number] { - if (ep.cdn) { - return { - type: "cdn", - ssl: ep.cdn.isSslEnabled, - ports: - ep.cdn.portMappings?.map((p) => ({ - public: p.exposedPort ?? p.containerPort, - container: p.containerPort, - })) ?? [], - }; - } - if (ep.anycast) { - return { - type: "anycast", - ports: ep.anycast.portMappings.map((p) => ({ - public: p.exposedPort ?? p.containerPort, - container: p.containerPort, - })), - }; - } - return { type: "cdn" }; -} - -async function promptSuggestedEnv( - suggestions: components["schemas"]["EnvironmentVariableSuggestion"][], -): Promise> { - const env: Record = {}; - const required = suggestions.filter((s) => s.required); - const optional = suggestions.filter((s) => !s.required); - - for (const item of required) { - if (!item.name) continue; - const { value } = await prompts({ - type: "text", - name: "value", - message: `${item.name}${item.description ? ` (${item.description})` : ""}:`, - initial: item.defaultValue ?? "", - }); - if (value !== undefined && value !== "") env[item.name] = String(value); - } - - if (optional.length > 0) { - const { value: confirm } = await prompts({ - type: "confirm", - name: "value", - message: `Configure ${optional.length} optional env var${optional.length === 1 ? "" : "s"} now?`, - initial: false, - }); - if (confirm) { - for (const item of optional) { - if (!item.name) continue; - const { value } = await prompts({ - type: "text", - name: "value", - message: `${item.name}${item.description ? ` (${item.description})` : ""}:`, - initial: item.defaultValue ?? "", - }); - if (value !== undefined && value !== "") env[item.name] = String(value); - } - } - } - - return env; -} diff --git a/packages/cli/src/commands/registries/index.ts b/packages/cli/src/commands/registries/index.ts index 810a643..b50d37b 100644 --- a/packages/cli/src/commands/registries/index.ts +++ b/packages/cli/src/commands/registries/index.ts @@ -2,6 +2,7 @@ import type { CommandModule } from "yargs"; import { registryAddCommand } from "./add.ts"; import { registryListCommand } from "./list.ts"; import { registryRemoveCommand } from "./remove.ts"; +import { registryUpdateCommand } from "./update.ts"; export const registriesNamespace: CommandModule = { command: "registries", @@ -10,6 +11,7 @@ export const registriesNamespace: CommandModule = { yargs.command(registryAddCommand); yargs.command(registryListCommand); yargs.command(registryRemoveCommand); + yargs.command(registryUpdateCommand); return yargs; }, handler: registryListCommand.handler, diff --git a/packages/cli/src/commands/registries/update.ts b/packages/cli/src/commands/registries/update.ts new file mode 100644 index 0000000..8863370 --- /dev/null +++ b/packages/cli/src/commands/registries/update.ts @@ -0,0 +1,182 @@ +import { createMcClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; +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 { spinner } from "../../core/ui.ts"; + +const COMMAND = "update "; +const DESCRIPTION = "Update a container registry."; + +interface UpdateArgs { + "registry-id": number; + name?: string; + username?: string; + password?: string; +} + +export const registryUpdateCommand = defineCommand({ + command: COMMAND, + describe: DESCRIPTION, + examples: [ + [ + "$0 registries update 123 --username notrab --password $(gh auth token)", + "Rotate the credentials on registry 123", + ], + ["$0 registries update 123 --name 'ghcr.io (notrab)'", "Rename only"], + ], + + builder: (yargs) => + yargs + .positional("registry-id", { + type: "number", + describe: "Registry ID", + demandOption: true, + }) + .option("name", { + type: "string", + describe: "New display name (omit to keep current)", + }) + .option("username", { + type: "string", + describe: + "New registry username. Requires --password (or you'll be prompted).", + }) + .option("password", { + type: "string", + describe: + "New registry password/token. Requires --username (or you'll be prompted).", + }), + + handler: async ({ + "registry-id": registryId, + name: nameFlag, + username: usernameFlag, + password: passwordFlag, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createMcClient(clientOptions(config, verbose)); + + const fetchSpin = spinner("Fetching registry..."); + fetchSpin.start(); + const { data: existing } = await client.GET("/registries/{registryId}", { + params: { path: { registryId } }, + }); + fetchSpin.stop(); + + if (!existing) { + throw new UserError(`Registry ${registryId} not found.`); + } + + const nonInteractive = Boolean( + nameFlag || usernameFlag !== undefined || passwordFlag !== undefined, + ); + + // Resolve display name: flag → keep existing → prompt. + let displayName = nameFlag ?? existing.displayName ?? ""; + if (!nonInteractive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Display name:", + initial: displayName, + }); + if (value !== undefined) displayName = value; + } + if (!displayName) { + throw new UserError("Display name is required."); + } + + // Resolve credentials. Either both flags (rotate creds) or neither + // (keep existing). In interactive mode, ask explicitly. + let userName: string | undefined; + let password: string | undefined; + + if (usernameFlag !== undefined || passwordFlag !== undefined) { + userName = usernameFlag; + if (userName === undefined) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Username:", + }); + userName = value; + } + if (!userName) { + throw new UserError("Username is required when rotating credentials."); + } + + password = passwordFlag; + if (password === undefined) { + const { value } = await prompts({ + type: "password", + name: "value", + message: "Password/Token:", + }); + password = value; + } + if (!password) { + throw new UserError("Password is required when rotating credentials."); + } + } else if (!nonInteractive) { + const { value: rotate } = await prompts({ + type: "confirm", + name: "value", + message: "Rotate credentials?", + initial: false, + }); + if (rotate) { + const { value: u } = await prompts({ + type: "text", + name: "value", + message: "Username:", + initial: existing.userName ?? undefined, + }); + userName = u; + if (!userName) throw new UserError("Username is required."); + + const { value: p } = await prompts({ + type: "password", + name: "value", + message: "Password/Token:", + }); + password = p; + if (!password) throw new UserError("Password is required."); + } + } + + const updateSpin = spinner("Updating registry..."); + updateSpin.start(); + + const { data: result } = await client.PUT("/registries/{registryId}", { + params: { path: { registryId } }, + body: { + displayName, + ...(userName && password + ? { passwordCredentials: { userName, password } } + : {}), + }, + }); + + updateSpin.stop(); + + if (output === "json") { + logger.log(JSON.stringify(result, null, 2)); + return; + } + + if (result?.status === "saved") { + logger.success(`Registry "${displayName}" updated.`); + } else { + throw new UserError( + `Failed to update registry: ${result?.error ?? result?.status ?? "unknown error"}`, + ); + } + }, +}); diff --git a/packages/openapi-client/src/middleware.ts b/packages/openapi-client/src/middleware.ts index 6650741..90c435f 100644 --- a/packages/openapi-client/src/middleware.ts +++ b/packages/openapi-client/src/middleware.ts @@ -11,6 +11,21 @@ export interface ClientOptions { onDebug?: (msg: string) => void; } +/** + * Conservative content-type check - `application/json`, + * `application/problem+json`, `application/vnd.api+json`, anything + * with a `+json` suffix. Falls back to a substring match so + * `application/json; charset=utf-8` still counts. + */ +function looksLikeJson(contentType: string): boolean { + const lower = contentType.toLowerCase(); + return ( + lower.includes("application/json") || + lower.includes("+json") || + lower.includes("text/json") + ); +} + const STATUS_MESSAGES: Record = { 401: "Unauthorized. Check your API key.", 403: "Forbidden. You don't have permission for this action.", @@ -86,13 +101,44 @@ export function authMiddleware(options: ClientOptions): Middleware { if (debug) { const cloned = response.clone(); debug(`← ${response.status} ${response.statusText}`); - try { - const body = await cloned.json(); - debug(`← Body: ${JSON.stringify(body, null, 2)}`); - } catch {} + const contentType = response.headers.get("content-type") ?? ""; + if (looksLikeJson(contentType)) { + try { + const body = await cloned.json(); + debug(`← Body: ${JSON.stringify(body, null, 2)}`); + } catch {} + } else { + // Non-JSON body - surface the raw text (truncated) so the + // caller can see what arrived instead of getting nothing. + try { + const text = await cloned.text(); + const preview = text.length > 500 ? `${text.slice(0, 500)}…` : text; + debug(`← Body (${contentType || "no content-type"}): ${preview}`); + } catch {} + } } - if (response.ok) return; + // OK responses with a non-JSON body would otherwise crash + // openapi-fetch when it tries to JSON.parse the bytes. Detect + // that here and translate it into a clearer ApiError. This + // commonly happens when a CDN / proxy / captive portal serves an + // HTML error page with a 200 status code. + if (response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + if (!looksLikeJson(contentType)) { + const text = await response.clone().text(); + if (text.trim().length > 0) { + const preview = text.length > 200 ? `${text.slice(0, 200)}…` : text; + throw new ApiError( + `API returned a non-JSON ${response.status} response (content-type: ${contentType || "unset"}). ` + + "This usually means an intermediate proxy or CDN is intercepting the request. " + + `Body starts with: ${preview.replace(/\s+/g, " ").trim()}`, + response.status, + ); + } + } + return; + } let body: any = null; try {