botholomew 0.7.13 → 0.8.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.
Files changed (54) hide show
  1. package/README.md +37 -32
  2. package/package.json +1 -1
  3. package/src/chat/agent.ts +13 -11
  4. package/src/cli.ts +2 -2
  5. package/src/commands/chat.ts +29 -44
  6. package/src/commands/nuke.ts +11 -8
  7. package/src/commands/schedule.ts +1 -1
  8. package/src/commands/thread.ts +2 -2
  9. package/src/commands/with-db.ts +1 -1
  10. package/src/commands/worker.ts +231 -0
  11. package/src/config/schemas.ts +12 -0
  12. package/src/constants.ts +1 -27
  13. package/src/db/schedules.ts +66 -0
  14. package/src/db/schema.ts +16 -4
  15. package/src/db/sql/12-workers.sql +66 -0
  16. package/src/db/tasks.ts +25 -1
  17. package/src/db/threads.ts +1 -1
  18. package/src/db/workers.ts +207 -0
  19. package/src/init/index.ts +3 -1
  20. package/src/tools/context/read-large-result.ts +1 -1
  21. package/src/tools/mcp/exec.ts +1 -1
  22. package/src/tools/mcp/search.ts +1 -1
  23. package/src/tools/registry.ts +5 -0
  24. package/src/tools/thread/list.ts +2 -2
  25. package/src/tools/worker/spawn.ts +50 -0
  26. package/src/tui/App.tsx +15 -7
  27. package/src/tui/components/HelpPanel.tsx +5 -5
  28. package/src/tui/components/StatusBar.tsx +22 -18
  29. package/src/tui/components/TabBar.tsx +3 -2
  30. package/src/tui/components/ThreadPanel.tsx +7 -7
  31. package/src/tui/components/WorkerPanel.tsx +207 -0
  32. package/src/utils/title.ts +1 -1
  33. package/src/worker/heartbeat.ts +78 -0
  34. package/src/worker/index.ts +200 -0
  35. package/src/{daemon → worker}/llm.ts +5 -5
  36. package/src/{daemon → worker}/prompt.ts +2 -2
  37. package/src/worker/run.ts +26 -0
  38. package/src/{daemon → worker}/schedules.ts +30 -2
  39. package/src/worker/spawn.ts +48 -0
  40. package/src/{daemon → worker}/tick.ts +93 -35
  41. package/src/commands/daemon.ts +0 -152
  42. package/src/daemon/ensure-running.ts +0 -16
  43. package/src/daemon/healthcheck.ts +0 -47
  44. package/src/daemon/index.ts +0 -106
  45. package/src/daemon/run.ts +0 -14
  46. package/src/daemon/spawn.ts +0 -38
  47. package/src/daemon/watchdog.ts +0 -306
  48. package/src/utils/pid.ts +0 -55
  49. package/src/utils/project-registry.ts +0 -48
  50. /package/src/{daemon → worker}/context.ts +0 -0
  51. /package/src/{daemon → worker}/fake-llm.ts +0 -0
  52. /package/src/{daemon → worker}/fake-mcp.ts +0 -0
  53. /package/src/{daemon → worker}/large-results.ts +0 -0
  54. /package/src/{daemon → worker}/llm-client.ts +0 -0
