@indigoai-us/hq-cloud 6.14.9 → 6.14.11
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/share.d.ts.map +1 -1
- package/dist/cli/share.js +89 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +131 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +138 -41
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +271 -25
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +22 -1
- package/dist/journal.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/personal-vault.d.ts +6 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +12 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/s3.d.ts +23 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +74 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +71 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +176 -3
- package/src/cli/share.ts +114 -15
- package/src/cli/sync.test.ts +330 -24
- package/src/cli/sync.ts +184 -50
- package/src/journal.ts +28 -0
- package/src/personal-vault-exclusions.test.ts +23 -0
- package/src/personal-vault-exclusions.ts +39 -3
- package/src/personal-vault.ts +15 -0
- package/src/s3.test.ts +91 -0
- package/src/s3.ts +88 -2
- package/src/types.ts +13 -0
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,
|
|
@@ -809,6 +892,7 @@ export async function downloadFile(
|
|
|
809
892
|
ctx: EntityContext,
|
|
810
893
|
key: string,
|
|
811
894
|
localPath: string,
|
|
895
|
+
options: { beforeReplace?: () => void } = {},
|
|
812
896
|
): Promise<{
|
|
813
897
|
metadata?: Record<string, string>;
|
|
814
898
|
contentHash?: string;
|
|
@@ -902,7 +986,8 @@ export async function downloadFile(
|
|
|
902
986
|
const tempPath = downloadTempPath(localPath);
|
|
903
987
|
try {
|
|
904
988
|
fs.symlinkSync(symlinkTarget, tempPath);
|
|
905
|
-
|
|
989
|
+
options.beforeReplace?.();
|
|
990
|
+
replaceStagedPath(tempPath, localPath);
|
|
906
991
|
} catch (err) {
|
|
907
992
|
removeTempPath(tempPath);
|
|
908
993
|
throw err;
|
|
@@ -1011,7 +1096,8 @@ export async function downloadFile(
|
|
|
1011
1096
|
}
|
|
1012
1097
|
}
|
|
1013
1098
|
|
|
1014
|
-
|
|
1099
|
+
options.beforeReplace?.();
|
|
1100
|
+
replaceStagedPath(tempPath, localPath);
|
|
1015
1101
|
tempReady = false;
|
|
1016
1102
|
} finally {
|
|
1017
1103
|
if (tempReady) removeTempPath(tempPath);
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface JournalEntry {
|
|
|
26
26
|
size: number;
|
|
27
27
|
syncedAt: string;
|
|
28
28
|
direction: "up" | "down";
|
|
29
|
+
/** Kind of local object represented by this entry. */
|
|
30
|
+
kind?: "file" | "symlink";
|
|
29
31
|
/**
|
|
30
32
|
* Cognito `sub` of the file's author, captured from the object's
|
|
31
33
|
* `created-by-sub` S3 user-metadata at download time (zero extra network —
|
|
@@ -72,6 +74,17 @@ export interface JournalEntry {
|
|
|
72
74
|
*/
|
|
73
75
|
removedAt?: string;
|
|
74
76
|
removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
|
|
77
|
+
/**
|
|
78
|
+
* Explicit local-delete intent. Unlike the legacy `removedReason` marker,
|
|
79
|
+
* this is bound to the exact synced remote revision and local object shape
|
|
80
|
+
* that the user approved for deletion.
|
|
81
|
+
*/
|
|
82
|
+
localDeleteIntent?: {
|
|
83
|
+
version: 1;
|
|
84
|
+
remoteEtag: string;
|
|
85
|
+
localHash: string;
|
|
86
|
+
localKind: "file" | "symlink";
|
|
87
|
+
};
|
|
75
88
|
/**
|
|
76
89
|
* Durable automatic-pull retention marker. Set when a scope shrink keeps an
|
|
77
90
|
* out-of-scope caller-authored or unknown-author entry on disk instead of
|