@inceptionstack/roundhouse 0.5.1 → 0.5.3

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,1193 +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() !== "linux") {
452
- ok("Not Linux — skipping service check");
453
- return;
454
- }
455
-
456
- if (isServiceActive()) {
457
- log(" Stopping existing gateway...");
458
- try {
459
- systemctl("stop");
460
- ok("Service stopped");
461
- } catch {
462
- warn("Could not stop service (may need sudo). Continuing anyway.");
463
- }
464
- } else {
465
- ok("No running gateway");
466
- }
467
- }
468
-
469
- async function stepInstallPackages(opts: SetupOptions, agent: AgentDefinition): Promise<void> {
470
- step("⑤", "Installing packages...");
471
-
472
- // Roundhouse
473
- const rhInstalled = whichSync("roundhouse");
474
- if (rhInstalled && !opts.force) {
475
- ok(`@inceptionstack/roundhouse (already installed)`);
476
- } else {
477
- log(" Installing @inceptionstack/roundhouse...");
478
- execOrFail("npm", ["install", "-g", "@inceptionstack/roundhouse"], "roundhouse install");
479
- ok("@inceptionstack/roundhouse");
480
- }
481
-
482
- // Agent packages (driven by agent definition)
483
- if (opts._skipAgentInstall) {
484
- ok("Agent already configured — skipping package install");
485
- } else {
486
- for (const pkg of agent.packages) {
487
- const label = pkg.name ?? pkg.packageName;
488
- const installed = pkg.binary ? whichSync(pkg.binary) : false;
489
- if (installed && !opts.force) {
490
- ok(`${label} (already installed)`);
491
- } else {
492
- log(` Installing ${label}...`);
493
- const args = pkg.install === "global"
494
- ? ["install", "-g", pkg.packageName]
495
- : ["install", pkg.packageName];
496
- execOrFail("npm", args, `${label} install`);
497
- ok(label);
498
- }
499
- }
500
- }
501
-
502
- // psst-cli (requires bun runtime)
503
- if (opts.psst) {
504
- // Install bun if not present (psst-cli shebang is #!/usr/bin/env bun)
505
- if (!whichSync("bun")) {
506
- log(" Installing bun runtime (required by psst)...");
507
- try {
508
- execFileSync("bash", ["-c", "curl -fsSL https://bun.sh/install | bash"], {
509
- encoding: "utf8", stdio: "pipe", timeout: 120_000,
510
- env: { ...process.env, HOME: homedir() },
511
- });
512
- // bun installs to ~/.bun/bin/bun
513
- const bunPath = resolve(homedir(), ".bun", "bin");
514
- process.env.PATH = `${bunPath}:${process.env.PATH}`;
515
- ok("bun runtime");
516
- } catch (err: any) {
517
- warn(`bun install failed: ${err.message}`);
518
- warn("psst requires bun — install manually: curl -fsSL https://bun.sh/install | bash");
519
- opts.psst = false;
520
- }
521
- } else {
522
- ok("bun runtime (already installed)");
523
- }
524
- }
525
-
526
- // psst-cli
527
- if (opts.psst) {
528
- const psstInstalled = whichSync("psst");
529
- if (psstInstalled && !opts.force) {
530
- ok(`psst-cli (already installed)`);
531
- } else {
532
- log(" Installing psst-cli...");
533
- try {
534
- execFileSync("npm", ["install", "-g", "psst-cli"], {
535
- encoding: "utf8", stdio: "pipe", timeout: 120_000,
536
- });
537
- } catch {
538
- // npm may exit non-zero due to postinstall warnings — check if binary exists
539
- }
540
- if (whichSync("psst")) {
541
- ok("psst-cli");
542
- } else {
543
- warn("psst-cli install failed");
544
- opts.psst = false;
545
- }
546
- }
547
-
548
- // Initialize psst vault
549
- const vaultExists = await fileExists(resolve(homedir(), ".psst", "envs"));
550
- if (vaultExists) {
551
- ok("psst vault exists");
552
- } else {
553
- log(" Initializing psst vault...");
554
- // On headless servers, no keychain is available — use PSST_PASSWORD
555
- const psstEnv = { ...process.env };
556
- if (!psstEnv.PSST_PASSWORD) {
557
- // Generate a random password and store it for future use
558
- const psstPw = randomBytes(32).toString("base64");
559
- const pwFile = resolve(ROUNDHOUSE_DIR, ".psst-password");
560
- await atomicWriteText(pwFile, psstPw + "\n", 0o600);
561
- psstEnv.PSST_PASSWORD = psstPw;
562
- // Also set for subsequent psst calls in this process
563
- process.env.PSST_PASSWORD = psstPw;
564
- }
565
- try {
566
- execFileSync("psst", ["init"], {
567
- encoding: "utf8", stdio: "pipe", timeout: 30_000,
568
- env: psstEnv,
569
- });
570
- ok("psst vault initialized");
571
- } catch (err: any) {
572
- warn(`psst vault init failed: ${err.stderr?.trim() || err.message}`);
573
- // Clean up orphan password file
574
- try { await unlink(resolve(ROUNDHOUSE_DIR, ".psst-password")); } catch {}
575
- delete process.env.PSST_PASSWORD;
576
- opts.psst = false;
577
- }
578
- }
579
-
580
- // Install agent-specific psst extension (Pi: pi-psst)
581
- if (agent.installExtension) {
582
- log(" Installing agent psst extension...");
583
- try {
584
- await agent.installExtension("@miclivs/pi-psst");
585
- ok("@miclivs/pi-psst extension");
586
- } catch {
587
- ok("@miclivs/pi-psst extension (already installed)");
588
- }
589
- }
590
- }
591
-
592
- // User extensions
593
- for (const ext of opts.extensions) {
594
- if (!agent.installExtension) {
595
- fail(`--extension is not supported for agent "${agent.type}"`);
596
- throw new Error(`Agent "${agent.type}" does not support extensions`);
597
- }
598
- log(` Installing extension: ${ext}...`);
599
- await agent.installExtension(ext);
600
- ok(ext);
601
- }
602
- }
603
-
604
- async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
605
- if (!opts.psst) {
606
- step("⑧", "Storing secrets...");
607
- ok("Skipped (default — use --with-psst to enable)");
608
- return;
609
- }
610
-
611
- step("⑧", "Storing secrets in psst...");
612
-
613
- const secrets: [string, string][] = [
614
- ["TELEGRAM_BOT_TOKEN", opts.botToken],
615
- ["BOT_USERNAME", botInfo.username],
616
- ["ALLOWED_USERS", opts.users.join(",")],
617
- ];
618
-
619
- for (const [name, value] of secrets) {
620
- try {
621
- execFileSync("psst", ["set", name, "--stdin"], {
622
- input: value,
623
- encoding: "utf8",
624
- stdio: ["pipe", "pipe", "pipe"],
625
- timeout: 10_000,
626
- });
627
- ok(`${name} → psst vault`);
628
- } catch {
629
- // May already exist with same value
630
- // Try overwrite
631
- try {
632
- execFileSync("psst", ["set", name, "--stdin"], {
633
- input: value,
634
- encoding: "utf8",
635
- stdio: ["pipe", "pipe", "pipe"],
636
- timeout: 10_000,
637
- env: { ...process.env, PSST_FORCE: "1" },
638
- });
639
- ok(`${name} → psst vault (updated)`);
640
- } catch (err: any) {
641
- warn(`Failed to store ${name} in psst: ${err.message}`);
642
- }
643
- }
644
- }
645
- }
646
-
647
- // ── Bundle install ──────────────────────────────────────────────────
648
-
649
- async function stepInstallBundle(opts: SetupOptions): Promise<void> {
650
- step("⑥", "Installing bundle (skills + CLI tools)...");
651
-
652
- const bundleLog: ProvisionLog = {
653
- info: (msg) => log(` ${msg}`),
654
- warn: (msg) => warn(msg),
655
- ok: (msg) => ok(msg),
656
- };
657
-
658
- provisionBundle({ force: opts.force, log: bundleLog });
659
- }
660
-
661
- async function stepConfigure(
662
- opts: SetupOptions,
663
- botInfo: BotInfo,
664
- pairResult: PairResult | null,
665
- agent: AgentDefinition,
666
- ): Promise<void> {
667
- step("⑨", "Configuring...");
668
-
669
- await mkdir(ROUNDHOUSE_DIR, { recursive: true });
670
-
671
- // Agent-specific config (Pi: ~/.pi/agent/settings.json)
672
- if (agent.configure) {
673
- await agent.configure({
674
- provider: opts.provider,
675
- model: opts.model,
676
- cwd: opts.cwd,
677
- force: opts.force,
678
- psst: opts.psst,
679
- extensions: opts.extensions,
680
- });
681
- }
682
-
683
- // ── Gateway config ──
684
- let gatewayConfig: Record<string, any> = {};
685
- if (!opts.force) {
686
- try {
687
- gatewayConfig = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
688
- } catch { /* new install */ }
689
- }
690
-
691
- // Merge users
692
- const existingUsers: string[] = gatewayConfig.chat?.allowedUsers ?? [];
693
- const existingUserIds: number[] = gatewayConfig.chat?.allowedUserIds ?? [];
694
- const existingNotifyIds: number[] = (gatewayConfig.chat?.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n));
695
-
696
- const mergedUsers = [...new Set([...existingUsers, ...opts.users])];
697
- const mergedUserIds = [...existingUserIds];
698
- const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds])];
699
-
700
- // Add paired user data
701
- if (pairResult) {
702
- if (!mergedUserIds.includes(pairResult.userId)) {
703
- mergedUserIds.push(pairResult.userId);
704
- }
705
- if (!mergedNotifyIds.includes(pairResult.chatId)) {
706
- mergedNotifyIds.push(pairResult.chatId);
707
- }
708
- }
709
-
710
- gatewayConfig = {
711
- ...gatewayConfig,
712
- _version: 1, // Config schema version — for future migration support
713
- agent: { ...gatewayConfig.agent, ...agent.configDefaults, type: agent.type, cwd: opts.cwd },
714
- chat: {
715
- ...gatewayConfig.chat,
716
- botUsername: botInfo.username,
717
- allowedUsers: mergedUsers,
718
- allowedUserIds: mergedUserIds,
719
- notifyChatIds: mergedNotifyIds,
720
- adapters: gatewayConfig.chat?.adapters ?? { telegram: { mode: "polling" } },
721
- },
722
- ...(opts.voice === false ? { voice: { stt: { enabled: false } } } : {}),
723
- };
724
-
725
- await atomicWriteJson(CONFIG_PATH, gatewayConfig);
726
- ok(`~/.roundhouse/gateway.config.json`);
727
-
728
- // ── Env file ──
729
- // With psst: only non-secret config
730
- // Without psst: include secrets
731
- const envLines: string[] = [];
732
-
733
- if (!opts.psst) {
734
- envLines.push(`TELEGRAM_BOT_TOKEN=${envQuote(opts.botToken)}`);
735
- envLines.push(`BOT_USERNAME=${envQuote(botInfo.username)}`);
736
- envLines.push(`ALLOWED_USERS=${envQuote(opts.users.join(","))}`);
737
- }
738
-
739
- // If psst uses a generated password (headless), include it in env for systemd.
740
- // Threat model tradeoff: the vault key is plaintext in a 0600 file, but this is
741
- // unavoidable on headless servers with no keychain. The benefit is that individual
742
- // secrets are still managed centrally via psst and injected at runtime.
743
- if (opts.psst) {
744
- const pwFile = resolve(ROUNDHOUSE_DIR, ".psst-password");
745
- if (await fileExists(pwFile)) {
746
- const pw = (await readFile(pwFile, "utf8")).trim();
747
- envLines.push(`PSST_PASSWORD=${envQuote(pw)}`);
748
- }
749
- }
750
-
751
- if (opts.provider === "amazon-bedrock") {
752
- // Preserve existing AWS config
753
- let existingEnv = new Map<string, string>();
754
- try {
755
- existingEnv = parseEnvFile(await readFile(ENV_PATH, "utf8"));
756
- } catch {}
757
- const getExisting = (key: string) => existingEnv.get(key);
758
-
759
- if (!envLines.some((l) => l.startsWith("AWS_PROFILE="))) {
760
- envLines.push(`AWS_PROFILE=${getExisting("AWS_PROFILE") ?? '"default"'}`);
761
- }
762
- if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
763
- envLines.push(`AWS_DEFAULT_REGION=${getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
764
- }
765
- // Pi agent requires AWS_REGION (not just AWS_DEFAULT_REGION) to discover Bedrock models
766
- if (!envLines.some((l) => l.startsWith("AWS_REGION="))) {
767
- envLines.push(`AWS_REGION=${getExisting("AWS_REGION") ?? getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
768
- }
769
- }
770
-
771
- await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n");
772
- ok(`~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`);
773
- }
774
-
775
- async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
776
- step("⑦", "Pairing with Telegram...");
777
-
778
- // Skip if chat IDs already known
779
- if (opts.notifyChatIds.length > 0) {
780
- ok(`Using provided notify chat IDs: ${opts.notifyChatIds.join(", ")}`);
781
-
782
- // Send test message
783
- for (const chatId of opts.notifyChatIds) {
784
- try {
785
- await sendMessage(opts.botToken, chatId, "✅ Roundhouse setup complete! Gateway is starting.");
786
- ok(`Sent test message to chat ${chatId}`);
787
- } catch {
788
- warn(`Could not send message to chat ${chatId}`);
789
- }
790
- }
791
- return null;
792
- }
793
-
794
- // Skip if existing config already has notifyChatIds
795
- if (!opts.force) {
796
- try {
797
- const existing = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
798
- const existingIds = existing.chat?.notifyChatIds ?? [];
799
- if (existingIds.length > 0) {
800
- ok(`Already paired (chat IDs: ${existingIds.join(", ")})`);
801
- return null;
802
- }
803
- } catch {}
804
- }
805
-
806
- // Skip if non-interactive
807
- if (opts.nonInteractive) {
808
- warn("Skipping pairing (--non-interactive)");
809
- warn("Startup notifications won't work until paired.");
810
- warn("Run 'roundhouse pair' later to pair.");
811
- return null;
812
- }
813
-
814
- const result = await pairTelegram(opts.botToken, botInfo.username, opts.users, 300_000, log);
815
-
816
- if (result) {
817
- ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
818
- // Add paired username to allowedUsers if not already present
819
- const lcUsername = result.username.toLowerCase();
820
- if (!opts.users.some((u) => u.toLowerCase() === lcUsername)) {
821
- opts.users.push(result.username);
822
- }
823
- return result;
824
- }
825
-
826
- warn("Pairing timed out.");
827
- warn("Run 'roundhouse pair' later to pair.");
828
- return null;
829
- }
830
-
831
- async function stepRegisterCommands(opts: SetupOptions): Promise<void> {
832
- step("⑩", "Registering bot commands...");
833
- await registerBotCommands(opts.botToken);
834
- ok(`${BOT_COMMANDS.length} commands registered with Telegram`);
835
- }
836
-
837
- async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
838
- step("⑩b", "Installing systemd service...");
839
-
840
- if (!opts.systemd) {
841
- ok("Skipped (--no-systemd)");
842
- log(" Run manually: roundhouse start");
843
- return;
844
- }
845
-
846
- if (platform() !== "linux") {
847
- warn(`Systemd not available (${platform()})`);
848
- log(" Run manually: roundhouse start");
849
- return;
850
- }
851
-
852
- // Check sudo
853
- if (!hasSudoAccess()) {
854
- warn("No passwordless sudo — cannot install systemd service");
855
- log(" Run manually: roundhouse start");
856
- log(" Or install with: roundhouse setup --telegram");
857
- return;
858
- }
859
-
860
- const user = process.env.USER || process.env.LOGNAME;
861
- if (!user) {
862
- warn("Cannot determine current user ($USER not set). Skipping systemd.");
863
- log(" Run manually: roundhouse start");
864
- return;
865
- }
866
-
867
- const psstBin = opts.psst ? whichSync("psst") : null;
868
- const { execStart, nodeBinDir } = resolveExecStart({ psstBin });
869
- const unit = generateUnit({ execStart, nodeBinDir, user });
870
-
871
- try {
872
- await writeServiceUnit(unit);
873
- systemctl("enable");
874
- systemctl("start");
875
- ok("roundhouse.service enabled and started");
876
- } catch (err: any) {
877
- warn(`Systemd install failed: ${err.message}`);
878
- log(" Run manually: roundhouse start");
879
- }
880
- }
881
-
882
- async function stepPostflight(): Promise<void> {
883
- step("⑪", "Postflight checks...");
884
-
885
- if (platform() === "linux") {
886
- if (isServiceActive()) {
887
- const pid = systemctlShow("MainPID");
888
- ok(`Service active (PID ${pid})`);
889
- } else {
890
- warn("Service not active — check: roundhouse logs");
891
- }
892
- }
893
-
894
- if (await fileExists(CONFIG_PATH)) {
895
- ok("Config readable");
896
- } else {
897
- warn(`Config missing: ${CONFIG_PATH}`);
898
- }
899
-
900
- // Optional checks
901
- if (!whichSync("ffmpeg")) {
902
- warn("ffmpeg not found (install for voice support)");
903
- }
904
-
905
- // Whisper STT check (only if voice is enabled)
906
- if (platform() === "linux" || process.env.ROUNDHOUSE_VOICE === "1") {
907
- if (!whichSync("whisper")) {
908
- warn("whisper not found — STT will auto-install on first voice message");
909
- log(" Pre-install: pip3 install openai-whisper");
910
- } else {
911
- ok("whisper available");
912
- }
913
- }
914
-
915
- if (!process.env.TAVILY_API_KEY) {
916
- warn("TAVILY_API_KEY not set — web search extension won't work");
917
- log(" Get a free key at https://tavily.com and add to ~/.roundhouse/.env");
918
- }
919
- }
920
-
921
- // ── BotFather Guide ──────────────────────────────────
922
-
923
- function printBotFatherGuide(): void {
924
- log("");
925
- log(" 🤖 Create a Telegram Bot");
926
- log(" ────────────────────────");
927
- log(" 1. Open https://t.me/BotFather");
928
- log(" 2. Send /newbot");
929
- log(" 3. Choose a display name (e.g. 'My Roundhouse')");
930
- log(" 4. Choose a username ending in 'bot' (e.g. 'my_roundhouse_bot')");
931
- log(" 5. Copy the token BotFather returns");
932
- log("");
933
- }
934
-
935
- // ── Interactive Telegram Setup ───────────────────────
936
-
937
- async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
938
- const agent = resolveAgentForSetup(opts);
939
- log("\n🔧 Roundhouse Telegram Setup");
940
- log("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
941
-
942
- try {
943
- // Step 1: Preflight
944
- await stepPreflight(opts, agent);
945
-
946
- // Detect existing agent installations
947
- const env = detectEnvironment();
948
- if (env.agents.length > 0) {
949
- log("");
950
- log(" 🔍 Agent detection:");
951
- for (const line of formatDetectionResults(env)) {
952
- ok(line);
953
- }
954
- // If the selected agent is already configured, skip package install
955
- if (!opts.force) {
956
- const selected = env.agents.find(a => a.type === opts.agent);
957
- if (selected?.configured) {
958
- opts._skipAgentInstall = true;
959
- }
960
- }
961
- }
962
-
963
- // Step 2: Get bot token (prompt if not provided)
964
- if (!opts.botToken) {
965
- log("");
966
- printBotFatherGuide();
967
- opts.botToken = await promptMasked(" Paste your bot token");
968
- if (!opts.botToken) {
969
- fail("No token provided");
970
- process.exit(2);
971
- }
972
- }
973
- const botInfo = await stepValidateToken(opts);
974
-
975
- // Step 3: Get username (prompt if not provided)
976
- if (opts.users.length === 0) {
977
- step("③", "Telegram username...");
978
- const username = await promptText(" Your Telegram username (without @)");
979
- if (!username) {
980
- fail("Username required");
981
- process.exit(2);
982
- }
983
- opts.users.push(username.replace(/^@/, ""));
984
- ok(`Allowed: ${opts.users.map(u => `@${u}`).join(", ")}`);
985
- }
986
-
987
- // Step 4: Stop existing gateway
988
- await stepStopGateway();
989
-
990
- // Step 5: Install packages
991
- await stepInstallPackages(opts, agent);
992
-
993
- // Step 6: Install bundle (skills + CLI tools)
994
- await stepInstallBundle(opts);
995
-
996
- // Step 7: Pair via Telegram
997
- step("⑦", "Pairing with Telegram...");
998
- const nonce = createPairingNonce();
999
- const pairingLink = createPairingLink(botInfo.username, nonce);
1000
- log(`\n Open this link to pair:\n`);
1001
- log(` 🔗 ${pairingLink}\n`);
1002
- printQr(pairingLink, opts.qr);
1003
- log(` Or send /start ${nonce} to @${botInfo.username}`);
1004
- log("");
1005
-
1006
- // Auto-open the pairing link on macOS
1007
- if (process.platform === "darwin") {
1008
- try {
1009
- execFileSync("open", [pairingLink], { stdio: "ignore" });
1010
- log(" (Opened in Telegram — switch to the app to complete pairing)");
1011
- } catch { /* ignore if open fails */ }
1012
- }
1013
-
1014
- log(" Waiting for you to tap the link in Telegram...");
1015
-
1016
- const pairResult = await pairTelegram(
1017
- opts.botToken, botInfo.username, opts.users,
1018
- 300_000, log, { nonce, showLink: false },
1019
- );
1020
- if (!pairResult) {
1021
- warn("Pairing timed out. Run 'roundhouse pair' later.");
1022
- } else {
1023
- ok(`Paired with @${pairResult.username} (chat: ${pairResult.chatId})`);
1024
- if (!opts.notifyChatIds.includes(pairResult.chatId)) {
1025
- opts.notifyChatIds.push(pairResult.chatId);
1026
- }
1027
- }
1028
-
1029
- // Step 8: Store secrets
1030
- await stepStoreSecrets(opts, botInfo);
1031
-
1032
- // Step 9: Write config
1033
- await stepConfigure(opts, botInfo, pairResult, agent);
1034
-
1035
- // Step 10: Register commands + install service
1036
- await stepRegisterCommands(opts);
1037
- await stepInstallSystemd(opts);
1038
-
1039
- // Step 11: Verify
1040
- await stepPostflight();
1041
-
1042
- // Done!
1043
- log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1044
- log("✅ Roundhouse is ready!");
1045
- log(` Bot: @${botInfo.username}`);
1046
- log(` Send /status to @${botInfo.username} on Telegram.\n`);
1047
- } catch (err: any) {
1048
- log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1049
- log(`❌ Setup failed: ${err.message}`);
1050
- log(" Re-run: roundhouse setup --telegram\n");
1051
- process.exit(1);
1052
- }
1053
- }
1054
-
1055
- // ── Headless Telegram Setup ─────────────────────────
1056
-
1057
- async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
1058
- const agent = resolveAgentForSetup(opts);
1059
- const logger = createJsonLogger();
1060
-
1061
- // Override module-level log helpers to emit JSON instead of text
1062
- const savedLog = log, savedStep = step, savedOk = ok, savedWarn = warn, savedFail = fail;
1063
- log = (msg) => logger.info("log", msg);
1064
- step = (_n, label) => logger.info("step", label);
1065
- ok = (msg) => logger.ok(msg);
1066
- warn = (msg) => logger.warn("warn", msg);
1067
- fail = (msg) => logger.fail(msg);
1068
-
1069
- try {
1070
- // Validate required inputs
1071
- if (!opts.botToken) {
1072
- logger.error("validation.failed", "TELEGRAM_BOT_TOKEN env var required for --headless");
1073
- process.exit(2);
1074
- }
1075
- if (opts.users.length === 0) {
1076
- logger.error("validation.failed", "--user is required for --headless");
1077
- process.exit(2);
1078
- }
1079
-
1080
- // Step 1: Preflight
1081
- logger.step(1, 9, "preflight.start", "Running preflight checks");
1082
- await stepPreflight(opts, agent);
1083
- logger.ok("Preflight passed");
1084
-
1085
- // Step 2: Validate token
1086
- logger.step(2, 9, "telegram.validate", "Validating Telegram bot token");
1087
- const botInfo = await stepValidateToken(opts);
1088
- logger.ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
1089
-
1090
- // Step 3: Stop existing gateway
1091
- logger.step(3, 9, "gateway.stop", "Checking for running gateway");
1092
- await stepStopGateway();
1093
-
1094
- // Step 4: Install packages
1095
- logger.step(4, 9, "packages.install", "Installing packages");
1096
- await stepInstallPackages(opts, agent);
1097
- logger.ok("Packages installed");
1098
-
1099
- // Step 4b: Install bundle
1100
- await stepInstallBundle(opts);
1101
-
1102
- // Step 5: Create pending pairing
1103
- logger.step(5, 9, "pairing.pending", "Creating pending pairing");
1104
- let nonce: string;
1105
- const existing = await readPendingPairing();
1106
- if (existing?.status === "pending" && !opts.force) {
1107
- nonce = existing.nonce;
1108
- logger.info("pairing.reuse", `Reusing existing nonce: ${nonce}`);
1109
- } else {
1110
- nonce = createPairingNonce();
1111
- }
1112
- const pairingLink = createPairingLink(botInfo.username, nonce);
1113
- const pendingPairing: PendingPairing = {
1114
- version: 1,
1115
- nonce,
1116
- botUsername: botInfo.username,
1117
- allowedUsers: opts.users,
1118
- createdAt: new Date().toISOString(),
1119
- status: "pending",
1120
- };
1121
- await writePendingPairing(pendingPairing);
1122
- logger.info("pairing.link", `Pairing link: ${pairingLink}`, { pairingLink, nonce });
1123
-
1124
- // Step 6: Store secrets
1125
- logger.step(6, 9, "secrets.store", "Storing secrets");
1126
- await stepStoreSecrets(opts, botInfo);
1127
-
1128
- // Step 7: Write config (no pair result yet — gateway will complete pairing)
1129
- logger.step(7, 9, "config.write", "Writing configuration");
1130
- await stepConfigure(opts, botInfo, null, agent);
1131
- logger.ok("Config written");
1132
-
1133
- // Step 8: Register commands
1134
- logger.step(8, 9, "commands.register", "Registering bot commands");
1135
- await stepRegisterCommands(opts);
1136
- logger.ok("Bot commands registered");
1137
-
1138
- // Step 9: Install and start service
1139
- logger.step(9, 9, "service.install", "Installing and starting service");
1140
- if (!opts.systemd) {
1141
- logger.warn("service.skip", "--no-systemd: service not installed. Start manually: roundhouse start");
1142
- } else {
1143
- await stepInstallSystemd(opts);
1144
- logger.ok("Service installed and started");
1145
-
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}`);
1154
- }
1155
- } catch {
1156
- logger.warn("service.state", "Could not verify service state");
1157
- }
1158
- }
1159
-
1160
- // Success
1161
- logger.info("setup.complete", "Headless setup complete", {
1162
- botUsername: botInfo.username,
1163
- pairingLink,
1164
- pairingStatus: "pending",
1165
- serviceInstalled: opts.systemd,
1166
- });
1167
- log("");
1168
- log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1169
- log(`✅ Roundhouse installed and running!`);
1170
- log(``);
1171
- log(` Bot: @${botInfo.username}`);
1172
- log(` Pairing: Open ${pairingLink} to complete setup`);
1173
- log(` Gateway is running and will accept pairing automatically.`);
1174
- log(``);
1175
- } catch (err: any) {
1176
- const diag: SetupDiagnostics = {
1177
- node: process.version,
1178
- platform: platform(),
1179
- arch: process.arch,
1180
- cwd: process.cwd(),
1181
- roundhouseDir: ROUNDHOUSE_DIR,
1182
- configExists: await fileExists(CONFIG_PATH).catch(() => false),
1183
- envExists: await fileExists(ENV_PATH).catch(() => false),
1184
- pairingStatus: (await readPendingPairing())?.status ?? "not found",
1185
- serviceState: "unknown",
1186
- error: { name: err.name, message: err.message, stack: err.stack },
1187
- };
1188
- try {
1189
- const { execFileSync } = await import("node:child_process");
1190
- diag.serviceState = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
1191
- } catch {}
1192
- printDiagnosticError(diag, true);
1193
- process.exit(1);
1194
- } finally {
1195
- // Restore module-level log helpers
1196
- log = savedLog; step = savedStep; ok = savedOk; warn = savedWarn; fail = savedFail;
1197
- }
1198
- }
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";
1199
46
 
1200
47
  // ── Orchestrator ─────────────────────────────────────
1201
48
 
@@ -1225,58 +72,59 @@ export async function cmdSetup(argv: string[]): Promise<void> {
1225
72
  }
1226
73
 
1227
74
  // Legacy flow (no --telegram flag)
1228
- const agent = resolveAgentForSetup(opts);
1229
- log("\n🔧 Roundhouse Setup");
1230
- log("━━━━━━━━━━━━━━━━━━━");
75
+ const logger = textStepLog;
76
+ const agent = resolveAgentForSetup(opts, logger);
77
+ textLog("\n🔧 Roundhouse Setup");
78
+ textLog("━━━━━━━━━━━━━━━━━━━");
1231
79
 
1232
80
  try {
1233
81
  // Phase 1: Validate (no mutations)
1234
- await stepPreflight(opts, agent);
1235
- const botInfo = await stepValidateToken(opts);
1236
- await stepStopGateway();
82
+ await stepPreflight(logger, opts, agent);
83
+ const botInfo = await stepValidateToken(logger, opts);
84
+ await stepStopGateway(logger);
1237
85
 
1238
86
  // Phase 2: Install packages
1239
- await stepInstallPackages(opts, agent);
87
+ await stepInstallPackages(logger, opts, agent);
1240
88
 
1241
89
  // Phase 2b: Install bundle (skills + CLI tools)
1242
- await stepInstallBundle(opts);
90
+ await stepInstallBundle(logger, opts);
1243
91
 
1244
92
  // Phase 3: Pair (before secrets/config, so paired username is included)
1245
- const pairResult = await stepPair(opts, botInfo);
93
+ const pairResult = await stepPair(logger, opts, botInfo);
1246
94
 
1247
95
  // Phase 4: Store secrets (after pairing, so ALLOWED_USERS includes paired user)
1248
- await stepStoreSecrets(opts, botInfo);
96
+ await stepStoreSecrets(logger, opts, botInfo);
1249
97
 
1250
98
  // Phase 5: Write config (includes pair data)
1251
- await stepConfigure(opts, botInfo, pairResult, agent);
99
+ await stepConfigure(logger, opts, botInfo, pairResult, agent);
1252
100
 
1253
101
  // Phase 6: Remote setup
1254
- await stepRegisterCommands(opts);
102
+ await stepRegisterCommands(logger, opts);
1255
103
 
1256
104
  // Phase 7: Service
1257
- await stepInstallSystemd(opts);
105
+ await stepInstallSystemd(logger, opts);
1258
106
 
1259
107
  // Phase 8: Verify
1260
- await stepPostflight();
108
+ await stepPostflight(logger);
1261
109
 
1262
110
  // Final message
1263
111
  const warnings = !opts.notifyChatIds.length && !pairResult;
1264
- log("\n━━━━━━━━━━━━━━━━━━━");
112
+ textLog("\n━━━━━━━━━━━━━━━━━━━");
1265
113
  if (warnings) {
1266
- log("⚠️ Installed, action required:");
1267
- log(` • Not paired — run: roundhouse pair`);
114
+ textLog("⚠️ Installed, action required:");
115
+ textLog(` • Not paired — run: roundhouse pair`);
1268
116
  } else {
1269
- log("✅ Roundhouse is running!");
117
+ textLog("✅ Roundhouse is running!");
1270
118
  }
1271
- log(` Bot: @${botInfo.username}`);
1272
- log(` Memory: ${opts.extensions.some((e) => e.includes("pi-memory")) ? "agent-managed" : "roundhouse-managed"}`);
1273
- log(` Secrets: ${opts.psst ? "psst vault (encrypted)" : "~/.roundhouse/.env (plaintext)"}`);
1274
- 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`);
1275
123
  } catch (err: any) {
1276
- log("\n━━━━━━━━━━━━━━━━━━━");
1277
- log(`❌ Setup failed: ${err.message}`);
1278
- log(" Partial changes may have been applied.");
1279
- 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");
1280
128
  process.exit(1);
1281
129
  }
