diff --git a/.changeset/apps-generate-dockerfile.md b/.changeset/apps-generate-dockerfile.md new file mode 100644 index 00000000..7733c9f1 --- /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`. 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/.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/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index 5e1a19c7..baf73aae 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 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,31 @@ 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 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 | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | ----------------- | +| 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 `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 | +| 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` 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). 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; 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, 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 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 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. @@ -85,20 +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. | -| `--dockerfile` | Build from a Dockerfile, then deploy. Pass a path or use the bare flag for `./Dockerfile`. | -| `--context` | Docker build context directory. Defaults to the directory of the Dockerfile. | -| `--tag` | Override the auto-generated `-` image tag. | -| `--registry` | 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-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 @@ -126,6 +153,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: @@ -179,15 +216,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. @@ -221,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 or instances on purpose (see Volumes) +bunny apps push --allow-split-volumes ``` ## `bunny apps env` @@ -379,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, regions and scaling + +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 06bc68d9..efc3c1fd 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 { + assertVolumesNotSplit, + type BunnyAppConfig, configExists, loadConfig, saveConfig, @@ -172,3 +174,60 @@ test("load → save → reload preserves intent fields", () => { "daemon off;", ]); }); + +describe("assertVolumesNotSplit", () => { + const app = ( + regions: BunnyAppConfig["app"]["regions"], + { volumes = true, max = 1 } = {}, + ): BunnyAppConfig => ({ + version: "2026-05-11", + app: { + name: "x", + regions, + scaling: { min: 1, max }, + containers: { + web: {}, + db: volumes + ? { volumes: [{ name: "data", mount: "/data", size: 1 }] } + : {}, + }, + }, + }); + + 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(() => + assertVolumesNotSplit(app({ allowed: ["de", "uk"], required: ["de"] })), + ).toThrow(); + }); + + test.each([ + ["one region and one instance", app(["de"])], + ["no volume", app(["de", "uk"], { volumes: false, max: 3 })], + ])("allows %s", (_, config) => { + expect(() => assertVolumesNotSplit(config)).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 899c573a..53cbc313 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,38 @@ export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void { } } +/** + * 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 assertVolumesNotSplit( + config: BunnyAppConfig, + allow?: boolean, +): void { + if (allow) 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 ${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.", + ); +} + /** * Resolve the active app ID. * diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index 94475443..23ce97ba 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -15,8 +15,9 @@ 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 { + assertVolumesNotSplit, type BunnyAppConfig, type ContainerConfig, configExists, @@ -164,6 +165,8 @@ const DEFAULT_DOCKERFILE = "Dockerfile"; interface DeployArgs { image?: string; + "allow-split-volumes"?: boolean; + region?: string; dockerfile?: string | boolean; context?: string; tag?: string; @@ -182,7 +185,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 +237,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: @@ -247,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-split-volumes", { + type: "boolean", + describe: + "Deploy a volume to more than one region or instance, each keeping its own separate data", + }) .option("no-push", { type: "boolean", describe: "Build only — skip push and deploy", @@ -285,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(); } @@ -304,6 +324,9 @@ export const appsDeployCommand = defineCommand({ portOverride: args.port, commandOverride: args.command, nameOverride: args.name, + regionFlag: args.region, + interactive: isInteractive(output), + buildsImage: !dryRun, configPath, dryRun, }); @@ -311,12 +334,16 @@ 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 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) => Boolean(c.dockerfile), ); - if (hasAnyBuild) { + if (hasAnyBuild && !dryRun) { await ensureDockerAvailable(); } } 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/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..1a078d60 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/dotnet.ts @@ -0,0 +1,69 @@ +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(\d+\.\d+)/, + "10.0", + ); + 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", + port: 8080, + name: assembly.toLowerCase(), + ignore: ["bin", "obj"], + dockerfile: (port) => + dockerfile("ASP.NET Core", [ + `FROM mcr.microsoft.com/dotnet/sdk:${sdk} AS build`, + "WORKDIR /src", + ...restore, + `RUN dotnet publish ${csproj.file} -c Release -o /out --no-restore`, + "", + `FROM mcr.microsoft.com/dotnet/aspnet:${runtime}`, + "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 new file mode 100644 index 00000000..5c5b0f8f --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/go.ts @@ -0,0 +1,57 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +export function detectGo(project: Project): DetectedApp | undefined { + const gomod = project.read("go.mod"); + // 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. + 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"); + // 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, + ignore: [], + dockerfile: (port) => + dockerfile("Go", [ + `FROM golang:${version} AS build`, + "WORKDIR /src", + ...modules, + `RUN CGO_ENABLED=0 go build -ldflags="-s -w" -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..c971ee2a --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/index.test.ts @@ -0,0 +1,467 @@ +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 { + 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: list(""), + list, + 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 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({ + "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(); + }); + + test("a script next to index.html is a static site's browser code", () => { + 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("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"]', + ); + }); +}); + +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\nCOPY . .\nRUN uv sync --frozen --no-dev --inexact\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": "" })?.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("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"]', + ); + }); +}); + +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", + }); + expect(dockerfile).toContain("FROM golang:1.23 AS build"); + expect(dockerfile).toContain("COPY go.mod go.sum ./"); + expect(dockerfile).toContain("-o /server ./cmd/api"); + }); + + test("a lone main.go gets a module", () => { + 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("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]\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", () => { + 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"'); + }); + + 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', + "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("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" }, + }, + "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("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("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("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("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({ + "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("RUN sh gradlew bootJar"); + expect(dockerfile).toContain("grep -v -- -plain.jar"); + 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" }), + ).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"]', + ); + }); +}); + +test("dockerignore keeps env files and bunny config out, plus the stack's own lines", () => { + 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"); + expect(lines).toContain(".env"); + expect(lines).toContain(".env.*"); + expect(lines).toContain("bunny.jsonc"); + 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"); + + 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 new file mode 100644 index 00000000..9a3b443c --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/index.ts @@ -0,0 +1,101 @@ +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 { 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 { detectRuby } from "./ruby.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"; + +// 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[] = [ + detectPhp, + detectRuby, + detectPython, + detectGo, + detectRust, + detectDotnet, + detectJava, + detectDeno, + 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 { + const list = (path: string) => { + try { + return readdirSync(join(root, path)); + } catch { + return []; + } + }; + return detectAppFrom({ + entries: list(""), + list, + read: (path) => { + try { + return readFileSync(join(root, path), "utf-8"); + } catch { + return null; + } + }, + }); +} + +// 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"]; + +/** + * 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, + 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 new file mode 100644 index 00000000..3a206f01 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/java.ts @@ -0,0 +1,56 @@ +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; + + // `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_)?['"]?)(?:1[._])?(\d{1,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. `sh` runs + // the wrapper even when a Windows checkout lost its executable bit. + const [image, command] = pom + ? has("mvnw") + ? [`eclipse-temurin:${jdk}-jdk`, "sh mvnw"] + : [`maven:3-eclipse-temurin-${jdk}`, "mvn"] + : has("gradlew") + ? [`eclipse-temurin:${jdk}-jdk`, "sh 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/node.ts b/packages/cli/src/commands/apps/dockerfile/node.ts new file mode 100644 index 00000000..bc12cbb3 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/node.ts @@ -0,0 +1,317 @@ +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": + // 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" + : " --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`); + } + // 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; +} + +/** + * 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; +} + +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", + "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 { + // 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 { + 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 detectScript(project); + + 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))); + // 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, + // 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/php.ts b/packages/cli/src/commands/apps/dockerfile/php.ts new file mode 100644 index 00000000..be46f9af --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/php.ts @@ -0,0 +1,65 @@ +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", ""]; + // FrankenPHP publishes images for PHP 8.2 and later. + const php = versionFrom([require.php], /(8\.[2-9])/, "8.4"); + // 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 { + 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 new file mode 100644 index 00000000..89aad75a --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/project.ts @@ -0,0 +1,73 @@ +/** 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; + /** 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. */ +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; +} + +/** 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_HEADER} for ${label}. Edit freely: deploys build this file as-is.`, + `# It covers common setups only. ${GITHUB_HINT}`, + ...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..4ed97e64 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/python.ts @@ -0,0 +1,133 @@ +import { + type DetectedApp, + dockerfile, + type Project, + versionFrom, +} from "./project.ts"; + +// 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 { + 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"); + const entry = ENTRIES.find((f) => project.entries.includes(f)); + const manifest = requirements !== null || pyproject !== null; + if (!manifest && !entry) return undefined; + + // 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 = "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, APP_MODULES); + const flask = has("flask") && moduleOf(project, APP_MODULES); + if (has("django") && settings) { + label = "Django"; + command = gunicorn(`${settings}.wsgi`); + packages = ["django", "gunicorn"]; + } else if (fastapi) { + label = "FastAPI"; + command = (port) => [ + "uvicorn", + `${fastapi}:app`, + "--host", + "0.0.0.0", + "--port", + String(port), + ]; + packages = ["fastapi", "uvicorn"]; + } else if (flask) { + label = "Flask"; + command = gunicorn(`${flask}:app`); + packages = ["flask", "gunicorn"]; + } else if (entry) { + command = () => ["python", entry]; + } else { + return undefined; + } + + const version = versionFrom( + [project.read(".python-version")], + /(3\.\d+)/, + "3.13", + ); + // 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 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${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 + ? [ + "COPY requirements.txt ./", + `RUN pip install --no-cache-dir -r requirements.txt ${missing}`.trim(), + "COPY . .", + ] + : pyproject !== null + ? ["COPY . .", `RUN pip install --no-cache-dir . ${missing}`.trim()] + : [ + ...(missing ? [`RUN pip install --no-cache-dir ${missing}`] : []), + "COPY . .", + ]; + + 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(command(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..c03f4eba --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/ruby.ts @@ -0,0 +1,74 @@ +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"); + 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( + [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)] + : 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, + 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 ${packages.join(" ")} && 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/dockerfile/rust.ts b/packages/cli/src/commands/apps/dockerfile/rust.ts new file mode 100644 index 00000000..f690f045 --- /dev/null +++ b/packages/cli/src/commands/apps/dockerfile/rust.ts @@ -0,0 +1,38 @@ +import { type DetectedApp, dockerfile, type Project } from "./project.ts"; + +export function detectRust(project: Project): DetectedApp | undefined { + 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" : ""; + + return { + label: "Rust", + port: 8080, + name, + ignore: ["target"], + dockerfile: (port) => + dockerfile("Rust", [ + // 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 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/init.ts b/packages/cli/src/commands/apps/init.ts index 13c52ee3..05b70629 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,9 @@ export const appsInitCommand = defineCommand({ portOverride: port, commandOverride: command, nameOverride: name, + regionFlag: region, + interactive: isInteractive(output), + buildsImage: false, configPath, }); diff --git a/packages/cli/src/commands/apps/push.ts b/packages/cli/src/commands/apps/push.ts index e5f8caed..91ff6e3d 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 { + assertVolumesNotSplit, + 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-split-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-split-volumes", { + type: "boolean", + describe: + "Apply a volume in more than one region or instance, each keeping its own separate data", + }), + + handler: async ({ + "dry-run": dryRun, + "allow-split-volumes": allowSplitVolumes, + profile, + output, + verbose, + apiKey, + }) => { const appId = resolveAppId(); const toml = loadConfig(); + assertVolumesNotSplit(toml, allowSplitVolumes); const config = resolveConfig(profile, apiKey, verbose); const client = createMcClient(clientOptions(config, verbose)); 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..d6047bd3 --- /dev/null +++ b/packages/cli/src/commands/apps/walkthrough.test.ts @@ -0,0 +1,68 @@ +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, + buildsImage: 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 ba7b7e09..93d21e14 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 { 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"; @@ -18,6 +19,7 @@ import { } from "./config.ts"; import { assignContainerNamesToDockerfiles, + BUNNY_REGISTRY_ID, ensureDockerAvailable, findDockerfiles, getConfigSuggestions, @@ -28,6 +30,11 @@ import { readDockerfileExposedPorts, resolveRegistryForImage, } from "./docker.ts"; +import { + type DetectedApp, + detectApp, + dockerignore, +} from "./dockerfile/index.ts"; import { confirmEndpointSuggestions, endpointRequestToConfig, @@ -53,6 +60,19 @@ 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; + /** + * Whether the caller goes on to build an image (`deploy`, but not + * `init` or `--dry-run`). Docker is only required when it does. + */ + buildsImage: boolean; /** * If true, skip every write side-effect: don't generate files to * disk, don't save bunny.jsonc. The walkthrough still prompts and @@ -103,10 +123,44 @@ export async function runWalkthrough( let imageRef: string | undefined = input.positionalImage; let dockerfilePath: string | undefined = input.dockerfileFlag; + // 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; + + // `--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. + 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 || existsSync(resolve(process.cwd(), "index.html"))) { + logger.dim( + `This looks like a static${site ? ` ${site.label}` : ""} site. \`bunny sites deploy\` hosts it without a container.`, + ); + } + } const composeServicesCount = composeFile ? Object.keys(loadComposeFile(composeFile).services).length @@ -132,6 +186,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 +201,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" && input.buildsImage) { await ensureDockerAvailable(); } @@ -149,7 +211,13 @@ export async function runWalkthrough( return runComposeImport(client, composeFile, input); } - if (value === "dockerfile") { + if (value === "generate" && detectedApp) { + generated = { + app: detectedApp, + port: input.portOverride ?? detectedApp.port, + }; + dockerfilePath = writeGeneratedDockerfile(generated, input.dryRun); + } else if (value === "dockerfile") { const selectedPaths = await chooseDockerfiles(detectedDockerfiles); if (selectedPaths.length === 0) { throw new UserError("At least one Dockerfile is required."); @@ -171,16 +239,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; @@ -192,6 +264,19 @@ export async function runWalkthrough( logger.dim(suggestions.instructions); logger.log(); } + } else if (generated) { + suggestions = { + appName: generated.app.name, + 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 @@ -212,11 +297,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 @@ -242,9 +325,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); } @@ -263,7 +346,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 }; } @@ -305,6 +392,48 @@ 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. + */ +function writeGeneratedDockerfile( + { app, port }: { app: DetectedApp; port: number }, + dryRun?: boolean, +): string { + 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.dim(contents); + logger.dim("--- end preview ---"); + return path; + } + + writeFileSync(resolve(process.cwd(), path), contents); + logger.success(`Wrote a Dockerfile for ${app.label}.`); + // An existing .dockerignore is kept, with any missing secret entries + // added: the generated Dockerfile copies the whole project. + const ignorePath = resolve(process.cwd(), ".dockerignore"); + const existing = existsSync(ignorePath) + ? readFileSync(ignorePath, "utf-8") + : undefined; + const ignore = dockerignore(app, existing); + if (ignore) { + writeFileSync(ignorePath, ignore); + logger.success( + existing === undefined + ? "Wrote .dockerignore." + : "Added .env and bunny config to .dockerignore.", + ); + } + return path; +} + /** * Compose import path: translate `compose.yml` → `bunny.jsonc` and write. * @@ -326,17 +455,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, { @@ -381,8 +508,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", @@ -393,7 +537,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"); @@ -408,6 +555,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({ @@ -511,9 +675,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", @@ -664,17 +831,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 = {}; @@ -709,7 +874,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; }