From 50b0d2d2e552283525313db397c082b197f09878 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Thu, 16 Jul 2026 18:20:59 +0000 Subject: [PATCH] fix(draft): name is_static in UNCAPTURED.md when the live read cannot see it (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify 4.1.2 never serializes is_static on any read path — it lives on the ApplicationSetting relation, which no read endpoint loads (cast#68). diff already degrades the field honestly (staticNotCompared, warn-and- skip, #69), but the draft path did not: applicationSpec emits `static: true` only for a present truthy raw.is_static, so on 4.1.2 the key is simply absent, the drafted manifest of a live static site silently omits the flag, and UNCAPTURED.md said nothing. That breaks the draft's own contract (#27) — a reviewer approving the draft has no cue the field even exists to lose, and the #63 failure mode (static site rebuilt and run as a plain app) re-enters through the draft door. Now, when raw.is_static is absent/null (the same predicate the diff path's staticNotCompared uses) and the app is plausibly static — a nixpacks/static build pack with a publish_directory — the draft flags is_static in UNCAPTURED.md as unreadable on this Coolify, telling the reviewer to check the box in the UI and add `static: true` by hand if set. A real boolean (a future Coolify) behaves exactly as before: expressed in the manifest, never flagged. Part of #70; the remaining items there are blocked on Coolify v4.2. Co-Authored-By: Claude Fable 5 --- src/draft.ts | 21 ++++++++ test/draft.test.ts | 119 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/src/draft.ts b/src/draft.ts index d548974..4dfcde7 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -434,6 +434,27 @@ function applicationSpec( ); } + // is_static lives on Coolify 4.1.2's ApplicationSetting relation, which no + // read endpoint serializes (cast#68) — so on that Coolify the key is simply + // ABSENT here, and the `static: true` emission below can never fire, however + // the box is actually configured. The same predicate `diff` uses for its + // staticNotCompared escape hatch (cli.ts: `is_static == null`), applied to + // the draft's contract: a drafted static site would silently come back as a + // plain app (the #63 crash), so when the app even LOOKS static — a + // nixpacks/static pack serving a publish_directory — UNCAPTURED.md must name + // the flag as unreadable, not let its absence pass for `false`. A real + // boolean (a future Coolify) is handled below and says nothing here. + if ( + r.raw.is_static == null && + (pack === "nixpacks" || pack === "static") && + r.raw.publish_directory + ) { + flag( + "is_static", + `this Coolify cannot say whether the app serves as a static site — is_static lives on the ApplicationSetting relation, which 4.1.2's read API never returns (cast#68) — and this app is plausibly static (${pack} pack with publish_directory ${String(r.raw.publish_directory)}). The draft carries no \`static: true\`; if the box has "Is it a static site?" checked, a rebuild from this draft would build and RUN it as a plain app (the #63 crash). Check the box in the Coolify UI (Build settings) and, if set, add \`static: true\` under \`build:\` yourself.`, + ); + } + return { source: { repo: repo ?? String(r.raw.git_repository ?? ""), diff --git a/test/draft.test.ts b/test/draft.test.ts index 09452ee..628b04f 100644 --- a/test/draft.test.ts +++ b/test/draft.test.ts @@ -333,6 +333,125 @@ describe("planDraft — the emitted shape", () => { expect(build).not.toHaveProperty("static"); }); + // #70: Coolify 4.1.2 never returns is_static on any read (it lives on the + // ApplicationSetting relation, cast#68), so on that Coolify the key is ABSENT + // from the raw payload and the draft cannot know whether the box is a static + // site. UNCAPTURED.md is the draft's honesty contract (#27): when the app + // even looks static, the unreadable flag must be NAMED there — otherwise a + // reviewer has no cue the field exists to lose, and the #63 crash (static + // site rebuilt as a plain app) re-enters through the draft door. + it("names is_static in UNCAPTURED.md when the live read cannot see it and the app looks static", () => { + const p = project({ + resources: [ + { + kind: "application", + name: "Landing", + uuid: "a1", + raw: { + git_repository: "https://github.com/heavy-duty/incubator", + git_branch: "main", + build_pack: "nixpacks", + base_directory: "/", + publish_directory: "/apps/landing-site/dist", + fqdn: "https://landing.example.com", + // no is_static at all — Coolify 4.1.2's read path + }, + env: {}, + }, + ], + }); + const plan = planDraft([p], ctx); + const item = plan.uncaptured.find((u) => u.setting === "is_static"); + expect(item).toBeDefined(); + expect(item?.resource).toBe("Landing"); + // The entry tells the reviewer where the truth lives: the Coolify UI. + expect(item?.detail).toContain("Coolify UI"); + expect(item?.detail).toContain("static: true"); + // And it reaches the page a reviewer actually reads. + const uncap = plan.files.find((f) => f.path.endsWith("UNCAPTURED.md")); + expect(uncap?.content).toContain("is_static"); + // The manifest itself stays silent — absent is not `true`, and a guessed + // `static: true` would be exactly the fabrication UNCAPTURED.md exists to + // prevent. + 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).not.toHaveProperty("static"); + }); + + // The flag is only worth a reviewer's attention when the app is PLAUSIBLY + // static — nixpacks/static pack serving a publish_directory (the same + // heuristic as #70). An app with nothing to serve statically gets no entry; + // flagging every application would bury the page in noise. + it("does not flag is_static for an app that does not look static", () => { + const p = project({ + resources: [ + { + kind: "application", + name: "Api", + uuid: "a1", + raw: { + git_repository: "https://github.com/heavy-duty/incubator", + git_branch: "main", + build_pack: "nixpacks", + base_directory: "/", + fqdn: "https://api.example.com", + // no publish_directory, no is_static + }, + env: {}, + }, + { + kind: "application", + name: "Built", + uuid: "a2", + raw: { + git_repository: "https://github.com/heavy-duty/incubator", + git_branch: "main", + build_pack: "dockerfile", + base_directory: "/", + publish_directory: "/dist", + fqdn: "https://built.example.com", + // dockerfile pack — Coolify's static toggle is a buildpack concept + }, + env: {}, + }, + ], + }); + const plan = planDraft([p], ctx); + expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false); + }); + + // A future Coolify that DOES serialize is_static gets the old behavior + // untouched: a real boolean is expressed in the manifest, not flagged. + // (`static: true` emission for is_static: true is covered above; here the + // read said `false`, which is an answer, not an absence.) + it("does not flag is_static when the live read answered false", () => { + const p = project({ + resources: [ + { + kind: "application", + name: "Plain", + uuid: "a1", + raw: { + git_repository: "https://github.com/heavy-duty/incubator", + git_branch: "main", + build_pack: "nixpacks", + base_directory: "/", + publish_directory: "/dist", + fqdn: "https://plain.example.com", + is_static: false, + }, + env: {}, + }, + ], + }); + const plan = planDraft([p], ctx); + expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false); + }); + 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"));