@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/cli/sync.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
migratePersonalVaultJournal,
|
|
50
50
|
} from "../journal.js";
|
|
51
51
|
import { PERSONAL_VAULT_MANIFEST_KEY } from "../personal-vault.js";
|
|
52
|
+
import { isPersonalVaultExcluded } from "../personal-vault-exclusions.js";
|
|
52
53
|
import {
|
|
53
54
|
buildScopeShrinkPlan,
|
|
54
55
|
applyScopeShrink,
|
|
@@ -221,7 +222,13 @@ export type SyncProgressEvent =
|
|
|
221
222
|
path: string;
|
|
222
223
|
journalEtag: string;
|
|
223
224
|
remoteEtag: string;
|
|
224
|
-
reason:
|
|
225
|
+
reason:
|
|
226
|
+
| "stale-etag"
|
|
227
|
+
| "legacy-no-etag"
|
|
228
|
+
| "bulk-asymmetry"
|
|
229
|
+
| "divergent-local"
|
|
230
|
+
| "missing-delete-intent"
|
|
231
|
+
| "intent-changed";
|
|
225
232
|
}
|
|
226
233
|
| {
|
|
227
234
|
/**
|
|
@@ -1202,11 +1209,19 @@ function executeFileTombstoneDelete(
|
|
|
1202
1209
|
counters: PullCounters,
|
|
1203
1210
|
): void {
|
|
1204
1211
|
const tombstoneKey = item.remoteFile.key;
|
|
1205
|
-
const tombstonePath =
|
|
1206
|
-
if (
|
|
1212
|
+
const tombstonePath = item.localPath;
|
|
1213
|
+
if (!isDownloadWritePathStillContained(run.companyRoot, tombstoneKey, tombstonePath)) {
|
|
1214
|
+
run.emit({
|
|
1215
|
+
type: "error",
|
|
1216
|
+
path: tombstoneKey,
|
|
1217
|
+
message: "tombstone-suppress unlink skipped: local parent escaped the sync root",
|
|
1218
|
+
});
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1207
1221
|
try {
|
|
1208
1222
|
const lstat = fs.lstatSync(tombstonePath);
|
|
1209
|
-
if (
|
|
1223
|
+
if (!matchesLocalSnapshot(tombstonePath, item.localSnapshot)) {
|
|
1224
|
+
reportLocalSnapshotConflict(run, counters, tombstoneKey);
|
|
1210
1225
|
return;
|
|
1211
1226
|
}
|
|
1212
1227
|
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
@@ -1252,7 +1267,13 @@ async function executeConflictItem(
|
|
|
1252
1267
|
// the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
|
|
1253
1268
|
// divergent local content) that the keep path produced for these files.
|
|
1254
1269
|
if (isCloudAuthoritative(vaultKeyForLocalPath(run.hqRoot, localPath))) {
|
|
1255
|
-
downloadItems.push({
|
|
1270
|
+
downloadItems.push({
|
|
1271
|
+
action: "download",
|
|
1272
|
+
remoteFile,
|
|
1273
|
+
localPath,
|
|
1274
|
+
isNew: false,
|
|
1275
|
+
localSnapshot: item.localSnapshot,
|
|
1276
|
+
});
|
|
1256
1277
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1257
1278
|
return null;
|
|
1258
1279
|
}
|
|
@@ -1315,6 +1336,7 @@ async function executeConflictItem(
|
|
|
1315
1336
|
"down",
|
|
1316
1337
|
remoteFile.etag,
|
|
1317
1338
|
item.localMtime.getTime(),
|
|
1339
|
+
item.localSnapshot.kind === "symlink" ? "symlink" : "file",
|
|
1318
1340
|
);
|
|
1319
1341
|
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1320
1342
|
counters.filesSkipped++;
|
|
@@ -1404,6 +1426,7 @@ async function executeConflictItem(
|
|
|
1404
1426
|
"down",
|
|
1405
1427
|
remoteFile.etag,
|
|
1406
1428
|
item.localMtime.getTime(),
|
|
1429
|
+
item.localSnapshot.kind === "symlink" ? "symlink" : "file",
|
|
1407
1430
|
);
|
|
1408
1431
|
// Journal-honesty: we recorded the remote etag so this conflict can't
|
|
1409
1432
|
// re-fire (#137), but the KEPT local copy diverges from that remote — it
|
|
@@ -1421,6 +1444,7 @@ async function executeConflictItem(
|
|
|
1421
1444
|
remoteFile,
|
|
1422
1445
|
localPath,
|
|
1423
1446
|
isNew: false,
|
|
1447
|
+
localSnapshot: item.localSnapshot,
|
|
1424
1448
|
});
|
|
1425
1449
|
return null;
|
|
1426
1450
|
}
|
|
@@ -1494,6 +1518,13 @@ async function downloadOne(
|
|
|
1494
1518
|
run.ctx,
|
|
1495
1519
|
remoteFile.key,
|
|
1496
1520
|
localPath,
|
|
1521
|
+
{
|
|
1522
|
+
beforeReplace: () => {
|
|
1523
|
+
if (!matchesLocalSnapshot(localPath, downloadItem.localSnapshot)) {
|
|
1524
|
+
throw new LocalSnapshotChangedError(remoteFile.key);
|
|
1525
|
+
}
|
|
1526
|
+
},
|
|
1527
|
+
},
|
|
1497
1528
|
);
|
|
1498
1529
|
reportModeWarnings(run, modeWarnings);
|
|
1499
1530
|
const author = metadata?.["created-by"] ?? null;
|
|
@@ -1515,6 +1546,7 @@ async function downloadOne(
|
|
|
1515
1546
|
remoteFile.etag,
|
|
1516
1547
|
localLstat.mtimeMs,
|
|
1517
1548
|
createdBySub,
|
|
1549
|
+
isLocalSymlink ? "symlink" : "file",
|
|
1518
1550
|
);
|
|
1519
1551
|
|
|
1520
1552
|
const priorEntry = getEntry(run.journal, remoteFile.key);
|
|
@@ -1531,7 +1563,9 @@ async function downloadOne(
|
|
|
1531
1563
|
counters.bytesDownloaded += size;
|
|
1532
1564
|
counters.changedPaths.push(remoteFile.key);
|
|
1533
1565
|
} catch (err) {
|
|
1534
|
-
if (
|
|
1566
|
+
if (err instanceof LocalSnapshotChangedError) {
|
|
1567
|
+
reportLocalSnapshotConflict(run, counters, remoteFile.key);
|
|
1568
|
+
} else if (isAccessDenied(err)) {
|
|
1535
1569
|
counters.filesSkipped++;
|
|
1536
1570
|
} else {
|
|
1537
1571
|
run.emit({
|
|
@@ -1622,13 +1656,13 @@ async function verifyPlannedJournalTombstones(
|
|
|
1622
1656
|
// never legitimately exist in a company vault, so treating it as verified
|
|
1623
1657
|
// is safe: the executor cleans the local copy + journal entry instead of
|
|
1624
1658
|
// the push leg ever re-uploading it (tombstone-not-reupload).
|
|
1625
|
-
const autoVerified:
|
|
1626
|
-
const toHeadVerify:
|
|
1627
|
-
for (const
|
|
1628
|
-
if (run.options.personalMode !== true && key.startsWith("companies/")) {
|
|
1629
|
-
autoVerified.push(
|
|
1659
|
+
const autoVerified: PullPlan["tombstones"] = [];
|
|
1660
|
+
const toHeadVerify: PullPlan["tombstones"] = [];
|
|
1661
|
+
for (const tombstone of plan.tombstones) {
|
|
1662
|
+
if (run.options.personalMode !== true && tombstone.key.startsWith("companies/")) {
|
|
1663
|
+
autoVerified.push(tombstone);
|
|
1630
1664
|
} else {
|
|
1631
|
-
toHeadVerify.push(
|
|
1665
|
+
toHeadVerify.push(tombstone);
|
|
1632
1666
|
}
|
|
1633
1667
|
}
|
|
1634
1668
|
plan.tombstones = toHeadVerify;
|
|
@@ -1637,22 +1671,22 @@ async function verifyPlannedJournalTombstones(
|
|
|
1637
1671
|
return;
|
|
1638
1672
|
}
|
|
1639
1673
|
|
|
1640
|
-
await primeObjectTransport(run.ctx, "get", plan.tombstones);
|
|
1674
|
+
await primeObjectTransport(run.ctx, "get", plan.tombstones.map((t) => t.key));
|
|
1641
1675
|
|
|
1642
1676
|
const HEAD_VERIFY_CONCURRENCY = 5;
|
|
1643
|
-
const verified:
|
|
1677
|
+
const verified: PullPlan["tombstones"] = [];
|
|
1644
1678
|
for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
|
|
1645
1679
|
const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
|
|
1646
1680
|
const results = await Promise.all(
|
|
1647
|
-
batch.map(async (
|
|
1681
|
+
batch.map(async (tombstone) => {
|
|
1648
1682
|
try {
|
|
1649
|
-
const head = await headRemoteFile(run.ctx, key);
|
|
1650
|
-
return head === null ?
|
|
1683
|
+
const head = await headRemoteFile(run.ctx, tombstone.key);
|
|
1684
|
+
return head === null ? tombstone : null;
|
|
1651
1685
|
} catch (err) {
|
|
1652
1686
|
if (isAccessDenied(err)) return null;
|
|
1653
1687
|
run.emit({
|
|
1654
1688
|
type: "error",
|
|
1655
|
-
path: key,
|
|
1689
|
+
path: tombstone.key,
|
|
1656
1690
|
message: `tombstone HEAD verify failed (deferring): ${
|
|
1657
1691
|
err instanceof Error ? err.message : String(err)
|
|
1658
1692
|
}`,
|
|
@@ -1661,8 +1695,8 @@ async function verifyPlannedJournalTombstones(
|
|
|
1661
1695
|
}
|
|
1662
1696
|
}),
|
|
1663
1697
|
);
|
|
1664
|
-
for (const
|
|
1665
|
-
if (
|
|
1698
|
+
for (const tombstone of results) {
|
|
1699
|
+
if (tombstone !== null) verified.push(tombstone);
|
|
1666
1700
|
}
|
|
1667
1701
|
}
|
|
1668
1702
|
plan.tombstones = [...autoVerified, ...verified];
|
|
@@ -1673,13 +1707,21 @@ function executeJournalTombstoneDeletes(
|
|
|
1673
1707
|
plan: PullPlan,
|
|
1674
1708
|
counters: PullCounters,
|
|
1675
1709
|
): void {
|
|
1676
|
-
for (const
|
|
1677
|
-
const localPath =
|
|
1678
|
-
if (
|
|
1710
|
+
for (const tombstone of plan.tombstones) {
|
|
1711
|
+
const { key, localPath, localSnapshot } = tombstone;
|
|
1712
|
+
if (!isDownloadWritePathStillContained(run.companyRoot, key, localPath)) {
|
|
1713
|
+
run.emit({
|
|
1714
|
+
type: "error",
|
|
1715
|
+
path: key,
|
|
1716
|
+
message: "tombstone unlink skipped: local parent escaped the sync root",
|
|
1717
|
+
});
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1679
1720
|
let removedSomething = false;
|
|
1680
1721
|
try {
|
|
1681
1722
|
const lstat = fs.lstatSync(localPath);
|
|
1682
|
-
if (
|
|
1723
|
+
if (!matchesLocalSnapshot(localPath, localSnapshot)) {
|
|
1724
|
+
reportLocalSnapshotConflict(run, counters, key);
|
|
1683
1725
|
continue;
|
|
1684
1726
|
}
|
|
1685
1727
|
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
@@ -1880,29 +1922,64 @@ function isDownloadWritePathStillContained(
|
|
|
1880
1922
|
return resolved !== null && path.resolve(resolved) === path.resolve(localPath);
|
|
1881
1923
|
}
|
|
1882
1924
|
|
|
1883
|
-
function
|
|
1884
|
-
journal: SyncJournal,
|
|
1885
|
-
key: string,
|
|
1886
|
-
localPath: string,
|
|
1887
|
-
lstat: fs.Stats,
|
|
1888
|
-
): boolean {
|
|
1889
|
-
const journalEntry = journal.files[key];
|
|
1890
|
-
if (!journalEntry?.hash) {
|
|
1891
|
-
return lstat.isSymbolicLink() || lstat.isFile();
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1925
|
+
function snapshotLocalState(localPath: string): LocalSnapshot | null {
|
|
1894
1926
|
try {
|
|
1927
|
+
const lstat = fs.lstatSync(localPath);
|
|
1895
1928
|
if (lstat.isSymbolicLink()) {
|
|
1896
|
-
return hashSymlinkTarget(fs.readlinkSync(localPath))
|
|
1929
|
+
return { kind: "symlink", hash: hashSymlinkTarget(fs.readlinkSync(localPath)) };
|
|
1897
1930
|
}
|
|
1898
1931
|
if (lstat.isFile()) {
|
|
1899
|
-
return hashFile(localPath)
|
|
1932
|
+
return { kind: "file", hash: hashFile(localPath) };
|
|
1900
1933
|
}
|
|
1901
|
-
|
|
1902
|
-
return
|
|
1934
|
+
if (lstat.isDirectory()) return { kind: "directory" };
|
|
1935
|
+
return { kind: "other" };
|
|
1936
|
+
} catch (err: unknown) {
|
|
1937
|
+
const code =
|
|
1938
|
+
err && typeof err === "object" && "code" in err
|
|
1939
|
+
? (err as { code?: string }).code
|
|
1940
|
+
: undefined;
|
|
1941
|
+
return code === "ENOENT" ? { kind: "absent" } : null;
|
|
1903
1942
|
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function matchesLocalSnapshot(localPath: string, expected: LocalSnapshot): boolean {
|
|
1946
|
+
const actual = snapshotLocalState(localPath);
|
|
1947
|
+
if (actual === null || actual.kind !== expected.kind) return false;
|
|
1948
|
+
return (
|
|
1949
|
+
(actual.kind !== "file" && actual.kind !== "symlink") ||
|
|
1950
|
+
actual.hash === (expected as Extract<LocalSnapshot, { hash: string }>).hash
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function hasCurrentLocalDeleteIntent(
|
|
1955
|
+
entry: SyncJournal["files"][string] | undefined,
|
|
1956
|
+
): boolean {
|
|
1957
|
+
const intent = entry?.localDeleteIntent;
|
|
1958
|
+
return !!(
|
|
1959
|
+
intent &&
|
|
1960
|
+
intent.version === 1 &&
|
|
1961
|
+
entry.remoteEtag &&
|
|
1962
|
+
entry.kind &&
|
|
1963
|
+
intent.remoteEtag === entry.remoteEtag &&
|
|
1964
|
+
intent.localHash === entry.hash &&
|
|
1965
|
+
intent.localKind === entry.kind
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1904
1968
|
|
|
1905
|
-
|
|
1969
|
+
function reportLocalSnapshotConflict(
|
|
1970
|
+
run: PullRunContext,
|
|
1971
|
+
counters: PullCounters,
|
|
1972
|
+
key: string,
|
|
1973
|
+
): void {
|
|
1974
|
+
counters.conflicts++;
|
|
1975
|
+
counters.conflictPaths.push(key);
|
|
1976
|
+
run.emit({ type: "conflict", path: key, direction: "pull", resolution: "skip" });
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
class LocalSnapshotChangedError extends Error {
|
|
1980
|
+
constructor(key: string) {
|
|
1981
|
+
super(`local state changed while downloading ${key}; replanning required`);
|
|
1982
|
+
}
|
|
1906
1983
|
}
|
|
1907
1984
|
|
|
1908
1985
|
/**
|
|
@@ -1911,8 +1988,18 @@ function tombstoneTargetDiverged(
|
|
|
1911
1988
|
* decide what to do. `localHash` is carried on `conflict` items so the
|
|
1912
1989
|
* executor can hand it to `resolveConflict` without re-hashing.
|
|
1913
1990
|
*/
|
|
1991
|
+
type LocalSnapshot =
|
|
1992
|
+
| { kind: "absent" | "directory" | "other" }
|
|
1993
|
+
| { kind: "file" | "symlink"; hash: string };
|
|
1994
|
+
|
|
1914
1995
|
type PullPlanItem =
|
|
1915
|
-
| {
|
|
1996
|
+
| {
|
|
1997
|
+
action: "download";
|
|
1998
|
+
remoteFile: RemoteFile;
|
|
1999
|
+
localPath: string;
|
|
2000
|
+
isNew: boolean;
|
|
2001
|
+
localSnapshot: LocalSnapshot;
|
|
2002
|
+
}
|
|
1916
2003
|
| { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
|
|
1917
2004
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
1918
2005
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
@@ -1941,7 +2028,12 @@ type PullPlanItem =
|
|
|
1941
2028
|
// (keys ABSENT from the LIST, HEAD-verified before delete), these are
|
|
1942
2029
|
// suppressed purely on the FILE_TOMBSTONE authority and skip HEAD-verify (the
|
|
1943
2030
|
// remote object is present by definition).
|
|
1944
|
-
| {
|
|
2031
|
+
| {
|
|
2032
|
+
action: "tombstone-delete";
|
|
2033
|
+
remoteFile: RemoteFile;
|
|
2034
|
+
localPath: string;
|
|
2035
|
+
localSnapshot: LocalSnapshot;
|
|
2036
|
+
}
|
|
1945
2037
|
| {
|
|
1946
2038
|
action: "conflict";
|
|
1947
2039
|
remoteFile: RemoteFile;
|
|
@@ -1963,6 +2055,7 @@ type PullPlanItem =
|
|
|
1963
2055
|
// and silently fail to stamp the journal — leaving the
|
|
1964
2056
|
// conflict to re-fire forever.
|
|
1965
2057
|
localSize: number;
|
|
2058
|
+
localSnapshot: LocalSnapshot;
|
|
1966
2059
|
};
|
|
1967
2060
|
|
|
1968
2061
|
interface PullPlan {
|
|
@@ -1984,7 +2077,11 @@ interface PullPlan {
|
|
|
1984
2077
|
* propagating the peer's push-side delete cross-machine (Bug #9).
|
|
1985
2078
|
* Carried on the plan so the executor can iterate without re-walking.
|
|
1986
2079
|
*/
|
|
1987
|
-
tombstones:
|
|
2080
|
+
tombstones: Array<{
|
|
2081
|
+
key: string;
|
|
2082
|
+
localPath: string;
|
|
2083
|
+
localSnapshot: LocalSnapshot;
|
|
2084
|
+
}>;
|
|
1988
2085
|
/**
|
|
1989
2086
|
* Count of `tombstone-delete` items — remote keys present in the LIST but
|
|
1990
2087
|
* suppressed by a FILE_TOMBSTONE (delete-resync). Surfaced on the plan event's
|
|
@@ -2120,6 +2217,16 @@ function computePullPlan(
|
|
|
2120
2217
|
continue;
|
|
2121
2218
|
}
|
|
2122
2219
|
|
|
2220
|
+
// Personal-vault policy must be symmetric across both transfer legs.
|
|
2221
|
+
// The push walker refuses derived/machine-local/sensitive paths, but old
|
|
2222
|
+
// objects can remain in the bucket from clients that predate an exclusion.
|
|
2223
|
+
// Refuse those objects here too so a pull cannot rematerialize them (or,
|
|
2224
|
+
// for generated skill wrappers, fight reindex over a directory link).
|
|
2225
|
+
if (personalMode && isPersonalVaultExcluded(remoteFile.key)) {
|
|
2226
|
+
items.push({ action: "skip-excluded-policy", remoteFile, localPath });
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2123
2230
|
if (
|
|
2124
2231
|
personalMode &&
|
|
2125
2232
|
remoteFile.key.startsWith("companies/") &&
|
|
@@ -2221,6 +2328,7 @@ function computePullPlan(
|
|
|
2221
2328
|
const localTombstoneSuppresses =
|
|
2222
2329
|
journalEntry !== undefined &&
|
|
2223
2330
|
isTombstone(journalEntry) &&
|
|
2331
|
+
hasCurrentLocalDeleteIntent(journalEntry) &&
|
|
2224
2332
|
journalEntry.removedAt !== undefined &&
|
|
2225
2333
|
!isRemoteRecreateAfterTombstone(remoteFile, {
|
|
2226
2334
|
deletedAt: journalEntry.removedAt,
|
|
@@ -2239,6 +2347,7 @@ function computePullPlan(
|
|
|
2239
2347
|
// dangling link on every run.
|
|
2240
2348
|
// 3. A regular file: indistinguishable from current behaviour.
|
|
2241
2349
|
let localLstat: fs.Stats | null = null;
|
|
2350
|
+
let plannedLocalSnapshot: LocalSnapshot = { kind: "absent" };
|
|
2242
2351
|
let localPathBlockedByFileAncestor = false;
|
|
2243
2352
|
try {
|
|
2244
2353
|
localLstat = fs.lstatSync(localPath);
|
|
@@ -2345,6 +2454,10 @@ function computePullPlan(
|
|
|
2345
2454
|
: hashFile(localPath);
|
|
2346
2455
|
}
|
|
2347
2456
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
2457
|
+
plannedLocalSnapshot = {
|
|
2458
|
+
kind: isLocalSymlink ? "symlink" : "file",
|
|
2459
|
+
hash: localHash,
|
|
2460
|
+
};
|
|
2348
2461
|
const remoteChanged =
|
|
2349
2462
|
!!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
2350
2463
|
|
|
@@ -2367,9 +2480,15 @@ function computePullPlan(
|
|
|
2367
2480
|
localHash,
|
|
2368
2481
|
localMtime: localLstat!.mtime,
|
|
2369
2482
|
localSize: isLocalSymlink ? 0 : localLstat!.size,
|
|
2483
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2370
2484
|
});
|
|
2371
2485
|
} else {
|
|
2372
|
-
items.push({
|
|
2486
|
+
items.push({
|
|
2487
|
+
action: "tombstone-delete",
|
|
2488
|
+
remoteFile,
|
|
2489
|
+
localPath,
|
|
2490
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2491
|
+
});
|
|
2373
2492
|
}
|
|
2374
2493
|
continue;
|
|
2375
2494
|
}
|
|
@@ -2395,6 +2514,7 @@ function computePullPlan(
|
|
|
2395
2514
|
// every sync.
|
|
2396
2515
|
localMtime: localLstat!.mtime,
|
|
2397
2516
|
localSize: isLocalSymlink ? 0 : localLstat!.size,
|
|
2517
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2398
2518
|
});
|
|
2399
2519
|
continue;
|
|
2400
2520
|
}
|
|
@@ -2454,6 +2574,7 @@ function computePullPlan(
|
|
|
2454
2574
|
localHash,
|
|
2455
2575
|
localMtime: localLstat!.mtime,
|
|
2456
2576
|
localSize: localLstat!.size,
|
|
2577
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2457
2578
|
});
|
|
2458
2579
|
continue;
|
|
2459
2580
|
}
|
|
@@ -2483,7 +2604,12 @@ function computePullPlan(
|
|
|
2483
2604
|
if (journalEntry?.removedReason === "local-delete") {
|
|
2484
2605
|
continue;
|
|
2485
2606
|
}
|
|
2486
|
-
items.push({
|
|
2607
|
+
items.push({
|
|
2608
|
+
action: "tombstone-delete",
|
|
2609
|
+
remoteFile,
|
|
2610
|
+
localPath,
|
|
2611
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2612
|
+
});
|
|
2487
2613
|
continue;
|
|
2488
2614
|
}
|
|
2489
2615
|
|
|
@@ -2496,15 +2622,20 @@ function computePullPlan(
|
|
|
2496
2622
|
!localExists &&
|
|
2497
2623
|
journalEntry !== undefined &&
|
|
2498
2624
|
!isTombstone(journalEntry) &&
|
|
2499
|
-
|
|
2500
|
-
journalEntry.remoteEtag !== undefined &&
|
|
2625
|
+
hasCurrentLocalDeleteIntent(journalEntry) &&
|
|
2501
2626
|
normalizeEtag(remoteFile.etag) === journalEntry.remoteEtag
|
|
2502
2627
|
) {
|
|
2503
2628
|
intentionalDeleteCandidates.push({ remoteFile, localPath });
|
|
2504
2629
|
continue;
|
|
2505
2630
|
}
|
|
2506
2631
|
|
|
2507
|
-
items.push({
|
|
2632
|
+
items.push({
|
|
2633
|
+
action: "download",
|
|
2634
|
+
remoteFile,
|
|
2635
|
+
localPath,
|
|
2636
|
+
isNew: !localExists,
|
|
2637
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2638
|
+
});
|
|
2508
2639
|
}
|
|
2509
2640
|
|
|
2510
2641
|
// ── Intentional-delete bulk-asymmetry decision (pull-leg producer) ─────────
|
|
@@ -2526,6 +2657,7 @@ function computePullPlan(
|
|
|
2526
2657
|
remoteFile: cand.remoteFile,
|
|
2527
2658
|
localPath: cand.localPath,
|
|
2528
2659
|
isNew: true,
|
|
2660
|
+
localSnapshot: { kind: "absent" },
|
|
2529
2661
|
});
|
|
2530
2662
|
} else {
|
|
2531
2663
|
tombstoneEntry(journal, cand.remoteFile.key, "local-delete");
|
|
@@ -2585,7 +2717,7 @@ function computePullPlan(
|
|
|
2585
2717
|
// `propagateDeletes` plan in share.ts.
|
|
2586
2718
|
const remoteKeySet = new Set<string>();
|
|
2587
2719
|
for (const rf of remoteFiles) remoteKeySet.add(rf.key);
|
|
2588
|
-
const tombstones:
|
|
2720
|
+
const tombstones: PullPlan["tombstones"] = [];
|
|
2589
2721
|
for (const key of Object.keys(journal.files)) {
|
|
2590
2722
|
// Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
|
|
2591
2723
|
// carry backslash separators; the remote LIST is always forward-slash, so a
|
|
@@ -2665,7 +2797,9 @@ function computePullPlan(
|
|
|
2665
2797
|
// we can't read local state, so we can't safely decide.
|
|
2666
2798
|
if (code !== "ENOENT") continue;
|
|
2667
2799
|
}
|
|
2668
|
-
|
|
2800
|
+
const localSnapshot = snapshotLocalState(localPath);
|
|
2801
|
+
if (localSnapshot === null) continue;
|
|
2802
|
+
tombstones.push({ key, localPath, localSnapshot });
|
|
2669
2803
|
}
|
|
2670
2804
|
|
|
2671
2805
|
return {
|
package/src/journal.ts
CHANGED
|
@@ -425,6 +425,7 @@ export function updateEntry(
|
|
|
425
425
|
remoteEtag?: string,
|
|
426
426
|
mtimeMs?: number,
|
|
427
427
|
createdBySub?: string,
|
|
428
|
+
kind?: "file" | "symlink",
|
|
428
429
|
): void {
|
|
429
430
|
const entry: JournalEntry = {
|
|
430
431
|
hash,
|
|
@@ -444,6 +445,9 @@ export function updateEntry(
|
|
|
444
445
|
if (createdBySub !== undefined && createdBySub !== "") {
|
|
445
446
|
entry.createdBySub = createdBySub;
|
|
446
447
|
}
|
|
448
|
+
if (kind !== undefined) {
|
|
449
|
+
entry.kind = kind;
|
|
450
|
+
}
|
|
447
451
|
journal.files[relativePath] = entry;
|
|
448
452
|
journal.lastSync = new Date().toISOString();
|
|
449
453
|
}
|
|
@@ -593,6 +597,30 @@ export function tombstoneEntry(
|
|
|
593
597
|
entry.removedReason = reason;
|
|
594
598
|
}
|
|
595
599
|
|
|
600
|
+
/**
|
|
601
|
+
* Mark a journal entry for a user-authorized local delete. Callers must
|
|
602
|
+
* capture the local hash and kind before removing the object; an absent path
|
|
603
|
+
* alone is never deletion intent.
|
|
604
|
+
*/
|
|
605
|
+
export function markLocalDeleteIntent(
|
|
606
|
+
journal: SyncJournal,
|
|
607
|
+
relativePath: string,
|
|
608
|
+
localHash: string,
|
|
609
|
+
localKind: "file" | "symlink",
|
|
610
|
+
): boolean {
|
|
611
|
+
const entry = journal.files[relativePath];
|
|
612
|
+
if (!entry?.remoteEtag || entry.hash !== localHash || entry.kind !== localKind) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
entry.localDeleteIntent = {
|
|
616
|
+
version: 1,
|
|
617
|
+
remoteEtag: entry.remoteEtag,
|
|
618
|
+
localHash,
|
|
619
|
+
localKind,
|
|
620
|
+
};
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
|
|
596
624
|
/** True if the entry is a tombstone (set by `tombstoneEntry`). */
|
|
597
625
|
export function isTombstone(entry: JournalEntry | undefined): boolean {
|
|
598
626
|
return !!entry && typeof entry.removedAt === "string";
|
|
@@ -183,6 +183,29 @@ describe("OS / build cruft exclusions", () => {
|
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
describe("generated namespaced skill-wrapper exclusion", () => {
|
|
187
|
+
it.each([
|
|
188
|
+
".claude/skills/indigo:call-prep-brief/scripts",
|
|
189
|
+
".claude/skills/indigo:team-agent-telemetry-report/agents",
|
|
190
|
+
".claude/skills/indigo%3Astandup-brief/template",
|
|
191
|
+
".claude/skills/core:review/SKILL.md",
|
|
192
|
+
])("excludes the derived link-farm path %s", (p) => {
|
|
193
|
+
expect(matchPersonalVaultExclusion(p)?.id).toBe("generated-skill-wrapper");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it.each([
|
|
197
|
+
".claude/skills/deep-plan/SKILL.md",
|
|
198
|
+
"personal/skills/indigo:custom/SKILL.md",
|
|
199
|
+
".claude/skills/indigo-call-prep/scripts",
|
|
200
|
+
".claude/skills/:missing-namespace/SKILL.md",
|
|
201
|
+
".claude/skills/missing-skill:/SKILL.md",
|
|
202
|
+
])("does not exclude non-wrapper content %s", (p) => {
|
|
203
|
+
expect(matchPersonalVaultExclusion(p)?.id).not.toBe(
|
|
204
|
+
"generated-skill-wrapper",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
186
209
|
// ── _testing internals ───────────────────────────────────────────────────────
|
|
187
210
|
|
|
188
211
|
describe("_testing internals", () => {
|
|
@@ -29,7 +29,13 @@
|
|
|
29
29
|
* handled by `EPHEMERAL_PATH_PATTERN` in share.ts). Same logic
|
|
30
30
|
* applies: these are local-only safety backups.
|
|
31
31
|
*
|
|
32
|
-
* 5.
|
|
32
|
+
* 5. Reindex-generated skill wrappers. `.claude/skills/<ns>:<skill>/`
|
|
33
|
+
* (encoded as `<ns>%3A<skill>` on Windows) is a derived link farm over
|
|
34
|
+
* canonical `core/`, `personal/`, package, or company skill sources.
|
|
35
|
+
* Syncing the links duplicates their owners and makes personal pulls
|
|
36
|
+
* fight reindex over directory links.
|
|
37
|
+
*
|
|
38
|
+
* 6. OS / build cruft. `.DS_Store`, `node_modules/`, `dist/`, `.next/`,
|
|
33
39
|
* `build/`. Universally noise inside HQ (personal scope should not
|
|
34
40
|
* contain code projects, but defense-in-depth catches anyone who
|
|
35
41
|
* symlinks a project subtree in).
|
|
@@ -43,14 +49,16 @@
|
|
|
43
49
|
* Application scope: personal vault only. Company vaults have separate
|
|
44
50
|
* first-push protection (settings/, data/, workers/, .git/ exclusion in
|
|
45
51
|
* `src-tauri/src/util/ignore.rs`) and may legitimately ship `output/` or
|
|
46
|
-
* `.env*` paths inside their data folders. Wired
|
|
47
|
-
*
|
|
52
|
+
* `.env*` paths inside their data folders. Wired only when `personalMode`
|
|
53
|
+
* is true.
|
|
48
54
|
*
|
|
49
55
|
* Wire-points (parallel to `EPHEMERAL_PATH_PATTERN`):
|
|
50
56
|
* - `collectFiles` / `walkDir` (push) — wrap `shouldSync` so excluded
|
|
51
57
|
* relative paths are rejected before upload.
|
|
52
58
|
* - `computeDeletePlan` (delete) — same `shouldSync` wrap, so journal
|
|
53
59
|
* entries matching an exclusion are skipped on the delete pass too.
|
|
60
|
+
* - `computePullPlan` (pull) — reject legacy remote objects before any
|
|
61
|
+
* local inspection or download so excluded state cannot rematerialize.
|
|
54
62
|
*/
|
|
55
63
|
|
|
56
64
|
import * as path from "path";
|
|
@@ -79,6 +87,7 @@ export interface PersonalVaultExclusion {
|
|
|
79
87
|
| "machine-local"
|
|
80
88
|
| "scratch"
|
|
81
89
|
| "conflict-mirror"
|
|
90
|
+
| "generated-mirror"
|
|
82
91
|
| "os-cruft"
|
|
83
92
|
| "build-output";
|
|
84
93
|
/**
|
|
@@ -104,6 +113,25 @@ function basename(relPath: string): string {
|
|
|
104
113
|
return i === -1 ? relPath : relPath.slice(i + 1);
|
|
105
114
|
}
|
|
106
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Reindex owns every namespaced wrapper under `.claude/skills/`. The wrapper
|
|
118
|
+
* segment is `<namespace>:<skill>` on POSIX and `<namespace>%3A<skill>` after
|
|
119
|
+
* the Win32 local-path codec escapes the reserved colon. Require non-empty
|
|
120
|
+
* names on both sides so hand-authored malformed paths are not swept up.
|
|
121
|
+
*/
|
|
122
|
+
function isGeneratedSkillWrapperPath(relPath: string): boolean {
|
|
123
|
+
const parts = relPath.split("/");
|
|
124
|
+
if (parts[0] !== ".claude" || parts[1] !== "skills" || parts.length < 3) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const wrapper = parts[2] ?? "";
|
|
128
|
+
const literalColon = wrapper.indexOf(":");
|
|
129
|
+
if (literalColon > 0 && literalColon < wrapper.length - 1) return true;
|
|
130
|
+
|
|
131
|
+
const encodedColon = wrapper.toUpperCase().indexOf("%3A");
|
|
132
|
+
return encodedColon > 0 && encodedColon + 3 < wrapper.length;
|
|
133
|
+
}
|
|
134
|
+
|
|
107
135
|
export const PERSONAL_VAULT_DEFAULT_EXCLUSIONS: readonly PersonalVaultExclusion[] = [
|
|
108
136
|
// ── secrets ───────────────────────────────────────────────────────────
|
|
109
137
|
{
|
|
@@ -175,6 +203,13 @@ export const PERSONAL_VAULT_DEFAULT_EXCLUSIONS: readonly PersonalVaultExclusion[
|
|
|
175
203
|
pattern: "**/.hq-conflicts/**",
|
|
176
204
|
test: (p) => hasSegment(p, ".hq-conflicts"),
|
|
177
205
|
},
|
|
206
|
+
// ── reindex-generated link farm ───────────────────────────────────────
|
|
207
|
+
{
|
|
208
|
+
id: "generated-skill-wrapper",
|
|
209
|
+
category: "generated-mirror",
|
|
210
|
+
pattern: ".claude/skills/<namespace>:<skill>/**",
|
|
211
|
+
test: (p) => isGeneratedSkillWrapperPath(p),
|
|
212
|
+
},
|
|
178
213
|
// ── OS / build cruft ──────────────────────────────────────────────────
|
|
179
214
|
{
|
|
180
215
|
id: "ds-store",
|
|
@@ -285,4 +320,5 @@ export function wrapFilterWithPersonalVaultDefaults(
|
|
|
285
320
|
export const _testing = {
|
|
286
321
|
hasSegment,
|
|
287
322
|
basename,
|
|
323
|
+
isGeneratedSkillWrapperPath,
|
|
288
324
|
};
|
package/src/personal-vault.ts
CHANGED
|
@@ -105,6 +105,21 @@ export interface PersonalVaultOptions {
|
|
|
105
105
|
*/
|
|
106
106
|
export const PERSONAL_VAULT_MANIFEST_KEY = "companies/manifest.yaml";
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Release-owned scaffold and reindex-generated wrappers must never be
|
|
110
|
+
* delete-propagated from the personal vault. They are recreated locally by a
|
|
111
|
+
* release or `hq reindex`, so an ordinary local absence is not user intent.
|
|
112
|
+
*/
|
|
113
|
+
export function isPersonalVaultDeletionExcluded(key: string): boolean {
|
|
114
|
+
const normalized = key.split("\\").join("/").replace(/^\.\//, "");
|
|
115
|
+
return (
|
|
116
|
+
normalized === "core" ||
|
|
117
|
+
normalized.startsWith("core/") ||
|
|
118
|
+
normalized === ".claude/skills" ||
|
|
119
|
+
normalized.startsWith(".claude/skills/")
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
export function computePersonalVaultPaths(
|
|
109
124
|
hqRoot: string,
|
|
110
125
|
opts: PersonalVaultOptions = {},
|