From d45c98819deaa5a87ae1274a431554142bfc6880 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 10:45:01 +0100 Subject: [PATCH] respect env --- .changeset/apps-deploy-dotenv-reconcile.md | 5 + AGENTS.md | 6 +- README.md | 1 + packages/cli/src/commands/apps/APPS.md | 23 +++ packages/cli/src/commands/apps/constants.ts | 8 +- packages/cli/src/commands/apps/deploy.ts | 43 +++- .../src/commands/apps/env/reconcile.test.ts | 94 +++++++++ .../cli/src/commands/apps/env/reconcile.ts | 187 ++++++++++++++++++ 8 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 .changeset/apps-deploy-dotenv-reconcile.md create mode 100644 packages/cli/src/commands/apps/env/reconcile.test.ts create mode 100644 packages/cli/src/commands/apps/env/reconcile.ts diff --git a/.changeset/apps-deploy-dotenv-reconcile.md b/.changeset/apps-deploy-dotenv-reconcile.md new file mode 100644 index 00000000..5a4297f8 --- /dev/null +++ b/.changeset/apps-deploy-dotenv-reconcile.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(apps): `deploy` now offers `.env` keys the target container doesn't set yet as a multi-select, writing accepted keys to `bunny.jsonc` as name-only pointers (values stay in `.env`); declined keys are remembered in `.bunny/app.json` so each key is asked about once, and the prompt is skipped for non-TTY runs, `--output json` and `--dry-run` diff --git a/AGENTS.md b/AGENTS.md index 2487c3ca..f0cd9748 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,9 @@ bunny-cli/ │ │ │ │ ├── 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 +│ │ │ │ ├── resolve.ts # resolveContainerEnv(): swap `bunny.jsonc` env pointers for .env values at deploy time +│ │ │ │ ├── reconcile.ts # reconcileDotenv(): offer .env keys the target container doesn't declare (redeploy multi-select); declines persist to app.json envIgnore +│ │ │ │ └── *.test.ts # parse, resolve, reconcile unit tests │ │ │ ├── endpoints/ │ │ │ │ ├── index.ts # defineNamespace("endpoints", ...) │ │ │ │ ├── list.ts # List endpoints per container @@ -1414,7 +1416,7 @@ Schemas and types are defined in `@bunny.net/config` using Zod. `core/bunny-conf **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`. +- **`.bunny/app.json`** - per-user _identity_ state (gitignored). `id` (app ID), container-template IDs, account-scoped registry IDs, `envIgnore` (`.env` keys declined at the deploy-time reconcile prompt, so it asks once per key), 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. diff --git a/README.md b/README.md index c3e6ec22..b237f775 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ 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 auto-detects Dockerfile(s) (including monorepo subdirs) so you can pick one or many, or falls back to a pre-built image. +bun ny apps deploy # already have a bunny.jsonc? Any key in .env the container doesn't set yet is offered as a multi-select; accepted keys are written as name-only pointers, declined ones are remembered. 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 diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 89febe5f..d427ce03 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -151,6 +151,29 @@ bunny apps delete --force --id $(jq -r .app.id /tmp/agent-task.jsonc) rm /tmp/agent-task.jsonc ``` +### `.env` during deploy + +`bunny.jsonc` is committable, so it never holds secret values. Instead a container's `env` map can point at a key in the `.env` file sitting next to `bunny.jsonc`: + +```jsonc +"env": { + "PORT": "3000", // literal + "BETTER_AUTH_SECRET": "BETTER_AUTH_SECRET", // resolved from .env + "DATABASE_URL": "PROD_DATABASE_URL" // resolved + renamed +} +``` + +At deploy time each value that matches a `.env` key is swapped for the real value before the API call; everything else is sent literally. The `.env` is only ever read, never written. + +Two places offer to wire those pointers up for you, both as a multi-select with everything pre-selected: + +- **First run** (walkthrough, build path) — keys are discovered from `.env` / `.env.example` next to the Dockerfile, falling back to cwd for monorepos. +- **Every deploy after that** — a key added to `.env` later would otherwise stay invisible, since resolution only covers keys the config already declares. Deploy diffs `.env` against the target container and offers whatever's unaccounted for. Keys already used on either side of the map (as a name or as a pointer target) aren't re-offered. + +Declining a key records it in `.bunny/app.json` under `containers..envIgnore`, so it's asked about once and never again. Delete the entry to be asked again. Cancelling the prompt records nothing. + +The prompt is skipped when there's no TTY or with `--output json`, so CI deploys never block. It's also skipped under `--dry-run`. When `bunny.jsonc` has several containers and no `--container` was passed there's no safe guess, so deploy prints the keys no container accounts for and leaves them alone. + ### 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: diff --git a/packages/cli/src/commands/apps/constants.ts b/packages/cli/src/commands/apps/constants.ts index ecb4ef14..1e28c1a9 100644 --- a/packages/cli/src/commands/apps/constants.ts +++ b/packages/cli/src/commands/apps/constants.ts @@ -40,6 +40,12 @@ export interface AppManifest { * - `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. + * - `envIgnore`: `.env` keys the user declined at deploy time, so the + * `.env` reconcile prompt doesn't re-offer them every run. A local + * preference about a local file, hence not in `bunny.jsonc`. */ - containers: Record; + containers: Record< + string, + { id?: string; registry?: string; envIgnore?: string[] } + >; } diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 830ea3a4..86d0aa34 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -11,7 +11,7 @@ import { formatTable } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { spinner } from "../../core/ui.ts"; +import { isInteractive, spinner } from "../../core/ui.ts"; import { type BunnyAppConfig, type ContainerConfig, @@ -141,6 +141,7 @@ import { collectDeployedEndpoints, type DeployedEndpointLine, } from "./endpoints/format.ts"; +import { reconcileDotenv } from "./env/reconcile.ts"; import { resolveContainerEnv } from "./env/resolve.ts"; import { confirmEndpointSuggestions, @@ -286,7 +287,10 @@ export const appsDeployCommand = defineCommand({ let toml: BunnyAppConfig; /** Registries the walkthrough resolved, to seed the manifest draft below. */ let walkthroughRegistries: Record = {}; - if (!configExists(configPath)) { + // The walkthrough runs its own `.env` picker on the config it writes, so + // the reconcile below is for configs that already existed. + const hadConfig = configExists(configPath); + if (!hadConfig) { const result = await runWalkthrough(client, { positionalImage, dockerfileFlag, @@ -353,6 +357,14 @@ export const appsDeployCommand = defineCommand({ draft.containers[name] = { ...draft.containers[name], id: templateId }; persistDraft(); }; + const addContainerEnvDeclines = (name: string, keys: string[]) => { + const existing = draft.containers[name]?.envIgnore ?? []; + draft.containers[name] = { + ...draft.containers[name], + envIgnore: [...new Set([...existing, ...keys])].sort(), + }; + persistDraft(); + }; if (dryRun) { logger.log(); @@ -365,6 +377,33 @@ export const appsDeployCommand = defineCommand({ return; } + // A `.env` key added after `bunny.jsonc` was written is invisible to + // `resolveContainerEnv` until the config declares it. Offer the gap + // here, once per new key - declines are remembered in the manifest. + // Skipped when nobody's there to answer. + if (hadConfig && isInteractive(output)) { + const changed = await reconcileDotenv(toml, dotenvPath, { + explicitContainer: args.container, + declinedFor: (name) => draft.containers[name]?.envIgnore ?? [], + onDeclined: addContainerEnvDeclines, + }); + if (changed) { + // This is the earliest `saveConfig` of the run and it strips legacy + // `app.id` / `container.registry`, so mirror both into the manifest + // before writing rather than losing them if the deploy then fails. + for (const [name, container] of Object.entries(toml.app.containers)) { + if (container.registry && !draft.containers[name]?.registry) { + draft.containers[name] = { + ...draft.containers[name], + registry: container.registry, + }; + } + } + persistDraft(); + saveConfig(toml, configPath); + } + } + // 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 diff --git a/packages/cli/src/commands/apps/env/reconcile.test.ts b/packages/cli/src/commands/apps/env/reconcile.test.ts new file mode 100644 index 00000000..6a2c1816 --- /dev/null +++ b/packages/cli/src/commands/apps/env/reconcile.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import type { BunnyAppConfig } from "../config.ts"; +import { + claimedEnvKeys, + orphanEnvKeys, + reconcileTarget, + unclaimedEnvKeys, +} from "./reconcile.ts"; + +function config(containers: BunnyAppConfig["app"]["containers"]) { + return { + version: "1", + app: { name: "orbit", regions: ["DE"], containers }, + } as BunnyAppConfig; +} + +describe("claimedEnvKeys", () => { + test("claims both the container key and the .env key it points at", () => { + const claimed = claimedEnvKeys({ + env: { DATABASE_URL: "PROD_DATABASE_URL", PORT: "3000" }, + }); + expect([...claimed].sort()).toEqual([ + "3000", + "DATABASE_URL", + "PORT", + "PROD_DATABASE_URL", + ]); + }); + + test("is empty for a container with no env", () => { + expect(claimedEnvKeys({ image: "nginx" }).size).toBe(0); + }); +}); + +describe("unclaimedEnvKeys", () => { + test("returns keys the container neither sets nor points at", () => { + expect( + unclaimedEnvKeys( + ["STRIPE_SECRET_KEY", "DATABASE_URL", "PROD_DATABASE_URL"], + { env: { DATABASE_URL: "PROD_DATABASE_URL" } }, + ), + ).toEqual(["STRIPE_SECRET_KEY"]); + }); + + test("drops previously declined keys", () => { + expect(unclaimedEnvKeys(["A", "B", "C"], {}, ["B"])).toEqual(["A", "C"]); + }); + + test("sorts the result", () => { + expect(unclaimedEnvKeys(["Z_VAR", "A_VAR"], {})).toEqual([ + "A_VAR", + "Z_VAR", + ]); + }); + + test("returns nothing when everything is claimed", () => { + expect(unclaimedEnvKeys(["A"], { env: { A: "A" } })).toEqual([]); + }); +}); + +describe("reconcileTarget", () => { + test("picks the only container when there's just one", () => { + expect(reconcileTarget(config({ api: {} }))).toBe("api"); + }); + + test("returns undefined for multiple containers without a flag", () => { + expect(reconcileTarget(config({ api: {}, postgres: {} }))).toBeUndefined(); + }); + + test("matches an explicit container case-insensitively", () => { + expect(reconcileTarget(config({ api: {}, postgres: {} }), "API")).toBe( + "api", + ); + }); + + test("returns undefined when the explicit container doesn't exist", () => { + expect(reconcileTarget(config({ api: {} }), "worker")).toBeUndefined(); + }); +}); + +describe("orphanEnvKeys", () => { + test("only reports keys no container accounts for", () => { + const toml = config({ + api: { env: { DATABASE_URL: "DATABASE_URL" } }, + postgres: { env: { POSTGRES_PASSWORD: "POSTGRES_PASSWORD" } }, + }); + expect( + orphanEnvKeys( + ["DATABASE_URL", "POSTGRES_PASSWORD", "STRIPE_SECRET_KEY"], + toml, + ), + ).toEqual(["STRIPE_SECRET_KEY"]); + }); +}); diff --git a/packages/cli/src/commands/apps/env/reconcile.ts b/packages/cli/src/commands/apps/env/reconcile.ts new file mode 100644 index 00000000..64fbf3be --- /dev/null +++ b/packages/cli/src/commands/apps/env/reconcile.ts @@ -0,0 +1,187 @@ +import { existsSync, readFileSync } from "node:fs"; +import { basename } from "node:path"; +import prompts from "prompts"; +import { logger } from "../../../core/logger.ts"; +import type { BunnyAppConfig, ContainerConfig } from "../config.ts"; +import { parseDotenv } from "./parse.ts"; + +/** How many orphan key names we name inline before collapsing to a count. */ +const ORPHAN_PREVIEW = 5; + +/** + * Env keys a container already accounts for. + * + * Both sides of the map count: `"DATABASE_URL": "PROD_DATABASE_URL"` claims + * `DATABASE_URL` (it's set on the container) *and* `PROD_DATABASE_URL` (it's + * the `.env` key feeding it), so neither should be offered again. + */ +export function claimedEnvKeys(container: ContainerConfig): Set { + const claimed = new Set(); + for (const [key, value] of Object.entries(container.env ?? {})) { + claimed.add(key); + claimed.add(value); + } + return claimed; +} + +/** `.env` keys this container neither declares nor has previously declined. */ +export function unclaimedEnvKeys( + dotenvKeys: string[], + container: ContainerConfig, + declined: string[] = [], +): string[] { + const claimed = claimedEnvKeys(container); + const ignored = new Set(declined); + return dotenvKeys + .filter((key) => !claimed.has(key) && !ignored.has(key)) + .sort(); +} + +/** + * Which container a `.env` reconcile should attach keys to. + * + * Only an unambiguous target qualifies: an explicit `--container` (matched + * case-insensitively, same as `resolveTargetContainer`) or a config with + * exactly one container. With several containers and no flag there's no + * defensible guess - a `DATABASE_URL` could belong to either side of an + * app/postgres pair. + */ +export function reconcileTarget( + toml: BunnyAppConfig, + explicit?: string, +): string | undefined { + const names = Object.keys(toml.app.containers); + if (explicit) { + return names.find((n) => n.toLowerCase() === explicit.toLowerCase()); + } + return names.length === 1 ? names[0] : undefined; +} + +/** `.env` keys that no container in the config accounts for. */ +export function orphanEnvKeys( + dotenvKeys: string[], + toml: BunnyAppConfig, +): string[] { + const containers = Object.values(toml.app.containers); + return dotenvKeys.filter((key) => + containers.every((c) => !claimedEnvKeys(c).has(key)), + ); +} + +/** + * Offer any `.env` keys the target container doesn't already set, and add + * the accepted ones to `bunny.jsonc` as self-pointers (`KEY: KEY`). + * + * This is the redeploy counterpart to the first-run walkthrough's env + * picker: once `bunny.jsonc` exists, a key added to `.env` afterwards is + * invisible to deploy, because {@link resolveContainerEnv} only ever + * *resolves* keys the config already declares. Writing pointers keeps the + * values in `.env` and out of the committed config. + * + * Declined keys are reported to `onDeclined` so the caller can persist them + * and stop re-asking on every deploy. A cancelled prompt records nothing. + * + * Returns `true` when the config was mutated and needs saving. + */ +export async function reconcileDotenv( + toml: BunnyAppConfig, + dotenvPath: string, + opts: { + explicitContainer?: string; + declinedFor: (container: string) => string[]; + onDeclined: (container: string, keys: string[]) => void; + }, +): Promise { + if (!existsSync(dotenvPath)) return false; + + let dotenvKeys: string[]; + try { + dotenvKeys = Object.keys(parseDotenv(readFileSync(dotenvPath, "utf-8"))); + } catch { + // Unreadable `.env` - not worth failing a deploy over. + return false; + } + if (dotenvKeys.length === 0) return false; + + const target = reconcileTarget(toml, opts.explicitContainer); + if (!target) { + reportOrphans(orphanEnvKeys(dotenvKeys, toml), dotenvPath); + return false; + } + + const container = toml.app.containers[target]; + if (!container) return false; + + const candidates = unclaimedEnvKeys( + dotenvKeys, + container, + opts.declinedFor(target), + ); + if (candidates.length === 0) return false; + + logger.log(); + logger.dim( + `Only key names are written to bunny.jsonc; values are read from ${basename(dotenvPath)} at deploy time.`, + ); + + let cancelled = false; + const { picked } = await prompts( + { + type: "multiselect", + name: "picked", + message: `Set these ${basename(dotenvPath)} keys on "${target}"?`, + choices: candidates.map((key) => ({ + title: key, + value: key, + selected: true, + })), + hint: "space to toggle, enter to confirm", + instructions: false, + }, + { onCancel: () => (cancelled = true) }, + ); + // Ctrl-C leaves `picked` empty, which is indistinguishable from + // "deselected everything" - bail before we record the whole list as + // declined and silence it forever. + if (cancelled) return false; + + const accepted = Array.isArray(picked) + ? picked.filter((k): k is string => typeof k === "string") + : []; + + const declined = candidates.filter((key) => !accepted.includes(key)); + if (declined.length > 0) opts.onDeclined(target, declined); + + if (accepted.length === 0) return false; + + container.env = { ...(container.env ?? {}) }; + for (const key of accepted) container.env[key] = key; + + logger.success( + `Added ${accepted.length} env key${accepted.length === 1 ? "" : "s"} to "${target}" in bunny.jsonc.`, + ); + return true; +} + +/** + * Multi-container configs get a nudge instead of a prompt - we can't pick a + * container for them, but silently ignoring a `.env` full of unused keys + * reads as "nothing to do here" when there is. + */ +function reportOrphans(orphans: string[], dotenvPath: string): void { + if (orphans.length === 0) return; + + const preview = orphans.slice(0, ORPHAN_PREVIEW).join(", "); + const more = + orphans.length > ORPHAN_PREVIEW + ? ` (+${orphans.length - ORPHAN_PREVIEW} more)` + : ""; + + logger.log(); + logger.dim( + `${orphans.length} key${orphans.length === 1 ? "" : "s"} in ${basename(dotenvPath)} aren't set on any container: ${preview}${more}.`, + ); + logger.dim( + "Pass --container to choose which container they belong to.", + ); +}