@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
@@ -62,6 +62,18 @@ const mockVendResponse = {
62
62
  function setupFetchMock() {
63
63
  const fetchMock = vi.fn().mockImplementation(async (url: string) => {
64
64
  const urlStr = String(url);
65
+ // New per-user-namespace slug resolver (hq-pro PR 67). Returns the
66
+ // mockEntity's uid as the in-namespace match; the caller then
67
+ // re-fetches it via `/entity/{uid}`, which is matched by the
68
+ // `/entity/cmp_/` branch below.
69
+ if (urlStr.includes("/entity/check-slug/me")) {
70
+ return {
71
+ ok: true,
72
+ status: 200,
73
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
74
+ text: async () => "",
75
+ };
76
+ }
65
77
  if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
66
78
  return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
67
79
  }
@@ -740,4 +752,349 @@ describe("sync", () => {
740
752
  filesToSkip: 0,
741
753
  });
742
754
  });
755
+
756
+ it("stamps the journal with sha256(target) + size 0 when the downloaded entry is a symlink", async () => {
757
+ // Pre-fix, the post-download journal stamp called hashFile (which
758
+ // follows the link and reads the target file's bytes) and statSync
759
+ // (which follows the link and reports the target's size). The next
760
+ // push's skipUnchanged check would then compare its own
761
+ // sha256(target string) against the journal's "target file bytes"
762
+ // hash, never match, and re-upload every symlink on every tick. The
763
+ // fix: when localPath is a symlink after downloadFile, stamp the
764
+ // hash of readlink(localPath) and size 0 — exactly the values the
765
+ // push side would produce next.
766
+ const linkKey = "policies/personal-link.md";
767
+ const linkTarget = "../../personal/policies/real.md";
768
+
769
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
770
+ { key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
771
+ ]);
772
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
773
+ async (_ctx: unknown, _key: string, localPath: string) => {
774
+ const dir = path.dirname(localPath);
775
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
776
+ // Mirror real downloadFile's symlink branch.
777
+ fs.symlinkSync(linkTarget, localPath);
778
+ },
779
+ );
780
+
781
+ const result = await sync({
782
+ company: "acme",
783
+ vaultConfig: mockConfig,
784
+ hqRoot: tmpDir,
785
+ });
786
+ expect(result.filesDownloaded).toBe(1);
787
+
788
+ // Recompute the expected hash from the target string — same helper
789
+ // the push side uses in computePushPlan for kind: "symlink", so
790
+ // skip-unchanged works on the next tick.
791
+ const journalMod = await import("../journal.js");
792
+ const expectedHash = journalMod.hashSymlinkTarget(linkTarget);
793
+
794
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
795
+ expect(journal.files[linkKey]).toMatchObject({
796
+ hash: expectedHash,
797
+ size: 0,
798
+ direction: "down",
799
+ });
800
+ });
801
+
802
+ it("does not crash when local is a real directory at a remote-key path (EISDIR guard)", async () => {
803
+ // Codex round-9 P2 follow-up: pre-fix, the pull planner's
804
+ // post-lstat branch always called hashFile (readFileSync) on a
805
+ // localPath whose lstat succeeded but wasn't a symlink. For a
806
+ // local *directory* at a remote-key path (e.g. user has
807
+ // `companies/acme/knowledge/` as a real dir, cloud has a single
808
+ // object at the same key), readFileSync threw EISDIR and the
809
+ // entire sync run aborted during planning. The fix surfaces it
810
+ // as a non-fatal skip-local-only with a warning so the operator
811
+ // can manually reconcile rather than crash the whole pull.
812
+ const localDirKey = "knowledge";
813
+ const localDir = path.join(tmpDir, "companies", "acme", localDirKey);
814
+ fs.mkdirSync(localDir, { recursive: true });
815
+ fs.writeFileSync(path.join(localDir, "inside.md"), "local-only content");
816
+
817
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
818
+ {
819
+ key: localDirKey,
820
+ size: 100,
821
+ lastModified: new Date(),
822
+ etag: '"some-etag"',
823
+ },
824
+ ]);
825
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
826
+
827
+ let crashed = false;
828
+ let result;
829
+ try {
830
+ result = await sync({
831
+ company: "acme",
832
+ vaultConfig: mockConfig,
833
+ hqRoot: tmpDir,
834
+ });
835
+ } catch {
836
+ crashed = true;
837
+ }
838
+ warnSpy.mockRestore();
839
+
840
+ expect(crashed).toBe(false);
841
+ expect(result).toBeDefined();
842
+ // Skipped, not downloaded — destructive replacement of a local
843
+ // directory is a manual-reconciliation decision, not an automatic
844
+ // sync action.
845
+ expect(result!.filesDownloaded).toBe(0);
846
+ expect(result!.filesSkipped).toBe(1);
847
+ // Local directory still intact.
848
+ expect(fs.existsSync(path.join(localDir, "inside.md"))).toBe(true);
849
+ });
850
+
851
+ it("pulls a remote symlink record under an .hqinclude dir-only allowlist pattern", async () => {
852
+ // Codex round-7 P1 follow-up: pre-fix, computePullPlan called
853
+ // shouldSync(localPath) with the default isDir=false. LIST gives
854
+ // no file-vs-dir signal, so a remote symlink record at a path
855
+ // covered only by a dir-only .hqinclude pattern (e.g.
856
+ // `companies/*/knowledge/`) would be classified skip-ignored —
857
+ // the symlink record uploaded by the push-side fix would never
858
+ // propagate to peers. Symmetric to the walkDir/collectFiles
859
+ // dual-hint fix on the push side.
860
+ const linkKey = "knowledge";
861
+ const linkTarget = "../../repos/private/knowledge-acme";
862
+ // Allowlist with ONLY a dir-only pattern. The pre-fix bug was
863
+ // that the slashless probe didn't match this rule, so a bare
864
+ // `knowledge` alternative would mask the regression — keep the
865
+ // pattern strictly dir-only so the dual-hint probe is what
866
+ // actually has to make the test pass.
867
+ fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "knowledge/\n");
868
+
869
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
870
+ { key: linkKey, size: 5, lastModified: new Date(), etag: '"link-etag-3"' },
871
+ ]);
872
+ let downloadCalls = 0;
873
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
874
+ async (_ctx: unknown, _key: string, localPath: string) => {
875
+ downloadCalls++;
876
+ const dir = path.dirname(localPath);
877
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
878
+ fs.symlinkSync(linkTarget, localPath);
879
+ },
880
+ );
881
+
882
+ const result = await sync({
883
+ company: "acme",
884
+ vaultConfig: mockConfig,
885
+ hqRoot: tmpDir,
886
+ });
887
+
888
+ // Pre-fix: filesDownloaded=0, filesSkipped=1 (skip-ignored).
889
+ // Post-fix: filesDownloaded=1.
890
+ expect(downloadCalls).toBe(1);
891
+ expect(result.filesDownloaded).toBe(1);
892
+ expect(
893
+ fs
894
+ .lstatSync(path.join(tmpDir, "companies", "acme", linkKey))
895
+ .isSymbolicLink(),
896
+ ).toBe(true);
897
+ });
898
+
899
+ it("does not flag a pristine local symlink as locally-changed in the pull planner", async () => {
900
+ // Codex P2 follow-up: pre-fix, computePullPlan called hashFile on
901
+ // localPath (which follows symlinks → hashes the target file's
902
+ // bytes) and compared that against the journal's
903
+ // sha256(target string). The hashes never matched, so a pristine
904
+ // symlink was always classified localChanged. Combined with a
905
+ // remote-side change from another machine, that mis-classified as a
906
+ // conflict and aborted overwrites; without remote change, it was
907
+ // only logged as skip-local-only. Either way the planner thought
908
+ // the local link was "edited" when it wasn't.
909
+ const linkKey = "policies/personal-link.md";
910
+ const linkTarget = "../../personal/policies/real.md";
911
+ // listRemoteFiles surfaces the raw S3 etag with literal quotes;
912
+ // the journal stamps the normalized (unquoted) form. Match the
913
+ // journal convention here so hasRemoteChanged returns false.
914
+ const remoteEtagWire = '"link-etag-2"';
915
+ const remoteEtagNormalized = "link-etag-2";
916
+
917
+ // Pre-create the link locally so the planner sees an existing
918
+ // symlink, and pre-stamp the journal with the SAME hash the push
919
+ // side would have written (hashSymlinkTarget of the target string).
920
+ const companyRoot = path.join(tmpDir, "companies", "acme");
921
+ const linkDir = path.dirname(path.join(companyRoot, linkKey));
922
+ fs.mkdirSync(linkDir, { recursive: true });
923
+ fs.symlinkSync(linkTarget, path.join(companyRoot, linkKey));
924
+ const journalMod = await import("../journal.js");
925
+ const matchingHash = journalMod.hashSymlinkTarget(linkTarget);
926
+ fs.writeFileSync(
927
+ journalPath,
928
+ JSON.stringify({
929
+ version: "1",
930
+ lastSync: new Date().toISOString(),
931
+ files: {
932
+ [linkKey]: {
933
+ hash: matchingHash,
934
+ size: 0,
935
+ syncedAt: new Date().toISOString(),
936
+ direction: "down",
937
+ remoteEtag: remoteEtagNormalized,
938
+ },
939
+ },
940
+ }),
941
+ );
942
+
943
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValue([
944
+ { key: linkKey, size: 0, lastModified: new Date(), etag: remoteEtagWire },
945
+ ]);
946
+ // downloadFile must NOT be called: the planner should skip
947
+ // (unchanged on both sides). Use a sentinel that throws if invoked.
948
+ vi.mocked(s3Module.downloadFile).mockImplementation(async () => {
949
+ throw new Error("download must not be called for unchanged symlink");
950
+ });
951
+
952
+ const result = await sync({
953
+ company: "acme",
954
+ vaultConfig: mockConfig,
955
+ hqRoot: tmpDir,
956
+ });
957
+
958
+ expect(result.filesDownloaded).toBe(0);
959
+ expect(result.filesSkipped).toBe(1);
960
+ expect(result.conflicts).toBe(0);
961
+ });
962
+
963
+ it("classifies a dangling-symlink + remote-change as a conflict without crashing on statSync", async () => {
964
+ // Codex round-11 P2 follow-up: pre-fix, the conflict executor
965
+ // built the resolveConflict prompt with `fs.statSync(localPath).
966
+ // mtime`, which follows symlinks. For a dangling symlink (target
967
+ // missing locally) that ALSO had a target rewrite (localChanged)
968
+ // and a remote change (remoteChanged), the planner classified it
969
+ // as a conflict, then statSync threw ENOENT trying to follow the
970
+ // dangling link to read mtime — aborting the entire pull before
971
+ // resolveConflict could run. Fix: carry the planner's lstat
972
+ // mtime forward through PullPlanItem.conflict.localMtime so the
973
+ // executor never re-stats the path.
974
+ const linkKey = "dangling-link.md";
975
+ // Old target — what the journal-recorded hash will reflect.
976
+ const oldTarget = "old-target.md";
977
+ // New (current) target — points to a path that doesn't exist
978
+ // locally → the link is dangling. The hash differs from the
979
+ // journal so localChanged = true.
980
+ const newTarget = "missing-target.md";
981
+
982
+ const companyRoot = path.join(tmpDir, "companies", "acme");
983
+ fs.mkdirSync(companyRoot, { recursive: true });
984
+ fs.symlinkSync(newTarget, path.join(companyRoot, linkKey));
985
+
986
+ // Pre-stamp the journal with the OLD target's symlink hash, plus a
987
+ // remoteEtag that won't match the LIST etag we mock (forcing
988
+ // remoteChanged = true on top of localChanged = true).
989
+ const journalMod = await import("../journal.js");
990
+ const oldHash = journalMod.hashSymlinkTarget(oldTarget);
991
+ fs.writeFileSync(
992
+ journalPath,
993
+ JSON.stringify({
994
+ version: "1",
995
+ lastSync: new Date().toISOString(),
996
+ files: {
997
+ [linkKey]: {
998
+ hash: oldHash,
999
+ size: 0,
1000
+ syncedAt: new Date().toISOString(),
1001
+ direction: "down",
1002
+ remoteEtag: "old-remote-etag",
1003
+ },
1004
+ },
1005
+ }),
1006
+ );
1007
+
1008
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1009
+ {
1010
+ key: linkKey,
1011
+ size: 30,
1012
+ lastModified: new Date(),
1013
+ etag: '"new-remote-etag"',
1014
+ },
1015
+ ]);
1016
+
1017
+ // Auto-resolve the conflict by aborting. What we're really testing
1018
+ // is that the executor *reaches* resolveConflict at all — pre-fix
1019
+ // it crashed at fs.statSync before getting there.
1020
+ const result = await sync({
1021
+ company: "acme",
1022
+ vaultConfig: mockConfig,
1023
+ hqRoot: tmpDir,
1024
+ onConflict: "abort",
1025
+ });
1026
+
1027
+ expect(result.aborted).toBe(true);
1028
+ expect(result.conflicts).toBe(1);
1029
+ expect(result.conflictPaths).toContain(linkKey);
1030
+ });
1031
+
1032
+ it("conflict resolution 'keep' on a dangling symlink stamps the journal so it doesn't re-fire next sync", async () => {
1033
+ // Codex round-12 P2 follow-up: pre-fix, the keep/skip branch of
1034
+ // the conflict executor called `fs.statSync(localPath).size` to
1035
+ // populate the journal stamp. For a dangling symlink, statSync
1036
+ // followed the link and threw ENOENT, the catch swallowed it,
1037
+ // and the journal was never updated — so the next sync saw the
1038
+ // same localChanged && remoteChanged combination and prompted
1039
+ // for the SAME conflict forever, ignoring the user's keep
1040
+ // choice. Fix: carry localSize (symlink-aware: 0 for symlinks)
1041
+ // through the conflict item so the executor never re-stats.
1042
+ const linkKey = "dangling-keep.md";
1043
+ const oldTarget = "old-target.md";
1044
+ const newTarget = "missing-target.md";
1045
+ const newRemoteEtagWire = '"new-remote-etag-keep"';
1046
+ const newRemoteEtagNormalized = "new-remote-etag-keep";
1047
+
1048
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1049
+ fs.mkdirSync(companyRoot, { recursive: true });
1050
+ fs.symlinkSync(newTarget, path.join(companyRoot, linkKey));
1051
+
1052
+ const journalMod = await import("../journal.js");
1053
+ const oldHash = journalMod.hashSymlinkTarget(oldTarget);
1054
+ const expectedNewHash = journalMod.hashSymlinkTarget(newTarget);
1055
+ fs.writeFileSync(
1056
+ journalPath,
1057
+ JSON.stringify({
1058
+ version: "1",
1059
+ lastSync: new Date().toISOString(),
1060
+ files: {
1061
+ [linkKey]: {
1062
+ hash: oldHash,
1063
+ size: 0,
1064
+ syncedAt: new Date().toISOString(),
1065
+ direction: "down",
1066
+ remoteEtag: "old-remote-etag-keep",
1067
+ },
1068
+ },
1069
+ }),
1070
+ );
1071
+
1072
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1073
+ {
1074
+ key: linkKey,
1075
+ size: 30,
1076
+ lastModified: new Date(),
1077
+ etag: newRemoteEtagWire,
1078
+ },
1079
+ ]);
1080
+
1081
+ const result = await sync({
1082
+ company: "acme",
1083
+ vaultConfig: mockConfig,
1084
+ hqRoot: tmpDir,
1085
+ onConflict: "keep",
1086
+ });
1087
+
1088
+ expect(result.aborted).toBe(false);
1089
+ expect(result.conflicts).toBe(1);
1090
+
1091
+ // The journal MUST be updated to reflect the user's keep
1092
+ // decision. Pre-fix: catch swallowed the ENOENT, journal stayed
1093
+ // at oldHash + old etag, every subsequent sync re-fired.
1094
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1095
+ const entry = journal.files[linkKey];
1096
+ expect(entry.hash).toBe(expectedNewHash);
1097
+ expect(entry.size).toBe(0);
1098
+ expect(entry.remoteEtag).toBe(newRemoteEtagNormalized);
1099
+ });
743
1100
  });
