@inceptionstack/roundhouse 0.5.1 → 0.5.3

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/src/cli/cron.ts CHANGED
@@ -1,32 +1,14 @@
1
1
  /**
2
- * cli/cron.ts — roundhouse cron CLI subcommands
2
+ * cli/cron.ts — roundhouse cron CLI dispatcher
3
+ *
4
+ * Parses args and dispatches to individual command handlers.
3
5
  */
4
6
 
5
- import { CronStore, validateJobId } from "../cron/store";
6
- import { isBuiltinJob } from "../cron/helpers";
7
- import { CronRunner } from "../cron/runner";
8
- import { validateSchedule } from "../cron/schedule";
9
- import { validateTemplate } from "../cron/template";
10
- import { parseDuration } from "../cron/durations";
11
- import type { CronJobConfig, CronSchedule } from "../cron/types";
12
- import { DEFAULT_TIMEOUT_MS, DEFAULT_TIMEZONE, VALID_NOTIFY_ON, DEFAULT_RUNS_LIMIT } from "../cron/constants";
13
- import { formatSchedule, formatRunCounts, formatJobSummary, formatJobDetail, formatRunLine, runStatusIcon, jobEnabledIcon } from "../cron/format";
14
-
15
- function rejectBuiltin(id: string): void {
16
- if (isBuiltinJob(id)) {
17
- console.error(`Job ID "${id}" is reserved for built-in jobs.`);
18
- process.exit(1);
19
- }
20
- }
21
-
22
- function validateNotifyOn(value?: string): "always" | "success" | "failure" {
23
- const v = value ?? "always";
24
- if (!(VALID_NOTIFY_ON as readonly string[]).includes(v)) {
25
- console.error(`Invalid --notify-on: "${v}". Use ${VALID_NOTIFY_ON.join(", ")}.`);
26
- process.exit(1);
27
- }
28
- return v as "always" | "success" | "failure";
29
- }
7
+ import { CronStore } from "../cron/store";
8
+ import {
9
+ cronAdd, cronList, cronShow, cronTrigger, cronRuns,
10
+ cronPause, cronResume, cronEdit, cronDelete, cronHelp,
11
+ } from "./cron-commands";
30
12
 
