function systemdEscapeArg(value: string): string { if (!/[\\s"\\\\]/.test(value)) return value; return `"${value.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"')}"`; } function renderEnvLines(env: Record | undefined): string[] { if (!env) return []; const entries = Object.entries(env).filter( ([, value]) => typeof value === "string" && value.trim(), ); if (entries.length === 0) return []; return entries.map( ([key, value]) => `Environment=${systemdEscapeArg(`${key}=${value?.trim() ?? ""}`)}`, ); } export function buildSystemdUnit({ description, programArguments, workingDirectory, environment, }: { description?: string; programArguments: string[]; workingDirectory?: string; environment?: Record; }): string { const execStart = programArguments.map(systemdEscapeArg).join(" "); const descriptionLine = `Description=${description?.trim() || "Clawdbot Gateway"}`; const workingDirLine = workingDirectory ? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}` : null; const envLines = renderEnvLines(environment); return [ "[Unit]", descriptionLine, "After=network-online.target", "Wants=network-online.target", "", "[Service]", `ExecStart=${execStart}`, "Restart=always", "RestartSec=5", // KillMode=process ensures systemd only waits for the main process to exit. // Without this, podman's conmon (container monitor) processes block shutdown // since they run as children of the gateway and stay in the same cgroup. "KillMode=process", workingDirLine, ...envLines, "", "[Install]", "WantedBy=default.target", "", ] .filter((line) => line !== null) .join("\n"); } export function parseSystemdExecStart(value: string): string[] { const args: string[] = []; let current = ""; let inQuotes = false; let escapeNext = false; for (const char of value) { if (escapeNext) { current += char; escapeNext = false; continue; } if (char === "\\\\") { escapeNext = true; continue; } if (char === '"') { inQuotes = !inQuotes; continue; } if (!inQuotes && /\s/.test(char)) { if (current) { args.push(current); current = ""; } continue; } current += char; } if (current) args.push(current); return args; } export function parseSystemdEnvAssignment(raw: string): { key: string; value: string } | null { const trimmed = raw.trim(); if (!trimmed) return null; const unquoted = (() => { if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) return trimmed; let out = ""; let escapeNext = false; for (const ch of trimmed.slice(1, -1)) { if (escapeNext) { out += ch; escapeNext = false; continue; } if (ch === "\\\\") { escapeNext = true; continue; } out += ch; } return out; })(); const eq = unquoted.indexOf("="); if (eq <= 0) return null; const key = unquoted.slice(0, eq).trim(); if (!key) return null; const value = unquoted.slice(eq + 1); return { key, value }; }