@indigoai-us/hq-cloud 6.11.10 → 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 (173) 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 +330 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/reindex.d.ts.map +1 -1
  8. package/dist/cli/reindex.js +16 -1
  9. package/dist/cli/reindex.js.map +1 -1
  10. package/dist/cli/reindex.test.js +39 -1
  11. package/dist/cli/reindex.test.js.map +1 -1
  12. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  13. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  14. package/dist/cli/rescue-core.js +229 -15
  15. package/dist/cli/rescue-core.js.map +1 -1
  16. package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
  17. package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
  18. package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
  19. package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
  20. package/dist/cli/share.d.ts +2 -1
  21. package/dist/cli/share.d.ts.map +1 -1
  22. package/dist/cli/share.js +100 -32
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +30 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts +28 -1
  27. package/dist/cli/sync.d.ts.map +1 -1
  28. package/dist/cli/sync.js +188 -59
  29. package/dist/cli/sync.js.map +1 -1
  30. package/dist/cli/sync.test.js +487 -1
  31. package/dist/cli/sync.test.js.map +1 -1
  32. package/dist/cognito-auth.d.ts.map +1 -1
  33. package/dist/cognito-auth.js +55 -10
  34. package/dist/cognito-auth.js.map +1 -1
  35. package/dist/cognito-auth.test.js +61 -0
  36. package/dist/cognito-auth.test.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/journal.d.ts.map +1 -1
  42. package/dist/journal.js +93 -6
  43. package/dist/journal.js.map +1 -1
  44. package/dist/journal.test.js +59 -0
  45. package/dist/journal.test.js.map +1 -1
  46. package/dist/machine-auth.test.js +60 -2
  47. package/dist/machine-auth.test.js.map +1 -1
  48. package/dist/object-io.d.ts +37 -1
  49. package/dist/object-io.d.ts.map +1 -1
  50. package/dist/object-io.js +148 -29
  51. package/dist/object-io.js.map +1 -1
  52. package/dist/object-io.test.js +121 -0
  53. package/dist/object-io.test.js.map +1 -1
  54. package/dist/operation-lock.d.ts +8 -8
  55. package/dist/operation-lock.d.ts.map +1 -1
  56. package/dist/operation-lock.js +99 -32
  57. package/dist/operation-lock.js.map +1 -1
  58. package/dist/operation-lock.test.js +51 -4
  59. package/dist/operation-lock.test.js.map +1 -1
  60. package/dist/personal-vault.d.ts +8 -0
  61. package/dist/personal-vault.d.ts.map +1 -1
  62. package/dist/personal-vault.js +17 -3
  63. package/dist/personal-vault.js.map +1 -1
  64. package/dist/personal-vault.test.js +34 -0
  65. package/dist/personal-vault.test.js.map +1 -1
  66. package/dist/prefix-coalesce.d.ts +20 -9
  67. package/dist/prefix-coalesce.d.ts.map +1 -1
  68. package/dist/prefix-coalesce.js +124 -28
  69. package/dist/prefix-coalesce.js.map +1 -1
  70. package/dist/prefix-coalesce.test.js +57 -2
  71. package/dist/prefix-coalesce.test.js.map +1 -1
  72. package/dist/remote-pull.d.ts +6 -1
  73. package/dist/remote-pull.d.ts.map +1 -1
  74. package/dist/remote-pull.js +62 -13
  75. package/dist/remote-pull.js.map +1 -1
  76. package/dist/remote-pull.test.js +189 -0
  77. package/dist/remote-pull.test.js.map +1 -1
  78. package/dist/s3.d.ts +2 -0
  79. package/dist/s3.d.ts.map +1 -1
  80. package/dist/s3.js +197 -116
  81. package/dist/s3.js.map +1 -1
  82. package/dist/s3.test.js +109 -0
  83. package/dist/s3.test.js.map +1 -1
  84. package/dist/scope-shrink.d.ts +3 -2
  85. package/dist/scope-shrink.d.ts.map +1 -1
  86. package/dist/scope-shrink.js +1 -1
  87. package/dist/scope-shrink.js.map +1 -1
  88. package/dist/skill-telemetry.d.ts +1 -1
  89. package/dist/skill-telemetry.d.ts.map +1 -1
  90. package/dist/skill-telemetry.js +69 -9
  91. package/dist/skill-telemetry.js.map +1 -1
  92. package/dist/skill-telemetry.test.js +86 -0
  93. package/dist/skill-telemetry.test.js.map +1 -1
  94. package/dist/sync/event-sync.d.ts +6 -0
  95. package/dist/sync/event-sync.d.ts.map +1 -1
  96. package/dist/sync/event-sync.js +34 -1
  97. package/dist/sync/event-sync.js.map +1 -1
  98. package/dist/sync/event-sync.test.js +73 -0
  99. package/dist/sync/event-sync.test.js.map +1 -1
  100. package/dist/sync/metrics.d.ts +17 -1
  101. package/dist/sync/metrics.d.ts.map +1 -1
  102. package/dist/sync/metrics.js +32 -1
  103. package/dist/sync/metrics.js.map +1 -1
  104. package/dist/sync/metrics.test.js +74 -1
  105. package/dist/sync/metrics.test.js.map +1 -1
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +15 -7
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/sync/push-receiver.d.ts +6 -5
  110. package/dist/sync/push-receiver.d.ts.map +1 -1
  111. package/dist/sync/push-receiver.js +13 -15
  112. package/dist/sync/push-receiver.js.map +1 -1
  113. package/dist/sync/push-receiver.test.js +36 -1
  114. package/dist/sync/push-receiver.test.js.map +1 -1
  115. package/dist/telemetry.d.ts +1 -1
  116. package/dist/telemetry.d.ts.map +1 -1
  117. package/dist/telemetry.js +59 -6
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/telemetry.test.js +74 -0
  120. package/dist/telemetry.test.js.map +1 -1
  121. package/dist/types.d.ts +8 -0
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/watcher.d.ts +36 -0
  124. package/dist/watcher.d.ts.map +1 -1
  125. package/dist/watcher.js +152 -30
  126. package/dist/watcher.js.map +1 -1
  127. package/dist/watcher.test.js +103 -0
  128. package/dist/watcher.test.js.map +1 -1
  129. package/package.json +1 -1
  130. package/src/bin/sync-runner.test.ts +396 -11
  131. package/src/bin/sync-runner.ts +254 -52
  132. package/src/cli/reindex.test.ts +47 -1
  133. package/src/cli/reindex.ts +17 -1
  134. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  135. package/src/cli/rescue-core.ts +261 -15
  136. package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
  137. package/src/cli/share.test.ts +38 -0
  138. package/src/cli/share.ts +103 -34
  139. package/src/cli/sync.test.ts +594 -1
  140. package/src/cli/sync.ts +229 -65
  141. package/src/cognito-auth.test.ts +77 -0
  142. package/src/cognito-auth.ts +73 -11
  143. package/src/index.ts +8 -0
  144. package/src/journal.test.ts +72 -0
  145. package/src/journal.ts +95 -8
  146. package/src/machine-auth.test.ts +64 -2
  147. package/src/object-io.test.ts +142 -0
  148. package/src/object-io.ts +182 -30
  149. package/src/operation-lock.test.ts +63 -4
  150. package/src/operation-lock.ts +99 -31
  151. package/src/personal-vault.test.ts +42 -0
  152. package/src/personal-vault.ts +18 -3
  153. package/src/prefix-coalesce.test.ts +71 -1
  154. package/src/prefix-coalesce.ts +155 -30
  155. package/src/remote-pull.test.ts +205 -0
  156. package/src/remote-pull.ts +77 -14
  157. package/src/s3.test.ts +126 -0
  158. package/src/s3.ts +237 -122
  159. package/src/scope-shrink.ts +6 -3
  160. package/src/skill-telemetry.test.ts +109 -0
  161. package/src/skill-telemetry.ts +82 -14
  162. package/src/sync/event-sync.test.ts +75 -0
  163. package/src/sync/event-sync.ts +54 -1
  164. package/src/sync/metrics.test.ts +81 -0
  165. package/src/sync/metrics.ts +59 -4
  166. package/src/sync/pull-scope.ts +23 -7
  167. package/src/sync/push-receiver.test.ts +38 -1
  168. package/src/sync/push-receiver.ts +15 -18
  169. package/src/telemetry.test.ts +85 -0
  170. package/src/telemetry.ts +69 -6
  171. package/src/types.ts +8 -0
  172. package/src/watcher.test.ts +117 -0
  173. 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");
