@indigoai-us/hq-cloud 6.11.11 → 6.11.12

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 (160) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +265 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  8. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  9. package/dist/cli/rescue-core.js +138 -15
  10. package/dist/cli/rescue-core.js.map +1 -1
  11. package/dist/cli/share.d.ts +2 -1
  12. package/dist/cli/share.d.ts.map +1 -1
  13. package/dist/cli/share.js +100 -32
  14. package/dist/cli/share.js.map +1 -1
  15. package/dist/cli/share.test.js +30 -0
  16. package/dist/cli/share.test.js.map +1 -1
  17. package/dist/cli/sync.d.ts +28 -1
  18. package/dist/cli/sync.d.ts.map +1 -1
  19. package/dist/cli/sync.js +178 -58
  20. package/dist/cli/sync.js.map +1 -1
  21. package/dist/cli/sync.test.js +362 -1
  22. package/dist/cli/sync.test.js.map +1 -1
  23. package/dist/cognito-auth.d.ts.map +1 -1
  24. package/dist/cognito-auth.js +55 -10
  25. package/dist/cognito-auth.js.map +1 -1
  26. package/dist/cognito-auth.test.js +61 -0
  27. package/dist/cognito-auth.test.js.map +1 -1
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/journal.d.ts.map +1 -1
  33. package/dist/journal.js +93 -6
  34. package/dist/journal.js.map +1 -1
  35. package/dist/journal.test.js +59 -0
  36. package/dist/journal.test.js.map +1 -1
  37. package/dist/machine-auth.test.js +60 -2
  38. package/dist/machine-auth.test.js.map +1 -1
  39. package/dist/object-io.d.ts +37 -1
  40. package/dist/object-io.d.ts.map +1 -1
  41. package/dist/object-io.js +148 -29
  42. package/dist/object-io.js.map +1 -1
  43. package/dist/object-io.test.js +121 -0
  44. package/dist/object-io.test.js.map +1 -1
  45. package/dist/operation-lock.d.ts +8 -8
  46. package/dist/operation-lock.d.ts.map +1 -1
  47. package/dist/operation-lock.js +99 -32
  48. package/dist/operation-lock.js.map +1 -1
  49. package/dist/operation-lock.test.js +51 -4
  50. package/dist/operation-lock.test.js.map +1 -1
  51. package/dist/personal-vault.d.ts.map +1 -1
  52. package/dist/personal-vault.js +8 -2
  53. package/dist/personal-vault.js.map +1 -1
  54. package/dist/personal-vault.test.js +34 -0
  55. package/dist/personal-vault.test.js.map +1 -1
  56. package/dist/prefix-coalesce.d.ts +20 -9
  57. package/dist/prefix-coalesce.d.ts.map +1 -1
  58. package/dist/prefix-coalesce.js +124 -28
  59. package/dist/prefix-coalesce.js.map +1 -1
  60. package/dist/prefix-coalesce.test.js +57 -2
  61. package/dist/prefix-coalesce.test.js.map +1 -1
  62. package/dist/remote-pull.d.ts +6 -1
  63. package/dist/remote-pull.d.ts.map +1 -1
  64. package/dist/remote-pull.js +62 -13
  65. package/dist/remote-pull.js.map +1 -1
  66. package/dist/remote-pull.test.js +189 -0
  67. package/dist/remote-pull.test.js.map +1 -1
  68. package/dist/s3.d.ts +2 -0
  69. package/dist/s3.d.ts.map +1 -1
  70. package/dist/s3.js +197 -116
  71. package/dist/s3.js.map +1 -1
  72. package/dist/s3.test.js +109 -0
  73. package/dist/s3.test.js.map +1 -1
  74. package/dist/scope-shrink.d.ts +3 -2
  75. package/dist/scope-shrink.d.ts.map +1 -1
  76. package/dist/scope-shrink.js +1 -1
  77. package/dist/scope-shrink.js.map +1 -1
  78. package/dist/skill-telemetry.d.ts +1 -1
  79. package/dist/skill-telemetry.d.ts.map +1 -1
  80. package/dist/skill-telemetry.js +69 -9
  81. package/dist/skill-telemetry.js.map +1 -1
  82. package/dist/skill-telemetry.test.js +86 -0
  83. package/dist/skill-telemetry.test.js.map +1 -1
  84. package/dist/sync/event-sync.d.ts +6 -0
  85. package/dist/sync/event-sync.d.ts.map +1 -1
  86. package/dist/sync/event-sync.js +34 -1
  87. package/dist/sync/event-sync.js.map +1 -1
  88. package/dist/sync/event-sync.test.js +73 -0
  89. package/dist/sync/event-sync.test.js.map +1 -1
  90. package/dist/sync/metrics.d.ts +17 -1
  91. package/dist/sync/metrics.d.ts.map +1 -1
  92. package/dist/sync/metrics.js +32 -1
  93. package/dist/sync/metrics.js.map +1 -1
  94. package/dist/sync/metrics.test.js +74 -1
  95. package/dist/sync/metrics.test.js.map +1 -1
  96. package/dist/sync/pull-scope.d.ts.map +1 -1
  97. package/dist/sync/pull-scope.js +15 -7
  98. package/dist/sync/pull-scope.js.map +1 -1
  99. package/dist/sync/push-receiver.d.ts +6 -5
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +13 -15
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +36 -1
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/telemetry.d.ts +1 -1
  106. package/dist/telemetry.d.ts.map +1 -1
  107. package/dist/telemetry.js +59 -6
  108. package/dist/telemetry.js.map +1 -1
  109. package/dist/telemetry.test.js +74 -0
  110. package/dist/telemetry.test.js.map +1 -1
  111. package/dist/types.d.ts +8 -0
  112. package/dist/types.d.ts.map +1 -1
  113. package/dist/watcher.d.ts +36 -0
  114. package/dist/watcher.d.ts.map +1 -1
  115. package/dist/watcher.js +152 -30
  116. package/dist/watcher.js.map +1 -1
  117. package/dist/watcher.test.js +103 -0
  118. package/dist/watcher.test.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/bin/sync-runner.test.ts +298 -11
  121. package/src/bin/sync-runner.ts +254 -52
  122. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  123. package/src/cli/rescue-core.ts +174 -15
  124. package/src/cli/share.test.ts +38 -0
  125. package/src/cli/share.ts +103 -34
  126. package/src/cli/sync.test.ts +435 -1
  127. package/src/cli/sync.ts +217 -64
  128. package/src/cognito-auth.test.ts +77 -0
  129. package/src/cognito-auth.ts +73 -11
  130. package/src/index.ts +8 -0
  131. package/src/journal.test.ts +72 -0
  132. package/src/journal.ts +95 -8
  133. package/src/machine-auth.test.ts +64 -2
  134. package/src/object-io.test.ts +142 -0
  135. package/src/object-io.ts +182 -30
  136. package/src/operation-lock.test.ts +63 -4
  137. package/src/operation-lock.ts +99 -31
  138. package/src/personal-vault.test.ts +42 -0
  139. package/src/personal-vault.ts +8 -2
  140. package/src/prefix-coalesce.test.ts +71 -1
  141. package/src/prefix-coalesce.ts +155 -30
  142. package/src/remote-pull.test.ts +205 -0
  143. package/src/remote-pull.ts +77 -14
  144. package/src/s3.test.ts +126 -0
  145. package/src/s3.ts +237 -122
  146. package/src/scope-shrink.ts +6 -3
  147. package/src/skill-telemetry.test.ts +109 -0
  148. package/src/skill-telemetry.ts +82 -14
  149. package/src/sync/event-sync.test.ts +75 -0
  150. package/src/sync/event-sync.ts +54 -1
  151. package/src/sync/metrics.test.ts +81 -0
  152. package/src/sync/metrics.ts +59 -4
  153. package/src/sync/pull-scope.ts +23 -7
  154. package/src/sync/push-receiver.test.ts +38 -1
  155. package/src/sync/push-receiver.ts +15 -18
  156. package/src/telemetry.test.ts +85 -0
  157. package/src/telemetry.ts +69 -6
  158. package/src/types.ts +8 -0
  159. package/src/watcher.test.ts +117 -0
  160. package/src/watcher.ts +209 -33
