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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ben Younes
2026-04-15 09:58:29 +02:00
committed by GitHub
parent f97c50bfb9
commit 4538e686ad
4 changed files with 207 additions and 2 deletions

View File

@@ -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
}
]

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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);
});
});