-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathheal-better-sqlite3.mjs
More file actions
430 lines (414 loc) · 17.5 KB
/
Copy pathheal-better-sqlite3.mjs
File metadata and controls
430 lines (414 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
/**
* Self-heal a missing better-sqlite3 native binding (#408).
*
* Single source of truth for the 3-layer heal used by both
* `scripts/postinstall.mjs` (install-time) and `hooks/ensure-deps.mjs`
* (runtime). Keeping one implementation avoids the duplicated logic the
* maintainer flagged on PR #410.
*
* Background:
* On Windows, `npm rebuild better-sqlite3` falls through to `node-gyp`
* when prebuild-install is not on cmd.exe PATH, then dies for users
* without Visual Studio C++ tooling. We bypass that by spawning
* prebuild-install JS directly with `process.execPath`.
*
* On macOS / Linux, when conda's `python3` is first on PATH (very
* common data-science setup), node-gyp picks it up via its `python3`
* PATH fallback and fails to build on Node 26 (arm64). We defend by
* pinning PYTHON + npm_config_python to a "safe" interpreter and
* stripping CONDA_* keys before shelling out to npm / node-gyp (#533).
*
* Layered heal:
* A. Spawn prebuild-install via process.execPath — bypasses PATH/MSVC.
* B. `npm install better-sqlite3` (re-resolves tree, NOT `npm rebuild`).
* C. Write actionable stderr message naming `npm install better-sqlite3`
* and the Windows / #408 context.
*
* Best-effort posture: every layer is wrapped in try/catch and the
* function never throws. Caller will fail naturally on first DB open if
* heal could not produce a working binding.
*
* @see https://github.com/mksglu/context-mode/issues/408
* @see https://github.com/mksglu/context-mode/issues/533
*
* Windows VS 2026+ detection:
* node-gyp has a hardcoded internal-version→year map. VS 2026 (internal
* major 18) was absent from older node-gyp builds, causing "unknown version"
* failures on machines that only have VS 2026 installed. Rather than
* extending the map (which would break again with VS 2029, etc.), we query
* vswhere's `displayName` property ("Visual Studio Community 2026") and
* extract the 4-digit year with a regex. `catalog_productLineVersion` is NOT
* used — it returns the internal major ("18") on VS 2026, not the year.
*/
import { existsSync as fsExistsSync } from "node:fs";
import { execSync, execFileSync, spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { createRequire } from "node:module";
/**
* Conda installation path prefixes that must NEVER be selected as the
* Python interpreter for node-gyp. Conda's Python ships environment
* activation hooks and a custom site-packages layout that breaks
* better-sqlite3's native build on Node 26 arm64 (#533).
*/
const CONDA_PATH_PATTERNS = [
/^\/opt\/anaconda/i,
/^\/opt\/miniconda/i,
/\/miniforge\d*\//i,
/\/anaconda\d*\//i,
/\/miniconda\d*\//i,
/\/\.conda\//i,
/\/conda\//i,
];
/**
* CONDA_* environment keys that must be stripped from the child env
* before spawning npm / node-gyp. Even after pinning PYTHON, leaving
* CONDA_PREFIX intact causes npm lifecycle scripts to re-activate
* conda's shims via .npmrc / shell rc files.
*/
const CONDA_ENV_KEYS = [
"CONDA_PREFIX",
"CONDA_DEFAULT_ENV",
"CONDA_EXE",
"CONDA_PROMPT_MODIFIER",
"CONDA_SHLVL",
"CONDA_PYTHON_EXE",
];
/**
* Decide whether a candidate python path is "safe" — i.e. not under
* any known conda installation prefix.
*
* @param {string} candidate - absolute path to a python interpreter
* @returns {boolean}
*/
function isSafePythonPath(candidate) {
if (!candidate) return false;
return !CONDA_PATH_PATTERNS.some((rx) => rx.test(candidate));
}
/**
* Resolve a "safe" python interpreter that node-gyp can drive without
* conda activation noise. Pure / side-effect-free / dependency-injected
* so the unit test can exercise it on any host.
*
* Strategy:
* - darwin: prefer /usr/bin/python3 (Apple's system Python, ships
* with every macOS 10.15+ install). If absent, return null.
* - linux: scan PATH for the first python3 that is not under a
* conda prefix. If none, return null.
* - win32: not affected — node-gyp uses the py launcher, not PATH.
*
* @param {object} [deps]
* @param {string} [deps.platform] - process.platform override
* @param {NodeJS.ProcessEnv} [deps.env] - environment to inspect
* @param {(p: string) => boolean} [deps.existsSync] - fs probe override
* @returns {string | null}
*/
export function resolveSafePython({
platform = process.platform,
env = process.env,
existsSync = fsExistsSync,
} = {}) {
if (platform === "darwin") {
// Apple-shipped Python is the safe choice — it is outside any
// conda prefix by definition and node-gyp builds against it
// cleanly on arm64.
return existsSync("/usr/bin/python3") ? "/usr/bin/python3" : null;
}
if (platform === "linux") {
const pathEntries = (env.PATH || "").split(":").filter(Boolean);
for (const dir of pathEntries) {
const candidate = `${dir}/python3`;
if (existsSync(candidate) && isSafePythonPath(candidate)) {
return candidate;
}
}
return null;
}
// Windows / other — no override needed.
return null;
}
/**
* Detect whether the current environment is conda-activated. Used to
* decide whether to emit the override breadcrumb (we don't spam stderr
* for users who never had conda interference).
*
* @param {NodeJS.ProcessEnv} [env]
* @returns {boolean}
*/
function isCondaActive(env = process.env) {
if (env.CONDA_PREFIX || env.CONDA_DEFAULT_ENV) return true;
const pathEntries = (env.PATH || "").split(process.platform === "win32" ? ";" : ":");
return pathEntries.some((dir) => !isSafePythonPath(dir + "/python3"));
}
/**
* Detect the installed Visual Studio year string via vswhere.exe.
*
* Uses the `displayName` property (e.g. "Visual Studio Community 2026")
* and extracts the 4-digit year with a regex. This is more reliable than
* `catalog_productLineVersion`, which returns the internal major version
* number ("18") on VS 2026 instead of the year — making it useless as a
* direct msvs_version value without a mapping table.
*
* `displayName` has consistently included the branded year across every
* VS release (2017, 2019, 2022, 2026) and will continue to do so because
* it is the user-visible product name Microsoft ships.
*
* Dependency-injected so unit tests can exercise all branches without
* spawning a real process or requiring vswhere to be present on the host.
*
* Returns null on non-Windows, when vswhere is absent, or on any error.
*
* Timeout: 15s. Cold-disk vswhere queries on HDD-backed Windows CI runners
* with multiple VS installs have been observed to exceed the previous 5s
* budget (see ARCH-REVIEW #571 Part B). 15s comfortably covers slow-disk
* scenarios without freezing /ctx-upgrade.
*
* Year sanity cap: the regex matches any 21st-century 4-digit year, but
* we additionally reject anything > currentYear+5. Corrupted vswhere
* output or a future MS rebrand could surface a bogus "2099"; passing
* that through to `npm_config_msvs_version` would fail node-gyp
* silently. Cap-and-null lets the caller fall back to node-gyp's own
* detection and we log a single stderr breadcrumb for support triage.
*
* @param {object} [deps]
* @param {string} [deps.platform] - process.platform override
* @param {(p: string) => boolean} [deps.existsSync] - fs probe override
* @param {(cmd: string, opts: object) => string} [deps.exec] - execSync override
* @param {() => number} [deps.now] - clock override for sanity cap (test seam)
* @returns {string | null}
*/
export function detectWindowsVsYear({
platform = process.platform,
existsSync = fsExistsSync,
exec = execSync,
now = () => new Date().getFullYear(),
} = {}) {
if (platform !== "win32") return null;
try {
const vswhere =
"C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe";
if (!existsSync(vswhere)) return null;
const displayName = exec(
`"${vswhere}" -latest -property displayName`,
{ encoding: "utf-8", stdio: "pipe", timeout: 15000 },
).trim();
// "Visual Studio Community 2026" → "2026"
const match = displayName.match(/\b(20\d{2})\b/);
if (!match) return null;
const year = Number(match[1]);
const ceiling = now() + 5;
if (year > ceiling) {
// Fail LOUD, not silent: poisoning npm_config_msvs_version with
// a bogus year would manifest as opaque node-gyp errors deep in
// the rebuild. Surface a breadcrumb and return null so the caller
// falls back to node-gyp's own version detection.
try {
process.stderr.write(
`[context-mode] vswhere displayName reports VS year ${year} ` +
`(> ${ceiling}); ignoring as likely corrupted output. ` +
`Falling back to node-gyp default detection.\n`,
);
} catch { /* stderr unavailable — proceed silently */ }
return null;
}
return match[1];
} catch {
return null;
}
}
/**
* Build the child-process env for an npm/node-gyp invocation. Pins
* PYTHON + npm_config_python to the resolved safe interpreter, strips
* CONDA_* keys, prepends /usr/bin to PATH on darwin so any downstream
* PATH-based python3 lookup resolves to system python, and on Windows
* injects npm_config_msvs_version from vswhere so node-gyp finds VS
* regardless of which major version is installed.
*
* @param {string | null} safePython - output of resolveSafePython()
* @param {NodeJS.ProcessEnv} [base] - starting env (defaults to process.env)
* @returns {NodeJS.ProcessEnv}
*/
function buildSafeEnv(safePython, base = process.env) {
const env = { ...base };
if (safePython) {
// node-gyp reads env.PYTHON (see lib/find-python.js — second slot in
// its `checks` array, before any PATH-based fallback).
env.PYTHON = safePython;
// npm passes npm_config_python through to node-gyp as --python,
// which sits in the FIRST slot of node-gyp's `checks` array — even
// higher priority than env.PYTHON. Set both for belt-and-suspenders.
env.npm_config_python = safePython;
}
// Wipe every CONDA_* key so npm lifecycle scripts can't re-shim
// python3 via shell activation hooks.
for (const key of CONDA_ENV_KEYS) {
delete env[key];
}
// Prepend /usr/bin on darwin so any sub-script that does `python3`
// unqualified still resolves to /usr/bin/python3.
if (process.platform === "darwin" && env.PATH) {
const parts = env.PATH.split(":");
if (parts[0] !== "/usr/bin") {
env.PATH = "/usr/bin:" + parts.filter((p) => p !== "/usr/bin").join(":");
}
}
// ── Windows: pin npm_config_msvs_version via vswhere (#VS2026) ───────
// node-gyp defaults to VS 2022. On machines that only have VS 2026 (or
// a later release) installed the build fails with "unknown version" or
// "msvs_version does not match". Querying vswhere directly gives us the
// correct year string without a hardcoded mapping table.
if (process.platform === "win32" && !env.npm_config_msvs_version) {
const year = detectWindowsVsYear();
if (year) env.npm_config_msvs_version = year;
}
return env;
}
/**
* Self-heal a missing better_sqlite3.node binding.
*
* @param {string} pkgRoot - the directory containing node_modules/better-sqlite3
* @returns {{ healed: boolean, reason?: string }}
*/
export function healBetterSqlite3Binding(pkgRoot) {
try {
const bsqRoot = resolve(pkgRoot, "node_modules", "better-sqlite3");
const bindingPath = resolve(bsqRoot, "build", "Release", "better_sqlite3.node");
const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
// ── Conda defense (#533) ─────────────────────────────────────────
// Resolve once up front; reuse across all child spawns. The probe
// is cheap (one stat call on darwin, a PATH walk on linux).
const safePython = resolveSafePython();
const condaActive = isCondaActive();
const childEnv = buildSafeEnv(safePython, process.env);
if (condaActive && safePython) {
// Emit a single breadcrumb so support requests are
// self-diagnosing. Best-effort: stderr may be unavailable in
// postinstall captured by npm logs.
try {
process.stderr.write(
`[context-mode] conda python detected on PATH — overriding with ` +
`PYTHON=${safePython} for better-sqlite3 build (#533).\n`,
);
} catch { /* stderr unavailable — proceed silently */ }
}
if (!fsExistsSync(bsqRoot)) {
// ── Package itself missing (#514) ───────────────────────────────
// npm@7+ silently drops optionalDependencies whose engines field
// does not match the running Node version (Node 26 vs
// better-sqlite3@12.x → silent skip, package never written).
// Even after promoting the package back to dependencies, an
// existing install where the package directory was previously
// skipped will still have an empty slot. Take ownership and
// install the package by name with --no-optional, which forces
// npm to install the named package even if it would otherwise
// be filtered out as an optional dep.
if (condaActive && !safePython) {
// Conda is active AND we couldn't find a system fallback. The
// install will almost certainly fail. Surface a distinct reason
// code so /ctx-upgrade can print conda-specific remediation.
return { healed: false, reason: "python-conda-blocked" };
}
try {
execFileSync(
npmBin,
[
"install",
"better-sqlite3",
"--no-optional",
"--no-save",
"--no-audit",
"--no-fund",
],
{
cwd: pkgRoot,
stdio: "pipe",
timeout: 180000,
shell: process.platform === "win32",
env: childEnv,
},
);
} catch {
// Install failed — surface the cause via the manual-required
// exit so the caller (cli.ts upgrade verifier) reports it.
return { healed: false, reason: "package-missing" };
}
// Re-check after install. If npm wrote the package AND its
// postinstall produced the binding, we're done. Otherwise fall
// through into the binding-missing flow below.
if (fsExistsSync(bindingPath)) {
return { healed: true, reason: "package-installed" };
}
if (!fsExistsSync(bsqRoot)) {
// npm reported success but the directory is still absent.
// This indicates the engine-mismatch silent-skip is still in
// effect (e.g. npm < 7 or pnpm without --shamefully-hoist).
return { healed: false, reason: "package-missing" };
}
// Package present but binding still missing — recurse into
// the existing 3-layer heal that owns prebuild-install / npm
// install / actionable-stderr.
}
if (fsExistsSync(bindingPath)) {
return { healed: true, reason: "binding-present" };
}
// ── Layer A: spawn prebuild-install directly via process.execPath ──
// Bypasses cmd.exe PATH and MSVC requirement.
try {
let prebuildBin = null;
try {
const req = createRequire(resolve(bsqRoot, "package.json"));
prebuildBin = req.resolve("prebuild-install/bin");
} catch { /* fall through to manual walk */ }
if (!prebuildBin) {
const candidates = [
resolve(bsqRoot, "node_modules", "prebuild-install", "bin.js"),
resolve(pkgRoot, "node_modules", "prebuild-install", "bin.js"),
];
for (const c of candidates) {
if (fsExistsSync(c)) { prebuildBin = c; break; }
}
}
if (prebuildBin) {
const r = spawnSync(
process.execPath,
[prebuildBin, "--target", process.versions.node, "--runtime", "node"],
{ cwd: bsqRoot, stdio: "pipe", timeout: 120000, env: childEnv },
);
if (r.status === 0 && fsExistsSync(bindingPath)) {
return { healed: true, reason: "prebuild-install" };
}
}
} catch { /* best effort — try Layer B */ }
// ── Layer B: `npm install better-sqlite3` — NOT `npm rebuild` ──
// Re-resolves tree and re-runs prebuild-install via the package's
// own install script. Avoids the rebuild → node-gyp fall-through.
try {
execSync(
`${npmBin} install better-sqlite3 --no-package-lock --no-save --silent`,
{ cwd: pkgRoot, stdio: "pipe", timeout: 120000, shell: true, env: childEnv },
);
if (fsExistsSync(bindingPath)) {
return { healed: true, reason: "npm-install" };
}
} catch { /* best effort — fall through to Layer C */ }
// ── Layer C: actionable stderr — give the user a real next step ──
try {
const condaHint = condaActive && !safePython
? " Conda python detected on PATH and no system /usr/bin/python3 fallback found (#533).\n" +
" Deactivate conda (`conda deactivate`) or install system python3, then retry.\n"
: "";
process.stderr.write(
"\n[context-mode] better-sqlite3 native binding could not be installed automatically.\n" +
" This is a known issue on Windows when prebuild-install is not on PATH (#408).\n" +
condaHint +
" Workaround: run `npm install better-sqlite3` from the plugin directory.\n\n",
);
} catch { /* stderr unavailable — give up silently */ }
if (condaActive && !safePython) {
return { healed: false, reason: "python-conda-blocked" };
}
return { healed: false, reason: "manual-required" };
} catch {
// Outermost guard — never throw, never block the caller.
return { healed: false, reason: "manual-required" };
}
}