@inceptionstack/roundhouse 0.3.13 → 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,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
- └── (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.13",
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,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
- // If systemd service is installed, start via systemctl
60
- const serviceInstalled = run(`systemctl list-unit-files ${SERVICE_NAME}.service`, { silent: true }).includes(SERVICE_NAME);
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
- if (await fileExists(resolvedEnvPath)) {
113
- const raw = await readFile(resolvedEnvPath, "utf8");
114
- for (const line of raw.split("\n")) {
115
- const trimmed = line.trim();
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, `"${process.env[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$$").replace(/`/g, "\\`").replace(/\n/g, "\\n")}"`);
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
- const envFileContent = [...existing.entries()].map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
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
- // Resolve paths prefer the installed bin, fall back to tsx + source
139
- const binPath = run("which roundhouse", { silent: true });
140
- const nodePath = run("which node", { silent: true }) || process.execPath;
141
- const tsxPath = resolve(__dirname, "..", "..", "node_modules", ".bin", "tsx");
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
- // Show systemd status
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 = run(`systemctl show -p MainPID --value ${SERVICE_NAME}`, { silent: true });
238
- const activeState = run(`systemctl show -p ActiveState --value ${SERVICE_NAME}`, { silent: true });
239
- 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");
240
182
 
241
183
  // Compute uptime
242
184
  let uptimeStr = "unknown";
@@ -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 — 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 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
+ }