@indigoai-us/hq-cloud 5.11.1 → 5.11.3
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 +9 -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 +60 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +9 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +10 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +33 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/ignore.d.ts +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +104 -5
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +65 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/remote-pull.d.ts +51 -0
- package/dist/remote-pull.d.ts.map +1 -0
- package/dist/remote-pull.js +40 -0
- package/dist/remote-pull.js.map +1 -0
- package/dist/remote-pull.test.d.ts +2 -0
- package/dist/remote-pull.test.d.ts.map +1 -0
- package/dist/remote-pull.test.js +229 -0
- package/dist/remote-pull.test.js.map +1 -0
- package/dist/s3.d.ts +12 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +44 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.d.ts +9 -0
- package/dist/s3.test.d.ts.map +1 -0
- package/dist/s3.test.js +164 -0
- package/dist/s3.test.js.map +1 -0
- package/dist/watcher.d.ts +3 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +9 -3
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +82 -0
- package/src/bin/sync-runner.ts +77 -4
- package/src/cli/share.test.ts +48 -0
- package/src/cli/share.ts +20 -5
- package/src/ignore.test.ts +74 -0
- package/src/ignore.ts +102 -6
- package/src/index.ts +1 -1
- package/src/remote-pull.test.ts +241 -0
- package/src/remote-pull.ts +101 -0
- package/src/s3.test.ts +166 -0
- package/src/s3.ts +63 -0
- package/src/watcher.ts +12 -4
package/dist/watcher.js
CHANGED
|
@@ -17,20 +17,24 @@ export class SyncWatcher {
|
|
|
17
17
|
watcher = null;
|
|
18
18
|
hqRoot;
|
|
19
19
|
ctx;
|
|
20
|
+
author;
|
|
20
21
|
shouldSync;
|
|
21
22
|
pendingChanges = new Map();
|
|
22
23
|
debounceTimer = null;
|
|
23
24
|
processing = false;
|
|
24
|
-
constructor(hqRoot, ctx) {
|
|
25
|
+
constructor(hqRoot, ctx, author) {
|
|
25
26
|
this.hqRoot = hqRoot;
|
|
26
27
|
this.ctx = ctx;
|
|
28
|
+
this.author = author;
|
|
27
29
|
this.shouldSync = createIgnoreFilter(hqRoot);
|
|
28
30
|
}
|
|
29
31
|
start() {
|
|
30
32
|
if (this.watcher)
|
|
31
33
|
return;
|
|
32
34
|
this.watcher = watch(this.hqRoot, {
|
|
33
|
-
|
|
35
|
+
// Forward chokidar's stats hint so dir-only gitignore patterns
|
|
36
|
+
// (`foo/`) match directory entries during the descent decision.
|
|
37
|
+
ignored: (filePath, stats) => !this.shouldSync(filePath, stats?.isDirectory()),
|
|
34
38
|
persistent: true,
|
|
35
39
|
ignoreInitial: true,
|
|
36
40
|
awaitWriteFinish: {
|
|
@@ -91,7 +95,9 @@ export class SyncWatcher {
|
|
|
91
95
|
const existing = journal.files[relativePath];
|
|
92
96
|
if (existing && existing.hash === hash)
|
|
93
97
|
continue;
|
|
94
|
-
const { etag } =
|
|
98
|
+
const { etag } = this.author
|
|
99
|
+
? await uploadFile(this.ctx, change.absolutePath, relativePath, this.author)
|
|
100
|
+
: await uploadFile(this.ctx, change.absolutePath, relativePath);
|
|
95
101
|
updateEntry(journal, relativePath, hash, stat.size, "up", etag);
|
|
96
102
|
}
|
|
97
103
|
}
|
package/dist/watcher.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"watcher.js","sourceRoot":"","sources":["../src/watcher.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAGjC,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChF,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"watcher.js","sourceRoot":"","sources":["../src/watcher.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1419,6 +1419,88 @@ describe("personal slot fanout", () => {
|
|
|
1419
1419
|
});
|
|
1420
1420
|
});
|
|
1421
1421
|
|
|
1422
|
+
// ---------------------------------------------------------------------------
|
|
1423
|
+
// watch mode (Auto-sync Beta) — argv-level contract only
|
|
1424
|
+
// ---------------------------------------------------------------------------
|
|
1425
|
+
//
|
|
1426
|
+
// hq-sync-runner today exits after one pass. Auto-sync (Beta) extends it with
|
|
1427
|
+
// `--watch` (stay alive) and `--poll-remote-ms <n>` (delay between remote
|
|
1428
|
+
// pulls). These tests pin the parser-level contract: the flags exist, accept
|
|
1429
|
+
// valid values, and reject malformed ones. Loop-lifecycle behavior (timer
|
|
1430
|
+
// injection, graceful shutdown on SIGTERM) belongs in a follow-up test once
|
|
1431
|
+
// the dep-injection seam is chosen — kept out of this seed file deliberately.
|
|
1432
|
+
|
|
1433
|
+
describe("watch mode argv parsing", () => {
|
|
1434
|
+
it("rejects --watch today (will pass once implemented)", async () => {
|
|
1435
|
+
// Failing-test seed: --watch is currently an unknown argument. After the
|
|
1436
|
+
// parser learns it, this assertion flips to the new shape below.
|
|
1437
|
+
const deps = makeDeps();
|
|
1438
|
+
const code = await runRunner(["--companies", "--watch"], deps);
|
|
1439
|
+
// Once implemented: code should be 0 (or whatever a one-shot watch run
|
|
1440
|
+
// returns under the test deps), and the argv error must NOT contain
|
|
1441
|
+
// "Unknown argument".
|
|
1442
|
+
expect(deps.stderr.raw()).not.toContain("Unknown argument: --watch");
|
|
1443
|
+
// Code is asserted to be non-1 to flag the parser change without
|
|
1444
|
+
// committing to exact loop-exit semantics yet.
|
|
1445
|
+
expect(code).not.toBe(1);
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
it("rejects --poll-remote-ms with a missing value", async () => {
|
|
1449
|
+
const deps = makeDeps();
|
|
1450
|
+
const code = await runRunner(
|
|
1451
|
+
["--companies", "--watch", "--poll-remote-ms"],
|
|
1452
|
+
deps,
|
|
1453
|
+
);
|
|
1454
|
+
expect(code).toBe(1);
|
|
1455
|
+
expect(deps.stderr.raw()).toContain("--poll-remote-ms requires a value");
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("rejects --poll-remote-ms with a non-integer value", async () => {
|
|
1459
|
+
const deps = makeDeps();
|
|
1460
|
+
const code = await runRunner(
|
|
1461
|
+
["--companies", "--watch", "--poll-remote-ms", "abc"],
|
|
1462
|
+
deps,
|
|
1463
|
+
);
|
|
1464
|
+
expect(code).toBe(1);
|
|
1465
|
+
expect(deps.stderr.raw()).toContain("--poll-remote-ms");
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
it("rejects a non-positive --poll-remote-ms (no zero, no negative)", async () => {
|
|
1469
|
+
const deps = makeDeps();
|
|
1470
|
+
const code = await runRunner(
|
|
1471
|
+
["--companies", "--watch", "--poll-remote-ms", "0"],
|
|
1472
|
+
deps,
|
|
1473
|
+
);
|
|
1474
|
+
expect(code).toBe(1);
|
|
1475
|
+
expect(deps.stderr.raw()).toContain("--poll-remote-ms");
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it("accepts --poll-remote-ms 600000 (the menubar's 10-minute pin)", async () => {
|
|
1479
|
+
const deps = makeDeps({
|
|
1480
|
+
createVaultClient: () => makeVaultStub({ memberships: [] }),
|
|
1481
|
+
});
|
|
1482
|
+
const code = await runRunner(
|
|
1483
|
+
["--companies", "--watch", "--poll-remote-ms", "600000"],
|
|
1484
|
+
deps,
|
|
1485
|
+
);
|
|
1486
|
+
expect(deps.stderr.raw()).not.toContain("Unknown argument");
|
|
1487
|
+
expect(deps.stderr.raw()).not.toContain("--poll-remote-ms");
|
|
1488
|
+
// No memberships → setup-needed exit on the first pass; under test deps
|
|
1489
|
+
// that's a 0 exit. The point of this test is to pin that 600000 parses.
|
|
1490
|
+
expect(code).toBe(0);
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
it("rejects --poll-remote-ms outside watch mode (the flag has no meaning otherwise)", async () => {
|
|
1494
|
+
const deps = makeDeps();
|
|
1495
|
+
const code = await runRunner(
|
|
1496
|
+
["--companies", "--poll-remote-ms", "600000"],
|
|
1497
|
+
deps,
|
|
1498
|
+
);
|
|
1499
|
+
expect(code).toBe(1);
|
|
1500
|
+
expect(deps.stderr.raw()).toContain("--poll-remote-ms requires --watch");
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1422
1504
|
// ---------------------------------------------------------------------------
|
|
1423
1505
|
// Re-initialize for each test (mock state hygiene)
|
|
1424
1506
|
// ---------------------------------------------------------------------------
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -78,6 +78,7 @@ import type {
|
|
|
78
78
|
import { share as defaultShare } from "../cli/share.js";
|
|
79
79
|
import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
80
80
|
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
81
|
+
import type { UploadAuthor } from "../s3.js";
|
|
81
82
|
|
|
82
83
|
/**
|
|
83
84
|
* Sync direction for a run.
|
|
@@ -348,6 +349,10 @@ interface ParsedArgs {
|
|
|
348
349
|
onConflict: ConflictStrategy;
|
|
349
350
|
hqRoot: string;
|
|
350
351
|
direction: Direction;
|
|
352
|
+
/** Auto-sync (Beta): keep the runner alive after the first pass. */
|
|
353
|
+
watch: boolean;
|
|
354
|
+
/** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
|
|
355
|
+
pollRemoteMs?: number;
|
|
351
356
|
}
|
|
352
357
|
|
|
353
358
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
@@ -356,6 +361,8 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
356
361
|
let onConflict: ConflictStrategy = "abort";
|
|
357
362
|
let hqRoot = DEFAULT_HQ_ROOT;
|
|
358
363
|
let direction: Direction = "pull";
|
|
364
|
+
let watch = false;
|
|
365
|
+
let pollRemoteMs: number | undefined;
|
|
359
366
|
|
|
360
367
|
for (let i = 0; i < argv.length; i++) {
|
|
361
368
|
const arg = argv[i];
|
|
@@ -391,6 +398,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
391
398
|
hqRoot = argv[++i];
|
|
392
399
|
if (!hqRoot) return { error: "--hq-root requires a value" };
|
|
393
400
|
break;
|
|
401
|
+
case "--watch":
|
|
402
|
+
watch = true;
|
|
403
|
+
break;
|
|
404
|
+
case "--poll-remote-ms": {
|
|
405
|
+
const val = argv[++i];
|
|
406
|
+
if (!val) return { error: "--poll-remote-ms requires a value" };
|
|
407
|
+
const n = Number(val);
|
|
408
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
409
|
+
return {
|
|
410
|
+
error: `--poll-remote-ms must be a positive integer (ms), got: ${val}`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
pollRemoteMs = n;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
394
416
|
case "--json":
|
|
395
417
|
// Accepted but ignored — ndjson is the only output mode.
|
|
396
418
|
break;
|
|
@@ -405,8 +427,11 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
405
427
|
if (!companies && !company) {
|
|
406
428
|
return { error: "Pass --companies or --company <slug>" };
|
|
407
429
|
}
|
|
430
|
+
if (pollRemoteMs !== undefined && !watch) {
|
|
431
|
+
return { error: "--poll-remote-ms requires --watch" };
|
|
432
|
+
}
|
|
408
433
|
|
|
409
|
-
return { companies, company, onConflict, hqRoot, direction };
|
|
434
|
+
return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
|
|
410
435
|
}
|
|
411
436
|
|
|
412
437
|
// ---------------------------------------------------------------------------
|
|
@@ -490,6 +515,22 @@ export async function runRunner(
|
|
|
490
515
|
const client =
|
|
491
516
|
deps.createVaultClient?.(vaultConfig) ?? new VaultClient(vaultConfig);
|
|
492
517
|
|
|
518
|
+
// ---- resolve identity claims -----------------------------------------
|
|
519
|
+
// Read the cached idToken claims once. Two consumers downstream:
|
|
520
|
+
// 1. The claim-dance (only fires in `--companies` mode for setup-needed
|
|
521
|
+
// invitees).
|
|
522
|
+
// 2. The S3 upload author (every share() call stamps `Metadata['created-by']`
|
|
523
|
+
// with `claims.email` so the hq-console vault UI's CREATED BY column
|
|
524
|
+
// attributes the file to the syncing user).
|
|
525
|
+
// Resolved here (not inside `parsed.companies`) so single-company runs also
|
|
526
|
+
// get author attribution. `null` is fine — share() simply omits the metadata.
|
|
527
|
+
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
528
|
+
const claims = getClaims();
|
|
529
|
+
const uploadAuthor: UploadAuthor | undefined =
|
|
530
|
+
claims?.sub && claims?.email
|
|
531
|
+
? { userSub: claims.sub, email: claims.email }
|
|
532
|
+
: undefined;
|
|
533
|
+
|
|
493
534
|
// ---- resolve targets --------------------------------------------------
|
|
494
535
|
let memberships: Pick<Membership, "companyUid">[];
|
|
495
536
|
try {
|
|
@@ -497,8 +538,6 @@ export async function runRunner(
|
|
|
497
538
|
// Before giving up on memberships, run the claim-dance: new users signed
|
|
498
539
|
// in via the tray may have email-keyed invites waiting for them. Without
|
|
499
540
|
// this, an invited user would see "setup-needed" on every tray click.
|
|
500
|
-
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
501
|
-
const claims = getClaims();
|
|
502
541
|
if (claims) {
|
|
503
542
|
await runClaimDance(client, claims, stderr);
|
|
504
543
|
}
|
|
@@ -715,6 +754,7 @@ export async function runRunner(
|
|
|
715
754
|
// next pull because the remote object is still listable.
|
|
716
755
|
propagateDeletes: true,
|
|
717
756
|
onEvent: tagAndEmit,
|
|
757
|
+
...(uploadAuthor ? { author: uploadAuthor } : {}),
|
|
718
758
|
});
|
|
719
759
|
}
|
|
720
760
|
|
|
@@ -895,8 +935,41 @@ const isDirectInvocation = (() => {
|
|
|
895
935
|
}
|
|
896
936
|
})();
|
|
897
937
|
|
|
938
|
+
/**
|
|
939
|
+
* Auto-sync (Beta) watch loop. Re-runs the one-shot runner every
|
|
940
|
+
* `pollRemoteMs` until the process is killed (SIGTERM from the menubar's
|
|
941
|
+
* stop_daemon command) or until a pass returns a non-zero exit code (hard
|
|
942
|
+
* error worth surfacing to the operator). `setup-needed` and `auth-error`
|
|
943
|
+
* exit 0 today and so will retry — acceptable noise for the beta; deal with
|
|
944
|
+
* it via a richer return shape if it shows up in Sentry.
|
|
945
|
+
*/
|
|
946
|
+
export async function runRunnerWithLoop(argv: string[]): Promise<number> {
|
|
947
|
+
if (!argv.includes("--watch")) {
|
|
948
|
+
return runRunner(argv);
|
|
949
|
+
}
|
|
950
|
+
const pollIdx = argv.indexOf("--poll-remote-ms");
|
|
951
|
+
const pollMs =
|
|
952
|
+
pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
953
|
+
|
|
954
|
+
// Strip --watch / --poll-remote-ms before delegating: the parser inside
|
|
955
|
+
// runRunner accepts them, but we don't want runRunner to think it's
|
|
956
|
+
// re-entering watch mode each iteration.
|
|
957
|
+
const passArgv = argv.filter((a, i) => {
|
|
958
|
+
if (a === "--watch") return false;
|
|
959
|
+
if (a === "--poll-remote-ms") return false;
|
|
960
|
+
if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
|
|
961
|
+
return true;
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
while (true) {
|
|
965
|
+
const code = await runRunner(passArgv);
|
|
966
|
+
if (code !== 0) return code;
|
|
967
|
+
await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
898
971
|
if (isDirectInvocation) {
|
|
899
|
-
|
|
972
|
+
runRunnerWithLoop(process.argv.slice(2))
|
|
900
973
|
.then((code) => process.exit(code))
|
|
901
974
|
.catch((err) => {
|
|
902
975
|
process.stderr.write(
|
package/src/cli/share.test.ts
CHANGED
|
@@ -481,6 +481,54 @@ describe("share", () => {
|
|
|
481
481
|
expect(journal.files["fresh.md"].remoteEtag).toBe("new-upload-etag");
|
|
482
482
|
});
|
|
483
483
|
|
|
484
|
+
it("forwards UploadAuthor to uploadFile when present (created-by metadata)", async () => {
|
|
485
|
+
// Regression: hq-console vault UI's CREATED BY column was always blank
|
|
486
|
+
// because the sync engine never stamped Metadata['created-by'] on PUT.
|
|
487
|
+
// share() now accepts an `author` and threads it to s3.uploadFile so
|
|
488
|
+
// every synced file lands in S3 with the syncer's identity attached.
|
|
489
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
490
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
491
|
+
const testFile = path.join(companyRoot, "attribution.md");
|
|
492
|
+
fs.writeFileSync(testFile, "attributed content");
|
|
493
|
+
|
|
494
|
+
await share({
|
|
495
|
+
paths: [testFile],
|
|
496
|
+
company: "acme",
|
|
497
|
+
vaultConfig: mockConfig,
|
|
498
|
+
hqRoot: tmpDir,
|
|
499
|
+
author: { userSub: "abc-123", email: "alice@example.com" },
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
503
|
+
expect.anything(),
|
|
504
|
+
testFile,
|
|
505
|
+
"attribution.md",
|
|
506
|
+
{ userSub: "abc-123", email: "alice@example.com" },
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("omits author arg when not provided (back-compat)", async () => {
|
|
511
|
+
// share() must remain a 3-arg call to uploadFile when no author is
|
|
512
|
+
// configured — older test stubs and external integrations rely on it.
|
|
513
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
514
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
515
|
+
const testFile = path.join(companyRoot, "no-author.md");
|
|
516
|
+
fs.writeFileSync(testFile, "anonymous");
|
|
517
|
+
|
|
518
|
+
await share({
|
|
519
|
+
paths: [testFile],
|
|
520
|
+
company: "acme",
|
|
521
|
+
vaultConfig: mockConfig,
|
|
522
|
+
hqRoot: tmpDir,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
526
|
+
expect.anything(),
|
|
527
|
+
testFile,
|
|
528
|
+
"no-author.md",
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
484
532
|
it("skipUnchanged=false (default) uploads even when hash matches", async () => {
|
|
485
533
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
486
534
|
fs.mkdirSync(companyRoot, { recursive: true });
|
package/src/cli/share.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as path from "path";
|
|
|
10
10
|
import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
12
|
import { uploadFile, headRemoteFile, deleteRemoteFile } from "../s3.js";
|
|
13
|
+
import type { UploadAuthor } from "../s3.js";
|
|
13
14
|
import { readJournal, writeJournal, hashFile, updateEntry, removeEntry, normalizeEtag } from "../journal.js";
|
|
14
15
|
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
15
16
|
import { resolveConflict } from "./conflict.js";
|
|
@@ -184,6 +185,14 @@ export interface ShareOptions {
|
|
|
184
185
|
* full-tree bidirectional runner opts in.
|
|
185
186
|
*/
|
|
186
187
|
propagateDeletes?: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* Identity stamped onto each uploaded object's S3 user metadata
|
|
190
|
+
* (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
|
|
191
|
+
* reads `Metadata['created-by']` for its "CREATED BY" column; uploads
|
|
192
|
+
* without an author leave that column blank for every file synced via
|
|
193
|
+
* this engine. The runner pipes Cognito idToken claims through here.
|
|
194
|
+
*/
|
|
195
|
+
author?: UploadAuthor;
|
|
187
196
|
}
|
|
188
197
|
|
|
189
198
|
export interface ShareResult {
|
|
@@ -383,7 +392,9 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
383
392
|
try {
|
|
384
393
|
const stat = fs.statSync(absolutePath);
|
|
385
394
|
|
|
386
|
-
const { etag } =
|
|
395
|
+
const { etag } = options.author
|
|
396
|
+
? await uploadFile(ctx, absolutePath, relativePath, options.author)
|
|
397
|
+
: await uploadFile(ctx, absolutePath, relativePath);
|
|
387
398
|
|
|
388
399
|
// Update journal with optional message; capture the post-upload ETag
|
|
389
400
|
// so the next sync can distinguish "remote moved since we last wrote"
|
|
@@ -513,7 +524,7 @@ function collectFiles(
|
|
|
513
524
|
paths: string[],
|
|
514
525
|
hqRoot: string,
|
|
515
526
|
syncRoot: string,
|
|
516
|
-
filter: (p: string) => boolean,
|
|
527
|
+
filter: (p: string, isDir?: boolean) => boolean,
|
|
517
528
|
): { absolutePath: string; relativePath: string }[] {
|
|
518
529
|
const results: { absolutePath: string; relativePath: string }[] = [];
|
|
519
530
|
|
|
@@ -532,6 +543,7 @@ function collectFiles(
|
|
|
532
543
|
|
|
533
544
|
const stat = fs.statSync(absolutePath);
|
|
534
545
|
if (stat.isDirectory()) {
|
|
546
|
+
if (!filter(absolutePath, true)) continue;
|
|
535
547
|
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
536
548
|
} else if (stat.isFile()) {
|
|
537
549
|
const relativePath = path.relative(syncRoot, absolutePath);
|
|
@@ -547,7 +559,7 @@ function collectFiles(
|
|
|
547
559
|
function walkDir(
|
|
548
560
|
dir: string,
|
|
549
561
|
syncRoot: string,
|
|
550
|
-
filter: (p: string) => boolean,
|
|
562
|
+
filter: (p: string, isDir?: boolean) => boolean,
|
|
551
563
|
): { absolutePath: string; relativePath: string }[] {
|
|
552
564
|
const results: { absolutePath: string; relativePath: string }[] = [];
|
|
553
565
|
if (!fs.existsSync(dir)) return results;
|
|
@@ -555,9 +567,12 @@ function walkDir(
|
|
|
555
567
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
556
568
|
for (const entry of entries) {
|
|
557
569
|
const absolutePath = path.join(dir, entry.name);
|
|
558
|
-
|
|
570
|
+
const isDir = entry.isDirectory();
|
|
571
|
+
// Pass the dir hint so dir-only ignore/include patterns (`foo/`)
|
|
572
|
+
// resolve correctly for the descent decision.
|
|
573
|
+
if (!filter(absolutePath, isDir)) continue;
|
|
559
574
|
|
|
560
|
-
if (
|
|
575
|
+
if (isDir) {
|
|
561
576
|
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
562
577
|
} else if (entry.isFile()) {
|
|
563
578
|
results.push({
|
package/src/ignore.test.ts
CHANGED
|
@@ -110,6 +110,80 @@ describe("createIgnoreFilter", () => {
|
|
|
110
110
|
expect(shouldSync(path.join(hqRoot, "personal/journal/2026-04-26.md"))).toBe(false);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it("allowlist mode: directory entries match dir-suffix include patterns (walker descent)", () => {
|
|
114
|
+
// Regression: walkDir tests directory entries when deciding whether to
|
|
115
|
+
// descend. The `ignore` lib only matches dir-only patterns like
|
|
116
|
+
// `companies/*/knowledge/` against paths that end with `/`. The filter
|
|
117
|
+
// accepts an `isDir` hint and appends the trailing slash before probing,
|
|
118
|
+
// mirroring how git itself decides (it knows from the index whether
|
|
119
|
+
// each entry is a tree or a blob). Without the hint the walker would
|
|
120
|
+
// see `companies/indigo/knowledge` -> not allowed -> never descend ->
|
|
121
|
+
// entire subtree silently lost. Files inside still work because
|
|
122
|
+
// `ignore` walks ancestors internally; the bug only bites the descent
|
|
123
|
+
// decision for directory entries.
|
|
124
|
+
fs.writeFileSync(
|
|
125
|
+
path.join(hqRoot, ".hqinclude"),
|
|
126
|
+
"companies/*/knowledge/\ncompanies/*/projects/\n.claude/\n",
|
|
127
|
+
);
|
|
128
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
129
|
+
// Directory entries must be allowed when the caller hints isDir=true.
|
|
130
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge"), true)).toBe(true);
|
|
131
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/projects"), true)).toBe(true);
|
|
132
|
+
expect(shouldSync(path.join(hqRoot, ".claude"), true)).toBe(true);
|
|
133
|
+
// Files inside still resolve correctly without the hint.
|
|
134
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge/x.md"))).toBe(true);
|
|
135
|
+
expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
|
|
136
|
+
// Non-allowlisted siblings stay excluded — privacy guarantee intact.
|
|
137
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/data"), true)).toBe(false);
|
|
138
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/workers"), true)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("allowlist mode: walker descends through intermediate ancestor dirs", () => {
|
|
142
|
+
// Full gitignore semantics: a walker starting at hqRoot must be able to
|
|
143
|
+
// descend through `companies/` and `companies/indigo/` to reach the
|
|
144
|
+
// allowlisted leaf `companies/*/knowledge/`. The ancestor matcher
|
|
145
|
+
// permits this for dir-mode queries while keeping files directly inside
|
|
146
|
+
// those intermediate dirs excluded — privacy invariant intact.
|
|
147
|
+
fs.writeFileSync(
|
|
148
|
+
path.join(hqRoot, ".hqinclude"),
|
|
149
|
+
"companies/*/knowledge/\n",
|
|
150
|
+
);
|
|
151
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
152
|
+
// Intermediate directories along the path to the leaf are descent-allowed.
|
|
153
|
+
expect(shouldSync(path.join(hqRoot, "companies"), true)).toBe(true);
|
|
154
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo"), true)).toBe(true);
|
|
155
|
+
// The leaf itself is allowed.
|
|
156
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge"), true)).toBe(true);
|
|
157
|
+
// Files directly inside intermediate dirs are still excluded — only the
|
|
158
|
+
// dir is descent-allowed, the dir's loose files are not.
|
|
159
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(false);
|
|
160
|
+
expect(shouldSync(path.join(hqRoot, "companies/README.md"))).toBe(false);
|
|
161
|
+
// Files inside the allowlisted leaf still sync.
|
|
162
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/knowledge/x.md"))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("dir-only ignore patterns honor the isDir hint symmetrically", () => {
|
|
166
|
+
// Gitignore semantics: a trailing-slash pattern (`foo/`) matches only
|
|
167
|
+
// directories, never files of the same name. The filter applies this
|
|
168
|
+
// on BOTH layers — exclude and include — by appending `/` to the probe
|
|
169
|
+
// when the caller passes isDir=true, and leaving it as-is otherwise.
|
|
170
|
+
// This guarantees:
|
|
171
|
+
// - a directory `node_modules` is excluded (descent skipped),
|
|
172
|
+
// - but a regular file literally named `node_modules` is NOT
|
|
173
|
+
// mistakenly excluded by the dir-only pattern.
|
|
174
|
+
fs.writeFileSync(path.join(hqRoot, ".hqignore"), "build/\n");
|
|
175
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
176
|
+
// Directory `build` matches the dir-only exclusion.
|
|
177
|
+
expect(shouldSync(path.join(hqRoot, "build"), true)).toBe(false);
|
|
178
|
+
// A file literally named `build` (not a dir) does NOT match `build/`.
|
|
179
|
+
// This is the asymmetry that previous PR comments warned about and
|
|
180
|
+
// that the isDir hint resolves cleanly.
|
|
181
|
+
expect(shouldSync(path.join(hqRoot, "build"), false)).toBe(true);
|
|
182
|
+
// Default-call (no hint) is treated as "not a directory" — matches
|
|
183
|
+
// gitignore's blob-default and preserves the file-side guarantee.
|
|
184
|
+
expect(shouldSync(path.join(hqRoot, "build"))).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
113
187
|
it("allowlist mode: exclusion layers still subtract on top", () => {
|
|
114
188
|
// Even when a subtree is allowlisted, default ignores like node_modules/
|
|
115
189
|
// and .env must still apply. Otherwise an allowlisted subdir would sync
|
package/src/ignore.ts
CHANGED
|
@@ -105,7 +105,79 @@ function readIgnoreFile(filePath: string): string | null {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Compile a depth-anchored ancestor matcher from .hqinclude content.
|
|
110
|
+
*
|
|
111
|
+
* Git itself never asks "should I descend into this dir?" — it walks its
|
|
112
|
+
* index and checks each file directly. Our walkers/watchers don't have an
|
|
113
|
+
* index, so they have to make a descent decision per directory. To preserve
|
|
114
|
+
* full gitignore semantics in allowlist mode, we must allow descent into
|
|
115
|
+
* every ancestor of any include pattern. We can't reuse the `ignore` lib
|
|
116
|
+
* for this because gitignore's `foo/*\/` would also match `foo/x/y/z/` —
|
|
117
|
+
* recursing into the dir — which would defeat the privacy invariant.
|
|
118
|
+
*
|
|
119
|
+
* The matcher therefore checks an ancestor candidate against each include
|
|
120
|
+
* pattern's prefix segments at the EXACT same depth, with `*` and `?`
|
|
121
|
+
* resolved per-segment. `**` segments are treated as wildcards that match
|
|
122
|
+
* any single segment here — sufficient for descent decisions, since the
|
|
123
|
+
* include matcher itself still gates files.
|
|
124
|
+
*/
|
|
125
|
+
function compileAncestorMatcher(
|
|
126
|
+
includeContent: string,
|
|
127
|
+
): ((relDir: string) => boolean) | null {
|
|
128
|
+
const prefixes: RegExp[][] = [];
|
|
129
|
+
for (const raw of includeContent.split(/\r?\n/)) {
|
|
130
|
+
const line = raw.trim();
|
|
131
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
132
|
+
const stripped = line.replace(/^\//, "").replace(/\/$/, "");
|
|
133
|
+
if (!stripped.includes("/")) continue;
|
|
134
|
+
const segs = stripped.split("/").map(segmentToRegex);
|
|
135
|
+
for (let i = 1; i < segs.length; i++) {
|
|
136
|
+
prefixes.push(segs.slice(0, i));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!prefixes.length) return null;
|
|
140
|
+
return (relDir: string): boolean => {
|
|
141
|
+
const parts = relDir.split("/");
|
|
142
|
+
for (const pat of prefixes) {
|
|
143
|
+
if (pat.length !== parts.length) continue;
|
|
144
|
+
let ok = true;
|
|
145
|
+
for (let i = 0; i < pat.length; i++) {
|
|
146
|
+
if (!pat[i].test(parts[i])) {
|
|
147
|
+
ok = false;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (ok) return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function segmentToRegex(seg: string): RegExp {
|
|
158
|
+
// Translate a single gitignore path segment to an anchored regex. `*` and
|
|
159
|
+
// `**` both match any single segment here (segments never contain `/`),
|
|
160
|
+
// `?` matches one char. Everything else is escaped literal.
|
|
161
|
+
let body = "";
|
|
162
|
+
for (let i = 0; i < seg.length; i++) {
|
|
163
|
+
const ch = seg[i];
|
|
164
|
+
if (ch === "*") {
|
|
165
|
+
if (seg[i + 1] === "*") i++;
|
|
166
|
+
body += "[^/]*";
|
|
167
|
+
} else if (ch === "?") {
|
|
168
|
+
body += "[^/]";
|
|
169
|
+
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
170
|
+
body += "\\" + ch;
|
|
171
|
+
} else {
|
|
172
|
+
body += ch;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return new RegExp(`^${body}$`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function createIgnoreFilter(
|
|
179
|
+
hqRoot: string,
|
|
180
|
+
): (filePath: string, isDir?: boolean) => boolean {
|
|
109
181
|
const ig = ignore();
|
|
110
182
|
|
|
111
183
|
// Layer 1: baseline defaults
|
|
@@ -130,13 +202,37 @@ export function createIgnoreFilter(hqRoot: string): (filePath: string) => boolea
|
|
|
130
202
|
// still skipped.
|
|
131
203
|
const hqinclude = readIgnoreFile(path.join(hqRoot, ".hqinclude"));
|
|
132
204
|
const includeMatcher = hqinclude ? ignore().add(hqinclude) : null;
|
|
133
|
-
|
|
134
|
-
|
|
205
|
+
// Ancestor matcher: matches every directory that lies on the path to an
|
|
206
|
+
// include pattern. Consulted ONLY for `isDir=true` queries so a walker can
|
|
207
|
+
// descend through `companies/` and `companies/indigo/` to reach the leaf
|
|
208
|
+
// `companies/*/knowledge/`. Files directly inside those intermediate dirs
|
|
209
|
+
// remain excluded — this is the privacy invariant of allowlist mode.
|
|
210
|
+
const ancestorMatcher = hqinclude ? compileAncestorMatcher(hqinclude) : null;
|
|
211
|
+
|
|
212
|
+
return (filePath: string, isDir = false): boolean => {
|
|
135
213
|
const relative = path.relative(hqRoot, filePath);
|
|
136
214
|
if (!relative || relative.startsWith("..")) return true; // outside HQ root
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
215
|
+
|
|
216
|
+
// Gitignore dir-only patterns (`foo/`) only match candidate paths that
|
|
217
|
+
// end with `/`. The `ignore` lib has no stat awareness, so when the
|
|
218
|
+
// caller knows the entry is a directory we hand the matcher the
|
|
219
|
+
// canonical trailing-slash form. This mirrors how git itself decides:
|
|
220
|
+
// it knows from the index whether each entry is a tree or a blob.
|
|
221
|
+
// Applied symmetrically to BOTH layers — exclude and include — to
|
|
222
|
+
// preserve full gitignore semantics on both sides.
|
|
223
|
+
const probe = isDir && !relative.endsWith("/") ? relative + "/" : relative;
|
|
224
|
+
|
|
225
|
+
if (ig.ignores(probe)) return false;
|
|
226
|
+
if (!includeMatcher) return true;
|
|
227
|
+
if (includeMatcher.ignores(probe)) return true;
|
|
228
|
+
// Directory query that didn't match the include pattern itself — allow
|
|
229
|
+
// if it's an ancestor of one (so the walker can descend to the leaf).
|
|
230
|
+
if (isDir && ancestorMatcher) {
|
|
231
|
+
// Ancestor matching is depth-anchored, so feed it the slashless form.
|
|
232
|
+
const relDir = relative.replace(/\/$/, "");
|
|
233
|
+
if (relDir && ancestorMatcher(relDir)) return true;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
140
236
|
};
|
|
141
237
|
}
|
|
142
238
|
|