1282
130
  }
@@ -1328,19 +176,19 @@ export async function cmdPair(argv: string[]): Promise<void> {
1328
176
  process.exit(1);
1329
177
  }
1330
178
 
1331
- log("\n🔗 Roundhouse Pairing\n");
179
+ textLog("\n🔗 Roundhouse Pairing\n");
1332
180
 
1333
181
  const botInfo = await validateBotToken(token);
1334
- ok(`Bot: @${botInfo.username}`);
182
+ textStepLog.ok(`Bot: @${botInfo.username}`);
1335
183
 
1336
- const result = await pairTelegram(token, botInfo.username, users, 300_000, log);
184
+ const result = await pairTelegram(token, botInfo.username, users, 300_000, textLog);
1337
185
 
1338
186
  if (!result) {
1339
- log("\n⚠ Pairing timed out. Try again: roundhouse pair\n");
187
+ textLog("\n⚠ Pairing timed out. Try again: roundhouse pair\n");
1340
188
  process.exit(1);
1341
189
  }
1342
190
 
1343
- 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})`);
1344
192
 
1345
193
  // Update config
1346
194
  try {
@@ -1356,52 +204,52 @@ export async function cmdPair(argv: string[]): Promise<void> {
1356
204
  config.chat.notifyChatIds = existingNotifyIds;
1357
205
 
1358
206
  await atomicWriteJson(CONFIG_PATH, config);
1359
- ok("Config updated with chat ID");
207
+ textStepLog.ok("Config updated with chat ID");
1360
208
  } catch {
1361
- warn("Could not update config — add notifyChatIds manually");
209
+ textStepLog.warn("Could not update config — add notifyChatIds manually");
1362
210
  }
1363
211
 
1364
- log("\n✅ Paired! Restart gateway to apply: roundhouse restart\n");
212
+ textLog("\n✅ Paired! Restart gateway to apply: roundhouse restart\n");
1365
213
  }
1366
214
 
1367
215
  // ── Dry run ──────────────────────────────────────────
1368
216
 
1369
217
  function printDryRun(opts: SetupOptions): void {
1370
218
  const agent = getAgentDefinition(opts.agent);
1371
- log("\n🔧 Roundhouse Setup (DRY RUN)");
1372
- log("━━━━━━━━━━━━━━━━━━━\n");
1373
- log(`Agent: ${agent.name} (${agent.type})`);
1374
- log("Would validate Telegram token");
1375
- log("Would stop existing gateway (if running)");
1376
- 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`);
1377
225
  for (const pkg of agent.packages) {
1378
226
  const scope = pkg.install === "global" ? "-g " : "";
1379
- log(`Would install: npm install ${scope}${pkg.packageName}`);
227
+ textLog(`Would install: npm install ${scope}${pkg.packageName}`);
1380
228
  }
