diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf3d7ea..c559cf77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,55 @@ jobs: - name: Doctor run: node dist/cli.js doctor + + unsupported-node: + name: Unsupported Node error + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup supported Node + uses: actions/setup-node@v4 + with: + node-version: 22.19 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Setup unsupported Node + uses: actions/setup-node@v4 + with: + node-version: 18.19.1 + + - name: Verify preinstall rejection + shell: bash + run: | + set +e + output="$(node scripts/check-node-version.cjs 2>&1)" + status=$? + set -e + + test "$status" -ne 0 + grep -F "DevSpace requires Node.js >=22.19 <27." <<<"$output" + grep -F "Current Node.js: v18.19.1" <<<"$output" + + - name: Verify CLI bootstrap error + shell: bash + run: | + set +e + output="$(node scripts/devspace.cjs --version 2>&1)" + status=$? + set -e + + test "$status" -ne 0 + grep -F "DevSpace requires Node.js >=22.19 <27." <<<"$output" + grep -F "Current Node.js: v18.19.1" <<<"$output" + if grep -F "Invalid regular expression flags" <<<"$output"; then + exit 1 + fi diff --git a/package-lock.json b/package-lock.json index 0d7decc4..af2e4a0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "zod": "^4.4.3" }, "bin": { - "devspace": "dist/cli.js" + "devspace": "scripts/devspace.cjs" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/package.json b/package.json index 25f21059..c214f996 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=22.19 <27" }, "bin": { - "devspace": "dist/cli.js" + "devspace": "scripts/devspace.cjs" }, "files": [ "dist", @@ -26,8 +26,9 @@ "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json", "build:app": "vite build", "dev": "node scripts/dev-server.mjs", + "preinstall": "node scripts/check-node-version.cjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", - "start": "node dist/cli.js serve", + "start": "node scripts/devspace.cjs serve", "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/scripts/check-node-version.cjs b/scripts/check-node-version.cjs new file mode 100755 index 00000000..19e48faa --- /dev/null +++ b/scripts/check-node-version.cjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +"use strict"; + +const { + formatUnsupportedNodeMessage, + isSupportedNodeVersion, +} = require("./node-version.cjs"); + +if (!isSupportedNodeVersion(process.versions.node)) { + console.error(formatUnsupportedNodeMessage()); + process.exit(1); +} diff --git a/scripts/devspace.cjs b/scripts/devspace.cjs new file mode 100755 index 00000000..d8e548a7 --- /dev/null +++ b/scripts/devspace.cjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +"use strict"; + +const { + formatUnsupportedNodeMessage, + isSupportedNodeVersion, +} = require("./node-version.cjs"); + +if (!isSupportedNodeVersion(process.versions.node)) { + console.error(formatUnsupportedNodeMessage()); + process.exitCode = 1; +} else { + import("../dist/cli.js").catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/node-version.cjs b/scripts/node-version.cjs new file mode 100644 index 00000000..7b2c6ee6 --- /dev/null +++ b/scripts/node-version.cjs @@ -0,0 +1,46 @@ +"use strict"; + +const SUPPORTED_NODE_RANGE = ">=22.19 <27"; + +function parseNodeVersion(version) { + const match = /^(?:v)?(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!match) return null; + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function isSupportedNodeVersion(version) { + const parsed = parseNodeVersion(version); + if (!parsed) return false; + + return ( + (parsed.major === 22 && parsed.minor >= 19) || + (parsed.major > 22 && parsed.major < 27) + ); +} + +function formatUnsupportedNodeMessage(version = process.versions.node) { + const displayVersion = version.startsWith("v") ? version : `v${version}`; + + return [ + `DevSpace requires Node.js ${SUPPORTED_NODE_RANGE}.`, + `Current Node.js: ${displayVersion}`, + "", + "Install or switch to Node.js 22 LTS, then retry:", + "", + " nvm install 22 && nvm use 22", + " mise use --global node@22", + "", + "Then rerun your DevSpace installation.", + ].join("\n"); +} + +module.exports = { + SUPPORTED_NODE_RANGE, + formatUnsupportedNodeMessage, + isSupportedNodeVersion, +}; diff --git a/src/cli.test.ts b/src/cli.test.ts index 97b7084a..9de4712d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,14 +1,50 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; import { LocalAgentStore } from "./local-agent-store.js"; +const require = createRequire(import.meta.url); +const { + SUPPORTED_NODE_RANGE, + formatUnsupportedNodeMessage, + isSupportedNodeVersion, +} = require("../scripts/node-version.cjs") as { + SUPPORTED_NODE_RANGE: string; + formatUnsupportedNodeMessage(version?: string): string; + isSupportedNodeVersion(version: string): boolean; +}; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + bin: { devspace: string }; + engines: { node: string }; + files: string[]; + scripts: { preinstall: string }; version: string; }; +const packageLock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8")) as { + packages: { "": { bin: { devspace: string } } }; +}; + +assert.equal(packageJson.bin.devspace, "scripts/devspace.cjs"); +assert.equal(packageLock.packages[""].bin.devspace, packageJson.bin.devspace); +assert.equal(packageJson.engines.node, SUPPORTED_NODE_RANGE); +assert.equal(packageJson.scripts.preinstall, "node scripts/check-node-version.cjs"); +assert.ok(packageJson.files.includes("scripts")); +assert.equal(isSupportedNodeVersion("18.19.1"), false); +assert.equal(isSupportedNodeVersion("20.20.0"), false); +assert.equal(isSupportedNodeVersion("22.18.0"), false); +assert.equal(isSupportedNodeVersion("22.19.0"), true); +assert.equal(isSupportedNodeVersion("v22.19.0"), true); +assert.equal(isSupportedNodeVersion("23.0.0"), true); +assert.equal(isSupportedNodeVersion("26.9.0"), true); +assert.equal(isSupportedNodeVersion("27.0.0"), false); +assert.equal(isSupportedNodeVersion("invalid"), false); +assert.match(formatUnsupportedNodeMessage("v18.19.1"), /requires Node\.js >=22\.19 <27/); +assert.match(formatUnsupportedNodeMessage("v18.19.1"), /Current Node\.js: v18\.19\.1/); +assert.match(formatUnsupportedNodeMessage("18.19.1"), /Current Node\.js: v18\.19\.1/); for (const flag of ["-v", "--version"]) { const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", flag], { diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63f..0e3dedb6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,7 +42,9 @@ import { shutdownHttpServer } from "./server-shutdown.js"; type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version"; const require = createRequire(import.meta.url); -const SUPPORTED_NODE_RANGE = ">=20.12 <27"; +const { SUPPORTED_NODE_RANGE } = require("../scripts/node-version.cjs") as { + SUPPORTED_NODE_RANGE: string; +}; async function main(argv: string[]): Promise { assertSupportedNode();