@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.
- package/.parachute/module.json +1 -1
- package/README.md +78 -41
- 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/schema.ts +106 -5
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +7 -3
- package/src/auth-status.ts +4 -0
- package/src/auth.test.ts +5 -112
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/backup.ts +17 -3
- package/src/cli.ts +95 -66
- 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 +21 -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 +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +102 -99
- package/src/routing.ts +33 -47
- 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 +412 -0
- package/src/self-register.ts +247 -0
- package/src/server.ts +47 -23
- 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-name.ts +3 -2
- package/src/vault.test.ts +347 -0
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
package/src/scribe-env.test.ts
CHANGED
|
@@ -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
|
+
});
|