#!/usr/bin/env node /** * Dev launcher: probe the three dev ports (server / gateway / vite), find the * first free one for each starting from the project defaults, then exec the * existing `concurrently` script with the resolved values injected as env so * gateway / server / vite all bind/connect to matching numbers. * * This means a stale leftover process on 3001 (or another team member's tool * occupying 18789) no longer breaks `npm run dev` — the launcher just slides * over to 3002 / 18790 / etc. and prints the resolved map up top. * * Port resolution priority (highest wins): * SERVER_PORT / VITE_PORT (env hard-pin, skips probing) * > SERVER_PORT_BASE / VITE_PORT_BASE (env base override) * > webui.runtime.serverPort / vitePort (from ~/.pilotdeck/pilotdeck.yaml) * > 3001 / 5173 (hardcoded defaults) * * Hard-pinned ports still win — if SERVER_PORT / PILOTDECK_GATEWAY_PORT / * VITE_PORT are already exported the launcher trusts them and skips probing * (so prod-style setups don't accidentally slide). */ import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { createServer } from 'node:net'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); function readYamlPortConfig() { const home = process.env.PILOT_HOME || join(homedir(), '.pilotdeck'); const configPath = process.env.PILOTDECK_CONFIG_PATH || join(home, 'pilotdeck.yaml'); try { const raw = readFileSync(configPath, 'utf8'); const config = parseYaml(raw); return config?.webui?.runtime ?? {}; } catch { return {}; } } const yamlRuntime = readYamlPortConfig(); const SERVER_PORT_BASE = parsePort(process.env.SERVER_PORT_BASE, yamlRuntime.serverPort ?? 3001); const GATEWAY_PORT_BASE = parsePort(process.env.PILOTDECK_GATEWAY_PORT_BASE, 18789); const VITE_PORT_BASE = parsePort(process.env.VITE_PORT_BASE, yamlRuntime.vitePort ?? 5173); const MAX_PORT_TRIES = 20; function parsePort(value, fallback) { const parsed = Number.parseInt(value ?? '', 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function isPortFree(port, host = '0.0.0.0') { return new Promise((resolveCheck) => { const probe = createServer(); probe.once('error', () => resolveCheck(false)); probe.once('listening', () => { probe.close(() => resolveCheck(true)); }); probe.listen(port, host); }); } async function findFreePort(label, base, hardOverride) { if (hardOverride !== undefined) { return { port: hardOverride, source: 'env-pinned' }; } for (let offset = 0; offset < MAX_PORT_TRIES; offset += 1) { const candidate = base + offset; // eslint-disable-next-line no-await-in-loop const free = await isPortFree(candidate); if (free) { return { port: candidate, source: offset === 0 ? 'default' : `fallback (+${offset})`, }; } } throw new Error( `[dev-launcher] Could not find a free ${label} port within ${MAX_PORT_TRIES} of ${base}.`, ); } function envPortOverride(name) { const raw = process.env[name]; if (raw === undefined || raw === '') return undefined; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed <= 0) return undefined; return parsed; } async function main() { const server = await findFreePort('server', SERVER_PORT_BASE, envPortOverride('SERVER_PORT')); const gateway = await findFreePort('gateway', GATEWAY_PORT_BASE, envPortOverride('PILOTDECK_GATEWAY_PORT')); const vite = await findFreePort('vite', VITE_PORT_BASE, envPortOverride('VITE_PORT')); const map = [ ['server (express/ws)', server], ['gateway (pilotdeck)', gateway], ['vite client ', vite], ]; console.log('[dev-launcher] resolved dev ports:'); for (const [label, info] of map) { console.log(` ${label} → ${info.port} ${info.source !== 'default' ? `(${info.source})` : ''}`); } console.log(''); const env = { ...process.env, SERVER_PORT: String(server.port), PILOTDECK_GATEWAY_PORT: String(gateway.port), PILOTDECK_GATEWAY_URL: process.env.PILOTDECK_GATEWAY_URL ?? `ws://127.0.0.1:${gateway.port}/ws`, VITE_PORT: String(vite.port), PILOTDECK_SKIP_DEFAULT_PROJECT: '1', }; const child = spawn( 'npm', ['--workspace', 'ui', 'run', 'dev:concurrent'], { cwd: repoRoot, env, stdio: 'inherit' }, ); const forward = (signal) => { if (!child.killed) child.kill(signal); }; process.on('SIGINT', () => forward('SIGINT')); process.on('SIGTERM', () => forward('SIGTERM')); child.on('exit', (code, signal) => { if (signal) { process.kill(process.pid, signal); return; } process.exit(code ?? 0); }); } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });