diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f18526b..90146e88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: --redact --no-banner --config .gitleaks.toml . npm-install: - name: npm install (${{ matrix.os }}, Node 22.0.0) + name: npm install (${{ matrix.os }}, Node 22.11.0) strategy: fail-fast: false matrix: @@ -118,7 +118,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.0.0" + node-version: "22.11.0" cache: npm - run: npm ci - run: npm run build diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 0fecee30..bb47a309 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -67,7 +67,7 @@ jobs: ref: ${{ github.event.release.tag_name }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.0.0" + node-version: "22.11.0" package-manager-cache: false - name: Install and execute the exact npm release run: node scripts/check-npm-install.mjs --spec "screenrig@${{ needs.publish.outputs.version }}" diff --git a/AGENTS.md b/AGENTS.md index adbd8c65..4e0a11de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ notes and examples live in `src/cli-commands/guidance.ts`. ## Product and security boundaries -- Package metadata requires Node.js 22 or newer. +- Package metadata requires Node.js 22.11 or newer. - `agent enroll --email ADDRESS` is the explicit first-agent step. Other authenticated commands do not enroll as a side effect; they fail with `not_enrolled` (exit 3). Keep that code stable. diff --git a/README.md b/README.md index f64d15e0..7ec38ebf 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ npm install --global screenrig@ screenrig version ``` -Node.js 22 or newer is required. `media upload` additionally requires ffmpeg +Node.js 22.11 or newer is required. `media upload` additionally requires ffmpeg and ffprobe, as do batch uploads and playlist preparation from local media files. Run `screenrig doctor` to inspect the optional media toolchain before an upload. diff --git a/RELEASING.md b/RELEASING.md index 86f78f13..8bb2eb71 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -55,7 +55,7 @@ fetch. and attach the offline archive. It rejects a tag that is not `vYY.MM.N`. 5. Require all Linux, macOS, and Windows clean-install jobs to pass. Each installs the exact registry version and runs `screenrig --json version` plus - `screenrig --json compose catalog` on Node 22.0.0. + `screenrig --json compose catalog` on Node 22.11.0. 6. Confirm the workflow attached `screenrig-cli.tgz` and its SHA-256 file to the same GitHub release. 7. Verify the npm package page shows provenance before announcing availability. diff --git a/package-lock.json b/package-lock.json index 994c2628..db5c0b53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "typescript": "^5.9.2" }, "engines": { - "node": ">=22" + "node": ">=22.11" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index d8f8b88b..ab948b74 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "!dist/transport/fake.d.ts.map" ], "engines": { - "node": ">=22" + "node": ">=22.11" }, "packageManager": "npm@10.9.4", "scripts": { diff --git a/scripts/check-npm-install.mjs b/scripts/check-npm-install.mjs index dbe7c6b8..a0db7964 100644 --- a/scripts/check-npm-install.mjs +++ b/scripts/check-npm-install.mjs @@ -9,12 +9,18 @@ import { PLACEHOLDER_VERSION, isReleaseVersion, resolvePackVersion, versionFromT const root = fileURLToPath(new URL("..", import.meta.url)); const packageJson = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); -const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +// Windows command shims cannot be spawned without a shell. Invoke npm's JS +// entry point with this Node runtime so paths and arguments need no shell quoting. +const npm = process.platform === "win32" ? process.execPath : "npm"; +const npmArgs = process.platform === "win32" + ? [process.env.npm_execpath || path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js")] + : []; function run(args, options = {}) { - const result = spawnSync(npm, args, { encoding: "utf8", ...options }); + const result = spawnSync(npm, [...npmArgs, ...args], { encoding: "utf8", ...options }); + if (result.error) throw result.error; if (result.status !== 0) { - throw new Error(`${npm} ${args.join(" ")}: ${result.stderr.trim() || result.stdout.trim()}`); + throw new Error(`npm ${args.join(" ")}: ${result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status}`}`); } return result; } diff --git a/scripts/check-public-repo.py b/scripts/check-public-repo.py index af497b41..7a14fb1c 100644 --- a/scripts/check-public-repo.py +++ b/scripts/check-public-repo.py @@ -49,6 +49,7 @@ def check_metadata(errors: list[str]) -> None: "version": PLACEHOLDER_VERSION, "private": False, "license": "Apache-2.0", + "engines": {"node": ">=22.11"}, } for field, value in expected.items(): if package.get(field) != value: @@ -81,7 +82,7 @@ def check_metadata(errors: list[str]) -> None: errors.append("commands.ts must not freeze CLI_VERSION to the committed placeholder") root_lock = (lock.get("packages") or {}).get("") or {} - for field in ("name", "version", "license"): + for field in ("name", "version", "license", "engines"): if root_lock.get(field) != package.get(field): errors.append(f"package-lock.json root {field!r} drifts from package.json") @@ -101,7 +102,8 @@ def check_public_tree(errors: list[str]) -> None: "npm run check", "npm audit --audit-level=high --package-lock-only", "name: screenrig-cli", - "name: npm install (${{ matrix.os }}, Node 22.0.0)", + "name: npm install (${{ matrix.os }}, Node 22.11.0)", + 'node-version: "22.11.0"', "os: [ubuntu-24.04, macos-14, windows-2022]", "npm run check:npm-install", "gitleaks\" git", @@ -144,6 +146,7 @@ def check_public_tree(errors: list[str]) -> None: "npm publish --access public", "screenrig@${{ needs.publish.outputs.version }}", "ubuntu-24.04, macos-14, windows-2022", + 'node-version: "22.11.0"', "gh release upload", ): if fact not in npm_release: diff --git a/src/cli.test.ts b/src/cli.test.ts index fa28d4c8..ccf7445e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2604,6 +2604,35 @@ test("events follow --timeout exits during backoff without hanging", async () => await rm(configDir, { recursive: true, force: true }); }); +test("doctor requires Node.js 22.11 or newer", async () => { + const descriptor = Object.getOwnPropertyDescriptor(process.versions, "node")!; + try { + for (const [version, status] of [ + ["20.19.0", "fail"], + ["22.0.0", "fail"], + ["22.10.0", "fail"], + ["22.11.0", "pass"], + ["22.11.1", "pass"], + ["22.12.0", "pass"], + ["23.0.0", "pass"], + ["24.0.0", "pass"], + ]) { + Object.defineProperty(process.versions, "node", { ...descriptor, value: version }); + resetFfmpegToolchainCache(); + const { code, stdout, configDir } = await withRuntime(["--json", "doctor"], memoryBackend(), { runProcess: fullToolchainProbe() }); + try { + const envelope = JSON.parse(stdout) as { data: { checks: Array<{ name: string; status: string }> } }; + assert.equal(envelope.data.checks.find((check) => check.name === "node")?.status, status, version); + assert.equal(code === ExitCode.Success, status === "pass", version); + } finally { + await rm(configDir, { recursive: true, force: true }); + } + } + } finally { + Object.defineProperty(process.versions, "node", descriptor); + } +}); + test("doctor reports checks over the published foundation routes", async () => { resetFfmpegToolchainCache(); const transport = memoryBackend(); diff --git a/src/commands.ts b/src/commands.ts index ad11a1e5..3d75c948 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5985,10 +5985,10 @@ async function doctor( resolved: Awaited>, ): Promise { const checks: DoctorCheck[] = []; - const nodeMajor = Number(process.versions.node.split(".")[0]); + const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split(".").map(Number); checks.push({ name: "node", - status: nodeMajor >= 22 ? "pass" : "fail", + status: nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 11) ? "pass" : "fail", detail: `node ${process.versions.node}`, }); checks.push({