@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.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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
|
+
});
|