@indigoai-us/hq-cloud 5.32.0 → 5.34.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.
Files changed (60) 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 +53 -27
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +69 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +60 -4
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +129 -8
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +104 -6
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +20 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +260 -7
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/cli/sync.test.js +469 -0
  18. package/dist/cli/sync.test.js.map +1 -1
  19. package/dist/ignore.d.ts.map +1 -1
  20. package/dist/ignore.js +7 -1
  21. package/dist/ignore.js.map +1 -1
  22. package/dist/ignore.test.js +19 -3
  23. package/dist/ignore.test.js.map +1 -1
  24. package/dist/lib/conflict-file.d.ts +7 -6
  25. package/dist/lib/conflict-file.d.ts.map +1 -1
  26. package/dist/lib/conflict-file.js +7 -27
  27. package/dist/lib/conflict-file.js.map +1 -1
  28. package/dist/lib/conflict.test.d.ts +4 -3
  29. package/dist/lib/conflict.test.d.ts.map +1 -1
  30. package/dist/lib/conflict.test.js +5 -33
  31. package/dist/lib/conflict.test.js.map +1 -1
  32. package/dist/lib/machine-id.d.ts +108 -0
  33. package/dist/lib/machine-id.d.ts.map +1 -0
  34. package/dist/lib/machine-id.js +170 -0
  35. package/dist/lib/machine-id.js.map +1 -0
  36. package/dist/lib/machine-id.test.d.ts +8 -0
  37. package/dist/lib/machine-id.test.d.ts.map +1 -0
  38. package/dist/lib/machine-id.test.js +195 -0
  39. package/dist/lib/machine-id.test.js.map +1 -0
  40. package/dist/s3.d.ts +21 -0
  41. package/dist/s3.d.ts.map +1 -1
  42. package/dist/s3.js +69 -2
  43. package/dist/s3.js.map +1 -1
  44. package/dist/s3.test.js +129 -2
  45. package/dist/s3.test.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/bin/sync-runner.test.ts +85 -0
  48. package/src/bin/sync-runner.ts +62 -25
  49. package/src/cli/share.test.ts +115 -6
  50. package/src/cli/share.ts +149 -9
  51. package/src/cli/sync.test.ts +529 -0
  52. package/src/cli/sync.ts +295 -8
  53. package/src/ignore.test.ts +20 -3
  54. package/src/ignore.ts +7 -1
  55. package/src/lib/conflict-file.ts +7 -27
  56. package/src/lib/conflict.test.ts +4 -40
  57. package/src/lib/machine-id.test.ts +221 -0
  58. package/src/lib/machine-id.ts +175 -0
  59. package/src/s3.test.ts +142 -2
  60. package/src/s3.ts +71 -2
package/src/cli/sync.ts CHANGED
@@ -17,10 +17,12 @@ import {
17
17
  hashFile,
18
18
  hashSymlinkTarget,
19
19
  updateEntry,
20
+ removeEntry,
20
21
  getEntry,
21
22
  normalizeEtag,
22
23
  } from "../journal.js";
23
24
  import { createIgnoreFilter } from "../ignore.js";
25
+ import { isEphemeralPath } from "./share.js";
24
26
  import { resolveConflict } from "./conflict.js";
25
27
  import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
