import * as plugins from './plugins.js'; import * as paths from './paths.js'; import { spawn } from 'child_process'; import type { IpcMethodMap, RequestForMethod, ResponseForMethod, } from './ipc.types.js'; /** * IPC client for communicating with the TSPM daemon */ export class TspmIpcClient { private ipcClient: plugins.smartipc.IpcClient | null = null; private socketPath: string; private daemonPidFile: string; private isConnected: boolean = false; constructor() { this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock'); this.daemonPidFile = plugins.path.join(paths.tspmDir, 'daemon.pid'); } /** * Connect to the daemon, starting it if necessary */ public async connect(): Promise { // Check if already connected if (this.isConnected && this.ipcClient) { return; } // Check if daemon is running const daemonRunning = await this.isDaemonRunning(); if (!daemonRunning) { console.log('Daemon not running, starting it...'); await this.startDaemon(); // Wait a bit for daemon to initialize await new Promise((resolve) => setTimeout(resolve, 1000)); } // Create IPC client this.ipcClient = new plugins.smartipc.IpcClient({ id: 'tspm-cli', socketPath: this.socketPath, }); // Connect to the daemon try { await this.ipcClient.connect(); this.isConnected = true; console.log('Connected to TSPM daemon'); } catch (error) { console.error('Failed to connect to daemon:', error); throw new Error( 'Could not connect to TSPM daemon. Please try running "tspm daemon start" manually.', ); } } /** * Disconnect from the daemon */ public async disconnect(): Promise { if (this.ipcClient) { await this.ipcClient.disconnect(); this.ipcClient = null; this.isConnected = false; } } /** * Send a request to the daemon */ public async request( method: M, params: RequestForMethod, ): Promise> { if (!this.isConnected || !this.ipcClient) { await this.connect(); } try { const response = await this.ipcClient!.request< RequestForMethod, ResponseForMethod >(method, params); return response; } catch (error) { // Handle connection errors by trying to reconnect once if ( error.message?.includes('ECONNREFUSED') || error.message?.includes('ENOENT') ) { console.log('Connection lost, attempting to reconnect...'); this.isConnected = false; await this.connect(); // Retry the request return await this.ipcClient!.request< RequestForMethod, ResponseForMethod >(method, params); } throw error; } } /** * Check if the daemon is running */ private async isDaemonRunning(): Promise { try { const fs = await import('fs'); // Check if PID file exists try { const pidContent = await fs.promises.readFile( this.daemonPidFile, 'utf-8', ); const pid = parseInt(pidContent.trim(), 10); // Check if process is running try { process.kill(pid, 0); // Also check if socket exists and is accessible try { await fs.promises.access(this.socketPath); return true; } catch { // Socket doesn't exist, daemon might be starting return false; } } catch { // Process doesn't exist, clean up stale PID file await fs.promises.unlink(this.daemonPidFile).catch(() => {}); return false; } } catch { // PID file doesn't exist return false; } } catch { return false; } } /** * Start the daemon process */ private async startDaemon(): Promise { const daemonScript = plugins.path.join( paths.packageDir, 'dist_ts', 'daemon.js', ); // Spawn the daemon as a detached process const daemonProcess = spawn(process.execPath, [daemonScript], { detached: true, stdio: ['ignore', 'ignore', 'ignore'], env: { ...process.env, TSPM_DAEMON_MODE: 'true', }, }); // Unref the process so the parent can exit daemonProcess.unref(); console.log(`Started daemon process with PID: ${daemonProcess.pid}`); // Wait for daemon to be ready (check for socket file) const maxWaitTime = 10000; // 10 seconds const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { if (await this.isDaemonRunning()) { return; } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error('Daemon failed to start within timeout period'); } /** * Stop the daemon */ public async stopDaemon(graceful: boolean = true): Promise { if (!(await this.isDaemonRunning())) { console.log('Daemon is not running'); return; } try { await this.connect(); await this.request('daemon:shutdown', { graceful, timeout: 10000, }); console.log('Daemon shutdown initiated'); // Wait for daemon to actually stop const maxWaitTime = 15000; // 15 seconds const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { if (!(await this.isDaemonRunning())) { console.log('Daemon stopped successfully'); return; } await new Promise((resolve) => setTimeout(resolve, 500)); } console.warn( 'Daemon did not stop within timeout, it may still be running', ); } catch (error) { console.error('Error stopping daemon:', error); // Try to kill the process directly if graceful shutdown failed try { const fs = await import('fs'); const pidContent = await fs.promises.readFile( this.daemonPidFile, 'utf-8', ); const pid = parseInt(pidContent.trim(), 10); process.kill(pid, 'SIGKILL'); console.log('Force killed daemon process'); } catch { console.error('Could not force kill daemon'); } } } /** * Get daemon status */ public async getDaemonStatus(): Promise | null> { try { if (!(await this.isDaemonRunning())) { return null; } await this.connect(); return await this.request('daemon:status', {}); } catch (error) { console.error('Error getting daemon status:', error); return null; } } } // Singleton instance export const tspmIpcClient = new TspmIpcClient();