@inceptionstack/roundhouse 0.5.2 → 0.5.4

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/src/cli/setup.ts CHANGED
@@ -9,1237 +9,40 @@
9
9
  * and starts the systemd service.
10
10
  */
11
11
 
12
- import { homedir, platform } from "node:os";
13
- import { resolve, dirname } from "node:path";
14
- import { readFile, writeFile, mkdir, rename, unlink, realpath, stat } from "node:fs/promises";
15
- import { execFileSync } from "node:child_process";
16
- import { randomBytes } from "node:crypto";
12
+ import { readFile } from "node:fs/promises";
17
13
  import { BOT_COMMANDS } from "../commands";
18
- import { provisionBundle, type ProvisionLog } from "../bundle";
14
+ import { atomicWriteJson, execSafe } from "./setup/helpers";
15
+ import { type SetupOptions } from "./setup/types";
16
+ import { parseSetupArgs } from "./setup/args";
17
+ export { parseSetupArgs } from "./setup/args";
19
18
  import {
20
- ROUNDHOUSE_DIR,
21
19
  CONFIG_PATH,
22
20
  ENV_FILE_PATH as ENV_PATH,
23
- fileExists,
24
21
  } from "../config";
25
- import { envQuote, parseEnvFile, unquoteEnvValue } from "./env-file";
26
- import {
27
- whichSync,
28
- systemctl,
29
- isServiceActive,
30
- systemctlShow,
31
- resolveExecStart,
32
- generateUnit,
33
- writeServiceUnit,
34
- hasSudoAccess,
35
- } from "./systemd";
22
+ import { parseEnvFile, unquoteEnvValue } from "./env-file";
36
23
  import {
37
24
  getAgentDefinition,
38
25
  listAvailableAgentTypes,
39
- type AgentDefinition,
40
- type AgentSetupContext,
41
26
  } from "../agents/registry";
42
27
  import {
43
28
  validateBotToken,
44
- checkWebhook,
45
- registerBotCommands,
46
29
  pairTelegram,
47
- sendMessage,
48
- type BotInfo,
49
- type PairResult,
50
30
  } from "./setup-telegram";
