From cd1864aaad5cdf653664898b2fd7b1cec9ae1f59 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 23:46:14 +0000 Subject: [PATCH] fix(apply): express static-site build settings so a monorepo app is served, not run (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply created applications but dropped install_command, build_command, and is_static — settings the manifest had no field for — so a static site in an npm-workspace monorepo (landing) was built and RUN from the repo-root package.json, booting the core API server, which crash-looped on a missing DATABASE_URL. The build block gains install_command / build_command / start_command (free-form strings) and static (-> Coolify is_static). apply writes and diffs them; draft emits them (they left its NO_HOME list, and is_static was never in it — the silent loss that caused the crash), and only emits static alongside a publish_directory so a draft always loads. Managing is_static is opt-in: declaring `static:` is required to serve a static app, and NOT emitting is_static by default avoids the first apply PATCHing static serving OFF on an un-migrated app (or fighting a pack:static coupling forever). static:true with no publish_directory, and any of the four on a dockercompose app, are parse-time refusals. Co-Authored-By: Claude Opus 4.8 --- docs/semantics.md | 54 +++++++++++++++++++++++++++++++ src/cli.ts | 15 +++++++++ src/draft.ts | 30 +++++++++++++++-- src/manifest.ts | 43 +++++++++++++++++++++++++ src/resolve.ts | 25 +++++++++++++++ test/draft.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++++ test/manifest.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++ test/resolve.test.ts | 64 ++++++++++++++++++++++++++++++++++++ test/wire.test.ts | 31 ++++++++++++++++++ 9 files changed, 405 insertions(+), 3 deletions(-) diff --git a/docs/semantics.md b/docs/semantics.md index d5787b3..10ef9be 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -221,6 +221,60 @@ softened by an implementation detail): - **Reserved names:** cast never writes `SOURCE_COMMIT` or a `COOLIFY_*` var, under any manifest, in any environment. See below. +## Build settings (`install_command`, `build_command`, `start_command`, `static`) + +A manifest application's `build` block carries, beyond `pack` and the checkout +paths, four optional settings Coolify accepts on both the create +(`POST /applications/private-github-app`) and update (`PATCH /applications/{uuid}`) +routes: + +```yaml +landing: + source: { repo: acme/widget, branch: main } + build: + pack: nixpacks + base_directory: / + install_command: npm ci + build_command: npm run build -w apps/landing-site + publish_directory: /apps/landing-site/dist + static: true + domains: ["https://widget.example.com"] +``` + +- `install_command` / `build_command` / `start_command` are free-form strings, + passed through verbatim — cast does not parse the shell in them. They exist so + a **workspace monorepo** can scope the build to one app (`npm run build -w + apps/landing-site`) instead of letting nixpacks auto-detect from the repo-root + `package.json`, whose scripts may build and run a *different* workspace. +- `static: true` maps to Coolify's `is_static`: Coolify then **serves + `publish_directory` and runs no start command**. Without it a static site in a + monorepo gets built and then *run* as its root `package.json` — for the + incubator's `landing` that meant Coolify ran `npm run start -w apps/core`, the + API server, which crash-looped on a missing `DATABASE_URL` (#63). + +**All four are rejected on a `dockercompose` app** — a compose app builds and +runs from its compose file, so Coolify never consults them — and `static: true` +is rejected without a `publish_directory` (nothing to serve). Both are parse-time +refusals, like the checkout-path rules above. + +**Managing `is_static` is opt-in.** cast emits it only when the manifest declares +`static:` — like the three commands, not on every app. Emitting `is_static:false` +by default would make the first apply after this ships PATCH `is_static=false` +onto any static/SPA app configured in the UI whose manifest has not yet been +migrated — silently disabling static serving and re-creating the very crash, now +caused by cast; a `pack: static` app that Coolify couples to `is_static=true` +would drift-and-revert forever. So: declare `static: true` to serve, `static: +false` to actively guard against a UI flip to `true`, or omit it to leave the +field alone (Coolify keeps `pack` and `is_static` independent, which is why this +is an explicit field, not inferred from `pack`). The three commands are likewise +conditional (an unset command means "let the build pack decide"), diffed only +when declared. `projectLiveFields` reads `is_static` back on every app so it is +there to compare when a manifest does declare it. `draft` emits all four when the +live box carries them (and `static` only alongside a `publish_directory`, so the +draft always loads) — they used to sit in its `NO_HOME` list of settings a +rebuild silently dropped, and `is_static` was not even there, which is exactly +how a rebuilt static site came back wrong. + ## Reserved env var names (`SOURCE_COMMIT`, `COOLIFY_*`) Coolify injects a set of values into an application's runtime environment diff --git a/src/cli.ts b/src/cli.ts index 70124e8..e358d75 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -326,6 +326,17 @@ export function projectLiveFields( ? { docker_compose_location: raw.docker_compose_location } : {}), ...(composeDomains ? { docker_compose_domains: composeDomains } : {}), + // is_static is read on every application so it is there to compare WHEN a + // manifest declares `static:`. computeDiff compares only fields the DESIRED + // side declares, so an app whose manifest omits `static` never diffs on it + // (which is what keeps this from PATCHing is_static off an un-migrated + // static app), and the three commands are the same — live carrying them + // here is safe and never reads as spurious drift. Coolify's Application + // model casts is_static to boolean; tolerate a 1/0 defensively. + is_static: raw.is_static === true || raw.is_static === 1, + ...(raw.install_command ? { install_command: raw.install_command } : {}), + ...(raw.build_command ? { build_command: raw.build_command } : {}), + ...(raw.start_command ? { start_command: raw.start_command } : {}), }; } if (kind === "database") { @@ -2445,6 +2456,10 @@ export function applicationApiFields( const { port, healthcheck, domains, docker_compose_domains, ...rest } = fields; return { + // is_static/install_command/build_command/start_command ride through `rest` + // unchanged: they are valid API params verbatim, accepted on both the create + // (POST /applications/private-github-app) and update (PATCH + // /applications/{uuid}) routes — verified against the vendored OpenAPI. ...rest, // ports_exposes wants a string; healthcheck -> health_check_path; // domains wants a comma-separated string, not an array. diff --git a/src/draft.ts b/src/draft.ts index 9ac1952..c01b3b0 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -435,9 +435,6 @@ function applicationSpec( const NO_HOME: Array<[string, string]> = [ ["custom_labels", "custom Traefik/Docker labels (Basic Auth lives here)"], ["ports_mappings", "host port mappings"], - ["install_command", "a custom install command"], - ["build_command", "a custom build command"], - ["start_command", "a custom start command"], ["pre_deployment_command", "a pre-deployment command"], ["post_deployment_command", "a post-deployment command"], ["dockerfile", "an inline Dockerfile"], @@ -483,6 +480,33 @@ function applicationSpec( ...(!compose && r.raw.publish_directory ? { publish_directory: String(r.raw.publish_directory) } : {}), + // The three build/run commands and the static flag now HAVE manifest + // fields (see manifest.ts), so a rebuild carries them instead of silently + // dropping them. `is_static` was previously not even in NO_HOME, so a + // rebuild lost it without a word — that is exactly the #63 crash: a static + // site whose is_static was true on the box came back as false, got built + // and RUN from the repo-root package.json, and crash-looped. draft now + // carries it. (Compose apps get none of these — see the else above.) + ...(!compose && r.raw.install_command + ? { install_command: String(r.raw.install_command) } + : {}), + ...(!compose && r.raw.build_command + ? { build_command: String(r.raw.build_command) } + : {}), + ...(!compose && r.raw.start_command + ? { start_command: String(r.raw.start_command) } + : {}), + // Gated on publish_directory as well: the schema refuses `static: true` + // with nothing to serve, and a draft must only ever emit a manifest that + // loads. Coolify's static apps carry a publish_directory (its default is + // `/`), so this drops `static` only for a box in a state the manifest could + // not express anyway — a loud, correct omission rather than an unloadable + // file. + ...(!compose && + (r.raw.is_static === true || r.raw.is_static === 1) && + r.raw.publish_directory + ? { static: true } + : {}), }, ...(compose ? {} diff --git a/src/manifest.ts b/src/manifest.ts index 06e295a..d6ebdc2 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -43,6 +43,19 @@ const AppSpecSchema = z base_directory: repoDirectoryPath("base_directory"), publish_directory: repoDirectoryPath("publish_directory").optional(), compose_file: composeFilePath.optional(), + // The three build/run commands and the static flag Coolify accepts on + // both the create (POST /applications/private-github-app) and the + // update (PATCH /applications/{uuid}) routes. They are free-form + // strings passed through verbatim — cast does not parse or validate the + // shell in them, only whether they belong on this pack (superRefine + // below). `static` maps to Coolify's `is_static`: it makes Coolify + // SERVE `publish_directory` and run NO start command, which is exactly + // the fix for a static site in a workspace monorepo that otherwise gets + // built and RUN from the repo-root package.json (#63). + install_command: z.string().optional(), + build_command: z.string().optional(), + start_command: z.string().optional(), + static: z.boolean().optional(), }) .strict(), port: z.number().int().optional(), @@ -75,6 +88,26 @@ const AppSpecSchema = z code: "custom", message: "publish_directory not allowed on a dockercompose app", }); + // A compose app builds and runs from its compose file — Coolify never + // consults these on it. Reject them at parse time rather than post them + // and have them silently ignored (the same reasoning as publish_directory + // and port above). + for (const k of [ + "install_command", + "build_command", + "start_command", + ] as const) + if (app.build[k] !== undefined) + ctx.addIssue({ + code: "custom", + message: `build.${k} not allowed on a dockercompose app (it builds from its compose file)`, + }); + if (app.build.static !== undefined) + ctx.addIssue({ + code: "custom", + message: + "build.static not allowed on a dockercompose app (a compose file decides what is served)", + }); } else { if (!app.domains) ctx.addIssue({ @@ -87,6 +120,16 @@ const AppSpecSchema = z message: "service_domains/compose_file only allowed with pack dockercompose", }); + // `static: true` tells Coolify to serve publish_directory and run no + // start command — so a static app with nothing to serve is almost + // certainly a mistake, and one that would deploy green while serving an + // empty site. Catch it in the file, once, not on a live box. + if (app.build.static === true && !app.build.publish_directory) + ctx.addIssue({ + code: "custom", + message: + "build.static: true serves publish_directory and runs no start command — but no publish_directory is set, so there is nothing to serve", + }); } }); diff --git a/src/resolve.ts b/src/resolve.ts index 497b4bd..103bd26 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -414,6 +414,31 @@ export function desiredFromManifest( ...(app.port !== undefined ? { port: app.port } : {}), ...(app.healthcheck ? { healthcheck: app.healthcheck } : {}), domains: app.domains, + // Emitted only when the manifest DECLARES `static:` — like the + // three commands, not unconditionally. Emitting `is_static:false` + // on every non-compose app would make the first apply after this + // ships PATCH `is_static=false` onto any static/SPA app configured + // in the UI whose manifest has not yet been migrated — silently + // disabling static serving and re-creating the #63 crash, now + // caused by cast. And a `pack: static` app that Coolify couples to + // is_static=true would drift-and-revert forever. So managing + // is_static is opt-in: declare `static: true` to serve, `static: + // false` to actively guard against a UI flip to true, or omit it to + // leave the field alone. (Coolify keeps pack and is_static + // independent, which is why this stays an explicit field, not a + // heuristic off `pack`.) + ...(app.build.static !== undefined + ? { is_static: app.build.static } + : {}), + ...(app.build.install_command !== undefined + ? { install_command: app.build.install_command } + : {}), + ...(app.build.build_command !== undefined + ? { build_command: app.build.build_command } + : {}), + ...(app.build.start_command !== undefined + ? { start_command: app.build.start_command } + : {}), }), }, env: resolveEnvFile(name, app.env_template), diff --git a/test/draft.test.ts b/test/draft.test.ts index b6c4955..a4c2d4c 100644 --- a/test/draft.test.ts +++ b/test/draft.test.ts @@ -171,6 +171,81 @@ describe("planDraft — the emitted shape", () => { expect(env.generated_secrets).toEqual(["DATABASE_URL"]); }); + // #63: is_static was previously not even in NO_HOME, so a rebuild silently + // lost it — the exact crash. draft now carries it, plus the install/build/ + // start commands that used to be flagged as NO_HOME. + it("carries static + install/build/start commands, and does NOT flag them as uncaptured", () => { + const p = project({ + resources: [ + { + kind: "application", + name: "Landing", + uuid: "a1", + raw: { + git_repository: "https://github.com/heavy-duty/incubator", + git_branch: "main", + build_pack: "static", + base_directory: "/", + publish_directory: "/apps/landing-site/dist", + fqdn: "https://landing.example.com", + is_static: true, + install_command: "npm ci", + build_command: "npm run build -w apps/landing-site", + }, + env: {}, + }, + ], + }); + const plan = planDraft([p], ctx); + const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml")); + const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); + const path = join(dir, "manifest.yaml"); + writeFileSync(path, manifest?.content ?? ""); + const build = + loadManifest(path).environments.prod.applications.Landing.build; + expect(build.static).toBe(true); + expect(build.install_command).toBe("npm ci"); + expect(build.build_command).toBe("npm run build -w apps/landing-site"); + // These now have a manifest home, so they must NOT be reported as settings + // cast could see but not express. + for (const setting of ["is_static", "install_command", "build_command"]) { + expect(plan.uncaptured.some((u) => u.setting === setting)).toBe(false); + } + }); + + // A draft must only ever emit a manifest that LOADS. is_static true with no + // publish_directory would be `static: true` with nothing to serve, which the + // schema refuses — so draft omits `static` for that (rare, malformed) box + // rather than writing a file that throws on load. + it("does not emit static:true when the box has is_static but no publish_directory", () => { + const p = project({ + resources: [ + { + kind: "application", + name: "Odd", + uuid: "a1", + raw: { + git_repository: "https://github.com/heavy-duty/incubator", + git_branch: "main", + build_pack: "nixpacks", + base_directory: "/", + fqdn: "https://odd.example.com", + is_static: true, + }, + env: {}, + }, + ], + }); + const plan = planDraft([p], ctx); + const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml")); + const dir = mkdtempSync(join(tmpdir(), "cast-draft-")); + const path = join(dir, "manifest.yaml"); + writeFileSync(path, manifest?.content ?? ""); + // The whole point: it loads (does not throw), and simply carries no `static`. + const build = loadManifest(path).environments.prod.applications.Odd.build; + expect(build).not.toHaveProperty("static"); + }); + it("emits an env template every cast reader can parse", () => { const plan = planDraft([project()], ctx); const tpl = plan.files.find((f) => f.path.endsWith(".env.template")); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index d9f88ab..beb26cd 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -212,6 +212,77 @@ environments: }), ).toThrow(/service_domains/); }); + // #63: the working (hand-built) config for a static site in a workspace + // monorepo — is_static plus workspace-scoped install/build commands that build + // only the target app and serve its dist, instead of the repo root's start + // script booting a different workspace. + it("accepts install/build/start commands and static on a non-compose app, round-tripping them", () => { + const m = loadManifest(`${FIX}manifest.yaml`, { + overrideText: ` +project: widget +environments: + prod: + applications: + landing: + source: { repo: acme/widget, branch: main } + build: + pack: static + base_directory: / + publish_directory: /apps/landing-site/dist + install_command: npm ci + build_command: npm run build -w apps/landing-site + start_command: node server.js + static: true + domains: ["https://landing.example.com"] +`, + }); + const b = m.environments.prod.applications.landing.build; + expect(b.install_command).toBe("npm ci"); + expect(b.build_command).toBe("npm run build -w apps/landing-site"); + expect(b.start_command).toBe("node server.js"); + expect(b.static).toBe(true); + expect(b.publish_directory).toBe("/apps/landing-site/dist"); + }); + it("rejects static: true with nothing to serve (no publish_directory)", () => { + expect(() => + loadManifest(`${FIX}manifest.yaml`, { + overrideText: ` +project: widget +environments: + prod: + applications: + landing: + source: { repo: acme/widget, branch: main } + build: { pack: static, base_directory: /, static: true } + domains: ["https://landing.example.com"] +`, + }), + ).toThrow(/nothing to serve/); + }); + it("rejects install/build/start commands and static on a dockercompose app", () => { + for (const field of [ + "install_command: npm ci", + "build_command: npm run build", + "start_command: node server.js", + "static: true", + ]) { + expect(() => + loadManifest(`${FIX}manifest.yaml`, { + overrideText: ` +project: widget +environments: + prod: + applications: + core: + source: { repo: acme/widget, branch: main } + build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml, ${field} } + service_domains: + api: ["https://api.example.com"] +`, + }), + ).toThrow(/not allowed on a dockercompose app/); + } + }); }); describe("loadBindings", () => { diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 98f4361..84ec09f 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -158,6 +158,64 @@ environments: value: "secret-v", secret: true, }); + // None of the four build settings are emitted for an app that declares none: + // managing is_static is opt-in (declaring it would otherwise PATCH static + // serving OFF on an un-migrated app), and the commands default to "let the + // build pack decide". + expect(desired[0].fields).not.toHaveProperty("is_static"); + expect(desired[0].fields).not.toHaveProperty("install_command"); + expect(desired[0].fields).not.toHaveProperty("build_command"); + expect(desired[0].fields).not.toHaveProperty("start_command"); + }); + it("emits is_static:false when static:false is explicitly declared (a guard against a UI flip)", () => { + const dir = mkdtempSync(join(tmpdir(), "infra-co-")); + mkdirSync(join(dir, ".infra"), { recursive: true }); + writeFileSync( + join(dir, ".infra", "manifest.yaml"), + `project: widget +environments: + staging: + applications: + core: + source: { repo: acme/widget, branch: main } + build: { pack: nixpacks, base_directory: /, static: false } + domains: ["https://c.example.com"] +`, + ); + const { desired } = desiredFromManifest(dir, "staging", {}); + expect(desired[0].fields.is_static).toBe(false); + }); + // #63: the static-site build settings a workspace monorepo needs. + it("emits is_static:true and the three commands for a non-compose app that declares them", () => { + const dir = mkdtempSync(join(tmpdir(), "infra-co-")); + mkdirSync(join(dir, ".infra"), { recursive: true }); + writeFileSync( + join(dir, ".infra", "manifest.yaml"), + `project: widget +environments: + staging: + applications: + landing: + source: { repo: acme/widget, branch: main } + build: + pack: static + base_directory: / + publish_directory: /apps/landing-site/dist + install_command: npm ci + build_command: npm run build -w apps/landing-site + start_command: node server.js + static: true + domains: ["https://landing.example.com"] +`, + ); + const { desired } = desiredFromManifest(dir, "staging", {}); + expect(desired[0].fields).toMatchObject({ + is_static: true, + install_command: "npm ci", + build_command: "npm run build -w apps/landing-site", + start_command: "node server.js", + publish_directory: "/apps/landing-site/dist", + }); }); // The reverse of what this file used to assert. `backup` was deliberately // routed AROUND `fields` into a side channel, because live Coolify was @@ -345,6 +403,12 @@ environments: expect(desired[0].fields).not.toHaveProperty("port"); expect(desired[0].fields).not.toHaveProperty("healthcheck"); expect(desired[0].fields).not.toHaveProperty("domains"); + // A compose app builds from its compose file — none of the static/command + // fields belong on it, not even is_static (which every NON-compose app gets). + expect(desired[0].fields).not.toHaveProperty("is_static"); + expect(desired[0].fields).not.toHaveProperty("install_command"); + expect(desired[0].fields).not.toHaveProperty("build_command"); + expect(desired[0].fields).not.toHaveProperty("start_command"); }); it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => { const dir = mkdtempSync(join(tmpdir(), "infra-co-")); diff --git a/test/wire.test.ts b/test/wire.test.ts index a78292c..43ffa1d 100644 --- a/test/wire.test.ts +++ b/test/wire.test.ts @@ -119,6 +119,37 @@ describe("projectLiveFields", () => { }); expect(out).not.toHaveProperty("docker_compose_domains"); }); + + // #63: the static-site fields must read back off a live application so they + // diff against the manifest — otherwise a UI flip of is_static is invisible. + it("reads is_static and the build/run commands off a live application", () => { + const out = projectLiveFields("application", { + git_repository: "org/repo", + git_branch: "main", + build_pack: "static", + base_directory: "/", + is_static: true, + install_command: "npm ci", + build_command: "npm run build -w apps/landing-site", + start_command: "node server.js", + }); + expect(out.is_static).toBe(true); + expect(out.install_command).toBe("npm ci"); + expect(out.build_command).toBe("npm run build -w apps/landing-site"); + expect(out.start_command).toBe("node server.js"); + }); + + it("always reports is_static as a boolean, tolerating Coolify's 1/0", () => { + expect(projectLiveFields("application", { is_static: 1 }).is_static).toBe( + true, + ); + expect(projectLiveFields("application", { is_static: 0 }).is_static).toBe( + false, + ); + // Absent on the wire reads as false (its real default), never undefined — + // so it compares against the desired side, which always emits it. + expect(projectLiveFields("application", {}).is_static).toBe(false); + }); }); describe("compose app idempotency (review finding #2)", () => { -- 2.45.2