495 lines
13 KiB
JavaScript
495 lines
13 KiB
JavaScript
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 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;
|
|
}
|
|
|
|
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 running = pid ? isPidRunning(pid) : false;
|
|
const portText = service.port ? ` port=${service.port}` : "";
|
|
const logText = fs.existsSync(logFilePath) ? ` log=${logFilePath}` : "";
|
|
console.log(`${service.key}: ${running ? "running" : "stopped"} pid=${pid ?? "-"}${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 <start|stop|status|logs>");
|
|
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;
|
|
});
|
|
}
|