@indigoai-us/hq-cloud 6.14.50 → 6.15.1

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 (340) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +64 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/reindex-knowledge.d.ts +7 -0
  34. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  35. package/dist/cli/reindex-knowledge.js +389 -0
  36. package/dist/cli/reindex-knowledge.js.map +1 -0
  37. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  38. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  39. package/dist/cli/reindex-knowledge.test.js +260 -0
  40. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  41. package/dist/cli/reindex.d.ts.map +1 -1
  42. package/dist/cli/reindex.js +8 -0
  43. package/dist/cli/reindex.js.map +1 -1
  44. package/dist/cli/rescue-settings-reconcile.test.js +47 -0
  45. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -1
  46. package/dist/cli/sync.d.ts +27 -0
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +52 -0
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +31 -1
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/skill-telemetry.d.ts +6 -0
  57. package/dist/skill-telemetry.d.ts.map +1 -1
  58. package/dist/skill-telemetry.js +14 -2
  59. package/dist/skill-telemetry.js.map +1 -1
  60. package/dist/skill-telemetry.test.js +79 -0
  61. package/dist/skill-telemetry.test.js.map +1 -1
  62. package/dist/sync/candidate-uploader.d.ts +88 -0
  63. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  64. package/dist/sync/candidate-uploader.js +212 -0
  65. package/dist/sync/candidate-uploader.js.map +1 -0
  66. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  67. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  68. package/dist/sync/candidate-uploader.test.js +132 -0
  69. package/dist/sync/candidate-uploader.test.js.map +1 -0
  70. package/dist/sync/delta-client.d.ts +73 -0
  71. package/dist/sync/delta-client.d.ts.map +1 -0
  72. package/dist/sync/delta-client.js +201 -0
  73. package/dist/sync/delta-client.js.map +1 -0
  74. package/dist/sync/delta-client.test.d.ts +2 -0
  75. package/dist/sync/delta-client.test.d.ts.map +1 -0
  76. package/dist/sync/delta-client.test.js +97 -0
  77. package/dist/sync/delta-client.test.js.map +1 -0
  78. package/dist/sync/durable-apply.d.ts +76 -0
  79. package/dist/sync/durable-apply.d.ts.map +1 -0
  80. package/dist/sync/durable-apply.js +530 -0
  81. package/dist/sync/durable-apply.js.map +1 -0
  82. package/dist/sync/durable-apply.test.d.ts +2 -0
  83. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  84. package/dist/sync/durable-apply.test.js +180 -0
  85. package/dist/sync/durable-apply.test.js.map +1 -0
  86. package/dist/sync/event-sync.d.ts +33 -1
  87. package/dist/sync/event-sync.d.ts.map +1 -1
  88. package/dist/sync/event-sync.js +149 -1
  89. package/dist/sync/event-sync.js.map +1 -1
  90. package/dist/sync/event-sync.test.js +142 -1
  91. package/dist/sync/event-sync.test.js.map +1 -1
  92. package/dist/sync/index.d.ts +2 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/multipart-uploader.d.ts +99 -0
  97. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  98. package/dist/sync/multipart-uploader.js +447 -0
  99. package/dist/sync/multipart-uploader.js.map +1 -0
  100. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  101. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  102. package/dist/sync/multipart-uploader.test.js +119 -0
  103. package/dist/sync/multipart-uploader.test.js.map +1 -0
  104. package/dist/sync/mutation-client.d.ts +82 -0
  105. package/dist/sync/mutation-client.d.ts.map +1 -0
  106. package/dist/sync/mutation-client.js +221 -0
  107. package/dist/sync/mutation-client.js.map +1 -0
  108. package/dist/sync/mutation-client.test.d.ts +2 -0
  109. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  110. package/dist/sync/mutation-client.test.js +51 -0
  111. package/dist/sync/mutation-client.test.js.map +1 -0
  112. package/dist/sync/push-receiver.d.ts +45 -0
  113. package/dist/sync/push-receiver.d.ts.map +1 -1
  114. package/dist/sync/push-receiver.js +101 -0
  115. package/dist/sync/push-receiver.js.map +1 -1
  116. package/dist/sync/push-receiver.test.js +54 -2
  117. package/dist/sync/push-receiver.test.js.map +1 -1
  118. package/dist/sync/scope-inventory-client.d.ts +69 -0
  119. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  120. package/dist/sync/scope-inventory-client.js +210 -0
  121. package/dist/sync/scope-inventory-client.js.map +1 -0
  122. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  123. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  124. package/dist/sync/scope-inventory-client.test.js +94 -0
  125. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  126. package/dist/sync/snapshot-client.d.ts +98 -0
  127. package/dist/sync/snapshot-client.d.ts.map +1 -0
  128. package/dist/sync/snapshot-client.js +402 -0
  129. package/dist/sync/snapshot-client.js.map +1 -0
  130. package/dist/sync/snapshot-client.test.d.ts +2 -0
  131. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  132. package/dist/sync/snapshot-client.test.js +169 -0
  133. package/dist/sync/snapshot-client.test.js.map +1 -0
  134. package/dist/sync/uploader-finalization.d.ts +97 -0
  135. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  136. package/dist/sync/uploader-finalization.js +273 -0
  137. package/dist/sync/uploader-finalization.js.map +1 -0
  138. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  139. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  140. package/dist/sync/uploader-finalization.test.js +92 -0
  141. package/dist/sync/uploader-finalization.test.js.map +1 -0
  142. package/dist/telemetry.d.ts +11 -1
  143. package/dist/telemetry.d.ts.map +1 -1
  144. package/dist/telemetry.js +21 -2
  145. package/dist/telemetry.js.map +1 -1
  146. package/dist/telemetry.test.js +80 -0
  147. package/dist/telemetry.test.js.map +1 -1
  148. package/package.json +6 -1
  149. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  150. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  151. package/.github/workflows/ci.yml +0 -84
  152. package/.github/workflows/publish.yml +0 -56
  153. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  154. package/eslint.config.js +0 -67
  155. package/pnpm-workspace.yaml +0 -2
  156. package/scripts/presign-transport-e2e.mjs +0 -250
  157. package/scripts/vault-rebaseline.sh +0 -323
  158. package/scripts/vault-rescue.sh +0 -332
  159. package/src/active-company.test.ts +0 -188
  160. package/src/active-company.ts +0 -168
  161. package/src/agent-codex-instructions.test.ts +0 -332
  162. package/src/agent-codex-instructions.ts +0 -309
  163. package/src/auth.ts +0 -146
  164. package/src/backup-prune.test.ts +0 -98
  165. package/src/backup-prune.ts +0 -182
  166. package/src/bin/backup-prune-runner.ts +0 -33
  167. package/src/bin/rescue-runner.ts +0 -25
  168. package/src/bin/sync-runner-company.ts +0 -695
  169. package/src/bin/sync-runner-events.test.ts +0 -143
  170. package/src/bin/sync-runner-events.ts +0 -55
  171. package/src/bin/sync-runner-planning.test.ts +0 -311
  172. package/src/bin/sync-runner-planning.ts +0 -258
  173. package/src/bin/sync-runner-rollup.test.ts +0 -37
  174. package/src/bin/sync-runner-rollup.ts +0 -97
  175. package/src/bin/sync-runner-telemetry.ts +0 -15
  176. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  177. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  178. package/src/bin/sync-runner-watch-routes.ts +0 -184
  179. package/src/bin/sync-runner.test.ts +0 -8767
  180. package/src/bin/sync-runner.ts +0 -2190
  181. package/src/cli/accept.ts +0 -124
  182. package/src/cli/conflict.ts +0 -119
  183. package/src/cli/doctor.test.ts +0 -581
  184. package/src/cli/doctor.ts +0 -642
  185. package/src/cli/index.ts +0 -49
  186. package/src/cli/invite.test.ts +0 -250
  187. package/src/cli/invite.ts +0 -214
  188. package/src/cli/promote.ts +0 -157
  189. package/src/cli/reindex.test.ts +0 -957
  190. package/src/cli/reindex.ts +0 -970
  191. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  192. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  193. package/src/cli/rescue-core.ts +0 -3011
  194. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  195. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  196. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  197. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  198. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  199. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  200. package/src/cli/rescue-settings-reconcile.test.ts +0 -583
  201. package/src/cli/rescue-snapshot.test.ts +0 -57
  202. package/src/cli/rescue-snapshot.ts +0 -51
  203. package/src/cli/rescue.reindex.test.ts +0 -63
  204. package/src/cli/rescue.test.ts +0 -131
  205. package/src/cli/rescue.ts +0 -182
  206. package/src/cli/share.test.ts +0 -7843
  207. package/src/cli/share.ts +0 -3663
  208. package/src/cli/sync-scope.test.ts +0 -652
  209. package/src/cli/sync.test.ts +0 -5207
  210. package/src/cli/sync.ts +0 -3470
  211. package/src/cli/tombstones.ts +0 -106
  212. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  213. package/src/client-info.test.ts +0 -214
  214. package/src/client-info.ts +0 -121
  215. package/src/cognito-auth.test.ts +0 -712
  216. package/src/cognito-auth.ts +0 -1422
  217. package/src/company-resolver.test.ts +0 -618
  218. package/src/company-resolver.ts +0 -521
  219. package/src/context.test.ts +0 -583
  220. package/src/context.ts +0 -378
  221. package/src/daemon-worker.ts +0 -26
  222. package/src/daemon.ts +0 -99
  223. package/src/entity-resolver.test.ts +0 -315
  224. package/src/entity-resolver.ts +0 -180
  225. package/src/ignore.test.ts +0 -466
  226. package/src/ignore.ts +0 -469
  227. package/src/index.ts +0 -439
  228. package/src/journal.test.ts +0 -968
  229. package/src/journal.ts +0 -765
  230. package/src/lib/cloud-authoritative.test.ts +0 -45
  231. package/src/lib/cloud-authoritative.ts +0 -59
  232. package/src/lib/conflict-file.ts +0 -86
  233. package/src/lib/conflict-index.ts +0 -289
  234. package/src/lib/conflict.test.ts +0 -348
  235. package/src/lib/describe-error.test.ts +0 -100
  236. package/src/lib/describe-error.ts +0 -58
  237. package/src/lib/exit-codes.ts +0 -24
  238. package/src/lib/machine-id.test.ts +0 -231
  239. package/src/lib/machine-id.ts +0 -175
  240. package/src/lib/net-errors.test.ts +0 -65
  241. package/src/lib/net-errors.ts +0 -86
  242. package/src/lib/readlink-safe.test.ts +0 -43
  243. package/src/lib/readlink-safe.ts +0 -29
  244. package/src/local-path-codec.test.ts +0 -138
  245. package/src/local-path-codec.ts +0 -161
  246. package/src/machine-auth.test.ts +0 -1323
  247. package/src/manifest-reconcile.test.ts +0 -1123
  248. package/src/manifest-reconcile.ts +0 -518
  249. package/src/object-io.test.ts +0 -1221
  250. package/src/object-io.ts +0 -1306
  251. package/src/operation-lock.test.ts +0 -484
  252. package/src/operation-lock.ts +0 -680
  253. package/src/outcome-telemetry.test.ts +0 -498
  254. package/src/outcome-telemetry.ts +0 -639
  255. package/src/personal-vault-exclusions.test.ts +0 -308
  256. package/src/personal-vault-exclusions.ts +0 -354
  257. package/src/personal-vault.test.ts +0 -756
  258. package/src/personal-vault.ts +0 -496
  259. package/src/prefix-coalesce.test.ts +0 -240
  260. package/src/prefix-coalesce.ts +0 -273
  261. package/src/public-surface.test.ts +0 -117
  262. package/src/qmd-reindex.test.ts +0 -877
  263. package/src/qmd-reindex.ts +0 -842
  264. package/src/read-only-state-dir.test.ts +0 -188
  265. package/src/remote-pull.test.ts +0 -1130
  266. package/src/remote-pull.ts +0 -618
  267. package/src/s3.symlink-materialize.test.ts +0 -492
  268. package/src/s3.test.ts +0 -1789
  269. package/src/s3.ts +0 -1532
  270. package/src/schemas/signal-types.test.ts +0 -82
  271. package/src/schemas/signal-types.ts +0 -38
  272. package/src/schemas/source-channels.test.ts +0 -82
  273. package/src/schemas/source-channels.ts +0 -53
  274. package/src/scope-shrink.test.ts +0 -633
  275. package/src/scope-shrink.ts +0 -481
  276. package/src/signals/get.test.ts +0 -310
  277. package/src/signals/get.ts +0 -75
  278. package/src/signals/internals.ts +0 -195
  279. package/src/signals/list.test.ts +0 -420
  280. package/src/signals/list.ts +0 -79
  281. package/src/signals/parse.ts +0 -8
  282. package/src/signals/types.ts +0 -91
  283. package/src/skill-telemetry.test.ts +0 -1825
  284. package/src/skill-telemetry.ts +0 -1439
  285. package/src/sources/get.test.ts +0 -293
  286. package/src/sources/get.ts +0 -66
  287. package/src/sources/internals.ts +0 -198
  288. package/src/sources/list.test.ts +0 -402
  289. package/src/sources/list.ts +0 -84
  290. package/src/sources/parse.ts +0 -43
  291. package/src/sources/types.ts +0 -84
  292. package/src/sync/event-sync.test.ts +0 -594
  293. package/src/sync/event-sync.ts +0 -545
  294. package/src/sync/feature-flags.test.ts +0 -378
  295. package/src/sync/feature-flags.ts +0 -62
  296. package/src/sync/index.ts +0 -76
  297. package/src/sync/lease-client.test.ts +0 -128
  298. package/src/sync/lease-client.ts +0 -207
  299. package/src/sync/logger.test.ts +0 -242
  300. package/src/sync/logger.ts +0 -79
  301. package/src/sync/metrics.test.ts +0 -462
  302. package/src/sync/metrics.ts +0 -213
  303. package/src/sync/pull-scope.ts +0 -265
  304. package/src/sync/push-event.test.ts +0 -266
  305. package/src/sync/push-event.ts +0 -224
  306. package/src/sync/push-receiver.test.ts +0 -566
  307. package/src/sync/push-receiver.ts +0 -1048
  308. package/src/sync/push-transport.ts +0 -231
  309. package/src/sync/realtime-rollout.test.ts +0 -86
  310. package/src/sync/realtime-rollout.ts +0 -262
  311. package/src/sync/state-store.test.ts +0 -194
  312. package/src/sync/state-store.ts +0 -727
  313. package/src/sync-core.ts +0 -58
  314. package/src/sync-progress.test.ts +0 -94
  315. package/src/sync-progress.ts +0 -140
  316. package/src/telemetry-events.test.ts +0 -88
  317. package/src/telemetry-events.ts +0 -205
  318. package/src/telemetry.test.ts +0 -1280
  319. package/src/telemetry.ts +0 -1109
  320. package/src/types.ts +0 -314
  321. package/src/vault-client.test.ts +0 -1380
  322. package/src/vault-client.ts +0 -1694
  323. package/src/version.ts +0 -24
  324. package/src/watch-roots.test.ts +0 -278
  325. package/src/watch-roots.ts +0 -162
  326. package/src/watcher-event-gate.test.ts +0 -212
  327. package/src/watcher.test.ts +0 -1079
  328. package/src/watcher.ts +0 -1741
  329. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  330. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  331. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  332. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  333. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  334. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  335. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  336. package/test/invite-flow.integration.test.ts +0 -244
  337. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  338. package/test/share-sync.integration.test.ts +0 -213
  339. package/tsconfig.json +0 -19
  340. package/vitest.config.ts +0 -22
