@inceptionstack/roundhouse 0.3.13 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/architecture.md +3 -1
- package/package.json +1 -1
- package/src/cli/cli.ts +30 -88
- package/src/cli/doctor/checks/telegram.ts +126 -0
- package/src/cli/doctor/runner.ts +2 -0
- package/src/cli/env-file.ts +40 -0
- package/src/cli/setup.ts +36 -99
- package/src/cli/systemd.ts +182 -0
package/README.md
CHANGED
|
@@ -95,6 +95,8 @@ roundhouse install # installs as systemd service, starts automatically
|
|
|
95
95
|
roundhouse <command>
|
|
96
96
|
|
|
97
97
|
Commands:
|
|
98
|
+
setup One-command install & configure (also works via npx)
|
|
99
|
+
pair Pair Telegram account for notifications
|
|
98
100
|
start Start the gateway daemon
|
|
99
101
|
run Run the gateway in foreground
|
|
100
102
|
tui [thread] Open agent TUI on a gateway session
|
|
@@ -106,6 +108,9 @@ Commands:
|
|
|
106
108
|
stop Stop the daemon
|
|
107
109
|
restart Restart the daemon
|
|
108
110
|
config Show config path and contents
|
|
111
|
+
agent <message> Send a message to the agent and print response
|
|
112
|
+
doctor [--fix] Check system health and configuration
|
|
113
|
+
cron <command> Manage scheduled jobs (add, list, trigger, etc.)
|
|
109
114
|
```
|
|
110
115
|
|
|
111
116
|
### `roundhouse status`
|
|
@@ -442,7 +447,9 @@ No other changes needed — the gateway's unified handler covers all platforms.
|
|
|
442
447
|
| `src/router.ts` | `AgentRouter` interface + `SingleAgentRouter` |
|
|
443
448
|
| `src/types.ts` | Core interfaces: `AgentAdapter`, `AgentStreamEvent`, `AgentRouter`, `GatewayConfig` |
|
|
444
449
|
| `src/util.ts` | Pure utilities: `splitMessage`, `isAllowed`, `threadIdToDir`, `startTypingLoop` |
|
|
445
|
-
| `src/cli/cli.ts` | CLI: start, install, tui, update, logs, etc. |
|
|
450
|
+
| `src/cli/cli.ts` | CLI: start, run, install, tui, update, logs, etc. |
|
|
451
|
+
| `src/cli/env-file.ts` | Shared env file parsing, serialization, and quoting |
|
|
452
|
+
| `src/cli/systemd.ts` | Shared systemd service management (unit generation, install, status) |
|
|
446
453
|
| `src/cli/doctor.ts` | CLI doctor command |
|
|
447
454
|
| `src/cli/doctor/runner.ts` | Shared doctor runner (CLI + gateway) |
|
|
448
455
|
| `src/cli/doctor/checks/` | Individual health check modules |
|
package/architecture.md
CHANGED
|
@@ -268,9 +268,11 @@ index.ts
|
|
|
268
268
|
cli/cli.ts
|
|
269
269
|
├── config.ts (DEFAULT_CONFIG, CONFIG_PATH, loadConfig, etc.)
|
|
270
270
|
├── agents/registry.ts (getAgentSdkPackage)
|
|
271
|
+
├── cli/env-file.ts (parseEnvFile, serializeEnvFile, envQuote)
|
|
272
|
+
├── cli/systemd.ts (resolveExecStart, generateUnit, writeServiceUnit, systemctl, etc.)
|
|
271
273
|
├── cli/doctor.ts → cli/doctor/runner.ts → cli/doctor/checks/*
|
|
272
274
|
├── cli/cron.ts → cron/store.ts, cron/runner.ts, cron/helpers.ts
|
|
273
|
-
└──
|
|
275
|
+
└── cli/setup.ts → cli/env-file.ts, cli/systemd.ts, cli/setup-telegram.ts
|
|
274
276
|
|
|
275
277
|
gateway.ts also imports:
|
|
276
278
|
→ cli/doctor/runner.ts for /doctor command
|
package/package.json
CHANGED
package/src/cli/cli.ts
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { resolve, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { readFile, writeFile, mkdir
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
9
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
11
10
|
import { readdirSync, statSync } from "node:fs";
|
|
12
|
-
import { execSync,
|
|
11
|
+
import { execSync, spawn } from "node:child_process";
|
|
13
12
|
import { fileURLToPath } from "node:url";
|
|
14
13
|
|
|
15
14
|
import {
|
|
@@ -23,12 +22,22 @@ import {
|
|
|
23
22
|
resolveEnvFilePath,
|
|
24
23
|
} from "../config";
|
|
25
24
|
import { getAgentSdkPackage } from "../agents/registry";
|
|
25
|
+
import { parseEnvFile, serializeEnvFile, envQuote } from "./env-file";
|
|
26
|
+
import {
|
|
27
|
+
SERVICE_PATH,
|
|
28
|
+
systemctl,
|
|
29
|
+
runSudo,
|
|
30
|
+
isServiceInstalled,
|
|
31
|
+
isServiceActive,
|
|
32
|
+
systemctlShow,
|
|
33
|
+
resolveExecStart,
|
|
34
|
+
generateUnit,
|
|
35
|
+
writeServiceUnit,
|
|
36
|
+
} from "./systemd";
|
|
26
37
|
|
|
27
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
39
|
const __dirname = dirname(__filename);
|
|
29
40
|
|
|
30
|
-
const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
31
|
-
|
|
32
41
|
// ── Shell helpers ───────────────────────────────────
|
|
33
42
|
|
|
34
43
|
function run(cmd: string, opts?: { silent?: boolean }): string {
|
|
@@ -40,27 +49,11 @@ function run(cmd: string, opts?: { silent?: boolean }): string {
|
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
51
|
|
|
43
|
-
function runSudo(...args: string[]): void {
|
|
44
|
-
// Try non-interactive first, fall back to interactive for password prompts
|
|
45
|
-
const result = spawnSync("sudo", ["-n", ...args], { stdio: "inherit" });
|
|
46
|
-
if (result.status !== 0) {
|
|
47
|
-
execFileSync("sudo", args, { stdio: "inherit" });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function systemctl(verb: string, message?: string): void {
|
|
52
|
-
runSudo("systemctl", verb, SERVICE_NAME);
|
|
53
|
-
if (message) console.log(` ✅ ${message}`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
// ── Commands ────────────────────────────────────────
|
|
57
53
|
|
|
58
54
|
async function cmdStart() {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (serviceInstalled) {
|
|
62
|
-
const isActive = run(`systemctl is-active ${SERVICE_NAME}`, { silent: true }) === "active";
|
|
63
|
-
if (isActive) {
|
|
55
|
+
if (isServiceInstalled()) {
|
|
56
|
+
if (isServiceActive()) {
|
|
64
57
|
console.log("Roundhouse is already running.");
|
|
65
58
|
console.log(" Use: roundhouse restart to restart");
|
|
66
59
|
console.log(" roundhouse status to check status");
|
|
@@ -107,22 +100,16 @@ async function cmdInstall() {
|
|
|
107
100
|
|
|
108
101
|
// Write environment file for secrets — merge with existing to preserve manually-added keys
|
|
109
102
|
const ENV_KEYS = ["TELEGRAM_BOT_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "BOT_USERNAME", "ALLOWED_USERS", "NOTIFY_CHAT_IDS", "AWS_PROFILE", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"];
|
|
110
|
-
const existing = new Map<string, string>();
|
|
111
103
|
const resolvedEnvPath = await resolveEnvFilePath();
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
117
|
-
const eq = trimmed.indexOf("=");
|
|
118
|
-
if (eq > 0) existing.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
|
|
119
|
-
}
|
|
120
|
-
}
|
|
104
|
+
const existing = await fileExists(resolvedEnvPath)
|
|
105
|
+
? parseEnvFile(await readFile(resolvedEnvPath, "utf8"))
|
|
106
|
+
: new Map<string, string>();
|
|
107
|
+
|
|
121
108
|
// Override with current env vars for known keys
|
|
122
109
|
let envChanged = false;
|
|
123
110
|
for (const key of ENV_KEYS) {
|
|
124
111
|
if (process.env[key]) {
|
|
125
|
-
existing.set(key,
|
|
112
|
+
existing.set(key, envQuote(process.env[key]));
|
|
126
113
|
envChanged = true;
|
|
127
114
|
}
|
|
128
115
|
}
|
|
@@ -130,56 +117,14 @@ async function cmdInstall() {
|
|
|
130
117
|
if (resolvedEnvPath !== ENV_FILE_PATH && await fileExists(resolvedEnvPath)) {
|
|
131
118
|
console.log(` Copying env file from ${resolvedEnvPath} to ${ENV_FILE_PATH}`);
|
|
132
119
|
}
|
|
133
|
-
|
|
134
|
-
await writeFile(ENV_FILE_PATH, envFileContent, { mode: 0o600 });
|
|
120
|
+
await writeFile(ENV_FILE_PATH, serializeEnvFile(existing), { mode: 0o600 });
|
|
135
121
|
console.log(` Environment file: ${ENV_FILE_PATH}`);
|
|
136
122
|
}
|
|
137
123
|
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let execStart: string;
|
|
144
|
-
if (binPath) {
|
|
145
|
-
execStart = `${nodePath} ${binPath} run`;
|
|
146
|
-
} else {
|
|
147
|
-
// No global install — run CLI via tsx with 'run' subcommand
|
|
148
|
-
const tsxBin = run("which tsx", { silent: true }) || tsxPath;
|
|
149
|
-
const cliPath = resolve(__dirname, "cli.ts");
|
|
150
|
-
execStart = `${tsxBin} ${cliPath} run`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Compute PATH that includes node's bin dir (for mise/nvm setups)
|
|
154
|
-
const nodeBinDir = dirname(nodePath);
|
|
155
|
-
const pathValue = `${nodeBinDir}:/usr/local/bin:/usr/bin:/bin`;
|
|
156
|
-
|
|
157
|
-
const unit = `[Unit]
|
|
158
|
-
Description=Roundhouse Chat Gateway
|
|
159
|
-
After=network.target
|
|
160
|
-
|
|
161
|
-
[Service]
|
|
162
|
-
Type=simple
|
|
163
|
-
User=${process.env.USER || "root"}
|
|
164
|
-
WorkingDirectory=${homedir()}
|
|
165
|
-
ExecStart=${execStart}
|
|
166
|
-
Restart=on-failure
|
|
167
|
-
RestartSec=5
|
|
168
|
-
EnvironmentFile=-${ENV_FILE_PATH}
|
|
169
|
-
Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
|
|
170
|
-
Environment=NODE_ENV=production
|
|
171
|
-
Environment=PATH=${pathValue}
|
|
172
|
-
|
|
173
|
-
[Install]
|
|
174
|
-
WantedBy=multi-user.target
|
|
175
|
-
`;
|
|
176
|
-
|
|
177
|
-
const tmpDir = await mkdtemp(resolve(tmpdir(), "roundhouse-"));
|
|
178
|
-
const tmpUnit = resolve(tmpDir, `${SERVICE_NAME}.service`);
|
|
179
|
-
await writeFile(tmpUnit, unit, { mode: 0o600 });
|
|
180
|
-
runSudo("cp", tmpUnit, SERVICE_PATH);
|
|
181
|
-
runSudo("rm", "-rf", "--", tmpDir);
|
|
182
|
-
runSudo("systemctl", "daemon-reload");
|
|
124
|
+
// Generate and install systemd unit
|
|
125
|
+
const { execStart, nodeBinDir } = resolveExecStart();
|
|
126
|
+
const unit = generateUnit({ execStart, nodeBinDir });
|
|
127
|
+
await writeServiceUnit(unit);
|
|
183
128
|
systemctl("enable");
|
|
184
129
|
systemctl("start", "Daemon installed and started.");
|
|
185
130
|
|
|
@@ -217,10 +162,7 @@ async function cmdUpdate() {
|
|
|
217
162
|
}
|
|
218
163
|
|
|
219
164
|
async function cmdStatus() {
|
|
220
|
-
|
|
221
|
-
const isActive = run(`systemctl is-active ${SERVICE_NAME}`, { silent: true }) === "active";
|
|
222
|
-
|
|
223
|
-
if (!isActive) {
|
|
165
|
+
if (!isServiceActive()) {
|
|
224
166
|
console.log("\n ❌ Roundhouse is not running.\n");
|
|
225
167
|
console.log(" Install with: roundhouse install");
|
|
226
168
|
console.log(" Or start foreground: roundhouse start\n");
|
|
@@ -234,9 +176,9 @@ async function cmdStatus() {
|
|
|
234
176
|
} catch {}
|
|
235
177
|
|
|
236
178
|
// Gather systemd info
|
|
237
|
-
const pid =
|
|
238
|
-
const activeState =
|
|
239
|
-
const startedAt =
|
|
179
|
+
const pid = systemctlShow("MainPID");
|
|
180
|
+
const activeState = systemctlShow("ActiveState");
|
|
181
|
+
const startedAt = systemctlShow("ActiveEnterTimestamp");
|
|
240
182
|
|
|
241
183
|
// Compute uptime
|
|
242
184
|
let uptimeStr = "unknown";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram connectivity checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import type { DoctorCheck } from "../types";
|
|
7
|
+
import { parseEnvFile } from "../../env-file";
|
|
8
|
+
|
|
9
|
+
/** Resolve the bot token from env or .env file */
|
|
10
|
+
async function resolveToken(ctx: { env: NodeJS.ProcessEnv; envFilePath: string }): Promise<string | null> {
|
|
11
|
+
let token = ctx.env.TELEGRAM_BOT_TOKEN;
|
|
12
|
+
if (!token) {
|
|
13
|
+
try {
|
|
14
|
+
const entries = parseEnvFile(await readFile(ctx.envFilePath, "utf8"));
|
|
15
|
+
const raw = entries.get("TELEGRAM_BOT_TOKEN");
|
|
16
|
+
if (raw) token = raw.replace(/^["']|["']$/g, "");
|
|
17
|
+
} catch {}
|
|
18
|
+
}
|
|
19
|
+
return token || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const telegramChecks: DoctorCheck[] = [
|
|
23
|
+
{
|
|
24
|
+
id: "telegram-configured", category: "network", name: "Telegram adapter configured",
|
|
25
|
+
async run(ctx) {
|
|
26
|
+
const base = { id: "telegram-configured", category: "network" as const, name: "Telegram adapter configured" };
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(ctx.configPath, "utf8");
|
|
29
|
+
const cfg = JSON.parse(raw);
|
|
30
|
+
if (cfg.chat?.adapters?.telegram) {
|
|
31
|
+
const mode = cfg.chat.adapters.telegram.mode ?? "polling";
|
|
32
|
+
return { ...base, status: "pass", summary: `mode: ${mode}` };
|
|
33
|
+
}
|
|
34
|
+
return { ...base, status: "info", summary: "not configured (no telegram adapter in config)" };
|
|
35
|
+
} catch {
|
|
36
|
+
return { ...base, status: "info", summary: "skipped (no config file)" };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
id: "telegram-api", category: "network", name: "Telegram API reachable",
|
|
43
|
+
async run(ctx) {
|
|
44
|
+
const base = { id: "telegram-api", category: "network" as const, name: "Telegram API reachable" };
|
|
45
|
+
const token = await resolveToken(ctx);
|
|
46
|
+
if (!token) {
|
|
47
|
+
return { ...base, status: "info", summary: "skipped (no token)" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, {
|
|
52
|
+
signal: AbortSignal.timeout(10000),
|
|
53
|
+
});
|
|
54
|
+
if (res.ok) {
|
|
55
|
+
const data = await res.json() as any;
|
|
56
|
+
return { ...base, status: "pass", summary: `@${data.result?.username ?? "unknown"}` };
|
|
57
|
+
}
|
|
58
|
+
if (res.status === 401) {
|
|
59
|
+
return { ...base, status: "fail", summary: "401 Unauthorized — token is invalid or revoked" };
|
|
60
|
+
}
|
|
61
|
+
return { ...base, status: "fail", summary: `API returned ${res.status}` };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return {
|
|
64
|
+
...base, status: "fail", summary: "cannot reach api.telegram.org",
|
|
65
|
+
details: [(err as Error).message],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
id: "telegram-webhook", category: "network", name: "Telegram webhook status",
|
|
73
|
+
async run(ctx) {
|
|
74
|
+
const base = { id: "telegram-webhook", category: "network" as const, name: "Telegram webhook status" };
|
|
75
|
+
const token = await resolveToken(ctx);
|
|
76
|
+
if (!token) {
|
|
77
|
+
return { ...base, status: "info", summary: "skipped (no token)" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check what mode is configured
|
|
81
|
+
let configuredMode = "polling";
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readFile(ctx.configPath, "utf8");
|
|
84
|
+
const cfg = JSON.parse(raw);
|
|
85
|
+
configuredMode = cfg.chat?.adapters?.telegram?.mode ?? "polling";
|
|
86
|
+
} catch {}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/getWebhookInfo`, {
|
|
90
|
+
signal: AbortSignal.timeout(10000),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
return { ...base, status: "warn", summary: `getWebhookInfo returned ${res.status}` };
|
|
94
|
+
}
|
|
95
|
+
const data = await res.json() as any;
|
|
96
|
+
const webhookUrl = data.result?.url;
|
|
97
|
+
const pendingUpdates = data.result?.pending_update_count ?? 0;
|
|
98
|
+
|
|
99
|
+
if (configuredMode === "polling") {
|
|
100
|
+
if (webhookUrl) {
|
|
101
|
+
return {
|
|
102
|
+
...base, status: "warn", summary: `webhook set but mode is polling`,
|
|
103
|
+
details: [
|
|
104
|
+
`Webhook URL: ${webhookUrl}`,
|
|
105
|
+
"Polling won't receive updates while a webhook is active.",
|
|
106
|
+
"The gateway will clear this on startup, but if it fails to start, messages are lost.",
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ...base, status: "pass", summary: `no webhook (polling mode), ${pendingUpdates} pending updates` };
|
|
111
|
+
} else {
|
|
112
|
+
// webhook mode
|
|
113
|
+
if (!webhookUrl) {
|
|
114
|
+
return { ...base, status: "warn", summary: "webhook mode configured but no webhook set" };
|
|
115
|
+
}
|
|
116
|
+
return { ...base, status: "pass", summary: `webhook: ${webhookUrl}, ${pendingUpdates} pending` };
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
...base, status: "warn", summary: "cannot check webhook status",
|
|
121
|
+
details: [(err as Error).message],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
package/src/cli/doctor/runner.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { agentChecks } from "./checks/agent";
|
|
|
13
13
|
import { systemdChecks } from "./checks/systemd";
|
|
14
14
|
import { diskChecks } from "./checks/disk";
|
|
15
15
|
import { sttChecks } from "./checks/stt";
|
|
16
|
+
import { telegramChecks } from "./checks/telegram";
|
|
16
17
|
|
|
17
18
|
/** Create a DoctorContext with sensible defaults */
|
|
18
19
|
export async function createDoctorContext(overrides: Partial<DoctorContext> = {}): Promise<DoctorContext> {
|
|
@@ -34,6 +35,7 @@ const ALL_CHECKS: DoctorCheck[] = [
|
|
|
34
35
|
...configChecks,
|
|
35
36
|
...credentialChecks,
|
|
36
37
|
...agentChecks,
|
|
38
|
+
...telegramChecks,
|
|
37
39
|
...sttChecks,
|
|
38
40
|
...diskChecks,
|
|
39
41
|
...systemdChecks,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/env-file.ts — Shared env file parsing and quoting
|
|
3
|
+
*
|
|
4
|
+
* Used by install, setup, status, doctor, and pair commands.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse a systemd-compatible env file into a key→value map.
|
|
9
|
+
* Skips blank lines and comments (#).
|
|
10
|
+
*/
|
|
11
|
+
export function parseEnvFile(content: string): Map<string, string> {
|
|
12
|
+
const entries = new Map<string, string>();
|
|
13
|
+
for (const line of content.split("\n")) {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
16
|
+
const eq = trimmed.indexOf("=");
|
|
17
|
+
if (eq > 0) entries.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
|
|
18
|
+
}
|
|
19
|
+
return entries;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Serialize a key→value map to env file content.
|
|
24
|
+
*/
|
|
25
|
+
export function serializeEnvFile(entries: Map<string, string>): string {
|
|
26
|
+
return [...entries.entries()].map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shell-escape a value for env files (double-quoted).
|
|
31
|
+
*/
|
|
32
|
+
export function envQuote(value: string): string {
|
|
33
|
+
const escaped = value
|
|
34
|
+
.replace(/\\/g, "\\\\")
|
|
35
|
+
.replace(/"/g, '\\"')
|
|
36
|
+
.replace(/\$/g, "\\$")
|
|
37
|
+
.replace(/`/g, "\\`")
|
|
38
|
+
.replace(/\n/g, "\\n");
|
|
39
|
+
return `"${escaped}"`;
|
|
40
|
+
}
|
package/src/cli/setup.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { homedir, platform } from "node:os";
|
|
13
13
|
import { resolve, dirname } from "node:path";
|
|
14
14
|
import { readFile, writeFile, mkdir, rename, unlink, realpath, stat } from "node:fs/promises";
|
|
15
|
-
import { execFileSync
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
16
|
import { randomBytes } from "node:crypto";
|
|
17
17
|
import { BOT_COMMANDS } from "../commands";
|
|
18
18
|
import {
|
|
@@ -21,6 +21,17 @@ import {
|
|
|
21
21
|
ENV_FILE_PATH as ENV_PATH,
|
|
22
22
|
fileExists,
|
|
23
23
|
} from "../config";
|
|
24
|
+
import { envQuote, parseEnvFile } from "./env-file";
|
|
25
|
+
import {
|
|
26
|
+
whichSync,
|
|
27
|
+
systemctl,
|
|
28
|
+
isServiceActive,
|
|
29
|
+
systemctlShow,
|
|
30
|
+
resolveExecStart,
|
|
31
|
+
generateUnit,
|
|
32
|
+
writeServiceUnit,
|
|
33
|
+
hasSudoAccess,
|
|
34
|
+
} from "./systemd";
|
|
24
35
|
import {
|
|
25
36
|
validateBotToken,
|
|
26
37
|
checkWebhook,
|
|
@@ -90,18 +101,6 @@ async function atomicWriteText(path: string, content: string, mode = 0o600): Pro
|
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
/** Shell-escape a value for env files */
|
|
94
|
-
function envQuote(value: string): string {
|
|
95
|
-
// Escape backslash, double-quote, dollar, backtick, newline
|
|
96
|
-
const escaped = value
|
|
97
|
-
.replace(/\\/g, "\\\\")
|
|
98
|
-
.replace(/"/g, '\\"')
|
|
99
|
-
.replace(/\$/g, "\\$")
|
|
100
|
-
.replace(/`/g, "\\`")
|
|
101
|
-
.replace(/\n/g, "\\n");
|
|
102
|
-
return `"${escaped}"`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
104
|
function execSafe(cmd: string, args: string[], opts: { silent?: boolean; input?: string } = {}): string {
|
|
106
105
|
try {
|
|
107
106
|
const result = execFileSync(cmd, args, {
|
|
@@ -124,11 +123,6 @@ function execOrFail(cmd: string, args: string[], label: string): string {
|
|
|
124
123
|
}
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
function whichSync(cmd: string): string | null {
|
|
128
|
-
const result = execSafe("which", [cmd], { silent: true });
|
|
129
|
-
return result || null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
126
|
// ── Arg parser ───────────────────────────────────────
|
|
133
127
|
|
|
134
128
|
export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
@@ -142,7 +136,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
|
142
136
|
notifyChatIds: [],
|
|
143
137
|
systemd: platform() === "linux",
|
|
144
138
|
voice: true,
|
|
145
|
-
psst:
|
|
139
|
+
psst: false,
|
|
146
140
|
nonInteractive: false,
|
|
147
141
|
force: false,
|
|
148
142
|
dryRun: false,
|
|
@@ -165,7 +159,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
|
165
159
|
case "--notify-chat": opts.notifyChatIds.push(parseInt(next(), 10)); break;
|
|
166
160
|
case "--no-systemd": opts.systemd = false; break;
|
|
167
161
|
case "--no-voice": opts.voice = false; break;
|
|
168
|
-
case "--
|
|
162
|
+
case "--with-psst": opts.psst = true; break;
|
|
169
163
|
case "--non-interactive": opts.nonInteractive = true; break;
|
|
170
164
|
case "--force": opts.force = true; break;
|
|
171
165
|
case "--dry-run": opts.dryRun = true; break;
|
|
@@ -316,11 +310,10 @@ async function stepStopGateway(): Promise<void> {
|
|
|
316
310
|
return;
|
|
317
311
|
}
|
|
318
312
|
|
|
319
|
-
|
|
320
|
-
if (isActive === "active") {
|
|
313
|
+
if (isServiceActive()) {
|
|
321
314
|
log(" Stopping existing gateway...");
|
|
322
315
|
try {
|
|
323
|
-
|
|
316
|
+
systemctl("stop");
|
|
324
317
|
ok("Service stopped");
|
|
325
318
|
} catch {
|
|
326
319
|
warn("Could not stop service (may need sudo). Continuing anyway.");
|
|
@@ -453,7 +446,7 @@ async function stepInstallPackages(opts: SetupOptions): Promise<void> {
|
|
|
453
446
|
async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
|
|
454
447
|
if (!opts.psst) {
|
|
455
448
|
step("⑥", "Storing secrets...");
|
|
456
|
-
ok("Skipped (--
|
|
449
|
+
ok("Skipped (default — use --with-psst to enable)");
|
|
457
450
|
return;
|
|
458
451
|
}
|
|
459
452
|
|
|
@@ -606,26 +599,21 @@ async function stepConfigure(
|
|
|
606
599
|
|
|
607
600
|
if (opts.provider === "amazon-bedrock") {
|
|
608
601
|
// Preserve existing AWS config
|
|
609
|
-
let existingEnv
|
|
602
|
+
let existingEnv = new Map<string, string>();
|
|
610
603
|
try {
|
|
611
|
-
|
|
612
|
-
for (const line of raw.split("\n")) {
|
|
613
|
-
const trimmed = line.trim();
|
|
614
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
615
|
-
const eq = trimmed.indexOf("=");
|
|
616
|
-
if (eq > 0) existingEnv[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
617
|
-
}
|
|
604
|
+
existingEnv = parseEnvFile(await readFile(ENV_PATH, "utf8"));
|
|
618
605
|
} catch {}
|
|
606
|
+
const getExisting = (key: string) => existingEnv.get(key);
|
|
619
607
|
|
|
620
608
|
if (!envLines.some((l) => l.startsWith("AWS_PROFILE="))) {
|
|
621
|
-
envLines.push(`AWS_PROFILE=${
|
|
609
|
+
envLines.push(`AWS_PROFILE=${getExisting("AWS_PROFILE") ?? '"default"'}`);
|
|
622
610
|
}
|
|
623
611
|
if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
|
|
624
|
-
envLines.push(`AWS_DEFAULT_REGION=${
|
|
612
|
+
envLines.push(`AWS_DEFAULT_REGION=${getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
|
|
625
613
|
}
|
|
626
614
|
// Pi agent requires AWS_REGION (not just AWS_DEFAULT_REGION) to discover Bedrock models
|
|
627
615
|
if (!envLines.some((l) => l.startsWith("AWS_REGION="))) {
|
|
628
|
-
envLines.push(`AWS_REGION=${
|
|
616
|
+
envLines.push(`AWS_REGION=${getExisting("AWS_REGION") ?? getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
|
|
629
617
|
}
|
|
630
618
|
}
|
|
631
619
|
|
|
@@ -711,19 +699,13 @@ async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
|
|
|
711
699
|
}
|
|
712
700
|
|
|
713
701
|
// Check sudo
|
|
714
|
-
|
|
715
|
-
if (!hasSudo) {
|
|
702
|
+
if (!hasSudoAccess()) {
|
|
716
703
|
warn("No passwordless sudo — cannot install systemd service");
|
|
717
704
|
log(" Run manually: roundhouse start");
|
|
718
705
|
log(" Or install with: sudo roundhouse install");
|
|
719
706
|
return;
|
|
720
707
|
}
|
|
721
708
|
|
|
722
|
-
// Resolve paths
|
|
723
|
-
const roundhouseBin = whichSync("roundhouse") ?? resolve(dirname(process.execPath), "roundhouse");
|
|
724
|
-
const psstBin = opts.psst ? whichSync("psst") : null;
|
|
725
|
-
const nodeBin = process.execPath;
|
|
726
|
-
const nodeBinDir = dirname(nodeBin);
|
|
727
709
|
const user = process.env.USER || process.env.LOGNAME;
|
|
728
710
|
if (!user) {
|
|
729
711
|
warn("Cannot determine current user ($USER not set). Skipping systemd.");
|
|
@@ -731,60 +713,16 @@ async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
|
|
|
731
713
|
return;
|
|
732
714
|
}
|
|
733
715
|
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
if (val && /[\n\r]/.test(val)) {
|
|
738
|
-
warn(`Unsafe value for ${key} (contains newline). Skipping systemd.`);
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// Build ExecStart — uses `roundhouse run` (foreground mode for systemd)
|
|
744
|
-
let execStart: string;
|
|
745
|
-
if (psstBin) {
|
|
746
|
-
execStart = `${psstBin} run ${nodeBin} ${roundhouseBin} run`;
|
|
747
|
-
} else {
|
|
748
|
-
execStart = `${nodeBin} ${roundhouseBin} run`;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// Always include env file for non-secret config (AWS_PROFILE, etc)
|
|
752
|
-
// When using psst, ExecStart wraps with `psst run` to inject secrets
|
|
753
|
-
const unit = `[Unit]
|
|
754
|
-
Description=Roundhouse Chat Gateway
|
|
755
|
-
After=network.target
|
|
756
|
-
|
|
757
|
-
[Service]
|
|
758
|
-
Type=simple
|
|
759
|
-
User=${user}
|
|
760
|
-
WorkingDirectory=${homedir()}
|
|
761
|
-
ExecStart=${execStart}
|
|
762
|
-
Restart=on-failure
|
|
763
|
-
RestartSec=5
|
|
764
|
-
EnvironmentFile=-${ENV_PATH}
|
|
765
|
-
Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
|
|
766
|
-
Environment=NODE_ENV=production
|
|
767
|
-
Environment=PATH=${nodeBinDir}:/usr/local/bin:/usr/bin:/bin
|
|
768
|
-
Environment=HOME=${homedir()}
|
|
769
|
-
|
|
770
|
-
[Install]
|
|
771
|
-
WantedBy=multi-user.target
|
|
772
|
-
`;
|
|
773
|
-
|
|
774
|
-
// Write to tmp, then sudo cp
|
|
775
|
-
const tmpPath = resolve(ROUNDHOUSE_DIR, `roundhouse.service.tmp.${randomBytes(4).toString("hex")}`);
|
|
776
|
-
const servicePath = `/etc/systemd/system/roundhouse.service`;
|
|
716
|
+
const psstBin = opts.psst ? whichSync("psst") : null;
|
|
717
|
+
const { execStart, nodeBinDir } = resolveExecStart({ psstBin });
|
|
718
|
+
const unit = generateUnit({ execStart, nodeBinDir, user });
|
|
777
719
|
|
|
778
720
|
try {
|
|
779
|
-
await
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
execFileSync("sudo", ["-n", "systemctl", "daemon-reload"], { stdio: "pipe" });
|
|
783
|
-
execFileSync("sudo", ["-n", "systemctl", "enable", "roundhouse"], { stdio: "pipe" });
|
|
784
|
-
execFileSync("sudo", ["-n", "systemctl", "start", "roundhouse"], { stdio: "pipe" });
|
|
721
|
+
await writeServiceUnit(unit);
|
|
722
|
+
systemctl("enable");
|
|
723
|
+
systemctl("start");
|
|
785
724
|
ok("roundhouse.service enabled and started");
|
|
786
725
|
} catch (err: any) {
|
|
787
|
-
try { await unlink(tmpPath); } catch {}
|
|
788
726
|
warn(`Systemd install failed: ${err.message}`);
|
|
789
727
|
log(" Run manually: roundhouse start");
|
|
790
728
|
}
|
|
@@ -794,9 +732,8 @@ async function stepPostflight(): Promise<void> {
|
|
|
794
732
|
step("⑩", "Postflight checks...");
|
|
795
733
|
|
|
796
734
|
if (platform() === "linux") {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const pid = execSafe("systemctl", ["show", "-p", "MainPID", "--value", "roundhouse"], { silent: true });
|
|
735
|
+
if (isServiceActive()) {
|
|
736
|
+
const pid = systemctlShow("MainPID");
|
|
800
737
|
ok(`Service active (PID ${pid})`);
|
|
801
738
|
} else {
|
|
802
739
|
warn("Service not active — check: roundhouse logs");
|
|
@@ -907,9 +844,9 @@ export async function cmdPair(argv: string[]): Promise<void> {
|
|
|
907
844
|
// Try existing env file
|
|
908
845
|
if (!token) {
|
|
909
846
|
try {
|
|
910
|
-
const
|
|
911
|
-
const
|
|
912
|
-
if (
|
|
847
|
+
const entries = parseEnvFile(await readFile(ENV_PATH, "utf8"));
|
|
848
|
+
const raw = entries.get("TELEGRAM_BOT_TOKEN");
|
|
849
|
+
if (raw) token = raw.replace(/^["']|["']$/g, "");
|
|
913
850
|
} catch {}
|
|
914
851
|
}
|
|
915
852
|
|
|
@@ -1026,7 +963,7 @@ Channel:
|
|
|
1026
963
|
Service:
|
|
1027
964
|
--no-systemd Skip systemd install
|
|
1028
965
|
--no-voice Disable voice/STT
|
|
1029
|
-
--
|
|
966
|
+
--with-psst Use psst vault for secrets (default: .env file)
|
|
1030
967
|
|
|
1031
968
|
Behavior:
|
|
1032
969
|
--non-interactive No pairing, no prompts
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/systemd.ts — Shared systemd service management
|
|
3
|
+
*
|
|
4
|
+
* Generates unit files, resolves ExecStart, and installs/writes services.
|
|
5
|
+
* Used by both `roundhouse install` (cli.ts) and `roundhouse setup` (setup.ts).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { resolve, dirname } from "node:path";
|
|
10
|
+
import { writeFile, unlink } from "node:fs/promises";
|
|
11
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
ROUNDHOUSE_DIR,
|
|
17
|
+
CONFIG_PATH,
|
|
18
|
+
ENV_FILE_PATH,
|
|
19
|
+
SERVICE_NAME,
|
|
20
|
+
} from "../config";
|
|
21
|
+
|
|
22
|
+
const __systemdDir = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
|
|
24
|
+
export const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
25
|
+
|
|
26
|
+
// ── Shell helpers ───────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export function whichSync(cmd: string): string | null {
|
|
29
|
+
try {
|
|
30
|
+
return execFileSync("which", [cmd], { encoding: "utf8", stdio: "pipe" }).trim() || null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function execSilent(cmd: string, args: string[]): string {
|
|
37
|
+
try {
|
|
38
|
+
return execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe" }).trim();
|
|
39
|
+
} catch {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function runSudo(...args: string[]): void {
|
|
45
|
+
const result = spawnSync("sudo", ["-n", ...args], { stdio: "inherit" });
|
|
46
|
+
if (result.status !== 0) {
|
|
47
|
+
execFileSync("sudo", args, { stdio: "inherit" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function systemctl(verb: string, message?: string): void {
|
|
52
|
+
runSudo("systemctl", verb, SERVICE_NAME);
|
|
53
|
+
if (message) console.log(` ✅ ${message}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hasSudoAccess(): boolean {
|
|
57
|
+
return spawnSync("sudo", ["-n", "true"], { stdio: "pipe" }).status === 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isServiceInstalled(): boolean {
|
|
61
|
+
return execSilent("systemctl", ["list-unit-files", `${SERVICE_NAME}.service`]).includes(SERVICE_NAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isServiceActive(): boolean {
|
|
65
|
+
return execSilent("systemctl", ["is-active", SERVICE_NAME]) === "active";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Query a systemd service property via `systemctl show`.
|
|
70
|
+
*/
|
|
71
|
+
export function systemctlShow(property: string): string {
|
|
72
|
+
return execSilent("systemctl", ["show", "-p", property, "--value", SERVICE_NAME]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── ExecStart resolution ────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface ExecStartOptions {
|
|
78
|
+
/** Path to psst binary (if using psst for secrets) */
|
|
79
|
+
psstBin?: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the ExecStart command for the systemd unit.
|
|
84
|
+
* Prefers the global `roundhouse` binary; falls back to tsx + cli.ts.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveExecStart(opts: ExecStartOptions = {}): { execStart: string; nodeBinDir: string } {
|
|
87
|
+
const roundhouseBin = whichSync("roundhouse");
|
|
88
|
+
const nodeBin = whichSync("node") || process.execPath;
|
|
89
|
+
const nodeBinDir = dirname(nodeBin);
|
|
90
|
+
|
|
91
|
+
let execStart: string;
|
|
92
|
+
if (roundhouseBin) {
|
|
93
|
+
const base = `${nodeBin} ${roundhouseBin} run`;
|
|
94
|
+
execStart = opts.psstBin ? `${opts.psstBin} run ${base}` : base;
|
|
95
|
+
} else {
|
|
96
|
+
// No global install — run CLI via tsx with 'run' subcommand
|
|
97
|
+
const tsxBin = whichSync("tsx") || resolve(__systemdDir, "..", "..", "node_modules", ".bin", "tsx");
|
|
98
|
+
const cliPath = resolve(__systemdDir, "cli.ts");
|
|
99
|
+
const base = `${tsxBin} ${cliPath} run`;
|
|
100
|
+
execStart = opts.psstBin ? `${opts.psstBin} run ${base}` : base;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { execStart, nodeBinDir };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Unit file generation ────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface UnitOptions {
|
|
109
|
+
execStart: string;
|
|
110
|
+
nodeBinDir: string;
|
|
111
|
+
user?: string;
|
|
112
|
+
envFilePath?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Guard against newline injection in values interpolated into the unit template.
|
|
117
|
+
* A crafted $USER or path containing \n/\r could inject arbitrary systemd directives.
|
|
118
|
+
*/
|
|
119
|
+
function assertSafeForUnit(label: string, value: unknown): void {
|
|
120
|
+
if (typeof value !== "string") {
|
|
121
|
+
throw new Error(`Missing or non-string value for ${label} — cannot generate systemd unit`);
|
|
122
|
+
}
|
|
123
|
+
if (/[\n\r]/.test(value)) {
|
|
124
|
+
throw new Error(`Unsafe value for ${label} (contains newline) — cannot generate systemd unit`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generate a systemd unit file string.
|
|
130
|
+
*/
|
|
131
|
+
export function generateUnit(opts: UnitOptions): string {
|
|
132
|
+
const user = opts.user || process.env.USER || "root";
|
|
133
|
+
const envFilePath = opts.envFilePath || ENV_FILE_PATH;
|
|
134
|
+
const home = homedir();
|
|
135
|
+
const pathValue = `${opts.nodeBinDir}:/usr/local/bin:/usr/bin:/bin`;
|
|
136
|
+
|
|
137
|
+
// Validate all interpolated values before generating the unit
|
|
138
|
+
for (const [label, value] of Object.entries({
|
|
139
|
+
user, execStart: opts.execStart, nodeBinDir: opts.nodeBinDir,
|
|
140
|
+
envFilePath, home, configPath: CONFIG_PATH, pathValue,
|
|
141
|
+
})) {
|
|
142
|
+
assertSafeForUnit(label, value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `[Unit]
|
|
146
|
+
Description=Roundhouse Chat Gateway
|
|
147
|
+
After=network.target
|
|
148
|
+
|
|
149
|
+
[Service]
|
|
150
|
+
Type=simple
|
|
151
|
+
User=${user}
|
|
152
|
+
WorkingDirectory=${home}
|
|
153
|
+
ExecStart=${opts.execStart}
|
|
154
|
+
Restart=on-failure
|
|
155
|
+
RestartSec=5
|
|
156
|
+
EnvironmentFile=-${envFilePath}
|
|
157
|
+
Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
|
|
158
|
+
Environment=NODE_ENV=production
|
|
159
|
+
Environment=PATH=${pathValue}
|
|
160
|
+
Environment=HOME=${home}
|
|
161
|
+
|
|
162
|
+
[Install]
|
|
163
|
+
WantedBy=multi-user.target
|
|
164
|
+
`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Install service ─────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Write a systemd unit file via sudo and reload the daemon.
|
|
171
|
+
* Uses atomic write-to-tmp + sudo cp pattern.
|
|
172
|
+
*/
|
|
173
|
+
export async function writeServiceUnit(unitContent: string): Promise<void> {
|
|
174
|
+
const tmpPath = resolve(ROUNDHOUSE_DIR, `roundhouse.service.tmp.${randomBytes(4).toString("hex")}`);
|
|
175
|
+
try {
|
|
176
|
+
await writeFile(tmpPath, unitContent, { mode: 0o600 });
|
|
177
|
+
execFileSync("sudo", ["-n", "cp", tmpPath, SERVICE_PATH], { stdio: "pipe" });
|
|
178
|
+
} finally {
|
|
179
|
+
try { await unlink(tmpPath); } catch {}
|
|
180
|
+
}
|
|
181
|
+
runSudo("systemctl", "daemon-reload");
|
|
182
|
+
}
|