@@ -1,152 +0,0 @@
1
- import type { Command } from "commander";
2
- import { logger } from "../utils/logger.ts";
3
-
4
- export function registerDaemonCommand(program: Command) {
5
- const daemon = program
6
- .command("daemon")
7
- .description("Manage the Botholomew daemon");
8
-
9
- daemon
10
- .command("run")
11
- .description("Run the daemon in the foreground (blocks until stopped)")
12
- .action(async () => {
13
- const dir = program.opts().dir;
14
- const { startDaemon } = await import("../daemon/index.ts");
15
- await startDaemon(dir, { foreground: true });
16
- });
17
-
18
- daemon
19
- .command("start")
20
- .description("Start the daemon as a background process")
21
- .action(async () => {
22
- const dir = program.opts().dir;
23
- const { spawnDaemon } = await import("../daemon/spawn.ts");
24
- await spawnDaemon(dir);
25
- });
26
-
27
- daemon
28
- .command("stop")
29
- .description("Stop the daemon for this project")
30
- .action(async () => {
31
- const dir = program.opts().dir;
32
- const { stopDaemon } = await import("../utils/pid.ts");
33
- const stopped = await stopDaemon(dir);
34
- if (stopped) {
35
- logger.success("Daemon stopped.");
36
- } else {
37
- logger.warn("No running daemon found.");
38
- }
39
- });
40
-
41
- daemon
42
- .command("status")
43
- .description("Check if the daemon is running")
44
- .action(async () => {
45
- const dir = program.opts().dir;
46
- const { getDaemonStatus } = await import("../utils/pid.ts");
47
- const status = await getDaemonStatus(dir);
48
- if (status) {
49
- logger.success(`Daemon running (PID ${status.pid})`);
50
- } else {
51
- logger.dim("Daemon is not running.");
52
- }
53
-
54
- const { getWatchdogStatus } = await import("../daemon/watchdog.ts");
55
- try {
56
- const watchdog = await getWatchdogStatus(dir);
57
- if (watchdog.installed) {
58
- logger.info(`Watchdog: installed (${watchdog.platform})`);
59
- if (watchdog.configPath) {
60
- logger.dim(` Config: ${watchdog.configPath}`);
61
- }
62
- } else {
63
- logger.dim("Watchdog: not installed");
64
- }
65
- } catch {
66
- logger.dim("Watchdog: not installed");
67
- }
68
- });
69
-
70
- daemon
71
- .command("install")
72
- .description("Install OS-level watchdog (launchd/systemd)")
73
- .action(async () => {
74
- const dir = program.opts().dir;
75
- const { installWatchdog } = await import("../daemon/watchdog.ts");
76
- try {
77
- const result = await installWatchdog(dir);
78
- logger.success(`Watchdog installed (${result.platform})`);
79
- for (const p of result.paths) {
80
- logger.dim(` ${p}`);
81
- }
82
- } catch (err) {
83
- logger.error(
84
- `Failed to install watchdog: ${err instanceof Error ? err.message : err}`,
85
- );
86
- }
87
- });
88
-
89
- daemon
90
- .command("uninstall")
91
- .description("Remove OS-level watchdog")
92
- .action(async () => {
93
- const dir = program.opts().dir;
94
- const { uninstallWatchdog } = await import("../daemon/watchdog.ts");
95
- try {
96
- const result = await uninstallWatchdog(dir);
97
- if (result.removed) {
98
- logger.success(`Watchdog removed (${result.platform})`);
99
- } else {
100
- logger.warn("No watchdog found to remove.");
101
- }
102
- } catch (err) {
103
- logger.error(
104
- `Failed to remove watchdog: ${err instanceof Error ? err.message : err}`,
105
- );
106
- }
107
- });
108
-
109
- daemon
110
- .command("list")
111
- .description("List all registered Botholomew projects on this machine")
112
- .option("-l, --limit <n>", "max number of projects", Number.parseInt)
113
- .option("-o, --offset <n>", "skip first N projects", Number.parseInt)
114
- .action(async (opts: { limit?: number; offset?: number }) => {
115
- const { listAllWatchdogProjects } = await import("../daemon/watchdog.ts");
116
- try {
117
- const projects = await listAllWatchdogProjects();
118
- if (projects.length === 0) {
119
- logger.dim("No registered projects found.");
120
- return;
121
- }
122
- const total = projects.length;
123
- const start = opts.offset ?? 0;
124
- const end = opts.limit ? start + opts.limit : undefined;
125
- const page = projects.slice(start, end);
126
- if (page.length === 0) {
127
- logger.dim(`No projects on this page (total: ${total}).`);
128
- return;
129
- }
130
- for (const p of page) {
131
- logger.info(p.projectDir);
132
- logger.dim(` Config: ${p.configPath}`);
133
- }
134
- if (page.length !== total) {
135
- logger.dim(`\nshowing ${page.length} of ${total} project(s)`);
136
- }
137
- } catch (err) {
138
- logger.error(
139
- `Failed to list projects: ${err instanceof Error ? err.message : err}`,
140
- );
141
- }
142
- });
143
-
144
- daemon
145
- .command("healthcheck")
146
- .description("Run health check (used by watchdog)")
147
- .action(async () => {
148
- const dir = program.opts().dir;
149
- const { runHealthCheck } = await import("../daemon/healthcheck.ts");
150
- await runHealthCheck(dir);
151
- });
152
- }
@@ -1,16 +0,0 @@
1
- import { getDaemonStatus } from "../utils/pid.ts";
2
- import { spawnDaemon } from "./spawn.ts";
3
-
4
- /**
5
- * If no daemon is running for this project, spawn one in the background.
6
- * Returns true if a new daemon was spawned, false if one was already running.
7
- */
8
- export async function ensureDaemonRunning(
9
- projectDir: string,
10
- ): Promise<boolean> {
11
- const status = await getDaemonStatus(projectDir);
12
- if (status) return false;
13
-
14
- await spawnDaemon(projectDir);
15
- return true;
16
- }
@@ -1,47 +0,0 @@
1
- import { rename } from "node:fs/promises";
2
- import { getLogPath, LOG_MAX_BYTES } from "../constants.ts";
3
- import { isProcessAlive, readPidFile, removePidFile } from "../utils/pid.ts";
4
- import { spawnDaemon } from "./spawn.ts";
5
-
6
- export async function runHealthCheck(projectDir: string): Promise<void> {
7
- const pid = await readPidFile(projectDir);
8
-
9
- if (pid !== null) {
10
- if (isProcessAlive(pid)) {
11
- // Daemon is healthy — nothing to do
12
- return;
13
- }
14
- // Stale PID file — clean it up
15
- await removePidFile(projectDir);
16
- }
17
-
18
- // Daemon is not running — start it
19
- await spawnDaemon(projectDir);
20
-
21
- // Rotate daemon.log if it's too large
22
- await rotateLogIfNeeded(projectDir);
23
- }
24
-
25
- export async function rotateLogIfNeeded(projectDir: string): Promise<void> {
26
- const logPath = getLogPath(projectDir);
27
- const logFile = Bun.file(logPath);
28
-
29
- if (!(await logFile.exists())) return;
30
-
31
- if (logFile.size > LOG_MAX_BYTES) {
32
- try {
33
- await rename(logPath, `${logPath}.1`);
34
- } catch {
35
- // Best-effort rotation — don't fail the healthcheck
36
- }
37
- }
38
- }
39
-
40
- if (import.meta.main) {
41
- const projectDir = process.argv[2];
42
- if (!projectDir) {
43
- console.error("Usage: bun run healthcheck.ts <projectDir>");
44
- process.exit(1);
45
- }
46
- await runHealthCheck(projectDir);
47
- }
@@ -1,106 +0,0 @@
1
- import ansis from "ansis";
2
- import { loadConfig } from "../config/loader.ts";
3
- import { getDbPath } from "../constants.ts";
4
- import { withDb } from "../db/connection.ts";
5
- import { migrate } from "../db/schema.ts";
6
- import { createMcpxClient } from "../mcpx/client.ts";
7
- import { logger } from "../utils/logger.ts";
8
- import { removePidFile, writePidFile } from "../utils/pid.ts";
9
- import type { DaemonStreamCallbacks } from "./llm.ts";
10
- import { tick } from "./tick.ts";
11
-
12
- function buildForegroundCallbacks(): DaemonStreamCallbacks {
13
- return {
14
- onTaskStart(task) {
15
- process.stdout.write(
16
- `\n${ansis.bold.blue(`Task: ${task.name}`)} ${ansis.dim(`(${task.id})`)}\n`,
17
- );
18
- if (task.description) {
19
- process.stdout.write(`${ansis.dim(task.description)}\n`);
20
- }
21
- process.stdout.write("\n");
22
- },
23
- onToken(text) {
24
- process.stdout.write(text);
25
- },
26
- onToolStart(name, input) {
27
- process.stdout.write(
28
- ` ${ansis.yellow("▶")} ${ansis.bold(name)} ${ansis.dim(input)}\n`,
29
- );
30
- },
31
- onToolEnd(name, _output, isError, durationMs) {
32
- const seconds = (durationMs / 1000).toFixed(1);
33
- if (isError) {
34
- process.stdout.write(
35
- ` ${ansis.red("✗")} ${ansis.bold(name)} ${ansis.red("error")} ${ansis.dim(`(${seconds}s)`)}\n`,
36
- );
37
- } else {
38
- process.stdout.write(
39
- ` ${ansis.green("✓")} ${ansis.bold(name)} ${ansis.dim(`(${seconds}s)`)}\n`,
40
- );
41
- }
42
- },
43
- };
44
- }
45
-
46
- export async function startDaemon(
47
- projectDir: string,
48
- options?: { foreground?: boolean },
49
- ): Promise<void> {
50
- const config = await loadConfig(projectDir);
51
- const dbPath = getDbPath(projectDir);
52
-
53
- // One short-lived connection to apply migrations. After this returns,
54
- // the file lock is released so CLI invocations can run freely.
55
- await withDb(dbPath, (conn) => migrate(conn));
56
-
57
- // Initialize MCPX client for external tool access
58
- const mcpxClient = await createMcpxClient(projectDir);
59
- if (mcpxClient) {
60
- logger.info("MCPX client initialized with external tools");
61
- }
62
-
63
- writePidFile(projectDir, process.pid);
64
-
65
- const shutdown = async () => {
66
- logger.info("Daemon shutting down...");
67
- await mcpxClient?.close();
68
- await removePidFile(projectDir);
69
- process.exit(0);
70
- };
71
-
72
- process.on("SIGTERM", shutdown);
73
- process.on("SIGINT", shutdown);
74
-
75
- const callbacks = options?.foreground
76
- ? buildForegroundCallbacks()
77
- : undefined;
78
-
79
- logger.info(
80
- `Daemon started ${new Date().toISOString()} for ${projectDir} (PID ${process.pid})`,
81
- );
82
- logger.info(`Tick interval: ${config.tick_interval_seconds}s`);
83
-
84
- let tickNum = 0;
85
- while (true) {
86
- tickNum++;
87
- let didWork = false;
88
- try {
89
- didWork = await tick(
90
- projectDir,
91
- dbPath,
92
- config,
93
- mcpxClient,
94
- callbacks,
95
- tickNum,
96
- );
97
- } catch (err) {
98
- logger.error(`Tick failed: ${err}`);
99
- }
100
-
101
- if (!didWork) {
102
- logger.phase("sleeping", `${config.tick_interval_seconds}s`);
103
- await Bun.sleep(config.tick_interval_seconds * 1000);
104
- }
105
- }
106
- }
package/src/daemon/run.ts DELETED
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- // Standalone entry point for the daemon when spawned as a detached process.
4
- // Usage: bun run src/daemon/run.ts <projectDir>
5
-
6
- import { startDaemon } from "./index.ts";
7
-
8
- const projectDir = process.argv[2];
9
- if (!projectDir) {
10
- console.error("Usage: bun run src/daemon/run.ts <projectDir>");
11
- process.exit(1);
12
- }
13
-
14
- await startDaemon(projectDir);
@@ -1,38 +0,0 @@
1
- import { join } from "node:path";
2
- import { getBotholomewDir, getLogPath } from "../constants.ts";
3
- import { logger } from "../utils/logger.ts";
4
- import { isProcessAlive, readPidFile } from "../utils/pid.ts";
5
-
6
- export async function spawnDaemon(projectDir: string): Promise<void> {
7
- // Check if already running
8
- const existingPid = await readPidFile(projectDir);
9
- if (existingPid && isProcessAlive(existingPid)) {
10
- logger.warn(`Daemon already running (PID ${existingPid})`);
11
- return;
12
- }
13
-
14
- // Ensure .botholomew dir exists
15
- const dotDir = getBotholomewDir(projectDir);
16
- const dirExists = await Bun.file(join(dotDir, "config.json")).exists();
17
- if (!dirExists) {
18
- logger.error("Project not initialized. Run 'botholomew init' first.");
19
- process.exit(1);
20
- }
21
-
22
- const logPath = getLogPath(projectDir);
23
- const logFile = Bun.file(logPath);
24
-
25
- // Find the daemon entry script
26
- const daemonScript = new URL("./run.ts", import.meta.url).pathname;
27
-
28
- const proc = Bun.spawn(["bun", "run", daemonScript, projectDir], {
29
- stdio: ["ignore", logFile, logFile],
30
- env: { ...process.env },
31
- });
32
-
33
- // Detach the process
34
- proc.unref();
35
-
36
- logger.success(`Daemon started in background (PID ${proc.pid})`);
37
- logger.dim(` Log: ${logPath}`);
38
- }
@@ -1,306 +0,0 @@
1
- import { unlink } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
4
- import {
5
- getWatchdogLogPath,
6
- LAUNCHD_LABEL_PREFIX,
7
- SYSTEMD_UNIT_PREFIX,
8
- sanitizePathForServiceName,
9
- } from "../constants.ts";
10
- import {
11
- listRegisteredProjects,
12
- registerProject,
13
- unregisterProject,
14
- } from "../utils/project-registry.ts";
15
-
16
- export type Platform = "macos" | "linux";
17
-
18
- export function detectPlatform(): Platform {
19
- if (process.platform === "darwin") return "macos";
20
- if (process.platform === "linux") return "linux";
21
- throw new Error(`Unsupported platform: ${process.platform}`);
22
- }
23
-
24
- function getServiceName(projectDir: string): string {
25
- return sanitizePathForServiceName(resolve(projectDir));
26
- }
27
-
28
- function getHealthcheckCommand(projectDir: string): string[] {
29
- const healthcheckScript = new URL("./healthcheck.ts", import.meta.url)
30
- .pathname;
31
- return ["bun", "run", healthcheckScript, resolve(projectDir)];
32
- }
33
-
34
- function getLaunchdLabel(projectDir: string): string {
35
- return `${LAUNCHD_LABEL_PREFIX}${getServiceName(projectDir)}`;
36
- }
37
-
38
- function getLaunchdPlistPath(projectDir: string): string {
39
- return join(
40
- homedir(),
41
- "Library",
42
- "LaunchAgents",
43
- `${getLaunchdLabel(projectDir)}.plist`,
44
- );
45
- }
46
-
47
- function getSystemdBaseName(projectDir: string): string {
48
- return `${SYSTEMD_UNIT_PREFIX}${getServiceName(projectDir)}`;
49
- }
50
-
51
- function getSystemdDir(): string {
52
- return join(homedir(), ".config", "systemd", "user");
53
- }
54
-
55
- export function generateLaunchdPlist(
56
- projectDir: string,
57
- healthcheckCmd: string[],
58
- ): string {
59
- const absDir = resolve(projectDir);
60
- const label = getLaunchdLabel(absDir);
61
- const watchdogLog = getWatchdogLogPath(absDir);
62
-
63
- const programArgs = healthcheckCmd
64
- .map((arg) => ` <string>${escapeXml(arg)}</string>`)
65
- .join("\n");
66
-
67
- return `<?xml version="1.0" encoding="UTF-8"?>
68
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
69
- <plist version="1.0">
70
- <dict>
71
- <key>Label</key>
72
- <string>${escapeXml(label)}</string>
73
- <key>ProgramArguments</key>
74
- <array>
75
- ${programArgs}
76
- </array>
77
- <key>StartInterval</key>
78
- <integer>60</integer>
79
- <key>KeepAlive</key>
80
- <false/>
81
- <key>StandardOutPath</key>
82
- <string>${escapeXml(watchdogLog)}</string>
83
- <key>StandardErrorPath</key>
84
- <string>${escapeXml(watchdogLog)}</string>
85
- </dict>
86
- </plist>
87
- `;
88
- }
89
-
90
- export function generateSystemdService(
91
- projectDir: string,
92
- healthcheckCmd: string[],
93
- ): string {
94
- const absDir = resolve(projectDir);
95
- return `[Unit]
96
- Description=Botholomew healthcheck for ${absDir}
97
-
98
- [Service]
99
- Type=oneshot
100
- ExecStart=${healthcheckCmd.join(" ")}
101
-
102
- [Install]
103
- WantedBy=default.target
104
- `;
105
- }
106
-
107
- export function generateSystemdTimer(serviceName: string): string {
108
- return `[Unit]
109
- Description=Botholomew watchdog timer for ${serviceName}
110
-
111
- [Timer]
112
- OnBootSec=60
113
- OnUnitActiveSec=60
114
-
115
- [Install]
116
- WantedBy=timers.target
117
- `;
118
- }
119
-
120
- export function generateWatchdogConfig(projectDir: string): {
121
- platform: Platform;
122
- files: Array<{ path: string; content: string }>;
123
- } {
124
- const absDir = resolve(projectDir);
125
- const platform = detectPlatform();
126
- const cmd = getHealthcheckCommand(absDir);
127
-
128
- if (platform === "macos") {
129
- return {
130
- platform,
131
- files: [
132
- {
133
- path: getLaunchdPlistPath(absDir),
134
- content: generateLaunchdPlist(absDir, cmd),
135
- },
136
- ],
137
- };
138
- }
139
-
140
- const baseName = getSystemdBaseName(absDir);
141
- const systemdDir = getSystemdDir();
142
- return {
143
- platform,
144
- files: [
145
- {
146
- path: join(systemdDir, `${baseName}.service`),
147
- content: generateSystemdService(absDir, cmd),
148
- },
149
- {
150
- path: join(systemdDir, `${baseName}.timer`),
151
- content: generateSystemdTimer(baseName),
152
- },
153
- ],
154
- };
155
- }
156
-
157
- export async function installWatchdog(projectDir: string): Promise<{
158
- installed: boolean;
159
- platform: Platform;
160
- paths: string[];
161
- }> {
162
- const absDir = resolve(projectDir);
163
- const config = generateWatchdogConfig(absDir);
164
- const paths: string[] = [];
165
-
166
- for (const file of config.files) {
167
- await Bun.write(file.path, file.content);
168
- paths.push(file.path);
169
- }
170
-
171
- if (config.platform === "macos") {
172
- const plistPath = config.files[0]?.path;
173
- if (!plistPath) throw new Error("No plist file generated");
174
- const proc = Bun.spawnSync(["launchctl", "load", plistPath]);
175
- if (proc.exitCode !== 0) {
176
- const stderr = proc.stderr.toString().trim();
177
- throw new Error(`launchctl load failed: ${stderr}`);
178
- }
179
- } else {
180
- const baseName = getSystemdBaseName(absDir);
181
- const reload = Bun.spawnSync(["systemctl", "--user", "daemon-reload"]);
182
- if (reload.exitCode !== 0) {
183
- throw new Error(
184
- `systemctl daemon-reload failed: ${reload.stderr.toString().trim()}`,
185
- );
186
- }
187
- const enable = Bun.spawnSync([
188
- "systemctl",
189
- "--user",
190
- "enable",
191
- "--now",
192
- `${baseName}.timer`,
193
- ]);
194
- if (enable.exitCode !== 0) {
195
- throw new Error(
196
- `systemctl enable failed: ${enable.stderr.toString().trim()}`,
197
- );
198
- }
199
- }
200
-
201
- await registerProject(absDir);
202
-
203
- return { installed: true, platform: config.platform, paths };
204
- }
205
-
206
- export async function uninstallWatchdog(projectDir: string): Promise<{
207
- removed: boolean;
208
- platform: Platform;
209
- }> {
210
- const absDir = resolve(projectDir);
211
- const platform = detectPlatform();
212
-
213
- if (platform === "macos") {
214
- const plistPath = getLaunchdPlistPath(absDir);
215
- const file = Bun.file(plistPath);
216
- if (!(await file.exists())) {
217
- return { removed: false, platform };
218
- }
219
- Bun.spawnSync(["launchctl", "unload", plistPath]);
220
- try {
221
- await unlink(plistPath);
222
- } catch {
223
- // ignore
224
- }
225
- } else {
226
- const baseName = getSystemdBaseName(absDir);
227
- Bun.spawnSync([
228
- "systemctl",
229
- "--user",
230
- "disable",
231
- "--now",
232
- `${baseName}.timer`,
233
- ]);
234
- const systemdDir = getSystemdDir();
235
- for (const ext of [".service", ".timer"]) {
236
- try {
237
- await unlink(join(systemdDir, `${baseName}${ext}`));
238
- } catch {
239
- // ignore
240
- }
241
- }
242
- Bun.spawnSync(["systemctl", "--user", "daemon-reload"]);
243
- }
244
-
245
- await unregisterProject(absDir);
246
-
247
- return { removed: true, platform };
248
- }
249
-
250
- export async function getWatchdogStatus(projectDir: string): Promise<{
251
- installed: boolean;
252
- platform: Platform;
253
- configPath?: string;
254
- }> {
255
- const absDir = resolve(projectDir);
256
- const platform = detectPlatform();
257
-
258
- if (platform === "macos") {
259
- const plistPath = getLaunchdPlistPath(absDir);
260
- const exists = await Bun.file(plistPath).exists();
261
- return {
262
- installed: exists,
263
- platform,
264
- configPath: exists ? plistPath : undefined,
265
- };
266
- }
267
-
268
- const baseName = getSystemdBaseName(absDir);
269
- const timerPath = join(getSystemdDir(), `${baseName}.timer`);
270
- const exists = await Bun.file(timerPath).exists();
271
- return {
272
- installed: exists,
273
- platform,
274
- configPath: exists ? timerPath : undefined,
275
- };
276
- }
277
-
278
- export async function listAllWatchdogProjects(): Promise<
279
- Array<{ projectDir: string; configPath: string }>
280
- > {
281
- const projects = await listRegisteredProjects();
282
- const platform = detectPlatform();
283
- const results: Array<{ projectDir: string; configPath: string }> = [];
284
-
285
- for (const project of projects) {
286
- let configPath: string;
287
- if (platform === "macos") {
288
- configPath = getLaunchdPlistPath(project.projectDir);
289
- } else {
290
- const baseName = getSystemdBaseName(project.projectDir);
291
- configPath = join(getSystemdDir(), `${baseName}.timer`);
292
- }
293
- results.push({ projectDir: project.projectDir, configPath });
294
- }
295
-
296
- return results;
297
- }
298
-
299
- function escapeXml(str: string): string {
300
- return str
301
- .replace(/&/g, "&amp;")
302
- .replace(/</g, "&lt;")
303
- .replace(/>/g, "&gt;")
304
- .replace(/"/g, "&quot;")
305
- .replace(/'/g, "&apos;");
306
- }