26
28
  import {
@@ -240,6 +242,26 @@ export interface SyncResult {
240
242
  newFiles: Array<{ path: string; bytes: number }>;
241
243
  /** Convenience count: `newFiles.length`. */
242
244
  newFilesCount: number;
245
+ /**
246
+ * Count of remote keys refused at planning time because they matched
247
+ * `EPHEMERAL_PATH_PATTERN` (conflict-mirror files that must never round-
248
+ * trip through the bucket). Mirrors `ShareResult.filesExcludedByPolicy`
249
+ * so push and pull report the same shape. Pre-fix this count was always
250
+ * 0 on the pull side and legacy `.conflict-*` litter rode every sync —
251
+ * see Bug #2 in workspace/reports/hq-cloud-5.33.0-deep-test.md.
252
+ */
253
+ filesExcludedByPolicy: number;
254
+ /**
255
+ * Count of journal-known keys applied as local deletes during this pull
256
+ * because the remote LIST no longer contains them — the cross-machine
257
+ * delete-propagation signal that Bug #9 closes. The peer's push leg
258
+ * removed the object from S3 (`hq sync` push side verified-to-work in
259
+ * the deep-test addendum), but pre-fix the pull side never enumerated
260
+ * "what's missing-from-remote-that-was-there-before", so the file
261
+ * lingered locally forever. Always 0 when no journal-known keys have
262
+ * disappeared from the remote.
263
+ */
264
+ filesTombstoned: number;
243
265
  }
244
266
 
245
267
  /**
@@ -279,6 +301,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
279
301
  let bytesDownloaded = 0;
280
302
  let filesSkipped = 0;
281
303
  let conflicts = 0;
304
+ let filesTombstoned = 0;
282
305
  const conflictPaths: string[] = [];
283
306
 
284
307
  // List all remote files (IAM session policy filters at the AWS layer)
@@ -322,6 +345,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
322
345
  filesSkipped++;
323
346
  continue;
324
347
  }
348
+ if (item.action === "skip-excluded-policy") {
349
+ // Policy-excluded items count separately from `filesSkipped` so the
350
+ // pull result mirrors the push side's `filesExcludedByPolicy`
351
+ // counter — `filesSkipped` stays a measure of "unchanged on this
352
+ // run", not a catch-all for everything we didn't download.
353
+ continue;
354
+ }
325
355
 
326
356
  const { remoteFile, localPath } = item;
327
357
 
@@ -365,7 +395,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
365
395
  if (resolution !== "abort" && resolution !== "overwrite") {
366
396
  try {
367
397
  const detectedAt = new Date().toISOString();
368
- const machineId = readShortMachineId();
398
+ const machineId = readShortMachineId(hqRoot);
369
399
  const originalRelative = path.relative(hqRoot, localPath);
370
400
  const conflictRelative = buildConflictPath(
371
401
  originalRelative,
@@ -407,6 +437,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
407
437
  aborted: true,
408
438
  newFiles: plan.newFiles,
409
439
  newFilesCount: plan.newFilesCount,
440
+ filesExcludedByPolicy: plan.filesExcludedByPolicy,
441
+ // Abort short-circuits before the tombstone loop runs; report
442
+ // 0 so the field shape stays stable for consumers that
443
+ // destructure it.
444
+ filesTombstoned: 0,
410
445
  };
411
446
  }
412
447
  if (resolution === "keep" || resolution === "skip") {
@@ -526,6 +561,108 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
526
561
  }
527
562
  emit({ type: "new-files", files: enrichedNewFiles });
528
563
 
564
+ // Codex P1 (PR #24 follow-up): scope-gate tombstone candidates with
565
+ // a per-object HEAD before unlinking. `listRemoteFiles` is STS-scoped
566
+ // (guest sessions with `allowedPrefixes`, role downgrade, custom
567
+ // sync-mode prefix sets — see the AccessDenied branch in the download
568
+ // loop above), so a journal entry's absence from LIST does not prove
569
+ // the object was deleted; it may simply be invisible to this session.
570
+ // HEAD each candidate:
571
+ // - HEAD returns metadata → object exists → NOT in our LIST scope →
572
+ // skip the tombstone (peer didn't delete it; we just can't see it).
573
+ // - HEAD returns null (NotFound) → confirmed deleted → tombstone.
574
+ // - HEAD throws AccessDenied → can't tell → defensive skip; journal
575
+ // stays so next sync (with broader scope) can re-evaluate.
576
+ // - HEAD throws transient → defensive skip + emit error.
577
+ // Bounded concurrency mirrors the new-files attribution pass above.
578
+ if (plan.tombstones.length > 0) {
579
+ const HEAD_VERIFY_CONCURRENCY = 5;
580
+ const verified: string[] = [];
581
+ for (let i = 0; i < plan.tombstones.length; i += HEAD_VERIFY_CONCURRENCY) {
582
+ const batch = plan.tombstones.slice(i, i + HEAD_VERIFY_CONCURRENCY);
583
+ const results = await Promise.all(
584
+ batch.map(async (key) => {
585
+ try {
586
+ const head = await headRemoteFile(ctx, key);
587
+ return head === null ? key : null;
588
+ } catch (err) {
589
+ if (isAccessDenied(err)) return null;
590
+ emit({
591
+ type: "error",
592
+ path: key,
593
+ message: `tombstone HEAD verify failed (deferring): ${
594
+ err instanceof Error ? err.message : String(err)
595
+ }`,
596
+ });
597
+ return null;
598
+ }
599
+ }),
600
+ );
601
+ for (const k of results) {
602
+ if (k !== null) verified.push(k);
603
+ }
604
+ }
605
+ plan.tombstones = verified;
606
+ }
607
+
608
+ // Bug #9 — apply cross-machine delete propagation. Each tombstone is a
609
+ // key the journal records as previously synced but the remote LIST no
610
+ // longer contains. We delete the local file (or symlink, or empty dir
611
+ // remnant) and drop the journal entry so the next sync's planner stays
612
+ // converged. Failures are reported but non-fatal — the entry stays in
613
+ // the journal and the next run retries.
614
+ for (const key of plan.tombstones) {
615
+ const localPath = path.join(companyRoot, key);
616
+ let removedSomething = false;
617
+ try {
618
+ const lstat = fs.lstatSync(localPath);
619
+ if (lstat.isSymbolicLink() || lstat.isFile()) {
620
+ fs.unlinkSync(localPath);
621
+ removedSomething = true;
622
+ } else if (lstat.isDirectory()) {
623
+ // A dir at a key — likely from a (local-dir, cloud-file) historic
624
+ // state. Don't recursively rm-rf the operator's dir; just drop
625
+ // the journal entry so we converge with reality.
626
+ }
627
+ } catch (err: unknown) {
628
+ const code =
629
+ err && typeof err === "object" && "code" in err
630
+ ? (err as { code?: string }).code
631
+ : undefined;
632
+ // ENOENT → local already gone; safe to drop the journal entry.
633
+ // Other errors (EACCES/EPERM/EBUSY/etc.) leave the local file in
634
+ // place — if we dropped the journal entry anyway, the pull side
635
+ // would forget the peer's delete and a later push could re-upload
636
+ // the still-present local file, silently undoing the peer's delete.
637
+ // Surface the error and KEEP the journal entry so the next sync
638
+ // retries the unlink after the operator fixes the permission.
639
+ if (code !== "ENOENT") {
640
+ emit({
641
+ type: "error",
642
+ path: key,
643
+ message: `tombstone unlink failed: ${
644
+ err instanceof Error ? err.message : String(err)
645
+ }`,
646
+ });
647
+ // Skip removeEntry / filesTombstoned / progress event — the
648
+ // tombstone hasn't actually been honored. Next sync retries.
649
+ continue;
650
+ }
651
+ }
652
+ removeEntry(journal, key);
653
+ filesTombstoned++;
654
+ emit({
655
+ type: "progress",
656
+ path: key,
657
+ bytes: 0,
658
+ deleted: true,
659
+ // Suffix differentiates a tombstone from a normal delete in the
660
+ // tty stream — matches the push-side `defaultConsoleLogger`
661
+ // tombstone surface in share.ts.
662
+ message: removedSomething ? "tombstone (cross-machine delete)" : "tombstone (already absent locally)",
663
+ });
664
+ }
665
+
529
666
  // Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
530
667
  // ticks even when nothing transferred. updateEntry only fires on actual
531
668
  // downloads; without this, a no-op sync leaves lastSync at the time of the
@@ -542,6 +679,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
542
679
  aborted: false,
543
680
  newFiles: plan.newFiles,
544
681
  newFilesCount: plan.newFilesCount,
682
+ filesExcludedByPolicy: plan.filesExcludedByPolicy,
683
+ filesTombstoned,
545
684
  };
546
685
  }
547
686
 
@@ -590,6 +729,10 @@ type PullPlanItem =
590
729
  | { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
591
730
  | { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
592
731
  | { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
732
+ // Remote keys refused by ephemeral-mirror policy. The push walker has
733
+ // refused to upload these since 5.33.0; the pull walker now refuses to
734
+ // download them so legacy litter in cloud staging drains naturally.
735
+ | { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string }
593
736
  | {
594
737
  action: "conflict";
595
738
  remoteFile: RemoteFile;
@@ -622,6 +765,15 @@ interface PullPlan {
622
765
  /** Files classified as new (no local counterpart at classification time). */
623
766
  newFiles: Array<{ path: string; bytes: number }>;
624
767
  newFilesCount: number;
768
+ /** Count of remote keys refused by ephemeral-mirror policy. */
769
+ filesExcludedByPolicy: number;
770
+ /**
771
+ * Journal-known keys missing from the remote LIST. The executor will
772
+ * apply each as a local delete (file or symlink) + journal removal,
773
+ * propagating the peer's push-side delete cross-machine (Bug #9).
774
+ * Carried on the plan so the executor can iterate without re-walking.
775
+ */
776
+ tombstones: string[];
625
777
  }
