@indigoai-us/hq-cloud 6.11.19 → 6.12.0
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/.github/workflows/ci.yml +1 -1
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +9 -11
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +35 -64
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +1 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.js +25 -19
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +63 -9
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +11 -25
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -17
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +40 -42
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/lib/conflict-file.js +1 -1
- package/dist/lib/conflict-file.js.map +1 -1
- package/dist/personal-vault.d.ts +23 -24
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +38 -30
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.d.ts +9 -6
- package/dist/personal-vault.test.d.ts.map +1 -1
- package/dist/personal-vault.test.js +90 -79
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +0 -3
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/s3.test.js +0 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/vault-client.d.ts +1 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +1 -1
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +15 -0
- package/dist/vault-client.test.js.map +1 -1
- package/eslint.config.js +67 -0
- package/package.json +5 -1
- package/src/bin/sync-runner-company.ts +9 -12
- package/src/bin/sync-runner.test.ts +35 -74
- package/src/bin/sync-runner.ts +0 -1
- package/src/cli/rescue-core.ts +1 -1
- package/src/cli/share.test.ts +79 -11
- package/src/cli/share.ts +24 -20
- package/src/cli/sync.test.ts +40 -42
- package/src/cli/sync.ts +25 -42
- package/src/company-resolver.test.ts +1 -1
- package/src/lib/conflict-file.ts +1 -1
- package/src/personal-vault.test.ts +91 -83
- package/src/personal-vault.ts +45 -41
- package/src/prefix-coalesce.ts +0 -4
- package/src/s3.test.ts +0 -2
- package/src/sync/push-receiver.test.ts +1 -1
- package/src/vault-client.test.ts +20 -0
- package/src/vault-client.ts +2 -2
package/src/cli/share.test.ts
CHANGED
|
@@ -993,7 +993,7 @@ describe("share", () => {
|
|
|
993
993
|
expect(putKeys).not.toContain("projects/.gitkeep");
|
|
994
994
|
});
|
|
995
995
|
|
|
996
|
-
it("scoped push defense-in-depth: a 403 on HEAD skips that key
|
|
996
|
+
it("scoped push defense-in-depth: a 403 on HEAD skips that key as scope-excluded without an error event", async () => {
|
|
997
997
|
// Belt-and-suspenders: even if a key slips past the prefix filter (a grant
|
|
998
998
|
// that changed mid-run, a pin outside the grant, prefix-coalesce
|
|
999
999
|
// imprecision), the server's correct 403 on the HEAD must NOT abort the
|
|
@@ -1017,7 +1017,7 @@ describe("share", () => {
|
|
|
1017
1017
|
return null;
|
|
1018
1018
|
});
|
|
1019
1019
|
|
|
1020
|
-
const events: Array<{ type?: string; path?: string; message?: string }> = [];
|
|
1020
|
+
const events: Array<{ type?: string; path?: string; message?: string; count?: number; samplePaths?: string[] }> = [];
|
|
1021
1021
|
const result = await share({
|
|
1022
1022
|
paths: [okFile, blockedFile],
|
|
1023
1023
|
company: "acme",
|
|
@@ -1031,21 +1031,85 @@ describe("share", () => {
|
|
|
1031
1031
|
expect(result.filesUploaded).toBe(1);
|
|
1032
1032
|
const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
1033
1033
|
expect(putKeys).toEqual(["in-scope.md"]);
|
|
1034
|
-
|
|
1034
|
+
expect(result.filesExcludedByScope).toBe(1);
|
|
1035
|
+
const scopeEv = events.find((e) => e.type === "scope-excluded") as
|
|
1036
|
+
| { count: number; samplePaths: string[] }
|
|
1037
|
+
| undefined;
|
|
1038
|
+
expect(scopeEv).toBeDefined();
|
|
1039
|
+
expect(scopeEv!.count).toBe(1);
|
|
1040
|
+
expect(scopeEv!.samplePaths).toEqual(["out-of-reach.md"]);
|
|
1041
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it("scoped push defense-in-depth: a 403 on PUT skips that key as scope-excluded without an error event", async () => {
|
|
1045
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1046
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1047
|
+
const blockedFile = path.join(companyRoot, "put-denied.md");
|
|
1048
|
+
fs.writeFileSync(blockedFile, "denied\n");
|
|
1049
|
+
|
|
1050
|
+
vi.mocked(uploadFile).mockImplementation(async (_ctx, _abs, key) => {
|
|
1051
|
+
if (key === "put-denied.md") {
|
|
1052
|
+
const err = new Error("access denied");
|
|
1053
|
+
(err as { name: string }).name = "AccessDenied";
|
|
1054
|
+
throw err;
|
|
1055
|
+
}
|
|
1056
|
+
return { etag: '"upload-etag"' };
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
const events: Array<{ type?: string; path?: string; message?: string; count?: number; samplePaths?: string[] }> = [];
|
|
1060
|
+
const result = await share({
|
|
1061
|
+
paths: [blockedFile],
|
|
1062
|
+
company: "acme",
|
|
1063
|
+
vaultConfig: mockConfig,
|
|
1064
|
+
hqRoot: tmpDir,
|
|
1065
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
expect(result.aborted).toBe(false);
|
|
1069
|
+
expect(result.filesUploaded).toBe(0);
|
|
1070
|
+
expect(result.filesExcludedByScope).toBe(1);
|
|
1071
|
+
const scopeEv = events.find((e) => e.type === "scope-excluded") as
|
|
1072
|
+
| { count: number; samplePaths: string[] }
|
|
1073
|
+
| undefined;
|
|
1074
|
+
expect(scopeEv).toBeDefined();
|
|
1075
|
+
expect(scopeEv!.count).toBe(1);
|
|
1076
|
+
expect(scopeEv!.samplePaths).toEqual(["put-denied.md"]);
|
|
1077
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it("scoped push defense-in-depth: a non-access-denied PUT failure still emits an error event", async () => {
|
|
1081
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1082
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1083
|
+
const failedFile = path.join(companyRoot, "put-failed.md");
|
|
1084
|
+
fs.writeFileSync(failedFile, "failed\n");
|
|
1085
|
+
|
|
1086
|
+
vi.mocked(uploadFile).mockRejectedValueOnce(new Error("S3 down"));
|
|
1087
|
+
|
|
1088
|
+
const events: Array<{ type?: string; path?: string; message?: string }> = [];
|
|
1089
|
+
const result = await share({
|
|
1090
|
+
paths: [failedFile],
|
|
1091
|
+
company: "acme",
|
|
1092
|
+
vaultConfig: mockConfig,
|
|
1093
|
+
hqRoot: tmpDir,
|
|
1094
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
expect(result.filesUploaded).toBe(0);
|
|
1098
|
+
expect(result.filesExcludedByScope).toBe(0);
|
|
1035
1099
|
const errs = events.filter((e) => e.type === "error") as Array<{ path?: string; message?: string }>;
|
|
1036
1100
|
expect(errs).toHaveLength(1);
|
|
1037
|
-
expect(errs[0].path).toBe("
|
|
1038
|
-
expect(errs[0].message).
|
|
1101
|
+
expect(errs[0].path).toBe("put-failed.md");
|
|
1102
|
+
expect(errs[0].message).toBe("S3 down");
|
|
1039
1103
|
});
|
|
1040
1104
|
|
|
1041
|
-
it("a Forbidden HEAD (presigned-transport denial) skips the key — NEVER an unconditional PUT", async () => {
|
|
1105
|
+
it("a Forbidden HEAD (presigned-transport denial) skips the key as scope-excluded — NEVER an unconditional PUT", async () => {
|
|
1042
1106
|
// Regression for the 2026-06-10..12 vault regression storm: the presigned
|
|
1043
1107
|
// transport used to map per-key denials and signed-GET 403s to `null`,
|
|
1044
1108
|
// which `processUploadItem` read as "no remote object" — skipping every
|
|
1045
1109
|
// conflict guard and blind-PUTting this machine's (possibly stale) bytes
|
|
1046
1110
|
// over a newer remote. The transport now throws `name: "Forbidden"`
|
|
1047
1111
|
// (SDK HeadObject parity); this locks the share side of that contract:
|
|
1048
|
-
// a Forbidden HEAD must skip the key
|
|
1112
|
+
// a Forbidden HEAD must skip the key as scope-excluded and issue NO PUT.
|
|
1049
1113
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1050
1114
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1051
1115
|
const staleFile = path.join(companyRoot, "stale-local.md");
|
|
@@ -1059,7 +1123,7 @@ describe("share", () => {
|
|
|
1059
1123
|
throw err;
|
|
1060
1124
|
});
|
|
1061
1125
|
|
|
1062
|
-
const events: Array<{ type?: string; path?: string; message?: string }> = [];
|
|
1126
|
+
const events: Array<{ type?: string; path?: string; message?: string; count?: number; samplePaths?: string[] }> = [];
|
|
1063
1127
|
const result = await share({
|
|
1064
1128
|
paths: [staleFile],
|
|
1065
1129
|
company: "acme",
|
|
@@ -1072,9 +1136,13 @@ describe("share", () => {
|
|
|
1072
1136
|
expect(vi.mocked(uploadFile)).not.toHaveBeenCalled();
|
|
1073
1137
|
expect(result.filesUploaded).toBe(0);
|
|
1074
1138
|
expect(result.aborted).toBe(false);
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1139
|
+
expect(result.filesExcludedByScope).toBe(1);
|
|
1140
|
+
const scopeEv = events.find((e) => e.type === "scope-excluded") as
|
|
1141
|
+
| { count: number; samplePaths: string[] }
|
|
1142
|
+
| undefined;
|
|
1143
|
+
expect(scopeEv).toBeDefined();
|
|
1144
|
+
expect(scopeEv!.samplePaths).toEqual(["stale-local.md"]);
|
|
1145
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
1078
1146
|
});
|
|
1079
1147
|
|
|
1080
1148
|
it("uploads (no conflict) when only the local side changed since last sync", async () => {
|
package/src/cli/share.ts
CHANGED
|
@@ -1156,14 +1156,7 @@ async function executeUploads(
|
|
|
1156
1156
|
remoteMeta = await headRemoteFile(run.ctx, relativePath);
|
|
1157
1157
|
} catch (headErr) {
|
|
1158
1158
|
if (isAccessDenied(headErr)) {
|
|
1159
|
-
run.
|
|
1160
|
-
type: "error",
|
|
1161
|
-
path: relativePath,
|
|
1162
|
-
message:
|
|
1163
|
-
"skipped: outside granted ACL scope (server returned 403 " +
|
|
1164
|
-
"SCOPE_EXCEEDS_PARENT / access denied on HEAD). Grant this path " +
|
|
1165
|
-
"to push it, or it stays local-only.",
|
|
1166
|
-
});
|
|
1159
|
+
run.scopeExcludedSet.add(relativePath);
|
|
1167
1160
|
return;
|
|
1168
1161
|
}
|
|
1169
1162
|
throw headErr;
|
|
@@ -1325,16 +1318,14 @@ async function executeUploads(
|
|
|
1325
1318
|
counters.filesSkipped++;
|
|
1326
1319
|
return;
|
|
1327
1320
|
}
|
|
1321
|
+
if (isAccessDenied(err)) {
|
|
1322
|
+
run.scopeExcludedSet.add(relativePath);
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1328
1325
|
run.emit({
|
|
1329
1326
|
type: "error",
|
|
1330
1327
|
path: relativePath,
|
|
1331
|
-
message:
|
|
1332
|
-
? "skipped: outside granted ACL scope (server returned 403 " +
|
|
1333
|
-
"SCOPE_EXCEEDS_PARENT / access denied on PUT). Grant this path " +
|
|
1334
|
-
"to push it, or it stays local-only."
|
|
1335
|
-
: err instanceof Error
|
|
1336
|
-
? err.message
|
|
1337
|
-
: String(err),
|
|
1328
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1338
1329
|
});
|
|
1339
1330
|
}
|
|
1340
1331
|
};
|
|
@@ -2256,12 +2247,25 @@ async function computeDeletePlan(
|
|
|
2256
2247
|
for (let i = 0; i < headCandidates.length; i += DELETE_PLAN_HEAD_CONCURRENCY) {
|
|
2257
2248
|
const chunk = headCandidates.slice(i, i + DELETE_PLAN_HEAD_CONCURRENCY);
|
|
2258
2249
|
const results = await Promise.all(
|
|
2259
|
-
chunk.map(async (c) =>
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2250
|
+
chunk.map(async (c) => {
|
|
2251
|
+
try {
|
|
2252
|
+
return {
|
|
2253
|
+
candidate: c,
|
|
2254
|
+
remote: await headRemoteFile(ctx, c.key),
|
|
2255
|
+
scopeExcluded: false,
|
|
2256
|
+
};
|
|
2257
|
+
} catch (err) {
|
|
2258
|
+
if (isAccessDenied(err)) {
|
|
2259
|
+
return { candidate: c, remote: null, scopeExcluded: true };
|
|
2260
|
+
}
|
|
2261
|
+
throw err;
|
|
2262
|
+
}
|
|
2263
|
+
}),
|
|
2263
2264
|
);
|
|
2264
|
-
for (const { candidate, remote } of results) {
|
|
2265
|
+
for (const { candidate, remote, scopeExcluded } of results) {
|
|
2266
|
+
if (scopeExcluded) {
|
|
2267
|
+
continue;
|
|
2268
|
+
}
|
|
2265
2269
|
if (remote === null) {
|
|
2266
2270
|
plan.toTombstone.push(candidate.key);
|
|
2267
2271
|
continue;
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -895,7 +895,7 @@ describe("sync", () => {
|
|
|
895
895
|
expect(fs.existsSync(journalPath)).toBe(false);
|
|
896
896
|
});
|
|
897
897
|
|
|
898
|
-
it("personalMode:
|
|
898
|
+
it("personalMode: downloads local companies/* keys and root keys to hqRoot by default", async () => {
|
|
899
899
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
900
900
|
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
901
901
|
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"abc000"' },
|
|
@@ -906,22 +906,24 @@ describe("sync", () => {
|
|
|
906
906
|
vaultConfig: mockConfig,
|
|
907
907
|
hqRoot: tmpDir,
|
|
908
908
|
personalMode: true,
|
|
909
|
+
// No teamSyncedSlugs → foo is a local (non-cloud) company, downloaded.
|
|
909
910
|
});
|
|
910
911
|
|
|
911
|
-
// Exact counts (regression-tight)
|
|
912
|
-
expect(result.filesSkipped).toBe(
|
|
913
|
-
expect(result.filesDownloaded).toBe(
|
|
912
|
+
// Exact counts (regression-tight): local company + root key both land.
|
|
913
|
+
expect(result.filesSkipped).toBe(0);
|
|
914
|
+
expect(result.filesDownloaded).toBe(2);
|
|
914
915
|
|
|
915
|
-
// companies
|
|
916
|
+
// companies/foo (local, non-cloud) lands at <hqRoot>/companies/foo/bar.md,
|
|
917
|
+
// NOT double-nested under <hqRoot>/companies/<slug>/companies/...
|
|
918
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(true);
|
|
916
919
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "companies", "foo", "bar.md"))).toBe(false);
|
|
917
|
-
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
|
|
918
920
|
|
|
919
921
|
// docs/readme.md MUST land at <hqRoot>/docs/readme.md (NOT <hqRoot>/companies/<slug>/docs/readme.md)
|
|
920
922
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
921
923
|
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
922
924
|
});
|
|
923
925
|
|
|
924
|
-
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping
|
|
926
|
+
it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping team-synced orphan keys", async () => {
|
|
925
927
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
926
928
|
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
927
929
|
{ key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"man111"' },
|
|
@@ -932,10 +934,12 @@ describe("sync", () => {
|
|
|
932
934
|
vaultConfig: mockConfig,
|
|
933
935
|
hqRoot: tmpDir,
|
|
934
936
|
personalMode: true,
|
|
937
|
+
// foo is now cloud-backed (team-synced) → its key is a stale orphan.
|
|
938
|
+
teamSyncedSlugs: new Set(["foo"]),
|
|
935
939
|
});
|
|
936
940
|
|
|
937
|
-
// The manifest
|
|
938
|
-
//
|
|
941
|
+
// The manifest always downloads (routing source-of-truth carve-out);
|
|
942
|
+
// the team-synced orphan key is dropped.
|
|
939
943
|
expect(result.filesSkipped).toBe(1);
|
|
940
944
|
expect(result.filesDownloaded).toBe(1);
|
|
941
945
|
expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
|
|
@@ -1080,13 +1084,12 @@ describe("sync", () => {
|
|
|
1080
1084
|
expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
|
|
1081
1085
|
});
|
|
1082
1086
|
|
|
1083
|
-
it("personalMode
|
|
1084
|
-
//
|
|
1085
|
-
// Machine A pushed `companies/free-co/notes.md` to the personal bucket
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
// the feature is push-only and the destination machine never sees them.
|
|
1087
|
+
it("personalMode: downloads companies/{local-slug}/... keys by default when slug NOT in teamSyncedSlugs", async () => {
|
|
1088
|
+
// Local (non-cloud) companies sync to the personal vault by default.
|
|
1089
|
+
// Machine A pushed `companies/free-co/notes.md` to the personal bucket;
|
|
1090
|
+
// machine B subscribes: pull must allow these keys through the
|
|
1091
|
+
// personalMode filter, otherwise the feature is push-only and the
|
|
1092
|
+
// destination machine never sees them.
|
|
1090
1093
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1091
1094
|
{ key: "companies/free-co/notes.md", size: 50, lastModified: new Date(), etag: '"abc"' },
|
|
1092
1095
|
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"def"' },
|
|
@@ -1097,7 +1100,6 @@ describe("sync", () => {
|
|
|
1097
1100
|
vaultConfig: mockConfig,
|
|
1098
1101
|
hqRoot: tmpDir,
|
|
1099
1102
|
personalMode: true,
|
|
1100
|
-
includeLocalCompanies: true,
|
|
1101
1103
|
teamSyncedSlugs: new Set(), // empty → no slug is "orphan"
|
|
1102
1104
|
});
|
|
1103
1105
|
|
|
@@ -1107,21 +1109,20 @@ describe("sync", () => {
|
|
|
1107
1109
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
1108
1110
|
});
|
|
1109
1111
|
|
|
1110
|
-
it("personalMode
|
|
1111
|
-
// The orphan-cleanup half. Once a company
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
// Push-side `decommissionPrefixes` eventually removes them from the
|
|
1112
|
+
it("personalMode: drops companies/{team-synced-slug}/... keys as orphans (company became cloud-true)", async () => {
|
|
1113
|
+
// The orphan-cleanup half. Once a company becomes cloud-true, the
|
|
1114
|
+
// operator gains an active Membership for it (slug enters
|
|
1115
|
+
// teamSyncedSlugs). Any leftover `companies/{that-slug}/...` keys in
|
|
1116
|
+
// the personal bucket are stale residue — downloading them would clash
|
|
1117
|
+
// with the team-bucket pull at the same disk path. Filter drops them
|
|
1118
|
+
// silently. Push-side `decommissionPrefixes` removes them from the
|
|
1118
1119
|
// bucket; this filter keeps the local tree clean in the meantime
|
|
1119
1120
|
// (especially important for pull-only mode where decommission never
|
|
1120
1121
|
// runs).
|
|
1121
1122
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1122
1123
|
// Orphan: this slug is now team-synced — drop the key.
|
|
1123
1124
|
{ key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"old"' },
|
|
1124
|
-
// Legitimate
|
|
1125
|
+
// Legitimate local content: download.
|
|
1125
1126
|
{ key: "companies/free-co/notes.md", size: 30, lastModified: new Date(), etag: '"new"' },
|
|
1126
1127
|
]);
|
|
1127
1128
|
|
|
@@ -1130,7 +1131,6 @@ describe("sync", () => {
|
|
|
1130
1131
|
vaultConfig: mockConfig,
|
|
1131
1132
|
hqRoot: tmpDir,
|
|
1132
1133
|
personalMode: true,
|
|
1133
|
-
includeLocalCompanies: true,
|
|
1134
1134
|
teamSyncedSlugs: new Set(["acme"]),
|
|
1135
1135
|
});
|
|
1136
1136
|
|
|
@@ -1142,18 +1142,16 @@ describe("sync", () => {
|
|
|
1142
1142
|
expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
|
|
1143
1143
|
});
|
|
1144
1144
|
|
|
1145
|
-
it("personalMode
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
// the pull-side filter drops them as a safety net. This test pins
|
|
1151
|
-
// that contract under the new option shape: includeLocalCompanies
|
|
1152
|
-
// omitted → behaves identically to the original filter, even if
|
|
1153
|
-
// teamSyncedSlugs is supplied.
|
|
1145
|
+
it("personalMode: companies/manifest.yaml always downloads (routing source-of-truth), even when other slugs are team-synced", async () => {
|
|
1146
|
+
// The manifest is the one `companies/...` key carved into the personal
|
|
1147
|
+
// vault unconditionally. It must round-trip on pull even when the
|
|
1148
|
+
// operator has team-synced slugs (its key segment `manifest.yaml` is
|
|
1149
|
+
// not a real slug and must never be treated as an orphan).
|
|
1154
1150
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
1155
|
-
{ key: "companies/
|
|
1156
|
-
|
|
1151
|
+
{ key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"m"' },
|
|
1152
|
+
// A team-synced orphan that must still be dropped.
|
|
1153
|
+
{ key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"o"' },
|
|
1154
|
+
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"d"' },
|
|
1157
1155
|
]);
|
|
1158
1156
|
|
|
1159
1157
|
const result = await sync({
|
|
@@ -1161,13 +1159,13 @@ describe("sync", () => {
|
|
|
1161
1159
|
vaultConfig: mockConfig,
|
|
1162
1160
|
hqRoot: tmpDir,
|
|
1163
1161
|
personalMode: true,
|
|
1164
|
-
|
|
1165
|
-
teamSyncedSlugs: new Set(["unrelated"]),
|
|
1162
|
+
teamSyncedSlugs: new Set(["acme"]),
|
|
1166
1163
|
});
|
|
1167
1164
|
|
|
1168
|
-
expect(result.filesDownloaded).toBe(
|
|
1165
|
+
expect(result.filesDownloaded).toBe(2);
|
|
1169
1166
|
expect(result.filesSkipped).toBe(1);
|
|
1170
|
-
expect(fs.existsSync(path.join(tmpDir, "companies", "
|
|
1167
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
|
|
1168
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "file.md"))).toBe(false);
|
|
1171
1169
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
1172
1170
|
});
|
|
1173
1171
|
|
package/src/cli/sync.ts
CHANGED
|
@@ -323,35 +323,21 @@ export interface SyncOptions {
|
|
|
323
323
|
onEvent?: (event: SyncProgressEvent) => void;
|
|
324
324
|
/**
|
|
325
325
|
* When true, the caller is syncing against the caller's person-entity
|
|
326
|
-
* bucket. Pulled keys whose path starts with `companies/` are
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
326
|
+
* bucket. Pulled keys whose path starts with `companies/` are local
|
|
327
|
+
* (non-cloud) companies that sync to the personal vault by default —
|
|
328
|
+
* they are allowed through EXCEPT for slugs in `teamSyncedSlugs` (which
|
|
329
|
+
* are cloud-backed orphans, see below). `companies/manifest.yaml` is
|
|
330
|
+
* always allowed (routing source-of-truth).
|
|
330
331
|
*/
|
|
331
332
|
personalMode?: boolean;
|
|
332
|
-
/**
|
|
333
|
-
* Opt-in: when `personalMode === true`, allow `companies/{slug}/...` keys
|
|
334
|
-
* through the pull filter EXCEPT for slugs in `teamSyncedSlugs` (which
|
|
335
|
-
* are orphan remnants from a company that was promoted to its own team
|
|
336
|
-
* bucket and remain pre-decommission). Mirrors the push-side
|
|
337
|
-
* `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` gate: when an operator opts
|
|
338
|
-
* into the cloud:false → personal-bucket fallback on their machine,
|
|
339
|
-
* pull must also know about the new key shape on the OTHER machine that
|
|
340
|
-
* subscribes to those keys. Without this, the feature is push-only —
|
|
341
|
-
* the destination machine never sees the keys.
|
|
342
|
-
*
|
|
343
|
-
* Default false preserves the pre-5.20 behavior (drop all
|
|
344
|
-
* `companies/...` keys in personalMode).
|
|
345
|
-
*/
|
|
346
|
-
includeLocalCompanies?: boolean;
|
|
347
333
|
/**
|
|
348
334
|
* Slugs of companies the operator has an active team-bucket Membership
|
|
349
|
-
* for. Only consulted when `personalMode === true
|
|
350
|
-
* `
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
335
|
+
* for. Only consulted when `personalMode === true`: keys under
|
|
336
|
+
* `companies/{slug}/...` for any slug in this set are dropped as orphans
|
|
337
|
+
* from a pre-promotion personal-bucket fallback (the company became
|
|
338
|
+
* cloud-true). The push-side decommission cycle removes these from the
|
|
339
|
+
* bucket; this filter prevents them from re-downloading into the same
|
|
340
|
+
* disk paths the team-bucket pull manages.
|
|
355
341
|
*/
|
|
356
342
|
teamSyncedSlugs?: ReadonlySet<string>;
|
|
357
343
|
/**
|
|
@@ -798,7 +784,6 @@ function planPull(run: PullRunContext): PullPlan {
|
|
|
798
784
|
run.companyRoot,
|
|
799
785
|
run.shouldSync,
|
|
800
786
|
run.options.personalMode === true,
|
|
801
|
-
run.options.includeLocalCompanies === true,
|
|
802
787
|
run.options.teamSyncedSlugs ?? null,
|
|
803
788
|
run.currentPrefixSet,
|
|
804
789
|
run.fileTombstones,
|
|
@@ -1729,7 +1714,6 @@ function computePullPlan(
|
|
|
1729
1714
|
companyRoot: string,
|
|
1730
1715
|
shouldSync: (filePath: string, isDir?: boolean) => boolean,
|
|
1731
1716
|
personalMode: boolean,
|
|
1732
|
-
includeLocalCompanies: boolean,
|
|
1733
1717
|
teamSyncedSlugs: ReadonlySet<string> | null,
|
|
1734
1718
|
// Coalesced, company-relative prefixes the pull is scoped to (US-005).
|
|
1735
1719
|
// `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
|
|
@@ -1774,18 +1758,13 @@ function computePullPlan(
|
|
|
1774
1758
|
// fall through to download + journal like any personal file.
|
|
1775
1759
|
remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY
|
|
1776
1760
|
) {
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
// company whose `company.yaml` declares `cloud: false`. Allow
|
|
1785
|
-
// them through EXCEPT for slugs the operator has an active team-
|
|
1786
|
-
// bucket Membership for: those are orphan remnants from before
|
|
1787
|
-
// the company was promoted, and downloading them would clash
|
|
1788
|
-
// with the team-bucket pull at the same disk path.
|
|
1761
|
+
// Local (non-cloud) companies sync to the personal vault: keys under
|
|
1762
|
+
// `companies/{slug}/...` are legitimate content a peer machine pushed
|
|
1763
|
+
// for a company that is not cloud-backed. Allow them through EXCEPT
|
|
1764
|
+
// for slugs the operator has an active team-bucket Membership for:
|
|
1765
|
+
// those are orphan remnants from before the company became cloud-true,
|
|
1766
|
+
// and downloading them would clash with the team-bucket pull at the
|
|
1767
|
+
// same disk path.
|
|
1789
1768
|
//
|
|
1790
1769
|
// Symmetric to the push-side `decommissionPrefixes` logic in
|
|
1791
1770
|
// share.ts — both target the same orphan class, both honor the
|
|
@@ -1794,7 +1773,7 @@ function computePullPlan(
|
|
|
1794
1773
|
const slug = remoteFile.key.split("/")[1] ?? "";
|
|
1795
1774
|
const isTeamSyncedOrphan =
|
|
1796
1775
|
teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
|
|
1797
|
-
if (
|
|
1776
|
+
if (isTeamSyncedOrphan) {
|
|
1798
1777
|
items.push({ action: "skip-personal-mode", remoteFile, localPath });
|
|
1799
1778
|
continue;
|
|
1800
1779
|
}
|
|
@@ -2138,12 +2117,16 @@ function computePullPlan(
|
|
|
2138
2117
|
if (remoteKeySet.has(posixKey)) continue;
|
|
2139
2118
|
const localPath = resolveContainedVaultPath(companyRoot, key);
|
|
2140
2119
|
if (localPath === null) continue;
|
|
2141
|
-
// PersonalMode key gating — mirror the download branch.
|
|
2120
|
+
// PersonalMode key gating — mirror the download branch. Local (non-cloud)
|
|
2121
|
+
// company keys are tombstone-eligible (a peer's delete should propagate),
|
|
2122
|
+
// but team-synced orphans are left alone (the team-bucket pull owns those
|
|
2123
|
+
// disk paths), and the manifest — the routing source-of-truth — is never
|
|
2124
|
+
// auto-tombstoned out of the personal vault.
|
|
2142
2125
|
if (personalMode && key.startsWith("companies/")) {
|
|
2143
2126
|
const slug = key.split("/")[1] ?? "";
|
|
2144
2127
|
const isTeamSyncedOrphan =
|
|
2145
2128
|
teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
|
|
2146
|
-
if (
|
|
2129
|
+
if (isTeamSyncedOrphan || key === PERSONAL_VAULT_MANIFEST_KEY) continue;
|
|
2147
2130
|
}
|
|
2148
2131
|
// Ephemeral keys are filtered both directions; never tombstone-
|
|
2149
2132
|
// propagate a conflict-mirror.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* the trailing-slash sibling boundary, and the unattributed (no-match) path.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { describe, it, expect,
|
|
7
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
8
8
|
import * as fs from "fs";
|
|
9
9
|
import * as os from "os";
|
|
10
10
|
import * as path from "path";
|
package/src/lib/conflict-file.ts
CHANGED
|
@@ -76,6 +76,6 @@ export function buildConflictId(
|
|
|
76
76
|
detectedAt: string,
|
|
77
77
|
): string {
|
|
78
78
|
const safeTs = detectedAt.replace(/:/g, "-").replace(/\.\d+/, "");
|
|
79
|
-
const safePath = originalRelative.replace(/[
|
|
79
|
+
const safePath = originalRelative.replace(/[/\\.]/g, "-");
|
|
80
80
|
return `${safePath}-${safeTs}`;
|
|
81
81
|
}
|