51
- import { promptText, promptMasked } from "./setup-prompts";
52
- import { createTextLogger, createJsonLogger, type SetupLogger, type SetupDiagnostics, printDiagnosticError } from "./setup-logger";
53
- import { printQr } from "./qr";
54
31
  import {
55
- createPairingNonce,
56
- createPairingLink,
57
- readPendingPairing,
58
- writePendingPairing,
59
- type PendingPairing,
60
- } from "../pairing";
61
- import { detectEnvironment, formatDetectionResults } from "./detect";
62
-
63
- // ── Types ────────────────────────────────────────────
64
-
65
- interface SetupOptions {
66
- botToken: string;
67
- users: string[];
68
- provider: string;
69
- model: string;
70
- extensions: string[];
71
- cwd: string;
72
- notifyChatIds: number[];
73
- systemd: boolean;
74
- voice: boolean;
75
- psst: boolean;
76
- nonInteractive: boolean;
77
- force: boolean;
78
- dryRun: boolean;
79
- /** Telegram-focused setup flow */
80
- telegram: boolean;
81
- /** Fully headless automation (no TTY prompts) */
82
- headless: boolean;
83
- /** QR code display mode */
84
- qr: "auto" | "always" | "never";
85
- /** Agent type (default: pi) */
86
- agent: string;
87
- /** Set by detection: skip agent package install if already configured */
88
- _skipAgentInstall?: boolean;
89
- }
90
-
91
- type StepStatus = "ok" | "warn" | "skip" | "fail";
92
-
93
- // ── Constants ────────────────────────────────────────
94
-
95
- const PI_SETTINGS_PATH = resolve(homedir(), ".pi", "agent", "settings.json");
96
-
97
- const DEFAULT_PROVIDER = "amazon-bedrock";
98
- const DEFAULT_MODEL = "us.anthropic.claude-opus-4-6-v1";
99
-
100
- const EXTENSION_NAME_RE = /^@?[a-z0-9][\w.\-/]*$/i;
101
-
102
- /**
103
- * Resolve agent definition and wire up setup-specific functions.
104
- * Pi: configure writes ~/.pi/agent/settings.json, installExtension calls pi install.
105
- */
106
- function resolveAgentForSetup(opts: SetupOptions): AgentDefinition {
107
- const agent = { ...getAgentDefinition(opts.agent) };
108
-
109
- if (agent.type === "pi") {
110
- agent.configure = async (ctx: AgentSetupContext) => {
111
- // Read existing settings if present
112
- let existing: Record<string, unknown> = {};
113
- try {
114
- existing = JSON.parse(await readFile(PI_SETTINGS_PATH, "utf8"));
115
- } catch {}
116
-
117
- const settings: Record<string, unknown> = { ...existing };
118
-
119
- if (ctx.force) {
120
- settings.defaultProvider = ctx.provider;
121
- settings.defaultModel = ctx.model;
122
- } else {
123
- if (existing.defaultProvider && existing.defaultProvider !== ctx.provider) {
124
- warn(`Pi provider already set to '${existing.defaultProvider}' (keeping, use --force to override)`);
125
- } else {
126
- settings.defaultProvider = ctx.provider;
127
- }
128
- if (existing.defaultModel && existing.defaultModel !== ctx.model) {
129
- warn(`Pi model already set to '${existing.defaultModel}' (keeping, use --force to override)`);
130
- } else {
131
- settings.defaultModel = ctx.model;
132
- }
133
- }
134
-
135
- // Ensure packages array exists
136
- if (!Array.isArray(settings.packages)) settings.packages = [];
137
-
138
- const pkgs = settings.packages as string[];
139
-
140
- // Remove stale self-reference (roundhouse no longer ships pi extensions)
141
- const selfPkg = "npm:@inceptionstack/roundhouse";
142
- const selfIdx = pkgs.indexOf(selfPkg);
143
- if (selfIdx !== -1) pkgs.splice(selfIdx, 1);
144
-
145
- // Add code review + branch protection extensions
146
- const coreExtensions = [
147
- "npm:@inceptionstack/pi-hard-no",
148
- "npm:@inceptionstack/pi-branch-enforcer",
149
- ];
150
- for (const ext of coreExtensions) {
151
- if (!pkgs.includes(ext)) pkgs.push(ext);
152
- }
153
-
154
- // Add pi-psst if using psst
155
- if (ctx.psst) {
156
- const psstPkg = "npm:@miclivs/pi-psst";
157
- if (!pkgs.includes(psstPkg)) pkgs.push(psstPkg);
158
- }
159
-
160
- await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
161
- await atomicWriteJson(PI_SETTINGS_PATH, settings);
162
- ok(`~/.pi/agent/settings.json (${settings.defaultProvider}, ${settings.defaultModel})`);
163
- };
164
-
165
- agent.installExtension = async (ext: string) => {
166
- execOrFail("pi", ["install", `npm:${ext}`], `extension ${ext}`);
167
- };
168
- }
169
-
170
- return agent;
171
- }
172
-
173
- // ── Helpers ──────────────────────────────────────────
174
-
175
- let log = (msg: string) => { console.log(msg); };
176
- let step = (n: string, label: string) => { log(`\n${n} ${label}`); };
177
- let ok = (msg: string) => { log(` ✓ ${msg}`); };
178
- let warn = (msg: string) => { log(` ⚠ ${msg}`); };
179
- let fail = (msg: string) => { log(` ✗ ${msg}`); };
180
-
181
- async function atomicWriteJson(path: string, data: unknown): Promise<void> {
182
- const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
183
- try {
184
- await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
185
- await rename(tmp, path);
186
- } catch (err) {
187
- try { await unlink(tmp); } catch {}
188
- throw err;
189
- }
190
- }
191
-
192
- async function atomicWriteText(path: string, content: string, mode = 0o600): Promise<void> {
193
- const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
194
- try {
195
- await writeFile(tmp, content, { mode });
196
- await rename(tmp, path);
197
- } catch (err) {
198
- try { await unlink(tmp); } catch {}
199
- throw err;
200
- }
201
- }
202
-
203
- function execSafe(cmd: string, args: string[], opts: { silent?: boolean; input?: string } = {}): string {
204
- try {
205
- const result = execFileSync(cmd, args, {
206
- encoding: "utf8",
207
- stdio: opts.silent ? "pipe" : opts.input ? ["pipe", "pipe", "pipe"] : "pipe",
208
- input: opts.input,
209
- timeout: 120_000,
210
- });
211
- return result.trim();
212
- } catch {
213
- return "";
214
- }
215
- }
216
-
217
- function execOrFail(cmd: string, args: string[], label: string): string {
218
- try {
219
- return execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe", timeout: 120_000 }).trim();
220
- } catch (err: any) {
221
- throw new Error(`${label}: ${err.stderr?.trim() || err.message}`);
222
- }
223
- }
224
-
225
- // ── Arg parser ───────────────────────────────────────
226
-
227
- export function parseSetupArgs(argv: string[]): SetupOptions {
228
- const opts: SetupOptions = {
229
- botToken: "",
230
- users: [],
231
- provider: DEFAULT_PROVIDER,
232
- model: DEFAULT_MODEL,
233
- extensions: [],
234
- cwd: homedir(),
235
- notifyChatIds: [],
236
- systemd: platform() === "linux",
237
- voice: platform() === "linux", // Default off on macOS (whisper install is heavy)
238
- psst: false,
239
- nonInteractive: false,
240
- force: false,
241
- dryRun: false,
242
- telegram: false,
243
- headless: false,
244
- qr: "auto",
245
- agent: "pi",
246
- };
247
-
248
- for (let i = 0; i < argv.length; i++) {
249
- const arg = argv[i];
250
- const next = () => {
251
- if (i + 1 >= argv.length) throw new Error(`Missing value for ${arg}`);
252
- return argv[++i];
253
- };
254
-
255
- switch (arg) {
256
- case "--bot-token": opts.botToken = next(); break;
257
- case "--user": opts.users.push(next().replace(/^@/, "")); break;
258
- case "--provider": opts.provider = next(); break;
259
- case "--model": opts.model = next(); break;
260
- case "--extension": opts.extensions.push(next()); break;
261
- case "--cwd": opts.cwd = next(); break;
262
- case "--notify-chat": opts.notifyChatIds.push(parseInt(next(), 10)); break;
263
- case "--no-systemd": opts.systemd = false; break;
264
- case "--no-voice": opts.voice = false; break;
265
- case "--with-psst": opts.psst = true; break;
266
- case "--non-interactive": opts.nonInteractive = true; break;
267
- case "--telegram": opts.telegram = true; break;
268
- case "--headless": opts.headless = true; opts.nonInteractive = true; break;
269
- case "--agent": opts.agent = next().toLowerCase(); break;
270
- case "--qr": opts.qr = "always"; break;
271
- case "--no-qr": opts.qr = "never"; break;
272
- case "--force": opts.force = true; break;
273
- case "--dry-run": opts.dryRun = true; break;
274
- default:
275
- if (arg.startsWith("-")) throw new Error(`Unknown flag: ${arg}`);
276
- throw new Error(`Unexpected argument: ${arg}`);
277
- }
278
- }
279
-
280
- // Token from env if not in flags
281
- if (!opts.botToken) {
282
- opts.botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
283
- }
284
-
285
- // Headless: reject --bot-token (argv visible in process listings)
286
- if (opts.headless && argv.some((a) => a === "--bot-token")) {
287
- throw new Error(
288
- "--bot-token is not accepted in --headless mode (argv visible in process listings).\n" +
289
- "Use: TELEGRAM_BOT_TOKEN=... roundhouse setup --telegram --headless --user USERNAME",
290
- );
291
- }
292
-
293
- // Validate agent type
294
- try {
295
- getAgentDefinition(opts.agent);
296
- } catch (err: any) {
297
- throw new Error(err.message);
298
- }
299
-
300
- // Interactive --telegram defers token/user prompting to the wizard
301
- const isInteractiveTelegram = opts.telegram && !opts.headless && !opts.nonInteractive && process.stdin.isTTY;
302
-
303
- // Validate
304
- if (!opts.botToken && !opts.dryRun && !isInteractiveTelegram) {
305
- throw new Error(
306
- "Bot token required. Provide via:\n" +
307
- " TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" +
308
- " roundhouse setup --bot-token TOKEN --user USERNAME",
309
- );
310
- }
311
- if (opts.users.length === 0 && !isInteractiveTelegram) {
312
- throw new Error(
313
- "At least one --user USERNAME is required.\n" +
314
- "This is your Telegram username (without @).",
315
- );
316
- }
317
- for (const ext of opts.extensions) {
318
- if (!EXTENSION_NAME_RE.test(ext)) {
319
- throw new Error(`Invalid extension name: ${ext}`);
320
- }
321
- }
322
- if (opts.notifyChatIds.some(isNaN)) {
323
- throw new Error("--notify-chat must be a number");
324
- }
325
-
326
- return opts;
327
- }
328
-
329
- // ── Steps ────────────────────────────────────────────
330
-
331
- async function stepPreflight(opts: SetupOptions, agent: AgentDefinition): Promise<void> {
332
- step("①", "Preflight checks...");
333
-
334
- // Node version
335
- const nodeVer = process.version;
336
- const major = parseInt(nodeVer.replace("v", ""));
337
- if (major < 20) {
338
- fail(`Node.js ${nodeVer} — version 20+ required`);
339
- throw new Error("Node.js 20+ required");
340
- }
341
- ok(`Node.js ${nodeVer}`);
342
-
343
- // npm
344
- if (!whichSync("npm")) {
345
- fail("npm not found on PATH");
346
- throw new Error("npm required");
347
- }
348
- ok("npm available");
349
-
350
- // Config dirs writable
351
- const dirs = [ROUNDHOUSE_DIR, ...(agent.configDirs ?? [])];
352
- for (const dir of dirs) {
353
- try {
354
- await mkdir(dir, { recursive: true });
355
- ok(`Writable: ${dir.replace(homedir(), "~")}`);
356
- } catch {
357
- fail(`Cannot create: ${dir}`);
358
- throw new Error(`Cannot write to ${dir}`);
359
- }
360
- }
361
-
362
- // Seed .env with commented-out example if it doesn't exist yet
363
- if (!(await fileExists(ENV_PATH))) {
364
- const seed = [
365
- "# Roundhouse environment file",
366
- "# Uncomment and set values, or use: roundhouse setup",
367
- "#",
368
- "# TELEGRAM_BOT_TOKEN=\"your-bot-token\"",
369
- "# BOT_USERNAME=\"your_bot_username\"",
370
- "# ALLOWED_USERS=\"your_telegram_username\"",
371
- "# AWS_PROFILE=\"default\"",
372
- "# AWS_REGION=\"us-east-1\"",
373
- "",
374
- ].join("\n");
375
- await writeFile(ENV_PATH, seed, { mode: 0o600 });
376
- }
377
-
378
- // Disk space (rough check)
379
- try {
380
- const dfOut = execSafe("df", ["-BG", "--output=avail", homedir()], { silent: true });
381
- const match = dfOut.match(/(\d+)G/);
382
- if (match) {
383
- const freeGB = parseInt(match[1]);
384
- if (freeGB < 1) {
385
- fail(`Disk: ${freeGB} GB free (need >= 1 GB)`);
386
- throw new Error("Insufficient disk space");
387
- }
388
- ok(`Disk: ${freeGB} GB free`);
389
- }
390
- } catch { /* non-fatal, df might not support these flags */ }
391
-
392
- // Provider credentials (warn only)
393
- if (opts.provider === "amazon-bedrock") {
394
- const hasAws =
395
- process.env.AWS_ACCESS_KEY_ID ||
396
- process.env.AWS_PROFILE ||
397
- await fileExists(resolve(homedir(), ".aws", "credentials")) ||
398
- await fileExists(resolve(homedir(), ".aws", "config"));
399
-
400
- // Also check instance metadata (EC2 IAM role)
401
- let hasInstanceRole = false;
402
- if (!hasAws) {
403
- try {
404
- const result = execSafe("curl", ["-sf", "--max-time", "2",
405
- "http://169.254.169.254/latest/meta-data/iam/security-credentials/"], { silent: true });
406
- hasInstanceRole = result.length > 0;
407
- } catch {}
408
- }
409
-
410
- if (hasAws) {
411
- ok("AWS credentials found");
412
- } else if (hasInstanceRole) {
413
- ok("AWS credentials found (instance IAM role)");
414
- } else {
415
- warn("AWS credentials not found — configure before first use");
416
- }
417
- }
418
-
419
- // --cwd validation
420
- try {
421
- const resolved = await realpath(opts.cwd);
422
- const st = await stat(resolved);
423
- if (!st.isDirectory()) throw new Error("not a directory");
424
- opts.cwd = resolved;
425
- ok(`Working directory: ${resolved.replace(homedir(), "~")}`);
426
- } catch {
427
- fail(`--cwd path invalid: ${opts.cwd}`);
428
- throw new Error(`Invalid --cwd: ${opts.cwd}`);
429
- }
430
- }
431
-
432
- async function stepValidateToken(opts: SetupOptions): Promise<BotInfo> {
433
- step("②", "Validating Telegram bot token...");
434
-
435
- const botInfo = await validateBotToken(opts.botToken);
436
- ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
437
-
438
- // Check for conflicting webhook
439
- const webhook = await checkWebhook(opts.botToken);
440
- if (webhook) {
441
- warn(`Webhook active: ${webhook}`);
442
- warn("Polling won't work while a webhook is set. Remove it or switch to webhook mode.");
443
- }
444
-
445
- return botInfo;
446
- }
447
-
448
- async function stepStopGateway(): Promise<void> {
449
- step("④", "Checking for running gateway...");
450
-
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
- }
464
- return;
465
- }
466
-
467
- if (platform() !== "linux") {
468
- ok("Skipped (not Linux or macOS)");
469
- return;
470
- }
471
- if (isServiceActive()) {
472
- log(" Stopping existing gateway...");
473
- try {
474
- systemctl("stop");
475
- ok("Service stopped");
476
- } catch {
477
- warn("Could not stop service (may need sudo). Continuing anyway.");
478
- }
479
- } else {
480
- ok("No running gateway");
481
- }
482
- }
483
-
484
- async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition): Promise<void> {
485
- step("⑤", "Installing packages...");
486
-
487
- // Roundhouse
488
- const rhInstalled = whichSync("roundhouse");
489
- if (rhInstalled && !opts.force) {
490
- ok(`@inceptionstack/roundhouse (already installed)`);
491
- } else {
492
- log(" Installing @inceptionstack/roundhouse...");
493
- execOrFail("npm", ["install", "-g", "@inceptionstack/roundhouse"], "roundhouse install");
494
- ok("@inceptionstack/roundhouse");
495
- }
496
-
497
- // Agent packages (driven by agent definition)
498
- if (opts._skipAgentInstall) {
499
- ok("Agent already configured — skipping package install");
500
- } else {
501
- for (const pkg of agent.packages) {
502
- const label = pkg.name ?? pkg.packageName;
503
- const installed = pkg.binary ? whichSync(pkg.binary) : false;
504
- if (installed && !opts.force) {
505
- ok(`${label} (already installed)`);
506
- } else {
507
- log(` Installing ${label}...`);
508
- const args = pkg.install === "global"
509
- ? ["install", "-g", pkg.packageName]
510
- : ["install", pkg.packageName];
511
- execOrFail("npm", args, `${label} install`);
512
- ok(label);
513
- }
514
- }
515
- }
516
-
517
- // psst-cli (requires bun runtime)
518
- if (opts.psst) {
519
- // Install bun if not present (psst-cli shebang is #!/usr/bin/env bun)
520
- if (!whichSync("bun")) {
521
- log(" Installing bun runtime (required by psst)...");
522
- try {
523
- execFileSync("bash", ["-c", "curl -fsSL https://bun.sh/install | bash"], {
524
- encoding: "utf8", stdio: "pipe", timeout: 120_000,
525
- env: { ...process.env, HOME: homedir() },
526
- });
527
- // bun installs to ~/.bun/bin/bun
528
- const bunPath = resolve(homedir(), ".bun", "bin");
529
- process.env.PATH = `${bunPath}:${process.env.PATH}`;
530
- ok("bun runtime");
531
- } catch (err: any) {
532
- warn(`bun install failed: ${err.message}`);
533
- warn("psst requires bun — install manually: curl -fsSL https://bun.sh/install | bash");
534
- opts.psst = false;
535
- }
536
- } else {
537
- ok("bun runtime (already installed)");
538
- }
539
- }
540
-
541
- // psst-cli
542
- if (opts.psst) {
543
- const psstInstalled = whichSync("psst");
544
- if (psstInstalled && !opts.force) {
545
- ok(`psst-cli (already installed)`);
546
- } else {
547
- log(" Installing psst-cli...");
548
- try {
549
- execFileSync("npm", ["install", "-g", "psst-cli"], {
550
- encoding: "utf8", stdio: "pipe", timeout: 120_000,
551
- });
552
- } catch {
553
- // npm may exit non-zero due to postinstall warnings — check if binary exists
554
- }
555
- if (whichSync("psst")) {
556
- ok("psst-cli");
557
- } else {
558
- warn("psst-cli install failed");
559
- opts.psst = false;
560
- }
561
- }
562
-
563
- // Initialize psst vault
564
- const vaultExists = await fileExists(resolve(homedir(), ".psst", "envs"));
565
- if (vaultExists) {
566
- ok("psst vault exists");
567
- } else {
568
- log(" Initializing psst vault...");
569
- // On headless servers, no keychain is available — use PSST_PASSWORD
570
- const psstEnv = { ...process.env };
571
- if (!psstEnv.PSST_PASSWORD) {
572
- // Generate a random password and store it for future use
573
- const psstPw = randomBytes(32).toString("base64");
574
- const pwFile = resolve(ROUNDHOUSE_DIR, ".psst-password");
575
- await atomicWriteText(pwFile, psstPw + "\n", 0o600);
576
- psstEnv.PSST_PASSWORD = psstPw;
577
- // Also set for subsequent psst calls in this process
578
- process.env.PSST_PASSWORD = psstPw;
579
- }
580
- try {
581
- execFileSync("psst", ["init"], {
582
- encoding: "utf8", stdio: "pipe", timeout: 30_000,
583
- env: psstEnv,
584
- });
585
- ok("psst vault initialized");
586
- } catch (err: any) {
587
- warn(`psst vault init failed: ${err.stderr?.trim() || err.message}`);
588
- // Clean up orphan password file
589
- try { await unlink(resolve(ROUNDHOUSE_DIR, ".psst-password")); } catch {}
590
- delete process.env.PSST_PASSWORD;
591
- opts.psst = false;
592
- }
593
- }
594
-
595
- // Install agent-specific psst extension (Pi: pi-psst)
596
- if (agent.installExtension) {
597
- log(" Installing agent psst extension...");
598
- try {
599
- await agent.installExtension("@miclivs/pi-psst");
600
- ok("@miclivs/pi-psst extension");
601
- } catch {
602
- ok("@miclivs/pi-psst extension (already installed)");
603
- }
604
- }
605
- }
606
-
607
- // User extensions
608
- for (const ext of opts.extensions) {
609
- if (!agent.installExtension) {
610
- fail(`--extension is not supported for agent "${agent.type}"`);
611
- throw new Error(`Agent "${agent.type}" does not support extensions`);
612
- }
613
- log(` Installing extension: ${ext}...`);
614
- await agent.installExtension(ext);
615
- ok(ext);
616
- }
617
- }
618
-
619
- async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
620
- if (!opts.psst) {
621
- step("⑧", "Storing secrets...");
622
- ok("Skipped (default — use --with-psst to enable)");
623
- return;
624
- }
625
-
626
- step("⑧", "Storing secrets in psst...");
627
-
628
- const secrets: [string, string][] = [
629
- ["TELEGRAM_BOT_TOKEN", opts.botToken],
630
- ["BOT_USERNAME", botInfo.username],
631
- ["ALLOWED_USERS", opts.users.join(",")],
632
- ];
633
-
634
- for (const [name, value] of secrets) {
635
- try {
636
- execFileSync("psst", ["set", name, "--stdin"], {
637
- input: value,
638
- encoding: "utf8",
639
- stdio: ["pipe", "pipe", "pipe"],
640
- timeout: 10_000,
641
- });
642
- ok(`${name} → psst vault`);
643
- } catch {
644
- // May already exist with same value
645
- // Try overwrite
646
- try {
647
- execFileSync("psst", ["set", name, "--stdin"], {
648
- input: value,
649
- encoding: "utf8",
650
- stdio: ["pipe", "pipe", "pipe"],
651
- timeout: 10_000,
652
- env: { ...process.env, PSST_FORCE: "1" },
653
- });
654
- ok(`${name} → psst vault (updated)`);
655
- } catch (err: any) {
656
- warn(`Failed to store ${name} in psst: ${err.message}`);
657
- }
658
- }
659
- }
660
- }
661
-
662
- // ── Bundle install ──────────────────────────────────────────────────
663
-
664
- async function stepInstallBundle(opts: SetupOptions): Promise<void> {
665
- step("⑥", "Installing bundle (skills + CLI tools)...");
666
-
667
- const bundleLog: ProvisionLog = {
668
- info: (msg) => log(` ${msg}`),
669
- warn: (msg) => warn(msg),
670
- ok: (msg) => ok(msg),
671
- };
672
-
673
- provisionBundle({ force: opts.force, log: bundleLog });
674
- }
675
-
676
- async function stepConfigure(
677
- opts: SetupOptions,
678
- botInfo: BotInfo,
679
- pairResult: PairResult | null,
680
- agent: AgentDefinition,
681
- ): Promise<void> {
682
- step("⑨", "Configuring...");
683
-
684
- await mkdir(ROUNDHOUSE_DIR, { recursive: true });
685
-
686
- // Agent-specific config (Pi: ~/.pi/agent/settings.json)
687
- if (agent.configure) {
688
- await agent.configure({
689
- provider: opts.provider,
690
- model: opts.model,
691
- cwd: opts.cwd,
692
- force: opts.force,
693
- psst: opts.psst,
694
- extensions: opts.extensions,
695
- });
696
- }
697
-
698
- // ── Gateway config ──
699
- let gatewayConfig: Record<string, any> = {};
700
- if (!opts.force) {
701
- try {
702
- gatewayConfig = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
703
- } catch { /* new install */ }
704
- }
705
-
706
- // Merge users
707
- const existingUsers: string[] = gatewayConfig.chat?.allowedUsers ?? [];
708
- const existingUserIds: number[] = gatewayConfig.chat?.allowedUserIds ?? [];
709
- const existingNotifyIds: number[] = (gatewayConfig.chat?.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n));
710
-
711
- const mergedUsers = [...new Set([...existingUsers, ...opts.users])];
712
- const mergedUserIds = [...existingUserIds];
713
- const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds])];
714
-
715
- // Add paired user data
716
- if (pairResult) {
717
- if (!mergedUserIds.includes(pairResult.userId)) {
718
- mergedUserIds.push(pairResult.userId);
719
- }
720
- if (!mergedNotifyIds.includes(pairResult.chatId)) {
721
- mergedNotifyIds.push(pairResult.chatId);
722
- }
723
- }
724
-
725
- gatewayConfig = {
726
- ...gatewayConfig,
727
- _version: 1, // Config schema version — for future migration support
728
- agent: { ...gatewayConfig.agent, ...agent.configDefaults, type: agent.type, cwd: opts.cwd },
729
- chat: {
730
- ...gatewayConfig.chat,
731
- botUsername: botInfo.username,
732
- allowedUsers: mergedUsers,
733
- allowedUserIds: mergedUserIds,
734
- notifyChatIds: mergedNotifyIds,
735
- adapters: gatewayConfig.chat?.adapters ?? { telegram: { mode: "polling" } },
736
- },
737
- ...(opts.voice === false ? { voice: { stt: { enabled: false } } } : {}),
738
- };
739
-
740
- await atomicWriteJson(CONFIG_PATH, gatewayConfig);
741
- ok(`~/.roundhouse/gateway.config.json`);
742
-
743
- // ── Env file ──
744
- // With psst: only non-secret config
745
- // Without psst: include secrets
746
- const envLines: string[] = [];
747
-
748
- if (!opts.psst) {
749
- envLines.push(`TELEGRAM_BOT_TOKEN=${envQuote(opts.botToken)}`);
750
- envLines.push(`BOT_USERNAME=${envQuote(botInfo.username)}`);
751
- envLines.push(`ALLOWED_USERS=${envQuote(opts.users.join(","))}`);
752
- }
753
-
754
- // If psst uses a generated password (headless), include it in env for systemd.
755
- // Threat model tradeoff: the vault key is plaintext in a 0600 file, but this is
756
- // unavoidable on headless servers with no keychain. The benefit is that individual
757
- // secrets are still managed centrally via psst and injected at runtime.
758
- if (opts.psst) {
759
- const pwFile = resolve(ROUNDHOUSE_DIR, ".psst-password");
760
- if (await fileExists(pwFile)) {
761
- const pw = (await readFile(pwFile, "utf8")).trim();
762
- envLines.push(`PSST_PASSWORD=${envQuote(pw)}`);
763
- }
764
- }
765
-
766
- if (opts.provider === "amazon-bedrock") {
767
- // Preserve existing AWS config
768
- let existingEnv = new Map<string, string>();
769
- try {
770
- existingEnv = parseEnvFile(await readFile(ENV_PATH, "utf8"));
771
- } catch {}
772
- const getExisting = (key: string) => existingEnv.get(key);
773
-
774
- if (!envLines.some((l) => l.startsWith("AWS_PROFILE="))) {
775
- envLines.push(`AWS_PROFILE=${getExisting("AWS_PROFILE") ?? '"default"'}`);
776
- }
777
- if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
778
- envLines.push(`AWS_DEFAULT_REGION=${getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
779
- }
780
- // Pi agent requires AWS_REGION (not just AWS_DEFAULT_REGION) to discover Bedrock models
781
- if (!envLines.some((l) => l.startsWith("AWS_REGION="))) {
782
- envLines.push(`AWS_REGION=${getExisting("AWS_REGION") ?? getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
783
- }
784
- }
785
-
786
- await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n");
787
- ok(`~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`);
788
- }
789
-
790
- async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
791
- step("⑦", "Pairing with Telegram...");
792
-
793
- // Skip if chat IDs already known
794
- if (opts.notifyChatIds.length > 0) {
795
- ok(`Using provided notify chat IDs: ${opts.notifyChatIds.join(", ")}`);
796
-
797
- // Send test message
798
- for (const chatId of opts.notifyChatIds) {
799
- try {
800
- await sendMessage(opts.botToken, chatId, "✅ Roundhouse setup complete! Gateway is starting.");
801
- ok(`Sent test message to chat ${chatId}`);
802
- } catch {
803
- warn(`Could not send message to chat ${chatId}`);
804
- }
805
- }
806
- return null;
807
- }
808
-
809
- // Skip if existing config already has notifyChatIds
810
- if (!opts.force) {
811
- try {
812
- const existing = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
813
- const existingIds = existing.chat?.notifyChatIds ?? [];
814
- if (existingIds.length > 0) {
815
- ok(`Already paired (chat IDs: ${existingIds.join(", ")})`);
816
- return null;
817
- }
818
- } catch {}
819
- }
820
-
821
- // Skip if non-interactive
822
- if (opts.nonInteractive) {
823
- warn("Skipping pairing (--non-interactive)");
824
- warn("Startup notifications won't work until paired.");
825
- warn("Run 'roundhouse pair' later to pair.");
826
- return null;
827
- }
828
-
829
- const result = await pairTelegram(opts.botToken, botInfo.username, opts.users, 300_000, log);
830
-
831
- if (result) {
832
- ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
833
- // Add paired username to allowedUsers if not already present
834
- const lcUsername = result.username.toLowerCase();
835
- if (!opts.users.some((u) => u.toLowerCase() === lcUsername)) {
836
- opts.users.push(result.username);
837
- }
838
- return result;
839
- }
840
-
841
- warn("Pairing timed out.");
842
- warn("Run 'roundhouse pair' later to pair.");
843
- return null;
844
- }
845
-
846
- async function stepRegisterCommands(opts: SetupOptions): Promise<void> {
847
- step("⑩", "Registering bot commands...");
848
- await registerBotCommands(opts.botToken);
849
- ok(`${BOT_COMMANDS.length} commands registered with Telegram`);
850
- }
851
-
852
- async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
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
-
869
-
870
- if (!opts.systemd) {
871
- ok("Skipped (--no-systemd)");
872
- log(" Run manually: roundhouse start");
873
- return;
874
- }
875
- if (platform() !== "linux") {
876
- warn(`Service install not supported on ${platform()}`);
877
- log(" Run manually: roundhouse start");
878
- return;
879
- }
880
-
881
- // Check sudo
882
- if (!hasSudoAccess()) {
883
- warn("No passwordless sudo — cannot install systemd service");
884
- log(" Run manually: roundhouse start");
885
- log(" Or install with: roundhouse setup --telegram");
886
- return;
887
- }
888
-
889
- const user = process.env.USER || process.env.LOGNAME;
890
- if (!user) {
891
- warn("Cannot determine current user ($USER not set). Skipping systemd.");
892
- log(" Run manually: roundhouse start");
893
- return;
894
- }
895
-
896
- const psstBin = opts.psst ? whichSync("psst") : null;
897
- const { execStart, nodeBinDir } = resolveExecStart({ psstBin });
898
- const unit = generateUnit({ execStart, nodeBinDir, user });
899
-
900
- try {
901
- await writeServiceUnit(unit);
902
- systemctl("enable");
903
- systemctl("start");
904
- ok("roundhouse.service enabled and started");
905
- } catch (err: any) {
906
- warn(`Systemd install failed: ${err.message}`);
907
- log(" Run manually: roundhouse start");
908
- }
909
- }
910
-
911
- async function stepPostflight(): Promise<void> {
912
- step("⑪", "Postflight checks...");
913
-
914
- if (platform() === "linux") {
915
- if (isServiceActive()) {
916
- const pid = systemctlShow("MainPID");
917
- ok(`Service active (PID ${pid})`);
918
- } else {
919
- warn("Service not active — check: roundhouse logs");
920
- }
921
- }
922
-
923
- if (await fileExists(CONFIG_PATH)) {
924
- ok("Config readable");
925
- } else {
926
- warn(`Config missing: ${CONFIG_PATH}`);
927
- }
928
-
929
- // Optional checks
930
- if (!whichSync("ffmpeg")) {
931
- warn("ffmpeg not found (install for voice support)");
932
- }
933
-
934
- // Whisper STT check (only if voice is enabled)
935
- if (platform() === "linux" || process.env.ROUNDHOUSE_VOICE === "1") {
936
- if (!whichSync("whisper")) {
937
- warn("whisper not found — STT will auto-install on first voice message");
938
- log(" Pre-install: pip3 install openai-whisper");
939
- } else {
940
- ok("whisper available");
941
- }
942
- }
943
-
944
- if (!process.env.TAVILY_API_KEY) {
945
- warn("TAVILY_API_KEY not set — web search extension won't work");
946
- log(" Get a free key at https://tavily.com and add to ~/.roundhouse/.env");
947
- }
948
- }
949
-
950
- // ── BotFather Guide ──────────────────────────────────
951
-
952
- function printBotFatherGuide(): void {
953
- log("");
954
- log(" 🤖 Create a Telegram Bot");
955
- log(" ────────────────────────");
956
- log(" 1. Open https://t.me/BotFather");
957
- log(" 2. Send /newbot");
958
- log(" 3. Choose a display name (e.g. 'My Roundhouse')");
959
- log(" 4. Choose a username ending in 'bot' (e.g. 'my_roundhouse_bot')");
960
- log(" 5. Copy the token BotFather returns");
961
- log("");
962
- }
963
-
964
- // ── Interactive Telegram Setup ───────────────────────
965
-
966
- async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
967
- const agent = resolveAgentForSetup(opts);
968
- log("\n🔧 Roundhouse Telegram Setup");
969
- log("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
970
-
971
- try {
972
- // Step 1: Preflight
973
- await stepPreflight(opts, agent);
974
-
975
- // Detect existing agent installations
976
- const env = detectEnvironment();
977
- if (env.agents.length > 0) {
978
- log("");
979
- log(" 🔍 Agent detection:");
980
- for (const line of formatDetectionResults(env)) {
981
- ok(line);
982
- }
983
- // If the selected agent is already configured, skip package install
984
- if (!opts.force) {
985
- const selected = env.agents.find(a => a.type === opts.agent);
986
- if (selected?.configured) {
987
- opts._skipAgentInstall = true;
988
- }
989
- }
990
- }
991
-
992
- // Step 2: Get bot token (prompt if not provided)
993
- if (!opts.botToken) {
994
- log("");
995
- printBotFatherGuide();
996
- opts.botToken = await promptMasked(" Paste your bot token");
997
- if (!opts.botToken) {
998
- fail("No token provided");
999
- process.exit(2);
1000
- }
1001
- }
1002
- const botInfo = await stepValidateToken(opts);
1003
-
1004
- // Step 3: Get username (prompt if not provided)
1005
- if (opts.users.length === 0) {
1006
- step("③", "Telegram username...");
1007
- const username = await promptText(" Your Telegram username (without @)");
1008
- if (!username) {
1009
- fail("Username required");
1010
- process.exit(2);
1011
- }
1012
- opts.users.push(username.replace(/^@/, ""));
1013
- ok(`Allowed: ${opts.users.map(u => `@${u}`).join(", ")}`);
1014
- }
1015
-
1016
- // Step 4: Stop existing gateway
1017
- await stepStopGateway();
1018
-
1019
- // Step 5: Install packages
1020
- await stepInstallPackages(opts, agent);
1021
-
1022
- // Step 6: Install bundle (skills + CLI tools)
1023
- await stepInstallBundle(opts);
1024
-
1025
- // Step 7: Pair via Telegram
1026
- step("⑦", "Pairing with Telegram...");
1027
- const nonce = createPairingNonce();
1028
- const pairingLink = createPairingLink(botInfo.username, nonce);
1029
- log(`\n Open this link to pair:\n`);
1030
- log(` 🔗 ${pairingLink}\n`);
1031
- printQr(pairingLink, opts.qr);
1032
- log(` Or send /start ${nonce} to @${botInfo.username}`);
1033
- log("");
1034
-
1035
- // Auto-open the pairing link on macOS
1036
- if (process.platform === "darwin") {
1037
- try {
1038
- execFileSync("open", [pairingLink], { stdio: "ignore" });
1039
- log(" (Opened in Telegram — switch to the app to complete pairing)");
1040
- } catch { /* ignore if open fails */ }
1041
- }
1042
-
1043
- log(" Waiting for you to tap the link in Telegram...");
1044
-
1045
- const pairResult = await pairTelegram(
1046
- opts.botToken, botInfo.username, opts.users,
1047
- 300_000, log, { nonce, showLink: false },
1048
- );
1049
- if (!pairResult) {
1050
- warn("Pairing timed out. Run 'roundhouse pair' later.");
1051
- } else {
1052
- ok(`Paired with @${pairResult.username} (chat: ${pairResult.chatId})`);
1053
- if (!opts.notifyChatIds.includes(pairResult.chatId)) {
1054
- opts.notifyChatIds.push(pairResult.chatId);
1055
- }
1056
- }
1057
-
1058
- // Step 8: Store secrets
1059
- await stepStoreSecrets(opts, botInfo);
1060
-
1061
- // Step 9: Write config
1062
- await stepConfigure(opts, botInfo, pairResult, agent);
1063
-
1064
- // Step 10: Register commands + install service
1065
- await stepRegisterCommands(opts);
1066
- await stepInstallSystemd(opts);
1067
-
1068
- // Step 11: Verify
1069
- await stepPostflight();
1070
-
1071
- // Done!
1072
- log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1073
- log("✅ Roundhouse is ready!");
1074
- log(` Bot: @${botInfo.username}`);
1075
- log(` Send /status to @${botInfo.username} on Telegram.\n`);
1076
- } catch (err: any) {
1077
- log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1078
- log(`❌ Setup failed: ${err.message}`);
1079
- log(" Re-run: roundhouse setup --telegram\n");
1080
- process.exit(1);
1081
- }
1082
- }
1083
-
1084
- // ── Headless Telegram Setup ─────────────────────────
1085
-
1086
- async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
1087
- const agent = resolveAgentForSetup(opts);
1088
- const logger = createJsonLogger();
1089
-
1090
- // Override module-level log helpers to emit JSON instead of text
1091
- const savedLog = log, savedStep = step, savedOk = ok, savedWarn = warn, savedFail = fail;
1092
- log = (msg) => logger.info("log", msg);
1093
- step = (_n, label) => logger.info("step", label);
1094
- ok = (msg) => logger.ok(msg);
1095
- warn = (msg) => logger.warn("warn", msg);
1096
- fail = (msg) => logger.fail(msg);
1097
-
1098
- try {
1099
- // Validate required inputs
1100
- if (!opts.botToken) {
1101
- logger.error("validation.failed", "TELEGRAM_BOT_TOKEN env var required for --headless");
1102
- process.exit(2);
1103
- }
1104
- if (opts.users.length === 0) {
1105
- logger.error("validation.failed", "--user is required for --headless");
1106
- process.exit(2);
1107
- }
1108
-
1109
- // Step 1: Preflight
1110
- logger.step(1, 9, "preflight.start", "Running preflight checks");
1111
- await stepPreflight(opts, agent);
1112
- logger.ok("Preflight passed");
1113
-
1114
- // Step 2: Validate token
1115
- logger.step(2, 9, "telegram.validate", "Validating Telegram bot token");
1116
- const botInfo = await stepValidateToken(opts);
1117
- logger.ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
1118
-
1119
- // Step 3: Stop existing gateway
1120
- logger.step(3, 9, "gateway.stop", "Checking for running gateway");
1121
- await stepStopGateway();
1122
-
1123
- // Step 4: Install packages
1124
- logger.step(4, 9, "packages.install", "Installing packages");
1125
- await stepInstallPackages(opts, agent);
1126
- logger.ok("Packages installed");
1127
-
1128
- // Step 4b: Install bundle
1129
- await stepInstallBundle(opts);
1130
-
1131
- // Step 5: Create pending pairing
1132
- logger.step(5, 9, "pairing.pending", "Creating pending pairing");
1133
- let nonce: string;
1134
- const existing = await readPendingPairing();
1135
- if (existing?.status === "pending" && !opts.force) {
1136
- nonce = existing.nonce;
1137
- logger.info("pairing.reuse", `Reusing existing nonce: ${nonce}`);
1138
- } else {
1139
- nonce = createPairingNonce();
1140
- }
1141
- const pairingLink = createPairingLink(botInfo.username, nonce);
1142
- const pendingPairing: PendingPairing = {
1143
- version: 1,
1144
- nonce,
1145
- botUsername: botInfo.username,
1146
- allowedUsers: opts.users,
1147
- createdAt: new Date().toISOString(),
1148
- status: "pending",
1149
- };
1150
- await writePendingPairing(pendingPairing);
1151
- logger.info("pairing.link", `Pairing link: ${pairingLink}`, { pairingLink, nonce });
1152
-
1153
- // Step 6: Store secrets
1154
- logger.step(6, 9, "secrets.store", "Storing secrets");
1155
- await stepStoreSecrets(opts, botInfo);
1156
-
1157
- // Step 7: Write config (no pair result yet — gateway will complete pairing)
1158
- logger.step(7, 9, "config.write", "Writing configuration");
1159
- await stepConfigure(opts, botInfo, null, agent);
1160
- logger.ok("Config written");
1161
-
1162
- // Step 8: Register commands
1163
- logger.step(8, 9, "commands.register", "Registering bot commands");
1164
- await stepRegisterCommands(opts);
1165
- logger.ok("Bot commands registered");
1166
-
1167
- let serviceInstalled = false;
1168
- // Step 9: Install and start service
1169
- logger.step(9, 9, "service.install", "Installing and starting service");
1170
- if (!opts.systemd && platform() !== "darwin") {
1171
- logger.warn("service.skip", "--no-systemd: service not installed. Start manually: roundhouse start");
1172
- } else {
1173
- await stepInstallSystemd(opts);
1174
-
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");
1200
- }
1201
- }
1202
- }
1203
-
1204
- // Success
1205
- logger.info("setup.complete", "Headless setup complete", {
1206
- botUsername: botInfo.username,
1207
- pairingLink,
1208
- pairingStatus: "pending",
1209
- serviceInstalled,
1210
- });
1211
- log("");
1212
- log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1213
- log(`✅ Roundhouse installed and running!`);
1214
- log(``);
1215
- log(` Bot: @${botInfo.username}`);
1216
- log(` Pairing: Open ${pairingLink} to complete setup`);
1217
- log(` Gateway is running and will accept pairing automatically.`);
1218
- log(``);
1219
- } catch (err: any) {
1220
- const diag: SetupDiagnostics = {
1221
- node: process.version,
1222
- platform: platform(),
1223
- arch: process.arch,
1224
- cwd: process.cwd(),
1225
- roundhouseDir: ROUNDHOUSE_DIR,
1226
- configExists: await fileExists(CONFIG_PATH).catch(() => false),
1227
- envExists: await fileExists(ENV_PATH).catch(() => false),
1228
- pairingStatus: (await readPendingPairing())?.status ?? "not found",
1229
- serviceState: "unknown",
1230
- error: { name: err.name, message: err.message, stack: err.stack },
1231
- };
1232
- try {
1233
- const { execFileSync } = await import("node:child_process");
1234
- diag.serviceState = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
1235
- } catch {}
1236
- printDiagnosticError(diag, true);
1237
- process.exit(1);
1238
- } finally {
1239
- // Restore module-level log helpers
1240
- log = savedLog; step = savedStep; ok = savedOk; warn = savedWarn; fail = savedFail;
1241
- }
1242
- }
32
+ stepPreflight,
33
+ stepValidateToken,
34
+ stepStopGateway,
35
+ stepInstallPackages,
36
+ stepStoreSecrets,
37
+ stepInstallBundle,
38
+ stepConfigure,
39
+ stepPair,
40
+ stepRegisterCommands,
41
+ stepInstallSystemd,
42
+ stepPostflight,
43
+ } from "./setup/steps";
44
+ import { resolveAgentForSetup, textLog, textStepLog } from "./setup/runtime";
45
+ import { runInteractiveTelegramSetup, runHeadlessTelegramSetup } from "./setup/flows";
1243
46
 
