@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
package/dist/dashboard/feed.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { buildDashboardView } from "./view.js";
|
|
2
|
+
import { detectInstallDrift } from "../shared/installs.js";
|
|
2
3
|
const DEFAULTS = {
|
|
3
4
|
eventHistory: 60,
|
|
4
5
|
slowRefreshMs: 15_000,
|
|
5
6
|
refreshDebounceMs: 40,
|
|
6
7
|
inspectEveryMs: 60_000,
|
|
7
8
|
reconnectBaseMs: 250,
|
|
9
|
+
driftCheckMs: 15_000,
|
|
10
|
+
driftSurface: "cli",
|
|
8
11
|
};
|
|
9
12
|
export class DashboardFeed {
|
|
10
13
|
client;
|
|
@@ -20,10 +23,14 @@ export class DashboardFeed {
|
|
|
20
23
|
daemonStartedAt = 0;
|
|
21
24
|
refreshTimer = null;
|
|
22
25
|
slowTimer = null;
|
|
26
|
+
driftTimer = null;
|
|
27
|
+
drift = null;
|
|
23
28
|
stopped = false;
|
|
24
29
|
unsub = null;
|
|
25
30
|
connecting = null;
|
|
26
31
|
viewSeq = 0;
|
|
32
|
+
/** daemonVersion seen at the previous resync — powers the "restarted onto" note. */
|
|
33
|
+
lastDaemonVersion;
|
|
27
34
|
/** Fired whenever the rendered view might have changed (state or data). */
|
|
28
35
|
onChange;
|
|
29
36
|
/** Fired on connection-state transitions. */
|
|
@@ -63,7 +70,9 @@ export class DashboardFeed {
|
|
|
63
70
|
typeof this.client.onDisconnect === "function"
|
|
64
71
|
? (this.client.onDisconnect(() => void this.handleDisconnect()) ?? undefined)
|
|
65
72
|
: null;
|
|
66
|
-
|
|
73
|
+
// Timers are installed BEFORE the first connect: a failed initial
|
|
74
|
+
// connect must not leave the feed without its slow-refresh/drift
|
|
75
|
+
// timers once the reconnect loop eventually recovers (issue #39 H3).
|
|
67
76
|
this.slowTimer =
|
|
68
77
|
this.opts.slowRefreshMs > 0
|
|
69
78
|
? setInterval(() => {
|
|
@@ -72,6 +81,27 @@ export class DashboardFeed {
|
|
|
72
81
|
}, this.opts.slowRefreshMs)
|
|
73
82
|
: null;
|
|
74
83
|
this.slowTimer?.unref?.();
|
|
84
|
+
this.driftTimer =
|
|
85
|
+
this.opts.driftCheckMs > 0
|
|
86
|
+
? setInterval(() => {
|
|
87
|
+
void this.refreshDrift();
|
|
88
|
+
}, this.opts.driftCheckMs)
|
|
89
|
+
: null;
|
|
90
|
+
this.driftTimer?.unref?.();
|
|
91
|
+
void this.refreshDrift();
|
|
92
|
+
try {
|
|
93
|
+
await this.connectAndSync();
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
// Issue #39 (hypothesis 3): previously a failed initial connect
|
|
97
|
+
// rejected start() with no socket ever attached, so no close event
|
|
98
|
+
// could ever fire handleDisconnect and the feed wedged in
|
|
99
|
+
// "connecting" forever. Enter the standard reconnect loop instead —
|
|
100
|
+
// the loop owns recovery, and start() still reports the failure.
|
|
101
|
+
this.setState("reconnecting");
|
|
102
|
+
void this.reconnectLoop();
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
75
105
|
}
|
|
76
106
|
async stop() {
|
|
77
107
|
this.stopped = true;
|
|
@@ -79,8 +109,11 @@ export class DashboardFeed {
|
|
|
79
109
|
clearTimeout(this.refreshTimer);
|
|
80
110
|
if (this.slowTimer)
|
|
81
111
|
clearInterval(this.slowTimer);
|
|
112
|
+
if (this.driftTimer)
|
|
113
|
+
clearInterval(this.driftTimer);
|
|
82
114
|
this.refreshTimer = null;
|
|
83
115
|
this.slowTimer = null;
|
|
116
|
+
this.driftTimer = null;
|
|
84
117
|
this.unsub?.();
|
|
85
118
|
this.unsub = null;
|
|
86
119
|
this.client.close();
|
|
@@ -112,12 +145,23 @@ export class DashboardFeed {
|
|
|
112
145
|
let restartNote;
|
|
113
146
|
if (daemonStartChanged || evRes.latestEventId < this.latestEventId) {
|
|
114
147
|
// Daemon restarted: ids restarted from scratch. Reset and re-pull.
|
|
115
|
-
|
|
148
|
+
const pid = status.daemon?.pid ?? "?";
|
|
149
|
+
const newVersion = status.daemon?.daemonVersion;
|
|
150
|
+
// Issue #39 (deliverable d): when the v1.3.0 handshake reports a
|
|
151
|
+
// DIFFERENT version than the daemon we were talking to, say which
|
|
152
|
+
// version the daemon restarted onto (e.g. after an update).
|
|
153
|
+
if (this.lastDaemonVersion && newVersion && newVersion !== this.lastDaemonVersion) {
|
|
154
|
+
restartNote = `daemon restarted onto v${newVersion} (was v${this.lastDaemonVersion}; pid ${pid}); event history re-synced`;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
restartNote = `daemon restarted (pid ${pid}); event history re-synced`;
|
|
158
|
+
}
|
|
116
159
|
this.events = [];
|
|
117
160
|
this.latestEventId = 0;
|
|
118
161
|
const fresh = await this.client.request("events", { sinceEventId: 0 });
|
|
119
162
|
this.mergeEvents(fresh.events, fresh.latestEventId);
|
|
120
163
|
}
|
|
164
|
+
this.lastDaemonVersion = status.daemon?.daemonVersion;
|
|
121
165
|
this.restartNote = restartNote;
|
|
122
166
|
if (this.inspectAt === 0 || Date.now() - this.inspectAt > this.opts.inspectEveryMs) {
|
|
123
167
|
await this.refreshInspect();
|
|
@@ -240,6 +284,22 @@ export class DashboardFeed {
|
|
|
240
284
|
await this.client.request("admin_release", { requestId });
|
|
241
285
|
await this.refresh().catch(() => { });
|
|
242
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Re-probe install drift (running copy vs PATH-resolved CLI) and surface
|
|
289
|
+
* it on the next view. Public so surfaces can trigger it on demand
|
|
290
|
+
* (e.g. right after a plugin's panel regains focus).
|
|
291
|
+
*/
|
|
292
|
+
refreshDrift() {
|
|
293
|
+
if (this.stopped || this.opts.driftCheckMs === 0)
|
|
294
|
+
return;
|
|
295
|
+
try {
|
|
296
|
+
this.drift = detectInstallDrift();
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
this.drift = null;
|
|
300
|
+
}
|
|
301
|
+
this.emitChange();
|
|
302
|
+
}
|
|
243
303
|
/** Render-ready snapshot; countdowns are computed at `now`. */
|
|
244
304
|
view(now = Date.now()) {
|
|
245
305
|
return buildDashboardView({
|
|
@@ -250,6 +310,8 @@ export class DashboardFeed {
|
|
|
250
310
|
state: this.state,
|
|
251
311
|
lastSyncAt: this.lastSyncAt,
|
|
252
312
|
daemonRestartNote: this.restartNote,
|
|
313
|
+
installDrift: this.drift,
|
|
314
|
+
driftSurface: this.opts.driftSurface,
|
|
253
315
|
now,
|
|
254
316
|
});
|
|
255
317
|
}
|
package/dist/dashboard/frame.js
CHANGED
|
@@ -70,6 +70,8 @@ export function renderFrame(view, width = 100, height = 40) {
|
|
|
70
70
|
push(`note: ${view.daemonRestartNote}`);
|
|
71
71
|
if (view.versionSkewNote)
|
|
72
72
|
push(`note: ${view.versionSkewNote}`);
|
|
73
|
+
if (view.installDriftNote)
|
|
74
|
+
push(`note: ${view.installDriftNote}`);
|
|
73
75
|
if (view.machine) {
|
|
74
76
|
const m = view.machine;
|
|
75
77
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup ${m.cgroupVersion ?? ""} quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
package/dist/dashboard/view.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* "now" is always an explicit parameter, which makes countdowns testable.
|
|
10
10
|
*/
|
|
11
11
|
import type * as P from "../shared/protocol.js";
|
|
12
|
+
import type { InstallDrift } from "../shared/installs.js";
|
|
12
13
|
/** Connection state of the dashboard's daemon feed. */
|
|
13
14
|
export type FeedState = "connecting" | "live" | "reconnecting" | "down";
|
|
14
15
|
export interface HolderCard {
|
|
@@ -116,6 +117,12 @@ export interface DashboardView {
|
|
|
116
117
|
daemonRestartNote?: string;
|
|
117
118
|
/** Set when the running daemon is older than the installed copy. */
|
|
118
119
|
versionSkewNote?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Set when the RUNNING copy is older than the PATH-resolved CLI install
|
|
122
|
+
* (issue #39: harnesses like opencode load plugin code once and never
|
|
123
|
+
* hot-reload it, so an update leaves this session running stale code).
|
|
124
|
+
*/
|
|
125
|
+
installDriftNote?: string;
|
|
119
126
|
daemon: DaemonView | null;
|
|
120
127
|
pools: PoolView[];
|
|
121
128
|
events: EventLine[];
|
|
@@ -148,6 +155,10 @@ export interface DashboardInputs {
|
|
|
148
155
|
state: FeedState;
|
|
149
156
|
lastSyncAt: number;
|
|
150
157
|
daemonRestartNote?: string;
|
|
158
|
+
/** Running-copy vs PATH-install drift probe result (null = unknown). */
|
|
159
|
+
installDrift?: InstallDrift | null;
|
|
160
|
+
/** Wording hint for the drift note; the TUI plugin passes "opencode". */
|
|
161
|
+
driftSurface?: "cli" | "opencode";
|
|
151
162
|
now: number;
|
|
152
163
|
}
|
|
153
164
|
export declare function buildDashboardView(inps: DashboardInputs): DashboardView;
|
package/dist/dashboard/view.js
CHANGED
|
@@ -186,10 +186,23 @@ export function buildDashboardView(inps) {
|
|
|
186
186
|
: null;
|
|
187
187
|
// Newest first; keep the tail (feed already bounds history length).
|
|
188
188
|
const evs = [...inps.events].reverse();
|
|
189
|
+
// Issue #39: make in-memory staleness visible. A harness that loads
|
|
190
|
+
// plugin code once (opencode never hot-reloads plugins) keeps running
|
|
191
|
+
// the OLD copy after `npm i -g @aron98/load-lock@latest`; the panel can
|
|
192
|
+
// only hint at the recovery action, which differs per surface.
|
|
193
|
+
const drift = inps.installDrift;
|
|
194
|
+
let installDriftNote;
|
|
195
|
+
if (drift?.stale && drift.pathVersion) {
|
|
196
|
+
installDriftNote =
|
|
197
|
+
inps.driftSurface === "opencode"
|
|
198
|
+
? `plugin updated — restart opencode to pick up v${drift.pathVersion} (this session still runs v${drift.ownVersion}; opencode does not hot-reload plugins)`
|
|
199
|
+
: `PATH CLI v${drift.pathVersion} is newer than this copy v${drift.ownVersion} — you are running a stale install`;
|
|
200
|
+
}
|
|
189
201
|
return {
|
|
190
202
|
now,
|
|
191
203
|
state: inps.state,
|
|
192
204
|
daemonRestartNote: inps.daemonRestartNote,
|
|
205
|
+
installDriftNote,
|
|
193
206
|
versionSkewNote: d && typeof d.daemonVersion === "string" && compareVersions(d.daemonVersion, packageVersion()) < 0
|
|
194
207
|
? `daemon v${d.daemonVersion} is older than installed v${packageVersion()} — it self-restarts on newer-client contact`
|
|
195
208
|
: undefined,
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -16,11 +16,18 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
18
|
import { LLClient } from "../shared/client.js";
|
|
19
|
+
import { type CliDrift } from "../shared/drift.js";
|
|
19
20
|
export interface McpServerOptions {
|
|
20
21
|
client: LLClient;
|
|
21
22
|
serverName?: string;
|
|
22
23
|
/** Overridden by OpenCode's per-request session metadata when present. */
|
|
23
24
|
agentId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* CLI-drift notice to surface (issue #38). Defaults to auto-detection at
|
|
27
|
+
* build time (this adapter copy vs. the PATH-resolved CLI copy);
|
|
28
|
+
* pass null to suppress / a value to inject in tests.
|
|
29
|
+
*/
|
|
30
|
+
drift?: CliDrift | null;
|
|
24
31
|
}
|
|
25
32
|
export declare function buildServer(opts: McpServerOptions): McpServer;
|
|
26
33
|
export declare function runStdioMcp(opts?: {
|
package/dist/mcp/server.js
CHANGED
|
@@ -23,6 +23,8 @@ import { POLICY_BRIEF } from "../policy/text.js";
|
|
|
23
23
|
import { waitForTurn, WaitAbortedError } from "../shared/wait-turn.js";
|
|
24
24
|
import { waitForLocalJob, waitForTrackedJob } from "../shared/wait-completion.js";
|
|
25
25
|
import { killProcessGroup } from "../shared/transport.js";
|
|
26
|
+
import { detectCliDrift } from "../shared/drift.js";
|
|
27
|
+
import { packageVersion } from "../shared/version.js";
|
|
26
28
|
const ACQUIRE_DESC = `Acquire a machine-wide permit before starting resource-intensive local work (full builds, test suites, big compiles/containers).
|
|
27
29
|
|
|
28
30
|
Pass a short reason and a stable op_key identifying the pending operation (duplicate acquires for the same op_key return the existing request — do not invent a new key while one is live). Set wait_ms (default a few seconds) to block briefly for capacity instead of returning queued immediately; keep doing light work while queued. You will receive an offer to claim when capacity frees. Never start heavy work while merely queued.`;
|
|
@@ -31,7 +33,14 @@ const RELEASE_DESC = `Release a held permit immediately after the heavy work fin
|
|
|
31
33
|
export function buildServer(opts) {
|
|
32
34
|
const client = opts.client;
|
|
33
35
|
const defaultAgent = opts.agentId ?? "agt_" + process.pid.toString(36) + Math.random().toString(36).slice(2, 8);
|
|
34
|
-
|
|
36
|
+
// Drift notice (issue #38): one clear notice naming the exact command when
|
|
37
|
+
// this adapter copy is newer than the PATH-installed CLI. Notify only —
|
|
38
|
+
// never auto-install. Printed to stderr at init and embedded in the
|
|
39
|
+
// initialize instructions so any MCP client sees it exactly once.
|
|
40
|
+
const drift = opts.drift !== undefined ? opts.drift : detectCliDrift(packageVersion());
|
|
41
|
+
if (drift)
|
|
42
|
+
process.stderr.write(drift.message + "\n");
|
|
43
|
+
const server = new McpServer({ name: opts.serverName ?? "load-lock", version: "0.1.0" }, { instructions: POLICY_BRIEF + (drift ? "\n\n" + drift.message : "") });
|
|
35
44
|
const json = (v) => ({
|
|
36
45
|
content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
|
|
37
46
|
});
|
package/dist/shared/client.js
CHANGED
|
@@ -30,6 +30,7 @@ import { ProtocolError } from "./protocol.js";
|
|
|
30
30
|
import { socketPath as defaultSocket, stateDir as defaultStateDir } from "../core/persist.js";
|
|
31
31
|
import { probeTransport } from "./transport.js";
|
|
32
32
|
import { packageVersion } from "./version.js";
|
|
33
|
+
import { resolveDaemonEntry } from "./installs.js";
|
|
33
34
|
export class LLClient {
|
|
34
35
|
sock = null;
|
|
35
36
|
nextId = 1;
|
|
@@ -230,7 +231,12 @@ function daemonMainPath() {
|
|
|
230
231
|
if (process.env.LOAD_LOCK_DAEMON_BIN)
|
|
231
232
|
return process.env.LOAD_LOCK_DAEMON_BIN;
|
|
232
233
|
const here = dirname(fileURLToPath(import.meta.url)); // .../dist/shared
|
|
233
|
-
|
|
234
|
+
const own = join(here, "..", "core", "daemon-main.js");
|
|
235
|
+
// Issue #39: a harness (e.g. opencode's timestamped plugin cache) may
|
|
236
|
+
// prune this copy's directory while a session still executes it from
|
|
237
|
+
// memory — resolve to the PATH-installed CLI's daemon entry instead of
|
|
238
|
+
// spawning an ENOENT forever.
|
|
239
|
+
return resolveDaemonEntry(own);
|
|
234
240
|
}
|
|
235
241
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
236
242
|
export async function ensureDaemon(stateDir, sockPath) {
|
|
@@ -274,6 +280,10 @@ export async function ensureDaemon(stateDir, sockPath) {
|
|
|
274
280
|
stdio: "ignore",
|
|
275
281
|
env: { ...process.env, LOAD_LOCK_DAEMON_SPAWNED: "1" },
|
|
276
282
|
});
|
|
283
|
+
// Issue #39: an unreadable/vanished entry must surface as the
|
|
284
|
+
// "did not come up" probe failure below, not crash the host process
|
|
285
|
+
// with an uncaught ENOENT 'error' event.
|
|
286
|
+
child.on("error", () => { });
|
|
277
287
|
child.unref();
|
|
278
288
|
}
|
|
279
289
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface CliDrift {
|
|
2
|
+
/** Version of this (adapter) copy — the newer one. */
|
|
3
|
+
ownVersion: string;
|
|
4
|
+
/** Version of the PATH-resolved `load-lock` CLI copy — the older one. */
|
|
5
|
+
cliVersion: string;
|
|
6
|
+
/** Package root of the PATH-resolved CLI copy. */
|
|
7
|
+
cliPath: string;
|
|
8
|
+
/** The one clear notice, with the exact command. */
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
/** The notice text (exported for tests and doc-sync checks). */
|
|
12
|
+
export declare function driftMessage(ownVersion: string, cliVersion: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Compare `ownVersion` (this adapter copy) with the version of the
|
|
15
|
+
* `load-lock` binary that PATH resolves to, by reading that copy's
|
|
16
|
+
* package.json — never by spawning the old CLI, which may predate any
|
|
17
|
+
* introspection. Returns a drift notice when the PATH CLI is strictly
|
|
18
|
+
* older; null when it is the same or newer, absent, or unreadable.
|
|
19
|
+
* Best-effort: never throws.
|
|
20
|
+
*/
|
|
21
|
+
export declare function detectCliDrift(ownVersion: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): CliDrift | null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter-init drift notice (issue #38): when a harness adapter copy NEWER
|
|
3
|
+
* than the PATH-installed `load-lock` CLI initializes — typically because
|
|
4
|
+
* opencode refreshed its private cache copy — surface ONE clear notice
|
|
5
|
+
* naming the exact command (`load-lock update`).
|
|
6
|
+
*
|
|
7
|
+
* Notify-first, NEVER auto-install: spawning a package manager from inside
|
|
8
|
+
* a harness plugin needs explicit opt-in; the default is to tell the
|
|
9
|
+
* agent/user, who can run `load-lock update` as an ordinary command or
|
|
10
|
+
* tool call.
|
|
11
|
+
*
|
|
12
|
+
* Honest limitation (documented in docs/OPERATIONS.md): a harness session
|
|
13
|
+
* that was already running keeps the OLD adapter code in memory until the
|
|
14
|
+
* harness restarts — this notice is emitted by the NEW copy at its init,
|
|
15
|
+
* which is the best it can do for sessions started before the refresh.
|
|
16
|
+
*
|
|
17
|
+
* Pure and synchronous (PATH scan + package.json reads only) so both the
|
|
18
|
+
* OpenCode plugin setup and the MCP server startup can call it inline.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { isWindows } from "./transport.js";
|
|
23
|
+
import { enclosingPackage, PKG_NAME } from "./pkginfo.js";
|
|
24
|
+
import { compareVersions } from "./version.js";
|
|
25
|
+
/** The notice text (exported for tests and doc-sync checks). */
|
|
26
|
+
export function driftMessage(ownVersion, cliVersion) {
|
|
27
|
+
return (`load-lock: this load-lock adapter copy (v${ownVersion}) is newer than the installed CLI (v${cliVersion}). ` +
|
|
28
|
+
`Run \`load-lock update\` to update the CLI on PATH and restart the daemon. ` +
|
|
29
|
+
`(Notify only — nothing is auto-installed; if your CLI predates the \`update\` command, run ` +
|
|
30
|
+
`\`npm i -g ${PKG_NAME}@latest\` instead.)`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compare `ownVersion` (this adapter copy) with the version of the
|
|
34
|
+
* `load-lock` binary that PATH resolves to, by reading that copy's
|
|
35
|
+
* package.json — never by spawning the old CLI, which may predate any
|
|
36
|
+
* introspection. Returns a drift notice when the PATH CLI is strictly
|
|
37
|
+
* older; null when it is the same or newer, absent, or unreadable.
|
|
38
|
+
* Best-effort: never throws.
|
|
39
|
+
*/
|
|
40
|
+
export function detectCliDrift(ownVersion, env = process.env, platform = process.platform) {
|
|
41
|
+
try {
|
|
42
|
+
const sep = isWindows(platform) ? ";" : ":";
|
|
43
|
+
const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
|
|
44
|
+
const dirs = (env.PATH ?? "").split(sep).filter(Boolean);
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
for (const bin of binNames) {
|
|
47
|
+
const binPath = join(dir, bin);
|
|
48
|
+
if (!existsSync(binPath) || !statSync(binPath).isFile())
|
|
49
|
+
continue;
|
|
50
|
+
// Resolve through symlinks/shims to the copy that owns the bin.
|
|
51
|
+
let realBin;
|
|
52
|
+
try {
|
|
53
|
+
realBin = realpathSync(binPath);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
realBin = binPath;
|
|
57
|
+
}
|
|
58
|
+
const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dir);
|
|
59
|
+
if (!found || found.version === null)
|
|
60
|
+
return null; // first PATH hit wins; cannot compare honestly
|
|
61
|
+
if (compareVersions(found.version, ownVersion) < 0) {
|
|
62
|
+
return {
|
|
63
|
+
ownVersion,
|
|
64
|
+
cliVersion: found.version,
|
|
65
|
+
cliPath: found.path,
|
|
66
|
+
message: driftMessage(ownVersion, found.version),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null; // PATH CLI is same/newer — no drift
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* best-effort: unreadable PATH entries are skipped, never fatal */
|
|
75
|
+
}
|
|
76
|
+
return null; // no load-lock on PATH — nothing to compare against
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** All `load-lock` binaries found on PATH, in PATH order (files only). */
|
|
2
|
+
export declare function pathBinCandidates(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string[];
|
|
3
|
+
export interface PathInstall {
|
|
4
|
+
/** Package root of the install (the directory containing package.json). */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Version from that package.json, or null when unreadable. */
|
|
7
|
+
version: string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The FIRST PATH-resolved `load-lock`'s enclosing install. Symlinks are
|
|
11
|
+
* resolved (npm/bun global layouts symlink the bin into the install), and a
|
|
12
|
+
* plain shim script falls back to the PATH directory itself.
|
|
13
|
+
*/
|
|
14
|
+
export declare function pathInstall(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): PathInstall | null;
|
|
15
|
+
export interface InstallDrift {
|
|
16
|
+
/** Version of THIS running copy (src/shared/version.ts, cached). */
|
|
17
|
+
ownVersion: string;
|
|
18
|
+
/** Version of the PATH-resolved CLI install, or null when unresolvable. */
|
|
19
|
+
pathVersion: string | null;
|
|
20
|
+
/** True when the PATH install is strictly newer than this running copy. */
|
|
21
|
+
stale: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* In-memory staleness probe (issue #39): a harness that loads plugin code
|
|
25
|
+
* once at startup (opencode never hot-reloads plugins) keeps executing the
|
|
26
|
+
* old copy after `npm i -g @aron98/load-lock@latest` — this detects exactly
|
|
27
|
+
* that: the running copy vs. what the PATH `load-lock` resolves to.
|
|
28
|
+
*/
|
|
29
|
+
export declare function detectInstallDrift(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): InstallDrift;
|
|
30
|
+
/**
|
|
31
|
+
* Daemon entry to auto-spawn (issue #39, hypothesis 2): normally the running
|
|
32
|
+
* copy's own dist/core/daemon-main.js. When that file has vanished — a
|
|
33
|
+
* harness may prune its old plugin-cache timestamped dir while a session
|
|
34
|
+
* still runs that copy in memory — spawn from the PATH-installed CLI's copy
|
|
35
|
+
* instead, which is what the user most recently installed. If nothing on
|
|
36
|
+
* PATH resolves either, the own path is returned unchanged so the spawn
|
|
37
|
+
* fails with the original error rather than a misleading one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveDaemonEntry(ownEntry: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install-location helpers (issue #39): resolving the PATH-installed
|
|
3
|
+
* copy of the CLI, detecting version drift between the running copy and that
|
|
4
|
+
* install, and finding a usable daemon entry when the running copy's own
|
|
5
|
+
* entry has vanished (e.g. a harness pruned an old plugin-cache copy while a
|
|
6
|
+
* session still executes it from memory).
|
|
7
|
+
*
|
|
8
|
+
* `enclosingPackage` / `PKG_NAME` live in `./pkginfo.js` (shipped by #38's
|
|
9
|
+
* adapter-init drift notice) and are reused here; this module adds the
|
|
10
|
+
* PATH-install scan and the two #39-specific probes on top of them:
|
|
11
|
+
* `detectInstallDrift` (running copy OLDER than the PATH CLI — the mirror
|
|
12
|
+
* image of pkginfo-neighbor `drift.ts`'s `detectCliDrift`, which flags the
|
|
13
|
+
* PATH CLI being older) and `resolveDaemonEntry` (spawn fallback when this
|
|
14
|
+
* copy's own entry has vanished).
|
|
15
|
+
*
|
|
16
|
+
* Everything here is best-effort and never throws: missing paths, unreadable
|
|
17
|
+
* files, and unexpected layouts yield null / false / the input unchanged.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { isWindows } from "./transport.js";
|
|
22
|
+
import { compareVersions, packageVersion } from "./version.js";
|
|
23
|
+
import { enclosingPackage } from "./pkginfo.js";
|
|
24
|
+
/** All `load-lock` binaries found on PATH, in PATH order (files only). */
|
|
25
|
+
export function pathBinCandidates(platform = process.platform, env = process.env) {
|
|
26
|
+
const binNames = isWindows(platform) ? ["load-lock.cmd", "load-lock.exe", "load-lock"] : ["load-lock"];
|
|
27
|
+
const sep = isWindows(platform) ? ";" : ":";
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const dir of (env.PATH ?? "").split(sep).filter(Boolean)) {
|
|
30
|
+
for (const bin of binNames) {
|
|
31
|
+
try {
|
|
32
|
+
const binPath = join(dir, bin);
|
|
33
|
+
if (statSync(binPath).isFile())
|
|
34
|
+
out.push(binPath);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* unreadable PATH entry — skip */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The FIRST PATH-resolved `load-lock`'s enclosing install. Symlinks are
|
|
45
|
+
* resolved (npm/bun global layouts symlink the bin into the install), and a
|
|
46
|
+
* plain shim script falls back to the PATH directory itself.
|
|
47
|
+
*/
|
|
48
|
+
export function pathInstall(platform = process.platform, env = process.env) {
|
|
49
|
+
for (const binPath of pathBinCandidates(platform, env)) {
|
|
50
|
+
let realBin = binPath;
|
|
51
|
+
try {
|
|
52
|
+
realBin = realpathSync(binPath);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* keep the raw path */
|
|
56
|
+
}
|
|
57
|
+
// A shim/wrapper script is not itself the copy — resolve through its
|
|
58
|
+
// enclosing package, falling back to the PATH dir (same logic the
|
|
59
|
+
// doctor's multi-copy scan uses).
|
|
60
|
+
const found = enclosingPackage(dirname(realBin)) ?? enclosingPackage(dirname(binPath));
|
|
61
|
+
if (found)
|
|
62
|
+
return { path: found.path, version: found.version };
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* In-memory staleness probe (issue #39): a harness that loads plugin code
|
|
68
|
+
* once at startup (opencode never hot-reloads plugins) keeps executing the
|
|
69
|
+
* old copy after `npm i -g @aron98/load-lock@latest` — this detects exactly
|
|
70
|
+
* that: the running copy vs. what the PATH `load-lock` resolves to.
|
|
71
|
+
*/
|
|
72
|
+
export function detectInstallDrift(platform = process.platform, env = process.env) {
|
|
73
|
+
const ownVersion = packageVersion();
|
|
74
|
+
const inst = pathInstall(platform, env);
|
|
75
|
+
const pathVersion = inst?.version ?? null;
|
|
76
|
+
return {
|
|
77
|
+
ownVersion,
|
|
78
|
+
pathVersion,
|
|
79
|
+
stale: pathVersion != null && compareVersions(pathVersion, ownVersion) > 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Daemon entry to auto-spawn (issue #39, hypothesis 2): normally the running
|
|
84
|
+
* copy's own dist/core/daemon-main.js. When that file has vanished — a
|
|
85
|
+
* harness may prune its old plugin-cache timestamped dir while a session
|
|
86
|
+
* still runs that copy in memory — spawn from the PATH-installed CLI's copy
|
|
87
|
+
* instead, which is what the user most recently installed. If nothing on
|
|
88
|
+
* PATH resolves either, the own path is returned unchanged so the spawn
|
|
89
|
+
* fails with the original error rather than a misleading one.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveDaemonEntry(ownEntry, platform = process.platform, env = process.env) {
|
|
92
|
+
if (existsSync(ownEntry))
|
|
93
|
+
return ownEntry;
|
|
94
|
+
const inst = pathInstall(platform, env);
|
|
95
|
+
if (inst) {
|
|
96
|
+
const candidate = join(inst.path, "dist", "core", "daemon-main.js");
|
|
97
|
+
if (existsSync(candidate))
|
|
98
|
+
return candidate;
|
|
99
|
+
}
|
|
100
|
+
return ownEntry;
|
|
101
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Package name of this package (used to find enclosing package roots). */
|
|
2
|
+
export declare const PKG_NAME = "@aron98/load-lock";
|
|
3
|
+
export interface PkgRootInfo {
|
|
4
|
+
/** Package root (the directory holding the matching package.json). */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Version string, or null when the package.json has no/invalid version. */
|
|
7
|
+
version: string | null;
|
|
8
|
+
}
|
|
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): PkgRootInfo | null;
|
|
15
|
+
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
16
|
+
export declare function opencodeCacheRoot(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-identity plumbing shared by copy detection (cli/copies.ts), the
|
|
3
|
+
* update command (cli/update.ts), and the adapter drift notice
|
|
4
|
+
* (shared/drift.ts): the package name and the "which package root
|
|
5
|
+
* contains this file" climb.
|
|
6
|
+
*
|
|
7
|
+
* Pure functions of their arguments (no process.env / import.meta at call
|
|
8
|
+
* time beyond what callers pass in) so tests can plant layouts.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
/** Package name of this package (used to find enclosing package roots). */
|
|
14
|
+
export const PKG_NAME = "@aron98/load-lock";
|
|
15
|
+
/**
|
|
16
|
+
* Find the package.json of the @aron98/load-lock copy that contains the
|
|
17
|
+
* given file, by climbing parent directories. Best-effort: returns null if
|
|
18
|
+
* no enclosing package.json matches (or is readable).
|
|
19
|
+
*/
|
|
20
|
+
export function enclosingPackage(startDir, maxDepth = 12) {
|
|
21
|
+
let dir = startDir;
|
|
22
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
23
|
+
const pkgPath = join(dir, "package.json");
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(pkgPath)) {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
27
|
+
if (pkg?.name === PKG_NAME) {
|
|
28
|
+
return { path: dir, version: typeof pkg.version === "string" ? pkg.version : null };
|
|
29
|
+
}
|
|
30
|
+
// A foreign package.json terminates the climb (e.g. a PATH bin
|
|
31
|
+
// inside some other tool's package).
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir)
|
|
40
|
+
return null;
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Cache root the way opencode resolves it (its npm cache lives under it). */
|
|
46
|
+
export function opencodeCacheRoot(platform, env) {
|
|
47
|
+
if (env.XDG_CACHE_HOME)
|
|
48
|
+
return env.XDG_CACHE_HOME;
|
|
49
|
+
if (platform === "win32")
|
|
50
|
+
return env.LOCALAPPDATA ?? null;
|
|
51
|
+
if (platform === "darwin")
|
|
52
|
+
return join(env.HOME ?? homedir(), "Library", "Caches");
|
|
53
|
+
return join(env.HOME ?? homedir(), ".cache");
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|