@euqns/nudge-mcp 1.23.0 → 1.26.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/dist/service.js CHANGED
@@ -1,4 +1,4 @@
1
- // Run the runner or the agent companion as a login service.
1
+ // Run the runner or the agent companion as a login service, and manage it.
2
2
  //
3
3
  // Both long-lived subcommands die with the terminal that started them, so a
4
4
  // reboot (or a closed laptop lid followed by a logout) silently takes the
@@ -9,39 +9,35 @@
9
9
  // macOS → ~/Library/LaunchAgents/com.nudge.<runner|agent>.plist (launchd)
10
10
  // Linux → ~/.config/systemd/user/nudge-<runner|agent>.service (systemd --user)
11
11
  //
12
- // The service runs THIS node binary and THIS installed copy of nudge-mcp, with
13
- // the caller's PATH baked in launchd and systemd start user services with a
14
- // bare PATH, and the runner needs `git`, `gh`, `claude`, `codex` and friends to
15
- // be findable. The in-process supervisor (runner-restart.ts) still handles
16
- // rebuilds; the OS service manager handles reboots and crashes.
12
+ // and gives both kinds the same verbs: install, uninstall, start, stop,
13
+ // restart, status, logs. The service runs THIS node binary and THIS installed
14
+ // copy of nudge-mcp, with the caller's PATH baked in launchd and systemd
15
+ // start user services with a bare PATH, and the runner needs `git`, `gh`,
16
+ // `claude`, `codex` and friends to be findable. The in-process supervisor
17
+ // (runner-restart.ts) still handles rebuilds; the OS service manager handles
18
+ // reboots and crashes.
17
19
  //
18
- // Windows has no user-level equivalent this code can write safely, so it gets
19
- // an actionable message instead of a half-working Task Scheduler entry.
20
- import { execFile } from "node:child_process";
20
+ // `status` and `install` never ask the user to read a log: the process itself
21
+ // reports in through ~/.nudge/state (service-state.ts) version, machine,
22
+ // board, listener, pairing URL — and when it fails to, the CLI shows the exit
23
+ // code and the last log lines inline.
24
+ //
25
+ // Windows has no user-level equivalent this code can write safely, so every
26
+ // lifecycle verb gets an actionable message instead of a half-working Task
27
+ // Scheduler entry; `status` still reports a process started from a terminal.
28
+ import { execFile, spawn } from "node:child_process";
21
29
  import fs from "node:fs/promises";
22
30
  import os from "node:os";
23
31
  import path from "node:path";
24
32
  import { promisify } from "node:util";
25
33
  import { CREDENTIALS_DIR } from "./credentials.js";
34
+ import { SERVICE_KINDS, SERVICE_LABEL_ENV, SERVICE_MANAGER_ENV, isStateLive, readProcessState, sanitizeInstance, serviceLabel, unitName, waitForProcessState, } from "./service-state.js";
35
+ import { NUDGE_MCP_VERSION } from "./version.js";
36
+ import { SERVICE_ACTIONS } from "./service-command-registry.js";
37
+ export { SERVICE_ACTIONS } from "./service-command-registry.js";
38
+ export { SERVICE_KINDS, sanitizeInstance, serviceLabel, unitName };
26
39
  const execFileAsync = promisify(execFile);
27
- export const SERVICE_KINDS = ["runner", "agent"];
28
40
  export const LOG_DIR = path.join(CREDENTIALS_DIR, "logs");
29
- /** Only [a-z0-9-] survive; anything else becomes a dash. `--instance "Mac 2"` → `mac-2`. */
30
- export function sanitizeInstance(raw) {
31
- return raw
32
- .toLowerCase()
33
- .replace(/[^a-z0-9]+/g, "-")
34
- .replace(/^-+|-+$/g, "");
35
- }
36
- export function serviceLabel(kind, instance) {
37
- const suffix = instance ? sanitizeInstance(instance) : "";
38
- return suffix ? `com.nudge.${kind}.${suffix}` : `com.nudge.${kind}`;
39
- }
40
- /** systemd unit names can't start with the reverse-DNS prefix comfortably;
41
- * `com.nudge.runner` → `nudge-runner`, `com.nudge.runner.mac-2` → `nudge-runner-mac-2`. */
42
- export function unitName(label) {
43
- return label.replace(/^com\.nudge\./, "nudge-").replace(/\./g, "-");
44
- }
45
41
  /** Value of `--flag VALUE` or `--flag=VALUE` in argv, or null. */