31
13
  function parseArgs(args: string[]): { positional: string[]; flags: Record<string, string> } {
32
14
  const positional: string[] = [];
@@ -44,6 +26,19 @@ function parseArgs(args: string[]): { positional: string[]; flags: Record<string
44
26
  return { positional, flags };
45
27
  }
46
28
 
29
+ const COMMANDS: Record<string, (store: CronStore, pos: string[], flags: Record<string, string>) => Promise<void>> = {
30
+ add: cronAdd,
31
+ list: cronList,
32
+ show: cronShow,
33
+ trigger: cronTrigger,
34
+ run: cronTrigger,
35
+ runs: cronRuns,
36
+ pause: cronPause,
37
+ resume: cronResume,
38
+ edit: cronEdit,
39
+ delete: cronDelete,
40
+ };
41
+
47
42
  export async function cmdCron(args: string[]): Promise<void> {
48
43
  const { positional, flags } = parseArgs(args);
49
44
  const sub = positional[0];
@@ -51,246 +46,10 @@ export async function cmdCron(args: string[]): Promise<void> {
51
46
  const store = new CronStore();
52
47
  await store.ensureDirs();
53
48
 
54
- switch (sub) {
55
- case "add": {
56
- const id = positional[1];
57
- if (!id) { console.error("Usage: roundhouse cron add <id> --prompt '...' --cron '...' --tz '...'"); process.exit(1); }
58
- validateJobId(id);
59
- rejectBuiltin(id);
60
-
61
- const existing = await store.getJob(id);
62
- if (existing && !flags.replace) {
63
- console.error(`Job "${id}" already exists. Use --replace to overwrite.`);
64
- process.exit(1);
65
- }
66
-
67
- const prompt = flags.prompt;
68
- if (!prompt) { console.error("--prompt is required"); process.exit(1); }
69
-
70
- // Parse schedule — only one allowed
71
- const schedCount = [flags.cron, flags.every, flags.at].filter(Boolean).length;
72
- if (schedCount > 1) { console.error("Specify only one of --cron, --every, or --at"); process.exit(1); }
73
- let schedule: CronSchedule;
74
- if (flags.cron) {
75
- schedule = { type: "cron", cron: flags.cron, tz: flags.tz ?? DEFAULT_TIMEZONE };
76
- } else if (flags.every) {
77
- schedule = { type: "interval", every: flags.every };
78
- } else if (flags.at) {
79
- schedule = { type: "once", at: flags.at, tz: flags.tz };
80
- } else {
81
- console.error("Schedule required: --cron '...', --every '...', or --at '...'");
82
- process.exit(1);
83
- }
84
-
85
- validateSchedule(schedule);
86
-
87
- // Parse vars
88
- const vars: Record<string, string> = {};
89
- if (flags.var) {
90
- for (const v of flags.var.split(",")) {
91
- const [k, ...rest] = v.split("=");
92
- if (k && rest.length) vars[k.trim()] = rest.join("=").trim();
93
- }
94
- }
95
-
96
- // Validate template
97
- const templateErrors = validateTemplate(prompt, new Set(Object.keys(vars)));
98
- if (templateErrors.length) {
99
- console.error("Template errors:");
100
- templateErrors.forEach((e) => console.error(` ${e}`));
101
- process.exit(1);
102
- }
103
-
104
- // Parse notify
105
- const notify: CronJobConfig["notify"] = {};
106
- if (flags.telegram) {
107
- notify.telegram = {
108
- chatIds: flags.telegram.split(",").map((s) => s.trim()),
109
- onlyOn: validateNotifyOn(flags["notify-on"]),
110
- };
111
- }
112
-
113
- const now = new Date().toISOString();
114
- const job: CronJobConfig = {
115
- id,
116
- enabled: true,
117
- description: flags.description,
118
- createdAt: existing?.createdAt ?? now,
119
- updatedAt: now,
120
- schedule,
121
- prompt,
122
- vars: Object.keys(vars).length ? vars : undefined,
123
- timeoutMs: flags.timeout ? parseDuration(flags.timeout) : undefined,
124
- notify: Object.keys(notify).length ? notify : undefined,
125
- };
126
-
127
- await store.writeJob(job);
128
- console.log(`✅ Cron job "${id}" ${existing ? "updated" : "created"}.`);
129
- if (flags.json) console.log(JSON.stringify(job, null, 2));
130
- break;
131
- }
132
-
133
- case "list": {
134
- const jobs = await store.listJobs();
135
- if (jobs.length === 0) {
136
- console.log("No cron jobs configured.");
137
- break;
138
- }
139
- if (flags.json) {
140
- console.log(JSON.stringify(jobs, null, 2));
141
- } else {
142
- for (const j of jobs) {
143
- const state = await store.getState(j.id);
144
- console.log(` ${formatJobSummary(j, state)}`);
145
- }
146
- }
147
- break;
148
- }
149
-
150
- case "show": {
151
- const id = positional[1];
152
- if (!id) { console.error("Usage: roundhouse cron show <id>"); process.exit(1); }
153
- const job = await store.getJob(id);
154
- if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
155
- const state = await store.getState(id);
156
- const runs = await store.listRuns(id, 5);
157
- if (flags.json) {
158
- console.log(JSON.stringify({ job, state, recentRuns: runs }, null, 2));
159
- } else {
160
- console.log(`\n${formatJobDetail(job, state, runs)}`);
161
- }
162
- break;
163
- }
164
-
165
- case "trigger":
166
- case "run": {
167
- const id = positional[1];
168
- if (!id) { console.error("Usage: roundhouse cron trigger <id>"); process.exit(1); }
169
- rejectBuiltin(id);
170
- const job = await store.getJob(id);
171
- if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
172
- console.log(`Triggering ${id}...`);
173
- const runner = new CronRunner(store);
174
- const record = await runner.runJob(job, new Date(), "manual");
175
- console.log(`\nResult: ${record.status} (${record.durationMs}ms)`);
176
- if (record.responseText) console.log(`\n${record.responseText.slice(0, 2000)}`);
177
- if (record.error) console.log(`\nError: ${record.error}`);
178
- process.exit(record.status === "completed" ? 0 : 1);
179
- break;
180
- }
181
-
182
- case "runs": {
183
- const id = positional[1];
184
- if (!id) { console.error("Usage: roundhouse cron runs <id>"); process.exit(1); }
185
- const runs = await store.listRuns(id, parseInt(flags.limit ?? String(DEFAULT_RUNS_LIMIT), 10));
186
- if (runs.length === 0) {
187
- console.log(`No runs for ${id}.`);
188
- } else if (flags.json) {
189
- console.log(JSON.stringify(runs, null, 2));
190
- } else {
191
- for (const r of runs) {
192
- console.log(` ${formatRunLine(r)} (${r.kind})`);
193
- }
194
- }
195
- break;
196
- }
197
-
198
- case "pause": {
199
- const id = positional[1];
200
- if (!id) { console.error("Usage: roundhouse cron pause <id>"); process.exit(1); }
201
- rejectBuiltin(id);
202
- const job = await store.getJob(id);
203
- if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
204
- job.enabled = false;
205
- job.updatedAt = new Date().toISOString();
206
- await store.writeJob(job);
207
- console.log(`⏸️ Job "${id}" paused.`);
208
- break;
209
- }
210
-
211
- case "resume": {
212
- const id = positional[1];
213
- if (!id) { console.error("Usage: roundhouse cron resume <id>"); process.exit(1); }
214
- rejectBuiltin(id);
215
- const job = await store.getJob(id);
216
- if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
217
- job.enabled = true;
218
- job.updatedAt = new Date().toISOString();
219
- await store.writeJob(job);
220
- console.log(`▶️ Job "${id}" resumed.`);
221
- break;
222
- }
223
-
224
- case "edit": {
225
- const id = positional[1];
226
- if (!id) { console.error("Usage: roundhouse cron edit <id> [--prompt '...'] [--cron '...'] ..."); process.exit(1); }
227
- rejectBuiltin(id);
228
- const job = await store.getJob(id);
229
- if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
230
-
231
- if (flags.prompt) job.prompt = flags.prompt;
232
- if (flags.description) job.description = flags.description;
233
- if (flags.timeout) job.timeoutMs = parseDuration(flags.timeout);
234
- // Reject multiple schedule flags
235
- const editSchedCount = [flags.cron, flags.every, flags.at].filter(Boolean).length;
236
- if (editSchedCount > 1) { console.error("Specify only one of --cron, --every, or --at"); process.exit(1); }
237
- if (flags.cron) job.schedule = { type: "cron", cron: flags.cron, tz: flags.tz ?? (job.schedule.type === "cron" ? job.schedule.tz : DEFAULT_TIMEZONE) };
238
- if (flags.every) job.schedule = { type: "interval", every: flags.every };
239
- if (flags.at) job.schedule = { type: "once", at: flags.at, tz: flags.tz };
240
- if (flags.telegram) {
241
- job.notify = { ...job.notify, telegram: { chatIds: flags.telegram.split(",").map((s) => s.trim()), onlyOn: validateNotifyOn(flags["notify-on"]) } };
242
- }
243
-
244
- validateSchedule(job.schedule);
245
- // Validate template after edit
246
- const editVars = new Set(Object.keys(job.vars ?? {}));
247
- const editErrors = validateTemplate(job.prompt, editVars);
248
- if (editErrors.length) {
249
- console.error("Template errors:");
250
- editErrors.forEach((e) => console.error(` ${e}`));
251
- process.exit(1);
252
- }
253
- job.updatedAt = new Date().toISOString();
254
- await store.writeJob(job);
255
- console.log(`✅ Job "${id}" updated.`);
256
- break;
257
- }
258
-
259
- case "delete": {
260
- const id = positional[1];
261
- if (!id) { console.error("Usage: roundhouse cron delete <id>"); process.exit(1); }
262
- rejectBuiltin(id);
263
- const job = await store.getJob(id);
264
- if (!job) { console.error(`Job not found: ${id}`); process.exit(1); }
265
- await store.deleteJob(id);
266
- console.log(`🗑️ Job "${id}" deleted.`);
267
- break;
268
- }
269
-
270
- default:
271
- console.log(`roundhouse cron <command>
272
-
273
- Commands:
274
- add <id> [flags] Create a cron job
275
- list List all jobs
276
- show <id> Show job details
277
- trigger <id> Run job now
278
- runs <id> Show run history
279
- edit <id> [flags] Edit a job
280
- pause <id> Disable a job
281
- resume <id> Enable a job
282
- delete <id> Delete a job
283
-
284
- Flags for add/edit:
285
- --prompt "..." Prompt template (required for add)
286
- --cron "..." Cron expression (e.g. "0 8 * * *")
287
- --every "..." Interval (e.g. "6h")
288
- --at "..." One-shot time (e.g. "30m" or ISO date)
289
- --tz "..." Timezone (e.g. "Asia/Jerusalem")
290
- --telegram "..." Telegram chat IDs (comma-separated)
291
- --var "k=v,..." Template variables (comma-separated)
292
- --timeout "..." Timeout (e.g. "30m")
293
- --description "..." Job description
294
- --json JSON output`);
49
+ const handler = sub && Object.hasOwn(COMMANDS, sub) ? COMMANDS[sub] : undefined;
50
+ if (handler) {
51
+ await handler(store, positional, flags);
52
+ } else {
53
+ cronHelp();
295
54
  }
296
55
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * launchd.ts — macOS launchd service management for roundhouse
3
+ *
4
+ * Generates and installs a LaunchAgent plist so roundhouse
5
+ * auto-starts on login and can be managed via launchctl.
6
+ */
7
+
8
+ import { resolve } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { writeFile, mkdir } from "node:fs/promises";
11
+ import { existsSync } from "node:fs";
12
+ import { execFileSync } from "node:child_process";
13
+ import { whichSync } from "./shell";
14
+ import { ROUNDHOUSE_DIR } from "../config";
15
+ const __dirname = new URL(".", import.meta.url).pathname.replace(/\/$/, "");
16
+
17
+ const LABEL = "com.inceptionstack.roundhouse";
18
+ const PLIST_DIR = resolve(homedir(), "Library", "LaunchAgents");
19
+ export const PLIST_PATH = resolve(PLIST_DIR, `${LABEL}.plist`);
20
+ /**
21
+ * Generate a LaunchAgent plist for roundhouse.
22
+ */
23
+ export function generatePlist(): string {
24
+ const nodeBin = whichSync("node") || process.execPath;
25
+ const roundhouseBin = whichSync("roundhouse");
26
+
27
+ let programArgs: string[];
28
+ if (roundhouseBin) {
29
+ programArgs = [nodeBin, roundhouseBin, "run"];
30
+ } else {
31
+ // Fallback: tsx path
32
+ const tsxBin = whichSync("tsx") || resolve(__dirname, "..", "..", "node_modules", ".bin", "tsx");
33
+ const cliPath = resolve(__dirname, "cli.ts");
34
+ programArgs = [nodeBin, tsxBin, cliPath, "run"];
35
+ }
36
+
37
+ const logDir = resolve(ROUNDHOUSE_DIR, "logs");
38
+ let envSection = "";
39
+ const envVars: Record<string, string> = {
40
+ HOME: homedir(),
41
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
42
+ ROUNDHOUSE_CONFIG: resolve(ROUNDHOUSE_DIR, "gateway.config.json"),
43
+ NODE_NO_WARNINGS: "1",
44
+ };
45
+
46
+ envSection = Object.entries(envVars)
47
+ .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
48
+ .join("\n");
49
+
50
+ return `<?xml version="1.0" encoding="UTF-8"?>
51
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
52
+ <plist version="1.0">
53
+ <dict>
54
+ <key>Label</key>
55
+ <string>${LABEL}</string>
56
+
57
+ <key>ProgramArguments</key>
58
+ <array>
59
+ ${programArgs.map(a => ` <string>${escapeXml(a)}</string>`).join("\n")}
60
+ </array>
61
+
62
+ <key>EnvironmentVariables</key>
63
+ <dict>
64
+ ${envSection}
65
+ </dict>
66
+
67
+ <key>RunAtLoad</key>
68
+ <true/>
69
+
70
+ <key>KeepAlive</key>
71
+ <true/>
72
+
73
+ <key>StandardOutPath</key>
74
+ <string>${escapeXml(resolve(logDir, "roundhouse.log"))}</string>
75
+
76
+ <key>StandardErrorPath</key>
77
+ <string>${escapeXml(resolve(logDir, "roundhouse.err"))}</string>
78
+
79
+ <key>WorkingDirectory</key>
80
+ <string>${escapeXml(homedir())}</string>
81
+
82
+ <key>ThrottleInterval</key>
83
+ <integer>5</integer>
84
+ </dict>
85
+ </plist>
86
+ `;
87
+ }
88
+
89
+ /**
90
+ * Install the plist and load the service.
91
+ */
92
+ export async function installLaunchAgent(): Promise<void> {
93
+ await mkdir(PLIST_DIR, { recursive: true });
94
+ await mkdir(resolve(ROUNDHOUSE_DIR, "logs"), { recursive: true });
95
+
96
+ const plist = generatePlist();
97
+ await writeFile(PLIST_PATH, plist, { mode: 0o644 });
98
+
99
+ // Unload first if already loaded (ignore errors)
100
+ try {
101
+ execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
102
+ } catch {}
103
+
104
+ // Load the agent
105
+ execFileSync("launchctl", ["load", PLIST_PATH], { stdio: "pipe" });
106
+ }
107
+
108
+ /**
109
+ * Unload and remove the launch agent.
110
+ */
111
+ export async function uninstallLaunchAgent(): Promise<void> {
112
+ try {
113
+ execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "pipe" });
114
+ } catch {}
115
+
116
+ const { unlink } = await import("node:fs/promises");
117
+ try {
118
+ await unlink(PLIST_PATH);
119
+ } catch {}
120
+ }
121
+
122
+ /**
123
+ * Check if the launch agent is loaded and running.
124
+ */
125
+ export function isLaunchAgentRunning(): boolean {
126
+ try {
127
+ const output = execFileSync("launchctl", ["list", LABEL], { encoding: "utf8", stdio: "pipe" });
128
+ return output.includes(LABEL);
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check if the plist file exists.
136
+ */
137
+ export function isLaunchAgentInstalled(): boolean {
138
+ return existsSync(PLIST_PATH);
139
+ }
140
+
141
+ function escapeXml(s: string): string {
142
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
143
+ }
144
+
@@ -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
+ }