machine-bridge-mcp 0.11.0 → 0.12.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.
@@ -0,0 +1,56 @@
1
+ export async function stopAndRemoveAutostart({
2
+ states = [],
3
+ stateRoot,
4
+ logger = console,
5
+ reason = "service uninstall",
6
+ stopAutostart,
7
+ uninstallAutostart,
8
+ stopWorkspaceServiceDaemon,
9
+ } = {}) {
10
+ assertFunction(stopAutostart, "stopAutostart");
11
+ assertFunction(uninstallAutostart, "uninstallAutostart");
12
+ assertFunction(stopWorkspaceServiceDaemon, "stopWorkspaceServiceDaemon");
13
+
14
+ const platformStop = await stopAutostart({ logger });
15
+ if (platformStop?.ok !== true) {
16
+ return {
17
+ ok: false,
18
+ removed: false,
19
+ reason: "platform_stop_failed",
20
+ platform_stop: platformStop,
21
+ workspace_daemons: [],
22
+ removal: null,
23
+ };
24
+ }
25
+
26
+ const workspaceDaemons = [];
27
+ for (const state of states) {
28
+ const result = await stopWorkspaceServiceDaemon(state, { logger, reason });
29
+ workspaceDaemons.push({ workspace: state?.workspace?.path || null, ...result });
30
+ if (result.found && !result.ok) {
31
+ return {
32
+ ok: false,
33
+ removed: false,
34
+ reason: "workspace_daemon_stop_failed",
35
+ platform_stop: platformStop,
36
+ workspace_daemons: workspaceDaemons,
37
+ removal: null,
38
+ };
39
+ }
40
+ }
41
+
42
+ const removal = await uninstallAutostart({ stateRoot, logger });
43
+ const removed = removal?.ok === true;
44
+ return {
45
+ ok: removed,
46
+ removed,
47
+ reason: removed ? "removed" : "platform_remove_failed",
48
+ platform_stop: platformStop,
49
+ workspace_daemons: workspaceDaemons,
50
+ removal,
51
+ };
52
+ }
53
+
54
+ function assertFunction(value, name) {
55
+ if (typeof value !== "function") throw new Error(`${name} is required`);
56
+ }
@@ -1,9 +1,9 @@
1
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
1
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, writeSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { run } from "./shell.mjs";
5
5
  import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
6
- import { replaceFileSync } from "./atomic-fs.mjs";
6
+ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
7
7
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
8
8
 
9
9
  const LABEL = "dev.machine-bridge-mcp.daemon";
@@ -40,17 +40,34 @@ export async function autostartStatus({ logger = console } = {}) {
40
40
 
41
41
  export async function startAutostart({ logger = console } = {}) {
42
42
  if (process.platform === "darwin") return startLaunchd(logger);
43
- if (process.platform === "win32") return serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
44
- return serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]);
43
+ if (process.platform === "win32") {
44
+ return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]));
45
+ }
46
+ return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]));
45
47
  }
46
48
 
47
49
  export async function stopAutostart({ logger = console } = {}) {
48
50
  if (process.platform === "darwin") return stopLaunchd(logger);
49
- if (process.platform === "win32") return serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
50
- return serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]);
51
+ if (process.platform === "win32") {
52
+ return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]), { allowAlreadyStopped: true });
53
+ }
54
+ return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]), { allowAlreadyStopped: true });
51
55
  }
52
56
 
53
57
 
