@indigoai-us/hq-cloud 5.17.0 → 5.19.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 (53) hide show
  1. package/.github/workflows/ci.yml +19 -0
  2. package/.github/workflows/publish.yml +53 -0
  3. package/dist/cli/invite.js +4 -1
  4. package/dist/cli/invite.js.map +1 -1
  5. package/dist/cli/invite.test.js +3 -0
  6. package/dist/cli/invite.test.js.map +1 -1
  7. package/dist/cli/promote.js +3 -0
  8. package/dist/cli/promote.js.map +1 -1
  9. package/dist/cli/share.d.ts +7 -5
  10. package/dist/cli/share.d.ts.map +1 -1
  11. package/dist/cli/share.js +189 -18
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +304 -3
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts.map +1 -1
  16. package/dist/cli/sync.js +98 -17
  17. package/dist/cli/sync.js.map +1 -1
  18. package/dist/cli/sync.test.js +314 -0
  19. package/dist/cli/sync.test.js.map +1 -1
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +107 -18
  22. package/dist/context.js.map +1 -1
  23. package/dist/context.test.js +63 -14
  24. package/dist/context.test.js.map +1 -1
  25. package/dist/journal.d.ts +26 -0
  26. package/dist/journal.d.ts.map +1 -1
  27. package/dist/journal.js +31 -0
  28. package/dist/journal.js.map +1 -1
  29. package/dist/s3.d.ts +91 -0
  30. package/dist/s3.d.ts.map +1 -1
  31. package/dist/s3.js +245 -0
  32. package/dist/s3.js.map +1 -1
  33. package/dist/s3.test.js +347 -1
  34. package/dist/s3.test.js.map +1 -1
  35. package/dist/vault-client.d.ts +24 -0
  36. package/dist/vault-client.d.ts.map +1 -1
  37. package/dist/vault-client.js +29 -0
  38. package/dist/vault-client.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/invite.test.ts +3 -0
  41. package/src/cli/invite.ts +4 -1
  42. package/src/cli/promote.ts +3 -0
  43. package/src/cli/share.test.ts +377 -3
  44. package/src/cli/share.ts +241 -28
  45. package/src/cli/sync.test.ts +357 -0
  46. package/src/cli/sync.ts +133 -24
  47. package/src/context.test.ts +73 -14
  48. package/src/context.ts +116 -20
  49. package/src/journal.ts +33 -0
  50. package/src/s3.test.ts +415 -1
  51. package/src/s3.ts +271 -0
  52. package/src/vault-client.ts +37 -0
  53. package/tsconfig.json +12 -1
@@ -11,9 +11,12 @@ import type { VaultServiceConfig } from "../types.js";
11
11
 
12
12
  // Mock s3 module at the top level. uploadFile resolves to a synthetic ETag
13
13
  // so share() can record it on the journal entry — the real PutObject
