@indigoai-us/hq-cloud 5.16.0 → 5.18.1

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.
@@ -8,16 +8,19 @@ import * as os from "os";
8
8
  import { clearContextCache } from "../context.js";
9
9
  // Mock s3 module at the top level. uploadFile resolves to a synthetic ETag
10
10
  // so share() can record it on the journal entry — the real PutObject
11
- // response shape is `{ ETag: '"<hex>"' }`.
11
+ // response shape is `{ ETag: '"<hex>"' }`. uploadSymlink is the symlink-
12
+ // preserving sibling that puts a zero-byte object with target metadata
13
+ // instead of dereferencing the link.
12
14
  vi.mock("../s3.js", () => ({
13
15
  uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
16
+ uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
14
17
  downloadFile: vi.fn().mockResolvedValue(undefined),
15
18
  listRemoteFiles: vi.fn().mockResolvedValue([]),
16
19
  deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
17
20
  headRemoteFile: vi.fn().mockResolvedValue(null),
18
21
  }));
19
22
  import { share } from "./share.js";
20
- import { deleteRemoteFile, headRemoteFile, uploadFile } from "../s3.js";
23
+ import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
21
24
  const mockConfig = {
22
25
  apiUrl: "https://vault-api.test",
23
26
  authToken: "test-jwt-token",
@@ -93,6 +96,7 @@ describe("share", () => {
93
96
  // clearAllMocks wipes the default ETag impl set in vi.mock(), so
94
97
  // re-prime it for the next test.
95
98
  vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
99
+ vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
96
100
  vi.mocked(headRemoteFile).mockResolvedValue(null);
97
101
  fs.rmSync(tmpDir, { recursive: true, force: true });
98
102
  fs.rmSync(stateDir, { recursive: true, force: true });
@@ -690,6 +694,96 @@ describe("share", () => {
690
694
  // propagate local deletes to S3 on the push side. The vault buckets have
691
695
  // versioning enabled, so DeleteObject is soft (a delete-marker becomes the
692
696
  // current version; prior object versions remain recoverable).
697
+ it("propagateDeletes: deletes a journal entry whose key matches only a dir-only allowlist (dual-hint shouldSync)", async () => {
698
+ // Codex round-8 P2 follow-up: third instance of the dual-hint
699
+ // pattern. By the time computeDeletePlan considers an entry for
700
+ // remote deletion, the local file is already gone — we don't know
701
+ // whether it was a regular file or a symlink record. Pre-fix, the
702
+ // single isDir=false probe of shouldSync rejected paths whose
703
+ // only matching .hqinclude pattern was dir-only (e.g.
704
+ // `companies/*/knowledge/`), so a deleted symlink at such a path
705
+ // would leave the remote record orphaned forever. Mirrors the
706
+ // walkDir/collectFiles (push) and computePullPlan (pull) fixes.
707
+ const companyRoot = path.join(tmpDir, "companies", "acme");
708
+ fs.mkdirSync(companyRoot, { recursive: true });
709
+ // Allowlist with ONLY a dir-only pattern. Without the dual-hint
710
+ // probe, the slashless probe wouldn't match and the entry would
711
+ // not be queued for delete.
712
+ fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
713
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
714
+ fs.writeFileSync(journalPath, JSON.stringify({
715
+ version: "1",
716
+ lastSync: new Date().toISOString(),
717
+ files: {
718
+ // Locally absent — was a symlink record. Should
719
+ // delete-propagate now that the path is no longer ignored
720
+ // under the dual-hint probe.
721
+ knowledge: {
722
+ hash: "irrelevant-not-checked",
723
+ size: 0,
724
+ syncedAt: new Date().toISOString(),
725
+ direction: "up",
726
+ remoteEtag: "knowledge-etag",
727
+ },
728
+ },
729
+ }));
730
+ const result = await share({
731
+ paths: [companyRoot],
732
+ company: "acme",
733
+ vaultConfig: mockConfig,
734
+ hqRoot: tmpDir,
735
+ skipUnchanged: true,
736
+ propagateDeletes: true,
737
+ });
738
+ expect(result.filesDeleted).toBe(1);
739
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
740
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
741
+ expect(journal.files["knowledge"]).toBeUndefined();
742
+ });
743
+ it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
744
+ // Codex round-3 P2 follow-up: pre-fix, computeDeletePlan used
745
+ // fs.existsSync which follows symlinks → returns false for a
746
+ // dangling link → the link's journal entry was queued for remote
747
+ // DeleteObject in the same sync cycle that just uploaded it via
748
+ // uploadSymlink. The link round-tripped as "upload, then delete"
749
+ // in one pass. Switching to lstat means the link file itself
750
+ // counts as locally present even when its target is missing.
751
+ const companyRoot = path.join(tmpDir, "companies", "acme");
752
+ fs.mkdirSync(companyRoot, { recursive: true });
753
+ // Dangling symlink: target file deliberately not created.
754
+ const danglingLink = path.join(companyRoot, "dangling-link.md");
755
+ fs.symlinkSync("./missing-target.md", danglingLink);
756
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
757
+ fs.writeFileSync(journalPath, JSON.stringify({
758
+ version: "1",
759
+ lastSync: new Date().toISOString(),
760
+ files: {
761
+ "dangling-link.md": {
762
+ // sha256("./missing-target.md") so the planner's
763
+ // skipUnchanged gate would also see this as unchanged on
764
+ // a normal upload pass.
765
+ hash: "irrelevant-not-checked-here",
766
+ size: 0,
767
+ syncedAt: new Date().toISOString(),
768
+ direction: "up",
769
+ remoteEtag: "dangling-etag",
770
+ },
771
+ },
772
+ }));
773
+ const result = await share({
774
+ paths: [companyRoot],
775
+ company: "acme",
776
+ vaultConfig: mockConfig,
777
+ hqRoot: tmpDir,
778
+ skipUnchanged: true,
779
+ propagateDeletes: true,
780
+ });
781
+ expect(result.filesDeleted).toBe(0);
782
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
783
+ // Journal entry must survive the run.
784
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
785
+ expect(journal.files["dangling-link.md"]).toBeDefined();
786
+ });
693
787
  it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
694
788
  const companyRoot = path.join(tmpDir, "companies", "acme");
695
789
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -873,6 +967,128 @@ describe("share", () => {
873
967
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
874
968
  expect(journal.files["flaky.md"]).toBeDefined();
875
969
  });
970
+ // ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
971
+ //
972
+ // The pre-fix planner converted any journal-tracked path whose local file
973
+ // was missing into a `DeleteObject`. Two real-world failure modes followed:
974
+ //
975
+ // B. A behind machine's first `sync now` ran push-before-pull. Its
976
+ // journal had `direction: "down"` entries for files it had pulled
977
+ // historically (or seeded from another machine); the moment a peer
978
+ // uploaded a new file, the behind machine's next push concluded
979
+ // "local missing → delete" and erased the peer's upload from the
980
+ // vault before the pull leg ever ran.
981
+ //
982
+ // C. The pull's ignore filter and the push's local walk used the same
983
+ // `createIgnoreFilter(hqRoot)` symbol, but `hqRoot` differed across
984
+ // machines (older HQ layout had `knowledge/public/` and
985
+ // `workers/public/` at top level; the post-v14 layout collapses
986
+ // them under `core/`). When a new-layout machine pulled, the
987
+ // old-layout paths were filtered locally; the journal still
988
+ // recorded them as "down"; the next push concluded "local missing
989
+ // → delete" and erased other machines' content from the vault.
990
+ //
991
+ // Both fixes live in `computeDeletePlan` and are belt-and-suspenders:
992
+ // (i) `direction === "up"` requirement under the default policy.
993
+ // (ii) `shouldSync` must accept the key — same filter the pull uses.
994
+ it("propagateDeletes: under owned-only (default), skips direction:'down' entries", async () => {
995
+ const companyRoot = path.join(tmpDir, "companies", "acme");
996
+ fs.mkdirSync(companyRoot, { recursive: true });
997
+ // No local files. One journal entry pulled from elsewhere (direction:'down')
998
+ // and one this machine uploaded (direction:'up'). Only the 'up' one should
999
+ // be eligible for delete-propagation.
1000
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1001
+ fs.writeFileSync(journalPath, JSON.stringify({
1002
+ version: "1",
1003
+ lastSync: new Date().toISOString(),
1004
+ files: {
1005
+ "pulled-from-peer.md": {
1006
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1007
+ direction: "down",
1008
+ },
1009
+ "i-uploaded.md": {
1010
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1011
+ direction: "up",
1012
+ },
1013
+ },
1014
+ }));
1015
+ const result = await share({
1016
+ paths: [companyRoot],
1017
+ company: "acme",
1018
+ vaultConfig: mockConfig,
1019
+ hqRoot: tmpDir,
1020
+ skipUnchanged: true,
1021
+ propagateDeletes: true,
1022
+ // propagateDeletePolicy omitted ⇒ defaults to "owned-only".
1023
+ });
1024
+ // Only the 'up' entry is deleted; the 'down' entry is left alone so a
1025
+ // behind-machine first-sync can't erase peer uploads.
1026
+ expect(result.filesDeleted).toBe(1);
1027
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
1028
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "i-uploaded.md");
1029
+ });
1030
+ it("propagateDeletes: policy:'all' opts back into legacy any-direction deletes", async () => {
1031
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1032
+ fs.mkdirSync(companyRoot, { recursive: true });
1033
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1034
+ fs.writeFileSync(journalPath, JSON.stringify({
1035
+ version: "1",
1036
+ lastSync: new Date().toISOString(),
1037
+ files: {
1038
+ "pulled.md": {
1039
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1040
+ direction: "down",
1041
+ },
1042
+ },
1043
+ }));
1044
+ const result = await share({
1045
+ paths: [companyRoot],
1046
+ company: "acme",
1047
+ vaultConfig: mockConfig,
1048
+ hqRoot: tmpDir,
1049
+ skipUnchanged: true,
1050
+ propagateDeletes: true,
1051
+ propagateDeletePolicy: "all",
1052
+ });
1053
+ expect(result.filesDeleted).toBe(1);
1054
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "pulled.md");
1055
+ });
1056
+ it("propagateDeletes: skips entries the ignore filter would reject (filter symmetry)", async () => {
1057
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1058
+ fs.mkdirSync(companyRoot, { recursive: true });
1059
+ // Author a .hqignore at hqRoot that filters out a path the journal still
1060
+ // tracks. The push must NOT delete that path from the vault — the local
1061
+ // absence is a filter outcome, not a user-intended delete.
1062
+ fs.writeFileSync(path.join(tmpDir, ".hqignore"), "companies/acme/legacy/**\n");
1063
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1064
+ fs.writeFileSync(journalPath, JSON.stringify({
1065
+ version: "1",
1066
+ lastSync: new Date().toISOString(),
1067
+ files: {
1068
+ "legacy/old-layout.md": {
1069
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1070
+ direction: "up",
1071
+ },
1072
+ "active/current.md": {
1073
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1074
+ direction: "up",
1075
+ },
1076
+ },
1077
+ }));
1078
+ const result = await share({
1079
+ paths: [companyRoot],
1080
+ company: "acme",
1081
+ vaultConfig: mockConfig,
1082
+ hqRoot: tmpDir,
1083
+ skipUnchanged: true,
1084
+ propagateDeletes: true,
1085
+ });
1086
+ // legacy/old-layout.md is filter-skipped; only active/current.md is
1087
+ // eligible for delete.
1088
+ expect(result.filesDeleted).toBe(1);
1089
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "active/current.md");
1090
+ expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "legacy/old-layout.md");
1091
+ });
876
1092
  // ── personalMode ───────────────────────────────────────────────────────────
