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