@openparachute/vault 0.4.8 → 0.4.9-rc.11

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 (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -21,6 +21,7 @@ import {
21
21
  type MirrorDeps,
22
22
  } from "./mirror-manager.ts";
23
23
  import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
24
+ import { HookRegistry } from "../core/src/hooks.ts";
24
25
 
25
26
  // Snapshot HOME + PARACHUTE_HOME at module load; restore after every test
26
27
  // so the `process.env.HOME = ...` rewrite in `makeFakeDeps` doesn't leak
@@ -82,8 +83,18 @@ function makeFakeDeps(opts: {
82
83
  initialConfig?: MirrorConfig | undefined;
83
84
  /** Optional override for runExport — return note count per call. */
84
85
  runExport?: (call: { outDir: string; sinceCursor?: string }) => Promise<{ notes: number }>;
86
+ /** Optional override for runPrune. */
87
+ runPrune?: (call: { outDir: string }) => Promise<{
88
+ notes_removed: number;
89
+ sidecars_removed: number;
90
+ schemas_removed: number;
91
+ attachment_dirs_removed: number;
92
+ }>;
93
+ /** Hook registry for the event-driven path. Tests that exercise hooks pass one. */
94
+ hooks?: HookRegistry;
85
95
  }): MirrorDeps & {
86
96
  exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
97
+ pruneCalls: Array<{ outDir: string }>;
87
98
  storedConfig: MirrorConfig | undefined;
88
99
  } {
89
100
  process.env.PARACHUTE_HOME = opts.parachuteHome;
@@ -92,9 +103,11 @@ function makeFakeDeps(opts: {
92
103
  const state: {
93
104
  config: MirrorConfig | undefined;
94
105
  exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
106
+ pruneCalls: Array<{ outDir: string }>;
95
107
  } = {
96
108
  config: opts.initialConfig,
97
109
  exportCalls: [],
110
+ pruneCalls: [],
98
111
  };
99
112
 
100
113
  const base: MirrorDeps = {
@@ -107,11 +120,17 @@ function makeFakeDeps(opts: {
107
120
  if (opts.runExport) return opts.runExport(call);
108
121
  return { notes: 1 };
109
122
  },
123
+ runPrune: async (call: { outDir: string }) => {
124
+ state.pruneCalls.push({ outDir: call.outDir });
125
+ if (opts.runPrune) return opts.runPrune(call);
126
+ return { notes_removed: 0, sidecars_removed: 0, schemas_removed: 0, attachment_dirs_removed: 0 };
127
+ },
110
128
  firstChangedNoteTitle: async () => "Inbox/fake",
111
129
  readMirrorConfig: () => state.config,
112
130
  writeMirrorConfig: (c: MirrorConfig) => {
113
131
  state.config = c;
114
132
  },
133
+ hooks: opts.hooks,
115
134
  };
116
135
  // Defining the test-visible getters via defineProperty so the getter
117
136
  // bodies run on every access — otherwise an Object.assign snapshots
@@ -120,12 +139,17 @@ function makeFakeDeps(opts: {
120
139
  get: () => state.exportCalls,
121
140
  enumerable: true,
122
141
  });
142
+ Object.defineProperty(base, "pruneCalls", {
143
+ get: () => state.pruneCalls,
144
+ enumerable: true,
145
+ });
123
146
  Object.defineProperty(base, "storedConfig", {
124
147
  get: () => state.config,
125
148
  enumerable: true,
126
149
  });
127
150
  return base as MirrorDeps & {
128
151
  exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
152
+ pruneCalls: Array<{ outDir: string }>;
129
153
  storedConfig: MirrorConfig | undefined;
130
154
  };
131
155
  }
@@ -240,7 +264,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
240
264
  ...defaultMirrorConfig(),
241
265
  enabled: true,
242
266
  location: "internal",
243
- watch: false,
267
+ sync_mode: "manual",
244
268
  auto_commit: false, // skip commit cycle for this unit
245
269
  },
246
270
  });
@@ -268,9 +292,9 @@ describe("MirrorManager.start — lifecycle matrix", () => {
268
292
  ...defaultMirrorConfig(),
269
293
  enabled: true,
270
294
  location: "internal",
271
- watch: true,
295
+ sync_mode: "events",
272
296
  auto_commit: false,
273
- interval_seconds: 1,
297
+ safety_net_seconds: 60,
274
298
  },
275
299
  });
