@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,231 +0,0 @@
1
- /**
2
- * Machine-ID resolver tests. Pins the four-tier fallback contract so a
3
- * regression in tier ordering, the migration-forward behavior, or the
4
- * "unknown sentinel is no longer reachable" invariant is caught at build
5
- * time rather than re-litigating it on a user's Lightsail outpost.
6
- */
7
-
8
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
9
- import * as fs from "fs";
10
- import * as os from "os";
11
- import * as path from "path";
12
- import { getOrCreateMachineId, readShortMachineId } from "./machine-id.js";
13
-
14
- function freshTmp(prefix: string): string {
15
- return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
16
- }
17
-
18
- describe("getOrCreateMachineId (four-tier resolver)", () => {
19
- let originalHome: string | undefined;
20
- let originalUserProfile: string | undefined;
21
- let originalEnvId: string | undefined;
22
- let tmpHome: string;
23
- let tmpHqRoot: string;
24
-
25
- beforeEach(() => {
26
- originalHome = process.env.HOME;
27
- originalUserProfile = process.env.USERPROFILE;
28
- originalEnvId = process.env.HQ_MACHINE_ID;
29
- delete process.env.HQ_MACHINE_ID;
30
- tmpHome = freshTmp("hq-machineid-home-");
31
- tmpHqRoot = freshTmp("hq-machineid-root-");
32
- process.env.HOME = tmpHome;
33
- process.env.USERPROFILE = tmpHome;
34
- });
35
-
36
- afterEach(() => {
37
- if (originalHome) process.env.HOME = originalHome;
38
- else delete process.env.HOME;
39
- if (originalUserProfile) process.env.USERPROFILE = originalUserProfile;
40
- else delete process.env.USERPROFILE;
41
- if (originalEnvId !== undefined) process.env.HQ_MACHINE_ID = originalEnvId;
42
- else delete process.env.HQ_MACHINE_ID;
43
- fs.rmSync(tmpHome, { recursive: true, force: true });
44
- fs.rmSync(tmpHqRoot, { recursive: true, force: true });
45
- });
46
-
47
- // ── tier 1: HQ_MACHINE_ID env override ────────────────────────────────
48
- it("tier 1: returns HQ_MACHINE_ID env when set, ignoring lower tiers", () => {
49
- process.env.HQ_MACHINE_ID = "env-override-id";
50
- // Even if a persisted file exists, env wins.
51
- fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
52
- fs.writeFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "persisted\n");
53
- expect(getOrCreateMachineId(tmpHqRoot)).toBe("env-override-id");
54
- // Env-only resolution must not clobber the on-disk source-of-truth.
55
- expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(
56
- "persisted",
57
- );
58
- });
59
-
60
- // ── tier 2: <hqRoot>/.hq/machine-id ───────────────────────────────────
61
- it("tier 2: returns the trimmed contents of <hqRoot>/.hq/machine-id", () => {
62
- fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
63
- fs.writeFileSync(
64
- path.join(tmpHqRoot, ".hq", "machine-id"),
65
- " abc-123-persisted \n\n",
66
- );
67
- expect(getOrCreateMachineId(tmpHqRoot)).toBe("abc-123-persisted");
68
- });
69
-
70
- it("tier 2: empty machine-id file falls through to autogen", () => {
71
- fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
72
- fs.writeFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "");
73
- const id = getOrCreateMachineId(tmpHqRoot);
74
- // UUID v4 shape.
75
- expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
76
- // Persisted on disk for next call.
77
- expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(id);
78
- });
79
-
80
- // ── tier 3: ~/.hq/menubar.json (legacy, migrated forward) ─────────────
81
- it("tier 3: reads menubar.json AND migrates the value into <hqRoot>/.hq/machine-id", () => {
82
- fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
83
- fs.writeFileSync(
84
- path.join(tmpHome, ".hq", "menubar.json"),
85
- JSON.stringify({ machineId: "menubar-legacy-id" }),
86
- );
87
- expect(getOrCreateMachineId(tmpHqRoot)).toBe("menubar-legacy-id");
88
- // Migrated forward — subsequent calls now hit tier 2.
89
- expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(
90
- "menubar-legacy-id",
91
- );
92
- });
93
-
94
- it("tier 3: malformed menubar.json falls through to autogen", () => {
95
- fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
96
- fs.writeFileSync(path.join(tmpHome, ".hq", "menubar.json"), "{not-json");
97
- const id = getOrCreateMachineId(tmpHqRoot);
98
- expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
99
- });
100
-
101
- it("tier 3: menubar.json without a machineId field falls through to autogen", () => {
102
- fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
103
- fs.writeFileSync(
104
- path.join(tmpHome, ".hq", "menubar.json"),
105
- JSON.stringify({ telemetryEnabled: true }),
106
- );
107
- const id = getOrCreateMachineId(tmpHqRoot);
108
- expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
109
- });
110
-
111
- // ── tier 4: autogen + persist ─────────────────────────────────────────
112
- it("tier 4: generates a UUID and persists it for the next call", () => {
113
- const id = getOrCreateMachineId(tmpHqRoot);
114
- expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
115
- expect(fs.readFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "utf-8").trim()).toBe(id);
116
- // Stable across calls.
117
- expect(getOrCreateMachineId(tmpHqRoot)).toBe(id);
118
- });
119
-
120
- it("tier 4: stays in-process even if hqRoot is read-only (best-effort persist)", () => {
121
- // Pre-create .hq as a regular dir, then strip write perms.
122
- fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
123
- fs.chmodSync(path.join(tmpHqRoot, ".hq"), 0o500); // r-x only
124
- try {
125
- const id = getOrCreateMachineId(tmpHqRoot);
126
- expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
127
- } finally {
128
- fs.chmodSync(path.join(tmpHqRoot, ".hq"), 0o700);
129
- }
130
- });
131
-
132
- // ── invariant: "unknown" sentinel is unreachable ──────────────────────
133
- it("never returns the legacy 'unknown' sentinel — every host gets a real id", () => {
134
- // No env, no persisted file, no menubar.json — pure tier-4 path.
135
- const id = getOrCreateMachineId(tmpHqRoot);
136
- expect(id).not.toBe("unknown");
137
- expect(id.length).toBeGreaterThan(6);
138
- });
139
- });
140
-
141
- describe("readShortMachineId", () => {
142
- let originalHome: string | undefined;
143
- let originalUserProfile: string | undefined;
144
- let originalEnvId: string | undefined;
145
- let tmpHome: string;
146
- let tmpHqRoot: string;
147
-
148
- beforeEach(() => {
149
- originalHome = process.env.HOME;
150
- originalUserProfile = process.env.USERPROFILE;
151
- originalEnvId = process.env.HQ_MACHINE_ID;
152
- delete process.env.HQ_MACHINE_ID;
153
- tmpHome = freshTmp("hq-machineid-short-home-");
154
- tmpHqRoot = freshTmp("hq-machineid-short-root-");
155
- process.env.HOME = tmpHome;
156
- process.env.USERPROFILE = tmpHome;
157
- });
158
-
159
- afterEach(() => {
160
- if (originalHome) process.env.HOME = originalHome;
161
- else delete process.env.HOME;
162
- if (originalUserProfile) process.env.USERPROFILE = originalUserProfile;
163
- else delete process.env.USERPROFILE;
164
- if (originalEnvId !== undefined) process.env.HQ_MACHINE_ID = originalEnvId;
165
- else delete process.env.HQ_MACHINE_ID;
166
- fs.rmSync(tmpHome, { recursive: true, force: true });
167
- fs.rmSync(tmpHqRoot, { recursive: true, force: true });
168
- });
169
-
170
- it("returns the first 6 chars when the resolved id has a hex prefix", () => {
171
- process.env.HQ_MACHINE_ID = "deadbeefcafe1234567890";
172
- expect(readShortMachineId(tmpHqRoot)).toBe("deadbe");
173
- });
174
-
175
- it("returns the first 6 chars of an autogenerated UUID", () => {
176
- const short = readShortMachineId(tmpHqRoot);
177
- expect(short).toHaveLength(6);
178
- expect(short).toMatch(/^[0-9a-f]{6}$/);
179
- expect(short).not.toBe("unknow"); // legacy "unknown" prefix — must not reappear
180
- });
181
-
182
- it("reads the same hex prefix from <hqRoot>/.hq/machine-id when persisted", () => {
183
- fs.mkdirSync(path.join(tmpHqRoot, ".hq"), { recursive: true });
184
- fs.writeFileSync(path.join(tmpHqRoot, ".hq", "machine-id"), "abcdef-rest-can-be-anything");
185
- expect(readShortMachineId(tmpHqRoot)).toBe("abcdef");
186
- });
187
-
188
- // ── normalization invariant: short token is ALWAYS [a-f0-9]{6} ────────
189
- //
190
- // Regression coverage for the Codex-flagged P2 — without this, a
191
- // non-hex `HQ_MACHINE_ID` or legacy menubar value (e.g. "ci-runner-42",
192
- // "menubar-legacy-id") would slice to a non-hex 6-char prefix, the
193
- // conflict filename would carry that non-hex token, and the
194
- // `EPHEMERAL_PATH_PATTERN` in `src/cli/share.ts` (which only accepts
195
- // `[a-f0-9]+` or the literal `unknown`) would refuse it, restoring the
196
- // exact litter-ratchet loop this module exists to close.
197
- it.each([
198
- // Tier-1 env override with non-hex characters.
199
- ["ci-runner-42"],
200
- ["env-override-id"],
201
- // Tier-3 legacy menubar value with non-hex characters.
202
- ["menubar-legacy-id"],
203
- // Mixed-case that contains non-hex letters in the first 6 chars.
204
- ["Gabc12-rest"],
205
- // First 6 chars are hex but contain uppercase (regex is case-sensitive).
206
- ["ABCDEF-rest"],
207
- ])("normalizes non-hex source ids to a hex token: %s", (sourceId) => {
208
- process.env.HQ_MACHINE_ID = sourceId;
209
- const short = readShortMachineId(tmpHqRoot);
210
- expect(short).toMatch(/^[a-f0-9]{6}$/);
211
- expect(short).toHaveLength(6);
212
- });
213
-
214
- it("normalization is deterministic — same source id always yields same short token", () => {
215
- process.env.HQ_MACHINE_ID = "menubar-legacy-id";
216
- const a = readShortMachineId(tmpHqRoot);
217
- const b = readShortMachineId(tmpHqRoot);
218
- expect(a).toBe(b);
219
- expect(a).toMatch(/^[a-f0-9]{6}$/);
220
- });
221
-
222
- it("normalization distinguishes different source ids", () => {
223
- process.env.HQ_MACHINE_ID = "menubar-legacy-id";
224
- const a = readShortMachineId(tmpHqRoot);
225
- process.env.HQ_MACHINE_ID = "ci-runner-42";
226
- const b = readShortMachineId(tmpHqRoot);
227
- expect(a).not.toBe(b);
228
- expect(a).toMatch(/^[a-f0-9]{6}$/);
229
- expect(b).toMatch(/^[a-f0-9]{6}$/);
230
- });
231
- });
@@ -1,175 +0,0 @@
1
- /**
2
- * Machine-ID provisioning — owns the per-host identity used to attribute
3
- * conflict mirrors, telemetry rows, and `.hq-conflicts/index.json` entries.
4
- *
5
- * Historically the menubar app (`indigoai-us/hq-sync`) was the sole writer
6
- * of `machineId` via `~/.hq/menubar.json`, and every other sync caller
7
- * best-effort read from there. That arrangement is backwards: hq-cloud is
8
- * the engine that runs on every sync host (macOS-with-menubar, macOS CLI,
9
- * Linux HQ Pro Outposts, future Windows), while the menubar is an optional
10
- * macOS-only UI. Linux outposts therefore had no menubar.json, so
11
- * `readShortMachineId()` returned the literal string `"unknown"` and
12
- * every conflict file on those hosts was tagged `-unknown` (which then
13
- * also slipped past `EPHEMERAL_PATH_PATTERN` in `src/cli/share.ts` — see
14
- * Fix 2 — and rode S3 round-trips as a regular file).
15
- *
16
- * This module flips ownership: hq-cloud provisions a UUID on first call
17
- * and persists it to `<hqRoot>/.hq/machine-id` (one line, plain text).
18
- * Every subsequent call hits the persisted file. Existing macOS installs
19
- * with menubar-written IDs are migrated forward on first call: tier 3
20
- * picks up the menubar.json value, writes it to `<hqRoot>/.hq/machine-id`,
21
- * and returns it — so the id is stable across the migration window.
22
- *
23
- * Resolution order (first hit wins, every miss falls through):
24
- * 1. `process.env.HQ_MACHINE_ID` — explicit override for CI / tests.
25
- * 2. `<hqRoot>/.hq/machine-id` — source-of-truth on the host.
26
- * 3. `~/.hq/menubar.json` `machineId` field — back-compat for existing
27
- * macOS installs; migrated forward to tier 2 on first read.
28
- * 4. Autogen — write a fresh UUID to `<hqRoot>/.hq/machine-id` and return.
29
- *
30
- * Concurrent autogen race is benign: two writers each pick a fresh UUID,
31
- * last-writer-wins on disk, both processes re-read the now-stable file on
32
- * their next call. The window is narrow (single sync run startup) and the
33
- * downside (one extra conflict-file rename across a single race window) is
34
- * trivial compared to the litter-ratchet bug it replaces.
35
- */
36
-
37
- import { createHash, randomUUID } from "node:crypto";
38
- import * as fs from "node:fs";
39
- import * as os from "node:os";
40
- import * as path from "node:path";
41
-
42
- /**
43
- * Path to `~/.hq/menubar.json`. Evaluated lazily at call time (not module
44
- * load) so tests overriding `HOME` post-import see the right file. Going
45
- * through `os.homedir()` rather than `process.env.HOME` keeps the Windows
46
- * USERPROFILE fallback intact.
47
- */
48
- function menubarJsonPath(): string {
49
- return path.join(os.homedir(), ".hq", "menubar.json");
50
- }
51
-
52
- /**
53
- * Path to `<hqRoot>/.hq/machine-id` — the source-of-truth file.
54
- */
55
- function hqRootMachineIdPath(hqRoot: string): string {
56
- return path.join(hqRoot, ".hq", "machine-id");
57
- }
58
-
59
- /**
60
- * Read the persisted id from `<hqRoot>/.hq/machine-id`, or undefined if
61
- * absent/unreadable/empty. Trims trailing whitespace so manual edits with
62
- * a final newline don't break attribution.
63
- */
64
- function readHqRootMachineId(hqRoot: string): string | undefined {
65
- try {
66
- const raw = fs.readFileSync(hqRootMachineIdPath(hqRoot), "utf-8").trim();
67
- return raw.length > 0 ? raw : undefined;
68
- } catch {
69
- return undefined;
70
- }
71
- }
72
-
73
- /**
74
- * Read the menubar-written id from `~/.hq/menubar.json`, or undefined if
75
- * the file is missing / unreadable / doesn't contain a string `machineId`.
76
- */
77
- function readMenubarMachineId(): string | undefined {
78
- try {
79
- const raw = fs.readFileSync(menubarJsonPath(), "utf-8");
80
- const parsed = JSON.parse(raw) as { machineId?: unknown };
81
- if (typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
82
- return parsed.machineId;
83
- }
84
- return undefined;
85
- } catch {
86
- return undefined;
87
- }
88
- }
89
-
90
- /**
91
- * Persist `id` to `<hqRoot>/.hq/machine-id`. Best-effort — failures are
92
- * silent so a read-only hqRoot (e.g. a CI mount) still gets a working id
93
- * for the current process, even if it can't be persisted for the next run.
94
- */
95
- function persistMachineId(hqRoot: string, id: string): void {
96
- try {
97
- fs.mkdirSync(path.join(hqRoot, ".hq"), { recursive: true });
98
- fs.writeFileSync(hqRootMachineIdPath(hqRoot), `${id}\n`);
99
- } catch {
100
- // Read-only filesystem or permission issue — caller gets the id back
101
- // anyway. Next sync run will retry the persist.
102
- }
103
- }
104
-
105
- /**
106
- * Resolve or provision the machine id for this host, persisting it to
107
- * `<hqRoot>/.hq/machine-id` so the result is stable across sync runs.
108
- *
109
- * Returns the full id (UUID-shaped on first generation, free-form when
110
- * migrated from a menubar.json that wrote something non-UUID). Use
111
- * {@link readShortMachineId} for the 6-char prefix used in conflict
112
- * filenames.
113
- */
114
- export function getOrCreateMachineId(hqRoot: string): string {
115
- // Tier 1: env override.
116
- const fromEnv = process.env.HQ_MACHINE_ID;
117
- if (fromEnv && fromEnv.length > 0) return fromEnv;
118
-
119
- // Tier 2: persisted source-of-truth.
120
- const persisted = readHqRootMachineId(hqRoot);
121
- if (persisted) return persisted;
122
-
123
- // Tier 3: back-compat read of menubar.json. Migrate forward on first hit
124
- // so subsequent calls take tier 2 and the menubar dependency drops out.
125
- const fromMenubar = readMenubarMachineId();
126
- if (fromMenubar) {
127
- persistMachineId(hqRoot, fromMenubar);
128
- return fromMenubar;
129
- }
130
-
131
- // Tier 4: autogen + persist.
132
- const fresh = randomUUID();
133
- persistMachineId(hqRoot, fresh);
134
- return fresh;
135
- }
136
-
137
- /**
138
- * Short form (six hex chars) for use in conflict filenames. The short
139
- * token is what gets stamped into `<orig>.conflict-<ts>-<short>.<ext>` —
140
- * see `buildConflictPath` in `./conflict-file.ts`.
141
- *
142
- * **Always returns `[a-f0-9]{6}`** so the resulting filename matches the
143
- * `EPHEMERAL_PATH_PATTERN` in `src/cli/share.ts`. Tier 1 (`HQ_MACHINE_ID`)
144
- * and tier 3 (legacy menubar values) can return arbitrary non-hex strings
145
- * — e.g. an env override of `"ci-runner-42"` or a menubar-written
146
- * `"menubar-legacy-id"`. Slicing those raw would produce `ci-run` or
147
- * `menuba`, which the ephemeral filter would refuse and the push walker
148
- * would round-trip to S3 — the exact litter-ratchet bug this module
149
- * exists to close.
150
- *
151
- * Normalization: if the first 6 chars of the resolved id are all hex
152
- * (the typical UUID / hex-id case), use them as-is so the short token
153
- * remains an intuitive prefix of the full id. Otherwise derive a
154
- * deterministic SHA-1 hash of the full id and take the first 6 chars —
155
- * stable across calls, attributable to the same machine, always hex.
156
- */
157
- export function readShortMachineId(hqRoot: string): string {
158
- const full = getOrCreateMachineId(hqRoot);
159
- const head = full.slice(0, 6);
160
- if (/^[a-f0-9]{6}$/.test(head)) return head;
161
- return createHash("sha1").update(full).digest("hex").slice(0, 6);
162
- }
163
-
164
- /**
165
- * Test-only exports. Mirrors the `_testing` namespace pattern used by
166
- * `src/cli/share.ts` so regression-critical helpers can be pinned by
167
- * direct unit tests without round-tripping through the public API.
168
- */
169
- export const _testing = {
170
- menubarJsonPath,
171
- hqRootMachineIdPath,
172
- readHqRootMachineId,
173
- readMenubarMachineId,
174
- persistMachineId,
175
- };
@@ -1,65 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { isTransientNetworkError, TRANSIENT_NETWORK_EXIT } from "./net-errors.js";
3
-
4
- describe("isTransientNetworkError", () => {
5
- it("treats undici's canonical `fetch failed` TypeError as transient", () => {
6
- // This is the exact shape the runner's discovery step sees in production
7
- // (HQ-SYNC-1W): `{"type":"error","message":"fetch failed","path":"(discovery)"}`.
8
- expect(isTransientNetworkError(new TypeError("fetch failed"))).toBe(true);
9
- });
10
-
11
- it("treats direct transport `.code`s as transient", () => {
12
- for (const code of ["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"]) {
13
- const err = Object.assign(new Error("boom"), { code });
14
- expect(isTransientNetworkError(err)).toBe(true);
15
- }
16
- });
17
-
18
- it("walks the .cause chain (fetch failed wrapping a DNS error)", () => {
19
- const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api"), {
20
- code: "ENOTFOUND",
21
- });
22
- const err = Object.assign(new TypeError("fetch failed"), { cause });
23
- expect(isTransientNetworkError(err)).toBe(true);
24
- });
25
-
26
- it("walks an AWS SDK UnknownError cause chain to the underlying DNS error", () => {
27
- const cause = Object.assign(
28
- new Error("getaddrinfo ENOTFOUND hq-vault-prs-test.s3.us-east-1.amazonaws.com"),
29
- { code: "ENOTFOUND", syscall: "getaddrinfo" },
30
- );
31
- const err = Object.assign(new Error("UnknownError"), {
32
- name: "UnknownError",
33
- cause,
34
- });
35
- expect(isTransientNetworkError(err)).toBe(true);
36
- });
37
-
38
- it("walks AggregateError.errors (happy-eyeballs)", () => {
39
- const agg = Object.assign(new AggregateError([
40
- Object.assign(new Error("v4"), { code: "ECONNREFUSED" }),
41
- Object.assign(new Error("v6"), { code: "EHOSTUNREACH" }),
42
- ], "all attempts failed"), {});
43
- expect(isTransientNetworkError(agg)).toBe(true);
44
- });
45
-
46
- it("is conservative: an arbitrary error is NOT transient", () => {
47
- // A bare message like "network down" (no code, not the canonical undici
48
- // message) must not be misclassified — only real transport shapes qualify.
49
- expect(isTransientNetworkError(new Error("network down"))).toBe(false);
50
- expect(isTransientNetworkError(new Error("boom"))).toBe(false);
51
- expect(isTransientNetworkError("fetch failed")).toBe(false);
52
- expect(isTransientNetworkError(undefined)).toBe(false);
53
- });
54
-
55
- it("does not infinite-loop on a self-referential cause", () => {
56
- const err = new Error("weird") as Error & { cause?: unknown };
57
- err.cause = err;
58
- expect(isTransientNetworkError(err)).toBe(false);
59
- });
60
-
61
- it("exposes a distinct exit code off the runner's other codes", () => {
62
- expect(TRANSIENT_NETWORK_EXIT).toBe(75);
63
- expect([0, 1, 2, 17]).not.toContain(TRANSIENT_NETWORK_EXIT);
64
- });
65
- });
@@ -1,86 +0,0 @@
1
- /**
2
- * Transient network-failure classification for the sync runner.
3
- *
4
- * The auto-sync watcher runs an unattended poll loop. At the top of every pass
5
- * it calls `GET /membership/me` to resolve which companies to sync, then it
6
- * runs a per-company fanout. When the machine is briefly offline (wifi drop,
7
- * waking from sleep, a DNS blip, the vault API or a vault S3 endpoint
8
- * momentarily unreachable), either boundary can fail at the transport layer —
9
- * Node's `fetch` throws `TypeError: fetch failed` with the real cause (an
10
- * `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT` / … Error) on `.cause`.
11
- *
12
- * This is NOT a crash: the next poll (~30s later) succeeds once the network is
13
- * back. But the runner used to `return 1` for it, the watch loop propagated
14
- * that non-zero exit, the process exited, and the menubar supervisor reported
15
- * "auto-sync watcher exited unexpectedly (code=Some(1))" for every blip — the
16
- * HQ-SYNC-1W cluster. A company-leg failure was also previously counted as an
17
- * exit-2 partial sync, producing the HQ-SYNC-X symptom even though it was just
18
- * retryable DNS/transport loss. We classify both boundaries so the watch loop
19
- * can stay alive and retry instead of surfacing a false crash, while a one-shot
20
- * `hq sync` still exits non-zero so a human running it by hand sees the failure.
21
- */
22
-
23
- /**
24
- * Distinct runner exit code for a transient network failure. The watch loop
25
- * treats it as "stay alive, retry next tick" (never a crash); a one-shot run
26
- * still exits non-zero so the failure is visible to a human/script. Value 75 =
27
- * `EX_TEMPFAIL` from sysexits.h ("temporary failure; retry"). Kept off the
28
- * codes already in use by the runner (0 ok, 1 error, 2 partial, 17 op-locked).
29
- */
30
- export const TRANSIENT_NETWORK_EXIT = 75;
31
-
32
- /**
33
- * Node/undici error `code`s that mean "the request never completed at the
34
- * transport layer" — the machine could not reach the host. All are transient:
35
- * a later attempt from the same machine can succeed unchanged.
36
- */
37
- const TRANSIENT_CODES = new Set<string>([
38
- "ECONNREFUSED",
39
- "ECONNRESET",
40
- "ENOTFOUND",
41
- "EAI_AGAIN",
42
- "ETIMEDOUT",
43
- "EPIPE",
44
- "EHOSTUNREACH",
45
- "EHOSTDOWN",
46
- "ENETUNREACH",
47
- "ENETDOWN",
48
- "ECONNABORTED",
49
- // undici's own timeout/socket failures
50
- "UND_ERR_CONNECT_TIMEOUT",
51
- "UND_ERR_HEADERS_TIMEOUT",
52
- "UND_ERR_BODY_TIMEOUT",
53
- "UND_ERR_SOCKET",
54
- ]);
55
-
56
- /**
57
- * True when `err` is (or wraps) a transient network-transport failure. Walks the
58
- * `.cause` chain and `AggregateError.errors` (undici's happy-eyeballs raises the
59
- * latter), matching either undici's canonical `TypeError: fetch failed` message
60
- * or a known transient `.code`. Conservative by design: an arbitrary
61
- * `new Error("network down")` with no code and a non-canonical message is NOT
62
- * treated as transient, so only real transport-failure shapes qualify.
63
- */
64
- export function isTransientNetworkError(err: unknown, depth = 0): boolean {
65
- if (depth > 8 || !(err instanceof Error)) return false;
66
-
67
- // undici's transport failure surfaces as exactly this message.
68
- if (err.message === "fetch failed") return true;
69
-
70
- const code = (err as { code?: unknown }).code;
71
- if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
72
-
73
- // AggregateError (e.g. happy-eyeballs) stashes the per-attempt errors here.
74
- const aggregated = (err as { errors?: unknown }).errors;
75
- if (Array.isArray(aggregated)) {
76
- for (const inner of aggregated) {
77
- if (isTransientNetworkError(inner, depth + 1)) return true;
78
- }
79
- }
80
-
81
- const cause = (err as { cause?: unknown }).cause;
82
- if (cause !== undefined && cause !== err) {
83
- return isTransientNetworkError(cause, depth + 1);
84
- }
85
- return false;
86
- }
@@ -1,43 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { readlinkOrNull } from "./readlink-safe.js";
3
-
4
- function errnoError(code: string): NodeJS.ErrnoException {
5
- const err = new Error(`${code}: scripted readlink failure`) as NodeJS.ErrnoException;
6
- err.code = code;
7
- return err;
8
- }
9
-
10
- describe("readlinkOrNull", () => {
11
- it("uses the filesystem reader by default", () => {
12
- expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
13
- });
14
-
15
- it("returns the target string verbatim when readlink succeeds", () => {
16
- expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe(
17
- "../target with spaces",
18
- );
19
- });
20
-
21
- it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
22
- expect(readlinkOrNull("/test/link", () => {
23
- throw errnoError(code);
24
- })).toBeNull();
25
- });
26
-
27
- it("rethrows non-filesystem failures", () => {
28
- const defect = new Error("unexpected test defect");
29
-
30
- expect(() => readlinkOrNull("/test/link", () => {
31
- throw defect;
32
- })).toThrow(defect);
33
- });
34
-
35
- it("rethrows code-bearing programming errors", () => {
36
- const defect = new TypeError("path must be a string") as TypeError & { code: string };
37
- defect.code = "ERR_INVALID_ARG_TYPE";
38
-
39
- expect(() => readlinkOrNull("/test/link", () => {
40
- throw defect;
41
- })).toThrow(defect);
42
- });
43
- });
@@ -1,29 +0,0 @@
1
- import * as fs from "fs";
2
-
3
- /**
4
- * Read a link target without turning an expected filesystem race or an
5
- * unsupported link shape into a caller-wide failure.
6
- *
7
- * A `null` result is deliberately distinct from an empty target string: it
8
- * means the caller must preserve the link boundary and choose its conservative
9
- * skip/defer path. Non-filesystem exceptions still propagate so programming
10
- * errors cannot be mistaken for an unreadable link.
11
- */
12
- export function readlinkOrNull(
13
- linkPath: string,
14
- readlink: (path: string) => string = (path) => fs.readlinkSync(path, "utf8"),
15
- ): string | null {
16
- try {
17
- return readlink(linkPath);
18
- } catch (err: unknown) {
19
- const code =
20
- err && typeof err === "object" && "code" in err
21
- ? (err as { code?: unknown }).code
22
- : undefined;
23
- // Node system errors use POSIX-style errno names such as EINVAL and
24
- // ENOENT. Runtime/programming errors use ERR_* codes; those must remain
25
- // loud rather than being misclassified as an unreadable link.
26
- if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code)) return null;
27
- throw err;
28
- }
29
- }