1244
47
  // ── Orchestrator ─────────────────────────────────────
1245
48
 
@@ -1269,58 +72,59 @@ export async function cmdSetup(argv: string[]): Promise<void> {
1269
72
  }
1270
73
 
1271
74
  // Legacy flow (no --telegram flag)
1272
- const agent = resolveAgentForSetup(opts);
1273
- log("\n🔧 Roundhouse Setup");
1274
- log("━━━━━━━━━━━━━━━━━━━");
75
+ const logger = textStepLog;
76
+ const agent = resolveAgentForSetup(opts, logger);
77
+ textLog("\n🔧 Roundhouse Setup");
78
+ textLog("━━━━━━━━━━━━━━━━━━━");
1275
79
 
1276
80
  try {
1277
81
  // Phase 1: Validate (no mutations)
1278
- await stepPreflight(opts, agent);
1279
- const botInfo = await stepValidateToken(opts);
1280
- await stepStopGateway();
82
+ await stepPreflight(logger, opts, agent);
83
+ const botInfo = await stepValidateToken(logger, opts);
84
+ await stepStopGateway(logger);
1281
85
 
1282
86
  // Phase 2: Install packages
1283
- await stepInstallPackages(opts, agent);
87
+ await stepInstallPackages(logger, opts, agent);
1284
88
 
1285
89
  // Phase 2b: Install bundle (skills + CLI tools)
1286
- await stepInstallBundle(opts);
90
+ await stepInstallBundle(logger, opts);
1287
91
 
1288
92
  // Phase 3: Pair (before secrets/config, so paired username is included)
1289
- const pairResult = await stepPair(opts, botInfo);
93
+ const pairResult = await stepPair(logger, opts, botInfo);
1290
94
 
1291
95
  // Phase 4: Store secrets (after pairing, so ALLOWED_USERS includes paired user)
1292
- await stepStoreSecrets(opts, botInfo);
96
+ await stepStoreSecrets(logger, opts, botInfo);
1293
97
 
1294
98
  // Phase 5: Write config (includes pair data)
1295
- await stepConfigure(opts, botInfo, pairResult, agent);
99
+ await stepConfigure(logger, opts, botInfo, pairResult, agent);
1296
100
 
1297
101
  // Phase 6: Remote setup
1298
- await stepRegisterCommands(opts);
102
+ await stepRegisterCommands(logger, opts);
1299
103
 
1300
104
  // Phase 7: Service
1301
- await stepInstallSystemd(opts);
105
+ await stepInstallSystemd(logger, opts);
1302
106
 
1303
107
  // Phase 8: Verify
1304
- await stepPostflight();
108
+ await stepPostflight(logger);
1305
109
 
1306
110
  // Final message
1307
111
  const warnings = !opts.notifyChatIds.length && !pairResult;
1308
- log("\n━━━━━━━━━━━━━━━━━━━");
112
+ textLog("\n━━━━━━━━━━━━━━━━━━━");
1309
113
  if (warnings) {
1310
- log("⚠️ Installed, action required:");
1311
- log(` • Not paired — run: roundhouse pair`);
114
+ textLog("⚠️ Installed, action required:");
115
+ textLog(` • Not paired — run: roundhouse pair`);
1312
116
  } else {
1313
- log("✅ Roundhouse is running!");
117
+ textLog("✅ Roundhouse is running!");
1314
118
  }
1315
- log(` Bot: @${botInfo.username}`);
1316
- log(` Memory: ${opts.extensions.some((e) => e.includes("pi-memory")) ? "agent-managed" : "roundhouse-managed"}`);
1317
- log(` Secrets: ${opts.psst ? "psst vault (encrypted)" : "~/.roundhouse/.env (plaintext)"}`);
1318
- log(` Send /status to @${botInfo.username} on Telegram.\n`);
119
+ textLog(` Bot: @${botInfo.username}`);
120
+ textLog(` Memory: ${opts.extensions.some((e) => e.includes("pi-memory")) ? "agent-managed" : "roundhouse-managed"}`);
121
+ textLog(` Secrets: ${opts.psst ? "psst vault (encrypted)" : "~/.roundhouse/.env (plaintext)"}`);
122
+ textLog(` Send /status to @${botInfo.username} on Telegram.\n`);
1319
123
  } catch (err: any) {
1320
- log("\n━━━━━━━━━━━━━━━━━━━");
1321
- log(`❌ Setup failed: ${err.message}`);
1322
- log(" Partial changes may have been applied.");
1323
- log(" Re-run setup to complete, or run: roundhouse doctor\n");
124
+ textLog("\n━━━━━━━━━━━━━━━━━━━");
125
+ textLog(`❌ Setup failed: ${err.message}`);
126
+ textLog(" Partial changes may have been applied.");
127
+ textLog(" Re-run setup to complete, or run: roundhouse doctor\n");
1324
128
  process.exit(1);
1325
129
  }
