@indigoai-us/hq-cloud 6.14.10 → 6.14.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/cli/rescue-classify-ordering.test.js +42 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +5 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.test.js +26 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +10 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +30 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +20 -4
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +37 -3
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +19 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -1
- package/dist/s3.d.ts +20 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +71 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -1
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/rescue-classify-ordering.test.ts +49 -0
- package/src/cli/rescue-core.ts +5 -1
- package/src/cli/share.test.ts +47 -0
- package/src/cli/sync.test.ts +37 -0
- package/src/cli/sync.ts +11 -0
- package/src/personal-vault-exclusions.test.ts +23 -0
- package/src/personal-vault-exclusions.ts +39 -3
- package/src/s3.test.ts +91 -0
- package/src/s3.ts +85 -2
package/src/s3.test.ts
CHANGED
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
FILE_BTIME_META_KEY,
|
|
96
96
|
classifyVaultKey,
|
|
97
97
|
validateVaultUploadKey,
|
|
98
|
+
replaceStagedPath,
|
|
98
99
|
} from "./s3.js";
|
|
99
100
|
import {
|
|
100
101
|
setObjectIOFactory,
|
|
@@ -918,6 +919,96 @@ describe("downloadFile", () => {
|
|
|
918
919
|
expect(fs.readFileSync(localPath, "utf-8")).toBe("existing local copy");
|
|
919
920
|
});
|
|
920
921
|
|
|
922
|
+
it("replaces an existing directory link without changing its target", async () => {
|
|
923
|
+
const targetDir = path.join(tmpRoot, "generated-skill-source");
|
|
924
|
+
const localPath = path.join(tmpRoot, "generated-skill-link");
|
|
925
|
+
fs.mkdirSync(targetDir);
|
|
926
|
+
fs.writeFileSync(path.join(targetDir, "sentinel.txt"), "source-owned");
|
|
927
|
+
fs.symlinkSync(targetDir, localPath, "dir");
|
|
928
|
+
|
|
929
|
+
nextGetObjectResponse = {
|
|
930
|
+
Body: (async function* () {
|
|
931
|
+
yield Buffer.from("remote replacement");
|
|
932
|
+
})(),
|
|
933
|
+
Metadata: {},
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
await downloadFile(makeCtx(), "generated-skill-link", localPath);
|
|
937
|
+
|
|
938
|
+
expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
|
|
939
|
+
expect(fs.readFileSync(localPath, "utf8")).toBe("remote replacement");
|
|
940
|
+
expect(fs.readFileSync(path.join(targetDir, "sentinel.txt"), "utf8")).toBe(
|
|
941
|
+
"source-owned",
|
|
942
|
+
);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it("restores an existing directory link when staged replacement fails", () => {
|
|
946
|
+
const targetDir = path.join(tmpRoot, "rollback-source");
|
|
947
|
+
const localPath = path.join(tmpRoot, "rollback-link");
|
|
948
|
+
const stagedPath = path.join(tmpRoot, ".hq-tmp-rollback-test");
|
|
949
|
+
fs.mkdirSync(targetDir);
|
|
950
|
+
fs.symlinkSync(targetDir, localPath, "dir");
|
|
951
|
+
fs.writeFileSync(stagedPath, "staged replacement");
|
|
952
|
+
|
|
953
|
+
let renameCalls = 0;
|
|
954
|
+
expect(() =>
|
|
955
|
+
replaceStagedPath(stagedPath, localPath, {
|
|
956
|
+
lstat: (p) => fs.lstatSync(p),
|
|
957
|
+
rename: (from, to) => {
|
|
958
|
+
renameCalls++;
|
|
959
|
+
if (renameCalls === 2) {
|
|
960
|
+
throw Object.assign(new Error("simulated Windows replacement failure"), {
|
|
961
|
+
code: "EPERM",
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
fs.renameSync(from, to);
|
|
965
|
+
},
|
|
966
|
+
remove: (p) => fs.unlinkSync(p),
|
|
967
|
+
}),
|
|
968
|
+
).toThrow("simulated Windows replacement failure");
|
|
969
|
+
|
|
970
|
+
expect(renameCalls).toBe(3);
|
|
971
|
+
expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
|
|
972
|
+
expect(fs.readlinkSync(localPath)).toBe(targetDir);
|
|
973
|
+
expect(fs.readFileSync(stagedPath, "utf8")).toBe("staged replacement");
|
|
974
|
+
expect(
|
|
975
|
+
fs.readdirSync(tmpRoot).filter((name) => name.startsWith(".hq-backup-")),
|
|
976
|
+
).toEqual([]);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("restores an existing directory link when backup cleanup fails", () => {
|
|
980
|
+
const targetDir = path.join(tmpRoot, "cleanup-rollback-source");
|
|
981
|
+
const localPath = path.join(tmpRoot, "cleanup-rollback-link");
|
|
982
|
+
const stagedPath = path.join(tmpRoot, ".hq-tmp-cleanup-rollback-test");
|
|
983
|
+
fs.mkdirSync(targetDir);
|
|
984
|
+
fs.symlinkSync(targetDir, localPath, "dir");
|
|
985
|
+
fs.writeFileSync(stagedPath, "staged replacement");
|
|
986
|
+
|
|
987
|
+
let renameCalls = 0;
|
|
988
|
+
expect(() =>
|
|
989
|
+
replaceStagedPath(stagedPath, localPath, {
|
|
990
|
+
lstat: (p) => fs.lstatSync(p),
|
|
991
|
+
rename: (from, to) => {
|
|
992
|
+
renameCalls++;
|
|
993
|
+
fs.renameSync(from, to);
|
|
994
|
+
},
|
|
995
|
+
remove: () => {
|
|
996
|
+
throw Object.assign(new Error("simulated backup cleanup failure"), {
|
|
997
|
+
code: "EPERM",
|
|
998
|
+
});
|
|
999
|
+
},
|
|
1000
|
+
}),
|
|
1001
|
+
).toThrow("simulated backup cleanup failure");
|
|
1002
|
+
|
|
1003
|
+
expect(renameCalls).toBe(4);
|
|
1004
|
+
expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
|
|
1005
|
+
expect(fs.readlinkSync(localPath)).toBe(targetDir);
|
|
1006
|
+
expect(fs.readFileSync(stagedPath, "utf8")).toBe("staged replacement");
|
|
1007
|
+
expect(
|
|
1008
|
+
fs.readdirSync(tmpRoot).filter((name) => name.startsWith(".hq-backup-")),
|
|
1009
|
+
).toEqual([]);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
921
1012
|
it("R-F11: downloads to a near component-limit basename via a bounded temp name", async () => {
|
|
922
1013
|
nextGetObjectResponse = {
|
|
923
1014
|
Body: (async function* () {
|
package/src/s3.ts
CHANGED
|
@@ -253,6 +253,89 @@ function removeTempPath(tempPath: string): void {
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
export interface ReplaceStagedPathOps {
|
|
257
|
+
lstat(path: string): fs.Stats;
|
|
258
|
+
rename(from: string, to: string): void;
|
|
259
|
+
remove(path: string): void;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const DEFAULT_REPLACE_STAGED_PATH_OPS: ReplaceStagedPathOps = {
|
|
263
|
+
lstat: (p) => fs.lstatSync(p),
|
|
264
|
+
rename: (from, to) => fs.renameSync(from, to),
|
|
265
|
+
remove: (p) => fs.unlinkSync(p),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function replacementBackupPath(localPath: string): string {
|
|
269
|
+
const dir = path.dirname(localPath);
|
|
270
|
+
const random = crypto.randomBytes(10).toString("hex");
|
|
271
|
+
return path.join(dir, `.hq-backup-${random}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Install a fully-staged download over its destination.
|
|
276
|
+
*
|
|
277
|
+
* A direct rename is atomic and remains the fast path for absent destinations
|
|
278
|
+
* and ordinary files. Windows cannot rename over an existing directory link,
|
|
279
|
+
* though, because MoveFileEx treats that link as an existing directory. For a
|
|
280
|
+
* symlink/junction destination, move the old link to a sibling backup first,
|
|
281
|
+
* install the staged entry, then remove the backup. Any failure after the
|
|
282
|
+
* backup move restores the exact prior link before rethrowing.
|
|
283
|
+
*
|
|
284
|
+
* The tiny operations seam keeps the Windows failure/rollback contract
|
|
285
|
+
* deterministic in ESM tests without spying on non-configurable fs exports.
|
|
286
|
+
*/
|
|
287
|
+
export function replaceStagedPath(
|
|
288
|
+
stagedPath: string,
|
|
289
|
+
localPath: string,
|
|
290
|
+
ops: ReplaceStagedPathOps = DEFAULT_REPLACE_STAGED_PATH_OPS,
|
|
291
|
+
): void {
|
|
292
|
+
let existing: fs.Stats;
|
|
293
|
+
try {
|
|
294
|
+
existing = ops.lstat(localPath);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
297
|
+
ops.rename(stagedPath, localPath);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!existing.isSymbolicLink()) {
|
|
304
|
+
ops.rename(stagedPath, localPath);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const backupPath = replacementBackupPath(localPath);
|
|
309
|
+
ops.rename(localPath, backupPath);
|
|
310
|
+
let stagedInstalled = false;
|
|
311
|
+
try {
|
|
312
|
+
ops.rename(stagedPath, localPath);
|
|
313
|
+
stagedInstalled = true;
|
|
314
|
+
ops.remove(backupPath);
|
|
315
|
+
} catch (primaryError) {
|
|
316
|
+
const rollbackErrors: unknown[] = [];
|
|
317
|
+
if (stagedInstalled) {
|
|
318
|
+
try {
|
|
319
|
+
ops.rename(localPath, stagedPath);
|
|
320
|
+
} catch (rollbackError) {
|
|
321
|
+
rollbackErrors.push(rollbackError);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
ops.rename(backupPath, localPath);
|
|
326
|
+
} catch (rollbackError) {
|
|
327
|
+
rollbackErrors.push(rollbackError);
|
|
328
|
+
}
|
|
329
|
+
if (rollbackErrors.length > 0) {
|
|
330
|
+
throw new AggregateError(
|
|
331
|
+
[primaryError, ...rollbackErrors],
|
|
332
|
+
"Staged path replacement failed and the previous directory link could not be fully restored",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
throw primaryError;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
256
339
|
async function getObjectStream(
|
|
257
340
|
io: ObjectIO,
|
|
258
341
|
key: string,
|
|
@@ -904,7 +987,7 @@ export async function downloadFile(
|
|
|
904
987
|
try {
|
|
905
988
|
fs.symlinkSync(symlinkTarget, tempPath);
|
|
906
989
|
options.beforeReplace?.();
|
|
907
|
-
|
|
990
|
+
replaceStagedPath(tempPath, localPath);
|
|
908
991
|
} catch (err) {
|
|
909
992
|
removeTempPath(tempPath);
|
|
910
993
|
throw err;
|
|
@@ -1014,7 +1097,7 @@ export async function downloadFile(
|
|
|
1014
1097
|
}
|
|
1015
1098
|
|
|
1016
1099
|
options.beforeReplace?.();
|
|
1017
|
-
|
|
1100
|
+
replaceStagedPath(tempPath, localPath);
|
|
1018
1101
|
tempReady = false;
|
|
1019
1102
|
} finally {
|
|
1020
1103
|
if (tempReady) removeTempPath(tempPath);
|