@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.
@@ -830,7 +830,7 @@ export class Machine {
830
830
  }
831
831
  history = [];
832
832
  // ----------------------------------------------------------------- journal
833
- /** Held permits to persist for conservative restart reconciliation. */
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 held permits after a daemon restart (conservative reconciliation):
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 (j.phase === "claimed") {
917
+ if (e.phase === "claimed") {
893
918
  e.startDeadline = j.claimedAt + this.config.startTimeoutMs;
894
919
  }
895
- else if (j.phase === "running_untracked") {
920
+ else if (e.phase === "running_untracked") {
896
921
  e.leaseDeadline = this.now() + this.config.untrackedLeaseMs; // fresh window
897
922
  }
898
- else if (j.phase === "running_tracked") {
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
  }
@@ -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} · events through #${view.latestEventId}`);
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` : "";
@@ -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[];
@@ -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,
@@ -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
- sock.write(JSON.stringify({ id, method, params }) + "\n");
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
- permitToken: string;
144
- phase: PermitPhase;
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;
@@ -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 {};