@inceptionstack/roundhouse 0.3.12 → 0.3.14

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 CHANGED
@@ -95,7 +95,10 @@ roundhouse install # installs as systemd service, starts automatically
95
95
  roundhouse <command>
96
96
 
97
97
  Commands:
98
- start Start the gateway (foreground)
98
+ setup One-command install & configure (also works via npx)
99
+ pair Pair Telegram account for notifications
100
+ start Start the gateway daemon
101
+ run Run the gateway in foreground
99
102
  tui [thread] Open agent TUI on a gateway session
100
103
  install Install as a systemd daemon (requires sudo)
101
104
  uninstall Remove the systemd daemon
@@ -105,6 +108,9 @@ Commands:
105
108
  stop Stop the daemon
106
109
  restart Restart the daemon
107
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.)
108
114
  ```
109
115
 
110
116
  ### `roundhouse status`
@@ -441,7 +447,9 @@ No other changes needed — the gateway's unified handler covers all platforms.
441
447
  | `src/router.ts` | `AgentRouter` interface + `SingleAgentRouter` |
442
448
  | `src/types.ts` | Core interfaces: `AgentAdapter`, `AgentStreamEvent`, `AgentRouter`, `GatewayConfig` |
443
449
  | `src/util.ts` | Pure utilities: `splitMessage`, `isAllowed`, `threadIdToDir`, `startTypingLoop` |
444
- | `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) |
445
453
  | `src/cli/doctor.ts` | CLI doctor command |
446
454
  | `src/cli/doctor/runner.ts` | Shared doctor runner (CLI + gateway) |
447
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
- └── (node:fs, node:child_process for daemon management)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
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, mkdtemp } from "node:fs/promises";
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, execFileSync, spawnSync, spawn } from "node:child_process";
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,22 +49,28 @@ 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" });
52
+ // ── Commands ────────────────────────────────────────
53
+
54
+ async function cmdStart() {
55
+ if (isServiceInstalled()) {
56
+ if (isServiceActive()) {
57
+ console.log("Roundhouse is already running.");
58
+ console.log(" Use: roundhouse restart to restart");
59
+ console.log(" roundhouse status to check status");
60
+ console.log(" roundhouse logs to tail logs");
61
+ return;
62
+ }
63
+ systemctl("start", "Daemon started.");
64
+ return;
48
65
  }
49
- }
50
66
 
51
- function systemctl(verb: string, message?: string): void {
52
- runSudo("systemctl", verb, SERVICE_NAME);
53
- if (message) console.log(` ${message}`);
67
+ // No systemd service fall back to foreground
68
+ console.log("No systemd service found. Running in foreground (use Ctrl+C to stop)...");
69
+ console.log(" Tip: run 'roundhouse install' to set up the systemd daemon.\n");
70
+ await cmdRun();
54
71
  }
55
72
 