package/src/cli/sync.ts CHANGED
@@ -11,7 +11,15 @@ import type { VaultServiceConfig, SyncJournal } from "../types.js";
11
11
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
12
  import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
13
13
  import type { RemoteFile } from "../s3.js";
14
- import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
14
+ import {
15
+ readJournal,
16
+ writeJournal,
17
+ hashFile,
18
+ hashSymlinkTarget,
19
+ updateEntry,
20
+ getEntry,
21
+ normalizeEtag,
22
+ } from "../journal.js";
15
23
  import { createIgnoreFilter } from "../ignore.js";
16
24
  import { resolveConflict } from "./conflict.js";
17
25
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
@@ -235,7 +243,10 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
235
243
  path: remoteFile.key,
236
244
  localHash: item.localHash,
237
245
  remoteModified: remoteFile.lastModified,
238
- localModified: fs.statSync(localPath).mtime,
246
+ // Use the lstat-mtime captured by the planner — statSync
247
+ // here would follow a dangling symlink and throw ENOENT,
248
+ // aborting the pull before resolveConflict could prompt.
249
+ localModified: item.localMtime,
239
250
  direction: "pull",
240
251
  },
241
252
  onConflict,
@@ -311,19 +322,20 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
311
322
  // the next sync "no change on either side" until something new