1326
130
  }
@@ -1372,19 +176,19 @@ export async function cmdPair(argv: string[]): Promise<void> {
1372
176
  process.exit(1);
1373
177
  }
1374
178
 
1375
- log("\n🔗 Roundhouse Pairing\n");
179
+ textLog("\n🔗 Roundhouse Pairing\n");
1376
180
 
1377
181
  const botInfo = await validateBotToken(token);
1378
- ok(`Bot: @${botInfo.username}`);
182
+ textStepLog.ok(`Bot: @${botInfo.username}`);
1379
183
 
1380
- const result = await pairTelegram(token, botInfo.username, users, 300_000, log);
184
+ const result = await pairTelegram(token, botInfo.username, users, 300_000, textLog);
1381
185
 
1382
186
  if (!result) {
1383
- log("\n⚠ Pairing timed out. Try again: roundhouse pair\n");
187
+ textLog("\n⚠ Pairing timed out. Try again: roundhouse pair\n");
1384
188
  process.exit(1);
1385
189
  }
1386
190
 
1387
- ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
191
+ textStepLog.ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
1388
192
 
1389
193
  // Update config
1390
194
  try {
@@ -1400,52 +204,52 @@ export async function cmdPair(argv: string[]): Promise<void> {
1400
204
  config.chat.notifyChatIds = existingNotifyIds;
1401
205
 
1402
206
  await atomicWriteJson(CONFIG_PATH, config);
1403
- ok("Config updated with chat ID");
207
+ textStepLog.ok("Config updated with chat ID");
1404
208
  } catch {
1405
- warn("Could not update config — add notifyChatIds manually");
209
+ textStepLog.warn("Could not update config — add notifyChatIds manually");
1406
210
  }
1407
211
 
1408
- log("\n✅ Paired! Restart gateway to apply: roundhouse restart\n");
212
+ textLog("\n✅ Paired! Restart gateway to apply: roundhouse restart\n");
1409
213
  }
