install/windows-script-host.mjs
Rama master · réplica de lectura del repositorio original.
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes } from "node:crypto";
const installRoot = realpathSync.native(dirname(fileURLToPath(import.meta.url)));
const envPath = join(installRoot, ".env");
const args = process.argv.slice(2);
const allowed = new Set(["--open", "--stop"]);
if (args.some((arg) => !allowed.has(arg))) {
console.error("Usage: windows-script-host.mjs [--open|--stop]");
process.exitCode = 2;
process.exit();
}
const wantsOpen = args.includes("--open");
const wantsStop = args.includes("--stop");
let stopping = false;
function parseEnv(text) {
const result = {};
for (const raw of text.split(/\r?\n/u)) {
const line = raw.replace(/^\uFEFF/u, "").trim();
if (!line || line.startsWith("#")) continue;
const at = line.indexOf("=");
if (at <= 0) continue;
let value = line.slice(at + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
result[line.slice(0, at).trim()] = value;
}
return result;
}
const config = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
for (const [key, value] of Object.entries(config)) process.env[key] = value;
process.env.NODE_ENV = "production";
const dataDir = resolve(config.CHAT_DATA_DIR || config.METIS_DATA_DIR || join(installRoot, "data"));
mkdirSync(dataDir, { recursive: true });
const paths = {
lock: join(dataDir, ".windows-script-host.lock"),
stop: join(dataDir, ".windows-script-host.stop"),
hostLog: join(dataDir, "host.log"),
};
const token = randomBytes(24).toString("hex");
function log(line) {
appendFileSync(paths.hostLog, new Date().toISOString() + " " + line + "\n");
}
function windowsHidden(options = {}) {
return { windowsHide: true, stdio: options.stdio || "ignore", cwd: installRoot, ...options };
}
function runHidden(command, commandArgs, options = {}) {
return spawnSync(command, commandArgs, { ...windowsHidden(options), encoding: "utf8", timeout: options.timeout });
}
function processExists(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
const result = runHidden("tasklist.exe", ["/FI", "PID eq " + pid, "/FO", "CSV", "/NH"], { stdio: ["ignore", "pipe", "ignore"] });
return result.status === 0 && new RegExp('"[^"]+","\\s*' + pid + '","', "u").test(result.stdout || "");
}
function atomicWrite(file, value) {
const temp = file + "." + process.pid + "." + Date.now() + ".tmp";
writeFileSync(temp, value, { encoding: "utf8", mode: 0o600 });
renameSync(temp, file);
}
function readLock() {
try { return JSON.parse(readFileSync(paths.lock, "utf8")); } catch { return null; }
}
function staleLock() {
const current = readLock();
if (!current) {
try { return Date.now() - statSync(paths.lock).mtimeMs > 15000; } catch { return false; }
}
return !processExists(Number(current.pid));
}
function acquireLock() {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const fd = openSync(paths.lock, "wx", 0o600);
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, root: installRoot, token, heartbeat: Date.now() }) + "\n"); } finally { closeSync(fd); }
return true;
} catch (error) {
if (error.code !== "EEXIST" || !staleLock()) return false;
try { renameSync(paths.lock, paths.lock + ".stale." + Date.now()); } catch {}
}
}
return false;
}
function releaseLock() {
const current = readLock();
if (current?.pid === process.pid && current.token === token) rmSync(paths.lock, { force: true });
}
function requestStop() {
const current = readLock();
if (!current || current.root !== installRoot || !processExists(Number(current.pid))) return;
atomicWrite(paths.stop, JSON.stringify({ root: installRoot, pid: current.pid, token: current.token, requestedAt: Date.now() }) + "\n");
for (let i = 0; i < 75; i += 1) {
if (!existsSync(paths.lock)) return;
const latest = readLock();
if (!latest || latest.root !== installRoot) return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
}
throw new Error("Metis background host did not stop within 15 seconds.");
}
function removeOwnStop() { try { rmSync(paths.stop, { force: true }); } catch {} }
function appendChildLog(name, chunk) {
if (chunk) appendFileSync(join(dataDir, name + ".log"), chunk);
}
function childCommand(name) {
const node = config.METIS_NODE_BIN || "node.exe";
const rootPath = (value) => isAbsolute(value) ? value : join(installRoot, value);
if (name === "app") return { command: node, args: ["--import", "tsx", rootPath("server.mjs")] };
if (name === "worker") return { command: node, args: ["--import", "tsx", rootPath("worker.ts")] };
return { command: node, args: [rootPath("lib/mcp-core/gateway-core.mjs")] };
}
function startChild(name) {
const command = childCommand(name);
const child = spawn(command.command, command.args, {
cwd: installRoot, env: { ...process.env, NODE_ENV: "production" }, windowsHide: true, stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => appendChildLog(name, chunk));
child.stderr.on("data", (chunk) => appendChildLog(name, chunk));
child.on("error", (error) => log(name + " spawn error: " + error.message));
return child;
}
function stopChild(child, name) {
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return;
log("stopping " + name + " tree pid=" + child.pid);
runHidden("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { timeout: 10000 });
}
function healthyUrl() { return config.AI_CHAT_PUBLIC_URL || "http://127.0.0.1:" + (config.PORT || "3100"); }
async function isHealthy() {
try {
const response = await fetch("http://127.0.0.1:" + (config.PORT || "3100") + "/api/status", { signal: AbortSignal.timeout(3000) });
const status = await response.json();
return response.ok && typeof status.authenticated === "boolean" && status.worker?.ok === true && status.mcp?.ok === true;
} catch { return false; }
}
async function openBrowser() {
const url = healthyUrl();
for (let i = 0; i < 60; i += 1) {
if (stopping) return;
if (await isHealthy()) {
runHidden("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command",
"Start-Process -FilePath '" + url.replaceAll("'", "''") + "'"], { timeout: 5000 });
return;
}
await new Promise((resolveSleep) => setTimeout(resolveSleep, 1000));
}
throw new Error("Metis did not become ready at " + url + ". Check " + dataDir + " for startup errors.");
}
function showError(error) {
log(error.stack || String(error));
if (!wantsOpen) return;
const message = String(error.message || error).replaceAll("'", "''");
runHidden("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command",
"Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('" + message + "','Metis AI','OK','Error') | Out-Null"], { timeout: 10000 });
}
function dockerEnabled() { return config.METIS_DOCKER === "1" || config.METIS_DOCKER === "true"; }
function dockerStart() {
const result = runHidden("docker.exe", ["compose", "--env-file", envPath, "up", "-d", "--remove-orphans"],
{ stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
appendChildLog("host", result.stdout || "");
appendChildLog("host", result.stderr || "");
if (result.status !== 0) throw new Error("Docker Compose failed. Check host.log.");
log("docker compose started; stop leaves the compose stack running.");
}
async function main() {
if (wantsStop) { requestStop(); return; }
if (!acquireLock()) {
if (wantsOpen) await openBrowser();
return;
}
removeOwnStop();
const children = [];
const docker = dockerEnabled();
try {
if (config.PORT && config.MCP_PORT && config.PORT === config.MCP_PORT) throw new Error("PORT and MCP_PORT must be different.");
if (!docker) {
for (const portName of ["PORT", "MCP_PORT"]) {
const port = Number(config[portName] || (portName === "PORT" ? 3100 : 8787));
const probe = runHidden("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command",
"$l=New-Object Net.Sockets.TcpListener([Net.IPAddress]::Any," + port + "); try{$l.Start();$l.Stop();exit 0}catch{exit 1}"], { timeout: 5000 });
if (probe.status !== 0) throw new Error("Port " + port + " (" + portName + ") is occupied or unavailable.");
}
for (const name of ["app", "worker", "mcp"]) children.push({ name, child: startChild(name) });
} else dockerStart();
let openPromise;
if (wantsOpen) { openPromise = openBrowser().catch(showError); }
while (true) {
const stopRequest = readLock();
let command = null;
try { command = JSON.parse(readFileSync(paths.stop, "utf8")); } catch {}
if (command && command.root === installRoot && command.token === token && stopRequest?.token === token) break;
for (const entry of children) {
if (entry.child.exitCode !== null) {
log(entry.name + " exited code=" + entry.child.exitCode + "; restarting");
entry.child = startChild(entry.name);
}
}
atomicWrite(paths.lock, JSON.stringify({ pid: process.pid, root: installRoot, token, heartbeat: Date.now() }) + "\n");
await new Promise((resolveSleep) => setTimeout(resolveSleep, 500));
}
} finally {
stopping = true;
for (const entry of children) stopChild(entry.child, entry.name);
removeOwnStop();
releaseLock();
}
}
main().catch((error) => { showError(error); process.exitCode = 1; });