@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.
Files changed (53) hide show
  1. package/dist/bin/sync-runner.d.ts +9 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +71 -4
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +60 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +9 -0
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +10 -3
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +33 -0
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/ignore.d.ts +1 -1
  14. package/dist/ignore.d.ts.map +1 -1
  15. package/dist/ignore.js +104 -5
  16. package/dist/ignore.js.map +1 -1
  17. package/dist/ignore.test.js +65 -0
  18. package/dist/ignore.test.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/remote-pull.d.ts +51 -0
  22. package/dist/remote-pull.d.ts.map +1 -0
  23. package/dist/remote-pull.js +40 -0
  24. package/dist/remote-pull.js.map +1 -0
  25. package/dist/remote-pull.test.d.ts +2 -0
  26. package/dist/remote-pull.test.d.ts.map +1 -0
  27. package/dist/remote-pull.test.js +229 -0
  28. package/dist/remote-pull.test.js.map +1 -0
  29. package/dist/s3.d.ts +12 -1
  30. package/dist/s3.d.ts.map +1 -1
  31. package/dist/s3.js +44 -1
  32. package/dist/s3.js.map +1 -1
  33. package/dist/s3.test.d.ts +9 -0
  34. package/dist/s3.test.d.ts.map +1 -0
  35. package/dist/s3.test.js +164 -0
  36. package/dist/s3.test.js.map +1 -0
  37. package/dist/watcher.d.ts +3 -1
  38. package/dist/watcher.d.ts.map +1 -1
  39. package/dist/watcher.js +9 -3
  40. package/dist/watcher.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/bin/sync-runner.test.ts +82 -0
  43. package/src/bin/sync-runner.ts +77 -4
  44. package/src/cli/share.test.ts +48 -0
  45. package/src/cli/share.ts +20 -5
  46. package/src/ignore.test.ts +74 -0
  47. package/src/ignore.ts +102 -6
  48. package/src/index.ts +1 -1
  49. package/src/remote-pull.test.ts +241 -0
  50. package/src/remote-pull.ts +101 -0
  51. package/src/s3.test.ts +166 -0
  52. package/src/s3.ts +63 -0
  53. 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
- ignored: (filePath) => !this.shouldSync(filePath),
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 } = await uploadFile(this.ctx, change.absolutePath, relativePath);
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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"watcher.js","sourceRoot":"","sources":["../src/watcher.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"watcher.js","sourceRoot":"","sources":["../src/watcher.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "5.11.1",
3
+ "version": "5.11.3",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
  // ---------------------------------------------------------------------------
@@ -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
- runRunner(process.argv.slice(2))
972
+ runRunnerWithLoop(process.argv.slice(2))
900
973
  .then((code) => process.exit(code))
901
974
  .catch((err) => {
902
975
  process.stderr.write(
@@ -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 } = await uploadFile(ctx, absolutePath, relativePath);
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
- if (!filter(absolutePath)) continue;
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 (entry.isDirectory()) {
575
+ if (isDir) {
561
576
  results.push(...walkDir(absolutePath, syncRoot, filter));
562
577
  } else if (entry.isFile()) {
563
578
  results.push({
@@ -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
- export function createIgnoreFilter(hqRoot: string): (filePath: string) => boolean {
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
- return (filePath: string): boolean => {
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
- if (ig.ignores(relative)) return false;
138
- if (includeMatcher && !includeMatcher.ignores(relative)) return false;
139
- return true;
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
 
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export {
20
20
  headRemoteFile,
21
21
  } from "./s3.js";
22
22
 
23
- export type { RemoteFile } from "./s3.js";
23
+ export type { RemoteFile, UploadAuthor } from "./s3.js";
24
24
 
25
25
  export {
26
26
  readJournal,