@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/README.md +111 -26
- package/dist/codex-companion.js +39 -0
- package/dist/codex-companion.js.map +1 -1
- package/dist/codex-companion.test.js +2 -0
- package/dist/codex-companion.test.js.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/install-context.js +37 -0
- package/dist/install-context.js.map +1 -0
- package/dist/install-context.test.js +28 -0
- package/dist/install-context.test.js.map +1 -0
- package/dist/runner.js +122 -23
- package/dist/runner.js.map +1 -1
- package/dist/service-command-registry.js +22 -0
- package/dist/service-command-registry.js.map +1 -0
- package/dist/service-state.js +136 -0
- package/dist/service-state.js.map +1 -0
- package/dist/service-state.test.js +118 -0
- package/dist/service-state.test.js.map +1 -0
- package/dist/service.js +641 -172
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +448 -4
- package/dist/service.test.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
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
|
|
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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.
|
|
260
|
-
return
|
|
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
|
|
263
|
-
|
|
264
|
-
return false;
|
|
265
|
-
throw err;
|
|
296
|
+
catch {
|
|
297
|
+
return "";
|
|
266
298
|
}
|
|
267
299
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
async
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
323
|
-
" nudge-mcp service
|
|
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
|
-
"
|
|
326
|
-
"
|
|
327
|
-
"
|
|
328
|
-
"
|
|
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
|
|
331
|
-
|
|
332
|
-
`Login services are supported on macOS (launchd) and Linux (systemd --user); this is ${
|
|
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
|
-
` "${
|
|
335
|
-
"and set it to restart on failure.",
|
|
336
|
-
|
|
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
|
|
639
|
+
throw new Error(`Expected "runner" or "agent" after \`service ${action}\`, got ${raw ? `"${raw}"` : "nothing"}.\n\n${SERVICE_USAGE}`);
|
|
342
640
|
}
|
|
343
|
-
|
|
344
|
-
const
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|