@murmurv2/cli 2.11.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/bin/murmur-npm.mjs +6 -0
- package/package.json +42 -0
- package/runtime/bin/murmur-svc.exe +0 -0
- package/runtime/npm-runtime-manifest.json +299 -0
- package/runtime/package.json +1 -0
- package/runtime/packages/mcp-server/dist/src/index.js +1 -0
- package/runtime/packages/mcp-server/package.json +1 -0
- package/runtime/packages/setup/bin/murmur.mjs +26 -0
- package/runtime/packages/setup/dist/src/appdata.d.ts +9 -0
- package/runtime/packages/setup/dist/src/appdata.js +53 -0
- package/runtime/packages/setup/dist/src/cli.d.ts +9 -0
- package/runtime/packages/setup/dist/src/cli.js +130 -0
- package/runtime/packages/setup/dist/src/clients.d.ts +151 -0
- package/runtime/packages/setup/dist/src/clients.js +251 -0
- package/runtime/packages/setup/dist/src/commands.d.ts +35 -0
- package/runtime/packages/setup/dist/src/commands.js +115 -0
- package/runtime/packages/setup/dist/src/config.d.ts +49 -0
- package/runtime/packages/setup/dist/src/config.js +70 -0
- package/runtime/packages/setup/dist/src/doctor.d.ts +25 -0
- package/runtime/packages/setup/dist/src/doctor.js +174 -0
- package/runtime/packages/setup/dist/src/file-identity.d.ts +3 -0
- package/runtime/packages/setup/dist/src/file-identity.js +11 -0
- package/runtime/packages/setup/dist/src/index.d.ts +7 -0
- package/runtime/packages/setup/dist/src/index.js +7 -0
- package/runtime/packages/setup/dist/src/onboarding.d.ts +46 -0
- package/runtime/packages/setup/dist/src/onboarding.js +298 -0
- package/runtime/packages/setup/dist/src/outbox-attention.d.ts +51 -0
- package/runtime/packages/setup/dist/src/outbox-attention.js +98 -0
- package/runtime/packages/setup/dist/src/paths.d.ts +10 -0
- package/runtime/packages/setup/dist/src/paths.js +41 -0
- package/runtime/packages/setup/dist/src/platform/darwin.d.ts +17 -0
- package/runtime/packages/setup/dist/src/platform/darwin.js +270 -0
- package/runtime/packages/setup/dist/src/platform/linux.d.ts +9 -0
- package/runtime/packages/setup/dist/src/platform/linux.js +176 -0
- package/runtime/packages/setup/dist/src/platform/windows.d.ts +25 -0
- package/runtime/packages/setup/dist/src/platform/windows.js +189 -0
- package/runtime/packages/setup/dist/src/private-file.d.ts +5 -0
- package/runtime/packages/setup/dist/src/private-file.js +101 -0
- package/runtime/packages/setup/dist/src/reply-test.d.ts +25 -0
- package/runtime/packages/setup/dist/src/reply-test.js +75 -0
- package/runtime/packages/setup/dist/src/state.d.ts +3 -0
- package/runtime/packages/setup/dist/src/state.js +12 -0
- package/runtime/packages/setup/dist/src/status-line.d.ts +5 -0
- package/runtime/packages/setup/dist/src/status-line.js +19 -0
- package/runtime/packages/setup/dist/src/status.d.ts +137 -0
- package/runtime/packages/setup/dist/src/status.js +199 -0
- package/runtime/packages/setup/dist/src/types.d.ts +40 -0
- package/runtime/packages/setup/dist/src/types.js +1 -0
- package/runtime/packages/setup/dist/src/updates.d.ts +46 -0
- package/runtime/packages/setup/dist/src/updates.js +284 -0
- package/runtime/packages/setup/dist/src/verdict.d.ts +11 -0
- package/runtime/packages/setup/dist/src/verdict.js +159 -0
- package/runtime/packages/setup/package.json +1 -0
- package/runtime/plugins/claude-code/.claude-plugin/plugin.json +34 -0
- package/runtime/plugins/claude-code/.mcp.json +8 -0
- package/runtime/plugins/claude-code/scripts/configure-statusline.mjs +124 -0
- package/runtime/plugins/claude-code/scripts/statusline.mjs +210 -0
- package/runtime/plugins/claude-code/skills/inbox/SKILL.md +8 -0
- package/runtime/plugins/claude-code/skills/mark-read/SKILL.md +13 -0
- package/runtime/plugins/claude-code/skills/status/SKILL.md +13 -0
- package/runtime/scripts/ack-security.mjs +22 -0
- package/runtime/scripts/codex-app-server-wake.mjs +696 -0
- package/runtime/scripts/daemon-observation.mjs +48 -0
- package/runtime/scripts/doctor-protocol.mjs +96 -0
- package/runtime/scripts/lease.mjs +193 -0
- package/runtime/scripts/murmur-daemon.mjs +545 -0
- package/runtime/scripts/murmur-jetstream-advisory.mjs +11 -0
- package/runtime/scripts/murmur-shell-send.mjs +142 -0
- package/runtime/scripts/notify-router.mjs +309 -0
- package/runtime/scripts/runtime-capability.mjs +34 -0
- package/runtime/scripts/secure-state.mjs +105 -0
- package/runtime/scripts/wake-drain-claude.mjs +484 -0
- package/runtime/scripts/wake-monitor.mjs +653 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
const marker = "<!-- Managed by Murmur setup: darwin-v1 -->";
|
|
8
|
+
const launchctl = "/bin/launchctl";
|
|
9
|
+
const xml = (s) => s.replace(/[&<>"']/g, c => ({
|
|
10
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
11
|
+
}[c]));
|
|
12
|
+
function absolute(value, name) {
|
|
13
|
+
if (!path.isAbsolute(value) || /[\x00-\x1f\x7f]/.test(value) || path.normalize(value) !== value) {
|
|
14
|
+
throw new Error(`darwin-invalid-absolute-path:${name}`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function validate(ctx) {
|
|
19
|
+
for (const key of ["dataDir", "configPath", "storePath", "repoRoot", "nodePath", "logDir"]) {
|
|
20
|
+
absolute(ctx[key], key);
|
|
21
|
+
}
|
|
22
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(ctx.serviceName)) {
|
|
23
|
+
throw new Error("darwin-invalid-service-name");
|
|
24
|
+
}
|
|
25
|
+
// The current daemon resolves both paths from DATA_DIR, ignoring STORE_PATH.
|
|
26
|
+
// Refuse an inconsistent context instead of starting against the wrong DB.
|
|
27
|
+
if (ctx.configPath !== path.join(ctx.dataDir, "agent-config.json") ||
|
|
28
|
+
ctx.storePath !== path.join(ctx.dataDir, "murmur.db") ||
|
|
29
|
+
ctx.logDir !== path.join(ctx.dataDir, "logs")) {
|
|
30
|
+
throw new Error("darwin-daemon-path-contract-mismatch");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const defaultRun = (file, args) => new Promise(resolve => {
|
|
34
|
+
execFile(file, args, {
|
|
35
|
+
encoding: "utf8", timeout: 8_000, maxBuffer: 512 * 1024,
|
|
36
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
37
|
+
}, (error, stdout, stderr) => resolve({
|
|
38
|
+
code: error ? (typeof error.code === "number" ? error.code : -1) : 0,
|
|
39
|
+
stdout: String(stdout), stderr: String(stderr),
|
|
40
|
+
}));
|
|
41
|
+
});
|
|
42
|
+
const absent = (r) => r.code !== 0 &&
|
|
43
|
+
/Could not find service|Could not find specified service/.test(r.stderr + r.stdout);
|
|
44
|
+
const baseSnapshot = () => ({
|
|
45
|
+
state: "unknown", manager: "launchd", since: null, pid: null,
|
|
46
|
+
lastExitCode: null, observedStorePath: null,
|
|
47
|
+
restartCount: null, restartWindowMs: null,
|
|
48
|
+
});
|
|
49
|
+
async function accessible(file, mode = constants.F_OK) {
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(file, mode);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function createDarwinAdapter(options = {}) {
|
|
59
|
+
const home = absolute(options.homeDir ?? os.homedir(), "homeDir");
|
|
60
|
+
const uid = options.uid ?? process.getuid?.();
|
|
61
|
+
if (!Number.isSafeInteger(uid) || uid < 0)
|
|
62
|
+
throw new Error("darwin-invalid-user-id");
|
|
63
|
+
const env = options.env ?? process.env;
|
|
64
|
+
const run = options.run ?? defaultRun;
|
|
65
|
+
const domain = `gui/${uid}`;
|
|
66
|
+
const plistPath = (ctx) => path.join(home, "Library", "LaunchAgents", `${ctx.serviceName}.plist`);
|
|
67
|
+
const target = (ctx) => `${domain}/${ctx.serviceName}`;
|
|
68
|
+
const servicePath = (output) => /^\s*path = (.+)$/m.exec(output)?.[1]?.trim();
|
|
69
|
+
function plist(ctx) {
|
|
70
|
+
const args = [ctx.nodePath, path.join(ctx.repoRoot, "scripts", "murmur-daemon.mjs")];
|
|
71
|
+
const environment = {
|
|
72
|
+
DATA_DIR: ctx.dataDir,
|
|
73
|
+
MURMUR_STORE_PATH: ctx.storePath,
|
|
74
|
+
PATH: [path.dirname(ctx.nodePath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":"),
|
|
75
|
+
};
|
|
76
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n${marker}\n<plist version="1.0"><dict>
|
|
77
|
+
<key>Label</key><string>${xml(ctx.serviceName)}</string>
|
|
78
|
+
<key>ProgramArguments</key><array>${args.map(x => `<string>${xml(x)}</string>`).join("")}</array>
|
|
79
|
+
<key>WorkingDirectory</key><string>${xml(ctx.repoRoot)}</string>
|
|
80
|
+
<key>EnvironmentVariables</key><dict>${Object.entries(environment).map(([k, v]) => `<key>${k}</key><string>${xml(v)}</string>`).join("")}</dict>
|
|
81
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>10</integer>
|
|
82
|
+
<key>StandardOutPath</key><string>${xml(path.join(ctx.logDir, `${ctx.serviceName}.out.log`))}</string>
|
|
83
|
+
<key>StandardErrorPath</key><string>${xml(path.join(ctx.logDir, `${ctx.serviceName}.err.log`))}</string>
|
|
84
|
+
</dict></plist>\n`;
|
|
85
|
+
}
|
|
86
|
+
async function readOwned(ctx) {
|
|
87
|
+
const file = plistPath(ctx);
|
|
88
|
+
try {
|
|
89
|
+
const stat = await fs.lstat(file);
|
|
90
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
91
|
+
throw new Error("darwin-refuse-nonregular-plist");
|
|
92
|
+
const content = await fs.readFile(file, "utf8");
|
|
93
|
+
if (!content.includes(marker))
|
|
94
|
+
throw new Error("darwin-refuse-unmanaged-plist");
|
|
95
|
+
return content;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT")
|
|
99
|
+
return null;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function inspect(ctx) {
|
|
104
|
+
return run(launchctl, ["print", target(ctx)]);
|
|
105
|
+
}
|
|
106
|
+
function requireSameLoadedPath(ctx, result) {
|
|
107
|
+
if (servicePath(result.stdout) !== plistPath(ctx))
|
|
108
|
+
throw new Error("darwin-refuse-other-loaded-service");
|
|
109
|
+
}
|
|
110
|
+
async function observedStore(pid, expected) {
|
|
111
|
+
const result = await run("/usr/sbin/lsof", ["-a", "-p", String(pid), "-Fn"]);
|
|
112
|
+
if (result.code !== 0)
|
|
113
|
+
return null;
|
|
114
|
+
let canonical;
|
|
115
|
+
try {
|
|
116
|
+
canonical = await fs.realpath(expected);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
for (const line of result.stdout.split("\n")) {
|
|
122
|
+
if (!line.startsWith("n/"))
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
if (await fs.realpath(line.slice(1)) === canonical)
|
|
126
|
+
return canonical;
|
|
127
|
+
}
|
|
128
|
+
catch { /* closed file */ }
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
async function status(ctx) {
|
|
133
|
+
validate(ctx);
|
|
134
|
+
const value = baseSnapshot();
|
|
135
|
+
const result = await inspect(ctx);
|
|
136
|
+
if (absent(result))
|
|
137
|
+
return { ...value, state: "stopped", detail: "launchd service is not loaded" };
|
|
138
|
+
if (result.code !== 0)
|
|
139
|
+
return { ...value, detail: "launchctl could not inspect this user service" };
|
|
140
|
+
if (servicePath(result.stdout) !== plistPath(ctx))
|
|
141
|
+
return { ...value, detail: "loaded service path differs from the selected profile" };
|
|
142
|
+
const state = /^\s*state = (.+)$/m.exec(result.stdout)?.[1]?.trim();
|
|
143
|
+
const pid = Number(/^\s*pid = (\d+)$/m.exec(result.stdout)?.[1]);
|
|
144
|
+
const exitMatch = /^\s*last exit code = (-?\d+)$/m.exec(result.stdout);
|
|
145
|
+
value.lastExitCode = exitMatch ? Number(exitMatch[1]) : null;
|
|
146
|
+
if (state === "running" && Number.isSafeInteger(pid) && pid > 0) {
|
|
147
|
+
value.state = "running";
|
|
148
|
+
value.pid = pid;
|
|
149
|
+
const start = await run("/bin/ps", ["-p", String(pid), "-o", "lstart="]);
|
|
150
|
+
if (start.code === 0 && start.stdout.trim()) {
|
|
151
|
+
const parsed = Date.parse(start.stdout.trim() + " UTC");
|
|
152
|
+
if (Number.isFinite(parsed))
|
|
153
|
+
value.since = new Date(parsed).toISOString();
|
|
154
|
+
}
|
|
155
|
+
value.observedStorePath = await observedStore(pid, ctx.storePath);
|
|
156
|
+
}
|
|
157
|
+
else if (value.lastExitCode !== null && value.lastExitCode !== 0) {
|
|
158
|
+
value.state = "failed";
|
|
159
|
+
}
|
|
160
|
+
else if (["waiting", "not running", "exited"].includes(state ?? "")) {
|
|
161
|
+
value.state = "stopped";
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
async function install(ctx) {
|
|
166
|
+
validate(ctx);
|
|
167
|
+
if (!await accessible(ctx.nodePath, constants.X_OK) ||
|
|
168
|
+
!await accessible(path.join(ctx.repoRoot, "scripts", "murmur-daemon.mjs"))) {
|
|
169
|
+
throw new Error("darwin-daemon-executable-missing");
|
|
170
|
+
}
|
|
171
|
+
const content = plist(ctx);
|
|
172
|
+
const previous = await readOwned(ctx);
|
|
173
|
+
if (previous === content)
|
|
174
|
+
return;
|
|
175
|
+
const loaded = await inspect(ctx);
|
|
176
|
+
if (!absent(loaded)) {
|
|
177
|
+
if (loaded.code === 0)
|
|
178
|
+
throw new Error("darwin-stop-service-before-install-update");
|
|
179
|
+
throw new Error("darwin-cannot-confirm-service-unloaded");
|
|
180
|
+
}
|
|
181
|
+
const file = plistPath(ctx);
|
|
182
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o755 });
|
|
183
|
+
await fs.mkdir(ctx.logDir, { recursive: true, mode: 0o700 });
|
|
184
|
+
if (previous !== null)
|
|
185
|
+
await fs.writeFile(`${file}.backup-${randomUUID()}`, previous, { flag: "wx", mode: 0o600 });
|
|
186
|
+
const temporary = `${file}.tmp-${randomUUID()}`;
|
|
187
|
+
try {
|
|
188
|
+
await fs.writeFile(temporary, content, { flag: "wx", mode: 0o600 });
|
|
189
|
+
await fs.rename(temporary, file);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
await fs.rm(temporary, { force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function start(ctx) {
|
|
196
|
+
validate(ctx);
|
|
197
|
+
if (await readOwned(ctx) !== plist(ctx))
|
|
198
|
+
throw new Error("darwin-install-required-for-current-profile");
|
|
199
|
+
const loaded = await inspect(ctx);
|
|
200
|
+
if (absent(loaded)) {
|
|
201
|
+
const result = await run(launchctl, ["bootstrap", domain, plistPath(ctx)]);
|
|
202
|
+
if (result.code !== 0)
|
|
203
|
+
throw new Error(`darwin-bootstrap-failed:${result.code}`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
if (loaded.code !== 0)
|
|
207
|
+
throw new Error("darwin-cannot-inspect-service-before-start");
|
|
208
|
+
requireSameLoadedPath(ctx, loaded);
|
|
209
|
+
}
|
|
210
|
+
const result = await run(launchctl, ["kickstart", target(ctx)]);
|
|
211
|
+
if (result.code !== 0)
|
|
212
|
+
throw new Error(`darwin-kickstart-failed:${result.code}`);
|
|
213
|
+
}
|
|
214
|
+
async function stop(ctx) {
|
|
215
|
+
validate(ctx);
|
|
216
|
+
const loaded = await inspect(ctx);
|
|
217
|
+
if (absent(loaded))
|
|
218
|
+
return;
|
|
219
|
+
if (loaded.code !== 0)
|
|
220
|
+
throw new Error("darwin-cannot-inspect-service-before-stop");
|
|
221
|
+
requireSameLoadedPath(ctx, loaded);
|
|
222
|
+
if (await readOwned(ctx) !== plist(ctx))
|
|
223
|
+
throw new Error("darwin-stop-profile-mismatch");
|
|
224
|
+
const result = await run(launchctl, ["bootout", target(ctx)]);
|
|
225
|
+
if (result.code !== 0 && !absent(result))
|
|
226
|
+
throw new Error(`darwin-bootout-failed:${result.code}`);
|
|
227
|
+
}
|
|
228
|
+
async function detectClients(ctx) {
|
|
229
|
+
validate(ctx);
|
|
230
|
+
const appDirs = options.applicationDirs ?? ["/Applications", path.join(home, "Applications")];
|
|
231
|
+
async function hasApp(names, bundleId) {
|
|
232
|
+
for (const dir of appDirs)
|
|
233
|
+
for (const name of names) {
|
|
234
|
+
const info = path.join(absolute(dir, "applicationDir"), name, "Contents", "Info.plist");
|
|
235
|
+
if (!await accessible(info))
|
|
236
|
+
continue;
|
|
237
|
+
const result = await run("/usr/bin/plutil", ["-extract", "CFBundleIdentifier", "raw", info]);
|
|
238
|
+
if (result.code === 0 && result.stdout.trim() === bundleId)
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const executableDirs = options.executableDirs?.map(p => absolute(p, "executableDir")) ?? [...new Set([
|
|
244
|
+
...(env.PATH ?? "").split(":"), path.join(home, ".local", "bin"),
|
|
245
|
+
"/opt/homebrew/bin", "/usr/local/bin",
|
|
246
|
+
].filter(p => path.isAbsolute(p) && !/[\x00-\x1f\x7f]/.test(p)))];
|
|
247
|
+
async function hasExecutable(name) {
|
|
248
|
+
for (const dir of executableDirs) {
|
|
249
|
+
const candidate = path.join(dir, name);
|
|
250
|
+
if (await accessible(candidate, constants.X_OK) && await fs.stat(candidate).then(s => s.isFile(), () => false))
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
const codexHome = env.CODEX_HOME || path.join(home, ".codex");
|
|
256
|
+
const codexConfig = path.isAbsolute(codexHome) && !/[\x00-\x1f\x7f]/.test(codexHome)
|
|
257
|
+
? path.join(codexHome, "config.toml") : null;
|
|
258
|
+
// Do not guess a writable MCP target for non-default Claude profiles.
|
|
259
|
+
// Shared setup may resolve it by a client-supported configuration query.
|
|
260
|
+
const claudeConfig = env.CLAUDE_CONFIG_DIR ? null : path.join(home, ".claude.json");
|
|
261
|
+
return [
|
|
262
|
+
{ id: "claude-code", installed: await hasExecutable("claude"), configPath: claudeConfig, format: "json",
|
|
263
|
+
...(claudeConfig === null ? { detail: "Custom CLAUDE_CONFIG_DIR detected; MCP config target requires client verification" } : {}) },
|
|
264
|
+
{ id: "claude-desktop", installed: await hasApp(["Claude.app"], "com.anthropic.claudefordesktop"), configPath: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"), format: "json" },
|
|
265
|
+
{ id: "codex-cli", installed: await hasExecutable("codex"), configPath: codexConfig, format: "toml" },
|
|
266
|
+
{ id: "codex-desktop", installed: await hasApp(["Codex.app", "ChatGPT.app"], "com.openai.codex"), configPath: codexConfig, format: "toml" },
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
return { manager: "launchd", status, install, start, stop, detectClients };
|
|
270
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PlatformAdapter, ServiceContext } from "../types.js";
|
|
2
|
+
export interface LinuxOptions {
|
|
3
|
+
homeDir?: string;
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
run?: (args: string[]) => Promise<string>;
|
|
6
|
+
procDir?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function renderLinuxUnit(c: ServiceContext): string;
|
|
9
|
+
export declare function createLinuxAdapter(options?: LinuxOptions): PlatformAdapter;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { access, mkdir, open, readdir, readlink, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const marker = "# Managed by Murmur setup: systemd-v1\n";
|
|
10
|
+
const unit = (c) => `${c.serviceName}.service`;
|
|
11
|
+
const q = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
12
|
+
const check = (c) => {
|
|
13
|
+
for (const value of [c.dataDir, c.configPath, c.storePath, c.repoRoot, c.nodePath, c.logDir]) {
|
|
14
|
+
if (!path.isAbsolute(value) || /[\x00-\x1f\x7f]/.test(value) || path.normalize(value) !== value)
|
|
15
|
+
throw new Error("service.invalid-path");
|
|
16
|
+
}
|
|
17
|
+
if (c.configPath !== path.join(c.dataDir, "agent-config.json") || c.storePath !== path.join(c.dataDir, "murmur.db")
|
|
18
|
+
|| c.logDir !== path.join(c.dataDir, "logs"))
|
|
19
|
+
throw new Error("service.conflicting-store-path");
|
|
20
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,100}$/.test(c.serviceName))
|
|
21
|
+
throw new Error("service.invalid-name");
|
|
22
|
+
};
|
|
23
|
+
export function renderLinuxUnit(c) {
|
|
24
|
+
check(c);
|
|
25
|
+
// ExecStart expands $ independently from specifiers; Environment values do not.
|
|
26
|
+
const arg = (v) => q(v.replaceAll("$", () => "$$"));
|
|
27
|
+
return `${marker}[Unit]\nDescription=Murmur agent daemon\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nWorkingDirectory=${c.repoRoot.replaceAll("%", "%%")}\nEnvironment=${q(`DATA_DIR=${c.dataDir}`)}\nExecStart=${arg(c.nodePath)} ${arg(path.join(c.repoRoot, "scripts", "murmur-daemon.mjs"))}\nStandardOutput=append:${path.join(c.logDir, `${c.serviceName}.out.log`).replaceAll("%", "%%")}\nStandardError=append:${path.join(c.logDir, `${c.serviceName}.err.log`).replaceAll("%", "%%")}\nRestart=on-failure\nRestartSec=5\nUMask=0077\n\n[Install]\nWantedBy=default.target\n`;
|
|
28
|
+
}
|
|
29
|
+
export function createLinuxAdapter(options = {}) {
|
|
30
|
+
const env = options.env ?? process.env, home = options.homeDir ?? homedir();
|
|
31
|
+
const run = options.run ?? (async (args) => {
|
|
32
|
+
try {
|
|
33
|
+
return (await exec("systemctl", ["--user", ...args], { timeout: 5000, maxBuffer: 256 * 1024 })).stdout;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const result = e;
|
|
37
|
+
if (args[0] === "show" && /^LoadState=not-found$/m.test(result.stdout ?? ""))
|
|
38
|
+
return result.stdout;
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const dir = path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "systemd", "user");
|
|
43
|
+
if (!path.isAbsolute(dir))
|
|
44
|
+
throw new Error("service.invalid-config-directory");
|
|
45
|
+
const target = (c) => path.join(dir, unit(c));
|
|
46
|
+
const inspect = async (c) => Object.fromEntries((await run(["show", unit(c), "--property=LoadState,ActiveState,SubState,MainPID,ExecMainStatus,ActiveEnterTimestamp,FragmentPath"]))
|
|
47
|
+
.trim().split("\n").filter(line => line.includes("=")).map(line => { const i = line.indexOf("="); return [line.slice(0, i), line.slice(i + 1)]; }));
|
|
48
|
+
const sameLoadedPath = (c, values) => {
|
|
49
|
+
if (values.LoadState !== "not-found" && values.FragmentPath !== target(c))
|
|
50
|
+
throw new Error("service.foreign-loaded-unit");
|
|
51
|
+
};
|
|
52
|
+
async function readOwned(c) {
|
|
53
|
+
let handle;
|
|
54
|
+
try {
|
|
55
|
+
handle = await open(target(c), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
56
|
+
const info = await handle.stat();
|
|
57
|
+
if (!info.isFile() || info.size > 65536 || info.uid !== process.getuid?.())
|
|
58
|
+
throw new Error("service.foreign-unit-file");
|
|
59
|
+
const text = await handle.readFile("utf8");
|
|
60
|
+
if (!text.startsWith(marker))
|
|
61
|
+
throw new Error("service.unmanaged-unit");
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e.code === "ENOENT")
|
|
66
|
+
return null;
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await handle?.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function requireProfile(c) {
|
|
74
|
+
check(c);
|
|
75
|
+
sameLoadedPath(c, await inspect(c));
|
|
76
|
+
if (await readOwned(c) !== renderLinuxUnit(c))
|
|
77
|
+
throw new Error("service.profile-mismatch");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
manager: "systemd",
|
|
81
|
+
async status(c) {
|
|
82
|
+
check(c);
|
|
83
|
+
const result = { state: "unknown", manager: "systemd", since: null, pid: null,
|
|
84
|
+
lastExitCode: null, observedStorePath: null, restartCount: null, restartWindowMs: null };
|
|
85
|
+
let values;
|
|
86
|
+
try {
|
|
87
|
+
values = await inspect(c);
|
|
88
|
+
sameLoadedPath(c, values);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { ...result, detail: "service.manager-or-profile-unavailable" };
|
|
92
|
+
}
|
|
93
|
+
if (values.LoadState === "not-found")
|
|
94
|
+
return { ...result, state: "stopped", detail: "service.not-installed" };
|
|
95
|
+
result.state = values.ActiveState === "failed" ? "failed" : values.ActiveState === "active" && values.SubState === "running" ? "running"
|
|
96
|
+
: values.ActiveState === "inactive" ? "stopped" : "unknown";
|
|
97
|
+
result.pid = Number(values.MainPID) > 0 ? Number(values.MainPID) : null;
|
|
98
|
+
result.lastExitCode = /^\d+$/.test(values.ExecMainStatus ?? "") ? Number(values.ExecMainStatus) : null;
|
|
99
|
+
const started = Date.parse(values.ActiveEnterTimestamp ?? "");
|
|
100
|
+
result.since = Number.isFinite(started) ? new Date(started).toISOString() : null;
|
|
101
|
+
if (result.pid) {
|
|
102
|
+
try {
|
|
103
|
+
const expected = await realpath(c.storePath), fdDir = path.join(options.procDir ?? "/proc", String(result.pid), "fd");
|
|
104
|
+
for (const fd of await readdir(fdDir)) {
|
|
105
|
+
try {
|
|
106
|
+
if (await readlink(path.join(fdDir, fd)) === expected) {
|
|
107
|
+
result.observedStorePath = expected;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch { /* Inaccessible descriptors mean unverified, not guessed from config. */ }
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
},
|
|
118
|
+
async install(c) {
|
|
119
|
+
check(c);
|
|
120
|
+
const values = await inspect(c);
|
|
121
|
+
sameLoadedPath(c, values);
|
|
122
|
+
const previous = await readOwned(c), content = renderLinuxUnit(c);
|
|
123
|
+
if (previous !== content && !["inactive", "failed", undefined].includes(values.ActiveState))
|
|
124
|
+
throw new Error("service.stop-before-update");
|
|
125
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
126
|
+
await mkdir(c.logDir, { recursive: true, mode: 0o700 });
|
|
127
|
+
if (previous !== content) {
|
|
128
|
+
if (previous !== null)
|
|
129
|
+
await writeFile(`${target(c)}.backup-${randomUUID()}`, previous, { flag: "wx", mode: 0o600 });
|
|
130
|
+
const temporary = `${target(c)}.${randomUUID()}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
await writeFile(temporary, content, { flag: "wx", mode: 0o600 });
|
|
133
|
+
await rename(temporary, target(c));
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await unlink(temporary).catch(() => { });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await run(["daemon-reload"]);
|
|
140
|
+
await run(["enable", unit(c)]);
|
|
141
|
+
},
|
|
142
|
+
async start(c) { await requireProfile(c); await run(["start", unit(c)]); },
|
|
143
|
+
async stop(c) {
|
|
144
|
+
check(c);
|
|
145
|
+
const values = await inspect(c);
|
|
146
|
+
if (values.LoadState === "not-found")
|
|
147
|
+
return;
|
|
148
|
+
await requireProfile(c);
|
|
149
|
+
await run(["stop", unit(c)]);
|
|
150
|
+
},
|
|
151
|
+
async detectClients() {
|
|
152
|
+
const rows = [];
|
|
153
|
+
for (const [id, command, configPath, format] of [
|
|
154
|
+
["claude-code", "claude", env.CLAUDE_CONFIG_DIR ? null : path.join(home, ".claude.json"), "json"],
|
|
155
|
+
["codex-cli", "codex", path.join(env.CODEX_HOME || path.join(home, ".codex"), "config.toml"), "toml"],
|
|
156
|
+
]) {
|
|
157
|
+
let installed = false;
|
|
158
|
+
for (const candidate of (env.PATH ?? "").split(path.delimiter)) {
|
|
159
|
+
if (!path.isAbsolute(candidate))
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
const file = path.join(candidate, command);
|
|
163
|
+
await access(file, constants.X_OK);
|
|
164
|
+
if ((await stat(file)).isFile()) {
|
|
165
|
+
installed = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch { }
|
|
170
|
+
}
|
|
171
|
+
rows.push({ id, installed, configPath, format, ...(configPath === null ? { detail: "Custom Claude profile requires client verification" } : {}) });
|
|
172
|
+
}
|
|
173
|
+
return rows;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PlatformAdapter } from '../types.js';
|
|
2
|
+
export interface WindowsOptions {
|
|
3
|
+
helperPath?: string;
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
homeDir?: string;
|
|
6
|
+
canonicalize?: (value: string) => Promise<string>;
|
|
7
|
+
run?: (file: string, args: string[], options: {
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
timeout: number;
|
|
10
|
+
}) => Promise<{
|
|
11
|
+
code: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** true/false when the token's integrity level was measured, null when it could not be. */
|
|
16
|
+
elevated?: () => Promise<boolean | null>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* murmur-svc ends a failure with exactly one footer line `murmur-svc: reason=<key>` (LF). Only that
|
|
20
|
+
* strict last line is trusted and only its key is kept, case preserved; the rest of stderr may carry
|
|
21
|
+
* paths or OS text and is never read. Anything else keeps the old generic code.
|
|
22
|
+
*/
|
|
23
|
+
export declare function helperReason(stderr: string): string;
|
|
24
|
+
/** Native SCM helper is the only service observer and mutator. No shell, PID guessing or registry fallback. */
|
|
25
|
+
export declare function createWindowsAdapter(options?: WindowsOptions): PlatformAdapter;
|