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.
- package/README.md +37 -32
- package/package.json +1 -1
- package/src/chat/agent.ts +13 -11
- package/src/cli.ts +2 -2
- package/src/commands/chat.ts +29 -44
- package/src/commands/nuke.ts +11 -8
- package/src/commands/schedule.ts +1 -1
- package/src/commands/thread.ts +2 -2
- package/src/commands/with-db.ts +1 -1
- package/src/commands/worker.ts +231 -0
- package/src/config/schemas.ts +12 -0
- package/src/constants.ts +1 -27
- package/src/db/schedules.ts +66 -0
- package/src/db/schema.ts +16 -4
- package/src/db/sql/12-workers.sql +66 -0
- package/src/db/tasks.ts +25 -1
- package/src/db/threads.ts +1 -1
- package/src/db/workers.ts +207 -0
- package/src/init/index.ts +3 -1
- package/src/tools/context/read-large-result.ts +1 -1
- package/src/tools/mcp/exec.ts +1 -1
- package/src/tools/mcp/search.ts +1 -1
- package/src/tools/registry.ts +5 -0
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/worker/spawn.ts +50 -0
- package/src/tui/App.tsx +15 -7
- package/src/tui/components/HelpPanel.tsx +5 -5
- package/src/tui/components/StatusBar.tsx +22 -18
- package/src/tui/components/TabBar.tsx +3 -2
- package/src/tui/components/ThreadPanel.tsx +7 -7
- package/src/tui/components/WorkerPanel.tsx +207 -0
- package/src/utils/title.ts +1 -1
- package/src/worker/heartbeat.ts +78 -0
- package/src/worker/index.ts +200 -0
- package/src/{daemon → worker}/llm.ts +5 -5
- package/src/{daemon → worker}/prompt.ts +2 -2
- package/src/worker/run.ts +26 -0
- package/src/{daemon → worker}/schedules.ts +30 -2
- package/src/worker/spawn.ts +48 -0
- package/src/{daemon → worker}/tick.ts +93 -35
- package/src/commands/daemon.ts +0 -152
- package/src/daemon/ensure-running.ts +0 -16
- package/src/daemon/healthcheck.ts +0 -47
- package/src/daemon/index.ts +0 -106
- package/src/daemon/run.ts +0 -14
- package/src/daemon/spawn.ts +0 -38
- package/src/daemon/watchdog.ts +0 -306
- package/src/utils/pid.ts +0 -55
- package/src/utils/project-registry.ts +0 -48
- /package/src/{daemon → worker}/context.ts +0 -0
- /package/src/{daemon → worker}/fake-llm.ts +0 -0
- /package/src/{daemon → worker}/fake-mcp.ts +0 -0
- /package/src/{daemon → worker}/large-results.ts +0 -0
- /package/src/{daemon → worker}/llm-client.ts +0 -0
package/src/commands/daemon.ts
DELETED
|
@@ -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
|
-
}
|
package/src/daemon/index.ts
DELETED
|
@@ -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);
|
package/src/daemon/spawn.ts
DELETED
|
@@ -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
|
-
}
|
package/src/daemon/watchdog.ts
DELETED
|
@@ -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, "&")
|
|
302
|
-
.replace(/</g, "<")
|
|
303
|
-
.replace(/>/g, ">")
|
|
304
|
-
.replace(/"/g, """)
|
|
305
|
-
.replace(/'/g, "'");
|
|
306
|
-
}
|