import fs from "node:fs"; import path from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import net from "node:net"; import http from "node:http"; import https from "node:https"; const STACK_DIR_NAME = ".dev-stack"; const DEFAULT_TEMPORAL_CANDIDATES = [ "temporal-sdk-python-1.24.0", "temporal-sdk-python-1.25.0", "temporal-sdk-python-1.26.0", "temporal-cli", ]; export function getDefaultTemporalCandidates(tempDirectory) { return DEFAULT_TEMPORAL_CANDIDATES.map((name) => path.join(tempDirectory, name)); } export function resolveTemporalCli({ pathLookupResult, candidatePaths, existingPaths, }) { if (pathLookupResult && existingPaths.has(pathLookupResult)) { return pathLookupResult; } for (const candidatePath of candidatePaths) { if (existingPaths.has(candidatePath)) { return candidatePath; } } return null; } export function createStackConfig(frontendRoot, backendRoot = path.join(path.dirname(frontendRoot), "auto-virtual-tryon")) { const runtimeRoot = path.join(frontendRoot, STACK_DIR_NAME); const pidRoot = path.join(runtimeRoot, "pids"); const logRoot = path.join(runtimeRoot, "logs"); const temporalDatabaseFile = path.join(runtimeRoot, "temporal-cli-dev.db"); const backendPython = path.join(backendRoot, ".venv", "bin", "python"); const frontendNext = path.join(frontendRoot, "node_modules", ".bin", "next"); return { frontendRoot, backendRoot, runtimeRoot, pidRoot, logRoot, temporalDatabaseFile, services: [ { key: "temporal", cwd: runtimeRoot, port: 7233, command: [ "__TEMPORAL_CLI__", "server", "start-dev", "--ip", "127.0.0.1", "--port", "7233", "--headless", "--db-filename", temporalDatabaseFile, ], }, { key: "backend-api", cwd: backendRoot, port: 8000, command: [ backendPython, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "8000", ], }, { key: "backend-worker", cwd: backendRoot, port: null, command: [backendPython, "-m", "app.workers.runner"], }, { key: "frontend", cwd: frontendRoot, port: 3000, command: [frontendNext, "dev", "--hostname", "127.0.0.1", "--port", "3000"], }, ], }; } export function selectServicesForLogs(services, serviceFilter) { if (!serviceFilter) { return services; } const matched = services.find((service) => service.key === serviceFilter); if (!matched) { const supportedKeys = services.map((service) => service.key).join(", "); throw new Error(`Unknown service "${serviceFilter}". Expected one of: ${supportedKeys}`); } return [matched]; } export function formatServiceLogs(entries) { return entries .flatMap((entry, index) => { const header = `--- ${entry.key} (${entry.logFilePath}) ---`; const body = entry.lines.length > 0 ? entry.lines : ["(log file is empty)"]; return [...(index === 0 ? [] : [""]), header, ...body]; }) .join("\n"); } function ensureDirectory(directoryPath) { fs.mkdirSync(directoryPath, { recursive: true }); } function getServicePidFile(config, serviceKey) { return path.join(config.pidRoot, `${serviceKey}.pid`); } function getServiceLogFile(config, serviceKey) { return path.join(config.logRoot, `${serviceKey}.log`); } function readLastLogLines(logFilePath, lineLimit) { if (!fs.existsSync(logFilePath)) { return ["(log file not found yet)"]; } const raw = fs.readFileSync(logFilePath, "utf8"); if (!raw.trim()) { return []; } const normalizedLines = raw.replace(/\r\n/g, "\n").split("\n"); if (normalizedLines.at(-1) === "") { normalizedLines.pop(); } return normalizedLines.slice(-lineLimit); } function readPid(pidFilePath) { if (!fs.existsSync(pidFilePath)) { return null; } const raw = fs.readFileSync(pidFilePath, "utf8").trim(); if (!raw) { return null; } const parsed = Number(raw); return Number.isInteger(parsed) ? parsed : null; } function isPidRunning(pid) { if (!pid) { return false; } try { process.kill(pid, 0); return true; } catch { return false; } } function readProcessCommand(pid) { if (!pid) { return null; } const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8", }); if (result.status !== 0) { return null; } const command = result.stdout.trim(); return command.length > 0 ? command : null; } export function getServiceCommandMatchers(service) { switch (service.key) { case "temporal": return ["temporal", "server", "start-dev"]; case "backend-api": return ["uvicorn", "app.main:app"]; case "backend-worker": return ["app.workers.runner"]; case "frontend": return ["next", "dev", "127.0.0.1", "3000"]; default: return service.command.map((part) => path.basename(part)); } } export function isServiceProcessMatch(service, processCommand) { if (!processCommand) { return false; } const normalizedCommand = processCommand.toLowerCase(); return getServiceCommandMatchers(service).every((matcher) => normalizedCommand.includes(matcher.toLowerCase()), ); } export function isServiceHealthy(service, { pid, processCommand, portOpen }) { if (!pid || !isServiceProcessMatch(service, processCommand)) { return false; } if (service.port) { return portOpen; } return true; } function removePidFile(pidFilePath) { if (fs.existsSync(pidFilePath)) { fs.unlinkSync(pidFilePath); } } function pathExists(candidatePath) { try { fs.accessSync(candidatePath, fs.constants.X_OK); return true; } catch { return false; } } function getTemporalCliPath() { const pathLookup = spawnSync("which", ["temporal"], { encoding: "utf8" }); const fromPath = pathLookup.status === 0 ? pathLookup.stdout.trim() : null; const tempDirectory = process.env.TMPDIR || "/tmp"; const candidatePaths = getDefaultTemporalCandidates(tempDirectory); const existingPaths = new Set( [fromPath, ...candidatePaths].filter(Boolean).filter((candidatePath) => pathExists(candidatePath)), ); return resolveTemporalCli({ pathLookupResult: fromPath, candidatePaths, existingPaths, }); } function materializeServices(config, temporalCliPath) { return config.services.map((service) => ({ ...service, command: service.command.map((part) => (part === "__TEMPORAL_CLI__" ? temporalCliPath : part)), })); } function waitForPort(port, timeoutMs) { return new Promise((resolve, reject) => { const startedAt = Date.now(); const attempt = () => { const socket = net.createConnection({ host: "127.0.0.1", port }); socket.once("connect", () => { socket.end(); resolve(); }); socket.once("error", () => { socket.destroy(); if (Date.now() - startedAt >= timeoutMs) { reject(new Error(`Timed out waiting for port ${port}`)); return; } setTimeout(attempt, 250); }); }; attempt(); }); } function waitForHttp(url, timeoutMs) { const client = url.startsWith("https://") ? https : http; return new Promise((resolve, reject) => { const startedAt = Date.now(); const attempt = () => { const request = client.get(url, (response) => { response.resume(); if (response.statusCode && response.statusCode < 500) { resolve(); return; } if (Date.now() - startedAt >= timeoutMs) { reject(new Error(`Timed out waiting for ${url}`)); return; } setTimeout(attempt, 250); }); request.on("error", () => { if (Date.now() - startedAt >= timeoutMs) { reject(new Error(`Timed out waiting for ${url}`)); return; } setTimeout(attempt, 250); }); }; attempt(); }); } function isLocalPortBusy(port) { return new Promise((resolve) => { const socket = net.createConnection({ host: "127.0.0.1", port }); socket.once("connect", () => { socket.end(); resolve(true); }); socket.once("error", () => { socket.destroy(); resolve(false); }); }); } function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function stopService(config, service) { const pidFilePath = getServicePidFile(config, service.key); const pid = readPid(pidFilePath); if (!pid) { return false; } if (!isPidRunning(pid)) { removePidFile(pidFilePath); return false; } const processCommand = readProcessCommand(pid); if (!isServiceProcessMatch(service, processCommand)) { removePidFile(pidFilePath); return false; } process.kill(-pid, "SIGTERM"); for (let index = 0; index < 20; index += 1) { if (!isPidRunning(pid)) { removePidFile(pidFilePath); return true; } await sleep(250); } process.kill(-pid, "SIGKILL"); removePidFile(pidFilePath); return true; } async function startService(config, service) { const pidFilePath = getServicePidFile(config, service.key); const logFilePath = getServiceLogFile(config, service.key); const existingPid = readPid(pidFilePath); if (existingPid && isPidRunning(existingPid)) { return { key: service.key, pid: existingPid, reused: true }; } removePidFile(pidFilePath); if (service.port && (await isLocalPortBusy(service.port))) { throw new Error(`Port ${service.port} is already in use. Stop the existing process before running stack:start.`); } ensureDirectory(path.dirname(logFilePath)); const logDescriptor = fs.openSync(logFilePath, "a"); const child = spawn(service.command[0], service.command.slice(1), { cwd: service.cwd, detached: true, stdio: ["ignore", logDescriptor, logDescriptor], env: { ...process.env, BACKEND_BASE_URL: process.env.BACKEND_BASE_URL || "http://127.0.0.1:8000/api/v1", }, }); child.unref(); fs.writeFileSync(pidFilePath, `${child.pid}\n`, "utf8"); fs.closeSync(logDescriptor); if (service.key === "temporal") { await waitForPort(7233, 15_000); } else if (service.key === "backend-api") { await waitForHttp("http://127.0.0.1:8000/healthz", 15_000); } else if (service.key === "frontend") { await waitForHttp("http://127.0.0.1:3000", 20_000); } else { await sleep(1_500); } if (!isPidRunning(child.pid)) { throw new Error(`${service.key} exited early. Check ${logFilePath}`); } return { key: service.key, pid: child.pid, reused: false }; } async function startStack() { const frontendRoot = process.cwd(); const config = createStackConfig(frontendRoot); const temporalCliPath = getTemporalCliPath(); if (!temporalCliPath) { throw new Error( "Temporal CLI not found. Install `temporal` or ensure the SDK-downloaded CLI exists under TMPDIR.", ); } ensureDirectory(config.runtimeRoot); ensureDirectory(config.pidRoot); ensureDirectory(config.logRoot); const services = materializeServices(config, temporalCliPath); const startedServices = []; try { for (const service of services) { const result = await startService(config, service); startedServices.push(service); const marker = result.reused ? "reused" : "started"; console.log(`${service.key}: ${marker} (pid ${result.pid})`); } console.log(`frontend: http://127.0.0.1:3000`); console.log(`backend: http://127.0.0.1:8000`); console.log(`temporal: 127.0.0.1:7233`); } catch (error) { for (const service of startedServices.reverse()) { await stopService(config, service); } throw error; } } async function stopStack() { const frontendRoot = process.cwd(); const config = createStackConfig(frontendRoot); const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__"; const services = materializeServices(config, temporalCliPath); for (const service of [...services].reverse()) { const stopped = await stopService(config, service); console.log(`${service.key}: ${stopped ? "stopped" : "not running"}`); } } async function printStatus() { const frontendRoot = process.cwd(); const config = createStackConfig(frontendRoot); const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__"; const services = materializeServices(config, temporalCliPath); for (const service of services) { const pidFilePath = getServicePidFile(config, service.key); const logFilePath = getServiceLogFile(config, service.key); const pid = readPid(pidFilePath); const processCommand = pid && isPidRunning(pid) ? readProcessCommand(pid) : null; const portOpen = service.port ? await isLocalPortBusy(service.port) : false; const running = isServiceHealthy(service, { pid, processCommand, portOpen, }); if (!running && pid) { removePidFile(pidFilePath); } const displayPid = running ? pid : null; const portText = service.port ? ` port=${service.port}` : ""; const logText = fs.existsSync(logFilePath) ? ` log=${logFilePath}` : ""; console.log(`${service.key}: ${running ? "running" : "stopped"} pid=${displayPid ?? "-"}${portText}${logText}`); } } async function printLogs() { const frontendRoot = process.cwd(); const config = createStackConfig(frontendRoot); const temporalCliPath = getTemporalCliPath() || "__TEMPORAL_CLI__"; const services = materializeServices(config, temporalCliPath); const serviceFilter = process.env.SERVICE || null; const lineLimit = Number(process.env.LINES || "80"); const selectedServices = selectServicesForLogs(services, serviceFilter); const entries = selectedServices.map((service) => { const logFilePath = getServiceLogFile(config, service.key); return { key: service.key, logFilePath, lines: readLastLogLines(logFilePath, Number.isFinite(lineLimit) && lineLimit > 0 ? lineLimit : 80), }; }); console.log(formatServiceLogs(entries)); } async function main() { const command = process.argv[2]; if (command === "start") { await startStack(); return; } if (command === "stop") { await stopStack(); return; } if (command === "status") { await printStatus(); return; } if (command === "logs") { await printLogs(); return; } console.error("Usage: node scripts/dev-stack/stack.mjs "); process.exitCode = 1; } const currentFilePath = fileURLToPath(import.meta.url); if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }