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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/apps-deploy-dotenv-reconcile.md
Original file line number Diff line number Diff line change
@@ -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`
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <app-id>`, 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 <app-id>`, 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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app-id> # link a specific app to this directory (writes .bunny/app.json)
bun ny apps unlink # remove .bunny/app.json
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/commands/apps/APPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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 `<image>` for the first time, the CLI parses the hostname and tries to match it to an existing registry on your account:
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/commands/apps/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { id?: string; registry?: string }>;
containers: Record<
string,
{ id?: string; registry?: string; envIgnore?: string[] }
>;
}
43 changes: 41 additions & 2 deletions packages/cli/src/commands/apps/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -286,7 +287,10 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
let toml: BunnyAppConfig;
/** Registries the walkthrough resolved, to seed the manifest draft below. */
let walkthroughRegistries: Record<string, string> = {};
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,
Expand Down Expand Up @@ -353,6 +357,14 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
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();
Expand All @@ -365,6 +377,33 @@ export const appsDeployCommand = defineCommand<DeployArgs>({
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
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/commands/apps/env/reconcile.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading