@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.10

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 (54) hide show
  1. package/.parachute/module.json +1 -1
  2. package/README.md +78 -41
  3. package/core/src/connection-pragmas.test.ts +232 -0
  4. package/core/src/core.test.ts +257 -0
  5. package/core/src/cursor.test.ts +160 -0
  6. package/core/src/cursor.ts +272 -0
  7. package/core/src/mcp.ts +51 -7
  8. package/core/src/notes.ts +164 -2
  9. package/core/src/schema.ts +106 -5
  10. package/core/src/store.ts +11 -1
  11. package/core/src/types.ts +32 -0
  12. package/package.json +7 -3
  13. package/src/auth-status.ts +4 -0
  14. package/src/auth.test.ts +5 -112
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/backup.ts +17 -3
  18. package/src/cli.ts +95 -66
  19. package/src/config.test.ts +26 -0
  20. package/src/config.ts +53 -1
  21. package/src/db.ts +15 -2
  22. package/src/export-watch.test.ts +21 -0
  23. package/src/mcp-install-interactive.test.ts +23 -2
  24. package/src/mcp-install-interactive.ts +21 -2
  25. package/src/mcp-install.test.ts +40 -0
  26. package/src/mcp-tools.ts +17 -1
  27. package/src/module-config.ts +70 -14
  28. package/src/module-manifest.test.ts +114 -0
  29. package/src/module-manifest.ts +104 -0
  30. package/src/oauth-discovery.ts +95 -0
  31. package/src/owner-auth.ts +22 -149
  32. package/src/routes.ts +268 -51
  33. package/src/routing.test.ts +102 -99
  34. package/src/routing.ts +33 -47
  35. package/src/scribe-discovery.test.ts +77 -0
  36. package/src/scribe-discovery.ts +91 -0
  37. package/src/scribe-env.test.ts +66 -1
  38. package/src/scribe-env.ts +42 -1
  39. package/src/self-register.test.ts +412 -0
  40. package/src/self-register.ts +247 -0
  41. package/src/server.ts +47 -23
  42. package/src/transcript-note.test.ts +171 -0
  43. package/src/transcript-note.ts +189 -0
  44. package/src/transcription-registry.ts +22 -0
  45. package/src/transcription-worker.test.ts +250 -0
  46. package/src/transcription-worker.ts +186 -27
  47. package/src/vault-name.ts +3 -2
  48. package/src/vault.test.ts +347 -0
  49. package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
  50. package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
  51. package/web/ui/dist/index.html +14 -0
  52. package/web/ui/tsconfig.json +21 -0
  53. package/src/oauth.test.ts +0 -2156
  54. package/src/oauth.ts +0 -973
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { resolveScribeAuthToken } from "./scribe-env.ts";
2
+ import { resolveScribeAuthToken, generateScribeBearer, ensureScribeBearer } from "./scribe-env.ts";
3
3
 
4
4
  function captureWarn() {
5
5
  const calls: unknown[][] = [];
@@ -47,3 +47,68 @@ describe("resolveScribeAuthToken", () => {
47
47
  expect(calls.length).toBe(0);
48
48
  });
49
49
  });
50
+
51
+ describe("generateScribeBearer (vault#353)", () => {
52
+ test("returns 32-byte base64url string (~43 chars, no padding)", () => {
53
+ const bearer = generateScribeBearer();
54
+ // 32 bytes base64url-encoded = 43 chars (no `=` padding in base64url).
55
+ expect(bearer.length).toBe(43);
56
+ expect(bearer).toMatch(/^[A-Za-z0-9_-]+$/);
57
+ });
58
+
59
+ test("each call yields a unique value", () => {
60
+ const a = generateScribeBearer();
61
+ const b = generateScribeBearer();
62
+ expect(a).not.toBe(b);
63
+ });
64
+ });
65
+
66
+ describe("ensureScribeBearer (vault#353)", () => {
67
+ test("generates + persists a bearer when neither env var is set", () => {
68
+ const env: Record<string, string> = {};
69
+ const writes: Array<[string, string]> = [];
70
+ const { created, token } = ensureScribeBearer(
71
+ () => ({ ...env }),
72
+ (k, v) => writes.push([k, v]),
73
+ );
74
+ expect(created).toBe(true);
75
+ expect(token.length).toBe(43);
76
+ expect(writes).toEqual([["SCRIBE_AUTH_TOKEN", token]]);
77
+ });
78
+
79
+ test("preserves existing SCRIBE_AUTH_TOKEN (idempotent)", () => {
80
+ const env: Record<string, string> = { SCRIBE_AUTH_TOKEN: "already-set" };
81
+ const writes: Array<[string, string]> = [];
82
+ const { created, token } = ensureScribeBearer(
83
+ () => ({ ...env }),
84
+ (k, v) => writes.push([k, v]),
85
+ );
86
+ expect(created).toBe(false);
87
+ expect(token).toBe("already-set");
88
+ expect(writes.length).toBe(0);
89
+ });
90
+
91
+ test("preserves legacy SCRIBE_TOKEN without rewriting it", () => {
92
+ const env: Record<string, string> = { SCRIBE_TOKEN: "legacy" };
93
+ const writes: Array<[string, string]> = [];
94
+ const { created, token } = ensureScribeBearer(
95
+ () => ({ ...env }),
96
+ (k, v) => writes.push([k, v]),
97
+ );
98
+ expect(created).toBe(false);
99
+ expect(token).toBe("legacy");
100
+ expect(writes.length).toBe(0);
101
+ });
102
+
103
+ test("treats whitespace-only env value as unset (generates fresh)", () => {
104
+ const env: Record<string, string> = { SCRIBE_AUTH_TOKEN: " " };
105
+ const writes: Array<[string, string]> = [];
106
+ const { created, token } = ensureScribeBearer(
107
+ () => ({ ...env }),
108
+ (k, v) => writes.push([k, v]),
109
+ );
110
+ expect(created).toBe(true);
111
+ expect(token.length).toBe(43);
112
+ expect(writes[0]?.[0]).toBe("SCRIBE_AUTH_TOKEN");
113
+ });
114
+ });
package/src/scribe-env.ts CHANGED
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * Lives in its own module so the boot-time token resolution in server.ts is
5
5
  * testable without running the rest of server.ts (which has side effects:
6
- * triggers, auto-init, Bun.serve). Keep this module pure and dependency-free.
6
+ * triggers, auto-init, Bun.serve). Keep this module pure and dependency-free
7
+ * (except for the `node:crypto` import used by bearer generation).
7
8
  */
8
9
 