58
+
59
+ export function normalizeServiceCommandResult(provider, result, { allowAlreadyStopped = false } = {}) {
60
+ const detail = `${result?.stdout || ""}
61
+ ${result?.stderr || ""}`;
62
+ const alreadyStopped = allowAlreadyStopped && /(?:not loaded|not found|does not exist|cannot find|not running|inactive)/i.test(detail);
63
+ return {
64
+ ...result,
65
+ ok: result?.code === 0 || alreadyStopped,
66
+ provider,
67
+ already_stopped: alreadyStopped,
68
+ };
69
+ }
70
+
54
71
  export function trimAutostartLogs(stateRoot, options = {}) {
55
72
  const root = expandHome(stateRoot);
56
73
  const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
@@ -233,21 +250,26 @@ function writePrivateServiceFile(file, content) {
233
250
  const info = lstatSync(file);
234
251
  if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
235
252
  }
236
- const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
237
- try {
238
- writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
239
- replaceFileSync(temporary, file);
240
- ownerOnlyFile(file);
241
- } catch (error) {
242
- try { rmSync(temporary, { force: true }); } catch {}
243
- throw error;
244
- }
253
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
254
+ ownerOnlyFile(file);
245
255
  }
246
256
 
247
257
  function launchdPlistPath() {
248
258
  return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
249
259
  }
250
260
 
