diff --git a/LESSONS.md b/LESSONS.md index 2d1891a..4b41aa6 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -61,7 +61,7 @@ express.json())`), since the proxy route needs the raw stream untouched. Nixpacks does not fail when a repo's `engines`/version requirement isn't met — it silently falls back to its own default (e.g. picked Node 18 for a repo requiring Node >=20 in `Social-MCP`). The build goes green, then the container crashes at runtime (`crypto is not defined`, missing globals, etc.). -**Avoid it:** don't trust a green build as proof the runtime is correct. The manager now detects a Node version hint from the repo itself (`.nvmrc`, else `package.json` `engines.node`) and passes it to Nixpacks as `NIXPACKS_NODE_VERSION` (`apps/server/src/build/nixpacks.ts`, `detectNodeVersion`), so the build uses the version the repo actually declares instead of Nixpacks' own default. Repos with no version hint at all still get Nixpacks' default, unchanged. +**Avoid it:** don't trust a green build as proof the runtime is correct. The manager now detects a Node version hint from the repo itself (`.nvmrc`, else `package.json` `engines.node`) and passes it to Nixpacks as `NIXPACKS_NODE_VERSION` (`apps/server/src/build/nixpacks.ts`, `detectNodeVersion`), so the build uses the version the repo actually declares instead of Nixpacks' own default. A Node repo (has `package.json`) with no hint of its own gets `DEFAULT_NODE_VERSION` (currently `20`) instead of falling through to Nixpacks' own default — confirmed necessary against a real repo (`ZenvairoSocial-MCP`, no `.nvmrc`/`engines.node` at all): build green, container crashed with "crypto is not defined" because Nixpacks picked Node 18, which predates stable `globalThis.crypto`. ## Nixpacks needs a dependency manifest, and fails cleanly without one diff --git a/apps/server/src/build/nixpacks.test.ts b/apps/server/src/build/nixpacks.test.ts index 9201d81..e6efc7a 100644 --- a/apps/server/src/build/nixpacks.test.ts +++ b/apps/server/src/build/nixpacks.test.ts @@ -28,14 +28,20 @@ describe("detectNodeVersion", () => { expect(detectNodeVersion(dir)).toBe("20"); }); - it("returns undefined when there is no version hint", () => { + it("returns undefined for a non-Node repo (no package.json at all)", () => { const dir = mkTmpRepo(); expect(detectNodeVersion(dir)).toBeUndefined(); }); - it("returns undefined for malformed package.json instead of throwing", () => { + it("falls back to the default version for a Node repo with no version hint", () => { + const dir = mkTmpRepo(); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "x" })); + expect(detectNodeVersion(dir)).toBe("20"); + }); + + it("falls back to the default version for malformed package.json instead of throwing", () => { const dir = mkTmpRepo(); fs.writeFileSync(path.join(dir, "package.json"), "{not valid json"); - expect(detectNodeVersion(dir)).toBeUndefined(); + expect(detectNodeVersion(dir)).toBe("20"); }); }); diff --git a/apps/server/src/build/nixpacks.ts b/apps/server/src/build/nixpacks.ts index fb9636e..e7c4075 100644 --- a/apps/server/src/build/nixpacks.ts +++ b/apps/server/src/build/nixpacks.ts @@ -17,11 +17,17 @@ function parseMajorVersion(raw: string): string | undefined { return match ? match[1] : undefined; } +// Nixpacks' own current default (18) predates stable globalThis.crypto/fetch — used as the +// fallback for repos with a package.json but no version hint at all, confirmed in production +// against ZenvairoSocial-MCP: build succeeded, container crashed on "crypto is not defined". +const DEFAULT_NODE_VERSION = "20"; + /** * Nixpacks doesn't fail when a repo's declared runtime requirement isn't met — it silently * falls back to its own default version (seen in practice: Node 18 picked for a repo requiring * >=20, build green, container crashed at runtime on a missing global). Pinning - * NIXPACKS_NODE_VERSION from the repo's own .nvmrc/package.json closes that gap for Node repos. + * NIXPACKS_NODE_VERSION from the repo's own .nvmrc/package.json closes that gap for Node repos; + * repos with no hint of their own still get DEFAULT_NODE_VERSION rather than nixpacks' default. */ export function detectNodeVersion(repoDir: string): string | undefined { const nvmrcPath = path.join(repoDir, ".nvmrc"); @@ -31,20 +37,21 @@ export function detectNodeVersion(repoDir: string): string | undefined { } const pkgPath = path.join(repoDir, "package.json"); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } }; - const engineNode = pkg.engines?.node; - if (typeof engineNode === "string") { - const version = parseMajorVersion(engineNode); - if (version) return version; - } - } catch { - // malformed package.json — nothing to pin, let nixpacks use its default + if (!fs.existsSync(pkgPath)) return undefined; + + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } }; + const engineNode = pkg.engines?.node; + if (typeof engineNode === "string") { + const version = parseMajorVersion(engineNode); + if (version) return version; } + } catch { + // malformed package.json — fall through to the default below } - return undefined; + // It's a Node project (has a package.json) but declares no version of its own. + return DEFAULT_NODE_VERSION; } export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise { @@ -58,9 +65,7 @@ export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string const nodeVersion = detectNodeVersion(repoDir); if (nodeVersion) { env.NIXPACKS_NODE_VERSION = nodeVersion; - logStream.write( - `[remote-mcp-manager] pinning NIXPACKS_NODE_VERSION=${nodeVersion} (detected from .nvmrc/package.json engines.node)\n`, - ); + logStream.write(`[remote-mcp-manager] pinning NIXPACKS_NODE_VERSION=${nodeVersion}\n`); } const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag], { env });