312
323
  // diverges. Without this, both `localChanged` and `remoteChanged`
313
324
  // stay true forever and the conflict is sticky.
314
- try {
315
- const stat = fs.statSync(localPath);
316
- updateEntry(
317
- journal,
318
- remoteFile.key,
319
- item.localHash,
320
- stat.size,
321
- "down",
322
- remoteFile.etag,
323
- );
324
- } catch {
325
- // best-effort — sync continues even if stat fails
326
- }
325
+ // Stamp from planner-captured size (symlink-aware), NOT
326
+ // statSync which would follow a dangling symlink and
327
+ // throw ENOENT, get swallowed, and leave the journal
328
+ // stale so this conflict would re-fire on every sync
329
+ // forever. localSize is sourced from the same lstat that
330
+ // computed localMtime + localHash above.
331
+ updateEntry(
332
+ journal,
333
+ remoteFile.key,
334
+ item.localHash,
335
+ item.localSize,
336
+ "down",
337
+ remoteFile.etag,
338
+ );
327
339
  continue;
328
340
  }
329
341
  // "overwrite" falls through to download
@@ -333,11 +345,24 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
333
345
  try {
334
346
  await downloadFile(ctx, remoteFile.key, localPath);
335
347
 
336
- const hash = hashFile(localPath);
337
- const stat = fs.statSync(localPath);
348
+ // Symlink records materialize as real symlinks on disk. lstat
349
+ // (does not follow) lets us detect that case so the journal stamp
350
+ // mirrors what the push side would emit on the next tick:
351
+ // hash = sha256(readlink target string)
352
+ // size = 0
353
+ // Without this check, hashFile would follow the link and stamp the
354
+ // target file's contents — a value the next push would never
355
+ // produce — which makes skipUnchanged perpetually re-upload every
356
+ // symlink, defeating the point of the gate.
357
+ const localLstat = fs.lstatSync(localPath);
358
+ const isLocalSymlink = localLstat.isSymbolicLink();
359
+ const hash = isLocalSymlink
360
+ ? hashSymlinkTarget(fs.readlinkSync(localPath))
361
+ : hashFile(localPath);
362
+ const size = isLocalSymlink ? 0 : fs.statSync(localPath).size;
338
363
  // Capture the listing's ETag so subsequent syncs can detect remote
339
364
  // drift independently of mtime drift.
340
- updateEntry(journal, remoteFile.key, hash, stat.size, "down", remoteFile.etag);
365
+ updateEntry(journal, remoteFile.key, hash, size, "down", remoteFile.etag);
341
366
 
342
367
  // Attach message from the prior journal entry if present (set by a
343
368
  // previous `share` operation that included a --message).
@@ -346,12 +371,12 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
346
371
  emit({
347
372
  type: "progress",
348
373
  path: remoteFile.key,
349
- bytes: stat.size,
374
+ bytes: size,
350
375
  ...(remoteJournalMessage ? { message: remoteJournalMessage } : {}),
351
376
  });
352
377
 
353
378
  filesDownloaded++;
354
- bytesDownloaded += stat.size;
379
+ bytesDownloaded += size;
355
380
  } catch (err) {
356
381
  // STS session policy may deny access to some paths — this is expected
357
382
  // for guest members with allowedPrefixes
@@ -471,6 +496,22 @@ type PullPlanItem =
471
496
  remoteFile: RemoteFile;
472
497
  localPath: string;
473
498
  localHash: string;
499
+ // Captured from the planner's lstat (does NOT follow symlinks),
500
+ // so a dangling symlink doesn't make the conflict executor
501
+ // statSync-then-ENOENT and abort the whole pull. See the
502
+ // executor: prior to this field, the prompt builder called
503
+ // fs.statSync(localPath).mtime to populate the conflict UI's
504
+ // "local modified at X" line, which crashed for dangling links.
505
+ localMtime: Date;
506
+ // Symlink-aware size for the post-resolution journal stamp.
507
+ // For a symlink, journal convention is 0 (the wire body is
508
+ // empty + target metadata; matches what the push side stamps).
509
+ // For a regular file, mirrors the on-disk byte length. The
510
+ // executor's keep/skip branch reads this instead of calling
511
+ // fs.statSync(localPath), which would follow a dangling link
512
+ // and silently fail to stamp the journal — leaving the
513
+ // conflict to re-fire forever.
514
+ localSize: number;
474
515
  };
