@openparachute/vault 0.6.0-rc.1 → 0.6.0

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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -23,6 +23,7 @@ import {
23
23
  validateExternalPath,
24
24
  validateMirrorConfigShape,
25
25
  } from "./mirror-config.ts";
26
+ import { GitNotInstalledError } from "./git-preflight.ts";
26
27
 
27
28
  function tmp(prefix: string): string {
28
29
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -496,6 +497,19 @@ describe("validateExternalPath", () => {
496
497
  expect(r.ok).toBe(true);
497
498
  if (r.ok) expect(r.resolved_path).toBe(dir);
498
499
  });
500
+
501
+ test("git not installed → throws GitNotInstalledError (route maps to 503)", async () => {
502
+ // vault#415 nit — the isGitRepo() check shells `git`. On a git-less
503
+ // server, throw the friendly error (handleMirrorPut maps it to 503
504
+ // git_not_installed) instead of a raw "Executable not found" crash.
505
+ // Force the preflight via the `which` seam; a real, valid git repo is
506
+ // used so the ONLY failure source is the preflight.
507
+ dir = tmp("mirror-validate-nogit-installed-");
508
+ initRepo(dir);
509
+ await expect(validateExternalPath(dir, () => null)).rejects.toBeInstanceOf(
510
+ GitNotInstalledError,
511
+ );
512
+ });
499
513
  });
500
514
 
501
515
  // ---------------------------------------------------------------------------
@@ -45,6 +45,7 @@ import { homedir } from "os";
45
45
 
46
46
  import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
47
47
  import { readCredentials, type MirrorCredentials } from "./mirror-credentials.ts";
48
+ import { ensureGitAvailable } from "./git-preflight.ts";
48
49
 
49
50
  // ---------------------------------------------------------------------------
50
51
  // Types
@@ -888,7 +889,17 @@ export type PathValidation = PathValidationOk | PathValidationError;
888
889
  */
