@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
@@ -215,6 +215,21 @@ describe("bootstrapInternalMirror", () => {
215
215
  expect(isGitRepoSync(dir)).toBe(true);
216
216
  }
217
217
  });
218
+
219
+ test("git missing → ok:false with actionable error (status surfaces it, no raw crash)", async () => {
220
+ // vault#415 — a git-less server can't bootstrap a mirror. The error
221
+ // channel carries the friendly message that MirrorManager.start threads
222
+ // into status.last_error, instead of a raw "Executable not found" crash.
223
+ dir = path.join(tmp("mirror-boot-nogit-"), "mirror");
224
+ const r = await bootstrapInternalMirror(dir, () => null);
225
+ expect(r.ok).toBe(false);
226
+ if (!r.ok) {
227
+ expect(r.error).toContain("git is required");
228
+ expect(r.error).toContain("dnf install git");
229
+ }
230
+ // Failed fast at the preflight — the dir was never created.
231
+ expect(fs.existsSync(dir)).toBe(false);
232
+ });
218
233
  });
219
234
 
220
235
  // ---------------------------------------------------------------------------
@@ -378,6 +393,42 @@ describe("MirrorManager.start — lifecycle matrix", () => {
378
393
  fs.rmSync(external, { recursive: true, force: true });
379
394
  });
380
395
 
