@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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/service-manager.ts — Platform-specific service lifecycle management
|
|
3
|
+
*
|
|
4
|
+
* Abstracts the difference between macOS (launchd) and Linux (systemd)
|
|
5
|
+
* behind a single interface. CLI commands delegate to getServiceManager()
|
|
6
|
+
* instead of branching on process.platform in every function.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
13
|
+
import { SERVICE_NAME } from "../config";
|
|
14
|
+
|
|
15
|
+
// ── Interface ────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ServiceStatus {
|
|
18
|
+
running: boolean;
|
|
19
|
+
installed: boolean;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ServiceManager {
|
|
24
|
+
/** Start the service (load agent / start daemon) */
|
|
25
|
+
start(): Promise<{ started: boolean; message: string }>;
|
|
26
|
+
/** Stop the service */
|
|
27
|
+
stop(): Promise<{ message: string }>;
|
|
28
|
+
/** Restart the service (stop + start) */
|
|
29
|
+
restart(): Promise<{ message: string }>;
|
|
30
|
+
/** Get current service status */
|
|
31
|
+
status(): Promise<ServiceStatus>;
|
|
32
|
+
/** Tail logs (spawns a child process, returns it) */
|
|
33
|
+
logs(): void;
|
|
34
|
+
/** Uninstall the service (remove plist / unit file) */
|
|
35
|
+
uninstall(): Promise<{ message: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── LaunchdManager (macOS) ───────────────────────────
|
|
39
|
+
|
|
40
|
+
class LaunchdManager implements ServiceManager {
|
|
41
|
+
private get plistPath(): string {
|
|
42
|
+
return resolve(homedir(), "Library", "LaunchAgents", "com.inceptionstack.roundhouse.plist");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private get label(): string {
|
|
46
|
+
return "com.inceptionstack.roundhouse";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private isInstalled(): boolean {
|
|
50
|
+
return existsSync(this.plistPath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private isRunning(): boolean {
|
|
54
|
+
try {
|
|
55
|
+
const output = execFileSync("launchctl", ["list", this.label], { encoding: "utf8", stdio: "pipe" });
|
|
56
|
+
return output.includes(this.label);
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async start(): Promise<{ started: boolean; message: string }> {
|
|
63
|
+
if (!this.isInstalled()) {
|
|
64
|
+
return { started: false, message: "no-service" };
|
|
65
|
+
}
|
|
66
|
+
if (this.isRunning()) {
|
|
67
|
+
return { started: false, message: "Roundhouse is already running (LaunchAgent)." };
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
execFileSync("launchctl", ["load", this.plistPath], { stdio: "pipe" });
|
|
71
|
+
return { started: true, message: "LaunchAgent started." };
|
|
72
|
+
} catch {
|
|
73
|
+
return { started: false, message: "Failed to load LaunchAgent." };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async stop(): Promise<{ message: string }> {
|
|
78
|
+
if (!this.isInstalled()) {
|
|
79
|
+
return { message: "No LaunchAgent installed. Nothing to stop." };
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
execFileSync("launchctl", ["unload", this.plistPath], { stdio: "pipe" });
|
|
83
|
+
} catch (e: any) {
|
|
84
|
+
if (!e.message?.includes("Could not find")) {
|
|
85
|
+
return { message: `(unload warning: ${e.message?.split("\n")[0]})` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { message: "LaunchAgent stopped." };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async restart(): Promise<{ message: string }> {
|
|
92
|
+
if (!this.isInstalled()) {
|
|
93
|
+
return { message: "No LaunchAgent installed. Run: roundhouse setup --telegram" };
|
|
94
|
+
}
|
|
95
|
+
try { execFileSync("launchctl", ["unload", this.plistPath], { stdio: "pipe" }); } catch {}
|
|
96
|
+
execFileSync("launchctl", ["load", this.plistPath], { stdio: "pipe" });
|
|
97
|
+
return { message: "LaunchAgent restarted." };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async status(): Promise<ServiceStatus> {
|
|
101
|
+
if (this.isRunning()) {
|
|
102
|
+
return { running: true, installed: true, message: "Roundhouse is running (LaunchAgent)." };
|
|
103
|
+
}
|
|
104
|
+
if (this.isInstalled()) {
|
|
105
|
+
return { running: false, installed: true, message: "LaunchAgent installed but not running." };
|
|
106
|
+
}
|
|
107
|
+
return { running: false, installed: false, message: "Roundhouse is not running." };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
logs(): void {
|
|
111
|
+
const logPath = resolve(homedir(), ".roundhouse", "logs", "roundhouse.log");
|
|
112
|
+
const child = spawn("tail", ["-f", "-n", "100", logPath], { stdio: "inherit" });
|
|
113
|
+
child.on("error", () => console.log("Could not read logs. Check ~/.roundhouse/logs/"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async uninstall(): Promise<{ message: string }> {
|
|
117
|
+
if (!this.isInstalled()) {
|
|
118
|
+
return { message: "No LaunchAgent installed." };
|
|
119
|
+
}
|
|
120
|
+
try { execFileSync("launchctl", ["unload", this.plistPath], { stdio: "pipe" }); } catch {}
|
|
121
|
+
const { unlink } = await import("node:fs/promises");
|
|
122
|
+
try { await unlink(this.plistPath); } catch {}
|
|
123
|
+
return { message: "LaunchAgent removed." };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── SystemdManager (Linux) ───────────────────────────
|
|
128
|
+
|
|
129
|
+
class SystemdManager implements ServiceManager {
|
|
130
|
+
async start(): Promise<{ started: boolean; message: string }> {
|
|
131
|
+
const { isServiceInstalled, isServiceActive, systemctl } = await import("./systemd");
|
|
132
|
+
if (!isServiceInstalled()) {
|
|
133
|
+
return { started: false, message: "no-service" }; // Signal to caller: fall through to foreground
|
|
134
|
+
}
|
|
135
|
+
if (isServiceActive()) {
|
|
136
|
+
return { started: false, message: "Roundhouse is already running." };
|
|
137
|
+
}
|
|
138
|
+
systemctl("start", "Daemon started.");
|
|
139
|
+
return { started: true, message: "Daemon started." };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async stop(): Promise<{ message: string }> {
|
|
143
|
+
const { systemctl } = await import("./systemd");
|
|
144
|
+
systemctl("stop", "Daemon stopped.");
|
|
145
|
+
return { message: "Daemon stopped." };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async restart(): Promise<{ message: string }> {
|
|
149
|
+
const { systemctl } = await import("./systemd");
|
|
150
|
+
systemctl("restart", "Daemon restarted.");
|
|
151
|
+
return { message: "Daemon restarted." };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async status(): Promise<ServiceStatus> {
|
|
155
|
+
const { isServiceActive, isServiceInstalled } = await import("./systemd");
|
|
156
|
+
if (isServiceActive()) {
|
|
157
|
+
return { running: true, installed: true, message: "Roundhouse is running." };
|
|
158
|
+
}
|
|
159
|
+
if (isServiceInstalled()) {
|
|
160
|
+
return { running: false, installed: true, message: "Service installed but not running." };
|
|
161
|
+
}
|
|
162
|
+
return { running: false, installed: false, message: "Roundhouse is not running." };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
logs(): void {
|
|
166
|
+
const child = spawn("journalctl", ["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"], {
|
|
167
|
+
stdio: "inherit",
|
|
168
|
+
});
|
|
169
|
+
child.on("error", () => console.log("Could not read logs. Is the daemon installed?"));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async uninstall(): Promise<{ message: string }> {
|
|
173
|
+
const { systemctl, runSudo, SERVICE_PATH } = await import("./systemd");
|
|
174
|
+
try { systemctl("stop"); } catch {}
|
|
175
|
+
try { systemctl("disable"); } catch {}
|
|
176
|
+
try { runSudo("rm", "-f", SERVICE_PATH); } catch {}
|
|
177
|
+
runSudo("systemctl", "daemon-reload");
|
|
178
|
+
return { message: "Daemon removed." };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Factory ──────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get the appropriate service manager for the current platform.
|
|
186
|
+
*/
|
|
187
|
+
export function getServiceManager(): ServiceManager {
|
|
188
|
+
if (process.platform === "darwin") {
|
|
189
|
+
return new LaunchdManager();
|
|
190
|
+
}
|
|
191
|
+
return new SystemdManager();
|
|
192
|
+
}
|
|
@@ -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";
|