refactor: simplify hook execution - use Node directly instead of Bun (#290)

Removes bun-wrapper indirection. Hooks are compiled JavaScript that work perfectly with Node. Worker still uses Bun where performance matters. Fixes #264
This commit is contained in:
Copilot
2025-12-13 20:58:38 -05:00
committed by GitHub
parent 1ac0db25e5
commit 6a63a8d69c
14 changed files with 244 additions and 3951 deletions
-3882
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -7,12 +7,12 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/smart-install.js\" && bun \"${CLAUDE_PLUGIN_ROOT}/scripts/context-hook.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/smart-install.js\" && node \"${CLAUDE_PLUGIN_ROOT}/scripts/context-hook.js\"",
"timeout": 300
},
{
"type": "command",
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/user-message-hook.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-message-hook.js\"",
"timeout": 10
}
]
@@ -23,7 +23,7 @@
"hooks": [
{
"type": "command",
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/new-hook.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/new-hook.js\"",
"timeout": 120
}
]
@@ -35,7 +35,7 @@
"hooks": [
{
"type": "command",
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/save-hook.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/save-hook.js\"",
"timeout": 120
}
]
@@ -46,7 +46,7 @@
"hooks": [
{
"type": "command",
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/summary-hook.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/summary-hook.js\"",
"timeout": 120
}
]
@@ -57,7 +57,7 @@
"hooks": [
{
"type": "command",
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/cleanup-hook.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/cleanup-hook.js\"",
"timeout": 120
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -10
View File
@@ -1,9 +1,10 @@
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
import { createWriteStream } from 'fs';
import { join } from 'path';
import { spawn, spawnSync } from 'child_process';
import { spawn } from 'child_process';
import { homedir } from 'os';
import { DATA_DIR } from '../../shared/paths.js';
import { getBunPath, isBunAvailable } from '../../utils/bun-path.js';
const PID_FILE = join(DATA_DIR, 'worker.pid');
const LOG_DIR = join(DATA_DIR, 'logs');
@@ -56,26 +57,22 @@ export class ProcessManager {
}
private static isBunAvailable(): boolean {
try {
const result = spawnSync('bun', ['--version'], { stdio: 'pipe', timeout: 5000 });
return result.status === 0;
} catch {
return false;
}
return isBunAvailable();
}
private static async startWithBun(script: string, logFile: string, port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
if (!this.isBunAvailable()) {
const bunPath = getBunPath();
if (!bunPath) {
return {
success: false,
error: 'Bun is required but not found in PATH. Install from https://bun.sh'
error: 'Bun is required but not found in PATH or common installation paths. Install from https://bun.sh'
};
}
try {
const isWindows = process.platform === 'win32';
const child = spawn('bun', [script], {
const child = spawn(bunPath, [script], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) },
+77
View File
@@ -0,0 +1,77 @@
/**
* Bun Path Utility
*
* Resolves the Bun executable path for environments where Bun is not in PATH
* (e.g., fish shell users where ~/.config/fish/config.fish isn't read by /bin/sh)
*/
import { spawnSync } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
/**
* Get the Bun executable path
* Tries PATH first, then checks common installation locations
* Returns absolute path if found, null otherwise
*/
export function getBunPath(): string | null {
const isWindows = process.platform === 'win32';
// Try PATH first
try {
const result = spawnSync('bun', ['--version'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows
});
if (result.status === 0) {
return 'bun'; // Available in PATH
}
} catch {
// Not in PATH, continue to check common locations
}
// Check common installation paths
const bunPaths = isWindows
? [join(homedir(), '.bun', 'bin', 'bun.exe')]
: [
join(homedir(), '.bun', 'bin', 'bun'),
'/usr/local/bin/bun',
'/opt/homebrew/bin/bun', // Apple Silicon Homebrew
'/home/linuxbrew/.linuxbrew/bin/bun' // Linux Homebrew
];
for (const bunPath of bunPaths) {
if (existsSync(bunPath)) {
return bunPath;
}
}
return null;
}
/**
* Get the Bun executable path or throw an error
* Use this when Bun is required for operation
*/
export function getBunPathOrThrow(): string {
const bunPath = getBunPath();
if (!bunPath) {
const isWindows = process.platform === 'win32';
const installCmd = isWindows
? 'powershell -c "irm bun.sh/install.ps1 | iex"'
: 'curl -fsSL https://bun.sh/install | bash';
throw new Error(
`Bun is required but not found. Install it with:\n ${installCmd}\nThen restart your terminal.`
);
}
return bunPath;
}
/**
* Check if Bun is available (in PATH or common locations)
*/
export function isBunAvailable(): boolean {
return getBunPath() !== null;
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from 'vitest';
import { existsSync } from 'fs';
import { spawnSync } from 'child_process';
// Mock the dependencies
vi.mock('fs', () => ({
existsSync: vi.fn()
}));
vi.mock('child_process', () => ({
spawnSync: vi.fn()
}));
// Import after mocking
import { getBunPath, isBunAvailable, getBunPathOrThrow } from '../src/utils/bun-path';
describe('bun-path utility', () => {
it('should return "bun" when available in PATH', () => {
// Mock successful bun --version check
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: Buffer.from('1.0.0'),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
const result = getBunPath();
expect(result).toBe('bun');
expect(spawnSync).toHaveBeenCalledWith('bun', ['--version'], expect.any(Object));
});
it('should check common installation paths when not in PATH', () => {
// Mock failed PATH check
vi.mocked(spawnSync).mockReturnValue({
status: 1,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
// Mock existsSync to return true for ~/.bun/bin/bun
vi.mocked(existsSync).mockImplementation((path: any) => {
return path.includes('.bun/bin/bun');
});
const result = getBunPath();
expect(result).toContain('.bun/bin/bun');
});
it('should return null when bun is not found anywhere', () => {
// Mock failed PATH check
vi.mocked(spawnSync).mockReturnValue({
status: 1,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
// Mock existsSync to always return false
vi.mocked(existsSync).mockReturnValue(false);
const result = getBunPath();
expect(result).toBeNull();
});
it('should return true for isBunAvailable when bun is found', () => {
// Mock successful bun check
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: Buffer.from('1.0.0'),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
const result = isBunAvailable();
expect(result).toBe(true);
});
it('should throw error in getBunPathOrThrow when bun not found', () => {
// Mock failed bun check
vi.mocked(spawnSync).mockReturnValue({
status: 1,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
vi.mocked(existsSync).mockReturnValue(false);
expect(() => getBunPathOrThrow()).toThrow('Bun is required');
});
});