diff --git a/plugin/scripts/smart-install.js b/plugin/scripts/smart-install.js index af44017b..8bb24777 100644 --- a/plugin/scripts/smart-install.js +++ b/plugin/scripts/smart-install.js @@ -4,16 +4,56 @@ * * Ensures Bun runtime and uv (Python package manager) are installed * (auto-installs if missing) and handles dependency installation when needed. + * + * Resolves the install directory from CLAUDE_PLUGIN_ROOT (set by Claude Code + * for both cache and marketplace installs), falling back to script location + * and legacy paths. */ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { execSync, spawnSync } from 'child_process'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { homedir } from 'os'; +import { fileURLToPath } from 'url'; -const ROOT = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack'); -const MARKER = join(ROOT, '.install-version'); const IS_WINDOWS = process.platform === 'win32'; +/** + * Resolve the plugin root directory where dependencies should be installed. + * + * Priority: + * 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks — works for + * both cache-based and marketplace installs) + * 2. Script location (dirname of this file, up one level from scripts/) + * 3. XDG path (~/.config/claude/plugins/marketplaces/thedotmack) + * 4. Legacy path (~/.claude/plugins/marketplaces/thedotmack) + */ +function resolveRoot() { + // CLAUDE_PLUGIN_ROOT is the authoritative location set by Claude Code + if (process.env.CLAUDE_PLUGIN_ROOT) { + const root = process.env.CLAUDE_PLUGIN_ROOT; + if (existsSync(join(root, 'package.json'))) return root; + } + + // Derive from script location (this file is in /scripts/) + try { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const candidate = dirname(scriptDir); + if (existsSync(join(candidate, 'package.json'))) return candidate; + } catch { + // import.meta.url not available + } + + // Probe XDG path, then legacy + const marketplaceRel = join('plugins', 'marketplaces', 'thedotmack'); + const xdg = join(homedir(), '.config', 'claude', marketplaceRel); + if (existsSync(join(xdg, 'package.json'))) return xdg; + + return join(homedir(), '.claude', marketplaceRel); +} + +const ROOT = resolveRoot(); +const MARKER = join(ROOT, '.install-version'); + /** * Check if Bun is installed and accessible */ @@ -287,7 +327,7 @@ function installUv() { * Add shell alias for claude-mem command */ function installCLI() { - const WORKER_CLI = join(ROOT, 'plugin', 'scripts', 'worker-service.cjs'); + const WORKER_CLI = join(ROOT, 'scripts', 'worker-service.cjs'); const bunPath = getBunPath() || 'bun'; const aliasLine = `alias claude-mem='${bunPath} "${WORKER_CLI}"'`; const markerPath = join(ROOT, '.cli-installed'); @@ -405,6 +445,31 @@ function installDeps() { })); } +/** + * Verify that critical runtime modules are resolvable from the install directory. + * Returns true if all critical modules exist, false otherwise. + */ +function verifyCriticalModules() { + const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); + const dependencies = Object.keys(pkg.dependencies || {}); + + const missing = []; + for (const dep of dependencies) { + // Check that the module directory exists in node_modules + const modulePath = join(ROOT, 'node_modules', ...dep.split('/')); + if (!existsSync(modulePath)) { + missing.push(dep); + } + } + + if (missing.length > 0) { + console.error(`❌ Post-install check failed: missing modules: ${missing.join(', ')}`); + return false; + } + + return true; +} + // Main execution try { // Step 1: Ensure Bun is installed and meets minimum version (REQUIRED) @@ -456,6 +521,21 @@ try { const newVersion = pkg.version; installDeps(); + + // Verify critical modules are resolvable + if (!verifyCriticalModules()) { + console.error('⚠️ Retrying install with npm...'); + try { + execSync('npm install --production', { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS }); + } catch { + // npm also failed + } + if (!verifyCriticalModules()) { + console.error('❌ Dependencies could not be installed. Plugin may not work correctly.'); + process.exit(1); + } + } + console.error('✅ Dependencies installed'); // Auto-restart worker to pick up new code diff --git a/scripts/smart-install.js b/scripts/smart-install.js index 74d6e7c1..4bc01613 100644 --- a/scripts/smart-install.js +++ b/scripts/smart-install.js @@ -7,34 +7,40 @@ */ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { execSync, spawnSync } from 'child_process'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { homedir } from 'os'; +import { fileURLToPath } from 'url'; const IS_WINDOWS = process.platform === 'win32'; /** - * Resolve the marketplace root directory. + * Resolve the plugin root directory where dependencies should be installed. * - * Claude Code may store plugins under either `~/.claude/plugins/` (legacy) or - * `~/.config/claude/plugins/` (XDG-compliant, e.g. Nix-managed installs). - * When `CLAUDE_PLUGIN_ROOT` is set we derive the base from it; otherwise we - * probe both candidate paths and fall back to the legacy location. + * Priority: + * 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks — works for + * both cache-based and marketplace installs) + * 2. Script location (dirname of this file, up one level from scripts/) + * 3. XDG path (~/.config/claude/plugins/marketplaces/thedotmack) + * 4. Legacy path (~/.claude/plugins/marketplaces/thedotmack) */ function resolveRoot() { - const marketplaceRel = join('plugins', 'marketplaces', 'thedotmack'); - - // Derive from CLAUDE_PLUGIN_ROOT (e.g. .../plugins/cache/thedotmack/claude-mem/) + // CLAUDE_PLUGIN_ROOT is the authoritative location set by Claude Code if (process.env.CLAUDE_PLUGIN_ROOT) { - let dir = process.env.CLAUDE_PLUGIN_ROOT; - const cacheIndex = dir.indexOf(join('plugins', 'cache')); - if (cacheIndex !== -1) { - const base = dir.substring(0, cacheIndex); - const candidate = join(base, marketplaceRel); - if (existsSync(join(candidate, 'package.json'))) return candidate; - } + const root = process.env.CLAUDE_PLUGIN_ROOT; + if (existsSync(join(root, 'package.json'))) return root; } - // Probe XDG path first, then legacy + // Derive from script location (this file is in /scripts/) + try { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const candidate = dirname(scriptDir); + if (existsSync(join(candidate, 'package.json'))) return candidate; + } catch { + // import.meta.url not available + } + + // Probe XDG path, then legacy + const marketplaceRel = join('plugins', 'marketplaces', 'thedotmack'); const xdg = join(homedir(), '.config', 'claude', marketplaceRel); if (existsSync(join(xdg, 'package.json'))) return xdg; @@ -275,12 +281,42 @@ function installDeps() { })); } +/** + * Verify that critical runtime modules are resolvable from the install directory. + * Returns true if all critical modules exist, false otherwise. + */ +function verifyCriticalModules() { + const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); + const dependencies = Object.keys(pkg.dependencies || {}); + + const missing = []; + for (const dep of dependencies) { + const modulePath = join(ROOT, 'node_modules', ...dep.split('/')); + if (!existsSync(modulePath)) { + missing.push(dep); + } + } + + if (missing.length > 0) { + console.error(`❌ Post-install check failed: missing modules: ${missing.join(', ')}`); + return false; + } + + return true; +} + // Main execution try { if (!isBunInstalled()) installBun(); if (!isUvInstalled()) installUv(); if (needsInstall()) { installDeps(); + + if (!verifyCriticalModules()) { + console.error('❌ Dependencies could not be installed. Plugin may not work correctly.'); + process.exit(1); + } + console.error('✅ Dependencies installed'); } } catch (e) { diff --git a/tests/smart-install.test.ts b/tests/smart-install.test.ts new file mode 100644 index 00000000..7c3f5f6d --- /dev/null +++ b/tests/smart-install.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +/** + * Smart Install Script Tests + * + * Tests the resolveRoot() and verifyCriticalModules() logic used by + * plugin/scripts/smart-install.js to find the correct install directory + * for cache-based and marketplace installs. + * + * These are unit tests that exercise the resolution logic in isolation + * using temp directories, without running actual bun/npm install. + */ + +const TEST_DIR = join(tmpdir(), `claude-mem-smart-install-test-${process.pid}`); + +function createDir(relativePath: string): string { + const fullPath = join(TEST_DIR, relativePath); + mkdirSync(fullPath, { recursive: true }); + return fullPath; +} + +function createPackageJson(dir: string, version = '10.0.0', deps: Record = {}): void { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'claude-mem-plugin', + version, + dependencies: deps + })); +} + +describe('smart-install resolveRoot logic', () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('should prefer CLAUDE_PLUGIN_ROOT when it contains package.json', () => { + const cacheDir = createDir('cache/thedotmack/claude-mem/10.0.0'); + createPackageJson(cacheDir); + + // Simulate what resolveRoot does + const root = cacheDir; + expect(existsSync(join(root, 'package.json'))).toBe(true); + }); + + it('should detect cache-based install paths', () => { + // Cache installs have paths like ~/.claude/plugins/cache/thedotmack/claude-mem// + const cacheDir = createDir('plugins/cache/thedotmack/claude-mem/10.3.0'); + createPackageJson(cacheDir); + + // Marketplace dir does NOT exist (fresh cache install, no marketplace) + const pluginRoot = cacheDir; + expect(existsSync(join(pluginRoot, 'package.json'))).toBe(true); + // The cache dir is valid — resolveRoot should use it, not try to navigate to marketplace + }); + + it('should fall back to script-relative path when CLAUDE_PLUGIN_ROOT is unset', () => { + // Simulate: scripts/smart-install.js lives in /scripts/ + const pluginRoot = createDir('marketplace-plugin'); + createPackageJson(pluginRoot); + const scriptsDir = createDir('marketplace-plugin/scripts'); + + // dirname(scripts/) = marketplace-plugin/ which has package.json + const candidate = join(scriptsDir, '..'); + expect(existsSync(join(candidate, 'package.json'))).toBe(true); + }); + + it('should handle missing package.json in CLAUDE_PLUGIN_ROOT gracefully', () => { + // CLAUDE_PLUGIN_ROOT points to a dir without package.json + const badDir = createDir('empty-cache-dir'); + expect(existsSync(join(badDir, 'package.json'))).toBe(false); + // resolveRoot should fall through to next candidate + }); +}); + +describe('smart-install verifyCriticalModules logic', () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('should pass when all dependencies exist in node_modules', () => { + const root = createDir('plugin-root'); + createPackageJson(root, '10.0.0', { + '@chroma-core/default-embed': '^0.1.9' + }); + + // Create the module directory + mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true }); + + // Simulate verifyCriticalModules + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')); + const dependencies = Object.keys(pkg.dependencies || {}); + const missing: string[] = []; + for (const dep of dependencies) { + const modulePath = join(root, 'node_modules', ...dep.split('/')); + if (!existsSync(modulePath)) { + missing.push(dep); + } + } + + expect(missing).toEqual([]); + }); + + it('should detect missing dependencies in node_modules', () => { + const root = createDir('plugin-root-missing'); + createPackageJson(root, '10.0.0', { + '@chroma-core/default-embed': '^0.1.9' + }); + + // Do NOT create node_modules — simulate a failed install + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')); + const dependencies = Object.keys(pkg.dependencies || {}); + const missing: string[] = []; + for (const dep of dependencies) { + const modulePath = join(root, 'node_modules', ...dep.split('/')); + if (!existsSync(modulePath)) { + missing.push(dep); + } + } + + expect(missing).toEqual(['@chroma-core/default-embed']); + }); + + it('should handle packages with no dependencies gracefully', () => { + const root = createDir('plugin-root-no-deps'); + createPackageJson(root, '10.0.0', {}); + + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')); + const dependencies = Object.keys(pkg.dependencies || {}); + + expect(dependencies).toEqual([]); + }); + + it('should detect partially installed scoped packages', () => { + const root = createDir('plugin-root-partial'); + createPackageJson(root, '10.0.0', { + '@chroma-core/default-embed': '^0.1.9', + '@chroma-core/other-pkg': '^1.0.0' + }); + + // Only install one of the two packages + mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true }); + + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')); + const dependencies = Object.keys(pkg.dependencies || {}); + const missing: string[] = []; + for (const dep of dependencies) { + const modulePath = join(root, 'node_modules', ...dep.split('/')); + if (!existsSync(modulePath)) { + missing.push(dep); + } + } + + expect(missing).toEqual(['@chroma-core/other-pkg']); + }); +});