@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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * cli/shell.ts — Shared shell utility functions
3
+ *
4
+ * Platform-agnostic helpers for process execution and binary discovery.
5
+ * Used by systemd.ts, launchd.ts, and cli.ts.
6
+ */
7
+
8
+ import { execFileSync, spawnSync } from "node:child_process";
9
+
10
+ /**
11
+ * Synchronously locate a binary on PATH.
12
+ * Returns the absolute path or null if not found.
13
+ */
14
+ export function whichSync(cmd: string): string | null {
15
+ try {
16
+ return execFileSync("which", [cmd], { encoding: "utf8", stdio: "pipe" }).trim() || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Execute a command silently, returning stdout or empty string on failure.
24
+ * Never throws.
25
+ */
26
+ export function execSilent(cmd: string, args: string[]): string {
27
+ try {
28
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe" }).trim();
29
+ } catch {
30
+ return "";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Check if passwordless sudo is available.
36
+ */
37
+ export function hasSudoAccess(): boolean {
38
+ return spawnSync("sudo", ["-n", "true"], { stdio: "pipe" }).status === 0;
39
+ }
40
+
41
+ /**
42
+ * Run a command with sudo. Falls back to interactive sudo if -n fails.
43
+ */
44
+ export function runSudo(...args: string[]): void {
45
+ const result = spawnSync("sudo", ["-n", ...args], { stdio: "inherit" });
46
+ if (result.status !== 0) {
47
+ execFileSync("sudo", args, { stdio: "inherit" });
48
+ }
49
+ }
@@ -8,7 +8,7 @@
8
8
  import { homedir } from "node:os";
9
9
  import { resolve, dirname } from "node:path";
10
10
  import { writeFile, unlink } from "node:fs/promises";
11
- import { execFileSync, spawnSync } from "node:child_process";
11
+ import { execFileSync } from "node:child_process";
12
12
  import { randomBytes } from "node:crypto";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
@@ -18,45 +18,22 @@ import {
18
18
  ENV_FILE_PATH,
19
19
  SERVICE_NAME,
20
20
  } from "../config";
21
+ import { whichSync, execSilent, hasSudoAccess, runSudo } from "./shell";
22
+
23
+ // Re-export shell utilities for backward compatibility with existing callers
24
+ export { whichSync, hasSudoAccess, runSudo };
21
25
 
22
26
  const __systemdDir = dirname(fileURLToPath(import.meta.url));
23
27
 
24
28
  export const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
25
29
 
26
- // ── Shell helpers ───────────────────────────────────
27
-
28
- export function whichSync(cmd: string): string | null {
29
- try {
30
- return execFileSync("which", [cmd], { encoding: "utf8", stdio: "pipe" }).trim() || null;
31
- } catch {
32
- return null;
33
- }
34
- }
35
-
36
- function execSilent(cmd: string, args: string[]): string {
37
- try {
38
- return execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe" }).trim();
39
- } catch {
40
- return "";
41
- }
42
- }
43
-
44
- export function runSudo(...args: string[]): void {
45
- const result = spawnSync("sudo", ["-n", ...args], { stdio: "inherit" });
46
- if (result.status !== 0) {
47
- execFileSync("sudo", args, { stdio: "inherit" });
48
- }
49
- }
30
+ // ── Systemd helpers ─────────────────────────────────
50
31
 
51
32
  export function systemctl(verb: string, message?: string): void {
52
33
  runSudo("systemctl", verb, SERVICE_NAME);
53
34
  if (message) console.log(` ✅ ${message}`);
54
35
  }
55
36
 
56
- export function hasSudoAccess(): boolean {
57
- return spawnSync("sudo", ["-n", "true"], { stdio: "pipe" }).status === 0;
58
- }
59
-
60
37
  export function isServiceInstalled(): boolean {
61
38
  return execSilent("systemctl", ["list-unit-files", `${SERVICE_NAME}.service`]).includes(SERVICE_NAME);
62
39
  }
@@ -93,7 +70,6 @@ export function resolveExecStart(opts: ExecStartOptions = {}): { execStart: stri
93
70
  const base = `${nodeBin} ${roundhouseBin} run`;
94
71
  execStart = opts.psstBin ? `${opts.psstBin} run ${base}` : base;
95
72
  } else {
96
- // No global install — run CLI via tsx with 'run' subcommand
97
73
  const tsxBin = whichSync("tsx") || resolve(__systemdDir, "..", "..", "node_modules", ".bin", "tsx");
98
74
  const cliPath = resolve(__systemdDir, "cli.ts");
99
75
  const base = `${tsxBin} ${cliPath} run`;
@@ -114,7 +90,6 @@ export interface UnitOptions {
114
90
 
115
91
  /**
116
92
  * Guard against newline injection in values interpolated into the unit template.
117
- * A crafted $USER or path containing \n/\r could inject arbitrary systemd directives.
118
93
  */
119
94
  function assertSafeForUnit(label: string, value: unknown): void {
120
95
  if (typeof value !== "string") {
@@ -134,7 +109,6 @@ export function generateUnit(opts: UnitOptions): string {
134
109
  const home = homedir();
135
110
  const pathValue = `${opts.nodeBinDir}:${home}/.local/bin:/usr/local/bin:/usr/bin:/bin`;
136
111
 
137
- // Validate all interpolated values before generating the unit
138
112
  for (const [label, value] of Object.entries({
139
113
  user, execStart: opts.execStart, nodeBinDir: opts.nodeBinDir,
140
114
  envFilePath, home, configPath: CONFIG_PATH, pathValue,
@@ -168,7 +142,6 @@ WantedBy=multi-user.target
168
142
 
169
143
  /**
170
144
  * Write a systemd unit file via sudo and reload the daemon.
171
- * Uses atomic write-to-tmp + sudo cp pattern.
172
145
  */
173
146
  export async function writeServiceUnit(unitContent: string): Promise<void> {
174
147
  const tmpPath = resolve(ROUNDHOUSE_DIR, `roundhouse.service.tmp.${randomBytes(4).toString("hex")}`);
package/src/config.ts CHANGED
@@ -151,66 +151,80 @@ export async function resolveEnvFilePath(): Promise<string> {
151
151
  }
152
152
 
153
153
  export async function loadConfig(): Promise<GatewayConfig> {
154
- let config: GatewayConfig | undefined;
154
+ // Resolver chain: first successful resolution wins (Fowler: Replace Nested Conditional with Guard Clauses)
155
+ const resolvers: Array<() => Promise<GatewayConfig | null>> = [
156
+ resolveFromEnvVar,
157
+ resolveFromFlag,
158
+ resolveFromCanonicalOrLegacy,
159
+ resolveFromCwd,
160
+ ];
161
+
162
+ for (const resolver of resolvers) {
163
+ const config = await resolver();
164
+ if (config) return applyEnvOverrides(config);
165
+ }
166
+
167
+ console.log("[roundhouse] using default config + env vars");
168
+ return applyEnvOverrides(DEFAULT_CONFIG);
169
+ }
155
170
 
156
- // Check for ROUNDHOUSE_CONFIG env var (set by CLI/daemon must be valid)
171
+ // ── Config Resolvers (each returns null if not applicable) ─────
172
+
173
+ async function resolveFromEnvVar(): Promise<GatewayConfig | null> {
157
174
  const envConfig = process.env.ROUNDHOUSE_CONFIG;
158
- if (envConfig) {
159
- try {
160
- const raw = await readFile(resolve(envConfig), "utf8");
161
- console.log(`[roundhouse] loaded config from ${envConfig}`);
162
- config = JSON.parse(raw) as GatewayConfig;
163
- } catch (err: any) {
164
- console.error(`[roundhouse] failed to load config from ROUNDHOUSE_CONFIG=${envConfig}: ${err.message}`);
165
- process.exit(1);
166
- }
175
+ if (!envConfig) return null;
176
+
177
+ try {
178
+ const raw = await readFile(resolve(envConfig), "utf8");
179
+ console.log(`[roundhouse] loaded config from ${envConfig}`);
180
+ return JSON.parse(raw) as GatewayConfig;
181
+ } catch (err: any) {
182
+ console.error(`[roundhouse] failed to load config from ROUNDHOUSE_CONFIG=${envConfig}: ${err.message}`);
183
+ process.exit(1);
167
184
  }
185
+ }
168
186
 
169
- // Check for --config flag
170
- if (!config) {
171
- const configIdx = process.argv.indexOf("--config");
172
- if (configIdx !== -1 && process.argv[configIdx + 1]) {
173
- const configPath = resolve(process.argv[configIdx + 1]);
174
- try {
175
- const raw = await readFile(configPath, "utf8");
176
- console.log(`[roundhouse] loaded config from ${configPath}`);
177
- config = JSON.parse(raw) as GatewayConfig;
178
- } catch (err: any) {
179
- console.error(`[roundhouse] failed to load config from ${configPath}: ${err.message}`);
180
- process.exit(1);
181
- }
182
- }
187
+ async function resolveFromFlag(): Promise<GatewayConfig | null> {
188
+ const configIdx = process.argv.indexOf("--config");
189
+ if (configIdx === -1 || !process.argv[configIdx + 1]) return null;
190
+
191
+ const configPath = resolve(process.argv[configIdx + 1]);
192
+ try {
193
+ const raw = await readFile(configPath, "utf8");
194
+ console.log(`[roundhouse] loaded config from ${configPath}`);
195
+ return JSON.parse(raw) as GatewayConfig;
196
+ } catch (err: any) {
197
+ console.error(`[roundhouse] failed to load config from ${configPath}: ${err.message}`);
198
+ process.exit(1);
183
199
  }
200
+ }
184
201
 
185
- // Try canonical path, then legacy, then cwd
186
- if (!config) {
187
- const resolved = await resolveConfigPath();
188
- try {
189
- const raw = await readFile(resolved.path, "utf8");
190
- console.log(`[roundhouse] loaded config from ${resolved.path}`);
191
- config = JSON.parse(raw) as GatewayConfig;
192
- } catch (err: any) {
193
- // File not found → try cwd. Parse error on existing file fail fast.
194
- if (err.code !== "ENOENT") {
195
- console.error(`[roundhouse] failed to parse config at ${resolved.path}: ${err.message}`);
196
- process.exit(1);
197
- }
198
- // Try cwd (with security warning)
199
- try {
200
- const cwdPath = resolve(process.cwd(), "gateway.config.json");
201
- const raw = await readFile(cwdPath, "utf8");
202
- console.warn(`[roundhouse] ⚠️ loaded gateway.config.json from cwd (${cwdPath}) — consider using ~/.roundhouse/gateway.config.json instead`);
203
- config = JSON.parse(raw) as GatewayConfig;
204
- } catch (cwdErr: any) {
205
- if (cwdErr.code !== "ENOENT") {
206
- console.error(`[roundhouse] failed to parse config at ./gateway.config.json: ${cwdErr.message}`);
207
- process.exit(1);
208
- }
209
- console.log("[roundhouse] using default config + env vars");
210
- config = DEFAULT_CONFIG;
211
- }
202
+ async function resolveFromCanonicalOrLegacy(): Promise<GatewayConfig | null> {
203
+ const resolved = await resolveConfigPath();
204
+ try {
205
+ const raw = await readFile(resolved.path, "utf8");
206
+ console.log(`[roundhouse] loaded config from ${resolved.path}`);
207
+ return JSON.parse(raw) as GatewayConfig;
208
+ } catch (err: any) {
209
+ if (err.code !== "ENOENT") {
210
+ console.error(`[roundhouse] failed to parse config at ${resolved.path}: ${err.message}`);
211
+ process.exit(1);
212
212
  }
213
+ return null;
213
214
  }
215
+ }
214
216
 
215
- return applyEnvOverrides(config);
217
+ async function resolveFromCwd(): Promise<GatewayConfig | null> {
218
+ const cwdPath = resolve(process.cwd(), "gateway.config.json");
219
+ try {
220
+ const raw = await readFile(cwdPath, "utf8");
221
+ console.warn(`[roundhouse] ⚠️ loaded gateway.config.json from cwd (${cwdPath}) — consider using ~/.roundhouse/gateway.config.json instead`);
222
+ return JSON.parse(raw) as GatewayConfig;
223
+ } catch (err: any) {
224
+ if (err.code !== "ENOENT") {
225
+ console.error(`[roundhouse] failed to parse config at ./gateway.config.json: ${err.message}`);
226
+ process.exit(1);
227
+ }
228
+ return null;
229
+ }
216
230
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * gateway/attachments.ts — Incoming file storage for chat attachments
3
+ *
4
+ * Saves voice messages, images, files, and videos to disk.
5
+ * Each message gets its own directory under ~/.roundhouse/incoming/.
6
+ */
7
+
8
+ import { join, basename } from "node:path";
9
+ import { mkdirSync } from "node:fs";
10
+ import { writeFile } from "node:fs/promises";
11
+ import { ROUNDHOUSE_DIR } from "../config";
12
+ import { threadIdToDir, generateAttachmentId } from "../util";
13
+ import type { MessageAttachment } from "../types";
14
+
15
+ // ── Constants ────────────────────────────────────────
16
+
17
+ const INCOMING_DIR = process.env.ROUNDHOUSE_INCOMING_DIR
18
+ ?? join(ROUNDHOUSE_DIR, "incoming");
19
+
20
+ export const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB per file
21
+ export const MAX_ATTACHMENTS = 5;
22
+
23
+ const MIME_EXTENSIONS: Record<string, string> = {
24
+ "audio/ogg": ".ogg",
25
+ "audio/mpeg": ".mp3",
26
+ "audio/mp4": ".m4a",
27
+ "audio/wav": ".wav",
28
+ "audio/webm": ".webm",
29
+ "image/jpeg": ".jpg",
30
+ "image/png": ".png",
31
+ "image/webp": ".webp",
32
+ "image/gif": ".gif",
33
+ "video/mp4": ".mp4",
34
+ "application/pdf": ".pdf",
35
+ };
36
+
37
+ const VALID_MEDIA_TYPES = new Set(["audio", "image", "file", "video"]);
38
+
39
+ // ── Types ────────────────────────────────────────────
40
+
41
+ export interface AttachmentResult {
42
+ saved: MessageAttachment[];
43
+ skipped: string[];
44
+ }
45
+
46
+ // ── Helpers ──────────────────────────────────────────
47
+
48
+ /** Sanitize a filename to safe ASCII characters, capped length */
49
+ function safeName(raw: string): string {
50
+ let name = basename(raw);
51
+ name = name.replace(/[^a-zA-Z0-9._-]/g, "_");
52
+ if (name.length > 100) name = name.slice(-100);
53
+ name = name.replace(/^[-_.]+/, "");
54
+ return name || "attachment";
55
+ }
56
+
57
+ // ── Main Function ────────────────────────────────────
58
+
59
+ /**
60
+ * Save incoming attachments to disk.
61
+ * Returns saved file metadata and user-facing skip reasons.
62
+ */
63
+ export async function saveAttachments(threadId: string, attachments: any[]): Promise<AttachmentResult> {
64
+ if (!attachments?.length) return { saved: [], skipped: [] };
65
+
66
+ const skipped: string[] = [];
67
+ const toProcess = attachments.slice(0, MAX_ATTACHMENTS);
68
+ if (attachments.length > MAX_ATTACHMENTS) {
69
+ skipped.push(`${attachments.length - MAX_ATTACHMENTS} attachment(s) skipped (max ${MAX_ATTACHMENTS} per message)`);
70
+ console.warn(`[roundhouse] too many attachments (${attachments.length}), processing first ${MAX_ATTACHMENTS}`);
71
+ }
72
+
73
+ const msgDir = join(INCOMING_DIR, threadIdToDir(threadId), `${Date.now()}_${generateAttachmentId()}`);
74
+ try {
75
+ mkdirSync(msgDir, { recursive: true, mode: 0o700 });
76
+ } catch (err) {
77
+ console.error(`[roundhouse] failed to create incoming dir ${msgDir}:`, (err as Error).message);
78
+ return { saved: [], skipped: ["Failed to create storage directory"] };
79
+ }
80
+
81
+ const saved: MessageAttachment[] = [];
82
+ for (let i = 0; i < toProcess.length; i++) {
83
+ const att = toProcess[i];
84
+ try {
85
+ if (att.size && att.size > MAX_FILE_SIZE) {
86
+ const sizeMB = (att.size / 1024 / 1024).toFixed(1);
87
+ skipped.push(`${att.name ?? att.type} (${sizeMB} MB) exceeds ${MAX_FILE_SIZE / 1024 / 1024} MB limit`);
88
+ continue;
89
+ }
90
+
91
+ const data = att.data ?? (att.fetchData ? await att.fetchData() : null);
92
+ if (!data) {
93
+ console.warn(`[roundhouse] attachment has no data: ${att.name ?? att.type}`);
94
+ continue;
95
+ }
96
+
97
+ let buf: Buffer;
98
+ if (Buffer.isBuffer(data)) {
99
+ buf = data;
100
+ } else if (data instanceof Blob) {
101
+ if (data.size > MAX_FILE_SIZE) {
102
+ skipped.push(`${att.name ?? att.type} (${(data.size / 1024 / 1024).toFixed(1)} MB) exceeds size limit`);
103
+ continue;
104
+ }
105
+ buf = Buffer.from(await data.arrayBuffer());
106
+ } else if (ArrayBuffer.isView(data)) {
107
+ buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
108
+ } else if (data instanceof ArrayBuffer) {
109
+ buf = Buffer.from(data);
110
+ } else {
111
+ console.warn(`[roundhouse] unknown attachment data type, skipping: ${att.name ?? att.type}`);
112
+ continue;
113
+ }
114
+
115
+ if (buf.length > MAX_FILE_SIZE) {
116
+ skipped.push(`${att.name ?? att.type} (${(buf.length / 1024 / 1024).toFixed(1)} MB) exceeds size limit`);
117
+ continue;
118
+ }
119
+
120
+ const mime = att.mimeType ?? "application/octet-stream";
121
+ const ext = att.name
122
+ ? (att.name.includes(".") ? "" : (MIME_EXTENSIONS[mime] ?? ""))
123
+ : (MIME_EXTENSIONS[mime] ?? ".bin");
124
+ const rawName = att.name ? safeName(att.name) + ext : `${att.type ?? "file"}${ext}`;
125
+ const fileName = `${i}-${rawName}`;
126
+ const filePath = join(msgDir, fileName);
127
+
128
+ await writeFile(filePath, buf, { mode: 0o600 });
129
+
130
+ const mediaType = VALID_MEDIA_TYPES.has(att.type) ? att.type : "file";
131
+ const id = generateAttachmentId();
132
+ saved.push({
133
+ id,
134
+ mediaType,
135
+ name: rawName,
136
+ localPath: filePath,
137
+ mime,
138
+ sizeBytes: buf.length,
139
+ untrusted: true,
140
+ });
141
+ console.log(`[roundhouse] saved ${att.type} [${id}]: ${filePath} (${buf.length} bytes)`);
142
+ } catch (err) {
143
+ console.error(`[roundhouse] failed to save attachment:`, (err as Error).message);
144
+ }
145
+ }
146
+ return { saved, skipped };
147
+ }