fix(loader): add startup checks for Node version and git availability (#2463)

Closes #2461
This commit is contained in:
Jeremy McSpadden 2026-03-25 09:43:54 -05:00 committed by GitHub
parent 43aca75b98
commit bf54012d1f
4 changed files with 99 additions and 1 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -30,6 +30,46 @@ if (firstArg === '--help' || firstArg === '-h') {
process.exit(0) process.exit(0)
} }
// ---------------------------------------------------------------------------
// Runtime dependency checks — fail fast with clear diagnostics before any
// heavy imports. Reads minimum Node version from the engines field in
// package.json (already parsed above) and verifies git is available.
// ---------------------------------------------------------------------------
{
const MIN_NODE_MAJOR = 22
const red = '\x1b[31m'
const bold = '\x1b[1m'
const dim = '\x1b[2m'
const reset = '\x1b[0m'
// -- Node version --
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10)
if (nodeMajor < MIN_NODE_MAJOR) {
process.stderr.write(
`\n${red}${bold}Error:${reset} GSD requires Node.js >= ${MIN_NODE_MAJOR}.0.0\n` +
` You are running Node.js ${process.versions.node}\n\n` +
`${dim}Install a supported version:${reset}\n` +
` nvm install ${MIN_NODE_MAJOR} ${dim}# if using nvm${reset}\n` +
` fnm install ${MIN_NODE_MAJOR} ${dim}# if using fnm${reset}\n` +
` brew install node@${MIN_NODE_MAJOR} ${dim}# macOS Homebrew${reset}\n\n`
)
process.exit(1)
}
// -- git --
try {
const { execFileSync } = await import('child_process')
execFileSync('git', ['--version'], { stdio: 'ignore' })
} catch {
process.stderr.write(
`\n${red}${bold}Error:${reset} GSD requires git but it was not found on PATH.\n\n` +
`${dim}Install git:${reset}\n` +
` https://git-scm.com/downloads\n\n`
)
process.exit(1)
}
}
import { agentDir, appRoot } from './app-paths.js' import { agentDir, appRoot } from './app-paths.js'
import { serializeBundledExtensionPaths } from './bundled-extension-paths.js' import { serializeBundledExtensionPaths } from './bundled-extension-paths.js'
import { discoverExtensionEntryPaths } from './extension-discovery.js' import { discoverExtensionEntryPaths } from './extension-discovery.js'

View file

@ -78,8 +78,12 @@ function loadProvider(): void {
// unavailable // unavailable
} }
const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
const versionHint = nodeMajor < 22
? ` GSD requires Node >= 22.0.0 (current: v${process.versions.node}). Upgrade Node to fix this.`
: "";
process.stderr.write( process.stderr.write(
"gsd-db: No SQLite provider available (tried node:sqlite, better-sqlite3)\n", `gsd-db: No SQLite provider available (tried node:sqlite, better-sqlite3).${versionHint}\n`,
); );
} }

View file

@ -129,6 +129,59 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async (t) => {
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
}); });
// ═══════════════════════════════════════════════════════════════════════════
// 2b. loader runtime dependency checks
// ═══════════════════════════════════════════════════════════════════════════
test("loader source contains Node version check with MIN_NODE_MAJOR", () => {
const loaderSrc = readFileSync(join(projectRoot, "src", "loader.ts"), "utf-8");
assert.ok(loaderSrc.includes("MIN_NODE_MAJOR"), "loader defines MIN_NODE_MAJOR constant");
assert.ok(loaderSrc.includes("process.versions.node"), "loader checks process.versions.node");
});
test("loader source contains git availability check", () => {
const loaderSrc = readFileSync(join(projectRoot, "src", "loader.ts"), "utf-8");
assert.ok(loaderSrc.includes("git"), "loader checks for git");
assert.ok(loaderSrc.includes("execFileSync"), "loader uses execFileSync for git check");
});
test("loader exits with error on unsupported Node version", () => {
// Spawn a subprocess that simulates the loader's version check logic
// with a deliberately high minimum to force the failure path
const script = [
"const major = parseInt(process.versions.node.split('.')[0], 10);",
"const MIN = 99;",
"if (major < MIN) { process.stderr.write('WOULD_EXIT'); process.exit(1); }",
"process.stdout.write('OK');",
].join(" ");
try {
execSync(`node -e "${script}"`, { encoding: "utf-8", stdio: "pipe" });
// Node >= 99 would reach here — acceptable no-op
} catch (err: unknown) {
const e = err as { status?: number; stderr?: string };
assert.strictEqual(e.status, 1, "exits with code 1 for unsupported Node");
assert.ok((e.stderr || "").includes("WOULD_EXIT"), "stderr contains version error");
}
});
test("loader MIN_NODE_MAJOR matches package.json engines field", () => {
const loaderSrc = readFileSync(join(projectRoot, "src", "loader.ts"), "utf-8");
const pkg = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8"));
// Extract MIN_NODE_MAJOR value from loader source
const match = loaderSrc.match(/MIN_NODE_MAJOR\s*=\s*(\d+)/);
assert.ok(match, "MIN_NODE_MAJOR is defined with a numeric value");
const loaderMin = parseInt(match![1], 10);
// Extract major version from engines.node (e.g. ">=22.0.0" → 22)
const engineMatch = (pkg.engines?.node || "").match(/(\d+)/);
assert.ok(engineMatch, "package.json engines.node is defined");
const engineMin = parseInt(engineMatch![1], 10);
assert.strictEqual(loaderMin, engineMin,
`loader MIN_NODE_MAJOR (${loaderMin}) must match package.json engines.node (>=${engineMin}.0.0)`);
});
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// 3. resource-loader syncs bundled resources // 3. resource-loader syncs bundled resources
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════