@aron98/load-lock 1.4.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.js +15 -1
- 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 +48 -208
- 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/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 +10 -1
- 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/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/docs/AGENT_GUIDE.md +12 -0
- package/docs/COMPATIBILITY.md +16 -2
- package/docs/OPERATIONS.md +116 -8
- package/package.json +1 -1
|
@@ -24,6 +24,8 @@ import { POLICY_BRIEF } from "../../policy/text.js";
|
|
|
24
24
|
import { waitForTurn, WaitAbortedError } from "../../shared/wait-turn.js";
|
|
25
25
|
import { waitForLocalJob, waitForTrackedJob } from "../../shared/wait-completion.js";
|
|
26
26
|
import { killProcessGroup } from "../../shared/transport.js";
|
|
27
|
+
import { detectCliDrift } from "../../shared/drift.js";
|
|
28
|
+
import { packageVersion } from "../../shared/version.js";
|
|
27
29
|
const client = new LLClient();
|
|
28
30
|
const knownSessions = new Set();
|
|
29
31
|
const jobs = new Map();
|
|
@@ -44,6 +46,18 @@ function toolContent(v) {
|
|
|
44
46
|
export default Plugin.define({
|
|
45
47
|
id: "load-lock",
|
|
46
48
|
setup(ctx) {
|
|
49
|
+
// Drift notice (issue #38): when this adapter copy is NEWER than the
|
|
50
|
+
// `load-lock` CLI that PATH resolves to (opencode refreshed its cache
|
|
51
|
+
// copy but the global install was never updated), surface one clear
|
|
52
|
+
// notice naming the exact command — on stderr at init, and once in the
|
|
53
|
+
// injected context. Notify only: nothing is ever auto-installed. Note
|
|
54
|
+
// the honest limitation (docs/OPERATIONS.md): a harness session that
|
|
55
|
+
// was already running keeps the OLD adapter code in memory until the
|
|
56
|
+
// harness itself restarts — this fires from the NEW copy at its init.
|
|
57
|
+
const cliDrift = detectCliDrift(packageVersion());
|
|
58
|
+
if (cliDrift)
|
|
59
|
+
process.stderr.write(cliDrift.message + "\n");
|
|
60
|
+
const DRIFT_NOTE = cliDrift ? "\n\n" + cliDrift.message : "";
|
|
47
61
|
// ------------------------------------------------------------- tools
|
|
48
62
|
ctx.tool.transform((editor) => {
|
|
49
63
|
editor.namespace({
|
|
@@ -314,7 +328,7 @@ export default Plugin.define({
|
|
|
314
328
|
});
|
|
315
329
|
// ------------------------------------------- policy on every model call
|
|
316
330
|
void ctx.session.hook("context", async (event) => {
|
|
317
|
-
event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) });
|
|
331
|
+
event.system.push({ type: "text", text: POLICY_BRIEF + "\n\n" + (await statusBrief()) + DRIFT_NOTE });
|
|
318
332
|
});
|
|
319
333
|
// -------------------------------------------------- idle wakeup via watch
|
|
320
334
|
void (async () => {
|
|
@@ -100,6 +100,8 @@ function dashboardLines(v, width, f) {
|
|
|
100
100
|
push(`note: ${v.daemonRestartNote}`, f.warn);
|
|
101
101
|
if (v.versionSkewNote)
|
|
102
102
|
push(`note: ${v.versionSkewNote}`, f.warn);
|
|
103
|
+
if (v.installDriftNote)
|
|
104
|
+
push(`note: ${v.installDriftNote}`, f.warn);
|
|
103
105
|
if (v.machine) {
|
|
104
106
|
const m = v.machine;
|
|
105
107
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
|
@@ -209,11 +211,14 @@ export default Plugin.define({
|
|
|
209
211
|
id: "load-lock.tui",
|
|
210
212
|
setup(ctx) {
|
|
211
213
|
const [view, setView] = createSignal(null);
|
|
212
|
-
|
|
214
|
+
// driftSurface "opencode": when this session's copy is older than the
|
|
215
|
+
// PATH CLI, the hint says to restart opencode (plugins never hot-reload).
|
|
216
|
+
const feed = new DashboardFeed(new LLClient(), { driftSurface: "opencode" });
|
|
213
217
|
feed.onChange = () => setView(feed.view());
|
|
214
218
|
feed.onState = () => setView(feed.view());
|
|
215
219
|
void feed.start().catch(() => {
|
|
216
|
-
/* daemon down or spawning; the
|
|
220
|
+
/* daemon down or spawning; the reconnect loop keeps retrying and
|
|
221
|
+
will go live — start() only reports the FIRST connect failure */
|
|
217
222
|
});
|
|
218
223
|
// Local countdown tick: produces a fresh snapshot once per second so
|
|
219
224
|
// ages/deadlines advance even between daemon events.
|
package/dist/cli/copies.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
|
|
1
2
|
export interface InstalledCopy {
|
|
2
3
|
/** Where this copy was found. */
|
|
3
4
|
source: "cli" | "path" | "opencode-cache";
|
|
@@ -6,15 +7,6 @@ export interface InstalledCopy {
|
|
|
6
7
|
/** Version string, or null when the package.json could not be read. */
|
|
7
8
|
version: string | null;
|
|
8
9
|
}
|
|
9
|
-
/**
|
|
10
|
-
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
11
|
-
* given file, by climbing parent directories. Best-effort: returns null if
|
|
12
|
-
* no enclosing package.json matches (or is readable).
|
|
13
|
-
*/
|
|
14
|
-
export declare function enclosingPackage(startDir: string, maxDepth?: number): {
|
|
15
|
-
path: string;
|
|
16
|
-
version: string | null;
|
|
17
|
-
} | null;
|
|
18
10
|
/** The copy of load-lock this process runs from (its own package root). */
|
|
19
11
|
export declare function thisCopy(): InstalledCopy | null;
|
|
20
12
|
/**
|
package/dist/cli/copies.js
CHANGED
|
@@ -10,41 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
-
import { homedir } from "node:os";
|
|
14
13
|
import { fileURLToPath } from "node:url";
|
|
15
14
|
import { isWindows } from "../shared/transport.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
20
|
-
* given file, by climbing parent directories. Best-effort: returns null if
|
|
21
|
-
* no enclosing package.json matches (or is readable).
|
|
22
|
-
*/
|
|
23
|
-
export function enclosingPackage(startDir, maxDepth = 12) {
|
|
24
|
-
let dir = startDir;
|
|
25
|
-
for (let i = 0; i < maxDepth; i++) {
|
|
26
|
-
const pkgPath = join(dir, "package.json");
|
|
27
|
-
try {
|
|
28
|
-
if (existsSync(pkgPath)) {
|
|
29
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
30
|
-
if (pkg?.name === PKG_NAME) {
|
|
31
|
-
return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
|
|
32
|
-
}
|
|
33
|
-
// A foreign package.json terminates the climb (e.g. a PATH bin
|
|
34
|
-
// inside some other tool's package).
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
const parent = dirname(dir);
|
|
42
|
-
if (parent === dir)
|
|
43
|
-
return null;
|
|
44
|
-
dir = parent;
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
15
|
+
import { enclosingPackage, opencodeCacheRoot, PKG_NAME } from "../shared/pkginfo.js";
|
|
16
|
+
export { enclosingPackage, opencodeCacheRoot } from "../shared/pkginfo.js";
|
|
48
17
|
/** The copy of load-lock this process runs from (its own package root). */
|
|
49
18
|
export function thisCopy() {
|
|
50
19
|
// dist/cli/copies.js -> dist/cli -> ... -> <pkg>/package.json
|
|
@@ -195,13 +164,3 @@ export function findInstalledCopies(platform = process.platform, env = process.e
|
|
|
195
164
|
}
|
|
196
165
|
return copies;
|
|
197
166
|
}
|
|
198
|
-
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
199
|
-
function opencodeCacheRoot(platform, env) {
|
|
200
|
-
if (env.XDG_CACHE_HOME)
|
|
201
|
-
return env.XDG_CACHE_HOME;
|
|
202
|
-
if (platform === "win32")
|
|
203
|
-
return env.LOCALAPPDATA ?? null;
|
|
204
|
-
if (platform === "darwin")
|
|
205
|
-
return join(env.HOME ?? homedir(), "Library", "Caches");
|
|
206
|
-
return join(env.HOME ?? homedir(), ".cache");
|
|
207
|
-
}
|
package/dist/cli/install.js
CHANGED
|
@@ -1154,7 +1154,7 @@ function installOmac(opts, P) {
|
|
|
1154
1154
|
"LOAD_LOCK_LABEL_PREFIX=omac (dashboard shows holders as omac:<label>); " +
|
|
1155
1155
|
"LOAD_LOCK_SOCKET only if the default <state dir>/daemon.sock is not what the sandbox sees");
|
|
1156
1156
|
actions.push("label prefixes need load-lock >= 1.2.0 on BOTH ends: a daemon started before v1.2.0 " +
|
|
1157
|
-
"silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service
|
|
1157
|
+
"silently strips the unknown labelPrefix field — upgrade and restart the daemon (load-lock service restart)");
|
|
1158
1158
|
actions.push("verified live against omac 0.11.0 (bubblewrap + Landlock): sandboxed client -> host daemon " +
|
|
1159
1159
|
"round-trip, prefixed labels, LOAD_LOCK_NO_SPAWN error contract");
|
|
1160
1160
|
return actions;
|
package/dist/cli/main.js
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
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";
|
|
@@ -21,6 +20,8 @@ import { findInstalledCopies } from "./copies.js";
|
|
|
21
20
|
import { installCommand, uninstallCommand } from "./install.js";
|
|
22
21
|
import { hookCommand } from "./hook.js";
|
|
23
22
|
import { topCommand } from "./top.js";
|
|
23
|
+
import { awaitDaemonUpgraded, serviceRestart, serviceStart, serviceStop, ServiceError } from "./service.js";
|
|
24
|
+
import { updateCommand } from "./update.js";
|
|
24
25
|
// ---------------------------------------------------------------- arg parsing
|
|
25
26
|
const argv = process.argv.slice(2);
|
|
26
27
|
let flags = {};
|
|
@@ -35,7 +36,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
35
36
|
const eq = a.indexOf("=");
|
|
36
37
|
if (eq !== -1)
|
|
37
38
|
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
38
|
-
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)))
|
|
39
40
|
flags[a.slice(2)] = true;
|
|
40
41
|
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--"))
|
|
41
42
|
flags[a.slice(2)] = argv[++i];
|
|
@@ -66,87 +67,6 @@ function defaultAgent() {
|
|
|
66
67
|
return (process.env.LOAD_LOCK_AGENT ||
|
|
67
68
|
"cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
|
|
68
69
|
}
|
|
69
|
-
// ------------------------------------------------- daemon service helpers
|
|
70
|
-
/** daemon.json contents, or null when missing/unreadable. */
|
|
71
|
-
function readDaemonInfo(dir) {
|
|
72
|
-
try {
|
|
73
|
-
return JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Version-less hello over a raw socket connection: deliberately does NOT
|
|
81
|
-
* send clientVersion, so asking a daemon for its identity never orders it
|
|
82
|
-
* to self-restart (used by `service stop` and post-restart confirmation).
|
|
83
|
-
* Best-effort: resolves null on any failure.
|
|
84
|
-
*/
|
|
85
|
-
function rawHello(sockPath, timeoutMs = 2000) {
|
|
86
|
-
return new Promise((resolve) => {
|
|
87
|
-
let settled = false;
|
|
88
|
-
let buf = "";
|
|
89
|
-
const done = (v) => {
|
|
90
|
-
if (settled)
|
|
91
|
-
return;
|
|
92
|
-
settled = true;
|
|
93
|
-
try {
|
|
94
|
-
sock.destroy();
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
/* ignore */
|
|
98
|
-
}
|
|
99
|
-
resolve(v);
|
|
100
|
-
};
|
|
101
|
-
const sock = connect(sockPath, () => {
|
|
102
|
-
sock.write(JSON.stringify({ id: 1, method: "hello", params: {} }) + "\n");
|
|
103
|
-
});
|
|
104
|
-
sock.on("data", (chunk) => {
|
|
105
|
-
buf += chunk.toString("utf8");
|
|
106
|
-
const idx = buf.indexOf("\n");
|
|
107
|
-
if (idx === -1)
|
|
108
|
-
return;
|
|
109
|
-
try {
|
|
110
|
-
const msg = JSON.parse(buf.slice(0, idx));
|
|
111
|
-
done(msg.ok ? msg.result : null);
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
done(null);
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
sock.on("error", () => done(null));
|
|
118
|
-
const timer = setTimeout(() => done(null), timeoutMs);
|
|
119
|
-
timer.unref?.();
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
/** Poll the socket until nothing answers there (bounded). */
|
|
123
|
-
async function waitSocketFree(sockPath, timeoutMs) {
|
|
124
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
125
|
-
for (;;) {
|
|
126
|
-
if (!(await probeTransport(sockPath)))
|
|
127
|
-
return true;
|
|
128
|
-
if (Date.now() >= deadline)
|
|
129
|
-
return false;
|
|
130
|
-
await delay(100);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* After ordering a self-restart, wait for the daemon to come back running
|
|
135
|
-
* at least `installed` (the version of this CLI copy). Resolves its hello
|
|
136
|
-
* result, or null if it did not come back (or an older/pre-handshake
|
|
137
|
-
* daemon answered again — e.g. the daemon's copy is not actually updated).
|
|
138
|
-
*/
|
|
139
|
-
async function awaitDaemonUpgraded(sockPath, timeoutMs, installed) {
|
|
140
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
141
|
-
for (;;) {
|
|
142
|
-
const h = await rawHello(sockPath, 1500);
|
|
143
|
-
if (h && typeof h.daemonVersion === "string" && compareVersions(h.daemonVersion, installed) >= 0)
|
|
144
|
-
return h;
|
|
145
|
-
if (Date.now() >= deadline)
|
|
146
|
-
return null;
|
|
147
|
-
await delay(250);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
70
|
const HELP_GROUPS = [
|
|
151
71
|
{
|
|
152
72
|
title: "permit operations",
|
|
@@ -177,12 +97,14 @@ const HELP_GROUPS = [
|
|
|
177
97
|
{ names: ["config", "get-config"], desc: "current configuration" },
|
|
178
98
|
{ names: ["configure"], desc: "change configuration (compare-and-swap): --reason required" },
|
|
179
99
|
{ names: ["dashboard", "top"], desc: "full-screen live dashboard, read-only (never acquires): [--once]" },
|
|
100
|
+
{ names: ["version"], desc: "print this CLI copy's version" },
|
|
180
101
|
],
|
|
181
102
|
},
|
|
182
103
|
{
|
|
183
104
|
title: "service & diagnostics",
|
|
184
105
|
items: [
|
|
185
|
-
{ 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)" },
|
|
186
108
|
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, versions + copies, machine" },
|
|
187
109
|
{ names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
|
|
188
110
|
],
|
|
@@ -216,10 +138,15 @@ function usage() {
|
|
|
216
138
|
for (const item of g.items)
|
|
217
139
|
lines.push(helpLine(item));
|
|
218
140
|
}
|
|
219
|
-
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");
|
|
220
142
|
process.stdout.write(lines.join("\n") + "\n");
|
|
221
143
|
}
|
|
222
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
|
+
}
|
|
223
150
|
// `--help`, `-h` and a bare `load-lock` all print the same usage as `help`.
|
|
224
151
|
if (f["help"] !== undefined || cmd === "-h" || (positional.length === 0 && argv.length === 0)) {
|
|
225
152
|
usage();
|
|
@@ -452,130 +379,29 @@ async function main() {
|
|
|
452
379
|
// --------------------------------------------------------------- service
|
|
453
380
|
case "service": {
|
|
454
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
|
+
};
|
|
455
387
|
if (sub === "stop") {
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
// daemon.json (crash leftovers) is cleaned up either way.
|
|
460
|
-
const dir = stateDir();
|
|
461
|
-
const sock = socketPath();
|
|
462
|
-
const timeoutMs = N("timeout", 5000);
|
|
463
|
-
const info = readDaemonInfo(dir);
|
|
464
|
-
const answering = await probeTransport(sock);
|
|
465
|
-
if (!answering) {
|
|
466
|
-
// Nothing is listening: no daemon to stop. Clean crash leftovers.
|
|
467
|
-
try {
|
|
468
|
-
if (!isWindows() && existsSync(sock))
|
|
469
|
-
unlinkSync(sock);
|
|
470
|
-
}
|
|
471
|
-
catch {
|
|
472
|
-
/* ignore */
|
|
473
|
-
}
|
|
474
|
-
try {
|
|
475
|
-
rmSync(join(dir, "daemon.json"), { force: true });
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
/* ignore */
|
|
479
|
-
}
|
|
480
|
-
process.stdout.write("load-lock: no running daemon found (stale files cleaned)\n");
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
// Identify the daemon: prefer daemon.json, else ask it directly
|
|
484
|
-
// (version-less hello — stopping must not order a self-restart).
|
|
485
|
-
let pid = typeof info?.pid === "number" ? info.pid : undefined;
|
|
486
|
-
if (!pid) {
|
|
487
|
-
const hello = await rawHello(sock, 1500);
|
|
488
|
-
if (hello && typeof hello.pid === "number")
|
|
489
|
-
pid = hello.pid;
|
|
490
|
-
}
|
|
491
|
-
if (!pid) {
|
|
492
|
-
fail(`the socket ${sock} answers but no pid could be identified (daemon did not respond and daemon.json names none) ` +
|
|
493
|
-
`— cannot stop safely; run \`load-lock doctor\``, 70);
|
|
494
|
-
}
|
|
495
|
-
let how = "SIGTERM";
|
|
496
|
-
let stopped = false;
|
|
497
|
-
try {
|
|
498
|
-
process.kill(pid, "SIGTERM");
|
|
499
|
-
}
|
|
500
|
-
catch (e) {
|
|
501
|
-
if (e?.code === "ESRCH")
|
|
502
|
-
stopped = true; // already gone
|
|
503
|
-
else
|
|
504
|
-
fail(`cannot signal daemon pid ${pid}: ${e?.message ?? e}`, 70);
|
|
505
|
-
}
|
|
506
|
-
if (!stopped)
|
|
507
|
-
stopped = await waitSocketFree(sock, timeoutMs);
|
|
508
|
-
if (!stopped) {
|
|
509
|
-
how = "SIGKILL";
|
|
510
|
-
try {
|
|
511
|
-
process.kill(pid, "SIGKILL");
|
|
512
|
-
}
|
|
513
|
-
catch {
|
|
514
|
-
/* already dead */
|
|
515
|
-
}
|
|
516
|
-
stopped = await waitSocketFree(sock, timeoutMs);
|
|
517
|
-
}
|
|
518
|
-
if (!stopped) {
|
|
519
|
-
fail(`daemon pid ${pid} did not stop within ${timeoutMs}ms even after SIGKILL — run \`load-lock doctor\``, 70);
|
|
520
|
-
}
|
|
521
|
-
try {
|
|
522
|
-
if (!isWindows() && existsSync(sock))
|
|
523
|
-
unlinkSync(sock);
|
|
524
|
-
}
|
|
525
|
-
catch {
|
|
526
|
-
/* ignore */
|
|
527
|
-
}
|
|
528
|
-
try {
|
|
529
|
-
rmSync(join(dir, "daemon.json"), { force: true });
|
|
530
|
-
}
|
|
531
|
-
catch {
|
|
532
|
-
/* ignore */
|
|
533
|
-
}
|
|
534
|
-
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`);
|
|
535
391
|
return;
|
|
536
392
|
}
|
|
537
393
|
if (sub === "start") {
|
|
538
394
|
// Connecting auto-spawns a detached daemon (see shared/client.ts);
|
|
539
395
|
// hello confirms it is up. Refusals name the conflicting pid.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const up = await awaitDaemonUpgraded(sock, 8000, installed);
|
|
550
|
-
if (up) {
|
|
551
|
-
process.stdout.write(`load-lock: daemon self-restarted ${r.pid} -> ${up.pid} (v${dv} -> v${up.daemonVersion}), running on ${sock}\n`);
|
|
552
|
-
}
|
|
553
|
-
else {
|
|
554
|
-
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock}), daemon v${dv} — older than this CLI v${installed}; ` +
|
|
555
|
-
`self-restart ordered but not confirmed (its copy may not be updated in place — see docs/OPERATIONS.md)\n`);
|
|
556
|
-
}
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
process.stdout.write(`load-lock: daemon running on pid ${r.pid} (${sock})${dv ? `, daemon v${dv}` : ""}\n`);
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
catch (e) {
|
|
563
|
-
if (await probeTransport(sock)) {
|
|
564
|
-
// Something owns the endpoint. Name it.
|
|
565
|
-
const hello = await rawHello(sock, 1500);
|
|
566
|
-
if (hello && typeof hello.pid === "number") {
|
|
567
|
-
fail(`start refused: socket answered from pid ${hello.pid} — another daemon owns ${sock}; ` +
|
|
568
|
-
`if it is stale, run \`load-lock service stop\` first`, 70);
|
|
569
|
-
}
|
|
570
|
-
const info = readDaemonInfo(stateDir());
|
|
571
|
-
if (info && typeof info.pid === "number") {
|
|
572
|
-
fail(`socket ${sock} answers but the daemon did not respond (daemon.json names pid ${info.pid}) — likely hung; ` +
|
|
573
|
-
`run \`load-lock service stop\``, 70);
|
|
574
|
-
}
|
|
575
|
-
fail(`start refused: ${sock} is in use but nothing responsive owns it — run \`load-lock doctor\``, 70);
|
|
576
|
-
}
|
|
577
|
-
throw e;
|
|
578
|
-
}
|
|
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;
|
|
579
405
|
}
|
|
580
406
|
if (sub === "status") {
|
|
581
407
|
const r = (await withClient((c) => c.request("hello", {})));
|
|
@@ -595,9 +421,23 @@ async function main() {
|
|
|
595
421
|
});
|
|
596
422
|
return;
|
|
597
423
|
}
|
|
598
|
-
fail(`service: unknown subcommand "${sub}" (use start | stop | status)`);
|
|
424
|
+
fail(`service: unknown subcommand "${sub}" (use start | stop | restart | status)`);
|
|
599
425
|
return;
|
|
600
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);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case "version":
|
|
439
|
+
process.stdout.write(packageVersion() + "\n");
|
|
440
|
+
return;
|
|
601
441
|
case "doctor": {
|
|
602
442
|
const report = {};
|
|
603
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
|
+
}>;
|