276
300
  const mgr = new MirrorManager(deps);
@@ -296,9 +320,9 @@ describe("MirrorManager.start — lifecycle matrix", () => {
296
320
  enabled: true,
297
321
  location: "external",
298
322
  external_path: external,
299
- watch: true,
323
+ sync_mode: "events",
300
324
  auto_commit: false,
301
- interval_seconds: 1,
325
+ safety_net_seconds: 60,
302
326
  },
303
327
  });
304
328
  const mgr = new MirrorManager(deps);
@@ -322,7 +346,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
322
346
  enabled: true,
323
347
  location: "external",
324
348
  external_path: "/definitely/not/a/path/here",
325
- watch: true,
349
+ sync_mode: "events",
326
350
  },
327
351
  });
328
352
  const mgr = new MirrorManager(deps);
@@ -365,7 +389,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
365
389
  ...defaultMirrorConfig(),
366
390
  enabled: true,
367
391
  location: "internal",
368
- watch: false,
392
+ sync_mode: "manual",
369
393
  },
370
394
  });
371
395
  const mgr = new MirrorManager(deps);
@@ -391,7 +415,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
391
415
  ...defaultMirrorConfig(),
392
416
  enabled: true,
393
417
  location: "internal",
394
- watch: false,
418
+ sync_mode: "manual",
395
419
  auto_commit: false,
396
420
  },
397
421
  });
@@ -423,9 +447,9 @@ describe("MirrorManager.stop / reload", () => {
423
447
  ...defaultMirrorConfig(),
424
448
  enabled: true,
425
449
  location: "internal",
426
- watch: true,
450
+ sync_mode: "events",
427
451
  auto_commit: false,
428
- interval_seconds: 1,
452
+ safety_net_seconds: 60,
429
453
  },
430
454
  });
431
455
  const mgr = new MirrorManager(deps);
@@ -477,7 +501,7 @@ describe("MirrorManager.stop / reload", () => {
477
501
  ...defaultMirrorConfig(),
478
502
  enabled: true,
479
503
  location: "internal",
480
- watch: false,
504
+ sync_mode: "manual",
481
505
  auto_commit: false,
482
506
  },
483
507
  });
@@ -520,7 +544,7 @@ describe("MirrorManager.runNow", () => {
520
544
  ...defaultMirrorConfig(),
521
545
  enabled: true,
522
546
  location: "internal",
523
- watch: false,
547
+ sync_mode: "manual",
524
548
  auto_commit: false,
525
549
  },
526
550
  });
@@ -548,3 +572,390 @@ describe("MirrorManager.runNow", () => {
548
572
  await mgr.stop();
549
573
  });
