launcher.ts
1 /** 2 * Electron app launcher — auto-detect, confirm, launch, and connect. 3 * 4 * Flow: 5 * 1. Probe CDP port → already running with debug? connect directly 6 * 2. Detect process → running without CDP? prompt to restart 7 * 3. Discover app path → not installed? error 8 * 4. Launch with --remote-debugging-port 9 * 5. Poll /json until ready 10 */ 11 12 import { execFileSync, spawn } from 'node:child_process'; 13 import { request as httpRequest } from 'node:http'; 14 import * as path from 'node:path'; 15 import type { ElectronAppEntry } from './electron-apps.js'; 16 import { getElectronApp } from './electron-apps.js'; 17 import { confirmPrompt } from './tui.js'; 18 import { CommandExecutionError } from './errors.js'; 19 import { log } from './logger.js'; 20 21 const POLL_INTERVAL_MS = 500; 22 const POLL_TIMEOUT_MS = 15_000; 23 const PROBE_TIMEOUT_MS = 2_000; 24 const KILL_GRACE_MS = 3_000; 25 26 /** 27 * Probe whether a CDP endpoint is listening on the given port. 28 * Returns true if http://127.0.0.1:{port}/json responds successfully. 29 */ 30 export function probeCDP(port: number, timeoutMs: number = PROBE_TIMEOUT_MS): Promise<boolean> { 31 return new Promise((resolve) => { 32 const req = httpRequest( 33 { hostname: '127.0.0.1', port, path: '/json', method: 'GET', timeout: timeoutMs }, 34 (res) => { 35 res.resume(); 36 resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300); 37 }, 38 ); 39 req.on('error', () => resolve(false)); 40 req.on('timeout', () => { req.destroy(); resolve(false); }); 41 req.end(); 42 }); 43 } 44 45 /** 46 * Check if a process with the given name is running. 47 * Uses pgrep on macOS/Linux. 48 */ 49 export function detectProcess(processName: string): boolean { 50 if (process.platform === 'win32') return false; // pgrep not available on Windows 51 try { 52 execFileSync('pgrep', ['-x', processName], { encoding: 'utf-8', stdio: 'pipe' }); 53 return true; 54 } catch { 55 return false; 56 } 57 } 58 59 /** 60 * Kill a process by name. Sends SIGTERM first, then SIGKILL after grace period. 61 */ 62 export async function killProcess(processName: string): Promise<void> { 63 if (process.platform === 'win32') return; // pkill not available on Windows 64 try { 65 execFileSync('pkill', ['-x', processName], { stdio: 'pipe' }); 66 } catch { 67 // Process may have already exited 68 } 69 70 const deadline = Date.now() + KILL_GRACE_MS; 71 while (Date.now() < deadline) { 72 if (!detectProcess(processName)) return; 73 await new Promise((r) => setTimeout(r, 200)); 74 } 75 76 try { 77 execFileSync('pkill', ['-9', '-x', processName], { stdio: 'pipe' }); 78 } catch { 79 // Ignore 80 } 81 } 82 83 /** 84 * Discover the app installation path on macOS. 85 * Uses osascript to resolve the app name to a POSIX path. 86 * Returns null if the app is not installed. 87 */ 88 export function discoverAppPath(displayName: string): string | null { 89 if (process.platform !== 'darwin') { 90 return null; 91 } 92 93 try { 94 const result = execFileSync('osascript', [ 95 '-e', `POSIX path of (path to application "${displayName}")`, 96 ], { encoding: 'utf-8', stdio: 'pipe', timeout: 5_000 }); 97 return result.trim().replace(/\/$/, ''); 98 } catch { 99 return null; 100 } 101 } 102 103 function resolveExecutable(appPath: string, processName: string): string { 104 return `${appPath}/Contents/MacOS/${processName}`; 105 } 106 107 function isMissingExecutableError(err: unknown, label: string): boolean { 108 return err instanceof CommandExecutionError 109 && err.message.startsWith(`Could not launch ${label}: executable not found at `); 110 } 111 112 export function resolveExecutableCandidates(appPath: string, app: ElectronAppEntry): string[] { 113 const executableNames = app.executableNames?.length ? app.executableNames : [app.processName]; 114 return [...new Set(executableNames)].map((name) => resolveExecutable(appPath, name)); 115 } 116 117 export async function launchDetachedApp(executable: string, args: string[], label: string): Promise<void> { 118 await new Promise<void>((resolve, reject) => { 119 const child = spawn(executable, args, { 120 detached: true, 121 stdio: 'ignore', 122 }); 123 124 const onError = (err: NodeJS.ErrnoException): void => { 125 if (err.code === 'ENOENT') { 126 reject(new CommandExecutionError( 127 `Could not launch ${label}: executable not found at ${executable}`, 128 `Install ${label}, reinstall it, or register a custom app path in ~/.opencli/apps.yaml`, 129 )); 130 return; 131 } 132 133 reject(new CommandExecutionError( 134 `Failed to launch ${label}`, 135 err.message, 136 )); 137 }; 138 139 child.once('error', onError); 140 child.once('spawn', () => { 141 child.off('error', onError); 142 child.unref(); 143 resolve(); 144 }); 145 }); 146 } 147 148 export async function launchElectronApp(appPath: string, app: ElectronAppEntry, args: string[], label: string): Promise<void> { 149 const executables = resolveExecutableCandidates(appPath, app); 150 let lastMissingExecutableError: CommandExecutionError | undefined; 151 152 for (const executable of executables) { 153 log.debug(`[launcher] Launching: ${executable} ${args.join(' ')}`); 154 try { 155 await launchDetachedApp(executable, args, label); 156 return; 157 } catch (err) { 158 if (isMissingExecutableError(err, label)) { 159 lastMissingExecutableError = err as CommandExecutionError; 160 continue; 161 } 162 throw err; 163 } 164 } 165 166 if (executables.length > 1) { 167 throw new CommandExecutionError( 168 `Could not launch ${label}: no compatible executable found in ${path.join(appPath, 'Contents', 'MacOS')}`, 169 `Tried: ${executables.map((executable) => path.basename(executable)).join(', ')}. Install ${label}, reinstall it, or register a custom app path in ~/.opencli/apps.yaml`, 170 ); 171 } 172 173 throw lastMissingExecutableError ?? new CommandExecutionError( 174 `Could not launch ${label}`, 175 `Install ${label}, reinstall it, or register a custom app path in ~/.opencli/apps.yaml`, 176 ); 177 } 178 179 async function pollForReady(port: number): Promise<void> { 180 const deadline = Date.now() + POLL_TIMEOUT_MS; 181 while (Date.now() < deadline) { 182 if (await probeCDP(port, 1_000)) return; 183 await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); 184 } 185 throw new CommandExecutionError( 186 `App launched but CDP not available on port ${port} after ${POLL_TIMEOUT_MS / 1000}s`, 187 'The app may be slow to start. Try running the command again.', 188 ); 189 } 190 191 /** 192 * Main entry point: resolve an Electron app to a CDP endpoint URL. 193 * 194 * Returns the endpoint URL: http://127.0.0.1:{port} 195 */ 196 export async function resolveElectronEndpoint(site: string): Promise<string> { 197 const app = getElectronApp(site); 198 if (!app) { 199 throw new CommandExecutionError( 200 `No Electron app registered for site "${site}"`, 201 'Register the app in ~/.opencli/apps.yaml or check the site name.', 202 ); 203 } 204 205 const { port, processName, displayName } = app; 206 const label = displayName ?? processName; 207 const endpoint = `http://127.0.0.1:${port}`; 208 209 // Step 1: Already running with CDP? 210 log.debug(`[launcher] Probing CDP on port ${port}...`); 211 if (await probeCDP(port)) { 212 log.debug(`[launcher] CDP already available on port ${port}`); 213 return endpoint; 214 } 215 216 // Step 2: Running without CDP? (process detection requires Unix tools) 217 if (process.platform !== 'darwin' && process.platform !== 'linux') { 218 throw new CommandExecutionError( 219 `${label} is not reachable on CDP port ${port}.`, 220 `Auto-launch is not yet supported on ${process.platform}.\n` + 221 `Start ${label} manually with --remote-debugging-port=${port}, then either:\n` + 222 ` • Set OPENCLI_CDP_ENDPOINT=http://127.0.0.1:${port}\n` + 223 ` • Or just re-run the command once ${label} is listening on port ${port}.`, 224 ); 225 } 226 227 const isRunning = detectProcess(processName); 228 if (isRunning) { 229 log.debug(`[launcher] ${label} is running but CDP not available`); 230 const confirmed = await confirmPrompt( 231 `${label} is running but CDP is not enabled. Restart with debug port?`, 232 true, 233 ); 234 if (!confirmed) { 235 throw new CommandExecutionError( 236 `${label} needs to be restarted with CDP enabled.`, 237 `Manually restart: kill the app and relaunch with --remote-debugging-port=${port}`, 238 ); 239 } 240 process.stderr.write(` Restarting ${label}...\n`); 241 await killProcess(processName); 242 } 243 244 // Step 3: Discover path 245 const appPath = discoverAppPath(label); 246 if (!appPath) { 247 throw new CommandExecutionError( 248 `Could not find ${label} on this machine.`, 249 `Install ${label} or register a custom path in ~/.opencli/apps.yaml`, 250 ); 251 } 252 253 // Step 4: Launch 254 const args = [`--remote-debugging-port=${port}`, ...(app.extraArgs ?? [])]; 255 await launchElectronApp(appPath, app, args, label); 256 257 // Step 5: Poll for readiness 258 process.stderr.write(` Waiting for ${label} on port ${port}...\n`); 259 await pollForReady(port); 260 process.stderr.write(` Connected to ${label} on port ${port}.\n`); 261 262 return endpoint; 263 }