626
778
 
627
779
  /**
@@ -649,6 +801,17 @@ function computePullPlan(
649
801
  for (const remoteFile of remoteFiles) {
650
802
  const localPath = path.join(companyRoot, remoteFile.key);
651
803
 
804
+ // Ephemeral-mirror filter — symmetric with the push-side walker. Bug #2
805
+ // in the 5.33.0 deep-test: the push side has refused to upload conflict
806
+ // mirrors since 5.33.0, but the pull side downloaded them freely from
807
+ // staging, so legacy `.conflict-*` litter rode every sync into clean
808
+ // trees. Refuse them here; the V2 verification test (direct S3 inject
809
+ // bypassing the push filter entirely) is the regression contract.
810
+ if (isEphemeralPath(remoteFile.key)) {
811
+ items.push({ action: "skip-excluded-policy", remoteFile, localPath });
812
+ continue;
813
+ }
814
+
652
815
  if (personalMode && remoteFile.key.startsWith("companies/")) {
653
816
  // Default: drop every `companies/...` key — the legacy contract
654
817
  // is that the personal bucket should never contain them.
@@ -705,21 +868,47 @@ function computePullPlan(
705
868
  // dangling link on every run.
706
869
  // 3. A regular file: indistinguishable from current behaviour.
707
870
  let localLstat: fs.Stats | null = null;
871
+ let localPathBlockedByFileAncestor = false;
708
872
  try {
709
873
  localLstat = fs.lstatSync(localPath);
710
874
  } catch (err: unknown) {
711
- // ENOENT means truly absent; other errors propagate.
712
- if (
713
- err &&
714
- typeof err === "object" &&
715
- "code" in err &&
716
- (err as { code?: string }).code !== "ENOENT"
717
- ) {
875
+ const code =
876
+ err && typeof err === "object" && "code" in err
877
+ ? (err as { code?: string }).code
878
+ : undefined;
879
+ // ENOENT → truly absent → treated as "new file from cloud".
880
+ // ENOTDIR an ancestor of localPath is a regular file (cloud has a
881
+ // dir at that key, local has a file). Pre-fix this threw
882
+ // and aborted the whole company sync (Bug #10 in the
883
+ // 5.33.0 deep-test verification — \`v4-dir-vs-file\` repro
884
+ // wedged the personal company at \"errored\" status,
885
+ // skipping every later file). Recover by classifying as
886
+ // a structural collision the operator must resolve
887
+ // manually, and let the rest of the company process.
888
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
718
889
  throw err;
719
890
  }
891
+ if (code === "ENOTDIR") {
892
+ localPathBlockedByFileAncestor = true;
893
+ }
720
894
  }
721
895
  const localExists = localLstat !== null;
722
896
 
897
+ if (localPathBlockedByFileAncestor) {
898
+ // Symmetric counterpart to the (local-dir, cloud-file) warning below.
899
+ // Emit the same "manual reconciliation required" surface so the
900
+ // operator sees one consistent message for both topologies; record as
901
+ // skip-local-only so the file is not silently dropped from the
902
+ // count and the executor never tries to lstat/write it.
903
+ console.error(
904
+ ` Warning: an ancestor of ${remoteFile.key} exists locally as a regular file; ` +
905
+ `cloud has a deeper key under that path. Skipping; manual ` +
906
+ `reconciliation required (rm the conflicting local file to pull).`,
907
+ );
908
+ items.push({ action: "skip-local-only", remoteFile, localPath });
909
+ continue;
910
+ }
911
+
723
912
  if (localExists) {
724
913
  const isLocalSymlink = localLstat!.isSymbolicLink();
725
914
  // Kind-mismatch guard: a remote LIST entry is always a single
@@ -788,6 +977,7 @@ function computePullPlan(
788
977
  let bytesToDownload = 0;
789
978
  let filesToSkip = 0;
790
979
  let filesToConflict = 0;
980
+ let filesExcludedByPolicy = 0;
791
981
  const newFiles: Array<{ path: string; bytes: number }> = [];
792
982
  for (const item of items) {
793
983
  if (item.action === "download") {
@@ -798,11 +988,106 @@ function computePullPlan(
798
988
  }
799
989
  } else if (item.action === "conflict") {
800
990
  filesToConflict++;
991
+ } else if (item.action === "skip-excluded-policy") {
992
+ filesExcludedByPolicy++;
993
+ // Excluded-policy items don't roll into filesToSkip — they're a
994
+ // distinct class surfaced via filesExcludedByPolicy so consumers
995
+ // can render a "N refused by policy" line independently of the
996
+ // generic "N unchanged" tally.
801
997
  } else {
802
998
  filesToSkip++;
803
999
  }
804
1000
  }
805
1001
 
1002
+ // Bug #9 — cross-machine delete propagation. The peer's push leg
1003
+ // already removed the file from S3 (verified in the deep-test
1004
+ // addendum: `aws s3 ls` showed the keys absent post-push). Pre-fix
1005
+ // the pull side enumerated remote keys and downloaded anything
1006
+ // missing locally, but never enumerated "what's missing-from-remote-
1007
+ // that-was-there-before" — so the file stayed forever on every
1008
+ // receiver and `filesTombstoned: 0` showed up on every pull.
1009
+ //
1010
+ // Closing the loop: walk the journal, find every entry whose key is
1011
+ // NOT in the remote LIST set, AND whose path passes the current
1012
+ // ignore filter (paths newly excluded by .hqignore must not trigger
1013
+ // mass-delete), AND — in personalMode — survives the same companies/*
1014
+ // gating the download branch applies. The executor will apply each
1015
+ // as a local delete + journal removal. Symmetric to the push side's
1016
+ // `propagateDeletes` plan in share.ts.
1017
+ const remoteKeySet = new Set<string>();
1018
+ for (const rf of remoteFiles) remoteKeySet.add(rf.key);
1019
+ const tombstones: string[] = [];
1020
+ for (const key of Object.keys(journal.files)) {
1021
+ if (remoteKeySet.has(key)) continue;
1022
+ // PersonalMode key gating — mirror the download branch.
1023
+ if (personalMode && key.startsWith("companies/")) {
1024
+ const slug = key.split("/")[1] ?? "";
1025
+ const isTeamSyncedOrphan =
1026
+ teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
1027
+ if (!includeLocalCompanies || isTeamSyncedOrphan) continue;
1028
+ }
1029
+ // Ephemeral keys are filtered both directions; never tombstone-
1030
+ // propagate a conflict-mirror.
1031
+ if (isEphemeralPath(key)) continue;
1032
+ // Honor the current ignore filter — if a path was previously synced
1033
+ // but is now ignored (operator edited .hqignore), do NOT delete
1034
+ // the local copy. They're keeping it deliberately.
1035
+ const localPath = path.join(companyRoot, key);
1036
+ if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
1037
+ // Codex P1 (PR #24 round 3): detect local edits before tombstoning.
1038
+ // Delete-vs-local-edit race: peer deleted the file remotely while
1039
+ // this machine edited it locally before the next sync. Without
1040
+ // this check, the tombstone executor would unlink the locally-
1041
+ // edited file and drop the journal entry, silently destroying the
1042
+ // user's unsynced work. Compare local hash against the journal
1043
+ // baseline; if they diverge, defer the tombstone. The next pull
1044
+ // run after the operator resolves (re-pushes / overwrites) the
1045
+ // local state will re-evaluate.
1046
+ try {
1047
+ const lstat = fs.lstatSync(localPath);
1048
+ if (lstat.isFile()) {
1049
+ const localHash = hashFile(localPath);
1050
+ const journalEntry = journal.files[key];
1051
+ if (journalEntry?.hash && journalEntry.hash !== localHash) {
1052
+ // Local has unsynced edits — defer this tombstone.
1053
+ continue;
1054
+ }
1055
+ } else if (lstat.isSymbolicLink()) {
1056
+ // Codex P1 (PR #24 round 4): symlinks have target strings
1057
+ // that can be locally edited too. Pre-fix, `isFile()` was
1058
+ // false for symlinks so the divergence guard skipped them,
1059
+ // and a locally-edited symlink (`ln -sfn new-target old-link`
1060
+ // before the peer's remote-delete) was unlinked silently —
1061
+ // the exact race the guard is meant to prevent. Compare the
1062
+ // readlink hash against the journal baseline; defer on
1063
+ // divergence. readlink errors (race / EACCES) also defer.
1064
+ try {
1065
+ const localHash = hashSymlinkTarget(fs.readlinkSync(localPath));
1066
+ const journalEntry = journal.files[key];
1067
+ if (journalEntry?.hash && journalEntry.hash !== localHash) {
1068
+ continue;
1069
+ }
1070
+ } catch {
1071
+ continue;
1072
+ }
1073
+ }
1074
+ // Directories: no hash comparison. The tombstone executor's
1075
+ // dir branch doesn't recursively rm-rf — it just drops the
1076
+ // journal entry, which is the safe-by-default behavior.
1077
+ } catch (err) {
1078
+ const code =
1079
+ err && typeof err === "object" && "code" in err
1080
+ ? (err as { code?: string }).code
1081
+ : undefined;
1082
+ // ENOENT → local already gone; safe to drop journal entry via
1083
+ // the executor's tombstone path.
1084
+ // Other lstat errors (EACCES on parent dir, etc.) → defer:
1085
+ // we can't read local state, so we can't safely decide.
1086
+ if (code !== "ENOENT") continue;
1087
+ }
1088
+ tombstones.push(key);
1089
+ }
1090
+
806
1091
  return {
807
1092
  items,
808
1093
  filesToDownload,
@@ -811,6 +1096,8 @@ function computePullPlan(
811
1096
  filesToConflict,
812
1097
  newFiles,
813
1098
  newFilesCount: newFiles.length,
1099
+ filesExcludedByPolicy,
1100
+ tombstones,
814
1101
  };
815
1102
  }
816
1103
 
@@ -83,7 +83,7 @@ describe("createIgnoreFilter", () => {
83
83
  expect(shouldSync(path.join(hqRoot, ".claude/settings.json"))).toBe(true);
84
84
  });
85
85
 
86
- it("permissive mode: .hq-* internal state is ignored, .hqignore family + .hq/ still sync", () => {
86
+ it("permissive mode: .hq-* internal state is ignored, .hqignore family still sync", () => {
87
87
  const shouldSync = createIgnoreFilter(hqRoot);
88
88
  // Internal state files that must never round-trip through the bucket.
89
89
  expect(shouldSync(path.join(hqRoot, ".hq-sync.pid"))).toBe(false);
@@ -92,11 +92,28 @@ describe("createIgnoreFilter", () => {
92
92
  expect(shouldSync(path.join(hqRoot, ".hq-embeddings-pending.json"))).toBe(false);
93
93
  expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq-foo.json"))).toBe(false);
94
94
  expect(shouldSync(path.join(hqRoot, ".hq-cache/blob.bin"))).toBe(false);
95
- // Sync-config files and the .hq/ directory still sync.
95
+ // Sync-config files still sync (no hyphen, doesn't match `.hq-*`).
96
96
  expect(shouldSync(path.join(hqRoot, ".hqignore"))).toBe(true);
97
97
  expect(shouldSync(path.join(hqRoot, ".hqsyncignore"))).toBe(true);
98
98
  expect(shouldSync(path.join(hqRoot, ".hqinclude"))).toBe(true);
99
- expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq/config.json"))).toBe(true);
99
+ });
100
+
101
+ it("permissive mode: .hq/ directory is per-host state, never synced (Bug #1/#6/#8)", () => {
102
+ // Bug catalog: `.hq/install-manifest.json`, `.hq/machine-id`, and
103
+ // `.hq/machine.json` are per-host source-of-truth files. The original
104
+ // `DEFAULT_IGNORES` only covered the `.hq-*` wildcard (with hyphen) and
105
+ // explicitly left the `.hq/` directory unaffected — so the install
106
+ // manifest and machine-id round-tripped through S3 between hosts,
107
+ // breaking the per-machine identity contract that the 5.33.0
108
+ // machine-id fix relies on.
109
+ const shouldSync = createIgnoreFilter(hqRoot);
110
+ expect(shouldSync(path.join(hqRoot, ".hq/install-manifest.json"))).toBe(false);
111
+ expect(shouldSync(path.join(hqRoot, ".hq/machine-id"))).toBe(false);
112
+ expect(shouldSync(path.join(hqRoot, ".hq/machine.json"))).toBe(false);
113
+ expect(shouldSync(path.join(hqRoot, ".hq/config.json"))).toBe(false);
114
+ expect(shouldSync(path.join(hqRoot, ".hq"), true)).toBe(false);
115
+ // Nested .hq/ inside a company also stays local (per-host scoping).
116
+ expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq/config.json"))).toBe(false);
100
117
  });
101
118
 
102
119
  it("allowlist mode: presence of .hqinclude switches to opt-in", () => {
package/src/ignore.ts CHANGED
@@ -71,9 +71,15 @@ export const DEFAULT_IGNORES = [
71
71
  // covers `.hq-sync.pid`, `.hq-sync-journal.json`, `.hq-sync-state.json`,
72
72
  // `.hq-embeddings-pending.json`, and any future internal-state file. The
73
73
  // `.hqignore` / `.hqsyncignore` / `.hqinclude` config files don't match
74
- // (no hyphen) and the `.hq/` directory is unaffected.
74
+ // (no hyphen). The `.hq/` directory holds per-host source-of-truth files
75
+ // (`install-manifest.json`, `machine-id`, `machine.json`, `config.json`)
76
+ // that MUST NEVER round-trip through the bucket — see Bug #1/#6/#8 in
77
+ // the 5.33.0 deep-test report. Leaking these between machines makes two
78
+ // hosts share an installer id / machine-id and breaks the contract the
79
+ // 5.33.0 machine-id fix relies on.
75
80
  "*.pid",
76
81
  ".hq-*",
82
+ ".hq/",
77
83
  "modules.lock",
78
84
  // hq-root identity marker — discovered locally per-machine, never synced.
79
85
  // Root-anchored: only the literal `core.yaml` at hq-root matches. Without
@@ -7,38 +7,18 @@
7
7
  * surface their own conflicts without name collisions, and lets the user
8
8
  * (or the `/resolve-conflicts` HQ skill) see local + cloud side-by-side
9
9
  * in their file browser.
10
+ *
11
+ * Machine-id provisioning lives in `./machine-id.ts` — hq-cloud owns the
12
+ * source-of-truth file `<hqRoot>/.hq/machine-id` so every sync host
13
+ * (including Linux outposts with no menubar app) gets a stable id. This
14
+ * module re-exports `readShortMachineId` for back-compat with existing
15
+ * callers; new callers should import directly from `./machine-id.js`.
10
16
  */
