feat: Auto-detect and rebuild native modules on Node.js version changes (#149)
Implements three-layer defense against native module version mismatches: Layer 1: Node.js Version Tracking - Track Node.js version alongside package version in .install-version marker - Auto-trigger npm install when Node.js version changes - Backward compatible with old plain-text version marker format Layer 2: Native Module Verification - Add verifyNativeModules() function to test better-sqlite3 loads correctly - Verify after install completes to catch corrupted builds - Retry with force flag if initial install verification fails Layer 3: Graceful Failure - Catch ERR_DLOPEN_FAILED in context-hook and delete version marker - Exit cleanly to avoid error spam in Claude Code UI - Auto-fix on next session start Changes: - scripts/smart-install.js: Add Node.js version tracking and verification - src/hooks/context-hook.ts: Add graceful failure handling for native module errors - tests/smart-install.test.js: Add tests for version marker format compatibility - plugin/scripts/context-hook.js: Built output from TypeScript source Fixes the issue where users see ERR_DLOPEN_FAILED errors after Node.js upgrades, requiring manual npm install. Now automatically detects and fixes the issue. Related design doc: docs/context/native-module-auto-fix-design.md Implementation plan: docs/context/native-module-auto-fix-implementation.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alex Newman <thedotmack@gmail.com>
This commit is contained in:
committed by
GitHub
parent
de279ef6bf
commit
69b17e15a2
Generated
+4
@@ -1484,6 +1484,7 @@
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -2337,6 +2338,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -3849,6 +3851,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -4850,6 +4853,7 @@
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+119
-16
@@ -51,10 +51,31 @@ function getPackageVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeVersion() {
|
||||
return process.version; // e.g., "v22.21.1"
|
||||
}
|
||||
|
||||
function getInstalledVersion() {
|
||||
try {
|
||||
if (existsSync(VERSION_MARKER_PATH)) {
|
||||
return readFileSync(VERSION_MARKER_PATH, 'utf-8').trim();
|
||||
const content = readFileSync(VERSION_MARKER_PATH, 'utf-8').trim();
|
||||
|
||||
// Try parsing as JSON (new format)
|
||||
try {
|
||||
const marker = JSON.parse(content);
|
||||
return {
|
||||
packageVersion: marker.packageVersion,
|
||||
nodeVersion: marker.nodeVersion,
|
||||
installedAt: marker.installedAt
|
||||
};
|
||||
} catch {
|
||||
// Fallback: old format (plain text version string)
|
||||
return {
|
||||
packageVersion: content,
|
||||
nodeVersion: null, // Unknown
|
||||
installedAt: null
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Marker doesn't exist or can't be read
|
||||
@@ -62,9 +83,14 @@ function getInstalledVersion() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function setInstalledVersion(version) {
|
||||
function setInstalledVersion(packageVersion, nodeVersion) {
|
||||
try {
|
||||
writeFileSync(VERSION_MARKER_PATH, version, 'utf-8');
|
||||
const marker = {
|
||||
packageVersion,
|
||||
nodeVersion,
|
||||
installedAt: new Date().toISOString()
|
||||
};
|
||||
writeFileSync(VERSION_MARKER_PATH, JSON.stringify(marker, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
log(`⚠️ Failed to write version marker: ${error.message}`, colors.yellow);
|
||||
}
|
||||
@@ -84,24 +110,77 @@ function needsInstall() {
|
||||
}
|
||||
|
||||
// Check version marker
|
||||
const currentVersion = getPackageVersion();
|
||||
const installedVersion = getInstalledVersion();
|
||||
const currentPackageVersion = getPackageVersion();
|
||||
const currentNodeVersion = getNodeVersion();
|
||||
const installed = getInstalledVersion();
|
||||
|
||||
if (!installedVersion) {
|
||||
if (!installed) {
|
||||
log('📦 No version marker found - installing', colors.cyan);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentVersion !== installedVersion) {
|
||||
log(`📦 Version changed (${installedVersion} → ${currentVersion}) - updating`, colors.cyan);
|
||||
// Check package version
|
||||
if (currentPackageVersion !== installed.packageVersion) {
|
||||
log(`📦 Version changed (${installed.packageVersion} → ${currentPackageVersion}) - updating`, colors.cyan);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check Node.js version
|
||||
if (installed.nodeVersion && currentNodeVersion !== installed.nodeVersion) {
|
||||
log(`📦 Node.js version changed (${installed.nodeVersion} → ${currentNodeVersion}) - rebuilding native modules`, colors.cyan);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If old format (no nodeVersion), assume needs install
|
||||
if (!installed.nodeVersion) {
|
||||
log('📦 Old version marker format - updating', colors.cyan);
|
||||
return true;
|
||||
}
|
||||
|
||||
// All good - no install needed
|
||||
log(`✓ Dependencies already installed (v${currentVersion})`, colors.dim);
|
||||
log(`✓ Dependencies already installed (v${currentPackageVersion})`, colors.dim);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that better-sqlite3 native module loads correctly
|
||||
* This catches ABI mismatches and corrupted builds
|
||||
*/
|
||||
async function verifyNativeModules() {
|
||||
try {
|
||||
log('🔍 Verifying native modules...', colors.dim);
|
||||
|
||||
// Try to actually load better-sqlite3
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
|
||||
// Try to create a test in-memory database
|
||||
const db = new Database(':memory:');
|
||||
|
||||
// Run a simple query to ensure it works
|
||||
const result = db.prepare('SELECT 1 + 1 as result').get();
|
||||
|
||||
// Clean up
|
||||
db.close();
|
||||
|
||||
if (result.result !== 2) {
|
||||
throw new Error('SQLite math check failed');
|
||||
}
|
||||
|
||||
log('✓ Native modules verified', colors.dim);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_DLOPEN_FAILED') {
|
||||
log('⚠️ Native module ABI mismatch detected', colors.yellow);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other errors are unexpected - log and fail
|
||||
log(`❌ Native module verification failed: ${error.message}`, colors.red);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getWindowsErrorHelp(errorOutput) {
|
||||
// Detect Python version at runtime
|
||||
let pythonStatus = ' Python not detected or version unknown';
|
||||
@@ -157,7 +236,7 @@ function getWindowsErrorHelp(errorOutput) {
|
||||
return help.join('\n');
|
||||
}
|
||||
|
||||
function runNpmInstall() {
|
||||
async function runNpmInstall() {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
log('', colors.cyan);
|
||||
@@ -175,7 +254,7 @@ function runNpmInstall() {
|
||||
for (const { command, label } of strategies) {
|
||||
try {
|
||||
log(`Attempting install ${label}...`, colors.dim);
|
||||
|
||||
|
||||
// Run npm install silently
|
||||
execSync(command, {
|
||||
cwd: PLUGIN_ROOT,
|
||||
@@ -188,12 +267,20 @@ function runNpmInstall() {
|
||||
throw new Error('better-sqlite3 installation verification failed');
|
||||
}
|
||||
|
||||
const version = getPackageVersion();
|
||||
setInstalledVersion(version);
|
||||
// NEW: Verify native modules actually work
|
||||
const nativeModulesWork = await verifyNativeModules();
|
||||
if (!nativeModulesWork) {
|
||||
throw new Error('Native modules failed to load after install');
|
||||
}
|
||||
|
||||
const packageVersion = getPackageVersion();
|
||||
const nodeVersion = getNodeVersion();
|
||||
setInstalledVersion(packageVersion, nodeVersion);
|
||||
|
||||
log('', colors.green);
|
||||
log('✅ Dependencies installed successfully!', colors.bright);
|
||||
log(` Version: ${version}`, colors.dim);
|
||||
log(` Package version: ${packageVersion}`, colors.dim);
|
||||
log(` Node.js version: ${nodeVersion}`, colors.dim);
|
||||
log('', colors.reset);
|
||||
|
||||
return true;
|
||||
@@ -251,8 +338,8 @@ async function main() {
|
||||
const installNeeded = needsInstall();
|
||||
|
||||
if (installNeeded) {
|
||||
// Run installation
|
||||
const installSuccess = runNpmInstall();
|
||||
// Run installation (now async)
|
||||
const installSuccess = await runNpmInstall();
|
||||
|
||||
if (!installSuccess) {
|
||||
log('', colors.red);
|
||||
@@ -260,6 +347,22 @@ async function main() {
|
||||
log('', colors.reset);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// NEW: Even if install not needed, verify native modules work
|
||||
const nativeModulesWork = await verifyNativeModules();
|
||||
|
||||
if (!nativeModulesWork) {
|
||||
log('📦 Native modules need rebuild - reinstalling', colors.cyan);
|
||||
const installSuccess = await runNpmInstall();
|
||||
|
||||
if (!installSuccess) {
|
||||
log('', colors.red);
|
||||
log('⚠️ Native module rebuild failed', colors.yellow);
|
||||
log('', colors.reset);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to start the PM2 worker after fresh install
|
||||
try {
|
||||
|
||||
@@ -5,10 +5,20 @@
|
||||
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||
import { stdin } from 'process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
||||
|
||||
// Get __dirname equivalent in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Version marker path (same as smart-install.js)
|
||||
// From src/hooks/ we need to go up to plugin root: ../../
|
||||
const VERSION_MARKER_PATH = path.join(__dirname, '../../.install-version');
|
||||
|
||||
/**
|
||||
* Get context depth from settings
|
||||
* Priority: ~/.claude/settings.json > env var > default
|
||||
@@ -156,7 +166,27 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
|
||||
const cwd = input?.cwd ?? process.cwd();
|
||||
const project = cwd ? path.basename(cwd) : 'unknown-project';
|
||||
|
||||
const db = new SessionStore();
|
||||
let db: SessionStore;
|
||||
try {
|
||||
db = new SessionStore();
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ERR_DLOPEN_FAILED') {
|
||||
// Native module ABI mismatch - delete version marker to trigger reinstall
|
||||
try {
|
||||
unlinkSync(VERSION_MARKER_PATH);
|
||||
} catch (unlinkError) {
|
||||
// Marker might not exist, that's okay
|
||||
}
|
||||
|
||||
// Log once (not error spam) and exit cleanly
|
||||
console.error('⚠️ Native module rebuild needed - restart Claude Code to auto-fix');
|
||||
console.error(' (This happens after Node.js version upgrades)');
|
||||
process.exit(0); // Exit cleanly to avoid error spam
|
||||
}
|
||||
|
||||
// Other errors should still throw
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Get ALL recent observations for this project (not filtered by summaries)
|
||||
// This ensures we show observations even when summaries haven't been generated
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const VERSION_MARKER_PATH = join(process.cwd(), '.install-version');
|
||||
|
||||
test('version marker - new JSON format', () => {
|
||||
const marker = {
|
||||
packageVersion: '6.3.2',
|
||||
nodeVersion: 'v22.21.1',
|
||||
installedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
writeFileSync(VERSION_MARKER_PATH, JSON.stringify(marker, null, 2));
|
||||
const content = JSON.parse(readFileSync(VERSION_MARKER_PATH, 'utf-8'));
|
||||
|
||||
assert.strictEqual(content.packageVersion, '6.3.2');
|
||||
assert.strictEqual(content.nodeVersion, 'v22.21.1');
|
||||
assert.ok(content.installedAt);
|
||||
|
||||
unlinkSync(VERSION_MARKER_PATH);
|
||||
});
|
||||
|
||||
test('version marker - backward compatibility with old format', () => {
|
||||
// Old format: plain text version string
|
||||
writeFileSync(VERSION_MARKER_PATH, '6.3.2');
|
||||
const content = readFileSync(VERSION_MARKER_PATH, 'utf-8').trim();
|
||||
|
||||
// Should be able to parse old format
|
||||
let marker;
|
||||
try {
|
||||
marker = JSON.parse(content);
|
||||
} catch {
|
||||
// Old format - create compatible object
|
||||
marker = {
|
||||
packageVersion: content,
|
||||
nodeVersion: null,
|
||||
installedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
assert.strictEqual(marker.packageVersion, '6.3.2');
|
||||
assert.strictEqual(marker.nodeVersion, null);
|
||||
|
||||
unlinkSync(VERSION_MARKER_PATH);
|
||||
});
|
||||
Reference in New Issue
Block a user