56
- // ── Commands ────────────────────────────────────────
57
-
58
- async function cmdStart() {
73
+ async function cmdRun() {
59
74
  process.env.ROUNDHOUSE_CONFIG = CONFIG_PATH;
60
75
  const indexPath = resolve(__dirname, "..", "index.ts");
61
76
  const jsPath = resolve(__dirname, "..", "dist", "index.js");
@@ -85,22 +100,16 @@ async function cmdInstall() {
85
100
 
86
101
  // Write environment file for secrets — merge with existing to preserve manually-added keys
87
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"];
88
- const existing = new Map<string, string>();
89
103
  const resolvedEnvPath = await resolveEnvFilePath();
90
- if (await fileExists(resolvedEnvPath)) {
91
- const raw = await readFile(resolvedEnvPath, "utf8");
92
- for (const line of raw.split("\n")) {
93
- const trimmed = line.trim();
94
- if (!trimmed || trimmed.startsWith("#")) continue;
95
- const eq = trimmed.indexOf("=");
96
- if (eq > 0) existing.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
97
- }
98
- }
104
+ const existing = await fileExists(resolvedEnvPath)
105
+ ? parseEnvFile(await readFile(resolvedEnvPath, "utf8"))
106
+ : new Map<string, string>();
107
+
99
108
  // Override with current env vars for known keys
100
109
  let envChanged = false;
101
110
  for (const key of ENV_KEYS) {
102
111
  if (process.env[key]) {
103
- existing.set(key, `"${process.env[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$$").replace(/`/g, "\\`").replace(/\n/g, "\\n")}"`);
112
+ existing.set(key, envQuote(process.env[key]));
104
113
  envChanged = true;
105
114
  }
106
115
  }
@@ -108,56 +117,14 @@ async function cmdInstall() {
108
117
  if (resolvedEnvPath !== ENV_FILE_PATH && await fileExists(resolvedEnvPath)) {
109
118
  console.log(` Copying env file from ${resolvedEnvPath} to ${ENV_FILE_PATH}`);
110
119
  }
111
- const envFileContent = [...existing.entries()].map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
112
- await writeFile(ENV_FILE_PATH, envFileContent, { mode: 0o600 });
120
+ await writeFile(ENV_FILE_PATH, serializeEnvFile(existing), { mode: 0o600 });
113
121
  console.log(` Environment file: ${ENV_FILE_PATH}`);
114
122
  }
115
123
 
116
- // Resolve paths prefer the installed bin, fall back to tsx + source
117
- const binPath = run("which roundhouse", { silent: true });
118
- const nodePath = run("which node", { silent: true }) || process.execPath;
119
- const tsxPath = resolve(__dirname, "..", "..", "node_modules", ".bin", "tsx");
120
- const srcIndex = resolve(__dirname, "..", "index.ts");
121
-
122
- let execStart: string;
123
- if (binPath) {
124
- execStart = `${nodePath} ${binPath} start`;
125
- } else {
126
- // No global install — use tsx directly
127
- const tsxBin = run("which tsx", { silent: true }) || tsxPath;
128
- execStart = `${tsxBin} ${srcIndex}`;
129
- }
130
-
131
- // Compute PATH that includes node's bin dir (for mise/nvm setups)
132
- const nodeBinDir = dirname(nodePath);
133
- const pathValue = `${nodeBinDir}:/usr/local/bin:/usr/bin:/bin`;
134
-
135
- const unit = `[Unit]
136
- Description=Roundhouse Chat Gateway
137
- After=network.target
138
-
139
- [Service]
140
- Type=simple
141
- User=${process.env.USER || "root"}
142
- WorkingDirectory=${homedir()}
143
- ExecStart=${execStart}
144
- Restart=on-failure
145
- RestartSec=5
146
- EnvironmentFile=-${ENV_FILE_PATH}
147
- Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
148
- Environment=NODE_ENV=production
149
- Environment=PATH=${pathValue}
150
-
151
- [Install]
152
- WantedBy=multi-user.target
153
- `;
154
-
155
- const tmpDir = await mkdtemp(resolve(tmpdir(), "roundhouse-"));
156
- const tmpUnit = resolve(tmpDir, `${SERVICE_NAME}.service`);
157
- await writeFile(tmpUnit, unit, { mode: 0o600 });
158
- runSudo("cp", tmpUnit, SERVICE_PATH);
159
- runSudo("rm", "-rf", "--", tmpDir);
160
- 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);
161
128
  systemctl("enable");
162
129
  systemctl("start", "Daemon installed and started.");
163
130
 
@@ -195,10 +162,7 @@ async function cmdUpdate() {
195
162
  }
196
163
 
197
164
  async function cmdStatus() {
198
- // Show systemd status
199
- const isActive = run(`systemctl is-active ${SERVICE_NAME}`, { silent: true }) === "active";
200
-
201
- if (!isActive) {
165
+ if (!isServiceActive()) {
202
166
  console.log("\n ❌ Roundhouse is not running.\n");
203
167
  console.log(" Install with: roundhouse install");
204
168
  console.log(" Or start foreground: roundhouse start\n");
@@ -212,9 +176,9 @@ async function cmdStatus() {
212
176
  } catch {}
213
177
 
214
178
  // Gather systemd info
215
- const pid = run(`systemctl show -p MainPID --value ${SERVICE_NAME}`, { silent: true });
216
- const activeState = run(`systemctl show -p ActiveState --value ${SERVICE_NAME}`, { silent: true });
217
- const startedAt = run(`systemctl show -p ActiveEnterTimestamp --value ${SERVICE_NAME}`, { silent: true });
179
+ const pid = systemctlShow("MainPID");
180
+ const activeState = systemctlShow("ActiveState");
181
+ const startedAt = systemctlShow("ActiveEnterTimestamp");
218
182
 
219
183
  // Compute uptime
220
184
  let uptimeStr = "unknown";
@@ -417,7 +381,8 @@ Usage:
417
381
  Commands:
418
382
  setup One-command install & configure (also works via npx)
419
383
  pair Pair Telegram account for notifications
420
- start Start the gateway (foreground)
384
+ start Start the gateway daemon
385
+ run Run the gateway in foreground
421
386
  tui [thread] Open agent TUI on a gateway session
422
387
  install Install as a systemd daemon (requires sudo)
423
388
  uninstall Remove the systemd daemon
@@ -600,6 +565,7 @@ const commands: Record<string, () => void | Promise<void>> = {
600
565
  setup: () => cmdSetup(process.argv.slice(3)),
601
566
  pair: () => cmdPair(process.argv.slice(3)),
602
567
  start: cmdStart,
568
+ run: cmdRun,
603
569
  install: cmdInstall,
604
570
  uninstall: cmdUninstall,
605
571
  update: cmdUpdate,
@@ -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, spawnSync } from "node:child_process";
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 {
@@ -316,11 +310,10 @@ async function stepStopGateway(): Promise<void> {
316
310
  return;
317
311
  }
318
312
 
319
- const isActive = execSafe("systemctl", ["is-active", "roundhouse"], { silent: true });
320
- if (isActive === "active") {
313
+ if (isServiceActive()) {
321
314
  log(" Stopping existing gateway...");
322
315
  try {
323
- execFileSync("sudo", ["-n", "systemctl", "stop", "roundhouse"], { stdio: "pipe", timeout: 30_000 });
316
+ systemctl("stop");
324
317
  ok("Service stopped");
325
318
  } catch {
326
319
  warn("Could not stop service (may need sudo). Continuing anyway.");
@@ -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: Record<string, string> = {};
602
+ let existingEnv = new Map<string, string>();
610
603
  try {
611
- const raw = await readFile(ENV_PATH, "utf8");
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=${existingEnv.AWS_PROFILE ?? '"default"'}`);
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=${existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
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=${existingEnv.AWS_REGION ?? existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
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
- const hasSudo = spawnSync("sudo", ["-n", "true"], { stdio: "pipe" }).status === 0;
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
- // Guard against newline injection in interpolated values
735
- const unitPaths = { user, roundhouseBin, nodeBin, nodeBinDir, psstBin, home: homedir(), cwd: opts.cwd };
736
- for (const [key, val] of Object.entries(unitPaths)) {
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
744
- let execStart: string;
745
- if (psstBin) {
746
- execStart = `${psstBin} run ${nodeBin} ${roundhouseBin} start`;
747
- } else {
748
- execStart = `${nodeBin} ${roundhouseBin} start`;
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 writeFile(tmpPath, unit, { mode: 0o600 });
780
- execFileSync("sudo", ["-n", "cp", tmpPath, servicePath], { stdio: "pipe" });
781
- await unlink(tmpPath);
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
- const isActive = execSafe("systemctl", ["is-active", "roundhouse"], { silent: true });
798
- if (isActive === "active") {
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 envContent = await readFile(ENV_PATH, "utf8");
911
- const match = envContent.match(/TELEGRAM_BOT_TOKEN=["']?([^"'\n]+)["']?/);
912
- if (match) token = match[1];
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
 
@@ -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
+ }