@inceptionstack/roundhouse 0.5.1 → 0.5.2

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
@@ -10,7 +10,7 @@ Multiple chat inputs (Telegram, Slack, Discord via [Vercel Chat SDK](https://cha
10
10
  ```bash
11
11
  npm install -g @inceptionstack/roundhouse
12
12
  roundhouse setup --telegram
13
- roundhouse start # macOS (foreground) Linux auto-starts via systemd
13
+ roundhouse start # Auto-starts via LaunchAgent (macOS) or systemd (Linux)
14
14
  ```
15
15
 
16
16
  ## Architecture
@@ -483,6 +483,7 @@ No other changes needed — the gateway's unified handler covers all platforms.
483
483
  | `src/cli/cli.ts` | CLI: start, run, install, tui, update, logs, etc. |
484
484
  | `src/cli/env-file.ts` | Shared env file parsing, serialization, and quoting |
485
485
  | `src/cli/systemd.ts` | Shared systemd service management (unit generation, install, status) |
486
+ | `src/cli/launchd.ts` | macOS LaunchAgent management (plist generation, install, status) |
486
487
  | `src/cli/doctor.ts` | CLI doctor command |
487
488
  | `src/cli/doctor/runner.ts` | Shared doctor runner (CLI + gateway) |
488
489
  | `src/cli/doctor/checks/` | Individual health check modules |
package/architecture.md CHANGED
@@ -276,9 +276,10 @@ cli/cli.ts
276
276
  ├── agents/registry.ts (getAgentSdkPackage)
277
277
  ├── cli/env-file.ts (parseEnvFile, serializeEnvFile, envQuote)
278
278
  ├── cli/systemd.ts (resolveExecStart, generateUnit, writeServiceUnit, systemctl, etc.)
279
+ ├── cli/launchd.ts (generatePlist, installLaunchAgent, uninstallLaunchAgent, isLaunchAgentRunning)
279
280
  ├── cli/doctor.ts → cli/doctor/runner.ts → cli/doctor/checks/*
280
281
  ├── cli/cron.ts → cron/store.ts, cron/runner.ts, cron/helpers.ts
281
- └── cli/setup.ts → cli/env-file.ts, cli/systemd.ts, cli/setup-telegram.ts, bundle.ts
282
+ └── cli/setup.ts → cli/env-file.ts, cli/systemd.ts, cli/launchd.ts, cli/setup-telegram.ts, bundle.ts
282
283
 
283
284
  gateway.ts also imports:
284
285
  → commands/update.ts → bundle.ts (bundle provisioning)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { resolve, dirname } from "node:path";
8
+ import { homedir } from "node:os";
8
9
  import { readFile } from "node:fs/promises";
9
10
  import { readdirSync, statSync } from "node:fs";
10
11
  import { execSync, execFileSync, spawn } from "node:child_process";
@@ -54,6 +55,28 @@ function run(cmd: string, opts?: { silent?: boolean }): string {
54
55
  // ── Commands ────────────────────────────────────────
55
56
 
56
57
  async function cmdStart() {
58
+ // macOS: check launchd agent
59
+ if (process.platform === "darwin") {
60
+ const { isLaunchAgentInstalled, isLaunchAgentRunning, PLIST_PATH } = await import("./launchd.ts");
61
+ if (isLaunchAgentInstalled()) {
62
+ if (isLaunchAgentRunning()) {
63
+ console.log("Roundhouse is already running (LaunchAgent).");
64
+ console.log(" Logs: ~/.roundhouse/logs/roundhouse.log");
65
+ console.log(" Stop: roundhouse stop");
66
+ return;
67
+ }
68
+ // Load it
69
+ try {
70
+ execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
71
+ console.log("LaunchAgent started.");
72
+ console.log(" Logs: ~/.roundhouse/logs/roundhouse.log");
73
+ return;
74
+ } catch {
75
+ // Fall through to foreground
76
+ }
77
+ }
78
+ }
79
+
57
80
  if (isServiceInstalled()) {
58
81
  if (isServiceActive()) {
59
82
  console.log("Roundhouse is already running.");
@@ -147,6 +170,16 @@ async function cmdInstall() {
147
170
  console.log(" and installs the systemd service — all in one command.\n");
148
171
  }
149
172
  async function cmdUninstall() {
173
+ if (process.platform === "darwin") {
174
+ const { uninstallLaunchAgent, isLaunchAgentInstalled } = await import("./launchd.ts");
175
+ if (isLaunchAgentInstalled()) {
176
+ await uninstallLaunchAgent();
177
+ console.log(" ✅ LaunchAgent removed. Config preserved at:", CONFIG_PATH);
178
+ } else {
179
+ console.log(" No LaunchAgent installed.");
180
+ }
181
+ return;
182
+ }
150
183
 
151
184
  console.log("[roundhouse] Removing systemd daemon...");
152
185
  try { systemctl("stop"); } catch {}
@@ -167,7 +200,21 @@ async function cmdUpdate() {
167
200
 
168
201
  console.log(`[roundhouse] Updated to v${result.latestVersion}`);
169
202
 
170
- if (process.platform === "darwin" || !isServiceInstalled()) {
203
+ if (process.platform === "darwin") {
204
+ // Try to restart launchd agent
205
+ const { isLaunchAgentInstalled, PLIST_PATH } = await import("./launchd.ts");
206
+ if (isLaunchAgentInstalled()) {
207
+ try {
208
+ execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
209
+ execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
210
+ console.log("\n ✅ Updated and restarted (LaunchAgent).");
211
+ } catch {
212
+ console.log("\n ✅ Update complete. Restart with: roundhouse start");
213
+ }
214
+ } else {
215
+ console.log("\n ✅ Update complete. Restart with: roundhouse start");
216
+ }
217
+ } else if (!isServiceInstalled()) {
171
218
  console.log("\n ✅ Update complete. Restart with: roundhouse start");
172
219
  } else {
173
220
  console.log("\n[roundhouse] Restarting daemon...");
@@ -180,6 +227,23 @@ async function cmdUpdate() {
180
227
  }
181
228
 
182
229
  async function cmdStatus() {
230
+ // macOS: check launchd
231
+ if (process.platform === "darwin") {
232
+ const { isLaunchAgentInstalled, isLaunchAgentRunning } = await import("./launchd.ts");
233
+ if (isLaunchAgentRunning()) {
234
+ console.log("\n ✅ Roundhouse is running (LaunchAgent).\n");
235
+ console.log(" Logs: ~/.roundhouse/logs/roundhouse.log");
236
+ console.log(" Stop: roundhouse stop\n");
237
+ } else if (isLaunchAgentInstalled()) {
238
+ console.log("\n ⚠️ LaunchAgent installed but not running.\n");
239
+ console.log(" Start with: roundhouse start\n");
240
+ } else {
241
+ console.log("\n ❌ Roundhouse is not running.\n");
242
+ console.log(" Start with: roundhouse start\n");
243
+ }
244
+ return;
245
+ }
246
+
183
247
  if (!isServiceActive()) {
184
248
  console.log("\n ❌ Roundhouse is not running.\n");
185
249
  console.log(" Start with: roundhouse start\n");
@@ -268,15 +332,47 @@ async function cmdStatus() {
268
332
  console.log();
269
333
  }
270
334
 
271
- function cmdLogs() {
335
+ async function cmdLogs() {
336
+ if (process.platform === "darwin") {
337
+ const logPath = resolve(homedir(), ".roundhouse", "logs", "roundhouse.log");
338
+ const child = spawn("tail", ["-f", "-n", "100", logPath], { stdio: "inherit" });
339
+ child.on("error", () => console.log("Could not read logs. Check ~/.roundhouse/logs/"));
340
+ return;
341
+ }
272
342
  const child = spawn("journalctl", ["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"], {
273
343
  stdio: "inherit",
274
344
  });
275
345
  child.on("error", () => console.log("Could not read logs. Is the daemon installed?"));
276
346
  }
277
347
 
278
- function cmdStop() { systemctl("stop", "Daemon stopped."); }
279
- function cmdRestart() { systemctl("restart", "Daemon restarted."); }
348
+ async function cmdStop() {
349
+ if (process.platform === "darwin") {
350
+ const { isLaunchAgentInstalled, PLIST_PATH } = await import("./launchd.ts");
351
+ if (isLaunchAgentInstalled()) {
352
+ try { execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" }); } catch (e: any) { if (!e.message?.includes("Could not find")) console.warn(" (unload warning:", e.message?.split("\n")[0], ")"); }
353
+ console.log("LaunchAgent stopped.");
354
+ } else {
355
+ console.log("No LaunchAgent installed. Nothing to stop.");
356
+ }
357
+ return;
358
+ }
359
+ systemctl("stop", "Daemon stopped.");
360
+ }
361
+
362
+ async function cmdRestart() {
363
+ if (process.platform === "darwin") {
364
+ const { isLaunchAgentInstalled, PLIST_PATH } = await import("./launchd.ts");
365
+ if (isLaunchAgentInstalled()) {
366
+ try { execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" }); } catch {}
367
+ execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
368
+ console.log("LaunchAgent restarted.");
369
+ } else {
370
+ console.log("No LaunchAgent installed. Run: roundhouse setup --telegram");
371
+ }
372
+ return;
373
+ }
374
+ systemctl("restart", "Daemon restarted.");
375
+ }
280
376
 
281
377
  async function cmdConfig() {
282
378
  console.log(`Config path: ${CONFIG_PATH}\n`);
@@ -0,0 +1,144 @@
1
+ /**
2
+ * launchd.ts — macOS launchd service management for roundhouse
3
+ *
4
+ * Generates and installs a LaunchAgent plist so roundhouse
5
+ * auto-starts on login and can be managed via launchctl.
6
+ */
7
+
8
+ import { resolve } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { writeFile, mkdir } from "node:fs/promises";
11
+ import { existsSync } from "node:fs";
12
+ import { execFileSync } from "node:child_process";
13
+ import { whichSync } from "./systemd";
14
+ import { ROUNDHOUSE_DIR } from "../config";
15
+ const __dirname = new URL(".", import.meta.url).pathname.replace(/\/$/, "");
16
+
17
+ const LABEL = "com.inceptionstack.roundhouse";
18
+ const PLIST_DIR = resolve(homedir(), "Library", "LaunchAgents");
19
+ export const PLIST_PATH = resolve(PLIST_DIR, `${LABEL}.plist`);
20
+ /**
21
+ * Generate a LaunchAgent plist for roundhouse.
22
+ */
23
+ export function generatePlist(): string {
24
+ const nodeBin = whichSync("node") || process.execPath;
25
+ const roundhouseBin = whichSync("roundhouse");
26
+
27
+ let programArgs: string[];
28
+ if (roundhouseBin) {
29
+ programArgs = [nodeBin, roundhouseBin, "run"];
30
+ } else {
31
+ // Fallback: tsx path
32
+ const tsxBin = whichSync("tsx") || resolve(__dirname, "..", "..", "node_modules", ".bin", "tsx");
33
+ const cliPath = resolve(__dirname, "cli.ts");
34
+ programArgs = [nodeBin, tsxBin, cliPath, "run"];
35
+ }
36
+
37
+ const logDir = resolve(ROUNDHOUSE_DIR, "logs");
38
+ let envSection = "";
39
+ const envVars: Record<string, string> = {
40
+ HOME: homedir(),
41
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
42
+ ROUNDHOUSE_CONFIG: resolve(ROUNDHOUSE_DIR, "gateway.config.json"),
43
+ NODE_NO_WARNINGS: "1",
44
+ };
45
+
46
+ envSection = Object.entries(envVars)
47
+ .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
48
+ .join("\n");
49
+
50
+ return `<?xml version="1.0" encoding="UTF-8"?>
51
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
52
+ <plist version="1.0">
53
+ <dict>
54
+ <key>Label</key>
55
+ <string>${LABEL}</string>
56
+
57
+ <key>ProgramArguments</key>
58
+ <array>
59
+ ${programArgs.map(a => ` <string>${escapeXml(a)}</string>`).join("\n")}
60
+ </array>
61
+
62
+ <key>EnvironmentVariables</key>
63
+ <dict>
64
+ ${envSection}
65
+ </dict>
66
+
67
+ <key>RunAtLoad</key>
68
+ <true/>
69
+
70
+ <key>KeepAlive</key>
71
+ <true/>
72
+
73
+ <key>StandardOutPath</key>
74
+ <string>${escapeXml(resolve(logDir, "roundhouse.log"))}</string>
75
+
76
+ <key>StandardErrorPath</key>
77
+ <string>${escapeXml(resolve(logDir, "roundhouse.err"))}</string>
78
+
79
+ <key>WorkingDirectory</key>
80
+ <string>${escapeXml(homedir())}</string>
81
+
82
+ <key>ThrottleInterval</key>
83
+ <integer>5</integer>
84
+ </dict>
85
+ </plist>
86
+ `;
87
+ }
88
+
89
+ /**
90
+ * Install the plist and load the service.
91
+ */
92
+ export async function installLaunchAgent(): Promise<void> {
93
+ await mkdir(PLIST_DIR, { recursive: true });
94
+ await mkdir(resolve(ROUNDHOUSE_DIR, "logs"), { recursive: true });
95
+
96
+ const plist = generatePlist();
97
+ await writeFile(PLIST_PATH, plist, { mode: 0o644 });
98
+
99
+ // Unload first if already loaded (ignore errors)
100
+ try {
101
+ execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
102
+ } catch {}
103
+
104
+ // Load the agent
105
+ execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
106
+ }
107
+
108
+ /**
109
+ * Unload and remove the launch agent.
110
+ */
111
+ export async function uninstallLaunchAgent(): Promise<void> {
112
+ try {
113
+ execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
114
+ } catch {}
115
+
116
+ const { unlink } = await import("node:fs/promises");
117
+ try {
118
+ await unlink(PLIST_PATH);
119
+ } catch {}
120
+ }
121
+
122
+ /**
123
+ * Check if the launch agent is loaded and running.
124
+ */
125
+ export function isLaunchAgentRunning(): boolean {
126
+ try {
127
+ const output = execFileSync("launchctl", ["list", LABEL], { encoding: "utf8", stdio: "pipe" });
128
+ return output.includes(LABEL);
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check if the plist file exists.
136
+ */
137
+ export function isLaunchAgentInstalled(): boolean {
138
+ return existsSync(PLIST_PATH);
139
+ }
140
+
141
+ function escapeXml(s: string): string {
142
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
143
+ }
144
+
package/src/cli/setup.ts CHANGED
@@ -448,11 +448,26 @@ async function stepValidateToken(opts: SetupOptions): Promise<BotInfo> {
448
448
  async function stepStopGateway(): Promise<void> {
449
449
  step("④", "Checking for running gateway...");
450
450
 
451
- if (platform() !== "linux") {
452
- ok("Not Linux — skipping service check");
451
+ if (platform() === "darwin") {
452
+ try {
453
+ const { isLaunchAgentRunning, PLIST_PATH } = await import("./launchd.ts");
454
+ if (isLaunchAgentRunning()) {
455
+ log(" Stopping existing LaunchAgent...");
456
+ execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
457
+ ok("LaunchAgent stopped");
458
+ } else {
459
+ ok("No running gateway");
460
+ }
461
+ } catch {
462
+ ok("No running gateway");
463
+ }
453
464
  return;
454
465
  }
455
466
 
467
+ if (platform() !== "linux") {
468
+ ok("Skipped (not Linux or macOS)");
469
+ return;
470
+ }
456
471
  if (isServiceActive()) {
457
472
  log(" Stopping existing gateway...");
458
473
  try {
@@ -835,16 +850,30 @@ async function stepRegisterCommands(opts: SetupOptions): Promise<void> {
835
850
  }
836
851
 
837
852
  async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
838
- step("⑩b", "Installing systemd service...");
853
+ step("⑩b", "Installing service...");
854
+
855
+ // macOS: install launchd agent
856
+ if (platform() === "darwin") {
857
+ try {
858
+ const { installLaunchAgent } = await import("./launchd.ts");
859
+ await installLaunchAgent();
860
+ ok("LaunchAgent installed and loaded");
861
+ log(" Logs: ~/.roundhouse/logs/roundhouse.log");
862
+ } catch (err: any) {
863
+ warn(`LaunchAgent install failed: ${err.message}`);
864
+ log(" Run manually: roundhouse start");
865
+ }
866
+ return;
867
+ }
868
+
839
869
 
840
870
  if (!opts.systemd) {
841
871
  ok("Skipped (--no-systemd)");
842
872
  log(" Run manually: roundhouse start");
843
873
  return;
844
874
  }
845
-
846
875
  if (platform() !== "linux") {
847
- warn(`Systemd not available (${platform()})`);
876
+ warn(`Service install not supported on ${platform()}`);
848
877
  log(" Run manually: roundhouse start");
849
878
  return;
850
879
  }
@@ -1135,25 +1164,40 @@ async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
1135
1164
  await stepRegisterCommands(opts);
1136
1165
  logger.ok("Bot commands registered");
1137
1166
 
1167
+ let serviceInstalled = false;
1138
1168
  // Step 9: Install and start service
1139
1169
  logger.step(9, 9, "service.install", "Installing and starting service");
1140
- if (!opts.systemd) {
1170
+ if (!opts.systemd && platform() !== "darwin") {
1141
1171
  logger.warn("service.skip", "--no-systemd: service not installed. Start manually: roundhouse start");
1142
1172
  } else {
1143
1173
  await stepInstallSystemd(opts);
1144
- logger.ok("Service installed and started");
1145
1174
 
1146
- // Verify service is active
1147
- try {
1148
- const { execFileSync } = await import("node:child_process");
1149
- const state = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
1150
- if (state === "active") {
1151
- logger.ok("Service is active");
1152
- } else {
1153
- logger.warn("service.state", `Service state: ${state}`);
1175
+ // Verify service is active and set serviceInstalled based on reality
1176
+ if (platform() === "darwin") {
1177
+ try {
1178
+ const { isLaunchAgentRunning } = await import("./launchd.ts");
1179
+ if (isLaunchAgentRunning()) {
1180
+ logger.ok("LaunchAgent is running");
1181
+ serviceInstalled = true;
1182
+ } else {
1183
+ logger.warn("service.state", "LaunchAgent loaded but not yet running");
1184
+ }
1185
+ } catch {
1186
+ logger.warn("service.state", "Could not verify LaunchAgent state");
1187
+ }
1188
+ } else {
1189
+ try {
1190
+ const { execFileSync } = await import("node:child_process");
1191
+ const state = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
1192
+ if (state === "active") {
1193
+ logger.ok("Service is active");
1194
+ serviceInstalled = true;
1195
+ } else {
1196
+ logger.warn("service.state", `Service state: ${state}`);
1197
+ }
1198
+ } catch {
1199
+ logger.warn("service.state", "Could not verify service state");
1154
1200
  }
1155
- } catch {
1156
- logger.warn("service.state", "Could not verify service state");
1157
1201
  }
1158
1202
  }
1159
1203
 
@@ -1162,7 +1206,7 @@ async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
1162
1206
  botUsername: botInfo.username,
1163
1207
  pairingLink,
1164
1208
  pairingStatus: "pending",
1165
- serviceInstalled: opts.systemd,
1209
+ serviceInstalled,
1166
1210
  });
1167
1211
  log("");
1168
1212
  log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━`);