Skip to content

Commit 562c12b

Browse files
Fuyuan Chengclaude
andcommitted
fix(build): derive the Node floor from Node's changelog, not local installs
The engines range shipped in 6938757 was >=22.15.0, and the only reason for that number was that 22.15.0 was the oldest Node on the laptop it was measured on. 22.13 and 22.14 were never tested because they were not installed — not because they fail. A machine's `nvm ls` is not a fact about Node, and using one to bound every user of an OSS package is the wrong instrument. nodejs/node#55890 unflagged node:sqlite in 22.13.0 and 23.4.0. So the honest range is neither >=22.15.0 nor >=22.13.0: - >=22.15.0 locked out 22.13 and 22.14 users, who are fine. - >=22.13.0 would admit 23.0-23.3, which are semver-newer than 22.13 yet still lack the module — a hard runtime failure, silently allowed by the floor. Both mistakes are invisible on a machine that has neither range's edge cases installed, which is exactly why the boundary had to come from the changelog. engines.node is now ">=22.13.0 <23.0.0 || >=23.4.0", and the runtime error quotes the same requirement rather than a bare minimum that misdescribes the 23 line. The .nvmrc guard is rewritten against Bun.semver instead of a regex that only understood a single `>=x.y.z` — the shape the range no longer has. It now also pins the 23.0-23.3 hole with explicit versions, and checks the .nvmrc line's OLDEST possible release rather than assuming a bare major resolves to something recent: `nvm use 24` yields whatever 24.x that machine happens to have. Verified: the guard rejects a naive >=22.13.0, and 22.13/22.15/23.4/24/26 are admitted while 20.20.1/22.12/23.0/23.3.9 are refused. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6938757 commit 562c12b

3 files changed

Lines changed: 61 additions & 31 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@rikaidev/yomi",
33
"engines": {
4-
"node": ">=22.15.0"
4+
"node": ">=22.13.0 <23.0.0 || >=23.4.0"
55
},
66
"version": "0.2.4",
77
"description": "Yomi (読み) — reads your LINE (decrypting Letter-Sealing E2EE) and sends on your behalf, via a stdio MCP server, so an agent can catch up on your LINE for you.",

‎src/nvmrc.test.ts‎

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,50 @@ import { fileURLToPath } from 'node:url'
66
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
77