877
1093
  //
878
1094
  // The personal vault (slug "personal" in the runner's fanout plan) shares
@@ -998,5 +1214,201 @@ describe("share", () => {
998
1214
  expect(uploadFile).not.toHaveBeenCalled();
999
1215
  });
1000
1216
  });
1217
+ describe("symlinks", () => {
1218
+ // Pre-fix bug: collectFiles called fs.statSync (which dereferences) on
1219
+ // top-level paths and walkDir relied on Dirent.isFile()/isDirectory()
1220
+ // (both false for symlinks), so a top-level symlink got uploaded as the
1221
+ // target's bytes under the link's key while a nested symlink was
1222
+ // silently dropped from every push. The link topology never survived a
1223
+ // round trip — fresh-machine pulls landed in a state where overlay
1224
+ // symlinks just didn't exist until master-sync.sh recreated them
1225
+ // locally. The fix detects symlinks via lstat / Dirent.isSymbolicLink
1226
+ // and routes them to a new uploadSymlink primitive that PUTs a
1227
+ // zero-byte object with x-amz-meta-hq-symlink-target carrying the
1228
+ // readlink string verbatim.
1229
+ it("uploads a top-level symlink as a symlink record (not the target's bytes)", async () => {
1230
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1231
+ fs.mkdirSync(companyRoot, { recursive: true });
1232
+ const target = path.join(companyRoot, "target.md");
1233
+ fs.writeFileSync(target, "I am the target");
1234
+ const link = path.join(companyRoot, "link.md");
1235
+ fs.symlinkSync("target.md", link);
1236
+ const result = await share({
1237
+ paths: [link],
1238
+ company: "acme",
1239
+ vaultConfig: mockConfig,
1240
+ hqRoot: tmpDir,
1241
+ });
1242
+ expect(result.filesUploaded).toBe(1);
1243
+ expect(uploadSymlink).toHaveBeenCalledWith(expect.anything(), "target.md", "link.md");
1244
+ // The link itself must NOT be uploaded as a regular file. Pre-fix,
1245
+ // fs.statSync(link) followed the link and uploadFile got called with
1246
+ // the link's path → cloud stored a copy of target.md's bytes under
1247
+ // the key "link.md", silently flattening the link.
1248
+ const fileCalls = vi.mocked(uploadFile).mock.calls.filter((c) => c[2] === "link.md");
1249
+ expect(fileCalls).toHaveLength(0);
1250
+ });
1251
+ it("uploads a nested symlink discovered during walkDir as a symlink record", async () => {
1252
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1253
+ const policiesDir = path.join(companyRoot, "policies");
1254
+ fs.mkdirSync(policiesDir, { recursive: true });
1255
+ const realPolicy = path.join(policiesDir, "real.md");
1256
+ fs.writeFileSync(realPolicy, "real content");
1257
+ const linkPolicy = path.join(policiesDir, "link.md");
1258
+ // Mirrors the master-sync.sh overlay shape: relative target pointing
1259
+ // to a sibling in the same dir.
1260
+ fs.symlinkSync("real.md", linkPolicy);
1261
+ const result = await share({
1262
+ paths: [companyRoot],
1263
+ company: "acme",
1264
+ vaultConfig: mockConfig,
1265
+ hqRoot: tmpDir,
1266
+ });
1267
+ // Two uploads: one regular file (real.md), one symlink record (link.md).
1268
+ // Pre-fix, walkDir's Dirent.isFile() returned false for the symlink and
1269
+ // it was silently dropped — only the real file was uploaded.
1270
+ expect(result.filesUploaded).toBe(2);
1271
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), realPolicy, "policies/real.md");
1272
+ expect(uploadSymlink).toHaveBeenCalledWith(expect.anything(), "real.md", "policies/link.md");
1273
+ });
1274
+ it("accepts a symlink inside the company folder even when its target lives outside", async () => {
1275
+ // Codex P2 follow-up: pre-fix, isWithin canonicalized the link
1276
+ // via realpathSync, so a directory symlink whose target lived
1277
+ // outside the company folder (the canonical motivating shape:
1278
+ // companies/{co}/knowledge → repos/private/knowledge-{co}/) was
1279
+ // rejected with "outside company folder" and the link was
1280
+ // dropped entirely. The link's own pathname is inside the
1281
+ // company folder; that's what containment must compare against.
1282
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1283
+ fs.mkdirSync(companyRoot, { recursive: true });
1284
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
1285
+ fs.mkdirSync(externalTarget, { recursive: true });
1286
+ const linkPath = path.join(companyRoot, "knowledge");
1287
+ fs.symlinkSync(externalTarget, linkPath);
1288
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => { });
1289
+ const result = await share({
1290
+ paths: [linkPath],
1291
+ company: "acme",
1292
+ vaultConfig: mockConfig,
1293
+ hqRoot: tmpDir,
1294
+ });
1295
+ warnSpy.mockRestore();
1296
+ expect(result.filesUploaded).toBe(1);
1297
+ expect(uploadSymlink).toHaveBeenCalledWith(expect.anything(), externalTarget, "knowledge");
1298
+ // No "outside company folder" warning for this case.
1299
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringMatching(/outside company folder/i));
1300
+ });
1301
+ it("uploads a symlink even when its target string equals the prior regular file's contents (skipUnchanged distinguishability)", async () => {
1302
+ // Codex round-5 P1 follow-up: pre-fix, the journal hash for a
1303
+ // symlink was sha256(target). For a key whose journal entry was
1304
+ // a regular file containing the bytes "real.md", a local
1305
+ // replacement with a symlink → "real.md" produced the same
1306
+ // hash, so skipUnchanged short-circuited the upload and the
1307
+ // representation drift never propagated. The pull side also
1308
+ // saw no etag change (because we never uploaded), so the remote
1309
+ // never repaired. Fix: hash symlinks as sha256("hq-symlink:" +
1310
+ // target), making the two representations structurally
1311
+ // inequal in journal-hash space.
1312
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1313
+ fs.mkdirSync(companyRoot, { recursive: true });
1314
+ const target = "real.md";
1315
+ // Pre-stamp the journal as if the prior sync had uploaded a
1316
+ // regular file whose contents were exactly the target string.
1317
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1318
+ const crypto = await import("crypto");
1319
+ const regularFileHash = crypto
1320
+ .createHash("sha256")
1321
+ .update(target)
1322
+ .digest("hex");
1323
+ fs.writeFileSync(journalPath, JSON.stringify({
1324
+ version: "1",
1325
+ lastSync: new Date().toISOString(),
1326
+ files: {
1327
+ "case-key.md": {
1328
+ hash: regularFileHash,
1329
+ size: target.length,
1330
+ syncedAt: new Date().toISOString(),
1331
+ direction: "up",
1332
+ remoteEtag: "regular-file-etag",
1333
+ },
1334
+ },
1335
+ }));
1336
+ // Now locally, the user has replaced that key with a symlink to
1337
+ // the same target string.
1338
+ fs.symlinkSync(target, path.join(companyRoot, "case-key.md"));
1339
+ const result = await share({
1340
+ paths: [companyRoot],
1341
+ company: "acme",
1342
+ vaultConfig: mockConfig,
1343
+ hqRoot: tmpDir,
1344
+ skipUnchanged: true, // the gate the bug lives behind
1345
+ });
1346
+ // Upload MUST happen — the hash-namespace prefix means the
1347
+ // symlink's journal hash differs from the regular-file hash.
1348
+ expect(result.filesUploaded).toBe(1);
1349
+ expect(result.filesSkipped).toBe(0);
1350
+ expect(uploadSymlink).toHaveBeenCalledWith(expect.anything(), target, "case-key.md");
1351
+ });
1352
+ it("includes a directory symlink whose only matching allowlist pattern is dir-only", async () => {
1353
+ // Codex round-6 P1 follow-up: pre-fix, walkDir called the filter
1354
+ // with isDir = entry.isDirectory(), which returns false for any
1355
+ // symlink — including directory symlinks. With an .hqinclude
1356
+ // dir-only allowlist pattern like `companies/*/knowledge/`, the
1357
+ // filter's `ignore.ignores('foo')` vs `ignore.ignores('foo/')`
1358
+ // distinction means the slashless probe doesn't match and the
1359
+ // symlink is dropped before reaching the record-only branch.
1360
+ // Same story for collectFiles' top-level path classification.
1361
+ // Fix: probe the filter with both isDir hints for symlinks; the
1362
+ // filter is pure path lookup, so two calls cost nothing.
1363
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1364
+ fs.mkdirSync(companyRoot, { recursive: true });
1365
+ const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
1366
+ fs.mkdirSync(externalTarget, { recursive: true });
1367
+ // Create the directory symlink AND a sibling regular file at the
1368
+ // same depth. The .hqinclude pattern matches only the dir-only
1369
+ // form; the regular file should NOT make it past the filter,
1370
+ // proving the include rule actually applies (otherwise this
1371
+ // test would pass for the wrong reason).
1372
+ fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
1373
+ fs.writeFileSync(path.join(companyRoot, "stray.md"), "not in allowlist");
1374
+ // Allowlist mode: only `companies/*/knowledge/` is in scope.
1375
+ fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
1376
+ const result = await share({
1377
+ paths: [companyRoot],
1378
+ company: "acme",
1379
+ vaultConfig: mockConfig,
1380
+ hqRoot: tmpDir,
1381
+ });
1382
+ // The symlink record uploaded; the sibling regular file did NOT.
1383
+ expect(result.filesUploaded).toBe(1);
1384
+ expect(uploadSymlink).toHaveBeenCalledWith(expect.anything(), externalTarget, "knowledge");
1385
+ expect(uploadFile).not.toHaveBeenCalledWith(expect.anything(), expect.stringContaining("stray.md"), expect.anything());
1386
+ });
1387
+ it("does not recurse into directory symlinks (record-only)", async () => {
1388
+ // Following directory symlinks during walk would duplicate content
1389
+ // into the wrong vault path — exactly what the legacy "silently
1390
+ // skipped" topology accidentally prevented. The fix preserves that
1391
+ // topology while now actually recording the link.
1392
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1393
+ fs.mkdirSync(companyRoot, { recursive: true });
1394
+ const realDir = path.join(tmpDir, "outside");
1395
+ fs.mkdirSync(realDir, { recursive: true });
1396
+ fs.writeFileSync(path.join(realDir, "secret.md"), "do not upload me");
1397
+ const linkDir = path.join(companyRoot, "linked-dir");
1398
+ fs.symlinkSync(realDir, linkDir);
1399
+ const result = await share({
1400
+ paths: [companyRoot],
1401
+ company: "acme",
1402
+ vaultConfig: mockConfig,
1403
+ hqRoot: tmpDir,
1404
+ });
1405
+ // The link record itself uploads; the target's contents do NOT.
1406
+ expect(result.filesUploaded).toBe(1);
1407
+ expect(uploadSymlink).toHaveBeenCalledWith(expect.anything(), realDir, "linked-dir");
1408
+ // Crucially, no upload of the dir's contents.
1409
+ const calls = vi.mocked(uploadFile).mock.calls;
1410
+ expect(calls.find((c) => c[2].includes("secret.md"))).toBeUndefined();
1411
+ });
1412
+ });
1001
1413
  });
1002
1414
  //# sourceMappingURL=share.test.js.map