@aron98/load-lock 1.3.0 → 1.5.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/adapters/opencode/plugin.d.ts +2 -1
- package/dist/adapters/opencode/plugin.js +100 -6
- package/dist/adapters/opencode/tui.js +7 -2
- package/dist/cli/copies.d.ts +1 -9
- package/dist/cli/copies.js +2 -43
- package/dist/cli/install.js +1 -1
- package/dist/cli/main.js +82 -217
- package/dist/cli/service.d.ts +56 -0
- package/dist/cli/service.js +261 -0
- package/dist/cli/update.d.ts +53 -0
- package/dist/cli/update.js +274 -0
- package/dist/core/daemon.d.ts +24 -0
- package/dist/core/daemon.js +221 -0
- package/dist/core/state.d.ts +5 -0
- package/dist/core/state.js +16 -0
- package/dist/dashboard/feed.d.ts +23 -0
- package/dist/dashboard/feed.js +64 -2
- package/dist/dashboard/frame.js +2 -0
- package/dist/dashboard/view.d.ts +11 -0
- package/dist/dashboard/view.js +13 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +117 -8
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +6 -0
- package/dist/shared/client.js +11 -1
- package/dist/shared/drift.d.ts +21 -0
- package/dist/shared/drift.js +77 -0
- package/dist/shared/installs.d.ts +39 -0
- package/dist/shared/installs.js +101 -0
- package/dist/shared/pkginfo.d.ts +16 -0
- package/dist/shared/pkginfo.js +54 -0
- package/dist/shared/protocol.d.ts +32 -0
- package/dist/shared/wait-completion.d.ts +67 -0
- package/dist/shared/wait-completion.js +505 -0
- package/dist/test/mcp.test.js +115 -1
- package/dist/test/reconnect.test.d.ts +1 -0
- package/dist/test/reconnect.test.js +389 -0
- package/dist/test/update-command.test.d.ts +1 -0
- package/dist/test/update-command.test.js +608 -0
- package/dist/test/wait-completion.test.d.ts +1 -0
- package/dist/test/wait-completion.test.js +418 -0
- package/docs/ADAPTERS.md +3 -2
- package/docs/AGENT_GUIDE.md +61 -3
- package/docs/ARCHITECTURE.md +15 -0
- package/docs/COMPATIBILITY.md +16 -2
- package/docs/OPERATIONS.md +118 -9
- package/package.json +1 -1
package/dist/cli/main.js
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* load-lock CLI: inspection, recovery, scriptable flows, execution helper.
|
|
4
4
|
*
|
|
5
|
-
* Exit codes: 0 ok;
|
|
6
|
-
* (queued/registered); 69
|
|
5
|
+
* Exit codes: 0 ok; 1 update available (`update --check`); 64 usage; 65
|
|
6
|
+
* protocol/data error; 68 not granted yet (queued/registered); 69
|
|
7
|
+
* offer/claim failed; 70 internal.
|
|
7
8
|
*/
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { existsSync, readFileSync
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
11
|
import { join } from "node:path";
|
|
11
|
-
import { connect } from "node:net";
|
|
12
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
13
12
|
import { LLClient, withClient } from "../shared/client.js";
|
|
14
13
|
import { ProtocolError } from "../shared/protocol.js";
|
|
15
14
|
import { waitForTurn } from "../shared/wait-turn.js";
|
|
15
|
+
import { waitForTrackedJob } from "../shared/wait-completion.js";
|
|
16
16
|
import { stateDir, socketPath } from "../core/persist.js";
|
|
17
17
|
import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
|
|
18
18
|
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
@@ -20,6 +20,8 @@ import { findInstalledCopies } from "./copies.js";
|
|
|
20
20
|
import { installCommand, uninstallCommand } from "./install.js";
|
|
21
21
|
import { hookCommand } from "./hook.js";
|
|
22
22
|
import { topCommand } from "./top.js";
|
|
23
|
+
import { awaitDaemonUpgraded, serviceRestart, serviceStart, serviceStop, ServiceError } from "./service.js";
|
|
24
|
+
import { updateCommand } from "./update.js";
|
|
23
25
|
// ---------------------------------------------------------------- arg parsing
|
|
24
26
|
const argv = process.argv.slice(2);
|
|
25
27
|
let flags = {};
|
|
@@ -34,7 +36,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
34
36
|
const eq = a.indexOf("=");
|
|
35
37
|
if (eq !== -1)
|
|
36
38
|
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
37
|
-
else if (["json", "follow", "force", "admin", "quiet"].includes(a.slice(2)))
|
|
39
|
+
else if (["json", "follow", "force", "admin", "quiet", "check", "version"].includes(a.slice(2)))
|
|
38
40
|
flags[a.slice(2)] = true;
|
|
39
41
|
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--"))
|
|
40
42
|
flags[a.slice(2)] = argv[++i];
|
|
@@ -65,87 +67,6 @@ function defaultAgent() {
|
|
|
65
67
|
return (process.env.LOAD_LOCK_AGENT ||
|
|
66
68
|
"cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
|
|
67
69
|
}
|
|
68
|
-
// ------------------------------------------------- daemon service helpers
|
|
69
|
-
/** daemon.json contents, or null when missing/unreadable. */
|
|
70
|
-
function readDaemonInfo(dir) {
|
|
71
|
-
try {
|
|
72
|
-
return JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Version-less hello over a raw socket connection: deliberately does NOT
|
|
80
|
-
* send clientVersion, so asking a daemon for its identity never orders it
|
|
81
|
-
* to self-restart (used by `service stop` and post-restart confirmation).
|
|
82
|
-
* Best-effort: resolves null on any failure.
|
|
83
|
-
*/
|
|
84
|
-
function rawHello(sockPath, timeoutMs = 2000) {
|
|
85
|
-
return new Promise((resolve) => {
|
|
86
|
-
let settled = false;
|
|
87
|
-
let buf = "";
|
|
88
|
-
const done = (v) => {
|
|
89
|
-
if (settled)
|
|
90
|
-
return;
|
|
91
|
-
settled = true;
|
|
92
|
-
try {
|
|
93
|
-
sock.destroy();
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
/* ignore */
|
|
97
|
-
}
|
|
98
|
-
resolve(v);
|
|
99
|
-
};
|
|
100
|
-
const sock = connect(sockPath, () => {
|
|
101
|
-
sock.write(JSON.stringify({ id: 1, method: "hello", params: {} }) + "\n");
|
|
102
|
-
});
|
|
103
|
-
sock.on("data", (chunk) => {
|
|
104
|
-
buf += chunk.toString("utf8");
|
|
105
|
-
const idx = buf.indexOf("\n");
|
|
106
|
-
if (idx === -1)
|
|
107
|
-
return;
|
|
108
|
-
try {
|
|
109
|
-
const msg = JSON.parse(buf.slice(0, idx));
|
|
110
|
-
done(msg.ok ? msg.result : null);
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
done(null);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
sock.on("error", () => done(null));
|
|
117
|
-
const timer = setTimeout(() => done(null), timeoutMs);
|
|
118
|
-
timer.unref?.();
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
/** Poll the socket until nothing answers there (bounded). */
|
|
122
|
-
async function waitSocketFree(sockPath, timeoutMs) {
|
|
123
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
124
|
-
for (;;) {
|
|
125
|
-
if (!(await probeTransport(sockPath)))
|
|
126
|
-
return true;
|
|
127
|
-
if (Date.now() >= deadline)
|
|
128
|
-
return false;
|
|
129
|
-
await delay(100);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* After ordering a self-restart, wait for the daemon to come back running
|
|
134
|
-
* at least `installed` (the version of this CLI copy). Resolves its hello
|
|
135
|
-
* result, or null if it did not come back (or an older/pre-handshake
|
|
136
|
-
* daemon answered again — e.g. the daemon's copy is not actually updated).
|
|
137
|
-
*/
|
|
138
|
-
async function awaitDaemonUpgraded(sockPath, timeoutMs, installed) {
|
|
139
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
140
|
-
for (;;) {
|
|
141
|
-
const h = await rawHello(sockPath, 1500);
|
|
142
|
-
if (h && typeof h.daemonVersion === "string" && compareVersions(h.daemonVersion, installed) >= 0)
|
|
143
|
-
return h;
|
|
144
|
-
if (Date.now() >= deadline)
|
|
145
|
-
return null;
|
|
146
|
-
await delay(250);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
70
|
const HELP_GROUPS = [
|
|
150
71
|
{
|
|
151
72
|
title: "permit operations",
|
|
@@ -162,7 +83,7 @@ const HELP_GROUPS = [
|
|
|
162
83
|
{
|
|
163
84
|
title: "waiting & running",
|
|
164
85
|
items: [
|
|
165
|
-
{ names: ["wait"], desc: "sleep until a queued request's turn,
|
|
86
|
+
{ names: ["wait"], desc: "sleep until a queued request's turn (--id <request-id>), auto-claiming the offer — or until a heavy job finishes (--job <request-id>): [--timeout <ms>]" },
|
|
166
87
|
{ names: ["run"], desc: "acquire (or reuse) a permit and run a command under it: run [--permit <token> | acquire flags] -- <cmd>" },
|
|
167
88
|
],
|
|
168
89
|
},
|
|
@@ -176,12 +97,14 @@ const HELP_GROUPS = [
|
|
|
176
97
|
{ names: ["config", "get-config"], desc: "current configuration" },
|
|
177
98
|
{ names: ["configure"], desc: "change configuration (compare-and-swap): --reason required" },
|
|
178
99
|
{ names: ["dashboard", "top"], desc: "full-screen live dashboard, read-only (never acquires): [--once]" },
|
|
100
|
+
{ names: ["version"], desc: "print this CLI copy's version" },
|
|
179
101
|
],
|
|
180
102
|
},
|
|
181
103
|
{
|
|
182
104
|
title: "service & diagnostics",
|
|
183
105
|
items: [
|
|
184
|
-
{ names: ["service"], desc: "daemon lifecycle: start | stop | status (stop/start take --timeout <ms>)" },
|
|
106
|
+
{ names: ["service"], desc: "daemon lifecycle: start | stop | restart | status (stop/start/restart take --timeout <ms> for the stop phase)" },
|
|
107
|
+
{ names: ["update"], desc: "check npm for a newer version, install it via the detected package manager, then `service restart` (--check dry-run; refuses repo checkouts / opencode-managed copies)" },
|
|
185
108
|
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, versions + copies, machine" },
|
|
186
109
|
{ names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
|
|
187
110
|
],
|
|
@@ -215,10 +138,15 @@ function usage() {
|
|
|
215
138
|
for (const item of g.items)
|
|
216
139
|
lines.push(helpLine(item));
|
|
217
140
|
}
|
|
218
|
-
lines.push("", "exit codes: 0 ok, 64 usage, 65 protocol, 68 not granted yet, 69 offer/claim failed, 70 internal");
|
|
141
|
+
lines.push("", "exit codes: 0 ok, 1 update available (update --check), 64 usage, 65 protocol, 68 not granted yet, 69 offer/claim failed, 70 internal");
|
|
219
142
|
process.stdout.write(lines.join("\n") + "\n");
|
|
220
143
|
}
|
|
221
144
|
async function main() {
|
|
145
|
+
// `--version` prints this copy's version (same as the `version` command).
|
|
146
|
+
if (f["version"] === true) {
|
|
147
|
+
process.stdout.write(packageVersion() + "\n");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
222
150
|
// `--help`, `-h` and a bare `load-lock` all print the same usage as `help`.
|
|
223
151
|
if (f["help"] !== undefined || cmd === "-h" || (positional.length === 0 && argv.length === 0)) {
|
|
224
152
|
usage();
|
|
@@ -388,19 +316,43 @@ async function main() {
|
|
|
388
316
|
out(await withClient((c) => c.request("attach_pid", { permitToken: token, pid })));
|
|
389
317
|
return;
|
|
390
318
|
}
|
|
391
|
-
// ------------------------------------------------------- wait (offer -> permit)
|
|
319
|
+
// ------------------------------------------------------- wait (offer -> permit / job -> done)
|
|
392
320
|
case "wait": {
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
321
|
+
// Two honest bounded sleeps sharing one command:
|
|
322
|
+
// --id : queue phase — watches a queued request, auto-claims the
|
|
323
|
+
// offer when capacity reaches it, prints the permit token.
|
|
324
|
+
// --job : execution phase — sleeps until the daemon-tracked job for
|
|
325
|
+
// that request-id finishes (liveness sweep wake; the permit
|
|
326
|
+
// is released before you are woken). Structured result on
|
|
327
|
+
// every path; still_running = checkpoint, call again.
|
|
328
|
+
// Both share the exact implementations with the MCP tools and the
|
|
329
|
+
// OpenCode plugin (src/shared/wait-turn.ts / wait-completion.ts).
|
|
399
330
|
const timeoutMs = N("timeout", 600_000);
|
|
400
|
-
const agent = S("agent", defaultAgent());
|
|
401
331
|
const c = new LLClient();
|
|
402
332
|
await c.connect();
|
|
403
333
|
try {
|
|
334
|
+
const job = S("job");
|
|
335
|
+
if (job) {
|
|
336
|
+
const r = await waitForTrackedJob({ client: c, requestId: job, timeoutMs });
|
|
337
|
+
out({
|
|
338
|
+
state: r.state,
|
|
339
|
+
request_id: r.requestId ?? job,
|
|
340
|
+
exit_code: r.exitCode,
|
|
341
|
+
duration_ms: r.durationMs,
|
|
342
|
+
permit_released: r.permitReleased,
|
|
343
|
+
waited_ms: r.waitedMs,
|
|
344
|
+
effective_wait_ms: r.effectiveWaitMs,
|
|
345
|
+
started_at: r.startedAt,
|
|
346
|
+
detail: r.detail ?? r.nextAction,
|
|
347
|
+
});
|
|
348
|
+
if (r.state === "finished")
|
|
349
|
+
return;
|
|
350
|
+
if (r.state === "still_running")
|
|
351
|
+
fail(`wait: job still running after ${r.waitedMs}ms (checkpoint — call again)`, 68);
|
|
352
|
+
fail(`wait: job is ${r.state.replace(/_/g, " ")} — ${r.nextAction}`, 69);
|
|
353
|
+
}
|
|
354
|
+
const id = S("id") || fail("wait: --id <request-id> (queue wait) or --job <request-id> (job wait) required");
|
|
355
|
+
const agent = S("agent", defaultAgent());
|
|
404
356
|
const r = await waitForTurn({ client: c, agentId: agent, requestId: id, timeoutMs });
|
|
405
357
|
if (r.state === "granted" && r.entry?.permitToken) {
|
|
406
358
|
out({ permit_token: r.entry.permitToken, state: r.state, request_id: r.requestId, auto_claimed: r.autoClaimed ?? false });
|
|
@@ -427,130 +379,29 @@ async function main() {
|
|
|
427
379
|
// --------------------------------------------------------------- service
|
|
428
380
|
case "service": {
|
|
429
381
|
const sub = rest[0] ?? "status";
|
|
382
|
+
const phase = (e) => {
|
|
383
|
+
if (e instanceof ServiceError)
|
|
384
|
+
fail(e.message, e.exitCode);
|
|
385
|
+
throw e;
|
|
386
|
+
};
|
|
430
387
|
if (sub === "stop") {
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
// daemon.json (crash leftovers) is cleaned up either way.
|
|
435
|
-
const dir = stateDir();
|
|
436
|
-
const sock = socketPath();
|
|
437
|
-
const timeoutMs = N("timeout", 5000);
|
|
438
|
-
const info = readDaemonInfo(dir);
|
|
439
|
-
const answering = await probeTransport(sock);
|
|
440
|
-
if (!answering) {
|
|
441
|
-
// Nothing is listening: no daemon to stop. Clean crash leftovers.
|
|
442
|
-
try {
|
|
443
|
-
if (!isWindows() && existsSync(sock))
|
|
444
|
-
unlinkSync(sock);
|
|
445
|
-
}
|
|
446
|
-
catch {
|
|
447
|
-
/* ignore */
|
|
448
|
-
}
|
|
449
|
-
try {
|
|
450
|
-
rmSync(join(dir, "daemon.json"), { force: true });
|
|
451
|
-
}
|
|
452
|
-
catch {
|
|
453
|
-
/* ignore */
|
|
454
|
-
}
|
|
455
|
-
process.stdout.write("load-lock: no running daemon found (stale files cleaned)\n");
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
// Identify the daemon: prefer daemon.json, else ask it directly
|
|
459
|
-
// (version-less hello — stopping must not order a self-restart).
|
|
460
|
-
let pid = typeof info?.pid === "number" ? info.pid : undefined;
|
|
461
|
-
if (!pid) {
|
|
462
|
-
const hello = await rawHello(sock, 1500);
|
|
463
|
-
if (hello && typeof hello.pid === "number")
|
|
464
|
-
pid = hello.pid;
|
|
465
|
-
}
|
|
466
|
-
if (!pid) {
|
|
467
|
-
fail(`the socket ${sock} answers but no pid could be identified (daemon did not respond and daemon.json names none) ` +
|
|
468
|
-
`— cannot stop safely; run \`load-lock doctor\``, 70);
|
|
469
|
-
}
|
|
470
|
-
let how = "SIGTERM";
|
|
471
|
-
let stopped = false;
|
|
472
|
-
try {
|
|
473
|
-
process.kill(pid, "SIGTERM");
|
|
474
|
-
}
|
|
475
|
-
catch (e) {
|
|
476
|
-
if (e?.code === "ESRCH")
|
|
477
|
-
stopped = true; // already gone
|
|
478
|
-
else
|
|
479
|
-
fail(`cannot signal daemon pid ${pid}: ${e?.message ?? e}`, 70);
|
|
480
|
-
}
|
|
481
|
-
if (!stopped)
|
|
482
|
-
stopped = await waitSocketFree(sock, timeoutMs);
|
|
483
|
-
if (!stopped) {
|
|
484
|
-
how = "SIGKILL";
|
|
485
|
-
try {
|
|
486
|
-
process.kill(pid, "SIGKILL");
|
|
487
|
-
}
|
|
488
|
-
catch {
|
|
489
|
-
/* already dead */
|
|
490
|
-
}
|
|
491
|
-
stopped = await waitSocketFree(sock, timeoutMs);
|
|
492
|
-
}
|
|
493
|
-
if (!stopped) {
|
|
494
|
-
fail(`daemon pid ${pid} did not stop within ${timeoutMs}ms even after SIGKILL — run \`load-lock doctor\``, 70);
|
|
495
|
-
}
|
|
496
|
-
try {
|
|
497
|
-
if (!isWindows() && existsSync(sock))
|
|
498
|
-
unlinkSync(sock);
|
|
499
|
-
}
|
|
500
|
-
catch {
|
|
501
|
-
/* ignore */
|
|
502
|
-
}
|
|
503
|
-
try {
|
|
504
|
-
rmSync(join(dir, "daemon.json"), { force: true });
|
|
505
|
-
}
|
|
506
|
-
catch {
|
|
507
|
-
/* ignore */
|
|
508
|
-
}
|
|
509
|
-
process.stdout.write(`load-lock: daemon (pid ${pid}) stopped${how === "SIGKILL" ? " (required SIGKILL after SIGTERM)" : ""}\n`);
|
|
388
|
+
// Hardened stop: SIGTERM → bounded wait → SIGKILL → verified-free
|
|
389
|
+
// endpoint; crash leftovers cleaned either way.
|
|
390
|
+
process.stdout.write(`load-lock: ${await serviceStop(N("timeout", 5000)).catch(phase)}\n`);
|
|
510
391
|
return;
|
|
511
392
|
}
|
|
512
393
|
if (sub === "start") {
|
|
513
394
|
// Connecting auto-spawns a detached daemon (see shared/client.ts);
|
|
514
395
|
// hello confirms it is up. Refusals name the conflicting pid.
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const up = await awaitDaemonUpgraded(sock, 8000, installed);
|
|
525
|
-
if (up) {
|
|
526
|
-
process.stdout.write(`load-lock: daemon self-restarted ${r.pid} -> ${up.pid} (v${dv} -> v${up.daemonVersion}), running on ${sock}\n`);
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock}), daemon v${dv} — older than this CLI v${installed}; ` +
|
|
530
|
-
`self-restart ordered but not confirmed (its copy may not be updated in place — see docs/OPERATIONS.md)\n`);
|
|
531
|
-
}
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock})${dv ? `, daemon v${dv}` : ""}\n`);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
catch (e) {
|
|
538
|
-
if (await probeTransport(sock)) {
|
|
539
|
-
// Something owns the endpoint. Name it.
|
|
540
|
-
const hello = await rawHello(sock, 1500);
|
|
541
|
-
if (hello && typeof hello.pid === "number") {
|
|
542
|
-
fail(`start refused: socket answered from pid ${hello.pid} — another daemon owns ${sock}; ` +
|
|
543
|
-
`if it is stale, run \`load-lock service stop\` first`, 70);
|
|
544
|
-
}
|
|
545
|
-
const info = readDaemonInfo(stateDir());
|
|
546
|
-
if (info && typeof info.pid === "number") {
|
|
547
|
-
fail(`socket ${sock} answers but the daemon did not respond (daemon.json names pid ${info.pid}) — likely hung; ` +
|
|
548
|
-
`run \`load-lock service stop\``, 70);
|
|
549
|
-
}
|
|
550
|
-
fail(`start refused: ${sock} is in use but nothing responsive owns it — run \`load-lock doctor\``, 70);
|
|
551
|
-
}
|
|
552
|
-
throw e;
|
|
553
|
-
}
|
|
396
|
+
process.stdout.write(`load-lock: ${await serviceStart(N("timeout", 10_000)).catch(phase)}\n`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (sub === "restart") {
|
|
400
|
+
// Hardened stop, then honest start — both phases reported; a
|
|
401
|
+
// failure names the phase and reason (issue #38).
|
|
402
|
+
const r = await serviceRestart(N("timeout", 5000), 10_000).catch(phase);
|
|
403
|
+
process.stdout.write(`load-lock: ${r.stop}\nload-lock: ${r.start}\n`);
|
|
404
|
+
return;
|
|
554
405
|
}
|
|
555
406
|
if (sub === "status") {
|
|
556
407
|
const r = (await withClient((c) => c.request("hello", {})));
|
|
@@ -570,9 +421,23 @@ async function main() {
|
|
|
570
421
|
});
|
|
571
422
|
return;
|
|
572
423
|
}
|
|
573
|
-
fail(`service: unknown subcommand "${sub}" (use start | stop | status)`);
|
|
424
|
+
fail(`service: unknown subcommand "${sub}" (use start | stop | restart | status)`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// ---------------------------------------------------------------- update
|
|
428
|
+
case "update": {
|
|
429
|
+
// issue #38: registry check → classify self → apply via the detected
|
|
430
|
+
// package manager (or refuse honestly) → `service restart` on
|
|
431
|
+
// success. Exit code comes from updateCommand (1 = update available
|
|
432
|
+
// under --check; 70 = failure/refusal in apply mode).
|
|
433
|
+
const code = await updateCommand({ check: B("check"), json: B("json") });
|
|
434
|
+
if (code !== 0)
|
|
435
|
+
process.exit(code);
|
|
574
436
|
return;
|
|
575
437
|
}
|
|
438
|
+
case "version":
|
|
439
|
+
process.stdout.write(packageVersion() + "\n");
|
|
440
|
+
return;
|
|
576
441
|
case "doctor": {
|
|
577
442
|
const report = {};
|
|
578
443
|
report.node = process.version;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** A service-phase failure with the exit code the CLI should use. */
|
|
2
|
+
export declare class ServiceError extends Error {
|
|
3
|
+
readonly exitCode: number;
|
|
4
|
+
constructor(message: string, exitCode?: number);
|
|
5
|
+
}
|
|
6
|
+
/** daemon.json contents, or null when missing/unreadable. */
|
|
7
|
+
export declare function readDaemonInfo(dir: string): {
|
|
8
|
+
pid?: number;
|
|
9
|
+
startedAt?: number;
|
|
10
|
+
socket?: string;
|
|
11
|
+
} | null;
|
|
12
|
+
/**
|
|
13
|
+
* Version-less hello over a raw socket connection: deliberately does NOT
|
|
14
|
+
* send clientVersion, so asking a daemon for its identity never orders it
|
|
15
|
+
* to self-restart (used by `service stop` and post-restart confirmation).
|
|
16
|
+
* Best-effort: resolves null on any failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare function rawHello(sockPath: string, timeoutMs?: number): Promise<{
|
|
19
|
+
pid?: number;
|
|
20
|
+
daemonVersion?: string;
|
|
21
|
+
protocolVersion?: number;
|
|
22
|
+
} | null>;
|
|
23
|
+
/**
|
|
24
|
+
* After ordering a self-restart, wait for the daemon to come back running
|
|
25
|
+
* at least `installed` (the version of this CLI copy). Resolves its hello
|
|
26
|
+
* result, or null if it did not come back (or an older/pre-handshake
|
|
27
|
+
* daemon answered again — e.g. the daemon's copy is not actually updated).
|
|
28
|
+
*/
|
|
29
|
+
export declare function awaitDaemonUpgraded(sockPath: string, timeoutMs: number, installed: string): Promise<{
|
|
30
|
+
pid?: number;
|
|
31
|
+
daemonVersion?: string;
|
|
32
|
+
} | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Hardened stop: signal the daemon, wait bounded for its socket to go
|
|
35
|
+
* silent, escalate to SIGKILL, and only report success once the endpoint
|
|
36
|
+
* is verifiably free. A leftover silent socket file or daemon.json (crash
|
|
37
|
+
* leftovers) is cleaned up either way. Returns the report line; throws
|
|
38
|
+
* ServiceError on failure.
|
|
39
|
+
*/
|
|
40
|
+
export declare function serviceStop(timeoutMs: number): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Honest start: connecting auto-spawns a detached daemon (see
|
|
43
|
+
* shared/client.ts); hello confirms it is up. Refusals name the
|
|
44
|
+
* conflicting pid. Returns the report line; throws ServiceError on
|
|
45
|
+
* failure.
|
|
46
|
+
*/
|
|
47
|
+
export declare function serviceStart(timeoutMs: number): Promise<string>;
|
|
48
|
+
/**
|
|
49
|
+
* `service restart` (issue #38): hardened stop, then honest start — both
|
|
50
|
+
* phases reported; a failure names the phase and reason. Stopping when
|
|
51
|
+
* nothing runs is fine: the start phase simply spawns a fresh daemon.
|
|
52
|
+
*/
|
|
53
|
+
export declare function serviceRestart(stopTimeoutMs?: number, startTimeoutMs?: number): Promise<{
|
|
54
|
+
stop: string;
|
|
55
|
+
start: string;
|
|
56
|
+
}>;
|