@openparachute/vault 0.5.0-rc.1 → 0.5.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -11,7 +11,12 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
- import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
14
+ import {
15
+ defaultMirrorConfig,
16
+ readMirrorConfigForVault,
17
+ writeMirrorConfigForVault,
18
+ type MirrorConfig,
19
+ } from "./mirror-config.ts";
15
20
  import {
16
21
  MirrorManager,
17
22
  type MirrorDeps,
@@ -245,6 +250,37 @@ describe("handleMirrorPut", () => {
245
250
  }
246
251
  });
247
252
 
253
+ test("external + git not installed → 503 git_not_installed + actionable message", async () => {
254
+ // vault#415 nit — handleMirrorPut validates the external path via
255
+ // validateExternalPath, which shells `git`. On a git-less server it must
256
+ // return the friendly 503 (consistent with the import route), not let a
257
+ // raw "Executable not found" crash out. Force the preflight via the
258
+ // whichOverride seam against a REAL git repo so the only failure is the
259
+ // preflight.
260
+ home = tmp("mirror-put-nogit-installed-");
261
+ const { manager } = makeManager(home);
262
+ const external = tmp("mirror-put-nogit-target-");
263
+ initRepo(external);
264
+ try {
265
+ const req = new Request("http://x/admin/mirror", {
266
+ method: "PUT",
267
+ body: JSON.stringify({
268
+ enabled: true,
269
+ location: "external",
270
+ external_path: external,
271
+ }),
272
+ });
273
+ const res = await handleMirrorPut(req, manager, () => null);
274
+ expect(res.status).toBe(503);
275
+ const body = (await res.json()) as { error_type: string; message: string };
276
+ expect(body.error_type).toBe("git_not_installed");
277
+ expect(body.message).toContain("git is required");
278
+ expect(body.message).toContain("dnf install git");
279
+ } finally {
280
+ fs.rmSync(external, { recursive: true, force: true });
281
+ }
282
+ });
283
+
248
284
  test("accepts a valid external config, persists, restarts watch", async () => {
249
285
  home = tmp("mirror-put-happy-");
250
286
  const external = tmp("mirror-put-ext-");
@@ -1081,6 +1117,28 @@ const spawnCloneFail: GitSpawn = async () => ({
1081
1117
  timedOut: false,
1082
1118
  });
1083
1119
 
1120
+ /**
1121
+ * vault#416 — a MirrorManager wired to the REAL per-vault config file (so
1122
+ * `handleMirrorImport`'s `readMirrorConfigForVault` agrees with what
1123
+ * `manager.reload()` wrote) + a no-op export. Passed to `handleMirrorImport`
1124
+ * as the `managerOverride` so the sync-enable step has a live manager without
1125
+ * standing up the registry factory. Bootstrap (git init of the internal
1126
+ * mirror) runs for real; push to a fake remote fails non-fatally (we assert
1127
+ * on persisted config + credentials, not on a landed push).
1128
+ */
1129
+ function makeSyncManager(home: string): MirrorManager {
1130
+ process.env.PARACHUTE_HOME = home;
1131
+ process.env.HOME = home;
1132
+ const deps: MirrorDeps = {
1133
+ vaultName: "default",
1134
+ runExport: async () => ({ notes: 0 }),
1135
+ firstChangedNoteTitle: async () => "",
1136
+ readMirrorConfig: () => readMirrorConfigForVault("default"),
1137
+ writeMirrorConfig: (c) => writeMirrorConfigForVault("default", c),
1138
+ };
1139
+ return new MirrorManager(deps);
1140
+ }
1141
+
1084
1142
  describe("handleMirrorImport", () => {
1085
1143
  let home: string;
1086
1144
  let fixture: string;
@@ -1258,6 +1316,35 @@ describe("handleMirrorImport", () => {
1258
1316
  expect(body.message).toContain("vault.yaml");
1259
1317
  });
1260
1318
 
1319
+ test("git not installed returns 503 + git_not_installed + actionable message", async () => {
1320
+ // vault#415 — live bug on a git-less Amazon Linux EC2 box. Force the
1321
+ // preflight (via the whichOverride seam) to see no git; the spawn seam
1322
+ // should never be reached.
1323
+ home = tmp("import-route-nogit-");
1324
+ await bootstrapVault(home);
1325
+ let spawnCalled = false;
1326
+ const spyingSpawn: GitSpawn = async () => {
1327
+ spawnCalled = true;
1328
+ return { exitCode: 0, stderr: "", timedOut: false };
1329
+ };
1330
+ const req = new Request("http://x/import", {
1331
+ method: "POST",
1332
+ body: JSON.stringify({
1333
+ remote_url: "https://github.com/a/b.git",
1334
+ mode: "merge",
1335
+ credentials: { kind: "none" },
1336
+ }),
1337
+ });
1338
+ const res = await handleMirrorImport(req, "default", spyingSpawn, () => null);
1339
+ expect(res.status).toBe(503);
1340
+ const body = (await res.json()) as { error_type: string; message: string };
1341
+ expect(body.error_type).toBe("git_not_installed");
1342
+ expect(body.message).toContain("git is required");
1343
+ expect(body.message).toContain("dnf install git");
1344
+ // Failed fast: the git spawn was never reached.
1345
+ expect(spawnCalled).toBe(false);
1346
+ });
1347
+
1261
1348
  test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
1262
1349
  home = tmp("import-route-stored-creds-");
1263
1350
  await bootstrapVault(home);
@@ -1334,3 +1421,378 @@ describe("handleMirrorImport", () => {
1334
1421
  });
1335
1422
  });
1336
1423
 
1424
+ // ---------------------------------------------------------------------------
1425
+ // vault#416 — auto-enable sync to the imported repo (default-on, opt-out).
1426
+ // ---------------------------------------------------------------------------
1427
+
1428
+ describe("handleMirrorImport — auto-enable sync (vault#416)", () => {
1429
+ let home: string;
1430
+ let fixture: string;
1431
+ let manager: MirrorManager;
1432
+
1433
+ afterEach(async () => {
1434
+ if (manager) await manager.stop();
1435
+ if (home) fs.rmSync(home, { recursive: true, force: true });
1436
+ if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
1437
+ _resetImportInFlightForTest();
1438
+ clearVaultStoreCache();
1439
+ });
1440
+
1441
+ test("enable_sync true + PAT auth → mirror configured, creds persisted, auto_push on, sync_enabled true", async () => {
1442
+ home = tmp("import-sync-pat-");
1443
+ await bootstrapVault(home);
1444
+ fixture = await buildExportFixture();
1445
+ manager = makeSyncManager(home);
1446
+
1447
+ const req = new Request("http://x/import", {
1448
+ method: "POST",
1449
+ body: JSON.stringify({
1450
+ remote_url: "https://github.com/aaron/my-vault.git",
1451
+ mode: "merge",
1452
+ credentials: { kind: "pat", token: "ghp_import_token_abc" },
1453
+ enable_sync: true,
1454
+ }),
1455
+ });
1456
+ const res = await handleMirrorImport(
1457
+ req,
1458
+ "default",
1459
+ spawnCloneSuccess(fixture),
1460
+ undefined,
1461
+ manager,
1462
+ );
1463
+ expect(res.status).toBe(200);
1464
+ const body = (await res.json()) as {
1465
+ notes_imported: number;
1466
+ sync_enabled: boolean;
1467
+ sync_warning?: string;
1468
+ };
1469
+ expect(body.notes_imported).toBe(2);
1470
+ expect(body.sync_enabled).toBe(true);
1471
+ expect(body.sync_warning).toBeUndefined();
1472
+
1473
+ // Mirror config persisted with auto_push + enabled.
1474
+ const cfg = readMirrorConfigForVault("default");
1475
+ expect(cfg?.enabled).toBe(true);
1476
+ expect(cfg?.auto_push).toBe(true);
1477
+
1478
+ // Credentials persisted, pointing at the imported remote.
1479
+ const creds = readCredentials("default");
1480
+ expect(creds?.active_method).toBe("pat");
1481
+ expect(creds?.pat?.token).toBe("ghp_import_token_abc");
1482
+ expect(creds?.pat?.remote_url).toContain("github.com/aaron/my-vault.git");
1483
+ });
1484
+
1485
+ test("enable_sync false → no mirror configured, sync_enabled false, no warning", async () => {
1486
+ home = tmp("import-sync-optout-");
1487
+ await bootstrapVault(home);
1488
+ fixture = await buildExportFixture();
1489
+ manager = makeSyncManager(home);
1490
+
1491
+ const req = new Request("http://x/import", {
1492
+ method: "POST",
1493
+ body: JSON.stringify({
1494
+ remote_url: "https://github.com/aaron/my-vault.git",
1495
+ mode: "merge",
1496
+ credentials: { kind: "pat", token: "ghp_import_token_abc" },
1497
+ enable_sync: false,
1498
+ }),
1499
+ });
1500
+ const res = await handleMirrorImport(
1501
+ req,
1502
+ "default",
1503
+ spawnCloneSuccess(fixture),
1504
+ undefined,
1505
+ manager,
1506
+ );
1507
+ expect(res.status).toBe(200);
1508
+ const body = (await res.json()) as {
1509
+ sync_enabled: boolean;
1510
+ sync_warning?: string;
1511
+ };
1512
+ expect(body.sync_enabled).toBe(false);
1513
+ expect(body.sync_warning).toBeUndefined();
1514
+
1515
+ // Nothing configured.
1516
+ expect(readMirrorConfigForVault("default")).toBeUndefined();
1517
+ expect(readCredentials("default")).toBeNull();
1518
+ });
1519
+
1520
+ test("enable_sync true + auth none → sync_enabled false + needs-write-creds warning; no broken mirror", async () => {
1521
+ home = tmp("import-sync-nocreds-");
1522
+ await bootstrapVault(home);
1523
+ fixture = await buildExportFixture();
1524
+ manager = makeSyncManager(home);
1525
+
1526
+ const req = new Request("http://x/import", {
1527
+ method: "POST",
1528
+ body: JSON.stringify({
1529
+ remote_url: "https://github.com/aaron/my-vault.git",
1530
+ mode: "merge",
1531
+ credentials: { kind: "none" },
1532
+ enable_sync: true,
1533
+ }),
1534
+ });
1535
+ const res = await handleMirrorImport(
1536
+ req,
1537
+ "default",
1538
+ spawnCloneSuccess(fixture),
1539
+ undefined,
1540
+ manager,
1541
+ );
1542
+ expect(res.status).toBe(200);
1543
+ const body = (await res.json()) as {
1544
+ notes_imported: number;
1545
+ sync_enabled: boolean;
1546
+ sync_warning?: string;
1547
+ };
1548
+ // Import still succeeded.
1549
+ expect(body.notes_imported).toBe(2);
1550
+ expect(body.sync_enabled).toBe(false);
1551
+ expect(body.sync_warning).toContain("write credentials");
1552
+
1553
+ // No mirror left configured, no credentials written.
1554
+ expect(readMirrorConfigForVault("default")).toBeUndefined();
1555
+ expect(readCredentials("default")).toBeNull();
1556
+ });
1557
+
1558
+ test("enable_sync defaults to true when omitted", async () => {
1559
+ home = tmp("import-sync-default-");
1560
+ await bootstrapVault(home);
1561
+ fixture = await buildExportFixture();
1562
+ manager = makeSyncManager(home);
1563
+
1564
+ const req = new Request("http://x/import", {
1565
+ method: "POST",
1566
+ body: JSON.stringify({
1567
+ remote_url: "https://github.com/aaron/my-vault.git",
1568
+ mode: "merge",
1569
+ credentials: { kind: "pat", token: "ghp_default_on_token" },
1570
+ // enable_sync omitted — should default ON.
1571
+ }),
1572
+ });
1573
+ const res = await handleMirrorImport(
1574
+ req,
1575
+ "default",
1576
+ spawnCloneSuccess(fixture),
1577
+ undefined,
1578
+ manager,
1579
+ );
1580
+ expect(res.status).toBe(200);
1581
+ const body = (await res.json()) as { sync_enabled: boolean };
1582
+ expect(body.sync_enabled).toBe(true);
1583
+ expect(readMirrorConfigForVault("default")?.auto_push).toBe(true);
1584
+ });
1585
+
1586
+ test("existing mirror to a DIFFERENT remote → not clobbered, sync_enabled false + conflict warning", async () => {
1587
+ home = tmp("import-sync-conflict-");
1588
+ await bootstrapVault(home);
1589
+ fixture = await buildExportFixture();
1590
+ manager = makeSyncManager(home);
1591
+
1592
+ // Pre-existing mirror config (enabled) + credential pointing elsewhere.
1593
+ writeMirrorConfigForVault("default", {
1594
+ ...defaultMirrorConfig(),
1595
+ enabled: true,
1596
+ auto_push: true,
1597
+ });
1598
+ writeCredentials("default", {
1599
+ active_method: "pat",
1600
+ github_oauth: null,
1601
+ pat: {
1602
+ token: "ghp_existing_other",
1603
+ remote_url:
1604
+ "https://x-access-token:ghp_existing_other@github.com/aaron/OTHER-repo.git",
1605
+ label: "existing backup",
1606
+ },
1607
+ });
1608
+
1609
+ const req = new Request("http://x/import", {
1610
+ method: "POST",
1611
+ body: JSON.stringify({
1612
+ remote_url: "https://github.com/aaron/my-vault.git",
1613
+ mode: "merge",
1614
+ credentials: { kind: "pat", token: "ghp_import_token_abc" },
1615
+ enable_sync: true,
1616
+ }),
1617
+ });
1618
+ const res = await handleMirrorImport(
1619
+ req,
1620
+ "default",
1621
+ spawnCloneSuccess(fixture),
1622
+ undefined,
1623
+ manager,
1624
+ );
1625
+ expect(res.status).toBe(200);
1626
+ const body = (await res.json()) as {
1627
+ sync_enabled: boolean;
1628
+ sync_warning?: string;
1629
+ };
1630
+ expect(body.sync_enabled).toBe(false);
1631
+ expect(body.sync_warning).toContain("already syncs to a different repo");
1632
+
1633
+ // The existing credential was NOT clobbered.
1634
+ const creds = readCredentials("default");
1635
+ expect(creds?.pat?.token).toBe("ghp_existing_other");
1636
+ expect(creds?.pat?.remote_url).toContain("OTHER-repo.git");
1637
+ });
1638
+
1639
+ test("existing mirror to the SAME remote → no-op success (sync_enabled true)", async () => {
1640
+ home = tmp("import-sync-same-");
1641
+ await bootstrapVault(home);
1642
+ fixture = await buildExportFixture();
1643
+ manager = makeSyncManager(home);
1644
+
1645
+ writeMirrorConfigForVault("default", {
1646
+ ...defaultMirrorConfig(),
1647
+ enabled: true,
1648
+ auto_push: true,
1649
+ });
1650
+ writeCredentials("default", {
1651
+ active_method: "pat",
1652
+ github_oauth: null,
1653
+ pat: {
1654
+ token: "ghp_same_token",
1655
+ remote_url:
1656
+ "https://x-access-token:ghp_same_token@github.com/aaron/my-vault.git",
1657
+ label: "existing same",
1658
+ },
1659
+ });
1660
+
1661
+ const req = new Request("http://x/import", {
1662
+ method: "POST",
1663
+ body: JSON.stringify({
1664
+ remote_url: "https://github.com/aaron/my-vault.git",
1665
+ mode: "merge",
1666
+ credentials: { kind: "pat", token: "ghp_same_token" },
1667
+ enable_sync: true,
1668
+ }),
1669
+ });
1670
+ const res = await handleMirrorImport(
1671
+ req,
1672
+ "default",
1673
+ spawnCloneSuccess(fixture),
1674
+ undefined,
1675
+ manager,
1676
+ );
1677
+ expect(res.status).toBe(200);
1678
+ const body = (await res.json()) as {
1679
+ sync_enabled: boolean;
1680
+ sync_warning?: string;
1681
+ };
1682
+ expect(body.sync_enabled).toBe(true);
1683
+ expect(body.sync_warning).toBeUndefined();
1684
+ });
1685
+
1686
+ test("existing GitHub-connected mirror → PAT import doesn't clobber it (sync_enabled false + warning)", async () => {
1687
+ home = tmp("import-sync-oauth-conflict-");
1688
+ await bootstrapVault(home);
1689
+ fixture = await buildExportFixture();
1690
+ manager = makeSyncManager(home);
1691
+
1692
+ writeMirrorConfigForVault("default", {
1693
+ ...defaultMirrorConfig(),
1694
+ enabled: true,
1695
+ auto_push: true,
1696
+ });
1697
+ writeCredentials("default", {
1698
+ active_method: "github_oauth",
1699
+ github_oauth: {
1700
+ access_token: "gho_existing_oauth",
1701
+ scope: "repo",
1702
+ authorized_at: "2026-05-28T00:00:00.000Z",
1703
+ user_login: "aaron",
1704
+ user_id: 1,
1705
+ },
1706
+ pat: null,
1707
+ });
1708
+
1709
+ const req = new Request("http://x/import", {
1710
+ method: "POST",
1711
+ body: JSON.stringify({
1712
+ remote_url: "https://github.com/aaron/my-vault.git",
1713
+ mode: "merge",
1714
+ credentials: { kind: "pat", token: "ghp_import_token_abc" },
1715
+ enable_sync: true,
1716
+ }),
1717
+ });
1718
+ const res = await handleMirrorImport(
1719
+ req,
1720
+ "default",
1721
+ spawnCloneSuccess(fixture),
1722
+ undefined,
1723
+ manager,
1724
+ );
1725
+ expect(res.status).toBe(200);
1726
+ const body = (await res.json()) as {
1727
+ sync_enabled: boolean;
1728
+ sync_warning?: string;
1729
+ };
1730
+ expect(body.sync_enabled).toBe(false);
1731
+ expect(body.sync_warning).toContain("connected GitHub account");
1732
+
1733
+ // The existing OAuth credential is untouched (not switched to a PAT).
1734
+ const creds = readCredentials("default");
1735
+ expect(creds?.active_method).toBe("github_oauth");
1736
+ expect(creds?.pat).toBeNull();
1737
+ });
1738
+
1739
+ test("sync-setup failure after a successful import → import result still returned, sync_enabled false + warning", async () => {
1740
+ home = tmp("import-sync-setupfail-");
1741
+ await bootstrapVault(home);
1742
+ fixture = await buildExportFixture();
1743
+ manager = makeSyncManager(home);
1744
+
1745
+ // Force the sync-enable step to throw by stubbing the manager's reload.
1746
+ // The import itself has already succeeded by the time reload runs, so the
1747
+ // request must still return a 200 with the import counts intact.
1748
+ manager.reload = async () => {
1749
+ throw new Error("boom: simulated reload failure");
1750
+ };
1751
+
1752
+ const req = new Request("http://x/import", {
1753
+ method: "POST",
1754
+ body: JSON.stringify({
1755
+ remote_url: "https://github.com/aaron/my-vault.git",
1756
+ mode: "merge",
1757
+ credentials: { kind: "pat", token: "ghp_import_token_abc" },
1758
+ enable_sync: true,
1759
+ }),
1760
+ });
1761
+ const res = await handleMirrorImport(
1762
+ req,
1763
+ "default",
1764
+ spawnCloneSuccess(fixture),
1765
+ undefined,
1766
+ manager,
1767
+ );
1768
+ expect(res.status).toBe(200);
1769
+ const body = (await res.json()) as {
1770
+ notes_imported: number;
1771
+ sync_enabled: boolean;
1772
+ sync_warning?: string;
1773
+ };
1774
+ // Import NOT lost.
1775
+ expect(body.notes_imported).toBe(2);
1776
+ expect(body.sync_enabled).toBe(false);
1777
+ expect(body.sync_warning).toContain("enabling Sync failed");
1778
+ });
1779
+
1780
+ test("invalid enable_sync type → 400 validation error", async () => {
1781
+ home = tmp("import-sync-badtype-");
1782
+ await bootstrapVault(home);
1783
+ const req = new Request("http://x/import", {
1784
+ method: "POST",
1785
+ body: JSON.stringify({
1786
+ remote_url: "https://github.com/aaron/my-vault.git",
1787
+ mode: "merge",
1788
+ credentials: { kind: "none" },
1789
+ enable_sync: "yes",
1790
+ }),
1791
+ });
1792
+ const res = await handleMirrorImport(req, "default");
1793
+ expect(res.status).toBe(400);
1794
+ const body = (await res.json()) as { field: string };
1795
+ expect(body.field).toBe("enable_sync");
1796
+ });
1797
+ });
1798
+