1410
214
 
1411
215
  // ── Dry run ──────────────────────────────────────────
1412
216
 
1413
217
  function printDryRun(opts: SetupOptions): void {
1414
218
  const agent = getAgentDefinition(opts.agent);
1415
- log("\n🔧 Roundhouse Setup (DRY RUN)");
1416
- log("━━━━━━━━━━━━━━━━━━━\n");
1417
- log(`Agent: ${agent.name} (${agent.type})`);
1418
- log("Would validate Telegram token");
1419
- log("Would stop existing gateway (if running)");
1420
- log(`Would install: npm install -g @inceptionstack/roundhouse`);
219
+ textLog("\n🔧 Roundhouse Setup (DRY RUN)");
220
+ textLog("━━━━━━━━━━━━━━━━━━━\n");
221
+ textLog(`Agent: ${agent.name} (${agent.type})`);
222
+ textLog("Would validate Telegram token");
223
+ textLog("Would stop existing gateway (if running)");
224
+ textLog(`Would install: npm install -g @inceptionstack/roundhouse`);
1421
225
  for (const pkg of agent.packages) {
1422
226
  const scope = pkg.install === "global" ? "-g " : "";
1423
- log(`Would install: npm install ${scope}${pkg.packageName}`);
227
+ textLog(`Would install: npm install ${scope}${pkg.packageName}`);
1424
228
  }
1425
229
  if (opts.psst) {
1426
- log(`Would install: bun runtime (if not present)`);
1427
- log(`Would install: npm install -g psst-cli`);
1428
- log(`Would initialize psst vault`);
1429
- log(`Would install: pi-psst extension`);
230
+ textLog(`Would install: bun runtime (if not present)`);
231
+ textLog(`Would install: npm install -g psst-cli`);
232
+ textLog(`Would initialize psst vault`);
233
+ textLog(`Would install: pi-psst extension`);
1430
234
  }
1431
- for (const ext of opts.extensions) log(`Would install extension: ${ext}`);
235
+ for (const ext of opts.extensions) textLog(`Would install extension: ${ext}`);
1432
236
  if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
1433
- log(`Would pair via Telegram (interactive)`);
237
+ textLog(`Would pair via Telegram (interactive)`);
1434
238
  }
