From 88a64fe91475061d282fb7e249aebc6c26015aab Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 17:43:34 +0000 Subject: [PATCH 01/12] feat(apps): generate a Dockerfile for detected Node and Bun apps on first deploy --- packages/cli/src/commands/apps/APPS.md | 24 +- .../cli/src/commands/apps/dockerfile.test.ts | 185 ++++++++ packages/cli/src/commands/apps/dockerfile.ts | 411 ++++++++++++++++++ packages/cli/src/commands/apps/walkthrough.ts | 100 ++++- 4 files changed, 714 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/apps/dockerfile.test.ts create mode 100644 packages/cli/src/commands/apps/dockerfile.ts diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 5e1a19c7..3cef4535 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -34,7 +34,7 @@ Deploy an app. Three modes, chosen by what you pass: If no `bunny.jsonc` exists, the first run launches a walkthrough that: 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. +2. Otherwise offers to build from a `Dockerfile` (if one exists) or to deploy a pre-built image ref. With no Dockerfile, a detected Node or Bun app gets a third option: generate one (see below). 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. @@ -47,6 +47,28 @@ Previous image: ghcr.io/me/api:abc-123 To rollback: bunny apps deploy ghcr.io/me/api:abc-123 ``` +### Generating a Dockerfile + +When the project has no Dockerfile, the walkthrough reads `package.json` and offers to write one for you: + +| Detected from | Runs | Port | +| ------------------------------------------ | ---------------------------------------- | ---- | +| `next` | `next start` | 3000 | +| `nuxt` | `.output/server/index.mjs` (output only) | 3000 | +| `@sveltejs/kit` + `@sveltejs/adapter-node` | `build/index.js` | 3000 | +| `astro` + `@astrojs/node` | `dist/server/entry.mjs` | 4321 | +| `@react-router/serve` | `react-router-serve` | 3000 | +| `@remix-run/serve` | `remix-serve` | 3000 | +| `@nestjs/core` | `dist/main.js` | 3000 | +| a `start` script | the `start` script | 3000 | +| `module` / `main` with no `start` script | that file | 3000 | + +The package manager comes from the lockfile (`bun.lock`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`). Bun projects build on `oven/bun:1`; everything else on `node:-slim`, with the major read from `.nvmrc`, `.node-version` or `engines.node` (default 24). The build runs the `build` script when there is one, and the image sets `PORT` and binds to `0.0.0.0`. `--port` changes the port in the generated file and the endpoint. + +A frontend framework without a server adapter (Vite, Create React App, Astro or SvelteKit without the Node adapter) is not offered a Dockerfile: its `start` script is a dev server. The walkthrough points you at `bunny sites deploy` instead. + +The Dockerfile and a `.dockerignore` (unless you have one) are written to the project root. They are yours from then on: deploys build the file as-is, and the generator never runs again. `--dry-run` prints the Dockerfile instead of writing it. The `.dockerignore` keeps `.env` files out of the image; set them with `bunny apps env` instead. + ### 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. diff --git a/packages/cli/src/commands/apps/dockerfile.test.ts b/packages/cli/src/commands/apps/dockerfile.test.ts new file mode 100644 index 00000000..88dd7958 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; +import { + type AppSnapshot, + DEFAULT_NODE_VERSION, + DOCKERIGNORE, + detectAppFrom, + renderDockerfile, +} from "./dockerfile.ts"; + +function snapshot( + packageJson: Record | null, + entries: string[] = ["package.json"], + nodeVersionFile?: string, +): AppSnapshot { + return { entries, packageJson, nodeVersionFile }; +} + +describe("detectAppFrom", () => { + test("no package.json is not an app", () => { + expect(detectAppFrom(snapshot(null, []))).toBeUndefined(); + }); + + test("Next.js runs next start on 3000", () => { + const plan = detectAppFrom( + snapshot({ + name: "@acme/web", + scripts: { build: "next build", start: "next start" }, + dependencies: { next: "15.0.0" }, + }), + ); + expect(plan).toMatchObject({ + label: "Next.js", + port: 3000, + build: true, + runtime: "node", + appName: "web", + command: ["node_modules/.bin/next", "start"], + }); + }); + + test("Astro needs the node adapter to be a server", () => { + const deps = { astro: "5.0.0" }; + expect(detectAppFrom(snapshot({ dependencies: deps }))).toBeUndefined(); + expect( + detectAppFrom( + snapshot({ dependencies: { ...deps, "@astrojs/node": "9.0.0" } }), + ), + ).toMatchObject({ label: "Astro", port: 4321 }); + }); + + test("a frontend's dev-server start script is not an app", () => { + const plan = detectAppFrom( + snapshot({ + scripts: { start: "react-scripts start", build: "react-scripts build" }, + dependencies: { "react-scripts": "5.0.0" }, + }), + ); + expect(plan).toBeUndefined(); + }); + + test("a frontend with its own server runs the server", () => { + const plan = detectAppFrom( + snapshot({ + scripts: { start: "node server.js" }, + dependencies: { express: "5.0.0", vite: "6.0.0" }, + }), + ); + expect(plan).toMatchObject({ + label: "Node.js", + command: ["npm", "run", "start"], + }); + }); + + test("Bun lockfile runs on the bun image and swaps node for bun", () => { + const plan = detectAppFrom( + snapshot( + { dependencies: { nuxt: "3.0.0" }, scripts: { build: "nuxt build" } }, + ["package.json", "bun.lock"], + ), + ); + expect(plan).toMatchObject({ + runtime: "bun", + command: ["bun", ".output/server/index.mjs"], + }); + }); + + test("an entry point without a start script runs the entry", () => { + expect( + detectAppFrom( + snapshot({ module: "src/index.ts" }, ["package.json", "bun.lockb"]), + )?.command, + ).toEqual(["bun", "src/index.ts"]); + }); + + test("nothing to start is not an app", () => { + expect( + detectAppFrom(snapshot({ scripts: { test: "bun test" } })), + ).toBeUndefined(); + }); + + test.each([ + ["20.11.0\n", undefined, 20], + [undefined, ">=22", 22], + ["lts/*", undefined, DEFAULT_NODE_VERSION], + [undefined, undefined, DEFAULT_NODE_VERSION], + ])("node version from %j / engines %j → %d", (file, engines, expected) => { + const plan = detectAppFrom( + snapshot( + { scripts: { start: "node ." }, engines: { node: engines } }, + ["package.json"], + file, + ), + ); + expect(plan?.nodeVersion).toBe(expected); + }); +}); + +describe("renderDockerfile", () => { + test("installs from the lockfile before copying source so deps cache", () => { + const snap = snapshot( + { + scripts: { build: "next build" }, + dependencies: { next: "15.0.0" }, + }, + ["package.json", "package-lock.json", ".npmrc", "src"], + ); + const plan = detectAppFrom(snap); + if (!plan) throw new Error("expected a plan"); + const lines = renderDockerfile(plan, snap).split("\n"); + + const copyManifest = lines.indexOf( + "COPY package.json package-lock.json .npmrc ./", + ); + const install = lines.indexOf("RUN npm ci"); + const copySource = lines.indexOf("COPY . ."); + expect(copyManifest).toBeGreaterThan(-1); + expect(install).toBeGreaterThan(copyManifest); + expect(copySource).toBeGreaterThan(install); + expect(lines).toContain("RUN npm run build"); + expect(lines).toContain("EXPOSE 3000"); + expect(lines).toContain('CMD ["node_modules/.bin/next","start"]'); + }); + + test("workspaces install from the full source tree", () => { + const snap = snapshot( + { workspaces: ["apps/*"], scripts: { start: "node ." } }, + ["package.json", "pnpm-lock.yaml"], + ); + const plan = detectAppFrom(snap); + if (!plan) throw new Error("expected a plan"); + const text = renderDockerfile(plan, snap); + expect(text).toContain( + "COPY . .\nRUN corepack enable && pnpm install --frozen-lockfile", + ); + }); + + test("Nuxt ships only its build output", () => { + const snap = snapshot( + { dependencies: { nuxt: "3.0.0" }, scripts: { build: "nuxt build" } }, + ["package.json", "yarn.lock", ".yarnrc.yml"], + ); + const plan = detectAppFrom(snap); + if (!plan) throw new Error("expected a plan"); + const text = renderDockerfile(plan, snap); + expect(text).toContain("corepack enable && yarn install --immutable"); + expect(text).toContain("COPY --from=build /app/.output ./.output"); + expect(text).not.toContain("COPY --from=build /app ./"); + }); + + test("the port binds on every interface", () => { + const snap = snapshot({ scripts: { start: "node ." } }); + const plan = detectAppFrom(snap); + if (!plan) throw new Error("expected a plan"); + expect(renderDockerfile({ ...plan, port: 8080 }, snap)).toContain( + "ENV NODE_ENV=production PORT=8080 HOST=0.0.0.0 HOSTNAME=0.0.0.0", + ); + }); +}); + +test("dockerignore keeps env files out of the image", () => { + const lines = DOCKERIGNORE.split("\n"); + expect(lines).toContain(".env"); + expect(lines).toContain(".env.*"); + expect(lines).toContain("node_modules"); +}); diff --git a/packages/cli/src/commands/apps/dockerfile.ts b/packages/cli/src/commands/apps/dockerfile.ts new file mode 100644 index 00000000..a62efd3b --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile.ts @@ -0,0 +1,411 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + detectFrameworkFrom, + detectPackageManagerFrom, + type PackageManager, +} from "@bunny.net/framework-detector"; + +/** + * Detect a Node or Bun server app from the project root and render a + * Dockerfile for it, so `bunny apps deploy` works in a repo that has no + * Dockerfile. The generated file is written to disk and owned by the user + * from then on; the deploy builds it like any other Dockerfile. + */ + +/** What detection reads from the project root. */ +export interface AppSnapshot { + /** Names of the files and directories at the project root. */ + entries: readonly string[]; + /** The parsed package.json, or null when there is none. */ + packageJson: Record | null; + /** Contents of `.nvmrc` or `.node-version`, when present. */ + nodeVersionFile?: string | null; +} + +export interface AppPlan { + /** Human-readable framework name, e.g. "Next.js". */ + label: string; + packageManager: PackageManager; + /** Base image runtime: `bun` uses oven/bun, everything else uses node. */ + runtime: "node" | "bun"; + /** Node major version for the node base image. */ + nodeVersion: number; + /** Port the server listens on (set via `PORT` in the image). */ + port: number; + /** Run the package.json `build` script during the image build. */ + build: boolean; + /** Exec-form CMD. */ + command: string[]; + /** Paths copied into the runtime stage; omitted copies the whole app. */ + copy?: string[]; + /** Suggested app name from package.json. */ + appName?: string; +} + +export const DEFAULT_NODE_VERSION = 24; + +interface ServerFramework { + label: string; + /** Every dependency must be present (the framework plus its server adapter). */ + requires: string[]; + port: number; + command: string[]; + copy?: string[]; +} + +// Ordered most-specific first. Meta-frameworks that only produce a server +// with a Node adapter list the adapter too; without it the build is static +// or targets another platform and isn't something we can run. +const SERVER_FRAMEWORKS: ServerFramework[] = [ + { + label: "Next.js", + requires: ["next"], + port: 3000, + command: ["node_modules/.bin/next", "start"], + }, + { + label: "Nuxt", + requires: ["nuxt"], + port: 3000, + command: ["node", ".output/server/index.mjs"], + copy: [".output"], + }, + { + label: "SvelteKit", + requires: ["@sveltejs/kit", "@sveltejs/adapter-node"], + port: 3000, + command: ["node", "build/index.js"], + }, + { + label: "Astro", + requires: ["astro", "@astrojs/node"], + port: 4321, + command: ["node", "dist/server/entry.mjs"], + }, + { + label: "React Router", + requires: ["@react-router/serve"], + port: 3000, + command: ["node_modules/.bin/react-router-serve", "build/server/index.js"], + }, + { + label: "Remix", + requires: ["@remix-run/serve"], + port: 3000, + command: ["node_modules/.bin/remix-serve", "build/index.js"], + }, + { + label: "NestJS", + requires: ["@nestjs/core"], + port: 3000, + command: ["node", "dist/main.js"], + }, +]; + +// Libraries that mean the project runs its own HTTP server. +const SERVER_LIBRARIES = [ + "express", + "fastify", + "koa", + "hono", + "elysia", + "h3", + "@hapi/hapi", +]; + +function dependencies(pkg: Record): Set { + const names = new Set(); + for (const field of ["dependencies", "devDependencies"]) { + const deps = pkg[field]; + if (deps && typeof deps === "object") { + for (const name of Object.keys(deps)) names.add(name); + } + } + return names; +} + +function scripts(pkg: Record): Record { + const value = pkg.scripts; + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +/** First plausible Node major in `.nvmrc`, `.node-version` or `engines.node`. */ +export function nodeMajorFrom( + nodeVersionFile: string | null | undefined, + engines: unknown, +): number { + const candidates = [ + nodeVersionFile, + engines && typeof engines === "object" + ? (engines as Record).node + : undefined, + ]; + for (const candidate of candidates) { + if (typeof candidate !== "string") continue; + const major = Number(candidate.match(/\d+/)?.[0]); + if (major >= 18 && major < 100) return major; + } + return DEFAULT_NODE_VERSION; +} + +/** + * Work out how to containerise a Node/Bun project, or undefined when it + * isn't one we can run: no package.json, or no framework, `start` script + * or entry point to start a server from. + */ +export function detectAppFrom(snapshot: AppSnapshot): AppPlan | undefined { + const pkg = snapshot.packageJson; + if (!pkg) return undefined; + + const deps = dependencies(pkg); + const pkgScripts = scripts(pkg); + const packageManager = detectPackageManagerFrom(snapshot.entries); + const runtime = packageManager === "bun" ? "bun" : "node"; + const base = { + packageManager, + runtime, + nodeVersion: nodeMajorFrom(snapshot.nodeVersionFile, pkg.engines), + build: typeof pkgScripts.build === "string", + appName: appNameFrom(pkg.name), + } as const; + + const framework = SERVER_FRAMEWORKS.find((f) => + f.requires.every((dep) => deps.has(dep)), + ); + if (framework) { + return { + ...base, + label: framework.label, + port: framework.port, + command: runtime === "bun" ? onBun(framework.command) : framework.command, + copy: framework.copy, + }; + } + + // A frontend framework without a server adapter builds static files, and + // its `start` script is a dev server: that's a Sites deploy, not an app. + // Unless a server library is also present (an Express app with a Vite + // frontend, say), the server is what we run. + const frontend = detectFrameworkFrom(snapshot); + if ( + frontend?.toolchain === "js" && + !SERVER_LIBRARIES.some((dep) => deps.has(dep)) + ) { + return undefined; + } + + const label = runtime === "bun" ? "Bun" : "Node.js"; + if (typeof pkgScripts.start === "string") { + return { + ...base, + label, + port: 3000, + command: [runtime === "bun" ? "bun" : "npm", "run", "start"], + }; + } + + const entry = + typeof pkg.module === "string" + ? pkg.module + : typeof pkg.main === "string" + ? pkg.main + : undefined; + if (entry) { + return { + ...base, + label, + port: 3000, + command: [runtime === "bun" ? "bun" : "node", entry], + }; + } + + return undefined; +} + +/** + * Adapt a node command to the bun image, which has no node binary: + * `node x` runs the file with bun, and package binaries go through + * `bun run`, which shims `node` for their `#!/usr/bin/env node` shebangs. + */ +function onBun(command: string[]): string[] { + const [head, ...rest] = command; + if (head === "node") return ["bun", ...rest]; + if (head?.startsWith("node_modules/.bin/")) { + return ["bun", "run", head.slice("node_modules/.bin/".length), ...rest]; + } + return command; +} + +/** `@scope/my-app` → `my-app`; undefined for missing or unusable names. */ +function appNameFrom(name: unknown): string | undefined { + if (typeof name !== "string") return undefined; + const bare = name.split("/").pop()?.trim(); + return bare || undefined; +} + +function readOptional(path: string): string | null { + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +/** Read what detection needs from the project root, or undefined when it can't be read. */ +export function readAppSnapshot(root: string): AppSnapshot | undefined { + try { + return { + entries: readdirSync(root), + packageJson: JSON.parse( + readOptional(join(root, "package.json")) ?? "null", + ), + nodeVersionFile: + readOptional(join(root, ".nvmrc")) ?? + readOptional(join(root, ".node-version")), + }; + } catch { + return undefined; + } +} + +const LOCKFILES: Record = { + bun: ["bun.lock", "bun.lockb"], + pnpm: ["pnpm-lock.yaml"], + yarn: ["yarn.lock"], + npm: ["package-lock.json"], +}; + +// Config the install step reads, copied alongside the manifest when present. +const INSTALL_CONFIG_FILES = [".npmrc", ".yarnrc.yml", ".yarnrc"]; + +function installCommand( + packageManager: PackageManager, + entries: readonly string[], +): string { + const hasLockfile = LOCKFILES[packageManager].some((f) => + entries.includes(f), + ); + switch (packageManager) { + case "bun": + return hasLockfile ? "bun install --frozen-lockfile" : "bun install"; + case "pnpm": + return hasLockfile + ? "corepack enable && pnpm install --frozen-lockfile" + : "corepack enable && pnpm install"; + case "yarn": + if (!hasLockfile) return "corepack enable && yarn install"; + return entries.includes(".yarnrc.yml") + ? "corepack enable && yarn install --immutable" + : "corepack enable && yarn install --frozen-lockfile"; + case "npm": + return hasLockfile ? "npm ci" : "npm install"; + } +} + +function buildCommand(packageManager: PackageManager): string { + return packageManager === "npm" + ? "npm run build" + : `${packageManager === "bun" ? "bun" : `corepack enable && ${packageManager}`} run build`; +} + +/** + * Whether the install step needs the whole source tree: workspaces pull + * in nested package.json files, and patch/plugin directories are read + * during install. + */ +function needsFullSourceForInstall( + entries: readonly string[], + packageJson: Record | null, +): boolean { + return ( + Boolean(packageJson?.workspaces) || + entries.includes("pnpm-workspace.yaml") || + entries.includes("patches") || + entries.includes(".yarn") + ); +} + +/** + * Render a two-stage Dockerfile for `plan`: install and build in the first + * stage, then copy the app (or just its build output) into the runtime + * stage. `entries` and `packageJson` decide which files the install step + * needs, so dependency layers cache across source changes. + */ +export function renderDockerfile( + plan: AppPlan, + snapshot: Pick, +): string { + const image = + plan.runtime === "bun" ? "oven/bun:1" : `node:${plan.nodeVersion}-slim`; + const { entries } = snapshot; + + const lines = [ + `# Generated by \`bunny apps deploy\` for ${plan.label}. Edit freely: deploys build this file as-is.`, + `FROM ${image} AS build`, + "WORKDIR /app", + ]; + + if (needsFullSourceForInstall(entries, snapshot.packageJson)) { + lines.push( + "COPY . .", + `RUN ${installCommand(plan.packageManager, entries)}`, + ); + } else { + const manifest = [ + "package.json", + ...LOCKFILES[plan.packageManager], + ...INSTALL_CONFIG_FILES, + ].filter((f) => f === "package.json" || entries.includes(f)); + lines.push( + `COPY ${manifest.join(" ")} ./`, + `RUN ${installCommand(plan.packageManager, entries)}`, + "COPY . .", + ); + } + + if (plan.build) lines.push(`RUN ${buildCommand(plan.packageManager)}`); + + lines.push( + "", + `FROM ${image}`, + "WORKDIR /app", + // HOST and HOSTNAME bind the server to every interface: frameworks + // default to localhost, which is unreachable from outside the container. + `ENV NODE_ENV=production PORT=${plan.port} HOST=0.0.0.0 HOSTNAME=0.0.0.0`, + ); + if (plan.copy) { + for (const path of plan.copy) { + lines.push(`COPY --from=build /app/${path} ./${path}`); + } + } else { + lines.push("COPY --from=build /app ./"); + } + lines.push(`EXPOSE ${plan.port}`, `CMD ${JSON.stringify(plan.command)}`, ""); + + return lines.join("\n"); +} + +/** + * Keeps local installs, build output and secrets out of the build context. + * `.env` files must never be baked into an image; `bunny apps env` sets + * them at runtime instead. + */ +export const DOCKERIGNORE = `${[ + "# Generated by `bunny apps deploy`.", + ".git", + "node_modules", + ".next", + ".nuxt", + ".output", + ".svelte-kit", + ".astro", + ".env", + ".env.*", + "!.env.example", + "npm-debug.log*", + "Dockerfile", + ".dockerignore", +].join("\n")}\n`; diff --git a/packages/cli/src/commands/apps/walkthrough.ts b/packages/cli/src/commands/apps/walkthrough.ts index ba7b7e09..92efc7af 100644 --- a/packages/cli/src/commands/apps/walkthrough.ts +++ b/packages/cli/src/commands/apps/walkthrough.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { detectFrameworkFrom } from "@bunny.net/framework-detector"; import { parseDotenv } from "@/core/env.ts"; import { UserError } from "@/core/errors.ts"; import { logger } from "@/core/logger.ts"; @@ -28,6 +29,14 @@ import { readDockerfileExposedPorts, resolveRegistryForImage, } from "./docker.ts"; +import { + type AppPlan, + type AppSnapshot, + DOCKERIGNORE, + detectAppFrom, + readAppSnapshot, + renderDockerfile, +} from "./dockerfile.ts"; import { confirmEndpointSuggestions, endpointRequestToConfig, @@ -103,10 +112,27 @@ export async function runWalkthrough( let imageRef: string | undefined = input.positionalImage; let dockerfilePath: string | undefined = input.dockerfileFlag; + // Set when we write a Dockerfile for a detected framework; its port + // seeds the endpoint, since in --dry-run the file is never on disk. + let generated: AppPlan | undefined; if (!imageRef && !dockerfilePath) { const composeFile = findComposeFile(process.cwd()); const detectedDockerfiles = findDockerfiles(process.cwd()); + // Only offer to generate when there's no Dockerfile to build already. + const snapshot = + detectedDockerfiles.length === 0 + ? readAppSnapshot(process.cwd()) + : undefined; + const detectedApp = snapshot ? detectAppFrom(snapshot) : undefined; + // A frontend with no server to run belongs on Sites, not in a container. + const staticFramework = + snapshot && !detectedApp ? detectFrameworkFrom(snapshot) : undefined; + if (staticFramework && staticFramework.id !== "static") { + logger.dim( + `This looks like a static ${staticFramework.label} site. \`bunny sites deploy\` hosts it without a container.`, + ); + } const composeServicesCount = composeFile ? Object.keys(loadComposeFile(composeFile).services).length @@ -132,6 +158,14 @@ export async function runWalkthrough( }, ] : []), + ...(detectedApp + ? [ + { + title: `Generate a Dockerfile for ${detectedApp.label} and build it`, + value: "generate", + }, + ] + : []), { title: dockerfileChoiceTitle, value: "dockerfile" }, { title: "Deploy a pre-built image", value: "image" }, ], @@ -139,9 +173,9 @@ export async function runWalkthrough( if (!value) throw new UserError("Setup cancelled."); // Bail before any further prompts / disk writes if Docker is missing. - // Both "compose" and "dockerfile" picks always end in a local build; - // without Docker we'd write `bunny.jsonc` and then crash mid-deploy. - if (value === "compose" || value === "dockerfile") { + // Every pick but "image" ends in a local build; without Docker we'd + // write `bunny.jsonc` and then crash mid-deploy. + if (value !== "image") { await ensureDockerAvailable(); } @@ -149,7 +183,17 @@ export async function runWalkthrough( return runComposeImport(client, composeFile, input); } - if (value === "dockerfile") { + if (value === "generate" && detectedApp && snapshot) { + generated = { + ...detectedApp, + port: input.portOverride ?? detectedApp.port, + }; + dockerfilePath = writeGeneratedDockerfile( + generated, + snapshot, + input.dryRun, + ); + } else if (value === "dockerfile") { const selectedPaths = await chooseDockerfiles(detectedDockerfiles); if (selectedPaths.length === 0) { throw new UserError("At least one Dockerfile is required."); @@ -192,6 +236,19 @@ export async function runWalkthrough( logger.dim(suggestions.instructions); logger.log(); } + } else if (generated) { + suggestions = { + appName: generated.appName, + endpointSuggestions: [ + { + displayName: "cdn", + cdn: { + isSslEnabled: true, + portMappings: [{ exposedPort: 443, containerPort: generated.port }], + }, + }, + ], + }; } else if (mode === "build" && dockerfilePath) { // Read EXPOSE directives from the Dockerfile to seed endpoint // suggestions. bunny's getConfigSuggestions only works for pre-built @@ -305,6 +362,39 @@ export async function runWalkthrough( return { config: toml, registries: { [name]: registry.id } }; } +/** + * Write a Dockerfile (and a `.dockerignore`, unless one exists) for a + * detected app at the project root, and return the Dockerfile path. With + * `dryRun`, print the Dockerfile instead of writing anything. + */ +function writeGeneratedDockerfile( + plan: AppPlan, + snapshot: AppSnapshot, + dryRun?: boolean, +): string { + const cwd = process.cwd(); + const contents = renderDockerfile(plan, snapshot); + + if (dryRun) { + logger.dim("--- Dockerfile (preview) ---"); + logger.log(contents); + logger.dim("--- end preview ---"); + logger.dim("Would write Dockerfile (--dry-run)."); + return GENERATED_DOCKERFILE; + } + + writeFileSync(resolve(cwd, GENERATED_DOCKERFILE), contents); + logger.success(`Wrote a Dockerfile for ${plan.label}.`); + const dockerignore = resolve(cwd, ".dockerignore"); + if (!existsSync(dockerignore)) { + writeFileSync(dockerignore, DOCKERIGNORE); + logger.success("Wrote .dockerignore."); + } + return GENERATED_DOCKERFILE; +} + +const GENERATED_DOCKERFILE = "Dockerfile"; + /** * Compose import path: translate `compose.yml` → `bunny.jsonc` and write. * From 714ab6b71b2755eeeb0ec953785d1bf8f849e191 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 18:04:34 +0000 Subject: [PATCH 02/12] feat(apps): generate Dockerfiles for Python, Go, Rust, Rails and Laravel apps --- packages/cli/src/commands/apps/APPS.md | 32 +- .../cli/src/commands/apps/dockerfile.test.ts | 185 -------- packages/cli/src/commands/apps/dockerfile.ts | 411 ------------------ .../cli/src/commands/apps/dockerfile/go.ts | 48 ++ .../commands/apps/dockerfile/index.test.ts | 204 +++++++++ .../cli/src/commands/apps/dockerfile/index.ts | 73 ++++ .../src/commands/apps/dockerfile/laravel.ts | 44 ++ .../cli/src/commands/apps/dockerfile/node.ts | 261 +++++++++++ .../src/commands/apps/dockerfile/project.ts | 63 +++ .../src/commands/apps/dockerfile/python.ts | 99 +++++ .../cli/src/commands/apps/dockerfile/rails.ts | 42 ++ .../cli/src/commands/apps/dockerfile/rust.ts | 31 ++ packages/cli/src/commands/apps/walkthrough.ts | 82 ++-- 13 files changed, 916 insertions(+), 659 deletions(-) delete mode 100644 packages/cli/src/commands/apps/dockerfile.test.ts delete mode 100644 packages/cli/src/commands/apps/dockerfile.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/go.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/index.test.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/index.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/laravel.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/node.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/project.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/python.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/rails.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/rust.ts diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 3cef4535..58d160f5 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -34,7 +34,7 @@ Deploy an app. Three modes, chosen by what you pass: If no `bunny.jsonc` exists, the first run launches a walkthrough that: 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. With no Dockerfile, a detected Node or Bun app gets a third option: generate one (see below). +2. Otherwise offers to build from a `Dockerfile` (if one exists) or to deploy a pre-built image ref. With no Dockerfile, a detected app (Node, Bun, Python, Go, Rust, Rails, Laravel) gets a third option: generate one (see below). 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. @@ -49,23 +49,25 @@ To rollback: bunny apps deploy ghcr.io/me/api:abc-123 ### Generating a Dockerfile -When the project has no Dockerfile, the walkthrough reads `package.json` and offers to write one for you: +When the project has no Dockerfile, the walkthrough looks at the project root and offers to write one for you: -| Detected from | Runs | Port | -| ------------------------------------------ | ---------------------------------------- | ---- | -| `next` | `next start` | 3000 | -| `nuxt` | `.output/server/index.mjs` (output only) | 3000 | -| `@sveltejs/kit` + `@sveltejs/adapter-node` | `build/index.js` | 3000 | -| `astro` + `@astrojs/node` | `dist/server/entry.mjs` | 4321 | -| `@react-router/serve` | `react-router-serve` | 3000 | -| `@remix-run/serve` | `remix-serve` | 3000 | -| `@nestjs/core` | `dist/main.js` | 3000 | -| a `start` script | the `start` script | 3000 | -| `module` / `main` with no `start` script | that file | 3000 | +| Detected from | Image | Runs | Port | +| ---------------------------------------------------------------------------------------------------------- | ----------------------------------- | ---------------------------- | ----------------- | +| `composer.json` requiring `laravel/framework` | `dunglas/frankenphp` | FrankenPHP serving `public/` | 8080 | +| `Gemfile` with `rails` | `ruby:-slim` | `bin/rails server` | 3000 | +| `requirements.txt` / `pyproject.toml` with Django | `python:-slim` | `gunicorn .wsgi` | 8000 | +| ... with FastAPI (`main.py`, `app.py` or `app/main.py`) | `python:-slim` | `uvicorn :app` | 8000 | +| ... with Flask (`app.py`, `wsgi.py` or `main.py`) | `python:-slim` | `gunicorn :app` | 8000 | +| `go.mod` with a root package or `cmd//main.go` | `golang`, then distroless | the compiled binary | 8080 | +| `Cargo.toml` with a `[package]` | `rust`, then `debian:bookworm-slim` | the compiled binary | 8080 | +| `package.json` with Next.js, Nuxt, React Router, Remix, NestJS, or SvelteKit/Astro with their Node adapter | `node:-slim` or `oven/bun:1` | the framework's server | 3000 (Astro 4321) | +| `package.json` with a `start` script or a `main`/`module` entry | `node:-slim` or `oven/bun:1` | that script or file | 3000 | -The package manager comes from the lockfile (`bun.lock`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`). Bun projects build on `oven/bun:1`; everything else on `node:-slim`, with the major read from `.nvmrc`, `.node-version` or `engines.node` (default 24). The build runs the `build` script when there is one, and the image sets `PORT` and binds to `0.0.0.0`. `--port` changes the port in the generated file and the endpoint. +Versions come from `.nvmrc` / `.node-version` / `engines.node`, `.python-version`, `.ruby-version` / the Gemfile's `ruby` line, `go.mod`, and `composer.json`'s `php` constraint. JS package managers come from the lockfile, Python from `uv.lock` or `requirements.txt`. Dependencies install before the source is copied so they cache between deploys. Every image gets `PORT` set and binds to `0.0.0.0`; `--port` changes the port in the generated file and the endpoint. -A frontend framework without a server adapter (Vite, Create React App, Astro or SvelteKit without the Node adapter) is not offered a Dockerfile: its `start` script is a dev server. The walkthrough points you at `bunny sites deploy` instead. +Laravel and Rails win over a `package.json` next to them; Laravel builds its Vite assets in a separate Node stage when there is a `build` script. Set `APP_KEY` (Laravel) or `SECRET_KEY_BASE` / `RAILS_MASTER_KEY` (Rails) with `bunny apps env` before the first request. Rails 7.1+ apps already ship a Dockerfile, which the walkthrough uses as-is. + +A site with no server to run (Vite, Create React App, Astro or SvelteKit without the Node adapter, Hugo, Jekyll, and so on) is not offered a Dockerfile: the walkthrough points you at `bunny sites deploy` instead. The Dockerfile and a `.dockerignore` (unless you have one) are written to the project root. They are yours from then on: deploys build the file as-is, and the generator never runs again. `--dry-run` prints the Dockerfile instead of writing it. The `.dockerignore` keeps `.env` files out of the image; set them with `bunny apps env` instead. diff --git a/packages/cli/src/commands/apps/dockerfile.test.ts b/packages/cli/src/commands/apps/dockerfile.test.ts deleted file mode 100644 index 88dd7958..00000000 --- a/packages/cli/src/commands/apps/dockerfile.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type AppSnapshot, - DEFAULT_NODE_VERSION, - DOCKERIGNORE, - detectAppFrom, - renderDockerfile, -} from "./dockerfile.ts"; - -function snapshot( - packageJson: Record | null, - entries: string[] = ["package.json"], - nodeVersionFile?: string, -): AppSnapshot { - return { entries, packageJson, nodeVersionFile }; -} - -describe("detectAppFrom", () => { - test("no package.json is not an app", () => { - expect(detectAppFrom(snapshot(null, []))).toBeUndefined(); - }); - - test("Next.js runs next start on 3000", () => { - const plan = detectAppFrom( - snapshot({ - name: "@acme/web", - scripts: { build: "next build", start: "next start" }, - dependencies: { next: "15.0.0" }, - }), - ); - expect(plan).toMatchObject({ - label: "Next.js", - port: 3000, - build: true, - runtime: "node", - appName: "web", - command: ["node_modules/.bin/next", "start"], - }); - }); - - test("Astro needs the node adapter to be a server", () => { - const deps = { astro: "5.0.0" }; - expect(detectAppFrom(snapshot({ dependencies: deps }))).toBeUndefined(); - expect( - detectAppFrom( - snapshot({ dependencies: { ...deps, "@astrojs/node": "9.0.0" } }), - ), - ).toMatchObject({ label: "Astro", port: 4321 }); - }); - - test("a frontend's dev-server start script is not an app", () => { - const plan = detectAppFrom( - snapshot({ - scripts: { start: "react-scripts start", build: "react-scripts build" }, - dependencies: { "react-scripts": "5.0.0" }, - }), - ); - expect(plan).toBeUndefined(); - }); - - test("a frontend with its own server runs the server", () => { - const plan = detectAppFrom( - snapshot({ - scripts: { start: "node server.js" }, - dependencies: { express: "5.0.0", vite: "6.0.0" }, - }), - ); - expect(plan).toMatchObject({ - label: "Node.js", - command: ["npm", "run", "start"], - }); - }); - - test("Bun lockfile runs on the bun image and swaps node for bun", () => { - const plan = detectAppFrom( - snapshot( - { dependencies: { nuxt: "3.0.0" }, scripts: { build: "nuxt build" } }, - ["package.json", "bun.lock"], - ), - ); - expect(plan).toMatchObject({ - runtime: "bun", - command: ["bun", ".output/server/index.mjs"], - }); - }); - - test("an entry point without a start script runs the entry", () => { - expect( - detectAppFrom( - snapshot({ module: "src/index.ts" }, ["package.json", "bun.lockb"]), - )?.command, - ).toEqual(["bun", "src/index.ts"]); - }); - - test("nothing to start is not an app", () => { - expect( - detectAppFrom(snapshot({ scripts: { test: "bun test" } })), - ).toBeUndefined(); - }); - - test.each([ - ["20.11.0\n", undefined, 20], - [undefined, ">=22", 22], - ["lts/*", undefined, DEFAULT_NODE_VERSION], - [undefined, undefined, DEFAULT_NODE_VERSION], - ])("node version from %j / engines %j → %d", (file, engines, expected) => { - const plan = detectAppFrom( - snapshot( - { scripts: { start: "node ." }, engines: { node: engines } }, - ["package.json"], - file, - ), - ); - expect(plan?.nodeVersion).toBe(expected); - }); -}); - -describe("renderDockerfile", () => { - test("installs from the lockfile before copying source so deps cache", () => { - const snap = snapshot( - { - scripts: { build: "next build" }, - dependencies: { next: "15.0.0" }, - }, - ["package.json", "package-lock.json", ".npmrc", "src"], - ); - const plan = detectAppFrom(snap); - if (!plan) throw new Error("expected a plan"); - const lines = renderDockerfile(plan, snap).split("\n"); - - const copyManifest = lines.indexOf( - "COPY package.json package-lock.json .npmrc ./", - ); - const install = lines.indexOf("RUN npm ci"); - const copySource = lines.indexOf("COPY . ."); - expect(copyManifest).toBeGreaterThan(-1); - expect(install).toBeGreaterThan(copyManifest); - expect(copySource).toBeGreaterThan(install); - expect(lines).toContain("RUN npm run build"); - expect(lines).toContain("EXPOSE 3000"); - expect(lines).toContain('CMD ["node_modules/.bin/next","start"]'); - }); - - test("workspaces install from the full source tree", () => { - const snap = snapshot( - { workspaces: ["apps/*"], scripts: { start: "node ." } }, - ["package.json", "pnpm-lock.yaml"], - ); - const plan = detectAppFrom(snap); - if (!plan) throw new Error("expected a plan"); - const text = renderDockerfile(plan, snap); - expect(text).toContain( - "COPY . .\nRUN corepack enable && pnpm install --frozen-lockfile", - ); - }); - - test("Nuxt ships only its build output", () => { - const snap = snapshot( - { dependencies: { nuxt: "3.0.0" }, scripts: { build: "nuxt build" } }, - ["package.json", "yarn.lock", ".yarnrc.yml"], - ); - const plan = detectAppFrom(snap); - if (!plan) throw new Error("expected a plan"); - const text = renderDockerfile(plan, snap); - expect(text).toContain("corepack enable && yarn install --immutable"); - expect(text).toContain("COPY --from=build /app/.output ./.output"); - expect(text).not.toContain("COPY --from=build /app ./"); - }); - - test("the port binds on every interface", () => { - const snap = snapshot({ scripts: { start: "node ." } }); - const plan = detectAppFrom(snap); - if (!plan) throw new Error("expected a plan"); - expect(renderDockerfile({ ...plan, port: 8080 }, snap)).toContain( - "ENV NODE_ENV=production PORT=8080 HOST=0.0.0.0 HOSTNAME=0.0.0.0", - ); - }); -}); - -test("dockerignore keeps env files out of the image", () => { - const lines = DOCKERIGNORE.split("\n"); - expect(lines).toContain(".env"); - expect(lines).toContain(".env.*"); - expect(lines).toContain("node_modules"); -}); diff --git a/packages/cli/src/commands/apps/dockerfile.ts b/packages/cli/src/commands/apps/dockerfile.ts deleted file mode 100644 index a62efd3b..00000000 --- a/packages/cli/src/commands/apps/dockerfile.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { - detectFrameworkFrom, - detectPackageManagerFrom, - type PackageManager, -} from "@bunny.net/framework-detector"; - -/** - * Detect a Node or Bun server app from the project root and render a - * Dockerfile for it, so `bunny apps deploy` works in a repo that has no - * Dockerfile. The generated file is written to disk and owned by the user - * from then on; the deploy builds it like any other Dockerfile. - */ - -/** What detection reads from the project root. */ -export interface AppSnapshot { - /** Names of the files and directories at the project root. */ - entries: readonly string[]; - /** The parsed package.json, or null when there is none. */ - packageJson: Record | null; - /** Contents of `.nvmrc` or `.node-version`, when present. */ - nodeVersionFile?: string | null; -} - -export interface AppPlan { - /** Human-readable framework name, e.g. "Next.js". */ - label: string; - packageManager: PackageManager; - /** Base image runtime: `bun` uses oven/bun, everything else uses node. */ - runtime: "node" | "bun"; - /** Node major version for the node base image. */ - nodeVersion: number; - /** Port the server listens on (set via `PORT` in the image). */ - port: number; - /** Run the package.json `build` script during the image build. */ - build: boolean; - /** Exec-form CMD. */ - command: string[]; - /** Paths copied into the runtime stage; omitted copies the whole app. */ - copy?: string[]; - /** Suggested app name from package.json. */ - appName?: string; -} - -export const DEFAULT_NODE_VERSION = 24; - -interface ServerFramework { - label: string; - /** Every dependency must be present (the framework plus its server adapter). */ - requires: string[]; - port: number; - command: string[]; - copy?: string[]; -} - -// Ordered most-specific first. Meta-frameworks that only produce a server -// with a Node adapter list the adapter too; without it the build is static -// or targets another platform and isn't something we can run. -const SERVER_FRAMEWORKS: ServerFramework[] = [ - { - label: "Next.js", - requires: ["next"], - port: 3000, - command: ["node_modules/.bin/next", "start"], - }, - { - label: "Nuxt", - requires: ["nuxt"], - port: 3000, - command: ["node", ".output/server/index.mjs"], - copy: [".output"], - }, - { - label: "SvelteKit", - requires: ["@sveltejs/kit", "@sveltejs/adapter-node"], - port: 3000, - command: ["node", "build/index.js"], - }, - { - label: "Astro", - requires: ["astro", "@astrojs/node"], - port: 4321, - command: ["node", "dist/server/entry.mjs"], - }, - { - label: "React Router", - requires: ["@react-router/serve"], - port: 3000, - command: ["node_modules/.bin/react-router-serve", "build/server/index.js"], - }, - { - label: "Remix", - requires: ["@remix-run/serve"], - port: 3000, - command: ["node_modules/.bin/remix-serve", "build/index.js"], - }, - { - label: "NestJS", - requires: ["@nestjs/core"], - port: 3000, - command: ["node", "dist/main.js"], - }, -]; - -// Libraries that mean the project runs its own HTTP server. -const SERVER_LIBRARIES = [ - "express", - "fastify", - "koa", - "hono", - "elysia", - "h3", - "@hapi/hapi", -]; - -function dependencies(pkg: Record): Set { - const names = new Set(); - for (const field of ["dependencies", "devDependencies"]) { - const deps = pkg[field]; - if (deps && typeof deps === "object") { - for (const name of Object.keys(deps)) names.add(name); - } - } - return names; -} - -function scripts(pkg: Record): Record { - const value = pkg.scripts; - return value && typeof value === "object" - ? (value as Record) - : {}; -} - -/** First plausible Node major in `.nvmrc`, `.node-version` or `engines.node`. */ -export function nodeMajorFrom( - nodeVersionFile: string | null | undefined, - engines: unknown, -): number { - const candidates = [ - nodeVersionFile, - engines && typeof engines === "object" - ? (engines as Record).node - : undefined, - ]; - for (const candidate of candidates) { - if (typeof candidate !== "string") continue; - const major = Number(candidate.match(/\d+/)?.[0]); - if (major >= 18 && major < 100) return major; - } - return DEFAULT_NODE_VERSION; -} - -/** - * Work out how to containerise a Node/Bun project, or undefined when it - * isn't one we can run: no package.json, or no framework, `start` script - * or entry point to start a server from. - */ -export function detectAppFrom(snapshot: AppSnapshot): AppPlan | undefined { - const pkg = snapshot.packageJson; - if (!pkg) return undefined; - - const deps = dependencies(pkg); - const pkgScripts = scripts(pkg); - const packageManager = detectPackageManagerFrom(snapshot.entries); - const runtime = packageManager === "bun" ? "bun" : "node"; - const base = { - packageManager, - runtime, - nodeVersion: nodeMajorFrom(snapshot.nodeVersionFile, pkg.engines), - build: typeof pkgScripts.build === "string", - appName: appNameFrom(pkg.name), - } as const; - - const framework = SERVER_FRAMEWORKS.find((f) => - f.requires.every((dep) => deps.has(dep)), - ); - if (framework) { - return { - ...base, - label: framework.label, - port: framework.port, - command: runtime === "bun" ? onBun(framework.command) : framework.command, - copy: framework.copy, - }; - } - - // A frontend framework without a server adapter builds static files, and - // its `start` script is a dev server: that's a Sites deploy, not an app. - // Unless a server library is also present (an Express app with a Vite - // frontend, say), the server is what we run. - const frontend = detectFrameworkFrom(snapshot); - if ( - frontend?.toolchain === "js" && - !SERVER_LIBRARIES.some((dep) => deps.has(dep)) - ) { - return undefined; - } - - const label = runtime === "bun" ? "Bun" : "Node.js"; - if (typeof pkgScripts.start === "string") { - return { - ...base, - label, - port: 3000, - command: [runtime === "bun" ? "bun" : "npm", "run", "start"], - }; - } - - const entry = - typeof pkg.module === "string" - ? pkg.module - : typeof pkg.main === "string" - ? pkg.main - : undefined; - if (entry) { - return { - ...base, - label, - port: 3000, - command: [runtime === "bun" ? "bun" : "node", entry], - }; - } - - return undefined; -} - -/** - * Adapt a node command to the bun image, which has no node binary: - * `node x` runs the file with bun, and package binaries go through - * `bun run`, which shims `node` for their `#!/usr/bin/env node` shebangs. - */ -function onBun(command: string[]): string[] { - const [head, ...rest] = command; - if (head === "node") return ["bun", ...rest]; - if (head?.startsWith("node_modules/.bin/")) { - return ["bun", "run", head.slice("node_modules/.bin/".length), ...rest]; - } - return command; -} - -/** `@scope/my-app` → `my-app`; undefined for missing or unusable names. */ -function appNameFrom(name: unknown): string | undefined { - if (typeof name !== "string") return undefined; - const bare = name.split("/").pop()?.trim(); - return bare || undefined; -} - -function readOptional(path: string): string | null { - try { - return readFileSync(path, "utf-8"); - } catch { - return null; - } -} - -/** Read what detection needs from the project root, or undefined when it can't be read. */ -export function readAppSnapshot(root: string): AppSnapshot | undefined { - try { - return { - entries: readdirSync(root), - packageJson: JSON.parse( - readOptional(join(root, "package.json")) ?? "null", - ), - nodeVersionFile: - readOptional(join(root, ".nvmrc")) ?? - readOptional(join(root, ".node-version")), - }; - } catch { - return undefined; - } -} - -const LOCKFILES: Record = { - bun: ["bun.lock", "bun.lockb"], - pnpm: ["pnpm-lock.yaml"], - yarn: ["yarn.lock"], - npm: ["package-lock.json"], -}; - -// Config the install step reads, copied alongside the manifest when present. -const INSTALL_CONFIG_FILES = [".npmrc", ".yarnrc.yml", ".yarnrc"]; - -function installCommand( - packageManager: PackageManager, - entries: readonly string[], -): string { - const hasLockfile = LOCKFILES[packageManager].some((f) => - entries.includes(f), - ); - switch (packageManager) { - case "bun": - return hasLockfile ? "bun install --frozen-lockfile" : "bun install"; - case "pnpm": - return hasLockfile - ? "corepack enable && pnpm install --frozen-lockfile" - : "corepack enable && pnpm install"; - case "yarn": - if (!hasLockfile) return "corepack enable && yarn install"; - return entries.includes(".yarnrc.yml") - ? "corepack enable && yarn install --immutable" - : "corepack enable && yarn install --frozen-lockfile"; - case "npm": - return hasLockfile ? "npm ci" : "npm install"; - } -} - -function buildCommand(packageManager: PackageManager): string { - return packageManager === "npm" - ? "npm run build" - : `${packageManager === "bun" ? "bun" : `corepack enable && ${packageManager}`} run build`; -} - -/** - * Whether the install step needs the whole source tree: workspaces pull - * in nested package.json files, and patch/plugin directories are read - * during install. - */ -function needsFullSourceForInstall( - entries: readonly string[], - packageJson: Record | null, -): boolean { - return ( - Boolean(packageJson?.workspaces) || - entries.includes("pnpm-workspace.yaml") || - entries.includes("patches") || - entries.includes(".yarn") - ); -} - -/** - * Render a two-stage Dockerfile for `plan`: install and build in the first - * stage, then copy the app (or just its build output) into the runtime - * stage. `entries` and `packageJson` decide which files the install step - * needs, so dependency layers cache across source changes. - */ -export function renderDockerfile( - plan: AppPlan, - snapshot: Pick, -): string { - const image = - plan.runtime === "bun" ? "oven/bun:1" : `node:${plan.nodeVersion}-slim`; - const { entries } = snapshot; - - const lines = [ - `# Generated by \`bunny apps deploy\` for ${plan.label}. Edit freely: deploys build this file as-is.`, - `FROM ${image} AS build`, - "WORKDIR /app", - ]; - - if (needsFullSourceForInstall(entries, snapshot.packageJson)) { - lines.push( - "COPY . .", - `RUN ${installCommand(plan.packageManager, entries)}`, - ); - } else { - const manifest = [ - "package.json", - ...LOCKFILES[plan.packageManager], - ...INSTALL_CONFIG_FILES, - ].filter((f) => f === "package.json" || entries.includes(f)); - lines.push( - `COPY ${manifest.join(" ")} ./`, - `RUN ${installCommand(plan.packageManager, entries)}`, - "COPY . .", - ); - } - - if (plan.build) lines.push(`RUN ${buildCommand(plan.packageManager)}`); - - lines.push( - "", - `FROM ${image}`, - "WORKDIR /app", - // HOST and HOSTNAME bind the server to every interface: frameworks - // default to localhost, which is unreachable from outside the container. - `ENV NODE_ENV=production PORT=${plan.port} HOST=0.0.0.0 HOSTNAME=0.0.0.0`, - ); - if (plan.copy) { - for (const path of plan.copy) { - lines.push(`COPY --from=build /app/${path} ./${path}`); - } - } else { - lines.push("COPY --from=build /app ./"); - } - lines.push(`EXPOSE ${plan.port}`, `CMD ${JSON.stringify(plan.command)}`, ""); - - return lines.join("\n"); -} - -/** - * Keeps local installs, build output and secrets out of the build context. - * `.env` files must never be baked into an image; `bunny apps env` sets - * them at runtime instead. - */ -export const DOCKERIGNORE = `${[ - "# Generated by `bunny apps deploy`.", - ".git", - "node_modules", - ".next", - ".nuxt", - ".output", - ".svelte-kit", - ".astro", - ".env", - ".env.*", - "!.env.example", - "npm-debug.log*", - "Dockerfile", - ".dockerignore", -].join("\n")}\n`; diff --git a/packages/cli/src/commands/apps/dockerfile/go.ts b/packages/cli/src/commands/apps/dockerfile/go.ts new file mode 100644 index 00000000..e3490401 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/go.ts @@ -0,0 +1,48 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +export function detectGo(project: Project): DetectedApp | undefined { + const gomod = project.read("go.mod"); + if (!gomod) return undefined; + + // Build the root package, or the `cmd/` layout. + const module = gomod + .match(/^module\s+(\S+)/m)?.[1] + ?.split("/") + .pop(); + const pkg = project.entries.some((e) => e.endsWith(".go")) + ? "." + : module && project.read(`cmd/${module}/main.go`) !== null + ? `./cmd/${module}` + : undefined; + if (!pkg) return undefined; + + const version = versionFrom([gomod], /^go\s+(\d+\.\d+)/m, "1"); + const sum = project.entries.includes("go.sum") ? " go.sum" : ""; + + return { + label: "Go", + port: 8080, + name: module, + ignore: [], + dockerfile: (port) => + dockerfile("Go", [ + `FROM golang:${version} AS build`, + "WORKDIR /src", + `COPY go.mod${sum} ./`, + "RUN go mod download", + "COPY . .", + `RUN CGO_ENABLED=0 go build -o /server ${pkg}`, + "", + "FROM gcr.io/distroless/static-debian12", + "COPY --from=build /server /server", + `ENV PORT=${port}`, + `EXPOSE ${port}`, + 'CMD ["/server"]', + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/index.test.ts b/packages/cli/src/commands/apps/dockerfile/index.test.ts new file mode 100644 index 00000000..93c960a7 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test"; +import { type DetectedApp, detectAppFrom, dockerignore } from "./index.ts"; + +/** A project root holding `files`; JSON values are serialised. */ +function detect(files: Record): DetectedApp | undefined { + return detectAppFrom({ + entries: Object.keys(files).map((path) => path.split("/")[0] ?? path), + read: (path) => { + const value = files[path]; + if (value === undefined) return null; + return typeof value === "string" ? value : JSON.stringify(value); + }, + }); +} + +/** Detect and render at the default port, failing when nothing is detected. */ +function render(files: Record): string { + const app = detect(files); + if (!app) throw new Error("expected an app"); + return app.dockerfile(app.port); +} + +describe("node", () => { + test("Next.js caches deps, builds, and runs next start", () => { + const app = detect({ + "package.json": { + name: "@acme/web", + scripts: { build: "next build" }, + dependencies: { next: "15.0.0" }, + }, + "package-lock.json": "", + }); + expect(app).toMatchObject({ label: "Next.js", port: 3000, name: "web" }); + const lines = app?.dockerfile(3000).split("\n") ?? []; + expect(lines.indexOf("RUN npm ci")).toBeGreaterThan( + lines.indexOf("COPY package.json package-lock.json ./"), + ); + expect(lines).toContain("RUN npm run build"); + expect(lines).toContain('CMD ["node_modules/.bin/next","start"]'); + }); + + test("a frontend without a server adapter is a site, not an app", () => { + expect( + detect({ "package.json": { dependencies: { astro: "5" } } }), + ).toBeUndefined(); + expect( + detect({ + "package.json": { + scripts: { start: "react-scripts start" }, + dependencies: { "react-scripts": "5" }, + }, + }), + ).toBeUndefined(); + expect( + detect({ + "package.json": { dependencies: { astro: "5", "@astrojs/node": "9" } }, + }), + ).toMatchObject({ label: "Astro", port: 4321 }); + }); + + test("a frontend with its own server runs the server", () => { + expect( + detect({ + "package.json": { + scripts: { start: "node server.js" }, + dependencies: { express: "5", vite: "6" }, + }, + })?.label, + ).toBe("Node.js"); + }); + + test("bun runs node commands with bun", () => { + const dockerfile = render({ + "package.json": { dependencies: { nuxt: "3" } }, + "bun.lock": "", + }); + expect(dockerfile).toContain("FROM oven/bun:1"); + expect(dockerfile).toContain('CMD ["bun",".output/server/index.mjs"]'); + expect(dockerfile).toContain("COPY --from=build /app/.output ./.output"); + }); + + test.each([ + [{ ".nvmrc": "v20.11.0\n" }, "node:20-slim"], + [{}, "node:24-slim"], + ])("node version from %j", (extra, image) => { + expect( + render({ "package.json": { scripts: { start: "node ." } }, ...extra }), + ).toContain(`FROM ${image}`); + }); + + test("nothing to start is not an app", () => { + expect( + detect({ "package.json": { scripts: { test: "bun test" } } }), + ).toBeUndefined(); + }); +}); + +describe("python", () => { + test("Django runs its WSGI module under gunicorn", () => { + const dockerfile = render({ + "requirements.txt": "Django==5.1\ndjango-environ\n", + "manage.py": + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")', + }); + expect(dockerfile).toContain( + "RUN pip install --no-cache-dir -r requirements.txt gunicorn", + ); + expect(dockerfile).toContain( + 'CMD ["gunicorn","mysite.wsgi","--bind","0.0.0.0:8000"]', + ); + }); + + test("FastAPI with uv runs uvicorn from the venv", () => { + const dockerfile = render({ + "pyproject.toml": 'dependencies = ["fastapi[standard]", "uvicorn"]', + "uv.lock": "", + "app/main.py": "", + }); + expect(dockerfile).toContain( + "RUN uv sync --frozen --no-dev --no-install-project\n", + ); + expect(dockerfile).toContain( + 'CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","8000"]', + ); + }); + + test("a package that merely starts with a framework name doesn't count", () => { + expect( + detect({ "requirements.txt": "flask-cors\n", "app.py": "" }), + ).toBeUndefined(); + }); +}); + +describe("go", () => { + test("builds the cmd/ layout into a distroless image", () => { + const dockerfile = render({ + "go.mod": "module github.com/acme/api\n\ngo 1.23.4\n", + "go.sum": "", + "cmd/api/main.go": "package main", + }); + expect(dockerfile).toContain("FROM golang:1.23 AS build"); + expect(dockerfile).toContain("COPY go.mod go.sum ./"); + expect(dockerfile).toContain( + "RUN CGO_ENABLED=0 go build -o /server ./cmd/api", + ); + }); + + test("no main package is not an app", () => { + expect(detect({ "go.mod": "module example.com/lib\n" })).toBeUndefined(); + }); +}); + +describe("rust", () => { + test("ships the package binary", () => { + const dockerfile = render({ + "Cargo.toml": '[package]\nname = "api"\nversion = "0.1.0"\n', + "Cargo.lock": "", + }); + expect(dockerfile).toContain("RUN cargo build --release --locked"); + expect(dockerfile).toContain( + "/src/target/release/api /usr/local/bin/server", + ); + }); +}); + +describe("rails", () => { + test("wins over the package.json next to it", () => { + const dockerfile = render({ + Gemfile: 'ruby "3.3.0"\ngem "rails", "~> 8.0"\ngem "propshaft"\n', + "Gemfile.lock": "", + "package.json": { scripts: { start: "node ." } }, + }); + expect(dockerfile).toContain("FROM ruby:3.3-slim"); + expect(dockerfile).toContain("bin/rails assets:precompile"); + expect(dockerfile).toContain('"-b", "0.0.0.0", "-p", "3000"'); + }); +}); + +describe("laravel", () => { + test("builds Vite assets and serves with FrankenPHP", () => { + const app = detect({ + "composer.json": { + name: "acme/shop", + require: { php: "^8.3", "laravel/framework": "^12.0" }, + }, + "package.json": { scripts: { build: "vite build" } }, + "package-lock.json": "", + }); + expect(app).toMatchObject({ label: "Laravel", name: "shop" }); + const dockerfile = app?.dockerfile(8080) ?? ""; + expect(dockerfile).toContain("AS assets\n"); + expect(dockerfile).toContain("FROM dunglas/frankenphp:1-php8.3"); + expect(dockerfile).toContain("SERVER_NAME=:8080"); + }); +}); + +test("dockerignore keeps env files out and adds the stack's own lines", () => { + const app = detect({ "Cargo.toml": '[package]\nname = "api"\n' }); + if (!app) throw new Error("expected an app"); + const lines = dockerignore(app).split("\n"); + expect(lines).toContain(".env"); + expect(lines).toContain(".env.*"); + expect(lines).toContain("target"); +}); diff --git a/packages/cli/src/commands/apps/dockerfile/index.ts b/packages/cli/src/commands/apps/dockerfile/index.ts new file mode 100644 index 00000000..e5f5f233 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/index.ts @@ -0,0 +1,73 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { detectGo } from "./go.ts"; +import { detectLaravel } from "./laravel.ts"; +import { detectNode } from "./node.ts"; +import type { DetectedApp, Detector, Project } from "./project.ts"; +import { detectPython } from "./python.ts"; +import { detectRails } from "./rails.ts"; +import { detectRust } from "./rust.ts"; + +/** + * Detect the app at a project root and render a Dockerfile for it, so + * `bunny apps deploy` works in a repo that has none. The generated file is + * the user's from then on; deploys build it like any other Dockerfile. + */ + +export type { DetectedApp, Project } from "./project.ts"; + +// Laravel and Rails come before Node: they often carry a package.json for +// their frontend assets. +const DETECTORS: Detector[] = [ + detectLaravel, + detectRails, + detectPython, + detectGo, + detectRust, + detectNode, +]; + +export function detectAppFrom(project: Project): DetectedApp | undefined { + for (const detect of DETECTORS) { + const app = detect(project); + if (app) return app; + } + return undefined; +} + +export function detectApp(root: string): DetectedApp | undefined { + let entries: string[]; + try { + entries = readdirSync(root); + } catch { + return undefined; + } + return detectAppFrom({ + entries, + read: (path) => { + try { + return readFileSync(join(root, path), "utf-8"); + } catch { + return null; + } + }, + }); +} + +/** + * Keeps VCS data, secrets and the stack's local build output out of the + * build context. `.env` files must never be baked into an image; `bunny + * apps env` sets them at runtime instead. + */ +export function dockerignore(app: DetectedApp): string { + return `${[ + "# Generated by `bunny apps deploy`.", + ".git", + ".env", + ".env.*", + "!.env.example", + "Dockerfile", + ".dockerignore", + ...app.ignore, + ].join("\n")}\n`; +} diff --git a/packages/cli/src/commands/apps/dockerfile/laravel.ts b/packages/cli/src/commands/apps/dockerfile/laravel.ts new file mode 100644 index 00000000..607b93f7 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/laravel.ts @@ -0,0 +1,44 @@ +import { nodeBuildStage } from "./node.ts"; +import { + appName, + type DetectedApp, + dockerfile, + type Project, + readJson, + versionFrom, +} from "./project.ts"; + +export function detectLaravel(project: Project): DetectedApp | undefined { + const composer = readJson(project, "composer.json"); + const require = (composer?.require ?? {}) as Record; + if (!require["laravel/framework"]) return undefined; + + const php = versionFrom([require.php], /(8\.\d+)/, "8.4"); + // Most Laravel apps bundle frontend assets with Vite into public/build. + const pkg = readJson(project, "package.json"); + const build = pkg && (pkg.scripts as Record)?.build; + const assets = build ? [...nodeBuildStage(project, pkg, "assets"), ""] : []; + + return { + label: "Laravel", + port: 8080, + name: appName(composer?.name), + ignore: ["vendor", "node_modules", "public/build", "public/hot"], + dockerfile: (port) => + dockerfile("Laravel", [ + ...assets, + // FrankenPHP serves /app/public; SERVER_NAME=:port serves plain HTTP. + `FROM dunglas/frankenphp:1-php${php}`, + "RUN install-php-extensions bcmath intl opcache pcntl pdo_mysql pdo_pgsql zip", + "COPY --from=composer:2 /usr/bin/composer /usr/bin/composer", + "WORKDIR /app", + "COPY . .", + ...(build + ? ["COPY --from=assets /app/public/build ./public/build"] + : []), + "RUN composer install --no-dev --optimize-autoloader --no-interaction", + `ENV APP_ENV=production SERVER_NAME=:${port}`, + `EXPOSE ${port}`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/node.ts b/packages/cli/src/commands/apps/dockerfile/node.ts new file mode 100644 index 00000000..78896e66 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/node.ts @@ -0,0 +1,261 @@ +import { + detectFrameworkFrom, + detectPackageManagerFrom, + type PackageManager, +} from "@bunny.net/framework-detector"; +import { + appName, + type DetectedApp, + dockerfile, + type Project, + readJson, + versionFrom, +} from "./project.ts"; + +interface ServerFramework { + label: string; + /** Every dependency must be present (the framework plus its server adapter). */ + requires: string[]; + port: number; + command: string[]; + /** Build output the server needs; the runtime stage copies only this. */ + output?: string; +} + +// Ordered most-specific first. Meta-frameworks list their Node adapter +// too: without it the build is static or targets another platform. +const SERVER_FRAMEWORKS: ServerFramework[] = [ + { + label: "Next.js", + requires: ["next"], + port: 3000, + command: ["node_modules/.bin/next", "start"], + }, + { + label: "Nuxt", + requires: ["nuxt"], + port: 3000, + command: ["node", ".output/server/index.mjs"], + output: ".output", + }, + { + label: "SvelteKit", + requires: ["@sveltejs/kit", "@sveltejs/adapter-node"], + port: 3000, + command: ["node", "build/index.js"], + }, + { + label: "Astro", + requires: ["astro", "@astrojs/node"], + port: 4321, + command: ["node", "dist/server/entry.mjs"], + }, + { + label: "React Router", + requires: ["@react-router/serve"], + port: 3000, + command: ["node_modules/.bin/react-router-serve", "build/server/index.js"], + }, + { + label: "Remix", + requires: ["@remix-run/serve"], + port: 3000, + command: ["node_modules/.bin/remix-serve", "build/index.js"], + }, + { + label: "NestJS", + requires: ["@nestjs/core"], + port: 3000, + command: ["node", "dist/main.js"], + }, +]; + +// Libraries that mean the project runs its own HTTP server, even next to a +// frontend framework (an Express app with a Vite frontend, say). +const SERVER_LIBRARIES = [ + "express", + "fastify", + "koa", + "hono", + "elysia", + "h3", + "@hapi/hapi", +]; + +const LOCKFILES: Record = { + bun: ["bun.lock", "bun.lockb"], + pnpm: ["pnpm-lock.yaml"], + yarn: ["yarn.lock"], + npm: ["package-lock.json"], +}; + +// Config the install step reads, copied alongside the manifest. +const INSTALL_CONFIG_FILES = [".npmrc", ".yarnrc.yml", ".yarnrc"]; + +export const DEFAULT_NODE_VERSION = "24"; + +function record(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +function nodeImage(project: Project, pkg: Record): string { + if (detectPackageManagerFrom(project.entries) === "bun") return "oven/bun:1"; + const engines = record(pkg.engines).node; + const major = versionFrom( + [ + project.read(".nvmrc"), + project.read(".node-version"), + typeof engines === "string" ? engines : null, + ], + /(? project.entries.includes(f)); + const corepack = "corepack enable && "; + switch (pm) { + case "bun": + return locked ? "bun install --frozen-lockfile" : "bun install"; + case "pnpm": + return `${corepack}pnpm install${locked ? " --frozen-lockfile" : ""}`; + case "yarn": { + const flag = project.entries.includes(".yarnrc.yml") + ? " --immutable" + : " --frozen-lockfile"; + return `${corepack}yarn install${locked ? flag : ""}`; + } + case "npm": + return locked ? "npm ci" : "npm install"; + } +} + +/** + * Lines for a build stage named `stage` that installs dependencies and runs + * the `build` script. Shared with stacks that bundle frontend assets. + */ +export function nodeBuildStage( + project: Project, + pkg: Record, + stage: string, +): string[] { + const pm = detectPackageManagerFrom(project.entries); + const install = installCommand(project, pm); + const lines = [`FROM ${nodeImage(project, pkg)} AS ${stage}`, "WORKDIR /app"]; + + // Workspaces and patch directories are read during install, so they + // need the whole tree; otherwise copy the manifest first to cache deps. + const fullTree = + Boolean(pkg.workspaces) || + ["pnpm-workspace.yaml", "patches", ".yarn"].some((f) => + project.entries.includes(f), + ); + if (fullTree) { + lines.push("COPY . .", `RUN ${install}`); + } else { + const manifest = [ + "package.json", + ...[...LOCKFILES[pm], ...INSTALL_CONFIG_FILES].filter((f) => + project.entries.includes(f), + ), + ]; + lines.push(`COPY ${manifest.join(" ")} ./`, `RUN ${install}`, "COPY . ."); + } + + if (typeof record(pkg.scripts).build === "string") { + const run = pm === "npm" || pm === "bun" ? pm : `corepack enable && ${pm}`; + lines.push(`RUN ${run} run build`); + } + return lines; +} + +/** + * The bun image has no node binary: `node x` runs the file with bun, and + * package binaries go through `bun run`, which shims `node` for their + * `#!/usr/bin/env node` shebangs. + */ +function onBun(command: string[]): string[] { + const [head, ...rest] = command; + if (head === "node") return ["bun", ...rest]; + if (head?.startsWith("node_modules/.bin/")) { + return ["bun", "run", head.slice("node_modules/.bin/".length), ...rest]; + } + return command; +} + +export function detectNode(project: Project): DetectedApp | undefined { + const pkg = readJson(project, "package.json"); + if (!pkg) return undefined; + + const deps = new Set([ + ...Object.keys(record(pkg.dependencies)), + ...Object.keys(record(pkg.devDependencies)), + ]); + const scripts = record(pkg.scripts); + const bun = detectPackageManagerFrom(project.entries) === "bun"; + + let server: Omit | undefined = + SERVER_FRAMEWORKS.find((f) => f.requires.every((dep) => deps.has(dep))); + + if (!server) { + // A frontend framework without a server adapter builds static files, + // and its `start` script is a dev server: that's a Sites deploy. + const frontend = detectFrameworkFrom({ + entries: project.entries, + packageJson: pkg, + }); + if ( + frontend?.toolchain === "js" && + !SERVER_LIBRARIES.some((dep) => deps.has(dep)) + ) { + return undefined; + } + + const entry = [pkg.module, pkg.main].find((e) => typeof e === "string"); + const command = + typeof scripts.start === "string" + ? [bun ? "bun" : "npm", "run", "start"] + : typeof entry === "string" + ? ["node", entry] + : undefined; + if (!command) return undefined; + server = { label: bun ? "Bun" : "Node.js", port: 3000, command }; + } + + const { label, output } = server; + const command = bun ? onBun(server.command) : server.command; + + return { + label, + port: server.port, + name: appName(pkg.name), + ignore: [ + "node_modules", + ".next", + ".nuxt", + ".output", + ".svelte-kit", + ".astro", + "npm-debug.log*", + ], + dockerfile: (port) => + dockerfile(label, [ + ...nodeBuildStage(project, pkg, "build"), + "", + `FROM ${nodeImage(project, pkg)}`, + "WORKDIR /app", + // HOST and HOSTNAME bind every interface: frameworks default to + // localhost, which is unreachable from outside the container. + `ENV NODE_ENV=production PORT=${port} HOST=0.0.0.0 HOSTNAME=0.0.0.0`, + output + ? `COPY --from=build /app/${output} ./${output}` + : "COPY --from=build /app ./", + `EXPOSE ${port}`, + `CMD ${JSON.stringify(command)}`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/project.ts b/packages/cli/src/commands/apps/dockerfile/project.ts new file mode 100644 index 00000000..78bfd862 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/project.ts @@ -0,0 +1,63 @@ +/** What a stack detector reads from the project root. */ +export interface Project { + /** Names of the files and directories at the project root. */ + entries: readonly string[]; + /** Text of a file relative to the root, or null when it can't be read. */ + read(path: string): string | null; +} + +/** A detected app and the Dockerfile that builds and runs it. */ +export interface DetectedApp { + /** Framework or language, e.g. "Next.js" or "Go". */ + label: string; + /** Port the server listens on by default. */ + port: number; + /** App name suggested by the project manifest. */ + name?: string; + /** `.dockerignore` lines for the stack's dependency and build directories. */ + ignore: string[]; + /** Render the Dockerfile with the server on `port`. */ + dockerfile(port: number): string; +} + +export type Detector = (project: Project) => DetectedApp | undefined; + +export function readJson( + project: Project, + path: string, +): Record | null { + try { + const value = JSON.parse(project.read(path) ?? "null"); + return value && typeof value === "object" ? value : null; + } catch { + return null; + } +} + +/** App name from a package name: `@scope/app` or `vendor/app` → `app`. */ +export function appName(name: unknown): string | undefined { + return typeof name === "string" + ? name.split("/").pop() || undefined + : undefined; +} + +/** First capture of `pattern` across `sources`, or `fallback`. */ +export function versionFrom( + sources: (string | null | undefined)[], + pattern: RegExp, + fallback: string, +): string { + for (const source of sources) { + const match = source?.match(pattern)?.[1]; + if (match) return match; + } + return fallback; +} + +/** Join Dockerfile lines under the generated-file header. */ +export function dockerfile(label: string, lines: string[]): string { + return `${[ + `# Generated by \`bunny apps deploy\` for ${label}. Edit freely: deploys build this file as-is.`, + ...lines, + ].join("\n")}\n`; +} diff --git a/packages/cli/src/commands/apps/dockerfile/python.ts b/packages/cli/src/commands/apps/dockerfile/python.ts new file mode 100644 index 00000000..16b2019a --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/python.ts @@ -0,0 +1,99 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +/** The first of `files` that exists, as a Python module path (`app/main.py` → `app.main`). */ +function moduleOf(project: Project, files: string[]): string | undefined { + const file = files.find((f) => project.read(f) !== null); + return file?.replace(/\.py$/, "").replaceAll("/", "."); +} + +export function detectPython(project: Project): DetectedApp | undefined { + const requirements = project.read("requirements.txt"); + const pyproject = project.read("pyproject.toml"); + if (requirements === null && pyproject === null) return undefined; + + const deps = `${requirements ?? ""}\n${pyproject ?? ""}`.toLowerCase(); + // A whole package name, so `flask` doesn't match `flask-cors`. + const has = (name: string) => + new RegExp(`(?:^|[\\s"',[])${name}(?![\\w-])`, "m").test(deps); + + let label: string; + let server: "gunicorn" | "uvicorn"; + let target: string | undefined; + if (has("django")) { + label = "Django"; + server = "gunicorn"; + // manage.py names the settings module, e.g. `mysite.settings`. + const settings = project + .read("manage.py") + ?.match(/DJANGO_SETTINGS_MODULE["'],\s*["']([\w.]+)\.settings["']/)?.[1]; + target = settings && `${settings}.wsgi`; + } else if (has("fastapi")) { + label = "FastAPI"; + server = "uvicorn"; + const module = moduleOf(project, ["main.py", "app.py", "app/main.py"]); + target = module && `${module}:app`; + } else if (has("flask")) { + label = "Flask"; + server = "gunicorn"; + const module = moduleOf(project, ["app.py", "wsgi.py", "main.py"]); + target = module && `${module}:app`; + } else { + return undefined; + } + if (!target) return undefined; + + const version = versionFrom( + [project.read(".python-version")], + /(3\.\d+)/, + "3.13", + ); + const uv = project.entries.includes("uv.lock"); + // Install dependencies (plus the server, when the project doesn't pin + // it) before copying the source so they cache. A bare pyproject.toml + // installs the project itself, so it needs the source first. + const extra = has(server) ? "" : ` ${server}`; + const install = uv + ? [ + "COPY pyproject.toml uv.lock ./", + `RUN uv sync --frozen --no-dev --no-install-project${extra && ` && uv pip install${extra}`}`, + "COPY . .", + ] + : requirements !== null + ? [ + "COPY requirements.txt ./", + `RUN pip install --no-cache-dir -r requirements.txt${extra}`, + "COPY . .", + ] + : ["COPY . .", `RUN pip install --no-cache-dir .${extra}`]; + const bind = (port: number) => + server === "gunicorn" + ? ["gunicorn", target, "--bind", `0.0.0.0:${port}`] + : ["uvicorn", target, "--host", "0.0.0.0", "--port", String(port)]; + + return { + label, + port: 8000, + ignore: ["__pycache__", "*.pyc", ".venv", "venv"], + dockerfile: (port) => + dockerfile(label, [ + `FROM python:${version}-slim`, + ...(uv + ? [ + "COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv", + "ENV PATH=/app/.venv/bin:$PATH", + ] + : []), + "ENV PYTHONUNBUFFERED=1", + "WORKDIR /app", + ...install, + `ENV PORT=${port}`, + `EXPOSE ${port}`, + `CMD ${JSON.stringify(bind(port))}`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/rails.ts b/packages/cli/src/commands/apps/dockerfile/rails.ts new file mode 100644 index 00000000..c2c83162 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/rails.ts @@ -0,0 +1,42 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +export function detectRails(project: Project): DetectedApp | undefined { + const gemfile = project.read("Gemfile"); + if (!gemfile || !/^\s*gem\s+["']rails["']/m.test(gemfile)) return undefined; + + const version = versionFrom( + [project.read(".ruby-version"), gemfile], + /^(?:ruby\s+["']|ruby-)?(\d+\.\d+)/m, + "3.4", + ); + const lock = project.entries.includes("Gemfile.lock") ? " Gemfile.lock" : ""; + // API-only apps have no asset pipeline to precompile. + const assets = /^\s*gem\s+["'](propshaft|sprockets-rails)["']/m.test(gemfile); + + return { + label: "Rails", + port: 3000, + ignore: ["log", "tmp", "node_modules", "storage", ".bundle"], + dockerfile: (port) => + dockerfile("Rails", [ + `FROM ruby:${version}-slim`, + "RUN apt-get update && apt-get install -y --no-install-recommends build-essential git libpq-dev libyaml-dev pkg-config && rm -rf /var/lib/apt/lists/*", + "WORKDIR /app", + "ENV RAILS_ENV=production BUNDLE_WITHOUT=development:test", + `COPY Gemfile${lock} ./`, + "RUN bundle install", + "COPY . .", + ...(assets + ? ["RUN SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile"] + : []), + `ENV PORT=${port}`, + `EXPOSE ${port}`, + `CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "${port}"]`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/rust.ts b/packages/cli/src/commands/apps/dockerfile/rust.ts new file mode 100644 index 00000000..e3441875 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/rust.ts @@ -0,0 +1,31 @@ +import { type DetectedApp, dockerfile, type Project } from "./project.ts"; + +export function detectRust(project: Project): DetectedApp | undefined { + // The binary is named after `[package]`; workspaces have no single one. + const name = project + .read("Cargo.toml") + ?.match(/^\[package\][^[]*?^name\s*=\s*"([^"]+)"/m)?.[1]; + if (!name) return undefined; + const locked = project.entries.includes("Cargo.lock") ? " --locked" : ""; + + return { + label: "Rust", + port: 8080, + name, + ignore: ["target"], + dockerfile: (port) => + dockerfile("Rust", [ + "FROM rust:1 AS build", + "WORKDIR /src", + "COPY . .", + `RUN cargo build --release${locked}`, + "", + "FROM debian:bookworm-slim", + "RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*", + `COPY --from=build /src/target/release/${name} /usr/local/bin/server`, + `ENV PORT=${port}`, + `EXPOSE ${port}`, + 'CMD ["server"]', + ]), + }; +} diff --git a/packages/cli/src/commands/apps/walkthrough.ts b/packages/cli/src/commands/apps/walkthrough.ts index 92efc7af..0af97816 100644 --- a/packages/cli/src/commands/apps/walkthrough.ts +++ b/packages/cli/src/commands/apps/walkthrough.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; -import { detectFrameworkFrom } from "@bunny.net/framework-detector"; +import { detectFramework } from "@/commands/sites/ci/frameworks.ts"; import { parseDotenv } from "@/core/env.ts"; import { UserError } from "@/core/errors.ts"; import { logger } from "@/core/logger.ts"; @@ -30,13 +30,10 @@ import { resolveRegistryForImage, } from "./docker.ts"; import { - type AppPlan, - type AppSnapshot, - DOCKERIGNORE, - detectAppFrom, - readAppSnapshot, - renderDockerfile, -} from "./dockerfile.ts"; + type DetectedApp, + detectApp, + dockerignore, +} from "./dockerfile/index.ts"; import { confirmEndpointSuggestions, endpointRequestToConfig, @@ -112,26 +109,24 @@ export async function runWalkthrough( let imageRef: string | undefined = input.positionalImage; let dockerfilePath: string | undefined = input.dockerfileFlag; - // Set when we write a Dockerfile for a detected framework; its port - // seeds the endpoint, since in --dry-run the file is never on disk. - let generated: AppPlan | undefined; + // Set when we generate a Dockerfile; its port seeds the endpoint, since + // in --dry-run the file is never on disk. + let generated: { app: DetectedApp; port: number } | undefined; if (!imageRef && !dockerfilePath) { const composeFile = findComposeFile(process.cwd()); const detectedDockerfiles = findDockerfiles(process.cwd()); // Only offer to generate when there's no Dockerfile to build already. - const snapshot = - detectedDockerfiles.length === 0 - ? readAppSnapshot(process.cwd()) - : undefined; - const detectedApp = snapshot ? detectAppFrom(snapshot) : undefined; - // A frontend with no server to run belongs on Sites, not in a container. - const staticFramework = - snapshot && !detectedApp ? detectFrameworkFrom(snapshot) : undefined; - if (staticFramework && staticFramework.id !== "static") { - logger.dim( - `This looks like a static ${staticFramework.label} site. \`bunny sites deploy\` hosts it without a container.`, - ); + const detectedApp = + detectedDockerfiles.length === 0 ? detectApp(process.cwd()) : undefined; + if (detectedDockerfiles.length === 0 && !detectedApp) { + // A site with no server to run belongs on Sites, not in a container. + const site = await detectFramework(process.cwd()); + if (site && site.id !== "static") { + logger.dim( + `This looks like a static ${site.label} site. \`bunny sites deploy\` hosts it without a container.`, + ); + } } const composeServicesCount = composeFile @@ -183,16 +178,12 @@ export async function runWalkthrough( return runComposeImport(client, composeFile, input); } - if (value === "generate" && detectedApp && snapshot) { + if (value === "generate" && detectedApp) { generated = { - ...detectedApp, + app: detectedApp, port: input.portOverride ?? detectedApp.port, }; - dockerfilePath = writeGeneratedDockerfile( - generated, - snapshot, - input.dryRun, - ); + dockerfilePath = writeGeneratedDockerfile(generated, input.dryRun); } else if (value === "dockerfile") { const selectedPaths = await chooseDockerfiles(detectedDockerfiles); if (selectedPaths.length === 0) { @@ -238,7 +229,7 @@ export async function runWalkthrough( } } else if (generated) { suggestions = { - appName: generated.appName, + appName: generated.app.name, endpointSuggestions: [ { displayName: "cdn", @@ -363,38 +354,33 @@ export async function runWalkthrough( } /** - * Write a Dockerfile (and a `.dockerignore`, unless one exists) for a - * detected app at the project root, and return the Dockerfile path. With - * `dryRun`, print the Dockerfile instead of writing anything. + * Write the generated Dockerfile (and a `.dockerignore`, unless one exists) + * to the project root and return its path. With `dryRun`, print it instead. */ function writeGeneratedDockerfile( - plan: AppPlan, - snapshot: AppSnapshot, + { app, port }: { app: DetectedApp; port: number }, dryRun?: boolean, ): string { - const cwd = process.cwd(); - const contents = renderDockerfile(plan, snapshot); + const path = "Dockerfile"; + const contents = app.dockerfile(port); if (dryRun) { logger.dim("--- Dockerfile (preview) ---"); logger.log(contents); logger.dim("--- end preview ---"); - logger.dim("Would write Dockerfile (--dry-run)."); - return GENERATED_DOCKERFILE; + return path; } - writeFileSync(resolve(cwd, GENERATED_DOCKERFILE), contents); - logger.success(`Wrote a Dockerfile for ${plan.label}.`); - const dockerignore = resolve(cwd, ".dockerignore"); - if (!existsSync(dockerignore)) { - writeFileSync(dockerignore, DOCKERIGNORE); + writeFileSync(resolve(process.cwd(), path), contents); + logger.success(`Wrote a Dockerfile for ${app.label}.`); + const ignorePath = resolve(process.cwd(), ".dockerignore"); + if (!existsSync(ignorePath)) { + writeFileSync(ignorePath, dockerignore(app)); logger.success("Wrote .dockerignore."); } - return GENERATED_DOCKERFILE; + return path; } -const GENERATED_DOCKERFILE = "Dockerfile"; - /** * Compose import path: translate `compose.yml` → `bunny.jsonc` and write. * From 33a92faf2723902b7febd7e877d55bb29657d16f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 18:33:20 +0000 Subject: [PATCH 03/12] feat(apps): detect plain PHP, Symfony, .NET, Spring Boot, Rack, Deno and single-file apps --- packages/cli/src/commands/apps/APPS.md | 33 ++--- .../cli/src/commands/apps/dockerfile/deno.ts | 28 ++++ .../src/commands/apps/dockerfile/dotnet.ts | 60 +++++++++ .../cli/src/commands/apps/dockerfile/go.ts | 40 +++--- .../commands/apps/dockerfile/index.test.ts | 125 ++++++++++++++++-- .../cli/src/commands/apps/dockerfile/index.ts | 35 +++-- .../cli/src/commands/apps/dockerfile/java.ts | 54 ++++++++ .../src/commands/apps/dockerfile/laravel.ts | 44 ------ .../cli/src/commands/apps/dockerfile/node.ts | 36 ++++- .../cli/src/commands/apps/dockerfile/php.ts | 63 +++++++++ .../src/commands/apps/dockerfile/project.ts | 2 + .../src/commands/apps/dockerfile/python.ts | 101 +++++++++----- .../cli/src/commands/apps/dockerfile/rails.ts | 42 ------ .../cli/src/commands/apps/dockerfile/ruby.ts | 57 ++++++++ packages/cli/src/commands/apps/walkthrough.ts | 4 +- 15 files changed, 543 insertions(+), 181 deletions(-) create mode 100644 packages/cli/src/commands/apps/dockerfile/deno.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/dotnet.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/java.ts delete mode 100644 packages/cli/src/commands/apps/dockerfile/laravel.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/php.ts delete mode 100644 packages/cli/src/commands/apps/dockerfile/rails.ts create mode 100644 packages/cli/src/commands/apps/dockerfile/ruby.ts diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 58d160f5..ba5cfb8d 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -34,7 +34,7 @@ Deploy an app. Three modes, chosen by what you pass: If no `bunny.jsonc` exists, the first run launches a walkthrough that: 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. With no Dockerfile, a detected app (Node, Bun, Python, Go, Rust, Rails, Laravel) gets a third option: generate one (see below). +2. Otherwise offers to build from a `Dockerfile` (if one exists) or to deploy a pre-built image ref. With no Dockerfile, a detected app gets a third option: generate one (see below). 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. @@ -49,25 +49,26 @@ To rollback: bunny apps deploy ghcr.io/me/api:abc-123 ### Generating a Dockerfile -When the project has no Dockerfile, the walkthrough looks at the project root and offers to write one for you: +When the project has no Dockerfile, the walkthrough looks at the project root and offers to write one. A single file is enough: a lone `index.php`, `main.go`, `main.py` or `index.ts` deploys as-is. -| Detected from | Image | Runs | Port | -| ---------------------------------------------------------------------------------------------------------- | ----------------------------------- | ---------------------------- | ----------------- | -| `composer.json` requiring `laravel/framework` | `dunglas/frankenphp` | FrankenPHP serving `public/` | 8080 | -| `Gemfile` with `rails` | `ruby:-slim` | `bin/rails server` | 3000 | -| `requirements.txt` / `pyproject.toml` with Django | `python:-slim` | `gunicorn .wsgi` | 8000 | -| ... with FastAPI (`main.py`, `app.py` or `app/main.py`) | `python:-slim` | `uvicorn :app` | 8000 | -| ... with Flask (`app.py`, `wsgi.py` or `main.py`) | `python:-slim` | `gunicorn :app` | 8000 | -| `go.mod` with a root package or `cmd//main.go` | `golang`, then distroless | the compiled binary | 8080 | -| `Cargo.toml` with a `[package]` | `rust`, then `debian:bookworm-slim` | the compiled binary | 8080 | -| `package.json` with Next.js, Nuxt, React Router, Remix, NestJS, or SvelteKit/Astro with their Node adapter | `node:-slim` or `oven/bun:1` | the framework's server | 3000 (Astro 4321) | -| `package.json` with a `start` script or a `main`/`module` entry | `node:-slim` or `oven/bun:1` | that script or file | 3000 | +| Stack | Detected from | Runs | Port | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | ----------------- | +| PHP, Laravel, Symfony | `index.php` or `public/index.php` (framework from `composer.json`) | FrankenPHP, serving `public/` or the root | 8080 | +| Rails, Sinatra, Rack | `Gemfile` with `rails`, or a `Gemfile` and `config.ru` | `bin/rails server`, or Puma / `rackup` | 3000 / 9292 | +| Django, FastAPI, Flask, Python | `requirements.txt` / `pyproject.toml`, or a lone `main.py` / `app.py` / `server.py` (from its imports) | gunicorn, uvicorn, or `python ` | 8000 | +| Go | `go.mod`, or `.go` files at the root; the root package or the first `cmd//main.go` | the static binary on distroless | 8080 | +| Rust | `Cargo.toml` with a `[package]` | the release binary | 8080 | +| ASP.NET Core | a root `.csproj` on `Microsoft.NET.Sdk.Web` | `dotnet .dll` on the ASP.NET runtime image | 8080 | +| Spring Boot | `pom.xml` or `build.gradle(.kts)` using Spring Boot (wrapper when present) | `java -jar` on a JRE image | 8080 | +| Deno | `deno.json` with a `main.ts` / `main.js` | `deno run --allow-all` | 8000 | +| Next.js, Nuxt, SvelteKit, Astro, Remix, NestJS, Node | `package.json` (SvelteKit and Astro need their Node adapter) | the framework's server, the `start` script, or the entry | 3000 (Astro 4321) | +| Bun | a lone `index.ts` / `server.ts` / `index.js` with no `package.json` | `bun `; imports install on first start | 3000 | -Versions come from `.nvmrc` / `.node-version` / `engines.node`, `.python-version`, `.ruby-version` / the Gemfile's `ruby` line, `go.mod`, and `composer.json`'s `php` constraint. JS package managers come from the lockfile, Python from `uv.lock` or `requirements.txt`. Dependencies install before the source is copied so they cache between deploys. Every image gets `PORT` set and binds to `0.0.0.0`; `--port` changes the port in the generated file and the endpoint. +Versions come from the project's own files (`.nvmrc`, `.python-version`, `.ruby-version`, `go.mod`, the `.csproj` target framework, `composer.json`'s `php` constraint, the Java toolchain). Dependencies install before the source is copied so they cache between deploys, and frameworks a lone script imports are installed for it. Every image listens on the port shown, on `0.0.0.0`; `--port` changes it in the generated file and the endpoint. Go, Rust and plain scripts get `PORT` set and must read it (or listen on the default). -Laravel and Rails win over a `package.json` next to them; Laravel builds its Vite assets in a separate Node stage when there is a `build` script. Set `APP_KEY` (Laravel) or `SECRET_KEY_BASE` / `RAILS_MASTER_KEY` (Rails) with `bunny apps env` before the first request. Rails 7.1+ apps already ship a Dockerfile, which the walkthrough uses as-is. +Manifest-led stacks win over a `package.json` next to them, since PHP, Ruby and Python apps often carry one for frontend assets; PHP apps with a `build` script get a Node stage that builds into `public/build`. Set `APP_KEY` (Laravel), `APP_SECRET` (Symfony) or `SECRET_KEY_BASE` / `RAILS_MASTER_KEY` (Rails) with `bunny apps env`. Rails 7.1+ apps already ship a Dockerfile, which the walkthrough uses as-is. -A site with no server to run (Vite, Create React App, Astro or SvelteKit without the Node adapter, Hugo, Jekyll, and so on) is not offered a Dockerfile: the walkthrough points you at `bunny sites deploy` instead. +A site with no server to run (a lone `index.html`, Vite, Create React App, Astro or SvelteKit without the Node adapter, Hugo, Jekyll, Blazor WebAssembly) is not offered a Dockerfile: the walkthrough points you at `bunny sites deploy` instead. The Dockerfile and a `.dockerignore` (unless you have one) are written to the project root. They are yours from then on: deploys build the file as-is, and the generator never runs again. `--dry-run` prints the Dockerfile instead of writing it. The `.dockerignore` keeps `.env` files out of the image; set them with `bunny apps env` instead. diff --git a/packages/cli/src/commands/apps/dockerfile/deno.ts b/packages/cli/src/commands/apps/dockerfile/deno.ts new file mode 100644 index 00000000..157f4ece --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/deno.ts @@ -0,0 +1,28 @@ +import { type DetectedApp, dockerfile, type Project } from "./project.ts"; + +const ENTRIES = ["main.ts", "main.js", "main.mjs", "main.mts", "server.ts"]; + +export function detectDeno(project: Project): DetectedApp | undefined { + if (!project.entries.some((e) => e === "deno.json" || e === "deno.jsonc")) { + return undefined; + } + const entry = ENTRIES.find((e) => project.entries.includes(e)); + if (!entry) return undefined; + + return { + label: "Deno", + // Deno.serve listens on 8000 on every interface unless told otherwise. + port: 8000, + ignore: ["node_modules"], + dockerfile: (port) => + dockerfile("Deno", [ + "FROM denoland/deno:2", + "WORKDIR /app", + "COPY . .", + `RUN deno cache ${entry}`, + `ENV PORT=${port}`, + `EXPOSE ${port}`, + `CMD ["deno", "run", "--allow-all", "${entry}"]`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/dotnet.ts b/packages/cli/src/commands/apps/dockerfile/dotnet.ts new file mode 100644 index 00000000..efe29d11 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/dotnet.ts @@ -0,0 +1,60 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +// Read by `dotnet restore`, so copied with the project file to cache it. +const RESTORE_FILES = [ + "global.json", + "nuget.config", + "NuGet.config", + "Directory.Build.props", + "Directory.Packages.props", +]; + +export function detectDotnet(project: Project): DetectedApp | undefined { + // ASP.NET Core apps use the Web SDK; Blazor WebAssembly is a static site. + const csproj = project.entries + .filter((e) => e.endsWith(".csproj")) + .map((file) => ({ file, text: project.read(file) ?? "" })) + .find(({ text }) => text.includes('Sdk="Microsoft.NET.Sdk.Web"')); + if (!csproj) return undefined; + + const assembly = + csproj.text.match(/([^<]+)[^<]*?net|"version":\s*")(\d+\.\d+)/, + "10.0", + ); + const restore = [ + csproj.file, + ...RESTORE_FILES.filter((f) => project.entries.includes(f)), + ]; + + return { + label: "ASP.NET Core", + port: 8080, + name: assembly.toLowerCase(), + ignore: ["bin", "obj"], + dockerfile: (port) => + dockerfile("ASP.NET Core", [ + `FROM mcr.microsoft.com/dotnet/sdk:${version} AS build`, + "WORKDIR /src", + `COPY ${restore.join(" ")} ./`, + `RUN dotnet restore ${csproj.file}`, + "COPY . .", + `RUN dotnet publish ${csproj.file} -c Release -o /out --no-restore`, + "", + `FROM mcr.microsoft.com/dotnet/aspnet:${version}`, + "WORKDIR /app", + "COPY --from=build /out ./", + `ENV ASPNETCORE_URLS=http://+:${port}`, + `EXPOSE ${port}`, + `CMD ${JSON.stringify(["dotnet", `${assembly}.dll`])}`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/go.ts b/packages/cli/src/commands/apps/dockerfile/go.ts index e3490401..51861d4f 100644 --- a/packages/cli/src/commands/apps/dockerfile/go.ts +++ b/packages/cli/src/commands/apps/dockerfile/go.ts @@ -7,36 +7,42 @@ import { export function detectGo(project: Project): DetectedApp | undefined { const gomod = project.read("go.mod"); - if (!gomod) return undefined; + const rootGo = project.entries.some((e) => e.endsWith(".go")); + if (gomod === null && !rootGo) return undefined; - // Build the root package, or the `cmd/` layout. - const module = gomod - .match(/^module\s+(\S+)/m)?.[1] - ?.split("/") - .pop(); - const pkg = project.entries.some((e) => e.endsWith(".go")) - ? "." - : module && project.read(`cmd/${module}/main.go`) !== null - ? `./cmd/${module}` - : undefined; + // Build the root package, or else the first `cmd/` with a main.go. + const cmd = project + .list("cmd") + .find((dir) => project.read(`cmd/${dir}/main.go`) !== null); + const pkg = rootGo ? "." : cmd && `./cmd/${cmd}`; if (!pkg) return undefined; + const name = gomod + ?.match(/^module\s+(\S+)/m)?.[1] + ?.split("/") + .pop(); const version = versionFrom([gomod], /^go\s+(\d+\.\d+)/m, "1"); - const sum = project.entries.includes("go.sum") ? " go.sum" : ""; + // With a go.mod, download modules before copying the source so they + // cache. A lone main.go gets a module created for it. + const modules = gomod + ? [ + `COPY go.mod${project.entries.includes("go.sum") ? " go.sum" : ""} ./`, + "RUN go mod download", + "COPY . .", + ] + : ["COPY . .", "RUN go mod init app && go mod tidy"]; return { label: "Go", port: 8080, - name: module, + name, ignore: [], dockerfile: (port) => dockerfile("Go", [ `FROM golang:${version} AS build`, "WORKDIR /src", - `COPY go.mod${sum} ./`, - "RUN go mod download", - "COPY . .", - `RUN CGO_ENABLED=0 go build -o /server ${pkg}`, + ...modules, + `RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ${pkg}`, "", "FROM gcr.io/distroless/static-debian12", "COPY --from=build /server /server", diff --git a/packages/cli/src/commands/apps/dockerfile/index.test.ts b/packages/cli/src/commands/apps/dockerfile/index.test.ts index 93c960a7..c64228f3 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.test.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -3,8 +3,16 @@ import { type DetectedApp, detectAppFrom, dockerignore } from "./index.ts"; /** A project root holding `files`; JSON values are serialised. */ function detect(files: Record): DetectedApp | undefined { + const list = (dir: string) => { + const prefix = dir ? `${dir}/` : ""; + const names = Object.keys(files) + .filter((path) => path.startsWith(prefix)) + .map((path) => path.slice(prefix.length).split("/")[0] ?? ""); + return [...new Set(names)]; + }; return detectAppFrom({ - entries: Object.keys(files).map((path) => path.split("/")[0] ?? path), + entries: list(""), + list, read: (path) => { const value = files[path]; if (value === undefined) return null; @@ -93,6 +101,12 @@ describe("node", () => { detect({ "package.json": { scripts: { test: "bun test" } } }), ).toBeUndefined(); }); + + test("a lone script runs on bun", () => { + expect(render({ "index.ts": "Bun.serve({})" })).toContain( + 'CMD ["bun", "index.ts"]', + ); + }); }); describe("python", () => { @@ -126,8 +140,24 @@ describe("python", () => { test("a package that merely starts with a framework name doesn't count", () => { expect( - detect({ "requirements.txt": "flask-cors\n", "app.py": "" }), - ).toBeUndefined(); + detect({ "requirements.txt": "flask-cors\n", "app.py": "" })?.label, + ).toBe("Python"); + }); + + test("a lone script installs the framework it imports", () => { + const dockerfile = render({ + "main.py": "from fastapi import FastAPI\napp = FastAPI()\n", + }); + expect(dockerfile).toContain( + "RUN pip install --no-cache-dir fastapi uvicorn", + ); + expect(dockerfile).toContain('"main:app"'); + }); + + test("a lone script with no framework runs with python", () => { + expect(render({ "server.py": "import http.server" })).toContain( + 'CMD ["python","server.py"]', + ); }); }); @@ -140,9 +170,13 @@ describe("go", () => { }); expect(dockerfile).toContain("FROM golang:1.23 AS build"); expect(dockerfile).toContain("COPY go.mod go.sum ./"); - expect(dockerfile).toContain( - "RUN CGO_ENABLED=0 go build -o /server ./cmd/api", - ); + expect(dockerfile).toContain("-o /server ./cmd/api"); + }); + + test("a lone main.go gets a module", () => { + const dockerfile = render({ "main.go": "package main" }); + expect(dockerfile).toContain("RUN go mod init app && go mod tidy"); + expect(dockerfile).toContain("-o /server ."); }); test("no main package is not an app", () => { @@ -163,7 +197,7 @@ describe("rust", () => { }); }); -describe("rails", () => { +describe("ruby", () => { test("wins over the package.json next to it", () => { const dockerfile = render({ Gemfile: 'ruby "3.3.0"\ngem "rails", "~> 8.0"\ngem "propshaft"\n', @@ -172,13 +206,25 @@ describe("rails", () => { }); expect(dockerfile).toContain("FROM ruby:3.3-slim"); expect(dockerfile).toContain("bin/rails assets:precompile"); - expect(dockerfile).toContain('"-b", "0.0.0.0", "-p", "3000"'); + expect(dockerfile).toContain('"-b","0.0.0.0","-p","3000"'); + }); + + test("Sinatra starts config.ru with puma", () => { + const app = detect({ + Gemfile: 'gem "sinatra"\ngem "puma"\n', + "config.ru": "run Sinatra::Application", + }); + expect(app?.label).toBe("Sinatra"); + expect(app?.dockerfile(9292)).toContain( + 'CMD ["bundle","exec","puma","-b","tcp://0.0.0.0:9292"]', + ); }); }); -describe("laravel", () => { - test("builds Vite assets and serves with FrankenPHP", () => { +describe("php", () => { + test("Laravel builds Vite assets and serves public/ with FrankenPHP", () => { const app = detect({ + "public/index.php": "", "composer.json": { name: "acme/shop", require: { php: "^8.3", "laravel/framework": "^12.0" }, @@ -190,7 +236,64 @@ describe("laravel", () => { const dockerfile = app?.dockerfile(8080) ?? ""; expect(dockerfile).toContain("AS assets\n"); expect(dockerfile).toContain("FROM dunglas/frankenphp:1-php8.3"); - expect(dockerfile).toContain("SERVER_NAME=:8080"); + expect(dockerfile).toContain("ENV APP_ENV=production SERVER_NAME=:8080\n"); + }); + + test("a lone index.php serves the project root without composer", () => { + const dockerfile = render({ "index.php": " { + test("publishes a web project and runs its assembly", () => { + const dockerfile = render({ + "Api.csproj": + 'net9.0', + "global.json": "{}", + }); + expect(dockerfile).toContain( + "FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build", + ); + expect(dockerfile).toContain("COPY Api.csproj global.json ./"); + expect(dockerfile).toContain("ENV ASPNETCORE_URLS=http://+:8080"); + expect(dockerfile).toContain('CMD ["dotnet","Api.dll"]'); + }); + + test("Blazor WebAssembly is a static site", () => { + expect( + detect({ + "App.csproj": '', + }), + ).toBeUndefined(); + }); +}); + +describe("java", () => { + test("Spring Boot with the Gradle wrapper skips the plain jar", () => { + const dockerfile = render({ + "build.gradle.kts": + 'plugins { id("org.springframework.boot") }\njava { toolchain { languageVersion = JavaLanguageVersion.of(17) } }', + gradlew: "", + }); + expect(dockerfile).toContain("FROM eclipse-temurin:17-jdk AS build"); + expect(dockerfile).toContain("grep -v -- -plain.jar"); + expect(dockerfile).toContain("FROM eclipse-temurin:17-jre"); + }); + + test("Maven without a wrapper builds on the Maven image", () => { + expect( + render({ "pom.xml": "org.springframework.boot" }), + ).toContain("FROM maven:3-eclipse-temurin-21 AS build"); + }); +}); + +describe("deno", () => { + test("runs the main module", () => { + expect(render({ "deno.json": "{}", "main.ts": "" })).toContain( + 'CMD ["deno", "run", "--allow-all", "main.ts"]', + ); }); }); diff --git a/packages/cli/src/commands/apps/dockerfile/index.ts b/packages/cli/src/commands/apps/dockerfile/index.ts index e5f5f233..89b92864 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.ts @@ -1,11 +1,14 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { detectDeno } from "./deno.ts"; +import { detectDotnet } from "./dotnet.ts"; import { detectGo } from "./go.ts"; -import { detectLaravel } from "./laravel.ts"; +import { detectJava } from "./java.ts"; import { detectNode } from "./node.ts"; +import { detectPhp } from "./php.ts"; import type { DetectedApp, Detector, Project } from "./project.ts"; import { detectPython } from "./python.ts"; -import { detectRails } from "./rails.ts"; +import { detectRuby } from "./ruby.ts"; import { detectRust } from "./rust.ts"; /** @@ -16,14 +19,18 @@ import { detectRust } from "./rust.ts"; export type { DetectedApp, Project } from "./project.ts"; -// Laravel and Rails come before Node: they often carry a package.json for -// their frontend assets. +// Manifest-led stacks first. Node goes last: PHP, Ruby, Python and the +// rest often carry a package.json for frontend assets, and a lone +// index.js is the weakest signal. const DETECTORS: Detector[] = [ - detectLaravel, - detectRails, + detectPhp, + detectRuby, detectPython, detectGo, detectRust, + detectDotnet, + detectJava, + detectDeno, detectNode, ]; @@ -36,14 +43,16 @@ export function detectAppFrom(project: Project): DetectedApp | undefined { } export function detectApp(root: string): DetectedApp | undefined { - let entries: string[]; - try { - entries = readdirSync(root); - } catch { - return undefined; - } + const list = (path: string) => { + try { + return readdirSync(join(root, path)); + } catch { + return []; + } + }; return detectAppFrom({ - entries, + entries: list(""), + list, read: (path) => { try { return readFileSync(join(root, path), "utf-8"); diff --git a/packages/cli/src/commands/apps/dockerfile/java.ts b/packages/cli/src/commands/apps/dockerfile/java.ts new file mode 100644 index 00000000..2dc1892e --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/java.ts @@ -0,0 +1,54 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +export function detectJava(project: Project): DetectedApp | undefined { + const has = (file: string) => project.entries.includes(file); + const pom = project.read("pom.xml"); + const gradle = + project.read("build.gradle.kts") ?? project.read("build.gradle"); + const build = pom ?? gradle; + // Spring Boot packages a self-contained, runnable jar. + if (!build?.includes("org.springframework.boot")) return undefined; + + const jdk = versionFrom( + [build], + /(?:|JavaLanguageVersion\.of\(|sourceCompatibility\s*=\s*(?:JavaVersion\.VERSION_)?['"]?)(\d+)/, + "21", + ); + // Use the project's wrapper when it has one, so the build tool version + // matches; otherwise the build image provides Maven or Gradle. + const [image, command] = pom + ? has("mvnw") + ? [`eclipse-temurin:${jdk}-jdk`, "./mvnw"] + : [`maven:3-eclipse-temurin-${jdk}`, "mvn"] + : has("gradlew") + ? [`eclipse-temurin:${jdk}-jdk`, "./gradlew"] + : [`gradle:jdk${jdk}`, "gradle"]; + // Gradle also writes a `-plain.jar` without dependencies; skip it. + const jar = pom + ? `${command} -B -DskipTests package && cp target/*.jar /app.jar` + : `${command} bootJar -x test --no-daemon && cp $(ls build/libs/*.jar | grep -v -- -plain.jar) /app.jar`; + + return { + label: "Spring Boot", + port: 8080, + ignore: ["target", "build", ".gradle"], + dockerfile: (port) => + dockerfile("Spring Boot", [ + `FROM ${image} AS build`, + "WORKDIR /src", + "COPY . .", + `RUN ${jar}`, + "", + `FROM eclipse-temurin:${jdk}-jre`, + "COPY --from=build /app.jar /app.jar", + `ENV SERVER_PORT=${port}`, + `EXPOSE ${port}`, + 'CMD ["java", "-jar", "/app.jar"]', + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/laravel.ts b/packages/cli/src/commands/apps/dockerfile/laravel.ts deleted file mode 100644 index 607b93f7..00000000 --- a/packages/cli/src/commands/apps/dockerfile/laravel.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { nodeBuildStage } from "./node.ts"; -import { - appName, - type DetectedApp, - dockerfile, - type Project, - readJson, - versionFrom, -} from "./project.ts"; - -export function detectLaravel(project: Project): DetectedApp | undefined { - const composer = readJson(project, "composer.json"); - const require = (composer?.require ?? {}) as Record; - if (!require["laravel/framework"]) return undefined; - - const php = versionFrom([require.php], /(8\.\d+)/, "8.4"); - // Most Laravel apps bundle frontend assets with Vite into public/build. - const pkg = readJson(project, "package.json"); - const build = pkg && (pkg.scripts as Record)?.build; - const assets = build ? [...nodeBuildStage(project, pkg, "assets"), ""] : []; - - return { - label: "Laravel", - port: 8080, - name: appName(composer?.name), - ignore: ["vendor", "node_modules", "public/build", "public/hot"], - dockerfile: (port) => - dockerfile("Laravel", [ - ...assets, - // FrankenPHP serves /app/public; SERVER_NAME=:port serves plain HTTP. - `FROM dunglas/frankenphp:1-php${php}`, - "RUN install-php-extensions bcmath intl opcache pcntl pdo_mysql pdo_pgsql zip", - "COPY --from=composer:2 /usr/bin/composer /usr/bin/composer", - "WORKDIR /app", - "COPY . .", - ...(build - ? ["COPY --from=assets /app/public/build ./public/build"] - : []), - "RUN composer install --no-dev --optimize-autoloader --no-interaction", - `ENV APP_ENV=production SERVER_NAME=:${port}`, - `EXPOSE ${port}`, - ]), - }; -} diff --git a/packages/cli/src/commands/apps/dockerfile/node.ts b/packages/cli/src/commands/apps/dockerfile/node.ts index 78896e66..f2b38e42 100644 --- a/packages/cli/src/commands/apps/dockerfile/node.ts +++ b/packages/cli/src/commands/apps/dockerfile/node.ts @@ -187,9 +187,43 @@ function onBun(command: string[]): string[] { return command; } +// Files a lone JS/TS server starts from, in order. +const SCRIPT_ENTRIES = [ + "server.ts", + "index.ts", + "main.ts", + "server.js", + "index.js", + "main.js", + "app.js", +]; + +/** + * A lone script with no package.json runs on Bun, which runs TypeScript + * as-is and installs the packages it imports when it starts. + */ +function detectScript(project: Project): DetectedApp | undefined { + const entry = SCRIPT_ENTRIES.find((e) => project.entries.includes(e)); + if (!entry) return undefined; + return { + label: "Bun", + port: 3000, + ignore: ["node_modules"], + dockerfile: (port) => + dockerfile("Bun", [ + "FROM oven/bun:1", + "WORKDIR /app", + "COPY . .", + `ENV NODE_ENV=production PORT=${port}`, + `EXPOSE ${port}`, + `CMD ["bun", "${entry}"]`, + ]), + }; +} + export function detectNode(project: Project): DetectedApp | undefined { const pkg = readJson(project, "package.json"); - if (!pkg) return undefined; + if (!pkg) return detectScript(project); const deps = new Set([ ...Object.keys(record(pkg.dependencies)), diff --git a/packages/cli/src/commands/apps/dockerfile/php.ts b/packages/cli/src/commands/apps/dockerfile/php.ts new file mode 100644 index 00000000..dae9db9f --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/php.ts @@ -0,0 +1,63 @@ +import { nodeBuildStage } from "./node.ts"; +import { + appName, + type DetectedApp, + dockerfile, + type Project, + readJson, + versionFrom, +} from "./project.ts"; + +// What most frameworks and ORMs need beyond the extensions FrankenPHP ships. +const EXTENSIONS = "bcmath intl opcache pcntl pdo_mysql pdo_pgsql zip"; + +export function detectPhp(project: Project): DetectedApp | undefined { + // A front controller to serve: public/index.php (Laravel, Symfony, Slim) + // or a bare index.php at the root. + const publicRoot = project.read("public/index.php") !== null; + if (!publicRoot && !project.entries.includes("index.php")) return undefined; + const composer = readJson(project, "composer.json"); + + const require = (composer?.require ?? {}) as Record; + const [label, env] = require["laravel/framework"] + ? ["Laravel", "APP_ENV=production "] + : require["symfony/framework-bundle"] + ? ["Symfony", "APP_ENV=prod "] + : ["PHP", ""]; + const php = versionFrom([require.php], /(8\.\d+)/, "8.4"); + // Frontend assets (Vite, Encore) build in a Node stage into public/build. + const pkg = readJson(project, "package.json"); + const build = pkg && (pkg.scripts as Record)?.build; + + return { + label, + port: 8080, + name: appName(composer?.name), + ignore: ["vendor", "node_modules", "public/build", "public/hot"], + dockerfile: (port) => + dockerfile(label, [ + ...(build ? [...nodeBuildStage(project, pkg, "assets"), ""] : []), + `FROM dunglas/frankenphp:1-php${php}`, + ...(composer + ? [ + `RUN install-php-extensions ${EXTENSIONS}`, + "COPY --from=composer:2 /usr/bin/composer /usr/bin/composer", + ] + : []), + "WORKDIR /app", + "COPY . .", + ...(build + ? ["COPY --from=assets /app/public/build ./public/build"] + : []), + // FrankenPHP serves public/ by default; a bare index.php serves the + // project root. SERVER_NAME=:port serves plain HTTP on that port. + `ENV ${env}SERVER_NAME=:${port}${publicRoot ? "" : " SERVER_ROOT=/app"}`, + ...(composer + ? [ + "RUN composer install --no-dev --optimize-autoloader --no-interaction", + ] + : []), + `EXPOSE ${port}`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/dockerfile/project.ts b/packages/cli/src/commands/apps/dockerfile/project.ts index 78bfd862..e83e00f5 100644 --- a/packages/cli/src/commands/apps/dockerfile/project.ts +++ b/packages/cli/src/commands/apps/dockerfile/project.ts @@ -4,6 +4,8 @@ export interface Project { entries: readonly string[]; /** Text of a file relative to the root, or null when it can't be read. */ read(path: string): string | null; + /** Names in a directory relative to the root, or [] when it can't be read. */ + list(path: string): string[]; } /** A detected app and the Dockerfile that builds and runs it. */ diff --git a/packages/cli/src/commands/apps/dockerfile/python.ts b/packages/cli/src/commands/apps/dockerfile/python.ts index 16b2019a..d84892bd 100644 --- a/packages/cli/src/commands/apps/dockerfile/python.ts +++ b/packages/cli/src/commands/apps/dockerfile/python.ts @@ -5,7 +5,10 @@ import { versionFrom, } from "./project.ts"; -/** The first of `files` that exists, as a Python module path (`app/main.py` → `app.main`). */ +// Scripts a plain Python app starts from, in order. +const ENTRIES = ["main.py", "app.py", "server.py"]; + +/** The first of `files` that exists, as a module path (`app/main.py` → `app.main`). */ function moduleOf(project: Project, files: string[]): string | undefined { const file = files.find((f) => project.read(f) !== null); return file?.replace(/\.py$/, "").replaceAll("/", "."); @@ -14,66 +17,94 @@ function moduleOf(project: Project, files: string[]): string | undefined { export function detectPython(project: Project): DetectedApp | undefined { const requirements = project.read("requirements.txt"); const pyproject = project.read("pyproject.toml"); - if (requirements === null && pyproject === null) return undefined; + const entry = ENTRIES.find((f) => project.entries.includes(f)); + const manifest = requirements !== null || pyproject !== null; + if (!manifest && !entry) return undefined; - const deps = `${requirements ?? ""}\n${pyproject ?? ""}`.toLowerCase(); - // A whole package name, so `flask` doesn't match `flask-cors`. + // Dependencies come from the manifest, or for a lone script from what it + // imports. Whole names only, so `flask` doesn't match `flask-cors`. + const deps = manifest + ? `${requirements ?? ""}\n${pyproject ?? ""}`.toLowerCase() + : [ + ...(project.read(entry ?? "") ?? "").matchAll( + /^(?:from|import)\s+(\w+)/gm, + ), + ] + .map((m) => m[1]) + .join("\n"); const has = (name: string) => new RegExp(`(?:^|[\\s"',[])${name}(?![\\w-])`, "m").test(deps); - let label: string; - let server: "gunicorn" | "uvicorn"; - let target: string | undefined; - if (has("django")) { + let label = "Python"; + let command: (port: number) => string[]; + let packages: string[] = []; + const gunicorn = (target: string) => (port: number) => [ + "gunicorn", + target, + "--bind", + `0.0.0.0:${port}`, + ]; + + const settings = project + .read("manage.py") + ?.match(/DJANGO_SETTINGS_MODULE["'],\s*["']([\w.]+)\.settings["']/)?.[1]; + const fastapi = + has("fastapi") && moduleOf(project, [...ENTRIES, "app/main.py"]); + const flask = + has("flask") && moduleOf(project, ["app.py", "wsgi.py", "main.py"]); + if (has("django") && settings) { label = "Django"; - server = "gunicorn"; - // manage.py names the settings module, e.g. `mysite.settings`. - const settings = project - .read("manage.py") - ?.match(/DJANGO_SETTINGS_MODULE["'],\s*["']([\w.]+)\.settings["']/)?.[1]; - target = settings && `${settings}.wsgi`; - } else if (has("fastapi")) { + command = gunicorn(`${settings}.wsgi`); + packages = ["django", "gunicorn"]; + } else if (fastapi) { label = "FastAPI"; - server = "uvicorn"; - const module = moduleOf(project, ["main.py", "app.py", "app/main.py"]); - target = module && `${module}:app`; - } else if (has("flask")) { + command = (port) => [ + "uvicorn", + `${fastapi}:app`, + "--host", + "0.0.0.0", + "--port", + String(port), + ]; + packages = ["fastapi", "uvicorn"]; + } else if (flask) { label = "Flask"; - server = "gunicorn"; - const module = moduleOf(project, ["app.py", "wsgi.py", "main.py"]); - target = module && `${module}:app`; + command = gunicorn(`${flask}:app`); + packages = ["flask", "gunicorn"]; + } else if (entry) { + command = () => ["python", entry]; } else { return undefined; } - if (!target) return undefined; const version = versionFrom( [project.read(".python-version")], /(3\.\d+)/, "3.13", ); - const uv = project.entries.includes("uv.lock"); - // Install dependencies (plus the server, when the project doesn't pin - // it) before copying the source so they cache. A bare pyproject.toml + // Install dependencies, plus any framework package the project doesn't + // list, before copying the source so they cache. A bare pyproject.toml // installs the project itself, so it needs the source first. - const extra = has(server) ? "" : ` ${server}`; + const missing = packages.filter((p) => !manifest || !has(p)).join(" "); + const uv = project.entries.includes("uv.lock"); const install = uv ? [ "COPY pyproject.toml uv.lock ./", - `RUN uv sync --frozen --no-dev --no-install-project${extra && ` && uv pip install${extra}`}`, + `RUN uv sync --frozen --no-dev --no-install-project${missing && ` && uv pip install ${missing}`}`, "COPY . .", ] : requirements !== null ? [ "COPY requirements.txt ./", - `RUN pip install --no-cache-dir -r requirements.txt${extra}`, + `RUN pip install --no-cache-dir -r requirements.txt ${missing}`.trim(), "COPY . .", ] - : ["COPY . .", `RUN pip install --no-cache-dir .${extra}`]; - const bind = (port: number) => - server === "gunicorn" - ? ["gunicorn", target, "--bind", `0.0.0.0:${port}`] - : ["uvicorn", target, "--host", "0.0.0.0", "--port", String(port)]; + : pyproject !== null + ? ["COPY . .", `RUN pip install --no-cache-dir . ${missing}`.trim()] + : [ + ...(missing ? [`RUN pip install --no-cache-dir ${missing}`] : []), + "COPY . .", + ]; return { label, @@ -93,7 +124,7 @@ export function detectPython(project: Project): DetectedApp | undefined { ...install, `ENV PORT=${port}`, `EXPOSE ${port}`, - `CMD ${JSON.stringify(bind(port))}`, + `CMD ${JSON.stringify(command(port))}`, ]), }; } diff --git a/packages/cli/src/commands/apps/dockerfile/rails.ts b/packages/cli/src/commands/apps/dockerfile/rails.ts deleted file mode 100644 index c2c83162..00000000 --- a/packages/cli/src/commands/apps/dockerfile/rails.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - type DetectedApp, - dockerfile, - type Project, - versionFrom, -} from "./project.ts"; - -export function detectRails(project: Project): DetectedApp | undefined { - const gemfile = project.read("Gemfile"); - if (!gemfile || !/^\s*gem\s+["']rails["']/m.test(gemfile)) return undefined; - - const version = versionFrom( - [project.read(".ruby-version"), gemfile], - /^(?:ruby\s+["']|ruby-)?(\d+\.\d+)/m, - "3.4", - ); - const lock = project.entries.includes("Gemfile.lock") ? " Gemfile.lock" : ""; - // API-only apps have no asset pipeline to precompile. - const assets = /^\s*gem\s+["'](propshaft|sprockets-rails)["']/m.test(gemfile); - - return { - label: "Rails", - port: 3000, - ignore: ["log", "tmp", "node_modules", "storage", ".bundle"], - dockerfile: (port) => - dockerfile("Rails", [ - `FROM ruby:${version}-slim`, - "RUN apt-get update && apt-get install -y --no-install-recommends build-essential git libpq-dev libyaml-dev pkg-config && rm -rf /var/lib/apt/lists/*", - "WORKDIR /app", - "ENV RAILS_ENV=production BUNDLE_WITHOUT=development:test", - `COPY Gemfile${lock} ./`, - "RUN bundle install", - "COPY . .", - ...(assets - ? ["RUN SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile"] - : []), - `ENV PORT=${port}`, - `EXPOSE ${port}`, - `CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "${port}"]`, - ]), - }; -} diff --git a/packages/cli/src/commands/apps/dockerfile/ruby.ts b/packages/cli/src/commands/apps/dockerfile/ruby.ts new file mode 100644 index 00000000..8ff0f718 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/ruby.ts @@ -0,0 +1,57 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +const gem = (gemfile: string, name: string) => + new RegExp(`^\\s*gem\\s+["']${name}["']`, "m").test(gemfile); + +export function detectRuby(project: Project): DetectedApp | undefined { + const gemfile = project.read("Gemfile"); + if (!gemfile) return undefined; + + const rails = gem(gemfile, "rails"); + // Rack apps (Sinatra, Roda, Hanami) start from config.ru. + if (!rails && !project.entries.includes("config.ru")) return undefined; + + const label = rails ? "Rails" : gem(gemfile, "sinatra") ? "Sinatra" : "Rack"; + const version = versionFrom( + [project.read(".ruby-version"), gemfile], + /^(?:ruby\s+["']|ruby-)?(\d+\.\d+)/m, + "3.4", + ); + const lock = project.entries.includes("Gemfile.lock") ? " Gemfile.lock" : ""; + // API-only apps have no asset pipeline to precompile. + const assets = + rails && (gem(gemfile, "propshaft") || gem(gemfile, "sprockets-rails")); + const start = (port: number) => + rails + ? ["bin/rails", "server", "-b", "0.0.0.0", "-p", String(port)] + : gem(gemfile, "puma") + ? ["bundle", "exec", "puma", "-b", `tcp://0.0.0.0:${port}`] + : ["bundle", "exec", "rackup", "-o", "0.0.0.0", "-p", String(port)]; + + return { + label, + port: rails ? 3000 : 9292, + ignore: ["log", "tmp", "node_modules", "storage", ".bundle"], + dockerfile: (port) => + dockerfile(label, [ + `FROM ruby:${version}-slim`, + "RUN apt-get update && apt-get install -y --no-install-recommends build-essential git libpq-dev libyaml-dev pkg-config && rm -rf /var/lib/apt/lists/*", + "WORKDIR /app", + `ENV ${rails ? "RAILS_ENV" : "RACK_ENV"}=production BUNDLE_WITHOUT=development:test`, + `COPY Gemfile${lock} ./`, + "RUN bundle install", + "COPY . .", + ...(assets + ? ["RUN SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile"] + : []), + `ENV PORT=${port}`, + `EXPOSE ${port}`, + `CMD ${JSON.stringify(start(port))}`, + ]), + }; +} diff --git a/packages/cli/src/commands/apps/walkthrough.ts b/packages/cli/src/commands/apps/walkthrough.ts index 0af97816..3f58e28f 100644 --- a/packages/cli/src/commands/apps/walkthrough.ts +++ b/packages/cli/src/commands/apps/walkthrough.ts @@ -122,9 +122,9 @@ export async function runWalkthrough( if (detectedDockerfiles.length === 0 && !detectedApp) { // A site with no server to run belongs on Sites, not in a container. const site = await detectFramework(process.cwd()); - if (site && site.id !== "static") { + if (site || existsSync(resolve(process.cwd(), "index.html"))) { logger.dim( - `This looks like a static ${site.label} site. \`bunny sites deploy\` hosts it without a container.`, + `This looks like a static${site ? ` ${site.label}` : ""} site. \`bunny sites deploy\` hosts it without a container.`, ); } } From 6a9382c248c5c0c42109e557a5e3a42598bbf35b Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 18:36:57 +0000 Subject: [PATCH 04/12] fix(apps): tighten Go, Rust, Java, PHP and lone-script Dockerfile detection --- packages/cli/src/commands/apps/APPS.md | 6 ++--- .../cli/src/commands/apps/dockerfile/go.ts | 5 +++- .../commands/apps/dockerfile/index.test.ts | 26 ++++++++++++++++--- .../cli/src/commands/apps/dockerfile/java.ts | 9 ++++--- .../cli/src/commands/apps/dockerfile/node.ts | 2 ++ .../cli/src/commands/apps/dockerfile/php.ts | 3 ++- .../cli/src/commands/apps/dockerfile/rust.ts | 7 +++-- 7 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index ba5cfb8d..3609da8a 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -56,15 +56,15 @@ When the project has no Dockerfile, the walkthrough looks at the project root an | PHP, Laravel, Symfony | `index.php` or `public/index.php` (framework from `composer.json`) | FrankenPHP, serving `public/` or the root | 8080 | | Rails, Sinatra, Rack | `Gemfile` with `rails`, or a `Gemfile` and `config.ru` | `bin/rails server`, or Puma / `rackup` | 3000 / 9292 | | Django, FastAPI, Flask, Python | `requirements.txt` / `pyproject.toml`, or a lone `main.py` / `app.py` / `server.py` (from its imports) | gunicorn, uvicorn, or `python ` | 8000 | -| Go | `go.mod`, or `.go` files at the root; the root package or the first `cmd//main.go` | the static binary on distroless | 8080 | +| Go | `go.mod` or a root `package main`; builds the root or the first `cmd//main.go` | the static binary on distroless | 8080 | | Rust | `Cargo.toml` with a `[package]` | the release binary | 8080 | | ASP.NET Core | a root `.csproj` on `Microsoft.NET.Sdk.Web` | `dotnet .dll` on the ASP.NET runtime image | 8080 | | Spring Boot | `pom.xml` or `build.gradle(.kts)` using Spring Boot (wrapper when present) | `java -jar` on a JRE image | 8080 | | Deno | `deno.json` with a `main.ts` / `main.js` | `deno run --allow-all` | 8000 | | Next.js, Nuxt, SvelteKit, Astro, Remix, NestJS, Node | `package.json` (SvelteKit and Astro need their Node adapter) | the framework's server, the `start` script, or the entry | 3000 (Astro 4321) | -| Bun | a lone `index.ts` / `server.ts` / `index.js` with no `package.json` | `bun `; imports install on first start | 3000 | +| Bun | a lone `index.ts` / `server.ts` / `index.js` with no `package.json` or `index.html` | `bun `; imports install on first start | 3000 | -Versions come from the project's own files (`.nvmrc`, `.python-version`, `.ruby-version`, `go.mod`, the `.csproj` target framework, `composer.json`'s `php` constraint, the Java toolchain). Dependencies install before the source is copied so they cache between deploys, and frameworks a lone script imports are installed for it. Every image listens on the port shown, on `0.0.0.0`; `--port` changes it in the generated file and the endpoint. Go, Rust and plain scripts get `PORT` set and must read it (or listen on the default). +Versions come from the project's own files (`.nvmrc`, `.python-version`, `.ruby-version`, `go.mod`, the `.csproj` target framework, `composer.json`'s `php` constraint (8.2 or later), the Java toolchain). Dependencies install before the source is copied so they cache between deploys, and frameworks a lone script imports are installed for it. Every image listens on the port shown, on `0.0.0.0`; `--port` changes it in the generated file and the endpoint. Go, Rust and plain scripts get `PORT` set and must read it (or listen on the default). Manifest-led stacks win over a `package.json` next to them, since PHP, Ruby and Python apps often carry one for frontend assets; PHP apps with a `build` script get a Node stage that builds into `public/build`. Set `APP_KEY` (Laravel), `APP_SECRET` (Symfony) or `SECRET_KEY_BASE` / `RAILS_MASTER_KEY` (Rails) with `bunny apps env`. Rails 7.1+ apps already ship a Dockerfile, which the walkthrough uses as-is. diff --git a/packages/cli/src/commands/apps/dockerfile/go.ts b/packages/cli/src/commands/apps/dockerfile/go.ts index 51861d4f..5c5b0f8f 100644 --- a/packages/cli/src/commands/apps/dockerfile/go.ts +++ b/packages/cli/src/commands/apps/dockerfile/go.ts @@ -7,7 +7,10 @@ import { export function detectGo(project: Project): DetectedApp | undefined { const gomod = project.read("go.mod"); - const rootGo = project.entries.some((e) => e.endsWith(".go")); + // A root `package main`; root files of another package are a library. + const rootGo = project.entries.some( + (e) => e.endsWith(".go") && /^package main\b/m.test(project.read(e) ?? ""), + ); if (gomod === null && !rootGo) return undefined; // Build the root package, or else the first `cmd/` with a main.go. diff --git a/packages/cli/src/commands/apps/dockerfile/index.test.ts b/packages/cli/src/commands/apps/dockerfile/index.test.ts index c64228f3..1808ae79 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.test.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -102,6 +102,10 @@ describe("node", () => { ).toBeUndefined(); }); + test("a script next to index.html is a static site's browser code", () => { + expect(detect({ "index.html": "", "main.js": "" })).toBeUndefined(); + }); + test("a lone script runs on bun", () => { expect(render({ "index.ts": "Bun.serve({})" })).toContain( 'CMD ["bun", "index.ts"]', @@ -165,6 +169,7 @@ describe("go", () => { test("builds the cmd/ layout into a distroless image", () => { const dockerfile = render({ "go.mod": "module github.com/acme/api\n\ngo 1.23.4\n", + "doc.go": "package api", "go.sum": "", "cmd/api/main.go": "package main", }); @@ -174,20 +179,23 @@ describe("go", () => { }); test("a lone main.go gets a module", () => { - const dockerfile = render({ "main.go": "package main" }); + const dockerfile = render({ "main.go": "package main\n" }); expect(dockerfile).toContain("RUN go mod init app && go mod tidy"); expect(dockerfile).toContain("-o /server ."); }); - test("no main package is not an app", () => { - expect(detect({ "go.mod": "module example.com/lib\n" })).toBeUndefined(); + test("a library is not an app", () => { + expect( + detect({ "go.mod": "module example.com/lib\n", "lib.go": "package lib" }), + ).toBeUndefined(); }); }); describe("rust", () => { test("ships the package binary", () => { const dockerfile = render({ - "Cargo.toml": '[package]\nname = "api"\nversion = "0.1.0"\n', + "Cargo.toml": + '[package]\nauthors = ["a"]\nname = "api"\n\n[dependencies]\nname = "x"\n', "Cargo.lock": "", }); expect(dockerfile).toContain("RUN cargo build --release --locked"); @@ -239,6 +247,15 @@ describe("php", () => { expect(dockerfile).toContain("ENV APP_ENV=production SERVER_NAME=:8080\n"); }); + test("PHP older than FrankenPHP supports falls back to the default", () => { + expect( + render({ + "index.php": "", + "composer.json": { require: { php: "^8.1" } }, + }), + ).toContain("FROM dunglas/frankenphp:1-php8.4"); + }); + test("a lone index.php serves the project root without composer", () => { const dockerfile = render({ "index.php": " { gradlew: "", }); expect(dockerfile).toContain("FROM eclipse-temurin:17-jdk AS build"); + expect(dockerfile).toContain("RUN sh gradlew bootJar"); expect(dockerfile).toContain("grep -v -- -plain.jar"); expect(dockerfile).toContain("FROM eclipse-temurin:17-jre"); }); diff --git a/packages/cli/src/commands/apps/dockerfile/java.ts b/packages/cli/src/commands/apps/dockerfile/java.ts index 2dc1892e..a89767f3 100644 --- a/packages/cli/src/commands/apps/dockerfile/java.ts +++ b/packages/cli/src/commands/apps/dockerfile/java.ts @@ -16,17 +16,18 @@ export function detectJava(project: Project): DetectedApp | undefined { const jdk = versionFrom( [build], - /(?:|JavaLanguageVersion\.of\(|sourceCompatibility\s*=\s*(?:JavaVersion\.VERSION_)?['"]?)(\d+)/, + /(?:|JavaLanguageVersion\.of\(|sourceCompatibility\s*=\s*(?:JavaVersion\.VERSION_)?['"]?)(\d{2})/, "21", ); // Use the project's wrapper when it has one, so the build tool version - // matches; otherwise the build image provides Maven or Gradle. + // matches; otherwise the build image provides Maven or Gradle. `sh` runs + // the wrapper even when a Windows checkout lost its executable bit. const [image, command] = pom ? has("mvnw") - ? [`eclipse-temurin:${jdk}-jdk`, "./mvnw"] + ? [`eclipse-temurin:${jdk}-jdk`, "sh mvnw"] : [`maven:3-eclipse-temurin-${jdk}`, "mvn"] : has("gradlew") - ? [`eclipse-temurin:${jdk}-jdk`, "./gradlew"] + ? [`eclipse-temurin:${jdk}-jdk`, "sh gradlew"] : [`gradle:jdk${jdk}`, "gradle"]; // Gradle also writes a `-plain.jar` without dependencies; skip it. const jar = pom diff --git a/packages/cli/src/commands/apps/dockerfile/node.ts b/packages/cli/src/commands/apps/dockerfile/node.ts index f2b38e42..dc50c700 100644 --- a/packages/cli/src/commands/apps/dockerfile/node.ts +++ b/packages/cli/src/commands/apps/dockerfile/node.ts @@ -203,6 +203,8 @@ const SCRIPT_ENTRIES = [ * as-is and installs the packages it imports when it starts. */ function detectScript(project: Project): DetectedApp | undefined { + // Next to an index.html, the script is the page's browser code. + if (project.entries.includes("index.html")) return undefined; const entry = SCRIPT_ENTRIES.find((e) => project.entries.includes(e)); if (!entry) return undefined; return { diff --git a/packages/cli/src/commands/apps/dockerfile/php.ts b/packages/cli/src/commands/apps/dockerfile/php.ts index dae9db9f..ebec873a 100644 --- a/packages/cli/src/commands/apps/dockerfile/php.ts +++ b/packages/cli/src/commands/apps/dockerfile/php.ts @@ -24,7 +24,8 @@ export function detectPhp(project: Project): DetectedApp | undefined { : require["symfony/framework-bundle"] ? ["Symfony", "APP_ENV=prod "] : ["PHP", ""]; - const php = versionFrom([require.php], /(8\.\d+)/, "8.4"); + // FrankenPHP publishes images for PHP 8.2 and later. + const php = versionFrom([require.php], /(8\.[2-9])/, "8.4"); // Frontend assets (Vite, Encore) build in a Node stage into public/build. const pkg = readJson(project, "package.json"); const build = pkg && (pkg.scripts as Record)?.build; diff --git a/packages/cli/src/commands/apps/dockerfile/rust.ts b/packages/cli/src/commands/apps/dockerfile/rust.ts index e3441875..8cb03ea7 100644 --- a/packages/cli/src/commands/apps/dockerfile/rust.ts +++ b/packages/cli/src/commands/apps/dockerfile/rust.ts @@ -4,7 +4,9 @@ export function detectRust(project: Project): DetectedApp | undefined { // The binary is named after `[package]`; workspaces have no single one. const name = project .read("Cargo.toml") - ?.match(/^\[package\][^[]*?^name\s*=\s*"([^"]+)"/m)?.[1]; + ?.split(/^\[/m) + .find((section) => section.startsWith("package]")) + ?.match(/^name\s*=\s*"([^"]+)"/m)?.[1]; if (!name) return undefined; const locked = project.entries.includes("Cargo.lock") ? " --locked" : ""; @@ -15,7 +17,8 @@ export function detectRust(project: Project): DetectedApp | undefined { ignore: ["target"], dockerfile: (port) => dockerfile("Rust", [ - "FROM rust:1 AS build", + // Same Debian release in both stages, so the binary's glibc matches. + "FROM rust:1-bookworm AS build", "WORKDIR /src", "COPY . .", `RUN cargo build --release${locked}`, From ca9ea3055cd112b68dbf6faf4ed4d0077fd2538d Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 15 Oct 2026 18:40:54 +0000 Subject: [PATCH 05/12] fix(apps): keep bunny config out of images and skip Dockerfiles that can't start --- packages/cli/src/commands/apps/APPS.md | 10 ++--- .../commands/apps/dockerfile/index.test.ts | 39 ++++++++++++++++++- .../cli/src/commands/apps/dockerfile/index.ts | 9 +++-- .../cli/src/commands/apps/dockerfile/node.ts | 10 +++++ .../cli/src/commands/apps/dockerfile/php.ts | 5 ++- .../src/commands/apps/dockerfile/python.ts | 8 ++-- .../cli/src/commands/apps/dockerfile/ruby.ts | 25 ++++++++++-- 7 files changed, 87 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 3609da8a..765fa950 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -54,7 +54,7 @@ When the project has no Dockerfile, the walkthrough looks at the project root an | Stack | Detected from | Runs | Port | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | ----------------- | | PHP, Laravel, Symfony | `index.php` or `public/index.php` (framework from `composer.json`) | FrankenPHP, serving `public/` or the root | 8080 | -| Rails, Sinatra, Rack | `Gemfile` with `rails`, or a `Gemfile` and `config.ru` | `bin/rails server`, or Puma / `rackup` | 3000 / 9292 | +| Rails, Sinatra, Rack | `Gemfile` with `rails`, or `config.ru` with `puma` or `rackup` in the Gemfile | `bin/rails server`, or Puma / `rackup` | 3000 / 9292 | | Django, FastAPI, Flask, Python | `requirements.txt` / `pyproject.toml`, or a lone `main.py` / `app.py` / `server.py` (from its imports) | gunicorn, uvicorn, or `python ` | 8000 | | Go | `go.mod` or a root `package main`; builds the root or the first `cmd//main.go` | the static binary on distroless | 8080 | | Rust | `Cargo.toml` with a `[package]` | the release binary | 8080 | @@ -64,13 +64,13 @@ When the project has no Dockerfile, the walkthrough looks at the project root an | Next.js, Nuxt, SvelteKit, Astro, Remix, NestJS, Node | `package.json` (SvelteKit and Astro need their Node adapter) | the framework's server, the `start` script, or the entry | 3000 (Astro 4321) | | Bun | a lone `index.ts` / `server.ts` / `index.js` with no `package.json` or `index.html` | `bun `; imports install on first start | 3000 | -Versions come from the project's own files (`.nvmrc`, `.python-version`, `.ruby-version`, `go.mod`, the `.csproj` target framework, `composer.json`'s `php` constraint (8.2 or later), the Java toolchain). Dependencies install before the source is copied so they cache between deploys, and frameworks a lone script imports are installed for it. Every image listens on the port shown, on `0.0.0.0`; `--port` changes it in the generated file and the endpoint. Go, Rust and plain scripts get `PORT` set and must read it (or listen on the default). +Versions come from the project's own files (`.nvmrc`, `.python-version`, `.ruby-version`, `go.mod`, the `.csproj` target framework, `composer.json`'s `php` constraint (8.2 or later), the Java toolchain). For Node, Python, Ruby, Go and .NET, dependencies install before the source is copied so they cache between deploys. A lone Python script gets FastAPI or Flask installed when it imports them; other packages need a `requirements.txt`. Frameworks are started on `0.0.0.0` and the port shown, and `--port` changes it in the generated file and the endpoint. Go, Rust, Deno and plain scripts get `PORT` set and must listen on it. -Manifest-led stacks win over a `package.json` next to them, since PHP, Ruby and Python apps often carry one for frontend assets; PHP apps with a `build` script get a Node stage that builds into `public/build`. Set `APP_KEY` (Laravel), `APP_SECRET` (Symfony) or `SECRET_KEY_BASE` / `RAILS_MASTER_KEY` (Rails) with `bunny apps env`. Rails 7.1+ apps already ship a Dockerfile, which the walkthrough uses as-is. +Manifest-led stacks win over a `package.json` next to them, since PHP, Ruby and Python apps often carry one for frontend assets; Laravel and Symfony apps with a `build` script get a Node stage that builds into `public/build`. Set `APP_KEY` (Laravel), `APP_SECRET` (Symfony) or `SECRET_KEY_BASE` / `RAILS_MASTER_KEY` (Rails) with `bunny apps env`. Rails 7.1+ apps already ship a Dockerfile, which the walkthrough uses as-is. -A site with no server to run (a lone `index.html`, Vite, Create React App, Astro or SvelteKit without the Node adapter, Hugo, Jekyll, Blazor WebAssembly) is not offered a Dockerfile: the walkthrough points you at `bunny sites deploy` instead. +A site with no server to run (a lone `index.html`, Vite, Create React App, a Next.js static export, Astro or SvelteKit without the Node adapter, Hugo, Jekyll, Blazor WebAssembly) is not offered a Dockerfile: the walkthrough points you at `bunny sites deploy` instead. -The Dockerfile and a `.dockerignore` (unless you have one) are written to the project root. They are yours from then on: deploys build the file as-is, and the generator never runs again. `--dry-run` prints the Dockerfile instead of writing it. The `.dockerignore` keeps `.env` files out of the image; set them with `bunny apps env` instead. +The Dockerfile and a `.dockerignore` (unless you have one) are written to the project root. They are yours from then on: deploys build the file as-is, and the generator never runs again. `--dry-run` prints the Dockerfile instead of writing it. The `.dockerignore` keeps `.env` files and `bunny.jsonc` out of the image; set env vars with `bunny apps env` instead. Django static files aren't collected: serve them with WhiteNoise or a CDN. ### Importing from docker-compose diff --git a/packages/cli/src/commands/apps/dockerfile/index.test.ts b/packages/cli/src/commands/apps/dockerfile/index.test.ts index 1808ae79..04bc6f7f 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.test.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -66,6 +66,15 @@ describe("node", () => { ).toMatchObject({ label: "Astro", port: 4321 }); }); + test("a Next.js static export is a site, not an app", () => { + expect( + detect({ + "package.json": { dependencies: { next: "15" } }, + "next.config.mjs": 'export default { output: "export" };', + }), + ).toBeUndefined(); + }); + test("a frontend with its own server runs the server", () => { expect( detect({ @@ -158,6 +167,12 @@ describe("python", () => { expect(dockerfile).toContain('"main:app"'); }); + test("Flask in server.py runs under gunicorn, not its dev server", () => { + expect( + render({ "requirements.txt": "flask\n", "server.py": "" }), + ).toContain('CMD ["gunicorn","server:app","--bind","0.0.0.0:8000"]'); + }); + test("a lone script with no framework runs with python", () => { expect(render({ "server.py": "import http.server" })).toContain( 'CMD ["python","server.py"]', @@ -217,6 +232,18 @@ describe("ruby", () => { expect(dockerfile).toContain('"-b","0.0.0.0","-p","3000"'); }); + test("Rails with mysql2 gets the MySQL client headers", () => { + expect(render({ Gemfile: 'gem "rails"\ngem "mysql2"\n' })).toContain( + "default-libmysqlclient-dev", + ); + }); + + test("a Rack app with no server in the bundle is not an app", () => { + expect( + detect({ Gemfile: 'gem "sinatra"\n', "config.ru": "run App" }), + ).toBeUndefined(); + }); + test("Sinatra starts config.ru with puma", () => { const app = detect({ Gemfile: 'gem "sinatra"\ngem "puma"\n', @@ -256,6 +283,15 @@ describe("php", () => { ).toContain("FROM dunglas/frankenphp:1-php8.4"); }); + test("plain PHP skips the asset stage, which only Laravel and Symfony fit", () => { + expect( + render({ + "index.php": "", + "package.json": { scripts: { build: "tsc" } }, + }), + ).not.toContain("AS assets"); + }); + test("a lone index.php serves the project root without composer", () => { const dockerfile = render({ "index.php": " { }); }); -test("dockerignore keeps env files out and adds the stack's own lines", () => { +test("dockerignore keeps env files and bunny config out, plus the stack's own lines", () => { const app = detect({ "Cargo.toml": '[package]\nname = "api"\n' }); if (!app) throw new Error("expected an app"); const lines = dockerignore(app).split("\n"); expect(lines).toContain(".env"); expect(lines).toContain(".env.*"); + expect(lines).toContain("bunny.jsonc"); expect(lines).toContain("target"); }); diff --git a/packages/cli/src/commands/apps/dockerfile/index.ts b/packages/cli/src/commands/apps/dockerfile/index.ts index 89b92864..97ae09e3 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.ts @@ -64,9 +64,10 @@ export function detectApp(root: string): DetectedApp | undefined { } /** - * Keeps VCS data, secrets and the stack's local build output out of the - * build context. `.env` files must never be baked into an image; `bunny - * apps env` sets them at runtime instead. + * Keeps VCS data, secrets, bunny config and the stack's local build output + * out of the build context. `.env` files must never be baked into an image + * (`bunny apps env` sets them at runtime), and a server that serves the + * project root (plain PHP) would otherwise publish the rest. */ export function dockerignore(app: DetectedApp): string { return `${[ @@ -75,6 +76,8 @@ export function dockerignore(app: DetectedApp): string { ".env", ".env.*", "!.env.example", + "bunny.jsonc", + ".bunny", "Dockerfile", ".dockerignore", ...app.ignore, diff --git a/packages/cli/src/commands/apps/dockerfile/node.ts b/packages/cli/src/commands/apps/dockerfile/node.ts index dc50c700..06aecf6a 100644 --- a/packages/cli/src/commands/apps/dockerfile/node.ts +++ b/packages/cli/src/commands/apps/dockerfile/node.ts @@ -187,6 +187,12 @@ function onBun(command: string[]): string[] { return command; } +function nextStaticExport(project: Project): boolean { + return ["next.config.js", "next.config.mjs", "next.config.ts"].some((f) => + /output:\s*["']export["']/.test(project.read(f) ?? ""), + ); +} + // Files a lone JS/TS server starts from, in order. const SCRIPT_ENTRIES = [ "server.ts", @@ -236,6 +242,10 @@ export function detectNode(project: Project): DetectedApp | undefined { let server: Omit | undefined = SERVER_FRAMEWORKS.find((f) => f.requires.every((dep) => deps.has(dep))); + // A static export has no server to start; it's a Sites deploy. + if (server?.label === "Next.js" && nextStaticExport(project)) { + return undefined; + } if (!server) { // A frontend framework without a server adapter builds static files, diff --git a/packages/cli/src/commands/apps/dockerfile/php.ts b/packages/cli/src/commands/apps/dockerfile/php.ts index ebec873a..be46f9af 100644 --- a/packages/cli/src/commands/apps/dockerfile/php.ts +++ b/packages/cli/src/commands/apps/dockerfile/php.ts @@ -26,8 +26,9 @@ export function detectPhp(project: Project): DetectedApp | undefined { : ["PHP", ""]; // FrankenPHP publishes images for PHP 8.2 and later. const php = versionFrom([require.php], /(8\.[2-9])/, "8.4"); - // Frontend assets (Vite, Encore) build in a Node stage into public/build. - const pkg = readJson(project, "package.json"); + // Laravel (Vite) and Symfony (Encore) build frontend assets into + // public/build, in a Node stage. + const pkg = label === "PHP" ? null : readJson(project, "package.json"); const build = pkg && (pkg.scripts as Record)?.build; return { diff --git a/packages/cli/src/commands/apps/dockerfile/python.ts b/packages/cli/src/commands/apps/dockerfile/python.ts index d84892bd..d81a825c 100644 --- a/packages/cli/src/commands/apps/dockerfile/python.ts +++ b/packages/cli/src/commands/apps/dockerfile/python.ts @@ -7,6 +7,8 @@ import { // Scripts a plain Python app starts from, in order. const ENTRIES = ["main.py", "app.py", "server.py"]; +// Modules that hold a FastAPI or Flask `app`, in order. +const APP_MODULES = [...ENTRIES, "wsgi.py", "app/main.py"]; /** The first of `files` that exists, as a module path (`app/main.py` → `app.main`). */ function moduleOf(project: Project, files: string[]): string | undefined { @@ -48,10 +50,8 @@ export function detectPython(project: Project): DetectedApp | undefined { const settings = project .read("manage.py") ?.match(/DJANGO_SETTINGS_MODULE["'],\s*["']([\w.]+)\.settings["']/)?.[1]; - const fastapi = - has("fastapi") && moduleOf(project, [...ENTRIES, "app/main.py"]); - const flask = - has("flask") && moduleOf(project, ["app.py", "wsgi.py", "main.py"]); + const fastapi = has("fastapi") && moduleOf(project, APP_MODULES); + const flask = has("flask") && moduleOf(project, APP_MODULES); if (has("django") && settings) { label = "Django"; command = gunicorn(`${settings}.wsgi`); diff --git a/packages/cli/src/commands/apps/dockerfile/ruby.ts b/packages/cli/src/commands/apps/dockerfile/ruby.ts index 8ff0f718..c03f4eba 100644 --- a/packages/cli/src/commands/apps/dockerfile/ruby.ts +++ b/packages/cli/src/commands/apps/dockerfile/ruby.ts @@ -13,8 +13,15 @@ export function detectRuby(project: Project): DetectedApp | undefined { if (!gemfile) return undefined; const rails = gem(gemfile, "rails"); - // Rack apps (Sinatra, Roda, Hanami) start from config.ru. - if (!rails && !project.entries.includes("config.ru")) return undefined; + const puma = gem(gemfile, "puma"); + // Rack apps (Sinatra, Roda, Hanami) start from config.ru, with a server + // from the bundle: `bundle exec` can't run one the Gemfile doesn't list. + if ( + !rails && + !(project.entries.includes("config.ru") && (puma || gem(gemfile, "rackup"))) + ) { + return undefined; + } const label = rails ? "Rails" : gem(gemfile, "sinatra") ? "Sinatra" : "Rack"; const version = versionFrom( @@ -29,10 +36,20 @@ export function detectRuby(project: Project): DetectedApp | undefined { const start = (port: number) => rails ? ["bin/rails", "server", "-b", "0.0.0.0", "-p", String(port)] - : gem(gemfile, "puma") + : puma ? ["bundle", "exec", "puma", "-b", `tcp://0.0.0.0:${port}`] : ["bundle", "exec", "rackup", "-o", "0.0.0.0", "-p", String(port)]; + // Native extensions for the common database gems. + const packages = [ + "build-essential", + "git", + "libpq-dev", + "libyaml-dev", + "pkg-config", + ...(gem(gemfile, "mysql2") ? ["default-libmysqlclient-dev"] : []), + ]; + return { label, port: rails ? 3000 : 9292, @@ -40,7 +57,7 @@ export function detectRuby(project: Project): DetectedApp | undefined { dockerfile: (port) => dockerfile(label, [ `FROM ruby:${version}-slim`, - "RUN apt-get update && apt-get install -y --no-install-recommends build-essential git libpq-dev libyaml-dev pkg-config && rm -rf /var/lib/apt/lists/*", + `RUN apt-get update && apt-get install -y --no-install-recommends ${packages.join(" ")} && rm -rf /var/lib/apt/lists/*`, "WORKDIR /app", `ENV ${rails ? "RAILS_ENV" : "RACK_ENV"}=production BUNDLE_WITHOUT=development:test`, `COPY Gemfile${lock} ./`, From 891a6e222f867b45d54f3a1694e1bdc10db4bbb5 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 16 Oct 2026 06:19:14 +0000 Subject: [PATCH 06/12] chore(apps): add changeset for Dockerfile generation --- .changeset/apps-generate-dockerfile.md | 5 +++++ packages/cli/src/commands/apps/dockerfile/index.ts | 2 +- packages/cli/src/commands/apps/dockerfile/project.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/apps-generate-dockerfile.md diff --git a/.changeset/apps-generate-dockerfile.md b/.changeset/apps-generate-dockerfile.md new file mode 100644 index 00000000..17053f7f --- /dev/null +++ b/.changeset/apps-generate-dockerfile.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +`bunny apps deploy` and `bunny apps init` offer to generate a Dockerfile when a project has none, detecting Node, Bun, Deno, Python, Go, Rust, Ruby, PHP, .NET and Spring Boot apps, down to a single `index.php`, `main.go`, `main.py` or `index.ts` diff --git a/packages/cli/src/commands/apps/dockerfile/index.ts b/packages/cli/src/commands/apps/dockerfile/index.ts index 97ae09e3..c1dcfa94 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.ts @@ -71,7 +71,7 @@ export function detectApp(root: string): DetectedApp | undefined { */ export function dockerignore(app: DetectedApp): string { return `${[ - "# Generated by `bunny apps deploy`.", + "# Generated by the bunny CLI.", ".git", ".env", ".env.*", diff --git a/packages/cli/src/commands/apps/dockerfile/project.ts b/packages/cli/src/commands/apps/dockerfile/project.ts index e83e00f5..b3fe8b63 100644 --- a/packages/cli/src/commands/apps/dockerfile/project.ts +++ b/packages/cli/src/commands/apps/dockerfile/project.ts @@ -59,7 +59,7 @@ export function versionFrom( /** Join Dockerfile lines under the generated-file header. */ export function dockerfile(label: string, lines: string[]): string { return `${[ - `# Generated by \`bunny apps deploy\` for ${label}. Edit freely: deploys build this file as-is.`, + `# Generated by the bunny CLI for ${label}. Edit freely: deploys build this file as-is.`, ...lines, ].join("\n")}\n`; } From d068a7d72249bcb173b5329f51220769c354f422 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 16 Oct 2026 06:31:08 +0000 Subject: [PATCH 07/12] feat(apps): let agents deploy without prompts via --dockerfile and --region --- .changeset/apps-generate-dockerfile.md | 2 +- packages/cli/src/commands/apps/APPS.md | 32 ++-- packages/cli/src/commands/apps/deploy.ts | 19 ++- packages/cli/src/commands/apps/init.ts | 10 ++ .../cli/src/commands/apps/walkthrough.test.ts | 63 +++++++ packages/cli/src/commands/apps/walkthrough.ts | 158 +++++++++++++----- 6 files changed, 231 insertions(+), 53 deletions(-) create mode 100644 packages/cli/src/commands/apps/walkthrough.test.ts diff --git a/.changeset/apps-generate-dockerfile.md b/.changeset/apps-generate-dockerfile.md index 17053f7f..7733c9f1 100644 --- a/.changeset/apps-generate-dockerfile.md +++ b/.changeset/apps-generate-dockerfile.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -`bunny apps deploy` and `bunny apps init` offer to generate a Dockerfile when a project has none, detecting Node, Bun, Deno, Python, Go, Rust, Ruby, PHP, .NET and Spring Boot apps, down to a single `index.php`, `main.go`, `main.py` or `index.ts` +`bunny apps deploy` and `bunny apps init` offer to generate a Dockerfile when a project has none, detecting Node, Bun, Deno, Python, Go, Rust, Ruby, PHP, .NET and Spring Boot apps, down to a single `index.php`, `main.go`, `main.py` or `index.ts`. Agents can deploy without prompts: `--dockerfile` generates the file when it's missing, the new `--region` flag picks the region, and every other question takes its default diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 765fa950..6d6211e3 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -114,7 +114,8 @@ For compose files that have just one service, the import overlaps with the Docke | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | 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`. | +| `--region` | Region ID for a new app. Skips the region prompt; required without a terminal. | +| `--dockerfile` | Build from a Dockerfile, then deploy. Pass a path or use the bare flag for `./Dockerfile`, which is generated when there is none (see below). | | `--context` | Docker build context directory. Defaults to the directory of the Dockerfile. | | `--tag` | Override the auto-generated `-` image tag. | | `--registry` | Registry ID to push to, or `bunny` for the bunny.net registry (`registry.bunny.net`, override with `BUNNYNET_REGISTRY_URL`). Overrides `bunny.jsonc`. | @@ -151,6 +152,16 @@ bunny apps deploy --port 8000 --command "uvicorn app:app --host 0.0.0.0 --port 8 bunny apps deploy --config /tmp/agent-task-abc.jsonc --output json ``` +### Deploying from an agent + +Without a terminal (a coding agent, or `--output json`), the walkthrough never prompts. Every question with a default takes it: the bunny.net registry, the suggested app name, the suggested endpoints and the env keys found in `.env` / `.env.example`. The two questions with no default need flags: what to deploy (`--dockerfile` or an image) and `--region`. A missing one fails straight away with a hint naming the flag and, for the region, the IDs to choose from. + +`--dockerfile` with no `./Dockerfile` on disk generates one for the detected app, so a single command takes a project with no Docker setup to a running app: + +```bash +bunny apps deploy --dockerfile --region de --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: @@ -204,15 +215,16 @@ 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). | +| `--region` | Region ID (skips the region prompt; required without a terminal). | +| `--dockerfile` | Build from a Dockerfile. Pass a path or use the bare flag for `./Dockerfile`, generated when there is none. | +| `--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. diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 94475443..4a688a5f 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -15,7 +15,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, @@ -164,6 +164,7 @@ const DEFAULT_DOCKERFILE = "Dockerfile"; interface DeployArgs { image?: string; + region?: string; dockerfile?: string | boolean; context?: string; tag?: string; @@ -182,7 +183,14 @@ export const appsDeployCommand = defineCommand({ 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", + "Build ./Dockerfile and deploy, generating it when there is none", + ], + [ + "$0 apps deploy --dockerfile --region de --output json", + "First deploy without prompts (agents)", + ], [ "$0 apps deploy --dockerfile apps/api/Dockerfile --context apps/api", "Build with explicit context", @@ -227,6 +235,11 @@ export const appsDeployCommand = defineCommand({ describe: "App name (used during first-run walkthrough; skips the interactive prompt)", }) + .option("region", { + type: "string", + describe: + "Region ID for a new app (skips the interactive prompt; required without a terminal)", + }) .option("port", { type: "number", describe: @@ -304,6 +317,8 @@ export const appsDeployCommand = defineCommand({ portOverride: args.port, commandOverride: args.command, nameOverride: args.name, + regionFlag: args.region, + interactive: isInteractive(output), configPath, dryRun, }); diff --git a/packages/cli/src/commands/apps/init.ts b/packages/cli/src/commands/apps/init.ts index 13c52ee3..74b82495 100644 --- a/packages/cli/src/commands/apps/init.ts +++ b/packages/cli/src/commands/apps/init.ts @@ -5,6 +5,7 @@ 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 { isInteractive } from "@/core/ui.ts"; import { configExists } from "./config.ts"; import { normalizeRegistryFlag } from "./docker.ts"; import { runWalkthrough } from "./walkthrough.ts"; @@ -14,6 +15,7 @@ const DESCRIPTION = "Initialize a new app config (no deploy)."; interface InitArgs { name?: string; + region?: string; image?: string; dockerfile?: string | boolean; registry?: string; @@ -59,6 +61,11 @@ export const appsInitCommand = defineCommand({ type: "string", describe: "App name (skips the interactive prompt)", }) + .option("region", { + type: "string", + describe: + "Region ID for a new app (skips the interactive prompt; required without a terminal)", + }) .option("dockerfile", { type: "string", describe: @@ -87,6 +94,7 @@ export const appsInitCommand = defineCommand({ handler: async ({ name, + region, image, dockerfile, registry, @@ -120,6 +128,8 @@ export const appsInitCommand = defineCommand({ portOverride: port, commandOverride: command, nameOverride: name, + regionFlag: region, + interactive: isInteractive(output), configPath, }); diff --git a/packages/cli/src/commands/apps/walkthrough.test.ts b/packages/cli/src/commands/apps/walkthrough.test.ts new file mode 100644 index 00000000..eddbea8f --- /dev/null +++ b/packages/cli/src/commands/apps/walkthrough.test.ts @@ -0,0 +1,63 @@ +import { afterEach, 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 { BUNNY_REGISTRY_ID, type McClient } from "./docker.ts"; +import { runWalkthrough, type WalkthroughInput } from "./walkthrough.ts"; + +// Only the regions lookup reaches the API on the build path. +const client = { + GET: async () => ({ + data: { items: [{ id: "de", name: "Frankfurt", hasCapacity: true }] }, + }), +} as unknown as McClient; + +const originalCwd = process.cwd(); +afterEach(() => process.chdir(originalCwd)); + +describe("runWalkthrough without a terminal (agents)", () => { + const dir = useTempDir(); + + function inProject(files: Record) { + for (const [path, text] of Object.entries(files)) { + writeFileSync(join(dir(), path), text); + } + process.chdir(dir()); + } + + const agent = (input: Partial = {}) => + runWalkthrough(client, { interactive: false, dryRun: true, ...input }); + + test("--dockerfile generates one and takes every default", async () => { + inProject({ "index.php": " { + inProject({ "index.php": "" }); + await expect(agent({ dockerfileFlag: "Dockerfile" })).rejects.toMatchObject( + { + message: "A region is required.", + hint: "Pass --region with one of: de.", + }, + ); + }); + + test("nothing chosen to deploy names the flags instead of prompting", async () => { + inProject({ "index.php": "" }); + await expect(agent({ regionFlag: "de" })).rejects.toThrow( + /Nothing to deploy/, + ); + }); +}); diff --git a/packages/cli/src/commands/apps/walkthrough.ts b/packages/cli/src/commands/apps/walkthrough.ts index 3f58e28f..0fdd5123 100644 --- a/packages/cli/src/commands/apps/walkthrough.ts +++ b/packages/cli/src/commands/apps/walkthrough.ts @@ -19,6 +19,7 @@ import { } from "./config.ts"; import { assignContainerNamesToDockerfiles, + BUNNY_REGISTRY_ID, ensureDockerAvailable, findDockerfiles, getConfigSuggestions, @@ -59,6 +60,14 @@ export interface WalkthroughInput { commandOverride?: string; /** App name (--name). When set, skips the interactive name prompt. */ nameOverride?: string; + /** Region ID (--region). When set, skips the region prompt. */ + regionFlag?: string; + /** + * Whether prompts can be answered. Without a terminal (an agent, or + * `--output json`), each prompt takes its default answer, and the ones + * with no default (the region, what to deploy) need their flag. + */ + interactive: boolean; /** * If true, skip every write side-effect: don't generate files to * disk, don't save bunny.jsonc. The walkthrough still prompts and @@ -113,7 +122,26 @@ export async function runWalkthrough( // in --dry-run the file is never on disk. let generated: { app: DetectedApp; port: number } | undefined; + // `--dockerfile` with no Dockerfile on disk: generate one for the + // detected app, so agents can deploy a project that has none. + if ( + dockerfilePath === GENERATED_DOCKERFILE && + !existsSync(resolve(process.cwd(), dockerfilePath)) + ) { + const app = detectApp(process.cwd()); + if (app) { + generated = { app, port: input.portOverride ?? app.port }; + writeGeneratedDockerfile(generated, input.dryRun); + } + } + if (!imageRef && !dockerfilePath) { + if (!input.interactive) { + throw new UserError( + "Nothing to deploy: no bunny.jsonc, image or --dockerfile.", + "Pass --dockerfile to build (a Dockerfile is generated when there is none), or an image ref to deploy.", + ); + } const composeFile = findComposeFile(process.cwd()); const detectedDockerfiles = findDockerfiles(process.cwd()); // Only offer to generate when there's no Dockerfile to build already. @@ -206,16 +234,20 @@ export async function runWalkthrough( 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); + const registry = + mode === "image" && imageRef && !input.registryFlag + ? await resolveRegistryForImage(client, imageRef) + : await pickRegistry( + client, + input, + "Pick a registry to push your image to.", + ); + if (!registry) { + throw new UserError( + "A registry is required.", + "Pass --registry with an ID from `bunny apps registries`, or `bunny` for the bunny.net registry.", + ); } - if (!registry) throw new UserError("A registry is required."); let suggestions: Awaited> | null = null; @@ -260,11 +292,9 @@ export async function runWalkthrough( } } - const name = - input.nameOverride ?? - (await promptAppName(suggestions?.appName ?? undefined)); + const name = await promptAppName(input, suggestions?.appName ?? undefined); - const regions = await pickRegions(client); + const regions = await pickRegions(client, input); // `registry` is account-scoped - it lives in the manifest, not the // shared config. We hold it in memory long enough for the deploy run @@ -290,9 +320,9 @@ export async function runWalkthrough( } if (suggestions?.endpointSuggestions?.length) { - const accepted = await confirmEndpointSuggestions( - suggestions.endpointSuggestions, - ); + const accepted = input.interactive + ? await confirmEndpointSuggestions(suggestions.endpointSuggestions) + : suggestions.endpointSuggestions; if (accepted.length > 0) { container.endpoints = accepted.map(endpointRequestToConfig); } @@ -311,7 +341,11 @@ export async function runWalkthrough( // pushed. Compensate by scanning the Dockerfile's neighbourhood for a // `.env` / `.env.example` and offering those keys for inclusion. if (mode === "build" && dockerfilePath) { - const picked = await pickEnvKeysFromDockerfile(dockerfilePath, name); + const picked = await pickEnvKeysFromDockerfile( + dockerfilePath, + name, + input.interactive, + ); if (Object.keys(picked).length > 0) { container.env = { ...(container.env ?? {}), ...picked }; } @@ -353,6 +387,9 @@ export async function runWalkthrough( return { config: toml, registries: { [name]: registry.id } }; } +/** Where generated Dockerfiles go; also what a bare `--dockerfile` names. */ +const GENERATED_DOCKERFILE = "Dockerfile"; + /** * Write the generated Dockerfile (and a `.dockerignore`, unless one exists) * to the project root and return its path. With `dryRun`, print it instead. @@ -361,12 +398,13 @@ function writeGeneratedDockerfile( { app, port }: { app: DetectedApp; port: number }, dryRun?: boolean, ): string { - const path = "Dockerfile"; + const path = GENERATED_DOCKERFILE; const contents = app.dockerfile(port); if (dryRun) { + // On stderr, so `--output json` keeps stdout to the one JSON result. logger.dim("--- Dockerfile (preview) ---"); - logger.log(contents); + logger.dim(contents); logger.dim("--- end preview ---"); return path; } @@ -402,17 +440,15 @@ async function runComposeImport( `Importing ${serviceNames.length} service${serviceNames.length === 1 ? "" : "s"} from ${basename(composeFilePath)}: ${serviceNames.join(", ")}.`, ); - const name = input.nameOverride ?? (await promptAppName()); + const name = await promptAppName(input); - const regions = await pickRegions(client); + const regions = await pickRegions(client, input); - 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); - } + const registry = await pickRegistry( + client, + input, + "Pick a registry to use for all containers.", + ); if (!registry) throw new UserError("A registry is required."); const { config, warnings } = composeToConfig(compose, { @@ -457,8 +493,25 @@ async function runComposeImport( return { config, registries }; } -async function promptAppName(suggested?: string): Promise { +/** The registry to push to: --registry, else the bunny.net registry (the prompt's default when nobody can answer it). */ +async function pickRegistry( + client: McClient, + input: WalkthroughInput, + message: string, +): Promise { + if (input.registryFlag) return { id: input.registryFlag }; + if (!input.interactive) return { id: BUNNY_REGISTRY_ID }; + logger.info(message); + return promptRegistry(client); +} + +async function promptAppName( + input: WalkthroughInput, + suggested?: string, +): Promise { + if (input.nameOverride) return input.nameOverride; const initial = suggested?.trim() || basename(resolve(process.cwd())); + if (!input.interactive) return initial; const { value } = await prompts({ type: "text", name: "value", @@ -469,7 +522,10 @@ async function promptAppName(suggested?: string): Promise { return value; } -async function pickRegions(client: McClient): Promise { +async function pickRegions( + client: McClient, + input: WalkthroughInput, +): Promise { const spin = spinner("Fetching regions..."); spin.start(); const { data: regionsResult } = await client.GET("/regions"); @@ -484,6 +540,23 @@ async function pickRegions(client: McClient): Promise { throw new UserError("No regions with capacity are available right now."); } + const ids = regionsWithCapacity.map((r) => r.id); + if (input.regionFlag) { + if (!ids.includes(input.regionFlag)) { + throw new UserError( + `Region "${input.regionFlag}" is unknown or has no capacity.`, + `Pass --region with one of: ${ids.join(", ")}.`, + ); + } + return [input.regionFlag]; + } + if (!input.interactive) { + throw new UserError( + "A region is required.", + `Pass --region with one of: ${ids.join(", ")}.`, + ); + } + // 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({ @@ -587,9 +660,12 @@ function discoverEnvKeysForDockerfile(dockerfilePath: string): string[] { async function pickEnvKeysFromDockerfile( dockerfilePath: string, containerName: string, + interactive: boolean, ): Promise> { const keys = discoverEnvKeysForDockerfile(dockerfilePath); if (keys.length === 0) return {}; + // Every key is pre-selected, so that's the default answer. + if (!interactive) return Object.fromEntries(keys.map((key) => [key, key])); const { picked } = await prompts({ type: "multiselect", @@ -740,17 +816,15 @@ async function runMultiDockerfileImport( ); } - const name = input.nameOverride ?? (await promptAppName()); + const name = await promptAppName(input); - const regions = await pickRegions(client); + const regions = await pickRegions(client, input); - let registry: ResolvedRegistry | null; - if (input.registryFlag) { - registry = { id: input.registryFlag }; - } else { - logger.info("Pick a registry to push all images to."); - registry = await promptRegistry(client); - } + const registry = await pickRegistry( + client, + input, + "Pick a registry to push all images to.", + ); if (!registry) throw new UserError("A registry is required."); const containers: Record = {}; @@ -785,7 +859,11 @@ async function runMultiDockerfileImport( ]; } - const pickedEnv = await pickEnvKeysFromDockerfile(path, containerName); + const pickedEnv = await pickEnvKeysFromDockerfile( + path, + containerName, + input.interactive, + ); if (Object.keys(pickedEnv).length > 0) { container.env = pickedEnv; } From 585763b1f15bf5724e88613679c6d54eaba7b6fb Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 16 Oct 2026 06:37:24 +0000 Subject: [PATCH 08/12] feat(apps): refuse volumes in more than one region unless --allow-regional-volumes --- .changeset/apps-volume-regions.md | 5 ++ packages/cli/src/commands/apps/APPS.md | 38 +++++++++------ packages/cli/src/commands/apps/config.test.ts | 46 ++++++++++++++++++- packages/cli/src/commands/apps/config.ts | 29 +++++++++++- packages/cli/src/commands/apps/deploy.ts | 11 +++++ packages/cli/src/commands/apps/push.ts | 34 +++++++++++--- 6 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 .changeset/apps-volume-regions.md diff --git a/.changeset/apps-volume-regions.md b/.changeset/apps-volume-regions.md new file mode 100644 index 00000000..5ee49af6 --- /dev/null +++ b/.changeset/apps-volume-regions.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny apps deploy` and `bunny apps push` refuse an app that mounts a volume in more than one region, since each region gets its own empty volume; pass `--allow-regional-volumes` to deploy it anyway diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 6d6211e3..bfc6b847 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -110,21 +110,22 @@ 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. | -| `--region` | Region ID for a new app. Skips the region prompt; required without a terminal. | -| `--dockerfile` | Build from a Dockerfile, then deploy. Pass a path or use the bare flag for `./Dockerfile`, which is generated when there is none (see below). | -| `--context` | Docker build context directory. Defaults to the directory of the Dockerfile. | -| `--tag` | Override the auto-generated `-` image tag. | -| `--registry` | Registry ID to push to, or `bunny` for the bunny.net registry (`registry.bunny.net`, override with `BUNNYNET_REGISTRY_URL`). Overrides `bunny.jsonc`. | -| `--container` | Name of the container to update. Required when `bunny.jsonc` has multiple containers and you pass ``/`--dockerfile`. | -| `--port` | Override the container port. Retargets any endpoints written to `bunny.jsonc`. | -| `--command` | Override the container `CMD`. Passed as a single string, split on whitespace. | -| `--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. | +| `--region` | Region ID for a new app. Skips the region prompt; required without a terminal. | +| `--dockerfile` | Build from a Dockerfile, then deploy. Pass a path or use the bare flag for `./Dockerfile`, which is generated when there is none (see below). | +| `--context` | Docker build context directory. Defaults to the directory of the Dockerfile. | +| `--tag` | Override the auto-generated `-` image tag. | +| `--registry` | Registry ID to push to, or `bunny` for the bunny.net registry (`registry.bunny.net`, override with `BUNNYNET_REGISTRY_URL`). Overrides `bunny.jsonc`. | +| `--container` | Name of the container to update. Required when `bunny.jsonc` has multiple containers and you pass ``/`--dockerfile`. | +| `--port` | Override the container port. Retargets any endpoints written to `bunny.jsonc`. | +| `--command` | Override the container `CMD`. Passed as a single string, split on whitespace. | +| `--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. | +| `--allow-regional-volumes` | Deploy an app that mounts a volume in more than one region (see [Volumes and regions](#volumes-and-regions)). | ```bash # Deploy a pre-built image @@ -258,6 +259,9 @@ bunny apps pull --force # Push local bunny.jsonc to remote (config only — does not deploy) bunny apps push bunny apps push --dry-run + +# Apply a volume across regions on purpose (see Volumes and regions) +bunny apps push --allow-regional-volumes ``` ## `bunny apps env` @@ -416,3 +420,7 @@ For the rare case where you need to distinguish "regions bunny.net is allowed to ``` The array form sets both `allowed` and `required` to the same list under the hood. + +### Volumes and regions + +Volumes are regional: every region an app runs in gets its own empty volume, and nothing syncs between them. A database in three regions is three unrelated databases. So `bunny apps deploy` and `bunny apps push` refuse a config where any container mounts a volume and `regions` (or `regions.allowed`) lists more than one region. Keep one region, or pass `--allow-regional-volumes` when separate data per region is what you want (a cache, say). diff --git a/packages/cli/src/commands/apps/config.test.ts b/packages/cli/src/commands/apps/config.test.ts index 06bc68d9..456d6d27 100644 --- a/packages/cli/src/commands/apps/config.test.ts +++ b/packages/cli/src/commands/apps/config.test.ts @@ -1,8 +1,10 @@ -import { expect, test } from "bun:test"; +import { describe, 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 { + assertVolumeRegions, + type BunnyAppConfig, configExists, loadConfig, saveConfig, @@ -172,3 +174,45 @@ test("load → save → reload preserves intent fields", () => { "daemon off;", ]); }); + +describe("assertVolumeRegions", () => { + const app = ( + regions: BunnyAppConfig["app"]["regions"], + volumes = true, + ): BunnyAppConfig => ({ + version: "2026-05-11", + app: { + name: "x", + regions, + containers: { + web: {}, + db: volumes + ? { volumes: [{ name: "data", mount: "/data", size: 1 }] } + : {}, + }, + }, + }); + + test("refuses a volume in more than one region, naming both", () => { + expect(() => assertVolumeRegions(app(["de", "uk"]))).toThrow( + 'Container "db" mounts a volume, but the app runs in 2 regions (de, uk).', + ); + }); + + test("counts the regions an app may run in, not just the required ones", () => { + expect(() => + assertVolumeRegions(app({ allowed: ["de", "uk"], required: ["de"] })), + ).toThrow(); + }); + + test.each([ + ["one region", app(["de"])], + ["no volume", app(["de", "uk"], false)], + ])("allows %s", (_, config) => { + expect(() => assertVolumeRegions(config)).not.toThrow(); + }); + + test("--allow-regional-volumes opts in", () => { + expect(() => assertVolumeRegions(app(["de", "uk"]), true)).not.toThrow(); + }); +}); diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index 899c573a..6fa7be1e 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -1,6 +1,10 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { type BunnyAppConfig, BunnyAppConfigSchema } from "@bunny.net/config"; +import { + type BunnyAppConfig, + BunnyAppConfigSchema, + normalizeRegions, +} from "@bunny.net/config"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; import { CONFIG_FILENAME, @@ -128,6 +132,29 @@ export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void { } } +/** + * Refuse a config that mounts a volume in an app allowed to run in more than + * one region. Volumes are regional: each region gets its own empty volume and + * nothing syncs between them, so the data silently splits. + * `--allow-regional-volumes` opts in for apps that want that (a cache, say). + */ +export function assertVolumeRegions( + config: BunnyAppConfig, + allow?: boolean, +): void { + if (allow) return; + const { allowed } = normalizeRegions(config.app.regions); + if (allowed.length < 2) return; + const withVolume = Object.entries(config.app.containers).find( + ([, container]) => container.volumes?.length, + ); + if (!withVolume) return; + throw new UserError( + `Container "${withVolume[0]}" mounts a volume, but the app runs in ${allowed.length} regions (${allowed.join(", ")}).`, + "Volumes don't sync between regions: each region gets its own empty volume. Keep one region in bunny.jsonc, or pass --allow-regional-volumes if each region should keep separate data.", + ); +} + /** * Resolve the active app ID. * diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 4a688a5f..d2f1ead3 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -17,6 +17,7 @@ import { loadManifest, saveManifest } from "@/core/manifest.ts"; import type { OutputFormat } from "@/core/types.ts"; import { isInteractive, spinner } from "@/core/ui.ts"; import { + assertVolumeRegions, type BunnyAppConfig, type ContainerConfig, configExists, @@ -164,6 +165,7 @@ const DEFAULT_DOCKERFILE = "Dockerfile"; interface DeployArgs { image?: string; + "allow-regional-volumes"?: boolean; region?: string; dockerfile?: string | boolean; context?: string; @@ -260,6 +262,11 @@ export const appsDeployCommand = defineCommand({ describe: "Print the would-be bunny.jsonc and Dockerfile without writing anything or contacting the API to deploy", }) + .option("allow-regional-volumes", { + type: "boolean", + describe: + "Deploy a volume to more than one region, each keeping its own separate data", + }) .option("no-push", { type: "boolean", describe: "Build only — skip push and deploy", @@ -326,6 +333,10 @@ export const appsDeployCommand = defineCommand({ walkthroughRegistries = result.registries; } else { toml = loadConfig(configPath); + // Before the Docker check and any build: an edited bunny.jsonc is + // the only way to reach more than one region (the walkthrough + // picks one). + assertVolumeRegions(toml, args["allow-regional-volumes"]); // Existing config: bail before any registry prompts if a container // declares a `dockerfile` (we'll need Docker to build it). const hasAnyBuild = Object.values(toml.app.containers).some((c) => diff --git a/packages/cli/src/commands/apps/push.ts b/packages/cli/src/commands/apps/push.ts index e5f8caed..935ee10a 100644 --- a/packages/cli/src/commands/apps/push.ts +++ b/packages/cli/src/commands/apps/push.ts @@ -6,7 +6,12 @@ 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 { + assertVolumeRegions, + configToPatchRequest, + loadConfig, + resolveAppId, +} from "./config.ts"; import { APP_MANIFEST, type AppManifest } from "./constants.ts"; const COMMAND = "push"; @@ -14,6 +19,7 @@ const DESCRIPTION = "Apply local bunny.jsonc config to remote app."; interface PushArgs { "dry-run"?: boolean; + "allow-regional-volumes"?: boolean; } export const appsPushCommand = defineCommand({ @@ -21,14 +27,28 @@ export const appsPushCommand = defineCommand({ describe: DESCRIPTION, builder: (yargs) => - yargs.option("dry-run", { - type: "boolean", - describe: "Show what would change without applying", - }), - - handler: async ({ "dry-run": dryRun, profile, output, verbose, apiKey }) => { + yargs + .option("dry-run", { + type: "boolean", + describe: "Show what would change without applying", + }) + .option("allow-regional-volumes", { + type: "boolean", + describe: + "Apply a volume in more than one region, each keeping its own separate data", + }), + + handler: async ({ + "dry-run": dryRun, + "allow-regional-volumes": allowRegionalVolumes, + profile, + output, + verbose, + apiKey, + }) => { const appId = resolveAppId(); const toml = loadConfig(); + assertVolumeRegions(toml, allowRegionalVolumes); const config = resolveConfig(profile, apiKey, verbose); const client = createMcClient(clientOptions(config, verbose)); From 99efae71938478b2408f2fdf05b22d8fc2ab4bf2 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 16 Oct 2026 06:50:20 +0000 Subject: [PATCH 09/12] feat(apps): also refuse volumes when scaling above one instance, as --allow-split-volumes --- .changeset/apps-split-volumes.md | 5 +++ .changeset/apps-volume-regions.md | 5 --- packages/cli/src/commands/apps/APPS.md | 40 +++++++++---------- packages/cli/src/commands/apps/config.test.ts | 37 ++++++++++++----- packages/cli/src/commands/apps/config.ts | 27 ++++++++----- packages/cli/src/commands/apps/deploy.ts | 14 +++---- packages/cli/src/commands/apps/push.ts | 12 +++--- 7 files changed, 82 insertions(+), 58 deletions(-) create mode 100644 .changeset/apps-split-volumes.md delete mode 100644 .changeset/apps-volume-regions.md diff --git a/.changeset/apps-split-volumes.md b/.changeset/apps-split-volumes.md new file mode 100644 index 00000000..505916c6 --- /dev/null +++ b/.changeset/apps-split-volumes.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny apps deploy` and `bunny apps push` refuse an app that mounts a volume in more than one region or scales above one instance, since each copy gets its own empty volume; pass `--allow-split-volumes` to deploy it anyway diff --git a/.changeset/apps-volume-regions.md b/.changeset/apps-volume-regions.md deleted file mode 100644 index 5ee49af6..00000000 --- a/.changeset/apps-volume-regions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bunny.net/cli": patch ---- - -`bunny apps deploy` and `bunny apps push` refuse an app that mounts a volume in more than one region, since each region gets its own empty volume; pass `--allow-regional-volumes` to deploy it anyway diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index bfc6b847..67c5a5ed 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -110,22 +110,22 @@ 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. | -| `--region` | Region ID for a new app. Skips the region prompt; required without a terminal. | -| `--dockerfile` | Build from a Dockerfile, then deploy. Pass a path or use the bare flag for `./Dockerfile`, which is generated when there is none (see below). | -| `--context` | Docker build context directory. Defaults to the directory of the Dockerfile. | -| `--tag` | Override the auto-generated `-` image tag. | -| `--registry` | Registry ID to push to, or `bunny` for the bunny.net registry (`registry.bunny.net`, override with `BUNNYNET_REGISTRY_URL`). Overrides `bunny.jsonc`. | -| `--container` | Name of the container to update. Required when `bunny.jsonc` has multiple containers and you pass ``/`--dockerfile`. | -| `--port` | Override the container port. Retargets any endpoints written to `bunny.jsonc`. | -| `--command` | Override the container `CMD`. Passed as a single string, split on whitespace. | -| `--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. | -| `--allow-regional-volumes` | Deploy an app that mounts a volume in more than one region (see [Volumes and regions](#volumes-and-regions)). | +| 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. | +| `--region` | Region ID for a new app. Skips the region prompt; required without a terminal. | +| `--dockerfile` | Build from a Dockerfile, then deploy. Pass a path or use the bare flag for `./Dockerfile`, which is generated when there is none (see below). | +| `--context` | Docker build context directory. Defaults to the directory of the Dockerfile. | +| `--tag` | Override the auto-generated `-` image tag. | +| `--registry` | Registry ID to push to, or `bunny` for the bunny.net registry (`registry.bunny.net`, override with `BUNNYNET_REGISTRY_URL`). Overrides `bunny.jsonc`. | +| `--container` | Name of the container to update. Required when `bunny.jsonc` has multiple containers and you pass ``/`--dockerfile`. | +| `--port` | Override the container port. Retargets any endpoints written to `bunny.jsonc`. | +| `--command` | Override the container `CMD`. Passed as a single string, split on whitespace. | +| `--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. | +| `--allow-split-volumes` | Deploy an app that mounts a volume in more than one region or instance (see [Volumes](#volumes-regions-and-scaling)). | ```bash # Deploy a pre-built image @@ -260,8 +260,8 @@ bunny apps pull --force bunny apps push bunny apps push --dry-run -# Apply a volume across regions on purpose (see Volumes and regions) -bunny apps push --allow-regional-volumes +# Apply a volume across regions or instances on purpose (see Volumes) +bunny apps push --allow-split-volumes ``` ## `bunny apps env` @@ -421,6 +421,6 @@ For the rare case where you need to distinguish "regions bunny.net is allowed to The array form sets both `allowed` and `required` to the same list under the hood. -### Volumes and regions +### Volumes, regions and scaling -Volumes are regional: every region an app runs in gets its own empty volume, and nothing syncs between them. A database in three regions is three unrelated databases. So `bunny apps deploy` and `bunny apps push` refuse a config where any container mounts a volume and `regions` (or `regions.allowed`) lists more than one region. Keep one region, or pass `--allow-regional-volumes` when separate data per region is what you want (a cache, say). +Volumes are never shared: every region an app runs in, and every instance within a region, gets its own empty volume, and nothing syncs between them. A database in three regions, or scaled to three instances, is three unrelated databases. So `bunny apps deploy` and `bunny apps push` refuse a config where any container mounts a volume and either `regions` (or `regions.allowed`) lists more than one region or `scaling.max` is above 1. Keep one region and `scaling.max: 1`, or pass `--allow-split-volumes` when separate data per copy is what you want (a cache, say). diff --git a/packages/cli/src/commands/apps/config.test.ts b/packages/cli/src/commands/apps/config.test.ts index 456d6d27..efc3c1fd 100644 --- a/packages/cli/src/commands/apps/config.test.ts +++ b/packages/cli/src/commands/apps/config.test.ts @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { useTempDir } from "@/test-utils/temp-dir.ts"; import { - assertVolumeRegions, + assertVolumesNotSplit, type BunnyAppConfig, configExists, loadConfig, @@ -175,15 +175,16 @@ test("load → save → reload preserves intent fields", () => { ]); }); -describe("assertVolumeRegions", () => { +describe("assertVolumesNotSplit", () => { const app = ( regions: BunnyAppConfig["app"]["regions"], - volumes = true, + { volumes = true, max = 1 } = {}, ): BunnyAppConfig => ({ version: "2026-05-11", app: { name: "x", regions, + scaling: { min: 1, max }, containers: { web: {}, db: volumes @@ -193,26 +194,40 @@ describe("assertVolumeRegions", () => { }, }); - test("refuses a volume in more than one region, naming both", () => { - expect(() => assertVolumeRegions(app(["de", "uk"]))).toThrow( + test("refuses a volume in more than one region, naming them", () => { + expect(() => assertVolumesNotSplit(app(["de", "uk"]))).toThrow( 'Container "db" mounts a volume, but the app runs in 2 regions (de, uk).', ); }); + test("refuses a volume on more than one instance", () => { + expect(() => assertVolumesNotSplit(app(["de"], { max: 3 }))).toThrow( + 'Container "db" mounts a volume, but the app scales to 3 instances.', + ); + }); + + test("names both when regions and scaling split it", () => { + expect(() => assertVolumesNotSplit(app(["de", "uk"], { max: 2 }))).toThrow( + "runs in 2 regions (de, uk) and scales to 2 instances.", + ); + }); + test("counts the regions an app may run in, not just the required ones", () => { expect(() => - assertVolumeRegions(app({ allowed: ["de", "uk"], required: ["de"] })), + assertVolumesNotSplit(app({ allowed: ["de", "uk"], required: ["de"] })), ).toThrow(); }); test.each([ - ["one region", app(["de"])], - ["no volume", app(["de", "uk"], false)], + ["one region and one instance", app(["de"])], + ["no volume", app(["de", "uk"], { volumes: false, max: 3 })], ])("allows %s", (_, config) => { - expect(() => assertVolumeRegions(config)).not.toThrow(); + expect(() => assertVolumesNotSplit(config)).not.toThrow(); }); - test("--allow-regional-volumes opts in", () => { - expect(() => assertVolumeRegions(app(["de", "uk"]), true)).not.toThrow(); + test("--allow-split-volumes opts in", () => { + expect(() => + assertVolumesNotSplit(app(["de", "uk"], { max: 3 }), true), + ).not.toThrow(); }); }); diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index 6fa7be1e..53cbc313 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -133,25 +133,34 @@ export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void { } /** - * Refuse a config that mounts a volume in an app allowed to run in more than - * one region. Volumes are regional: each region gets its own empty volume and - * nothing syncs between them, so the data silently splits. - * `--allow-regional-volumes` opts in for apps that want that (a cache, say). + * Refuse a config that mounts a volume in an app that can run more than one + * copy. Volumes are never shared: every region, and every instance within a + * region, gets its own empty volume, so the data silently splits. + * `--allow-split-volumes` opts in for apps that want that (a cache, say). */ -export function assertVolumeRegions( +export function assertVolumesNotSplit( config: BunnyAppConfig, allow?: boolean, ): void { if (allow) return; - const { allowed } = normalizeRegions(config.app.regions); - if (allowed.length < 2) return; const withVolume = Object.entries(config.app.containers).find( ([, container]) => container.volumes?.length, ); if (!withVolume) return; + + const { allowed } = normalizeRegions(config.app.regions); + const max = config.app.scaling?.max ?? 1; + const reasons = [ + ...(allowed.length > 1 + ? [`runs in ${allowed.length} regions (${allowed.join(", ")})`] + : []), + ...(max > 1 ? [`scales to ${max} instances`] : []), + ]; + if (reasons.length === 0) return; + throw new UserError( - `Container "${withVolume[0]}" mounts a volume, but the app runs in ${allowed.length} regions (${allowed.join(", ")}).`, - "Volumes don't sync between regions: each region gets its own empty volume. Keep one region in bunny.jsonc, or pass --allow-regional-volumes if each region should keep separate data.", + `Container "${withVolume[0]}" mounts a volume, but the app ${reasons.join(" and ")}.`, + "Volumes aren't shared: each region and each instance gets its own empty volume. Use one region and `scaling.max: 1` in bunny.jsonc, or pass --allow-split-volumes if each copy should keep separate data.", ); } diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index d2f1ead3..f098a0e8 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -17,7 +17,7 @@ import { loadManifest, saveManifest } from "@/core/manifest.ts"; import type { OutputFormat } from "@/core/types.ts"; import { isInteractive, spinner } from "@/core/ui.ts"; import { - assertVolumeRegions, + assertVolumesNotSplit, type BunnyAppConfig, type ContainerConfig, configExists, @@ -165,7 +165,7 @@ const DEFAULT_DOCKERFILE = "Dockerfile"; interface DeployArgs { image?: string; - "allow-regional-volumes"?: boolean; + "allow-split-volumes"?: boolean; region?: string; dockerfile?: string | boolean; context?: string; @@ -262,10 +262,10 @@ export const appsDeployCommand = defineCommand({ describe: "Print the would-be bunny.jsonc and Dockerfile without writing anything or contacting the API to deploy", }) - .option("allow-regional-volumes", { + .option("allow-split-volumes", { type: "boolean", describe: - "Deploy a volume to more than one region, each keeping its own separate data", + "Deploy a volume to more than one region or instance, each keeping its own separate data", }) .option("no-push", { type: "boolean", @@ -334,9 +334,9 @@ export const appsDeployCommand = defineCommand({ } else { toml = loadConfig(configPath); // Before the Docker check and any build: an edited bunny.jsonc is - // the only way to reach more than one region (the walkthrough - // picks one). - assertVolumeRegions(toml, args["allow-regional-volumes"]); + // the only way to reach more than one region or instance (the + // walkthrough picks one of each). + assertVolumesNotSplit(toml, args["allow-split-volumes"]); // Existing config: bail before any registry prompts if a container // declares a `dockerfile` (we'll need Docker to build it). const hasAnyBuild = Object.values(toml.app.containers).some((c) => diff --git a/packages/cli/src/commands/apps/push.ts b/packages/cli/src/commands/apps/push.ts index 935ee10a..91ff6e3d 100644 --- a/packages/cli/src/commands/apps/push.ts +++ b/packages/cli/src/commands/apps/push.ts @@ -7,7 +7,7 @@ import { logger } from "@/core/logger.ts"; import { loadManifest } from "@/core/manifest.ts"; import { spinner } from "@/core/ui.ts"; import { - assertVolumeRegions, + assertVolumesNotSplit, configToPatchRequest, loadConfig, resolveAppId, @@ -19,7 +19,7 @@ const DESCRIPTION = "Apply local bunny.jsonc config to remote app."; interface PushArgs { "dry-run"?: boolean; - "allow-regional-volumes"?: boolean; + "allow-split-volumes"?: boolean; } export const appsPushCommand = defineCommand({ @@ -32,15 +32,15 @@ export const appsPushCommand = defineCommand({ type: "boolean", describe: "Show what would change without applying", }) - .option("allow-regional-volumes", { + .option("allow-split-volumes", { type: "boolean", describe: - "Apply a volume in more than one region, each keeping its own separate data", + "Apply a volume in more than one region or instance, each keeping its own separate data", }), handler: async ({ "dry-run": dryRun, - "allow-regional-volumes": allowRegionalVolumes, + "allow-split-volumes": allowSplitVolumes, profile, output, verbose, @@ -48,7 +48,7 @@ export const appsPushCommand = defineCommand({ }) => { const appId = resolveAppId(); const toml = loadConfig(); - assertVolumeRegions(toml, allowRegionalVolumes); + assertVolumesNotSplit(toml, allowSplitVolumes); const config = resolveConfig(profile, apiKey, verbose); const client = createMcClient(clientOptions(config, verbose)); From c23c563fd4cc77668f332df06e64ce0fd25106b8 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 16 Oct 2026 06:58:54 +0000 Subject: [PATCH 10/12] fix(apps): address review findings on generated Dockerfiles --- packages/cli/src/commands/apps/deploy.ts | 5 +- .../src/commands/apps/dockerfile/dotnet.ts | 33 ++++--- .../commands/apps/dockerfile/index.test.ts | 93 ++++++++++++++++++- .../cli/src/commands/apps/dockerfile/index.ts | 50 ++++++---- .../cli/src/commands/apps/dockerfile/java.ts | 3 +- .../cli/src/commands/apps/dockerfile/node.ts | 6 ++ .../src/commands/apps/dockerfile/python.ts | 3 + .../cli/src/commands/apps/dockerfile/rust.ts | 16 ++-- packages/cli/src/commands/apps/init.ts | 1 + .../cli/src/commands/apps/walkthrough.test.ts | 7 +- packages/cli/src/commands/apps/walkthrough.ts | 23 ++++- 11 files changed, 194 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index f098a0e8..23ce97ba 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -305,7 +305,7 @@ export const appsDeployCommand = defineCommand({ // walkthrough also checks this on its interactive picks, but for // `--dockerfile` (with no existing config) the choice is implicit so // it'd otherwise skip the menu and only blow up after writing files. - if (dockerfileFlag) { + if (dockerfileFlag && !dryRun) { await ensureDockerAvailable(); } @@ -326,6 +326,7 @@ export const appsDeployCommand = defineCommand({ nameOverride: args.name, regionFlag: args.region, interactive: isInteractive(output), + buildsImage: !dryRun, configPath, dryRun, }); @@ -342,7 +343,7 @@ export const appsDeployCommand = defineCommand({ const hasAnyBuild = Object.values(toml.app.containers).some((c) => Boolean(c.dockerfile), ); - if (hasAnyBuild) { + if (hasAnyBuild && !dryRun) { await ensureDockerAvailable(); } } diff --git a/packages/cli/src/commands/apps/dockerfile/dotnet.ts b/packages/cli/src/commands/apps/dockerfile/dotnet.ts index efe29d11..1a078d60 100644 --- a/packages/cli/src/commands/apps/dockerfile/dotnet.ts +++ b/packages/cli/src/commands/apps/dockerfile/dotnet.ts @@ -25,15 +25,26 @@ export function detectDotnet(project: Project): DetectedApp | undefined { const assembly = csproj.text.match(/([^<]+)[^<]*?net|"version":\s*")(\d+\.\d+)/, + // The runtime matches the target framework; the SDK follows global.json + // when it pins one, since restore refuses an older SDK than it names. + const runtime = versionFrom( + [csproj.text], + /[^<]*?net(\d+\.\d+)/, "10.0", ); - const restore = [ - csproj.file, - ...RESTORE_FILES.filter((f) => project.entries.includes(f)), - ]; + const sdk = versionFrom( + [project.read("global.json")], + /"version":\s*"(\d+\.\d+)/, + runtime, + ); + // Referenced projects live elsewhere in the tree, so restore needs it all. + const restore = csproj.text.includes(" project.entries.includes(f))].join(" ")} ./`, + `RUN dotnet restore ${csproj.file}`, + "COPY . .", + ]; return { label: "ASP.NET Core", @@ -42,14 +53,12 @@ export function detectDotnet(project: Project): DetectedApp | undefined { ignore: ["bin", "obj"], dockerfile: (port) => dockerfile("ASP.NET Core", [ - `FROM mcr.microsoft.com/dotnet/sdk:${version} AS build`, + `FROM mcr.microsoft.com/dotnet/sdk:${sdk} AS build`, "WORKDIR /src", - `COPY ${restore.join(" ")} ./`, - `RUN dotnet restore ${csproj.file}`, - "COPY . .", + ...restore, `RUN dotnet publish ${csproj.file} -c Release -o /out --no-restore`, "", - `FROM mcr.microsoft.com/dotnet/aspnet:${version}`, + `FROM mcr.microsoft.com/dotnet/aspnet:${runtime}`, "WORKDIR /app", "COPY --from=build /out ./", `ENV ASPNETCORE_URLS=http://+:${port}`, diff --git a/packages/cli/src/commands/apps/dockerfile/index.test.ts b/packages/cli/src/commands/apps/dockerfile/index.test.ts index 04bc6f7f..a2194d3a 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.test.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -115,6 +115,22 @@ describe("node", () => { expect(detect({ "index.html": "", "main.js": "" })).toBeUndefined(); }); + test("install config, which can hold registry tokens, never reaches the runtime", () => { + const lines = render({ + "package.json": { + scripts: { build: "next build" }, + dependencies: { next: "15" }, + }, + ".npmrc": "//npm.pkg.github.com/:_authToken=secret", + }).split("\n"); + expect(lines.indexOf("RUN rm -f .npmrc")).toBeGreaterThan( + lines.indexOf("RUN npm run build"), + ); + expect(lines.indexOf("RUN rm -f .npmrc")).toBeLessThan( + lines.indexOf("COPY --from=build /app ./"), + ); + }); + test("a lone script runs on bun", () => { expect(render({ "index.ts": "Bun.serve({})" })).toContain( 'CMD ["bun", "index.ts"]', @@ -144,7 +160,7 @@ describe("python", () => { "app/main.py": "", }); expect(dockerfile).toContain( - "RUN uv sync --frozen --no-dev --no-install-project\n", + "RUN uv sync --frozen --no-dev --no-install-project\nCOPY . .\nRUN uv sync --frozen --no-dev --inexact\n", ); expect(dockerfile).toContain( 'CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","8000"]', @@ -212,12 +228,31 @@ describe("rust", () => { "Cargo.toml": '[package]\nauthors = ["a"]\nname = "api"\n\n[dependencies]\nname = "x"\n', "Cargo.lock": "", + "src/main.rs": "fn main() {}", }); expect(dockerfile).toContain("RUN cargo build --release --locked"); expect(dockerfile).toContain( "/src/target/release/api /usr/local/bin/server", ); }); + + test("ships a named [[bin]] target", () => { + expect( + render({ + "Cargo.toml": + '[package]\nname = "api"\n\n[[bin]]\nname = "server-bin"\n', + }), + ).toContain("/src/target/release/server-bin "); + }); + + test("a library crate is not an app", () => { + expect( + detect({ + "Cargo.toml": '[package]\nname = "lib"\n', + "src/lib.rs": "", + }), + ).toBeUndefined(); + }); }); describe("ruby", () => { @@ -314,6 +349,29 @@ describe("dotnet", () => { expect(dockerfile).toContain('CMD ["dotnet","Api.dll"]'); }); + test("the SDK follows global.json while the runtime follows the target", () => { + const dockerfile = render({ + "Api.csproj": + 'net8.0', + "global.json": '{ "sdk": { "version": "9.0.100" } }', + }); + expect(dockerfile).toContain( + "FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build", + ); + expect(dockerfile).toContain("FROM mcr.microsoft.com/dotnet/aspnet:8.0"); + }); + + test("project references restore from the whole tree", () => { + expect( + render({ + "Api.csproj": + '', + }), + ).toContain( + "WORKDIR /src\nCOPY . .\nRUN dotnet restore Api.csproj\nRUN dotnet publish", + ); + }); + test("Blazor WebAssembly is a static site", () => { expect( detect({ @@ -336,6 +394,15 @@ describe("java", () => { expect(dockerfile).toContain("FROM eclipse-temurin:17-jre"); }); + test.each([ + ["8"], + ["1.8"], + ])("Java 8 from %s", (version) => { + expect( + render({ "pom.xml": `org.springframework.boot ${version}` }), + ).toContain("FROM eclipse-temurin:8-jre"); + }); + test("Maven without a wrapper builds on the Maven image", () => { expect( render({ "pom.xml": "org.springframework.boot" }), @@ -352,11 +419,31 @@ describe("deno", () => { }); test("dockerignore keeps env files and bunny config out, plus the stack's own lines", () => { - const app = detect({ "Cargo.toml": '[package]\nname = "api"\n' }); + const app = detect({ + "Cargo.toml": '[package]\nname = "api"\n', + "src/main.rs": "", + }); if (!app) throw new Error("expected an app"); - const lines = dockerignore(app).split("\n"); + const lines = (dockerignore(app) ?? "").split("\n"); expect(lines).toContain(".env"); expect(lines).toContain(".env.*"); expect(lines).toContain("bunny.jsonc"); expect(lines).toContain("target"); }); + +describe("an existing dockerignore", () => { + const app = detect({ "index.php": "" }); + if (!app) throw new Error("expected an app"); + + test("keeps the user's entries and adds the missing private ones", () => { + expect(dockerignore(app, "node_modules\n.env\n")).toBe( + "node_modules\n.env\n\n# Added by the bunny CLI: keep secrets and bunny config out of the image.\n.env.*\nbunny.jsonc\n.bunny\n!.env.example\n", + ); + }); + + test("is left alone when it already covers them", () => { + expect( + dockerignore(app, ".env\n.env.*\nbunny.jsonc\n.bunny\n"), + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/apps/dockerfile/index.ts b/packages/cli/src/commands/apps/dockerfile/index.ts index c1dcfa94..9a3b443c 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.ts @@ -63,23 +63,39 @@ export function detectApp(root: string): DetectedApp | undefined { }); } +// Never in an image: `.env` files (`bunny apps env` sets them at runtime), +// and bunny config, which a server of the project root (plain PHP) would +// otherwise publish. +const PRIVATE = [".env", ".env.*", "bunny.jsonc", ".bunny"]; + /** - * Keeps VCS data, secrets, bunny config and the stack's local build output - * out of the build context. `.env` files must never be baked into an image - * (`bunny apps env` sets them at runtime), and a server that serves the - * project root (plain PHP) would otherwise publish the rest. + * The `.dockerignore` to write: a new one for the stack, or the user's + * `existing` file with any missing private entries appended. Undefined + * when the existing file already covers them. */ -export function dockerignore(app: DetectedApp): string { - return `${[ - "# Generated by the bunny CLI.", - ".git", - ".env", - ".env.*", - "!.env.example", - "bunny.jsonc", - ".bunny", - "Dockerfile", - ".dockerignore", - ...app.ignore, - ].join("\n")}\n`; +export function dockerignore( + app: DetectedApp, + existing?: string, +): string | undefined { + if (existing === undefined) { + return `${[ + "# Generated by the bunny CLI.", + ".git", + ".env", + ".env.*", + "!.env.example", + "bunny.jsonc", + ".bunny", + "Dockerfile", + ".dockerignore", + ...app.ignore, + ].join("\n")}\n`; + } + + const have = new Set(existing.split("\n").map((line) => line.trim())); + const missing = PRIVATE.filter((line) => !have.has(line)); + if (missing.length === 0) return undefined; + // `.env.*` would also hide the example file, which is safe to ship. + if (missing.includes(".env.*")) missing.push("!.env.example"); + return `${existing.trimEnd()}\n\n# Added by the bunny CLI: keep secrets and bunny config out of the image.\n${missing.join("\n")}\n`; } diff --git a/packages/cli/src/commands/apps/dockerfile/java.ts b/packages/cli/src/commands/apps/dockerfile/java.ts index a89767f3..3a206f01 100644 --- a/packages/cli/src/commands/apps/dockerfile/java.ts +++ b/packages/cli/src/commands/apps/dockerfile/java.ts @@ -14,9 +14,10 @@ export function detectJava(project: Project): DetectedApp | undefined { // Spring Boot packages a self-contained, runnable jar. if (!build?.includes("org.springframework.boot")) return undefined; + // `8`, `17`, or the legacy `1.8` / `VERSION_1_8` spelling of Java 8. const jdk = versionFrom( [build], - /(?:|JavaLanguageVersion\.of\(|sourceCompatibility\s*=\s*(?:JavaVersion\.VERSION_)?['"]?)(\d{2})/, + /(?:|JavaLanguageVersion\.of\(|sourceCompatibility\s*=\s*(?:JavaVersion\.VERSION_)?['"]?)(?:1[._])?(\d{1,2})/, "21", ); // Use the project's wrapper when it has one, so the build tool version diff --git a/packages/cli/src/commands/apps/dockerfile/node.ts b/packages/cli/src/commands/apps/dockerfile/node.ts index 06aecf6a..97d153a6 100644 --- a/packages/cli/src/commands/apps/dockerfile/node.ts +++ b/packages/cli/src/commands/apps/dockerfile/node.ts @@ -170,6 +170,12 @@ export function nodeBuildStage( const run = pm === "npm" || pm === "bun" ? pm : `corepack enable && ${pm}`; lines.push(`RUN ${run} run build`); } + // Install config can hold private registry tokens. Delete it here, so a + // later stage copying this one's files never ships it. + const config = INSTALL_CONFIG_FILES.filter((f) => + project.entries.includes(f), + ); + if (config.length > 0) lines.push(`RUN rm -f ${config.join(" ")}`); return lines; } diff --git a/packages/cli/src/commands/apps/dockerfile/python.ts b/packages/cli/src/commands/apps/dockerfile/python.ts index d81a825c..4ed97e64 100644 --- a/packages/cli/src/commands/apps/dockerfile/python.ts +++ b/packages/cli/src/commands/apps/dockerfile/python.ts @@ -92,6 +92,9 @@ export function detectPython(project: Project): DetectedApp | undefined { "COPY pyproject.toml uv.lock ./", `RUN uv sync --frozen --no-dev --no-install-project${missing && ` && uv pip install ${missing}`}`, "COPY . .", + // Install the project itself now its source is here (a `src/` + // layout imports it). --inexact keeps the server added above. + "RUN uv sync --frozen --no-dev --inexact", ] : requirements !== null ? [ diff --git a/packages/cli/src/commands/apps/dockerfile/rust.ts b/packages/cli/src/commands/apps/dockerfile/rust.ts index 8cb03ea7..f690f045 100644 --- a/packages/cli/src/commands/apps/dockerfile/rust.ts +++ b/packages/cli/src/commands/apps/dockerfile/rust.ts @@ -1,12 +1,16 @@ import { type DetectedApp, dockerfile, type Project } from "./project.ts"; export function detectRust(project: Project): DetectedApp | undefined { - // The binary is named after `[package]`; workspaces have no single one. - const name = project - .read("Cargo.toml") - ?.split(/^\[/m) - .find((section) => section.startsWith("package]")) - ?.match(/^name\s*=\s*"([^"]+)"/m)?.[1]; + const sections = project.read("Cargo.toml")?.split(/^\[/m) ?? []; + const nameIn = (header: string) => + sections + .find((section) => section.startsWith(header)) + ?.match(/^name\s*=\s*"([^"]+)"/m)?.[1]; + // The binary: the first named `[[bin]]` target, else `src/main.rs`, named + // after the package. Library crates and workspaces have neither. + const name = + nameIn("[bin]]") ?? + (project.read("src/main.rs") !== null ? nameIn("package]") : undefined); if (!name) return undefined; const locked = project.entries.includes("Cargo.lock") ? " --locked" : ""; diff --git a/packages/cli/src/commands/apps/init.ts b/packages/cli/src/commands/apps/init.ts index 74b82495..05b70629 100644 --- a/packages/cli/src/commands/apps/init.ts +++ b/packages/cli/src/commands/apps/init.ts @@ -130,6 +130,7 @@ export const appsInitCommand = defineCommand({ nameOverride: name, regionFlag: region, interactive: isInteractive(output), + buildsImage: false, configPath, }); diff --git a/packages/cli/src/commands/apps/walkthrough.test.ts b/packages/cli/src/commands/apps/walkthrough.test.ts index eddbea8f..d6047bd3 100644 --- a/packages/cli/src/commands/apps/walkthrough.test.ts +++ b/packages/cli/src/commands/apps/walkthrough.test.ts @@ -26,7 +26,12 @@ describe("runWalkthrough without a terminal (agents)", () => { } const agent = (input: Partial = {}) => - runWalkthrough(client, { interactive: false, dryRun: true, ...input }); + runWalkthrough(client, { + interactive: false, + buildsImage: false, + dryRun: true, + ...input, + }); test("--dockerfile generates one and takes every default", async () => { inProject({ "index.php": " Date: Fri, 16 Oct 2026 07:21:00 +0000 Subject: [PATCH 11/12] fix(apps): don't fail pnpm installs on unapproved dependency build scripts --- .../cli/src/commands/apps/dockerfile/index.test.ts | 11 +++++++++++ packages/cli/src/commands/apps/dockerfile/node.ts | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/apps/dockerfile/index.test.ts b/packages/cli/src/commands/apps/dockerfile/index.test.ts index a2194d3a..c39f8e41 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.test.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -131,6 +131,17 @@ describe("node", () => { ); }); + test("pnpm leaves unapproved dependency builds unbuilt instead of failing", () => { + expect( + render({ + "package.json": { dependencies: { next: "15" } }, + "pnpm-lock.yaml": "", + }), + ).toContain( + "RUN corepack enable && pnpm install --frozen-lockfile --config.strict-dep-builds=false\n", + ); + }); + test("a lone script runs on bun", () => { expect(render({ "index.ts": "Bun.serve({})" })).toContain( 'CMD ["bun", "index.ts"]', diff --git a/packages/cli/src/commands/apps/dockerfile/node.ts b/packages/cli/src/commands/apps/dockerfile/node.ts index 97d153a6..bc12cbb3 100644 --- a/packages/cli/src/commands/apps/dockerfile/node.ts +++ b/packages/cli/src/commands/apps/dockerfile/node.ts @@ -122,7 +122,11 @@ function installCommand(project: Project, pm: PackageManager): string { case "bun": return locked ? "bun install --frozen-lockfile" : "bun install"; case "pnpm": - return `${corepack}pnpm install${locked ? " --frozen-lockfile" : ""}`; + // pnpm 11+ fails an install while dependency build scripts are + // neither allowed nor denied (`allowBuilds` in pnpm-workspace.yaml). + // Leave unapproved ones unbuilt rather than fail: approving them would + // run arbitrary scripts, which stays the project's call. + return `${corepack}pnpm install${locked ? " --frozen-lockfile" : ""} --config.strict-dep-builds=false`; case "yarn": { const flag = project.entries.includes(".yarnrc.yml") ? " --immutable" From adbad489702635ad27caa1fc54b5d3b0831b9228 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 16 Oct 2026 07:43:37 +0000 Subject: [PATCH 12/12] feat(apps): point generated Dockerfiles and their build failures to GitHub deploys --- packages/cli/src/commands/apps/APPS.md | 2 +- packages/cli/src/commands/apps/docker.ts | 8 +++++++- .../cli/src/commands/apps/dockerfile/index.test.ts | 7 +++++++ packages/cli/src/commands/apps/dockerfile/project.ts | 10 +++++++++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 67c5a5ed..baf73aae 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -49,7 +49,7 @@ To rollback: bunny apps deploy ghcr.io/me/api:abc-123 ### Generating a Dockerfile -When the project has no Dockerfile, the walkthrough looks at the project root and offers to write one. A single file is enough: a lone `index.php`, `main.go`, `main.py` or `index.ts` deploys as-is. +When the project has no Dockerfile, the walkthrough looks at the project root and offers to write one. Generation covers common setups, not every build a buildpack would: when a generated Dockerfile doesn't fit, edit it, or connect the GitHub repository in the bunny.net dashboard, the more reliable way to deploy without any config. A failed build of a generated Dockerfile says the same. A single file is enough: a lone `index.php`, `main.go`, `main.py` or `index.ts` deploys as-is. | Stack | Detected from | Runs | Port | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | ----------------- | diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index 72807718..60ed56f7 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -8,6 +8,7 @@ import { dockerLogin, imageHostname } from "@/core/docker.ts"; import { ApiError, UserError } from "@/core/errors.ts"; import { logger } from "@/core/logger.ts"; import { prompts, spinner } from "@/core/ui.ts"; +import { GENERATED_HEADER, GITHUB_HINT } from "./dockerfile/project.ts"; export { dockerLogin, @@ -299,9 +300,14 @@ export async function buildImage( const exitCode = await proc.exited; if (exitCode !== 0) { + const generated = readFileSync(dockerfileAbs, "utf-8").startsWith( + GENERATED_HEADER, + ); throw new UserError( `Docker build failed (exit code ${exitCode}).`, - "Check the Dockerfile and build output above for errors.", + generated + ? `Check the build output above, then edit the generated Dockerfile to fit the project. ${GITHUB_HINT}` + : "Check the Dockerfile and build output above for errors.", ); } } diff --git a/packages/cli/src/commands/apps/dockerfile/index.test.ts b/packages/cli/src/commands/apps/dockerfile/index.test.ts index c39f8e41..c971ee2a 100644 --- a/packages/cli/src/commands/apps/dockerfile/index.test.ts +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { type DetectedApp, detectAppFrom, dockerignore } from "./index.ts"; +import { GENERATED_HEADER, GITHUB_HINT } from "./project.ts"; /** A project root holding `files`; JSON values are serialised. */ function detect(files: Record): DetectedApp | undefined { @@ -442,6 +443,12 @@ test("dockerignore keeps env files and bunny config out, plus the stack's own li expect(lines).toContain("target"); }); +test("generated Dockerfiles point to GitHub deploys for anything they don't cover", () => { + const [header, note] = render({ "main.go": "package main\n" }).split("\n"); + expect(header).toStartWith(GENERATED_HEADER); + expect(note).toBe(`# It covers common setups only. ${GITHUB_HINT}`); +}); + describe("an existing dockerignore", () => { const app = detect({ "index.php": "" }); if (!app) throw new Error("expected an app"); diff --git a/packages/cli/src/commands/apps/dockerfile/project.ts b/packages/cli/src/commands/apps/dockerfile/project.ts index b3fe8b63..89aad75a 100644 --- a/packages/cli/src/commands/apps/dockerfile/project.ts +++ b/packages/cli/src/commands/apps/dockerfile/project.ts @@ -56,10 +56,18 @@ export function versionFrom( return fallback; } +/** How every generated Dockerfile starts, so a failed build can tell. */ +export const GENERATED_HEADER = "# Generated by the bunny CLI"; + +// Generation covers common setups, not every build a buildpack would. +export const GITHUB_HINT = + "For deploys without any config, connecting the GitHub repository in the bunny.net dashboard is more reliable."; + /** Join Dockerfile lines under the generated-file header. */ export function dockerfile(label: string, lines: string[]): string { return `${[ - `# Generated by the bunny CLI for ${label}. Edit freely: deploys build this file as-is.`, + `${GENERATED_HEADER} for ${label}. Edit freely: deploys build this file as-is.`, + `# It covers common setups only. ${GITHUB_HINT}`, ...lines, ].join("\n")}\n`; }