@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Hub-issued JWT validation — vault as resource server.
3
+ *
4
+ * Spins up a fake JWKS endpoint (Bun.serve) with a known RSA keypair, signs
5
+ * JWTs locally with `jose.SignJWT`, and asserts `validateHubJwt` accepts the
6
+ * good ones and rejects every failure mode the spec cares about: bad
7
+ * signature, wrong issuer, expired, missing kid, unknown kid, JWKS
8
+ * unreachable. Audience permissiveness is exercised — both `aud="operator"`
9
+ * and `aud="<client_id>"` shapes pass.
10
+ *
11
+ * Each test resets the JWKS cache so the origin/keys can change between
12
+ * cases. The cache is module-scoped; without `resetJwksCache()` we'd reuse
13
+ * the previous origin's getter and miss test rotations.
14
+ */
15
+ import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
16
+ import { generateKeyPair, exportJWK, SignJWT } from "jose";
17
+ import { resetJwksCache, resetRevocationCache, validateHubJwt, looksLikeJwt } from "./hub-jwt.ts";
18
+
19
+ interface Keypair {
20
+ privateKey: CryptoKey;
21
+ publicJwk: { kty: string; n: string; e: string; kid: string; alg: string; use: string };
22
+ kid: string;
23
+ }
24
+
25
+ async function makeKeypair(kid: string): Promise<Keypair> {
26
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
27
+ const jwk = await exportJWK(publicKey);
28
+ return {
29
+ privateKey,
30
+ publicJwk: {
31
+ kty: "RSA",
32
+ n: jwk.n!,
33
+ e: jwk.e!,
34
+ kid,
35
+ alg: "RS256",
36
+ use: "sig",
37
+ },
38
+ kid,
39
+ };
40
+ }
41
+
42
+ interface JwksFixture {
43
+ origin: string;
44
+ stop: () => void;
45
+ setKeys: (keys: Keypair[]) => void;
46
+ setUnreachable: (down: boolean) => void;
47
+ }
48
+
49
+ function startJwksFixture(): JwksFixture {
50
+ let keys: Keypair[] = [];
51
+ let down = false;
52
+ const server = Bun.serve({
53
+ port: 0,
54
+ fetch(req) {
55
+ const url = new URL(req.url);
56
+ if (down) return new Response("upstream down", { status: 503 });
57
+ if (url.pathname === "/.well-known/jwks.json") {
58
+ return Response.json({ keys: keys.map((k) => k.publicJwk) });
59
+ }
60
+ // scope-guard 0.2+ consults `/.well-known/parachute-revocation.json` on
61
+ // every JWT validation (when the token has a jti). Serve an empty list
62
+ // by default so unrelated tests in this file aren't fail-closed by a
63
+ // 404 on that endpoint. The integration tests (`auth-hub-jwt.test.ts`)
64
+ // own the revoked-jti / fail-closed cases separately.
65
+ if (url.pathname === "/.well-known/parachute-revocation.json") {
66
+ return Response.json({ generated_at: new Date().toISOString(), jtis: [] });
67
+ }
68
+ return new Response("not found", { status: 404 });
69
+ },
70
+ });
71
+ return {
72
+ origin: `http://127.0.0.1:${server.port}`,
73
+ stop: () => server.stop(true),
74
+ setKeys: (next) => { keys = next; },
75
+ setUnreachable: (v) => { down = v; },
76
+ };
77
+ }
78
+
79
+ interface SignOpts {
80
+ iss?: string;
81
+ aud?: string | string[];
82
+ sub?: string;
83
+ scope?: string;
84
+ jti?: string;
85
+ clientId?: string;
86
+ ttlSeconds?: number;
87
+ expiresAtSeconds?: number;
88
+ omitKid?: boolean;
89
+ kid?: string;
90
+ }
91
+
92
+ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
93
+ const iat = Math.floor(Date.now() / 1000);
94
+ const exp = opts.expiresAtSeconds ?? iat + (opts.ttlSeconds ?? 60);
95
+ const builder = new SignJWT({
96
+ scope: opts.scope ?? "vault:read vault:write",
97
+ client_id: opts.clientId ?? "test-client",
98
+ })
99
+ .setProtectedHeader(opts.omitKid ? { alg: "RS256" } : { alg: "RS256", kid: opts.kid ?? kp.kid })
100
+ .setIssuer(opts.iss ?? "http://issuer.invalid")
101
+ .setSubject(opts.sub ?? "user-1")
102
+ .setAudience(opts.aud ?? "operator")
103
+ .setIssuedAt(iat)
104
+ .setExpirationTime(exp)
105
+ .setJti(opts.jti ?? "jti-1");
106
+ return await builder.sign(kp.privateKey);
107
+ }
108
+
109
+ let fixture: JwksFixture;
110
+ let kp: Keypair;
111
+ let prevHubOrigin: string | undefined;
112
+
113
+ beforeAll(async () => {
114
+ fixture = startJwksFixture();
115
+ kp = await makeKeypair("k1");
116
+ fixture.setKeys([kp]);
117
+ });
118
+
119
+ afterAll(() => {
120
+ fixture.stop();
121
+ if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
122
+ else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
123
+ });
124
+
125
+ beforeEach(() => {
126
+ // Each test sets its own origin for clarity.
127
+ prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
128
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
129
+ fixture.setUnreachable(false);
130
+ fixture.setKeys([kp]);
131
+ resetJwksCache();
132
+ // Drop the per-process revocation cache so each test starts cold against
133
+ // the fixture (an empty list by default; tests opt into populated lists).
134
+ resetRevocationCache();
135
+ });
136
+
137
+ describe("looksLikeJwt", () => {
138
+ test("`eyJ` prefix → true", () => {
139
+ expect(looksLikeJwt("eyJhbGciOiJSUzI1NiJ9.x.y")).toBe(true);
140
+ });
141
+
142
+ test("pvt_ token → false", () => {
143
+ expect(looksLikeJwt("pvt_abcdef0123456789")).toBe(false);
144
+ });
145
+
146
+ test("empty / random → false", () => {
147
+ expect(looksLikeJwt("")).toBe(false);
148
+ expect(looksLikeJwt("hello-world")).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("validateHubJwt — happy path", () => {
153
+ test("valid JWT with correct iss → claims surface", async () => {
154
+ const token = await signJwt(kp, { iss: fixture.origin, scope: "vault:work:read vault:work:write" });
155
+ const claims = await validateHubJwt(token);
156
+ expect(claims.sub).toBe("user-1");
157
+ expect(claims.scopes).toEqual(["vault:work:read", "vault:work:write"]);
158
+ expect(claims.aud).toBe("operator");
159
+ expect(claims.jti).toBe("jti-1");
160
+ expect(claims.clientId).toBe("test-client");
161
+ });
162
+
163
+ test("aud=operator accepted when expectedAudience not set", async () => {
164
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "operator" });
165
+ const claims = await validateHubJwt(token);
166
+ expect(claims.aud).toBe("operator");
167
+ });
168
+
169
+ test("aud=<client_id> accepted when expectedAudience not set", async () => {
170
+ const token = await signJwt(kp, {
171
+ iss: fixture.origin,
172
+ aud: "did:plc:randomclientid",
173
+ clientId: "did:plc:randomclientid",
174
+ });
175
+ const claims = await validateHubJwt(token);
176
+ expect(claims.aud).toBe("did:plc:randomclientid");
177
+ });
178
+
179
+ test("empty scope claim → empty scopes array", async () => {
180
+ const token = await signJwt(kp, { iss: fixture.origin, scope: "" });
181
+ const claims = await validateHubJwt(token);
182
+ expect(claims.scopes).toEqual([]);
183
+ });
184
+
185
+ test("audience strict-check passes when expected matches", async () => {
186
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "vault.work" });
187
+ const claims = await validateHubJwt(token, { expectedAudience: "vault.work" });
188
+ expect(claims.aud).toBe("vault.work");
189
+ });
190
+ });
191
+
192
+ describe("validateHubJwt — audience strict-check", () => {
193
+ test("mismatched audience throws with the expected vs got values", async () => {
194
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "vault.personal" });
195
+ await expect(
196
+ validateHubJwt(token, { expectedAudience: "vault.work" }),
197
+ ).rejects.toThrow(/audience mismatch.*vault\.work.*vault\.personal/);
198
+ });
199
+
200
+ test("missing audience claim throws when expected is set", async () => {
201
+ // jose's SignJWT requires .setAudience() — provide an unrelated value to
202
+ // exercise "not the expected one" rather than a literal missing claim.
203
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "operator" });
204
+ await expect(
205
+ validateHubJwt(token, { expectedAudience: "vault.work" }),
206
+ ).rejects.toThrow(/audience mismatch/);
207
+ });
208
+
209
+ test("expectedAudience: null skips the check (cross-vault path)", async () => {
210
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "vault.anything" });
211
+ const claims = await validateHubJwt(token, { expectedAudience: null });
212
+ expect(claims.aud).toBe("vault.anything");
213
+ });
214
+
215
+ test("array aud: passes when expected is one of the entries", async () => {
216
+ const token = await signJwt(kp, {
217
+ iss: fixture.origin,
218
+ aud: ["vault.work", "vault.personal", "operator"],
219
+ });
220
+ const claims = await validateHubJwt(token, { expectedAudience: "vault.personal" });
221
+ expect(claims.aud).toBe("vault.personal");
222
+ });
223
+
224
+ test("array aud: rejects when expected is not in the entries", async () => {
225
+ const token = await signJwt(kp, {
226
+ iss: fixture.origin,
227
+ aud: ["vault.work", "operator"],
228
+ });
229
+ await expect(
230
+ validateHubJwt(token, { expectedAudience: "vault.personal" }),
231
+ ).rejects.toThrow(/audience mismatch.*vault\.personal.*vault\.work.*operator/);
232
+ });
233
+
234
+ test("array aud: surfaces the first entry when no expectation is set", async () => {
235
+ const token = await signJwt(kp, {
236
+ iss: fixture.origin,
237
+ aud: ["vault.first", "vault.second"],
238
+ });
239
+ const claims = await validateHubJwt(token, { expectedAudience: null });
240
+ expect(claims.aud).toBe("vault.first");
241
+ });
242
+ });
243
+
244
+ describe("validateHubJwt — failure modes", () => {
245
+ test("wrong issuer → throws", async () => {
246
+ const token = await signJwt(kp, { iss: "http://attacker.example" });
247
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
248
+ });
249
+
250
+ test("expired token → throws", async () => {
251
+ const past = Math.floor(Date.now() / 1000) - 10;
252
+ const token = await signJwt(kp, { iss: fixture.origin, expiresAtSeconds: past });
253
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
254
+ });
255
+
256
+ test("bad signature (token signed by an unpublished key) → throws", async () => {
257
+ const otherKp = await makeKeypair("k1"); // same kid, different key
258
+ const token = await signJwt(otherKp, { iss: fixture.origin });
259
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
260
+ });
261
+
262
+ test("unknown kid → throws", async () => {
263
+ const token = await signJwt(kp, { iss: fixture.origin, kid: "does-not-exist" });
264
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
265
+ });
266
+
267
+ test("missing kid header → throws when JWKS has multiple keys", async () => {
268
+ // jose's createRemoteJWKSet falls back to the only key when JWKS has just
269
+ // one — so to exercise the "no kid" failure path we need ≥2 keys.
270
+ const kp2 = await makeKeypair("k2");
271
+ fixture.setKeys([kp, kp2]);
272
+ resetJwksCache();
273
+ const token = await signJwt(kp, { iss: fixture.origin, omitKid: true });
274
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
275
+ });
276
+
277
+ test("JWKS endpoint unreachable → throws (fail closed)", async () => {
278
+ fixture.setUnreachable(true);
279
+ const token = await signJwt(kp, { iss: fixture.origin });
280
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
281
+ });
282
+
283
+ test("missing `sub` claim → throws", async () => {
284
+ // SignJWT requires .setSubject; build the token manually-ish: pass empty sub.
285
+ const iat = Math.floor(Date.now() / 1000);
286
+ const token = await new SignJWT({ scope: "vault:read" })
287
+ .setProtectedHeader({ alg: "RS256", kid: kp.kid })
288
+ .setIssuer(fixture.origin)
289
+ .setAudience("operator")
290
+ .setIssuedAt(iat)
291
+ .setExpirationTime(iat + 60)
292
+ .setJti("jti-no-sub")
293
+ .sign(kp.privateKey);
294
+ await expect(validateHubJwt(token)).rejects.toThrow(/missing required `sub`/);
295
+ });
296
+ });
297
+
298
+ describe("validateHubJwt — JWKS rotation", () => {
299
+ test("rotated key (new kid published) verifies after cache reset", async () => {
300
+ const kp2 = await makeKeypair("k2");
301
+ fixture.setKeys([kp, kp2]);
302
+ resetJwksCache();
303
+ const token = await signJwt(kp2, { iss: fixture.origin });
304
+ const claims = await validateHubJwt(token);
305
+ expect(claims.sub).toBe("user-1");
306
+ });
307
+ });
package/src/hub-jwt.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Hub-issued JWT validation. Vault as resource server: trusts tokens that the
3
+ * hub signs against keys we fetch from the hub's `/.well-known/jwks.json`.
4
+ *
5
+ * The trust kernel — JWKS fetch + verify, issuer pin, audience strict-check,
6
+ * RFC 7519 string-or-array `aud` handling — lives in the shared
7
+ * `@openparachute/scope-guard` library so vault, scribe, and paraclaw can't
8
+ * silently drift on the worst place to drift. This file is the vault-side
9
+ * adapter: hub-origin resolution (env-var precedence + loopback fallback),
10
+ * a process-wide guard instance, and re-exports preserving the public
11
+ * surface every existing call site already imports.
12
+ *
13
+ * Vault#169 / hub-as-issuer Phase B2; vault#TBD / scope-guard adoption.
14
+ */
15
+ import {
16
+ createScopeGuard,
17
+ HubJwtError,
18
+ type HubJwtClaims,
19
+ looksLikeJwt,
20
+ type ValidateHubJwtOptions,
21
+ } from "@openparachute/scope-guard";
22
+
23
+ const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
24
+
25
+ /**
26
+ * Resolve the hub origin used to fetch JWKS and validate `iss`. Strips a
27
+ * trailing slash so we get a single canonical form.
28
+ *
29
+ * Order: env var → loopback fallback. We deliberately don't read
30
+ * `~/.parachute/services.json` — the hub is the dispatcher, not a registered
31
+ * service in that file. If a deployment exposes the hub on a non-default
32
+ * origin, the env var is the contract.
33
+ */
34
+ export function getHubOrigin(): string {
35
+ const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
36
+ if (env && env.length > 0) return env;
37
+ return DEFAULT_HUB_LOOPBACK;
38
+ }
39
+
40
+ // Process-wide guard. The resolver form lets tests flip
41
+ // `PARACHUTE_HUB_ORIGIN` between cases — the lib re-resolves on every
42
+ // `validateHubJwt` and `resetJwksCache` call so the env-var change picks up
43
+ // without a server restart. JWKS cache (5min/30s defaults) lives inside the
44
+ // guard, shared across requests.
45
+ const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
46
+
47
+ /**
48
+ * Verify a presented JWT against the hub's JWKS. Throws `HubJwtError` on any
49
+ * failure (bad signature, wrong issuer, expired, missing kid, JWKS
50
+ * unreachable, audience mismatch). On success returns the surfaced claims
51
+ * plus the parsed scope list.
52
+ *
53
+ * Trust model:
54
+ * - `iss` MUST equal the configured hub origin. Without this, anyone could
55
+ * mint a token against any RSA key and pass verification.
56
+ * - `aud` is strict-checked against `opts.expectedAudience` when provided
57
+ * — the resource-server backstop for per-vault binding.
58
+ *
59
+ * Scope-shape policy (e.g. "hub-issued tokens may not carry broad
60
+ * `vault:<verb>` scopes") is enforced one layer up in `authenticateHubJwt`,
61
+ * not here — this function stays focused on JWT-level concerns.
62
+ */
63
+ export async function validateHubJwt(
64
+ token: string,
65
+ opts: ValidateHubJwtOptions = {},
66
+ ): Promise<HubJwtClaims> {
67
+ return guard.validateHubJwt(token, opts);
68
+ }
69
+
70
+ /**
71
+ * Reset the cached JWKS getter. Tests use this to switch origins between
72
+ * cases; production callers shouldn't need it (origin is process-stable).
73
+ */
74
+ export function resetJwksCache(): void {
75
+ guard.resetJwksCache();
76
+ }
77
+
78
+ /**
79
+ * Reset the cached revocation list. Tests use this to start from a clean
80
+ * fail-closed state between cases; production callers shouldn't need it
81
+ * (the cache refreshes itself on TTL expiry).
82
+ */
83
+ export function resetRevocationCache(): void {
84
+ guard.resetRevocationCache();
85
+ }
86
+
87
+ export { HubJwtError, looksLikeJwt };
88
+ export type { HubJwtClaims, ValidateHubJwtOptions };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Integration tests for `parachute-vault init` flag plumbing — the cases
3
+ * where init exits early without touching the daemon, ~/.claude.json, or
4
+ * the vault filesystem. The full happy-path of init isn't run here because
5
+ * it would install a launchd agent on macOS and write into the developer's
6
+ * real ~/Library/LaunchAgents — out of scope for unit tests. The vault-name
7
+ * decision logic is fully covered by `vault-name.test.ts`.
8
+ */
9
+
10
+ import { describe, test, expect } from "bun:test";
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
12
+ import { tmpdir } from "os";
13
+ import { join, resolve } from "path";
14
+
15
+ const CLI = resolve(import.meta.dir, "cli.ts");
16
+
17
+ function runCli(args: string[], env: Record<string, string> = {}): {
18
+ exitCode: number;
19
+ stdout: string;
20
+ stderr: string;
21
+ } {
22
+ const proc = Bun.spawnSync({
23
+ cmd: ["bun", CLI, ...args],
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ env: { ...process.env, ...env },
27
+ });
28
+ return {
29
+ exitCode: proc.exitCode ?? -1,
30
+ stdout: new TextDecoder().decode(proc.stdout),
31
+ stderr: new TextDecoder().decode(proc.stderr),
32
+ };
33
+ }
34
+
35
+ describe("vault init — --vault-name validation", () => {
36
+ test("rejects --vault-name with uppercase + space and exits non-zero", () => {
37
+ const { exitCode, stderr } = runCli(["init", "--vault-name", "My Vault"]);
38
+ expect(exitCode).not.toBe(0);
39
+ expect(stderr).toContain("--vault-name:");
40
+ expect(stderr).toContain("lowercase alphanumeric");
41
+ });
42
+
43
+ test("rejects --vault-name with a slash and exits non-zero", () => {
44
+ const { exitCode, stderr } = runCli(["init", "--vault-name", "team/work"]);
45
+ expect(exitCode).not.toBe(0);
46
+ expect(stderr).toContain("lowercase alphanumeric");
47
+ });
48
+
49
+ test("rejects --vault-name with no value and exits non-zero", () => {
50
+ // `--vault-name` is the last arg → no value follows.
51
+ const { exitCode, stderr } = runCli(["init", "--vault-name"]);
52
+ expect(exitCode).not.toBe(0);
53
+ expect(stderr).toContain("requires a value");
54
+ });
55
+
56
+ test("rejects reserved name 'list' and exits non-zero", () => {
57
+ const { exitCode, stderr } = runCli(["init", "--vault-name", "list"]);
58
+ expect(exitCode).not.toBe(0);
59
+ expect(stderr).toContain("reserved");
60
+ });
61
+ });
62
+
63
+ describe("vault init — --help mentions --vault-name", () => {
64
+ test("usage text documents the new flag", () => {
65
+ const { exitCode, stdout } = runCli(["--help"]);
66
+ expect(exitCode).toBe(0);
67
+ expect(stdout).toContain("--vault-name");
68
+ });
69
+
70
+ test("usage text documents --no-autostart (#113)", () => {
71
+ const { exitCode, stdout } = runCli(["--help"]);
72
+ expect(exitCode).toBe(0);
73
+ expect(stdout).toContain("--no-autostart");
74
+ });
75
+ });
76
+
77
+ /**
78
+ * End-to-end init under an isolated $HOME / $PARACHUTE_HOME so we never touch
79
+ * the developer's real ~/.parachute or ~/Library/LaunchAgents. With
80
+ * --no-autostart, init must:
81
+ * 1. Persist `autostart: false` in config.yaml.
82
+ * 2. NOT write the daemon wrapper (start.sh / server-path).
83
+ *
84
+ * --no-mcp / --no-token avoid the ~/.claude.json side effect; HOME=tmpdir
85
+ * makes the launchd-uninstall-prior-registration call land inside the
86
+ * sandbox even on macOS (where uninstallAgent operates on
87
+ * `homedir()/Library/LaunchAgents/...`).
88
+ */
89
+ describe("vault init — --no-autostart (#113)", () => {
90
+ test("persists autostart=false and skips the daemon wrapper", () => {
91
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
92
+ try {
93
+ const parachuteHome = join(sandbox, ".parachute");
94
+ const { exitCode, stdout } = runCli(
95
+ [
96
+ "init",
97
+ "--no-autostart",
98
+ "--no-mcp",
99
+ "--no-token",
100
+ "--vault-name",
101
+ "autostarttest",
102
+ ],
103
+ { HOME: sandbox, PARACHUTE_HOME: parachuteHome },
104
+ );
105
+
106
+ expect(exitCode).toBe(0);
107
+ expect(stdout).toContain("Autostart disabled");
108
+
109
+ const configPath = join(parachuteHome, "vault", "config.yaml");
110
+ expect(existsSync(configPath)).toBe(true);
111
+ expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
112
+
113
+ // Daemon wrapper / pointer are written by installAgent /
114
+ // installSystemdService — neither should run when autostart is off.
115
+ expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
116
+ expect(existsSync(join(parachuteHome, "vault", "server-path"))).toBe(false);
117
+ } finally {
118
+ rmSync(sandbox, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test("re-running init without a flag preserves persisted autostart=false", () => {
123
+ // We can't drive the --autostart re-run end-to-end here: it calls
124
+ // installAgent() / installSystemdService() which write to launchd /
125
+ // systemd state outside the PARACHUTE_HOME sandbox, breaking test
126
+ // hermeticity. Instead verify the inverse property — that a no-flag
127
+ // re-run honors the persisted opt-out and does NOT fall back to the
128
+ // default-on. This is the actual user-facing risk (forgetting to pass
129
+ // --no-autostart on every re-run shouldn't re-enable the daemon).
130
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
131
+ try {
132
+ const parachuteHome = join(sandbox, ".parachute");
133
+ const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
134
+
135
+ const first = runCli(
136
+ [
137
+ "init",
138
+ "--no-autostart",
139
+ "--no-mcp",
140
+ "--no-token",
141
+ "--vault-name",
142
+ "autostarttest",
143
+ ],
144
+ env,
145
+ );
146
+ expect(first.exitCode).toBe(0);
147
+
148
+ const configPath = join(parachuteHome, "vault", "config.yaml");
149
+ expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
150
+
151
+ // No --autostart / --no-autostart on this run; init should read the
152
+ // persisted false and skip daemon install again.
153
+ const second = runCli(["init", "--no-mcp", "--no-token"], env);
154
+ expect(second.exitCode).toBe(0);
155
+ expect(second.stdout).toContain("Autostart disabled");
156
+ expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
157
+ expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
158
+ } finally {
159
+ rmSync(sandbox, { recursive: true, force: true });
160
+ }
161
+ });
162
+ });
163
+
164
+ /**
165
+ * #210: re-running `parachute-vault init` is the documented recovery path
166
+ * for installs whose `services.json` is stale (#208 left some vaults out of
167
+ * the manifest). The recovery is implicit — init re-registers the full
168
+ * vault list every run via `buildVaultServicePaths` — so this test pins it
169
+ * down with an explicit fixture: corrupt the manifest to drop one vault,
170
+ * re-run init, expect the manifest to grow back.
171
+ */
172
+ describe("vault init — repairs stale services.json (#210)", () => {
173
+ test("re-running init rewrites services.json to include every vault on disk", () => {
174
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-repair-"));
175
+ try {
176
+ const parachuteHome = join(sandbox, ".parachute");
177
+ const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
178
+
179
+ // Use `create` to bootstrap two vaults into a real, healthy state —
180
+ // this also writes the initial services.json with both vaults so we
181
+ // have a known-good baseline to corrupt.
182
+ expect(runCli(["create", "alpha", "--json"], env).exitCode).toBe(0);
183
+ expect(runCli(["create", "beta", "--json"], env).exitCode).toBe(0);
184
+
185
+ const servicesPath = join(parachuteHome, "services.json");
186
+ const baseline = JSON.parse(readFileSync(servicesPath, "utf-8"));
187
+ const baselineEntry = baseline.services.find(
188
+ (s: { name: string }) => s.name === "parachute-vault",
189
+ );
190
+ expect(baselineEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
191
+
192
+ // Corrupt: drop beta from the manifest, mimicking the #208 state where
193
+ // an older `create` ran without the upsert.
194
+ baselineEntry.paths = ["/vault/alpha"];
195
+ writeFileSync(servicesPath, JSON.stringify(baseline, null, 2));
196
+
197
+ // Re-run init with no flags that would change vault topology. The
198
+ // sandbox env keeps launchd / ~/.claude.json side effects out of the
199
+ // dev environment.
200
+ const repair = runCli(
201
+ ["init", "--no-autostart", "--no-mcp", "--no-token"],
202
+ env,
203
+ );
204
+ expect(repair.exitCode).toBe(0);
205
+
206
+ const repaired = JSON.parse(readFileSync(servicesPath, "utf-8"));
207
+ const repairedEntry = repaired.services.find(
208
+ (s: { name: string }) => s.name === "parachute-vault",
209
+ );
210
+ // alpha is still default (created first), so it leads. beta is back.
211
+ expect(repairedEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
212
+ } finally {
213
+ rmSync(sandbox, { recursive: true, force: true });
214
+ }
215
+ });
216
+ });