10
+ import { randomBytes } from "node:crypto";
11
+
9
12
  /**
10
13
  * Resolve the scribe auth token. `SCRIBE_AUTH_TOKEN` is the canonical name
11
14
  * (matches the CLI's install-time auto-wire); `SCRIBE_TOKEN` is a legacy alias
@@ -31,3 +34,41 @@ export function resolveScribeAuthToken(
31
34
  }
32
35
  return undefined;
33
36
  }
37
+
38
+ /**
39
+ * Generate a fresh shared bearer for the vault↔scribe loopback contract
40
+ * (design 2026-05-21 Part 2, design question 2). 32 random bytes → base64url
41
+ * encoded. The operator (or hub install) writes the result into vault's
42
+ * `~/.parachute/vault/.env` as `SCRIBE_AUTH_TOKEN` AND into scribe's config
43
+ * (via env propagation or the scribe admin endpoint).
44
+ *
45
+ * Generation is callable as a pure function so install code, tests, and any
46
+ * future "rotate scribe bearer" admin endpoint share the same length +
47
+ * encoding without copy-paste.
48
+ */
49
+ export function generateScribeBearer(): string {
50
+ return randomBytes(32).toString("base64url");
51
+ }
52
+
53
+ /**
54
+ * Ensure a scribe bearer exists in the vault .env. Idempotent: if a value
55
+ * (canonical or legacy) is already set, returns `{ created: false, token: ... }`
56
+ * without touching the file. Otherwise generates a fresh bearer, persists it
57
+ * to the .env via the provided writer, and returns `{ created: true, token }`.
58
+ *
59
+ * The `envReader` + `envWriter` parameters are injection seams for tests; the
60
+ * production caller (`cli.ts` init flow) passes `readEnvFile` + `setEnvVar`.
61
+ */
62
+ export function ensureScribeBearer(
63
+ envReader: () => Record<string, string>,
64
+ envWriter: (key: string, value: string) => void,
65
+ ): { created: boolean; token: string } {
66
+ const env = envReader();
67
+ const existing = env.SCRIBE_AUTH_TOKEN ?? env.SCRIBE_TOKEN;
68
+ if (existing && existing.trim()) {
69
+ return { created: false, token: existing };
70
+ }
71
+ const token = generateScribeBearer();
72
+ envWriter("SCRIBE_AUTH_TOKEN", token);
73
+ return { created: true, token };
74
+ }
@@ -0,0 +1,412 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { buildVaultServicePaths, selfRegister } from "./self-register.ts";
6
+ import type { VaultModuleManifest } from "./module-manifest.ts";
7
+
8
+ /**
9
+ * Spin up a fresh PARACHUTE_HOME tmpdir + restore the prior env on teardown.
10
+ * The self-register pass writes through to `~/.parachute/services.json` via
11
+ * the per-call path resolution in services-manifest.ts (which honors
12
+ * PARACHUTE_HOME), so this is the canonical isolation seam.
13
+ */
14
+ function withParachuteHome(fn: (home: string) => void): void {
15
+ const home = mkdtempSync(join(tmpdir(), "pvault-self-register-"));
16
+ const prior = process.env.PARACHUTE_HOME;
17
+ process.env.PARACHUTE_HOME = home;
18
+ // Also override HOME so readGlobalConfig + listVaults don't fall through
19
+ // to the operator's real ~/.parachute.
20
+ const priorHome = process.env.HOME;
21
+ process.env.HOME = home;
22
+ try {
23
+ // Seed the config dir with an empty config.yaml so readGlobalConfig
24
+ // returns a clean state.
25
+ mkdirSync(join(home, "vault"), { recursive: true });
26
+ writeFileSync(join(home, "vault", "config.yaml"), "port: 1940\n");
27
+ fn(home);
28
+ } finally {
29
+ if (prior === undefined) delete process.env.PARACHUTE_HOME;
30
+ else process.env.PARACHUTE_HOME = prior;
31
+ if (priorHome === undefined) delete process.env.HOME;
32
+ else process.env.HOME = priorHome;
33
+ rmSync(home, { recursive: true, force: true });
34
+ }
35
+ }
36
+
37
+ const TEST_MANIFEST: VaultModuleManifest = {
38
+ name: "vault",
39
+ manifestName: "parachute-vault",
40
+ displayName: "Vault",
41
+ tagline: "Test tagline",
42
+ port: 1940,
43
+ paths: ["/vault/default"],
44
+ health: "/vault/default/health",
45
+ };
46
+
47
+ function captureLogs(): {
48
+ log: (m: string) => void;
49
+ warn: (m: string) => void;
50
+ logs: string[];
51
+ warnings: string[];
52
+ } {
53
+ const logs: string[] = [];
54
+ const warnings: string[] = [];
55
+ return {
56
+ log: (m: string) => logs.push(m),
57
+ warn: (m: string) => warnings.push(m),
58
+ logs,
59
+ warnings,
60
+ };
61
+ }
62
+
63
+ describe("self-register", () => {
64
+ test("buildVaultServicePaths — no vaults yet → manifest fallback", () => {
65
+ expect(buildVaultServicePaths(undefined, [], ["/vault/default"])).toEqual([
66
+ "/vault/default",
67
+ ]);
68
+ });
69
+
70
+ test("buildVaultServicePaths — default vault sorts first", () => {
71
+ expect(
72
+ buildVaultServicePaths("default", ["alpha", "default", "beta"], ["/"]),
73
+ ).toEqual(["/vault/default", "/vault/alpha", "/vault/beta"]);
74
+ });
75
+
76
+ test("buildVaultServicePaths — no default → map by listed order", () => {
77
+ expect(buildVaultServicePaths(undefined, ["alpha", "beta"], ["/"])).toEqual([
78
+ "/vault/alpha",
79
+ "/vault/beta",
80
+ ]);
81
+ });
82
+
83
+ test("buildVaultServicePaths — default points to missing vault → ignore default", () => {
84
+ expect(
85
+ buildVaultServicePaths("missing", ["alpha", "beta"], ["/"]),
86
+ ).toEqual(["/vault/alpha", "/vault/beta"]);
87
+ });
88
+
89
+ test("writes services.json with manifest-sourced fields + installDir", () => {
90
+ withParachuteHome((home) => {
91
+ const { log, warn, logs, warnings } = captureLogs();
92
+ const result = selfRegister({
93
+ version: "0.4.8-rc.3",
94
+ log,
95
+ warn,
96
+ readManifest: () => TEST_MANIFEST,
97
+ resolvePackageRoot: () => "/fake/install/dir",
98
+ listVaults: () => [],
99
+ readGlobalConfig: () => ({ port: 1940 }),
100
+ });
101
+ expect(result.status).toBe("registered");
102
+ expect(warnings).toEqual([]);
103
+ expect(logs[0]).toContain("registered parachute-vault");
104
+
105
+ const raw = readFileSync(join(home, "services.json"), "utf8");
106
+ const parsed = JSON.parse(raw) as { services: unknown[] };
107
+ expect(parsed.services).toHaveLength(1);
108
+ const row = parsed.services[0] as Record<string, unknown>;
109
+ expect(row.name).toBe("parachute-vault");
110
+ expect(row.port).toBe(1940);
111
+ expect(row.paths).toEqual(["/vault/default"]);
112
+ expect(row.health).toBe("/vault/default/health");
113
+ expect(row.version).toBe("0.4.8-rc.3");
114
+ expect(row.installDir).toBe("/fake/install/dir");
115
+ expect(row.displayName).toBe("Vault");
116
+ expect(row.tagline).toBe("Test tagline");
117
+ });
118
+ });
119
+
120
+ test("health path follows the primary vault name (closes vault#369)", () => {
121
+ // Regression: pre-fix self-register used `manifest.health` verbatim
122
+ // (which is `/vault/default/health`) regardless of the actual vault
123
+ // name. A hub-bundled wizard that lets the operator name their vault
124
+ // `notes` produced a services.json entry with `paths: ["/vault/notes"]`
125
+ // but `health: "/vault/default/health"`, so hub's per-module health
126
+ // probe hit a 404 even on a healthy vault. The fix derives health from
127
+ // paths[0]. Caught in the wild on a Render rebuild walkthrough.
128
+ withParachuteHome((home) => {
129
+ const { log, warn } = captureLogs();
130
+ selfRegister({
131
+ version: "0.4.8-rc.3",
132
+ log,
133
+ warn,
134
+ readManifest: () => TEST_MANIFEST,
135
+ resolvePackageRoot: () => "/fake/install/dir",
136
+ // Vault named something other than "default" — the manifest fallback
137
+ // path `/vault/default` should NOT leak into the health URL.
138
+ listVaults: () => ["notes"],
139
+ readGlobalConfig: () => ({ port: 1940, default_vault: "notes" }),
140
+ });
141
+ const parsed = JSON.parse(readFileSync(join(home, "services.json"), "utf8")) as {
142
+ services: { paths: string[]; health: string }[];
143
+ };
144
+ const row = parsed.services[0];
145
+ expect(row.paths).toEqual(["/vault/notes"]);
146
+ expect(row.health).toBe("/vault/notes/health");
147
+ // Sanity: health should never be the literal manifest template
148
+ // when a real vault exists with a different name.
149
+ expect(row.health).not.toBe("/vault/default/health");
150
+ });
151
+ });
152
+
153
+ test("idempotent — re-running reports no changes", () => {
154
+ withParachuteHome(() => {
155
+ const { log, warn } = captureLogs();
156
+ const deps = {
157
+ version: "0.4.8-rc.3",
158
+ log,
159
+ warn,
160
+ readManifest: () => TEST_MANIFEST,
161
+ resolvePackageRoot: () => "/fake/install/dir",
162
+ listVaults: () => [],
163
+ readGlobalConfig: () => ({ port: 1940 }),
164
+ };
165
+ const first = selfRegister(deps);
166
+ expect(first.status).toBe("registered");
167
+ if (first.status === "registered") expect(first.changed).toBe(true);
168
+
169
+ const second = selfRegister(deps);
170
+ expect(second.status).toBe("registered");
171
+ if (second.status === "registered") expect(second.changed).toBe(false);
172
+ });
173
+ });
174
+
175
+ test("preserves hub-stamped fields on the row", () => {
176
+ withParachuteHome((home) => {
177
+ // Pre-existing row carries a hub-stamped field we don't write.
178
+ const servicesPath = join(home, "services.json");
179
+ writeFileSync(
180
+ servicesPath,
181
+ `${JSON.stringify(
182
+ {
183
+ services: [
184
+ {
185
+ name: "parachute-vault",
186
+ port: 1940,
187
+ paths: ["/vault/default"],
188
+ health: "/vault/default/health",
189
+ version: "0.4.7",
190
+ // Pretend hub stamped some custom field — should survive.
191
+ hubCustomField: "preserved",
192
+ installDir: "/some/prior/path",
193
+ },
194
+ ],
195
+ },
196
+ null,
197
+ 2,
198
+ )}\n`,
199
+ );
200
+
201
+ const { log, warn } = captureLogs();
202
+ const result = selfRegister({
203
+ version: "0.4.8-rc.3",
204
+ log,
205
+ warn,
206
+ readManifest: () => TEST_MANIFEST,
207
+ resolvePackageRoot: () => "/new/install/dir",
208
+ listVaults: () => [],
209
+ readGlobalConfig: () => ({ port: 1940 }),
210
+ });
211
+ expect(result.status).toBe("registered");
212
+
213
+ const raw = readFileSync(servicesPath, "utf8");
214
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
215
+ const row = parsed.services[0]!;
216
+ // Vault-owned fields win.
217
+ expect(row.version).toBe("0.4.8-rc.3");
218
+ expect(row.installDir).toBe("/new/install/dir");
219
+ // Hub-stamped foreign field survives.
220
+ expect(row.hubCustomField).toBe("preserved");
221
+ });
222
+ });
223
+
224
+ test("preserves entries written by other modules", () => {
225
+ withParachuteHome((home) => {
226
+ const servicesPath = join(home, "services.json");
227
+ writeFileSync(
228
+ servicesPath,
229
+ `${JSON.stringify(
230
+ {
231
+ services: [
232
+ {
233
+ name: "parachute-scribe",
234
+ port: 1943,
235
+ paths: ["/scribe"],
236
+ health: "/scribe/health",
237
+ version: "0.3.0",
238
+ },
239
+ ],
240
+ },
241
+ null,
242
+ 2,
243
+ )}\n`,
244
+ );
245
+
246
+ const { log, warn } = captureLogs();
247
+ selfRegister({
248
+ version: "0.4.8-rc.3",
249
+ log,
250
+ warn,
251
+ readManifest: () => TEST_MANIFEST,
252
+ resolvePackageRoot: () => "/fake/install/dir",
253
+ listVaults: () => [],
254
+ readGlobalConfig: () => ({ port: 1940 }),
255
+ });
256
+
257
+ const raw = readFileSync(servicesPath, "utf8");
258
+ const parsed = JSON.parse(raw) as { services: { name: string }[] };
259
+ expect(parsed.services).toHaveLength(2);
260
+ expect(parsed.services.find((s) => s.name === "parachute-scribe")).toBeDefined();
261
+ expect(parsed.services.find((s) => s.name === "parachute-vault")).toBeDefined();
262
+ });
263
+ });
264
+
265
+ test("skipped when manifest absent — never throws", () => {
266
+ withParachuteHome(() => {
267
+ const { log, warn, warnings } = captureLogs();
268
+ const result = selfRegister({
269
+ version: "0.4.8-rc.3",
270
+ log,
271
+ warn,
272
+ readManifest: () => null,
273
+ resolvePackageRoot: () => "/fake/install/dir",
274
+ listVaults: () => [],
275
+ readGlobalConfig: () => ({ port: 1940 }),
276
+ });
277
+ expect(result.status).toBe("skipped");
278
+ if (result.status === "skipped") expect(result.reason).toMatch(/manifest absent/);
279
+ expect(warnings).toEqual([]); // skipped is informational, not a warning
280
+ });
281
+ });
282
+
283
+ test("failed when manifest read throws — does not propagate", () => {
284
+ withParachuteHome(() => {
285
+ const { log, warn, warnings } = captureLogs();
286
+ const result = selfRegister({
287
+ version: "0.4.8-rc.3",
288
+ log,
289
+ warn,
290
+ readManifest: () => {
291
+ throw new Error("corrupt JSON");
292
+ },
293
+ resolvePackageRoot: () => "/fake/install/dir",
294
+ listVaults: () => [],
295
+ readGlobalConfig: () => ({ port: 1940 }),
296
+ });
297
+ expect(result.status).toBe("failed");
298
+ if (result.status === "failed") expect(result.reason).toContain("corrupt JSON");
299
+ expect(warnings.some((w) => w.includes("could not read"))).toBe(true);
300
+ });
301
+ });
302
+
303
+ test("failed when upsert throws — does not propagate", () => {
304
+ withParachuteHome(() => {
305
+ const { log, warn, warnings } = captureLogs();
306
+ const result = selfRegister({
307
+ version: "0.4.8-rc.3",
308
+ log,
309
+ warn,
310
+ readManifest: () => TEST_MANIFEST,
311
+ resolvePackageRoot: () => "/fake/install/dir",
312
+ listVaults: () => [],
313
+ readGlobalConfig: () => ({ port: 1940 }),
314
+ upsertService: () => {
315
+ throw new Error("disk full");
316
+ },
317
+ });
318
+ expect(result.status).toBe("failed");
319
+ if (result.status === "failed") expect(result.reason).toContain("disk full");
320
+ expect(warnings.some((w) => w.includes("services.json write failed"))).toBe(true);
321
+ });
322
+ });
323
+
324
+ test("multi-vault: default vault sorts first in paths", () => {
325
+ withParachuteHome((home) => {
326
+ const { log, warn } = captureLogs();
327
+ selfRegister({
328
+ version: "0.4.8-rc.3",
329
+ log,
330
+ warn,
331
+ readManifest: () => TEST_MANIFEST,
332
+ resolvePackageRoot: () => "/fake/install/dir",
333
+ listVaults: () => ["alpha", "default", "beta"],
334
+ readGlobalConfig: () => ({ port: 1940, default_vault: "default" }),
335
+ });
336
+
337
+ const raw = readFileSync(join(home, "services.json"), "utf8");
338
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
339
+ expect(parsed.services[0]!.paths).toEqual([
340
+ "/vault/default",
341
+ "/vault/alpha",
342
+ "/vault/beta",
343
+ ]);
344
+ });
345
+ });
346
+
347
+ test("uses globalConfig.port over DEFAULT_PORT when set", () => {
348
+ withParachuteHome((home) => {
349
+ const { log, warn } = captureLogs();
350
+ selfRegister({
351
+ version: "0.4.8-rc.3",
352
+ log,
353
+ warn,
354
+ readManifest: () => TEST_MANIFEST,
355
+ resolvePackageRoot: () => "/fake/install/dir",
356
+ listVaults: () => [],
357
+ readGlobalConfig: () => ({ port: 19999 }),
358
+ });
359
+
360
+ const raw = readFileSync(join(home, "services.json"), "utf8");
361
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
362
+ expect(parsed.services[0]!.port).toBe(19999);
363
+ });
364
+ });
365
+
366
+ test("stripPrefix flows from manifest to row when set", () => {
367
+ withParachuteHome((home) => {
368
+ const { log, warn } = captureLogs();
369
+ selfRegister({
370
+ version: "0.4.8-rc.3",
371
+ log,
372
+ warn,
373
+ readManifest: () => ({ ...TEST_MANIFEST, stripPrefix: true }),
374
+ resolvePackageRoot: () => "/fake/install/dir",
375
+ listVaults: () => [],
376
+ readGlobalConfig: () => ({ port: 1940 }),
377
+ });
378
+
379
+ const raw = readFileSync(join(home, "services.json"), "utf8");
380
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
381
+ expect(parsed.services[0]!.stripPrefix).toBe(true);
382
+ });
383
+ });
384
+
385
+ test("changed=true when installDir differs from prior row", () => {
386
+ withParachuteHome(() => {
387
+ const { log, warn } = captureLogs();
388
+ const baseDeps = {
389
+ version: "0.4.8-rc.3",
390
+ log,
391
+ warn,
392
+ readManifest: () => TEST_MANIFEST,
393
+ listVaults: () => [],
394
+ readGlobalConfig: () => ({ port: 1940 }),
395
+ };
396
+ const first = selfRegister({
397
+ ...baseDeps,
398
+ resolvePackageRoot: () => "/old/install/dir",
399
+ });
400
+ expect(first.status).toBe("registered");
401
+ if (first.status === "registered") expect(first.changed).toBe(true);
402
+
403
+ // Second call with a different installDir — should re-stamp.
404
+ const second = selfRegister({
405
+ ...baseDeps,
406
+ resolvePackageRoot: () => "/new/install/dir",
407
+ });
408
+ expect(second.status).toBe("registered");
409
+ if (second.status === "registered") expect(second.changed).toBe(true);
410
+ });
411
+ });
412
+ });