@openparachute/vault 0.5.0-rc.2 → 0.5.0-rc.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.0-rc.2",
3
+ "version": "0.5.0-rc.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -297,6 +297,10 @@ describe("cloneAndImport — success", () => {
297
297
  expect(result.notes_imported).toBe(2); // alpha + beta
298
298
  expect(result.notes_deleted).toBeUndefined();
299
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();
300
304
 
301
305
  const restored = await store.getNote("n-alpha");
302
306
  expect(restored).toBeTruthy();
@@ -154,6 +154,29 @@ export interface ImportResult {
154
154
  * etc.). The HTTP handler returns these so the operator can audit.
155
155
  */
156
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;
157
180
  }
158
181
 
159
182
  /**
@@ -500,6 +523,10 @@ function importResultFromStats(
500
523
  tags_imported: stats.schemas_restored,
501
524
  attachments_imported: stats.attachments_restored,
502
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,
503
530
  };
504
531
  if (mode === "replace") {
505
532
  result.notes_deleted = stats.notes_wiped;
@@ -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,
@@ -1112,6 +1117,28 @@ const spawnCloneFail: GitSpawn = async () => ({
1112
1117
  timedOut: false,
1113
1118
  });
1114
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
+
1115
1142
  describe("handleMirrorImport", () => {
1116
1143
  let home: string;
1117
1144
  let fixture: string;
@@ -1394,3 +1421,378 @@ describe("handleMirrorImport", () => {
1394
1421
  });
1395
1422
  });
1396
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
+