@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.
- 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 +330 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -1
- package/dist/cli/reindex.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 +229 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- 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 +188 -59
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +487 -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 +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +17 -3
- 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 +396 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/reindex.test.ts +47 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +261 -15
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +594 -1
- package/src/cli/sync.ts +229 -65
- 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 +18 -3
- 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");
|
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1731
|
+
queueAction(ctx, `rescue diff-append: ${rel}`, [rel], () =>
|
|
1732
|
+
rescueOne(ctx, rel),
|
|
1733
|
+
);
|
|
1490
1734
|
} else if (isOverwriteSafe(rel)) {
|
|
1491
|
-
ctx
|
|
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
|
|
1741
|
+
queueAction(ctx, `conflict quarantine: ${rel}`, [rel], () =>
|
|
1742
|
+
conflictOne(ctx, rel),
|
|
1743
|
+
);
|
|
1498
1744
|
} else {
|
|
1499
|
-
ctx
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|
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 });
|