@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.
- package/README.md +44 -10
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/portable-md.test.ts +247 -0
- package/core/src/portable-md.ts +118 -1
- package/core/src/schema.ts +98 -2
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +1 -1
- package/src/auth-status.ts +4 -0
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/cli.ts +151 -50
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/export-watch.test.ts +99 -0
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +93 -0
- package/src/module-manifest.ts +94 -0
- package/src/routes.ts +267 -50
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +380 -0
- package/src/self-register.ts +234 -0
- package/src/server.ts +46 -11
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- 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
|
+
}
|