@indigoai-us/hq-cloud 5.11.3 → 5.13.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/dist/bin/sync-runner.d.ts +11 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +71 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +124 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +17 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +11 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +125 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +5 -1
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +8 -3
- package/dist/ignore.test.js.map +1 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +7 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +45 -1
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +158 -0
- package/src/bin/sync-runner.ts +72 -4
- package/src/cli/share.test.ts +146 -0
- package/src/cli/share.ts +28 -3
- package/src/ignore.test.ts +8 -3
- package/src/ignore.ts +5 -1
- package/src/s3.test.ts +53 -1
- package/src/s3.ts +7 -2
package/dist/s3.test.js
CHANGED
|
@@ -12,6 +12,10 @@ import * as path from "path";
|
|
|
12
12
|
// Capture every command sent to the S3Client across the test suite. Cleared
|
|
13
13
|
// in beforeEach so per-test assertions don't leak from neighbours.
|
|
14
14
|
const sentCommands = [];
|
|
15
|
+
// Per-test override for the ListObjectsV2Command response. Tests of
|
|
16
|
+
// `listRemoteFiles` push the Contents shape they want returned; the default
|
|
17
|
+
// is an empty bucket. Reset in beforeEach so cross-test leakage is impossible.
|
|
18
|
+
let nextListObjectsResponse = { Contents: [] };
|
|
15
19
|
vi.mock("@aws-sdk/client-s3", () => {
|
|
16
20
|
class FakeS3Client {
|
|
17
21
|
async send(command) {
|
|
@@ -24,6 +28,9 @@ vi.mock("@aws-sdk/client-s3", () => {
|
|
|
24
28
|
if (command.constructor.name === "PutObjectCommand") {
|
|
25
29
|
return { ETag: '"fake-etag"' };
|
|
26
30
|
}
|
|
31
|
+
if (command.constructor.name === "ListObjectsV2Command") {
|
|
32
|
+
return nextListObjectsResponse;
|
|
33
|
+
}
|
|
27
34
|
return {};
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -69,7 +76,7 @@ vi.mock("@aws-sdk/client-s3", () => {
|
|
|
69
76
|
DeleteObjectCommand,
|
|
70
77
|
};
|
|
71
78
|
});
|
|
72
|
-
import { uploadFile } from "./s3.js";
|
|
79
|
+
import { uploadFile, listRemoteFiles } from "./s3.js";
|
|
73
80
|
function makeCtx() {
|
|
74
81
|
return {
|
|
75
82
|
uid: "cmp_TEST",
|
|
@@ -161,4 +168,41 @@ describe("uploadFile", () => {
|
|
|
161
168
|
expect(meta["created-by-sub"]).toBeUndefined();
|
|
162
169
|
});
|
|
163
170
|
});
|
|
171
|
+
describe("listRemoteFiles", () => {
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
sentCommands.length = 0;
|
|
174
|
+
nextListObjectsResponse = { Contents: [] };
|
|
175
|
+
});
|
|
176
|
+
// Regression: pre-fix, `if (!obj.Key || !obj.Size) continue;` skipped every
|
|
177
|
+
// 0-byte object on the listing. The user's personal vault stored
|
|
178
|
+
// `projects/.gitkeep` (a 0-byte placeholder) and the sync pull plan never
|
|
179
|
+
// saw it — the file existed on S3 but was invisible to the runner, so the
|
|
180
|
+
// box never created `projects/` on first pull. The fix narrows the guard
|
|
181
|
+
// to `if (!obj.Key) continue;` so Size === 0 passes through with its real
|
|
182
|
+
// value (handled `?? 0` to keep TS Size?: number happy).
|
|
183
|
+
it("includes 0-byte objects in the result (no implicit skip-on-empty)", async () => {
|
|
184
|
+
nextListObjectsResponse = {
|
|
185
|
+
Contents: [
|
|
186
|
+
{ Key: "projects/.gitkeep", Size: 0, LastModified: new Date(), ETag: '"d41d8cd98f00b204e9800998ecf8427e"' },
|
|
187
|
+
{ Key: "AGENTS.md", Size: 100, LastModified: new Date(), ETag: '"e1"' },
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
const files = await listRemoteFiles(makeCtx());
|
|
191
|
+
expect(files).toHaveLength(2);
|
|
192
|
+
const gitkeep = files.find((f) => f.key === "projects/.gitkeep");
|
|
193
|
+
expect(gitkeep).toBeDefined();
|
|
194
|
+
expect(gitkeep.size).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
it("still skips Contents entries with no Key (defensive against malformed responses)", async () => {
|
|
197
|
+
nextListObjectsResponse = {
|
|
198
|
+
Contents: [
|
|
199
|
+
{ Size: 100, LastModified: new Date(), ETag: '"e1"' }, // no Key
|
|
200
|
+
{ Key: "real.md", Size: 100, LastModified: new Date(), ETag: '"e2"' },
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
const files = await listRemoteFiles(makeCtx());
|
|
204
|
+
expect(files).toHaveLength(1);
|
|
205
|
+
expect(files[0].key).toBe("real.md");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
164
208
|
//# sourceMappingURL=s3.test.js.map
|
package/dist/s3.test.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"s3.test.js","sourceRoot":"","sources":["../src/s3.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9D,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAE7B,4EAA4E;AAC5E,mEAAmE;AACnE,MAAM,YAAY,GAA4D,EAAE,CAAC;AAEjF,EAAE,CAAC,IAAI,CAAC,oBAAoB,EAAE,GAAG,EAAE;IACjC,MAAM,YAAY;QAChB,KAAK,CAAC,IAAI,CAAC,OAA0E;YACnF,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;YAC5E,IAAI,OAAO,CAAC,WAAW,CAAC,IAAI,KAAK,mBAAmB,EAAE,CAAC;gBACrD,oEAAoE;gBACpE,qEAAqE;gBACrE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;YAC1B,CAAC;YACD,IAAI,OAAO,CAAC,WAAW,CAAC,IAAI,KAAK,kBAAkB,EAAE,CAAC;gBACpD,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC;YACjC,CAAC;YACD,OAAO,EAAE,CAAC;QACZ,CAAC;KACF;IACD,2EAA2E;IAC3E,2EAA2E;IAC3E,gDAAgD;IAChD,MAAM,gBAAgB;QACD;QAAnB,YAAmB,KAA8B;YAA9B,UAAK,GAAL,KAAK,CAAyB;QAAG,CAAC;KACtD;IACD,MAAM,gBAAgB;QACD;QAAnB,YAAmB,KAA8B;YAA9B,UAAK,GAAL,KAAK,CAAyB;QAAG,CAAC;KACtD;IACD,MAAM,iBAAiB;QACF;QAAnB,YAAmB,KAA8B;YAA9B,UAAK,GAAL,KAAK,CAAyB;QAAG,CAAC;KACtD;IACD,MAAM,oBAAoB;QACL;QAAnB,YAAmB,KAA8B;YAA9B,UAAK,GAAL,KAAK,CAAyB;QAAG,CAAC;KACtD;IACD,MAAM,mBAAmB;QACJ;QAAnB,YAAmB,KAA8B;YAA9B,UAAK,GAAL,KAAK,CAAyB;QAAG,CAAC;KACtD;IACD,OAAO;QACL,QAAQ,EAAE,YAAY;QACtB,gBAAgB;QAChB,gBAAgB;QAChB,iBAAiB;QACjB,oBAAoB;QACpB,mBAAmB;KACpB,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"s3.test.js","sourceRoot":"","sources":["../src/s3.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
import * as path from "path";
|
|
11
14
|
import { runRunner } from "./sync-runner.js";
|
|
12
15
|
import type {
|
|
13
16
|
RunnerEvent,
|
|
@@ -1417,6 +1420,161 @@ describe("personal slot fanout", () => {
|
|
|
1417
1420
|
expect(keys).not.toContain("journalSlug");
|
|
1418
1421
|
}
|
|
1419
1422
|
});
|
|
1423
|
+
|
|
1424
|
+
// ── Push-side personal-vault contract (Slice 2 / personal-vault scope) ────
|
|
1425
|
+
//
|
|
1426
|
+
// Symmetric to the pull-side tests (A/B/C) above. The personal-vault scope
|
|
1427
|
+
// on push is an EXCLUSION list mirroring the Rust hq-sync first-push in
|
|
1428
|
+
// src-tauri/src/commands/personal.rs::PERSONAL_VAULT_EXCLUDED_TOP_LEVEL:
|
|
1429
|
+
// .git/, companies/, core/, data/, personal/, repos/, workspace/ are
|
|
1430
|
+
// skipped; every other top-level entry under hq_root is shared. The
|
|
1431
|
+
// runner enforces this by passing pre-filtered top-level paths to share()
|
|
1432
|
+
// along with personalMode: true + journalSlug: "personal" so share()
|
|
1433
|
+
// keys files hq-root-relative and writes the personal journal.
|
|
1434
|
+
|
|
1435
|
+
it("D: shareFn invoked with personalMode: true + journalSlug: 'personal' for personal slot (push side)", async () => {
|
|
1436
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1437
|
+
const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
|
|
1438
|
+
try {
|
|
1439
|
+
// Realistic personal-vault content so the exclusion-filter has something
|
|
1440
|
+
// to keep and something to drop. (Other tests pin exact contents — this
|
|
1441
|
+
// one only cares that personalMode/journalSlug are passed.)
|
|
1442
|
+
fs.mkdirSync(path.join(tmpHqRoot, "knowledge"), { recursive: true });
|
|
1443
|
+
fs.writeFileSync(path.join(tmpHqRoot, "knowledge", "notes.md"), "note");
|
|
1444
|
+
|
|
1445
|
+
const deps = makeDeps({
|
|
1446
|
+
createVaultClient: () =>
|
|
1447
|
+
makeVaultStub({
|
|
1448
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1449
|
+
entityGet: (uid: string) =>
|
|
1450
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1451
|
+
listPersons: () => Promise.resolve([olderPerson]),
|
|
1452
|
+
}),
|
|
1453
|
+
share: shareSpy,
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
const code = await runRunner(
|
|
1457
|
+
["--companies", "--direction", "both", "--hq-root", tmpHqRoot],
|
|
1458
|
+
deps,
|
|
1459
|
+
);
|
|
1460
|
+
expect(code).toBe(0);
|
|
1461
|
+
|
|
1462
|
+
const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
|
|
1463
|
+
(c) => c[0].company?.startsWith("prs_"),
|
|
1464
|
+
);
|
|
1465
|
+
expect(personalCall).toBeDefined();
|
|
1466
|
+
const personalArgs = personalCall![0] as ShareOptions & {
|
|
1467
|
+
personalMode?: boolean;
|
|
1468
|
+
journalSlug?: string;
|
|
1469
|
+
};
|
|
1470
|
+
expect(personalArgs.personalMode).toBe(true);
|
|
1471
|
+
expect(personalArgs.journalSlug).toBe("personal");
|
|
1472
|
+
} finally {
|
|
1473
|
+
fs.rmSync(tmpHqRoot, { recursive: true, force: true });
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
it("E: shareFn paths for personal slot = top-level entries under hq_root minus PERSONAL_VAULT_EXCLUDED_TOP_LEVEL", async () => {
|
|
1478
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1479
|
+
const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
|
|
1480
|
+
try {
|
|
1481
|
+
// Included top-level entries — must appear in shareFn paths.
|
|
1482
|
+
fs.mkdirSync(path.join(tmpHqRoot, ".claude"));
|
|
1483
|
+
fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
|
|
1484
|
+
fs.mkdirSync(path.join(tmpHqRoot, "modules"));
|
|
1485
|
+
fs.mkdirSync(path.join(tmpHqRoot, ".agents"));
|
|
1486
|
+
fs.writeFileSync(path.join(tmpHqRoot, "README.md"), "readme");
|
|
1487
|
+
// Excluded top-level entries — must be filtered out.
|
|
1488
|
+
fs.mkdirSync(path.join(tmpHqRoot, ".git"));
|
|
1489
|
+
fs.mkdirSync(path.join(tmpHqRoot, "companies"));
|
|
1490
|
+
fs.mkdirSync(path.join(tmpHqRoot, "core"));
|
|
1491
|
+
fs.mkdirSync(path.join(tmpHqRoot, "data"));
|
|
1492
|
+
fs.mkdirSync(path.join(tmpHqRoot, "personal"));
|
|
1493
|
+
fs.mkdirSync(path.join(tmpHqRoot, "repos"));
|
|
1494
|
+
fs.mkdirSync(path.join(tmpHqRoot, "workspace"));
|
|
1495
|
+
|
|
1496
|
+
const deps = makeDeps({
|
|
1497
|
+
createVaultClient: () =>
|
|
1498
|
+
makeVaultStub({
|
|
1499
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1500
|
+
entityGet: (uid: string) =>
|
|
1501
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1502
|
+
listPersons: () => Promise.resolve([olderPerson]),
|
|
1503
|
+
}),
|
|
1504
|
+
share: shareSpy,
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
const code = await runRunner(
|
|
1508
|
+
["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
|
|
1509
|
+
deps,
|
|
1510
|
+
);
|
|
1511
|
+
expect(code).toBe(0);
|
|
1512
|
+
|
|
1513
|
+
const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
|
|
1514
|
+
(c) => c[0].company?.startsWith("prs_"),
|
|
1515
|
+
);
|
|
1516
|
+
expect(personalCall).toBeDefined();
|
|
1517
|
+
const personalArgs = personalCall![0];
|
|
1518
|
+
|
|
1519
|
+
// Paths must be absolute, rooted at tmpHqRoot, and contain exactly the
|
|
1520
|
+
// included entries — no order guarantee from fs.readdirSync, so compare
|
|
1521
|
+
// as a set of basenames. NOTE: `core/` is now INCLUDED in the personal
|
|
1522
|
+
// vault (user directive 2026-05-13) — it ships real project content
|
|
1523
|
+
// (policies/, settings/, skills/, workers/, the hq-core scaffold), so
|
|
1524
|
+
// dropping it on first-push was leaving the box without the rules and
|
|
1525
|
+
// hooks the user expected. The hq-root `core.yaml` identity marker is
|
|
1526
|
+
// filtered separately by the anchored `/core.yaml` DEFAULT_IGNORES
|
|
1527
|
+
// rule, NOT by exclusion at the path level.
|
|
1528
|
+
const basenames = personalArgs.paths.map((p) => path.basename(p)).sort();
|
|
1529
|
+
expect(basenames).toEqual([".agents", ".claude", "README.md", "core", "knowledge", "modules"]);
|
|
1530
|
+
for (const p of personalArgs.paths) {
|
|
1531
|
+
expect(path.isAbsolute(p)).toBe(true);
|
|
1532
|
+
expect(p.startsWith(tmpHqRoot)).toBe(true);
|
|
1533
|
+
}
|
|
1534
|
+
// Excluded entries must NOT appear.
|
|
1535
|
+
for (const forbidden of [".git", "companies", "data", "personal", "repos", "workspace"]) {
|
|
1536
|
+
expect(basenames).not.toContain(forbidden);
|
|
1537
|
+
}
|
|
1538
|
+
} finally {
|
|
1539
|
+
fs.rmSync(tmpHqRoot, { recursive: true, force: true });
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it("F: shareFn paths for company slots stay [hqRoot/companies/{slug}] with no personalMode/journalSlug keys", async () => {
|
|
1544
|
+
const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
|
|
1545
|
+
const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
|
|
1546
|
+
try {
|
|
1547
|
+
const deps = makeDeps({
|
|
1548
|
+
createVaultClient: () =>
|
|
1549
|
+
makeVaultStub({
|
|
1550
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1551
|
+
entityGet: (uid: string) =>
|
|
1552
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1553
|
+
listPersons: () => Promise.resolve([olderPerson]),
|
|
1554
|
+
}),
|
|
1555
|
+
share: shareSpy,
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
const code = await runRunner(
|
|
1559
|
+
["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
|
|
1560
|
+
deps,
|
|
1561
|
+
);
|
|
1562
|
+
expect(code).toBe(0);
|
|
1563
|
+
|
|
1564
|
+
const companyCalls = (shareSpy.mock.calls as Array<[ShareOptions]>).filter(
|
|
1565
|
+
(c) => c[0].company?.startsWith("cmp_"),
|
|
1566
|
+
);
|
|
1567
|
+
expect(companyCalls.length).toBeGreaterThan(0);
|
|
1568
|
+
for (const [args] of companyCalls) {
|
|
1569
|
+
expect(args.paths).toEqual([path.join(tmpHqRoot, "companies", "acme")]);
|
|
1570
|
+
const keys = Object.keys(args);
|
|
1571
|
+
expect(keys).not.toContain("personalMode");
|
|
1572
|
+
expect(keys).not.toContain("journalSlug");
|
|
1573
|
+
}
|
|
1574
|
+
} finally {
|
|
1575
|
+
fs.rmSync(tmpHqRoot, { recursive: true, force: true });
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1420
1578
|
});
|
|
1421
1579
|
|
|
1422
1580
|
// ---------------------------------------------------------------------------
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -115,6 +115,64 @@ const DEFAULT_VAULT_API_URL =
|
|
|
115
115
|
|
|
116
116
|
const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
117
117
|
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Personal-vault scope (exclusion list)
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
//
|
|
122
|
+
// Top-level directories under `hq_root/` that the personal-vault push MUST
|
|
123
|
+
// NOT upload. Mirrors the Rust constant of the same name in
|
|
124
|
+
// `hq-sync/src-tauri/src/commands/personal.rs` so the Tauri menubar's
|
|
125
|
+
// first-push and this Node runner's steady-state push enforce identical
|
|
126
|
+
// scope. Every other top-level entry under hq_root (e.g. `.claude/`,
|
|
127
|
+
// `knowledge/`, `modules/`, `README.md`, `.codex/`) is included, subject
|
|
128
|
+
// to the gitignore/hqignore filter that share() applies per-file.
|
|
129
|
+
//
|
|
130
|
+
// Rationale per entry:
|
|
131
|
+
// - `.git`: a repo's internal state is large, opaque, and useless after
|
|
132
|
+
// sync; .gitignore alone doesn't cover `.git/` because it's the repo
|
|
133
|
+
// itself, not a tracked path.
|
|
134
|
+
// - `companies/`: synced separately by the runner's per-membership fanout;
|
|
135
|
+
// do not double-write into the personal vault.
|
|
136
|
+
// - `data/`, `personal/`, `repos/`, `workspace/`: per user directive —
|
|
137
|
+
// heavy local-only content (machine-state, datasets, cloned remotes,
|
|
138
|
+
// session threads) that has no business in the personal vault.
|
|
139
|
+
//
|
|
140
|
+
// Note: `core/` was previously excluded but is now INCLUDED (user directive
|
|
141
|
+
// 2026-05-13). It ships the hq-core scaffold — policies/, settings/,
|
|
142
|
+
// skills/, workers/, plus the rules manifest at core/core.yaml — all real
|
|
143
|
+
// project content that the box needs. The hq-root identity marker
|
|
144
|
+
// `core.yaml` (at hq_root, distinct from `core/core.yaml`) is filtered
|
|
145
|
+
// separately by the root-anchored `/core.yaml` DEFAULT_IGNORES rule.
|
|
146
|
+
export const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[] = [
|
|
147
|
+
".git",
|
|
148
|
+
"companies",
|
|
149
|
+
"data",
|
|
150
|
+
"personal",
|
|
151
|
+
"repos",
|
|
152
|
+
"workspace",
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compute absolute paths to share for the personal vault: every top-level
|
|
157
|
+
* entry under `hqRoot` whose basename is NOT in `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`.
|
|
158
|
+
* Mirrors the Rust `is_personal_vault_path` predicate (just hoisted to the
|
|
159
|
+
* top-level step). Order is whatever `fs.readdirSync` returns — share()
|
|
160
|
+
* doesn't care, and the per-file walk inside share() handles recursion
|
|
161
|
+
* uniformly. Missing hqRoot returns []; callers treat that as "no personal
|
|
162
|
+
* content to push" rather than a hard error.
|
|
163
|
+
*/
|
|
164
|
+
export function computePersonalVaultPaths(hqRoot: string): string[] {
|
|
165
|
+
let entries: string[];
|
|
166
|
+
try {
|
|
167
|
+
entries = fs.readdirSync(hqRoot);
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
return entries
|
|
172
|
+
.filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
|
|
173
|
+
.map((name) => path.join(hqRoot, name));
|
|
174
|
+
}
|
|
175
|
+
|
|
118
176
|
// ---------------------------------------------------------------------------
|
|
119
177
|
// Event protocol
|
|
120
178
|
// ---------------------------------------------------------------------------
|
|
@@ -736,13 +794,17 @@ export async function runRunner(
|
|
|
736
794
|
};
|
|
737
795
|
|
|
738
796
|
// Push first so a subsequent pull doesn't overwrite files we were about
|
|
739
|
-
// to broadcast.
|
|
740
|
-
//
|
|
741
|
-
//
|
|
797
|
+
// to broadcast. Company targets walk `companies/{slug}/`; the personal
|
|
798
|
+
// target walks every top-level entry under hqRoot minus the exclusion
|
|
799
|
+
// list (see PERSONAL_VAULT_EXCLUDED_TOP_LEVEL). `skipUnchanged: true`
|
|
800
|
+
// keeps both cases efficient on re-runs.
|
|
742
801
|
if (doPush) {
|
|
743
802
|
activePhase = "push";
|
|
803
|
+
const pushPaths = target.personalMode === true
|
|
804
|
+
? computePersonalVaultPaths(parsed.hqRoot)
|
|
805
|
+
: [path.join(parsed.hqRoot, "companies", target.slug)];
|
|
744
806
|
pushResult = await shareFn({
|
|
745
|
-
paths:
|
|
807
|
+
paths: pushPaths,
|
|
746
808
|
company: target.uid,
|
|
747
809
|
vaultConfig,
|
|
748
810
|
hqRoot: parsed.hqRoot,
|
|
@@ -755,6 +817,12 @@ export async function runRunner(
|
|
|
755
817
|
propagateDeletes: true,
|
|
756
818
|
onEvent: tagAndEmit,
|
|
757
819
|
...(uploadAuthor ? { author: uploadAuthor } : {}),
|
|
820
|
+
// Mirror the pull-side seam: only spread these for the personal
|
|
821
|
+
// slot so company-target args stay identical to the pre-Slice-2
|
|
822
|
+
// shape (the "no personalMode/journalSlug keys" regression test
|
|
823
|
+
// in sync-runner.test.ts pins that contract).
|
|
824
|
+
...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
|
|
825
|
+
...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
|
|
758
826
|
});
|
|
759
827
|
}
|
|
760
828
|
|
package/src/cli/share.test.ts
CHANGED
|
@@ -1082,4 +1082,150 @@ describe("share", () => {
|
|
|
1082
1082
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
1083
1083
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
1084
1084
|
});
|
|
1085
|
+
|
|
1086
|
+
// ── personalMode ───────────────────────────────────────────────────────────
|
|
1087
|
+
//
|
|
1088
|
+
// The personal vault (slug "personal" in the runner's fanout plan) shares
|
|
1089
|
+
// files from hqRoot DIRECTLY — not from hqRoot/companies/<slug>/. Mirrors
|
|
1090
|
+
// the Rust hq-sync first-push contract in src-tauri/src/commands/personal.rs:
|
|
1091
|
+
// syncRoot = hqRoot, journal slug = "personal", remote keys are hq-root-
|
|
1092
|
+
// relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md"). The
|
|
1093
|
+
// exclusion list itself is enforced by the runner (sync-runner.ts) by only
|
|
1094
|
+
// passing in the allowed top-level directories — share() trusts its
|
|
1095
|
+
// `paths` input.
|
|
1096
|
+
describe("personalMode", () => {
|
|
1097
|
+
it("personalMode=true keys files hq-root-relative, not companies/{slug}/-relative", async () => {
|
|
1098
|
+
fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
|
|
1099
|
+
fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
|
|
1100
|
+
fs.writeFileSync(path.join(tmpDir, ".claude", "skills", "foo.md"), "skill");
|
|
1101
|
+
fs.writeFileSync(path.join(tmpDir, "knowledge", "notes.md"), "note");
|
|
1102
|
+
|
|
1103
|
+
const result = await share({
|
|
1104
|
+
paths: [
|
|
1105
|
+
path.join(tmpDir, ".claude"),
|
|
1106
|
+
path.join(tmpDir, "knowledge"),
|
|
1107
|
+
],
|
|
1108
|
+
company: "acme",
|
|
1109
|
+
vaultConfig: mockConfig,
|
|
1110
|
+
hqRoot: tmpDir,
|
|
1111
|
+
personalMode: true,
|
|
1112
|
+
journalSlug: "personal",
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
expect(result.filesUploaded).toBe(2);
|
|
1116
|
+
// Remote keys must be hq-root-relative, NOT prefixed with companies/personal/
|
|
1117
|
+
const keys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
1118
|
+
expect(keys.sort()).toEqual([".claude/skills/foo.md", "knowledge/notes.md"]);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it("personalMode=true writes journal under the personal journalSlug", async () => {
|
|
1122
|
+
fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
|
|
1123
|
+
fs.writeFileSync(path.join(tmpDir, "knowledge", "notes.md"), "note");
|
|
1124
|
+
|
|
1125
|
+
await share({
|
|
1126
|
+
paths: [path.join(tmpDir, "knowledge")],
|
|
1127
|
+
company: "acme",
|
|
1128
|
+
vaultConfig: mockConfig,
|
|
1129
|
+
hqRoot: tmpDir,
|
|
1130
|
+
personalMode: true,
|
|
1131
|
+
journalSlug: "personal",
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Personal journal is keyed "personal", NOT the company's ctx.slug ("acme")
|
|
1135
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1136
|
+
const acmeJournalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1137
|
+
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
1138
|
+
expect(fs.existsSync(acmeJournalPath)).toBe(false);
|
|
1139
|
+
|
|
1140
|
+
const journal = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
|
|
1141
|
+
expect(journal.files["knowledge/notes.md"]).toBeDefined();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("personalMode=true accepts files outside companies/<slug>/ (companion to the company-folder rejection)", async () => {
|
|
1145
|
+
// Same fixture as the "skips files outside the company folder" test
|
|
1146
|
+
// above — file at hqRoot root, NOT under companies/acme/. Without
|
|
1147
|
+
// personalMode this is rejected with a "outside company folder" warning;
|
|
1148
|
+
// with personalMode=true the file IS uploaded because syncRoot=hqRoot.
|
|
1149
|
+
const outsideFile = path.join(tmpDir, "stray.md");
|
|
1150
|
+
fs.writeFileSync(outsideFile, "stray");
|
|
1151
|
+
|
|
1152
|
+
const result = await share({
|
|
1153
|
+
paths: [outsideFile],
|
|
1154
|
+
company: "acme",
|
|
1155
|
+
vaultConfig: mockConfig,
|
|
1156
|
+
hqRoot: tmpDir,
|
|
1157
|
+
personalMode: true,
|
|
1158
|
+
journalSlug: "personal",
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
expect(result.filesUploaded).toBe(1);
|
|
1162
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), outsideFile, "stray.md");
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it("uploads 0-byte files (e.g. .gitkeep placeholders)", async () => {
|
|
1166
|
+
// Regression for the bug where `projects/.gitkeep` (0 bytes) was
|
|
1167
|
+
// present locally + on the listing target but `listRemoteFiles`
|
|
1168
|
+
// silently skipped 0-byte objects via `!obj.Size`. Same class of bug
|
|
1169
|
+
// can also bite uploads if any size guard treats 0 as falsy. Pin the
|
|
1170
|
+
// push side here: a 0-byte file MUST upload like any other.
|
|
1171
|
+
fs.mkdirSync(path.join(tmpDir, "projects"), { recursive: true });
|
|
1172
|
+
const gitkeep = path.join(tmpDir, "projects", ".gitkeep");
|
|
1173
|
+
fs.writeFileSync(gitkeep, "");
|
|
1174
|
+
expect(fs.statSync(gitkeep).size).toBe(0);
|
|
1175
|
+
|
|
1176
|
+
const result = await share({
|
|
1177
|
+
paths: [path.join(tmpDir, "projects")],
|
|
1178
|
+
company: "acme",
|
|
1179
|
+
vaultConfig: mockConfig,
|
|
1180
|
+
hqRoot: tmpDir,
|
|
1181
|
+
personalMode: true,
|
|
1182
|
+
journalSlug: "personal",
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
expect(result.filesUploaded).toBe(1);
|
|
1186
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), gitkeep, "projects/.gitkeep");
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("personalMode=true + skipUnchanged honors the personal-journal hash", async () => {
|
|
1190
|
+
fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
|
|
1191
|
+
const testFile = path.join(tmpDir, "knowledge", "stable.md");
|
|
1192
|
+
fs.writeFileSync(testFile, "stable content");
|
|
1193
|
+
|
|
1194
|
+
const { hashFile } = await import("../journal.js");
|
|
1195
|
+
const hash = hashFile(testFile);
|
|
1196
|
+
|
|
1197
|
+
// Pre-seed the PERSONAL journal (not the per-company one) so the
|
|
1198
|
+
// skipUnchanged short-circuit fires for the right slug.
|
|
1199
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
1200
|
+
fs.writeFileSync(
|
|
1201
|
+
personalJournalPath,
|
|
1202
|
+
JSON.stringify({
|
|
1203
|
+
version: "1",
|
|
1204
|
+
lastSync: new Date().toISOString(),
|
|
1205
|
+
files: {
|
|
1206
|
+
"knowledge/stable.md": {
|
|
1207
|
+
hash,
|
|
1208
|
+
size: 15,
|
|
1209
|
+
syncedAt: new Date().toISOString(),
|
|
1210
|
+
direction: "up",
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
}),
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
const result = await share({
|
|
1217
|
+
paths: [path.join(tmpDir, "knowledge")],
|
|
1218
|
+
company: "acme",
|
|
1219
|
+
vaultConfig: mockConfig,
|
|
1220
|
+
hqRoot: tmpDir,
|
|
1221
|
+
personalMode: true,
|
|
1222
|
+
journalSlug: "personal",
|
|
1223
|
+
skipUnchanged: true,
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
expect(result.filesUploaded).toBe(0);
|
|
1227
|
+
expect(result.filesSkipped).toBe(1);
|
|
1228
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1085
1231
|
});
|
package/src/cli/share.ts
CHANGED
|
@@ -193,6 +193,23 @@ export interface ShareOptions {
|
|
|
193
193
|
* this engine. The runner pipes Cognito idToken claims through here.
|
|
194
194
|
*/
|
|
195
195
|
author?: UploadAuthor;
|
|
196
|
+
/**
|
|
197
|
+
* When true, share() targets the caller's person-entity bucket: syncRoot
|
|
198
|
+
* is `hqRoot` itself (NOT `hqRoot/companies/<slug>/`), so remote keys are
|
|
199
|
+
* hq-root-relative (e.g. ".claude/skills/foo.md", "knowledge/notes.md") to
|
|
200
|
+
* match the Rust hq-sync first-push contract in
|
|
201
|
+
* `src-tauri/src/commands/personal.rs`. The exclusion of top-level dirs
|
|
202
|
+
* (.git, companies, core, data, personal, repos, workspace) is enforced
|
|
203
|
+
* by the runner — share() trusts its `paths` input.
|
|
204
|
+
*/
|
|
205
|
+
personalMode?: boolean;
|
|
206
|
+
/**
|
|
207
|
+
* Override for the per-slug journal file name. Defaults to `ctx.slug`. The
|
|
208
|
+
* runner passes `journalSlug: "personal"` for the personal slot so the TS
|
|
209
|
+
* push and the Rust personal first-push share idempotency state under one
|
|
210
|
+
* `sync-journal.personal.json` file.
|
|
211
|
+
*/
|
|
212
|
+
journalSlug?: string;
|
|
196
213
|
}
|
|
197
214
|
|
|
198
215
|
export interface ShareResult {
|
|
@@ -263,9 +280,17 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
263
280
|
// Remote keys are company-relative; the on-disk scoping prefix is
|
|
264
281
|
// companies/{slug}/. Anything outside this folder gets skipped to avoid
|
|
265
282
|
// leaking cross-company state into the vault.
|
|
266
|
-
|
|
283
|
+
//
|
|
284
|
+
// In personalMode the syncRoot is `hqRoot` itself — remote keys are
|
|
285
|
+
// hq-root-relative to match the Rust personal first-push (which uploads
|
|
286
|
+
// every non-excluded top-level dir under ~/HQ). The exclusion list is
|
|
287
|
+
// enforced upstream by the runner; share() just trusts `paths`.
|
|
288
|
+
const syncRoot = options.personalMode === true
|
|
289
|
+
? hqRoot
|
|
290
|
+
: path.join(hqRoot, "companies", ctx.slug);
|
|
267
291
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
268
|
-
const
|
|
292
|
+
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
293
|
+
const journal = readJournal(journalSlug);
|
|
269
294
|
|
|
270
295
|
let filesUploaded = 0;
|
|
271
296
|
let bytesUploaded = 0;
|
|
@@ -457,7 +482,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
457
482
|
// See cli/sync.ts: stamp lastSync on completion so a no-op share still
|
|
458
483
|
// ticks the "Last sync" indicator.
|
|
459
484
|
journal.lastSync = new Date().toISOString();
|
|
460
|
-
writeJournal(
|
|
485
|
+
writeJournal(journalSlug, journal);
|
|
461
486
|
|
|
462
487
|
return {
|
|
463
488
|
filesUploaded,
|
package/src/ignore.test.ts
CHANGED
|
@@ -38,12 +38,17 @@ describe("createIgnoreFilter", () => {
|
|
|
38
38
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it("permissive mode: HQ-root core.yaml marker is ignored", () => {
|
|
42
|
-
// core.yaml is the local
|
|
41
|
+
it("permissive mode: HQ-root core.yaml marker is ignored, but nested core/core.yaml syncs", () => {
|
|
42
|
+
// The hq-root `core.yaml` is the local identity marker. It must never
|
|
43
43
|
// round-trip through the bucket — pulling another machine's marker
|
|
44
|
-
// would corrupt root discovery.
|
|
44
|
+
// would corrupt root discovery. But `core/core.yaml` (the hq-core
|
|
45
|
+
// scaffold definition) is a real project file that DOES need to sync.
|
|
46
|
+
// Before the rule was anchored, the unanchored `core.yaml` pattern
|
|
47
|
+
// matched at any depth and silently blocked `core/core.yaml` from
|
|
48
|
+
// syncing along with the rest of `core/`.
|
|
45
49
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
46
50
|
expect(shouldSync(path.join(hqRoot, "core.yaml"))).toBe(false);
|
|
51
|
+
expect(shouldSync(path.join(hqRoot, "core/core.yaml"))).toBe(true);
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
it("permissive mode: modules/modules.yaml manifest is ignored", () => {
|
package/src/ignore.ts
CHANGED
|
@@ -76,7 +76,11 @@ export const DEFAULT_IGNORES = [
|
|
|
76
76
|
".hq-*",
|
|
77
77
|
"modules.lock",
|
|
78
78
|
// hq-root identity marker — discovered locally per-machine, never synced.
|
|
79
|
-
|
|
79
|
+
// Root-anchored: only the literal `core.yaml` at hq-root matches. Without
|
|
80
|
+
// the leading slash, gitignore semantics match `core.yaml` at any depth,
|
|
81
|
+
// which previously silently filtered out `core/core.yaml` (the hq-core
|
|
82
|
+
// scaffold rules manifest — a real project file that DOES sync).
|
|
83
|
+
"/core.yaml",
|
|
80
84
|
// hq modules manifest — local module-resolution state, never synced.
|
|
81
85
|
"modules/modules.yaml",
|
|
82
86
|
// per-company identity file — written locally on first sync, never round-tripped.
|