@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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * cli/setup/args.ts — CLI argument parsing for setup command
3
+ */
4
+
5
+ import { homedir, platform } from "node:os";
6
+ import { getAgentDefinition } from "../../agents/registry";
7
+ import { type SetupOptions, DEFAULT_PROVIDER, DEFAULT_MODEL, EXTENSION_NAME_RE } from "./types";
8
+
9
+ export function parseSetupArgs(argv: string[]): SetupOptions {
10
+ const opts: SetupOptions = {
11
+ botToken: "",
12
+ users: [],
13
+ provider: DEFAULT_PROVIDER,
14
+ model: DEFAULT_MODEL,
15
+ extensions: [],
16
+ cwd: homedir(),
17
+ notifyChatIds: [],
18
+ systemd: platform() === "linux",
19
+ voice: platform() === "linux", // Default off on macOS (whisper install is heavy)
20
+ psst: false,
21
+ nonInteractive: false,
22
+ force: false,
23
+ dryRun: false,
24
+ telegram: false,
25
+ headless: false,
26
+ qr: "auto",
27
+ agent: "pi",
28
+ };
29
+
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const arg = argv[i];
32
+ const next = () => {
33
+ if (i + 1 >= argv.length) throw new Error(`Missing value for ${arg}`);
34
+ return argv[++i];
35
+ };
36
+
37
+ switch (arg) {
38
+ case "--bot-token": opts.botToken = next(); break;
39
+ case "--user": opts.users.push(next().replace(/^@/, "")); break;
40
+ case "--provider": opts.provider = next(); break;
41
+ case "--model": opts.model = next(); break;
42
+ case "--extension": opts.extensions.push(next()); break;
43
+ case "--cwd": opts.cwd = next(); break;
44
+ case "--notify-chat": opts.notifyChatIds.push(parseInt(next(), 10)); break;
45
+ case "--no-systemd": opts.systemd = false; break;
46
+ case "--no-voice": opts.voice = false; break;
47
+ case "--with-psst": opts.psst = true; break;
48
+ case "--non-interactive": opts.nonInteractive = true; break;
49
+ case "--telegram": opts.telegram = true; break;
50
+ case "--headless": opts.headless = true; opts.nonInteractive = true; break;
51
+ case "--agent": opts.agent = next().toLowerCase(); break;
52
+ case "--qr": opts.qr = "always"; break;
53
+ case "--no-qr": opts.qr = "never"; break;
54
+ case "--force": opts.force = true; break;
55
+ case "--dry-run": opts.dryRun = true; break;
56
+ default:
57
+ if (arg.startsWith("-")) throw new Error(`Unknown flag: ${arg}`);
58
+ throw new Error(`Unexpected argument: ${arg}`);
59
+ }
60
+ }
61
+
62
+ // Token from env if not in flags
63
+ if (!opts.botToken) {
64
+ opts.botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
65
+ }
66
+
67
+ // Headless: reject --bot-token (argv visible in process listings)
68
+ if (opts.headless && argv.some((a) => a === "--bot-token")) {
69
+ throw new Error(
70
+ "--bot-token is not accepted in --headless mode (argv visible in process listings).\n" +
71
+ "Use: TELEGRAM_BOT_TOKEN=... roundhouse setup --telegram --headless --user USERNAME",
72
+ );
73
+ }
74
+
75
+ // Validate agent type
76
+ try {
77
+ getAgentDefinition(opts.agent);
78
+ } catch (err: any) {
79
+ throw new Error(err.message);
80
+ }
81
+
82
+ // Interactive --telegram defers token/user prompting to the wizard
83
+ const isInteractiveTelegram = opts.telegram && !opts.headless && !opts.nonInteractive && process.stdin.isTTY;
84
+
85
+ // Validate
86
+ if (!opts.botToken && !opts.dryRun && !isInteractiveTelegram) {
87
+ throw new Error(
88
+ "Bot token required. Provide via:\n" +
89
+ " TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" +
90
+ " roundhouse setup --bot-token TOKEN --user USERNAME",
91
+ );
92
+ }
93
+ if (opts.users.length === 0 && !isInteractiveTelegram) {
94
+ throw new Error(
95
+ "At least one --user USERNAME is required.\n" +
96
+ "This is your Telegram username (without @).",
97
+ );
98
+ }
99
+ for (const ext of opts.extensions) {
100
+ if (!EXTENSION_NAME_RE.test(ext)) {
101
+ throw new Error(`Invalid extension name: ${ext}`);
102
+ }
103
+ }
104
+ if (opts.notifyChatIds.some(isNaN)) {
105
+ throw new Error("--notify-chat must be a number");
106
+ }
107
+
108
+ return opts;
109
+ }
@@ -0,0 +1,273 @@
1
+ import { platform } from "node:os";
2
+ import { execFileSync } from "node:child_process";
3
+ import { type SetupOptions } from "./types";
4
+ import { promptText, promptMasked } from "../setup-prompts";
5
+ import { createJsonLogger, type SetupDiagnostics, printDiagnosticError } from "../setup-logger";
6
+ import { printQr } from "../qr";
7
+ import {
8
+ createPairingNonce,
9
+ createPairingLink,
10
+ readPendingPairing,
11
+ writePendingPairing,
12
+ type PendingPairing,
13
+ } from "../../pairing";
14
+ import { detectEnvironment, formatDetectionResults } from "../detect";
15
+ import { fileExists, ROUNDHOUSE_DIR, CONFIG_PATH, ENV_FILE_PATH as ENV_PATH } from "../../config";
16
+ import { pairTelegram } from "../setup-telegram";
17
+ import {
18
+ stepPreflight,
19
+ stepValidateToken,
20
+ stepStopGateway,
21
+ stepInstallPackages,
22
+ stepStoreSecrets,
23
+ stepInstallBundle,
24
+ stepConfigure,
25
+ stepRegisterCommands,
26
+ stepInstallSystemd,
27
+ stepPostflight,
28
+ } from "./steps";
29
+ import { resolveAgentForSetup, textLog, textStepLog, createStepLog } from "./runtime";
30
+
31
+ export async function runInteractiveTelegramSetup(opts: SetupOptions): Promise<void> {
32
+ const logger = textStepLog;
33
+ const agent = resolveAgentForSetup(opts, logger);
34
+ textLog("\n🔧 Roundhouse Telegram Setup");
35
+ textLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━");
36
+
37
+ try {
38
+ await stepPreflight(logger, opts, agent);
39
+
40
+ const env = detectEnvironment();
41
+ if (env.agents.length > 0) {
42
+ textLog("");
43
+ textLog(" 🔍 Agent detection:");
44
+ for (const line of formatDetectionResults(env)) {
45
+ logger.ok(line);
46
+ }
47
+ if (!opts.force) {
48
+ const selected = env.agents.find(a => a.type === opts.agent);
49
+ if (selected?.configured) {
50
+ opts._skipAgentInstall = true;
51
+ }
52
+ }
53
+ }
54
+
55
+ if (!opts.botToken) {
56
+ textLog("");
57
+ printBotFatherGuide();
58
+ opts.botToken = await promptMasked(" Paste your bot token");
59
+ if (!opts.botToken) {
60
+ logger.fail("No token provided");
61
+ process.exit(2);
62
+ }
63
+ }
64
+ const botInfo = await stepValidateToken(logger, opts);
65
+
66
+ if (opts.users.length === 0) {
67
+ logger.step("③", "Telegram username...");
68
+ const username = await promptText(" Your Telegram username (without @)");
69
+ if (!username) {
70
+ logger.fail("Username required");
71
+ process.exit(2);
72
+ }
73
+ opts.users.push(username.replace(/^@/, ""));
74
+ logger.ok(`Allowed: ${opts.users.map(u => `@${u}`).join(", ")}`);
75
+ }
76
+
77
+ await stepStopGateway(logger);
78
+ await stepInstallPackages(logger, opts, agent);
79
+ await stepInstallBundle(logger, opts);
80
+
81
+ logger.step("⑦", "Pairing with Telegram...");
82
+ const nonce = createPairingNonce();
83
+ const pairingLink = createPairingLink(botInfo.username, nonce);
84
+ textLog(`\n Open this link to pair:\n`);
85
+ textLog(` 🔗 ${pairingLink}\n`);
86
+ printQr(pairingLink, opts.qr);
87
+ textLog(` Or send /start ${nonce} to @${botInfo.username}`);
88
+ textLog("");
89
+
90
+ if (process.platform === "darwin") {
91
+ try {
92
+ execFileSync("open", [pairingLink], { stdio: "ignore" });
93
+ textLog(" (Opened in Telegram — switch to the app to complete pairing)");
94
+ } catch {}
95
+ }
96
+
97
+ textLog(" Waiting for you to tap the link in Telegram...");
98
+
99
+ const pairResult = await pairTelegram(
100
+ opts.botToken, botInfo.username, opts.users,
101
+ 300_000, textLog, { nonce, showLink: false },
102
+ );
103
+ if (!pairResult) {
104
+ logger.warn("Pairing timed out. Run 'roundhouse pair' later.");
105
+ } else {
106
+ logger.ok(`Paired with @${pairResult.username} (chat: ${pairResult.chatId})`);
107
+ if (!opts.notifyChatIds.includes(pairResult.chatId)) {
108
+ opts.notifyChatIds.push(pairResult.chatId);
109
+ }
110
+ }
111
+
112
+ await stepStoreSecrets(logger, opts, botInfo);
113
+ await stepConfigure(logger, opts, botInfo, pairResult, agent);
114
+ await stepRegisterCommands(logger, opts);
115
+ await stepInstallSystemd(logger, opts);
116
+ await stepPostflight(logger);
117
+
118
+ textLog("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
119
+ textLog("✅ Roundhouse is ready!");
120
+ textLog(` Bot: @${botInfo.username}`);
121
+ textLog(` Send /status to @${botInfo.username} on Telegram.\n`);
122
+ } catch (err: any) {
123
+ textLog("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━");
124
+ textLog(`❌ Setup failed: ${err.message}`);
125
+ textLog(" Re-run: roundhouse setup --telegram\n");
126
+ process.exit(1);
127
+ }
128
+ }
129
+
130
+ export async function runHeadlessTelegramSetup(opts: SetupOptions): Promise<void> {
131
+ const logger = createJsonLogger();
132
+ const stepLogger = createStepLog(logger);
133
+ const agent = resolveAgentForSetup(opts, stepLogger);
134
+
135
+ try {
136
+ if (!opts.botToken) {
137
+ logger.error("validation.failed", "TELEGRAM_BOT_TOKEN env var required for --headless");
138
+ process.exit(2);
139
+ }
140
+ if (opts.users.length === 0) {
141
+ logger.error("validation.failed", "--user is required for --headless");
142
+ process.exit(2);
143
+ }
144
+
145
+ logger.step(1, 9, "preflight.start", "Running preflight checks");
146
+ await stepPreflight(stepLogger, opts, agent);
147
+ logger.ok("Preflight passed");
148
+
149
+ logger.step(2, 9, "telegram.validate", "Validating Telegram bot token");
150
+ const botInfo = await stepValidateToken(stepLogger, opts);
151
+ logger.ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
152
+
153
+ logger.step(3, 9, "gateway.stop", "Checking for running gateway");
154
+ await stepStopGateway(stepLogger);
155
+
156
+ logger.step(4, 9, "packages.install", "Installing packages");
157
+ await stepInstallPackages(stepLogger, opts, agent);
158
+ logger.ok("Packages installed");
159
+
160
+ await stepInstallBundle(stepLogger, opts);
161
+
162
+ logger.step(5, 9, "pairing.pending", "Creating pending pairing");
163
+ let nonce: string;
164
+ const existing = await readPendingPairing();
165
+ if (existing?.status === "pending" && !opts.force) {
166
+ nonce = existing.nonce;
167
+ logger.info("pairing.reuse", `Reusing existing nonce: ${nonce}`);
168
+ } else {
169
+ nonce = createPairingNonce();
170
+ }
171
+ const pairingLink = createPairingLink(botInfo.username, nonce);
172
+ const pendingPairing: PendingPairing = {
173
+ version: 1,
174
+ nonce,
175
+ botUsername: botInfo.username,
176
+ allowedUsers: opts.users,
177
+ createdAt: new Date().toISOString(),
178
+ status: "pending",
179
+ };
180
+ await writePendingPairing(pendingPairing);
181
+ logger.info("pairing.link", `Pairing link: ${pairingLink}`, { pairingLink, nonce });
182
+
183
+ logger.step(6, 9, "secrets.store", "Storing secrets");
184
+ await stepStoreSecrets(stepLogger, opts, botInfo);
185
+
186
+ logger.step(7, 9, "config.write", "Writing configuration");
187
+ await stepConfigure(stepLogger, opts, botInfo, null, agent);
188
+ logger.ok("Config written");
189
+
190
+ logger.step(8, 9, "commands.register", "Registering bot commands");
191
+ await stepRegisterCommands(stepLogger, opts);
192
+ logger.ok("Bot commands registered");
193
+
194
+ let serviceInstalled = false;
195
+ logger.step(9, 9, "service.install", "Installing and starting service");
196
+ if (!opts.systemd && platform() !== "darwin") {
197
+ logger.warn("service.skip", "--no-systemd: service not installed. Start manually: roundhouse start");
198
+ } else {
199
+ await stepInstallSystemd(stepLogger, opts);
200
+
201
+ if (platform() === "darwin") {
202
+ try {
203
+ const { isLaunchAgentRunning } = await import("../launchd.ts");
204
+ if (isLaunchAgentRunning()) {
205
+ logger.ok("LaunchAgent is running");
206
+ serviceInstalled = true;
207
+ } else {
208
+ logger.warn("service.state", "LaunchAgent loaded but not yet running");
209
+ }
210
+ } catch {
211
+ logger.warn("service.state", "Could not verify LaunchAgent state");
212
+ }
213
+ } else {
214
+ try {
215
+ const state = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
216
+ if (state === "active") {
217
+ logger.ok("Service is active");
218
+ serviceInstalled = true;
219
+ } else {
220
+ logger.warn("service.state", `Service state: ${state}`);
221
+ }
222
+ } catch {
223
+ logger.warn("service.state", "Could not verify service state");
224
+ }
225
+ }
226
+ }
227
+
228
+ logger.info("setup.complete", "Headless setup complete", {
229
+ botUsername: botInfo.username,
230
+ pairingLink,
231
+ pairingStatus: "pending",
232
+ serviceInstalled,
233
+ });
234
+ stepLogger.log("");
235
+ stepLogger.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
236
+ stepLogger.log(`✅ Roundhouse installed and running!`);
237
+ stepLogger.log(``);
238
+ stepLogger.log(` Bot: @${botInfo.username}`);
239
+ stepLogger.log(` Pairing: Open ${pairingLink} to complete setup`);
240
+ stepLogger.log(` Gateway is running and will accept pairing automatically.`);
241
+ stepLogger.log(``);
242
+ } catch (err: any) {
243
+ const diag: SetupDiagnostics = {
244
+ node: process.version,
245
+ platform: platform(),
246
+ arch: process.arch,
247
+ cwd: process.cwd(),
248
+ roundhouseDir: ROUNDHOUSE_DIR,
249
+ configExists: await fileExists(CONFIG_PATH).catch(() => false),
250
+ envExists: await fileExists(ENV_PATH).catch(() => false),
251
+ pairingStatus: (await readPendingPairing())?.status ?? "not found",
252
+ serviceState: "unknown",
253
+ error: { name: err.name, message: err.message, stack: err.stack },
254
+ };
255
+ try {
256
+ diag.serviceState = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim();
257
+ } catch {}
258
+ printDiagnosticError(diag, true);
259
+ process.exit(1);
260
+ }
261
+ }
262
+
263
+ function printBotFatherGuide(): void {
264
+ textLog("");
265
+ textLog(" 🤖 Create a Telegram Bot");
266
+ textLog(" ────────────────────────");
267
+ textLog(" 1. Open https://t.me/BotFather");
268
+ textLog(" 2. Send /newbot");
269
+ textLog(" 3. Choose a display name (e.g. 'My Roundhouse')");
270
+ textLog(" 4. Choose a username ending in 'bot' (e.g. 'my_roundhouse_bot')");
271
+ textLog(" 5. Copy the token BotFather returns");
272
+ textLog("");
273
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * cli/setup/helpers.ts — Low-level utilities for setup flows
3
+ *
4
+ * Atomic file writes, safe exec wrappers, and other primitives
5
+ * shared by interactive and headless setup paths.
6
+ */
7
+
8
+ import { writeFile, rename, unlink } from "node:fs/promises";
9
+ import { execFileSync } from "node:child_process";
10
+ import { randomBytes } from "node:crypto";
11
+
12
+ /**
13
+ * Atomically write JSON to a file (write to tmp, rename).
14
+ */
15
+ export async function atomicWriteJson(path: string, data: unknown): Promise<void> {
16
+ const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
17
+ try {
18
+ await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
19
+ await rename(tmp, path);
20
+ } catch (err) {
21
+ try { await unlink(tmp); } catch {}
22
+ throw err;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Atomically write text to a file (write to tmp, rename).
28
+ */
29
+ export async function atomicWriteText(path: string, content: string, mode = 0o600): Promise<void> {
30
+ const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
31
+ try {
32
+ await writeFile(tmp, content, { mode });
33
+ await rename(tmp, path);
34
+ } catch (err) {
35
+ try { await unlink(tmp); } catch {}
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Execute a command, returning stdout or empty string on failure. Never throws.
42
+ */
43
+ export function execSafe(cmd: string, args: string[], opts: { silent?: boolean; input?: string } = {}): string {
44
+ try {
45
+ const result = execFileSync(cmd, args, {
46
+ encoding: "utf8",
47
+ stdio: opts.silent ? "pipe" : opts.input ? ["pipe", "pipe", "pipe"] : "pipe",
48
+ input: opts.input,
49
+ timeout: 120_000,
50
+ });
51
+ return result.trim();
52
+ } catch {
53
+ return "";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Execute a command, throwing a descriptive error on failure.
59
+ */
60
+ export function execOrFail(cmd: string, args: string[], label: string): string {
61
+ try {
62
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe", timeout: 120_000 }).trim();
63
+ } catch (err: any) {
64
+ throw new Error(`${label}: ${err.stderr?.trim() || err.message}`);
65
+ }
66
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * cli/setup/index.ts — Barrel export for setup module
3
+ */
4
+
5
+ export { atomicWriteJson, atomicWriteText, execSafe, execOrFail } from "./helpers";
6
+ export { type SetupOptions, type StepStatus, PI_SETTINGS_PATH, DEFAULT_PROVIDER, DEFAULT_MODEL, EXTENSION_NAME_RE } from "./types";
7
+ export { parseSetupArgs } from "./args";
@@ -0,0 +1,109 @@
1
+ import { dirname } from "node:path";
2
+ import { readFile, mkdir } from "node:fs/promises";
3
+ import { atomicWriteJson, execOrFail } from "./helpers";
4
+ import { type SetupOptions, type StepLog, PI_SETTINGS_PATH } from "./types";
5
+ import {
6
+ getAgentDefinition,
7
+ type AgentDefinition,
8
+ type AgentSetupContext,
9
+ } from "../../agents/registry";
10
+ import { type SetupLogger } from "../setup-logger";
11
+
12
+ export function resolveAgentForSetup(opts: SetupOptions, logger: StepLog): AgentDefinition {
13
+ const agent = { ...getAgentDefinition(opts.agent) };
14
+
15
+ if (agent.type === "pi") {
16
+ agent.configure = async (ctx: AgentSetupContext) => {
17
+ let existing: Record<string, unknown> = {};
18
+ try {
19
+ existing = JSON.parse(await readFile(PI_SETTINGS_PATH, "utf8"));
20
+ } catch {}
21
+
22
+ const settings: Record<string, unknown> = { ...existing };
23
+
24
+ if (ctx.force) {
25
+ settings.defaultProvider = ctx.provider;
26
+ settings.defaultModel = ctx.model;
27
+ } else {
28
+ if (existing.defaultProvider && existing.defaultProvider !== ctx.provider) {
29
+ logger.warn(`Pi provider already set to '${existing.defaultProvider}' (keeping, use --force to override)`);
30
+ } else {
31
+ settings.defaultProvider = ctx.provider;
32
+ }
33
+ if (existing.defaultModel && existing.defaultModel !== ctx.model) {
34
+ logger.warn(`Pi model already set to '${existing.defaultModel}' (keeping, use --force to override)`);
35
+ } else {
36
+ settings.defaultModel = ctx.model;
37
+ }
38
+ }
39
+
40
+ if (!Array.isArray(settings.packages)) settings.packages = [];
41
+
42
+ const pkgs = settings.packages as string[];
43
+ const selfPkg = "npm:@inceptionstack/roundhouse";
44
+ const selfIdx = pkgs.indexOf(selfPkg);
45
+ if (selfIdx !== -1) pkgs.splice(selfIdx, 1);
46
+
47
+ const coreExtensions = [
48
+ "npm:@inceptionstack/pi-hard-no",
49
+ "npm:@inceptionstack/pi-branch-enforcer",
50
+ ];
51
+ for (const ext of coreExtensions) {
52
+ if (!pkgs.includes(ext)) pkgs.push(ext);
53
+ }
54
+
55
+ if (ctx.psst) {
56
+ const psstPkg = "npm:@miclivs/pi-psst";
57
+ if (!pkgs.includes(psstPkg)) pkgs.push(psstPkg);
58
+ }
59
+
60
+ await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
61
+ await atomicWriteJson(PI_SETTINGS_PATH, settings);
62
+ logger.ok(`~/.pi/agent/settings.json (${settings.defaultProvider}, ${settings.defaultModel})`);
63
+ };
64
+
65
+ agent.installExtension = async (ext: string) => {
66
+ execOrFail("pi", ["install", `npm:${ext}`], `extension ${ext}`);
67
+ };
68
+ }
69
+
70
+ return agent;
71
+ }
72
+
73
+ export const textLog = (msg: string): void => { console.log(msg); };
74
+
75
+ export const textStepLog: StepLog = {
76
+ log: textLog,
77
+ step(n, label) {
78
+ textLog(`\n${n} ${label}`);
79
+ },
80
+ ok(msg) {
81
+ textLog(` ✓ ${msg}`);
82
+ },
83
+ warn(msg) {
84
+ textLog(` ⚠ ${msg}`);
85
+ },
86
+ fail(msg) {
87
+ textLog(` ✗ ${msg}`);
88
+ },
89
+ };
90
+
91
+ export function createStepLog(logger: SetupLogger): StepLog {
92
+ return {
93
+ log(msg) {
94
+ logger.info("log", msg);
95
+ },
96
+ step(n, label) {
97
+ logger.info("step", label, { stepLabel: n });
98
+ },
99
+ ok(msg) {
100
+ logger.ok(msg);
101
+ },
102
+ warn(msg) {
103
+ logger.warn("warn", msg);
104
+ },
105
+ fail(msg) {
106
+ logger.fail(msg);
107
+ },
108
+ };
109
+ }