889
890
  export async function validateExternalPath(
890
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,
891
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
+
892
903
  if (!existsSync(externalPath)) {
893
904
  return {
894
905
  ok: false,
@@ -30,8 +30,10 @@ import {
30
30
  _resetImportInFlightForTest,
31
31
  authedCloneUrl,
32
32
  cloneAndImport,
33
+ defaultGitSpawn,
33
34
  type GitSpawn,
34
35
  } from "./mirror-import.ts";
36
+ import { GitNotInstalledError } from "./git-preflight.ts";
35
37
  import {
36
38
  emptyCredentials,
37
39
  mirrorCredentialsPath,
@@ -295,6 +297,10 @@ describe("cloneAndImport — success", () => {
295
297
  expect(result.notes_imported).toBe(2); // alpha + beta
296
298
  expect(result.notes_deleted).toBeUndefined();
297
299
  expect(result.warnings).toEqual([]);
300
+ // vault#416: cloneAndImport stays focused on content — sync-enabling is
301
+ // the route's job. The worker always returns sync_enabled: false.
302
+ expect(result.sync_enabled).toBe(false);
303
+ expect(result.sync_warning).toBeUndefined();
298
304
 
299
305
  const restored = await store.getNote("n-alpha");
300
306
  expect(restored).toBeTruthy();
@@ -548,3 +554,107 @@ describe("cloneAndImport — failures", () => {
548
554
  rmSync(notAnExport, { recursive: true, force: true });
549
555
  });
550
556
  });
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // cloneAndImport — git not installed (vault#415 — live bug on a git-less EC2)
560
+ // ---------------------------------------------------------------------------
561
+
562
+ describe("cloneAndImport — git not installed", () => {
563
+ let assetsDir: string;
564
+ let store: SqliteStore;
565
+
566
+ beforeEach(() => {
567
+ assetsDir = tmp("import-assets-nogit-");
568
+ store = new SqliteStore(new Database(":memory:"));
569
+ });
570
+
571
+ afterEach(() => {
572
+ if (assetsDir) rmSync(assetsDir, { recursive: true, force: true });
573
+ });
574
+
575
+ test("git missing → GitNotInstalledError, fails fast (no spawn, no tempdir)", async () => {
576
+ const workDirRoot = tmp("import-workroot-nogit-");
577
+ let spawnCalled = false;
578
+ const spyingSpawn: GitSpawn = async () => {
579
+ spawnCalled = true;
580
+ return { exitCode: 0, stderr: "", timedOut: false };
581
+ };
582
+ await expect(
583
+ cloneAndImport({
584
+ vaultName: "default",
585
+ remoteUrl: "https://github.com/owner/repo.git",
586
+ auth: { kind: "none" },
587
+ mode: "merge",
588
+ store,
589
+ assetsDir,
590
+ spawn: spyingSpawn,
591
+ workDirRoot,
592
+ // Force the preflight to see no git on PATH.
593
+ which: () => null,
594
+ }),
595
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
596
+
597
+ // Fails fast: the spawner is never reached and no tempdir is created.
598
+ expect(spawnCalled).toBe(false);
599
+ const { readdirSync } = await import("node:fs");
600
+ const entries = readdirSync(workDirRoot);
601
+ expect(entries.filter((e) => e.startsWith("parachute-import-"))).toEqual([]);
602
+ rmSync(workDirRoot, { recursive: true, force: true });
603
+ });
604
+
605
+ test("git missing → does not lay an in-flight marker (clean retry after install)", async () => {
606
+ await expect(
607
+ cloneAndImport({
608
+ vaultName: "default",
609
+ remoteUrl: "https://github.com/owner/repo.git",
610
+ auth: { kind: "none" },
611
+ mode: "merge",
612
+ store,
613
+ assetsDir,
614
+ spawn: async () => ({ exitCode: 0, stderr: "", timedOut: false }),
615
+ which: () => null,
616
+ }),
617
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
618
+ // The preflight runs BEFORE the concurrency marker, so a failed call
619
+ // leaves no stale in-flight entry blocking the post-install retry.
620
+ expect(_isImportInFlight("default")).toBe(false);
621
+ });
622
+
623
+ test("git present (injected which) → normal import path still works", async () => {
624
+ const fixtureDir = await buildExportFixture();
625
+ try {
626
+ const result = await cloneAndImport({
627
+ vaultName: "default",
628
+ remoteUrl: "https://github.com/owner/repo.git",
629
+ auth: { kind: "none" },
630
+ mode: "merge",
631
+ store,
632
+ assetsDir,
633
+ spawn: spawnCloneSuccess(fixtureDir),
634
+ which: () => "/usr/bin/git",
635
+ });
636
+ expect(result.notes_imported).toBe(2);
637
+ } finally {
638
+ rmSync(fixtureDir, { recursive: true, force: true });
639
+ }
640
+ });
641
+
642
+ test("GitNotInstalledError message is actionable (names install commands)", () => {
643
+ const msg = new GitNotInstalledError().message;
644
+ expect(msg).toContain("git is required");
645
+ expect(msg).toContain("dnf install git");
646
+ expect(msg).toContain("apt-get install");
647
+ expect(msg).toContain("brew install git");
648
+ });
649
+
650
+ test("defaultGitSpawn rethrows a git-not-found spawn error as GitNotInstalledError", async () => {
651
+ // Belt-and-suspenders: spawn a non-existent binary to trigger Bun's
652
+ // "Executable not found" throw, and confirm the friendly rethrow.
653
+ // (We can't uninstall git, so spawn an impossible command name.)
654
+ await expect(
655
+ defaultGitSpawn(["definitely-not-a-real-binary-xyzzy-git"], {
656
+ timeoutMs: 5_000,
657
+ }),
658
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
659
+ });
660
+ });
@@ -73,6 +73,11 @@ import {
73
73
  } from "../core/src/portable-md.ts";
74
74
  import type { SqliteStore } from "../core/src/store.ts";
75
75
  import { redactRemoteUrl, readCredentials } from "./mirror-credentials.ts";
76
+ import {
77
+ GitNotInstalledError,
78
+ ensureGitAvailable,
79
+ isGitNotFoundSpawnError,
80
+ } from "./git-preflight.ts";
76
81
 
77
82
  // ---------------------------------------------------------------------------
78
83
  // Types
@@ -125,6 +130,11 @@ export interface ImportOpts {
125
130
  importer?: typeof importPortableVault;
126
131
  /** Override the clone timeout (default 60s; test seam to shorten). */
127
132
  cloneTimeoutMs?: number;
133
+ /**
134
+ * Override the git-presence probe (test seam — defaults to `Bun.which`).
135
+ * Inject a fn returning `null` to exercise the git-not-installed path.
136
+ */
137
+ which?: (cmd: string) => string | null;
128
138
  }
129
139
 
130
140
  /**
@@ -144,6 +154,29 @@ export interface ImportResult {
144
154
  * etc.). The HTTP handler returns these so the operator can audit.
145
155
  */
146
156
  warnings: string[];
157
+ /**
158
+ * vault#416 — whether sync (mirror push-back to the imported repo) ended
159
+ * up enabled as part of this import. Default-on UX: the import request
160
+ * carries `enable_sync` (default true), and the route turns the imported
161
+ * repo into a configured, credential-backed, auto-pushing mirror after a
162
+ * successful import. `true` when sync is now wired (or was already wired
163
+ * to this same remote); `false` when sync was opted out, couldn't be
164
+ * enabled (no push-capable credentials), was skipped to avoid clobbering
165
+ * a different existing mirror, or threw during setup (import already
166
+ * succeeded — never lost to a sync error).
167
+ *
168
+ * `cloneAndImport` itself never sets these — the field is populated by the
169
+ * route (`handleMirrorImport`) after a successful import. `importResultFromStats`
170
+ * defaults `sync_enabled` to false; the route overwrites it.
171
+ */
172
+ sync_enabled: boolean;
173
+ /**
174
+ * Human-readable reason sync wasn't enabled (no creds / conflicting
175
+ * existing mirror / setup error). Only set when `sync_enabled` is false
176
+ * AND the caller asked for sync (`enable_sync !== false`). Absent when
177
+ * sync succeeded or the operator opted out.
178
+ */
179
+ sync_warning?: string;
147
180
  }
148
181
 
149
182
  /**
@@ -315,19 +348,32 @@ export function authedCloneUrl(
315
348
  * exit code + stderr text + timeout flag.
316
349
  */
317
350
  export const defaultGitSpawn: GitSpawn = async (argv, options) => {
318
- const proc = Bun.spawn(argv, {
319
- cwd: options.cwd,
320
- stdout: "pipe",
321
- stderr: "pipe",
322
- env: {
323
- ...process.env,
324
- GIT_TERMINAL_PROMPT: "0",
325
- // Kill any system credential helper from intercepting — we want
326
- // the clone to use ONLY the URL-embedded credential, not whatever's
327
- // in keychain. Same shape as the ls-remote probe.
328
- GIT_ASKPASS: "/bin/echo",
329
- },
330
- });
351
+ let proc;
352
+ try {
353
+ proc = Bun.spawn(argv, {
354
+ cwd: options.cwd,
355
+ stdout: "pipe",
356
+ stderr: "pipe",
357
+ env: {
358
+ ...process.env,
359
+ GIT_TERMINAL_PROMPT: "0",
360
+ // Kill any system credential helper from intercepting — we want
361
+ // the clone to use ONLY the URL-embedded credential, not whatever's
362
+ // in keychain. Same shape as the ls-remote probe.
363
+ GIT_ASKPASS: "/bin/echo",
364
+ },
365
+ });
366
+ } catch (err) {
367
+ // Belt-and-suspenders: `cloneAndImport` preflights via
368
+ // `ensureGitAvailable`, but if a git-missing spawn still slips through
369
+ // (race, or a future caller that skipped the preflight) rethrow it as
370
+ // the friendly error rather than leaking Bun's raw
371
+ // `Executable not found in $PATH: "git"`.
372
+ if (isGitNotFoundSpawnError(err)) {
373
+ throw new GitNotInstalledError();
374
+ }
375
+ throw err;
376
+ }
331
377
  let timedOut = false;
332
378
  const timer = setTimeout(() => {
333
379
  timedOut = true;
@@ -365,6 +411,14 @@ export const defaultGitSpawn: GitSpawn = async (argv, options) => {
365
411
  * Always cleans up the temp dir.
366
412
  */
367
413
  export async function cloneAndImport(opts: ImportOpts): Promise<ImportResult> {
414
+ // Fail fast + clean when git isn't installed — BEFORE the concurrency
415
+ // marker, tempdir creation, or any spawn. The route maps the resulting
416
+ // GitNotInstalledError to a friendly 503 (git_not_installed). Without
417
+ // this, the first `Bun.spawn(["git", ...])` threw a raw
418
+ // `Executable not found in $PATH: "git"` that only the generic 500 branch
419
+ // caught — the unhelpful failure mode found live on the gitcoin EC2 box.
420
+ ensureGitAvailable(opts.which);
421
+
368
422
  if (inFlight.has(opts.vaultName)) {
369
423
  throw new ImportConflictError(opts.vaultName);
370
424
  }
@@ -469,6 +523,10 @@ function importResultFromStats(
469
523
  tags_imported: stats.schemas_restored,
470
524
  attachments_imported: stats.attachments_restored,
471
525
  warnings,
526
+ // Default false; the route flips it true after wiring sync. Keeping the
527
+ // default here means a caller that bypasses the route (CLI, tests of
528
+ // cloneAndImport directly) gets a well-typed, conservative result.
529
+ sync_enabled: false,
472
530
  };
473
531
  if (mode === "replace") {
474
532
  result.notes_deleted = stats.notes_wiped;
@@ -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");
@@ -75,6 +75,7 @@ import {
75
75
  applyToGitRemote,
76
76
  readCredentials,
77
77
  } from "./mirror-credentials.ts";
78
+ import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
78
79
  import type { HookRegistry } from "../core/src/hooks.ts";
79
80
 
80
81
  /**
@@ -230,7 +231,20 @@ export type BootstrapResult = BootstrapResultOk | BootstrapResultError;
230
231
  */
231
232
  export async function bootstrapInternalMirror(
232
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,
233
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
+
234
248
  if (existsSync(path)) {
235
249
  let stat;
236
250
  try {
@@ -466,7 +480,11 @@ export class MirrorManager {
466
480
  * Returns the final status snapshot — useful for tests + the PUT
467
481
  * endpoint response.
468
482
  */
469
- 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> {
470
488
  this.startCount++;
471
489
  await this.stop({ preserveStatus: true });
472
490
 
@@ -501,6 +519,24 @@ export class MirrorManager {
501
519
  }
502
520
  this.status.mirror_path = path;
503
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
+
504
540
  // Internal bootstrap. External path is the operator's responsibility —
505
541
  // they should have validated via the PUT endpoint before we hit boot.
506
542
  // We re-check `isGitRepo` defensively here either way; a missing/non-
@@ -641,9 +677,13 @@ export class MirrorManager {
641
677
  * the operator-intended config on disk; on the next vault boot it
642
678
  * applies cleanly.
643
679
  */
644
- 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> {
645
685
  this.deps.writeMirrorConfig(newConfig);
646
- return this.start();
686
+ return this.start(which);
647
687
  }
648
688
 
649
689
  /**
@@ -887,14 +927,25 @@ export class MirrorManager {
887
927
  }
888
928
 
889
929
  const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
890
- const commitResult = await runGitCommitCycle({
891
- repoDir: path,
892
- template: this.currentConfig.commit_template,
893
- notesChanged: totalChanged,
894
- vaultName: this.deps.vaultName,
895
- firstNoteTitle,
896
- push: this.currentConfig.auto_push,
897
- });
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
+ }
898
949
 
899
950
  if (commitResult.committed) {
900
951
  // Resolve the new HEAD sha so the status displays the commit that
@@ -950,6 +1001,17 @@ export class MirrorManager {
950
1001
  if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
951
1002
  if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
952
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
+ }
953
1015
  const pushResult = await gitPush(path);
954
1016
  const now = new Date().toISOString();
955
1017
  // Refresh commits_unpushed either way — a no-op push still reflects