1381
229
  if (opts.psst) {
1382
- log(`Would install: bun runtime (if not present)`);
1383
- log(`Would install: npm install -g psst-cli`);
1384
- log(`Would initialize psst vault`);
1385
- 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`);
1386
234
  }
1387
- for (const ext of opts.extensions) log(`Would install extension: ${ext}`);
235
+ for (const ext of opts.extensions) textLog(`Would install extension: ${ext}`);
1388
236
  if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
1389
- log(`Would pair via Telegram (interactive)`);
237
+ textLog(`Would pair via Telegram (interactive)`);
1390
238
  }
1391
239
  if (opts.psst) {
1392
- 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`);
1393
241
  }
1394
242
  if (agent.configDirs?.length) {
1395
- log(`Would configure: agent-specific settings`);
1396
- log(` Agent: ${agent.name}`);
1397
- }
1398
- log(` Set defaultProvider: ${opts.provider}`);
1399
- log(` Set defaultModel: ${opts.model}`);
1400
- log(`Would write: ~/.roundhouse/gateway.config.json`);
1401
- log(`Would write: ~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`);
1402
- log(`Would register ${BOT_COMMANDS.length} bot commands`);
1403
- if (opts.systemd) log(`Would install systemd service`);
1404
- 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");
1405
253
  }
1406
254
 
1407
255
  // ── Help ─────────────────────────────────────────────