396
+ test("external + git not installed → enabled:false with friendly error, never spawns/exports", async () => {
397
+ // vault#415 nit — the external branch's isGitRepo() check shells `git`
398
+ // with no preflight; on a git-less server it would throw a raw
399
+ // "Executable not found in $PATH: \"git\"" and crash start(). The
400
+ // top-of-start() preflight (forced via the `which` seam) lands the
401
+ // friendly, actionable message in last_error for the external location.
402
+ home = tmp("mgr-ext-nogit-installed-");
403
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
404
+ // Use a real, valid external git repo so the ONLY thing that can fail is
405
+ // the git-presence preflight — proving the preflight (not the path/repo
406
+ // checks) produced the disabled state.
407
+ const external = tmp("mgr-ext-nogit-target-");
408
+ initRepo(external);
409
+ seedCommit(external);
410
+ const deps = makeFakeDeps({
411
+ parachuteHome: home,
412
+ initialConfig: {
413
+ ...defaultMirrorConfig(),
414
+ enabled: true,
415
+ location: "external",
416
+ external_path: external,
417
+ sync_mode: "events",
418
+ },
419
+ });
420
+ const mgr = new MirrorManager(deps);
421
+ // Force the preflight to see no git on PATH.
422
+ const status = await mgr.start(() => null);
423
+ expect(status.enabled).toBe(false);
424
+ expect(status.last_error).toContain("git is required");
425
+ expect(status.last_error).toContain("dnf install git");
426
+ // Never reached export — the preflight bailed before any git work.
427
+ expect(deps.exportCalls).toHaveLength(0);
428
+ await mgr.stop();
429
+ fs.rmSync(external, { recursive: true, force: true });
430
+ });
431
+
381
432
  test("internal bootstrap refuses to clobber pre-existing non-git data", async () => {
382
433
  home = tmp("mgr-int-clobber-");
383
434
  const mirrorPath = path.join(home, "vault", "data", "default", "mirror");
@@ -26,17 +26,16 @@
26
26
  * debounce timer, cancel safety-net poll, let an in-flight export
27
27
  * finish via the soft settle window.
28
28
  *
29
- * Singleton per-process: one `MirrorManager` instance backs the vault
30
- * server's lifecycle. Tests instantiate `MirrorManager` directly with
31
- * fake deps to exercise lifecycle transitions without spawning a full
32
- * vault server.
29
+ * One `MirrorManager` instance PER VAULT (vault#400). The per-vault
30
+ * registry (`mirror-registry.ts`) holds them keyed by vault name; boot
31
+ * stands up a manager for each vault whose config is enabled, and the
32
+ * routes resolve a manager by the URL's vault name. Tests instantiate
33
+ * `MirrorManager` directly with fake deps to exercise lifecycle
34
+ * transitions without spawning a full vault server.
33
35
  *
34
- * Phase A1 deliberately surfaces ONE mirror per vault server (matching
35
- * the design doc's single-mirror-per-vault model). Multi-vault server
36
- * deployments today already pin one vault per server via
37
- * `PARACHUTE_VAULT_NAME` / `default_vault`; the mirror config follows
38
- * suit. Multi-vault mirror routing is a future ripple (open question 2
39
- * in the design doc).
36
+ * Each manager is bound to its vault via `deps.vaultName` — its config
37
+ * read/write, credential reads, and resolved mirror path are all
38
+ * scoped to that one vault, so vault A's mirror never touches vault B's.
40
39
  *
41
40
  * ## Race-condition contract
42
41
  *
@@ -76,6 +75,7 @@ import {
76
75
  applyToGitRemote,
77
76
  readCredentials,
78
77
  } from "./mirror-credentials.ts";
78
+ import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
79
79
  import type { HookRegistry } from "../core/src/hooks.ts";
80
80
 
81
81
  /**
@@ -231,7 +231,20 @@ export type BootstrapResult = BootstrapResultOk | BootstrapResultError;
231
231
  */
232
232
  export async function bootstrapInternalMirror(
233
233
  path: string,
234
+ // Test seam for the git-presence preflight (default `Bun.which`). Inject a
235
+ // fn returning `null` to exercise the git-not-installed bootstrap path.
236
+ which?: (cmd: string) => string | null,
234
237
  ): Promise<BootstrapResult> {
238
+ // Preflight: a git-less server can't bootstrap a mirror. Surface the
239
+ // friendly, actionable message into the bootstrap-error channel so the
240
+ // caller threads it into mirror status (`last_error`) rather than letting
241
+ // a raw `Executable not found in $PATH: "git"` crash out of the spawn.
242
+ try {
243
+ ensureGitAvailable(which);
244
+ } catch (err) {
245
+ return { ok: false, error: (err as Error).message };
246
+ }
247
+
235
248
  if (existsSync(path)) {
236
249
  let stat;
237
250
  try {
@@ -410,6 +423,15 @@ export class MirrorManager {
410
423
  this.deps = deps;
411
424
  }
412
425
 
426
+ /**
427
+ * The vault this mirror manager belongs to. Credential reads/writes are
428
+ * per-vault (vault#399); route handlers thread this into the credential
429
+ * functions so they touch the right vault's `.mirror-credentials.yaml`.
430
+ */
431
+ getVaultName(): string {
432
+ return this.deps.vaultName;
433
+ }
434
+
413
435
  /**
414
436
  * Read the current config snapshot. Returns a copy so callers can't
415
437
  * accidentally mutate the manager's internal state.
@@ -418,6 +440,30 @@ export class MirrorManager {
418
440
  return { ...this.currentConfig };
419
441
  }
420
442
 
443
+ /**
444
+ * The config the operator + SPA should SEE for this vault — distinct from
445
+ * the live in-memory `getConfig()` only before the manager has started.
446
+ *
447
+ * Why this exists (vault#400): routes resolve a per-vault manager via the
448
+ * registry, which can LAZILY build a manager for a vault that has config
449
+ * on disk but no running instance yet (e.g. a non-default vault the
450
+ * operator configured, or a runtime-enabled vault between PUT and the
451
+ * reload's start). A freshly-constructed-but-never-started manager has
452
+ * `currentConfig === defaultMirrorConfig()` (disabled), which would make
453
+ * `GET /vault/<name>/.parachute/mirror` show "disabled" for a vault whose
454
+ * file says `enabled: true`. Reading the persisted config when we haven't
455
+ * started yet returns the truth for that vault. Once `start()` has run,
456
+ * `currentConfig` is authoritative (it reflects the same persisted config
457
+ * plus any bootstrap outcome).
458
+ */
459
+ getEffectiveConfig(): MirrorConfig {
460
+ if (this.startCount === 0) {
461
+ const persisted = this.deps.readMirrorConfig();
462
+ if (persisted) return { ...persisted };
463
+ }
464
+ return { ...this.currentConfig };
465
+ }
466
+
421
467
  /**
422
468
  * Get the current status snapshot. Returns a copy for the same reason as
423
469
  * `getConfig`.
@@ -434,7 +480,11 @@ export class MirrorManager {
434
480
  * Returns the final status snapshot — useful for tests + the PUT
435
481
  * endpoint response.
436
482
  */
437
- async start(): Promise<MirrorStatus> {
483
+ async start(
484
+ // Test seam for the git-presence preflight (default `Bun.which`). Inject
485
+ // a fn returning `null` to exercise the git-not-installed start path.
486
+ which?: (cmd: string) => string | null,
487
+ ): Promise<MirrorStatus> {
438
488
  this.startCount++;
439
489
  await this.stop({ preserveStatus: true });
440
490
 
@@ -469,6 +519,24 @@ export class MirrorManager {
469
519
  }
470
520
  this.status.mirror_path = path;
471
521
 
522
+ // Preflight git BEFORE branching on location. Both branches shell `git`
523
+ // (internal → bootstrapInternalMirror; external → isGitRepo). On a
524
+ // git-less server the external branch's `isGitRepo` would otherwise throw
525
+ // a raw "Executable not found in $PATH: \"git\"" and crash start();
526
+ // catching it here lands the friendly, actionable message in
527
+ // status.last_error (disabled) for either location, uniformly.
528
+ try {
529
+ ensureGitAvailable(which);
530
+ } catch (err) {
531
+ if (err instanceof GitNotInstalledError) {
532
+ this.status.enabled = false;
533
+ this.status.last_error = err.message;
534
+ console.warn(`[mirror] ${err.message}`);
535
+ return this.getStatus();
536
+ }
537
+ throw err;
538
+ }
539
+
472
540
  // Internal bootstrap. External path is the operator's responsibility —
473
541
  // they should have validated via the PUT endpoint before we hit boot.
474
542
  // We re-check `isGitRepo` defensively here either way; a missing/non-
@@ -609,9 +677,13 @@ export class MirrorManager {
609
677
  * the operator-intended config on disk; on the next vault boot it
610
678
  * applies cleanly.
611
679
  */
612
- async reload(newConfig: MirrorConfig): Promise<MirrorStatus> {
680
+ async reload(
681
+ newConfig: MirrorConfig,
682
+ // Test seam forwarded to `start()` — see `start(which)`.
683
+ which?: (cmd: string) => string | null,
684
+ ): Promise<MirrorStatus> {
613
685
  this.deps.writeMirrorConfig(newConfig);
614
- return this.start();
686
+ return this.start(which);
615
687
  }
616
688
 
617
689
  /**
@@ -855,14 +927,25 @@ export class MirrorManager {
855
927
  }
856
928
 
857
929
  const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
858
- const commitResult = await runGitCommitCycle({
859
- repoDir: path,
860
- template: this.currentConfig.commit_template,
861
- notesChanged: totalChanged,
862
- vaultName: this.deps.vaultName,
863
- firstNoteTitle,
864
- push: this.currentConfig.auto_push,
865
- });
930
+ let commitResult: Awaited<ReturnType<typeof runGitCommitCycle>>;
931
+ try {
932
+ commitResult = await runGitCommitCycle({
933
+ repoDir: path,
934
+ template: this.currentConfig.commit_template,
935
+ notesChanged: totalChanged,
936
+ vaultName: this.deps.vaultName,
937
+ firstNoteTitle,
938
+ push: this.currentConfig.auto_push,
939
+ });
940
+ } catch (err) {
941
+ // git-not-installed (or any commit-cycle throw) lands in status as a
942
+ // friendly last_error rather than crashing the cycle. Matches the
943
+ // "errors reflected in last_error, never rethrown" contract above.
944
+ const msg = (err as Error).message ?? String(err);
945
+ this.status.last_error = `commit cycle failed: ${msg}`;
946
+ console.warn(`[mirror] ${this.status.last_error}`);
947
+ return;
948
+ }
866
949
 
867
950
  if (commitResult.committed) {
868
951
  // Resolve the new HEAD sha so the status displays the commit that
@@ -918,6 +1001,17 @@ export class MirrorManager {
918
1001
  if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
919
1002
  if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
920
1003
  const path = this.status.mirror_path;
1004
+ // Preflight: git-less server can't push. Surface the friendly message
1005
+ // into last_push_error (the SPA renders it) rather than throwing a raw
1006
+ // "Executable not found" out of the gitPush spawn.
1007
+ try {
1008
+ ensureGitAvailable();
1009
+ } catch (err) {
1010
+ const msg = (err as Error).message ?? String(err);
1011
+ this.status.last_push_error = msg;
1012
+ console.warn(`[mirror] push-now failed: ${msg}`);
1013
+ return { fired: true, pushed: false, error: msg };
1014
+ }
921
1015
  const pushResult = await gitPush(path);
922
1016
  const now = new Date().toISOString();
923
1017
  // Refresh commits_unpushed either way — a no-op push still reflects
@@ -963,7 +1057,7 @@ export class MirrorManager {
963
1057
  */
964
1058
  private async applyCredentialsToRemote(repoDir: string): Promise<void> {
965
1059
  try {
966
- const creds = readCredentials();
1060
+ const creds = readCredentials(this.deps.vaultName);
967
1061
  if (!creds || !creds.active_method) {
968
1062
  // No UI-configured credentials. Leave the remote alone — the
969
1063
  // operator may have set one up via `git remote add` manually.