diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/src/loader.ts b/src/loader.ts index 237f5bab7..875956295 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -30,6 +30,46 @@ if (firstArg === '--help' || firstArg === '-h') { 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 { serializeBundledExtensionPaths } from './bundled-extension-paths.js' import { discoverExtensionEntryPaths } from './extension-discovery.js' diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index a32001cf3..d581c855c 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -78,8 +78,12 @@ function loadProvider(): void { // 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( - "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`, ); } diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index ef19def8d..90d8a7953 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -129,6 +129,59 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async (t) => { 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 // ═══════════════════════════════════════════════════════════════════════════