@inceptionstack/roundhouse 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -9
- package/architecture.md +77 -8
- package/package.json +9 -6
- package/src/agents/pi.ts +433 -26
- package/src/agents/registry.ts +8 -0
- package/src/cli/cli.ts +384 -189
- package/src/cli/cron.ts +296 -0
- package/src/cli/doctor/checks/agent.ts +68 -0
- package/src/cli/doctor/checks/config.ts +88 -0
- package/src/cli/doctor/checks/credentials.ts +62 -0
- package/src/cli/doctor/checks/disk.ts +69 -0
- package/src/cli/doctor/checks/stt.ts +76 -0
- package/src/cli/doctor/checks/system.ts +86 -0
- package/src/cli/doctor/checks/systemd.ts +76 -0
- package/src/cli/doctor/output.ts +58 -0
- package/src/cli/doctor/runner.ts +142 -0
- package/src/cli/doctor/shell.ts +33 -0
- package/src/cli/doctor/types.ts +44 -0
- package/src/cli/doctor.ts +48 -0
- package/src/cli/setup-telegram.ts +148 -0
- package/src/cli/setup.ts +936 -0
- package/src/commands.ts +23 -0
- package/src/config.ts +188 -0
- package/src/cron/constants.ts +54 -0
- package/src/cron/durations.ts +33 -0
- package/src/cron/format.ts +139 -0
- package/src/cron/helpers.ts +30 -0
- package/src/cron/runner.ts +148 -0
- package/src/cron/schedule.ts +101 -0
- package/src/cron/scheduler.ts +295 -0
- package/src/cron/store.ts +125 -0
- package/src/cron/template.ts +89 -0
- package/src/cron/types.ts +76 -0
- package/src/gateway.ts +927 -18
- package/src/index.ts +1 -58
- package/src/memory/bootstrap.ts +98 -0
- package/src/memory/files.ts +100 -0
- package/src/memory/inject.ts +41 -0
- package/src/memory/lifecycle.ts +245 -0
- package/src/memory/policy.ts +122 -0
- package/src/memory/prompts.ts +42 -0
- package/src/memory/state.ts +43 -0
- package/src/memory/types.ts +90 -0
- package/src/notify/telegram.ts +48 -0
- package/src/types.ts +68 -1
- package/src/util.ts +28 -2
- package/src/voice/providers/whisper.ts +339 -0
- package/src/voice/stt-service.ts +284 -0
- package/src/voice/types.ts +63 -0
package/src/cli/setup.ts
ADDED
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/setup.ts — One-command install & configure for roundhouse
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* TELEGRAM_BOT_TOKEN=... npx @inceptionstack/roundhouse setup --user badlogicgames
|
|
6
|
+
* roundhouse setup --bot-token "TOKEN" --user badlogicgames
|
|
7
|
+
*
|
|
8
|
+
* Installs roundhouse + pi + psst, configures everything, pairs Telegram,
|
|
9
|
+
* and starts the systemd service.
|
|
10
|
+
*/
|
|
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, spawnSync } from "node:child_process";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { BOT_COMMANDS } from "../commands";
|
|
18
|
+
import {
|
|
19
|
+
ROUNDHOUSE_DIR,
|
|
20
|
+
CONFIG_PATH,
|
|
21
|
+
ENV_FILE_PATH as ENV_PATH,
|
|
22
|
+
fileExists,
|
|
23
|
+
} from "../config";
|
|
24
|
+
import {
|
|
25
|
+
validateBotToken,
|
|
26
|
+
checkWebhook,
|
|
27
|
+
registerBotCommands,
|
|
28
|
+
pairTelegram,
|
|
29
|
+
sendMessage,
|
|
30
|
+
type BotInfo,
|
|
31
|
+
type PairResult,
|
|
32
|
+
} from "./setup-telegram";
|
|
33
|
+
|
|
34
|
+
// ── Types ────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface SetupOptions {
|
|
37
|
+
botToken: string;
|
|
38
|
+
users: string[];
|
|
39
|
+
provider: string;
|
|
40
|
+
model: string;
|
|
41
|
+
extensions: string[];
|
|
42
|
+
cwd: string;
|
|
43
|
+
notifyChatIds: number[];
|
|
44
|
+
systemd: boolean;
|
|
45
|
+
voice: boolean;
|
|
46
|
+
psst: boolean;
|
|
47
|
+
nonInteractive: boolean;
|
|
48
|
+
force: boolean;
|
|
49
|
+
dryRun: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type StepStatus = "ok" | "warn" | "skip" | "fail";
|
|
53
|
+
|
|
54
|
+
// ── Constants ────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const PI_SETTINGS_PATH = resolve(homedir(), ".pi", "agent", "settings.json");
|
|
57
|
+
|
|
58
|
+
const DEFAULT_PROVIDER = "amazon-bedrock";
|
|
59
|
+
const DEFAULT_MODEL = "us.anthropic.claude-opus-4-6-v1";
|
|
60
|
+
|
|
61
|
+
const EXTENSION_NAME_RE = /^@?[a-z0-9][\w.\-/]*$/i;
|
|
62
|
+
|
|
63
|
+
// ── Helpers ──────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function log(msg: string) { console.log(msg); }
|
|
66
|
+
function step(n: string, label: string) { log(`\n${n} ${label}`); }
|
|
67
|
+
function ok(msg: string) { log(` ✓ ${msg}`); }
|
|
68
|
+
function warn(msg: string) { log(` ⚠ ${msg}`); }
|
|
69
|
+
function fail(msg: string) { log(` ✗ ${msg}`); }
|
|
70
|
+
|
|
71
|
+
async function atomicWriteJson(path: string, data: unknown): Promise<void> {
|
|
72
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
|
|
73
|
+
try {
|
|
74
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
75
|
+
await rename(tmp, path);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
try { await unlink(tmp); } catch {}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function atomicWriteText(path: string, content: string, mode = 0o600): Promise<void> {
|
|
83
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
|
|
84
|
+
try {
|
|
85
|
+
await writeFile(tmp, content, { mode });
|
|
86
|
+
await rename(tmp, path);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
try { await unlink(tmp); } catch {}
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Shell-escape a value for env files */
|
|
94
|
+
function envQuote(value: string): string {
|
|
95
|
+
// Escape backslash, double-quote, dollar, backtick, newline
|
|
96
|
+
const escaped = value
|
|
97
|
+
.replace(/\\/g, "\\\\")
|
|
98
|
+
.replace(/"/g, '\\"')
|
|
99
|
+
.replace(/\$/g, "\\$")
|
|
100
|
+
.replace(/`/g, "\\`")
|
|
101
|
+
.replace(/\n/g, "\\n");
|
|
102
|
+
return `"${escaped}"`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function execSafe(cmd: string, args: string[], opts: { silent?: boolean; input?: string } = {}): string {
|
|
106
|
+
try {
|
|
107
|
+
const result = execFileSync(cmd, args, {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
stdio: opts.silent ? "pipe" : opts.input ? ["pipe", "pipe", "pipe"] : "pipe",
|
|
110
|
+
input: opts.input,
|
|
111
|
+
timeout: 120_000,
|
|
112
|
+
});
|
|
113
|
+
return result.trim();
|
|
114
|
+
} catch {
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function execOrFail(cmd: string, args: string[], label: string): string {
|
|
120
|
+
try {
|
|
121
|
+
return execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe", timeout: 120_000 }).trim();
|
|
122
|
+
} catch (err: any) {
|
|
123
|
+
throw new Error(`${label}: ${err.stderr?.trim() || err.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function whichSync(cmd: string): string | null {
|
|
128
|
+
const result = execSafe("which", [cmd], { silent: true });
|
|
129
|
+
return result || null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Arg parser ───────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
135
|
+
const opts: SetupOptions = {
|
|
136
|
+
botToken: "",
|
|
137
|
+
users: [],
|
|
138
|
+
provider: DEFAULT_PROVIDER,
|
|
139
|
+
model: DEFAULT_MODEL,
|
|
140
|
+
extensions: [],
|
|
141
|
+
cwd: homedir(),
|
|
142
|
+
notifyChatIds: [],
|
|
143
|
+
systemd: platform() === "linux",
|
|
144
|
+
voice: true,
|
|
145
|
+
psst: true,
|
|
146
|
+
nonInteractive: false,
|
|
147
|
+
force: false,
|
|
148
|
+
dryRun: false,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < argv.length; i++) {
|
|
152
|
+
const arg = argv[i];
|
|
153
|
+
const next = () => {
|
|
154
|
+
if (i + 1 >= argv.length) throw new Error(`Missing value for ${arg}`);
|
|
155
|
+
return argv[++i];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
switch (arg) {
|
|
159
|
+
case "--bot-token": opts.botToken = next(); break;
|
|
160
|
+
case "--user": opts.users.push(next().replace(/^@/, "")); break;
|
|
161
|
+
case "--provider": opts.provider = next(); break;
|
|
162
|
+
case "--model": opts.model = next(); break;
|
|
163
|
+
case "--extension": opts.extensions.push(next()); break;
|
|
164
|
+
case "--cwd": opts.cwd = next(); break;
|
|
165
|
+
case "--notify-chat": opts.notifyChatIds.push(parseInt(next(), 10)); break;
|
|
166
|
+
case "--no-systemd": opts.systemd = false; break;
|
|
167
|
+
case "--no-voice": opts.voice = false; break;
|
|
168
|
+
case "--no-psst": opts.psst = false; break;
|
|
169
|
+
case "--non-interactive": opts.nonInteractive = true; break;
|
|
170
|
+
case "--force": opts.force = true; break;
|
|
171
|
+
case "--dry-run": opts.dryRun = true; break;
|
|
172
|
+
default:
|
|
173
|
+
if (arg.startsWith("-")) throw new Error(`Unknown flag: ${arg}`);
|
|
174
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Token from env if not in flags
|
|
179
|
+
if (!opts.botToken) {
|
|
180
|
+
opts.botToken = process.env.TELEGRAM_BOT_TOKEN ?? "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Validate
|
|
184
|
+
if (!opts.botToken) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
"Bot token required. Provide via:\n" +
|
|
187
|
+
" TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" +
|
|
188
|
+
" roundhouse setup --bot-token TOKEN --user USERNAME",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (opts.users.length === 0) {
|
|
192
|
+
throw new Error("At least one --user USERNAME is required.");
|
|
193
|
+
}
|
|
194
|
+
for (const ext of opts.extensions) {
|
|
195
|
+
if (!EXTENSION_NAME_RE.test(ext)) {
|
|
196
|
+
throw new Error(`Invalid extension name: ${ext}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (opts.notifyChatIds.some(isNaN)) {
|
|
200
|
+
throw new Error("--notify-chat must be a number");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return opts;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Steps ────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
async function stepPreflight(opts: SetupOptions): Promise<void> {
|
|
209
|
+
step("①", "Preflight checks...");
|
|
210
|
+
|
|
211
|
+
// Node version
|
|
212
|
+
const nodeVer = process.version;
|
|
213
|
+
const major = parseInt(nodeVer.replace("v", ""));
|
|
214
|
+
if (major < 20) {
|
|
215
|
+
fail(`Node.js ${nodeVer} — version 20+ required`);
|
|
216
|
+
throw new Error("Node.js 20+ required");
|
|
217
|
+
}
|
|
218
|
+
ok(`Node.js ${nodeVer}`);
|
|
219
|
+
|
|
220
|
+
// npm
|
|
221
|
+
if (!whichSync("npm")) {
|
|
222
|
+
fail("npm not found on PATH");
|
|
223
|
+
throw new Error("npm required");
|
|
224
|
+
}
|
|
225
|
+
ok("npm available");
|
|
226
|
+
|
|
227
|
+
// Config dirs writable
|
|
228
|
+
for (const dir of [ROUNDHOUSE_DIR, dirname(PI_SETTINGS_PATH)]) {
|
|
229
|
+
try {
|
|
230
|
+
await mkdir(dir, { recursive: true });
|
|
231
|
+
ok(`Writable: ${dir.replace(homedir(), "~")}`);
|
|
232
|
+
} catch {
|
|
233
|
+
fail(`Cannot create: ${dir}`);
|
|
234
|
+
throw new Error(`Cannot write to ${dir}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Disk space (rough check)
|
|
239
|
+
try {
|
|
240
|
+
const dfOut = execSafe("df", ["-BG", "--output=avail", homedir()], { silent: true });
|
|
241
|
+
const match = dfOut.match(/(\d+)G/);
|
|
242
|
+
if (match) {
|
|
243
|
+
const freeGB = parseInt(match[1]);
|
|
244
|
+
if (freeGB < 1) {
|
|
245
|
+
fail(`Disk: ${freeGB} GB free (need >= 1 GB)`);
|
|
246
|
+
throw new Error("Insufficient disk space");
|
|
247
|
+
}
|
|
248
|
+
ok(`Disk: ${freeGB} GB free`);
|
|
249
|
+
}
|
|
250
|
+
} catch { /* non-fatal, df might not support these flags */ }
|
|
251
|
+
|
|
252
|
+
// Provider credentials (warn only)
|
|
253
|
+
if (opts.provider === "amazon-bedrock") {
|
|
254
|
+
const hasAws =
|
|
255
|
+
process.env.AWS_ACCESS_KEY_ID ||
|
|
256
|
+
process.env.AWS_PROFILE ||
|
|
257
|
+
await fileExists(resolve(homedir(), ".aws", "credentials"));
|
|
258
|
+
if (hasAws) {
|
|
259
|
+
ok("AWS credentials found");
|
|
260
|
+
} else {
|
|
261
|
+
warn("AWS credentials not found — configure before first use");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --cwd validation
|
|
266
|
+
try {
|
|
267
|
+
const resolved = await realpath(opts.cwd);
|
|
268
|
+
const st = await stat(resolved);
|
|
269
|
+
if (!st.isDirectory()) throw new Error("not a directory");
|
|
270
|
+
opts.cwd = resolved;
|
|
271
|
+
ok(`Working directory: ${resolved.replace(homedir(), "~")}`);
|
|
272
|
+
} catch {
|
|
273
|
+
fail(`--cwd path invalid: ${opts.cwd}`);
|
|
274
|
+
throw new Error(`Invalid --cwd: ${opts.cwd}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function stepValidateToken(opts: SetupOptions): Promise<BotInfo> {
|
|
279
|
+
step("②", "Validating Telegram bot token...");
|
|
280
|
+
|
|
281
|
+
const botInfo = await validateBotToken(opts.botToken);
|
|
282
|
+
ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
|
|
283
|
+
|
|
284
|
+
// Check for conflicting webhook
|
|
285
|
+
const webhook = await checkWebhook(opts.botToken);
|
|
286
|
+
if (webhook) {
|
|
287
|
+
warn(`Webhook active: ${webhook}`);
|
|
288
|
+
warn("Polling won't work while a webhook is set. Remove it or switch to webhook mode.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return botInfo;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function stepStopGateway(): Promise<void> {
|
|
295
|
+
step("③", "Checking for running gateway...");
|
|
296
|
+
|
|
297
|
+
if (platform() !== "linux") {
|
|
298
|
+
ok("Not Linux — skipping service check");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const isActive = execSafe("systemctl", ["is-active", "roundhouse"], { silent: true });
|
|
303
|
+
if (isActive === "active") {
|
|
304
|
+
log(" Stopping existing gateway...");
|
|
305
|
+
try {
|
|
306
|
+
execFileSync("sudo", ["-n", "systemctl", "stop", "roundhouse"], { stdio: "pipe", timeout: 30_000 });
|
|
307
|
+
ok("Service stopped");
|
|
308
|
+
} catch {
|
|
309
|
+
warn("Could not stop service (may need sudo). Continuing anyway.");
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
ok("No running gateway");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function stepInstallPackages(opts: SetupOptions): Promise<void> {
|
|
317
|
+
step("④", "Installing packages...");
|
|
318
|
+
|
|
319
|
+
// Roundhouse
|
|
320
|
+
const rhInstalled = whichSync("roundhouse");
|
|
321
|
+
if (rhInstalled && !opts.force) {
|
|
322
|
+
const ver = execSafe("roundhouse", ["--version"], { silent: true }) || "installed";
|
|
323
|
+
ok(`@inceptionstack/roundhouse (${ver}, already installed)`);
|
|
324
|
+
} else {
|
|
325
|
+
log(" Installing @inceptionstack/roundhouse...");
|
|
326
|
+
execOrFail("npm", ["install", "-g", "@inceptionstack/roundhouse"], "roundhouse install");
|
|
327
|
+
ok("@inceptionstack/roundhouse");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Pi agent
|
|
331
|
+
const piInstalled = whichSync("pi");
|
|
332
|
+
if (piInstalled && !opts.force) {
|
|
333
|
+
const ver = execSafe("pi", ["--version"], { silent: true }) || "installed";
|
|
334
|
+
ok(`@mariozechner/pi-coding-agent (${ver}, already installed)`);
|
|
335
|
+
} else {
|
|
336
|
+
log(" Installing @mariozechner/pi-coding-agent...");
|
|
337
|
+
execOrFail("npm", ["install", "-g", "@mariozechner/pi-coding-agent"], "pi install");
|
|
338
|
+
ok("@mariozechner/pi-coding-agent");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// psst-cli
|
|
342
|
+
if (opts.psst) {
|
|
343
|
+
const psstInstalled = whichSync("psst");
|
|
344
|
+
if (psstInstalled && !opts.force) {
|
|
345
|
+
ok(`psst-cli (already installed)`);
|
|
346
|
+
} else {
|
|
347
|
+
log(" Installing psst-cli...");
|
|
348
|
+
execOrFail("npm", ["install", "-g", "psst-cli"], "psst-cli install");
|
|
349
|
+
ok("psst-cli");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Initialize psst vault
|
|
353
|
+
const vaultExists = await fileExists(resolve(homedir(), ".psst", "envs"));
|
|
354
|
+
if (vaultExists) {
|
|
355
|
+
ok("psst vault exists");
|
|
356
|
+
} else {
|
|
357
|
+
log(" Initializing psst vault...");
|
|
358
|
+
execOrFail("psst", ["init"], "psst init");
|
|
359
|
+
ok("psst vault initialized");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Install pi-psst extension
|
|
363
|
+
log(" Installing pi-psst extension...");
|
|
364
|
+
try {
|
|
365
|
+
execFileSync("pi", ["install", "npm:@miclivs/pi-psst"], { encoding: "utf8", stdio: "pipe", timeout: 120_000 });
|
|
366
|
+
ok("@miclivs/pi-psst extension");
|
|
367
|
+
} catch {
|
|
368
|
+
// May already be installed
|
|
369
|
+
ok("@miclivs/pi-psst extension (already installed)");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// User extensions
|
|
374
|
+
for (const ext of opts.extensions) {
|
|
375
|
+
log(` Installing extension: ${ext}...`);
|
|
376
|
+
execOrFail("pi", ["install", `npm:${ext}`], `extension ${ext}`);
|
|
377
|
+
ok(ext);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
|
|
382
|
+
if (!opts.psst) return;
|
|
383
|
+
|
|
384
|
+
step("⑤", "Storing secrets in psst...");
|
|
385
|
+
|
|
386
|
+
const secrets: [string, string][] = [
|
|
387
|
+
["TELEGRAM_BOT_TOKEN", opts.botToken],
|
|
388
|
+
["BOT_USERNAME", botInfo.username],
|
|
389
|
+
["ALLOWED_USERS", opts.users.join(",")],
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
for (const [name, value] of secrets) {
|
|
393
|
+
try {
|
|
394
|
+
execFileSync("psst", ["set", name, "--stdin"], {
|
|
395
|
+
input: value,
|
|
396
|
+
encoding: "utf8",
|
|
397
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
398
|
+
timeout: 10_000,
|
|
399
|
+
});
|
|
400
|
+
ok(`${name} → psst vault`);
|
|
401
|
+
} catch {
|
|
402
|
+
// May already exist with same value
|
|
403
|
+
// Try overwrite
|
|
404
|
+
try {
|
|
405
|
+
execFileSync("psst", ["set", name, "--stdin"], {
|
|
406
|
+
input: value,
|
|
407
|
+
encoding: "utf8",
|
|
408
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
409
|
+
timeout: 10_000,
|
|
410
|
+
env: { ...process.env, PSST_FORCE: "1" },
|
|
411
|
+
});
|
|
412
|
+
ok(`${name} → psst vault (updated)`);
|
|
413
|
+
} catch (err: any) {
|
|
414
|
+
warn(`Failed to store ${name} in psst: ${err.message}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function stepConfigure(
|
|
421
|
+
opts: SetupOptions,
|
|
422
|
+
botInfo: BotInfo,
|
|
423
|
+
pairResult: PairResult | null,
|
|
424
|
+
): Promise<void> {
|
|
425
|
+
step("⑥", "Configuring...");
|
|
426
|
+
|
|
427
|
+
await mkdir(ROUNDHOUSE_DIR, { recursive: true });
|
|
428
|
+
await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
|
|
429
|
+
|
|
430
|
+
// ── Pi settings ──
|
|
431
|
+
let piSettings: Record<string, any> = {};
|
|
432
|
+
try {
|
|
433
|
+
piSettings = JSON.parse(await readFile(PI_SETTINGS_PATH, "utf8"));
|
|
434
|
+
} catch { /* doesn't exist */ }
|
|
435
|
+
|
|
436
|
+
if (opts.force) {
|
|
437
|
+
piSettings.defaultProvider = opts.provider;
|
|
438
|
+
piSettings.defaultModel = opts.model;
|
|
439
|
+
} else {
|
|
440
|
+
const existingProvider = piSettings.defaultProvider;
|
|
441
|
+
const existingModel = piSettings.defaultModel;
|
|
442
|
+
if (existingProvider && existingProvider !== opts.provider) {
|
|
443
|
+
warn(`Pi provider already set to '${existingProvider}' (keeping, use --force to override)`);
|
|
444
|
+
} else {
|
|
445
|
+
piSettings.defaultProvider = opts.provider;
|
|
446
|
+
}
|
|
447
|
+
if (existingModel && existingModel !== opts.model) {
|
|
448
|
+
warn(`Pi model already set to '${existingModel}' (keeping, use --force to override)`);
|
|
449
|
+
} else {
|
|
450
|
+
piSettings.defaultModel = opts.model;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Ensure packages array includes pi-psst if using psst
|
|
455
|
+
if (!piSettings.packages) piSettings.packages = [];
|
|
456
|
+
if (opts.psst && !piSettings.packages.includes("npm:@miclivs/pi-psst")) {
|
|
457
|
+
piSettings.packages.push("npm:@miclivs/pi-psst");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
await atomicWriteJson(PI_SETTINGS_PATH, piSettings);
|
|
461
|
+
ok(`~/.pi/agent/settings.json (${piSettings.defaultProvider}, ${piSettings.defaultModel})`);
|
|
462
|
+
|
|
463
|
+
// ── Gateway config ──
|
|
464
|
+
let gatewayConfig: Record<string, any> = {};
|
|
465
|
+
if (!opts.force) {
|
|
466
|
+
try {
|
|
467
|
+
gatewayConfig = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
468
|
+
} catch { /* new install */ }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Merge users
|
|
472
|
+
const existingUsers: string[] = gatewayConfig.chat?.allowedUsers ?? [];
|
|
473
|
+
const existingUserIds: number[] = gatewayConfig.chat?.allowedUserIds ?? [];
|
|
474
|
+
const existingNotifyIds: number[] = (gatewayConfig.chat?.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n));
|
|
475
|
+
|
|
476
|
+
const mergedUsers = [...new Set([...existingUsers, ...opts.users])];
|
|
477
|
+
const mergedUserIds = [...existingUserIds];
|
|
478
|
+
const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds])];
|
|
479
|
+
|
|
480
|
+
// Add paired user data
|
|
481
|
+
if (pairResult) {
|
|
482
|
+
if (!mergedUserIds.includes(pairResult.userId)) {
|
|
483
|
+
mergedUserIds.push(pairResult.userId);
|
|
484
|
+
}
|
|
485
|
+
if (!mergedNotifyIds.includes(pairResult.chatId)) {
|
|
486
|
+
mergedNotifyIds.push(pairResult.chatId);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
gatewayConfig = {
|
|
491
|
+
...gatewayConfig,
|
|
492
|
+
_version: 1, // Config schema version — for future migration support
|
|
493
|
+
agent: { ...gatewayConfig.agent, type: "pi", cwd: opts.cwd },
|
|
494
|
+
chat: {
|
|
495
|
+
...gatewayConfig.chat,
|
|
496
|
+
botUsername: botInfo.username,
|
|
497
|
+
allowedUsers: mergedUsers,
|
|
498
|
+
allowedUserIds: mergedUserIds,
|
|
499
|
+
notifyChatIds: mergedNotifyIds,
|
|
500
|
+
adapters: gatewayConfig.chat?.adapters ?? { telegram: { mode: "polling" } },
|
|
501
|
+
},
|
|
502
|
+
...(opts.voice === false ? { voice: { stt: { enabled: false } } } : {}),
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
await atomicWriteJson(CONFIG_PATH, gatewayConfig);
|
|
506
|
+
ok(`~/.roundhouse/gateway.config.json`);
|
|
507
|
+
|
|
508
|
+
// ── Env file ──
|
|
509
|
+
// With psst: only non-secret config
|
|
510
|
+
// Without psst: include secrets
|
|
511
|
+
const envLines: string[] = [];
|
|
512
|
+
|
|
513
|
+
if (!opts.psst) {
|
|
514
|
+
envLines.push(`TELEGRAM_BOT_TOKEN=${envQuote(opts.botToken)}`);
|
|
515
|
+
envLines.push(`BOT_USERNAME=${envQuote(botInfo.username)}`);
|
|
516
|
+
envLines.push(`ALLOWED_USERS=${envQuote(opts.users.join(","))}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (opts.provider === "amazon-bedrock") {
|
|
520
|
+
// Preserve existing AWS config
|
|
521
|
+
let existingEnv: Record<string, string> = {};
|
|
522
|
+
try {
|
|
523
|
+
const raw = await readFile(ENV_PATH, "utf8");
|
|
524
|
+
for (const line of raw.split("\n")) {
|
|
525
|
+
const trimmed = line.trim();
|
|
526
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
527
|
+
const eq = trimmed.indexOf("=");
|
|
528
|
+
if (eq > 0) existingEnv[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
529
|
+
}
|
|
530
|
+
} catch {}
|
|
531
|
+
|
|
532
|
+
if (!envLines.some((l) => l.startsWith("AWS_PROFILE="))) {
|
|
533
|
+
envLines.push(`AWS_PROFILE=${existingEnv.AWS_PROFILE ?? '"default"'}`);
|
|
534
|
+
}
|
|
535
|
+
if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
|
|
536
|
+
envLines.push(`AWS_DEFAULT_REGION=${existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n");
|
|
541
|
+
ok(`~/.roundhouse/env${opts.psst ? " (non-secret config only)" : ""}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
|
|
545
|
+
step("⑦", "Pairing with Telegram...");
|
|
546
|
+
|
|
547
|
+
// Skip if chat IDs already known
|
|
548
|
+
if (opts.notifyChatIds.length > 0) {
|
|
549
|
+
ok(`Using provided notify chat IDs: ${opts.notifyChatIds.join(", ")}`);
|
|
550
|
+
|
|
551
|
+
// Send test message
|
|
552
|
+
for (const chatId of opts.notifyChatIds) {
|
|
553
|
+
try {
|
|
554
|
+
await sendMessage(opts.botToken, chatId, "✅ Roundhouse setup complete! Gateway is starting.");
|
|
555
|
+
ok(`Sent test message to chat ${chatId}`);
|
|
556
|
+
} catch {
|
|
557
|
+
warn(`Could not send message to chat ${chatId}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Skip if existing config already has notifyChatIds
|
|
564
|
+
if (!opts.force) {
|
|
565
|
+
try {
|
|
566
|
+
const existing = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
567
|
+
const existingIds = existing.chat?.notifyChatIds ?? [];
|
|
568
|
+
if (existingIds.length > 0) {
|
|
569
|
+
ok(`Already paired (chat IDs: ${existingIds.join(", ")})`);
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
} catch {}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Skip if non-interactive
|
|
576
|
+
if (opts.nonInteractive) {
|
|
577
|
+
warn("Skipping pairing (--non-interactive)");
|
|
578
|
+
warn("Startup notifications won't work until paired.");
|
|
579
|
+
warn("Run 'roundhouse pair' later to pair.");
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const result = await pairTelegram(opts.botToken, botInfo.username, opts.users, 300_000, log);
|
|
584
|
+
|
|
585
|
+
if (result) {
|
|
586
|
+
ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
|
|
587
|
+
return result;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
warn("Pairing timed out.");
|
|
591
|
+
warn("Run 'roundhouse pair' later to pair.");
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function stepRegisterCommands(opts: SetupOptions): Promise<void> {
|
|
596
|
+
step("⑧", "Registering bot commands...");
|
|
597
|
+
await registerBotCommands(opts.botToken);
|
|
598
|
+
ok(`${BOT_COMMANDS.length} commands registered with Telegram`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function stepInstallSystemd(opts: SetupOptions): Promise<void> {
|
|
602
|
+
step("⑨", "Installing systemd service...");
|
|
603
|
+
|
|
604
|
+
if (!opts.systemd) {
|
|
605
|
+
ok("Skipped (--no-systemd)");
|
|
606
|
+
log(" Run manually: roundhouse start");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (platform() !== "linux") {
|
|
611
|
+
warn(`Systemd not available (${platform()})`);
|
|
612
|
+
log(" Run manually: roundhouse start");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Check sudo
|
|
617
|
+
const hasSudo = spawnSync("sudo", ["-n", "true"], { stdio: "pipe" }).status === 0;
|
|
618
|
+
if (!hasSudo) {
|
|
619
|
+
warn("No passwordless sudo — cannot install systemd service");
|
|
620
|
+
log(" Run manually: roundhouse start");
|
|
621
|
+
log(" Or install with: sudo roundhouse install");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Resolve paths
|
|
626
|
+
const roundhouseBin = whichSync("roundhouse") ?? resolve(dirname(process.execPath), "roundhouse");
|
|
627
|
+
const psstBin = opts.psst ? whichSync("psst") : null;
|
|
628
|
+
const nodeBin = process.execPath;
|
|
629
|
+
const nodeBinDir = dirname(nodeBin);
|
|
630
|
+
const user = process.env.USER || process.env.LOGNAME;
|
|
631
|
+
if (!user) {
|
|
632
|
+
warn("Cannot determine current user ($USER not set). Skipping systemd.");
|
|
633
|
+
log(" Run manually: roundhouse start");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Guard against newline injection in interpolated values
|
|
638
|
+
const unitPaths = { user, roundhouseBin, nodeBin, nodeBinDir, psstBin, home: homedir(), cwd: opts.cwd };
|
|
639
|
+
for (const [key, val] of Object.entries(unitPaths)) {
|
|
640
|
+
if (val && /[\n\r]/.test(val)) {
|
|
641
|
+
warn(`Unsafe value for ${key} (contains newline). Skipping systemd.`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Build ExecStart
|
|
647
|
+
let execStart: string;
|
|
648
|
+
if (psstBin) {
|
|
649
|
+
execStart = `${psstBin} run ${nodeBin} ${roundhouseBin} start`;
|
|
650
|
+
} else {
|
|
651
|
+
execStart = `${nodeBin} ${roundhouseBin} start`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Always include env file for non-secret config (AWS_PROFILE, etc)
|
|
655
|
+
// When using psst, ExecStart wraps with `psst run` to inject secrets
|
|
656
|
+
const unit = `[Unit]
|
|
657
|
+
Description=Roundhouse Chat Gateway
|
|
658
|
+
After=network.target
|
|
659
|
+
|
|
660
|
+
[Service]
|
|
661
|
+
Type=simple
|
|
662
|
+
User=${user}
|
|
663
|
+
WorkingDirectory=${homedir()}
|
|
664
|
+
ExecStart=${execStart}
|
|
665
|
+
Restart=on-failure
|
|
666
|
+
RestartSec=5
|
|
667
|
+
EnvironmentFile=-${ENV_PATH}
|
|
668
|
+
Environment=ROUNDHOUSE_CONFIG=${CONFIG_PATH}
|
|
669
|
+
Environment=NODE_ENV=production
|
|
670
|
+
Environment=PATH=${nodeBinDir}:/usr/local/bin:/usr/bin:/bin
|
|
671
|
+
Environment=HOME=${homedir()}
|
|
672
|
+
|
|
673
|
+
[Install]
|
|
674
|
+
WantedBy=multi-user.target
|
|
675
|
+
`;
|
|
676
|
+
|
|
677
|
+
// Write to tmp, then sudo cp
|
|
678
|
+
const tmpPath = resolve(ROUNDHOUSE_DIR, `roundhouse.service.tmp.${randomBytes(4).toString("hex")}`);
|
|
679
|
+
const servicePath = `/etc/systemd/system/roundhouse.service`;
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
await writeFile(tmpPath, unit, { mode: 0o600 });
|
|
683
|
+
execFileSync("sudo", ["-n", "cp", tmpPath, servicePath], { stdio: "pipe" });
|
|
684
|
+
await unlink(tmpPath);
|
|
685
|
+
execFileSync("sudo", ["-n", "systemctl", "daemon-reload"], { stdio: "pipe" });
|
|
686
|
+
execFileSync("sudo", ["-n", "systemctl", "enable", "roundhouse"], { stdio: "pipe" });
|
|
687
|
+
execFileSync("sudo", ["-n", "systemctl", "start", "roundhouse"], { stdio: "pipe" });
|
|
688
|
+
ok("roundhouse.service enabled and started");
|
|
689
|
+
} catch (err: any) {
|
|
690
|
+
try { await unlink(tmpPath); } catch {}
|
|
691
|
+
warn(`Systemd install failed: ${err.message}`);
|
|
692
|
+
log(" Run manually: roundhouse start");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function stepPostflight(): Promise<void> {
|
|
697
|
+
step("⑩", "Postflight checks...");
|
|
698
|
+
|
|
699
|
+
if (platform() === "linux") {
|
|
700
|
+
const isActive = execSafe("systemctl", ["is-active", "roundhouse"], { silent: true });
|
|
701
|
+
if (isActive === "active") {
|
|
702
|
+
const pid = execSafe("systemctl", ["show", "-p", "MainPID", "--value", "roundhouse"], { silent: true });
|
|
703
|
+
ok(`Service active (PID ${pid})`);
|
|
704
|
+
} else {
|
|
705
|
+
warn("Service not active — check: roundhouse logs");
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (await fileExists(CONFIG_PATH)) {
|
|
710
|
+
ok("Config readable");
|
|
711
|
+
} else {
|
|
712
|
+
warn(`Config missing: ${CONFIG_PATH}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Optional checks
|
|
716
|
+
if (!whichSync("ffmpeg")) {
|
|
717
|
+
warn("ffmpeg not found (install for voice support)");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── Orchestrator ─────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
export async function cmdSetup(argv: string[]): Promise<void> {
|
|
724
|
+
let opts: SetupOptions;
|
|
725
|
+
try {
|
|
726
|
+
opts = parseSetupArgs(argv);
|
|
727
|
+
} catch (err: any) {
|
|
728
|
+
console.error(`\n❌ ${err.message}\n`);
|
|
729
|
+
printSetupHelp();
|
|
730
|
+
process.exit(1);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (opts.dryRun) {
|
|
734
|
+
printDryRun(opts);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
log("\n🔧 Roundhouse Setup");
|
|
739
|
+
log("━━━━━━━━━━━━━━━━━━━");
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
// Phase 1: Validate (no mutations)
|
|
743
|
+
await stepPreflight(opts);
|
|
744
|
+
const botInfo = await stepValidateToken(opts);
|
|
745
|
+
await stepStopGateway();
|
|
746
|
+
|
|
747
|
+
// Phase 2: Install packages
|
|
748
|
+
await stepInstallPackages(opts);
|
|
749
|
+
|
|
750
|
+
// Phase 3: Store secrets
|
|
751
|
+
await stepStoreSecrets(opts, botInfo);
|
|
752
|
+
|
|
753
|
+
// Phase 4: Pair (before config, so we can include chat ID)
|
|
754
|
+
const pairResult = await stepPair(opts, botInfo);
|
|
755
|
+
|
|
756
|
+
// Phase 5: Write config (includes pair data)
|
|
757
|
+
await stepConfigure(opts, botInfo, pairResult);
|
|
758
|
+
|
|
759
|
+
// Phase 6: Remote setup
|
|
760
|
+
await stepRegisterCommands(opts);
|
|
761
|
+
|
|
762
|
+
// Phase 7: Service
|
|
763
|
+
await stepInstallSystemd(opts);
|
|
764
|
+
|
|
765
|
+
// Phase 8: Verify
|
|
766
|
+
await stepPostflight();
|
|
767
|
+
|
|
768
|
+
// Final message
|
|
769
|
+
const warnings = !opts.notifyChatIds.length && !pairResult;
|
|
770
|
+
log("\n━━━━━━━━━━━━━━━━━━━");
|
|
771
|
+
if (warnings) {
|
|
772
|
+
log("⚠️ Installed, action required:");
|
|
773
|
+
log(` • Not paired — run: roundhouse pair`);
|
|
774
|
+
} else {
|
|
775
|
+
log("✅ Roundhouse is running!");
|
|
776
|
+
}
|
|
777
|
+
log(` Bot: @${botInfo.username}`);
|
|
778
|
+
log(` Memory: ${opts.extensions.some((e) => e.includes("pi-memory")) ? "agent-managed" : "roundhouse-managed"}`);
|
|
779
|
+
log(` Secrets: ${opts.psst ? "psst vault (encrypted)" : "~/.roundhouse/env (plaintext)"}`);
|
|
780
|
+
log(` Send /status to @${botInfo.username} on Telegram.\n`);
|
|
781
|
+
} catch (err: any) {
|
|
782
|
+
log("\n━━━━━━━━━━━━━━━━━━━");
|
|
783
|
+
log(`❌ Setup failed: ${err.message}`);
|
|
784
|
+
log(" Partial changes may have been applied.");
|
|
785
|
+
log(" Re-run setup to complete, or run: roundhouse doctor\n");
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── Pair command ─────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
export async function cmdPair(argv: string[]): Promise<void> {
|
|
793
|
+
// Load token from psst, env, or flag
|
|
794
|
+
let token = "";
|
|
795
|
+
let users: string[] = [];
|
|
796
|
+
|
|
797
|
+
for (let i = 0; i < argv.length; i++) {
|
|
798
|
+
if (argv[i] === "--bot-token" && argv[i + 1]) token = argv[++i];
|
|
799
|
+
else if (argv[i] === "--user" && argv[i + 1]) users.push(argv[++i].replace(/^@/, ""));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Try env
|
|
803
|
+
if (!token) token = process.env.TELEGRAM_BOT_TOKEN ?? "";
|
|
804
|
+
|
|
805
|
+
// Try psst
|
|
806
|
+
if (!token) {
|
|
807
|
+
token = execSafe("psst", ["get", "TELEGRAM_BOT_TOKEN"], { silent: true });
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Try existing env file
|
|
811
|
+
if (!token) {
|
|
812
|
+
try {
|
|
813
|
+
const envContent = await readFile(ENV_PATH, "utf8");
|
|
814
|
+
const match = envContent.match(/TELEGRAM_BOT_TOKEN=["']?([^"'\n]+)["']?/);
|
|
815
|
+
if (match) token = match[1];
|
|
816
|
+
} catch {}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (!token) {
|
|
820
|
+
console.error("No bot token found. Provide via --bot-token, TELEGRAM_BOT_TOKEN env, or psst vault.");
|
|
821
|
+
process.exit(1);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Load users from config if not provided
|
|
825
|
+
if (users.length === 0) {
|
|
826
|
+
try {
|
|
827
|
+
const config = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
828
|
+
users = config.chat?.allowedUsers ?? [];
|
|
829
|
+
} catch {}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (users.length === 0) {
|
|
833
|
+
console.error("No users specified. Provide --user USERNAME or configure allowedUsers in gateway config.");
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
log("\n🔗 Roundhouse Pairing\n");
|
|
838
|
+
|
|
839
|
+
const botInfo = await validateBotToken(token);
|
|
840
|
+
ok(`Bot: @${botInfo.username}`);
|
|
841
|
+
|
|
842
|
+
const result = await pairTelegram(token, botInfo.username, users, 300_000, log);
|
|
843
|
+
|
|
844
|
+
if (!result) {
|
|
845
|
+
log("\n⚠ Pairing timed out. Try again: roundhouse pair\n");
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
|
|
850
|
+
|
|
851
|
+
// Update config
|
|
852
|
+
try {
|
|
853
|
+
const config = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
854
|
+
if (!config.chat) config.chat = {};
|
|
855
|
+
const existingUserIds: number[] = config.chat.allowedUserIds ?? [];
|
|
856
|
+
const existingNotifyIds: number[] = (config.chat.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n));
|
|
857
|
+
|
|
858
|
+
if (!existingUserIds.includes(result.userId)) existingUserIds.push(result.userId);
|
|
859
|
+
if (!existingNotifyIds.includes(result.chatId)) existingNotifyIds.push(result.chatId);
|
|
860
|
+
|
|
861
|
+
config.chat.allowedUserIds = existingUserIds;
|
|
862
|
+
config.chat.notifyChatIds = existingNotifyIds;
|
|
863
|
+
|
|
864
|
+
await atomicWriteJson(CONFIG_PATH, config);
|
|
865
|
+
ok("Config updated with chat ID");
|
|
866
|
+
} catch {
|
|
867
|
+
warn("Could not update config — add notifyChatIds manually");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
log("\n✅ Paired! Restart gateway to apply: roundhouse restart\n");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ── Dry run ──────────────────────────────────────────
|
|
874
|
+
|
|
875
|
+
function printDryRun(opts: SetupOptions): void {
|
|
876
|
+
log("\n🔧 Roundhouse Setup (DRY RUN)");
|
|
877
|
+
log("━━━━━━━━━━━━━━━━━━━\n");
|
|
878
|
+
log("Would validate Telegram token");
|
|
879
|
+
log("Would stop existing gateway (if running)");
|
|
880
|
+
log(`Would install: npm install -g @inceptionstack/roundhouse`);
|
|
881
|
+
log(`Would install: npm install -g @mariozechner/pi-coding-agent`);
|
|
882
|
+
if (opts.psst) {
|
|
883
|
+
log(`Would install: npm install -g psst-cli`);
|
|
884
|
+
log(`Would initialize psst vault`);
|
|
885
|
+
log(`Would install: pi-psst extension`);
|
|
886
|
+
log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
|
|
887
|
+
}
|
|
888
|
+
for (const ext of opts.extensions) log(`Would install extension: ${ext}`);
|
|
889
|
+
log(`Would configure: ~/.pi/agent/settings.json`);
|
|
890
|
+
log(` Set defaultProvider: ${opts.provider}`);
|
|
891
|
+
log(` Set defaultModel: ${opts.model}`);
|
|
892
|
+
log(`Would write: ~/.roundhouse/gateway.config.json`);
|
|
893
|
+
log(`Would write: ~/.roundhouse/env${opts.psst ? " (non-secret config only)" : ""}`);
|
|
894
|
+
log(`Would register ${BOT_COMMANDS.length} bot commands`);
|
|
895
|
+
if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
|
|
896
|
+
log(`Would pair via Telegram (interactive)`);
|
|
897
|
+
}
|
|
898
|
+
if (opts.systemd) log(`Would install systemd service`);
|
|
899
|
+
log("\nNo changes made.\n");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ── Help ─────────────────────────────────────────────
|
|
903
|
+
|
|
904
|
+
function printSetupHelp(): void {
|
|
905
|
+
console.log(`
|
|
906
|
+
Usage:
|
|
907
|
+
TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME
|
|
908
|
+
roundhouse setup --bot-token TOKEN --user USERNAME [options]
|
|
909
|
+
|
|
910
|
+
Required:
|
|
911
|
+
--user <username> Telegram username (repeatable, strips @)
|
|
912
|
+
|
|
913
|
+
Token (one required):
|
|
914
|
+
TELEGRAM_BOT_TOKEN env Preferred — not in shell history
|
|
915
|
+
--bot-token <token> Fallback for scripts
|
|
916
|
+
|
|
917
|
+
Agent:
|
|
918
|
+
--provider <provider> AI provider (default: amazon-bedrock)
|
|
919
|
+
--model <model> AI model (default: us.anthropic.claude-opus-4-6-v1)
|
|
920
|
+
--extension <pkg> Pi extension (repeatable)
|
|
921
|
+
--cwd <path> Agent working directory (default: ~)
|
|
922
|
+
|
|
923
|
+
Channel:
|
|
924
|
+
--notify-chat <id> Telegram chat ID (repeatable, skips pairing)
|
|
925
|
+
|
|
926
|
+
Service:
|
|
927
|
+
--no-systemd Skip systemd install
|
|
928
|
+
--no-voice Disable voice/STT
|
|
929
|
+
--no-psst Skip psst, use plaintext env file
|
|
930
|
+
|
|
931
|
+
Behavior:
|
|
932
|
+
--non-interactive No pairing, no prompts
|
|
933
|
+
--force Overwrite existing configs
|
|
934
|
+
--dry-run Preview without changes
|
|
935
|
+
`);
|
|
936
|
+
}
|