Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-version-message.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/scripts/nightly-pack.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions openspec/changes/archive/2026-11-11-node-version-guard/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 <range>, you have <version>. 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
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions openspec/changes/archive/2026-11-11-node-version-guard/tasks.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions openspec/specs/cli-project-launcher/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <range>, you have <version>. 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
15 changes: 15 additions & 0 deletions openspec/specs/cli/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 51 additions & 2 deletions packages/cli/assets/launcher/launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,52 @@ function versionFor(install, name) {
: undefined;
}

// `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*$/;

/**
* 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);
Expand Down Expand Up @@ -161,17 +207,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");
Expand Down Expand Up @@ -300,6 +348,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",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/scripts/build-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
11 changes: 6 additions & 5 deletions packages/cli/src/agent/ci.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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).
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/cli/src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
54 changes: 54 additions & 0 deletions packages/cli/src/node-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* 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
Comment thread
theCodeDrift marked this conversation as resolved.
Resolved
* of the same comparison and prints the same message. It is a standalone
* 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. */
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;
}
2 changes: 1 addition & 1 deletion packages/cli/test/build-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading