import { spawn, execFileSync } from "node:child_process"; import { createWriteStream, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { setTimeout as sleep } from "node:timers/promises"; const __dirname = dirname(fileURLToPath(import.meta.url)); export type ServerInfo = { httpBase: string; adminHttpBase: string; mqttWs: string; adminPassword: string; stop: () => Promise; }; let cachedBin: string | undefined; function findRepoRoot(): string { let dir = __dirname; for (;;) { const mod = join(dir, "go.mod"); const cmd = join(dir, "cmd", "nixmsg"); if (existsSync(mod) && existsSync(cmd)) { const text = readFileSync(mod, "utf8"); if (text.includes("module git.asio.asia/nixevol/NixMsg")) return dir; } const parent = dirname(dir); if (parent === dir) throw new Error("repo root not found"); dir = parent; } } function nixmsgBin(root: string): string { if (cachedBin && existsSync(cachedBin)) return cachedBin; const outDir = mkdtempSync(join(tmpdir(), "nixmsg-sdk-js-bin-")); const name = process.platform === "win32" ? "nixmsg.exe" : "nixmsg"; const out = join(outDir, name); execFileSync("go", ["build", "-o", out, "./cmd/nixmsg"], { cwd: root, env: { ...process.env, CGO_ENABLED: "0" }, stdio: ["ignore", "pipe", "pipe"], }); cachedBin = out; return out; } function parseAdminPassword(text: string): string { for (const line of text.split(/\r?\n/)) { const s = line.trim(); const lower = s.toLowerCase(); if (lower.startsWith("admin password:")) return s.slice("admin password:".length).trim(); if (lower.startsWith("password:")) return s.slice("password:".length).trim(); } return ""; } async function waitAddr(path: string, ms: number): Promise { const deadline = Date.now() + ms; let last = ""; while (Date.now() < deadline) { try { const addr = readFileSync(path, "utf8").trim(); if (addr) return addr; last = "empty"; } catch (e) { last = String(e); } await sleep(20); } throw new Error(`wait listen.addr: ${last}`); } export async function startNixmsg(): Promise { const root = findRepoRoot(); const bin = nixmsgBin(root); const dataDir = mkdtempSync(join(tmpdir(), "nixmsg-sdk-js-itest-")); const cfgPath = join(dataDir, "config.yaml"); const slash = dataDir.replace(/\\/g, "/"); writeFileSync(cfgPath, `listen: "127.0.0.1:0"\ndata_dir: "${slash}"\n`); const init = execFileSync(bin, ["admin", "init"], { env: { ...process.env, NIXMSG_CONFIG: cfgPath }, encoding: "utf8", }); const adminPassword = parseAdminPassword(init); if (!adminPassword) throw new Error(`admin init no password:\n${init}`); const child = spawn(bin, ["serve"], { env: { ...process.env, NIXMSG_CONFIG: cfgPath }, stdio: ["ignore", "pipe", "pipe"], }); const log = createWriteStream(join(dataDir, "serve.log")); child.stdout?.pipe(log); child.stderr?.pipe(log); let stopped = false; const stop = async () => { if (stopped) return; stopped = true; if (child.pid) { try { child.kill(); } catch { /* ignore */ } await new Promise((r) => child.once("exit", () => r())); } try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ } }; try { const addr = await waitAddr(join(dataDir, "listen.addr"), 20000); const httpBase = `http://${addr}`; return { httpBase, adminHttpBase: httpBase, mqttWs: `ws://${addr}/mqtt`, adminPassword, stop, }; } catch (e) { await stop(); throw e; } } export class AdminClient { private cookie = ""; constructor( private base: string, private password: string, ) {} async login(): Promise { const res = await fetch(`${this.base}/api/admin/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username: "admin", password: this.password }), }); const raw = await res.text(); if (!res.ok) throw new Error(`admin login ${res.status} ${raw}`); const set = res.headers.getSetCookie?.() ?? []; for (const c of set) { const m = /^nixmsg_admin=([^;]+)/.exec(c); if (m) this.cookie = m[1]; } if (!this.cookie) { // Node fetch may expose set-cookie differently const sc = res.headers.get("set-cookie"); if (sc) { const m = /nixmsg_admin=([^;]+)/.exec(sc); if (m) this.cookie = m[1]; } } if (!this.cookie) throw new Error("admin cookie missing"); } private headers(json = true): Record { const h: Record = { cookie: `nixmsg_admin=${this.cookie}`, "X-Nixmsg-Request": "1", }; if (json) h["content-type"] = "application/json"; return h; } async putRegistration(enabled: boolean, code?: string): Promise { const body: Record = { enabled }; if (code) body.code = code; const res = await fetch(`${this.base}/api/admin/registration`, { method: "PUT", headers: this.headers(), body: JSON.stringify(body), }); const raw = await res.text(); if (!res.ok) throw new Error(`registration put ${res.status} ${raw}`); } async kick(id: string): Promise { const res = await fetch(`${this.base}/api/admin/endpoints/${id}/kick`, { method: "POST", headers: this.headers(), body: "{}", }); const raw = await res.text(); if (!res.ok) throw new Error(`kick ${id} ${res.status} ${raw}`); } } export function delay0(): number { return 0; }