From 88c569596b60afdd6a2198aa0c504bc389a639eb Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 11 Nov 2026 18:28:10 -0800 Subject: [PATCH 1/3] fix(cli): stop with a clear message on a Node older than engines.node Closes #679 --- .changeset/node-version-message.md | 5 + .../changes/node-version-guard/proposal.md | 26 +++++ .../specs/cli-project-launcher/spec.md | 16 +++ .../node-version-guard/specs/cli/spec.md | 16 +++ openspec/changes/node-version-guard/tasks.md | 19 ++++ packages/cli/assets/launcher/launcher.mjs | 49 +++++++++- packages/cli/package.json | 2 +- packages/cli/scripts/build-target.ts | 2 +- packages/cli/src/agent/ci.md | 11 ++- packages/cli/src/bin.ts | 25 +++++ packages/cli/src/globals.d.ts | 2 + packages/cli/src/node-version.ts | 53 ++++++++++ packages/cli/test/launcher.test.ts | 91 ++++++++++++++++- packages/cli/test/node-version.test.ts | 97 +++++++++++++++++++ packages/cli/vite.config.ts | 17 +++- 15 files changed, 412 insertions(+), 19 deletions(-) create mode 100644 .changeset/node-version-message.md create mode 100644 openspec/changes/node-version-guard/proposal.md create mode 100644 openspec/changes/node-version-guard/specs/cli-project-launcher/spec.md create mode 100644 openspec/changes/node-version-guard/specs/cli/spec.md create mode 100644 openspec/changes/node-version-guard/tasks.md create mode 100644 packages/cli/src/bin.ts create mode 100644 packages/cli/src/node-version.ts create mode 100644 packages/cli/test/node-version.test.ts diff --git a/.changeset/node-version-message.md b/.changeset/node-version-message.md new file mode 100644 index 00000000..3d8bacd0 --- /dev/null +++ b/.changeset/node-version-message.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +On a Node older than the CLI needs (`>=22.22.0`), Taskless now stops with `Taskless needs Node >=22.22.0, you have 20.19.0. Install a newer Node and run it again.` and exits 1, instead of crashing with an unrelated `SyntaxError` about `globSync`. This holds for `npx @taskless/cli` and `pnpm dlx`, whose `bin` is now a small shim that checks the version before loading the CLI, and for the project launcher (`.taskless/bin/cli.mjs`), which checks before installing a version and before running one. Launchers pick this up when `init` rewrites them. The `ci` recipe now says the job needs Node 22.22 or later. diff --git a/openspec/changes/node-version-guard/proposal.md b/openspec/changes/node-version-guard/proposal.md new file mode 100644 index 00000000..4066989b --- /dev/null +++ b/openspec/changes/node-version-guard/proposal.md @@ -0,0 +1,26 @@ +## Why + +The CLI requires Node `>=22.22.0` (`engines.node`), but on an older Node it fails with an error that never mentions the version. Two runs in the CLI agent survey hit it: `init` on Node 20.19 crashed with a `SyntaxError` because `node:fs` has no `globSync`, after only npm's `EBADENGINE` warning. The crash is a link error in the CLI's static imports, so a check inside the CLI never runs. + +## What Changes + +- The package `bin` becomes a small shim (`dist/bin.js`) that compares the running Node against `engines.node`, then dynamically imports the CLI (`dist/index.js`). On an older Node it prints `taskless: Taskless needs Node >=22.22.0, you have 20.19.0. Install a newer Node and run it again.` and exits 1, loading nothing else. This covers `npx @taskless/cli` and `pnpm dlx`. +- The project launcher checks the same thing before it installs (from `engines.node` in its lockfile) and before it starts an installed version (from that version's `package.json`), with the same message and exit 1. +- The `ci` recipe says the job needs Node 22.22 or later. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli`: a new requirement that the `bin` checks the Node version before loading the CLI. +- `cli-project-launcher`: a new requirement that the launcher refuses a Node older than the package's `engines.node`. + +## Impact + +- `packages/cli/src/bin.ts`, `packages/cli/src/node-version.ts`, `packages/cli/vite.config.ts` (a second program entry and the `__TASKLESS_NODE_ENGINE__` define), `packages/cli/package.json` (`bin`), `packages/cli/assets/launcher/launcher.mjs`, and the `ci` recipe. +- `dist/index.js` stays the CLI and keeps its shebang, so `pnpm cli`, CI and the `self` build run it as before. +- Delivery shape: **single PR**. Two small guards, their tests, and a recipe line. diff --git a/openspec/changes/node-version-guard/specs/cli-project-launcher/spec.md b/openspec/changes/node-version-guard/specs/cli-project-launcher/spec.md new file mode 100644 index 00000000..3bf3dcda --- /dev/null +++ b/openspec/changes/node-version-guard/specs/cli-project-launcher/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: A launcher refuses a Node older than its package needs + +Before it installs a version, a launcher SHALL compare the running Node against the `engines.node` its lockfile records for the package, and before it runs an installed version, against the `engines.node` in that version's `package.json`. When either is a `>=` lower bound the running Node does not meet, the launcher SHALL write `taskless: Taskless needs Node , you have . Install a newer Node and run it again.` to standard error and exit with code 1, without installing and without starting the CLI. A range other than a `>=` lower bound, or none, SHALL NOT stop it. + +#### Scenario: First run on Node 20 + +- **WHEN** a launcher whose lockfile records `engines.node` `>=22.22.0` runs on Node 20.19.0 with nothing installed +- **THEN** it SHALL print the message naming `>=22.22.0` and `20.19.0` and exit with code 1 +- **AND** it SHALL NOT run npm + +#### Scenario: An installed version on an older Node + +- **WHEN** a version installed under a newer Node is run by the launcher on a Node older than its `engines.node` +- **THEN** the launcher SHALL print the message and exit with code 1 without starting the CLI diff --git a/openspec/changes/node-version-guard/specs/cli/spec.md b/openspec/changes/node-version-guard/specs/cli/spec.md new file mode 100644 index 00000000..5ed454bd --- /dev/null +++ b/openspec/changes/node-version-guard/specs/cli/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: The bin checks the Node version before loading the CLI + +The package `bin` SHALL be a separate built file from the CLI entry. It SHALL compare the running Node version against the package's `engines.node` before it loads any of the CLI, and SHALL load the CLI with a dynamic `import()` only when the version satisfies it, so that an older Node never links the CLI's module graph. When the version does not satisfy it, the `bin` SHALL write one line to standard error naming the required range and the running version, write nothing to standard output, and exit with code 1. A range other than a `>=` lower bound SHALL NOT stop the CLI. + +#### Scenario: npx on Node 20 + +- **WHEN** a user runs `npx @taskless/cli init` on Node 20.19.0 and `engines.node` is `>=22.22.0` +- **THEN** standard error SHALL be `taskless: Taskless needs Node >=22.22.0, you have 20.19.0. Install a newer Node and run it again.` +- **AND** the process SHALL exit with code 1 without loading the CLI + +#### Scenario: A satisfying Node runs the CLI + +- **WHEN** the running Node satisfies `engines.node` +- **THEN** the `bin` SHALL run the CLI with every argument unchanged diff --git a/openspec/changes/node-version-guard/tasks.md b/openspec/changes/node-version-guard/tasks.md new file mode 100644 index 00000000..b8c99075 --- /dev/null +++ b/openspec/changes/node-version-guard/tasks.md @@ -0,0 +1,19 @@ +## 1. Package bin + +- [x] 1.1 Add `src/node-version.ts`, the `>=` lower-bound comparison and its message +- [x] 1.2 Add `src/bin.ts`, which checks `__TASKLESS_NODE_ENGINE__` and then dynamically imports the CLI +- [x] 1.3 Build it as a second program entry with a shebang, define `__TASKLESS_NODE_ENGINE__` from `engines.node`, and point `bin` at `dist/bin.js` + +## 2. Project launcher + +- [x] 2.1 Check the lockfile's `engines.node` before installing, and the installed `package.json`'s before running + +## 3. Docs + +- [x] 3.1 Say in the `ci` recipe that the job needs Node 22.22 or later, and bump its topic version + +## 4. Tests + +- [x] 4.1 `nodeVersionProblem`: ranges it admits, ranges it refuses, ranges it does not judge +- [x] 4.2 The built `bin` on a spoofed Node 20.19.0 prints the message, exits 1, and loads nothing else +- [x] 4.3 The launcher on a spoofed older Node refuses before installing and refuses an already installed version diff --git a/packages/cli/assets/launcher/launcher.mjs b/packages/cli/assets/launcher/launcher.mjs index 1e934940..3a8e476e 100644 --- a/packages/cli/assets/launcher/launcher.mjs +++ b/packages/cli/assets/launcher/launcher.mjs @@ -79,6 +79,48 @@ function versionFor(install, name) { : undefined; } +// `engines.node` as a lower bound, `>=MAJOR[.MINOR[.PATCH]]`. +const MINIMUM_NODE = /^\s*>=\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/; + +/** + * Stop with a plain message when this Node is older than `engines` allows. + * The CLI cannot say so itself: on an older Node it fails while loading, + * with an error that does not mention the version. A range other than a + * lower bound is left for npm and the CLI to judge. + */ +function requireNode(engines) { + const required = engines?.node; + if (typeof required !== "string") return; + const match = MINIMUM_NODE.exec(required); + const running = /^(\d+)\.(\d+)\.(\d+)/.exec(process.versions.node); + if (match === null || running === null) return; + for (let index = 1; index <= 3; index++) { + const need = Number(match[index] ?? 0); + const have = Number(running[index]); + if (have > need) return; + if (have < need) { + fail( + `Taskless needs Node ${required.trim()}, you have ` + + `${process.versions.node}. Install a newer Node and run it again.` + ); + } + } +} + +/** The installed package's `engines` under `directory`, if it can be read. */ +function installedEngines(directory) { + try { + return JSON.parse( + readFileSync( + join(directory, "node_modules", PACKAGE, "package.json"), + "utf8" + ) + ).engines; + } catch { + return undefined; + } +} + /** The installed package's bin script under `directory`, if it is there. */ function entryPoint(directory) { const packageDirectory = join(directory, "node_modules", PACKAGE); @@ -161,17 +203,19 @@ function installVersion(version, target) { } let locked; try { - locked = JSON.parse(lock)?.packages?.[`node_modules/${PACKAGE}`]?.version; + locked = JSON.parse(lock)?.packages?.[`node_modules/${PACKAGE}`]; } catch { locked = undefined; } - if (locked !== version) { + if (locked?.version !== version) { fail( `${lockfile} does not pin ${PACKAGE}@${version}, the version ` + `${manifest} records. Run \`${INSTALLER}\` from the directory that ` + `contains .taskless/ to rewrite both.` ); } + // Before installing: a version this Node cannot run is not worth fetching. + requireNode(locked.engines); mkdirSync(cache, { recursive: true }); const ignore = join(cache, ".gitignore"); @@ -300,6 +344,7 @@ if (entry === undefined) { entry = installVersion(version, target); prune(install); } +requireNode(installedEngines(target)); const result = spawnSync(process.execPath, [entry, ...process.argv.slice(2)], { stdio: "inherit", diff --git a/packages/cli/package.json b/packages/cli/package.json index 878ebf2e..95d68631 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -82,7 +82,7 @@ "zod": "^4.3.6" }, "bin": { - "taskless": "./dist/index.js" + "taskless": "./dist/bin.js" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.51.1", diff --git a/packages/cli/scripts/build-target.ts b/packages/cli/scripts/build-target.ts index db979c47..2ae2210e 100644 --- a/packages/cli/scripts/build-target.ts +++ b/packages/cli/scripts/build-target.ts @@ -57,7 +57,7 @@ const REMOVED_TARGET = "dev"; * `nightly` IS THE EXCEPTION: it emits to `dist`, the same directory as prod, * and a local `build:nightly` therefore OVERWRITES a prod build in place. That * is not an oversight — the nightly tarball is packed from `packages/cli` with - * `files: ["dist"]` and `bin: ./dist/index.js`, so `dist` is the only directory + * `files: ["dist"]` and `bin: ./dist/bin.js`, so `dist` is the only directory * npm would carry. Run `pnpm --filter @taskless/cli build` afterwards to get an * ordinary `dist` back; `pnpm cli` runs whatever is there. */ diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index 4ccbddf2..4c2d3602 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,4 +1,4 @@ -# Topic: ci (CLI v%(CLI_VERSION)s / topic v10) +# Topic: ci (CLI v%(CLI_VERSION)s / topic v11) %(ONE_COMMAND_PER_CALL)s @@ -193,10 +193,11 @@ Simplifications: ### 6. Translate to other CIs The six universal steps: -1. Make Node available to the job (v22 or later; match the user's - existing CI version when it is newer): the CI system's Node setup - step, or a Node container image such as `node:22`. The Taskless job - needs nothing else from the project's own toolchain, and no +1. Make Node available to the job (22.22 or later, which `22` resolves + to; match the user's existing CI version when it is newer, and raise + one that is older, since Taskless stops on an older Node): the CI + system's Node setup step, or a Node container image such as + `node:22`. The Taskless job needs nothing else from the project's own toolchain, and no `package.json`, so in a Python, Go, or other non-Node repository it still runs as its own job with its own image. 2. Fetch with full depth (or enough to reach the target branch). diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts new file mode 100644 index 00000000..758989c4 --- /dev/null +++ b/packages/cli/src/bin.ts @@ -0,0 +1,25 @@ +import process from "node:process"; + +import { nodeVersionProblem } from "./node-version"; + +/** + * The package's `bin`: check the running Node against `engines.node`, then + * load the CLI. + * + * The CLI is loaded with a dynamic `import()` on purpose. A static import + * would link the CLI's whole module graph before this file's first line runs, + * and on an older Node that link is what fails, with an error about a missing + * export rather than about the Node version. Nothing this file imports + * statically may need a newer Node than the oldest one it is meant to turn + * away. + */ +const problem = nodeVersionProblem( + __TASKLESS_NODE_ENGINE__, + process.versions.node +); +if (problem === undefined) { + await import("./index"); +} else { + process.stderr.write(`taskless: ${problem}\n`); + process.exitCode = 1; +} diff --git a/packages/cli/src/globals.d.ts b/packages/cli/src/globals.d.ts index 4e0231a6..012b9a88 100644 --- a/packages/cli/src/globals.d.ts +++ b/packages/cli/src/globals.d.ts @@ -6,3 +6,5 @@ declare const __TASKLESS_CLI_NOTICE__: string; declare const __TASKLESS_BUILD_TARGET__: "prod" | "self" | "nightly"; /** The npm lockfile this build's launcher installs from; `null` for `self`. */ declare const __TASKLESS_INSTALL_LOCK__: string | null; +/** `engines.node` from `package.json`, which the `bin` shim checks; `null` if absent. */ +declare const __TASKLESS_NODE_ENGINE__: string | null; diff --git a/packages/cli/src/node-version.ts b/packages/cli/src/node-version.ts new file mode 100644 index 00000000..29c49c4a --- /dev/null +++ b/packages/cli/src/node-version.ts @@ -0,0 +1,53 @@ +/** + * The Node version check the package's `bin` runs before it loads the CLI. + * + * It lives apart from the CLI because the CLI cannot run it: on an older Node + * the CLI's module graph fails to link (`globSync` from `node:fs`, for one) + * before any of its code executes, so a check inside it never gets the chance + * to speak. This module and the shim that calls it (`src/bin.ts`) import + * nothing that an old Node lacks. + * + * The project launcher (`assets/launcher/launcher.mjs`) carries its own copy + * of the same comparison and prints the same message. It is a standalone + * script written into a project, so it cannot import this one. + */ + +/** `>=MAJOR[.MINOR[.PATCH]]`, the only `engines.node` shape this package uses. */ +const MINIMUM = /^\s*>=\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/; + +/** The numeric `[major, minor, patch]` of a version, ignoring any prerelease. */ +function parts(version: string): number[] | undefined { + const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version.trim()); + if (match === null) return undefined; + return [match[1], match[2], match[3]].map((part) => Number(part ?? 0)); +} + +/** + * The message to print when `running` does not satisfy `required`, or + * undefined when it does. + * + * Only a `>=` lower bound is understood. Any other range, or none, passes: + * a check that cannot read the requirement must not be the thing that stops + * the CLI from running. + */ +export function nodeVersionProblem( + required: string | null | undefined, + running: string +): string | undefined { + if (typeof required !== "string") return undefined; + const match = MINIMUM.exec(required); + const have = parts(running); + if (match === null || have === undefined) return undefined; + const need = [match[1], match[2], match[3]].map((part) => Number(part ?? 0)); + for (const [index, wanted] of need.entries()) { + const actual = have[index] ?? 0; + if (actual > wanted) return undefined; + if (actual < wanted) { + return ( + `Taskless needs Node ${required.trim()}, you have ${running}. ` + + `Install a newer Node and run it again.` + ); + } + } + return undefined; +} diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts index a280038c..f8da0ed5 100644 --- a/packages/cli/test/launcher.test.ts +++ b/packages/cli/test/launcher.test.ts @@ -69,7 +69,8 @@ if (lock.packages["node_modules/" + name]?.version !== version) { const install = (root) => { const directory = path.join(root, "node_modules", name); fs.mkdirSync(directory, { recursive: true }); - fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify({ name, version, bin: { taskless: "./cli.cjs" } })); + const engines = process.env.FAKE_ENGINES ? { engines: { node: process.env.FAKE_ENGINES } } : {}; + fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify({ name, version, bin: { taskless: "./cli.cjs" }, ...engines })); fs.writeFileSync(path.join(directory, "cli.cjs"), process.env.FAKE_CLI_SOURCE); }; if (process.env.FAKE_NPM_RACE) install(process.env.FAKE_NPM_RACE); @@ -95,12 +96,19 @@ afterEach(async () => { }); /** A lockfile pinning `packageName` at `version`, as `init` writes it. */ -function lockfileFor(packageName: string, version: string): string { +function lockfileFor( + packageName: string, + version: string, + nodeEngine?: string +): string { return JSON.stringify({ lockfileVersion: 3, packages: { "": { dependencies: { [packageName]: version } }, - [`node_modules/${packageName}`]: { version }, + [`node_modules/${packageName}`]: { + version, + ...(nodeEngine === undefined ? {} : { engines: { node: nodeEngine } }), + }, }, }); } @@ -108,7 +116,8 @@ function lockfileFor(packageName: string, version: string): string { async function install( packageName: LauncherPackage, manifest: unknown, - lockedVersion?: string + lockedVersion?: string, + nodeEngine?: string ): Promise { const bin = join(root, ".taskless", "bin"); await mkdir(bin, { recursive: true }); @@ -121,7 +130,7 @@ async function install( if (lockedVersion !== undefined) { await writeFile( join(root, lockfilePath(packageName)), - lockfileFor(packageName, lockedVersion) + lockfileFor(packageName, lockedVersion, nodeEngine) ); } const path = join(bin, launcherFileName(packageName)); @@ -209,6 +218,31 @@ const npm = spawnSync(process.execPath, [process.env.FAKE_NPM_PATH, "ci"], { std process.exit(npm.status ?? 1); `; +/** A preload that makes the launcher see `version` as the running Node. */ +async function asNode(version: string): Promise { + const preload = join(root, `as-node-${version}.mjs`); + await writeFile( + preload, + `Object.defineProperty(process.versions, "node", { value: ${JSON.stringify(version)} });\n` + ); + return pathToFileURL(preload).href; +} + +/** {@link launch}, with a preload from {@link asNode}. */ +function launchAs(preload: string, path: string, args: string[]) { + return spawnSync(process.execPath, ["--import", preload, path, ...args], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + FAKE_NPM_LOG: npmLog, + FAKE_CLI_SOURCE: FAKE_CLI, + FAKE_ENGINES: ">=22.22.0", + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + }, + }); +} + describe.skipIf(process.platform === "win32")("project launcher", () => { it("installs the recorded version once, then runs it with every argument", async () => { const path = await install("@taskless/cli", RELEASE, "0.13.0"); @@ -525,6 +559,53 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { } ); + describe("on a Node older than the package needs", () => { + it("names both versions and installs nothing", async () => { + const path = await install( + "@taskless/cli", + RELEASE, + "0.13.0", + ">=22.22.0" + ); + const result = launchAs(await asNode("20.19.0"), path, ["init"]); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "taskless: Taskless needs Node >=22.22.0, you have 20.19.0. " + + "Install a newer Node and run it again.\n" + ); + expect(await npmCalls()).toEqual([]); + }); + + it("refuses a version it installed before, without running it", async () => { + // Installed under a newer Node; the same project is then run with an + // older one, and the lockfile says nothing about engines. + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + expect( + launch(path, [], { env: { FAKE_ENGINES: ">=22.22.0" } }).status + ).toBe(0); + const result = launchAs(await asNode("22.21.9"), path, ["check"]); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "Taskless needs Node >=22.22.0, you have 22.21.9." + ); + expect(await npmCommands()).toEqual(["ci"]); + }); + + it("runs on a Node the range admits", async () => { + const path = await install( + "@taskless/cli", + RELEASE, + "0.13.0", + ">=22.22.0" + ); + const result = launchAs(await asNode("22.22.0"), path, ["check"]); + expect(result.status).toBe(0); + expect(reported(result.stdout).argv).toEqual(["check"]); + }); + }); + it("says npm is needed when it is not on PATH", async () => { const path = await install("@taskless/cli", RELEASE, "0.13.0"); const empty = join(root, "empty-bin"); diff --git a/packages/cli/test/node-version.test.ts b/packages/cli/test/node-version.test.ts new file mode 100644 index 00000000..faab90d2 --- /dev/null +++ b/packages/cli/test/node-version.test.ts @@ -0,0 +1,97 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { nodeVersionProblem } from "../src/node-version"; +import { builtDirectory, builtCliVersion } from "./support/built-cli"; + +/** The package's own `engines.node`, which the built bin was given. */ +const REQUIRED = ( + JSON.parse( + readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8") + ) as { engines: { node: string } } +).engines.node; + +describe("nodeVersionProblem", () => { + it.each([ + [">=22.22.0", "22.22.0"], + [">=22.22.0", "22.22.1"], + [">=22.22.0", "23.0.0"], + [">=22.22.0", "24.14.0-pre"], + [">=22", "22.0.0"], + [">= 22.22", "22.22.0"], + ])("lets %s run on %s", (required, running) => { + expect(nodeVersionProblem(required, running)).toBeUndefined(); + }); + + it.each([ + [">=22.22.0", "20.19.0"], + [">=22.22.0", "22.21.9"], + [">=22.22.0", "22.9.0"], + [">=22.22.1", "22.22.0"], + ])("stops %s on %s, naming both", (required, running) => { + expect(nodeVersionProblem(required, running)).toBe( + `Taskless needs Node ${required}, you have ${running}. ` + + `Install a newer Node and run it again.` + ); + }); + + it.each([[null], [undefined], ["^22.22.0"], [">=22 <26"], ["22.x || 24.x"]])( + "does not judge a range it cannot read: %s", + (required) => { + expect(nodeVersionProblem(required, "8.0.0")).toBeUndefined(); + } + ); +}); + +describe("the package bin", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "taskless-bin-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + /** Run the built `bin`, seeing `version` as the running Node. */ + async function runAs(version: string, args: string[]) { + const preload = join(root, "as-node.mjs"); + await writeFile( + preload, + `Object.defineProperty(process.versions, "node", { value: ${JSON.stringify(version)} });\n` + ); + return spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preload).href, + join(builtDirectory(), "bin.js"), + ...args, + ], + { cwd: root, encoding: "utf8" } + ); + } + + it("names the required and running Node, and loads nothing else", async () => { + const result = await runAs("20.19.0", ["init"]); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + `taskless: Taskless needs Node ${REQUIRED}, you have 20.19.0. ` + + "Install a newer Node and run it again.\n" + ); + }); + + it("starts the CLI on a Node the range admits", async () => { + const result = await runAs(process.versions.node, ["--version"]); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(builtCliVersion()); + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 6ea0c8f4..73010fa8 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -142,11 +142,16 @@ function assertSkillVersions(): Plugin { }; } -// `index` is the executable CLI; everything else is a library module consumers -// import as `@taskless/cli/`. Only the first is a program, so only it -// gets a shebang and the executable bit — a `#!` line on a library entry would -// be a syntax error to anything importing it as a module. +// `index` is the executable CLI, and `bin` is the package's `bin`: a shim that +// checks the running Node against `engines.node` and only then imports +// `index` (see src/bin.ts). Everything else is a library module consumers +// import as `@taskless/cli/`. Only the first two are programs, so only +// they get a shebang and the executable bit — a `#!` line on a library entry +// would be a syntax error to anything importing it as a module. `index` keeps +// both because `pnpm cli` and CI run it directly. const BIN_ENTRY = "index"; +const NODE_GUARD_ENTRY = "bin"; +const PROGRAM_ENTRIES = new Set([BIN_ENTRY, NODE_GUARD_ENTRY]); const PROMPTS_ENTRY = "prompts"; const LAYOUT_ENTRY = "layout"; const NODE_RUNTIMES_ENTRY = "node/runtimes"; @@ -155,6 +160,7 @@ const SCHEMAS_ENTRY = "schemas"; /** Every entry this build emits, and the module each one is rooted at. */ const ENTRY_SOURCES: Record = { [BIN_ENTRY]: resolve(import.meta.dirname, "src/index.ts"), + [NODE_GUARD_ENTRY]: resolve(import.meta.dirname, "src/bin.ts"), [PROMPTS_ENTRY]: resolve(import.meta.dirname, "src/prompts/index.ts"), [LAYOUT_ENTRY]: resolve(import.meta.dirname, "src/layout/index.ts"), [SCHEMAS_ENTRY]: resolve(import.meta.dirname, "src/schemas/index.ts"), @@ -323,7 +329,7 @@ function shebang(): Plugin { const isBinEntry = ( chunk: Rollup.OutputAsset | Rollup.OutputChunk ): chunk is Rollup.OutputChunk => - chunk.type === "chunk" && chunk.isEntry && chunk.name === BIN_ENTRY; + chunk.type === "chunk" && chunk.isEntry && PROGRAM_ENTRIES.has(chunk.name); return { name: "shebang", @@ -502,6 +508,7 @@ export default defineConfig({ __TASKLESS_CLI__: JSON.stringify(cliInvocation), __TASKLESS_CLI_NOTICE__: JSON.stringify(cliNotice), __TASKLESS_BUILD_TARGET__: JSON.stringify(buildTarget), + __TASKLESS_NODE_ENGINE__: JSON.stringify(pkg.engines?.node ?? null), __TASKLESS_INSTALL_LOCK__: JSON.stringify( installLockfile(buildTarget, cliVersion) ), From fe60cb4d0ded4b9ec75c3eef7c3764e78830a494 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 11 Nov 2026 18:28:52 -0800 Subject: [PATCH 2/3] spec(cli): archive node-version-guard --- .../2026-11-11-node-version-guard}/proposal.md | 0 .../specs/cli-project-launcher/spec.md | 0 .../specs/cli/spec.md | 0 .../2026-11-11-node-version-guard}/tasks.md | 0 openspec/specs/cli-project-launcher/spec.md | 15 +++++++++++++++ openspec/specs/cli/spec.md | 15 +++++++++++++++ 6 files changed, 30 insertions(+) rename openspec/changes/{node-version-guard => archive/2026-11-11-node-version-guard}/proposal.md (100%) rename openspec/changes/{node-version-guard => archive/2026-11-11-node-version-guard}/specs/cli-project-launcher/spec.md (100%) rename openspec/changes/{node-version-guard => archive/2026-11-11-node-version-guard}/specs/cli/spec.md (100%) rename openspec/changes/{node-version-guard => archive/2026-11-11-node-version-guard}/tasks.md (100%) diff --git a/openspec/changes/node-version-guard/proposal.md b/openspec/changes/archive/2026-11-11-node-version-guard/proposal.md similarity index 100% rename from openspec/changes/node-version-guard/proposal.md rename to openspec/changes/archive/2026-11-11-node-version-guard/proposal.md diff --git a/openspec/changes/node-version-guard/specs/cli-project-launcher/spec.md b/openspec/changes/archive/2026-11-11-node-version-guard/specs/cli-project-launcher/spec.md similarity index 100% rename from openspec/changes/node-version-guard/specs/cli-project-launcher/spec.md rename to openspec/changes/archive/2026-11-11-node-version-guard/specs/cli-project-launcher/spec.md diff --git a/openspec/changes/node-version-guard/specs/cli/spec.md b/openspec/changes/archive/2026-11-11-node-version-guard/specs/cli/spec.md similarity index 100% rename from openspec/changes/node-version-guard/specs/cli/spec.md rename to openspec/changes/archive/2026-11-11-node-version-guard/specs/cli/spec.md diff --git a/openspec/changes/node-version-guard/tasks.md b/openspec/changes/archive/2026-11-11-node-version-guard/tasks.md similarity index 100% rename from openspec/changes/node-version-guard/tasks.md rename to openspec/changes/archive/2026-11-11-node-version-guard/tasks.md diff --git a/openspec/specs/cli-project-launcher/spec.md b/openspec/specs/cli-project-launcher/spec.md index 28a326db..a036f580 100644 --- a/openspec/specs/cli-project-launcher/spec.md +++ b/openspec/specs/cli-project-launcher/spec.md @@ -232,3 +232,18 @@ After the launcher installs a new version, it SHALL remove every `.taskless/.cac - **WHEN** `install.launchers` records both packages and the release launcher installs a new release - **THEN** the cache entry for the recorded nightly version SHALL remain + +### Requirement: A launcher refuses a Node older than its package needs + +Before it installs a version, a launcher SHALL compare the running Node against the `engines.node` its lockfile records for the package, and before it runs an installed version, against the `engines.node` in that version's `package.json`. When either is a `>=` lower bound the running Node does not meet, the launcher SHALL write `taskless: Taskless needs Node , you have . Install a newer Node and run it again.` to standard error and exit with code 1, without installing and without starting the CLI. A range other than a `>=` lower bound, or none, SHALL NOT stop it. + +#### Scenario: First run on Node 20 + +- **WHEN** a launcher whose lockfile records `engines.node` `>=22.22.0` runs on Node 20.19.0 with nothing installed +- **THEN** it SHALL print the message naming `>=22.22.0` and `20.19.0` and exit with code 1 +- **AND** it SHALL NOT run npm + +#### Scenario: An installed version on an older Node + +- **WHEN** a version installed under a newer Node is run by the launcher on a Node older than its `engines.node` +- **THEN** the launcher SHALL print the message and exit with code 1 without starting the CLI diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index f98972fd..997c11be 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -698,3 +698,18 @@ When the CLI prints a command for its reader to run next in an installed project - **WHEN** the CLI runs with no subcommand, outside a terminal, in a directory with no launcher - **THEN** the notice SHALL name the running invocation for both `init` and `agent` + +### Requirement: The bin checks the Node version before loading the CLI + +The package `bin` SHALL be a separate built file from the CLI entry. It SHALL compare the running Node version against the package's `engines.node` before it loads any of the CLI, and SHALL load the CLI with a dynamic `import()` only when the version satisfies it, so that an older Node never links the CLI's module graph. When the version does not satisfy it, the `bin` SHALL write one line to standard error naming the required range and the running version, write nothing to standard output, and exit with code 1. A range other than a `>=` lower bound SHALL NOT stop the CLI. + +#### Scenario: npx on Node 20 + +- **WHEN** a user runs `npx @taskless/cli init` on Node 20.19.0 and `engines.node` is `>=22.22.0` +- **THEN** standard error SHALL be `taskless: Taskless needs Node >=22.22.0, you have 20.19.0. Install a newer Node and run it again.` +- **AND** the process SHALL exit with code 1 without loading the CLI + +#### Scenario: A satisfying Node runs the CLI + +- **WHEN** the running Node satisfies `engines.node` +- **THEN** the `bin` SHALL run the CLI with every argument unchanged From f11d1745929efcd123169bfc2b82d088e71b1c64 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 11 Nov 2026 18:48:56 -0800 Subject: [PATCH 3/3] fix(cli): point nightly-pack test at the new bin, cross-reference the two Node checks The bin moved from dist/index.js to dist/bin.js, and the nightly-pack test still asserted the old path. Also note in both node-version.ts and the launcher that their version comparisons mirror each other. --- .github/scripts/nightly-pack.test.cjs | 2 +- packages/cli/assets/launcher/launcher.mjs | 4 ++++ packages/cli/src/node-version.ts | 3 ++- packages/cli/test/build-target.test.ts | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/scripts/nightly-pack.test.cjs b/.github/scripts/nightly-pack.test.cjs index 26ab1926..ded855d4 100644 --- a/.github/scripts/nightly-pack.test.cjs +++ b/.github/scripts/nightly-pack.test.cjs @@ -167,7 +167,7 @@ test("applyNightlyIdentity renames and restamps, and changes nothing else", () = // A nightly is a drop-in: same executable name, same pinned platform deps. assert.deepEqual(nightly.bin, COMMITTED_CLI_MANIFEST.bin); - assert.deepEqual(nightly.bin, { taskless: "./dist/index.js" }); + assert.deepEqual(nightly.bin, { taskless: "./dist/bin.js" }); assert.deepEqual( nightly.optionalDependencies, COMMITTED_CLI_MANIFEST.optionalDependencies diff --git a/packages/cli/assets/launcher/launcher.mjs b/packages/cli/assets/launcher/launcher.mjs index 3a8e476e..3e7f5b81 100644 --- a/packages/cli/assets/launcher/launcher.mjs +++ b/packages/cli/assets/launcher/launcher.mjs @@ -80,6 +80,10 @@ function versionFor(install, name) { } // `engines.node` as a lower bound, `>=MAJOR[.MINOR[.PATCH]]`. +// +// This and `requireNode` mirror `nodeVersionProblem` in the CLI's +// `src/node-version.ts`, message text included. This script ships alone into +// a project and cannot import it, so a change to either belongs in both. const MINIMUM_NODE = /^\s*>=\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/; /** diff --git a/packages/cli/src/node-version.ts b/packages/cli/src/node-version.ts index 29c49c4a..5a59e79c 100644 --- a/packages/cli/src/node-version.ts +++ b/packages/cli/src/node-version.ts @@ -9,7 +9,8 @@ * * The project launcher (`assets/launcher/launcher.mjs`) carries its own copy * of the same comparison and prints the same message. It is a standalone - * script written into a project, so it cannot import this one. + * script written into a project, so it cannot import this one. A change to + * the accepted range syntax or the message belongs in both. */ /** `>=MAJOR[.MINOR[.PATCH]]`, the only `engines.node` shape this package uses. */ diff --git a/packages/cli/test/build-target.test.ts b/packages/cli/test/build-target.test.ts index a8f3ed01..08b9778a 100644 --- a/packages/cli/test/build-target.test.ts +++ b/packages/cli/test/build-target.test.ts @@ -222,7 +222,7 @@ describe("the nightly target", () => { }); // `dist`, unlike self — the tarball is packed with `files: ["dist"]` and - // `bin: ./dist/index.js`, so a nightly build overwrites a prod build. + // `bin: ./dist/bin.js`, so a nightly build overwrites a prod build. it("emits to dist, the same directory as prod", () => { expect(resolveOutputDirectory(nightlyEnvironment)).toBe(OUT_DIRS.prod); expect(OUT_DIRS.nightly).toBe("dist");