@@ -1,106 +0,0 @@
1
- /**
2
- * Shared FILE_TOMBSTONE fetch used by BOTH the pull planner (`sync.ts`) and the
3
- * push planner (`share.ts`).
4
- *
5
- * A FILE_TOMBSTONE is the authoritative record of an intentional, scoped delete
6
- * (`hq files delete <prefix>` → `POST /v1/files/delete` → `recordDeletions`).
7
- * Both sync legs consult it so a deleted key is neither re-downloaded (pull) nor
8
- * re-uploaded (push) and therefore cannot be resurrected by a behind peer.
9
- *
10
- * Lives in its own module so `share.ts` can use the fetch without importing from
11
- * `sync.ts` — `sync.ts` already imports values from `share.ts`
12
- * (`isEphemeralPath`, `isMalformedVaultKey`), so the reverse value-import would
13
- * create a runtime ESM cycle.
14
- */
15
-
16
- import type { VaultServiceConfig } from "../types.js";
17
- import { toPosixKey } from "../s3.js";
18
-
19
- /** Timeout for the best-effort FILE_TOMBSTONE fetch (GET /v1/files/tombstones). */
20
- export const FETCH_TOMBSTONES_TIMEOUT_MS = 5000;
21
-
22
- /**
23
- * A FILE_TOMBSTONE as the planners need it: the deleted key + when it was
24
- * deleted. The `deletedAt` timestamp is the decisive precedence signal on the
25
- * pull side — a remote object newer than it is a genuine re-create (sync it), an
26
- * object at or older than it is a stale resurrection of a deleted key (suppress
27
- * it).
28
- */
29
- export interface CompanyTombstone {
30
- deletedAt: string;
31
- }
32
-
33
- /**
34
- * Fetch the company's FILE_TOMBSTONE rows from hq-pro (GET /v1/files/tombstones)
35
- * and return them as a POSIX-keyed map the planners consult to avoid
36
- * resurrecting an intentionally-deleted object (delete-resync). The endpoint is
37
- * ACL-filtered server-side, so the map only ever contains keys this caller can
38
- * read — exactly the keys that can appear in the (STS-scoped) remote LIST.
39
- *
40
- * Best-effort and bounded by a 5s timeout: a tombstone read that fails, times
41
- * out, or returns non-2xx degrades to an EMPTY map — i.e. to the pre-fix
42
- * behavior (no suppression). That is the safe failure direction: a missed
43
- * tombstone re-pulls/re-pushes a deleted file (a known, recoverable annoyance),
44
- * whereas a spurious tombstone would HIDE a file the user wants. The failure is
45
- * logged (never silently swallowed) so a persistently-degraded read is visible.
46
- */
47
- export async function fetchCompanyTombstones(
48
- vaultConfig: VaultServiceConfig,
49
- companyUid: string,
50
- ): Promise<Map<string, CompanyTombstone>> {
51
- const out = new Map<string, CompanyTombstone>();
52
- try {
53
- const token =
54
- typeof vaultConfig.authToken === "function"
55
- ? await vaultConfig.authToken()
56
- : vaultConfig.authToken;
57
- const base = vaultConfig.apiUrl.replace(/\/+$/, "");
58
- const url = `${base}/v1/files/tombstones?company=${encodeURIComponent(
59
- companyUid,
60
- )}`;
61
- const controller = new AbortController();
62
- const timer = setTimeout(
63
- () => controller.abort(),
64
- FETCH_TOMBSTONES_TIMEOUT_MS,
65
- );
66
- try {
67
- const res = await fetch(url, {
68
- method: "GET",
69
- headers: { Authorization: `Bearer ${token}` },
70
- signal: controller.signal,
71
- });
72
- if (!res.ok) {
73
- // Non-2xx is non-fatal: log and degrade to no-suppression. A 404 means
74
- // the endpoint is not deployed yet (hq-pro release lag) — the sync
75
- // proceeds with the legacy behavior until it lands.
76
- console.error(
77
- `[hq-sync] tombstone fetch returned ${res.status} (degrading to no-suppression)`,
78
- );
79
- return out;
80
- }
81
- const body = (await res.json()) as {
82
- tombstones?: Array<{ key?: string; deletedAt?: string }>;
83
- };
84
- for (const t of body.tombstones ?? []) {
85
- if (typeof t.key === "string" && typeof t.deletedAt === "string") {
86
- out.set(toPosixKey(t.key), { deletedAt: t.deletedAt });
87
- }
88
- }
89
- } finally {
90
- clearTimeout(timer);
91
- }
92
- } catch (err) {
93
- // Best-effort: a failed tombstone read must never break the sync. Log
94
- // (policy: never silently swallow) and degrade to no-suppression.
95
- try {
96
- console.error(
97
- `[hq-sync] tombstone fetch failed (non-fatal, degrading to no-suppression): ${
98
- err instanceof Error ? err.message : String(err)
99
- }`,
100
- );
101
- } catch {
102
- // swallow — logging must never break sync
103
- }
104
- }
105
- return out;
106
- }
@@ -1,234 +0,0 @@
1
- /**
2
- * Regression guard: the watch + event-push path must NOT mint phantom
3
- * `.conflict-*` mirrors for an ordinary single-writer local edit.
4
- *
5
- * Background (feedback_ef2b7c8c): a user editing files under
6
- * `companies/{slug}/` saw the menubar's watch/event-push runner mint a
7
- * `.conflict-<ts>-<machine>` mirror on EVERY ordinary local Edit/Write —
8
- * thousands of phantom files in a single session. The watch/event-push runner
9
- * reacts to a local change by running a targeted `--direction push` pass, i.e.
10
- * `share()` over the changed company subtree, so the conflict-minting logic is
11
- * share()'s push-side conflict gate. The original gate flagged a conflict on
12
- * `localChanged` alone (`journalEntry.hash !== localHash`), which fires for
13
- * every edit of any already-synced file. The gate now requires
14
- * `(localChanged && remoteChanged) || isFreshCollision`: a single writer never
15
- * has `remoteChanged` (the live S3 ETag still equals the journal's recorded
16
- * baseline), so an ordinary edit is uploaded, not mirrored.
17
- *
18
- * This test pins that contract end-to-end through `share()` using the same
19
- * mocked-S3 harness as `share.test.ts`, modelling a single-writer remote whose
20
- * ETag is always exactly what THIS device last uploaded. A positive control
21
- * (a genuine out-of-band peer write) proves the guard is not trivially
22
- * always-zero: real divergence is still detected.
23
- */
24
-
25
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
26
- import * as fs from "fs";
27
- import * as path from "path";
28
- import * as os from "os";
29
- import { clearContextCache } from "../context.js";
30
- import type { VaultServiceConfig } from "../types.js";
31
-
32
- vi.mock("../s3.js", () => ({
33
- toPosixKey: (key: string) => key.split("\\").join("/"),
34
- uploadFile: vi.fn(),
35
- uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
36
- downloadFile: vi.fn().mockResolvedValue(undefined),
37
- listRemoteFiles: vi.fn().mockResolvedValue([]),
38
- deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
39
- headRemoteFile: vi.fn(),
40
- primeObjectTransport: vi.fn().mockResolvedValue(undefined),
41
- primeUploads: vi.fn().mockResolvedValue(undefined),
42
- }));
43
-
44
- vi.mock("readline", () => ({
45
- createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
46
- }));
47
-
48
- import { share } from "./share.js";
49
- import { downloadFile, headRemoteFile, uploadFile } from "../s3.js";
50
-
51
- const mockConfig: VaultServiceConfig = {
52
- apiUrl: "https://vault-api.test",
53
- authToken: "test-jwt-token",
54
- region: "us-east-1",
55
- };
56
-
57
- const mockEntity = {
58
- uid: "cmp_01ABCDEF",
59
- slug: "acme",
60
- bucketName: "hq-vault-acme-123",
61
- status: "active",
62
- };
63
-
64
- function setupFetchMock() {
65
- vi.stubGlobal(
66
- "fetch",
67
- vi.fn().mockImplementation(async (url: string) => {
68
- const u = String(url);
69
- if (u.includes("/entity/check-slug/me"))
70
- return {
71
- ok: true,
72
- status: 200,
73
- json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
74
- text: async () => "",
75
- };
76
- if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u))
77
- return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
78
- if (u.includes("/sts/vend"))
79
- return {
80
- ok: true,
81
- status: 200,
82
- json: async () => ({
83
- credentials: {
84
- accessKeyId: "ASIA_TEST",
85
- secretAccessKey: "s",
86
- sessionToken: "t",
87
- expiration: new Date(Date.now() + 9e5).toISOString(),
88
- },
89
- expiresAt: new Date(Date.now() + 9e5).toISOString(),
90
- }),
91
- text: async () => "",
92
- };
93
- return { ok: false, status: 404, text: async () => "Not found" };
94
- }),
95
- );
96
- }
97
-
98
- /** Every `.conflict-<ts>-*` mirror file anywhere under `root`. */
99
- function countConflictMirrors(root: string): string[] {
100
- const out: string[] = [];
101
- const walk = (d: string) => {
102
- for (const e of fs.readdirSync(d, { withFileTypes: true })) {
103
- const p = path.join(d, e.name);
104
- if (e.isDirectory()) walk(p);
105
- else if (/\.conflict-/.test(e.name)) out.push(p);
106
- }
107
- };
108
- walk(root);
109
- return out;
110
- }
111
-
112
- /**
113
- * In-memory single-writer S3: the object's ETag is always exactly what THIS
114
- * device last uploaded (no peer ever writes). `lastModified` advances on each
115
- * PUT to model S3 stamping it server-side. The S3-module signatures are
116
- * `uploadFile(ctx, localPath, key)`, `headRemoteFile(ctx, key)`,
117
- * `downloadFile(ctx, key, dest)` — wire the mocks to match exactly.
118
- */
119
- function wireSingleWriterRemote() {
120
- const remote = new Map<string, { etag: string; lastModified: Date; size: number }>();
121
- let clock = Date.now();
122
- let seq = 0;
123
- vi.mocked(uploadFile).mockImplementation(async (_ctx: unknown, localPath: string, key: string) => {
124
- const etag = `"etag-${seq++}"`;
125
- clock += 1000;
126
- remote.set(key, { etag, lastModified: new Date(clock), size: fs.statSync(localPath).size });
127
- return { etag };
128
- });
129
- vi.mocked(headRemoteFile).mockImplementation(async (_ctx: unknown, key: string) => {
130
- const r = remote.get(key);
131
- return r ? { lastModified: r.lastModified, etag: r.etag, size: r.size } : null;
132
- });
133
- vi.mocked(downloadFile).mockImplementation(async (_ctx: unknown, _key: string, dest: string) => {
134
- fs.mkdirSync(path.dirname(dest), { recursive: true });
135
- fs.writeFileSync(dest, "remote-bytes\n");
136
- return {};
137
- });
138
- return remote;
139
- }
140
-
141
- describe("watch + event-push conflict regression (feedback_ef2b7c8c)", () => {
142
- let tmpDir: string;
143
- let stateDir: string;
144
-
145
- beforeEach(() => {
146
- clearContextCache();
147
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-conflict-"));
148
- stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-state-"));
149
- process.env.HQ_STATE_DIR = stateDir;
150
- setupFetchMock();
151
- });
152
-
153
- afterEach(() => {
154
- vi.unstubAllGlobals();
155
- vi.clearAllMocks();
156
- fs.rmSync(tmpDir, { recursive: true, force: true });
157
- fs.rmSync(stateDir, { recursive: true, force: true });
158
- delete process.env.HQ_STATE_DIR;
159
- });
160
-
161
- it("a single writer's repeated local edits mint ZERO .conflict-* files", async () => {
162
- const companyRoot = path.join(tmpDir, "companies", "acme");
163
- fs.mkdirSync(companyRoot, { recursive: true });
164
- const files = ["notes.md", "plan.md", "ideas.md"].map((n) => path.join(companyRoot, n));
165
- for (const f of files) fs.writeFileSync(f, "v0\n");
166
-
167
- wireSingleWriterRemote();
168
-
169
- const conflictPathsSeen: string[] = [];
170
- // The event-push runner pushes the changed company subtree. Mirror that:
171
- // share() over the company root on every settled change.
172
- const eventPush = async () => {
173
- const r = await share({
174
- paths: [companyRoot],
175
- company: "acme",
176
- vaultConfig: mockConfig,
177
- hqRoot: tmpDir,
178
- onConflict: "keep",
179
- onEvent: () => {},
180
- });
181
- conflictPathsSeen.push(...r.conflictPaths);
182
- };
183
-
184
- // Initial sync establishes the journal baseline (first upload of each file).
185
- await eventPush();
186
-
187
- // An editing session: 30 ordinary local edits, round-robin across the
188
- // files, each followed by its targeted event-push.
189
- for (let i = 0; i < 30; i++) {
190
- fs.writeFileSync(files[i % files.length], `v${i + 1}\n`);
191
- await eventPush();
192
- }
193
-
194
- expect(countConflictMirrors(tmpDir)).toEqual([]);
195
- expect(conflictPathsSeen).toEqual([]);
196
- });
197
-
198
- it("positive control: a genuine peer write is still detected as a conflict", async () => {
199
- const companyRoot = path.join(tmpDir, "companies", "acme");
200
- fs.mkdirSync(companyRoot, { recursive: true });
201
- const file = path.join(companyRoot, "contested.md");
202
- fs.writeFileSync(file, "v0\n");
203
-
204
- const remote = wireSingleWriterRemote();
205
-
206
- const conflictPathsSeen: string[] = [];
207
- const eventPush = async () => {
208
- const r = await share({
209
- paths: [companyRoot],
210
- company: "acme",
211
- vaultConfig: mockConfig,
212
- hqRoot: tmpDir,
213
- onConflict: "keep",
214
- onEvent: () => {},
215
- });
216
- conflictPathsSeen.push(...r.conflictPaths);
217
- };
218
-
219
- await eventPush(); // baseline
220
-
221
- // A peer advances the remote object out-of-band to DIFFERENT bytes. Journal
222
- // keys are company-relative, so the key is "contested.md".
223
- remote.set("contested.md", { etag: '"peer-etag"', lastModified: new Date(Date.now() + 60_000), size: 99 });
224
- // ...and we also edit locally → both sides moved → a genuine conflict.
225
- fs.writeFileSync(file, "v1-local\n");
226
- await eventPush();
227
-
228
- // Existing-entry push conflicts surface via conflictPaths (the inspection
229
- // mirror is written by the pull leg). The contract under test: a REAL
230
- // divergence is still flagged — the single-writer guard above did not
231
- // over-correct into swallowing true conflicts.
232
- expect(conflictPathsSeen).toContain("contested.md");
233
- });
234
- });
@@ -1,214 +0,0 @@
1
- /**
2
- * Tests for the client-info helpers and header injection.
3
- *
4
- * Two layers are exercised here:
5
- * 1. The pure functions in `client-info.ts` — buildClientHeaders,
6
- * clientInfoFromPackage, detectHqCoreVersion.
7
- * 2. End-to-end injection into VaultClient.request, since "the headers
8
- * actually land on outbound fetch" is the property consumers care about.
9
- */
10
-
11
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
13
- import { tmpdir } from "node:os";
14
- import { join } from "node:path";
15
- import {
16
- buildClientHeaders,
17
- clientInfoFromPackage,
18
- detectHqCoreVersion,
19
- HEADER_CLIENT_NAME,
20
- HEADER_CLIENT_VERSION,
21
- HEADER_HQ_CORE_VERSION,
22
- } from "./client-info.js";
23
- import { VaultClient } from "./vault-client.js";
24
-
25
- describe("buildClientHeaders", () => {
26
- it("returns empty object when info is undefined", () => {
27
- expect(buildClientHeaders(undefined)).toEqual({});
28
- });
29
-
30
- it("emits User-Agent + x-hq-client-{name,version} from name/version", () => {
31
- const headers = buildClientHeaders({
32
- name: "@indigoai-us/hq-cli",
33
- version: "5.15.0",
34
- });
35
- expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
36
- expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
37
- expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
38
- expect(headers[HEADER_HQ_CORE_VERSION]).toBeUndefined();
39
- });
40
-
41
- it("includes x-hq-core-version only when hqCoreVersion is set", () => {
42
- const headers = buildClientHeaders({
43
- name: "@indigoai-us/hq-cli",
44
- version: "5.15.0",
45
- hqCoreVersion: "14.1.0",
46
- });
47
- expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
48
- });
49
-
50
- it("emits arbitrary extra fields as x-hq-client-<key> headers", () => {
51
- const headers = buildClientHeaders({
52
- name: "x",
53
- version: "1.0.0",
54
- extra: { machine: "ec2-bot-7", channel: "stable" },
55
- });
56
- expect(headers["x-hq-client-machine"]).toBe("ec2-bot-7");
57
- expect(headers["x-hq-client-channel"]).toBe("stable");
58
- });
59
- });
60
-
61
- describe("clientInfoFromPackage", () => {
62
- it("extracts name and version from a parsed package.json", () => {
63
- expect(clientInfoFromPackage({ name: "foo", version: "1.2.3" })).toEqual({
64
- name: "foo",
65
- version: "1.2.3",
66
- });
67
- });
68
-
69
- it("throws when name is missing", () => {
70
- expect(() => clientInfoFromPackage({ version: "1.0.0" })).toThrow(/name/);
71
- });
72
-
73
- it("throws when version is missing", () => {
74
- expect(() => clientInfoFromPackage({ name: "foo" })).toThrow(/version/);
75
- });
76
-
77
- it("throws on non-string fields", () => {
78
- expect(() =>
79
- clientInfoFromPackage({ name: 42 as unknown as string, version: "1.0.0" }),
80
- ).toThrow();
81
- });
82
- });
83
-
84
- describe("detectHqCoreVersion", () => {
85
- let tmpRoot: string;
86
- const origHqHome = process.env.HQ_HOME;
87
- const origHqRoot = process.env.HQ_ROOT;
88
-
89
- beforeEach(() => {
90
- tmpRoot = mkdtempSync(join(tmpdir(), "hq-core-detect-"));
91
- delete process.env.HQ_HOME;
92
- delete process.env.HQ_ROOT;
93
- });
94
-
95
- afterEach(() => {
96
- rmSync(tmpRoot, { recursive: true, force: true });
97
- if (origHqHome !== undefined) process.env.HQ_HOME = origHqHome;
98
- if (origHqRoot !== undefined) process.env.HQ_ROOT = origHqRoot;
99
- });
100
-
101
- function seedHqCore(root: string, version: string): void {
102
- mkdirSync(join(root, "core"), { recursive: true });
103
- mkdirSync(join(root, "companies"), { recursive: true });
104
- writeFileSync(
105
- join(root, "core", "core.yaml"),
106
- `version: 1\nhqVersion: "${version}"\nupdatedAt: "2026-05-13T00:00:00Z"\n`,
107
- );
108
- }
109
-
110
- it("returns undefined when nothing on the walk-up has core.yaml", () => {
111
- expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
112
- });
113
-
114
- it("returns hqVersion when startDir is the hq-core root", () => {
115
- seedHqCore(tmpRoot, "14.1.0");
116
- expect(detectHqCoreVersion(tmpRoot)).toBe("14.1.0");
117
- });
118
-
119
- it("walks upward to find core.yaml from a nested cwd", () => {
120
- seedHqCore(tmpRoot, "14.2.0");
121
- const nested = join(tmpRoot, "companies", "acme", "projects", "p1");
122
- mkdirSync(nested, { recursive: true });
123
- expect(detectHqCoreVersion(nested)).toBe("14.2.0");
124
- });
125
-
126
- it("ignores directories that have core.yaml but no companies/ — disambiguates fixtures", () => {
127
- mkdirSync(join(tmpRoot, "core"), { recursive: true });
128
- writeFileSync(
129
- join(tmpRoot, "core", "core.yaml"),
130
- `version: 1\nhqVersion: "99.0.0"\n`,
131
- );
132
- // No companies/ at this level → not an hq-core root.
133
- expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
134
- });
135
-
136
- it("honors HQ_HOME env override before walking cwd", () => {
137
- seedHqCore(tmpRoot, "14.3.0");
138
- process.env.HQ_HOME = tmpRoot;
139
- // startDir intentionally points elsewhere — env should win.
140
- const elsewhere = mkdtempSync(join(tmpdir(), "elsewhere-"));
141
- try {
142
- expect(detectHqCoreVersion(elsewhere)).toBe("14.3.0");
143
- } finally {
144
- rmSync(elsewhere, { recursive: true, force: true });
145
- }
146
- });
147
-
148
- it("parses unquoted hqVersion values too", () => {
149
- mkdirSync(join(tmpRoot, "core"), { recursive: true });
150
- mkdirSync(join(tmpRoot, "companies"), { recursive: true });
151
- writeFileSync(
152
- join(tmpRoot, "core", "core.yaml"),
153
- `version: 1\nhqVersion: 14.4.0\n`,
154
- );
155
- expect(detectHqCoreVersion(tmpRoot)).toBe("14.4.0");
156
- });
157
- });
158
-
159
- describe("VaultClient stamps client headers on outbound requests", () => {
160
- afterEach(() => {
161
- vi.restoreAllMocks();
162
- });
163
-
164
- it("includes x-hq-client-name + x-hq-client-version when clientInfo is set", async () => {
165
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
166
- new Response(JSON.stringify({ memberships: [] }), {
167
- status: 200,
168
- headers: { "Content-Type": "application/json" },
169
- }),
170
- );
171
-
172
- const client = new VaultClient({
173
- apiUrl: "https://vault.test.example.com",
174
- authToken: "tok",
175
- clientInfo: {
176
- name: "@indigoai-us/hq-cli",
177
- version: "5.15.0",
178
- hqCoreVersion: "14.1.0",
179
- },
180
- });
181
-
182
- await client.listMyMemberships();
183
-
184
- expect(fetchSpy).toHaveBeenCalledOnce();
185
- const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
186
- const headers = init.headers as Record<string, string>;
187
- expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
188
- expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
189
- expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
190
- expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
191
- });
192
-
193
- it("omits client headers when clientInfo is not set — back-compat", async () => {
194
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
195
- new Response(JSON.stringify({ memberships: [] }), {
196
- status: 200,
197
- headers: { "Content-Type": "application/json" },
198
- }),
199
- );
200
-
201
- const client = new VaultClient({
202
- apiUrl: "https://vault.test.example.com",
203
- authToken: "tok",
204
- });
205
-
206
- await client.listMyMemberships();
207
-
208
- const headers = (fetchSpy.mock.calls[0]?.[1] as RequestInit)
209
- .headers as Record<string, string>;
210
- expect(headers[HEADER_CLIENT_NAME]).toBeUndefined();
211
- expect(headers[HEADER_CLIENT_VERSION]).toBeUndefined();
212
- expect(headers["User-Agent"]).toBeUndefined();
213
- });
214
- });
@@ -1,121 +0,0 @@
1
- /**
2
- * Client identification — every HQ client that talks to hq-cloud-api should
3
- * stamp its name and version on outbound requests so the server can attribute
4
- * traffic, gate on minimum versions, and surface deprecation warnings.
5
- *
6
- * The CLI in particular sends a third header (`x-hq-core-version`) when it's
7
- * invoked from inside an hq-core checkout, so the server sees which scaffold
8
- * generation the user is running against.
9
- */
10
-
11
- import { readFileSync } from "node:fs";
12
- import { dirname, join, resolve } from "node:path";
13
- import type { ClientInfo } from "./types.js";
14
-
15
- export const HEADER_CLIENT_NAME = "x-hq-client-name";
16
- export const HEADER_CLIENT_VERSION = "x-hq-client-version";
17
- export const HEADER_HQ_CORE_VERSION = "x-hq-core-version";
18
-
19
- /**
20
- * Build the set of `x-hq-*` headers (plus a derived `User-Agent`) for a
21
- * ClientInfo. Returns an empty object when info is undefined so callers can
22
- * spread the result unconditionally.
23
- */
24
- export function buildClientHeaders(
25
- info: ClientInfo | undefined,
26
- ): Record<string, string> {
27
- if (!info) return {};
28
-
29
- const headers: Record<string, string> = {
30
- "User-Agent": `${info.name}/${info.version}`,
31
- [HEADER_CLIENT_NAME]: info.name,
32
- [HEADER_CLIENT_VERSION]: info.version,
33
- };
34
-
35
- if (info.hqCoreVersion) {
36
- headers[HEADER_HQ_CORE_VERSION] = info.hqCoreVersion;
37
- }
38
-
39
- if (info.extra) {
40
- for (const [k, v] of Object.entries(info.extra)) {
41
- headers[`x-hq-client-${k}`] = v;
42
- }
43
- }
44
-
45
- return headers;
46
- }
47
-
48
- /**
49
- * Build a ClientInfo from a parsed package.json. Most consumers will call this
50
- * once at startup and pass the result into every VaultServiceConfig.
51
- */
52
- export function clientInfoFromPackage(pkg: {
53
- name?: unknown;
54
- version?: unknown;
55
- }): ClientInfo {
56
- if (typeof pkg.name !== "string" || pkg.name.length === 0) {
57
- throw new Error("clientInfoFromPackage: package.json is missing a name");
58
- }
59
- if (typeof pkg.version !== "string" || pkg.version.length === 0) {
60
- throw new Error("clientInfoFromPackage: package.json is missing a version");
61
- }
62
- return { name: pkg.name, version: pkg.version };
63
- }
64
-
65
- /**
66
- * Walk upward from `startDir` looking for `core/core.yaml`. When found, parse
67
- * out the `hqVersion` field and return it. Returns undefined if we never find
68
- * an hq-core checkout — i.e. the caller is running from a plain user dir.
69
- *
70
- * Honors `HQ_HOME` env var as an explicit override so multi-checkout setups
71
- * (e.g. CI bots, the menubar app pointing at a non-cwd HQ root) can pin the
72
- * detection without relying on cwd.
73
- *
74
- * Why a regex instead of a YAML parser: `core.yaml` lives at the very top of
75
- * the file and the field has a stable shape — adding a YAML dep just to read
76
- * one string would balloon every consumer's bundle. The current shape is
77
- * `hqVersion: "X.Y.Z"` (quoted) per the canonical seed; we tolerate unquoted
78
- * too.
79
- */
80
- export function detectHqCoreVersion(startDir?: string): string | undefined {
81
- const fromEnv = process.env.HQ_HOME ?? process.env.HQ_ROOT;
82
- if (fromEnv) {
83
- const v = readCoreVersionAt(fromEnv);
84
- if (v) return v;
85
- }
86
-
87
- let dir = resolve(startDir ?? process.cwd());
88
- while (true) {
89
- const v = readCoreVersionAt(dir);
90
- if (v) return v;
91
- const parent = dirname(dir);
92
- if (parent === dir) return undefined;
93
- dir = parent;
94
- }
95
- }
96
-
97
- function readCoreVersionAt(hqRoot: string): string | undefined {
98
- // hq-core identity requires BOTH core/core.yaml AND companies/ — matches the
99
- // CLI's own detection (commit bc827d0). Without this guard, any directory
100
- // containing a stray `core/core.yaml` (e.g. a test fixture) would be
101
- // misidentified as an hq-core root.
102
- const yamlPath = join(hqRoot, "core", "core.yaml");
103
- const companiesPath = join(hqRoot, "companies");
104
- let content: string;
105
- try {
106
- content = readFileSync(yamlPath, "utf8");
107
- } catch {
108
- return undefined;
109
- }
110
- try {
111
- // statSync would be marginally cleaner, but readdirSync of a nonexistent
112
- // path throws synchronously which is what we want.
113
- readFileSync(companiesPath, { flag: "r" });
114
- } catch (err) {
115
- const e = err as NodeJS.ErrnoException;
116
- // EISDIR is the success case — companies/ exists and is a directory.
117
- if (e.code !== "EISDIR") return undefined;
118
- }
119
- const match = /^hqVersion:\s*["']?([^"'\s]+)/m.exec(content);
120
- return match ? match[1] : undefined;
121
- }