@inceptionstack/roundhouse 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/architecture.md +94 -32
- package/package.json +1 -1
- package/src/agents/kiro/kiro-adapter.ts +8 -1
- package/src/agents/pi/message-format.ts +87 -0
- package/src/agents/pi/pi-adapter.ts +33 -72
- package/src/cli/agent-command.ts +210 -0
- package/src/cli/cli.ts +63 -305
- package/src/cli/cron-commands.ts +258 -0
- package/src/cli/cron.ts +26 -267
- package/src/cli/launchd.ts +1 -1
- package/src/cli/service-manager.ts +192 -0
- package/src/cli/setup/args.ts +109 -0
- package/src/cli/setup/flows.ts +273 -0
- package/src/cli/setup/helpers.ts +66 -0
- package/src/cli/setup/index.ts +7 -0
- package/src/cli/setup/runtime.ts +109 -0
- package/src/cli/setup/steps.ts +617 -0
- package/src/cli/setup/types.ts +52 -0
- package/src/cli/setup.ts +79 -1275
- package/src/cli/shell.ts +49 -0
- package/src/cli/systemd.ts +6 -33
- package/src/config.ts +67 -53
- package/src/gateway/attachments.ts +147 -0
- package/src/gateway/commands.ts +371 -0
- package/src/gateway/helpers.ts +104 -0
- package/src/gateway/index.ts +11 -0
- package/src/gateway/streaming.ts +235 -0
- package/src/gateway.ts +212 -763
- package/src/types.ts +16 -1
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { homedir, platform } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { readFile, writeFile, mkdir, unlink, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { BOT_COMMANDS } from "../../commands";
|
|
7
|
+
import { provisionBundle, type ProvisionLog } from "../../bundle";
|
|
8
|
+
import {
|
|
9
|
+
ROUNDHOUSE_DIR,
|
|
10
|
+
CONFIG_PATH,
|
|
11
|
+
ENV_FILE_PATH as ENV_PATH,
|
|
12
|
+
fileExists,
|
|
13
|
+
} from "../../config";
|
|
14
|
+
import { type AgentDefinition } from "../../agents/registry";
|
|
15
|
+
import { envQuote, parseEnvFile } from "../env-file";
|
|
16
|
+
import { atomicWriteJson, atomicWriteText, execSafe, execOrFail } from "./helpers";
|
|
17
|
+
import { type SetupOptions, type StepLog } from "./types";
|
|
18
|
+
import {
|
|
19
|
+
whichSync,
|
|
20
|
+
systemctl,
|
|
21
|
+
isServiceActive,
|
|
22
|
+
systemctlShow,
|
|
23
|
+
resolveExecStart,
|
|
24
|
+
generateUnit,
|
|
25
|
+
writeServiceUnit,
|
|
26
|
+
hasSudoAccess,
|
|
27
|
+
} from "../systemd";
|
|
28
|
+
import {
|
|
29
|
+
validateBotToken,
|
|
30
|
+
checkWebhook,
|
|
31
|
+
registerBotCommands,
|
|
32
|
+
pairTelegram,
|
|
33
|
+
sendMessage,
|
|
34
|
+
type BotInfo,
|
|
35
|
+
type PairResult,
|
|
36
|
+
} from "../setup-telegram";
|
|
37
|
+
|
|
38
|
+
export async function stepPreflight(logger: StepLog, opts: SetupOptions, agent: AgentDefinition): Promise<void> {
|
|
39
|
+
logger.step("①", "Preflight checks...");
|
|
40
|
+
|
|
41
|
+
const nodeVer = process.version;
|
|
42
|
+
const major = parseInt(nodeVer.replace("v", ""));
|
|
43
|
+
if (major < 20) {
|
|
44
|
+
logger.fail(`Node.js ${nodeVer} — version 20+ required`);
|
|
45
|
+
throw new Error("Node.js 20+ required");
|
|
46
|
+
}
|
|
47
|
+
logger.ok(`Node.js ${nodeVer}`);
|
|
48
|
+
|
|
49
|
+
if (!whichSync("npm")) {
|
|
50
|
+
logger.fail("npm not found on PATH");
|
|
51
|
+
throw new Error("npm required");
|
|
52
|
+
}
|
|
53
|
+
logger.ok("npm available");
|
|
54
|
+
|
|
55
|
+
const dirs = [ROUNDHOUSE_DIR, ...(agent.configDirs ?? [])];
|
|
56
|
+
for (const dir of dirs) {
|
|
57
|
+
try {
|
|
58
|
+
await mkdir(dir, { recursive: true });
|
|
59
|
+
logger.ok(`Writable: ${dir.replace(homedir(), "~")}`);
|
|
60
|
+
} catch {
|
|
61
|
+
logger.fail(`Cannot create: ${dir}`);
|
|
62
|
+
throw new Error(`Cannot write to ${dir}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!(await fileExists(ENV_PATH))) {
|
|
67
|
+
const seed = [
|
|
68
|
+
"# Roundhouse environment file",
|
|
69
|
+
"# Uncomment and set values, or use: roundhouse setup",
|
|
70
|
+
"#",
|
|
71
|
+
"# TELEGRAM_BOT_TOKEN=\"your-bot-token\"",
|
|
72
|
+
"# BOT_USERNAME=\"your_bot_username\"",
|
|
73
|
+
"# ALLOWED_USERS=\"your_telegram_username\"",
|
|
74
|
+
"# AWS_PROFILE=\"default\"",
|
|
75
|
+
"# AWS_REGION=\"us-east-1\"",
|
|
76
|
+
"",
|
|
77
|
+
].join("\n");
|
|
78
|
+
await writeFile(ENV_PATH, seed, { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const dfOut = execSafe("df", ["-BG", "--output=avail", homedir()], { silent: true });
|
|
83
|
+
const match = dfOut.match(/(\d+)G/);
|
|
84
|
+
if (match) {
|
|
85
|
+
const freeGB = parseInt(match[1]);
|
|
86
|
+
if (freeGB < 1) {
|
|
87
|
+
logger.fail(`Disk: ${freeGB} GB free (need >= 1 GB)`);
|
|
88
|
+
throw new Error("Insufficient disk space");
|
|
89
|
+
}
|
|
90
|
+
logger.ok(`Disk: ${freeGB} GB free`);
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
93
|
+
|
|
94
|
+
if (opts.provider === "amazon-bedrock") {
|
|
95
|
+
const hasAws =
|
|
96
|
+
process.env.AWS_ACCESS_KEY_ID ||
|
|
97
|
+
process.env.AWS_PROFILE ||
|
|
98
|
+
await fileExists(resolve(homedir(), ".aws", "credentials")) ||
|
|
99
|
+
await fileExists(resolve(homedir(), ".aws", "config"));
|
|
100
|
+
|
|
101
|
+
let hasInstanceRole = false;
|
|
102
|
+
if (!hasAws) {
|
|
103
|
+
try {
|
|
104
|
+
const result = execSafe("curl", ["-sf", "--max-time", "2",
|
|
105
|
+
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"], { silent: true });
|
|
106
|
+
hasInstanceRole = result.length > 0;
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (hasAws) {
|
|
111
|
+
logger.ok("AWS credentials found");
|
|
112
|
+
} else if (hasInstanceRole) {
|
|
113
|
+
logger.ok("AWS credentials found (instance IAM role)");
|
|
114
|
+
} else {
|
|
115
|
+
logger.warn("AWS credentials not found — configure before first use");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const resolved = await realpath(opts.cwd);
|
|
121
|
+
const st = await stat(resolved);
|
|
122
|
+
if (!st.isDirectory()) throw new Error("not a directory");
|
|
123
|
+
opts.cwd = resolved;
|
|
124
|
+
logger.ok(`Working directory: ${resolved.replace(homedir(), "~")}`);
|
|
125
|
+
} catch {
|
|
126
|
+
logger.fail(`--cwd path invalid: ${opts.cwd}`);
|
|
127
|
+
throw new Error(`Invalid --cwd: ${opts.cwd}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function stepValidateToken(logger: StepLog, opts: SetupOptions): Promise<BotInfo> {
|
|
132
|
+
logger.step("②", "Validating Telegram bot token...");
|
|
133
|
+
|
|
134
|
+
const botInfo = await validateBotToken(opts.botToken);
|
|
135
|
+
logger.ok(`Bot: @${botInfo.username} (id: ${botInfo.id})`);
|
|
136
|
+
|
|
137
|
+
const webhook = await checkWebhook(opts.botToken);
|
|
138
|
+
if (webhook) {
|
|
139
|
+
logger.warn(`Webhook active: ${webhook}`);
|
|
140
|
+
logger.warn("Polling won't work while a webhook is set. Remove it or switch to webhook mode.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return botInfo;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function stepStopGateway(logger: StepLog): Promise<void> {
|
|
147
|
+
logger.step("④", "Checking for running gateway...");
|
|
148
|
+
|
|
149
|
+
if (platform() === "darwin") {
|
|
150
|
+
try {
|
|
151
|
+
const { isLaunchAgentRunning, PLIST_PATH } = await import("../launchd.ts");
|
|
152
|
+
if (isLaunchAgentRunning()) {
|
|
153
|
+
logger.log(" Stopping existing LaunchAgent...");
|
|
154
|
+
execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
|
|
155
|
+
logger.ok("LaunchAgent stopped");
|
|
156
|
+
} else {
|
|
157
|
+
logger.ok("No running gateway");
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
logger.ok("No running gateway");
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (platform() !== "linux") {
|
|
166
|
+
logger.ok("Skipped (not Linux or macOS)");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (isServiceActive()) {
|
|
170
|
+
logger.log(" Stopping existing gateway...");
|
|
171
|
+
try {
|
|
172
|
+
systemctl("stop");
|
|
173
|
+
logger.ok("Service stopped");
|
|
174
|
+
} catch {
|
|
175
|
+
logger.warn("Could not stop service (may need sudo). Continuing anyway.");
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
logger.ok("No running gateway");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function installAgentPackages(logger: StepLog, opts: SetupOptions, agent: AgentDefinition): Promise<void> {
|
|
183
|
+
if (opts._skipAgentInstall) {
|
|
184
|
+
logger.ok("Agent already configured — skipping package install");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const pkg of agent.packages) {
|
|
189
|
+
const label = pkg.name ?? pkg.packageName;
|
|
190
|
+
const installed = pkg.binary ? whichSync(pkg.binary) : false;
|
|
191
|
+
if (installed && !opts.force) {
|
|
192
|
+
logger.ok(`${label} (already installed)`);
|
|
193
|
+
} else {
|
|
194
|
+
logger.log(` Installing ${label}...`);
|
|
195
|
+
const args = pkg.install === "global"
|
|
196
|
+
? ["install", "-g", pkg.packageName]
|
|
197
|
+
: ["install", pkg.packageName];
|
|
198
|
+
execOrFail("npm", args, `${label} install`);
|
|
199
|
+
logger.ok(label);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function installPsst(logger: StepLog, opts: SetupOptions, agent: AgentDefinition): Promise<void> {
|
|
205
|
+
if (!opts.psst) return;
|
|
206
|
+
|
|
207
|
+
if (!whichSync("bun")) {
|
|
208
|
+
logger.log(" Installing bun runtime (required by psst)...");
|
|
209
|
+
try {
|
|
210
|
+
execFileSync("bash", ["-c", "curl -fsSL https://bun.sh/install | bash"], {
|
|
211
|
+
encoding: "utf8", stdio: "pipe", timeout: 120_000,
|
|
212
|
+
env: { ...process.env, HOME: homedir() },
|
|
213
|
+
});
|
|
214
|
+
const bunPath = resolve(homedir(), ".bun", "bin");
|
|
215
|
+
process.env.PATH = `${bunPath}:${process.env.PATH}`;
|
|
216
|
+
logger.ok("bun runtime");
|
|
217
|
+
} catch (err: any) {
|
|
218
|
+
logger.warn(`bun install failed: ${err.message}`);
|
|
219
|
+
logger.warn("psst requires bun — install manually: curl -fsSL https://bun.sh/install | bash");
|
|
220
|
+
opts.psst = false;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
logger.ok("bun runtime (already installed)");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const psstInstalled = whichSync("psst");
|
|
228
|
+
if (psstInstalled && !opts.force) {
|
|
229
|
+
logger.ok(`psst-cli (already installed)`);
|
|
230
|
+
} else {
|
|
231
|
+
logger.log(" Installing psst-cli...");
|
|
232
|
+
try {
|
|
233
|
+
execFileSync("npm", ["install", "-g", "psst-cli"], {
|
|
234
|
+
encoding: "utf8", stdio: "pipe", timeout: 120_000,
|
|
235
|
+
});
|
|
236
|
+
} catch {}
|
|
237
|
+
if (whichSync("psst")) {
|
|
238
|
+
logger.ok("psst-cli");
|
|
239
|
+
} else {
|
|
240
|
+
logger.warn("psst-cli install failed");
|
|
241
|
+
opts.psst = false;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const vaultExists = await fileExists(resolve(homedir(), ".psst", "envs"));
|
|
247
|
+
if (vaultExists) {
|
|
248
|
+
logger.ok("psst vault exists");
|
|
249
|
+
} else {
|
|
250
|
+
logger.log(" Initializing psst vault...");
|
|
251
|
+
const psstEnv = { ...process.env };
|
|
252
|
+
if (!psstEnv.PSST_PASSWORD) {
|
|
253
|
+
const psstPw = randomBytes(32).toString("base64");
|
|
254
|
+
const pwFile = resolve(ROUNDHOUSE_DIR, ".psst-password");
|
|
255
|
+
await atomicWriteText(pwFile, psstPw + "\n", 0o600);
|
|
256
|
+
psstEnv.PSST_PASSWORD = psstPw;
|
|
257
|
+
process.env.PSST_PASSWORD = psstPw;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
execFileSync("psst", ["init"], {
|
|
261
|
+
encoding: "utf8", stdio: "pipe", timeout: 30_000,
|
|
262
|
+
env: psstEnv,
|
|
263
|
+
});
|
|
264
|
+
logger.ok("psst vault initialized");
|
|
265
|
+
} catch (err: any) {
|
|
266
|
+
logger.warn(`psst vault init failed: ${err.stderr?.trim() || err.message}`);
|
|
267
|
+
try { await unlink(resolve(ROUNDHOUSE_DIR, ".psst-password")); } catch {}
|
|
268
|
+
delete process.env.PSST_PASSWORD;
|
|
269
|
+
opts.psst = false;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (agent.installExtension) {
|
|
275
|
+
logger.log(" Installing agent psst extension...");
|
|
276
|
+
try {
|
|
277
|
+
await agent.installExtension("@miclivs/pi-psst");
|
|
278
|
+
logger.ok("@miclivs/pi-psst extension");
|
|
279
|
+
} catch {
|
|
280
|
+
logger.ok("@miclivs/pi-psst extension (already installed)");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function installUserExtensions(logger: StepLog, opts: SetupOptions, agent: AgentDefinition): Promise<void> {
|
|
286
|
+
for (const ext of opts.extensions) {
|
|
287
|
+
if (!agent.installExtension) {
|
|
288
|
+
logger.fail(`--extension is not supported for agent "${agent.type}"`);
|
|
289
|
+
throw new Error(`Agent "${agent.type}" does not support extensions`);
|
|
290
|
+
}
|
|
291
|
+
logger.log(` Installing extension: ${ext}...`);
|
|
292
|
+
await agent.installExtension(ext);
|
|
293
|
+
logger.ok(ext);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function stepInstallPackages(logger: StepLog, opts: SetupOptions, agent: AgentDefinition): Promise<void> {
|
|
298
|
+
logger.step("⑤", "Installing packages...");
|
|
299
|
+
|
|
300
|
+
const rhInstalled = whichSync("roundhouse");
|
|
301
|
+
if (rhInstalled && !opts.force) {
|
|
302
|
+
logger.ok(`@inceptionstack/roundhouse (already installed)`);
|
|
303
|
+
} else {
|
|
304
|
+
logger.log(" Installing @inceptionstack/roundhouse...");
|
|
305
|
+
execOrFail("npm", ["install", "-g", "@inceptionstack/roundhouse"], "roundhouse install");
|
|
306
|
+
logger.ok("@inceptionstack/roundhouse");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
await installAgentPackages(logger, opts, agent);
|
|
310
|
+
await installPsst(logger, opts, agent);
|
|
311
|
+
await installUserExtensions(logger, opts, agent);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function stepStoreSecrets(logger: StepLog, opts: SetupOptions, botInfo: BotInfo): Promise<void> {
|
|
315
|
+
if (!opts.psst) {
|
|
316
|
+
logger.step("⑧", "Storing secrets...");
|
|
317
|
+
logger.ok("Skipped (default — use --with-psst to enable)");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
logger.step("⑧", "Storing secrets in psst...");
|
|
322
|
+
|
|
323
|
+
const secrets: [string, string][] = [
|
|
324
|
+
["TELEGRAM_BOT_TOKEN", opts.botToken],
|
|
325
|
+
["BOT_USERNAME", botInfo.username],
|
|
326
|
+
["ALLOWED_USERS", opts.users.join(",")],
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
for (const [name, value] of secrets) {
|
|
330
|
+
try {
|
|
331
|
+
execFileSync("psst", ["set", name, "--stdin"], {
|
|
332
|
+
input: value,
|
|
333
|
+
encoding: "utf8",
|
|
334
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
335
|
+
timeout: 10_000,
|
|
336
|
+
});
|
|
337
|
+
logger.ok(`${name} → psst vault`);
|
|
338
|
+
} catch {
|
|
339
|
+
try {
|
|
340
|
+
execFileSync("psst", ["set", name, "--stdin"], {
|
|
341
|
+
input: value,
|
|
342
|
+
encoding: "utf8",
|
|
343
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
344
|
+
timeout: 10_000,
|
|
345
|
+
env: { ...process.env, PSST_FORCE: "1" },
|
|
346
|
+
});
|
|
347
|
+
logger.ok(`${name} → psst vault (updated)`);
|
|
348
|
+
} catch (err: any) {
|
|
349
|
+
logger.warn(`Failed to store ${name} in psst: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function stepInstallBundle(logger: StepLog, opts: SetupOptions): Promise<void> {
|
|
356
|
+
logger.step("⑥", "Installing bundle (skills + CLI tools)...");
|
|
357
|
+
|
|
358
|
+
const bundleLog: ProvisionLog = {
|
|
359
|
+
info: (msg) => logger.log(` ${msg}`),
|
|
360
|
+
warn: (msg) => logger.warn(msg),
|
|
361
|
+
ok: (msg) => logger.ok(msg),
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
provisionBundle({ force: opts.force, log: bundleLog });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function stepConfigure(
|
|
368
|
+
logger: StepLog,
|
|
369
|
+
opts: SetupOptions,
|
|
370
|
+
botInfo: BotInfo,
|
|
371
|
+
pairResult: PairResult | null,
|
|
372
|
+
agent: AgentDefinition,
|
|
373
|
+
): Promise<void> {
|
|
374
|
+
logger.step("⑨", "Configuring...");
|
|
375
|
+
|
|
376
|
+
await mkdir(ROUNDHOUSE_DIR, { recursive: true });
|
|
377
|
+
|
|
378
|
+
if (agent.configure) {
|
|
379
|
+
await agent.configure({
|
|
380
|
+
provider: opts.provider,
|
|
381
|
+
model: opts.model,
|
|
382
|
+
cwd: opts.cwd,
|
|
383
|
+
force: opts.force,
|
|
384
|
+
psst: opts.psst,
|
|
385
|
+
extensions: opts.extensions,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let gatewayConfig: Record<string, any> = {};
|
|
390
|
+
if (!opts.force) {
|
|
391
|
+
try {
|
|
392
|
+
gatewayConfig = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
393
|
+
} catch {}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const existingUsers: string[] = gatewayConfig.chat?.allowedUsers ?? [];
|
|
397
|
+
const existingUserIds: number[] = gatewayConfig.chat?.allowedUserIds ?? [];
|
|
398
|
+
const existingNotifyIds: number[] = (gatewayConfig.chat?.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n));
|
|
399
|
+
|
|
400
|
+
const mergedUsers = [...new Set([...existingUsers, ...opts.users])];
|
|
401
|
+
const mergedUserIds = [...existingUserIds];
|
|
402
|
+
const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds])];
|
|
403
|
+
|
|
404
|
+
if (pairResult) {
|
|
405
|
+
if (!mergedUserIds.includes(pairResult.userId)) {
|
|
406
|
+
mergedUserIds.push(pairResult.userId);
|
|
407
|
+
}
|
|
408
|
+
if (!mergedNotifyIds.includes(pairResult.chatId)) {
|
|
409
|
+
mergedNotifyIds.push(pairResult.chatId);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
gatewayConfig = {
|
|
414
|
+
...gatewayConfig,
|
|
415
|
+
_version: 1,
|
|
416
|
+
agent: { ...gatewayConfig.agent, ...agent.configDefaults, type: agent.type, cwd: opts.cwd },
|
|
417
|
+
chat: {
|
|
418
|
+
...gatewayConfig.chat,
|
|
419
|
+
botUsername: botInfo.username,
|
|
420
|
+
allowedUsers: mergedUsers,
|
|
421
|
+
allowedUserIds: mergedUserIds,
|
|
422
|
+
notifyChatIds: mergedNotifyIds,
|
|
423
|
+
adapters: gatewayConfig.chat?.adapters ?? { telegram: { mode: "polling" } },
|
|
424
|
+
},
|
|
425
|
+
...(opts.voice === false ? { voice: { stt: { enabled: false } } } : {}),
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
await atomicWriteJson(CONFIG_PATH, gatewayConfig);
|
|
429
|
+
logger.ok(`~/.roundhouse/gateway.config.json`);
|
|
430
|
+
|
|
431
|
+
const envLines: string[] = [];
|
|
432
|
+
|
|
433
|
+
if (!opts.psst) {
|
|
434
|
+
envLines.push(`TELEGRAM_BOT_TOKEN=${envQuote(opts.botToken)}`);
|
|
435
|
+
envLines.push(`BOT_USERNAME=${envQuote(botInfo.username)}`);
|
|
436
|
+
envLines.push(`ALLOWED_USERS=${envQuote(opts.users.join(","))}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (opts.psst) {
|
|
440
|
+
const pwFile = resolve(ROUNDHOUSE_DIR, ".psst-password");
|
|
441
|
+
if (await fileExists(pwFile)) {
|
|
442
|
+
const pw = (await readFile(pwFile, "utf8")).trim();
|
|
443
|
+
envLines.push(`PSST_PASSWORD=${envQuote(pw)}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (opts.provider === "amazon-bedrock") {
|
|
448
|
+
let existingEnv = new Map<string, string>();
|
|
449
|
+
try {
|
|
450
|
+
existingEnv = parseEnvFile(await readFile(ENV_PATH, "utf8"));
|
|
451
|
+
} catch {}
|
|
452
|
+
const getExisting = (key: string) => existingEnv.get(key);
|
|
453
|
+
|
|
454
|
+
if (!envLines.some((l) => l.startsWith("AWS_PROFILE="))) {
|
|
455
|
+
envLines.push(`AWS_PROFILE=${getExisting("AWS_PROFILE") ?? '"default"'}`);
|
|
456
|
+
}
|
|
457
|
+
if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
|
|
458
|
+
envLines.push(`AWS_DEFAULT_REGION=${getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
|
|
459
|
+
}
|
|
460
|
+
if (!envLines.some((l) => l.startsWith("AWS_REGION="))) {
|
|
461
|
+
envLines.push(`AWS_REGION=${getExisting("AWS_REGION") ?? getExisting("AWS_DEFAULT_REGION") ?? '"us-east-1"'}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n");
|
|
466
|
+
logger.ok(`~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function stepPair(logger: StepLog, opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
|
|
470
|
+
logger.step("⑦", "Pairing with Telegram...");
|
|
471
|
+
|
|
472
|
+
if (opts.notifyChatIds.length > 0) {
|
|
473
|
+
logger.ok(`Using provided notify chat IDs: ${opts.notifyChatIds.join(", ")}`);
|
|
474
|
+
|
|
475
|
+
for (const chatId of opts.notifyChatIds) {
|
|
476
|
+
try {
|
|
477
|
+
await sendMessage(opts.botToken, chatId, "✅ Roundhouse setup complete! Gateway is starting.");
|
|
478
|
+
logger.ok(`Sent test message to chat ${chatId}`);
|
|
479
|
+
} catch {
|
|
480
|
+
logger.warn(`Could not send message to chat ${chatId}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (!opts.force) {
|
|
487
|
+
try {
|
|
488
|
+
const existing = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
489
|
+
const existingIds = existing.chat?.notifyChatIds ?? [];
|
|
490
|
+
if (existingIds.length > 0) {
|
|
491
|
+
logger.ok(`Already paired (chat IDs: ${existingIds.join(", ")})`);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
} catch {}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (opts.nonInteractive) {
|
|
498
|
+
logger.warn("Skipping pairing (--non-interactive)");
|
|
499
|
+
logger.warn("Startup notifications won't work until paired.");
|
|
500
|
+
logger.warn("Run 'roundhouse pair' later to pair.");
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const result = await pairTelegram(opts.botToken, botInfo.username, opts.users, 300_000, logger.log);
|
|
505
|
+
|
|
506
|
+
if (result) {
|
|
507
|
+
logger.ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
|
|
508
|
+
const lcUsername = result.username.toLowerCase();
|
|
509
|
+
if (!opts.users.some((u) => u.toLowerCase() === lcUsername)) {
|
|
510
|
+
opts.users.push(result.username);
|
|
511
|
+
}
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
logger.warn("Pairing timed out.");
|
|
516
|
+
logger.warn("Run 'roundhouse pair' later to pair.");
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function stepRegisterCommands(logger: StepLog, opts: SetupOptions): Promise<void> {
|
|
521
|
+
logger.step("⑩", "Registering bot commands...");
|
|
522
|
+
await registerBotCommands(opts.botToken);
|
|
523
|
+
logger.ok(`${BOT_COMMANDS.length} commands registered with Telegram`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export async function stepInstallSystemd(logger: StepLog, opts: SetupOptions): Promise<void> {
|
|
527
|
+
logger.step("⑩b", "Installing service...");
|
|
528
|
+
|
|
529
|
+
if (platform() === "darwin") {
|
|
530
|
+
try {
|
|
531
|
+
const { installLaunchAgent } = await import("../launchd.ts");
|
|
532
|
+
await installLaunchAgent();
|
|
533
|
+
logger.ok("LaunchAgent installed and loaded");
|
|
534
|
+
logger.log(" Logs: ~/.roundhouse/logs/roundhouse.log");
|
|
535
|
+
} catch (err: any) {
|
|
536
|
+
logger.warn(`LaunchAgent install failed: ${err.message}`);
|
|
537
|
+
logger.log(" Run manually: roundhouse start");
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!opts.systemd) {
|
|
543
|
+
logger.ok("Skipped (--no-systemd)");
|
|
544
|
+
logger.log(" Run manually: roundhouse start");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (platform() !== "linux") {
|
|
548
|
+
logger.warn(`Service install not supported on ${platform()}`);
|
|
549
|
+
logger.log(" Run manually: roundhouse start");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!hasSudoAccess()) {
|
|
554
|
+
logger.warn("No passwordless sudo — cannot install systemd service");
|
|
555
|
+
logger.log(" Run manually: roundhouse start");
|
|
556
|
+
logger.log(" Or install with: roundhouse setup --telegram");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const user = process.env.USER || process.env.LOGNAME;
|
|
561
|
+
if (!user) {
|
|
562
|
+
logger.warn("Cannot determine current user ($USER not set). Skipping systemd.");
|
|
563
|
+
logger.log(" Run manually: roundhouse start");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const psstBin = opts.psst ? whichSync("psst") : null;
|
|
568
|
+
const { execStart, nodeBinDir } = resolveExecStart({ psstBin });
|
|
569
|
+
const unit = generateUnit({ execStart, nodeBinDir, user });
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
await writeServiceUnit(unit);
|
|
573
|
+
systemctl("enable");
|
|
574
|
+
systemctl("start");
|
|
575
|
+
logger.ok("roundhouse.service enabled and started");
|
|
576
|
+
} catch (err: any) {
|
|
577
|
+
logger.warn(`Systemd install failed: ${err.message}`);
|
|
578
|
+
logger.log(" Run manually: roundhouse start");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export async function stepPostflight(logger: StepLog): Promise<void> {
|
|
583
|
+
logger.step("⑪", "Postflight checks...");
|
|
584
|
+
|
|
585
|
+
if (platform() === "linux") {
|
|
586
|
+
if (isServiceActive()) {
|
|
587
|
+
const pid = systemctlShow("MainPID");
|
|
588
|
+
logger.ok(`Service active (PID ${pid})`);
|
|
589
|
+
} else {
|
|
590
|
+
logger.warn("Service not active — check: roundhouse logs");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (await fileExists(CONFIG_PATH)) {
|
|
595
|
+
logger.ok("Config readable");
|
|
596
|
+
} else {
|
|
597
|
+
logger.warn(`Config missing: ${CONFIG_PATH}`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!whichSync("ffmpeg")) {
|
|
601
|
+
logger.warn("ffmpeg not found (install for voice support)");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (platform() === "linux" || process.env.ROUNDHOUSE_VOICE === "1") {
|
|
605
|
+
if (!whichSync("whisper")) {
|
|
606
|
+
logger.warn("whisper not found — STT will auto-install on first voice message");
|
|
607
|
+
logger.log(" Pre-install: pip3 install openai-whisper");
|
|
608
|
+
} else {
|
|
609
|
+
logger.ok("whisper available");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!process.env.TAVILY_API_KEY) {
|
|
614
|
+
logger.warn("TAVILY_API_KEY not set — web search extension won't work");
|
|
615
|
+
logger.log(" Get a free key at https://tavily.com and add to ~/.roundhouse/.env");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/setup/types.ts — Shared types and constants for the setup module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
// ── Types ────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface SetupOptions {
|
|
11
|
+
botToken: string;
|
|
12
|
+
users: string[];
|
|
13
|
+
provider: string;
|
|
14
|
+
model: string;
|
|
15
|
+
extensions: string[];
|
|
16
|
+
cwd: string;
|
|
17
|
+
notifyChatIds: number[];
|
|
18
|
+
systemd: boolean;
|
|
19
|
+
voice: boolean;
|
|
20
|
+
psst: boolean;
|
|
21
|
+
nonInteractive: boolean;
|
|
22
|
+
force: boolean;
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
/** Telegram-focused setup flow */
|
|
25
|
+
telegram: boolean;
|
|
26
|
+
/** Fully headless automation (no TTY prompts) */
|
|
27
|
+
headless: boolean;
|
|
28
|
+
/** QR code display mode */
|
|
29
|
+
qr: "auto" | "always" | "never";
|
|
30
|
+
/** Agent type (default: pi) */
|
|
31
|
+
agent: string;
|
|
32
|
+
/** Set by detection: skip agent package install if already configured */
|
|
33
|
+
_skipAgentInstall?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type StepStatus = "ok" | "warn" | "skip" | "fail";
|
|
37
|
+
|
|
38
|
+
/** Logger interface passed to setup step functions */
|
|
39
|
+
export interface StepLog {
|
|
40
|
+
log(msg: string): void;
|
|
41
|
+
step(n: string, label: string): void;
|
|
42
|
+
ok(msg: string): void;
|
|
43
|
+
warn(msg: string): void;
|
|
44
|
+
fail(msg: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Constants ────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export const PI_SETTINGS_PATH = resolve(homedir(), ".pi", "agent", "settings.json");
|
|
50
|
+
export const DEFAULT_PROVIDER = "amazon-bedrock";
|
|
51
|
+
export const DEFAULT_MODEL = "us.anthropic.claude-opus-4-6-v1";
|
|
52
|
+
export const EXTENSION_NAME_RE = /^@?[a-z0-9][\w.\-/]*$/i;
|