fix: default unhinted Node repos to Node 20 instead of Nixpacks' own default
All checks were successful
CI / test (push) Successful in 40s

Nixpacks' current default (18) predates stable globalThis.crypto —
confirmed in production against ZenvairoSocial-MCP (no .nvmrc/engines.node
at all): build succeeded, container crashed immediately with "crypto is
not defined". A repo with its own version hint still wins; this only
changes the fallback for repos with none.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 04:48:25 +02:00
parent 8eaccfbc47
commit 9f88769922
3 changed files with 30 additions and 19 deletions

View File

@@ -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.). 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 ## Nixpacks needs a dependency manifest, and fails cleanly without one

View File

@@ -28,14 +28,20 @@ describe("detectNodeVersion", () => {
expect(detectNodeVersion(dir)).toBe("20"); 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(); const dir = mkTmpRepo();
expect(detectNodeVersion(dir)).toBeUndefined(); 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(); const dir = mkTmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), "{not valid json"); fs.writeFileSync(path.join(dir, "package.json"), "{not valid json");
expect(detectNodeVersion(dir)).toBeUndefined(); expect(detectNodeVersion(dir)).toBe("20");
}); });
}); });

View File

@@ -17,11 +17,17 @@ function parseMajorVersion(raw: string): string | undefined {
return match ? match[1] : 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 * 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 * 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 * >=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 { export function detectNodeVersion(repoDir: string): string | undefined {
const nvmrcPath = path.join(repoDir, ".nvmrc"); const nvmrcPath = path.join(repoDir, ".nvmrc");
@@ -31,7 +37,8 @@ export function detectNodeVersion(repoDir: string): string | undefined {
} }
const pkgPath = path.join(repoDir, "package.json"); const pkgPath = path.join(repoDir, "package.json");
if (fs.existsSync(pkgPath)) { if (!fs.existsSync(pkgPath)) return undefined;
try { try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } }; const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { engines?: { node?: string } };
const engineNode = pkg.engines?.node; const engineNode = pkg.engines?.node;
@@ -40,11 +47,11 @@ export function detectNodeVersion(repoDir: string): string | undefined {
if (version) return version; if (version) return version;
} }
} catch { } catch {
// malformed package.json — nothing to pin, let nixpacks use its default // 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<BuildResult> { export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string): Promise<BuildResult> {
@@ -58,9 +65,7 @@ export function runNixpacksBuild(slug: string, repoDir: string, imageTag: string
const nodeVersion = detectNodeVersion(repoDir); const nodeVersion = detectNodeVersion(repoDir);
if (nodeVersion) { if (nodeVersion) {
env.NIXPACKS_NODE_VERSION = nodeVersion; env.NIXPACKS_NODE_VERSION = nodeVersion;
logStream.write( logStream.write(`[remote-mcp-manager] pinning NIXPACKS_NODE_VERSION=${nodeVersion}\n`);
`[remote-mcp-manager] pinning NIXPACKS_NODE_VERSION=${nodeVersion} (detected from .nvmrc/package.json engines.node)\n`,
);
} }
const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag], { env }); const child = spawn("nixpacks", ["build", repoDir, "--name", imageTag], { env });