@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2

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 (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. package/web/ui/dist/assets/index-Degr8snN.js +0 -60
@@ -4,12 +4,25 @@
4
4
  * Builds on the manual export primitives from vault#346 (`parachute-vault
5
5
  * export --watch --git-commit`). This module owns the *persistent* form:
6
6
  *
7
- * - Schema for the `mirror:` block in `~/.parachute/vault/config.yaml`.
8
- * - Parse + serialize that block alongside the existing global config.
7
+ * - Schema for the per-vault mirror config block.
8
+ * - Parse + serialize that block.
9
+ * - Per-vault read/write of the config to `data/<vault>/mirror-config.yaml`
10
+ * (vault#400 — see below).
9
11
  * - Resolve the on-disk mirror path (internal vs external).
10
12
  * - Validate the operator-supplied shape (location enum, external_path
11
13
  * existence + git-repo-ness).
12
14
  *
15
+ * **Per-vault config (vault#400).** Before vault#400, mirror config lived in a
16
+ * SINGLE server-wide `mirror:` block in `~/.parachute/vault/config.yaml`.
17
+ * That made every vault's mirror page show the same config + the same git
18
+ * remote, and configuring vault B clobbered vault A. vault#399 had already
19
+ * moved credential STORAGE per-vault; vault#400 completes the job by moving
20
+ * the CONFIG block to `data/<vault>/mirror-config.yaml` — alongside the
21
+ * per-vault credentials file + the SQLite DB. The legacy server-wide
22
+ * `mirror:` block is migrated to its owning vault on boot (default/first
23
+ * vault, matching how the single server-wide mirror was bound); other
24
+ * vaults start with no mirror config. See `migrateLegacyServerWideConfig`.
25
+ *
13
26
  * The lifecycle wiring (boot-time bootstrap, watch loop start/stop/reload)
14
27
  * lives in `./mirror-manager.ts`; the HTTP surface lives in
15
28
  * `./mirror-routes.ts`. This file is intentionally I/O-light: pure parsing,
@@ -19,11 +32,20 @@
19
32
  * `parachute.computer/design/2026-05-20-vault-as-git-projection.md`.
20
33
  */
21
34
 
22
- import { existsSync, statSync } from "fs";
23
- import { join } from "path";
35
+ import {
36
+ existsSync,
37
+ mkdirSync,
38
+ readFileSync,
39
+ renameSync,
40
+ statSync,
41
+ writeFileSync,
42
+ } from "fs";
43
+ import { dirname, join } from "path";
44
+ import { homedir } from "os";
24
45
 
25
46
  import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
26
47
  import { readCredentials, type MirrorCredentials } from "./mirror-credentials.ts";
48
+ import { ensureGitAvailable } from "./git-preflight.ts";
27
49
 
28
50
  // ---------------------------------------------------------------------------
29
51
  // Types
@@ -65,9 +87,9 @@ export const MIN_SAFETY_NET_SECONDS = 60;
65
87
  export const MAX_SAFETY_NET_SECONDS = 86400;
66
88
 
67
89
  /**
68
- * The persistent mirror configuration block. Lives under the `mirror:` key
69
- * in the global config.yaml (one mirror per vault server today — multi-vault
70
- * mirroring is a future ripple, see open question 2 in the design doc).
90
+ * The persistent mirror configuration block. Per-vault since vault#400: each
91
+ * vault stores its own block in `data/<vault>/mirror-config.yaml` (real
92
+ * multi-vault mirroring every vault can mirror to its own git remote).
71
93
  *
72
94
  * Field semantics:
73
95
  * - `enabled` — master switch. When false (the default for upgrading
@@ -312,6 +334,225 @@ export function serializeMirrorConfig(config: MirrorConfig): string[] {
312
334
  return lines;
313
335
  }
314
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // Per-vault config storage (vault#400)
339
+ //
340
+ // Each vault's mirror config lives in `data/<vault>/mirror-config.yaml`,
341
+ // alongside its SQLite DB (`vault.db`), config (`vault.yaml`), and per-vault
342
+ // credentials file (`.mirror-credentials.yaml`, vault#399). The file holds
343
+ // the same `mirror:`-prefixed block `serializeMirrorConfig` already emits, so
344
+ // `parseMirrorConfig`/`serializeMirrorConfig` are reused verbatim.
345
+ //
346
+ // Why a separate file from credentials (not folded in): config is
347
+ // non-secret + hand-editable; credentials are 0o600 + token-bearing. Keeping
348
+ // them separate avoids accidentally widening the credentials file's perms,
349
+ // and keeps the credentials migration (vault#399) and config migration
350
+ // (vault#400) cleanly independent.
351
+ //
352
+ // Path resolution mirrors `mirror-credentials.ts` rather than importing
353
+ // `config.ts:vaultDir()` — config.ts imports this module, so importing it
354
+ // back would close a cycle. We re-derive from `PARACHUTE_HOME` (the canonical
355
+ // override the rest of vault honors) instead.
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
359
+ function vaultHomeRoot(): string {
360
+ const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
361
+ return join(root, "vault");
362
+ }
363
+
364
+ /**
365
+ * Path to a vault's per-vault mirror-config file (vault#400):
366
+ * `<configDir>/vault/data/<vaultName>/mirror-config.yaml`.
367
+ */
368
+ export function mirrorConfigPath(vaultName: string): string {
369
+ return join(vaultHomeRoot(), "data", vaultName, "mirror-config.yaml");
370
+ }
371
+
372
+ /**
373
+ * Read a vault's mirror config from its per-vault file. Returns `undefined`
374
+ * when the file is absent (operator has never configured this vault's
375
+ * mirror) — distinct from "configured with enabled:false" so callers can
376
+ * tell "never touched" apart from "explicitly disabled" (same distinction
377
+ * `parseMirrorConfig` preserves). Callers that just want a usable config
378
+ * coalesce with `defaultMirrorConfig()`.
379
+ */
380
+ export function readMirrorConfigForVault(vaultName: string): MirrorConfig | undefined {
381
+ const path = mirrorConfigPath(vaultName);
382
+ if (!existsSync(path)) return undefined;
383
+ try {
384
+ const raw = readFileSync(path, "utf8");
385
+ return parseMirrorConfig(raw);
386
+ } catch {
387
+ // Unreadable / malformed → treat as "no config" rather than crashing
388
+ // the boot path or a route. The operator can re-PUT to repair it.
389
+ return undefined;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Persist a vault's mirror config to its per-vault file, atomically
395
+ * (write-temp → rename). Creates the vault data dir if missing (fresh
396
+ * installs / tests). The file is NOT secret (no tokens — those live in
397
+ * `.mirror-credentials.yaml`), so default perms are fine.
398
+ */
399
+ export function writeMirrorConfigForVault(
400
+ vaultName: string,
401
+ config: MirrorConfig,
402
+ ): void {
403
+ const path = mirrorConfigPath(vaultName);
404
+ const dir = dirname(path);
405
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
406
+ const body = serializeMirrorConfig(config).join("\n") + "\n";
407
+ const tmp = `${path}.tmp`;
408
+ writeFileSync(tmp, body);
409
+ renameSync(tmp, path);
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Migration — legacy server-wide `mirror:` config → per-vault (vault#400)
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /**
417
+ * One-time migration of the legacy server-wide `mirror:` config block to the
418
+ * per-vault layout (vault#400). Symmetric counterpart to vault#399's
419
+ * `migrateLegacyServerWideCredentials`.
420
+ *
421
+ * The bug: pre-vault#400, mirror config lived in a single `mirror:` block in
422
+ * `<configDir>/vault/config.yaml`, shared across ALL vaults. Every vault's
423
+ * mirror page rendered that one config (+ the owning vault's git remote);
424
+ * configuring vault B clobbered vault A.
425
+ *
426
+ * Attribution: attribute the legacy block to the mirror-owning vault
427
+ * (`resolveMirrorVaultName` = default_vault → first listed) — the same vault
428
+ * the single server-wide mirror was actually bound to. Other vaults start
429
+ * with no mirror config (they read `undefined` → default disabled).
430
+ *
431
+ * Safety:
432
+ * - No-op when no legacy block exists (fresh installs, already-migrated).
433
+ * - No-op when the target vault already has a per-vault config file (don't
434
+ * clobber config the operator set post-migration).
435
+ * - The legacy block is preserved (commented out in place) rather than
436
+ * silently dropped, so nothing is lost if attribution was wrong.
437
+ * - Logs the attribution decision clearly.
438
+ *
439
+ * This function is intentionally I/O-light on the config.yaml side: it takes
440
+ * the raw config.yaml text + a rewriter callback so it doesn't import
441
+ * `config.ts` (which imports this module). server.ts supplies the read/write.
442
+ *
443
+ * @param legacyConfig the `mirror:` block parsed from config.yaml, or
444
+ * undefined when there is none. server.ts passes `readGlobalConfig().mirror`.
445
+ * @param targetVaultName the vault to attribute the legacy config to
446
+ * (caller passes `resolveMirrorVaultName()`).
447
+ * @param commentOutLegacyBlock callback that rewrites config.yaml to comment
448
+ * out the `mirror:` block (so it isn't re-migrated + the operator can see
449
+ * the old values). Best-effort; failure is non-fatal.
450
+ * @returns a struct describing what happened, for logging + tests.
451
+ */
452
+ export function migrateLegacyServerWideConfig(
453
+ legacyConfig: MirrorConfig | undefined,
454
+ targetVaultName: string | null,
455
+ commentOutLegacyBlock: () => void,
456
+ ):
457
+ | { migrated: false; reason: "no_legacy_block" | "no_target_vault" | "target_already_configured" }
458
+ | { migrated: true; targetVaultName: string } {
459
+ if (!legacyConfig) {
460
+ return { migrated: false, reason: "no_legacy_block" };
461
+ }
462
+ if (!targetVaultName) {
463
+ // Legacy block present but no vault to attribute it to. Leave it in
464
+ // place — a future boot (once a vault exists) migrates it.
465
+ return { migrated: false, reason: "no_target_vault" };
466
+ }
467
+ const targetPath = mirrorConfigPath(targetVaultName);
468
+ if (existsSync(targetPath)) {
469
+ // Target vault already has per-vault config. Don't clobber. Still
470
+ // comment out the legacy block so we don't re-evaluate it every boot.
471
+ try {
472
+ commentOutLegacyBlock();
473
+ } catch {
474
+ // Non-fatal — worst case we re-check next boot and short-circuit here.
475
+ }
476
+ return { migrated: false, reason: "target_already_configured" };
477
+ }
478
+
479
+ writeMirrorConfigForVault(targetVaultName, legacyConfig);
480
+ try {
481
+ commentOutLegacyBlock();
482
+ } catch {
483
+ // Non-fatal — the config is now in the per-vault file; the legacy block
484
+ // staying live would just re-migrate to the same place idempotently
485
+ // (the target_already_configured branch short-circuits next boot).
486
+ }
487
+
488
+ console.log(
489
+ `[mirror] migrated legacy server-wide mirror config → vault "${targetVaultName}" ` +
490
+ `(per-vault, vault#400). Other vaults start with no mirror config (configure each ` +
491
+ `separately). Legacy config.yaml \`mirror:\` block commented out (preserved for reference).`,
492
+ );
493
+ return { migrated: true, targetVaultName };
494
+ }
495
+
496
+ /**
497
+ * Pure string transform: comment out the server-wide `mirror:` block in a
498
+ * config.yaml string (vault#400 migration). Prefixes each line of the block
499
+ * with `# ` so the operator can still see the migrated values + nothing is
500
+ * silently dropped, and a subsequent boot's `parseMirrorConfig` no longer
501
+ * sees a LIVE block (so no re-migration — `parseMirrorConfig`'s anchor regex
502
+ * `^mirror:\s*$` doesn't match `# mirror:`).
503
+ *
504
+ * Block boundaries match the parser's stop rule exactly: the block starts at
505
+ * a 0-indent `mirror:` line and runs until the next 0-indent non-blank line
506
+ * (the next top-level key). Other top-level keys (port, default_vault, …) are
507
+ * left byte-for-byte intact; blank lines are preserved verbatim (not
508
+ * commented). Idempotent: an already-commented config has no live `mirror:`
509
+ * line, so it passes through unchanged.
510
+ *
511
+ * Extracted from server.ts (vault#408 review N3) so the YAML rewriting — which
512
+ * runs against the operator's real config.yaml — is directly unit-tested.
513
+ */
514
+ export function commentOutMirrorBlock(yaml: string): string {
515
+ const lines = yaml.split("\n");
516
+ const out: string[] = [];
517
+ let inBlock = false;
518
+ for (const line of lines) {
519
+ if (!inBlock && /^mirror:\s*$/.test(line)) {
520
+ inBlock = true;
521
+ out.push(`# [vault#400] migrated to per-vault data/<vault>/mirror-config.yaml`);
522
+ out.push(`# ${line}`);
523
+ continue;
524
+ }
525
+ if (inBlock) {
526
+ // The block runs until the next top-level (0-indent, non-blank) key.
527
+ if (/^\S/.test(line) && line.trim().length > 0) {
528
+ inBlock = false;
529
+ out.push(line);
530
+ } else if (line.trim().length === 0) {
531
+ // Preserve blank lines verbatim (don't comment them).
532
+ out.push(line);
533
+ } else {
534
+ out.push(`# ${line}`);
535
+ }
536
+ continue;
537
+ }
538
+ out.push(line);
539
+ }
540
+ return out.join("\n");
541
+ }
542
+
543
+ /**
544
+ * File-I/O wrapper around `commentOutMirrorBlock`: read config.yaml at
545
+ * `configPath`, comment out its `mirror:` block, write it back. No-op when
546
+ * the file is absent. server.ts passes this (bound to GLOBAL_CONFIG_PATH) as
547
+ * the migration's `commentOutLegacyBlock` callback. Best-effort — callers
548
+ * treat a throw as non-fatal.
549
+ */
550
+ export function commentOutLegacyMirrorBlockFile(configPath: string): void {
551
+ if (!existsSync(configPath)) return;
552
+ const yaml = readFileSync(configPath, "utf8");
553
+ writeFileSync(configPath, commentOutMirrorBlock(yaml));
554
+ }
555
+
315
556
  // ---------------------------------------------------------------------------
