@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.
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +265 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +138 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +178 -58
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +362 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +8 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +298 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +174 -15
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +435 -1
- package/src/cli/sync.ts +217 -64
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +8 -2
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
package/src/cli/rescue-core.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1731
|
+
queueAction(ctx, `rescue diff-append: ${rel}`, [rel], () =>
|
|
1732
|
+
rescueOne(ctx, rel),
|
|
1733
|
+
);
|
|
1577
1734
|
} else if (isOverwriteSafe(rel)) {
|
|
1578
|
-
ctx
|
|
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
|
|
1741
|
+
queueAction(ctx, `conflict quarantine: ${rel}`, [rel], () =>
|
|
1742
|
+
conflictOne(ctx, rel),
|
|
1743
|
+
);
|
|
1585
1744
|
} else {
|
|
1586
|
-
ctx
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|
package/src/cli/share.test.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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
|
|
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 === "
|
|
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
|
|
1285
|
-
//
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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
|