dev-launcher.mjs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. #!/usr/bin/env node
  2. /**
  3. * Dev launcher: probe the three dev ports (server / gateway / vite), find the
  4. * first free one for each starting from the project defaults, then exec the
  5. * existing `concurrently` script with the resolved values injected as env so
  6. * gateway / server / vite all bind/connect to matching numbers.
  7. *
  8. * This means a stale leftover process on 3001 (or another team member's tool
  9. * occupying 18789) no longer breaks `npm run dev` — the launcher just slides
  10. * over to 3002 / 18790 / etc. and prints the resolved map up top.
  11. *
  12. * Port resolution priority (highest wins):
  13. * SERVER_PORT / VITE_PORT (env hard-pin, skips probing)
  14. * > SERVER_PORT_BASE / VITE_PORT_BASE (env base override)
  15. * > webui.runtime.serverPort / vitePort (from ~/.pilotdeck/pilotdeck.yaml)
  16. * > 3001 / 5173 (hardcoded defaults)
  17. *
  18. * Hard-pinned ports still win — if SERVER_PORT / PILOTDECK_GATEWAY_PORT /
  19. * VITE_PORT are already exported the launcher trusts them and skips probing
  20. * (so prod-style setups don't accidentally slide).
  21. */
  22. import { spawn } from 'node:child_process';
  23. import { readFileSync } from 'node:fs';
  24. import { createServer } from 'node:net';
  25. import { homedir } from 'node:os';
  26. import { dirname, join, resolve } from 'node:path';
  27. import { fileURLToPath } from 'node:url';
  28. import { parse as parseYaml } from 'yaml';
  29. const __dirname = dirname(fileURLToPath(import.meta.url));
  30. const repoRoot = resolve(__dirname, '..');
  31. function readYamlPortConfig() {
  32. const home = process.env.PILOT_HOME || join(homedir(), '.pilotdeck');
  33. const configPath = process.env.PILOTDECK_CONFIG_PATH || join(home, 'pilotdeck.yaml');
  34. try {
  35. const raw = readFileSync(configPath, 'utf8');
  36. const config = parseYaml(raw);
  37. return config?.webui?.runtime ?? {};
  38. } catch {
  39. return {};
  40. }
  41. }
  42. const yamlRuntime = readYamlPortConfig();
  43. const SERVER_PORT_BASE = parsePort(process.env.SERVER_PORT_BASE, yamlRuntime.serverPort ?? 3001);
  44. const GATEWAY_PORT_BASE = parsePort(process.env.PILOTDECK_GATEWAY_PORT_BASE, 18789);
  45. const VITE_PORT_BASE = parsePort(process.env.VITE_PORT_BASE, yamlRuntime.vitePort ?? 5173);
  46. const MAX_PORT_TRIES = 20;
  47. function parsePort(value, fallback) {
  48. const parsed = Number.parseInt(value ?? '', 10);
  49. return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
  50. }
  51. function isPortFree(port, host = '0.0.0.0') {
  52. return new Promise((resolveCheck) => {
  53. const probe = createServer();
  54. probe.once('error', () => resolveCheck(false));
  55. probe.once('listening', () => {
  56. probe.close(() => resolveCheck(true));
  57. });
  58. probe.listen(port, host);
  59. });
  60. }
  61. async function findFreePort(label, base, hardOverride) {
  62. if (hardOverride !== undefined) {
  63. return { port: hardOverride, source: 'env-pinned' };
  64. }
  65. for (let offset = 0; offset < MAX_PORT_TRIES; offset += 1) {
  66. const candidate = base + offset;
  67. // eslint-disable-next-line no-await-in-loop
  68. const free = await isPortFree(candidate);
  69. if (free) {
  70. return {
  71. port: candidate,
  72. source: offset === 0 ? 'default' : `fallback (+${offset})`,
  73. };
  74. }
  75. }
  76. throw new Error(
  77. `[dev-launcher] Could not find a free ${label} port within ${MAX_PORT_TRIES} of ${base}.`,
  78. );
  79. }
  80. function envPortOverride(name) {
  81. const raw = process.env[name];
  82. if (raw === undefined || raw === '') return undefined;
  83. const parsed = Number.parseInt(raw, 10);
  84. if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
  85. return parsed;
  86. }
  87. async function main() {
  88. const server = await findFreePort('server', SERVER_PORT_BASE, envPortOverride('SERVER_PORT'));
  89. const gateway = await findFreePort('gateway', GATEWAY_PORT_BASE, envPortOverride('PILOTDECK_GATEWAY_PORT'));
  90. const vite = await findFreePort('vite', VITE_PORT_BASE, envPortOverride('VITE_PORT'));
  91. const map = [
  92. ['server (express/ws)', server],
  93. ['gateway (pilotdeck)', gateway],
  94. ['vite client ', vite],
  95. ];
  96. console.log('[dev-launcher] resolved dev ports:');
  97. for (const [label, info] of map) {
  98. console.log(` ${label} → ${info.port} ${info.source !== 'default' ? `(${info.source})` : ''}`);
  99. }
  100. console.log('');
  101. const env = {
  102. ...process.env,
  103. SERVER_PORT: String(server.port),
  104. PILOTDECK_GATEWAY_PORT: String(gateway.port),
  105. PILOTDECK_GATEWAY_URL:
  106. process.env.PILOTDECK_GATEWAY_URL ?? `ws://127.0.0.1:${gateway.port}/ws`,
  107. VITE_PORT: String(vite.port),
  108. PILOTDECK_SKIP_DEFAULT_PROJECT: '1',
  109. };
  110. const child = spawn(
  111. 'npm',
  112. ['--workspace', 'ui', 'run', 'dev:concurrent'],
  113. { cwd: repoRoot, env, stdio: 'inherit' },
  114. );
  115. const forward = (signal) => {
  116. if (!child.killed) child.kill(signal);
  117. };
  118. process.on('SIGINT', () => forward('SIGINT'));
  119. process.on('SIGTERM', () => forward('SIGTERM'));
  120. child.on('exit', (code, signal) => {
  121. if (signal) {
  122. process.kill(process.pid, signal);
  123. return;
  124. }
  125. process.exit(code ?? 0);
  126. });
  127. }
  128. main().catch((error) => {
  129. console.error(error instanceof Error ? error.message : String(error));
  130. process.exit(1);
  131. });