/ src / launcher.ts
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  }