@openparachute/vault 0.4.7-rc.1 → 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.
Files changed (42) hide show
  1. package/README.md +44 -10
  2. package/core/src/connection-pragmas.test.ts +232 -0
  3. package/core/src/core.test.ts +257 -0
  4. package/core/src/cursor.test.ts +160 -0
  5. package/core/src/cursor.ts +272 -0
  6. package/core/src/mcp.ts +51 -7
  7. package/core/src/notes.ts +164 -2
  8. package/core/src/portable-md.test.ts +247 -0
  9. package/core/src/portable-md.ts +118 -1
  10. package/core/src/schema.ts +98 -2
  11. package/core/src/store.ts +11 -1
  12. package/core/src/types.ts +32 -0
  13. package/package.json +1 -1
  14. package/src/auth-status.ts +4 -0
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/cli.ts +151 -50
  18. package/src/config.test.ts +26 -0
  19. package/src/config.ts +53 -1
  20. package/src/db.ts +15 -2
  21. package/src/export-watch.test.ts +99 -0
  22. package/src/mcp-install-interactive.test.ts +23 -2
  23. package/src/mcp-install-interactive.ts +21 -2
  24. package/src/mcp-install.test.ts +40 -0
  25. package/src/mcp-tools.ts +17 -1
  26. package/src/module-config.ts +70 -14
  27. package/src/module-manifest.test.ts +93 -0
  28. package/src/module-manifest.ts +94 -0
  29. package/src/routes.ts +267 -50
  30. package/src/scribe-discovery.test.ts +77 -0
  31. package/src/scribe-discovery.ts +91 -0
  32. package/src/scribe-env.test.ts +66 -1
  33. package/src/scribe-env.ts +42 -1
  34. package/src/self-register.test.ts +380 -0
  35. package/src/self-register.ts +234 -0
  36. package/src/server.ts +46 -11
  37. package/src/transcript-note.test.ts +171 -0
  38. package/src/transcript-note.ts +189 -0
  39. package/src/transcription-registry.ts +22 -0
  40. package/src/transcription-worker.test.ts +250 -0
  41. package/src/transcription-worker.ts +186 -27
  42. package/src/vault.test.ts +347 -0
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
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Boot-time self-registration of vault's manifest + `installDir` into
3
+ * `~/.parachute/services.json` — the POC for retiring hub's
4
+ * `FIRST_PARTY_FALLBACKS[vault]` (vault#266).
5
+ *
6
+ * Background: hub today vendors a `VAULT_FALLBACK` manifest in
7
+ * `parachute-hub/src/service-spec.ts` and stamps `installDir` onto the
8
+ * services.json row via `stampInstallDirOnRow` during `parachute install
9
+ * vault` / API install. That fallback exists because (a) bun-link dev
10
+ * mode never runs the hub install path so installDir isn't stamped,
11
+ * and (b) v0.5 vault didn't ship its own `.parachute/module.json` so
12
+ * hub had no manifest to read at lifecycle time.
13
+ *
14
+ * The endgame: every first-party module self-registers its manifest +
15
+ * installDir on startup so hub's vendored fallbacks retire one by one.
16
+ * This module is vault's piece of that pattern. Once vault/notes/scribe/
17
+ * runner all self-register reliably, a hub follow-up deletes
18
+ * `FIRST_PARTY_FALLBACKS` (see `parachute-hub` for the cleanup PR).
19
+ *
20
+ * Design choice — filesystem-direct rather than HTTP:
21
+ *
22
+ * In v0.6 (single-container, hub-as-supervisor — see workspace
23
+ * CLAUDE.md), hub and vault share the same filesystem. Writing directly
24
+ * to services.json with the existing merge-preserving `upsertService`
25
+ * is the simplest shape that works today. The hub-stamped fields the
26
+ * row already carries (`installDir`, anything else hub adds in the
27
+ * future) ride through because `upsertService` merges rather than
28
+ * replaces (see `services-manifest.ts`).
29
+ *
30
+ * v0.7 (multi-container cloud) will need an HTTP `POST
31
+ * /api/modules/self-register` on hub so a module on a different
32
+ * container can register without filesystem access to the operator's
33
+ * `~/.parachute/`. Filed as a separate hub follow-up; this module's
34
+ * shape is forward-compatible — `selfRegister` is the single seam that
35
+ * would swap from filesystem to HTTP transport.
36
+ *
37
+ * Failure mode: every error path logs + returns (never throws). A bad
38
+ * registration must not crash server boot — the running vault is more
39
+ * valuable than the discoverability bookkeeping. Symptom of failure is
40
+ * "vault doesn't appear on hub discovery / admin SPA"; the fix is to
41
+ * restart vault or run `parachute install vault` to stamp the row via
42
+ * the hub-side path.
43
+ */
44
+
45
+ import { readSelfManifest, resolvePackageRoot, type VaultModuleManifest } from "./module-manifest.ts";
46
+ import {
47
+ ServicesManifestError,
48
+ type ServiceEntry,
49
+ readManifest,
50
+ upsertService,
51
+ } from "./services-manifest.ts";
52
+ import { listVaults, readGlobalConfig, DEFAULT_PORT } from "./config.ts";
53
+
54
+ /**
55
+ * Compute the `paths` array for the parachute-vault services.json entry.
56
+ *
57
+ * Mirrors `buildVaultServicePaths` in `cli.ts` so the self-register pass
58
+ * produces the same multi-vault path advertisement as `parachute-vault
59
+ * init` / `vault create`. With no vaults yet, falls back to the manifest's
60
+ * canonical `paths[0]` so early-boot registration is still well-formed.
61
+ *
62
+ * Exported for tests; not part of the public module surface.
63
+ */
64
+ export function buildVaultServicePaths(
65
+ defaultVault: string | undefined,
66
+ vaults: readonly string[],
67
+ fallbackFromManifest: readonly string[],
68
+ ): string[] {
69
+ if (vaults.length === 0) return [...fallbackFromManifest];
70
+ if (defaultVault && vaults.includes(defaultVault)) {
71
+ return [
72
+ `/vault/${defaultVault}`,
73
+ ...vaults.filter((v) => v !== defaultVault).map((v) => `/vault/${v}`),
74
+ ];
75
+ }
76
+ return vaults.map((v) => `/vault/${v}`);
77
+ }
78
+
79
+ export interface SelfRegisterDeps {
80
+ /** Override the manifest reader (tests inject a stub manifest). */
81
+ readManifest?: () => VaultModuleManifest | null;
82
+ /** Override the package-root resolver (tests inject a tmp dir). */
83
+ resolvePackageRoot?: () => string;
84
+ /** Override the services.json reader (tests inject a tmp-file reader). */
85
+ readServicesManifest?: typeof readManifest;
86
+ /** Override the services.json upsert (tests inject a tmp-file writer). */
87
+ upsertService?: typeof upsertService;
88
+ /** Override the vault lister (tests pass a fixed list). */
89
+ listVaults?: typeof listVaults;
90
+ /** Override the global config reader (tests pass a synthetic config). */
91
+ readGlobalConfig?: typeof readGlobalConfig;
92
+ /** Sink for status + warning lines. Production passes a console wrapper. */
93
+ log?: (msg: string) => void;
94
+ warn?: (msg: string) => void;
95
+ /** Vault's runtime version (from `package.json`). Required — no default. */
96
+ version: string;
97
+ }
98
+
99
+ /** Result of a `selfRegister` call, surfaced to callers for observability. */
100
+ export type SelfRegisterResult =
101
+ | { status: "registered"; installDir: string; changed: boolean }
102
+ | { status: "skipped"; reason: string }
103
+ | { status: "failed"; reason: string };
104
+
105
+ /**
106
+ * Self-register vault's manifest + installDir into `~/.parachute/services.json`.
107
+ *
108
+ * Idempotent: re-runs with the same manifest + installDir produce the same
109
+ * row (the `changed` field on the result telegraphs whether the write
110
+ * actually mutated the file).
111
+ *
112
+ * Never throws. Errors (missing manifest, services.json unreadable,
113
+ * filesystem write failure) are logged via `warn` and surfaced as a
114
+ * `failed` / `skipped` result. The caller (server boot) treats failure
115
+ * as non-fatal — vault keeps serving without the row stamp.
116
+ */
117
+ export function selfRegister(deps: SelfRegisterDeps): SelfRegisterResult {
118
+ const log = deps.log ?? ((m) => console.log(m));
119
+ const warn = deps.warn ?? ((m) => console.warn(m));
120
+ const readManifestImpl = deps.readManifest ?? readSelfManifest;
121
+ const resolveRootImpl = deps.resolvePackageRoot ?? resolvePackageRoot;
122
+ const upsertImpl = deps.upsertService ?? upsertService;
123
+ const listVaultsImpl = deps.listVaults ?? listVaults;
124
+ const readGlobalConfigImpl = deps.readGlobalConfig ?? readGlobalConfig;
125
+
126
+ let manifest: VaultModuleManifest | null;
127
+ try {
128
+ manifest = readManifestImpl();
129
+ } catch (err) {
130
+ const msg = err instanceof Error ? err.message : String(err);
131
+ warn(`[self-register] could not read .parachute/module.json: ${msg}`);
132
+ return { status: "failed", reason: msg };
133
+ }
134
+ if (!manifest) {
135
+ log("[self-register] no .parachute/module.json found — skipping (legacy install or dev tree)");
136
+ return { status: "skipped", reason: "manifest absent" };
137
+ }
138
+
139
+ // `installDir` is the directory containing both `package.json` and
140
+ // `.parachute/module.json`. Hub's resolver (`<installDir>/.parachute/module.json`)
141
+ // expects exactly this shape — see `parachute-hub/src/post-install.ts`'s
142
+ // `stampInstallDirOnRow`.
143
+ let installDir: string;
144
+ try {
145
+ installDir = resolveRootImpl();
146
+ } catch (err) {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ warn(`[self-register] could not resolve package root: ${msg}`);
149
+ return { status: "failed", reason: msg };
150
+ }
151
+
152
+ let globalConfig: ReturnType<typeof readGlobalConfig>;
153
+ let vaults: string[];
154
+ try {
155
+ globalConfig = readGlobalConfigImpl();
156
+ vaults = listVaultsImpl();
157
+ } catch (err) {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ warn(`[self-register] could not read vault config: ${msg}`);
160
+ return { status: "failed", reason: msg };
161
+ }
162
+
163
+ const paths = buildVaultServicePaths(globalConfig.default_vault, vaults, manifest.paths);
164
+ const port = globalConfig.port ?? DEFAULT_PORT;
165
+
166
+ // Build the entry with manifest-sourced metadata (displayName, tagline,
167
+ // stripPrefix) layered on top of the operationally-determined fields
168
+ // (port from config, paths from current vault list, version from
169
+ // package.json). hub-stamped fields on the existing row (installDir
170
+ // from prior CLI install path, anything future) survive via
171
+ // `upsertService`'s merge semantics — see services-manifest.ts.
172
+ const entry: ServiceEntry & {
173
+ installDir: string;
174
+ displayName?: string;
175
+ tagline?: string;
176
+ stripPrefix?: boolean;
177
+ } = {
178
+ name: manifest.manifestName,
179
+ port,
180
+ paths,
181
+ health: manifest.health,
182
+ version: deps.version,
183
+ installDir,
184
+ };
185
+ if (manifest.displayName !== undefined) entry.displayName = manifest.displayName;
186
+ if (manifest.tagline !== undefined) entry.tagline = manifest.tagline;
187
+ if (manifest.stripPrefix !== undefined) entry.stripPrefix = manifest.stripPrefix;
188
+
189
+ // Detect whether the existing row already matches (no-op idempotency
190
+ // signal). We don't gate the write on this — `upsertService` itself is
191
+ // already idempotent at the byte level — but reporting `changed: false`
192
+ // lets the boot log say "already registered" instead of restamping noise.
193
+ let priorRow: (ServiceEntry & { installDir?: string }) | undefined;
194
+ try {
195
+ const current = (deps.readServicesManifest ?? readManifest)();
196
+ priorRow = current.services.find((s) => s.name === manifest.manifestName) as
197
+ | (ServiceEntry & { installDir?: string })
198
+ | undefined;
199
+ } catch (err) {
200
+ // Read failure here is non-fatal — we'll still attempt the write below.
201
+ // services.json may not exist yet (fresh boot); upsertService creates it.
202
+ if (err instanceof ServicesManifestError) {
203
+ warn(`[self-register] services.json read warning: ${err.message}`);
204
+ } else {
205
+ warn(`[self-register] services.json read warning: ${String(err)}`);
206
+ }
207
+ }
208
+
209
+ const changed =
210
+ !priorRow ||
211
+ priorRow.installDir !== installDir ||
212
+ priorRow.version !== deps.version ||
213
+ priorRow.port !== port ||
214
+ JSON.stringify(priorRow.paths) !== JSON.stringify(paths) ||
215
+ priorRow.health !== manifest.health ||
216
+ (priorRow as { displayName?: string }).displayName !== manifest.displayName ||
217
+ (priorRow as { tagline?: string }).tagline !== manifest.tagline ||
218
+ (priorRow as { stripPrefix?: boolean }).stripPrefix !== manifest.stripPrefix;
219
+
220
+ try {
221
+ upsertImpl(entry);
222
+ } catch (err) {
223
+ const msg = err instanceof Error ? err.message : String(err);
224
+ warn(`[self-register] services.json write failed: ${msg}`);
225
+ return { status: "failed", reason: msg };
226
+ }
227
+
228
+ if (changed) {
229
+ log(`[self-register] registered ${manifest.manifestName} (installDir=${installDir})`);
230
+ } else {
231
+ log(`[self-register] already registered ${manifest.manifestName} (no changes)`);
232
+ }
233
+ return { status: "registered", installDir, changed };
234
+ }