14
- // response shape is `{ ETag: '"<hex>"' }`.
14
+ // response shape is `{ ETag: '"<hex>"' }`. uploadSymlink is the symlink-
15
+ // preserving sibling that puts a zero-byte object with target metadata
16
+ // instead of dereferencing the link.
15
17
  vi.mock("../s3.js", () => ({
16
18
  uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
19
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
17
20
  downloadFile: vi.fn().mockResolvedValue(undefined),
18
21
  listRemoteFiles: vi.fn().mockResolvedValue([]),
19
22
  deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
@@ -21,7 +24,7 @@ vi.mock("../s3.js", () => ({
21
24
  }));
22
25
 
23
26
  import { share } from "./share.js";
24
- import { deleteRemoteFile, headRemoteFile, uploadFile } from "../s3.js";
27
+ import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
25
28
  import type { EntityContext } from "../types.js";
26
29
 
27
30
  const mockConfig: VaultServiceConfig = {
@@ -73,7 +76,18 @@ const mockVendResponse = {
73
76
  function setupFetchMock() {
74
77
  const fetchMock = vi.fn().mockImplementation(async (url: string) => {
75
78
  const urlStr = String(url);
76
- if (urlStr.includes("/entity/by-slug/")) {
79
+ // New namespace-aware slug resolver (hq-pro PR 67). Maps slug
80
+ // lookups to `{available: false, conflictingCompanyUid}`; the
81
+ // follow-up `/entity/{uid}` lands in the `/entity/cmp_` branch.
82
+ if (urlStr.includes("/entity/check-slug/me")) {
83
+ return {
84
+ ok: true,
85
+ status: 200,
86
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
87
+ text: async () => "",
88
+ };
89
+ }
90
+ if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
77
91
  return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
78
92
  }
79
93
  if (urlStr.includes("/sts/vend")) {
@@ -106,6 +120,7 @@ describe("share", () => {
106
120
  // clearAllMocks wipes the default ETag impl set in vi.mock(), so
107
121
  // re-prime it for the next test.
108
122
  vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
123
+ vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
109
124
  vi.mocked(headRemoteFile).mockResolvedValue(null);
110
125
  fs.rmSync(tmpDir, { recursive: true, force: true });
111
126
  fs.rmSync(stateDir, { recursive: true, force: true });
@@ -855,6 +870,110 @@ describe("share", () => {
855
870
  // versioning enabled, so DeleteObject is soft (a delete-marker becomes the
856
871
  // current version; prior object versions remain recoverable).
857
872
 
873
+ it("propagateDeletes: deletes a journal entry whose key matches only a dir-only allowlist (dual-hint shouldSync)", async () => {
874
+ // Codex round-8 P2 follow-up: third instance of the dual-hint
875
+ // pattern. By the time computeDeletePlan considers an entry for
876
+ // remote deletion, the local file is already gone — we don't know
877
+ // whether it was a regular file or a symlink record. Pre-fix, the
878
+ // single isDir=false probe of shouldSync rejected paths whose
879
+ // only matching .hqinclude pattern was dir-only (e.g.
880
+ // `companies/*/knowledge/`), so a deleted symlink at such a path
881
+ // would leave the remote record orphaned forever. Mirrors the
882
+ // walkDir/collectFiles (push) and computePullPlan (pull) fixes.
883
+ const companyRoot = path.join(tmpDir, "companies", "acme");
884
+ fs.mkdirSync(companyRoot, { recursive: true });
885
+ // Allowlist with ONLY a dir-only pattern. Without the dual-hint
886
+ // probe, the slashless probe wouldn't match and the entry would
887
+ // not be queued for delete.
888
+ fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
889
+
890
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
891
+ fs.writeFileSync(
892
+ journalPath,
893
+ JSON.stringify({
894
+ version: "1",
895
+ lastSync: new Date().toISOString(),
896
+ files: {
897
+ // Locally absent — was a symlink record. Should
898
+ // delete-propagate now that the path is no longer ignored
899
+ // under the dual-hint probe.
900
+ knowledge: {
901
+ hash: "irrelevant-not-checked",
902
+ size: 0,
903
+ syncedAt: new Date().toISOString(),
904
+ direction: "up",
905
+ remoteEtag: "knowledge-etag",
906
+ },
907
+ },
908
+ }),
909
+ );
910
+
911
+ const result = await share({
912
+ paths: [companyRoot],
913
+ company: "acme",
914
+ vaultConfig: mockConfig,
915
+ hqRoot: tmpDir,
916
+ skipUnchanged: true,
917
+ propagateDeletes: true,
918
+ });
919
+
920
+ expect(result.filesDeleted).toBe(1);
921
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
922
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
923
+ expect(journal.files["knowledge"]).toBeUndefined();
924
+ });
925
+
926
+ it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
927
+ // Codex round-3 P2 follow-up: pre-fix, computeDeletePlan used
928
+ // fs.existsSync which follows symlinks → returns false for a
929
+ // dangling link → the link's journal entry was queued for remote
930
+ // DeleteObject in the same sync cycle that just uploaded it via
931
+ // uploadSymlink. The link round-tripped as "upload, then delete"
932
+ // in one pass. Switching to lstat means the link file itself
933
+ // counts as locally present even when its target is missing.
934
+ const companyRoot = path.join(tmpDir, "companies", "acme");
935
+ fs.mkdirSync(companyRoot, { recursive: true });
936
+ // Dangling symlink: target file deliberately not created.
937
+ const danglingLink = path.join(companyRoot, "dangling-link.md");
938
+ fs.symlinkSync("./missing-target.md", danglingLink);
939
+
940
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
941
+ fs.writeFileSync(
942
+ journalPath,
943
+ JSON.stringify({
944
+ version: "1",
945
+ lastSync: new Date().toISOString(),
946
+ files: {
947
+ "dangling-link.md": {
948
+ // sha256("./missing-target.md") so the planner's
949
+ // skipUnchanged gate would also see this as unchanged on
950
+ // a normal upload pass.
951
+ hash: "irrelevant-not-checked-here",
952
+ size: 0,
953
+ syncedAt: new Date().toISOString(),
954
+ direction: "up",
955
+ remoteEtag: "dangling-etag",
956
+ },
957
+ },
958
+ }),
959
+ );
960
+
961
+ const result = await share({
962
+ paths: [companyRoot],
963
+ company: "acme",
964
+ vaultConfig: mockConfig,
965
+ hqRoot: tmpDir,
966
+ skipUnchanged: true,
967
+ propagateDeletes: true,
968
+ });
969
+
970
+ expect(result.filesDeleted).toBe(0);
971
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
972
+ // Journal entry must survive the run.
973
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
974
+ expect(journal.files["dangling-link.md"]).toBeDefined();
975
+ });
976
+
858
977
  it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
859
978
  const companyRoot = path.join(tmpDir, "companies", "acme");
860
979
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -1373,4 +1492,259 @@ describe("share", () => {
1373
1492
  expect(uploadFile).not.toHaveBeenCalled();
1374
1493
  });
1375
1494
  });
1495
+
1496
+ describe("symlinks", () => {
1497
+ // Pre-fix bug: collectFiles called fs.statSync (which dereferences) on
1498
+ // top-level paths and walkDir relied on Dirent.isFile()/isDirectory()
1499
+ // (both false for symlinks), so a top-level symlink got uploaded as the
1500
+ // target's bytes under the link's key while a nested symlink was
1501
+ // silently dropped from every push. The link topology never survived a
1502
+ // round trip — fresh-machine pulls landed in a state where overlay
1503
+ // symlinks just didn't exist until master-sync.sh recreated them
1504
+ // locally. The fix detects symlinks via lstat / Dirent.isSymbolicLink
1505
+ // and routes them to a new uploadSymlink primitive that PUTs a
1506
+ // zero-byte object with x-amz-meta-hq-symlink-target carrying the
1507
+ // readlink string verbatim.
1508
+
1509
+ it("uploads a top-level symlink as a symlink record (not the target's bytes)", async () => {
1510
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1511
+ fs.mkdirSync(companyRoot, { recursive: true });
1512
+ const target = path.join(companyRoot, "target.md");
1513
+ fs.writeFileSync(target, "I am the target");
1514
+ const link = path.join(companyRoot, "link.md");
1515
+ fs.symlinkSync("target.md", link);
1516
+
1517
+ const result = await share({
1518
+ paths: [link],
1519
+ company: "acme",
1520
+ vaultConfig: mockConfig,
1521
+ hqRoot: tmpDir,
1522
+ });
1523
+
1524
+ expect(result.filesUploaded).toBe(1);
1525
+ expect(uploadSymlink).toHaveBeenCalledWith(
1526
+ expect.anything(),
1527
+ "target.md",
1528
+ "link.md",
1529
+ );
1530
+ // The link itself must NOT be uploaded as a regular file. Pre-fix,
1531
+ // fs.statSync(link) followed the link and uploadFile got called with
1532
+ // the link's path → cloud stored a copy of target.md's bytes under
1533
+ // the key "link.md", silently flattening the link.
1534
+ const fileCalls = vi.mocked(uploadFile).mock.calls.filter(
1535
+ (c) => c[2] === "link.md",
1536
+ );
1537
+ expect(fileCalls).toHaveLength(0);
1538
+ });
1539
+
1540
+ it("uploads a nested symlink discovered during walkDir as a symlink record", async () => {
1541
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1542
+ const policiesDir = path.join(companyRoot, "policies");
1543
+ fs.mkdirSync(policiesDir, { recursive: true });
1544
+ const realPolicy = path.join(policiesDir, "real.md");
1545
+ fs.writeFileSync(realPolicy, "real content");
1546
+ const linkPolicy = path.join(policiesDir, "link.md");
1547
+ // Mirrors the master-sync.sh overlay shape: relative target pointing
1548
+ // to a sibling in the same dir.
1549
+ fs.symlinkSync("real.md", linkPolicy);
1550
+
1551
+ const result = await share({
1552
+ paths: [companyRoot],
1553
+ company: "acme",
1554
+ vaultConfig: mockConfig,
1555
+ hqRoot: tmpDir,
1556
+ });
1557
+
1558
+ // Two uploads: one regular file (real.md), one symlink record (link.md).
1559
+ // Pre-fix, walkDir's Dirent.isFile() returned false for the symlink and
1560
+ // it was silently dropped — only the real file was uploaded.
1561
+ expect(result.filesUploaded).toBe(2);
1562
+ expect(uploadFile).toHaveBeenCalledWith(
1563
+ expect.anything(),
1564
+ realPolicy,
1565
+ "policies/real.md",
1566
+ );
1567
+ expect(uploadSymlink).toHaveBeenCalledWith(
1568
+ expect.anything(),
1569
+ "real.md",
1570
+ "policies/link.md",
1571
+ );
1572
+ });
1573
+
1574
+ it("accepts a symlink inside the company folder even when its target lives outside", async () => {
1575
+ // Codex P2 follow-up: pre-fix, isWithin canonicalized the link
1576
+ // via realpathSync, so a directory symlink whose target lived
1577
+ // outside the company folder (the canonical motivating shape:
1578
+ // companies/{co}/knowledge → repos/private/knowledge-{co}/) was
1579
+ // rejected with "outside company folder" and the link was
1580
+ // dropped entirely. The link's own pathname is inside the
1581
+ // company folder; that's what containment must compare against.
1582
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1583
+ fs.mkdirSync(companyRoot, { recursive: true });
1584
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
1585
+ fs.mkdirSync(externalTarget, { recursive: true });
1586
+ const linkPath = path.join(companyRoot, "knowledge");
1587
+ fs.symlinkSync(externalTarget, linkPath);
1588
+
1589
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1590
+ const result = await share({
1591
+ paths: [linkPath],
1592
+ company: "acme",
1593
+ vaultConfig: mockConfig,
1594
+ hqRoot: tmpDir,
1595
+ });
1596
+ warnSpy.mockRestore();
1597
+
1598
+ expect(result.filesUploaded).toBe(1);
1599
+ expect(uploadSymlink).toHaveBeenCalledWith(
1600
+ expect.anything(),
1601
+ externalTarget,
1602
+ "knowledge",
1603
+ );
1604
+ // No "outside company folder" warning for this case.
1605
+ expect(warnSpy).not.toHaveBeenCalledWith(
1606
+ expect.stringMatching(/outside company folder/i),
1607
+ );
1608
+ });
1609
+
1610
+ it("uploads a symlink even when its target string equals the prior regular file's contents (skipUnchanged distinguishability)", async () => {
1611
+ // Codex round-5 P1 follow-up: pre-fix, the journal hash for a
1612
+ // symlink was sha256(target). For a key whose journal entry was
1613
+ // a regular file containing the bytes "real.md", a local
1614
+ // replacement with a symlink → "real.md" produced the same
1615
+ // hash, so skipUnchanged short-circuited the upload and the
1616
+ // representation drift never propagated. The pull side also
1617
+ // saw no etag change (because we never uploaded), so the remote
1618
+ // never repaired. Fix: hash symlinks as sha256("hq-symlink:" +
1619
+ // target), making the two representations structurally
1620
+ // inequal in journal-hash space.
1621
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1622
+ fs.mkdirSync(companyRoot, { recursive: true });
1623
+ const target = "real.md";
1624
+ // Pre-stamp the journal as if the prior sync had uploaded a
1625
+ // regular file whose contents were exactly the target string.
1626
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1627
+ const crypto = await import("crypto");
1628
+ const regularFileHash = crypto
1629
+ .createHash("sha256")
1630
+ .update(target)
1631
+ .digest("hex");
1632
+ fs.writeFileSync(
1633
+ journalPath,
1634
+ JSON.stringify({
1635
+ version: "1",
1636
+ lastSync: new Date().toISOString(),
1637
+ files: {
1638
+ "case-key.md": {
1639
+ hash: regularFileHash,
1640
+ size: target.length,
1641
+ syncedAt: new Date().toISOString(),
1642
+ direction: "up",
1643
+ remoteEtag: "regular-file-etag",
1644
+ },
1645
+ },
1646
+ }),
1647
+ );
1648
+ // Now locally, the user has replaced that key with a symlink to
1649
+ // the same target string.
1650
+ fs.symlinkSync(target, path.join(companyRoot, "case-key.md"));
1651
+
1652
+ const result = await share({
1653
+ paths: [companyRoot],
1654
+ company: "acme",
1655
+ vaultConfig: mockConfig,
1656
+ hqRoot: tmpDir,
1657
+ skipUnchanged: true, // the gate the bug lives behind
1658
+ });
1659
+
1660
+ // Upload MUST happen — the hash-namespace prefix means the
1661
+ // symlink's journal hash differs from the regular-file hash.
1662
+ expect(result.filesUploaded).toBe(1);
1663
+ expect(result.filesSkipped).toBe(0);
1664
+ expect(uploadSymlink).toHaveBeenCalledWith(
1665
+ expect.anything(),
1666
+ target,
1667
+ "case-key.md",
1668
+ );
1669
+ });
1670
+
1671
+ it("includes a directory symlink whose only matching allowlist pattern is dir-only", async () => {
1672
+ // Codex round-6 P1 follow-up: pre-fix, walkDir called the filter
1673
+ // with isDir = entry.isDirectory(), which returns false for any
1674
+ // symlink — including directory symlinks. With an .hqinclude
1675
+ // dir-only allowlist pattern like `companies/*/knowledge/`, the
1676
+ // filter's `ignore.ignores('foo')` vs `ignore.ignores('foo/')`
1677
+ // distinction means the slashless probe doesn't match and the
1678
+ // symlink is dropped before reaching the record-only branch.
1679
+ // Same story for collectFiles' top-level path classification.
1680
+ // Fix: probe the filter with both isDir hints for symlinks; the
1681
+ // filter is pure path lookup, so two calls cost nothing.
1682
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1683
+ fs.mkdirSync(companyRoot, { recursive: true });
1684
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
1685
+ fs.mkdirSync(externalTarget, { recursive: true });
1686
+ // Create the directory symlink AND a sibling regular file at the
1687
+ // same depth. The .hqinclude pattern matches only the dir-only
1688
+ // form; the regular file should NOT make it past the filter,
1689
+ // proving the include rule actually applies (otherwise this
1690
+ // test would pass for the wrong reason).
1691
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
1692
+ fs.writeFileSync(path.join(companyRoot, "stray.md"), "not in allowlist");
1693
+ // Allowlist mode: only `companies/*/knowledge/` is in scope.
1694
+ fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
1695
+
1696
+ const result = await share({
1697
+ paths: [companyRoot],
1698
+ company: "acme",
1699
+ vaultConfig: mockConfig,
1700
+ hqRoot: tmpDir,
1701
+ });
1702
+
1703
+ // The symlink record uploaded; the sibling regular file did NOT.
1704
+ expect(result.filesUploaded).toBe(1);
1705
+ expect(uploadSymlink).toHaveBeenCalledWith(
1706
+ expect.anything(),
1707
+ externalTarget,
1708
+ "knowledge",
1709
+ );
1710
+ expect(uploadFile).not.toHaveBeenCalledWith(
1711
+ expect.anything(),
1712
+ expect.stringContaining("stray.md"),
1713
+ expect.anything(),
1714
+ );
1715
+ });
1716
+
1717
+ it("does not recurse into directory symlinks (record-only)", async () => {
1718
+ // Following directory symlinks during walk would duplicate content
1719
+ // into the wrong vault path — exactly what the legacy "silently
1720
+ // skipped" topology accidentally prevented. The fix preserves that
1721
+ // topology while now actually recording the link.
1722
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1723
+ fs.mkdirSync(companyRoot, { recursive: true });
1724
+ const realDir = path.join(tmpDir, "outside");
1725
+ fs.mkdirSync(realDir, { recursive: true });
1726
+ fs.writeFileSync(path.join(realDir, "secret.md"), "do not upload me");
1727
+
1728
+ const linkDir = path.join(companyRoot, "linked-dir");
1729
+ fs.symlinkSync(realDir, linkDir);
1730
+
1731
+ const result = await share({
1732
+ paths: [companyRoot],
1733
+ company: "acme",
1734
+ vaultConfig: mockConfig,
1735
+ hqRoot: tmpDir,
1736
+ });
1737
+
1738
+ // The link record itself uploads; the target's contents do NOT.
1739
+ expect(result.filesUploaded).toBe(1);
1740
+ expect(uploadSymlink).toHaveBeenCalledWith(
1741
+ expect.anything(),
1742
+ realDir,
1743
+ "linked-dir",
1744
+ );
1745
+ // Crucially, no upload of the dir's contents.
1746
+ const calls = vi.mocked(uploadFile).mock.calls;
1747
+ expect(calls.find((c) => c[2].includes("secret.md"))).toBeUndefined();
1748
+ });
1749
+ });
1376
1750
  });