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

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Service discovery for the scribe transcription module.
3
+ *
4
+ * Per the 2026-05-21 vault↔scribe design (Part 2, design question 2), vault
5
+ * locates scribe via `~/.parachute/services.json` — the canonical hub-
6
+ * maintained registry. This module is the single read site so the
7
+ * resolution rule lives in one place.
8
+ *
9
+ * Resolution order (first hit wins):
10
+ *
11
+ * 1. `SCRIBE_URL` env var (operator override; useful for tests, Docker
12
+ * compose, and any deploy where scribe runs at a non-loopback host).
13
+ * 2. Entry `name === "parachute-scribe"` in `~/.parachute/services.json`
14
+ * → construct `http://127.0.0.1:<port>`.
15
+ * 3. `undefined` (auto-transcribe stays a no-op).
16
+ *
17
+ * The bearer token resolution stays in `./scribe-env.ts:resolveScribeAuthToken`.
18
+ * Service discovery is just about WHERE scribe lives; AUTH is a separate
19
+ * concern with its own env-var precedence (SCRIBE_AUTH_TOKEN over the legacy
20
+ * SCRIBE_TOKEN). When the v0.7 hub-issued-JWT path lands, the bearer source
21
+ * changes but the URL source stays the same — one file, one concern.
22
+ *
23
+ * v0.6 deploy is single-container (hub-as-supervisor) so loopback is fine.
24
+ * v0.7 cloud-multi-container will grow an `origin` field on the services.json
25
+ * entry; this resolver will honor it without API changes — `port` becomes
26
+ * a fallback when `origin` isn't set, no breaking change for v0.6 callers.
27
+ */
28
+
29
+ import { readManifest, ServicesManifestError } from "./services-manifest.ts";
30
+
31
+ /**
32
+ * Resolve the scribe base URL (no trailing slash) by consulting the env-var
33
+ * override first, then services.json. Returns `undefined` when scribe isn't
34
+ * configured — callers MUST treat that as "auto-transcribe disabled."
35
+ *
36
+ * The `env` + `readManifestImpl` parameters are injection seams for tests;
37
+ * production callers omit them and pick up `process.env` + the real
38
+ * `~/.parachute/services.json`.
39
+ */
40
+ export function resolveScribeUrl(
41
+ env: NodeJS.ProcessEnv = process.env,
42
+ readManifestImpl: typeof readManifest = readManifest,
43
+ logger: { warn?: (...args: unknown[]) => void } = console,
44
+ ): string | undefined {
45
+ const override = env.SCRIBE_URL?.trim();
46
+ if (override) return override.replace(/\/$/, "");
47
+
48
+ let manifest;
49
+ try {
50
+ manifest = readManifestImpl();
51
+ } catch (err) {
52
+ if (err instanceof ServicesManifestError) {
53
+ logger.warn?.(`[scribe-discovery] services.json unreadable: ${err.message}`);
54
+ } else {
55
+ logger.warn?.(`[scribe-discovery] services.json read failed: ${err}`);
56
+ }
57
+ return undefined;
58
+ }
59
+ const entry = manifest.services.find((s) => s.name === "parachute-scribe");
60
+ if (!entry) return undefined;
61
+ // v0.6 loopback shape; v0.7 will add an explicit `origin` field on the
62
+ // service entry which wins over loopback when present.
63
+ const origin = (entry as { origin?: string }).origin;
64
+ if (typeof origin === "string" && origin.trim()) {
65
+ return origin.trim().replace(/\/$/, "");
66
+ }
67
+ return `http://127.0.0.1:${entry.port}`;
68
+ }
69
+
70
+ /**
71
+ * Process-lifetime cache. Computed at first call (typically during server
72
+ * boot), reused for every subsequent transcription request. Operators who
73
+ * change the scribe URL via `services.json` (re-install of scribe with a
74
+ * different port) need to restart vault; we deliberately don't watch the
75
+ * file because the v0.6 deploy model has a single restart-on-change story.
76
+ *
77
+ * Tests should pass an explicit `env` + `readManifestImpl` to `resolveScribeUrl`
78
+ * directly to bypass the cache.
79
+ */
80
+ let cachedScribeUrl: string | undefined | null = null;
81
+
82
+ export function getCachedScribeUrl(): string | undefined {
83
+ if (cachedScribeUrl === null) {
84
+ cachedScribeUrl = resolveScribeUrl();
85
+ }
86
+ return cachedScribeUrl;
87
+ }
88
+
89
+ export function clearScribeUrlCache(): void {
90
+ cachedScribeUrl = null;
91
+ }
@@ -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,380 @@
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
+ kind: "api",
43
+ port: 1940,
44
+ paths: ["/vault/default"],
45
+ health: "/vault/default/health",
46
+ };
47
+
48
+ function captureLogs(): {
49
+ log: (m: string) => void;
50
+ warn: (m: string) => void;
51
+ logs: string[];
52
+ warnings: string[];
53
+ } {
54
+ const logs: string[] = [];
55
+ const warnings: string[] = [];
56
+ return {
57
+ log: (m: string) => logs.push(m),
58
+ warn: (m: string) => warnings.push(m),
59
+ logs,
60
+ warnings,
61
+ };
62
+ }
63
+
64
+ describe("self-register", () => {
65
+ test("buildVaultServicePaths — no vaults yet → manifest fallback", () => {
66
+ expect(buildVaultServicePaths(undefined, [], ["/vault/default"])).toEqual([
67
+ "/vault/default",
68
+ ]);
69
+ });
70
+
71
+ test("buildVaultServicePaths — default vault sorts first", () => {
72
+ expect(
73
+ buildVaultServicePaths("default", ["alpha", "default", "beta"], ["/"]),
74
+ ).toEqual(["/vault/default", "/vault/alpha", "/vault/beta"]);
75
+ });
76
+
77
+ test("buildVaultServicePaths — no default → map by listed order", () => {
78
+ expect(buildVaultServicePaths(undefined, ["alpha", "beta"], ["/"])).toEqual([
79
+ "/vault/alpha",
80
+ "/vault/beta",
81
+ ]);
82
+ });
83
+
84
+ test("buildVaultServicePaths — default points to missing vault → ignore default", () => {
85
+ expect(
86
+ buildVaultServicePaths("missing", ["alpha", "beta"], ["/"]),
87
+ ).toEqual(["/vault/alpha", "/vault/beta"]);
88
+ });
89
+
90
+ test("writes services.json with manifest-sourced fields + installDir", () => {
91
+ withParachuteHome((home) => {
92
+ const { log, warn, logs, warnings } = captureLogs();
93
+ const result = selfRegister({
94
+ version: "0.4.8-rc.3",
95
+ log,
96
+ warn,
97
+ readManifest: () => TEST_MANIFEST,
98
+ resolvePackageRoot: () => "/fake/install/dir",
99
+ listVaults: () => [],
100
+ readGlobalConfig: () => ({ port: 1940 }),
101
+ });
102
+ expect(result.status).toBe("registered");
103
+ expect(warnings).toEqual([]);
104
+ expect(logs[0]).toContain("registered parachute-vault");
105
+
106
+ const raw = readFileSync(join(home, "services.json"), "utf8");
107
+ const parsed = JSON.parse(raw) as { services: unknown[] };
108
+ expect(parsed.services).toHaveLength(1);
109
+ const row = parsed.services[0] as Record<string, unknown>;
110
+ expect(row.name).toBe("parachute-vault");
111
+ expect(row.port).toBe(1940);
112
+ expect(row.paths).toEqual(["/vault/default"]);
113
+ expect(row.health).toBe("/vault/default/health");
114
+ expect(row.version).toBe("0.4.8-rc.3");
115
+ expect(row.installDir).toBe("/fake/install/dir");
116
+ expect(row.displayName).toBe("Vault");
117
+ expect(row.tagline).toBe("Test tagline");
118
+ });
119
+ });
120
+
121
+ test("idempotent — re-running reports no changes", () => {
122
+ withParachuteHome(() => {
123
+ const { log, warn } = captureLogs();
124
+ const deps = {
125
+ version: "0.4.8-rc.3",
126
+ log,
127
+ warn,
128
+ readManifest: () => TEST_MANIFEST,
129
+ resolvePackageRoot: () => "/fake/install/dir",
130
+ listVaults: () => [],
131
+ readGlobalConfig: () => ({ port: 1940 }),
132
+ };
133
+ const first = selfRegister(deps);
134
+ expect(first.status).toBe("registered");
135
+ if (first.status === "registered") expect(first.changed).toBe(true);
136
+
137
+ const second = selfRegister(deps);
138
+ expect(second.status).toBe("registered");
139
+ if (second.status === "registered") expect(second.changed).toBe(false);
140
+ });
141
+ });
142
+
143
+ test("preserves hub-stamped fields on the row", () => {
144
+ withParachuteHome((home) => {
145
+ // Pre-existing row carries a hub-stamped field we don't write.
146
+ const servicesPath = join(home, "services.json");
147
+ writeFileSync(
148
+ servicesPath,
149
+ `${JSON.stringify(
150
+ {
151
+ services: [
152
+ {
153
+ name: "parachute-vault",
154
+ port: 1940,
155
+ paths: ["/vault/default"],
156
+ health: "/vault/default/health",
157
+ version: "0.4.7",
158
+ // Pretend hub stamped some custom field — should survive.
159
+ hubCustomField: "preserved",
160
+ installDir: "/some/prior/path",
161
+ },
162
+ ],
163
+ },
164
+ null,
165
+ 2,
166
+ )}\n`,
167
+ );
168
+
169
+ const { log, warn } = captureLogs();
170
+ const result = selfRegister({
171
+ version: "0.4.8-rc.3",
172
+ log,
173
+ warn,
174
+ readManifest: () => TEST_MANIFEST,
175
+ resolvePackageRoot: () => "/new/install/dir",
176
+ listVaults: () => [],
177
+ readGlobalConfig: () => ({ port: 1940 }),
178
+ });
179
+ expect(result.status).toBe("registered");
180
+
181
+ const raw = readFileSync(servicesPath, "utf8");
182
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
183
+ const row = parsed.services[0]!;
184
+ // Vault-owned fields win.
185
+ expect(row.version).toBe("0.4.8-rc.3");
186
+ expect(row.installDir).toBe("/new/install/dir");
187
+ // Hub-stamped foreign field survives.
188
+ expect(row.hubCustomField).toBe("preserved");
189
+ });
190
+ });
191
+
192
+ test("preserves entries written by other modules", () => {
193
+ withParachuteHome((home) => {
194
+ const servicesPath = join(home, "services.json");
195
+ writeFileSync(
196
+ servicesPath,
197
+ `${JSON.stringify(
198
+ {
199
+ services: [
200
+ {
201
+ name: "parachute-scribe",
202
+ port: 1943,
203
+ paths: ["/scribe"],
204
+ health: "/scribe/health",
205
+ version: "0.3.0",
206
+ },
207
+ ],
208
+ },
209
+ null,
210
+ 2,
211
+ )}\n`,
212
+ );
213
+
214
+ const { log, warn } = captureLogs();
215
+ selfRegister({
216
+ version: "0.4.8-rc.3",
217
+ log,
218
+ warn,
219
+ readManifest: () => TEST_MANIFEST,
220
+ resolvePackageRoot: () => "/fake/install/dir",
221
+ listVaults: () => [],
222
+ readGlobalConfig: () => ({ port: 1940 }),
223
+ });
224
+
225
+ const raw = readFileSync(servicesPath, "utf8");
226
+ const parsed = JSON.parse(raw) as { services: { name: string }[] };
227
+ expect(parsed.services).toHaveLength(2);
228
+ expect(parsed.services.find((s) => s.name === "parachute-scribe")).toBeDefined();
229
+ expect(parsed.services.find((s) => s.name === "parachute-vault")).toBeDefined();
230
+ });
231
+ });
232
+
233
+ test("skipped when manifest absent — never throws", () => {
234
+ withParachuteHome(() => {
235
+ const { log, warn, warnings } = captureLogs();
236
+ const result = selfRegister({
237
+ version: "0.4.8-rc.3",
238
+ log,
239
+ warn,
240
+ readManifest: () => null,
241
+ resolvePackageRoot: () => "/fake/install/dir",
242
+ listVaults: () => [],
243
+ readGlobalConfig: () => ({ port: 1940 }),
244
+ });
245
+ expect(result.status).toBe("skipped");
246
+ if (result.status === "skipped") expect(result.reason).toMatch(/manifest absent/);
247
+ expect(warnings).toEqual([]); // skipped is informational, not a warning
248
+ });
249
+ });
250
+
251
+ test("failed when manifest read throws — does not propagate", () => {
252
+ withParachuteHome(() => {
253
+ const { log, warn, warnings } = captureLogs();
254
+ const result = selfRegister({
255
+ version: "0.4.8-rc.3",
256
+ log,
257
+ warn,
258
+ readManifest: () => {
259
+ throw new Error("corrupt JSON");
260
+ },
261
+ resolvePackageRoot: () => "/fake/install/dir",
262
+ listVaults: () => [],
263
+ readGlobalConfig: () => ({ port: 1940 }),
264
+ });
265
+ expect(result.status).toBe("failed");
266
+ if (result.status === "failed") expect(result.reason).toContain("corrupt JSON");
267
+ expect(warnings.some((w) => w.includes("could not read"))).toBe(true);
268
+ });
269
+ });
270
+
271
+ test("failed when upsert throws — does not propagate", () => {
272
+ withParachuteHome(() => {
273
+ const { log, warn, warnings } = captureLogs();
274
+ const result = selfRegister({
275
+ version: "0.4.8-rc.3",
276
+ log,
277
+ warn,
278
+ readManifest: () => TEST_MANIFEST,
279
+ resolvePackageRoot: () => "/fake/install/dir",
280
+ listVaults: () => [],
281
+ readGlobalConfig: () => ({ port: 1940 }),
282
+ upsertService: () => {
283
+ throw new Error("disk full");
284
+ },
285
+ });
286
+ expect(result.status).toBe("failed");
287
+ if (result.status === "failed") expect(result.reason).toContain("disk full");
288
+ expect(warnings.some((w) => w.includes("services.json write failed"))).toBe(true);
289
+ });
290
+ });
291
+
292
+ test("multi-vault: default vault sorts first in paths", () => {
293
+ withParachuteHome((home) => {
294
+ const { log, warn } = captureLogs();
295
+ selfRegister({
296
+ version: "0.4.8-rc.3",
297
+ log,
298
+ warn,
299
+ readManifest: () => TEST_MANIFEST,
300
+ resolvePackageRoot: () => "/fake/install/dir",
301
+ listVaults: () => ["alpha", "default", "beta"],
302
+ readGlobalConfig: () => ({ port: 1940, default_vault: "default" }),
303
+ });
304
+
305
+ const raw = readFileSync(join(home, "services.json"), "utf8");
306
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
307
+ expect(parsed.services[0]!.paths).toEqual([
308
+ "/vault/default",
309
+ "/vault/alpha",
310
+ "/vault/beta",
311
+ ]);
312
+ });
313
+ });
314
+
315
+ test("uses globalConfig.port over DEFAULT_PORT when set", () => {
316
+ withParachuteHome((home) => {
317
+ const { log, warn } = captureLogs();
318
+ selfRegister({
319
+ version: "0.4.8-rc.3",
320
+ log,
321
+ warn,
322
+ readManifest: () => TEST_MANIFEST,
323
+ resolvePackageRoot: () => "/fake/install/dir",
324
+ listVaults: () => [],
325
+ readGlobalConfig: () => ({ port: 19999 }),
326
+ });
327
+
328
+ const raw = readFileSync(join(home, "services.json"), "utf8");
329
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
330
+ expect(parsed.services[0]!.port).toBe(19999);
331
+ });
332
+ });
333
+
334
+ test("stripPrefix flows from manifest to row when set", () => {
335
+ withParachuteHome((home) => {
336
+ const { log, warn } = captureLogs();
337
+ selfRegister({
338
+ version: "0.4.8-rc.3",
339
+ log,
340
+ warn,
341
+ readManifest: () => ({ ...TEST_MANIFEST, stripPrefix: true }),
342
+ resolvePackageRoot: () => "/fake/install/dir",
343
+ listVaults: () => [],
344
+ readGlobalConfig: () => ({ port: 1940 }),
345
+ });
346
+
347
+ const raw = readFileSync(join(home, "services.json"), "utf8");
348
+ const parsed = JSON.parse(raw) as { services: Record<string, unknown>[] };
349
+ expect(parsed.services[0]!.stripPrefix).toBe(true);
350
+ });
351
+ });
352
+
353
+ test("changed=true when installDir differs from prior row", () => {
354
+ withParachuteHome(() => {
355
+ const { log, warn } = captureLogs();
356
+ const baseDeps = {
357
+ version: "0.4.8-rc.3",
358
+ log,
359
+ warn,
360
+ readManifest: () => TEST_MANIFEST,
361
+ listVaults: () => [],
362
+ readGlobalConfig: () => ({ port: 1940 }),
363
+ };
364
+ const first = selfRegister({
365
+ ...baseDeps,
366
+ resolvePackageRoot: () => "/old/install/dir",
367
+ });
368
+ expect(first.status).toBe("registered");
369
+ if (first.status === "registered") expect(first.changed).toBe(true);
370
+
371
+ // Second call with a different installDir — should re-stamp.
372
+ const second = selfRegister({
373
+ ...baseDeps,
374
+ resolvePackageRoot: () => "/new/install/dir",
375
+ });
376
+ expect(second.status).toBe("registered");
377
+ if (second.status === "registered") expect(second.changed).toBe(true);
378
+ });
379
+ });
380
+ });