1435
239
  if (opts.psst) {
1436
- log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
240
+ textLog(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
1437
241
  }
1438
242
  if (agent.configDirs?.length) {
1439
- log(`Would configure: agent-specific settings`);
1440
- log(` Agent: ${agent.name}`);
1441
- }
1442
- log(` Set defaultProvider: ${opts.provider}`);
1443
- log(` Set defaultModel: ${opts.model}`);
1444
- log(`Would write: ~/.roundhouse/gateway.config.json`);
1445
- log(`Would write: ~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`);
1446
- log(`Would register ${BOT_COMMANDS.length} bot commands`);
1447
- if (opts.systemd) log(`Would install systemd service`);
1448
- log("\nNo changes made.\n");
243
+ textLog(`Would configure: agent-specific settings`);
244
+ textLog(` Agent: ${agent.name}`);
245
+ }
246
+ textLog(` Set defaultProvider: ${opts.provider}`);
247
+ textLog(` Set defaultModel: ${opts.model}`);
248
+ textLog(`Would write: ~/.roundhouse/gateway.config.json`);
249
+ textLog(`Would write: ~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`);
250
+ textLog(`Would register ${BOT_COMMANDS.length} bot commands`);
251
+ if (opts.systemd) textLog(`Would install systemd service`);
252
+ textLog("\nNo changes made.\n");
1449
253
  }
1450
254
 
1451
255
  // ── Help ─────────────────────────────────────────────