11
17
 
12
18
  import * as fs from "fs";
13
- import * as os from "os";
14
19
  import * as path from "path";
15
20
 
16
- /**
17
- * Path to `~/.hq/menubar.json`. Evaluated lazily at call time (not module
18
- * load) so that tests overriding `HOME` after import — and any future code
19
- * that changes the user's effective home dir at runtime — see the right
20
- * file. Going through `os.homedir()` rather than `process.env.HOME` keeps
21
- * the Windows USERPROFILE fallback intact.
22
- */
23
- function menubarJsonPath(): string {
24
- return path.join(os.homedir(), ".hq", "menubar.json");
25
- }
26
-
27
- /**
28
- * Read the short machine ID (first 6 chars) from `~/.hq/menubar.json`.
29
- * Falls back to "unknown" if the file is missing/unreadable — conflict
30
- * files should still be written even when machine identity is unclear.
31
- */
32
- export function readShortMachineId(): string {
33
- try {
34
- const raw = fs.readFileSync(menubarJsonPath(), "utf-8");
35
- const parsed = JSON.parse(raw);
36
- const id = typeof parsed.machineId === "string" ? parsed.machineId : "";
37
- return id.slice(0, 6) || "unknown";
38
- } catch {
39
- return "unknown";
40
- }
41
- }
21
+ export { readShortMachineId, getOrCreateMachineId } from "./machine-id.js";
42
22
 
