#!/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;
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);
});