@app-vitals/agent-runtime 0.0.1 → 0.1.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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,1495 @@
1
+ /**
2
+ * Integration tests for agent/src/runtime/setup.ts
3
+ *
4
+ * Tests use injected execFn — no real claude/mise binaries needed.
5
+ * All file I/O runs against a real temp dir (no mocks needed for fs).
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
9
+ import {
10
+ existsSync,
11
+ lstatSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ statSync,
16
+ symlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { join } from "node:path";
20
+ import type { AgentPlugin } from "@app-vitals/agent-admin";
21
+ import {
22
+ discoverBakedMarketplaces,
23
+ ensureAgentHome,
24
+ ensureDotClaudeSymlink,
25
+ findStalePluginSpecs,
26
+ installPlugins,
27
+ isNewWorkspace,
28
+ loadState,
29
+ runMiseStartup,
30
+ saveState,
31
+ seedFile,
32
+ } from "./setup.ts";
33
+
34
+ const TMP_BASE = "/tmp/test-shipwright-agent-home";
35
+
36
+ let testHome: string;
37
+
38
+ beforeEach(() => {
39
+ testHome = `${TMP_BASE}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
40
+ });
41
+
42
+ afterEach(() => {
43
+ if (existsSync(testHome)) {
44
+ rmSync(testHome, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // ensureDotClaudeSymlink
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe("ensureDotClaudeSymlink", () => {
53
+ it("creates the target dir so the symlink is NOT dangling", () => {
54
+ mkdirSync(testHome, { recursive: true });
55
+ const target = join(testHome, "dot-claude"); // does not exist yet
56
+ const link = join(testHome, "home-claude");
57
+
58
+ ensureDotClaudeSymlink(target, link, undefined, () => {});
59
+
60
+ // Target was created, link is a symlink, and it resolves (not dangling).
61
+ expect(existsSync(target)).toBe(true);
62
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
63
+ expect(existsSync(link)).toBe(true);
64
+ // A write THROUGH the link succeeds — this is the session-env path.
65
+ mkdirSync(join(link, "session-env"), { recursive: true });
66
+ expect(existsSync(join(target, "session-env"))).toBe(true);
67
+ });
68
+
69
+ it("replaces a stale DANGLING symlink without throwing (idempotent re-run)", () => {
70
+ mkdirSync(testHome, { recursive: true });
71
+ const target = join(testHome, "dot-claude");
72
+ const link = join(testHome, "home-claude");
73
+
74
+ // Simulate the original bug's leftover: a symlink to a missing target.
75
+ symlinkSync(join(testHome, "missing-target"), link);
76
+ expect(existsSync(link)).toBe(false); // dangling
77
+
78
+ ensureDotClaudeSymlink(target, link, undefined, () => {});
79
+
80
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
81
+ expect(existsSync(link)).toBe(true); // now resolves
82
+ });
83
+
84
+ it("replaces a real directory at the link path", () => {
85
+ mkdirSync(testHome, { recursive: true });
86
+ const target = join(testHome, "dot-claude");
87
+ const link = join(testHome, "home-claude");
88
+
89
+ // A real directory (with a file) sits where the symlink should go.
90
+ mkdirSync(link, { recursive: true });
91
+ writeFileSync(join(link, "stale.txt"), "x", "utf8");
92
+
93
+ let warned = false;
94
+ ensureDotClaudeSymlink(target, link, undefined, (m) => {
95
+ if (m.includes("real directory")) warned = true;
96
+ });
97
+
98
+ expect(warned).toBe(true);
99
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
100
+ expect(existsSync(join(link, "stale.txt"))).toBe(false);
101
+ });
102
+
103
+ it("is idempotent — calling twice leaves a valid symlink", () => {
104
+ mkdirSync(testHome, { recursive: true });
105
+ const target = join(testHome, "dot-claude");
106
+ const link = join(testHome, "home-claude");
107
+
108
+ ensureDotClaudeSymlink(target, link, undefined, () => {});
109
+ expect(() =>
110
+ ensureDotClaudeSymlink(target, link, undefined, () => {}),
111
+ ).not.toThrow();
112
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
113
+ expect(existsSync(link)).toBe(true);
114
+ });
115
+
116
+ it("creates the sibling ~/.claude.json symlink (dangling until Claude writes it)", () => {
117
+ mkdirSync(testHome, { recursive: true });
118
+ const target = join(testHome, "dot-claude");
119
+ const link = join(testHome, "home-claude");
120
+
121
+ ensureDotClaudeSymlink(target, link, undefined, () => {});
122
+
123
+ const jsonLink = `${link}.json`;
124
+ expect(lstatSync(jsonLink).isSymbolicLink()).toBe(true);
125
+ // Target does not exist yet — Claude writes it on first use. Resolves once created.
126
+ const jsonTarget = join(target, "..", "claude.json");
127
+ writeFileSync(jsonTarget, '{"mcpServers":{}}', "utf8");
128
+ expect(readFileSync(jsonLink, "utf8")).toBe('{"mcpServers":{}}');
129
+ });
130
+
131
+ it("replaces a dangling ~/.claude.json symlink on re-run", () => {
132
+ mkdirSync(testHome, { recursive: true });
133
+ const target = join(testHome, "dot-claude");
134
+ const link = join(testHome, "home-claude");
135
+ const jsonLink = `${link}.json`;
136
+
137
+ symlinkSync(join(testHome, "missing.json"), jsonLink);
138
+ expect(existsSync(jsonLink)).toBe(false); // dangling
139
+
140
+ ensureDotClaudeSymlink(target, link, undefined, () => {});
141
+
142
+ expect(lstatSync(jsonLink).isSymbolicLink()).toBe(true);
143
+ });
144
+
145
+ it("replaces a real file at ~/.claude.json (image ships a real file — PVC copy wins)", () => {
146
+ mkdirSync(testHome, { recursive: true });
147
+ const target = join(testHome, "dot-claude");
148
+ const link = join(testHome, "home-claude");
149
+ const jsonLink = `${link}.json`;
150
+
151
+ writeFileSync(jsonLink, "{}", "utf8");
152
+ expect(lstatSync(jsonLink).isSymbolicLink()).toBe(false);
153
+
154
+ ensureDotClaudeSymlink(target, link, undefined, () => {});
155
+
156
+ expect(lstatSync(jsonLink).isSymbolicLink()).toBe(true);
157
+ });
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // seedFile
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe("seedFile", () => {
165
+ it("creates file on first call", () => {
166
+ mkdirSync(testHome, { recursive: true });
167
+ const filePath = join(testHome, "test.txt");
168
+ seedFile(filePath, "hello");
169
+ expect(existsSync(filePath)).toBe(true);
170
+ expect(readFileSync(filePath, "utf8")).toBe("hello");
171
+ });
172
+
173
+ it("silently skips on second call (wx semantics)", () => {
174
+ mkdirSync(testHome, { recursive: true });
175
+ const filePath = join(testHome, "test.txt");
176
+ seedFile(filePath, "first");
177
+ expect(() => seedFile(filePath, "second")).not.toThrow();
178
+ // Original content preserved
179
+ expect(readFileSync(filePath, "utf8")).toBe("first");
180
+ });
181
+
182
+ it("rethrows errors that are not EEXIST", () => {
183
+ // Attempt to write to a path whose parent does not exist
184
+ const filePath = join(testHome, "nonexistent-dir", "test.txt");
185
+ expect(() => seedFile(filePath, "content")).toThrow();
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // loadState / saveState
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe("loadState", () => {
194
+ it("returns { version: 1 } when file absent", () => {
195
+ mkdirSync(testHome, { recursive: true });
196
+ const state = loadState(testHome);
197
+ expect(state).toEqual({ version: 1 });
198
+ });
199
+
200
+ it("returns parsed state when file present", () => {
201
+ mkdirSync(testHome, { recursive: true });
202
+ const data = {
203
+ version: 1 as const,
204
+ bootstrapSeededAt: "2026-01-01T00:00:00.000Z",
205
+ };
206
+ writeFileSync(
207
+ join(testHome, "workspace-state.json"),
208
+ JSON.stringify(data),
209
+ "utf8",
210
+ );
211
+ const state = loadState(testHome);
212
+ expect(state.bootstrapSeededAt).toBe("2026-01-01T00:00:00.000Z");
213
+ });
214
+
215
+ it("returns { version: 1 } when file is corrupted (invalid JSON)", () => {
216
+ mkdirSync(testHome, { recursive: true });
217
+ writeFileSync(
218
+ join(testHome, "workspace-state.json"),
219
+ '{"version":1,"bootstrapSeededAt":"2026-',
220
+ "utf8",
221
+ );
222
+ const state = loadState(testHome);
223
+ expect(state).toEqual({ version: 1 });
224
+ });
225
+ });
226
+
227
+ describe("saveState", () => {
228
+ it("writes valid JSON", () => {
229
+ mkdirSync(testHome, { recursive: true });
230
+ const state = {
231
+ version: 1 as const,
232
+ bootstrapSeededAt: "2026-04-14T00:00:00.000Z",
233
+ };
234
+ saveState(testHome, state);
235
+ const raw = readFileSync(join(testHome, "workspace-state.json"), "utf8");
236
+ const parsed = JSON.parse(raw);
237
+ expect(parsed.version).toBe(1);
238
+ expect(parsed.bootstrapSeededAt).toBe("2026-04-14T00:00:00.000Z");
239
+ });
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // isNewWorkspace
244
+ // ---------------------------------------------------------------------------
245
+
246
+ describe("isNewWorkspace", () => {
247
+ it("returns true when all markers absent", () => {
248
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
249
+ expect(isNewWorkspace(testHome)).toBe(true);
250
+ });
251
+
252
+ it("returns false when workspace/IDENTITY.md present", () => {
253
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
254
+ writeFileSync(
255
+ join(testHome, "workspace", "IDENTITY.md"),
256
+ "# Identity",
257
+ "utf8",
258
+ );
259
+ expect(isNewWorkspace(testHome)).toBe(false);
260
+ });
261
+
262
+ it("returns false when workspace/SOUL.md present", () => {
263
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
264
+ writeFileSync(join(testHome, "workspace", "SOUL.md"), "# Soul", "utf8");
265
+ expect(isNewWorkspace(testHome)).toBe(false);
266
+ });
267
+
268
+ it("returns false when workspace/memory/ directory present", () => {
269
+ mkdirSync(join(testHome, "workspace", "memory"), { recursive: true });
270
+ expect(isNewWorkspace(testHome)).toBe(false);
271
+ });
272
+
273
+ it("returns false when workspace/.git directory present", () => {
274
+ mkdirSync(join(testHome, "workspace", ".git"), { recursive: true });
275
+ expect(isNewWorkspace(testHome)).toBe(false);
276
+ });
277
+ });
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // ensureAgentHome
281
+ // ---------------------------------------------------------------------------
282
+
283
+ describe("ensureAgentHome", () => {
284
+ it("creates expected directory tree", () => {
285
+ ensureAgentHome(testHome);
286
+
287
+ // Workspace and subdirs
288
+ expect(existsSync(join(testHome, "workspace"))).toBe(true);
289
+ expect(existsSync(join(testHome, "workspace", "repos"))).toBe(true);
290
+ expect(existsSync(join(testHome, "workspace", "worktrees"))).toBe(true);
291
+ expect(existsSync(join(testHome, "workspace", "state"))).toBe(true);
292
+ });
293
+
294
+ it("is idempotent — calling twice does not throw", () => {
295
+ ensureAgentHome(testHome);
296
+ expect(() => ensureAgentHome(testHome)).not.toThrow();
297
+ });
298
+
299
+ it("works when $home does not exist yet (first deploy)", () => {
300
+ const deepHome = join(testHome, "nested", "deep");
301
+ try {
302
+ ensureAgentHome(deepHome);
303
+ expect(existsSync(join(deepHome, "workspace"))).toBe(true);
304
+ } finally {
305
+ rmSync(testHome, { recursive: true, force: true });
306
+ }
307
+ });
308
+
309
+ it("seeds CLAUDE.md, SOUL.md, IDENTITY.md into workspace/ on fresh home", () => {
310
+ ensureAgentHome(testHome);
311
+ expect(existsSync(join(testHome, "workspace", "CLAUDE.md"))).toBe(true);
312
+ expect(existsSync(join(testHome, "workspace", "SOUL.md"))).toBe(true);
313
+ expect(existsSync(join(testHome, "workspace", "IDENTITY.md"))).toBe(true);
314
+ });
315
+
316
+ it("CLAUDE.md loads @SOUL.md and @IDENTITY.md", () => {
317
+ ensureAgentHome(testHome);
318
+ const content = readFileSync(
319
+ join(testHome, "workspace", "CLAUDE.md"),
320
+ "utf8",
321
+ );
322
+ expect(content).toContain("@SOUL.md");
323
+ expect(content).toContain("@IDENTITY.md");
324
+ });
325
+
326
+ it("CLAUDE.md loads @BOOTSTRAP.md immediately after @IDENTITY.md for a freshly-seeded workspace", () => {
327
+ ensureAgentHome(testHome);
328
+ const content = readFileSync(
329
+ join(testHome, "workspace", "CLAUDE.md"),
330
+ "utf8",
331
+ );
332
+ expect(content).toContain("@BOOTSTRAP.md");
333
+ // @file includes are loaded literally by Claude Code, unconditionally — the
334
+ // ordering matters (right after @IDENTITY.md) so the First-Run Ritual is
335
+ // the next thing loaded after identity, not a prose note the model has to
336
+ // notice and act on.
337
+ const identityIdx = content.indexOf("@IDENTITY.md");
338
+ const bootstrapIdx = content.indexOf("@BOOTSTRAP.md");
339
+ expect(identityIdx).toBeGreaterThanOrEqual(0);
340
+ expect(bootstrapIdx).toBeGreaterThan(identityIdx);
341
+ const between = content.slice(
342
+ identityIdx + "@IDENTITY.md".length,
343
+ bootstrapIdx,
344
+ );
345
+ // Nothing but whitespace/newlines between the two includes.
346
+ expect(between.trim()).toBe("");
347
+ });
348
+
349
+ it("does not seed state/todos.json or state/reviews.json (superseded by the task-store HTTP API)", () => {
350
+ ensureAgentHome(testHome);
351
+ expect(existsSync(join(testHome, "workspace", "state", "todos.json"))).toBe(
352
+ false,
353
+ );
354
+ expect(
355
+ existsSync(join(testHome, "workspace", "state", "reviews.json")),
356
+ ).toBe(false);
357
+ });
358
+
359
+ it("creates state/reviews/ directory", () => {
360
+ ensureAgentHome(testHome);
361
+ const reviewsDir = join(testHome, "workspace", "state", "reviews");
362
+ expect(existsSync(reviewsDir)).toBe(true);
363
+ expect(statSync(reviewsDir).isDirectory()).toBe(true);
364
+ });
365
+
366
+ it("does not seed state/agent-policy.md", () => {
367
+ ensureAgentHome(testHome);
368
+ expect(
369
+ existsSync(join(testHome, "workspace", "state", "agent-policy.md")),
370
+ ).toBe(false);
371
+ });
372
+
373
+ it("seeds BOOTSTRAP.md into workspace/ and sets bootstrapSeededAt for new workspaces", () => {
374
+ ensureAgentHome(testHome);
375
+ expect(existsSync(join(testHome, "workspace", "BOOTSTRAP.md"))).toBe(true);
376
+ const state = loadState(testHome);
377
+ expect(state.bootstrapSeededAt).toBeDefined();
378
+ expect(typeof state.bootstrapSeededAt).toBe("string");
379
+ });
380
+
381
+ it("called twice does not overwrite existing workspace/SOUL.md", () => {
382
+ ensureAgentHome(testHome);
383
+ // Overwrite SOUL.md with custom content
384
+ writeFileSync(
385
+ join(testHome, "workspace", "SOUL.md"),
386
+ "custom soul content",
387
+ "utf8",
388
+ );
389
+ ensureAgentHome(testHome);
390
+ const content = readFileSync(
391
+ join(testHome, "workspace", "SOUL.md"),
392
+ "utf8",
393
+ );
394
+ expect(content).toBe("custom soul content");
395
+ });
396
+
397
+ it("does not create workspace/hooks/ (not used)", () => {
398
+ ensureAgentHome(testHome);
399
+ expect(existsSync(join(testHome, "workspace", "hooks"))).toBe(false);
400
+ });
401
+
402
+ it("seeds VOICE.md into workspace/", () => {
403
+ ensureAgentHome(testHome);
404
+ const voicePath = join(testHome, "workspace", "VOICE.md");
405
+ expect(existsSync(voicePath)).toBe(true);
406
+ const content = readFileSync(voicePath, "utf8");
407
+ expect(content).toContain("Voice Notes");
408
+ expect(content).toContain("[speak:");
409
+ });
410
+
411
+ it("sets setupCompletedAt when bootstrapSeededAt set but workspace/BOOTSTRAP.md is gone", () => {
412
+ ensureAgentHome(testHome);
413
+ // Simulate user completing bootstrap by deleting BOOTSTRAP.md
414
+ rmSync(join(testHome, "workspace", "BOOTSTRAP.md"));
415
+ // Also mark workspace as non-new so bootstrap branch doesn't re-seed
416
+ writeFileSync(
417
+ join(testHome, "workspace", "IDENTITY.md"),
418
+ "# Identity (filled)",
419
+ "utf8",
420
+ );
421
+
422
+ ensureAgentHome(testHome);
423
+ const state = loadState(testHome);
424
+ expect(state.setupCompletedAt).toBeDefined();
425
+ expect(typeof state.setupCompletedAt).toBe("string");
426
+ });
427
+
428
+ it("strips the @BOOTSTRAP.md include line from CLAUDE.md the moment setupCompletedAt first fires", () => {
429
+ ensureAgentHome(testHome);
430
+ const claudeMdPath = join(testHome, "workspace", "CLAUDE.md");
431
+ const beforeContent = readFileSync(claudeMdPath, "utf8");
432
+ expect(beforeContent).toContain("@BOOTSTRAP.md");
433
+
434
+ // Simulate the ritual completing: BOOTSTRAP.md deleted, IDENTITY.md filled in.
435
+ rmSync(join(testHome, "workspace", "BOOTSTRAP.md"));
436
+ writeFileSync(
437
+ join(testHome, "workspace", "IDENTITY.md"),
438
+ "# Identity (filled)",
439
+ "utf8",
440
+ );
441
+
442
+ ensureAgentHome(testHome);
443
+
444
+ const state = loadState(testHome);
445
+ expect(state.setupCompletedAt).toBeDefined();
446
+
447
+ const afterContent = readFileSync(claudeMdPath, "utf8");
448
+ expect(afterContent).not.toContain("@BOOTSTRAP.md");
449
+
450
+ // Rest of the file is untouched — compare byte-for-byte minus the
451
+ // stripped line (and its trailing newline).
452
+ const expected = beforeContent
453
+ .split("\n")
454
+ .filter((line) => !line.includes("@BOOTSTRAP.md"))
455
+ .join("\n");
456
+ expect(afterContent).toBe(expected);
457
+ });
458
+
459
+ it("does not re-strip or error when ensureAgentHome runs again after setupCompletedAt is already set", () => {
460
+ ensureAgentHome(testHome);
461
+ rmSync(join(testHome, "workspace", "BOOTSTRAP.md"));
462
+ writeFileSync(
463
+ join(testHome, "workspace", "IDENTITY.md"),
464
+ "# Identity (filled)",
465
+ "utf8",
466
+ );
467
+ ensureAgentHome(testHome); // fires the strip
468
+ const claudeMdPath = join(testHome, "workspace", "CLAUDE.md");
469
+ const strippedContent = readFileSync(claudeMdPath, "utf8");
470
+ expect(strippedContent).not.toContain("@BOOTSTRAP.md");
471
+
472
+ // Custom edit after ritual completion — must survive further startups.
473
+ const customContent = `${strippedContent}\n<!-- custom note -->\n`;
474
+ writeFileSync(claudeMdPath, customContent, "utf8");
475
+
476
+ ensureAgentHome(testHome); // must be a no-op w.r.t. CLAUDE.md content
477
+
478
+ expect(readFileSync(claudeMdPath, "utf8")).toBe(customContent);
479
+ });
480
+
481
+ it("does not re-seed BOOTSTRAP.md when bootstrapSeededAt already set", () => {
482
+ ensureAgentHome(testHome);
483
+ // Remove BOOTSTRAP.md and mark as completed via state
484
+ rmSync(join(testHome, "workspace", "BOOTSTRAP.md"));
485
+ const state = loadState(testHome);
486
+ state.bootstrapSeededAt = "2026-01-01T00:00:00.000Z";
487
+ saveState(testHome, state);
488
+
489
+ ensureAgentHome(testHome);
490
+ const newState = loadState(testHome);
491
+ expect(newState.setupCompletedAt).toBeDefined();
492
+ // BOOTSTRAP.md stays absent
493
+ expect(existsSync(join(testHome, "workspace", "BOOTSTRAP.md"))).toBe(false);
494
+ });
495
+ });
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // ensureAgentHome — mise
499
+ // ---------------------------------------------------------------------------
500
+
501
+ describe("ensureAgentHome — mise", () => {
502
+ it("creates $AGENT_HOME/mise/ directory", () => {
503
+ ensureAgentHome(testHome);
504
+ const miseDir = join(testHome, "mise");
505
+ expect(existsSync(miseDir)).toBe(true);
506
+ expect(statSync(miseDir).isDirectory()).toBe(true);
507
+ });
508
+
509
+ it("seeds workspace/mise.toml on first boot", () => {
510
+ ensureAgentHome(testHome);
511
+ const miseTomPath = join(testHome, "workspace", "mise.toml");
512
+ expect(existsSync(miseTomPath)).toBe(true);
513
+ const content = readFileSync(miseTomPath, "utf8");
514
+ expect(content).toContain("[tools]");
515
+ });
516
+
517
+ it("does not overwrite existing workspace/mise.toml (user edits survive)", () => {
518
+ ensureAgentHome(testHome);
519
+ const miseTomPath = join(testHome, "workspace", "mise.toml");
520
+ writeFileSync(
521
+ miseTomPath,
522
+ "# custom user content\n[tools]\npython = '3.12'\n",
523
+ "utf8",
524
+ );
525
+ ensureAgentHome(testHome);
526
+ const content = readFileSync(miseTomPath, "utf8");
527
+ expect(content).toContain("python = '3.12'");
528
+ expect(content).toContain("custom user content");
529
+ });
530
+
531
+ it("seeds workspace/requirements.txt from template on first boot", () => {
532
+ ensureAgentHome(testHome);
533
+ const requirementsPath = join(testHome, "workspace", "requirements.txt");
534
+ expect(existsSync(requirementsPath)).toBe(true);
535
+ const content = readFileSync(requirementsPath, "utf8");
536
+ expect(content).toContain("# Add pip packages here");
537
+ });
538
+
539
+ it("does not overwrite existing workspace/requirements.txt (user edits survive)", () => {
540
+ ensureAgentHome(testHome);
541
+ const requirementsPath = join(testHome, "workspace", "requirements.txt");
542
+ writeFileSync(requirementsPath, "faster-whisper\ntorch\n", "utf8");
543
+ ensureAgentHome(testHome);
544
+ const content = readFileSync(requirementsPath, "utf8");
545
+ expect(content).toContain("torch");
546
+ });
547
+ });
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // runMiseStartup
551
+ // ---------------------------------------------------------------------------
552
+
553
+ describe("runMiseStartup", () => {
554
+ const originalMiseDataDir = process.env.MISE_DATA_DIR;
555
+ const originalMiseCacheDir = process.env.MISE_CACHE_DIR;
556
+ const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
557
+ const originalXdgDataHome = process.env.XDG_DATA_HOME;
558
+ const originalGoPath = process.env.GOPATH;
559
+ const originalGoModCache = process.env.GOMODCACHE;
560
+ const originalNpmCache = process.env.npm_config_cache;
561
+ const originalBunCache = process.env.BUN_INSTALL_CACHE_DIR;
562
+ const originalHome = process.env.HOME;
563
+ const originalPath = process.env.PATH;
564
+
565
+ beforeEach(() => {
566
+ // Isolate HOME to the per-test dir so the ~/.bashrc mise-activate append in
567
+ // runMiseStartup never touches the real developer/CI home.
568
+ process.env.HOME = testHome;
569
+ });
570
+
571
+ afterEach(() => {
572
+ // Restore env vars modified by runMiseStartup (and the tests below). A
573
+ // variable that was unset must be deleted: Bun >= 1.4 turns
574
+ // `process.env.KEY = undefined` into the string "undefined", which later
575
+ // tests in the same process then read (e.g. Playwright's cache directory
576
+ // resolving to "undefined/ms-playwright").
577
+ const restore = (name: string, original: string | undefined) => {
578
+ if (original === undefined) delete process.env[name];
579
+ else process.env[name] = original;
580
+ };
581
+ restore("MISE_DATA_DIR", originalMiseDataDir);
582
+ restore("MISE_CACHE_DIR", originalMiseCacheDir);
583
+ restore("XDG_CACHE_HOME", originalXdgCacheHome);
584
+ restore("XDG_DATA_HOME", originalXdgDataHome);
585
+ restore("GOPATH", originalGoPath);
586
+ restore("GOMODCACHE", originalGoModCache);
587
+ restore("npm_config_cache", originalNpmCache);
588
+ restore("BUN_INSTALL_CACHE_DIR", originalBunCache);
589
+ restore("HOME", originalHome);
590
+ restore("PATH", originalPath);
591
+ });
592
+
593
+ it("sets MISE_DATA_DIR to the PVC path", async () => {
594
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
595
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
596
+ await runMiseStartup(testHome, mockExec);
597
+ expect(process.env.MISE_DATA_DIR).toBe(join(testHome, "mise"));
598
+ });
599
+
600
+ it("does NOT seed/copy the image mise dir onto the PVC", async () => {
601
+ // node + claude are system binaries (image /usr/bin), not mise tools, so
602
+ // runMiseStartup must NOT copy the image mise dir onto the PVC. The old seed
603
+ // hack did, which on a reused PVC left claude resolving to a shim with no
604
+ // install ("claude is not a valid shim").
605
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
606
+ const imageMiseDir = join(testHome, ".local", "share", "mise");
607
+ mkdirSync(join(imageMiseDir, "shims"), { recursive: true });
608
+ writeFileSync(join(imageMiseDir, "shims", "claude"), "image-shim", "utf8");
609
+ process.env.HOME = testHome;
610
+ delete process.env.XDG_DATA_HOME;
611
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
612
+ await runMiseStartup(testHome, mockExec);
613
+ // The image's claude shim must NOT have been copied to the PVC.
614
+ expect(existsSync(join(testHome, "mise", "shims", "claude"))).toBe(false);
615
+ });
616
+
617
+ it("pins MISE_CACHE_DIR and XDG_CACHE_HOME to the PVC", async () => {
618
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
619
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
620
+ await runMiseStartup(testHome, mockExec);
621
+ expect(process.env.MISE_CACHE_DIR).toBe(join(testHome, "mise", "cache"));
622
+ expect(process.env.XDG_CACHE_HOME).toBe(join(testHome, "cache"));
623
+ expect(existsSync(process.env.MISE_CACHE_DIR ?? "")).toBe(true);
624
+ expect(existsSync(process.env.XDG_CACHE_HOME ?? "")).toBe(true);
625
+ });
626
+
627
+ it("pins the Go, npm and bun caches to the PVC (they ignore XDG)", async () => {
628
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
629
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
630
+ await runMiseStartup(testHome, mockExec);
631
+
632
+ // GOMODCACHE defaults to $GOPATH/pkg/mod, which is under $HOME on the
633
+ // container's ephemeral overlay — a Terratest module tree there (~1.7GiB)
634
+ // blows the pod's ephemeral-storage limit and evicts it mid-run.
635
+ expect(process.env.GOPATH).toBe(join(testHome, "go"));
636
+ expect(process.env.GOMODCACHE).toBe(join(testHome, "go", "pkg", "mod"));
637
+ expect(process.env.npm_config_cache).toBe(join(testHome, "cache", "npm"));
638
+ expect(process.env.BUN_INSTALL_CACHE_DIR).toBe(
639
+ join(testHome, "cache", "bun"),
640
+ );
641
+
642
+ // Created eagerly so the first tool invocation writes to the PVC rather
643
+ // than falling back to a default path when the dir is missing.
644
+ expect(existsSync(process.env.GOMODCACHE ?? "")).toBe(true);
645
+ expect(existsSync(process.env.npm_config_cache ?? "")).toBe(true);
646
+ expect(existsSync(process.env.BUN_INSTALL_CACHE_DIR ?? "")).toBe(true);
647
+ });
648
+
649
+ it("leaves CARGO_HOME unset so mise owns the Rust toolchain install", async () => {
650
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
651
+ const before = process.env.CARGO_HOME;
652
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
653
+ await runMiseStartup(testHome, mockExec);
654
+ expect(process.env.CARGO_HOME).toBe(before);
655
+ });
656
+
657
+ it("skips mise install when mise.toml absent", async () => {
658
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
659
+ const calls: Array<{ cmd: string; args: string[] }> = [];
660
+ const mockExec = async (
661
+ cmd: string,
662
+ args: string[],
663
+ _opts: { cwd: string },
664
+ ) => {
665
+ calls.push({ cmd, args });
666
+ return { stdout: "", exitCode: 0 };
667
+ };
668
+ await runMiseStartup(testHome, mockExec);
669
+ const installCalls = calls.filter((c) => c.args.includes("install"));
670
+ expect(installCalls).toHaveLength(0);
671
+ });
672
+
673
+ it("skips mise install when mise.toml absent (idempotent — execFn called 0 times)", async () => {
674
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
675
+ let callCount = 0;
676
+ const mockExec = async (
677
+ _cmd: string,
678
+ _args: string[],
679
+ _opts: { cwd: string },
680
+ ) => {
681
+ callCount++;
682
+ return { stdout: "", exitCode: 0 };
683
+ };
684
+ await runMiseStartup(testHome, mockExec);
685
+ expect(callCount).toBe(0);
686
+ });
687
+
688
+ it("runs mise trust then mise install when mise.toml present", async () => {
689
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
690
+ const miseTomlPath = join(testHome, "workspace", "mise.toml");
691
+ writeFileSync(miseTomlPath, "[tools]\n", "utf8");
692
+ const calls: Array<{ cmd: string; args: string[] }> = [];
693
+ const mockExec = async (
694
+ cmd: string,
695
+ args: string[],
696
+ _opts: { cwd: string },
697
+ ) => {
698
+ calls.push({ cmd, args });
699
+ return { stdout: "", exitCode: 0 };
700
+ };
701
+ await runMiseStartup(testHome, mockExec);
702
+ const trustIdx = calls.findIndex(
703
+ (c) => c.cmd === "mise" && c.args.includes("trust"),
704
+ );
705
+ const installIdx = calls.findIndex(
706
+ (c) => c.cmd === "mise" && c.args.includes("install"),
707
+ );
708
+ expect(trustIdx).toBeGreaterThanOrEqual(0);
709
+ expect(calls[trustIdx].args).toContain(miseTomlPath);
710
+ expect(installIdx).toBeGreaterThan(trustIdx);
711
+ });
712
+
713
+ it("prepends MISE_DATA_DIR/shims (PVC path) to process.env.PATH", async () => {
714
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
715
+ writeFileSync(
716
+ join(testHome, "workspace", "mise.toml"),
717
+ "[tools]\n",
718
+ "utf8",
719
+ );
720
+ delete process.env.MISE_DATA_DIR;
721
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
722
+ await runMiseStartup(testHome, mockExec);
723
+ const expectedShims = join(testHome, "mise", "shims");
724
+ expect(process.env.PATH?.startsWith(`${expectedShims}:`)).toBe(true);
725
+ });
726
+
727
+ it("appends mise activate bash to ~/.bashrc", async () => {
728
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
729
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
730
+ await runMiseStartup(testHome, mockExec);
731
+ const bashrc = join(testHome, ".bashrc");
732
+ expect(existsSync(bashrc)).toBe(true);
733
+ expect(readFileSync(bashrc, "utf8")).toContain("mise activate bash");
734
+ });
735
+
736
+ it("does not duplicate the mise activate line on repeated calls (idempotent)", async () => {
737
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
738
+ const mockExec = async () => ({ stdout: "", exitCode: 0 });
739
+ await runMiseStartup(testHome, mockExec);
740
+ await runMiseStartup(testHome, mockExec);
741
+ const content = readFileSync(join(testHome, ".bashrc"), "utf8");
742
+ const count = (content.match(/mise activate bash/g) ?? []).length;
743
+ expect(count).toBe(1);
744
+ });
745
+
746
+ it("does not throw when mise trust execFn throws (non-fatal error handling)", async () => {
747
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
748
+ const miseTomlPath = join(testHome, "workspace", "mise.toml");
749
+ writeFileSync(miseTomlPath, "[tools]\n", "utf8");
750
+
751
+ const consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
752
+ try {
753
+ const mockExec = async (
754
+ cmd: string,
755
+ args: string[],
756
+ _opts: { cwd: string },
757
+ ) => {
758
+ if (cmd === "mise" && args.includes("trust")) {
759
+ throw new Error('Executable not found in $PATH: "mise"');
760
+ }
761
+ return { stdout: "", exitCode: 0 };
762
+ };
763
+
764
+ await expect(runMiseStartup(testHome, mockExec)).resolves.toBeUndefined();
765
+ expect(consoleWarnSpy).toHaveBeenCalled();
766
+ expect(consoleWarnSpy.mock.calls.flat().join(" ")).toContain("trust");
767
+ } finally {
768
+ consoleWarnSpy.mockRestore();
769
+ }
770
+ });
771
+
772
+ it("does not throw when mise install execFn throws (non-fatal error handling)", async () => {
773
+ mkdirSync(join(testHome, "workspace"), { recursive: true });
774
+ const miseTomlPath = join(testHome, "workspace", "mise.toml");
775
+ writeFileSync(miseTomlPath, "[tools]\n", "utf8");
776
+
777
+ const consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
778
+ try {
779
+ const mockExec = async (
780
+ cmd: string,
781
+ args: string[],
782
+ _opts: { cwd: string },
783
+ ) => {
784
+ if (cmd === "mise" && args.includes("install")) {
785
+ throw new Error('Executable not found in $PATH: "mise"');
786
+ }
787
+ return { stdout: "", exitCode: 0 };
788
+ };
789
+
790
+ await expect(runMiseStartup(testHome, mockExec)).resolves.toBeUndefined();
791
+ expect(consoleWarnSpy).toHaveBeenCalled();
792
+ expect(consoleWarnSpy.mock.calls.flat().join(" ")).toContain("install");
793
+ } finally {
794
+ consoleWarnSpy.mockRestore();
795
+ }
796
+ });
797
+ });
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // findStalePluginSpecs
801
+ // ---------------------------------------------------------------------------
802
+
803
+ describe("findStalePluginSpecs", () => {
804
+ it("returns empty array when manifest does not exist", () => {
805
+ const result = findStalePluginSpecs(
806
+ ["shipwright@shipwright"],
807
+ "/nonexistent/installed_plugins.json",
808
+ );
809
+ expect(result).toEqual([]);
810
+ });
811
+
812
+ it("returns empty array when all installPaths exist", () => {
813
+ mkdirSync(testHome, { recursive: true });
814
+ const pluginDir = join(testHome, "plugin-dir");
815
+ mkdirSync(pluginDir, { recursive: true });
816
+ const manifestPath = join(testHome, "installed_plugins.json");
817
+ writeFileSync(
818
+ manifestPath,
819
+ JSON.stringify({
820
+ version: 2,
821
+ plugins: {
822
+ "shipwright@shipwright": [{ installPath: pluginDir }],
823
+ },
824
+ }),
825
+ );
826
+
827
+ const result = findStalePluginSpecs(
828
+ ["shipwright@shipwright"],
829
+ manifestPath,
830
+ );
831
+ expect(result).toEqual([]);
832
+ });
833
+
834
+ it("returns specs whose installPath does not exist on disk", () => {
835
+ mkdirSync(testHome, { recursive: true });
836
+ const manifestPath = join(testHome, "installed_plugins.json");
837
+ writeFileSync(
838
+ manifestPath,
839
+ JSON.stringify({
840
+ version: 2,
841
+ plugins: {
842
+ "example-platform@example-platform": [
843
+ {
844
+ installPath: "/root/.claude/plugins/cache/example-platform/0.4.5",
845
+ },
846
+ ],
847
+ "shipwright@shipwright": [
848
+ { installPath: "/root/.claude/plugins/cache/shipwright/1.0.0" },
849
+ ],
850
+ },
851
+ }),
852
+ );
853
+
854
+ const result = findStalePluginSpecs(
855
+ ["example-platform@example-platform", "shipwright@shipwright"],
856
+ manifestPath,
857
+ );
858
+ expect(result).toContain("example-platform@example-platform");
859
+ expect(result).toContain("shipwright@shipwright");
860
+ });
861
+
862
+ it("skips specs not present in the manifest", () => {
863
+ mkdirSync(testHome, { recursive: true });
864
+ const manifestPath = join(testHome, "installed_plugins.json");
865
+ writeFileSync(manifestPath, JSON.stringify({ version: 2, plugins: {} }));
866
+
867
+ const result = findStalePluginSpecs(
868
+ ["shipwright@shipwright"],
869
+ manifestPath,
870
+ );
871
+ expect(result).toEqual([]);
872
+ });
873
+
874
+ it("returns empty array when manifest JSON is corrupted", () => {
875
+ mkdirSync(testHome, { recursive: true });
876
+ const manifestPath = join(testHome, "installed_plugins.json");
877
+ writeFileSync(manifestPath, "not valid json");
878
+
879
+ const result = findStalePluginSpecs(
880
+ ["shipwright@shipwright"],
881
+ manifestPath,
882
+ );
883
+ expect(result).toEqual([]);
884
+ });
885
+ });
886
+
887
+ // installPlugins
888
+ // ---------------------------------------------------------------------------
889
+
890
+ // Explicit empty extra-marketplace list — every call in this describe block
891
+ // must pass this (or an intentional array) instead of omitting the arg.
892
+ // Omitting it falls through to discoverBakedMarketplaces(BAKED_MARKETPLACES_ROOT),
893
+ // which reads the REAL /opt/shipwright/marketplaces directory on disk and makes
894
+ // these tests depend on ambient sandbox/image state.
895
+ const NO_EXTRA_MARKETPLACES: string[] = [];
896
+
897
+ describe("installPlugins", () => {
898
+ it("registers local marketplace then installs shipwright@shipwright using the shipwright marketplace", async () => {
899
+ const calls: Array<{ cmd: string; args: string[] }> = [];
900
+ const mockExec = async (
901
+ cmd: string,
902
+ args: string[],
903
+ _opts: { cwd: string },
904
+ ) => {
905
+ calls.push({ cmd, args });
906
+ return { stdout: "", exitCode: 0 };
907
+ };
908
+
909
+ await installPlugins(
910
+ mockExec,
911
+ testHome,
912
+ [],
913
+ "/repo/root",
914
+ join(testHome, "nonexistent.json"),
915
+ NO_EXTRA_MARKETPLACES,
916
+ );
917
+
918
+ // marketplace add + 1 install + 1 update = 3 calls (no stale paths)
919
+ expect(calls).toHaveLength(3);
920
+ expect(calls[0].args).toEqual([
921
+ "plugin",
922
+ "marketplace",
923
+ "add",
924
+ "/repo/root",
925
+ ]);
926
+ expect(calls[1].args).toEqual([
927
+ "plugin",
928
+ "install",
929
+ "shipwright@shipwright",
930
+ ]);
931
+ expect(calls[2].args).toEqual([
932
+ "plugin",
933
+ "update",
934
+ "shipwright@shipwright",
935
+ ]);
936
+ });
937
+
938
+ it("installs agent-specific plugins after default plugins", async () => {
939
+ const calls: Array<{ cmd: string; args: string[] }> = [];
940
+ const mockExec = async (
941
+ cmd: string,
942
+ args: string[],
943
+ _opts: { cwd: string },
944
+ ) => {
945
+ calls.push({ cmd, args });
946
+ return { stdout: "", exitCode: 0 };
947
+ };
948
+
949
+ const agentPlugins: AgentPlugin[] = [
950
+ { marketplace: "my-marketplace", plugin: "custom-plugin" },
951
+ { marketplace: "other-market", plugin: "another-plugin" },
952
+ ];
953
+
954
+ await installPlugins(
955
+ mockExec,
956
+ testHome,
957
+ agentPlugins,
958
+ "/repo/root",
959
+ join(testHome, "nonexistent.json"),
960
+ NO_EXTRA_MARKETPLACES,
961
+ );
962
+
963
+ // 1 marketplace add + 3 installs + 3 updates = 7 calls
964
+ expect(calls).toHaveLength(7);
965
+
966
+ expect(calls[0].args).toEqual([
967
+ "plugin",
968
+ "marketplace",
969
+ "add",
970
+ "/repo/root",
971
+ ]);
972
+
973
+ // installs: default first, then agent-specific
974
+ expect(calls[1].args).toEqual([
975
+ "plugin",
976
+ "install",
977
+ "shipwright@shipwright",
978
+ ]);
979
+ expect(calls[2].args).toEqual([
980
+ "plugin",
981
+ "install",
982
+ "custom-plugin@my-marketplace",
983
+ ]);
984
+ expect(calls[3].args).toEqual([
985
+ "plugin",
986
+ "install",
987
+ "another-plugin@other-market",
988
+ ]);
989
+
990
+ // updates: default first, then agent-specific
991
+ expect(calls[4].args).toEqual([
992
+ "plugin",
993
+ "update",
994
+ "shipwright@shipwright",
995
+ ]);
996
+ expect(calls[5].args).toEqual([
997
+ "plugin",
998
+ "update",
999
+ "custom-plugin@my-marketplace",
1000
+ ]);
1001
+ expect(calls[6].args).toEqual([
1002
+ "plugin",
1003
+ "update",
1004
+ "another-plugin@other-market",
1005
+ ]);
1006
+ });
1007
+
1008
+ it("empty agentPlugins installs only default plugins", async () => {
1009
+ const calls: Array<{ args: string[] }> = [];
1010
+ const mockExec = async (
1011
+ _cmd: string,
1012
+ args: string[],
1013
+ _opts: { cwd: string },
1014
+ ) => {
1015
+ calls.push({ args });
1016
+ return { stdout: "", exitCode: 0 };
1017
+ };
1018
+
1019
+ await installPlugins(
1020
+ mockExec,
1021
+ testHome,
1022
+ [],
1023
+ "/repo/root",
1024
+ join(testHome, "nonexistent.json"),
1025
+ NO_EXTRA_MARKETPLACES,
1026
+ );
1027
+
1028
+ // marketplace add + 1 install + 1 update = 3 calls
1029
+ expect(calls).toHaveLength(3);
1030
+ expect(calls[1].args).toContain("shipwright@shipwright");
1031
+ expect(calls[2].args).toContain("shipwright@shipwright");
1032
+ });
1033
+
1034
+ it("is non-fatal when the claude binary isn't available", async () => {
1035
+ const throwingExec = async () => {
1036
+ throw new Error("spawn claude ENOENT");
1037
+ };
1038
+
1039
+ await expect(
1040
+ installPlugins(
1041
+ throwingExec,
1042
+ testHome,
1043
+ [],
1044
+ "/repo/root",
1045
+ join(testHome, "nonexistent.json"),
1046
+ NO_EXTRA_MARKETPLACES,
1047
+ ),
1048
+ ).resolves.toBeUndefined();
1049
+ });
1050
+
1051
+ it("continues when a plugin install exits non-zero", async () => {
1052
+ const calls: Array<{ args: string[] }> = [];
1053
+ const mockExec = async (
1054
+ _cmd: string,
1055
+ args: string[],
1056
+ _opts: { cwd: string },
1057
+ ) => {
1058
+ calls.push({ args });
1059
+ const isInstall = args[1] === "install";
1060
+ return {
1061
+ stdout: isInstall ? "auth required" : "",
1062
+ exitCode: isInstall ? 1 : 0,
1063
+ };
1064
+ };
1065
+
1066
+ await expect(
1067
+ installPlugins(
1068
+ mockExec,
1069
+ testHome,
1070
+ [],
1071
+ "/repo/root",
1072
+ join(testHome, "nonexistent.json"),
1073
+ NO_EXTRA_MARKETPLACES,
1074
+ ),
1075
+ ).resolves.toBeUndefined();
1076
+
1077
+ // All calls still happened despite install failures
1078
+ expect(calls).toHaveLength(3);
1079
+ });
1080
+
1081
+ it("continues when marketplace add exits non-zero", async () => {
1082
+ const calls: Array<{ args: string[] }> = [];
1083
+ const mockExec = async (
1084
+ _cmd: string,
1085
+ args: string[],
1086
+ _opts: { cwd: string },
1087
+ ) => {
1088
+ calls.push({ args });
1089
+ const isMarketplaceAdd = args[1] === "marketplace";
1090
+ return {
1091
+ stdout: isMarketplaceAdd ? "network error" : "",
1092
+ exitCode: isMarketplaceAdd ? 1 : 0,
1093
+ };
1094
+ };
1095
+
1096
+ await expect(
1097
+ installPlugins(
1098
+ mockExec,
1099
+ testHome,
1100
+ [],
1101
+ "/repo/root",
1102
+ join(testHome, "nonexistent.json"),
1103
+ NO_EXTRA_MARKETPLACES,
1104
+ ),
1105
+ ).resolves.toBeUndefined();
1106
+
1107
+ // All 3 calls still happened despite marketplace add failure
1108
+ expect(calls).toHaveLength(3);
1109
+ });
1110
+
1111
+ it("is a silent no-op when plugins are already installed and up-to-date", async () => {
1112
+ const calls: Array<{ args: string[] }> = [];
1113
+ const mockExec = async (
1114
+ _cmd: string,
1115
+ args: string[],
1116
+ _opts: { cwd: string },
1117
+ ) => {
1118
+ calls.push({ args });
1119
+ return { stdout: "already present", exitCode: 0 };
1120
+ };
1121
+
1122
+ await expect(
1123
+ installPlugins(
1124
+ mockExec,
1125
+ testHome,
1126
+ [],
1127
+ "/repo/root",
1128
+ join(testHome, "nonexistent.json"),
1129
+ NO_EXTRA_MARKETPLACES,
1130
+ ),
1131
+ ).resolves.toBeUndefined();
1132
+ expect(calls).toHaveLength(3);
1133
+ });
1134
+
1135
+ it("uninstalls then reinstalls plugins with stale installPaths from a different-HOME container", async () => {
1136
+ // Simulate a PVC where plugins were installed as root (HOME=/root) but the
1137
+ // container now runs as uid 1000 (HOME=/home/bun). The recorded installPath
1138
+ // points to /root/.claude/... which doesn't exist in the new container.
1139
+ mkdirSync(testHome, { recursive: true });
1140
+ const manifestPath = join(testHome, "installed_plugins.json");
1141
+ writeFileSync(
1142
+ manifestPath,
1143
+ JSON.stringify({
1144
+ version: 2,
1145
+ plugins: {
1146
+ "shipwright@shipwright": [
1147
+ { installPath: "/root/.claude/plugins/cache/shipwright/1.0.0" },
1148
+ ],
1149
+ },
1150
+ }),
1151
+ );
1152
+
1153
+ const calls: Array<{ args: string[] }> = [];
1154
+ const mockExec = async (
1155
+ _cmd: string,
1156
+ args: string[],
1157
+ _opts: { cwd: string },
1158
+ ) => {
1159
+ calls.push({ args });
1160
+ return { stdout: "", exitCode: 0 };
1161
+ };
1162
+
1163
+ await installPlugins(
1164
+ mockExec,
1165
+ testHome,
1166
+ [],
1167
+ "/repo/root",
1168
+ manifestPath,
1169
+ NO_EXTRA_MARKETPLACES,
1170
+ );
1171
+
1172
+ // marketplace add + uninstall (stale) + install + update = 4 calls
1173
+ expect(calls).toHaveLength(4);
1174
+ expect(calls[1].args).toEqual([
1175
+ "plugin",
1176
+ "uninstall",
1177
+ "shipwright@shipwright",
1178
+ ]);
1179
+ expect(calls[2].args).toEqual([
1180
+ "plugin",
1181
+ "install",
1182
+ "shipwright@shipwright",
1183
+ ]);
1184
+ expect(calls[3].args).toEqual([
1185
+ "plugin",
1186
+ "update",
1187
+ "shipwright@shipwright",
1188
+ ]);
1189
+ });
1190
+
1191
+ it("skips install and update for a stale spec whose uninstall exits non-zero", async () => {
1192
+ // When uninstall fails the stale manifest entry survives. Re-installing
1193
+ // would silently succeed (idempotent) without fixing the stale path, so
1194
+ // the spec must be skipped — visible failure on next restart is better than
1195
+ // silent "success" with a still-broken path.
1196
+ mkdirSync(testHome, { recursive: true });
1197
+ const manifestPath = join(testHome, "installed_plugins.json");
1198
+ writeFileSync(
1199
+ manifestPath,
1200
+ JSON.stringify({
1201
+ version: 2,
1202
+ plugins: {
1203
+ "shipwright@shipwright": [
1204
+ { installPath: "/root/.claude/plugins/cache/shipwright/1.0.0" },
1205
+ ],
1206
+ },
1207
+ }),
1208
+ );
1209
+
1210
+ const calls: Array<{ args: string[] }> = [];
1211
+ const mockExec = async (
1212
+ _cmd: string,
1213
+ args: string[],
1214
+ _opts: { cwd: string },
1215
+ ) => {
1216
+ calls.push({ args });
1217
+ const isUninstall = args[1] === "uninstall";
1218
+ return {
1219
+ stdout: isUninstall ? "permission denied" : "",
1220
+ exitCode: isUninstall ? 1 : 0,
1221
+ };
1222
+ };
1223
+
1224
+ await installPlugins(
1225
+ mockExec,
1226
+ testHome,
1227
+ [],
1228
+ "/repo/root",
1229
+ manifestPath,
1230
+ NO_EXTRA_MARKETPLACES,
1231
+ );
1232
+
1233
+ // marketplace add + uninstall (failed) = 2 calls; install and update are skipped
1234
+ expect(calls).toHaveLength(2);
1235
+ expect(calls[0].args).toEqual([
1236
+ "plugin",
1237
+ "marketplace",
1238
+ "add",
1239
+ "/repo/root",
1240
+ ]);
1241
+ expect(calls[1].args).toEqual([
1242
+ "plugin",
1243
+ "uninstall",
1244
+ "shipwright@shipwright",
1245
+ ]);
1246
+ // No install or update calls for the failed spec
1247
+ expect(calls.every((c) => c.args[1] !== "install")).toBe(true);
1248
+ expect(calls.every((c) => c.args[1] !== "update")).toBe(true);
1249
+ });
1250
+
1251
+ it("does not uninstall plugins with valid installPaths", async () => {
1252
+ // Plugin installed with correct HOME — installPath exists on disk.
1253
+ mkdirSync(testHome, { recursive: true });
1254
+ const pluginDir = join(testHome, "plugins", "cache", "shipwright", "1.0.0");
1255
+ mkdirSync(pluginDir, { recursive: true });
1256
+ const manifestPath = join(testHome, "installed_plugins.json");
1257
+ writeFileSync(
1258
+ manifestPath,
1259
+ JSON.stringify({
1260
+ version: 2,
1261
+ plugins: {
1262
+ "shipwright@shipwright": [{ installPath: pluginDir }],
1263
+ },
1264
+ }),
1265
+ );
1266
+
1267
+ const calls: Array<{ args: string[] }> = [];
1268
+ const mockExec = async (
1269
+ _cmd: string,
1270
+ args: string[],
1271
+ _opts: { cwd: string },
1272
+ ) => {
1273
+ calls.push({ args });
1274
+ return { stdout: "", exitCode: 0 };
1275
+ };
1276
+
1277
+ await installPlugins(
1278
+ mockExec,
1279
+ testHome,
1280
+ [],
1281
+ "/repo/root",
1282
+ manifestPath,
1283
+ NO_EXTRA_MARKETPLACES,
1284
+ );
1285
+
1286
+ // marketplace add + install + update = 3 calls (no uninstall)
1287
+ expect(calls).toHaveLength(3);
1288
+ expect(calls.every((c) => c.args[1] !== "uninstall")).toBe(true);
1289
+ });
1290
+ });
1291
+
1292
+ // ---------------------------------------------------------------------------
1293
+ // discoverBakedMarketplaces
1294
+ // ---------------------------------------------------------------------------
1295
+
1296
+ describe("discoverBakedMarketplaces", () => {
1297
+ it("returns dirs that have .claude-plugin/marketplace.json", () => {
1298
+ mkdirSync(testHome, { recursive: true });
1299
+ const conventionRoot = join(testHome, "marketplaces");
1300
+
1301
+ // Create a valid marketplace dir
1302
+ const validDir = join(conventionRoot, "my-marketplace");
1303
+ mkdirSync(join(validDir, ".claude-plugin"), { recursive: true });
1304
+ writeFileSync(
1305
+ join(validDir, ".claude-plugin", "marketplace.json"),
1306
+ JSON.stringify({ name: "my-marketplace" }),
1307
+ "utf8",
1308
+ );
1309
+
1310
+ const result = discoverBakedMarketplaces(conventionRoot);
1311
+ expect(result).toHaveLength(1);
1312
+ expect(result[0]).toBe(validDir);
1313
+ });
1314
+
1315
+ it("skips dirs without .claude-plugin/marketplace.json", () => {
1316
+ mkdirSync(testHome, { recursive: true });
1317
+ const conventionRoot = join(testHome, "marketplaces");
1318
+
1319
+ // Valid dir
1320
+ const validDir = join(conventionRoot, "valid-marketplace");
1321
+ mkdirSync(join(validDir, ".claude-plugin"), { recursive: true });
1322
+ writeFileSync(
1323
+ join(validDir, ".claude-plugin", "marketplace.json"),
1324
+ JSON.stringify({ name: "valid-marketplace" }),
1325
+ "utf8",
1326
+ );
1327
+
1328
+ // Dir missing .claude-plugin/marketplace.json
1329
+ const invalidDir = join(conventionRoot, "invalid-marketplace");
1330
+ mkdirSync(join(invalidDir, ".claude-plugin"), { recursive: true });
1331
+ // No marketplace.json — only a different file
1332
+ writeFileSync(
1333
+ join(invalidDir, ".claude-plugin", "plugin.json"),
1334
+ "{}",
1335
+ "utf8",
1336
+ );
1337
+
1338
+ // Dir with no .claude-plugin at all
1339
+ const bareDir = join(conventionRoot, "bare-dir");
1340
+ mkdirSync(bareDir, { recursive: true });
1341
+
1342
+ const result = discoverBakedMarketplaces(conventionRoot);
1343
+ expect(result).toHaveLength(1);
1344
+ expect(result[0]).toBe(validDir);
1345
+ });
1346
+
1347
+ it("returns empty array when conventionRoot is absent", () => {
1348
+ const result = discoverBakedMarketplaces(
1349
+ join(testHome, "nonexistent-convention-root"),
1350
+ );
1351
+ expect(result).toEqual([]);
1352
+ });
1353
+
1354
+ it("returns empty array when conventionRoot is empty", () => {
1355
+ mkdirSync(testHome, { recursive: true });
1356
+ const conventionRoot = join(testHome, "empty-marketplaces");
1357
+ mkdirSync(conventionRoot, { recursive: true });
1358
+
1359
+ const result = discoverBakedMarketplaces(conventionRoot);
1360
+ expect(result).toEqual([]);
1361
+ });
1362
+ });
1363
+
1364
+ // ---------------------------------------------------------------------------
1365
+ // installPlugins — extra marketplace discovery
1366
+ // ---------------------------------------------------------------------------
1367
+
1368
+ describe("installPlugins — extra marketplace discovery", () => {
1369
+ it("registers each discovered marketplace dir before plugin installs", async () => {
1370
+ mkdirSync(testHome, { recursive: true });
1371
+ const conventionRoot = join(testHome, "marketplaces");
1372
+
1373
+ // Create a baked marketplace dir
1374
+ const extraMarketDir = join(conventionRoot, "example-platform");
1375
+ mkdirSync(join(extraMarketDir, ".claude-plugin"), { recursive: true });
1376
+ writeFileSync(
1377
+ join(extraMarketDir, ".claude-plugin", "marketplace.json"),
1378
+ JSON.stringify({ name: "example-platform" }),
1379
+ "utf8",
1380
+ );
1381
+
1382
+ const calls: Array<{ cmd: string; args: string[] }> = [];
1383
+ const mockExec = async (
1384
+ cmd: string,
1385
+ args: string[],
1386
+ _opts: { cwd: string },
1387
+ ) => {
1388
+ calls.push({ cmd, args });
1389
+ return { stdout: "", exitCode: 0 };
1390
+ };
1391
+
1392
+ await installPlugins(
1393
+ mockExec,
1394
+ testHome,
1395
+ [],
1396
+ "/repo/root",
1397
+ join(testHome, "nonexistent.json"),
1398
+ undefined, // use default discovery — pass conventionRoot via extraMarketplacesRoot
1399
+ conventionRoot,
1400
+ );
1401
+
1402
+ // Extra marketplace add must come BEFORE the /repo/root marketplace add and BEFORE plugin install
1403
+ const addCalls = calls.filter((c) => c.args[1] === "marketplace");
1404
+ expect(addCalls.length).toBeGreaterThanOrEqual(2);
1405
+ expect(addCalls[0].args[3]).toBe(extraMarketDir);
1406
+ expect(addCalls[1].args[3]).toBe("/repo/root");
1407
+
1408
+ // All install calls must come after all marketplace add calls
1409
+ const installIdx = calls.findIndex((c) => c.args[1] === "install");
1410
+ const lastAddIdx = calls.reduce(
1411
+ (max, c, i) => (c.args[1] === "marketplace" ? i : max),
1412
+ -1,
1413
+ );
1414
+ expect(installIdx).toBeGreaterThan(lastAddIdx);
1415
+ });
1416
+
1417
+ it("skips dirs that do not have .claude-plugin/marketplace.json", async () => {
1418
+ mkdirSync(testHome, { recursive: true });
1419
+ const conventionRoot = join(testHome, "marketplaces");
1420
+
1421
+ // Valid dir
1422
+ const validDir = join(conventionRoot, "valid");
1423
+ mkdirSync(join(validDir, ".claude-plugin"), { recursive: true });
1424
+ writeFileSync(
1425
+ join(validDir, ".claude-plugin", "marketplace.json"),
1426
+ JSON.stringify({ name: "valid" }),
1427
+ "utf8",
1428
+ );
1429
+
1430
+ // Invalid dir (no marketplace.json)
1431
+ const invalidDir = join(conventionRoot, "invalid");
1432
+ mkdirSync(join(invalidDir, ".claude-plugin"), { recursive: true });
1433
+
1434
+ const calls: Array<{ cmd: string; args: string[] }> = [];
1435
+ const mockExec = async (
1436
+ cmd: string,
1437
+ args: string[],
1438
+ _opts: { cwd: string },
1439
+ ) => {
1440
+ calls.push({ cmd, args });
1441
+ return { stdout: "", exitCode: 0 };
1442
+ };
1443
+
1444
+ await installPlugins(
1445
+ mockExec,
1446
+ testHome,
1447
+ [],
1448
+ "/repo/root",
1449
+ join(testHome, "nonexistent.json"),
1450
+ undefined,
1451
+ conventionRoot,
1452
+ );
1453
+
1454
+ const addCalls = calls.filter((c) => c.args[1] === "marketplace");
1455
+ // Only valid + /repo/root = 2 adds (invalid dir excluded)
1456
+ expect(addCalls).toHaveLength(2);
1457
+ const addDirs = addCalls.map((c) => c.args[3]);
1458
+ expect(addDirs).toContain(validDir);
1459
+ expect(addDirs).not.toContain(invalidDir);
1460
+ expect(addDirs).toContain("/repo/root");
1461
+ });
1462
+
1463
+ it("is backward compatible when no extra marketplace dirs exist", async () => {
1464
+ mkdirSync(testHome, { recursive: true });
1465
+ const conventionRoot = join(testHome, "empty-marketplaces");
1466
+ mkdirSync(conventionRoot, { recursive: true });
1467
+
1468
+ const calls: Array<{ cmd: string; args: string[] }> = [];
1469
+ const mockExec = async (
1470
+ cmd: string,
1471
+ args: string[],
1472
+ _opts: { cwd: string },
1473
+ ) => {
1474
+ calls.push({ cmd, args });
1475
+ return { stdout: "", exitCode: 0 };
1476
+ };
1477
+
1478
+ await installPlugins(
1479
+ mockExec,
1480
+ testHome,
1481
+ [],
1482
+ "/repo/root",
1483
+ join(testHome, "nonexistent.json"),
1484
+ undefined,
1485
+ conventionRoot,
1486
+ );
1487
+
1488
+ // Only the /repo/root marketplace add — same as before this feature
1489
+ const addCalls = calls.filter((c) => c.args[1] === "marketplace");
1490
+ expect(addCalls).toHaveLength(1);
1491
+ expect(addCalls[0].args[3]).toBe("/repo/root");
1492
+ // marketplace add + install + update = 3 calls total
1493
+ expect(calls).toHaveLength(3);
1494
+ });
1495
+ });