@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 +10 -2
- package/architecture.md +3 -1
- package/package.json +1 -1
- package/src/cli/cli.ts +49 -83
- package/src/cli/env-file.ts +40 -0
- package/src/cli/setup.ts +32 -95
- package/src/cli/systemd.ts +182 -0
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
|
-
|
|
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
|
-
└──
|
|
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,22 +49,28 @@ function run(cmd: string, opts?: { silent?: boolean }): string {
|
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
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
|
-
|
|
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 =
|
|
216
|
-
const activeState =
|
|
217
|
-
const startedAt =
|
|
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
|
|
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
|
|
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
|
-
|
|
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.");
|
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -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
|
+
}
|