@aron98/load-lock 1.2.0 → 1.3.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 +3 -2
- package/dist/adapters/opencode/tui.js +5 -1
- package/dist/cli/copies.d.ts +28 -0
- package/dist/cli/copies.js +207 -0
- package/dist/cli/install.js +218 -1
- package/dist/cli/main.js +264 -16
- package/dist/core/daemon.d.ts +18 -0
- package/dist/core/daemon.js +84 -3
- package/dist/core/persist.js +5 -3
- package/dist/core/state.d.ts +7 -4
- package/dist/core/state.js +64 -10
- package/dist/dashboard/frame.js +5 -1
- package/dist/dashboard/view.d.ts +4 -0
- package/dist/dashboard/view.js +5 -0
- package/dist/shared/client.js +4 -1
- package/dist/shared/protocol.d.ts +40 -2
- package/dist/shared/protocol.js +5 -0
- package/dist/shared/version.d.ts +10 -0
- package/dist/shared/version.js +57 -0
- package/dist/test/install.test.js +181 -1
- package/dist/test/update.test.d.ts +1 -0
- package/dist/test/update.test.js +455 -0
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/COMPATIBILITY.md +79 -4
- package/docs/OPERATIONS.md +171 -56
- package/package.json +1 -1
package/dist/core/state.js
CHANGED
|
@@ -830,7 +830,7 @@ export class Machine {
|
|
|
830
830
|
}
|
|
831
831
|
history = [];
|
|
832
832
|
// ----------------------------------------------------------------- journal
|
|
833
|
-
/** Held permits to persist
|
|
833
|
+
/** Held permits (first) and queued/offered requests to persist across restarts. */
|
|
834
834
|
journalEntries() {
|
|
835
835
|
const out = [];
|
|
836
836
|
for (const e of this.entries.values()) {
|
|
@@ -845,6 +845,7 @@ export class Machine {
|
|
|
845
845
|
opKey: e.opKey,
|
|
846
846
|
reason: e.reason,
|
|
847
847
|
weight: e.weight,
|
|
848
|
+
state: "held",
|
|
848
849
|
permitToken: e.permitToken,
|
|
849
850
|
phase: e.phase ?? "claimed",
|
|
850
851
|
pid: e.pid,
|
|
@@ -855,21 +856,45 @@ export class Machine {
|
|
|
855
856
|
leaseDeadline: e.leaseDeadline,
|
|
856
857
|
});
|
|
857
858
|
}
|
|
859
|
+
for (const e of this.entries.values()) {
|
|
860
|
+
if (e.state !== "queued" && e.state !== "offered")
|
|
861
|
+
continue;
|
|
862
|
+
out.push({
|
|
863
|
+
requestId: e.id,
|
|
864
|
+
pool: e.pool,
|
|
865
|
+
agentId: e.agentId,
|
|
866
|
+
agentLabel: e.agentLabel,
|
|
867
|
+
cwd: e.cwd,
|
|
868
|
+
opKey: e.opKey,
|
|
869
|
+
reason: e.reason,
|
|
870
|
+
weight: e.weight,
|
|
871
|
+
state: "queued",
|
|
872
|
+
enqueuedAt: e.enqueuedAt ?? e.since,
|
|
873
|
+
claimedAt: e.since,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
858
876
|
return out;
|
|
859
877
|
}
|
|
860
878
|
/**
|
|
861
|
-
* Restore
|
|
879
|
+
* Restore state after a daemon restart (conservative reconciliation):
|
|
880
|
+
* - queued (incl. previously offered): re-queued FIFO by original enqueue
|
|
881
|
+
* time, same requestIds so idempotent re-acquire still works; pools no
|
|
882
|
+
* longer in the config are dropped. Offered entries restart as queued
|
|
883
|
+
* and get a fresh offer (old offer tokens are invalid after restart).
|
|
862
884
|
* - tracked: re-check liveness via the supplied probe; dead => release,
|
|
863
885
|
* alive => keep (same token, so a late explicit release still works).
|
|
864
886
|
* - claimed: keep the start deadline (reclaimed by tick if already past).
|
|
865
887
|
* - untracked: keep, with a FRESH lease window from restore time.
|
|
866
|
-
* Queued/offered requests are deliberately NOT restored — queued agents
|
|
867
|
-
* re-acquire (documented restart behavior).
|
|
868
888
|
*/
|
|
869
889
|
restore(journal, liveness) {
|
|
870
890
|
let kept = 0;
|
|
871
891
|
let releasedDead = 0;
|
|
892
|
+
const queuedEntries = [];
|
|
872
893
|
for (const j of journal) {
|
|
894
|
+
if (j.state === "queued") {
|
|
895
|
+
queuedEntries.push({ j, at: j.enqueuedAt ?? j.claimedAt });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
873
898
|
const e = {
|
|
874
899
|
id: j.requestId,
|
|
875
900
|
pool: j.pool,
|
|
@@ -882,20 +907,20 @@ export class Machine {
|
|
|
882
907
|
state: "held",
|
|
883
908
|
since: j.claimedAt,
|
|
884
909
|
permitToken: j.permitToken,
|
|
885
|
-
phase: j.phase,
|
|
910
|
+
phase: j.phase ?? "claimed",
|
|
886
911
|
pid: j.pid,
|
|
887
912
|
pgid: j.pgid,
|
|
888
913
|
pidStartTime: j.pidStartTime,
|
|
889
914
|
startedAt: j.startedAt,
|
|
890
915
|
lastSeen: this.now(),
|
|
891
916
|
};
|
|
892
|
-
if (
|
|
917
|
+
if (e.phase === "claimed") {
|
|
893
918
|
e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
|
|
894
919
|
}
|
|
895
|
-
else if (
|
|
920
|
+
else if (e.phase === "running_untracked") {
|
|
896
921
|
e.leaseDeadline = this.now() + this.config.untrackedLeaseMs; // fresh window
|
|
897
922
|
}
|
|
898
|
-
else if (
|
|
923
|
+
else if (e.phase === "running_tracked") {
|
|
899
924
|
const alive = liveness(e);
|
|
900
925
|
if (alive === false) {
|
|
901
926
|
// Confirmed dead while we were down: release, but keep a terminal
|
|
@@ -915,11 +940,40 @@ export class Machine {
|
|
|
915
940
|
this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
|
|
916
941
|
kept++;
|
|
917
942
|
}
|
|
943
|
+
// Re-queue queued requests, oldest first, preserving FIFO fairness.
|
|
944
|
+
queuedEntries.sort((a, b) => a.at - b.at);
|
|
945
|
+
let queued = 0;
|
|
946
|
+
for (const { j } of queuedEntries) {
|
|
947
|
+
if (!this.config.pools[j.pool])
|
|
948
|
+
continue; // pool removed while down
|
|
949
|
+
const e = {
|
|
950
|
+
id: j.requestId,
|
|
951
|
+
pool: j.pool,
|
|
952
|
+
agentId: j.agentId,
|
|
953
|
+
agentLabel: j.agentLabel,
|
|
954
|
+
cwd: j.cwd,
|
|
955
|
+
opKey: j.opKey,
|
|
956
|
+
reason: j.reason,
|
|
957
|
+
weight: j.weight,
|
|
958
|
+
state: "queued",
|
|
959
|
+
since: this.now(),
|
|
960
|
+
enqueuedAt: j.enqueuedAt ?? j.claimedAt,
|
|
961
|
+
};
|
|
962
|
+
this.entries.set(e.id, e);
|
|
963
|
+
this.byKey.set(`${e.agentId}\u0000${e.opKey}`, e.id);
|
|
964
|
+
if (!this.queues.has(e.pool))
|
|
965
|
+
this.queues.set(e.pool, []);
|
|
966
|
+
this.queues.get(e.pool).push(e.id);
|
|
967
|
+
queued++;
|
|
968
|
+
}
|
|
969
|
+
// Admit newly-eligible waiters (capacity freed while we were down).
|
|
970
|
+
for (const name of this.poolNames())
|
|
971
|
+
this.admit(name);
|
|
918
972
|
this.pushEvent({
|
|
919
973
|
type: "daemon_recovered",
|
|
920
974
|
targetAgent: "*",
|
|
921
|
-
detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released)`,
|
|
975
|
+
detail: `restored ${kept} held permit(s) from journal (${releasedDead} confirmed dead and released); re-queued ${queued} waiting request(s)`,
|
|
922
976
|
});
|
|
923
|
-
return { kept, releasedDead };
|
|
977
|
+
return { kept, releasedDead, queued };
|
|
924
978
|
}
|
|
925
979
|
}
|
package/dist/dashboard/frame.js
CHANGED
|
@@ -59,13 +59,17 @@ export function renderFrame(view, width = 100, height = 40) {
|
|
|
59
59
|
const syncAge = view.lastSyncAt ? fmtDuration(Math.max(0, view.now - view.lastSyncAt)) : "never";
|
|
60
60
|
push(`load-lock dashboard — machine-wide heavy-work budget ${state} synced ${syncAge} ago`);
|
|
61
61
|
if (view.daemon) {
|
|
62
|
-
push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion}
|
|
62
|
+
push(`daemon pid ${view.daemon.pid} · uptime ${fmtDuration(view.daemon.uptimeMs)} · config v${view.daemon.configVersion} · protocol v${view.daemon.protocolVersion}` +
|
|
63
|
+
(view.daemon.daemonVersion ? ` · daemon v${view.daemon.daemonVersion}` : "") +
|
|
64
|
+
` · events through #${view.latestEventId}`);
|
|
63
65
|
}
|
|
64
66
|
else {
|
|
65
67
|
push(`daemon: unreachable — it auto-spawns on the next load-lock command`);
|
|
66
68
|
}
|
|
67
69
|
if (view.daemonRestartNote)
|
|
68
70
|
push(`note: ${view.daemonRestartNote}`);
|
|
71
|
+
if (view.versionSkewNote)
|
|
72
|
+
push(`note: ${view.versionSkewNote}`);
|
|
69
73
|
if (view.machine) {
|
|
70
74
|
const m = view.machine;
|
|
71
75
|
const cg = m.cgroupCpuQuota != null ? ` · cgroup ${m.cgroupVersion ?? ""} quota ${m.cgroupCpuQuota / 100} CPU` : "";
|
package/dist/dashboard/view.d.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface DaemonView {
|
|
|
105
105
|
uptimeMs: number;
|
|
106
106
|
protocolVersion: number;
|
|
107
107
|
configVersion: number;
|
|
108
|
+
/** Package version of the daemon's code (pre-handshake daemons omit it). */
|
|
109
|
+
daemonVersion?: string;
|
|
108
110
|
}
|
|
109
111
|
/** The complete render-ready snapshot of the machine-wide budget. */
|
|
110
112
|
export interface DashboardView {
|
|
@@ -112,6 +114,8 @@ export interface DashboardView {
|
|
|
112
114
|
state: FeedState;
|
|
113
115
|
/** False right after a daemon restart was detected (history was reset). */
|
|
114
116
|
daemonRestartNote?: string;
|
|
117
|
+
/** Set when the running daemon is older than the installed copy. */
|
|
118
|
+
versionSkewNote?: string;
|
|
115
119
|
daemon: DaemonView | null;
|
|
116
120
|
pools: PoolView[];
|
|
117
121
|
events: EventLine[];
|
package/dist/dashboard/view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { packageVersion, compareVersions } from "../shared/version.js";
|
|
1
2
|
// ---------------------------------------------------------------- formatters
|
|
2
3
|
/** Compact human duration: "412ms", "3s", "4m 12s", "2h 05m", "3d 4h". */
|
|
3
4
|
export function fmtDuration(ms) {
|
|
@@ -189,6 +190,9 @@ export function buildDashboardView(inps) {
|
|
|
189
190
|
now,
|
|
190
191
|
state: inps.state,
|
|
191
192
|
daemonRestartNote: inps.daemonRestartNote,
|
|
193
|
+
versionSkewNote: d && typeof d.daemonVersion === "string" && compareVersions(d.daemonVersion, packageVersion()) < 0
|
|
194
|
+
? `daemon v${d.daemonVersion} is older than installed v${packageVersion()} — it self-restarts on newer-client contact`
|
|
195
|
+
: undefined,
|
|
192
196
|
daemon: d
|
|
193
197
|
? {
|
|
194
198
|
pid: d.pid,
|
|
@@ -196,6 +200,7 @@ export function buildDashboardView(inps) {
|
|
|
196
200
|
uptimeMs: now - d.startedAt,
|
|
197
201
|
protocolVersion: d.protocolVersion,
|
|
198
202
|
configVersion: inps.status?.config.version ?? 0,
|
|
203
|
+
daemonVersion: d.daemonVersion,
|
|
199
204
|
}
|
|
200
205
|
: null,
|
|
201
206
|
pools,
|
package/dist/shared/client.js
CHANGED
|
@@ -29,6 +29,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
29
29
|
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
|
+
import { packageVersion } from "./version.js";
|
|
32
33
|
export class LLClient {
|
|
33
34
|
sock = null;
|
|
34
35
|
nextId = 1;
|
|
@@ -170,7 +171,9 @@ export class LLClient {
|
|
|
170
171
|
},
|
|
171
172
|
});
|
|
172
173
|
const sock = this.ensureSock();
|
|
173
|
-
|
|
174
|
+
// clientVersion: lets the daemon detect version skew and self-restart
|
|
175
|
+
// onto newer on-disk code (pre-handshake daemons ignore the field).
|
|
176
|
+
sock.write(JSON.stringify({ id, method, params, clientVersion: packageVersion() }) + "\n");
|
|
174
177
|
});
|
|
175
178
|
}
|
|
176
179
|
/** Subscribe to daemon events targeted at agentId (or "*" for all). */
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* receive unsolicited event lines.
|
|
7
7
|
*
|
|
8
8
|
* Request: {"id": <number>, "method": "<name>", "params": {...}}
|
|
9
|
+
* Newer clients also stamp the envelope with their package
|
|
10
|
+
* version: {..., "clientVersion": "1.2.1"}. Old daemons ignore
|
|
11
|
+
* the extra field (forward-compatible); newer daemons use it
|
|
12
|
+
* for the update handshake: a client strictly newer than the
|
|
13
|
+
* running daemon triggers a daemon self-restart (issue #34).
|
|
9
14
|
* Response: {"id": <number>, "ok": true, "result": {...}}
|
|
10
15
|
* {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
|
|
11
16
|
* Watch push: {"event": true, ...event fields...}
|
|
@@ -140,14 +145,40 @@ export interface JournalEntry {
|
|
|
140
145
|
opKey: string;
|
|
141
146
|
reason: string;
|
|
142
147
|
weight: number;
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
/** Held permits only; queued entries carry no token. */
|
|
149
|
+
permitToken?: string;
|
|
150
|
+
/** Held permits only. */
|
|
151
|
+
phase?: PermitPhase;
|
|
145
152
|
pid?: number;
|
|
146
153
|
pgid?: number;
|
|
147
154
|
pidStartTime?: string;
|
|
155
|
+
/** When the permit was claimed (held) / the request was journaled. */
|
|
148
156
|
claimedAt: number;
|
|
149
157
|
startedAt?: number;
|
|
150
158
|
leaseDeadline?: number;
|
|
159
|
+
/**
|
|
160
|
+
* Journaled lifecycle state (issue #34): "held" (permit — liveness is
|
|
161
|
+
* reconciled on restore) or "queued" (FIFO position preserved across
|
|
162
|
+
* restarts/self-restarts; offered entries are journaled as queued and
|
|
163
|
+
* re-offered with a fresh token after the restart). Absent in journals
|
|
164
|
+
* written before the update story => "held" (the original format).
|
|
165
|
+
*/
|
|
166
|
+
state?: "held" | "queued";
|
|
167
|
+
/** Queue insertion time (queued only) — restores FIFO order. */
|
|
168
|
+
enqueuedAt?: number;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Result of `hello`. `daemonVersion` is the running daemon's package
|
|
172
|
+
* version. OPTIONAL in responses from daemons older than the handshake —
|
|
173
|
+
* clients must treat a missing value as "unknown (old daemon)" and warn
|
|
174
|
+
* instead of failing.
|
|
175
|
+
*/
|
|
176
|
+
export interface HelloResult {
|
|
177
|
+
protocolVersion: number;
|
|
178
|
+
pid: number;
|
|
179
|
+
startedAt: number;
|
|
180
|
+
configVersion: number;
|
|
181
|
+
daemonVersion?: string;
|
|
151
182
|
}
|
|
152
183
|
export interface PoolStatus {
|
|
153
184
|
pool: string;
|
|
@@ -163,6 +194,13 @@ export interface StatusResult {
|
|
|
163
194
|
pid: number;
|
|
164
195
|
startedAt: number;
|
|
165
196
|
protocolVersion: number;
|
|
197
|
+
/**
|
|
198
|
+
* Running daemon's package version (update handshake, issue #34).
|
|
199
|
+
* Optional because daemons older than the handshake never send it —
|
|
200
|
+
* clients must treat a missing value as "unknown, old daemon" and
|
|
201
|
+
* warn rather than fail.
|
|
202
|
+
*/
|
|
203
|
+
daemonVersion?: string;
|
|
166
204
|
};
|
|
167
205
|
config: {
|
|
168
206
|
version: number;
|
package/dist/shared/protocol.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* receive unsolicited event lines.
|
|
7
7
|
*
|
|
8
8
|
* Request: {"id": <number>, "method": "<name>", "params": {...}}
|
|
9
|
+
* Newer clients also stamp the envelope with their package
|
|
10
|
+
* version: {..., "clientVersion": "1.2.1"}. Old daemons ignore
|
|
11
|
+
* the extra field (forward-compatible); newer daemons use it
|
|
12
|
+
* for the update handshake: a client strictly newer than the
|
|
13
|
+
* running daemon triggers a daemon self-restart (issue #34).
|
|
9
14
|
* Response: {"id": <number>, "ok": true, "result": {...}}
|
|
10
15
|
* {"id": <number>, "ok": false, "error": {"code": "...", "message": "..."}}
|
|
11
16
|
* Watch push: {"event": true, ...event fields...}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Version of THIS copy of load-lock (the on-disk install running now). */
|
|
2
|
+
export declare function packageVersion(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort dotted-version comparison ("1.2.1" vs "1.10.0"): numeric
|
|
5
|
+
* segments compared left to right, missing segments as 0, non-numeric
|
|
6
|
+
* prefixes of a segment ("2-beta" → 2) tolerated. Returns -1 / 0 / 1.
|
|
7
|
+
* Unparseable input never throws — it compares as 0.0.0, so the daemon
|
|
8
|
+
* never acts (restarts) on a garbage version.
|
|
9
|
+
*/
|
|
10
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-version plumbing for the update story (issue #34).
|
|
3
|
+
*
|
|
4
|
+
* Every load-lock process — daemon, CLI, MCP server, plugin — learns its
|
|
5
|
+
* own on-disk package version by reading the package.json that ships next
|
|
6
|
+
* to its compiled dist/ tree. That version takes part in the client→daemon
|
|
7
|
+
* handshake: clients stamp every request envelope with it
|
|
8
|
+
* (`clientVersion`), the daemon stamps every hello with it
|
|
9
|
+
* (`daemonVersion`), and a strictly-newer client triggers a daemon
|
|
10
|
+
* self-restart (see core/daemon.ts).
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
let cached;
|
|
16
|
+
/** Version of THIS copy of load-lock (the on-disk install running now). */
|
|
17
|
+
export function packageVersion() {
|
|
18
|
+
if (cached === undefined) {
|
|
19
|
+
try {
|
|
20
|
+
// dist/shared/version.js → <package root>/package.json — resolves the
|
|
21
|
+
// same way in the repo checkout, a global CLI install, and an
|
|
22
|
+
// opencode cache copy. npm always ships package.json with the tarball.
|
|
23
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
24
|
+
cached = String(JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
cached = "0.0.0";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort dotted-version comparison ("1.2.1" vs "1.10.0"): numeric
|
|
34
|
+
* segments compared left to right, missing segments as 0, non-numeric
|
|
35
|
+
* prefixes of a segment ("2-beta" → 2) tolerated. Returns -1 / 0 / 1.
|
|
36
|
+
* Unparseable input never throws — it compares as 0.0.0, so the daemon
|
|
37
|
+
* never acts (restarts) on a garbage version.
|
|
38
|
+
*/
|
|
39
|
+
export function compareVersions(a, b) {
|
|
40
|
+
const pa = parse(a);
|
|
41
|
+
const pb = parse(b);
|
|
42
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
43
|
+
const x = pa[i] ?? 0;
|
|
44
|
+
const y = pb[i] ?? 0;
|
|
45
|
+
if (x !== y)
|
|
46
|
+
return x < y ? -1 : 1;
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
function parse(v) {
|
|
51
|
+
return v
|
|
52
|
+
.split(".")
|
|
53
|
+
.map((seg) => {
|
|
54
|
+
const m = /^(\d+)/.exec(seg.trim());
|
|
55
|
+
return m ? Number(m[1]) : 0;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
|
-
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, cpSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join, dirname, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
@@ -656,3 +656,183 @@ test("cursor: uninstall with nothing installed is a harmless no-op", async () =>
|
|
|
656
656
|
assert.ok(!existsSync(join(proj, ".cursor")), "no files created by uninstall");
|
|
657
657
|
rmSync(home, { recursive: true, force: true });
|
|
658
658
|
});
|
|
659
|
+
// --------------------------------------------------------------------- omac
|
|
660
|
+
// The omac profile the installer must merge into without damaging it:
|
|
661
|
+
// entries in every known section plus an unknown field (a newer omac
|
|
662
|
+
// would accept it; we must preserve it untouched).
|
|
663
|
+
function omacUserProfile(home) {
|
|
664
|
+
return JSON.stringify({
|
|
665
|
+
workdir: { access: "readwrite" },
|
|
666
|
+
network: { allow_domain: ["example.com"], allow_tcp_connect: [22] },
|
|
667
|
+
filesystem: {
|
|
668
|
+
allow: ["/data"],
|
|
669
|
+
read: ["/docs"],
|
|
670
|
+
write: ["/out"],
|
|
671
|
+
allow_unix_dir: ["/tmp/other-socks"],
|
|
672
|
+
deny: [".env"],
|
|
673
|
+
},
|
|
674
|
+
environment: { allow_vars: ["ANTHROPIC_API_KEY", "MY_*"] },
|
|
675
|
+
someFutureField: { nested: true },
|
|
676
|
+
}, null, 2) + "\n";
|
|
677
|
+
}
|
|
678
|
+
function omacProfilePath(home) {
|
|
679
|
+
return join(home, ".config", "omac", "sandbox-profiles", "default.json");
|
|
680
|
+
}
|
|
681
|
+
test("omac: install/uninstall round-trip on a populated profile; re-install is byte-identical", async () => {
|
|
682
|
+
const home = tempHome();
|
|
683
|
+
const xdg = join(home, ".config");
|
|
684
|
+
const profileFile = omacProfilePath(home);
|
|
685
|
+
mkdirSync(dirname(profileFile), { recursive: true });
|
|
686
|
+
const before = omacUserProfile(home);
|
|
687
|
+
writeFileSync(profileFile, before);
|
|
688
|
+
const stateDir = join(home, "ll-state");
|
|
689
|
+
const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: stateDir, XDG_STATE_HOME: join(home, ".local", "state") };
|
|
690
|
+
// -- install
|
|
691
|
+
let r = await runCli(home, ["install", "--harness", "omac"], env);
|
|
692
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
693
|
+
// honesty: live-verified claim, the env prefix entry, and the state-dir grant
|
|
694
|
+
assert.match(r.stdout, /live-verified.*omac 0\.11\.0|verified live against omac 0\.11\.0/i, "installer output must state the verification status");
|
|
695
|
+
assert.match(r.stdout, /LOAD_LOCK_\*/, "installer output must mention the LOAD_LOCK_* allow_vars entry");
|
|
696
|
+
assert.match(r.stdout, /allow_unix_dir \+=/, "installer output must mention the state-dir grant");
|
|
697
|
+
assert.match(r.stdout, /skipped filesystem\.read.*repo checkout/, "installer output must honestly note the skipped read grant (CLI runs from a repo checkout)");
|
|
698
|
+
const p = JSON.parse(readFileSync(profileFile, "utf8"));
|
|
699
|
+
// user entries preserved everywhere
|
|
700
|
+
assert.deepEqual(p.workdir, { access: "readwrite" }, "user workdir section preserved");
|
|
701
|
+
assert.deepEqual(p.network, { allow_domain: ["example.com"], allow_tcp_connect: [22] }, "user network section preserved");
|
|
702
|
+
assert.deepEqual(p.filesystem.allow, ["/data"], "user filesystem.allow preserved");
|
|
703
|
+
assert.deepEqual(p.filesystem.write, ["/out"], "user filesystem.write preserved");
|
|
704
|
+
assert.deepEqual(p.filesystem.deny, [".env"], "user filesystem.deny preserved");
|
|
705
|
+
assert.deepEqual(p.filesystem.read, ["/docs"], "read untouched — repo checkout needs no package-tree grant");
|
|
706
|
+
assert.deepEqual(p.someFutureField, { nested: true }, "unknown (newer-omac) field preserved");
|
|
707
|
+
// our grants, exactly once, alongside the user's
|
|
708
|
+
assert.deepEqual(p.filesystem.allow_unix_dir, ["/tmp/other-socks", stateDir], "state dir appended to allow_unix_dir");
|
|
709
|
+
assert.deepEqual(p.environment.allow_vars, ["ANTHROPIC_API_KEY", "MY_*", "LOAD_LOCK_*"], "LOAD_LOCK_* appended to allow_vars");
|
|
710
|
+
assert.equal(p.filesystem.allow_unix_dir.filter((e) => e === stateDir).length, 1, "state dir granted exactly once");
|
|
711
|
+
// -- idempotent re-install: the file is not rewritten at all
|
|
712
|
+
const afterInstall = readFileSync(profileFile, "utf8");
|
|
713
|
+
r = await runCli(home, ["install", "--harness", "omac"], env);
|
|
714
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
715
|
+
assert.equal(readFileSync(profileFile, "utf8"), afterInstall, "re-install must be byte-identical");
|
|
716
|
+
assert.match(r.stdout, /already present.*nothing to do/, "re-install must say nothing was needed");
|
|
717
|
+
// -- uninstall removes exactly our entries
|
|
718
|
+
r = await runCli(home, ["uninstall", "--harness", "omac"], env);
|
|
719
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
720
|
+
const u = JSON.parse(readFileSync(profileFile, "utf8"));
|
|
721
|
+
assert.deepEqual(u.filesystem.allow_unix_dir, ["/tmp/other-socks"], "our state dir removed, user entry kept");
|
|
722
|
+
assert.ok(!u.filesystem.read.includes(stateDir), "no stray read grant");
|
|
723
|
+
assert.deepEqual(u.filesystem.read, ["/docs"], "user read entry kept");
|
|
724
|
+
assert.deepEqual(u.environment.allow_vars, ["ANTHROPIC_API_KEY", "MY_*"], "our env entry removed, user entries kept");
|
|
725
|
+
assert.deepEqual(u.workdir, { access: "readwrite" }, "user sections untouched by uninstall");
|
|
726
|
+
assert.deepEqual(u.someFutureField, { nested: true }, "unknown field untouched by uninstall");
|
|
727
|
+
rmSync(home, { recursive: true, force: true });
|
|
728
|
+
});
|
|
729
|
+
test("omac: install on a missing profile creates a minimal valid one (XDG_STATE_HOME honored); uninstall cleans it", async () => {
|
|
730
|
+
const home = tempHome();
|
|
731
|
+
const xdg = join(home, ".config");
|
|
732
|
+
const profileFile = omacProfilePath(home);
|
|
733
|
+
const xdgState = join(home, ".local", "state");
|
|
734
|
+
// LOAD_LOCK_STATE_DIR: "" clears any inherited override so XDG_STATE_HOME is honored
|
|
735
|
+
const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: "", XDG_STATE_HOME: xdgState };
|
|
736
|
+
let r = await runCli(home, ["install", "--harness", "omac"], env);
|
|
737
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
738
|
+
assert.ok(existsSync(profileFile), "profile created");
|
|
739
|
+
const p = JSON.parse(readFileSync(profileFile, "utf8"));
|
|
740
|
+
// minimal valid profile: only known fields, no empty arrays, no read grant (repo checkout)
|
|
741
|
+
assert.deepEqual(p, {
|
|
742
|
+
environment: { allow_vars: ["LOAD_LOCK_*"] },
|
|
743
|
+
filesystem: { allow_unix_dir: [join(xdgState, "load-lock")] },
|
|
744
|
+
}, "created profile holds exactly our grants");
|
|
745
|
+
// XDG_STATE_HOME honored: default state dir, not ~/.local/state off the real HOME
|
|
746
|
+
assert.ok(p.filesystem.allow_unix_dir[0].startsWith(xdgState), "state dir derived from XDG_STATE_HOME");
|
|
747
|
+
// idempotent re-install: byte-identical
|
|
748
|
+
const bytes = readFileSync(profileFile, "utf8");
|
|
749
|
+
r = await runCli(home, ["install", "--harness", "omac"], env);
|
|
750
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
751
|
+
assert.equal(readFileSync(profileFile, "utf8"), bytes, "re-install is byte-identical");
|
|
752
|
+
// uninstall removes the file (nothing user-owned was ever in it) and prunes dirs
|
|
753
|
+
r = await runCli(home, ["uninstall", "--harness", "omac"], env);
|
|
754
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
755
|
+
assert.ok(!existsSync(profileFile), "our file removed");
|
|
756
|
+
assert.ok(!existsSync(join(xdg, "omac")), "empty omac config tree pruned");
|
|
757
|
+
rmSync(home, { recursive: true, force: true });
|
|
758
|
+
});
|
|
759
|
+
test("omac: a global-style install (package outside any repo) adds the package-tree read grant; uninstall removes it by shape", async () => {
|
|
760
|
+
const home = tempHome();
|
|
761
|
+
const xdg = join(home, ".config");
|
|
762
|
+
const stateDir = join(home, "ll-state");
|
|
763
|
+
const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: stateDir, XDG_STATE_HOME: join(home, ".local", "state") };
|
|
764
|
+
// simulate a global install: package tree under node_modules, outside any .git
|
|
765
|
+
const pkgMock = join(home, "bun-mock", "install", "global", "node_modules", "@aron98", "load-lock");
|
|
766
|
+
mkdirSync(dirname(pkgMock), { recursive: true });
|
|
767
|
+
cpSync(join(HERE, ".."), join(pkgMock, "dist"), { recursive: true }); // dist/ from the worktree build
|
|
768
|
+
const mockCli = join(pkgMock, "dist", "cli", "main.js");
|
|
769
|
+
const profileFile = omacProfilePath(home);
|
|
770
|
+
const runMock = (args) => new Promise((resolveP, reject) => {
|
|
771
|
+
const child = spawn(process.execPath, [mockCli, ...args], { env: { ...process.env, HOME: home, ...env } });
|
|
772
|
+
let stdout = "";
|
|
773
|
+
let stderr = "";
|
|
774
|
+
child.stdout.on("data", (b) => (stdout += b));
|
|
775
|
+
child.stderr.on("data", (b) => (stderr += b));
|
|
776
|
+
child.stdin.end();
|
|
777
|
+
child.once("exit", (code) => resolveP({ code, stdout, stderr }));
|
|
778
|
+
child.once("error", reject);
|
|
779
|
+
});
|
|
780
|
+
let r = await runMock(["install", "--harness", "omac"]);
|
|
781
|
+
assert.equal(r.code, 0, `install failed: ${r.stderr}`);
|
|
782
|
+
assert.match(r.stdout, /filesystem\.read \+=/, "installer output must mention the package-tree read grant");
|
|
783
|
+
const p = JSON.parse(readFileSync(profileFile, "utf8"));
|
|
784
|
+
const pkgReal = resolve(pkgMock);
|
|
785
|
+
assert.deepEqual(p.filesystem.read, [pkgReal], "read grant is the realpath of the package root");
|
|
786
|
+
assert.ok(p.filesystem.allow_unix_dir.includes(stateDir), "state dir granted too");
|
|
787
|
+
// idempotent re-install from the global copy: byte-identical
|
|
788
|
+
const bytes = readFileSync(profileFile, "utf8");
|
|
789
|
+
r = await runMock(["install", "--harness", "omac"]);
|
|
790
|
+
assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
|
|
791
|
+
assert.equal(readFileSync(profileFile, "utf8"), bytes, "re-install is byte-identical");
|
|
792
|
+
// uninstall from the WORKTREE CLI (a different location, readTree is null
|
|
793
|
+
// there): the read entry still matches by its node_modules/@aron98/load-lock shape
|
|
794
|
+
r = await runCli(home, ["uninstall", "--harness", "omac"], env);
|
|
795
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
796
|
+
assert.ok(!existsSync(profileFile), "profile held only our grants — removed");
|
|
797
|
+
rmSync(home, { recursive: true, force: true });
|
|
798
|
+
});
|
|
799
|
+
test("omac: uninstall with nothing installed is a harmless no-op", async () => {
|
|
800
|
+
const home = tempHome();
|
|
801
|
+
const r = await runCli(home, ["uninstall", "--harness", "omac"], { XDG_CONFIG_HOME: join(home, ".config"), LOAD_LOCK_STATE_DIR: join(home, "ll-state") });
|
|
802
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
803
|
+
assert.ok(!existsSync(join(home, ".config", "omac")), "no files created by uninstall");
|
|
804
|
+
rmSync(home, { recursive: true, force: true });
|
|
805
|
+
});
|
|
806
|
+
test("omac: install refuses to touch a broken or misshapen profile", async () => {
|
|
807
|
+
const home = tempHome();
|
|
808
|
+
const xdg = join(home, ".config");
|
|
809
|
+
const profileFile = omacProfilePath(home);
|
|
810
|
+
mkdirSync(dirname(profileFile), { recursive: true });
|
|
811
|
+
const env = { XDG_CONFIG_HOME: xdg, LOAD_LOCK_STATE_DIR: join(home, "ll-state") };
|
|
812
|
+
// (a) invalid JSON
|
|
813
|
+
const broken = "{ not json";
|
|
814
|
+
writeFileSync(profileFile, broken);
|
|
815
|
+
let r = await runCli(home, ["install", "--harness", "omac"], env);
|
|
816
|
+
assert.notEqual(r.code, 0, "install must refuse invalid JSON");
|
|
817
|
+
assert.match(r.stderr + r.stdout, /not valid JSON|refus/i, "error must explain the refusal");
|
|
818
|
+
assert.equal(readFileSync(profileFile, "utf8"), broken, "file untouched on refusal");
|
|
819
|
+
// (b) allow_vars present but not an array of strings
|
|
820
|
+
const misshapen = JSON.stringify({ environment: { allow_vars: "PATH" } }, null, 2) + "\n";
|
|
821
|
+
writeFileSync(profileFile, misshapen);
|
|
822
|
+
r = await runCli(home, ["install", "--harness", "omac"], env);
|
|
823
|
+
assert.notEqual(r.code, 0, "install must refuse a non-array allow_vars");
|
|
824
|
+
assert.match(r.stderr + r.stdout, /allow_vars.*not an array/i, "error must name the offending field");
|
|
825
|
+
assert.equal(readFileSync(profileFile, "utf8"), misshapen, "file untouched on refusal");
|
|
826
|
+
// uninstall also refuses (never "fixes" the file by overwriting it) on invalid JSON
|
|
827
|
+
writeFileSync(profileFile, broken);
|
|
828
|
+
r = await runCli(home, ["uninstall", "--harness", "omac"], env);
|
|
829
|
+
assert.notEqual(r.code, 0, "uninstall must refuse invalid JSON too");
|
|
830
|
+
assert.equal(readFileSync(profileFile, "utf8"), broken, "file untouched on refusal");
|
|
831
|
+
// on a merely misshapen value (valid JSON, allow_vars not an array) uninstall
|
|
832
|
+
// is a graceful no-op: it never wrote that value, so it leaves it alone
|
|
833
|
+
writeFileSync(profileFile, misshapen);
|
|
834
|
+
r = await runCli(home, ["uninstall", "--harness", "omac"], env);
|
|
835
|
+
assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
|
|
836
|
+
assert.equal(readFileSync(profileFile, "utf8"), misshapen, "file untouched by uninstall");
|
|
837
|
+
rmSync(home, { recursive: true, force: true });
|
|
838
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|