550
574
  });
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Event-driven path (vault#382) — hook subscriptions, debounce, safety net
578
+ // ---------------------------------------------------------------------------
579
+
580
+ describe("MirrorManager — event-driven (sync_mode: events)", () => {
581
+ let home: string;
582
+ let hooks: HookRegistry;
583
+ afterEach(async () => {
584
+ if (home) fs.rmSync(home, { recursive: true, force: true });
585
+ });
586
+
587
+ /** Helper — build a manager pre-wired with hooks + events sync_mode. */
588
+ async function makeEventDrivenManager(homeArg: string): Promise<{
589
+ mgr: MirrorManager;
590
+ hooks: HookRegistry;
591
+ deps: ReturnType<typeof makeFakeDeps>;
592
+ }> {
593
+ fs.mkdirSync(path.join(homeArg, "vault", "data", "default"), { recursive: true });
594
+ const h = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
595
+ const deps = makeFakeDeps({
596
+ parachuteHome: homeArg,
597
+ initialConfig: {
598
+ ...defaultMirrorConfig(),
599
+ enabled: true,
600
+ location: "internal",
601
+ sync_mode: "events",
602
+ auto_commit: false,
603
+ },
604
+ hooks: h,
605
+ });
606
+ const mgr = new MirrorManager(deps);
607
+ await mgr.start();
608
+ return { mgr, hooks: h, deps };
609
+ }
610
+
611
+ test("start() subscribes to hooks (3 subscriptions: notes, tags, attachments)", async () => {
612
+ home = tmp("mgr-ev-sub-");
613
+ const { mgr } = await makeEventDrivenManager(home);
614
+ expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(3);
615
+ await mgr.stop();
616
+ });
617
+
618
+ test("start() does not subscribe when sync_mode: manual", async () => {
619
+ home = tmp("mgr-ev-manual-");
620
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
621
+ hooks = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
622
+ const deps = makeFakeDeps({
623
+ parachuteHome: home,
624
+ initialConfig: {
625
+ ...defaultMirrorConfig(),
626
+ enabled: true,
627
+ location: "internal",
628
+ sync_mode: "manual",
629
+ auto_commit: false,
630
+ },
631
+ hooks,
632
+ });
633
+ const mgr = new MirrorManager(deps);
634
+ await mgr.start();
635
+ expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
636
+ await mgr.stop();
637
+ });
638
+
639
+ test("one note-mutation event → debounced flush → one extra export", async () => {
640
+ home = tmp("mgr-ev-debounce-");
641
+ const { mgr, deps } = await makeEventDrivenManager(home);
642
+ expect(deps.exportCalls).toHaveLength(1); // initial export
643
+
644
+ // Fire a synthetic event via the hook registry. Use a fake store —
645
+ // the mirror's handler ignores the payload, just marks dirty.
646
+ const fakeStore = {} as never;
647
+ hooks = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
648
+ hooks.dispatch("created", { id: "n1", content: "hi", createdAt: new Date().toISOString() } as never, fakeStore);
649
+ // Let the dispatch microtask + handler run.
650
+ await Promise.resolve();
651
+ await Promise.resolve();
652
+ await hooks.drain();
653
+ // The debounce timer is armed; force it for the test.
654
+ await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
655
+
656
+ expect(deps.exportCalls).toHaveLength(2);
657
+ // The non-initial export carries the cursor from the initial pass.
658
+ expect(deps.exportCalls[1]!.sinceCursor).toBeDefined();
659
+ // Prune was called (event-driven flush always prunes).
660
+ expect(deps.pruneCalls.length).toBeGreaterThanOrEqual(2);
661
+ await mgr.stop();
662
+ });
663
+
664
+ test("burst of events → debounce collapses into one export", async () => {
665
+ home = tmp("mgr-ev-burst-");
666
+ const { mgr, deps } = await makeEventDrivenManager(home);
667
+ expect(deps.exportCalls).toHaveLength(1);
668
+ const initialPruneCount = deps.pruneCalls.length;
669
+
670
+ const fakeStore = {} as never;
671
+ hooks = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
672
+ for (let i = 0; i < 10; i++) {
673
+ hooks.dispatch("updated", { id: `n${i}`, content: "v", createdAt: new Date().toISOString() } as never, fakeStore);
674
+ }
675
+ await Promise.resolve();
676
+ await Promise.resolve();
677
+ await hooks.drain();
678
+ await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
679
+
680
+ // All 10 events collapsed into ONE additional export.
681
+ expect(deps.exportCalls).toHaveLength(2);
682
+ expect(deps.pruneCalls.length - initialPruneCount).toBe(1);
683
+ await mgr.stop();
684
+ });
685
+
686
+ test("event during in-flight export → second flush queued", async () => {
687
+ home = tmp("mgr-ev-during-flight-");
688
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
689
+ const h = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
690
+
691
+ // Build runExport that holds open for the first event-driven flush
692
+ // so we can dispatch a second event mid-flight.
693
+ let releaseFirstFlush: (() => void) | null = null;
694
+ let exportCallCount = 0;
695
+ const deps = makeFakeDeps({
696
+ parachuteHome: home,
697
+ initialConfig: {
698
+ ...defaultMirrorConfig(),
699
+ enabled: true,
700
+ location: "internal",
701
+ sync_mode: "events",
702
+ auto_commit: false,
703
+ },
704
+ hooks: h,
705
+ runExport: async (_call) => {
706
+ exportCallCount++;
707
+ // Only the FIRST event-driven flush blocks (call #2; #1 is initial).
708
+ if (exportCallCount === 2) {
709
+ await new Promise<void>((resolve) => {
710
+ releaseFirstFlush = resolve;
711
+ });
712
+ }
713
+ return { notes: 1 };
714
+ },
715
+ });
716
+ const mgr = new MirrorManager(deps);
717
+ await mgr.start();
718
+ expect(exportCallCount).toBe(1);
719
+
720
+ const fakeStore = {} as never;
721
+ h.dispatch("created", { id: "first", content: "x", createdAt: new Date().toISOString() } as never, fakeStore);
722
+ await Promise.resolve();
723
+ await Promise.resolve();
724
+ await h.drain();
725
+ // Kick off the debounce flush — it'll block on the gate.
726
+ const flushPromise = (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
727
+ // Give it a tick to enter runExport.
728
+ await new Promise((r) => setTimeout(r, 25));
729
+ expect(exportCallCount).toBe(2);
730
+
731
+ // While that one is in flight, dispatch a second event.
732
+ h.dispatch("updated", { id: "second", content: "y", createdAt: new Date().toISOString() } as never, fakeStore);
733
+ await Promise.resolve();
734
+ await Promise.resolve();
735
+ await h.drain();
736
+ // Release the first flush — dirtyDuringFlush should re-arm the debounce.
737
+ releaseFirstFlush!();
738
+ await flushPromise;
739
+ // Force the queued debounce too.
740
+ await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
741
+
742
+ expect(exportCallCount).toBe(3);
743
+ await mgr.stop();
744
+ });
745
+
746
+ test("stop() unsubscribes hooks + cancels debounce timer", async () => {
747
+ home = tmp("mgr-ev-stop-");
748
+ const { mgr, deps } = await makeEventDrivenManager(home);
749
+ const exportCountBeforeStop = deps.exportCalls.length;
750
+
751
+ await mgr.stop();
752
+ expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
753
+
754
+ // Subsequent events should produce no further exports.
755
+ const h = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
756
+ const fakeStore = {} as never;
757
+ h.dispatch("created", { id: "ghost", content: "z", createdAt: new Date().toISOString() } as never, fakeStore);
758
+ await Promise.resolve();
759
+ await h.drain();
760
+ await new Promise((r) => setTimeout(r, 100));
761
+ expect(deps.exportCalls.length).toBe(exportCountBeforeStop);
762
+ });
763
+
764
+ test("manual mode → no subscriptions, only runNow triggers export", async () => {
765
+ home = tmp("mgr-ev-manual-only-");
766
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
767
+ const h = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
768
+ const deps = makeFakeDeps({
769
+ parachuteHome: home,
770
+ initialConfig: {
771
+ ...defaultMirrorConfig(),
772
+ enabled: true,
773
+ location: "internal",
774
+ sync_mode: "manual",
775
+ auto_commit: false,
776
+ },
777
+ hooks: h,
778
+ });
779
+ const mgr = new MirrorManager(deps);
780
+ await mgr.start();
781
+ expect(deps.exportCalls).toHaveLength(1); // initial
782
+
783
+ // Events fired now should NOT trigger flushes (no subscriptions).
784
+ h.dispatch("created", { id: "n1", content: "x", createdAt: new Date().toISOString() } as never, {} as never);
785
+ await Promise.resolve();
786
+ await h.drain();
787
+ expect(deps.exportCalls).toHaveLength(1);
788
+
789
+ // runNow still fires.
790
+ await mgr.runNow();
791
+ expect(deps.exportCalls).toHaveLength(2);
792
+ await mgr.stop();
793
+ });
794
+
795
+ test("reload() from events → manual tears down subscriptions", async () => {
796
+ home = tmp("mgr-ev-swap-mode-");
797
+ const { mgr, hooks: h } = await makeEventDrivenManager(home);
798
+ expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(3);
799
+
800
+ await mgr.reload({
801
+ ...defaultMirrorConfig(),
802
+ enabled: true,
803
+ location: "internal",
804
+ sync_mode: "manual",
805
+ auto_commit: false,
806
+ });
807
+ expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
808
+
809
+ // Confirm a dispatched event in the new mode is a no-op.
810
+ h.dispatch("created", { id: "n1", content: "x", createdAt: new Date().toISOString() } as never, {} as never);
811
+ await Promise.resolve();
812
+ await h.drain();
813
+ // No additional export beyond the reload's initial pass.
814
+ await mgr.stop();
815
+ });
816
+
817
+ test("missing hooks dep → falls back to safety-net only (logs warning, doesn't crash)", async () => {
818
+ home = tmp("mgr-ev-no-hooks-");
819
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
820
+ // No `hooks` field on deps → manager should still start cleanly.
821
+ const deps = makeFakeDeps({
822
+ parachuteHome: home,
823
+ initialConfig: {
824
+ ...defaultMirrorConfig(),
825
+ enabled: true,
826
+ location: "internal",
827
+ sync_mode: "events",
828
+ auto_commit: false,
829
+ },
830
+ // Intentionally omit `hooks`.
831
+ });
832
+ const mgr = new MirrorManager(deps);
833
+ const status = await mgr.start();
834
+ expect(status.enabled).toBe(true);
835
+ expect(status.watch_running).toBe(true);
836
+ expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
837
+ await mgr.stop();
838
+ });
839
+
840
+ test("prune is invoked on initial export AND on event-driven flushes", async () => {
841
+ home = tmp("mgr-ev-prune-");
842
+ const { mgr, deps } = await makeEventDrivenManager(home);
843
+ // Initial export should have called prune.
844
+ expect(deps.pruneCalls.length).toBeGreaterThanOrEqual(1);
845
+
846
+ const fakeStore = {} as never;
847
+ hooks = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
848
+ hooks.dispatch("deleted", { id: "gone", path: "old/note" } as never, fakeStore);
849
+ await Promise.resolve();
850
+ await Promise.resolve();
851
+ await hooks.drain();
852
+ const pruneBefore = deps.pruneCalls.length;
853
+ await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
854
+ expect(deps.pruneCalls.length).toBe(pruneBefore + 1);
855
+ await mgr.stop();
856
+ });
857
+ });
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // MirrorManager.pushNow — Cut 6 of vault#392 (credentials → push round-trip)
861
+ //
862
+ // Live `git push` against a local bare repo as the "remote." Tests the
863
+ // status mutations (last_push_at, last_push_sha, last_push_error,
864
+ // commits_unpushed) the SPA renders.
865
+ // ---------------------------------------------------------------------------
866
+
867
+ describe("MirrorManager.pushNow — push observability", () => {
868
+ let home: string;
869
+ let remote: string;
870
+
871
+ afterEach(async () => {
872
+ if (home) fs.rmSync(home, { recursive: true, force: true });
873
+ if (remote) fs.rmSync(remote, { recursive: true, force: true });
874
+ });
875
+
876
+ test("returns not_enabled when mirror is disabled", async () => {
877
+ home = tmp("mgr-pushnow-disabled-");
878
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
879
+ const deps = makeFakeDeps({
880
+ parachuteHome: home,
881
+ initialConfig: { ...defaultMirrorConfig(), enabled: false },
882
+ });
883
+ const mgr = new MirrorManager(deps);
884
+ await mgr.start();
885
+ const r = await mgr.pushNow();
886
+ expect(r.fired).toBe(false);
887
+ if (r.fired === false) expect(r.reason).toBe("not_enabled");
888
+ });
889
+
890
+ test("first push wires upstream and sets last_push_at + last_push_sha", async () => {
891
+ // Spin up an enabled internal mirror; wire a local bare repo as
892
+ // origin; verify pushNow lands the seed commit and updates status.
893
+ home = tmp("mgr-pushnow-first-");
894
+ remote = tmp("mgr-pushnow-remote-");
895
+ Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
896
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
897
+ const deps = makeFakeDeps({
898
+ parachuteHome: home,
899
+ initialConfig: {
900
+ ...defaultMirrorConfig(),
901
+ enabled: true,
902
+ location: "internal",
903
+ sync_mode: "manual",
904
+ auto_commit: false,
905
+ },
906
+ });
907
+ const mgr = new MirrorManager(deps);
908
+ const status = await mgr.start();
909
+ expect(status.enabled).toBe(true);
910
+ // Wire origin to the bare repo. The internal-mirror's first commit
911
+ // (`initial mirror bootstrap`) is sitting on HEAD waiting to push.
912
+ Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: status.mirror_path! });
913
+
914
+ const result = await mgr.pushNow();
915
+ expect(result.fired).toBe(true);
916
+ if (result.fired) {
917
+ expect(result.pushed).toBe(true);
918
+ expect(typeof result.sha).toBe("string");
919
+ }
920
+ const after = mgr.getStatus();
921
+ expect(after.last_push_at).not.toBeNull();
922
+ expect(after.last_push_sha).not.toBeNull();
923
+ expect(after.last_push_error).toBeNull();
924
+ expect(after.commits_unpushed).toBe(0);
925
+ await mgr.stop();
926
+ });
927
+
928
+ test("push failure surfaces redacted error in last_push_error", async () => {
929
+ // Wire origin to a non-existent host. Push fails; status reflects
930
+ // the failure without leaking any potentially-sensitive URL parts.
931
+ home = tmp("mgr-pushnow-fail-");
932
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
933
+ const deps = makeFakeDeps({
934
+ parachuteHome: home,
935
+ initialConfig: {
936
+ ...defaultMirrorConfig(),
937
+ enabled: true,
938
+ location: "internal",
939
+ sync_mode: "manual",
940
+ auto_commit: false,
941
+ },
942
+ });
943
+ const mgr = new MirrorManager(deps);
944
+ const status = await mgr.start();
945
+ Bun.spawnSync(
946
+ ["git", "remote", "add", "origin", "https://x-access-token:ghp_fake1234567890abcdef@nonexistent.parachute.test/a/b.git"],
947
+ { cwd: status.mirror_path! },
948
+ );
949
+ const result = await mgr.pushNow();
950
+ expect(result.fired).toBe(true);
951
+ if (result.fired) expect(result.pushed).toBe(false);
952
+ const after = mgr.getStatus();
953
+ expect(after.last_push_error).not.toBeNull();
954
+ // Redacted — no token leak.
955
+ expect(after.last_push_error).not.toContain("ghp_fake1234567890");
956
+ // Last successful push timestamp is unchanged (still null on a
957
+ // never-succeeded mirror).
958
+ expect(after.last_push_at).toBeNull();
959
+ await mgr.stop();
960
+ }, 30_000);
961
+ });