| function systemdEscapeArg(value: string): string { |
| if (!/[\\s"\\\\]/.test(value)) { |
| return value; |
| } |
| return `"${value.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"')}"`; |
| } |
| |
| function renderEnvLines(env: Record<string, string | undefined> | 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, string | undefined>; |
| }): string { |
| const execStart = programArguments.map(systemdEscapeArg).join(" "); |
| const descriptionLine = `Description=${description?.trim() || "OpenClaw 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 }; |
| } |
| |