46
42
  export function findFlag(argv, flag) {
47
43
  for (let i = 0; i < argv.length; i++) {
@@ -52,6 +48,22 @@ export function findFlag(argv, flag) {
52
48
  }
53
49
  return null;
54
50
  }
51
+ /** argv without `--flag VALUE` / `--flag=VALUE` / bare `--flag` occurrences. */
52
+ export function stripFlag(argv, flag, takesValue) {
53
+ const out = [];
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const arg = argv[i];
56
+ if (arg === flag) {
57
+ if (takesValue)
58
+ i++;
59
+ continue;
60
+ }
61
+ if (arg.startsWith(`${flag}=`))
62
+ continue;
63
+ out.push(arg);
64
+ }
65
+ return out;
66
+ }
55
67
  /**
56
68
  * Rewrite `--cwd X` to an absolute path. The service has no notion of "the
57
69
  * directory I typed the install command in", so a relative `--cwd .` pasted
@@ -111,6 +123,13 @@ export function serviceEnv(kind, source = process.env) {
111
123
  }
112
124
  return env;
113
125
  }
126
+ export function managerForPlatform(platform) {
127
+ if (platform === "darwin")
128
+ return "launchd";
129
+ if (platform === "linux")
130
+ return "systemd";
131
+ return null;
132
+ }
114
133
  export function buildSpec(kind, argv, ctx) {
115
134
  // Flags pass through untouched (including `--save`: re-writing runner.json
116
135
  // with the same values at every start is idempotent), except that a
@@ -118,6 +137,11 @@ export function buildSpec(kind, argv, ctx) {
118
137
  const args = absolutizeCwd(argv, ctx.cwd);
119
138
  const label = serviceLabel(kind, kind === "runner" ? findFlag(args, "--instance") : null);
120
139
  const cwd = findFlag(args, "--cwd") ?? ctx.cwd;
140
+ const env = serviceEnv(kind, ctx.env);
141
+ if (ctx.manager) {
142
+ env[SERVICE_MANAGER_ENV] = ctx.manager;
143
+ env[SERVICE_LABEL_ENV] = label;
144
+ }
121
145
  return {
122
146
  kind,
123
147
  label,
@@ -125,7 +149,7 @@ export function buildSpec(kind, argv, ctx) {
125
149
  entry: ctx.entry,
126
150
  args,
127
151
  cwd,
128
- env: serviceEnv(kind, ctx.env),
152
+ env,
129
153
  logPath: path.join(LOG_DIR, `${label}.log`),
130
154
  };
131
155
  }
@@ -210,6 +234,17 @@ TimeoutStopSec=120
210
234
  WantedBy=default.target
211
235
  `;
212
236
  }
237
+ /** The nudge-mcp entry point an installed service file runs, so `status` can
238
+ * notice when an upgrade or uninstall moved it out from under the service. */
239
+ export function entryFromServiceFile(text) {
240
+ const plist = /<key>ProgramArguments<\/key>\s*<array>\s*<string>[^<]*<\/string>\s*<string>([^<]*)<\/string>/.exec(text);
241
+ if (plist)
242
+ return plist[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
243
+ const unit = /^ExecStart="[^"]*" "((?:[^"\\]|\\.)*)"/m.exec(text);
244
+ if (unit)
245
+ return unit[1].replace(/\\(.)/g, "$1");
246
+ return null;
247
+ }
213
248
  // ---- platform paths -------------------------------------------------------
214
249
  export function launchdPlistPath(label, home = os.homedir()) {
215
250
  return path.join(home, "Library", "LaunchAgents", `${label}.plist`);
@@ -240,184 +275,618 @@ async function run(cmd, args, opts) {
240
275
  throw new Error(`\`${cmd} ${args.join(" ")}\` failed${detail ? `: ${detail}` : ""}`);
241
276
  }
242
277
  }
243
- // ---- launchd (macOS) ------------------------------------------------------
244
- const gui = () => `gui/${process.getuid?.() ?? 501}`;
245
- async function launchdInstall(spec) {
246
- const plist = launchdPlistPath(spec.label);
247
- await fs.mkdir(LOG_DIR, { recursive: true, mode: 0o700 });
248
- await writePrivate(plist, renderLaunchdPlist(spec));
249
- // Replace rather than layer: bootstrapping a label that's already loaded
250
- // fails, and a stale definition would keep the old flags.
251
- await run("launchctl", ["bootout", `${gui()}/${spec.label}`], { ignoreFailure: true });
252
- await run("launchctl", ["bootstrap", gui(), plist]);
253
- return plist;
254
- }
255
- async function launchdUninstall(label) {
256
- const plist = launchdPlistPath(label);
257
- await run("launchctl", ["bootout", `${gui()}/${label}`], { ignoreFailure: true });
278
+ /** Stream a command's output to this terminal (for `logs`, esp. `--follow`). */
279
+ function stream(cmd, args) {
280
+ return new Promise((resolve, reject) => {
281
+ const child = spawn(cmd, args, { stdio: "inherit" });
282
+ child.once("error", (err) => reject(err.code === "ENOENT"
283
+ ? new Error(`\`${cmd}\` is not available on this machine.`)
284
+ : err));
285
+ child.once("exit", () => resolve());
286
+ });
287
+ }
288
+ async function fileExists(file) {
289
+ return fs.access(file).then(() => true, () => false);
290
+ }
291
+ async function tailFile(file, lines) {
258
292
  try {
259
- await fs.unlink(plist);
260
- return true;
293
+ const text = await fs.readFile(file, "utf8");
294
+ return text.split("\n").filter((l, i, all) => i < all.length - 1 || l !== "").slice(-lines).join("\n");
261
295
  }
262
- catch (err) {
263
- if (err.code === "ENOENT")
264
- return false;
265
- throw err;
296
+ catch {
297
+ return "";
266
298
  }
267
299
  }
268
- async function launchdStatus(label) {
269
- const plist = launchdPlistPath(label);
270
- const installed = await fs.access(plist).then(() => true, () => false);
271
- if (!installed)
272
- return "not installed";
273
- const out = await run("launchctl", ["print", `${gui()}/${label}`], { ignoreFailure: true });
274
- const pid = /^\s*pid = (\d+)/m.exec(out)?.[1];
275
- const state = /^\s*state = (\w+)/m.exec(out)?.[1];
276
- if (pid)
277
- return `running (pid ${pid})`;
278
- if (state)
279
- return state;
280
- return out ? "loaded" : "installed but not loaded (log in again, or run `nudge-mcp service install` once more)";
281
- }
282
- // ---- systemd --user (Linux) -----------------------------------------------
283
- async function systemdInstall(spec) {
284
- const unit = systemdUnitPath(spec.label);
285
- await writePrivate(unit, renderSystemdUnit(spec));
286
- await run("systemctl", ["--user", "daemon-reload"]);
287
- await run("systemctl", ["--user", "enable", "--now", path.basename(unit)]);
288
- return unit;
289
- }
290
- async function systemdUninstall(label) {
291
- const unit = systemdUnitPath(label);
292
- await run("systemctl", ["--user", "disable", "--now", path.basename(unit)], {
300
+ const gui = () => `gui/${process.getuid?.() ?? 501}`;
301
+ export const launchd = {
302
+ name: "launchd",
303
+ file: (label) => launchdPlistPath(label),
304
+ render: renderLaunchdPlist,
305
+ async install(spec) {
306
+ const plist = launchdPlistPath(spec.label);
307
+ await fs.mkdir(LOG_DIR, { recursive: true, mode: 0o700 });
308
+ await writePrivate(plist, renderLaunchdPlist(spec));
309
+ // Replace rather than layer: bootstrapping a label that's already loaded
310
+ // fails, and a stale definition would keep the old flags.
311
+ await run("launchctl", ["bootout", `${gui()}/${spec.label}`], { ignoreFailure: true });
312
+ await run("launchctl", ["bootstrap", gui(), plist]);
313
+ return plist;
314
+ },
315
+ async uninstall(label) {
316
+ const plist = launchdPlistPath(label);
317
+ await run("launchctl", ["bootout", `${gui()}/${label}`], { ignoreFailure: true });
318
+ try {
319
+ await fs.unlink(plist);
320
+ return true;
321
+ }
322
+ catch (err) {
323
+ if (err.code === "ENOENT")
324
+ return false;
325
+ throw err;
326
+ }
327
+ },
328
+ async start(label) {
329
+ const plist = launchdPlistPath(label);
330
+ // `bootstrap` fails when the label is already loaded — harmless here; a
331
+ // loaded-but-stopped service is what `kickstart` is for.
332
+ await run("launchctl", ["bootstrap", gui(), plist], { ignoreFailure: true });
333
+ await run("launchctl", ["kickstart", `${gui()}/${label}`]);
334
+ },
335
+ async stop(label) {
336
+ // Unloading (rather than killing) is the only way to stop a KeepAlive
337
+ // service without launchd restarting it. The plist stays, so it loads
338
+ // again at next login — same semantics as `systemctl --user stop`.
339
+ await run("launchctl", ["bootout", `${gui()}/${label}`]);
340
+ },
341
+ async restart(label) {
342
+ const plist = launchdPlistPath(label);
343
+ await run("launchctl", ["bootstrap", gui(), plist], { ignoreFailure: true });
344
+ await run("launchctl", ["kickstart", "-k", `${gui()}/${label}`]);
345
+ },
346
+ async status(label) {
347
+ const file = launchdPlistPath(label);
348
+ const installed = await fileExists(file);
349
+ const entry = installed ? entryFromServiceFile(await fs.readFile(file, "utf8")) : null;
350
+ if (!installed) {
351
+ return { installed, file, loaded: false, running: false, pid: null, lastExitCode: null, detail: null, entry };
352
+ }
353
+ const out = await run("launchctl", ["print", `${gui()}/${label}`], { ignoreFailure: true });
354
+ const pid = /^\s*pid = (\d+)/m.exec(out)?.[1];
355
+ const state = /^\s*state = (\w+)/m.exec(out)?.[1] ?? null;
356
+ const exit = /^\s*last exit code = (-?\d+|\(never exited\))/m.exec(out)?.[1];
357
+ return {
358
+ installed,
359
+ file,
360
+ loaded: out.trim().length > 0,
361
+ running: !!pid,
362
+ pid: pid ? Number(pid) : null,
363
+ lastExitCode: exit && exit !== "(never exited)" ? Number(exit) : null,
364
+ detail: state,
365
+ entry,
366
+ };
367
+ },
368
+ async logs(label, { lines, follow }) {
369
+ const file = path.join(LOG_DIR, `${label}.log`);
370
+ if (!(await fileExists(file))) {
371
+ throw new Error(`No log yet at ${file} — the service has not started.`);
372
+ }
373
+ await stream("tail", follow ? ["-n", String(lines), "-f", file] : ["-n", String(lines), file]);
374
+ },
375
+ recentLog: (label, lines) => tailFile(path.join(LOG_DIR, `${label}.log`), lines),
376
+ logHint: (label) => path.join(LOG_DIR, `${label}.log`),
377
+ installNotes: () => [],
378
+ };
379
+ export const systemd = {
380
+ name: "systemd",
381
+ file: (label) => systemdUnitPath(label),
382
+ render: renderSystemdUnit,
383
+ async install(spec) {
384
+ const unit = systemdUnitPath(spec.label);
385
+ await writePrivate(unit, renderSystemdUnit(spec));
386
+ await run("systemctl", ["--user", "daemon-reload"]);
387
+ // `restart` rather than `enable --now` alone: a re-install must load the
388
+ // new flags into an already-running unit.
389
+ await run("systemctl", ["--user", "enable", path.basename(unit)]);
390
+ await run("systemctl", ["--user", "restart", path.basename(unit)]);
391
+ return unit;
392
+ },
393
+ async uninstall(label) {
394
+ const unit = systemdUnitPath(label);
395
+ await run("systemctl", ["--user", "disable", "--now", path.basename(unit)], {
396
+ ignoreFailure: true,
397
+ });
398
+ let removed = true;
399
+ try {
400
+ await fs.unlink(unit);
401
+ }
402
+ catch (err) {
403
+ if (err.code !== "ENOENT")
404
+ throw err;
405
+ removed = false;
406
+ }
407
+ await run("systemctl", ["--user", "daemon-reload"], { ignoreFailure: true });
408
+ return removed;
409
+ },
410
+ start: (label) => run("systemctl", ["--user", "start", `${unitName(label)}.service`]).then(() => undefined),
411
+ stop: (label) => run("systemctl", ["--user", "stop", `${unitName(label)}.service`]).then(() => undefined),
412
+ restart: (label) => run("systemctl", ["--user", "restart", `${unitName(label)}.service`]).then(() => undefined),
413
+ async status(label) {
414
+ const file = systemdUnitPath(label);
415
+ const installed = await fileExists(file);
416
+ const entry = installed ? entryFromServiceFile(await fs.readFile(file, "utf8")) : null;
417
+ if (!installed) {
418
+ return { installed, file, loaded: false, running: false, pid: null, lastExitCode: null, detail: null, entry };
419
+ }
420
+ const out = await run("systemctl", ["--user", "show", `${unitName(label)}.service`, "-p", "ActiveState,SubState,MainPID,ExecMainStatus,UnitFileState"], { ignoreFailure: true });
421
+ const prop = (name) => new RegExp(`^${name}=(.*)$`, "m").exec(out)?.[1]?.trim() ?? null;
422
+ const pid = Number(prop("MainPID") ?? 0);
423
+ const active = prop("ActiveState");
424
+ const sub = prop("SubState");
425
+ const exitStatus = prop("ExecMainStatus");
426
+ const unitFileState = prop("UnitFileState");
427
+ return {
428
+ installed,
429
+ file,
430
+ loaded: unitFileState === "enabled" || unitFileState === "static" || active === "active",
431
+ running: active === "active" && pid > 0,
432
+ pid: pid > 0 ? pid : null,
433
+ lastExitCode: exitStatus !== null && exitStatus !== "" ? Number(exitStatus) : null,
434
+ detail: active ? (sub && sub !== active ? `${active} (${sub})` : active) : null,
435
+ entry,
436
+ };
437
+ },
438
+ async logs(label, { lines, follow }) {
439
+ const args = ["--user", "-u", `${unitName(label)}.service`, "-n", String(lines), "--no-pager"];
440
+ if (follow)
441
+ args.push("-f");
442
+ await stream("journalctl", args);
443
+ },
444
+ recentLog: (label, lines) => run("journalctl", ["--user", "-u", `${unitName(label)}.service`, "-n", String(lines), "--no-pager", "-o", "cat"], {
293
445
  ignoreFailure: true,
294
- });
295
- let removed = true;
296
- try {
297
- await fs.unlink(unit);
446
+ }).then((s) => s.trim()),
447
+ logHint: (label) => `journalctl --user -u ${unitName(label)} -f`,
448
+ installNotes: () => [
449
+ ` Boot: loginctl enable-linger ${os.userInfo().username} # start before anyone logs in`,
450
+ ],
451
+ };
452
+ function nudgeCmd(kind, verb, label) {
453
+ const instance = /^com\.nudge\.(?:runner|agent)\.(.+)$/.exec(label)?.[1];
454
+ return `nudge-mcp service ${verb} ${kind}${instance ? ` --instance ${instance}` : ""}`;
455
+ }
456
+ /** Turn raw facts into the summary, problems and next steps a person needs.
457
+ * Pure, so every branch is unit-testable without a service manager. */
458
+ export function diagnose(input) {
459
+ const { kind, label, service, process, live } = input;
460
+ const problems = [];
461
+ const nextSteps = [];
462
+ let summary;
463
+ let recentLog = null;
464
+ const startCmd = nudgeCmd(kind, "start", label);
465
+ const logsCmd = nudgeCmd(kind, "logs", label);
466
+ const installCmd = kind === "runner"
467
+ ? "nudge-mcp service install runner --cwd <checkout> [--token nrun_… --convex-url …] --save"
468
+ : "nudge-mcp service install agent --no-open";
469
+ if (!service) {
470
+ // No service manager on this platform: only the process report counts.
471
+ if (live) {
472
+ summary = `running in a terminal (pid ${process.pid})`;
473
+ problems.push(`Login services are not supported on ${input.platform}; this process dies with its terminal.`);
474
+ nextSteps.push(`Keep the terminal open, or add a Task Scheduler task that runs \`nudge-mcp ${kind}\` at log-on.`);
475
+ }
476
+ else {
477
+ summary = "not running";
478
+ problems.push(`Login services are not supported on ${input.platform}.`);
479
+ nextSteps.push(`Start it in a terminal: nudge-mcp ${kind}`);
480
+ }
298
481
  }
299
- catch (err) {
300
- if (err.code !== "ENOENT")
301
- throw err;
302
- removed = false;
482
+ else if (!service.installed) {
483
+ if (live) {
484
+ summary = `running in a terminal (pid ${process.pid}), not installed as a service`;
485
+ problems.push("This process will not come back after a reboot or a closed terminal.");
486
+ nextSteps.push(`Install it as a login service: ${installCmd}`);
487
+ }
488
+ else {
489
+ summary = "not installed";
490
+ nextSteps.push(installCmd);
491
+ }
492
+ }
493
+ else if (service.running) {
494
+ if (live) {
495
+ summary = `running (pid ${service.pid})`;
496
+ }
497
+ else if (process && !live) {
498
+ summary = `running (pid ${service.pid}), process has not reported in yet`;
499
+ problems.push("The service is up but the process has not written its status yet (starting, or an older build).");
500
+ nextSteps.push(`Wait a few seconds and run ${nudgeCmd(kind, "status", label)} again; if it stays this way: ${logsCmd}`);
501
+ }
502
+ else {
503
+ summary = `running (pid ${service.pid}), no status report yet`;
504
+ nextSteps.push(`Starting up? Run ${nudgeCmd(kind, "status", label)} again in a few seconds. Still nothing? The service runs a build older than 1.24 — \`${nudgeCmd(kind, "restart", label)}\` loads the installed copy${kind === "agent" ? " (and prints the new pairing URL)" : ""}.`);
505
+ }
506
+ if (input.entryExists === false && service.entry) {
507
+ problems.push(`The installed copy at ${service.entry} no longer exists — the running process is the last one that will start.`);
508
+ nextSteps.push(`Reinstall from the current copy: nudge-mcp service install ${kind} …`);
509
+ }
303
510
  }
304
- await run("systemctl", ["--user", "daemon-reload"], { ignoreFailure: true });
305
- return removed;
511
+ else {
512
+ // Installed, not running.
513
+ if (input.entryExists === false && service.entry) {
514
+ summary = "installed, cannot start";
515
+ problems.push(`The service points at ${service.entry}, which no longer exists (an npm upgrade or uninstall moved it).`);
516
+ nextSteps.push(`Reinstall from the current copy: nudge-mcp service install ${kind} …`);
517
+ }
518
+ else if (!service.loaded) {
519
+ summary = "installed, not loaded in this login session";
520
+ problems.push("The OS has not loaded the service (it was stopped, or installed from another session).");
521
+ nextSteps.push(startCmd);
522
+ }
523
+ else if (service.lastExitCode !== null && service.lastExitCode !== 0) {
524
+ summary = `stopped — last run exited with code ${service.lastExitCode}`;
525
+ problems.push(`The process exited with code ${service.lastExitCode}; the OS is holding it back or has given up.`);
526
+ recentLog = input.recentLog?.trim() ? input.recentLog.trim() : null;
527
+ nextSteps.push(`Fix the error above, then: ${startCmd}`);
528
+ nextSteps.push(`Full log: ${logsCmd}`);
529
+ }
530
+ else {
531
+ summary = `stopped${service.detail ? ` (${service.detail})` : ""}`;
532
+ nextSteps.push(startCmd);
533
+ }
534
+ }
535
+ return {
536
+ kind,
537
+ label,
538
+ platform: input.platform,
539
+ manager: input.manager,
540
+ service,
541
+ process,
542
+ live,
543
+ summary,
544
+ problems,
545
+ nextSteps,
546
+ recentLog,
547
+ };
306
548
  }
307
- async function systemdStatus(label) {
308
- const unit = systemdUnitPath(label);
309
- const installed = await fs.access(unit).then(() => true, () => false);
310
- if (!installed)
311
- return "not installed";
312
- const out = await run("systemctl", ["--user", "is-active", path.basename(unit)], {
313
- ignoreFailure: true,
314
- });
315
- return out.trim() || "inactive";
549
+ function ago(at, now) {
550
+ const s = Math.max(0, Math.round((now - at) / 1000));
551
+ if (s < 60)
552
+ return `${s}s ago`;
553
+ const m = Math.floor(s / 60);
554
+ if (m < 60)
555
+ return `${m}m ago`;
556
+ const h = Math.floor(m / 60);
557
+ if (h < 48)
558
+ return `${h}h ${m % 60}m ago`;
559
+ return `${Math.floor(h / 24)}d ago`;
560
+ }
561
+ function short(id) {
562
+ return id.length > 12 ? `${id.slice(0, 8)}…` : id;
563
+ }
564
+ /** Render one service's block for humans. */
565
+ export function formatStatus(report, opts = {}) {
566
+ const now = opts.now ?? Date.now();
567
+ const { kind, label, service, process: p, live } = report;
568
+ const lines = [];
569
+ lines.push(`${kind.padEnd(7)} ${label}`);
570
+ lines.push(` Service: ${report.manager ?? "none"} · ${report.summary}${service?.installed ? ` · ${service.file}` : ""}`);
571
+ if (p && live) {
572
+ const managed = p.service ? `managed by ${p.service.manager}` : p.supervised ? "auto-restart on rebuild" : "foreground";
573
+ lines.push(` Process: nudge-mcp ${p.version} · pid ${p.pid} · started ${ago(p.startedAt, now)} · ${managed}`);
574
+ lines.push(` Machine: ${p.hostname}${p.kind === "runner" ? ` · id ${short(p.machineId)}${p.instance ? ` · instance ${p.instance}` : ""}` : ""}`);
575
+ if (p.kind === "runner") {
576
+ lines.push(` Board: ${p.board.title}${p.board.repo ? ` (${p.board.repo})` : ""} · ${p.convexUrl}`);
577
+ lines.push(` Checkout: ${p.cwd}`);
578
+ lines.push(` Agents: ${p.engines.length ? p.engines.join(", ") : "none detected"} · up to ${p.concurrency} job${p.concurrency === 1 ? "" : "s"} at once`);
579
+ }
580
+ else {
581
+ lines.push(` Listener: http://127.0.0.1:${p.port} · app ${p.appUrl}`);
582
+ lines.push(` Workspace: ${p.cwd}`);
583
+ lines.push(` Agents: ${p.providers.length ? p.providers.join(", ") : "none ready"}`);
584
+ lines.push(` Pairing: ${p.pairingUrl}`);
585
+ lines.push(" (current — open it in the browser where Nudge is signed in)");
586
+ }
587
+ }
588
+ else if (p && !live) {
589
+ lines.push(` Process: last reported nudge-mcp ${p.version} on ${p.hostname}, ${ago(p.updatedAt ?? p.startedAt, now)} (pid ${p.pid} is gone)`);
590
+ if (p.kind === "agent")
591
+ lines.push(" Pairing: none current — a stopped companion's URL is not shown; start it and run status again");
592
+ }
593
+ if (report.recentLog) {
594
+ lines.push(" Last log lines:");
595
+ for (const l of report.recentLog.split("\n").slice(-12))
596
+ lines.push(` ${l}`);
597
+ }
598
+ for (const problem of report.problems)
599
+ lines.push(` ⚠ ${problem}`);
600
+ if (report.nextSteps.length) {
601
+ lines.push(` ${report.nextSteps.length === 1 ? "Next: " : "Next:"}${report.nextSteps.length === 1 ? ` ${report.nextSteps[0]}` : ""}`);
602
+ if (report.nextSteps.length > 1)
603
+ for (const step of report.nextSteps)
604
+ lines.push(` - ${step}`);
605
+ }
606
+ if (opts.logHint && (service?.installed || live))
607
+ lines.push(` Logs: ${nudgeCmd(kind, "logs", label)} (${opts.logHint})`);
608
+ return lines.join("\n");
316
609
  }
317
610
  // ---- CLI ------------------------------------------------------------------
318
611
  export const SERVICE_USAGE = [
319
612
  "Usage:",
320
613
  " nudge-mcp service install runner [runner options] Start the runner at login and keep it alive.",
321
614
  " nudge-mcp service install agent [agent options] Same for the Nudge Agent companion.",
322
- " nudge-mcp service uninstall runner|agent [--instance NAME]",
323
- " nudge-mcp service status",
615
+ " nudge-mcp service uninstall runner|agent Stop it and remove the login service.",
616
+ " nudge-mcp service start|stop|restart runner|agent Control the installed service now.",
617
+ " nudge-mcp service status [runner|agent] [--all] [--json] State, version, machine, board / pairing URL, and what to do next.",
618
+ " nudge-mcp service logs runner|agent [-n N] [-f] Show (or follow) the service log.",
324
619
  "",
325
- "Options after the kind are passed to the runner/agent unchanged (a relative",
326
- "--cwd is pinned to the current directory). Add --dry-run to print the service",
327
- "file without installing it. macOS uses a launchd LaunchAgent, Linux a systemd",
328
- "--user unit; both restart the process after a crash and at every login.",
620
+ "Every verb accepts --instance NAME for a second runner installed with --instance.",
621
+ "Options after `install <kind>` are passed to the runner/agent unchanged (a relative",
622
+ "--cwd is pinned to the current directory). Add --dry-run to print the service file",
623
+ "without installing it. macOS uses a launchd LaunchAgent, Linux a systemd --user unit;",
624
+ "both start the process at login and restart it after a crash. `stop` keeps the",
625
+ "service installed (it returns at next login); `uninstall` removes it for good.",
329
626
  ].join("\n");
330
- function unsupported(kind = "runner") {
331
- throw new Error([
332
- `Login services are supported on macOS (launchd) and Linux (systemd --user); this is ${process.platform}.`,
627
+ function unsupportedMessage(platform, kind, node, entry) {
628
+ return [
629
+ `Login services are supported on macOS (launchd) and Linux (systemd --user); this is ${platform}.`,
333
630
  "On Windows, create a Task Scheduler task that runs at log-on with the action:",
334
- ` "${process.execPath}" "${process.argv[1]}" ${kind}`,
335
- "and set it to restart on failure.",
336
- ].join("\n"));
631
+ ` "${node}" "${entry}" ${kind}`,
632
+ "and set it to restart on failure. `nudge-mcp service status` still reports a",
633
+ `${kind} started from a terminal.`,
634
+ ].join("\n");
337
635
  }
338
- function parseKind(raw) {
636
+ function parseKind(raw, action) {
339
637
  if (raw === "runner" || raw === "agent")
340
638
  return raw;
341
- throw new Error(`Expected "runner" or "agent" after the service action, got ${raw ? `"${raw}"` : "nothing"}.\n\n${SERVICE_USAGE}`);
639
+ throw new Error(`Expected "runner" or "agent" after \`service ${action}\`, got ${raw ? `"${raw}"` : "nothing"}.\n\n${SERVICE_USAGE}`);
342
640
  }
343
- export async function runService(argv) {
344
- const action = argv[0];
641
+ function parseLines(argv) {
642
+ const raw = findFlag(argv, "--lines") ?? findFlag(argv, "-n");
643
+ const n = raw === null ? 60 : Number(raw);
644
+ if (!Number.isInteger(n) || n <= 0)
645
+ throw new Error(`--lines must be a positive integer, got "${raw}".`);
646
+ return n;
647
+ }
648
+ async function listInstalledServiceLabels(kind, manager) {
649
+ if (!manager)
650
+ return [];
651
+ const dir = manager === "launchd"
652
+ ? path.join(os.homedir(), "Library", "LaunchAgents")
653
+ : path.join(os.homedir(), ".config", "systemd", "user");
654
+ let files;
655
+ try {
656
+ files = await fs.readdir(dir);
657
+ }
658
+ catch (err) {
659
+ if (err.code === "ENOENT")
660
+ return [];
661
+ throw err;
662
+ }
663
+ if (manager === "launchd") {
664
+ const prefix = `com.nudge.${kind}`;
665
+ return files
666
+ .filter((file) => file === `${prefix}.plist` || (file.startsWith(`${prefix}.`) && file.endsWith(".plist")))
667
+ .map((file) => file.slice(0, -".plist".length))
668
+ .sort();
669
+ }
670
+ const prefix = `nudge-${kind}`;
671
+ return files
672
+ .filter((file) => file === `${prefix}.service` || (file.startsWith(`${prefix}-`) && file.endsWith(".service")))
673
+ .map((file) => {
674
+ const unit = file.slice(0, -".service".length);
675
+ const suffix = unit === prefix ? "" : unit.slice(prefix.length + 1);
676
+ return serviceLabel(kind, suffix || null);
677
+ })
678
+ .sort();
679
+ }
680
+ export async function defaultDeps() {
345
681
  const platform = process.platform;
682
+ const managerName = managerForPlatform(platform);
683
+ return {
684
+ platform,
685
+ manager: managerName === "launchd" ? launchd : managerName === "systemd" ? systemd : null,
686
+ node: process.execPath,
687
+ entry: await fs.realpath(process.argv[1]),
688
+ cwd: process.cwd(),
689
+ env: process.env,
690
+ hostname: os.hostname(),
691
+ now: Date.now,
692
+ out: (line) => console.log(line),
693
+ warn: (line) => console.warn(line),
694
+ readState: (label) => readProcessState(label),
695
+ isLive: (state) => isStateLive(state),
696
+ waitForState: (label, since) => waitForProcessState(label, { since }),
697
+ entryExists: fileExists,
698
+ listServiceLabels: (kind) => listInstalledServiceLabels(kind, managerName),
699
+ };
700
+ }
701
+ async function buildReport(kind, label, deps) {
702
+ const service = deps.manager ? await deps.manager.status(label) : null;
703
+ const state = await deps.readState(label);
704
+ const live = !!state && deps.isLive(state);
705
+ const entryExists = service?.entry ? await deps.entryExists(service.entry) : null;
706
+ const needsLog = !!service && service.installed && !service.running && service.lastExitCode !== null && service.lastExitCode !== 0;
707
+ const recentLog = needsLog && deps.manager ? await deps.manager.recentLog(label, 12) : null;
708
+ return diagnose({ kind, label, platform: deps.platform, manager: deps.manager?.name ?? null, service, process: state, live, entryExists, recentLog });
709
+ }
710
+ export async function runService(argv, overrides = {}) {
711
+ const deps = { ...(await defaultDeps()), ...overrides };
712
+ const action = argv[0];
713
+ const { out, manager } = deps;
714
+ if (action === undefined || action === "help" || action === "--help" || action === "-h") {
715
+ out(SERVICE_USAGE);
716
+ return;
717
+ }
718
+ if (!SERVICE_ACTIONS.includes(action)) {
719
+ throw new Error(`Unknown service action "${action}".\n\n${SERVICE_USAGE}`);
720
+ }
721
+ // ---- status -------------------------------------------------------------
346
722
  if (action === "status") {
347
- if (platform !== "darwin" && platform !== "linux")
348
- unsupported();
349
- const rows = [];
350
- for (const kind of SERVICE_KINDS) {
351
- const label = serviceLabel(kind);
352
- const status = platform === "darwin" ? await launchdStatus(label) : await systemdStatus(label);
353
- rows.push(` ${kind.padEnd(7)} ${label.padEnd(20)} ${status}`);
723
+ const rest = argv.slice(1);
724
+ const json = rest.includes("--json");
725
+ const only = rest[0] === "runner" || rest[0] === "agent" ? rest[0] : null;
726
+ const instance = findFlag(rest, "--instance");
727
+ const all = rest.includes("--all");
728
+ if (all && instance)
729
+ throw new Error("Use either --all or --instance, not both.");
730
+ const kinds = only ? [only] : [...SERVICE_KINDS];
731
+ const targets = (await Promise.all(kinds.map(async (kind) => {
732
+ if (all)
733
+ return (await deps.listServiceLabels(kind)).map((label) => ({ kind, label }));
734
+ return [{ kind, label: serviceLabel(kind, kind === "runner" ? instance : null) }];
735
+ }))).flat();
736
+ const reports = await Promise.all(targets.map(({ kind, label }) => buildReport(kind, label, deps)));
737
+ if (json) {
738
+ out(JSON.stringify({ hostname: deps.hostname, platform: deps.platform, nudgeMcp: NUDGE_MCP_VERSION, services: reports }, null, 2));
739
+ return;
354
740
  }
355
- console.log(["Nudge login services:", ...rows].join("\n"));
356
- if (platform === "darwin")
357
- console.log(`\nLogs: ${LOG_DIR}/<label>.log`);
358
- else
359
- console.log("\nLogs: journalctl --user -u nudge-runner -f");
741
+ const platformName = deps.platform === "darwin" ? "macOS" : deps.platform;
742
+ out(`Nudge services on ${deps.hostname} (${platformName}, nudge-mcp ${NUDGE_MCP_VERSION})`);
743
+ out("");
744
+ if (all && reports.length === 0)
745
+ out("No installed Nudge services found.\n");
746
+ for (const report of reports) {
747
+ out(formatStatus(report, { now: deps.now(), logHint: manager ? manager.logHint(report.label) : null }));
748
+ out("");
749
+ }
750
+ if (!manager)
751
+ out(unsupportedMessage(deps.platform, only ?? "runner", deps.node, deps.entry));
360
752
  return;
361
753
  }
362
- if (action === "uninstall") {
363
- const kind = parseKind(argv[1]);
364
- const label = serviceLabel(kind, findFlag(argv.slice(2), "--instance"));
365
- if (platform !== "darwin" && platform !== "linux")
366
- unsupported(kind);
367
- const removed = platform === "darwin" ? await launchdUninstall(label) : await systemdUninstall(label);
368
- console.log(removed
369
- ? `Removed ${label}. The ${kind} no longer starts at login; a running instance was stopped.`
370
- : `${label} was not installed.`);
754
+ const kind = parseKind(argv[1], action);
755
+ const rest = argv.slice(2);
756
+ // ---- logs ---------------------------------------------------------------
757
+ if (action === "logs") {
758
+ if (!manager)
759
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
760
+ const label = serviceLabel(kind, kind === "runner" ? findFlag(rest, "--instance") : null);
761
+ await manager.logs(label, { lines: parseLines(rest), follow: rest.includes("--follow") || rest.includes("-f") });
371
762
  return;
372
763
  }
373
- if (action === "install") {
374
- const kind = parseKind(argv[1]);
375
- const rest = argv.slice(2);
376
- const dryRun = rest.includes("--dry-run");
377
- const passthrough = rest.filter((a) => a !== "--dry-run");
378
- const entry = await fs.realpath(process.argv[1]);
379
- const spec = buildSpec(kind, passthrough, {
380
- node: process.execPath,
381
- entry,
382
- cwd: process.cwd(),
383
- });
384
- if (platform !== "darwin" && platform !== "linux")
385
- unsupported(kind);
386
- const rendered = platform === "darwin" ? renderLaunchdPlist(spec) : renderSystemdUnit(spec);
387
- const target = platform === "darwin" ? launchdPlistPath(spec.label) : systemdUnitPath(spec.label);
388
- if (dryRun) {
389
- console.log(`# ${target}\n${rendered}`);
390
- return;
391
- }
392
- if (isNpxCachePath(entry)) {
393
- console.warn([
394
- `[nudge-mcp service] warning: this copy lives in npm's npx cache (${entry}).`,
395
- " npm may prune it, which would break the service on a later boot.",
396
- " Prefer: npm install -g @euqns/nudge-mcp && nudge-mcp service install " + kind,
397
- ].join("\n"));
764
+ // ---- start / stop / restart ---------------------------------------------
765
+ if (action === "start" || action === "stop" || action === "restart") {
766
+ if (!manager)
767
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
768
+ const label = serviceLabel(kind, kind === "runner" ? findFlag(rest, "--instance") : null);
769
+ const before = await manager.status(label);
770
+ if (!before.installed) {
771
+ throw new Error(`${label} is not installed, so there is nothing to ${action}.\n` +
772
+ `Install it first: nudge-mcp service install ${kind}${kind === "agent" ? " --no-open" : " …"}`);
398
773
  }
399
- const written = platform === "darwin" ? await launchdInstall(spec) : await systemdInstall(spec);
400
- const lines = [
401
- `Installed ${spec.label} ${written}`,
402
- ` Runs: ${[spec.kind, ...spec.args].join(" ")}`,
403
- ` Directory: ${spec.cwd}`,
404
- " Starts at login and restarts after a crash. It is starting now.",
405
- ];
406
- if (platform === "darwin") {
407
- lines.push(` Logs: ${spec.logPath}`);
408
- lines.push(` Stop: launchctl bootout ${gui()}/${spec.label}`);
774
+ if (before.entry && !(await deps.entryExists(before.entry))) {
775
+ throw new Error(`${label} points at ${before.entry}, which no longer exists (an npm upgrade or uninstall moved it).\n` +
776
+ `Reinstall from the current copy: nudge-mcp service install ${kind} …`);
409
777
  }
410
- else {
411
- lines.push(` Logs: journalctl --user -u ${unitName(spec.label)} -f`);
412
- lines.push(` Boot: loginctl enable-linger ${os.userInfo().username} # start before anyone logs in`);
778
+ if (action === "stop") {
779
+ await manager.stop(label);
780
+ out(`Stopped ${label}. It stays installed and starts again at next login; \`${nudgeCmd(kind, "start", label)}\` brings it back now.`);
781
+ return;
413
782
  }
414
- if (kind === "agent" && !passthrough.includes("--no-open")) {
415
- lines.push(" Note: the companion opens its pairing page in the browser at every login; add --no-open to skip that.");
783
+ const since = deps.now();
784
+ if (action === "start")
785
+ await manager.start(label);
786
+ else
787
+ await manager.restart(label);
788
+ out(`${action === "start" ? "Started" : "Restarted"} ${label}. Waiting for it to report in…`);
789
+ const state = await deps.waitForState(label, since);
790
+ if (state) {
791
+ out(describeFreshState(state));
792
+ return;
416
793
  }
417
- lines.push(` Remove: nudge-mcp service uninstall ${kind}`);
418
- console.log(lines.join("\n"));
794
+ // It did not report in: say why, right here.
795
+ const report = await buildReport(kind, label, deps);
796
+ out(formatStatus(report, { now: deps.now(), logHint: manager.logHint(label) }));
797
+ if (!report.service?.running)
798
+ throw new Error(`${label} did not stay running.`);
799
+ return;
800
+ }
801
+ // ---- uninstall ----------------------------------------------------------
802
+ if (action === "uninstall") {
803
+ if (!manager)
804
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
805
+ const label = serviceLabel(kind, kind === "runner" ? findFlag(rest, "--instance") : null);
806
+ const removed = await manager.uninstall(label);
807
+ out(removed
808
+ ? `Removed ${label}. The ${kind} no longer starts at login; a running instance was stopped.`
809
+ : `${label} was not installed.`);
419
810
  return;
420
811
  }
421
- throw new Error(SERVICE_USAGE);
812
+ // ---- install ------------------------------------------------------------
813
+ const dryRun = rest.includes("--dry-run");
814
+ const passthrough = rest.filter((a) => a !== "--dry-run");
815
+ const spec = buildSpec(kind, passthrough, {
816
+ node: deps.node,
817
+ entry: deps.entry,
818
+ cwd: deps.cwd,
819
+ env: deps.env,
820
+ manager: manager?.name ?? null,
821
+ });
822
+ if (!manager)
823
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
824
+ if (dryRun) {
825
+ out(`# ${manager.file(spec.label)}\n${manager.render(spec)}`);
826
+ return;
827
+ }
828
+ if (isNpxCachePath(deps.entry)) {
829
+ deps.warn([
830
+ `[nudge-mcp service] warning: this copy lives in npm's npx cache (${deps.entry}).`,
831
+ " npm may prune it, which would break the service on a later boot.",
832
+ ' Install a stable user-owned copy: npm install --global --prefix "$HOME/.local" @euqns/nudge-mcp@latest',
833
+ ` Then register it separately: "$HOME/.local/bin/nudge-mcp" service install ${kind}`,
834
+ ].join("\n"));
835
+ }
836
+ const since = deps.now();
837
+ const written = await manager.install(spec);
838
+ const lines = [
839
+ `Installed ${spec.label} → ${written}`,
840
+ ` Runs: ${[spec.kind, ...spec.args].join(" ")}`,
841
+ ` Directory: ${spec.cwd}`,
842
+ " Starts at login and restarts after a crash. It is starting now.",
843
+ ` Logs: ${nudgeCmd(kind, "logs", spec.label)} (${manager.logHint(spec.label)})`,
844
+ ` Status: ${nudgeCmd(kind, "status", spec.label)}`,
845
+ ` Control: ${nudgeCmd(kind, "start|stop|restart", spec.label)}`,
846
+ ...manager.installNotes(spec),
847
+ ];
848
+ if (kind === "agent" && !passthrough.includes("--no-open")) {
849
+ lines.push(" Note: the companion opens its pairing page in the browser at every login; add --no-open to skip that.");
850
+ }
851
+ lines.push(` Remove: ${nudgeCmd(kind, "uninstall", spec.label)}`);
852
+ out(lines.join("\n"));
853
+ // Wait for THIS start to report in — the state file carries the pairing URL
854
+ // (agent) or the registration (runner), so nobody has to open a log. Only a
855
+ // report written after `since` counts: a URL from a previous run is stale.
856
+ out("");
857
+ out(kind === "agent" ? "Waiting for the pairing URL…" : "Waiting for the runner to register…");
858
+ const state = await deps.waitForState(spec.label, since);
859
+ if (state) {
860
+ out(describeFreshState(state));
861
+ return;
862
+ }
863
+ const report = await buildReport(kind, spec.label, deps);
864
+ out("");
865
+ out(formatStatus(report, { now: deps.now(), logHint: manager.logHint(spec.label) }));
866
+ if (!report.service?.running)
867
+ throw new Error(`${spec.label} did not stay running — see above.`);
868
+ }
869
+ /** What to tell the user once a freshly (re)started process has reported in. */
870
+ export function describeFreshState(state) {
871
+ if (state.kind === "agent") {
872
+ return [
873
+ `Nudge Agent is running (nudge-mcp ${state.version} on ${state.hostname}, pid ${state.pid}).`,
874
+ ` Agents: ${state.providers.length ? state.providers.join(", ") : "none ready — sign in to codex, claude or cursor-agent"}`,
875
+ ` Listener: http://127.0.0.1:${state.port}`,
876
+ "",
877
+ "Open this pairing URL in the browser where Nudge is signed in:",
878
+ state.pairingUrl,
879
+ "",
880
+ "A rebuild restart keeps this pairing. A reboot or crash restart mints a new one —",
881
+ "`nudge-mcp service status agent` always prints the current URL.",
882
+ ].join("\n");
883
+ }
884
+ return [
885
+ `Runner is registered as "${state.name}" on ${state.board.title}${state.board.repo ? ` (${state.board.repo})` : ""}.`,
886
+ ` Version: nudge-mcp ${state.version} on ${state.hostname} · pid ${state.pid}`,
887
+ ` Checkout: ${state.cwd}`,
888
+ ` Agents: ${state.engines.length ? state.engines.join(", ") : "none detected"} · up to ${state.concurrency} job${state.concurrency === 1 ? "" : "s"} at once`,
889
+ "The board's Agent View shows this machine as online while it heartbeats.",
890
+ ].join("\n");
422
891
  }
423
892
  //# sourceMappingURL=service.js.map