feat: implement worker CLI and process management for bun integration

This commit is contained in:
Alex Newman
2025-12-10 23:14:56 -05:00
parent f8108047c4
commit 8bf22b3dc5
5 changed files with 443 additions and 0 deletions
+6
View File
File diff suppressed because one or more lines are too long
+30
View File
@@ -36,6 +36,11 @@ const CONTEXT_GENERATOR = {
source: 'src/services/context-generator.ts'
};
const WORKER_CLI = {
name: 'worker-cli',
source: 'src/cli/worker-cli.ts'
};
async function buildHooks() {
console.log('🔨 Building claude-mem hooks and worker service...\n');
@@ -160,6 +165,31 @@ async function buildHooks() {
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
// Build worker CLI
console.log(`\n🔧 Building worker CLI...`);
await build({
entryPoints: [WORKER_CLI.source],
bundle: true,
platform: 'node',
target: 'node18',
format: 'esm',
outfile: `${hooksDir}/${WORKER_CLI.name}.js`,
minify: true,
logLevel: 'error',
external: ['bun:sqlite'],
define: {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
},
banner: {
js: '#!/usr/bin/env bun'
}
});
// Make worker CLI executable
fs.chmodSync(`${hooksDir}/${WORKER_CLI.name}.js`, 0o755);
const workerCliStats = fs.statSync(`${hooksDir}/${WORKER_CLI.name}.js`);
console.log(`✓ worker-cli built (${(workerCliStats.size / 1024).toFixed(2)} KB)`);
// Build each hook
for (const hook of HOOKS) {
console.log(`\n🔧 Building ${hook.name}...`);
+64
View File
@@ -0,0 +1,64 @@
import { ProcessManager } from '../services/process/ProcessManager.js';
// During migration, use port 38888 to run alongside the PM2-managed worker on 37777
// Once migration is complete (Phase 3+), this will switch to using settings
const MIGRATION_PORT = 38888;
const command = process.argv[2];
const port = MIGRATION_PORT;
async function main() {
switch (command) {
case 'start': {
const result = await ProcessManager.start(port);
if (result.success) {
console.log(`Worker started (PID: ${result.pid})`);
const date = new Date().toISOString().slice(0, 10);
console.log(`Logs: ~/.claude-mem/logs/worker-${date}.log`);
} else {
console.error(`Failed to start: ${result.error}`);
process.exit(1);
}
break;
}
case 'stop': {
await ProcessManager.stop();
console.log('Worker stopped');
break;
}
case 'restart': {
const result = await ProcessManager.restart(port);
if (result.success) {
console.log(`Worker restarted (PID: ${result.pid})`);
} else {
console.error(`Failed to restart: ${result.error}`);
process.exit(1);
}
break;
}
case 'status': {
const status = await ProcessManager.status();
if (status.running) {
console.log('Worker is running');
console.log(` PID: ${status.pid}`);
console.log(` Port: ${status.port}`);
console.log(` Uptime: ${status.uptime}`);
} else {
console.log('Worker is not running');
}
break;
}
default:
console.log('Usage: worker-cli.js <start|stop|restart|status>');
process.exit(1);
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { DATA_DIR } from '../../shared/paths.js';
const BIN_DIR = join(DATA_DIR, 'bin');
const VERSION_FILE = join(BIN_DIR, 'version.txt');
const GITHUB_RELEASES = 'https://github.com/thedotmack/claude-mem/releases/download';
export class BinaryManager {
static async getExecutablePath(): Promise<string> {
if (process.platform !== 'win32') {
throw new Error('BinaryManager only used on Windows');
}
const version = this.getCurrentVersion();
const binaryPath = join(BIN_DIR, 'worker-service.exe');
// Check if we have correct version
if (existsSync(binaryPath)) {
const installed = this.getInstalledVersion();
if (installed === version) return binaryPath;
}
// Download
await this.downloadBinary(version);
return binaryPath;
}
private static async downloadBinary(version: string): Promise<void> {
const url = `${GITHUB_RELEASES}/v${version}/worker-service-v${version}-win-x64.exe`;
console.log(`Downloading worker binary v${version}...`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Download failed: ${response.status}\n` +
`URL: ${url}\n` +
`Make sure the release exists with a Windows binary attached.`
);
}
const buffer = await response.arrayBuffer();
mkdirSync(BIN_DIR, { recursive: true });
const binaryPath = join(BIN_DIR, 'worker-service.exe');
writeFileSync(binaryPath, Buffer.from(buffer));
// Write version file
writeFileSync(VERSION_FILE, version);
console.log('Download complete');
}
private static getCurrentVersion(): string {
// Read from package.json in the installed plugin location
// This ensures we get the correct version even when running from marketplace
try {
const packageJson = JSON.parse(
readFileSync(join(DATA_DIR, '..', '.claude', 'plugins', 'marketplaces', 'thedotmack', 'package.json'), 'utf-8')
);
return packageJson.version;
} catch {
// Fallback to environment variable
return process.env.npm_package_version || 'unknown';
}
}
private static getInstalledVersion(): string | null {
try {
if (!existsSync(VERSION_FILE)) return null;
return readFileSync(VERSION_FILE, 'utf-8').trim();
} catch {
return null;
}
}
}
+267
View File
@@ -0,0 +1,267 @@
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
import { createWriteStream } from 'fs';
import { join } from 'path';
import { spawn } from 'child_process';
import { homedir } from 'os';
import { DATA_DIR } from '../../shared/paths.js';
const PID_FILE = join(DATA_DIR, 'worker.pid');
const LOG_DIR = join(DATA_DIR, 'logs');
const MARKETPLACE_ROOT = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
interface PidInfo {
pid: number;
port: number;
startedAt: string;
version: string;
}
export class ProcessManager {
static async start(port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
// Check if already running
if (await this.isRunning()) {
const info = this.getPidInfo();
return { success: true, pid: info?.pid };
}
// Ensure log directory exists
mkdirSync(LOG_DIR, { recursive: true });
// Get worker script path
const workerScript = join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs');
if (!existsSync(workerScript)) {
return { success: false, error: `Worker script not found at ${workerScript}` };
}
const logFile = this.getLogFilePath();
// Platform-specific spawn
if (process.platform === 'win32') {
return this.startWindows(port);
} else {
return this.startUnix(workerScript, logFile, port);
}
}
private static async startUnix(script: string, logFile: string, port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
try {
const child = spawn('bun', [script], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) },
cwd: MARKETPLACE_ROOT
});
// Write logs
const logStream = createWriteStream(logFile, { flags: 'a' });
child.stdout?.pipe(logStream);
child.stderr?.pipe(logStream);
child.unref();
if (!child.pid) {
return { success: false, error: 'Failed to get PID from spawned process' };
}
// Write PID file
this.writePidFile({
pid: child.pid,
port,
startedAt: new Date().toISOString(),
version: process.env.npm_package_version || 'unknown'
});
// Wait for health
return this.waitForHealth(child.pid, port);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
private static async startWindows(port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
// Windows: Use compiled exe from ~/.claude-mem/bin/
// Import dynamically to avoid loading on non-Windows platforms
const { BinaryManager } = await import('./BinaryManager.js');
try {
const exePath = await BinaryManager.getExecutablePath();
const child = spawn(exePath, [], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) },
windowsHide: true
});
const logFile = this.getLogFilePath();
const logStream = createWriteStream(logFile, { flags: 'a' });
child.stdout?.pipe(logStream);
child.stderr?.pipe(logStream);
child.unref();
if (!child.pid) {
return { success: false, error: 'Failed to get PID from spawned process' };
}
this.writePidFile({
pid: child.pid,
port,
startedAt: new Date().toISOString(),
version: process.env.npm_package_version || 'unknown'
});
return this.waitForHealth(child.pid, port);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
static async stop(timeout: number = 5000): Promise<boolean> {
const info = this.getPidInfo();
if (!info) return true;
try {
process.kill(info.pid, 'SIGTERM');
await this.waitForExit(info.pid, timeout);
} catch {
try {
process.kill(info.pid, 'SIGKILL');
} catch {
// Process already dead
}
}
this.removePidFile();
return true;
}
static async restart(port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
await this.stop();
return this.start(port);
}
static async status(): Promise<{ running: boolean; pid?: number; port?: number; uptime?: string }> {
const info = this.getPidInfo();
if (!info) return { running: false };
const running = this.isProcessAlive(info.pid);
return {
running,
pid: running ? info.pid : undefined,
port: running ? info.port : undefined,
uptime: running ? this.formatUptime(info.startedAt) : undefined
};
}
static async isRunning(): Promise<boolean> {
const info = this.getPidInfo();
if (!info) return false;
return this.isProcessAlive(info.pid);
}
// Helper methods
private static getPidInfo(): PidInfo | null {
try {
if (!existsSync(PID_FILE)) return null;
const content = readFileSync(PID_FILE, 'utf-8');
return JSON.parse(content) as PidInfo;
} catch {
return null;
}
}
private static writePidFile(info: PidInfo): void {
mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(PID_FILE, JSON.stringify(info, null, 2));
}
private static removePidFile(): void {
try {
if (existsSync(PID_FILE)) {
unlinkSync(PID_FILE);
}
} catch {
// Ignore errors
}
}
private static isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
private static async waitForHealth(pid: number, port: number, timeoutMs: number = 10000): Promise<{ success: boolean; pid?: number; error?: string }> {
const startTime = Date.now();
const checkInterval = 200;
while (Date.now() - startTime < timeoutMs) {
// Check if process is still alive
if (!this.isProcessAlive(pid)) {
return { success: false, error: 'Process died during startup' };
}
// Try health check
try {
const response = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(1000)
});
if (response.ok) {
return { success: true, pid };
}
} catch {
// Not ready yet
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
return { success: false, error: 'Health check timed out' };
}
private static async waitForExit(pid: number, timeout: number): Promise<void> {
const startTime = Date.now();
const checkInterval = 100;
while (Date.now() - startTime < timeout) {
if (!this.isProcessAlive(pid)) {
return;
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
throw new Error('Process did not exit within timeout');
}
private static getLogFilePath(): string {
const date = new Date().toISOString().slice(0, 10);
return join(LOG_DIR, `worker-${date}.log`);
}
private static formatUptime(startedAt: string): string {
const startTime = new Date(startedAt).getTime();
const now = Date.now();
const diffMs = now - startTime;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
}