Skip to content
Open
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/src/utils/sqlite-runtime-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
Expand All @@ -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`);
Expand Down
113 changes: 106 additions & 7 deletions apps/cli/tests/sqlite-runtime-support.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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());
});
Expand All @@ -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('');
});
});
7 changes: 4 additions & 3 deletions apps/electron/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
},
Expand Down
4 changes: 2 additions & 2 deletions packages/turn-diff-store/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/turn-diff-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/turn-diff-store/src/sqlite-runtime-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}.`
);
}
Expand All @@ -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
Expand Down
15 changes: 8 additions & 7 deletions packages/turn-diff-store/tests/sqlite-runtime-support.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
45 changes: 45 additions & 0 deletions scripts/check-node-runtime.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading