import { spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; export interface HubEnsureResult { ok: boolean; alreadyRunning?: boolean; started?: boolean; baseUrl?: string; port?: number; pid?: number; error?: string; sessionUrl?: string; } function stateRoot(): string { return process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"); } export function hubMetaPath(): string { return join(stateRoot(), "intent-petri", "hub", "hub.json"); } export function resolveHubScriptPath(fromUrl = import.meta.url): string { // src/application/hub-client.ts → ../../bin/intent-petri-hub.mjs const here = dirname(fileURLToPath(fromUrl)); const candidate = join(here, "..", "..", "bin", "intent-petri-hub.mjs"); return candidate; } export function readHubMeta(): | { baseUrl?: string; port?: number; pid?: number; version?: string; } | undefined { try { return JSON.parse(readFileSync(hubMetaPath(), "utf8")) as { baseUrl?: string; port?: number; pid?: number; version?: string; }; } catch { return undefined; } } export function sessionViewerUrl(baseUrl: string, sessionId?: string): string { const root = baseUrl.replace(/\/$/, ""); if (!sessionId) return root; return `${root}/s/${encodeURIComponent(sessionId)}`; } export interface EnsureHubOptions { sessionId?: string; open?: boolean; port?: number; hubScriptPath?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; } /** * Ensure the singleton local hub is running. Never throws; returns structured result. * Spawns a detached Node process for `intent-petri-hub ensure`. */ export function ensureHub(options: EnsureHubOptions = {}): HubEnsureResult { const hubScript = options.hubScriptPath ?? resolveHubScriptPath(); if (!existsSync(hubScript)) { return { ok: false, error: `hub_script_missing:${hubScript}` }; } const args = [hubScript, "ensure"]; if (options.port) args.push("--port", String(options.port)); if (options.open) args.push("--open"); if (options.sessionId) args.push("--session", options.sessionId); try { const result = spawnSync(process.execPath, args, { encoding: "utf8", env: options.env ?? process.env, timeout: options.timeoutMs ?? 8_000, }); const stdout = (result.stdout || "").trim(); const line = stdout.split("\n").filter(Boolean).at(-1) ?? ""; if (!line) { return { ok: false, error: result.stderr?.trim() || `hub_ensure_empty_exit_${result.status ?? "null"}`, }; } const parsed = JSON.parse(line) as HubEnsureResult; if (parsed.baseUrl) { parsed.sessionUrl = sessionViewerUrl(parsed.baseUrl, options.sessionId); } return parsed; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } /** Fire-and-forget ensure for session_start paths that must not block. */ export function ensureHubDetached(options: EnsureHubOptions = {}): void { const hubScript = options.hubScriptPath ?? resolveHubScriptPath(); if (!existsSync(hubScript)) return; const args = [hubScript, "ensure"]; if (options.port) args.push("--port", String(options.port)); if (options.open) args.push("--open"); if (options.sessionId) args.push("--session", options.sessionId); try { const child = spawn(process.execPath, args, { detached: true, stdio: "ignore", env: options.env ?? process.env, }); child.unref(); } catch { // best effort } } export function shouldAutoOpenBrowser(): boolean { const value = process.env.INTENT_PETRI_HUB_OPEN; if (value === "0" || value === "false" || value === "off") return false; if (value === "1" || value === "true" || value === "on") return true; // Default: do not steal focus; user can /intent-petri viewer or set OPEN=1 return false; }