/** Port file discovery and app health check. */ import { readFile, unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { homedir } from 'node:os'; const PORT_FILE_DIR = '.openpencil'; const PORT_FILE_NAME = '.port'; const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR, PORT_FILE_NAME); const APP_BASE_URLS = ['http://127.0.0.1', 'http://localhost']; async function getReachableAppUrl(port: number): Promise { for (const baseUrl of APP_BASE_URLS) { const url = `${baseUrl}:${port}/api/mcp/server`; for (let attempt = 0; attempt < 5; attempt++) { try { const res = await fetch(url, { signal: AbortSignal.timeout(500), }); if (res.ok) return `${baseUrl}:${port}`; } catch { // App may still be starting, or the port file may be stale. } if (attempt < 4) { await new Promise((resolve) => setTimeout(resolve, 200)); } } } return null; } function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (err: unknown) { return (err as NodeJS.ErrnoException).code === 'EPERM'; } } export interface AppInfo { port: number; pid: number; timestamp: number; url: string; } /** Read port file and return app info, or null if no running instance. */ export async function getAppInfo(): Promise { try { const raw = await readFile(PORT_FILE_PATH, 'utf-8'); const { port, pid, timestamp } = JSON.parse(raw) as { port: number; pid: number; timestamp: number; }; const url = await getReachableAppUrl(port); if (url) { return { port, pid, timestamp, url }; } if (!isPidAlive(pid)) { try { await unlink(PORT_FILE_PATH); } catch { // Ignore stale port file cleanup failures. } return null; } return null; } catch { return null; } } /** Get app URL or throw if not running. */ export async function requireApp(): Promise { const info = await getAppInfo(); if (!info) { throw new Error('No running OpenPencil instance found. Run `openpencil start` first.'); } return info.url; } /** Quick check if app is running. */ export async function isAppRunning(): Promise { return (await getAppInfo()) !== null; }