@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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System requirement checks: Node, npm, pip3, ffmpeg, ffprobe, whisper
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DoctorCheck, DoctorContext, DoctorCheckResult } from "../types";
|
|
6
|
+
import { which, getVersion, run } from "../shell";
|
|
7
|
+
|
|
8
|
+
function sysCheck(id: string, name: string, fn: (ctx: DoctorContext) => Promise<DoctorCheckResult>): DoctorCheck {
|
|
9
|
+
return { id, category: "system", name, run: fn };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const systemChecks: DoctorCheck[] = [
|
|
13
|
+
sysCheck("node", "Node.js", async () => {
|
|
14
|
+
const ver = await getVersion("node");
|
|
15
|
+
if (!ver) return { id: "node", category: "system", name: "Node.js", status: "fail", summary: "not found on PATH" };
|
|
16
|
+
const major = parseInt(ver.replace("v", ""));
|
|
17
|
+
return {
|
|
18
|
+
id: "node", category: "system", name: "Node.js", summary: ver,
|
|
19
|
+
status: major >= 20 ? "pass" : "warn",
|
|
20
|
+
details: major < 20 ? ["Node.js 20+ recommended"] : undefined,
|
|
21
|
+
};
|
|
22
|
+
}),
|
|
23
|
+
|
|
24
|
+
sysCheck("npm", "npm", async () => {
|
|
25
|
+
const ver = await getVersion("npm");
|
|
26
|
+
return {
|
|
27
|
+
id: "npm", category: "system", name: "npm", summary: ver ?? "not found",
|
|
28
|
+
status: ver ? "pass" : "fail",
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
sysCheck("pip3", "pip3", async () => {
|
|
33
|
+
const ver = await getVersion("pip3");
|
|
34
|
+
return {
|
|
35
|
+
id: "pip3", category: "system", name: "pip3", summary: ver ? ver.split(" ")[1] ?? ver : "not found",
|
|
36
|
+
status: ver ? "pass" : "warn",
|
|
37
|
+
details: !ver ? ["Needed for whisper STT auto-install"] : undefined,
|
|
38
|
+
};
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
sysCheck("ffmpeg", "ffmpeg", async (ctx) => {
|
|
42
|
+
const path = await which("ffmpeg");
|
|
43
|
+
if (path) {
|
|
44
|
+
const ver = await run("ffmpeg", ["-version"]);
|
|
45
|
+
const v = ver?.split("\n")[0]?.match(/version\s+([\S]+)/)?.[1] ?? "unknown";
|
|
46
|
+
return { id: "ffmpeg", category: "system", name: "ffmpeg", status: "pass", summary: v };
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
id: "ffmpeg", category: "system", name: "ffmpeg", status: "warn", summary: "not found",
|
|
50
|
+
details: ["Needed for voice audio conversion"],
|
|
51
|
+
fix: { description: "Install ffmpeg", command: "sudo dnf install -y ffmpeg || sudo apt-get install -y ffmpeg" },
|
|
52
|
+
};
|
|
53
|
+
}),
|
|
54
|
+
|
|
55
|
+
sysCheck("ffprobe", "ffprobe", async () => {
|
|
56
|
+
const path = await which("ffprobe");
|
|
57
|
+
return {
|
|
58
|
+
id: "ffprobe", category: "system", name: "ffprobe", summary: path ? "available" : "not found",
|
|
59
|
+
status: path ? "pass" : "warn",
|
|
60
|
+
details: !path ? ["Needed for audio duration checks"] : undefined,
|
|
61
|
+
};
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
sysCheck("whisper", "whisper", async (ctx) => {
|
|
65
|
+
// Check same paths as the whisper provider
|
|
66
|
+
const { access: acc, constants: c } = await import("node:fs/promises");
|
|
67
|
+
const { join: j } = await import("node:path");
|
|
68
|
+
const { homedir: h } = await import("node:os");
|
|
69
|
+
const paths = [j(h(), ".local", "bin", "whisper"), "/usr/local/bin/whisper", "/usr/bin/whisper"];
|
|
70
|
+
for (const p of paths) {
|
|
71
|
+
try { await acc(p, c.X_OK); return { id: "whisper", category: "system", name: "whisper", status: "pass", summary: p }; } catch {}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
id: "whisper", category: "system", name: "whisper", status: "warn", summary: "not found",
|
|
75
|
+
details: ["Needed for voice message transcription"],
|
|
76
|
+
fix: {
|
|
77
|
+
description: "Install whisper",
|
|
78
|
+
command: "pip3 install --user openai-whisper",
|
|
79
|
+
run: async () => {
|
|
80
|
+
const result = await run("pip3", ["install", "--user", "openai-whisper"], 300000);
|
|
81
|
+
return result !== null;
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}),
|
|
86
|
+
];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Systemd service checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DoctorCheck } from "../types";
|
|
6
|
+
import { run, runLoose } from "../shell";
|
|
7
|
+
|
|
8
|
+
/** Redact known secret patterns from log lines */
|
|
9
|
+
function redactSecrets(line: string): string {
|
|
10
|
+
return line
|
|
11
|
+
.replace(/\b\d{8,}:[A-Za-z0-9_-]{20,}\b/g, "[REDACTED:TOKEN]")
|
|
12
|
+
.replace(/\b(sk-|pk-|key-|sk-proj-|sk-ant-)[A-Za-z0-9_-]{10,}\b/g, "[REDACTED:KEY]")
|
|
13
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]{20,}/gi, "Bearer [REDACTED]")
|
|
14
|
+
.replace(/(https?:\/\/)[^:]+:[^@]+@/g, "$1[REDACTED]@");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const systemdChecks: DoctorCheck[] = [
|
|
18
|
+
{
|
|
19
|
+
id: "systemd-unit", category: "systemd", name: "Service unit",
|
|
20
|
+
async run(ctx) {
|
|
21
|
+
const result = await run("systemctl", ["cat", ctx.serviceName]);
|
|
22
|
+
return {
|
|
23
|
+
id: "systemd-unit", category: "systemd", name: "Service unit",
|
|
24
|
+
status: result ? "pass" : "warn",
|
|
25
|
+
summary: result ? "installed" : "not installed",
|
|
26
|
+
details: !result ? ["Run: roundhouse install"] : undefined,
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
id: "systemd-active", category: "systemd", name: "Service status",
|
|
33
|
+
async run(ctx) {
|
|
34
|
+
// Use runLoose because systemctl exits non-zero for inactive/failed
|
|
35
|
+
const active = await runLoose("systemctl", ["is-active", ctx.serviceName]);
|
|
36
|
+
if (active === "active") {
|
|
37
|
+
return { id: "systemd-active", category: "systemd", name: "Service status", status: "pass", summary: "active" };
|
|
38
|
+
}
|
|
39
|
+
if (active === "inactive" || active === "failed") {
|
|
40
|
+
const enabled = await runLoose("systemctl", ["is-enabled", ctx.serviceName]);
|
|
41
|
+
return {
|
|
42
|
+
id: "systemd-active", category: "systemd", name: "Service status",
|
|
43
|
+
status: "warn", summary: active,
|
|
44
|
+
details: [
|
|
45
|
+
active === "failed" ? "Service has failed. Check: roundhouse logs" : "Service is stopped.",
|
|
46
|
+
...(enabled === "disabled" ? ["Service is disabled. Run: sudo systemctl enable roundhouse"] : []),
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// null = systemctl not available or service not found
|
|
51
|
+
return {
|
|
52
|
+
id: "systemd-active", category: "systemd", name: "Service status",
|
|
53
|
+
status: "info", summary: "not installed or systemctl unavailable",
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
id: "systemd-errors", category: "systemd", name: "Recent errors",
|
|
60
|
+
async run(ctx) {
|
|
61
|
+
const logs = await run("journalctl", ["-u", ctx.serviceName, "--since", "1 hour ago", "--no-pager", "-p", "err", "-q"]);
|
|
62
|
+
if (logs === null) {
|
|
63
|
+
return { id: "systemd-errors", category: "systemd", name: "Recent errors", status: "info", summary: "cannot read journal" };
|
|
64
|
+
}
|
|
65
|
+
const lines = logs.split("\n").filter(Boolean);
|
|
66
|
+
if (lines.length === 0) {
|
|
67
|
+
return { id: "systemd-errors", category: "systemd", name: "Recent errors", status: "pass", summary: "none in last hour" };
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
id: "systemd-errors", category: "systemd", name: "Recent errors",
|
|
71
|
+
status: "warn", summary: `${lines.length} error(s) in last hour`,
|
|
72
|
+
details: lines.slice(0, 5).map(redactSecrets),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/doctor/output.ts — Colored output for doctor
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DoctorCheckResult, DoctorStatus } from "./types";
|
|
6
|
+
|
|
7
|
+
const useColor = !process.env.NO_COLOR && process.stdout.isTTY === true;
|
|
8
|
+
const c = (code: string, text: string) => useColor ? `${code}${text}\x1b[0m` : text;
|
|
9
|
+
|
|
10
|
+
const ICONS: Record<DoctorStatus | "fixed", string> = {
|
|
11
|
+
pass: c("\x1b[32m", "[✓]"),
|
|
12
|
+
warn: c("\x1b[33m", "[!]"),
|
|
13
|
+
fail: c("\x1b[31m", "[✗]"),
|
|
14
|
+
info: c("\x1b[36m", "[-]"),
|
|
15
|
+
fixed: c("\x1b[32m", "[fixed]"),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function formatResult(r: DoctorCheckResult, verbose: boolean): string {
|
|
19
|
+
const icon = r.fixed ? ICONS.fixed : ICONS[r.status];
|
|
20
|
+
const lines: string[] = [];
|
|
21
|
+
lines.push(` ${icon} ${r.name}: ${r.summary}`);
|
|
22
|
+
|
|
23
|
+
if ((verbose || r.status === "fail" || r.status === "warn") && r.details?.length) {
|
|
24
|
+
for (const d of r.details) {
|
|
25
|
+
lines.push(` ${d}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (r.fix && !r.fixed && (r.status === "fail" || r.status === "warn")) {
|
|
30
|
+
if (r.fix.command) {
|
|
31
|
+
lines.push(` Fix: ${r.fix.command}`);
|
|
32
|
+
}
|
|
33
|
+
lines.push(` Auto-fix: ${r.fix.run ? "yes (--fix)" : "no"}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatSummary(results: DoctorCheckResult[]): string {
|
|
40
|
+
const counts = { pass: 0, warn: 0, fail: 0, info: 0, fixed: 0 };
|
|
41
|
+
for (const r of results) {
|
|
42
|
+
if (r.fixed) counts.fixed++;
|
|
43
|
+
else counts[r.status]++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
if (counts.fail) parts.push(c("\x1b[31m", `${counts.fail} failure(s)`));
|
|
48
|
+
if (counts.warn) parts.push(c("\x1b[33m", `${counts.warn} warning(s)`));
|
|
49
|
+
if (counts.fixed) parts.push(c("\x1b[32m", `${counts.fixed} fixed`));
|
|
50
|
+
if (counts.pass) parts.push(c("\x1b[32m", `${counts.pass} passed`));
|
|
51
|
+
|
|
52
|
+
return `\nDoctor found ${parts.join(", ")}.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatCategoryHeader(category: string): string {
|
|
56
|
+
const name = category.charAt(0).toUpperCase() + category.slice(1);
|
|
57
|
+
return `\n ${name}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/doctor/runner.ts — Shared doctor execution logic
|
|
3
|
+
*
|
|
4
|
+
* Used by both CLI (cmdDoctor) and gateway (/doctor command).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DoctorCheck, DoctorCheckResult, DoctorContext } from "./types";
|
|
8
|
+
import { CONFIG_PATH, SERVICE_NAME, resolveEnvFilePath, resolveConfigPath } from "../../config";
|
|
9
|
+
import { systemChecks } from "./checks/system";
|
|
10
|
+
import { configChecks } from "./checks/config";
|
|
11
|
+
import { credentialChecks } from "./checks/credentials";
|
|
12
|
+
import { agentChecks } from "./checks/agent";
|
|
13
|
+
import { systemdChecks } from "./checks/systemd";
|
|
14
|
+
import { diskChecks } from "./checks/disk";
|
|
15
|
+
import { sttChecks } from "./checks/stt";
|
|
16
|
+
|
|
17
|
+
/** Create a DoctorContext with sensible defaults */
|
|
18
|
+
export async function createDoctorContext(overrides: Partial<DoctorContext> = {}): Promise<DoctorContext> {
|
|
19
|
+
return {
|
|
20
|
+
fix: false,
|
|
21
|
+
verbose: false,
|
|
22
|
+
json: false,
|
|
23
|
+
configPath: (await resolveConfigPath()).path,
|
|
24
|
+
envFilePath: await resolveEnvFilePath(),
|
|
25
|
+
serviceName: SERVICE_NAME,
|
|
26
|
+
now: new Date(),
|
|
27
|
+
env: process.env,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ALL_CHECKS: DoctorCheck[] = [
|
|
33
|
+
...systemChecks,
|
|
34
|
+
...configChecks,
|
|
35
|
+
...credentialChecks,
|
|
36
|
+
...agentChecks,
|
|
37
|
+
...sttChecks,
|
|
38
|
+
...diskChecks,
|
|
39
|
+
...systemdChecks,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run all doctor checks and return results.
|
|
44
|
+
* If fix=true, attempts to fix fixable issues.
|
|
45
|
+
*/
|
|
46
|
+
export async function runDoctor(ctx: DoctorContext): Promise<DoctorCheckResult[]> {
|
|
47
|
+
const results: DoctorCheckResult[] = [];
|
|
48
|
+
|
|
49
|
+
for (const check of ALL_CHECKS) {
|
|
50
|
+
try {
|
|
51
|
+
const result = await check.run(ctx);
|
|
52
|
+
|
|
53
|
+
// Attempt fix if requested
|
|
54
|
+
if (ctx.fix && result.fix?.run && (result.status === "fail" || result.status === "warn")) {
|
|
55
|
+
try {
|
|
56
|
+
const success = await result.fix.run(ctx);
|
|
57
|
+
if (success) {
|
|
58
|
+
result.fixed = true;
|
|
59
|
+
result.status = "pass";
|
|
60
|
+
result.summary = `${result.summary} → fixed`;
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
result.details = [...(result.details ?? []), `Fix failed: ${(err as Error).message}`];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
results.push(result);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
results.push({
|
|
70
|
+
id: check.id,
|
|
71
|
+
category: check.category,
|
|
72
|
+
name: check.name,
|
|
73
|
+
status: "fail",
|
|
74
|
+
summary: `check crashed: ${(err as Error).message}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Format results for Telegram (Markdown) — shows every check */
|
|
83
|
+
export function formatDoctorTelegram(results: DoctorCheckResult[]): string {
|
|
84
|
+
const counts = { pass: 0, warn: 0, fail: 0, info: 0, fixed: 0 };
|
|
85
|
+
for (const r of results) {
|
|
86
|
+
if (r.fixed) counts.fixed++;
|
|
87
|
+
else counts[r.status]++;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const STATUS_ICON: Record<string, string> = {
|
|
91
|
+
pass: "✅",
|
|
92
|
+
warn: "⚠️",
|
|
93
|
+
fail: "❌",
|
|
94
|
+
info: "ℹ️",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const lines: string[] = [
|
|
98
|
+
"🩺 *Roundhouse Doctor*",
|
|
99
|
+
"",
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// Status summary line
|
|
103
|
+
const statusParts: string[] = [];
|
|
104
|
+
if (counts.fail) statusParts.push(`❌ ${counts.fail} fail`);
|
|
105
|
+
if (counts.warn) statusParts.push(`⚠️ ${counts.warn} warn`);
|
|
106
|
+
if (counts.fixed) statusParts.push(`🔧 ${counts.fixed} fixed`);
|
|
107
|
+
statusParts.push(`✅ ${counts.pass} pass`);
|
|
108
|
+
lines.push(statusParts.join(" · "));
|
|
109
|
+
|
|
110
|
+
// Group by category, show every check
|
|
111
|
+
const categories = [...new Set(results.map((r) => r.category))];
|
|
112
|
+
for (const cat of categories) {
|
|
113
|
+
const catResults = results.filter((r) => r.category === cat);
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(`*${capitalize(cat)}*`);
|
|
116
|
+
for (const r of catResults) {
|
|
117
|
+
const icon = r.fixed ? "🔧" : (STATUS_ICON[r.status] ?? "❓");
|
|
118
|
+
lines.push(`${icon} ${esc(r.name)}: ${esc(r.summary)}`);
|
|
119
|
+
|
|
120
|
+
// Show details for warnings and failures
|
|
121
|
+
if ((r.status === "fail" || r.status === "warn") && r.details?.length) {
|
|
122
|
+
for (const d of r.details.slice(0, 3)) {
|
|
123
|
+
lines.push(` ${esc(d)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (r.fix?.command && (r.status === "fail" || r.status === "warn") && !r.fixed) {
|
|
127
|
+
lines.push(` Fix: \`${esc(r.fix.command)}\``);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function capitalize(s: string): string {
|
|
136
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Escape Telegram Markdown special characters in dynamic text */
|
|
140
|
+
function esc(s: string): string {
|
|
141
|
+
return s.replace(/([\\\[\]_*`])/g, "\\$1");
|
|
142
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/doctor/shell.ts — Shell helpers for doctor checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
/** Run a command and return stdout, or null on failure */
|
|
8
|
+
export function run(cmd: string, args: string[] = [], timeoutMs = 10000): Promise<string | null> {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => {
|
|
11
|
+
resolve(err ? null : stdout.trim());
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Run a command and return stdout even if exit code is non-zero. Returns null only on spawn failure. */
|
|
17
|
+
export function runLoose(cmd: string, args: string[] = [], timeoutMs = 10000): Promise<string | null> {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
execFile(cmd, args, { timeout: timeoutMs }, (_err, stdout, _stderr) => {
|
|
20
|
+
resolve(stdout?.trim() || null);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Check if a command exists and return its path or null */
|
|
26
|
+
export async function which(cmd: string): Promise<string | null> {
|
|
27
|
+
return run("which", [cmd]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Get version from a command (e.g. node --version → "v25.9.0") */
|
|
31
|
+
export async function getVersion(cmd: string, flag = "--version"): Promise<string | null> {
|
|
32
|
+
return run(cmd, [flag]);
|
|
33
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/doctor/types.ts — Doctor check types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type DoctorStatus = "pass" | "warn" | "fail" | "info";
|
|
6
|
+
|
|
7
|
+
export type DoctorCategory =
|
|
8
|
+
| "system" | "config" | "credentials" | "agent" | "sessions"
|
|
9
|
+
| "stt" | "systemd" | "network" | "disk" | "permissions";
|
|
10
|
+
|
|
11
|
+
export interface DoctorContext {
|
|
12
|
+
fix: boolean;
|
|
13
|
+
verbose: boolean;
|
|
14
|
+
json: boolean;
|
|
15
|
+
configPath: string;
|
|
16
|
+
envFilePath: string;
|
|
17
|
+
serviceName: string;
|
|
18
|
+
now: Date;
|
|
19
|
+
env: NodeJS.ProcessEnv;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DoctorFix {
|
|
23
|
+
description: string;
|
|
24
|
+
command?: string;
|
|
25
|
+
run?: (ctx: DoctorContext) => Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DoctorCheckResult {
|
|
29
|
+
id: string;
|
|
30
|
+
category: DoctorCategory;
|
|
31
|
+
name: string;
|
|
32
|
+
status: DoctorStatus;
|
|
33
|
+
summary: string;
|
|
34
|
+
details?: string[];
|
|
35
|
+
fix?: DoctorFix;
|
|
36
|
+
fixed?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DoctorCheck {
|
|
40
|
+
id: string;
|
|
41
|
+
category: DoctorCategory;
|
|
42
|
+
name: string;
|
|
43
|
+
run(ctx: DoctorContext): Promise<DoctorCheckResult>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/doctor.ts — roundhouse doctor CLI command
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DoctorCategory } from "./doctor/types";
|
|
6
|
+
import { formatResult, formatSummary, formatCategoryHeader } from "./doctor/output";
|
|
7
|
+
import { runDoctor, createDoctorContext } from "./doctor/runner";
|
|
8
|
+
|
|
9
|
+
const CATEGORY_ORDER: DoctorCategory[] = [
|
|
10
|
+
"system", "config", "credentials", "agent", "stt", "disk", "systemd",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export async function cmdDoctor(args: string[]): Promise<void> {
|
|
14
|
+
const fix = args.includes("--fix");
|
|
15
|
+
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
16
|
+
const json = args.includes("--json");
|
|
17
|
+
|
|
18
|
+
const ctx = await createDoctorContext({ fix, verbose, json });
|
|
19
|
+
|
|
20
|
+
if (!json) {
|
|
21
|
+
console.log("\nRoundhouse Doctor\n");
|
|
22
|
+
if (fix) console.log(" Running with --fix (will attempt to fix issues)\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const results = await runDoctor(ctx);
|
|
26
|
+
|
|
27
|
+
if (json) {
|
|
28
|
+
const counts = { pass: 0, warn: 0, fail: 0, info: 0, fixed: 0 };
|
|
29
|
+
for (const r of results) {
|
|
30
|
+
if (r.fixed) counts.fixed++;
|
|
31
|
+
else counts[r.status]++;
|
|
32
|
+
}
|
|
33
|
+
console.log(JSON.stringify({ ok: counts.fail === 0, summary: counts, results }, null, 2));
|
|
34
|
+
} else {
|
|
35
|
+
for (const cat of CATEGORY_ORDER) {
|
|
36
|
+
const catResults = results.filter((r) => r.category === cat);
|
|
37
|
+
if (catResults.length === 0) continue;
|
|
38
|
+
console.log(formatCategoryHeader(cat));
|
|
39
|
+
for (const r of catResults) {
|
|
40
|
+
console.log(formatResult(r, verbose));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
console.log(formatSummary(results));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hasFail = results.some((r) => r.status === "fail" && !r.fixed);
|
|
47
|
+
process.exit(hasFail ? 1 : 0);
|
|
48
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/setup-telegram.ts — Telegram API helpers for setup
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency Telegram Bot API client using global fetch.
|
|
5
|
+
* Token is never logged — redacted in all error messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { BOT_COMMANDS } from "../commands";
|
|
10
|
+
|
|
11
|
+
// ── Types ────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface BotInfo {
|
|
14
|
+
id: number;
|
|
15
|
+
username: string;
|
|
16
|
+
firstName: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PairResult {
|
|
20
|
+
chatId: number;
|
|
21
|
+
userId: number;
|
|
22
|
+
username: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── API helper ───────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function redactToken(token: string): string {
|
|
28
|
+
if (token.length < 10) return "***";
|
|
29
|
+
return token.slice(0, 4) + "..." + token.slice(-4);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function telegramApi(
|
|
33
|
+
token: string,
|
|
34
|
+
method: string,
|
|
35
|
+
params?: Record<string, unknown>,
|
|
36
|
+
): Promise<any> {
|
|
37
|
+
const url = `https://api.telegram.org/bot${token}/${method}`;
|
|
38
|
+
const resp = await fetch(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: params ? JSON.stringify(params) : undefined,
|
|
42
|
+
});
|
|
43
|
+
const data = await resp.json() as any;
|
|
44
|
+
if (!data.ok) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Telegram API ${method} failed: ${data.description ?? "unknown error"} (token: ${redactToken(token)})`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Public functions ─────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Validate a bot token and return bot info */
|
|
55
|
+
export async function validateBotToken(token: string): Promise<BotInfo> {
|
|
56
|
+
const data = await telegramApi(token, "getMe");
|
|
57
|
+
const r = data.result;
|
|
58
|
+
return { id: r.id, username: r.username, firstName: r.first_name ?? r.username };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check if a webhook is active (conflicts with polling) */
|
|
62
|
+
export async function checkWebhook(token: string): Promise<string | null> {
|
|
63
|
+
const data = await telegramApi(token, "getWebhookInfo");
|
|
64
|
+
const url = data.result?.url;
|
|
65
|
+
return url && url.length > 0 ? url : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Register bot commands with Telegram */
|
|
69
|
+
export async function registerBotCommands(token: string): Promise<void> {
|
|
70
|
+
await telegramApi(token, "setMyCommands", { commands: BOT_COMMANDS });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Send a message to a chat */
|
|
74
|
+
export async function sendMessage(token: string, chatId: number, text: string): Promise<void> {
|
|
75
|
+
await telegramApi(token, "sendMessage", { chat_id: chatId, text });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pair with a Telegram user — wait for them to send /start <nonce>.
|
|
80
|
+
* Returns the user's chat ID and numeric user ID, or null on timeout.
|
|
81
|
+
*/
|
|
82
|
+
export async function pairTelegram(
|
|
83
|
+
token: string,
|
|
84
|
+
botUsername: string,
|
|
85
|
+
allowedUsers: string[],
|
|
86
|
+
timeoutMs = 300_000,
|
|
87
|
+
log: (msg: string) => void = console.log,
|
|
88
|
+
): Promise<PairResult | null> {
|
|
89
|
+
const nonce = `rh-${randomBytes(3).toString("hex")}`;
|
|
90
|
+
const normalizedUsers = allowedUsers.map((u) => u.replace(/^@/, "").toLowerCase());
|
|
91
|
+
|
|
92
|
+
// Clear stale updates — advance offset past existing
|
|
93
|
+
let offset = 0;
|
|
94
|
+
try {
|
|
95
|
+
const stale = await telegramApi(token, "getUpdates", { offset: -1, limit: 1 });
|
|
96
|
+
if (stale.result?.length > 0) {
|
|
97
|
+
offset = stale.result[stale.result.length - 1].update_id + 1;
|
|
98
|
+
await telegramApi(token, "getUpdates", { offset, timeout: 0 });
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// If getUpdates fails, start from 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log(` Open https://t.me/${botUsername} and send: /start ${nonce}`);
|
|
105
|
+
log(` Waiting... (Ctrl+C to skip)\n`);
|
|
106
|
+
|
|
107
|
+
const deadline = Date.now() + timeoutMs;
|
|
108
|
+
while (Date.now() < deadline) {
|
|
109
|
+
const pollTimeout = Math.min(10, Math.floor((deadline - Date.now()) / 1000));
|
|
110
|
+
if (pollTimeout <= 0) break;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const updates = await telegramApi(token, "getUpdates", { offset, timeout: pollTimeout });
|
|
114
|
+
|
|
115
|
+
for (const update of updates.result ?? []) {
|
|
116
|
+
offset = update.update_id + 1;
|
|
117
|
+
const msg = update.message;
|
|
118
|
+
if (!msg?.from?.username) continue;
|
|
119
|
+
|
|
120
|
+
const fromUser = msg.from.username.toLowerCase();
|
|
121
|
+
const text = (msg.text ?? "").trim();
|
|
122
|
+
|
|
123
|
+
// Accept /start <nonce> from allowed user (exact match)
|
|
124
|
+
if (normalizedUsers.includes(fromUser) && (text === `/start ${nonce}` || text === nonce)) {
|
|
125
|
+
// Send welcome
|
|
126
|
+
await telegramApi(token, "sendMessage", {
|
|
127
|
+
chat_id: msg.chat.id,
|
|
128
|
+
text: "✅ Roundhouse paired successfully!\n\nThe gateway is starting up. Send /status once it's ready.",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Advance offset past consumed updates only
|
|
132
|
+
await telegramApi(token, "getUpdates", { offset, timeout: 0 });
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
chatId: msg.chat.id,
|
|
136
|
+
userId: msg.from.id,
|
|
137
|
+
username: msg.from.username,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Network hiccup — retry
|
|
143
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|