316
557
  // Path resolution
317
558
  // ---------------------------------------------------------------------------
@@ -387,7 +628,17 @@ export type ShapeValidation = ShapeValidationOk | ShapeValidationError;
387
628
  */
388
629
  export function validateMirrorConfigShape(
389
630
  input: unknown,
390
- opts: { readCredentials?: () => MirrorCredentials | null } = {},
631
+ opts: {
632
+ /**
633
+ * Vault whose credentials gate `auto_push + internal`. Required for the
634
+ * production credential read (per-vault since vault#399). Omitting it
635
+ * AND `readCredentials` means the auto_push/internal credential check
636
+ * treats credentials as absent (fail-closed) — fine for callers that
637
+ * never set that combination.
638
+ */
639
+ vaultName?: string;
640
+ readCredentials?: () => MirrorCredentials | null;
641
+ } = {},
391
642
  ): ShapeValidation {
392
643
  if (input === null || typeof input !== "object") {
393
644
  return {
@@ -583,7 +834,12 @@ export function validateMirrorConfigShape(
583
834
  // dir, so vault IS the only thing that can wire a remote, which is
584
835
  // why the gate below requires vault-stored credentials specifically.
585
836
  if (out.enabled && out.auto_push && out.location === "internal") {
586
- const readCreds = opts.readCredentials ?? readCredentials;
837
+ // Per-vault credentials (vault#399): bind the read to opts.vaultName.
838
+ // When neither an injected reader nor a vaultName is supplied, treat
839
+ // credentials as absent (fail-closed) rather than reading a wrong file.
840
+ const readCreds =
841
+ opts.readCredentials ??
842
+ (opts.vaultName ? () => readCredentials(opts.vaultName!) : () => null);
587
843
  let creds: MirrorCredentials | null = null;
588
844
  try {
589
845
  creds = readCreds();
@@ -633,7 +889,17 @@ export type PathValidation = PathValidationOk | PathValidationError;
633
889
  */
634
890
  export async function validateExternalPath(
635
891
  externalPath: string,
892
+ // Test seam for the git-presence preflight (default `Bun.which`). Inject a
893
+ // fn returning `null` to exercise the git-not-installed path.
894
+ which?: (cmd: string) => string | null,
636
895
  ): Promise<PathValidation> {
896
+ // Preflight: the git-repo check below shells `git`. On a git-less server,
897
+ // throw the friendly, actionable GitNotInstalledError (which handleMirrorPut
898
+ // maps to a 503 `git_not_installed`, consistent with the import route)
899
+ // instead of letting `isGitRepo`'s `Bun.spawn` throw a raw
900
+ // "Executable not found in $PATH: \"git\"".
901
+ ensureGitAvailable(which);
902
+
637
903
  if (!existsSync(externalPath)) {
638
904
  return {
639
905
  ok: false,
@@ -25,6 +25,8 @@ import {
25
25
  deleteCredentials,
26
26
  emptyCredentials,
27
27
  githubAuthedRemoteUrl,
28
+ legacyServerWideCredentialsPath,
29
+ migrateLegacyServerWideCredentials,
28
30
  mirrorCredentialsPath,
29
31
  parseCredentials,
30
32
  previewToken,
@@ -194,7 +196,7 @@ describe("serialize + parse round-trip", () => {
194
196
  describe("readCredentials / writeCredentials", () => {
195
197
  test("returns null when no file exists", () => {
196
198
  withSandbox(() => {
197
- expect(readCredentials()).toBeNull();
199
+ expect(readCredentials("default")).toBeNull();
198
200
  });
199
201
  });
200
202
 
@@ -211,8 +213,8 @@ describe("readCredentials / writeCredentials", () => {
211
213
  },
212
214
  pat: null,
213
215
  };
214
- writeCredentials(creds);
215
- const read = readCredentials();
216
+ writeCredentials("default", creds);
217
+ const read = readCredentials("default");
216
218
  expect(read).toEqual(creds);
217
219
  });
218
220
  });
@@ -228,8 +230,8 @@ describe("readCredentials / writeCredentials", () => {
228
230
  label: "test",
229
231
  },
230
232
  };
231
- writeCredentials(creds);
232
- const p = mirrorCredentialsPath();
233
+ writeCredentials("default", creds);
234
+ const p = mirrorCredentialsPath("default");
233
235
  const stat = fs.statSync(p);
234
236
  const perms = stat.mode & 0o777;
235
237
  expect(perms).toBe(0o600);
@@ -241,15 +243,52 @@ describe("readCredentials / writeCredentials", () => {
241
243
  process.env.PARACHUTE_HOME = home;
242
244
  process.env.HOME = home;
243
245
  try {
244
- // Note: don't pre-create the vault subdir.
246
+ // Note: don't pre-create the vault data subdir.
245
247
  const creds: MirrorCredentials = { ...emptyCredentials() };
246
- writeCredentials(creds);
247
- expect(fs.existsSync(path.join(home, "vault", ".mirror-credentials.yaml"))).toBe(true);
248
+ writeCredentials("default", creds);
249
+ expect(
250
+ fs.existsSync(
251
+ path.join(home, "vault", "data", "default", ".mirror-credentials.yaml"),
252
+ ),
253
+ ).toBe(true);
248
254
  } finally {
249
255
  fs.rmSync(home, { recursive: true, force: true });
250
256
  }
251
257
  });
252
258
 
259
+ test("two vaults' credentials are isolated — no bleed (vault#399)", () => {
260
+ withSandbox(() => {
261
+ const aCreds: MirrorCredentials = {
262
+ ...emptyCredentials(),
263
+ active_method: "pat",
264
+ pat: {
265
+ token: "ghp_vaultA1234567890abc",
266
+ remote_url: "https://x-access-token:ghp_vaultA1234567890abc@github.com/aaron/vault-a.git",
267
+ label: "vault A",
268
+ },
269
+ };
270
+ const bCreds: MirrorCredentials = {
271
+ ...emptyCredentials(),
272
+ active_method: "pat",
273
+ pat: {
274
+ token: "ghp_vaultB9876543210xyz",
275
+ remote_url: "https://x-access-token:ghp_vaultB9876543210xyz@github.com/aaron/vault-b.git",
276
+ label: "vault B",
277
+ },
278
+ };
279
+ writeCredentials("alpha", aCreds);
280
+ writeCredentials("beta", bCreds);
281
+ // Each vault reads back ITS OWN remote + token — never the other's.
282
+ expect(readCredentials("alpha")).toEqual(aCreds);
283
+ expect(readCredentials("beta")).toEqual(bCreds);
284
+ expect(readCredentials("alpha")?.pat?.remote_url).toContain("vault-a.git");
285
+ expect(readCredentials("beta")?.pat?.remote_url).toContain("vault-b.git");
286
+ expect(readCredentials("alpha")?.pat?.token).not.toBe(readCredentials("beta")?.pat?.token);
287
+ // Files live in separate per-vault dirs.
288
+ expect(mirrorCredentialsPath("alpha")).not.toBe(mirrorCredentialsPath("beta"));
289
+ });
290
+ });
291
+
253
292
  test("write is atomic — partial file not left on failure path", () => {
254
293
  withSandbox(() => {
255
294
  const creds: MirrorCredentials = {
@@ -263,17 +302,17 @@ describe("readCredentials / writeCredentials", () => {
263
302
  },
264
303
  pat: null,
265
304
  };
266
- writeCredentials(creds);
305
+ writeCredentials("default", creds);
267
306
  // Subsequent write replaces atomically (.tmp rename onto final).
268
307
  const updated: MirrorCredentials = {
269
308
  ...creds,
270
309
  github_oauth: { ...creds.github_oauth!, access_token: "gho_updated123456789" },
271
310
  };
272
- writeCredentials(updated);
273
- const read = readCredentials();
311
+ writeCredentials("default", updated);
312
+ const read = readCredentials("default");
274
313
  expect(read?.github_oauth?.access_token).toBe("gho_updated123456789");
275
314
  // No leftover .tmp file.
276
- expect(fs.existsSync(`${mirrorCredentialsPath()}.tmp`)).toBe(false);
315
+ expect(fs.existsSync(`${mirrorCredentialsPath("default")}.tmp`)).toBe(false);
277
316
  });
278
317
  });
279
318
  });
@@ -281,20 +320,132 @@ describe("readCredentials / writeCredentials", () => {
281
320
  describe("deleteCredentials", () => {
282
321
  test("idempotent — missing file is a no-op (doesn't throw)", () => {
283
322
  withSandbox(() => {
284
- expect(() => deleteCredentials()).not.toThrow();
323
+ expect(() => deleteCredentials("default")).not.toThrow();
285
324
  });
286
325
  });
287
326
 
288
327
  test("removes the file when present", () => {
289
328
  withSandbox(() => {
290
- writeCredentials({ ...emptyCredentials(), active_method: null });
291
- expect(fs.existsSync(mirrorCredentialsPath())).toBe(true);
292
- deleteCredentials();
293
- expect(fs.existsSync(mirrorCredentialsPath())).toBe(false);
329
+ writeCredentials("default", { ...emptyCredentials(), active_method: null });
330
+ expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(true);
331
+ deleteCredentials("default");
332
+ expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(false);
294
333
  });
295
334
  });
296
335
  });
297
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // Migration — legacy server-wide → per-vault (vault#399)
339
+ // ---------------------------------------------------------------------------
340
+
341
+ describe("migrateLegacyServerWideCredentials", () => {
342
+ function writeLegacyFile(_home: string, creds: MirrorCredentials): string {
343
+ const legacyPath = legacyServerWideCredentialsPath();
344
+ fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
345
+ fs.writeFileSync(legacyPath, serializeCredentials(creds), { mode: 0o600 });
346
+ return legacyPath;
347
+ }
348
+
349
+ const sampleCreds: MirrorCredentials = {
350
+ ...emptyCredentials(),
351
+ active_method: "pat",
352
+ pat: {
353
+ token: "ghp_legacy1234567890abc",
354
+ remote_url: "https://x-access-token:ghp_legacy1234567890abc@github.com/aaron/first-vault.git",
355
+ label: "legacy",
356
+ },
357
+ };
358
+
359
+ test("no legacy file → no-op", () => {
360
+ withSandbox(() => {
361
+ const r = migrateLegacyServerWideCredentials("default");
362
+ expect(r.migrated).toBe(false);
363
+ expect((r as { reason: string }).reason).toBe("no_legacy_file");
364
+ });
365
+ });
366
+
367
+ test("legacy file → attributed to the FIRST vault, others stay empty", () => {
368
+ const home = tmp("mirror-migrate-");
369
+ process.env.PARACHUTE_HOME = home;
370
+ process.env.HOME = home;
371
+ try {
372
+ const legacyPath = writeLegacyFile(home, sampleCreds);
373
+ const r = migrateLegacyServerWideCredentials("first");
374
+ expect(r.migrated).toBe(true);
375
+ // First vault now owns the legacy creds.
376
+ expect(readCredentials("first")).toEqual(sampleCreds);
377
+ // A second vault that never had its own file starts EMPTY — the bug
378
+ // would have made it read the same remote. It must not.
379
+ expect(readCredentials("second")).toBeNull();
380
+ // Legacy file preserved as .bak — nothing silently lost.
381
+ expect(fs.existsSync(legacyPath)).toBe(false);
382
+ expect(fs.existsSync(`${legacyPath}.bak`)).toBe(true);
383
+ } finally {
384
+ fs.rmSync(home, { recursive: true, force: true });
385
+ }
386
+ });
387
+
388
+ test("idempotent — second run is a no-op once target has creds", () => {
389
+ const home = tmp("mirror-migrate-idem-");
390
+ process.env.PARACHUTE_HOME = home;
391
+ process.env.HOME = home;
392
+ try {
393
+ writeLegacyFile(home, sampleCreds);
394
+ expect(migrateLegacyServerWideCredentials("first").migrated).toBe(true);
395
+ // Run again: no legacy file remains, so no-op.
396
+ const r2 = migrateLegacyServerWideCredentials("first");
397
+ expect(r2.migrated).toBe(false);
398
+ // First vault's creds unchanged.
399
+ expect(readCredentials("first")).toEqual(sampleCreds);
400
+ } finally {
401
+ fs.rmSync(home, { recursive: true, force: true });
402
+ }
403
+ });
404
+
405
+ test("does not clobber an existing per-vault file", () => {
406
+ const home = tmp("mirror-migrate-noclobber-");
407
+ process.env.PARACHUTE_HOME = home;
408
+ process.env.HOME = home;
409
+ try {
410
+ writeLegacyFile(home, sampleCreds);
411
+ // Target vault already has its own (different) creds.
412
+ const existing: MirrorCredentials = {
413
+ ...emptyCredentials(),
414
+ active_method: "pat",
415
+ pat: {
416
+ token: "ghp_already1234567890ab",
417
+ remote_url: "https://x-access-token:ghp_already1234567890ab@github.com/aaron/already.git",
418
+ label: "already",
419
+ },
420
+ };
421
+ writeCredentials("first", existing);
422
+ const r = migrateLegacyServerWideCredentials("first");
423
+ expect(r.migrated).toBe(false);
424
+ expect((r as { reason: string }).reason).toBe("target_already_has_creds");
425
+ // Existing per-vault creds preserved, NOT overwritten by legacy.
426
+ expect(readCredentials("first")).toEqual(existing);
427
+ } finally {
428
+ fs.rmSync(home, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ test("no target vault → leaves legacy file in place for a later boot", () => {
433
+ const home = tmp("mirror-migrate-notarget-");
434
+ process.env.PARACHUTE_HOME = home;
435
+ process.env.HOME = home;
436
+ try {
437
+ const legacyPath = writeLegacyFile(home, sampleCreds);
438
+ const r = migrateLegacyServerWideCredentials(null);
439
+ expect(r.migrated).toBe(false);
440
+ expect((r as { reason: string }).reason).toBe("no_target_vault");
441
+ // Legacy file untouched — a future boot (after a vault exists) migrates.
442
+ expect(fs.existsSync(legacyPath)).toBe(true);
443
+ } finally {
444
+ fs.rmSync(home, { recursive: true, force: true });
445
+ }
446
+ });
447
+ });
448
+
298
449
  // ---------------------------------------------------------------------------
299
450
  // Redaction
300
451
  // ---------------------------------------------------------------------------