From 4538e686adc486a72a439c5c05b541096503f0f1 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Wed, 15 Apr 2026 09:58:29 +0200 Subject: [PATCH] fix: resolve Setup hook broken reference and warn on macOS-only binary (#1547) (#1696) * fix: resolve Setup hook broken reference and warn on macOS-only binary (#1547) On Linux ARM64, the plugin silently failed because: 1. The Setup hook called setup.sh which was removed; the hook exited 127 (file not found), causing the plugin to appear uninstalled. 2. The committed plugin/scripts/claude-mem binary is macOS arm64 only; no warning was shown when it could not execute on other platforms. Fix the Setup hook to call smart-install.js (the current setup mechanism) and add checkBinaryPlatformCompatibility() to smart-install.js, which reads the Mach-O magic bytes from the bundled binary and warns users on non-macOS platforms that the JS fallback (bun-runner.js + worker-service.cjs) is active. Generated by Claude Code Vibe coded by ousamabenyounes Co-Authored-By: Claude * fix: close fd in finally block, strengthen smart-install tests to use production function - Wrap openSync/readSync in checkBinaryPlatformCompatibility with a finally block so the file descriptor is always closed even if readSync throws - Export checkBinaryPlatformCompatibility with an optional binaryPath param for testability - Refactor Mach-O detection tests to call the production function directly, mocking process.platform and passing controlled binary paths, eliminating duplicated inline logic - Strengthen plugin-distribution test to assert at least one command hook exists before checking for smart-install.js, preventing vacuous pass Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude --- plugin/hooks/hooks.json | 2 +- plugin/scripts/smart-install.js | 55 +++++++- .../plugin-distribution.test.ts | 35 ++++++ tests/smart-install.test.ts | 117 ++++++++++++++++++ 4 files changed, 207 insertions(+), 2 deletions(-) diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index f56cda5e..9628ad45 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"", + "command": "export PATH=\"$HOME/.nvm/versions/node/v$(ls \\\"$HOME/.nvm/versions/node\\\" 2>/dev/null | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; _R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=$(ls -dt $HOME/.claude/plugins/cache/thedotmack/claude-mem/[0-9]*/ 2>/dev/null | head -1); _R=\"${_R%/}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"", "timeout": 300 } ] diff --git a/plugin/scripts/smart-install.js b/plugin/scripts/smart-install.js index 583b58c7..4d2f1a37 100644 --- a/plugin/scripts/smart-install.js +++ b/plugin/scripts/smart-install.js @@ -9,7 +9,7 @@ * for both cache and marketplace installs), falling back to script location * and legacy paths. */ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, openSync, readSync, closeSync } from 'fs'; import { execSync, spawnSync } from 'child_process'; import { join, dirname } from 'path'; import { homedir } from 'os'; @@ -490,6 +490,56 @@ function verifyCriticalModules() { return true; } +// Mach-O 64-bit magic values as seen when reading the first 4 file bytes with readUInt32LE. +// Native arm64/x86_64 Mach-O files start with bytes [CF FA ED FE]; readUInt32LE gives 0xFEEDFACF. +// Byte-swapped (big-endian) Mach-O files start with bytes [FE ED FA CF]; readUInt32LE gives 0xCFFAEDFE. +const MACHO_MAGIC_NATIVE = 0xFEEDFACF; // native 64-bit (arm64/x86_64) — file bytes CF FA ED FE +const MACHO_MAGIC_SWAPPED = 0xCFFAEDFE; // byte-swapped 64-bit — file bytes FE ED FA CF + +/** + * Warn when the bundled claude-mem binary cannot run on the current platform. + * + * The committed binary (plugin/scripts/claude-mem) is compiled for macOS arm64. + * On Linux or Windows it produces "Exec format error" and silently fails. + * This check surfaces the incompatibility at install time so users know why + * the binary path doesn't work, and confirms the JS fallback (bun-runner.js → + * worker-service.cjs) is active and covers all functionality. + * + * Fixes #1547 — Plugin silently fails on Linux ARM64. + */ +export function checkBinaryPlatformCompatibility(binaryPath = join(ROOT, 'scripts', 'claude-mem')) { + + if (!existsSync(binaryPath)) { + return; // Binary absent — nothing to check (e.g. after npm install which excludes it) + } + + // The binary only matters on non-macOS platforms; on macOS it works correctly. + if (process.platform === 'darwin') { + return; + } + + // Read the first 4 bytes to identify the binary format. + let fd; + try { + const buf = Buffer.alloc(4); + fd = openSync(binaryPath, 'r'); + readSync(fd, buf, 0, 4, 0); + + const magic = buf.readUInt32LE(0); + if (magic === MACHO_MAGIC_NATIVE || magic === MACHO_MAGIC_SWAPPED) { + console.error('⚠️ Platform notice: The bundled claude-mem binary is macOS-only.'); + console.error(` Current platform: ${process.platform} ${process.arch}`); + console.error(' The binary will not execute on this platform.'); + console.error(' Plugin functionality is provided by the JS fallback'); + console.error(' (bun-runner.js → worker-service.cjs) which works on all platforms.'); + } + } catch { + // Unreadable binary — not critical, skip silently + } finally { + if (fd !== undefined) closeSync(fd); + } +} + // Main execution try { // Step 1: Ensure Bun is installed and meets minimum version (REQUIRED) @@ -582,6 +632,9 @@ try { // Step 4: Install CLI to PATH installCLI(); + // Step 5: Warn if the bundled native binary is incompatible with this platform + checkBinaryPlatformCompatibility(); + // Output valid JSON for Claude Code hook contract console.log(JSON.stringify({ continue: true, suppressOutput: true })); } catch (e) { diff --git a/tests/infrastructure/plugin-distribution.test.ts b/tests/infrastructure/plugin-distribution.test.ts index 7daeb380..e48e0613 100644 --- a/tests/infrastructure/plugin-distribution.test.ts +++ b/tests/infrastructure/plugin-distribution.test.ts @@ -138,3 +138,38 @@ describe('Plugin Distribution - Build Script Verification', () => { expect(content).toContain('plugin/.claude-plugin/plugin.json'); }); }); + +describe('Plugin Distribution - Setup Hook (#1547)', () => { + it('should not reference removed setup.sh in Setup hook', () => { + // setup.sh was removed; the Setup hook must not reference it or the + // plugin silently fails to install on Linux (hooks disabled on setup failure). + const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json'); + const content = readFileSync(hooksPath, 'utf-8'); + expect(content).not.toContain('setup.sh'); + }); + + it('should call smart-install.js in the Setup hook', () => { + const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json'); + const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8')); + const setupHooks: any[] = parsed.hooks['Setup'] ?? []; + + // Collect all command hooks from all matchers + const commandHooks = setupHooks.flatMap((matcher: any) => + (matcher.hooks ?? []).filter((h: any) => h.type === 'command') + ); + + // There must be at least one command hook — otherwise the test vacuously passes + expect(commandHooks.length).toBeGreaterThan(0); + + // At least one command hook must reference smart-install.js + const smartInstallHooks = commandHooks.filter((h: any) => + h.command?.includes('smart-install.js') + ); + expect(smartInstallHooks.length).toBeGreaterThan(0); + }); + + it('smart-install.js referenced by Setup hook should exist on disk', () => { + const smartInstallPath = path.join(projectRoot, 'plugin/scripts/smart-install.js'); + expect(existsSync(smartInstallPath)).toBe(true); + }); +}); diff --git a/tests/smart-install.test.ts b/tests/smart-install.test.ts index 2a79557f..d702e66c 100644 --- a/tests/smart-install.test.ts +++ b/tests/smart-install.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; +import { checkBinaryPlatformCompatibility } from '../plugin/scripts/smart-install.js'; /** * Smart Install Script Tests @@ -237,3 +238,119 @@ describe('smart-install stdout JSON output (#1253)', () => { } }); }); + +/** + * Tests for checkBinaryPlatformCompatibility() (#1547). + * + * The bundled plugin/scripts/claude-mem binary is macOS arm64 only. + * On Linux/Windows it cannot execute and hooks fail silently. + * These tests call the production function directly, mocking process.platform + * and passing controlled binary paths to verify Mach-O detection behaviour. + */ +describe('smart-install binary platform compatibility (#1547)', () => { + let testDir: string; + let originalPlatform: PropertyDescriptor | undefined; + + beforeEach(() => { + testDir = join(tmpdir(), `claude-mem-binary-compat-test-${process.pid}`); + mkdirSync(testDir, { recursive: true }); + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + // Restore process.platform + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + }); + + function setPlatform(value: string) { + Object.defineProperty(process, 'platform', { value, configurable: true }); + } + + it('should detect native arm64/x86_64 Mach-O binary and warn on Linux', () => { + // Real macOS arm64 binary header: bytes CF FA ED FE (MH_MAGIC_64) + const binaryPath = join(testDir, 'claude-mem'); + writeFileSync(binaryPath, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x0C, 0x00, 0x00, 0x01])); + + const stderrLines: string[] = []; + const originalError = console.error; + console.error = (...args: any[]) => stderrLines.push(args.join(' ')); + + setPlatform('linux'); + try { + checkBinaryPlatformCompatibility(binaryPath); + } finally { + console.error = originalError; + } + + expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(true); + expect(stderrLines.some(l => l.includes('linux'))).toBe(true); + }); + + it('should detect byte-swapped Mach-O binary and warn on Linux', () => { + // Byte-swapped 64-bit Mach-O: bytes FE ED FA CF (MH_CIGAM_64) + const binaryPath = join(testDir, 'claude-mem-swapped'); + writeFileSync(binaryPath, Buffer.from([0xFE, 0xED, 0xFA, 0xCF, 0x01, 0x00, 0x00, 0x0C])); + + const stderrLines: string[] = []; + const originalError = console.error; + console.error = (...args: any[]) => stderrLines.push(args.join(' ')); + + setPlatform('linux'); + try { + checkBinaryPlatformCompatibility(binaryPath); + } finally { + console.error = originalError; + } + + expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(true); + }); + + it('should NOT warn for an ELF binary (Linux native) on Linux', () => { + // ELF magic: 0x7F 'E' 'L' 'F' + const binaryPath = join(testDir, 'claude-mem-elf'); + writeFileSync(binaryPath, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00])); + + const stderrLines: string[] = []; + const originalError = console.error; + console.error = (...args: any[]) => stderrLines.push(args.join(' ')); + + setPlatform('linux'); + try { + checkBinaryPlatformCompatibility(binaryPath); + } finally { + console.error = originalError; + } + + expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(false); + }); + + it('should not throw when binary path does not exist', () => { + const binaryPath = join(testDir, 'nonexistent-claude-mem'); + expect(existsSync(binaryPath)).toBe(false); + + setPlatform('linux'); + expect(() => checkBinaryPlatformCompatibility(binaryPath)).not.toThrow(); + }); + + it('should skip the check entirely when platform is darwin', () => { + // Write a Mach-O binary — on macOS the check returns early, so no warning + const binaryPath = join(testDir, 'claude-mem'); + writeFileSync(binaryPath, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x0C, 0x00, 0x00, 0x01])); + + const stderrLines: string[] = []; + const originalError = console.error; + console.error = (...args: any[]) => stderrLines.push(args.join(' ')); + + setPlatform('darwin'); + try { + checkBinaryPlatformCompatibility(binaryPath); + } finally { + console.error = originalError; + } + + expect(stderrLines.length).toBe(0); + }); +});