@@ -869,6 +869,16 @@ function doRescue(
869
869
  }
870
870
  }
871
871
 
872
+ // --- Restore executable bit on shipped shebang scripts (defense-in-depth) ---
873
+ // rsync -a already propagated each file's upstream git tree mode; this guards
874
+ // the case where upstream itself committed an executable script as 0644 -- a
875
+ // hook the runtime exec's then dies with EACCES and is silently disabled. A
876
+ // shebang is an unambiguous "meant to be run" marker. See restoreShebangExecBits.
877
+ const execBitFixes = restoreShebangExecBits(srcDir, hqRoot, env);
878
+ if (execBitFixes > 0) {
879
+ out(`==> Restored executable bit on ${execBitFixes} shipped shebang script(s) lacking +x\n`);
880
+ }
881
+
872
882
  // --- Stamp sync-point provenance into core/core.yaml ---
873
883
  // `last_sync_at` = the source commit's committer time, NOT wall-clock now:
874
884
  // the stamp must be a pure function of srcSha so every machine rescuing the
@@ -1085,7 +1095,7 @@ interface WalkCtx {
1085
1095
  // fail-safe — a throw mid-classify aborts before a single file is mutated, so
1086
1096
  // the wipe is never left half-applied (DEV-1767). Dry-run never fills this (it
1087
1097
  // returns after classification), so live + dry-run share one classify path.
1088
- actions: Array<() => void>;
1098
+ actions: RescueAction[];
1089
1099
  counts: {
1090
1100
  userOnly: number;
1091
1101
  unchanged: number;
@@ -1102,6 +1112,157 @@ interface WalkCtx {
1102
1112
  out: (s: string) => void;
1103
1113
  }
1104
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
+
1105
1266
  // --- Rescue-target mapping ---
1106
1267
  function mapRescueTarget(rel: string): string {
1107
1268
  if (rel === ".claude/CLAUDE.md") return "personal/CLAUDE.md";
@@ -1269,6 +1430,83 @@ function diffAppendClaudeMd(ctx: WalkCtx): void {
1269
1430
  ctx.counts.claudeDiffAppend += 1;
1270
1431
  }
1271
1432
 
1433
+ /**
1434
+ * Defense-in-depth for executable scaffold scripts. `rescue` rebuilds the tree
1435
+ * with `git clone` + `rsync -a`, faithfully propagating each file's git tree
1436
+ * mode (100755 vs 100644). So a script accidentally committed upstream WITHOUT
1437
+ * its executable bit (e.g. a `.claude/hooks/*.sh` checked in as 100644) is
1438
+ * delivered non-executable to every machine, and a hook the runtime exec's
1439
+ * directly then fails with EACCES ("Permission denied") and is silently
1440
+ * disabled.
1441
+ *
1442
+ * A `#!` shebang is an unambiguous "this file is meant to be run" marker, so
1443
+ * after the overlay we ensure every shipped shebang script is executable,
1444
+ * regardless of the (possibly wrong) upstream mode bit. Execute is added only
1445
+ * where read is already granted (0644 -> 0755, 0640 -> 0750), mirroring the
1446
+ * umask convention; files that already carry any exec bit, non-files, and
1447
+ * symlinks are left untouched. Paths are enumerated from the SOURCE repo's git
1448
+ * index, so this only ever touches release-shipped scaffold -- never user
1449
+ * content the rescue leaves in place.
1450
+ *
1451
+ * Best-effort and non-fatal: a failed `git ls-files` or chmod (read-only FS,
1452
+ * EPERM) is swallowed, matching the rest of rescue's posture. Returns the count
1453
+ * of files whose mode was changed, for the run summary.
1454
+ */
1455
+ function restoreShebangExecBits(
1456
+ srcDir: string,
1457
+ hqRoot: string,
1458
+ env: NodeJS.ProcessEnv,
1459
+ ): number {
1460
+ let listing: string;
1461
+ try {
1462
+ const r = spawnSync("git", ["-C", srcDir, "ls-files", "-z"], {
1463
+ encoding: "utf-8",
1464
+ env,
1465
+ maxBuffer: 128 * 1024 * 1024,
1466
+ });
1467
+ if (r.status !== 0 || typeof r.stdout !== "string") return 0;
1468
+ listing = r.stdout;
1469
+ } catch {
1470
+ return 0;
1471
+ }
1472
+ let fixed = 0;
1473
+ for (const rel of listing.split("\0")) {
1474
+ if (!rel) continue;
1475
+ const dest = path.join(hqRoot, rel);
1476
+ let st: fs.Stats;
1477
+ try {
1478
+ st = fs.lstatSync(dest);
1479
+ } catch {
1480
+ continue; // not delivered here (preserve-excluded, ignored, narrowed out)
1481
+ }
1482
+ if (!st.isFile()) continue; // skip dirs and symlinks (lstat: a link is not a file)
1483
+ if ((st.mode & 0o111) !== 0) continue; // already executable
1484
+ // Sniff the first two bytes for a shebang.
1485
+ let head = "";
1486
+ try {
1487
+ const fd = fs.openSync(dest, "r");
1488
+ try {
1489
+ const buf = Buffer.alloc(2);
1490
+ const n = fs.readSync(fd, buf, 0, 2, 0);
1491
+ head = buf.subarray(0, n).toString("latin1");
1492
+ } finally {
1493
+ fs.closeSync(fd);
1494
+ }
1495
+ } catch {
1496
+ continue;
1497
+ }
1498
+ if (head !== "#!") continue;
1499
+ const execBits = (st.mode & 0o444) >> 2; // copy r-bits into the x-bit slots
1500
+ try {
1501
+ fs.chmodSync(dest, st.mode | execBits);
1502
+ fixed += 1;
1503
+ } catch {
1504
+ // read-only FS / EPERM -- non-fatal.
1505
+ }
1506
+ }
1507
+ return fixed;
1508
+ }
1509
+
1272
1510
  function readFileOrEmpty(p: string): string {
1273
1511
  try {
1274
1512
  return fs.readFileSync(p, "utf-8");
@@ -1344,7 +1582,9 @@ function processOne(ctx: WalkCtx, rel: string): void {
1344
1582
  if (cfg.dryRun) {
1345
1583
  ctx.out(` drop conflict artifact: ${rel}\n`);
1346
1584
  } else {
1347
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1585
+ queueAction(ctx, `drop conflict artifact: ${rel}`, [rel], () =>
1586
+ fs.rmSync(localPath, { force: true }),
1587
+ );
1348
1588
  }
1349
1589
  return;
1350
1590
  }
@@ -1354,7 +1594,9 @@ function processOne(ctx: WalkCtx, rel: string): void {
1354
1594
  if (cfg.dryRun) {
1355
1595
  ctx.out(` skip script-managed (rewrites at stamp step): ${rel}\n`);
1356
1596
  } else {
1357
- ctx.actions.push(() => fs.rmSync(localPath, { force: true }));
1597
+ queueAction(ctx, `drop script-managed: ${rel}`, [rel], () =>
1598
+ fs.rmSync(localPath, { force: true }),
1599
+ );
1358
1600
  }
1359
1601
  return;
1360
1602
  }
@@ -1373,7 +1615,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1373
1615
  }
1374
1616
  ctx.out(` drop reindex symlink: ${rel} -> ${tgt}\n`);
1375
1617
  } else {
1376
- ctx.actions.push(() => {
1618
+ queueAction(ctx, `drop reindex symlink: ${rel}`, [rel], () => {
1377
1619
  fs.rmSync(localPath, { force: true });
1378
1620
  ctx.appendLog(`symlink-dropped\t${rel}\t(reindex regenerable)\n`);
1379
1621
  });
@@ -1443,7 +1685,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1443
1685
  ` cloud-symlink reconciled (unchanged): ${rel} (hq-symlink: marker matches upstream target)\n`,
1444
1686
  );
1445
1687
  } else {
1446
- ctx.actions.push(() => {
1688
+ queueAction(ctx, `cloud-symlink reconciled: ${rel}`, [rel], () => {
1447
1689
  fs.rmSync(localPath, { force: true });
1448
1690
  ctx.appendLog(
1449
1691
  `cloud-symlink-reconciled\t${rel}\t(hq-symlink: marker matches upstream target; overlay re-lays symlink)\n`,
@@ -1459,7 +1701,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1459
1701
  if (cfg.dryRun) {
1460
1702
  ctx.out(` drift reconciled (identical to upstream HEAD; no rescue): ${rel}\n`);
1461
1703
  } else {
1462
- ctx.actions.push(() => {
1704
+ queueAction(ctx, `drift reconciled: ${rel}`, [rel], () => {
1463
1705
  fs.rmSync(localPath, { force: true });
1464
1706
  ctx.appendLog(
1465
1707
  `drift-reconciled\t${rel}\t(identical to upstream HEAD; drifted from floor only — no personal/ copy)\n`,
@@ -1486,17 +1728,21 @@ function processOne(ctx: WalkCtx, rel: string): void {
1486
1728
  }
1487
1729
  } else {
1488
1730
  if (rel === ".claude/CLAUDE.md") {
1489
- ctx.actions.push(() => rescueOne(ctx, rel));
1731
+ queueAction(ctx, `rescue diff-append: ${rel}`, [rel], () =>
1732
+ rescueOne(ctx, rel),
1733
+ );
1490
1734
  } else if (isOverwriteSafe(rel)) {
1491
- ctx.actions.push(() => {
1735
+ queueAction(ctx, `overwrite-safe: ${rel}`, [rel], () => {
1492
1736
  fs.rmSync(localPath, { force: true });
1493
1737
  ctx.counts.userOverwrite += 1;
1494
1738
  ctx.appendLog(`overwritten\t${rel}\t(overwrite-safe; upstream wins, no copy preserved)\n`);
1495
1739
  });
1496
1740
  } else if (isConflictClass(rel)) {
1497
- ctx.actions.push(() => conflictOne(ctx, rel));
1741
+ queueAction(ctx, `conflict quarantine: ${rel}`, [rel], () =>
1742
+ conflictOne(ctx, rel),
1743
+ );
1498
1744
  } else {
1499
- ctx.actions.push(() => rescueOne(ctx, rel));
1745
+ queueAction(ctx, `rescue: ${rel}`, [rel], () => rescueOne(ctx, rel));
1500
1746
  }
1501
1747
  }
1502
1748
  } else {
@@ -1506,7 +1752,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1506
1752
  if (cfg.dryRun) {
1507
1753
  ctx.out(` unchanged (preserved in place): ${rel}\n`);
1508
1754
  } else {
1509
- ctx.actions.push(() => {
1755
+ queueAction(ctx, `preserve unchanged: ${rel}`, [], () => {
1510
1756
  fs.appendFileSync(ctx.unchangedList, `/${rel}\n`);
1511
1757
  ctx.appendLog(`unchanged\t${rel}\t(identical to upstream; left in place, mtime preserved)\n`);
1512
1758
  });
@@ -1515,7 +1761,7 @@ function processOne(ctx: WalkCtx, rel: string): void {
1515
1761
  if (cfg.dryRun) {
1516
1762
  ctx.out(` unchanged (delete + replace): ${rel}\n`);
1517
1763
  } else {
1518
- ctx.actions.push(() => {
1764
+ queueAction(ctx, `delete unchanged: ${rel}`, [rel], () => {
1519
1765
  fs.rmSync(localPath, { force: true });
1520
1766
  ctx.appendLog(`deleted\t${rel}\t(unchanged vs baseline; re-laid by overlay if still upstream)\n`);
1521
1767
  });
@@ -1640,7 +1886,7 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1640
1886
  if (cfg.dryRun) {
1641
1887
  ctx.out(" wholesale-replace: companies/_template (template carve-out)\n");
1642
1888
  } else {
1643
- ctx.actions.push(() =>
1889
+ queueAction(ctx, "wholesale-replace: companies/_template", [rootRel], () =>
1644
1890
  fs.rmSync(path.join(ctx.hqRoot, "companies", "_template"), {
1645
1891
  recursive: true,
1646
1892
  force: true,
@@ -1665,7 +1911,7 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
1665
1911
  }
1666
1912
  ctx.out(` drop reindex symlink: ${rootRel} -> ${tgt}\n`);
1667
1913
  } else {
1668
- ctx.actions.push(() => {
1914
+ queueAction(ctx, `drop reindex symlink: ${rootRel}`, [rootRel], () => {
1669
1915
  fs.rmSync(rootAbs, { force: true });
1670
1916
  ctx.appendLog(`symlink-dropped\t${rootRel}\t(reindex regenerable)\n`);
1671
1917
  });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Regression for the rescue executable-bit guarantee in src/cli/rescue-core.ts
3
+ * (restoreShebangExecBits).
4
+ *
5
+ * rescue rebuilds the tree with `git clone` + `rsync -a`, which faithfully
6
+ * propagates the upstream git tree mode. So a script committed upstream WITHOUT
7
+ * its executable bit (a `.sh` checked in as 100644) is delivered non-executable
8
+ * to every machine -- and a hook the runtime exec's directly then fails with
9
+ * "Permission denied". After the overlay, rescue restores +x on any shipped
10
+ * file that begins with a `#!` shebang (and leaves non-shebang files alone).
11
+ *
12
+ * Mirrors rescue-mtime-preserve.test.ts: shim `git clone` to a local fixture
13
+ * and run the real rescue (non-dry-run, --no-backup) so the overlay lays files
14
+ * down for real.
15
+ */
16
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
17
+ import { execFileSync } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as os from "os";
20
+ import * as path from "path";
21
+ import { runRescue } from "./rescue-core.js";
22
+
23
+ function has(bin: string, ...args: string[]): boolean {
24
+ try {
25
+ execFileSync(bin, args, { stdio: "ignore" });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+ const toolsAvailable = has("git", "--version") && has("rsync", "--version");
32
+
33
+ const FLOOR_EPOCH = 1577836800; // 2020-01-01
34
+ const HEAD_EPOCH = 1609459200; // 2021-01-01
35
+
36
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
37
+ let stdout = "";
38
+ let stderr = "";
39
+ const origOut = process.stdout.write.bind(process.stdout);
40
+ const origErr = process.stderr.write.bind(process.stderr);
41
+ process.stdout.write = ((chunk: unknown) => {
42
+ stdout += String(chunk);
43
+ return true;
44
+ }) as typeof process.stdout.write;
45
+ process.stderr.write = ((chunk: unknown) => {
46
+ stderr += String(chunk);
47
+ return true;
48
+ }) as typeof process.stderr.write;
49
+ let status: number;
50
+ try {
51
+ status = runRescue(argv, { env }).status;
52
+ } finally {
53
+ process.stdout.write = origOut;
54
+ process.stderr.write = origErr;
55
+ }
56
+ return { status, stdout, stderr };
57
+ }
58
+
59
+ describe.skipIf(!toolsAvailable)("rescue restores +x on shipped shebang scripts", () => {
60
+ let workDir: string;
61
+ let upstream: string;
62
+ let hqRoot: string;
63
+ let shimDir: string;
64
+ let floorSha: string;
65
+ let env: NodeJS.ProcessEnv;
66
+
67
+ const gitAt = (cwd: string, epoch: number, ...args: string[]) =>
68
+ execFileSync("git", args, {
69
+ cwd,
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ env: {
72
+ ...process.env,
73
+ GIT_AUTHOR_NAME: "t",
74
+ GIT_AUTHOR_EMAIL: "t@t",
75
+ GIT_COMMITTER_NAME: "t",
76
+ GIT_COMMITTER_EMAIL: "t@t",
77
+ GIT_AUTHOR_DATE: `${epoch} +0000`,
78
+ GIT_COMMITTER_DATE: `${epoch} +0000`,
79
+ },
80
+ })
81
+ .toString()
82
+ .trim();
83
+
84
+ const isExec = (p: string) => (fs.statSync(p).mode & 0o111) !== 0;
85
+ const mode = (p: string) => fs.statSync(p).mode & 0o777;
86
+
87
+ beforeAll(() => {
88
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-exec-"));
89
+
90
+ // --- "upstream" repo ----------------------------------------------------
91
+ upstream = path.join(workDir, "upstream");
92
+ fs.mkdirSync(path.join(upstream, "core/scripts"), { recursive: true });
93
+ fs.mkdirSync(path.join(upstream, "core/docs"), { recursive: true });
94
+ gitAt(workDir, FLOOR_EPOCH, "init", "-b", "main", "upstream");
95
+
96
+ // Shebang script committed WITHOUT +x (the bug: arrives non-executable).
97
+ const needs = path.join(upstream, "core/scripts/needs-exec.sh");
98
+ fs.writeFileSync(needs, "#!/bin/bash\necho needs\n", { mode: 0o644 });
99
+ fs.chmodSync(needs, 0o644);
100
+ // Shebang script committed WITH +x (control: rsync -a already preserves it).
101
+ const already = path.join(upstream, "core/scripts/already-exec.sh");
102
+ fs.writeFileSync(already, "#!/bin/bash\necho already\n");
103
+ fs.chmodSync(already, 0o755);
104
+ // Non-shebang data file (control: must NOT be made executable).
105
+ fs.writeFileSync(path.join(upstream, "core/docs/note.md"), "# note\n", { mode: 0o644 });
106
+
107
+ gitAt(upstream, FLOOR_EPOCH, "add", "-A");
108
+ gitAt(upstream, FLOOR_EPOCH, "commit", "-m", "floor");
109
+ floorSha = gitAt(upstream, FLOOR_EPOCH, "rev-parse", "HEAD");
110
+ // A second commit so floor != HEAD (mirrors the mtime fixture).
111
+ fs.writeFileSync(path.join(upstream, "core/docs/head.md"), "head\n");
112
+ gitAt(upstream, HEAD_EPOCH, "add", "-A");
113
+ gitAt(upstream, HEAD_EPOCH, "commit", "-m", "head");
114
+
115
+ // Confirm the fixture actually stored the intended tree modes.
116
+ const lsFloor = gitAt(upstream, HEAD_EPOCH, "ls-tree", "-r", "HEAD");
117
+ if (!/100644\s+blob\s+\S+\s+core\/scripts\/needs-exec\.sh/.test(lsFloor)) {
118
+ throw new Error(`fixture needs-exec.sh not stored as 100644:\n${lsFloor}`);
119
+ }
120
+ if (!/100755\s+blob\s+\S+\s+core\/scripts\/already-exec\.sh/.test(lsFloor)) {
121
+ throw new Error(`fixture already-exec.sh not stored as 100755:\n${lsFloor}`);
122
+ }
123
+
124
+ // --- local HQ root being rescued (fresh; upstream files are brand-new) --
125
+ hqRoot = path.join(workDir, "hq");
126
+ fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
127
+ fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
128
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
129
+
130
+ // --- git shim: redirect `git clone <github-url>` to the local fixture ---
131
+ const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
132
+ shimDir = path.join(workDir, "shim");
133
+ fs.mkdirSync(shimDir, { recursive: true });
134
+ const shim = `#!/usr/bin/env bash
135
+ if [ "$1" = "clone" ]; then
136
+ args=()
137
+ for a in "$@"; do
138
+ case "$a" in
139
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
140
+ esac
141
+ args+=("$a")
142
+ done
143
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
144
+ fi
145
+ exec ${JSON.stringify(realGit)} "$@"
146
+ `;
147
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
148
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
149
+
150
+ const r = runRescueCapture(
151
+ [
152
+ "--hq-root", hqRoot,
153
+ "--source", "test/repo",
154
+ "--ref", "main",
155
+ "--floor-sha", floorSha,
156
+ "--yes",
157
+ "--no-backup",
158
+ ],
159
+ env,
160
+ );
161
+ if (r.status !== 0) {
162
+ throw new Error(`rescue failed (${r.status}):\n${r.stdout}\n${r.stderr}`);
163
+ }
164
+ });
165
+
166
+ afterAll(() => {
167
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
168
+ });
169
+
170
+ it("restores +x on a shebang script shipped as 0644 (the bug)", () => {
171
+ const f = path.join(hqRoot, "core/scripts/needs-exec.sh");
172
+ expect(fs.readFileSync(f, "utf-8")).toBe("#!/bin/bash\necho needs\n");
173
+ expect(isExec(f)).toBe(true);
174
+ expect(mode(f)).toBe(0o755); // 0644 + (read-bits -> exec-bits)
175
+ });
176
+
177
+ it("leaves an already-executable shebang script executable", () => {
178
+ const f = path.join(hqRoot, "core/scripts/already-exec.sh");
179
+ expect(isExec(f)).toBe(true);
180
+ });
181
+
182
+ it("never makes a non-shebang data file executable", () => {
183
+ const f = path.join(hqRoot, "core/docs/note.md");
184
+ expect(fs.existsSync(f)).toBe(true);
185
+ expect(isExec(f)).toBe(false);
186
+ });
187
+ });
@@ -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 });