@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.
- 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/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 +57 -48
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- 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
|
@@ -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
|
+
}
|
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,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
|
+
});
|