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==",
|
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -2337,6 +2338,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -3849,6 +3851,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -4850,6 +4853,7 @@
|
|||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+118
-15
@@ -51,10 +51,31 @@ function getPackageVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNodeVersion() {
|
||||||
|
return process.version; // e.g., "v22.21.1"
|
||||||
|
}
|
||||||
|
|
||||||
function getInstalledVersion() {
|
function getInstalledVersion() {
|
||||||
try {
|
try {
|
||||||
if (existsSync(VERSION_MARKER_PATH)) {
|
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) {
|
} catch (error) {
|
||||||
// Marker doesn't exist or can't be read
|
// Marker doesn't exist or can't be read
|
||||||
@@ -62,9 +83,14 @@ function getInstalledVersion() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInstalledVersion(version) {
|
function setInstalledVersion(packageVersion, nodeVersion) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
log(`⚠️ Failed to write version marker: ${error.message}`, colors.yellow);
|
log(`⚠️ Failed to write version marker: ${error.message}`, colors.yellow);
|
||||||
}
|
}
|
||||||
@@ -84,24 +110,77 @@ function needsInstall() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check version marker
|
// Check version marker
|
||||||
const currentVersion = getPackageVersion();
|
const currentPackageVersion = getPackageVersion();
|
||||||
const installedVersion = getInstalledVersion();
|
const currentNodeVersion = getNodeVersion();
|
||||||
|
const installed = getInstalledVersion();
|
||||||
|
|
||||||
if (!installedVersion) {
|
if (!installed) {
|
||||||
log('📦 No version marker found - installing', colors.cyan);
|
log('📦 No version marker found - installing', colors.cyan);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentVersion !== installedVersion) {
|
// Check package version
|
||||||
log(`📦 Version changed (${installedVersion} → ${currentVersion}) - updating`, colors.cyan);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All good - no install needed
|
// All good - no install needed
|
||||||
log(`✓ Dependencies already installed (v${currentVersion})`, colors.dim);
|
log(`✓ Dependencies already installed (v${currentPackageVersion})`, colors.dim);
|
||||||
return false;
|
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) {
|
function getWindowsErrorHelp(errorOutput) {
|
||||||
// Detect Python version at runtime
|
// Detect Python version at runtime
|
||||||
let pythonStatus = ' Python not detected or version unknown';
|
let pythonStatus = ' Python not detected or version unknown';
|
||||||
@@ -157,7 +236,7 @@ function getWindowsErrorHelp(errorOutput) {
|
|||||||
return help.join('\n');
|
return help.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function runNpmInstall() {
|
async function runNpmInstall() {
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
log('', colors.cyan);
|
log('', colors.cyan);
|
||||||
@@ -188,12 +267,20 @@ function runNpmInstall() {
|
|||||||
throw new Error('better-sqlite3 installation verification failed');
|
throw new Error('better-sqlite3 installation verification failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = getPackageVersion();
|
// NEW: Verify native modules actually work
|
||||||
setInstalledVersion(version);
|
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('', colors.green);
|
||||||
log('✅ Dependencies installed successfully!', colors.bright);
|
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);
|
log('', colors.reset);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -251,8 +338,8 @@ async function main() {
|
|||||||
const installNeeded = needsInstall();
|
const installNeeded = needsInstall();
|
||||||
|
|
||||||
if (installNeeded) {
|
if (installNeeded) {
|
||||||
// Run installation
|
// Run installation (now async)
|
||||||
const installSuccess = runNpmInstall();
|
const installSuccess = await runNpmInstall();
|
||||||
|
|
||||||
if (!installSuccess) {
|
if (!installSuccess) {
|
||||||
log('', colors.red);
|
log('', colors.red);
|
||||||
@@ -260,6 +347,22 @@ async function main() {
|
|||||||
log('', colors.reset);
|
log('', colors.reset);
|
||||||
process.exit(1);
|
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 to start the PM2 worker after fresh install
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,10 +5,20 @@
|
|||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||||
import { stdin } from 'process';
|
import { stdin } from 'process';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
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
|
* Get context depth from settings
|
||||||
* Priority: ~/.claude/settings.json > env var > default
|
* 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 cwd = input?.cwd ?? process.cwd();
|
||||||
const project = cwd ? path.basename(cwd) : 'unknown-project';
|
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)
|
// Get ALL recent observations for this project (not filtered by summaries)
|
||||||
// This ensures we show observations even when summaries haven't been generated
|
// 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