475
516
 
476
517
  interface PullPlan {
@@ -499,7 +540,7 @@ function computePullPlan(
499
540
  remoteFiles: RemoteFile[],
500
541
  journal: SyncJournal,
501
542
  companyRoot: string,
502
- shouldSync: (filePath: string) => boolean,
543
+ shouldSync: (filePath: string, isDir?: boolean) => boolean,
503
544
  personalMode: boolean,
504
545
  ): PullPlan {
505
546
  const items: PullPlanItem[] = [];
@@ -512,16 +553,72 @@ function computePullPlan(
512
553
  continue;
513
554
  }
514
555
 
515
- if (!shouldSync(localPath)) {
556
+ // LIST gives us no kind signal for the remote object — we don't
557
+ // know whether this key is a regular file or a symlink record
558
+ // until we either HEAD it (expensive — N extra calls per pull) or
559
+ // download it. So probe the ignore filter with BOTH isDir hints
560
+ // and include the entry if either probe passes. Without this,
561
+ // a symlink record at a dir-only-allowlisted path (e.g. an
562
+ // .hqinclude of `companies/*/knowledge/`) would be classified
563
+ // skip-ignored and the symlinks freshly uploaded by the
564
+ // push-side fix would never propagate. Mirrors the dual-hint
565
+ // probe in walkDir/collectFiles. Pure path lookup, no I/O.
566
+ if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) {
516
567
  items.push({ action: "skip-ignored", remoteFile, localPath });
517
568
  continue;
518
569
  }
519
570
 
520
571
  const journalEntry = getEntry(journal, remoteFile.key);
521
- const localExists = fs.existsSync(localPath);
572
+ // lstat (not existsSync/statSync) handles three cases the legacy
573
+ // checks got wrong for symlinks:
574
+ // 1. A valid symlink at localPath: existsSync returns true and the
575
+ // planner falls through to hashFile, which follows the link and
576
+ // hashes the target file's bytes — never matching the journal's
577
+ // sha256(target string), so every pull would mis-classify a
578
+ // pristine symlink as locally changed.
579
+ // 2. A dangling symlink: existsSync returns false (existsSync
580
+ // follows links) so the planner treats the entry as missing and
581
+ // re-downloads, silently clobbering the user's intentional
582
+ // dangling link on every run.
583
+ // 3. A regular file: indistinguishable from current behaviour.
584
+ let localLstat: fs.Stats | null = null;
585
+ try {
586
+ localLstat = fs.lstatSync(localPath);
587
+ } catch (err: unknown) {
588
+ // ENOENT means truly absent; other errors propagate.
589
+ if (
590
+ err &&
591
+ typeof err === "object" &&
592
+ "code" in err &&
593
+ (err as { code?: string }).code !== "ENOENT"
594
+ ) {
595
+ throw err;
596
+ }
597
+ }
598
+ const localExists = localLstat !== null;
522
599
 
523
600
  if (localExists) {
524
- const localHash = hashFile(localPath);
601
+ const isLocalSymlink = localLstat!.isSymbolicLink();
602
+ // Kind-mismatch guard: a remote LIST entry is always a single
603
+ // S3 object (regular file or symlink record), but the local
604
+ // path may be a real directory if the user previously had a
605
+ // dir at this key. Hashing a directory throws EISDIR and
606
+ // aborts the whole pull. We don't auto-replace because that
607
+ // would `rm -rf` the user's directory; surface as a non-fatal
608
+ // skip and warn so the operator can reconcile manually
609
+ // (delete the local dir, or restructure the remote).
610
+ if (localLstat!.isDirectory() && !isLocalSymlink) {
611
+ console.error(
612
+ ` Warning: ${remoteFile.key} exists locally as a directory; ` +
613
+ `cloud has a single object at this key. Skipping; manual ` +
614
+ `reconciliation required (rm -rf the local directory to pull).`,
615
+ );
616
+ items.push({ action: "skip-local-only", remoteFile, localPath });
617
+ continue;
618
+ }
619
+ const localHash = isLocalSymlink
620
+ ? hashSymlinkTarget(fs.readlinkSync(localPath))
621
+ : hashFile(localPath);
525
622
  const localChanged = !!journalEntry && journalEntry.hash !== localHash;
526
623
  const remoteChanged =
527
624
  !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
@@ -535,6 +632,18 @@ function computePullPlan(
535
632
  remoteFile,
536
633
  localPath,
537
634
  localHash,
635
+ // localLstat is non-null inside this `if (localExists)`
636
+ // branch — the planner already lstat'd the path. Carry
637
+ // its mtime forward so the executor doesn't have to
638
+ // re-stat (which would follow links and crash on a
639
+ // dangling symlink). Same goes for size: the conflict
640
+ // executor's keep/skip branch needs to stamp the
641
+ // journal with a size, and statSync on a dangling
642
+ // symlink throws ENOENT (silently swallowed pre-fix),
643
+ // leaving the journal stale and re-firing the conflict
644
+ // every sync.
645
+ localMtime: localLstat!.mtime,
646
+ localSize: isLocalSymlink ? 0 : localLstat!.size,
538
647
  });
539
648
  continue;
540
649
  }