@@ -813,7 +813,7 @@ function doRescue(
813
813
  // Actions run in classification (walk) order — the same order the old
814
814
  // interleaved walk mutated in — so per-file output and on-disk results are
815
815
  // unchanged versus before the two-phase split.
816
- for (const act of ctx.actions) act();
816
+ applyClassifiedActions(ctx, backupDir);
817
817
 
818
818
  // --- Back up preserve-subpaths to a mktemp shuttle ---
819
819
  const shuttle = path.join(tmpdir, "preserve");
@@ -1095,7 +1095,7 @@ interface WalkCtx {
1095
1095
  // fail-safe — a throw mid-classify aborts before a single file is mutated, so
1096
1096
  // the wipe is never left half-applied (DEV-1767). Dry-run never fills this (it
1097
1097
  // returns after classification), so live + dry-run share one classify path.
1098
- actions: Array<() => void>;
1098
+ actions: RescueAction[];
1099
1099
  counts: {
1100
1100
  userOnly: number;
1101
1101
  unchanged: number;
@@ -1112,6 +1112,157 @@ interface WalkCtx {
1112
1112
  out: (s: string) => void;
1113
1113
  }
1114
1114
 
1115
+ interface RescueAction {
1116
+ label: string;
1117
+ affectedRels: string[];
1118
+ run: () => void;
1119
+ }
1120
+
1121
+ function queueAction(
1122
+ ctx: WalkCtx,
1123
+ label: string,
1124
+ affectedRels: string[],
1125
+ runAction: () => void,
1126
+ ): void {
1127
+ ctx.actions.push({ label, affectedRels, run: runAction });
1128
+ }
1129
+
1130
+ function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
1131
+ const completed: RescueAction[] = [];
1132
+ for (const action of ctx.actions) {
1133
+ try {
1134
+ action.run();
1135
+ completed.push(action);
1136
+ } catch (err) {
1137
+ const restored = rollbackCompletedActions(ctx, completed, backupDir);
1138
+ const manifest = writeApplyFailureManifest(
1139
+ ctx,
1140
+ backupDir,
1141
+ action,
1142
+ completed,
1143
+ restored,
1144
+ err,
1145
+ );
1146
+ ctx.out("\n");
1147
+ ctx.out(`error: rescue apply failed during '${action.label}'.\n`);
1148
+ if (restored.length > 0) {
1149
+ ctx.out(`==> Rolled back ${restored.length} prior action path(s) from the safety snapshot.\n`);
1150
+ }
1151
+ if (manifest) {
1152
+ ctx.out(`==> Recovery manifest: ${manifest}\n`);
1153
+ }
1154
+ throw err;
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ function rollbackCompletedActions(
1160
+ ctx: WalkCtx,
1161
+ completed: RescueAction[],
1162
+ backupDir: string,
1163
+ ): string[] {
1164
+ if (!backupDir || !isDir(backupDir)) return [];
1165
+
1166
+ const rels: string[] = [];
1167
+ const seen = new Set<string>();
1168
+ for (let i = completed.length - 1; i >= 0; i--) {
1169
+ const action = completed[i];
1170
+ for (let j = action.affectedRels.length - 1; j >= 0; j--) {
1171
+ const rel = action.affectedRels[j];
1172
+ if (rel && !seen.has(rel)) {
1173
+ seen.add(rel);
1174
+ rels.push(rel);
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ const restored: string[] = [];
1180
+ for (const rel of rels) {
1181
+ const snapshotPath = path.join(backupDir, rel);
1182
+ if (!lexists(snapshotPath)) continue;
1183
+ const dest = path.join(ctx.hqRoot, rel);
1184
+ try {
1185
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1186
+ fs.rmSync(dest, { recursive: true, force: true });
1187
+ cpATo(snapshotPath, dest);
1188
+ restored.push(rel);
1189
+ } catch {
1190
+ // Recovery manifest below records any path that could not be restored.
1191
+ }
1192
+ }
1193
+ return restored;
1194
+ }
1195
+
1196
+ function writeApplyFailureManifest(
1197
+ ctx: WalkCtx,
1198
+ backupDir: string,
1199
+ failedAction: RescueAction,
1200
+ completed: RescueAction[],
1201
+ restored: string[],
1202
+ err: unknown,
1203
+ ): string | null {
1204
+ const message = err instanceof Error ? err.message : String(err);
1205
+ const completedLines =
1206
+ completed.length === 0
1207
+ ? [" (none)"]
1208
+ : completed.map((action) => {
1209
+ const paths = action.affectedRels.length > 0 ? action.affectedRels.join(", ") : "(no filesystem path)";
1210
+ return ` - ${action.label}: ${paths}`;
1211
+ });
1212
+ const restoredSet = new Set(restored);
1213
+ const unrestored = completed
1214
+ .flatMap((action) => action.affectedRels)
1215
+ .filter((rel, idx, arr) => rel && arr.indexOf(rel) === idx && !restoredSet.has(rel));
1216
+
1217
+ const lines: string[] = [];
1218
+ lines.push("# HQ rescue apply recovery", "");
1219
+ lines.push(`Created: ${ctx.runTs}`);
1220
+ lines.push(`HQ root: ${ctx.hqRoot}`);
1221
+ lines.push(`Source: ${ctx.cfg.sourceRepo}@${ctx.cfg.ref} (${ctx.srcSha})`);
1222
+ lines.push(`Failed action: ${failedAction.label}`);
1223
+ lines.push(`Error: ${message}`);
1224
+ lines.push("");
1225
+ lines.push("## Completed Before Failure");
1226
+ lines.push(...completedLines);
1227
+ lines.push("");
1228
+ lines.push("## Failed Action Paths");
1229
+ if (failedAction.affectedRels.length > 0) {
1230
+ for (const rel of failedAction.affectedRels) lines.push(` - ${rel}`);
1231
+ } else {
1232
+ lines.push(" (no filesystem path)");
1233
+ }
1234
+ lines.push("");
1235
+ lines.push("## Rollback");
1236
+ if (restored.length > 0) {
1237
+ lines.push("Restored from the pre-op safety snapshot:");
1238
+ for (const rel of restored) lines.push(` - ${rel}`);
1239
+ } else {
1240
+ lines.push("No completed action paths were restored automatically.");
1241
+ }
1242
+ if (unrestored.length > 0) {
1243
+ lines.push("", "Completed action paths still requiring review:");
1244
+ for (const rel of unrestored) lines.push(` - ${rel}`);
1245
+ }
1246
+ if (backupDir && isDir(backupDir)) {
1247
+ lines.push("");
1248
+ lines.push("Manual restore examples:");
1249
+ lines.push(` cp "${backupDir}/<relpath>" "${ctx.hqRoot}/<relpath>"`);
1250
+ lines.push(` rsync -a "${backupDir}/" "${ctx.hqRoot}/"`);
1251
+ } else {
1252
+ lines.push("", "No pre-op safety snapshot was available for automatic restore.");
1253
+ }
1254
+
1255
+ const manifestDir = path.join(ctx.hqRoot, ".hq", "rescue-recovery");
1256
+ const manifestPath = path.join(manifestDir, `rescue-apply-failure-${ctx.runTs}.md`);
1257
+ try {
1258
+ fs.mkdirSync(manifestDir, { recursive: true });
1259
+ fs.writeFileSync(manifestPath, lines.join("\n") + "\n");
1260
+ return manifestPath;
1261
+ } catch {
1262
+ return null;
1263
+ }
1264
+ }
1265
+
1115
1266
  // --- Rescue-target mapping ---
1116
1267
  function mapRescueTarget(rel: string): string {
1117
1268
  if (rel === ".claude/CLAUDE.md") return "personal/CLAUDE.md";
@@ -1431,7 +1582,9 @@ function processOne(ctx: WalkCtx, rel: string): void {
1431
1582
  if (cfg.dryRun) {
1432
1583
  ctx.out(` drop conflict artifact: ${rel}\n`);
1433
1584
  } else {
1434
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1585
+ queueAction(ctx, `drop conflict artifact: ${rel}`, [rel], () =>
1586
+ fs.rmSync(localPath, { force: true }),
1587
+ );
1435
1588
  }
1436
1589
  return;
1437
1590
  }
@@ -1441,7 +1594,9 @@ function processOne(ctx: WalkCtx, rel: string): void {
1441
1594
  if (cfg.dryRun) {
1442
1595
  ctx.out(` skip script-managed (rewrites at stamp step): ${rel}\n`);
1443
1596
  } else {
1444
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1597
+ queueAction(ctx, `drop script-managed: ${rel}`, [rel], () =>
1598
+ fs.rmSync(localPath, { force: true }),
1599
+ );
1445
1600
  }
1446
1601
  return;
1447
1602
  }
@@ -1460,7 +1615,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1460
1615
  }
1461
1616
  ctx.out(` drop reindex symlink: ${rel} -> ${tgt}\n`);
1462
1617
  } else {
1463
- ctx.actions.push(() => {
1618
+ queueAction(ctx, `drop reindex symlink: ${rel}`, [rel], () => {
1464
1619
  fs.rmSync(localPath, { force: true });
1465
1620
  ctx.appendLog(`symlink-dropped\t${rel}\t(reindex regenerable)\n`);
1466
1621
  });
@@ -1530,7 +1685,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1530
1685
  ` cloud-symlink reconciled (unchanged): ${rel} (hq-symlink: marker matches upstream target)\n`,
1531
1686
  );
1532
1687
  } else {
1533
- ctx.actions.push(() => {
1688
+ queueAction(ctx, `cloud-symlink reconciled: ${rel}`, [rel], () => {
1534
1689
  fs.rmSync(localPath, { force: true });
1535
1690
  ctx.appendLog(
1536
1691
  `cloud-symlink-reconciled\t${rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`,
@@ -1546,7 +1701,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1546
1701
  if (cfg.dryRun) {
1547
1702
  ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${rel}\n`);
1548
1703
  } else {
1549
- ctx.actions.push(() => {
1704
+ queueAction(ctx, `drift reconciled: ${rel}`, [rel], () => {
1550
1705
  fs.rmSync(localPath, { force: true });
1551
1706
  ctx.appendLog(
1552
1707
  `drift-reconciled\t${rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`,
@@ -1573,17 +1728,21 @@ function processOne(ctx: WalkCtx, rel: string): void {
1573
1728
  }
1574
1729
  } else {
1575
1730
  if (rel === ".claude/CLAUDE.md") {
1576
- ctx.actions.push(() => rescueOne(ctx, rel));
1731
+ queueAction(ctx, `rescue diff-append: ${rel}`, [rel], () =>
1732
+ rescueOne(ctx, rel),
1733
+ );
1577
1734
  } else if (isOverwriteSafe(rel)) {
1578
- ctx.actions.push(() => {
1735
+ queueAction(ctx, `overwrite-safe: ${rel}`, [rel], () => {
1579
1736
  fs.rmSync(localPath, { force: true });
1580
1737
  ctx.counts.userOverwrite += 1;
1581
1738
  ctx.appendLog(`overwritten\t${rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
1582
1739
  });
1583
1740
  } else if (isConflictClass(rel)) {
1584
- ctx.actions.push(() => conflictOne(ctx, rel));
1741
+ queueAction(ctx, `conflict quarantine: ${rel}`, [rel], () =>
1742
+ conflictOne(ctx, rel),
1743
+ );
1585
1744
  } else {
1586
- ctx.actions.push(() => rescueOne(ctx, rel));
1745
+ queueAction(ctx, `rescue: ${rel}`, [rel], () => rescueOne(ctx, rel));
1587
1746
  }
1588
1747
  }
1589
1748
  } else {
@@ -1593,7 +1752,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1593
1752
  if (cfg.dryRun) {
1594
1753
  ctx.out(` unchanged (preserved in place): ${rel}\n`);
1595
1754
  } else {
1596
- ctx.actions.push(() => {
1755
+ queueAction(ctx, `preserve unchanged: ${rel}`, [], () => {
1597
1756
  fs.appendFileSync(ctx.unchangedList, `/${rel}\n`);
1598
1757
  ctx.appendLog(`unchanged\t${rel}\t(identical to upstream; left in place, mtime preserved)\n`);
1599
1758
  });
@@ -1602,7 +1761,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1602
1761
  if (cfg.dryRun) {
1603
1762
  ctx.out(` unchanged (delete + replace): ${rel}\n`);
1604
1763
  } else {
1605
- ctx.actions.push(() => {
1764
+ queueAction(ctx, `delete unchanged: ${rel}`, [rel], () => {
1606
1765
  fs.rmSync(localPath, { force: true });
1607
1766
  ctx.appendLog(`deleted\t${rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`);
1608
1767
  });
@@ -1727,7 +1886,7 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1727
1886
  if (cfg.dryRun) {
1728
1887
  ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1729
1888
  } else {
1730
- ctx.actions.push(() =>
1889
+ queueAction(ctx, "wholesale-replace: companies/_template", [rootRel], () =>
1731
1890
  fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1732
1891
  recursive: true,
1733
1892
  force: true,
@@ -1752,7 +1911,7 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1752
1911
  }
1753
1912
  ctx.out(` drop reindex symlink: ${rootRel} -> ${tgt}\n`);
1754
1913
  } else {
1755
- ctx.actions.push(() => {
1914
+ queueAction(ctx, `drop reindex symlink: ${rootRel}`, [rootRel], () => {
1756
1915
  fs.rmSync(rootAbs, { force: true });
1757
1916
  ctx.appendLog(`symlink-dropped\t${rootRel}\t(reindex regenerable)\n`);
1758
1917
  });
@@ -3108,6 +3108,44 @@ describe("share", () => {
3108
3108
  // zero-byte object with x-amz-meta-hq-symlink-target carrying the
3109
3109
  // readlink string verbatim.
3110
3110
 
3111
+ it("F32: first-sync symlink refuses to clobber an existing remote object", async () => {
3112
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3113
+ fs.mkdirSync(companyRoot, { recursive: true });
3114
+ fs.writeFileSync(path.join(companyRoot, "target.md"), "local target");
3115
+ const link = path.join(companyRoot, "link.md");
3116
+ fs.symlinkSync("target.md", link);
3117
+
3118
+ // No journal entry: this machine is seeing link.md for the first time.
3119
+ // A remote object already exists at the same key, and HEAD alone does
3120
+ // not prove it is the same symlink target, so the safe behavior is to
3121
+ // surface a push conflict instead of replacing the remote object.
3122
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
3123
+ lastModified: new Date(),
3124
+ etag: '"remote-existing-etag"',
3125
+ size: 42,
3126
+ });
3127
+
3128
+ const events: Array<{ type?: string; path?: string; direction?: string }> = [];
3129
+ const result = await share({
3130
+ paths: [link],
3131
+ company: "acme",
3132
+ vaultConfig: mockConfig,
3133
+ hqRoot: tmpDir,
3134
+ onConflict: "abort",
3135
+ onEvent: (e) => events.push(e as { type?: string; path?: string; direction?: string }),
3136
+ });
3137
+
3138
+ expect(result.aborted).toBe(true);
3139
+ expect(result.filesUploaded).toBe(0);
3140
+ expect(result.conflictPaths).toEqual(["link.md"]);
3141
+ expect(uploadSymlink).not.toHaveBeenCalled();
3142
+ expect(
3143
+ events.some(
3144
+ (e) => e.type === "conflict" && e.path === "link.md" && e.direction === "push",
3145
+ ),
3146
+ ).toBe(true);
3147
+ });
3148
+
3111
3149
  it("uploads a top-level symlink as a symlink record (not the target's bytes)", async () => {
3112
3150
  const companyRoot = path.join(tmpDir, "companies", "acme");
3113
3151
  fs.mkdirSync(companyRoot, { recursive: true });
package/src/cli/share.ts CHANGED
@@ -46,7 +46,11 @@ import {
46
46
  fetchCompanyTombstones,
47
47
  type CompanyTombstone,
48
48
  } from "./tombstones.js";
49
- import { isCoveredByAny, isDirInScope } from "../prefix-coalesce.js";
49
+ import {
50
+ isCoveredByAny,
51
+ isDirInScope,
52
+ type ScopePrefixInput,
53
+ } from "../prefix-coalesce.js";
50
54
  import {
51
55
  buildConflictId,
52
56
  buildConflictPath,
@@ -637,7 +641,7 @@ export interface ShareOptions {
637
641
  * filter — full access. An empty array means "no granted prefixes" → every
638
642
  * path is out of scope (mirrors the pull side's `isCoveredByAny([])`).
639
643
  */
640
- prefixSet?: string[];
644
+ prefixSet?: ScopePrefixInput[];
641
645
  /**
642
646
  * Pre-fetched FILE_TOMBSTONE map (POSIX key → tombstone) for the push-side
643
647
  * delete-resync consult. When omitted, share() fetches it itself via
@@ -778,7 +782,7 @@ function isPreconditionFailed(err: unknown): boolean {
778
782
  function wrapFilterWithScope(
779
783
  underlying: (absPath: string, isDir?: boolean) => boolean,
780
784
  syncRoot: string,
781
- prefixSet: readonly string[],
785
+ prefixSet: readonly ScopePrefixInput[],
782
786
  onScopeExcluded: (rel: string) => void,
783
787
  ): (absPath: string, isDir?: boolean) => boolean {
784
788
  return (absPath: string, isDir?: boolean) => {
@@ -1279,13 +1283,15 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1279
1283
 
1280
1284
  let isFreshCollision = false;
1281
1285
  let multipartConverged = false;
1282
- if (!journalEntry && item.kind === "file") {
1286
+ if (!journalEntry && item.kind === "symlink") {
1287
+ // A HEAD on an existing object does not expose the symlink target.
1288
+ // On a first sync, treat the existing remote as a fresh collision
1289
+ // instead of replacing it with the local link record.
1290
+ isFreshCollision = true;
1291
+ } else if (!journalEntry && item.kind === "file") {
1283
1292
  // Single-part S3 PUT etag is MD5 of the body. Multipart uploads
1284
- // produce \`<md5>-<partCount>\`. Symlink records (\`kind: "symlink"\`)
1285
- // skip the check entirely the wire body shape (\`hq-symlink:\`
1286
- // prefix + target) isn't a pure byte mirror and would mis-
1287
- // classify; symlink overwrites are rare and an audit pass after
1288
- // the broader bug-cleanup wave can extend coverage if needed.
1293
+ // produce `<md5>-<partCount>`. Symlink records cannot be classified
1294
+ // from HEAD alone, so the branch above fails closed.
1289
1295
  const remoteEtagNormalized = normalizeEtag(remoteMeta.etag);
1290
1296
  const isMultipart = /-\d+$/.test(remoteEtagNormalized);
1291
1297
  if (!isMultipart) {
@@ -1392,17 +1398,25 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1392
1398
  machineId,
1393
1399
  );
1394
1400
  const conflictAbs = path.join(hqRoot, conflictRelative);
1395
- await downloadFile(ctx, relativePath, conflictAbs);
1396
- appendConflictEntry(hqRoot, {
1397
- id: buildConflictId(originalRelative, detectedAt),
1398
- originalPath: originalRelative,
1399
- conflictPath: conflictRelative,
1400
- detectedAt,
1401
- side: "push",
1402
- machineId,
1403
- localHash,
1404
- remoteHash: normalizeEtag(remoteMeta.etag),
1405
- });
1401
+ if (!isMaterializationPathStillContained(syncRoot, conflictAbs)) {
1402
+ emit({
1403
+ type: "error",
1404
+ path: relativePath,
1405
+ message: "conflict mirror skipped: local parent escaped the sync root",
1406
+ });
1407
+ } else {
1408
+ await downloadFile(ctx, relativePath, conflictAbs);
1409
+ appendConflictEntry(hqRoot, {
1410
+ id: buildConflictId(originalRelative, detectedAt),
1411
+ originalPath: originalRelative,
1412
+ conflictPath: conflictRelative,
1413
+ detectedAt,
1414
+ side: "push",
1415
+ machineId,
1416
+ localHash,
1417
+ remoteHash: normalizeEtag(remoteMeta.etag),
1418
+ });
1419
+ }
1406
1420
  } catch (mirrorErr) {
1407
1421
  emit({
1408
1422
  type: "error",
@@ -1539,20 +1553,28 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
1539
1553
  machineId,
1540
1554
  );
1541
1555
  const conflictAbs = path.join(hqRoot, conflictRelative);
1542
- await downloadFile(ctx, relativePath, conflictAbs);
1543
- appendConflictEntry(hqRoot, {
1544
- id: buildConflictId(originalRelative, detectedAt),
1545
- originalPath: originalRelative,
1546
- conflictPath: conflictRelative,
1547
- detectedAt,
1548
- side: "push",
1549
- machineId,
1550
- localHash,
1551
- // remoteMeta (if any) predates the racing write that fired the
1552
- // fence — record what we knew ("" when the key was believed
1553
- // absent); the mirror file carries the authoritative remote bytes.
1554
- remoteHash: remoteMeta ? normalizeEtag(remoteMeta.etag) : "",
1555
- });
1556
+ if (!isMaterializationPathStillContained(syncRoot, conflictAbs)) {
1557
+ emit({
1558
+ type: "error",
1559
+ path: relativePath,
1560
+ message: "conflict mirror skipped: local parent escaped the sync root",
1561
+ });
1562
+ } else {
1563
+ await downloadFile(ctx, relativePath, conflictAbs);
1564
+ appendConflictEntry(hqRoot, {
1565
+ id: buildConflictId(originalRelative, detectedAt),
1566
+ originalPath: originalRelative,
1567
+ conflictPath: conflictRelative,
1568
+ detectedAt,
1569
+ side: "push",
1570
+ machineId,
1571
+ localHash,
1572
+ // remoteMeta (if any) predates the racing write that fired the
1573
+ // fence — record what we knew ("" when the key was believed
1574
+ // absent); the mirror file carries the authoritative remote bytes.
1575
+ remoteHash: remoteMeta ? normalizeEtag(remoteMeta.etag) : "",
1576
+ });
1577
+ }
1556
1578
  } catch (mirrorErr) {
1557
1579
  emit({
1558
1580
  type: "error",
@@ -2082,6 +2104,11 @@ function isWithin(parent: string, child: string): boolean {
2082
2104
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2083
2105
  }
2084
2106
 
2107
+ function isPathWithin(parent: string, child: string): boolean {
2108
+ const rel = path.relative(parent, child);
2109
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2110
+ }
2111
+
2085
2112
  function realpathSafe(p: string): string {
2086
2113
  try {
2087
2114
  return fs.realpathSync.native(p);
@@ -2090,6 +2117,48 @@ function realpathSafe(p: string): string {
2090
2117
  }
2091
2118
  }
2092
2119
 
2120
+ function deepestExistingAncestor(start: string): string | null {
2121
+ let current = start;
2122
+ for (;;) {
2123
+ try {
2124
+ fs.lstatSync(current);
2125
+ return current;
2126
+ } catch (err: unknown) {
2127
+ const code =
2128
+ err && typeof err === "object" && "code" in err
2129
+ ? (err as { code?: string }).code
2130
+ : undefined;
2131
+ if (code !== "ENOENT" && code !== "ENOTDIR") return null;
2132
+ }
2133
+
2134
+ const parent = path.dirname(current);
2135
+ if (parent === current) return null;
2136
+ current = parent;
2137
+ }
2138
+ }
2139
+
2140
+ function isMaterializationPathStillContained(root: string, localPath: string): boolean {
2141
+ const resolvedRoot = path.resolve(root);
2142
+ const resolvedLocal = path.resolve(localPath);
2143
+ if (!isPathWithin(resolvedRoot, resolvedLocal)) return false;
2144
+
2145
+ let realRoot: string;
2146
+ try {
2147
+ realRoot = fs.realpathSync.native(resolvedRoot);
2148
+ } catch {
2149
+ return false;
2150
+ }
2151
+
2152
+ const existingAncestor = deepestExistingAncestor(path.dirname(resolvedLocal));
2153
+ if (existingAncestor === null) return false;
2154
+ try {
2155
+ const realAncestor = fs.realpathSync.native(existingAncestor);
2156
+ return isPathWithin(realRoot, realAncestor);
2157
+ } catch {
2158
+ return false;
2159
+ }
2160
+ }
2161
+
2093
2162
  /**
2094
2163
  * Containment check tailored for symlinks. Canonicalizes the link's
2095
2164
  * PARENT DIR (which is a real dir, not the link), then compares the