/** * Operate — pure helpers behind the Run + Commands panels. * * Process lifecycle, env parsing, script ordering, and status presentation. No * DOM, no fetch — the panels own I/O; this owns the maths so it unit-tests * cleanly. */ export type ProcessStatus = "idle" | "starting" | "running" | "stopping" | "exited" | "crashed"; export interface ManagedProcess { id: string; topology: string; port?: number; startedAt: string; status: ProcessStatus; pid?: number; exitCode?: number | null; signal?: string | null; errorMessage?: string; /** "studio" — spawned here; "external" — discovered from .nwire/processes. */ source?: "studio" | "external"; env?: Record; } export interface LogLine { seq: number; ts: string; stream: "stdout" | "stderr"; line: string; } export interface ScriptEntry { name: string; command: string; } /** * Parse a `KEY=value` block (one per line) into an env object. Skips blanks + * `#` comments + malformed keys; strips matching surrounding quotes. */ export function parseEnvInput(raw: string): Record { const out: Record = {}; for (const line of raw.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq < 1) continue; const k = trimmed.slice(0, eq).trim(); const v = trimmed.slice(eq + 1).trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue; const dequoted = (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ? v.slice(1, -1) : v; out[k] = dequoted; } return out; } /** * package.json scripts ordered for the picker: `nwire`-prefixed first * (framework shorthands operators reach for most), then the rest — each group * alphabetical. */ export function sortScripts(scripts: readonly ScriptEntry[]): ScriptEntry[] { const byName = (a: ScriptEntry, b: ScriptEntry) => a.name.localeCompare(b.name); const nwire = scripts.filter((s) => s.name.startsWith("nwire")).sort(byName); const others = scripts.filter((s) => !s.name.startsWith("nwire")).sort(byName); return [...nwire, ...others]; } /** StatusBadge tone for a process status. */ export type StatusTone = "live" | "warn" | "error" | "idle"; export function statusTone(status: ProcessStatus): StatusTone { switch (status) { case "running": return "live"; case "starting": case "stopping": return "warn"; case "crashed": return "error"; case "exited": case "idle": return "idle"; } } /** Short, stable process id for display (first uuid segment). */ export function shortId(id: string): string { return id.split("-")[0] ?? id.slice(0, 8); } /** Human "Ns/Nm/Nh ago" from an ISO timestamp; `now` injected for testability. */ export function timeAgo(iso: string, now: number): string { const s = Math.max(0, Math.floor((now - new Date(iso).getTime()) / 1000)); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; return `${Math.floor(s / 3600)}h ago`; } /** A process is live (running) or transitioning (starting/stopping). */ export function isActive(status: ProcessStatus): boolean { return status === "running" || status === "starting" || status === "stopping"; } /** The wire `/_nwire/*` routes to — the most-recently-started running process. */ export function activeProcess(processes: readonly ManagedProcess[]): ManagedProcess | undefined { const running = processes.filter((p) => p.status === "running"); if (running.length === 0) return undefined; return [...running].sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0]; }