diff --git a/AGENTS.md b/AGENTS.md index 2e60ace6f..9f952d0b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,8 @@ after changing package scope or cloud/local composition. ## Checks and commits -Use Node.js 22+ and the pnpm version pinned in `package.json`. +Use a Node.js release with Node-API 10 (22.14+, 23.6+, or later) and the pnpm +version pinned in `package.json`. - Install dependencies with `pnpm install`. - When this checkout is embedded in a parent pnpm workspace, that parent owns diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f2af2478..c5b41079d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,8 @@ git submodule update --init --recursive ## Local Development -You need Node.js 22 or later and the pnpm version specified by this project. +You need a Node.js release with Node-API 10 (22.14+, 23.6+, or later) and the pnpm +version specified by this project. ```bash pnpm install diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index b85eae7e5..ef1ab0c94 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -64,7 +64,8 @@ Two things the dev build does deliberately, both load-bearing: - Embedded CLI packaging invariants (native deps, ABI, child runtime env) live in [apps/electron/AGENTS.md](../electron/AGENTS.md). Read them before changing runtime deps/bundle externals or spawning `process.execPath` with a filtered environment. -- `engines.node` is pinned to `>=22.14.0` by better-sqlite3's `NAPI_VERSION=10`, and +- `engines.node` is pinned to `>=22.14.0 <23 || >=23.6.0` by better-sqlite3's + `NAPI_VERSION=10`, and `src/utils/sqlite-runtime-support.ts` must stay the FIRST import in `src/index.ts`. Older Node segfaults on the SQLite binding instead of throwing. Rationale + the three places that must move together: [apps/electron/AGENTS.md](../electron/AGENTS.md) (native deps). diff --git a/apps/cli/package.json b/apps/cli/package.json index 4431ea1f9..ad69f62eb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -39,7 +39,7 @@ "author": "Leon Zhao", "license": "Apache-2.0", "engines": { - "node": ">=22.14.0" + "node": ">=22.14.0 <23 || >=23.6.0" }, "devDependencies": { "@agentclientprotocol/sdk": "catalog:", diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 42b3f9207..0f3d3a438 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node // MUST stay the first import: it exits with a readable message on the runtimes where -// the SQLite binding cannot load — Node < 22.14 segfaults rather than throwing, and +// the SQLite binding cannot load — Node-API < 10 segfaults rather than throwing, and // 32-bit ARM has no prebuild. See utils/sqlite-runtime-support.ts. import './utils/sqlite-runtime-support'; import './instrument'; diff --git a/apps/cli/src/utils/sqlite-runtime-support.ts b/apps/cli/src/utils/sqlite-runtime-support.ts index 4ba39a683..e4f3450ba 100644 --- a/apps/cli/src/utils/sqlite-runtime-support.ts +++ b/apps/cli/src/utils/sqlite-runtime-support.ts @@ -51,8 +51,9 @@ export function describeUnsupportedRuntime(runtime: { } if (!isNodeApiVersionSupported(runtime.napi)) { return ( - `Lody needs Node-API ${REQUIRED_NODE_API_VERSION}, which means Node.js v22.14.0 or ` + - `newer (you are on ${process.version}, Node-API ${runtime.napi ?? 'unknown'}).\n` + + `Lody needs Node-API ${REQUIRED_NODE_API_VERSION}, which means Node.js v22.14.0+, ` + + `v23.6.0+, or a later release (you are on ${process.version}, ` + + `Node-API ${runtime.napi ?? 'unknown'}).\n` + `Its SQLite binding would crash the process instead of failing cleanly here.\n` + `Upgrade Node, then re-run: npx lody@latest` ); @@ -65,7 +66,7 @@ export function assertSqliteRuntimeSupported(): void { napi: process.versions.napi, arch: process.arch, }); - if (!problem) { + if (problem === undefined) { return; } process.stderr.write(`${problem}\n`); diff --git a/apps/cli/tests/sqlite-runtime-support.test.ts b/apps/cli/tests/sqlite-runtime-support.test.ts index 4b2726558..79797a186 100644 --- a/apps/cli/tests/sqlite-runtime-support.test.ts +++ b/apps/cli/tests/sqlite-runtime-support.test.ts @@ -1,8 +1,10 @@ import { readdirSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { describeUnsupportedRuntime, @@ -11,9 +13,16 @@ import { REQUIRED_NODE_API_VERSION, SUPPORTED_ARCHS, } from '../src/utils/sqlite-runtime-support'; +import { + assertNodeRuntimeSupported, + describeUnsupportedNodeRuntime, + isNodeApiVersionSupported as isWorkspaceNodeApiVersionSupported, +} from '../../../scripts/check-node-runtime.mjs'; const require = createRequire(import.meta.url); const sqliteDir = dirname(require.resolve('better-sqlite3/package.json')); +const supportedNodeEngineRange = '>=22.14.0 <23 || >=23.6.0'; +const supportedPnpmVersion = '10.20.0'; describe('SQLite runtime support guard', () => { it('rejects every runtime below the Node-API version the binding is built against', () => { @@ -56,9 +65,7 @@ describe('SQLite runtime support guard', () => { it('matches the architectures better-sqlite3 actually ships binaries for', () => { const shipped = new Set( - readdirSync(`${sqliteDir}/prebuilds`).map( - (file) => file.replace(/\.node$/, '').split('-')[1] - ) + readdirSync(`${sqliteDir}/prebuilds`).map((file) => file.replace(/\.node$/, '').split('-')[1]) ); expect([...shipped].sort()).toEqual([...SUPPORTED_ARCHS].sort()); }); @@ -67,8 +74,100 @@ describe('SQLite runtime support guard', () => { const packageJson = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf8') ) as { engines?: { node?: string } }; - // Node-API 10 landed in 22.14.0; anything looser lets npm install onto a runtime - // that crashes. engines is only a warning, hence the runtime guard as well. - expect(packageJson.engines?.node).toBe('>=22.14.0'); + // Node-API 10 landed in 22.14.0 and 23.6.0; anything looser lets npm install + // onto a runtime that crashes. engines is only a warning, hence the guards. + expect(packageJson.engines?.node).toBe(supportedNodeEngineRange); + }); + + it('keeps the workspace runtime declarations aligned with the CLI', () => { + const workspacePackageJson = JSON.parse( + readFileSync(new URL('../../../package.json', import.meta.url), 'utf8') + ) as { + engines?: { node?: string; pnpm?: string }; + devEngines?: { + runtime?: { name?: string; version?: string; onFail?: string }; + }; + packageManager?: string; + scripts?: { preinstall?: string }; + }; + const cliPackageJson = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) as { engines?: { node?: string } }; + const turnDiffPackageJson = JSON.parse( + readFileSync( + new URL('../../../packages/turn-diff-store/package.json', import.meta.url), + 'utf8' + ) + ) as { engines?: { node?: string } }; + + expect(workspacePackageJson.engines?.node).toBe(cliPackageJson.engines?.node); + expect(turnDiffPackageJson.engines?.node).toBe(cliPackageJson.engines?.node); + expect(workspacePackageJson.devEngines?.runtime).toEqual({ + name: 'node', + version: cliPackageJson.engines?.node, + onFail: 'error', + }); + expect(workspacePackageJson.engines?.pnpm).toBe(supportedPnpmVersion); + expect(workspacePackageJson.packageManager).toMatch( + new RegExp(`^pnpm@${supportedPnpmVersion.replaceAll('.', '\\.')}(?:\\+|$)`) + ); + expect(workspacePackageJson.scripts?.preinstall).toBe( + 'node scripts/check-node-runtime.mjs && node scripts/guard-nested-workspace-install.mjs' + ); + }); + + it('hard-fails workspace installs below Node-API 10', () => { + expect(isWorkspaceNodeApiVersionSupported('9')).toBe(false); + expect(isWorkspaceNodeApiVersionSupported(undefined)).toBe(false); + expect(isWorkspaceNodeApiVersionSupported('')).toBe(false); + expect(isWorkspaceNodeApiVersionSupported('10suffix')).toBe(false); + expect(isWorkspaceNodeApiVersionSupported('10')).toBe(true); + expect( + describeUnsupportedNodeRuntime({ nodeVersion: 'v23.5.0', nodeApiVersion: '9' }) + ).toContain('Node-API 10'); + expect( + describeUnsupportedNodeRuntime({ nodeVersion: 'v23.6.0', nodeApiVersion: '10' }) + ).toBeUndefined(); + + const originalExitCode = process.exitCode; + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + assertNodeRuntimeSupported({ nodeVersion: 'v23.5.0', nodeApiVersion: '9' }); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Node-API 10')); + } finally { + process.exitCode = originalExitCode; + error.mockRestore(); + } + }); + + it('enforces the runtime check only when the preinstall script is executed', () => { + const scriptPath = fileURLToPath( + new URL('../../../scripts/check-node-runtime.mjs', import.meta.url) + ); + const supported = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8' }); + expect(supported.status).toBe(0); + expect(supported.stderr).toBe(''); + + const spoofNodeApi9 = `data:text/javascript,${encodeURIComponent( + "Object.defineProperty(process.versions, 'napi', { value: '9' });" + )}`; + const unsupported = spawnSync(process.execPath, ['--import', spoofNodeApi9, scriptPath], { + encoding: 'utf8', + }); + expect(unsupported.status).toBe(1); + expect(unsupported.stderr).toContain('Node-API 10'); + + const importOnly = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `Object.defineProperty(process.versions, 'napi', { value: '9' }); await import(${JSON.stringify(pathToFileURL(scriptPath).href)});`, + ], + { encoding: 'utf8' } + ); + expect(importOnly.status).toBe(0); + expect(importOnly.stderr).toBe(''); }); }); diff --git a/apps/electron/AGENTS.md b/apps/electron/AGENTS.md index b5316eb31..721953358 100644 --- a/apps/electron/AGENTS.md +++ b/apps/electron/AGENTS.md @@ -114,9 +114,10 @@ Root `AGENTS.md` also applies. them into `app.asar.unpacked`, assert the DeepSeek adapter plus all four pinned presets, then probe CLI `--help`, node-pty loading, and a real in-memory SQLite database before signing. -- Keep `better-sqlite3 >= 13.0.2`, CLI `engines.node >= 22.14.0`, the first-import - guard in `sqlite-runtime-support.ts`, and its tests aligned. Older Node versions can - segfault while loading the N-API 10 binding. Linux armv7 is unsupported. +- Keep `better-sqlite3 >= 13.0.2`, the Node-API 10 engine range + (`>=22.14.0 <23 || >=23.6.0`), the first-import guard in + `sqlite-runtime-support.ts`, and its tests aligned. Older runtimes can segfault + while loading the binding. Linux armv7 is unsupported. - When upgrading `@lydell/node-pty`, audit package layout and Windows ConPTY binding names. Apply the staged asar-path repair after downloading target artifacts; a pnpm patch cannot cover cross-architecture packages fetched during packaging. diff --git a/package.json b/package.json index aa3fe91f7..4f3b7fc04 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Local-first workspace for coding agents", "private": true, "scripts": { - "preinstall": "node scripts/guard-nested-workspace-install.mjs", + "preinstall": "node scripts/check-node-runtime.mjs && node scripts/guard-nested-workspace-install.mjs", "build": "corepack pnpm --dir apps/electron build", "dev:cli": "corepack pnpm --filter lody dev", "dev:site-docs": "corepack pnpm --filter @lody/site-docs dev", @@ -48,12 +48,13 @@ ], "author": "LodyAI", "engines": { - "node": ">=22.0.0" + "node": ">=22.14.0 <23 || >=23.6.0", + "pnpm": "10.20.0" }, "devEngines": { "runtime": { "name": "node", - "version": ">=22.0.0", + "version": ">=22.14.0 <23 || >=23.6.0", "onFail": "error" } }, diff --git a/packages/turn-diff-store/AGENTS.md b/packages/turn-diff-store/AGENTS.md index 5e9b073f2..41f210cda 100644 --- a/packages/turn-diff-store/AGENTS.md +++ b/packages/turn-diff-store/AGENTS.md @@ -39,8 +39,8 @@ Loro, Flock, session-schema, or network dependency. - SQLite uses WAL, `synchronous=FULL`, foreign keys, incremental auto-vacuum, gzip level 1 by default, and a single worker-owned connection. zstd level 1 is explicit opt-in only when every reader runtime supports it. -- `better-sqlite3` requires Node >=22.14 / Node-API 10 and x64 or arm64; the SQLite - entry fails before loading the addon on unsupported runtimes. +- `better-sqlite3` requires Node-API 10 (Node 22.14+, 23.6+, or later) and x64 or + arm64; the SQLite entry fails before loading the addon on unsupported runtimes. - Reads verify reconstructed snapshot SHA-256 and run selection + reconstruction in a short read transaction. Bounded reads check manifest `raw_size` before allocating, decompressing, or crossing the worker boundary. `PRAGMA application_id` owns new diff --git a/packages/turn-diff-store/package.json b/packages/turn-diff-store/package.json index 112533987..e3baad14f 100644 --- a/packages/turn-diff-store/package.json +++ b/packages/turn-diff-store/package.json @@ -4,7 +4,7 @@ "description": "Content-defined, compressed SQLite storage for local turn diff snapshots.", "type": "module", "engines": { - "node": ">=22.14.0" + "node": ">=22.14.0 <23 || >=23.6.0" }, "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/turn-diff-store/src/sqlite-runtime-support.ts b/packages/turn-diff-store/src/sqlite-runtime-support.ts index cf47fc2e8..42f55ac1e 100644 --- a/packages/turn-diff-store/src/sqlite-runtime-support.ts +++ b/packages/turn-diff-store/src/sqlite-runtime-support.ts @@ -21,7 +21,7 @@ export function describeUnsupportedTurnDiffSqliteRuntime( if (!Number.isFinite(napi) || napi < REQUIRED_NODE_API_VERSION) { return ( `@lody/turn-diff-store needs Node-API ${REQUIRED_NODE_API_VERSION} ` + - `(Node.js 22.14.0 or newer); received ${runtime.nodeVersion} with ` + + `(Node.js 22.14.0+, 23.6.0+, or a later release); received ${runtime.nodeVersion} with ` + `Node-API ${runtime.napi ?? 'unknown'}.` ); } @@ -34,7 +34,7 @@ export function assertTurnDiffSqliteRuntimeSupported(): void { arch: process.arch, nodeVersion: process.version, }); - if (problem) throw new Error(problem); + if (problem !== undefined) throw new Error(problem); } // This side-effect import must evaluate before better-sqlite3. Older Node-API diff --git a/packages/turn-diff-store/tests/sqlite-runtime-support.test.ts b/packages/turn-diff-store/tests/sqlite-runtime-support.test.ts index 89be53468..5aa4f8d54 100644 --- a/packages/turn-diff-store/tests/sqlite-runtime-support.test.ts +++ b/packages/turn-diff-store/tests/sqlite-runtime-support.test.ts @@ -14,13 +14,14 @@ describe('turn-diff SQLite runtime support', () => { }); it('rejects runtimes that would crash while loading the native addon', () => { - expect( - describeUnsupportedTurnDiffSqliteRuntime({ - napi: '9', - arch: 'x64', - nodeVersion: 'v22.13.1', - }) - ).toMatch(/Node-API 10/); + const problem = describeUnsupportedTurnDiffSqliteRuntime({ + napi: '9', + arch: 'x64', + nodeVersion: 'v22.13.1', + }); + + expect(problem).toMatch(/Node-API 10/); + expect(problem).toContain('23.6.0'); }); it('rejects architectures without a prebuilt SQLite binding', () => { diff --git a/scripts/check-node-runtime.mjs b/scripts/check-node-runtime.mjs new file mode 100644 index 000000000..bc33ec412 --- /dev/null +++ b/scripts/check-node-runtime.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const REQUIRED_NODE_API_VERSION = 10; + +export function isNodeApiVersionSupported(nodeApiVersion) { + if (typeof nodeApiVersion !== 'string' || nodeApiVersion.trim().length === 0) { + return false; + } + + const parsed = Number(nodeApiVersion); + return Number.isInteger(parsed) && parsed >= REQUIRED_NODE_API_VERSION; +} + +export function describeUnsupportedNodeRuntime({ nodeVersion, nodeApiVersion }) { + if (isNodeApiVersionSupported(nodeApiVersion)) { + return undefined; + } + + return ( + `Lody requires Node-API ${REQUIRED_NODE_API_VERSION} to load its SQLite binding. ` + + `Use Node.js v22.14.0+, v23.6.0+, or a later release ` + + `(current: ${nodeVersion}, Node-API ${nodeApiVersion ?? 'unknown'}).` + ); +} + +export function assertNodeRuntimeSupported({ + nodeVersion = process.version, + nodeApiVersion = process.versions.napi, +} = {}) { + const problem = describeUnsupportedNodeRuntime({ nodeVersion, nodeApiVersion }); + if (problem === undefined) { + return; + } + + console.error(problem); + process.exitCode = 1; +} + +const entryPoint = process.argv[1]; +if (entryPoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entryPoint)).href) { + assertNodeRuntimeSupported(); +}