43
23
  /**
44
24
  * Build the conflict file path for an original. ISO uses `-` instead of
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Tests for the pure conflict primitives — path building, machine-id
3
- * fallback, atomic index writes, dedup. Kept in one file so the related
4
- * helpers stay co-located.
2
+ * Tests for the pure conflict primitives — path building, atomic index
3
+ * writes, dedup. The machine-id resolver moved to `./machine-id.ts` (see
4
+ * `./machine-id.test.ts`) when hq-cloud took ownership of the provisioning
5
+ * step from the menubar app.
5
6
  */
6
7
 
7
8
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
@@ -11,7 +12,6 @@ import * as path from "path";
11
12
  import {
12
13
  buildConflictPath,
13
14
  buildConflictId,
14
- readShortMachineId,
15
15
  } from "./conflict-file.js";
16
16
  import {
17
17
  appendConflictEntry,
@@ -56,42 +56,6 @@ describe("buildConflictId", () => {
56
56
  });
57
57
  });
58
58
 
59
- describe("readShortMachineId", () => {
60
- let originalHome: string | undefined;
61
- let tmpHome: string;
62
-
63
- beforeEach(() => {
64
- originalHome = process.env.HOME;
65
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-machineid-"));
66
- process.env.HOME = tmpHome;
67
- });
68
-
69
- afterEach(() => {
70
- if (originalHome) process.env.HOME = originalHome;
71
- else delete process.env.HOME;
72
- fs.rmSync(tmpHome, { recursive: true, force: true });
73
- });
74
-
75
- it("returns the first 6 chars when menubar.json has a machineId", () => {
76
- fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
77
- fs.writeFileSync(
78
- path.join(tmpHome, ".hq", "menubar.json"),
79
- JSON.stringify({ machineId: "deadbeefcafe1234567890" }),
80
- );
81
- expect(readShortMachineId()).toBe("deadbe");
82
- });
83
-
84
- it("falls back to 'unknown' when menubar.json is missing", () => {
85
- expect(readShortMachineId()).toBe("unknown");
86
- });
87
-
88
- it("falls back to 'unknown' when menubar.json is malformed", () => {
89
- fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
90
- fs.writeFileSync(path.join(tmpHome, ".hq", "menubar.json"), "{not-json");
91
- expect(readShortMachineId()).toBe("unknown");
92
- });
93
- });
94
-
95
59
  describe("conflict index", () => {
96
60
  let tmpHq: string;
97
61