88
/**
9-
* `.nvmrc` and `engines.node` say the same thing to different audiences and
10-
* neither tool reads the other: nvm does not look at package.json at all (its
11-
* README documents only .nvmrc), and npm's engines check does not consult
12-
* .nvmrc. So they can drift apart silently — a contributor's `nvm use` would
13-
* hand them a Node that engines forbids, or vice versa.
9+
* `.nvmrc` and `engines.node` state one requirement to two tools that cannot
10+
* see each other: nvm never reads package.json (its README documents only
11+
* .nvmrc), and npm's engines check never reads .nvmrc. Two statements of one
12+
* fact drift, so this guards them the way version.test.ts guards version.ts
13+
* against package.json.
1414
*
15-
* .nvmrc also cannot hold the range itself: nvm rejects semver ranges and takes
16-
* only an exact version, a major, or an alias. Hence this guard rather than a
17-
* shared source.
15+
* .nvmrc cannot just hold the range: nvm rejects semver ranges and accepts only
16+
* an exact version, a bare major, or an alias.
1817
*/
19-
test('.nvmrc selects a Node that satisfies package.json engines.node', () => {
18+
test('.nvmrc selects a Node line where every release satisfies engines.node', () => {
2019
const nvmrc = readFileSync(join(ROOT, '.nvmrc'), 'utf8').trim()
2120
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
2221
const engines: string = pkg.engines?.node ?? ''
22+
expect(engines).not.toBe('')
2323

24-
const min = engines.match(/^>=\s*(\d+)\.(\d+)\.(\d+)$/)
25-
expect(min).not.toBeNull()
26-
const nvmrcMajor = nvmrc.match(/^(\d+)$/)
27-
expect(nvmrcMajor).not.toBeNull()
24+
const major = nvmrc.match(/^(\d+)$/)?.[1]
25+
expect(major).toBeDefined()
2826

29-
// A bare major in .nvmrc resolves to that line's newest release, so it
30-
// satisfies the floor whenever its major is strictly greater. Equal majors
31-
// would need the minor compared, which this repo does not currently pin.
32-
expect(Number(nvmrcMajor![1])).toBeGreaterThan(Number(min![1]))
27+
// `nvm use 24` resolves to the newest 24.x *that machine happens to have*,
28+
// which can be older than the floor. So the line qualifies only if its OLDEST
29+
// possible release satisfies engines — checking the newest would pass here and
30+
// still hand a contributor an unusable Node.
31+
expect(Bun.semver.satisfies(`${major}.0.0`, engines)).toBe(true)
32+
})
33+
34+
test('engines.node excludes the Node 23 releases that lack unflagged node:sqlite', () => {
35+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
36+
const engines: string = pkg.engines.node
37+
38+
// node:sqlite was unflagged in 22.13.0 AND 23.4.0 (nodejs/node#55890), so the
39+
// 23 line splits: 23.0-23.3 are semver-newer than 22.13 yet still lack the
40+
// module. A plain `>=22.13.0` would admit them and then fail at runtime with
41+
// "No such built-in module". Those cases are why the range is a disjunction,
42+
// and they are what this pins.
43+
for (const unsupported of ['20.20.1', '22.12.0', '23.0.0', '23.3.9']) {
44+
expect(Bun.semver.satisfies(unsupported, engines)).toBe(false)
45+
}
46+
for (const supported of [
47+
'22.13.0',
48+
'22.20.0',
49+
'23.4.0',
50+
'24.0.0',
51+
'26.5.0',
52+
]) {
53+
expect(Bun.semver.satisfies(supported, engines)).toBe(true)
54+
}
3355
})

‎src/search/sqlite.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,15 @@ function wrapNodeDb(raw: NodeDatabaseSync): Database {
123123
}
124124
}
125125

126-
/** Lowest Node with an unflagged `node:sqlite` that this project has verified. */
127-
const MIN_NODE_FOR_SQLITE = '22.15.0'
126+
/**
127+
* Node versions carrying an unflagged `node:sqlite`.
128+
*
129+
* It was unflagged in 22.13.0 and 23.4.0 (nodejs/node#55890), so the 23 line
130+
* splits: 23.0–23.3 are NEWER than 22.13 yet still lack it, which a plain
131+
* `>=22.13.0` floor would wrongly admit. Keep this in step with
132+
* package.json's `engines.node`.
133+
*/
134+
const NODE_SQLITE_REQUIREMENT = '22.13+ (or 23.4+ on the 23.x line)'
128135

129136
/** Thrown when the running runtime has no SQLite module at all. */
130137
export class SqliteUnavailableError extends Error {
@@ -150,19 +157,20 @@ export function openDatabase(path: string): Database {
150157
try {
151158
mod = runtimeRequire('node:sqlite')
152159
} catch (error: any) {
153-
// `node:sqlite` landed unflagged in Node 22.x; older runtimes throw
154-
// "No such built-in module: node:sqlite", which tells a user nothing about
155-
// what to do. The `engines` field cannot help here — it warns at install
156-
// time and npx ignores it — and the node running us is often not the one a
157-
// user would guess: Claude Desktop resolves `npx`'s `#!/usr/bin/env node`
158-
// against its own PATH, which can pick an old nvm version regardless of
159-
// which npx was configured. So say which node is actually running, and what
160-
// it needs to be.
160+
// Runtimes without it throw "No such built-in module: node:sqlite", which
161+
// tells a user nothing about what to change. `engines` does not help: npm
162+
// only warns at install time, and npx ignores it entirely.
163+
//
164+
// The trap worth naming is that the node running this is often not the one
165+
// the user configured. An MCP client launches the server through `npx`,
166+
// whose `#!/usr/bin/env node` resolves against the CLIENT's PATH — so the
167+
// runtime can be any node on that PATH regardless of which npx was named.
168+
// Report the actual execPath rather than assume the user can guess it.
161169
throw new SqliteUnavailableError(
162-
`This Node cannot open Yomi's message index: ${process.version} has no built-in \`node:sqlite\` (needs >= v${MIN_NODE_FOR_SQLITE}). ` +
170+
`This Node cannot open Yomi's message index: ${process.version} has no built-in \`node:sqlite\` (needs ${NODE_SQLITE_REQUIREMENT}). ` +
163171
`Live tools still work; search, scope and capture do not. ` +
164-
`The runtime is ${process.execPath} — note that an MCP client resolves \`node\` from its own PATH, so this may not be the node you expect. ` +
165-
`Point the client at a newer node (e.g. set the server's PATH so a >= v${MIN_NODE_FOR_SQLITE} node comes first), then restart it. ` +
172+
`The runtime is ${process.execPath} — an MCP client resolves \`node\` from its own PATH, so this may not be the node you expect. ` +
173+
`Point the client at a supported node and restart it. ` +
166174
`Original error: ${error?.message ?? String(error)}`,
167175
)
168176
}

0 commit comments

Comments
 (0)