261
+ export function launchdServiceTarget(uid = process.getuid?.()) {
262
+ const parsed = Number(uid);
263
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error("launchd requires a numeric user id");
264
+ return `gui/${parsed}/${LABEL}`;
265
+ }
266
+
267
+ function launchdDomainTarget(uid = process.getuid?.()) {
268
+ const parsed = Number(uid);
269
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error("launchd requires a numeric user id");
270
+ return `gui/${parsed}`;
271
+ }
272
+
251
273
  async function installLaunchd(spec, logger) {
252
274
  const plistPath = launchdPlistPath();
253
275
  mkdirSync(path.dirname(plistPath), { recursive: true });
@@ -259,34 +281,73 @@ async function installLaunchd(spec, logger) {
259
281
 
260
282
  async function startLaunchd(logger) {
261
283
  const plistPath = launchdPlistPath();
262
- const target = `gui/${process.getuid?.() ?? ""}`;
284
+ const target = launchdDomainTarget();
263
285
  if (!existsSync(plistPath)) return { ok: false, error: "launchd plist not installed" };
264
- await serviceRun("launchctl", ["bootout", target, plistPath]);
286
+ const stopped = await stopLaunchd({ info() {}, warn() {} });
287
+ if (!stopped.ok) return { ok: false, provider: "launchd", stop: stopped };
265
288
  const boot = await serviceRun("launchctl", ["bootstrap", target, plistPath]);
266
289
  const kick = await serviceRun("launchctl", ["kickstart", "-k", `${target}/${LABEL}`]);
267
- logger.info?.("launchd service started");
268
- return { ok: boot.code === 0 || kick.code === 0, bootstrap: boot, kickstart: kick };
290
+ const ok = boot.code === 0 || kick.code === 0;
291
+ if (ok) logger.info?.("launchd service started");
292
+ else logger.warn?.("launchd service failed to start");
293
+ return { ok, provider: "launchd", bootstrap: boot, kickstart: kick };
269
294
  }
270
295
 
271
296
  async function stopLaunchd(logger) {
272
297
  const plistPath = launchdPlistPath();
273
- const target = `gui/${process.getuid?.() ?? ""}`;
274
- const result = await serviceRun("launchctl", ["bootout", target, plistPath]);
275
- logger.info?.("launchd service stopped");
276
- return result;
298
+ const domainTarget = launchdDomainTarget();
299
+ const serviceTarget = launchdServiceTarget();
300
+ const before = await statusLaunchd();
301
+ if (!before.active) {
302
+ logger.info?.("launchd service is not loaded");
303
+ return {
304
+ ok: true,
305
+ provider: "launchd",
306
+ installed: existsSync(plistPath),
307
+ active_before: false,
308
+ active: false,
309
+ already_stopped: true,
310
+ code: 0,
311
+ stdout: "",
312
+ stderr: "",
313
+ };
314
+ }
315
+
316
+ const byServiceTarget = await serviceRun("launchctl", ["bootout", serviceTarget]);
317
+ const byPlist = byServiceTarget.code === 0
318
+ ? null
319
+ : await serviceRun("launchctl", ["bootout", domainTarget, plistPath]);
320
+ const after = await statusLaunchd();
321
+ const rawResult = byPlist || byServiceTarget;
322
+ const ok = !after.active;
323
+ if (ok) logger.info?.("launchd service stopped");
324
+ else logger.warn?.("launchd service is still active after the stop request");
325
+ return {
326
+ ...rawResult,
327
+ ok,
328
+ provider: "launchd",
329
+ installed: existsSync(plistPath),
330
+ active_before: true,
331
+ active: after.active,
332
+ already_stopped: false,
333
+ code: ok ? 0 : rawResult.code,
334
+ bootout_service_target: byServiceTarget,
335
+ ...(byPlist ? { bootout_plist_fallback: byPlist } : {}),
336
+ };
277
337
  }
278
338
 
279
339
  async function uninstallLaunchd(logger) {
280
- await stopLaunchd(logger).catch(() => {});
340
+ const stopped = await stopLaunchd(logger);
281
341
  const plistPath = launchdPlistPath();
342
+ if (!stopped.ok) return { ok: false, provider: "launchd", path: plistPath, stop: stopped };
282
343
  if (existsSync(plistPath)) rmSync(plistPath, { force: true });
283
344
  logger.info?.("Autostart removed.");
284
- return { ok: true, provider: "launchd", path: plistPath };
345
+ return { ok: true, provider: "launchd", path: plistPath, stop: stopped };
285
346
  }
286
347
 
287
348
  async function statusLaunchd() {
288
349
  const plistPath = launchdPlistPath();
289
- const target = `gui/${process.getuid?.() ?? ""}/${LABEL}`;
350
+ const target = launchdServiceTarget();
290
351
  const result = await serviceRun("launchctl", ["print", target]);
291
352
  return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
292
353
  }
@@ -328,17 +389,28 @@ async function installSystemd(spec, logger) {
328
389
  const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
329
390
  const enable = await serviceRun("systemctl", ["--user", "enable", "machine-bridge-mcp.service"]);
330
391
  const linger = await serviceRun("loginctl", ["enable-linger", os.userInfo().username]);
331
- logger.info?.("Autostart installed.");
332
- return { ok: reload.code === 0 && enable.code === 0, provider: "systemd", path: servicePath, reload, enable, linger };
392
+ const ok = reload.code === 0 && enable.code === 0;
393
+ if (ok) logger.info?.("Autostart installed.");
394
+ else logger.warn?.("Autostart installation failed; the service definition was kept for diagnosis.");
395
+ return { ok, provider: "systemd", path: servicePath, reload, enable, linger };
333
396
  }
334
397
 
335
398
  async function uninstallSystemd(logger) {
336
- await serviceRun("systemctl", ["--user", "disable", "--now", "machine-bridge-mcp.service"]);
337
399
  const servicePath = systemdPath();
400
+ const disable = await serviceRun("systemctl", ["--user", "disable", "--now", "machine-bridge-mcp.service"]);
401
+ const activeCheck = await serviceRun("systemctl", ["--user", "is-active", "machine-bridge-mcp.service"]);
402
+ const active = activeCheck.code === 0;
403
+ if (active) {
404
+ logger.warn?.("systemd service is still active; its definition was not removed.");
405
+ return { ok: false, provider: "systemd", path: servicePath, disable, active_check: activeCheck, active: true };
406
+ }
338
407
  if (existsSync(servicePath)) rmSync(servicePath, { force: true });
339
- await serviceRun("systemctl", ["--user", "daemon-reload"]);
340
- logger.info?.("Autostart removed.");
341
- return { ok: true, provider: "systemd", path: servicePath };
408
+ const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
409
+ const ok = reload.code === 0 && (disable.code === 0 || /not loaded|not found|does not exist/i.test(`${disable.stdout}
410
+ ${disable.stderr}`));
411
+ if (ok) logger.info?.("Autostart removed.");
412
+ else logger.warn?.("Autostart removal reported an error.");
413
+ return { ok, provider: "systemd", path: servicePath, disable, active_check: activeCheck, reload, active: false };
342
414
  }
343
415
 
344
416
  async function statusSystemd() {
@@ -370,14 +442,21 @@ WantedBy=default.target
370
442
  async function installWindowsTask(spec, logger) {
371
443
  const command = windowsCommand(spec);
372
444
  const result = await serviceRun("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/TR", command, "/F"]);
373
- logger.info?.("Windows Scheduled Task installed for logon");
374
- return { ok: result.code === 0, provider: "schtasks", task: WINDOWS_TASK, result };
445
+ const ok = result.code === 0;
446
+ if (ok) logger.info?.("Windows Scheduled Task installed for logon");
447
+ else logger.warn?.("Windows Scheduled Task installation failed");
448
+ return { ok, provider: "schtasks", task: WINDOWS_TASK, result };
375
449
  }
376
450
 
377
451
  async function uninstallWindowsTask(logger) {
452
+ const end = await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
378
453
  const result = await serviceRun("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
379
- logger.info?.("Windows Scheduled Task removed");
380
- return { ok: result.code === 0 || /cannot find/i.test(result.stderr), provider: "schtasks", task: WINDOWS_TASK, result };
454
+ const missing = /cannot find|not exist|not found/i.test(`${result.stdout}
455
+ ${result.stderr}`);
456
+ const ok = result.code === 0 || missing;
457
+ if (ok) logger.info?.("Windows Scheduled Task removed");
458
+ else logger.warn?.("Windows Scheduled Task removal failed");
459
+ return { ok, provider: "schtasks", task: WINDOWS_TASK, end, result };
381
460
  }
382
461
 
383
462
  async function statusWindowsTask() {
@@ -12,6 +12,7 @@ export function run(command, args = [], options = {}) {
12
12
  env: options.env || process.env,
13
13
  stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
14
14
  shell: false,
15
+ detached: process.platform !== "win32",
15
16
  windowsHide: true,
16
17
  });
17
18
  let stdout = "";
@@ -26,9 +27,8 @@ export function run(command, args = [], options = {}) {
26
27
  if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
27
28
  timer = setTimeout(() => {
28
29
  timedOut = true;
29
- try { child.kill("SIGTERM"); } catch {}
30
- killTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 2000);
31
- killTimer.unref?.();
30
+ terminateCommandTree(child, false);
31
+ killTimer = setTimeout(() => terminateCommandTree(child, true), 2000);
32
32
  }, timeoutMs);
33
33
  timer.unref?.();
34
34
  }
@@ -36,7 +36,7 @@ export function run(command, args = [], options = {}) {
36
36
  if (settled) return;
37
37
  settled = true;
38
38
  if (timer) clearTimeout(timer);
39
- if (killTimer) clearTimeout(killTimer);
39
+ if (killTimer && !timedOut) clearTimeout(killTimer);
40
40
  callback();
41
41
  };
42
42
  if (capture) {
@@ -73,6 +73,25 @@ export function run(command, args = [], options = {}) {
73
73
  });
74
74
  }
75
75
 
76
+
77
+ function terminateCommandTree(child, force) {
78
+ if (!child?.pid) return;
79
+ if (process.platform === "win32") {
80
+ try {
81
+ const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", ...(force ? ["/F"] : [])], {
82
+ stdio: "ignore",
83
+ windowsHide: true,
84
+ });
85
+ killer.unref();
86
+ return;
87
+ } catch {}
88
+ }
89
+ const signal = force ? "SIGKILL" : "SIGTERM";
90
+ try { process.kill(-child.pid, signal); } catch {
91
+ try { child.kill(signal); } catch {}
92
+ }
93
+ }
94
+
76
95
  function appendLimited(current, chunk, max) {
77
96
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
78
97
  const budget = Math.max(0, max - Buffer.byteLength(current));