@hominis/fireforge 0.34.2 → 0.36.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 (360) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +43 -5
  3. package/dist/bin/fireforge.d.ts +4 -0
  4. package/dist/bin/fireforge.js +25 -6
  5. package/dist/src/cli.js +22 -5
  6. package/dist/src/commands/bootstrap-checks.js +0 -1
  7. package/dist/src/commands/bootstrap.d.ts +2 -1
  8. package/dist/src/commands/bootstrap.js +16 -7
  9. package/dist/src/commands/build.js +17 -3
  10. package/dist/src/commands/config.js +30 -10
  11. package/dist/src/commands/discard.js +24 -5
  12. package/dist/src/commands/doctor/post-rebase-audit.js +0 -1
  13. package/dist/src/commands/doctor-check-core.js +0 -1
  14. package/dist/src/commands/doctor-external-toolchains.d.ts +3 -0
  15. package/dist/src/commands/doctor-external-toolchains.js +54 -0
  16. package/dist/src/commands/doctor-furnace-jar.js +0 -1
  17. package/dist/src/commands/doctor-furnace-manifest-sync.js +0 -1
  18. package/dist/src/commands/doctor-furnace.js +2 -2
  19. package/dist/src/commands/doctor-working-tree.js +0 -1
  20. package/dist/src/commands/doctor.js +4 -1
  21. package/dist/src/commands/download.js +51 -15
  22. package/dist/src/commands/export-all.js +5 -3
  23. package/dist/src/commands/export-flow.js +12 -7
  24. package/dist/src/commands/export-placement-gate.js +0 -1
  25. package/dist/src/commands/export-placement-policy.js +0 -1
  26. package/dist/src/commands/export-shared.d.ts +5 -1
  27. package/dist/src/commands/export-shared.js +18 -6
  28. package/dist/src/commands/export.js +15 -10
  29. package/dist/src/commands/furnace/apply.js +22 -6
  30. package/dist/src/commands/furnace/chrome-doc-remove.js +0 -1
  31. package/dist/src/commands/furnace/chrome-doc-templates.js +0 -1
  32. package/dist/src/commands/furnace/chrome-doc-tests.js +0 -1
  33. package/dist/src/commands/furnace/chrome-doc.js +0 -1
  34. package/dist/src/commands/furnace/create-browser-test.js +0 -1
  35. package/dist/src/commands/furnace/create-dry-run.js +0 -1
  36. package/dist/src/commands/furnace/create-features.js +0 -1
  37. package/dist/src/commands/furnace/create-mochikit.js +0 -1
  38. package/dist/src/commands/furnace/create-readback.js +0 -1
  39. package/dist/src/commands/furnace/create-templates.js +0 -1
  40. package/dist/src/commands/furnace/create-validation.js +0 -1
  41. package/dist/src/commands/furnace/create-xpcshell.js +0 -1
  42. package/dist/src/commands/furnace/create.js +0 -1
  43. package/dist/src/commands/furnace/deploy.js +7 -83
  44. package/dist/src/commands/furnace/diff.js +0 -1
  45. package/dist/src/commands/furnace/index.js +59 -25
  46. package/dist/src/commands/furnace/init.js +0 -1
  47. package/dist/src/commands/furnace/list.js +0 -1
  48. package/dist/src/commands/furnace/override.js +0 -1
  49. package/dist/src/commands/furnace/preview.js +3 -3
  50. package/dist/src/commands/furnace/refresh.js +0 -1
  51. package/dist/src/commands/furnace/remove-state.d.ts +15 -0
  52. package/dist/src/commands/furnace/remove-state.js +47 -1
  53. package/dist/src/commands/furnace/remove.js +3 -9
  54. package/dist/src/commands/furnace/rename-browser-test.js +1 -5
  55. package/dist/src/commands/furnace/rename-helpers.d.ts +6 -0
  56. package/dist/src/commands/furnace/rename-helpers.js +30 -1
  57. package/dist/src/commands/furnace/rename-xpcshell.js +7 -6
  58. package/dist/src/commands/furnace/rename.js +15 -36
  59. package/dist/src/commands/furnace/scan.js +0 -1
  60. package/dist/src/commands/furnace/status.js +0 -1
  61. package/dist/src/commands/furnace/sync.js +76 -4
  62. package/dist/src/commands/furnace/validate.js +0 -1
  63. package/dist/src/commands/furnace/validation-output.js +0 -1
  64. package/dist/src/commands/import.js +35 -9
  65. package/dist/src/commands/lint-per-patch.js +19 -7
  66. package/dist/src/commands/lint.js +13 -3
  67. package/dist/src/commands/manifest.js +1 -2
  68. package/dist/src/commands/package.js +2 -1
  69. package/dist/src/commands/patch/compact.js +1 -1
  70. package/dist/src/commands/patch/delete.js +1 -1
  71. package/dist/src/commands/patch/index.js +1 -1
  72. package/dist/src/commands/patch/lint-ignore.js +21 -6
  73. package/dist/src/commands/patch/move-files.js +1 -1
  74. package/dist/src/commands/patch/patch-context.js +0 -1
  75. package/dist/src/commands/patch/rename.js +4 -4
  76. package/dist/src/commands/patch/reorder.js +3 -4
  77. package/dist/src/commands/patch/split-plan.js +2 -1
  78. package/dist/src/commands/patch/split.js +4 -4
  79. package/dist/src/commands/patch/staged-dependency.js +1 -1
  80. package/dist/src/commands/patch/tier.js +18 -5
  81. package/dist/src/commands/re-export-bulk-scan.js +0 -1
  82. package/dist/src/commands/re-export-files.js +1 -1
  83. package/dist/src/commands/re-export-options.js +0 -1
  84. package/dist/src/commands/re-export-scan.js +0 -1
  85. package/dist/src/commands/re-export.js +6 -4
  86. package/dist/src/commands/rebase/abort.js +2 -9
  87. package/dist/src/commands/rebase/confirm.js +0 -1
  88. package/dist/src/commands/rebase/conflict-summary.js +0 -1
  89. package/dist/src/commands/rebase/continue.js +0 -1
  90. package/dist/src/commands/rebase/index.d.ts +1 -1
  91. package/dist/src/commands/rebase/index.js +10 -15
  92. package/dist/src/commands/rebase/patch-loop.js +38 -7
  93. package/dist/src/commands/rebase/summary.js +2 -3
  94. package/dist/src/commands/rebase.js +0 -1
  95. package/dist/src/commands/register.js +1 -1
  96. package/dist/src/commands/reset.js +16 -9
  97. package/dist/src/commands/resolve.js +2 -2
  98. package/dist/src/commands/run.js +18 -6
  99. package/dist/src/commands/setup-support.js +0 -1
  100. package/dist/src/commands/setup.js +1 -2
  101. package/dist/src/commands/source.js +1 -2
  102. package/dist/src/commands/status-output.js +1 -12
  103. package/dist/src/commands/status.js +147 -133
  104. package/dist/src/commands/test-appdir.js +0 -1
  105. package/dist/src/commands/test-diagnose.js +18 -2
  106. package/dist/src/commands/test-modes.d.ts +15 -0
  107. package/dist/src/commands/test-modes.js +61 -0
  108. package/dist/src/commands/test-register.d.ts +4 -0
  109. package/dist/src/commands/test-register.js +64 -0
  110. package/dist/src/commands/test-run.d.ts +23 -7
  111. package/dist/src/commands/test-run.js +15 -14
  112. package/dist/src/commands/test.d.ts +0 -4
  113. package/dist/src/commands/test.js +95 -56
  114. package/dist/src/commands/token-coverage.js +0 -1
  115. package/dist/src/commands/token.js +1 -2
  116. package/dist/src/commands/typecheck.js +1 -1
  117. package/dist/src/commands/verify.js +3 -9
  118. package/dist/src/commands/watch.js +5 -1
  119. package/dist/src/commands/wire.js +1 -1
  120. package/dist/src/core/ast-utils.js +0 -1
  121. package/dist/src/core/brand-validation.js +0 -1
  122. package/dist/src/core/branding.js +0 -1
  123. package/dist/src/core/browser-wire.js +0 -1
  124. package/dist/src/core/build-audit-platform.js +0 -1
  125. package/dist/src/core/build-audit-registration.js +0 -1
  126. package/dist/src/core/build-audit-resolve.js +0 -1
  127. package/dist/src/core/build-audit-transforms.js +0 -1
  128. package/dist/src/core/build-audit.js +0 -1
  129. package/dist/src/core/build-baseline-types.js +0 -1
  130. package/dist/src/core/build-baseline.js +0 -1
  131. package/dist/src/core/build-prepare.js +21 -7
  132. package/dist/src/core/config-mutate.js +0 -1
  133. package/dist/src/core/config-paths.d.ts +2 -2
  134. package/dist/src/core/config-paths.js +6 -1
  135. package/dist/src/core/config-state.js +0 -1
  136. package/dist/src/core/config-validate-patch-policy.js +0 -1
  137. package/dist/src/core/config-validate-test-toolchains.d.ts +8 -0
  138. package/dist/src/core/config-validate-test-toolchains.js +105 -0
  139. package/dist/src/core/config-validate.js +3 -1
  140. package/dist/src/core/config.js +0 -1
  141. package/dist/src/core/destructive.js +0 -1
  142. package/dist/src/core/diff-hunks.js +13 -7
  143. package/dist/src/core/engine-changes.js +0 -1
  144. package/dist/src/core/engine-session-lock.d.ts +12 -0
  145. package/dist/src/core/engine-session-lock.js +45 -0
  146. package/dist/src/core/file-lock.d.ts +8 -0
  147. package/dist/src/core/file-lock.js +157 -48
  148. package/dist/src/core/firefox-archive.d.ts +4 -0
  149. package/dist/src/core/firefox-archive.js +6 -1
  150. package/dist/src/core/firefox-cache.js +67 -8
  151. package/dist/src/core/firefox-download.js +0 -1
  152. package/dist/src/core/firefox-extract.d.ts +21 -1
  153. package/dist/src/core/firefox-extract.js +178 -9
  154. package/dist/src/core/firefox-ignorefile.js +0 -1
  155. package/dist/src/core/firefox.d.ts +12 -0
  156. package/dist/src/core/firefox.js +64 -2
  157. package/dist/src/core/furnace-apply-dry-run.js +0 -1
  158. package/dist/src/core/furnace-apply-ftl.d.ts +7 -3
  159. package/dist/src/core/furnace-apply-ftl.js +10 -5
  160. package/dist/src/core/furnace-apply-helpers.js +0 -1
  161. package/dist/src/core/furnace-apply-output.js +2 -2
  162. package/dist/src/core/furnace-apply.d.ts +7 -1
  163. package/dist/src/core/furnace-apply.js +47 -13
  164. package/dist/src/core/furnace-checksum-utils.js +0 -1
  165. package/dist/src/core/furnace-config-array-utils.js +0 -1
  166. package/dist/src/core/furnace-config-custom.js +0 -1
  167. package/dist/src/core/furnace-config-order.js +0 -1
  168. package/dist/src/core/furnace-config-tokens.js +0 -1
  169. package/dist/src/core/furnace-config-validate.js +0 -1
  170. package/dist/src/core/furnace-config.d.ts +11 -0
  171. package/dist/src/core/furnace-config.js +37 -1
  172. package/dist/src/core/furnace-constants.js +0 -1
  173. package/dist/src/core/furnace-css-fragments.js +0 -1
  174. package/dist/src/core/furnace-graph-utils.js +0 -1
  175. package/dist/src/core/furnace-jsconfig.d.ts +2 -3
  176. package/dist/src/core/furnace-jsconfig.js +25 -18
  177. package/dist/src/core/furnace-marker.js +0 -1
  178. package/dist/src/core/furnace-operation.js +0 -1
  179. package/dist/src/core/furnace-refresh.js +0 -1
  180. package/dist/src/core/furnace-registration-ast.d.ts +14 -0
  181. package/dist/src/core/furnace-registration-ast.js +1 -2
  182. package/dist/src/core/furnace-registration-remove.js +0 -1
  183. package/dist/src/core/furnace-registration-validate.js +1 -5
  184. package/dist/src/core/furnace-registration.js +0 -1
  185. package/dist/src/core/furnace-rollback.js +0 -1
  186. package/dist/src/core/furnace-scanner.js +0 -1
  187. package/dist/src/core/furnace-staleness.js +0 -1
  188. package/dist/src/core/furnace-state-persist.d.ts +72 -0
  189. package/dist/src/core/furnace-state-persist.js +118 -0
  190. package/dist/src/core/furnace-step-errors.d.ts +23 -0
  191. package/dist/src/core/furnace-step-errors.js +25 -0
  192. package/dist/src/core/furnace-stories.js +0 -1
  193. package/dist/src/core/furnace-validate-accessibility.js +0 -1
  194. package/dist/src/core/furnace-validate-checks.js +0 -1
  195. package/dist/src/core/furnace-validate-compatibility.js +0 -1
  196. package/dist/src/core/furnace-validate-helpers.js +0 -1
  197. package/dist/src/core/furnace-validate-registration.js +7 -7
  198. package/dist/src/core/furnace-validate-structure.d.ts +8 -0
  199. package/dist/src/core/furnace-validate-structure.js +11 -2
  200. package/dist/src/core/furnace-validate.js +0 -1
  201. package/dist/src/core/furnace-version-drift.d.ts +6 -3
  202. package/dist/src/core/furnace-version-drift.js +6 -4
  203. package/dist/src/core/git-base.js +0 -1
  204. package/dist/src/core/git-diff.js +0 -1
  205. package/dist/src/core/git-file-ops.js +0 -1
  206. package/dist/src/core/git-status.d.ts +13 -0
  207. package/dist/src/core/git-status.js +32 -2
  208. package/dist/src/core/git.d.ts +10 -1
  209. package/dist/src/core/git.js +65 -8
  210. package/dist/src/core/license-headers.d.ts +16 -0
  211. package/dist/src/core/license-headers.js +19 -1
  212. package/dist/src/core/lint-cache.js +0 -1
  213. package/dist/src/core/lint-projection.js +0 -1
  214. package/dist/src/core/mach-build-artifacts.js +0 -1
  215. package/dist/src/core/mach-error-hints.js +27 -1
  216. package/dist/src/core/mach-mozconfig.js +0 -1
  217. package/dist/src/core/mach-python.js +0 -1
  218. package/dist/src/core/mach-resource-shim.js +0 -1
  219. package/dist/src/core/mach.js +0 -1
  220. package/dist/src/core/manifest-helpers.js +0 -1
  221. package/dist/src/core/manifest-register.js +0 -1
  222. package/dist/src/core/manifest-rules.js +0 -1
  223. package/dist/src/core/manifest-tokenizers.js +0 -1
  224. package/dist/src/core/marionette-port.d.ts +8 -0
  225. package/dist/src/core/marionette-port.js +34 -3
  226. package/dist/src/core/marionette-preflight.js +0 -1
  227. package/dist/src/core/markdown-table.js +0 -1
  228. package/dist/src/core/ownership-table.js +2 -9
  229. package/dist/src/core/parser-fallback.js +0 -1
  230. package/dist/src/core/patch-apply-fuzz.d.ts +27 -9
  231. package/dist/src/core/patch-apply-fuzz.js +71 -34
  232. package/dist/src/core/patch-apply.d.ts +30 -0
  233. package/dist/src/core/patch-apply.js +152 -39
  234. package/dist/src/core/patch-artifact-normalize.d.ts +18 -5
  235. package/dist/src/core/patch-artifact-normalize.js +19 -15
  236. package/dist/src/core/patch-export-coverage.js +0 -1
  237. package/dist/src/core/patch-export-metadata.js +0 -1
  238. package/dist/src/core/patch-export-update.js +2 -3
  239. package/dist/src/core/patch-export.d.ts +2 -6
  240. package/dist/src/core/patch-export.js +24 -48
  241. package/dist/src/core/patch-files.js +0 -1
  242. package/dist/src/core/patch-identifier-suggest.js +0 -1
  243. package/dist/src/core/patch-lint-checkjs.js +0 -1
  244. package/dist/src/core/patch-lint-chrome-jsdoc.js +0 -1
  245. package/dist/src/core/patch-lint-cross.js +0 -1
  246. package/dist/src/core/patch-lint-css.js +0 -1
  247. package/dist/src/core/patch-lint-diff-tag.js +0 -1
  248. package/dist/src/core/patch-lint-diff.d.ts +2 -1
  249. package/dist/src/core/patch-lint-diff.js +17 -32
  250. package/dist/src/core/patch-lint-imports.js +0 -1
  251. package/dist/src/core/patch-lint-jsdoc.js +0 -1
  252. package/dist/src/core/patch-lint-observer.js +0 -1
  253. package/dist/src/core/patch-lint-ownership.js +0 -1
  254. package/dist/src/core/patch-lint-reexports.js +0 -1
  255. package/dist/src/core/patch-lint.d.ts +37 -2
  256. package/dist/src/core/patch-lint.js +48 -24
  257. package/dist/src/core/patch-lock.js +0 -1
  258. package/dist/src/core/patch-manifest-consistency.js +9 -1
  259. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  260. package/dist/src/core/patch-manifest-io.js +37 -12
  261. package/dist/src/core/patch-manifest-query.d.ts +6 -0
  262. package/dist/src/core/patch-manifest-query.js +27 -12
  263. package/dist/src/core/patch-manifest-resolve.js +0 -1
  264. package/dist/src/core/patch-manifest-validate.js +0 -1
  265. package/dist/src/core/patch-manifest.d.ts +1 -1
  266. package/dist/src/core/patch-manifest.js +1 -2
  267. package/dist/src/core/patch-parse.d.ts +59 -0
  268. package/dist/src/core/patch-parse.js +209 -92
  269. package/dist/src/core/patch-policy.js +0 -1
  270. package/dist/src/core/patch-registration-refs.js +16 -26
  271. package/dist/src/core/patch-source-metadata.js +0 -1
  272. package/dist/src/core/patch-transform.d.ts +4 -0
  273. package/dist/src/core/patch-transform.js +30 -38
  274. package/dist/src/core/rebase-session.js +0 -1
  275. package/dist/src/core/register-browser-content.js +0 -1
  276. package/dist/src/core/register-module.js +0 -1
  277. package/dist/src/core/register-result.js +0 -1
  278. package/dist/src/core/register-scaffold.js +0 -1
  279. package/dist/src/core/register-shared-css.js +0 -1
  280. package/dist/src/core/register-test-manifest.js +0 -1
  281. package/dist/src/core/register-xpcshell-test.js +0 -1
  282. package/dist/src/core/shared-ftl.js +0 -1
  283. package/dist/src/core/signal-critical.js +0 -1
  284. package/dist/src/core/smoke-patterns.js +0 -1
  285. package/dist/src/core/state-file.js +0 -1
  286. package/dist/src/core/status-classify.d.ts +16 -0
  287. package/dist/src/core/status-classify.js +24 -16
  288. package/dist/src/core/test-harness-crash.d.ts +64 -7
  289. package/dist/src/core/test-harness-crash.js +196 -15
  290. package/dist/src/core/test-harness-output.js +0 -1
  291. package/dist/src/core/test-path-scope.d.ts +62 -0
  292. package/dist/src/core/test-path-scope.js +148 -0
  293. package/dist/src/core/test-stale-check.js +0 -1
  294. package/dist/src/core/test-stale-symlink.js +0 -1
  295. package/dist/src/core/test-xpcshell-retry.js +0 -1
  296. package/dist/src/core/token-coverage.js +0 -1
  297. package/dist/src/core/token-dark-mode.js +0 -1
  298. package/dist/src/core/token-docs.js +0 -1
  299. package/dist/src/core/token-manager.js +0 -1
  300. package/dist/src/core/token-scaffold.js +0 -1
  301. package/dist/src/core/token-variant.js +0 -1
  302. package/dist/src/core/toolchain-preflight.d.ts +85 -0
  303. package/dist/src/core/toolchain-preflight.js +256 -0
  304. package/dist/src/core/typecheck-shim.d.ts +4 -1
  305. package/dist/src/core/typecheck-shim.js +61 -18
  306. package/dist/src/core/typecheck.js +0 -1
  307. package/dist/src/core/wire-destroy.js +0 -1
  308. package/dist/src/core/wire-dom-fragment.js +0 -1
  309. package/dist/src/core/wire-init.js +0 -1
  310. package/dist/src/core/wire-subscript.js +0 -1
  311. package/dist/src/core/wire-targets.js +0 -1
  312. package/dist/src/core/wire-utils.js +0 -1
  313. package/dist/src/core/xpcshell-appdir.js +1 -4
  314. package/dist/src/errors/base.d.ts +29 -0
  315. package/dist/src/errors/base.js +35 -1
  316. package/dist/src/errors/build.js +0 -1
  317. package/dist/src/errors/codes.d.ts +6 -0
  318. package/dist/src/errors/codes.js +6 -1
  319. package/dist/src/errors/config.js +0 -1
  320. package/dist/src/errors/download.js +2 -1
  321. package/dist/src/errors/furnace.d.ts +11 -1
  322. package/dist/src/errors/furnace.js +6 -5
  323. package/dist/src/errors/git.js +0 -1
  324. package/dist/src/errors/patch.d.ts +17 -0
  325. package/dist/src/errors/patch.js +29 -1
  326. package/dist/src/errors/rebase.js +0 -1
  327. package/dist/src/errors/run.js +1 -1
  328. package/dist/src/index.js +0 -1
  329. package/dist/src/types/cli.js +0 -1
  330. package/dist/src/types/commands/index.js +0 -1
  331. package/dist/src/types/commands/options.d.ts +20 -5
  332. package/dist/src/types/commands/options.js +0 -1
  333. package/dist/src/types/commands/patches.d.ts +8 -0
  334. package/dist/src/types/commands/patches.js +0 -1
  335. package/dist/src/types/commands/project.d.ts +11 -2
  336. package/dist/src/types/commands/project.js +0 -1
  337. package/dist/src/types/config.d.ts +32 -0
  338. package/dist/src/types/config.js +0 -1
  339. package/dist/src/types/furnace.d.ts +10 -0
  340. package/dist/src/types/furnace.js +0 -1
  341. package/dist/src/types/index.js +0 -1
  342. package/dist/src/types/typecheck.js +0 -1
  343. package/dist/src/utils/elapsed.js +0 -1
  344. package/dist/src/utils/errors.js +0 -1
  345. package/dist/src/utils/fs.js +37 -5
  346. package/dist/src/utils/logger.d.ts +7 -0
  347. package/dist/src/utils/logger.js +85 -3
  348. package/dist/src/utils/options.d.ts +15 -0
  349. package/dist/src/utils/options.js +25 -1
  350. package/dist/src/utils/package-root.js +11 -4
  351. package/dist/src/utils/parse.js +0 -1
  352. package/dist/src/utils/paths.js +0 -1
  353. package/dist/src/utils/platform.js +0 -1
  354. package/dist/src/utils/process.d.ts +33 -4
  355. package/dist/src/utils/process.js +183 -28
  356. package/dist/src/utils/regex.d.ts +13 -3
  357. package/dist/src/utils/regex.js +53 -4
  358. package/dist/src/utils/validation.d.ts +0 -12
  359. package/dist/src/utils/validation.js +13 -26
  360. package/package.json +8 -7
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { ConfigError } from '../errors/config.js';
3
+ import { parseObject } from '../utils/parse.js';
4
+ import { isContainedRelativePath } from '../utils/paths.js';
5
+ function optionalConfigString(rec, key, label) {
6
+ const value = rec.raw(key);
7
+ if (value === undefined)
8
+ return undefined;
9
+ if (typeof value !== 'string') {
10
+ throw new ConfigError(`Config field "${label}" must be a string`);
11
+ }
12
+ return value;
13
+ }
14
+ function optionalConfigObject(rec, key) {
15
+ const value = rec.raw(key);
16
+ if (value === undefined)
17
+ return undefined;
18
+ try {
19
+ return rec.object(key);
20
+ }
21
+ catch {
22
+ throw new ConfigError(`Config field "${key}" must be an object`);
23
+ }
24
+ }
25
+ /** Parses the optional `test` block. */
26
+ export function parseTestBlock(rec, config) {
27
+ const testRec = optionalConfigObject(rec, 'test');
28
+ if (!testRec)
29
+ return;
30
+ const out = {};
31
+ const canaryPath = optionalConfigString(testRec, 'canaryPath', 'test.canaryPath');
32
+ if (canaryPath !== undefined) {
33
+ if (canaryPath.trim() === '' || !isContainedRelativePath(canaryPath)) {
34
+ throw new ConfigError('Config field "test.canaryPath" must be a project-relative path');
35
+ }
36
+ out.canaryPath = canaryPath;
37
+ }
38
+ const canaryTimeout = testRec.raw('canaryTimeoutSeconds');
39
+ if (canaryTimeout !== undefined) {
40
+ if (typeof canaryTimeout !== 'number' ||
41
+ !Number.isInteger(canaryTimeout) ||
42
+ canaryTimeout < 1 ||
43
+ canaryTimeout > 600) {
44
+ throw new ConfigError('Config field "test.canaryTimeoutSeconds" must be an integer in 1..600');
45
+ }
46
+ out.canaryTimeoutSeconds = canaryTimeout;
47
+ }
48
+ config.test = out;
49
+ }
50
+ /** Parses optional project-specific external toolchain doctor probes. */
51
+ export function parseExternalToolchainsBlock(rec, config) {
52
+ const raw = rec.raw('externalToolchains');
53
+ if (raw === undefined)
54
+ return;
55
+ if (!Array.isArray(raw)) {
56
+ throw new ConfigError('Config field "externalToolchains" must be an array');
57
+ }
58
+ config.externalToolchains = raw.map((entry, index) => {
59
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
60
+ throw new ConfigError(`Config field "externalToolchains[${String(index)}]" must be an object`);
61
+ }
62
+ const item = entry;
63
+ const name = item['name'];
64
+ if (typeof name !== 'string' || name.trim() === '') {
65
+ throw new ConfigError(`Config field "externalToolchains[${String(index)}].name" must be a non-empty string`);
66
+ }
67
+ const tools = item['tools'];
68
+ if (!Array.isArray(tools) || tools.length === 0) {
69
+ throw new ConfigError(`Config field "externalToolchains[${String(index)}].tools" must be a non-empty array`);
70
+ }
71
+ return {
72
+ name,
73
+ tools: tools.map((tool, toolIndex) => parseExternalToolRequirement(tool, index, toolIndex)),
74
+ };
75
+ });
76
+ }
77
+ function parseExternalToolRequirement(tool, index, toolIndex) {
78
+ const label = `externalToolchains[${String(index)}].tools[${String(toolIndex)}]`;
79
+ if (typeof tool !== 'object' || tool === null || Array.isArray(tool)) {
80
+ throw new ConfigError(`Config field "${label}" must be an object`);
81
+ }
82
+ const toolRec = tool;
83
+ const toolName = toolRec['name'];
84
+ if (typeof toolName !== 'string' || toolName.trim() === '') {
85
+ throw new ConfigError(`Config field "${label}.name" must be a non-empty string`);
86
+ }
87
+ const toolPath = toolRec['path'];
88
+ if (toolPath !== undefined && (typeof toolPath !== 'string' || toolPath.trim() === '')) {
89
+ throw new ConfigError(`Config field "${label}.path" must be a non-empty string`);
90
+ }
91
+ const xcrun = toolRec['xcrun'];
92
+ if (xcrun !== undefined && typeof xcrun !== 'boolean') {
93
+ throw new ConfigError(`Config field "${label}.xcrun" must be a boolean`);
94
+ }
95
+ const required = toolRec['required'];
96
+ if (required !== undefined && typeof required !== 'boolean') {
97
+ throw new ConfigError(`Config field "${label}.required" must be a boolean`);
98
+ }
99
+ return {
100
+ name: toolName,
101
+ ...(toolPath !== undefined ? { path: toolPath } : {}),
102
+ ...(xcrun !== undefined ? { xcrun } : {}),
103
+ ...(required !== undefined ? { required } : {}),
104
+ };
105
+ }
@@ -9,6 +9,7 @@ import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.
9
9
  import { isValidAppId, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
10
10
  import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
11
11
  import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
12
+ import { parseExternalToolchainsBlock, parseTestBlock } from './config-validate-test-toolchains.js';
12
13
  /**
13
14
  * Parses and validates the four required identity fields (`name`,
14
15
  * `vendor`, `appId`, `binaryName`): all non-empty strings, with
@@ -145,6 +146,8 @@ export function validateConfig(data) {
145
146
  const firefox = parseFirefoxBlock(rec);
146
147
  const config = { ...identity, firefox };
147
148
  parseBuildBlock(rec, config);
149
+ parseTestBlock(rec, config);
150
+ parseExternalToolchainsBlock(rec, config);
148
151
  parseWireBlock(rec, config);
149
152
  parseLicenseField(rec, config);
150
153
  // Marker comment — appended to lines FireForge writes into upstream files.
@@ -429,4 +432,3 @@ function parseTypecheckProjectOverrides(raw, projects) {
429
432
  }
430
433
  return out;
431
434
  }
432
- //# sourceMappingURL=config-validate.js.map
@@ -149,4 +149,3 @@ export async function withConfigFileLock(root, operation) {
149
149
  `(age: ${Math.round(ageMs / 1000)}s). A previous fireforge process may have crashed.`,
150
150
  });
151
151
  }
152
- //# sourceMappingURL=config.js.map
@@ -134,4 +134,3 @@ export async function appendHistory(patchesDir, entry) {
134
134
  const line = `${JSON.stringify(record)}\n`;
135
135
  await appendFile(join(patchesDir, HISTORY_LOG_FILENAME), line, 'utf-8');
136
136
  }
137
- //# sourceMappingURL=destructive.js.map
@@ -194,7 +194,6 @@ function coalescedHunk(oldLines, newLines, context) {
194
194
  return [];
195
195
  }
196
196
  const contextStart = Math.max(0, firstDiff - context);
197
- const contextEndOld = Math.min(oldLines.length - 1, lastOldDiff + context);
198
197
  const contextEndNew = Math.min(newLines.length - 1, lastNewDiff + context);
199
198
  const hunkLines = [];
200
199
  for (let k = contextStart; k < firstDiff; k++) {
@@ -206,13 +205,21 @@ function coalescedHunk(oldLines, newLines, context) {
206
205
  for (let k = firstDiff; k <= lastNewDiff; k++) {
207
206
  hunkLines.push({ marker: '+', content: newLines[k] });
208
207
  }
209
- const trailingStart = Math.max(lastOldDiff + 1, lastNewDiff + 1);
210
- const trailingEnd = Math.max(contextEndOld, contextEndNew);
211
- for (let k = trailingStart; k <= trailingEnd && k < newLines.length; k++) {
208
+ // Trailing context comes from the common suffix, which lives at
209
+ // DIFFERENT indices on each side (lastOldDiff+1… vs lastNewDiff+1…).
210
+ // The previous mixed-coordinate loop (max() of both sides indexing
211
+ // newLines) emitted wrong context on asymmetric edits, and the trailing
212
+ // lines were excluded from the @@ header lengths entirely — so on
213
+ // >LCS_LINE_LIMIT files the rendered header disagreed with the body.
214
+ for (let k = lastNewDiff + 1; k <= contextEndNew; k++) {
212
215
  hunkLines.push({ marker: ' ', content: newLines[k] });
213
216
  }
214
- const oldLength = lastOldDiff - firstDiff + 1 + (firstDiff - contextStart);
215
- const newLength = lastNewDiff - firstDiff + 1 + (firstDiff - contextStart);
217
+ const leadingContext = firstDiff - contextStart;
218
+ // Common-suffix symmetry: oldLen-1-lastOldDiff === newLen-1-lastNewDiff,
219
+ // so both sides clamp to the same trailing context count.
220
+ const trailingContext = contextEndNew - lastNewDiff;
221
+ const oldLength = lastOldDiff - firstDiff + 1 + leadingContext + trailingContext;
222
+ const newLength = lastNewDiff - firstDiff + 1 + leadingContext + trailingContext;
216
223
  return [
217
224
  {
218
225
  oldStart: contextStart + 1,
@@ -265,4 +272,3 @@ export function renderHunks(hunks) {
265
272
  }
266
273
  return out;
267
274
  }
268
- //# sourceMappingURL=diff-hunks.js.map
@@ -61,4 +61,3 @@ export async function collectChangedEnginePaths(engineDir, baseline, contextLabe
61
61
  }
62
62
  return [...collected].sort();
63
63
  }
64
- //# sourceMappingURL=engine-changes.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ *
3
+ */
4
+ export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>): Promise<T>;
5
+ /**
6
+ *
7
+ */
8
+ export declare function snapshotEngineGeneration(engineDir: string): Promise<string>;
9
+ /**
10
+ *
11
+ */
12
+ export declare function assertEngineGenerationUnchanged(engineDir: string, before: string): Promise<void>;
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { GeneralError } from '../errors/base.js';
4
+ import { withFileLock } from './file-lock.js';
5
+ import { git } from './git-base.js';
6
+ const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
7
+ /**
8
+ *
9
+ */
10
+ export async function withEngineSessionLock(projectRoot, command, operation) {
11
+ if (process.env['FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST'] !== '1' &&
12
+ (process.env['NODE_ENV'] === 'test' || process.env['VITEST'] !== undefined)) {
13
+ return operation();
14
+ }
15
+ return withFileLock(join(projectRoot, ENGINE_SESSION_LOCK_PATH), operation, {
16
+ timeoutMs: 1000,
17
+ ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`],
18
+ onTimeoutMessage: `Another FireForge engine-mutating command is already running. ` +
19
+ `Wait for it to finish, then retry \`${command}\`.`,
20
+ onStaleLockMessage: (ageMs) => `Removed stale FireForge engine session lock (${Math.round(ageMs / 1000)}s old).`,
21
+ });
22
+ }
23
+ /**
24
+ *
25
+ */
26
+ export async function snapshotEngineGeneration(engineDir) {
27
+ try {
28
+ const head = (await git(['rev-parse', 'HEAD'], engineDir)).trim();
29
+ const status = await git(['status', '--porcelain=v1', '-z'], engineDir);
30
+ return `${head}\0${status}`;
31
+ }
32
+ catch (error) {
33
+ return `unavailable:${error instanceof Error ? error.message : String(error)}`;
34
+ }
35
+ }
36
+ /**
37
+ *
38
+ */
39
+ export async function assertEngineGenerationUnchanged(engineDir, before) {
40
+ const after = await snapshotEngineGeneration(engineDir);
41
+ if (after === before)
42
+ return;
43
+ throw new GeneralError('engine/ changed while `fireforge test` was running, so this test verdict is invalid/inconclusive.\n\n' +
44
+ 'Use one writer per engine checkout, then rebuild and rerun the affected tests.');
45
+ }
@@ -2,8 +2,16 @@ export interface FileLockOptions {
2
2
  timeoutMs?: number;
3
3
  pollMs?: number;
4
4
  staleMs?: number;
5
+ /**
6
+ * Interval between stale-lock probes while waiting (defaults to
7
+ * {@link STALE_REPROBE_INTERVAL_MS}). Exists mainly so tests can exercise
8
+ * the mid-wait reaping path without multi-second sleeps.
9
+ */
10
+ staleReprobeMs?: number;
5
11
  onTimeoutMessage?: string;
6
12
  onStaleLockMessage?: (ageMs: number) => string | undefined;
13
+ /** Extra owner-file lines for human diagnostics; ignored by lock mechanics. */
14
+ ownerMetadata?: readonly string[];
7
15
  }
8
16
  /** Derives the sibling lock-directory path used to guard a file-based resource. */
9
17
  export declare function createSiblingLockPath(filePath: string, suffix?: string): string;
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { toError } from '../utils/errors.js';
5
6
  import { ensureDir } from '../utils/fs.js';
@@ -7,6 +8,15 @@ import { verbose, warn } from '../utils/logger.js';
7
8
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
8
9
  const DEFAULT_LOCK_POLL_MS = 50;
9
10
  const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
11
+ /**
12
+ * Minimum interval between stale-lock probes while a waiter polls. The probe
13
+ * used to run exactly once per waiter: if the holder died *after* that single
14
+ * probe, the waiter polled a permanently dead lock until `timeoutMs` — for
15
+ * the build lock that meant up to 24 hours (see `withBuildLock` in mach.ts).
16
+ * Re-probing on a small interval bounds that hang to seconds while keeping
17
+ * the per-poll cost negligible (one stat + one small read every ~5 s).
18
+ */
19
+ const STALE_REPROBE_INTERVAL_MS = 5_000;
10
20
  function sleep(ms) {
11
21
  return new Promise((resolve) => {
12
22
  setTimeout(resolve, ms);
@@ -39,6 +49,100 @@ function isProcessAlive(pid) {
39
49
  return getNodeErrorCode(error) !== 'ESRCH';
40
50
  }
41
51
  }
52
+ /**
53
+ * Reads the owner PID (line 1) and acquisition token (line 2) from a lock
54
+ * directory's PID file. Returns `{ present: false }` when the PID file is
55
+ * missing or the PID does not parse as a finite integer (caller falls back
56
+ * to the age-only staleness heuristic).
57
+ *
58
+ * File format note: external readers (`doctor-furnace.ts`, older FireForge
59
+ * releases) do `parseInt(content.trim(), 10)`, which parses the leading
60
+ * digits of a multi-line file — so adding the token line stays compatible.
61
+ */
62
+ async function readLockOwner(lockPath) {
63
+ try {
64
+ const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
65
+ const [pidLine, tokenLine] = pidContent.split('\n');
66
+ const pid = parseInt((pidLine ?? '').trim(), 10);
67
+ if (Number.isFinite(pid)) {
68
+ const token = tokenLine?.trim();
69
+ if (token !== undefined && token.length > 0) {
70
+ return { present: true, pid, token };
71
+ }
72
+ return { present: true, pid };
73
+ }
74
+ }
75
+ catch {
76
+ // PID file missing or unreadable — treat as absent.
77
+ }
78
+ return { present: false };
79
+ }
80
+ /**
81
+ * Reaps a lock directory believed to be stale, safely against concurrent
82
+ * reapers and a concurrent legitimate re-acquisition.
83
+ *
84
+ * The naive stat → read-PID → `rm` sequence had a TOCTOU hole: two waiters
85
+ * could both observe the dead owner, one would remove the stale dir and
86
+ * `mkdir` its own lock, and the other's already-decided `rm` then deleted
87
+ * the *fresh* lock — two processes inside the critical section at once.
88
+ *
89
+ * Rename-aside closes it: `rename()` is atomic, so exactly one reaper wins
90
+ * ownership of the directory (the loser gets ENOENT and simply re-polls),
91
+ * and nothing ever deletes the live path directly. After winning the rename
92
+ * we re-read the owner from the renamed dir — if it is now a *live* process
93
+ * (the lock was released and re-acquired between our staleness read and the
94
+ * rename), we rename it back instead of reaping.
95
+ *
96
+ * @returns true when the caller should immediately retry `mkdir` (the lock
97
+ * path is now free or was freed by someone else), false when the lock is
98
+ * legitimately held and the caller should keep waiting.
99
+ */
100
+ async function reapStaleLock(lockPath, ageMs, onStaleLockMessage) {
101
+ const reapPath = `${lockPath}.reaping-${String(process.pid)}-${randomUUID()}`;
102
+ try {
103
+ await rename(lockPath, reapPath);
104
+ }
105
+ catch (error) {
106
+ if (getNodeErrorCode(error) === 'ENOENT') {
107
+ // Another reaper won, or the owner released — either way, retry mkdir.
108
+ verbose(`Stale lock disappeared before reaping completed: ${lockPath}`);
109
+ return true;
110
+ }
111
+ verbose(`Could not rename stale lock aside (${lockPath}): ${toError(error).message}`);
112
+ return false;
113
+ }
114
+ // We exclusively own reapPath now. Re-verify: the owner may have changed
115
+ // between the staleness probe and the rename (release + fresh acquire).
116
+ const owner = await readLockOwner(reapPath);
117
+ if (owner.present && isProcessAlive(owner.pid)) {
118
+ try {
119
+ await rename(reapPath, lockPath);
120
+ verbose(`Aborted stale-lock reap of ${lockPath}: owner PID ${String(owner.pid)} is alive (lock was re-acquired mid-probe)`);
121
+ }
122
+ catch (restoreError) {
123
+ // Rename-back can only fail if a third process mkdir'd the lock path
124
+ // in this instant. The displaced live owner keeps running without its
125
+ // lock — surface it loudly instead of hiding the lost exclusion.
126
+ warn(`Could not restore live lock for PID ${String(owner.pid)} at ${lockPath} ` +
127
+ `(${toError(restoreError).message}). Displaced lock kept at ${reapPath} for inspection.`);
128
+ }
129
+ return false;
130
+ }
131
+ const staleMessage = onStaleLockMessage?.(ageMs);
132
+ if (staleMessage) {
133
+ warn(staleMessage);
134
+ }
135
+ else {
136
+ verbose(`Removing stale lock ${lockPath} (age: ${Math.round(ageMs / 1000)}s)`);
137
+ }
138
+ await rm(reapPath, { recursive: true, force: true });
139
+ return true;
140
+ }
141
+ /**
142
+ * Probes a contended lock for staleness and reaps it when the owner is gone.
143
+ *
144
+ * @returns true when the caller should immediately retry `mkdir`.
145
+ */
42
146
  async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
43
147
  try {
44
148
  const lockStat = await stat(lockPath);
@@ -48,27 +152,19 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
48
152
  // 5 minutes) was never removed even when its PID file pointed at a
49
153
  // process that had already exited. That's the exact situation an
50
154
  // operator lands in after SIGINT'ing `furnace preview` — the signal
51
- // handler calls `process.exit`, `withFileLock`'s `finally { rm }`
52
- // never runs, and the next command has to either wait 5 minutes for
53
- // the staleness gate or manually remove the lock directory. With the
54
- // PID-first check, an explicitly-dead owner unblocks immediately.
55
- const pidCheck = await readLockOwnerPid(lockPath);
56
- if (pidCheck.present) {
57
- if (!isProcessAlive(pidCheck.pid)) {
58
- const staleMessage = onStaleLockMessage?.(ageMs);
59
- if (staleMessage) {
60
- warn(staleMessage);
61
- }
62
- else {
63
- verbose(`Lock at ${lockPath} owner PID ${pidCheck.pid} is no longer running — removing (age: ${Math.round(ageMs / 1000)}s)`);
64
- }
65
- await rm(lockPath, { recursive: true, force: true });
66
- return true;
155
+ // handler calls `process.exit`, `withFileLock`'s release never runs,
156
+ // and the next command has to either wait 5 minutes for the staleness
157
+ // gate or manually remove the lock directory. With the PID-first check,
158
+ // an explicitly-dead owner unblocks immediately.
159
+ const owner = await readLockOwner(lockPath);
160
+ if (owner.present) {
161
+ if (!isProcessAlive(owner.pid)) {
162
+ return await reapStaleLock(lockPath, ageMs, onStaleLockMessage);
67
163
  }
68
164
  // PID is alive — respect it regardless of age. A slow `mach build`
69
165
  // legitimately holds the lock past the stale threshold and we don't
70
166
  // want to race-remove it.
71
- verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${pidCheck.pid} is still running — not removing`);
167
+ verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${String(owner.pid)} is still running — not removing`);
72
168
  return false;
73
169
  }
74
170
  // No readable PID file. Fall back to the age-only heuristic so locks
@@ -77,12 +173,7 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
77
173
  if (ageMs <= staleMs) {
78
174
  return false;
79
175
  }
80
- const staleMessage = onStaleLockMessage?.(ageMs);
81
- if (staleMessage) {
82
- warn(staleMessage);
83
- }
84
- await rm(lockPath, { recursive: true, force: true });
85
- return true;
176
+ return await reapStaleLock(lockPath, ageMs, onStaleLockMessage);
86
177
  }
87
178
  catch (error) {
88
179
  const code = getNodeErrorCode(error);
@@ -95,30 +186,38 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
95
186
  }
96
187
  }
97
188
  /**
98
- * Reads the owner PID from a lock directory's PID file. Returns `{ present:
99
- * false }` when the PID file is missing or the content does not parse as a
100
- * finite integer (caller falls back to the age-only staleness heuristic).
189
+ * Releases a lock previously acquired by this process, verifying ownership
190
+ * first. The unconditional `finally { rm }` this replaces would remove
191
+ * *whichever* lock directory was present if a stale-lock reaper had (in a
192
+ * pathological race) replaced our lock, we deleted the new owner's lock and
193
+ * compounded the double-acquisition. When the PID file no longer names us,
194
+ * warn and leave the directory alone.
195
+ *
196
+ * `ownerFileWritten` is false when our own owner-file write failed at
197
+ * acquisition (non-fatal there); ownership then cannot be verified and we
198
+ * fall back to unconditional removal — identical to the historical behavior
199
+ * and strictly better than leaking the lock.
101
200
  */
102
- async function readLockOwnerPid(lockPath) {
103
- try {
104
- const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
105
- const pid = parseInt(pidContent.trim(), 10);
106
- if (Number.isFinite(pid)) {
107
- return { present: true, pid };
201
+ async function releaseLock(lockPath, ownerToken, ownerFileWritten) {
202
+ if (ownerFileWritten) {
203
+ const owner = await readLockOwner(lockPath);
204
+ if (owner.present &&
205
+ (owner.pid !== process.pid || (owner.token !== undefined && owner.token !== ownerToken))) {
206
+ warn(`Not removing lock ${lockPath}: it is now owned by PID ${String(owner.pid)} — ` +
207
+ 'this process no longer holds it.');
208
+ return;
108
209
  }
109
210
  }
110
- catch {
111
- // PID file missing or unreadable — treat as absent.
112
- }
113
- return { present: false };
211
+ await rm(lockPath, { recursive: true, force: true });
114
212
  }
115
213
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
116
214
  export async function withFileLock(lockPath, operation, options = {}) {
117
215
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
118
216
  const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
119
217
  const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
218
+ const staleReprobeMs = options.staleReprobeMs ?? STALE_REPROBE_INTERVAL_MS;
120
219
  const deadline = Date.now() + timeoutMs;
121
- let attemptedStaleRecovery = false;
220
+ let lastStaleProbeAt;
122
221
  await ensureDir(dirname(lockPath));
123
222
  for (;;) {
124
223
  try {
@@ -134,32 +233,42 @@ export async function withFileLock(lockPath, operation, options = {}) {
134
233
  if (!isAlreadyLocked) {
135
234
  throw error;
136
235
  }
137
- if (!attemptedStaleRecovery) {
138
- attemptedStaleRecovery = true;
236
+ if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
237
+ lastStaleProbeAt = Date.now();
139
238
  if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
140
239
  continue;
141
240
  }
142
241
  }
143
242
  if (Date.now() >= deadline) {
144
- throw new Error(options.onTimeoutMessage ??
145
- `Timed out waiting for file lock ${lockPath}. Remove the lock directory if it is stale.`, { cause: error });
243
+ const owner = await readLockOwner(lockPath);
244
+ const holderHint = owner.present && isProcessAlive(owner.pid)
245
+ ? ` It is held by running process ${String(owner.pid)} — wait for it to finish, or stop it and retry.`
246
+ : ' If no other FireForge process is running, remove the lock directory and retry.';
247
+ throw new Error(options.onTimeoutMessage ?? `Timed out waiting for file lock ${lockPath}.${holderHint}`, { cause: error });
146
248
  }
147
249
  await sleep(pollMs);
148
250
  }
149
251
  }
150
- // Write PID into the lock directory so stale-lock recovery can check
151
- // whether the owning process is still alive before removing.
252
+ // Stamp ownership (PID + per-acquisition token) into the lock directory so
253
+ // stale-lock recovery can check liveness and release can verify the lock
254
+ // is still ours before removing it.
255
+ const ownerToken = randomUUID();
256
+ let ownerFileWritten = false;
152
257
  try {
153
- await writeFile(join(lockPath, LOCK_PID_FILE), String(process.pid), 'utf-8');
258
+ const metadata = options.ownerMetadata
259
+ ?.map((line) => line.replace(/\r?\n/g, ' ').trim())
260
+ .filter((line) => line.length > 0) ?? [];
261
+ await writeFile(join(lockPath, LOCK_PID_FILE), `${String(process.pid)}\n${ownerToken}\n${metadata.length > 0 ? `${metadata.join('\n')}\n` : ''}`, 'utf-8');
262
+ ownerFileWritten = true;
154
263
  }
155
264
  catch {
156
- // Non-fatal: stale recovery falls back to age-only heuristic
265
+ // Non-fatal: stale recovery falls back to age-only heuristic and release
266
+ // falls back to unconditional removal.
157
267
  }
158
268
  try {
159
269
  return await operation();
160
270
  }
161
271
  finally {
162
- await rm(lockPath, { recursive: true, force: true });
272
+ await releaseLock(lockPath, ownerToken, ownerFileWritten);
163
273
  }
164
274
  }
165
- //# sourceMappingURL=file-lock.js.map
@@ -12,6 +12,10 @@ export interface ResolvedArchive {
12
12
  url: string;
13
13
  filename: string;
14
14
  metadataFilename: string;
15
+ /** URL of Mozilla's published SHA256SUMS file for the release. */
16
+ checksumsUrl: string;
17
+ /** The archive's path as listed inside SHA256SUMS (relative to the release dir). */
18
+ pathInChecksums: string;
15
19
  }
16
20
  /**
17
21
  * Sidecar metadata stored alongside a cached archive.
@@ -62,6 +62,11 @@ export function resolveArchive(version, product = 'firefox') {
62
62
  url: `${getArchiveBaseUrl(product)}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
63
63
  filename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz`,
64
64
  metadataFilename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz.json`,
65
+ // Mozilla publishes one SHA256SUMS per release directory listing every
66
+ // artifact by its release-relative path. Downloads verify against it by
67
+ // default (fail closed on mismatch), so a compromised CDN response
68
+ // cannot silently become the trusted git baseline.
69
+ checksumsUrl: `${getArchiveBaseUrl(product)}/${archiveVersion}/SHA256SUMS`,
70
+ pathInChecksums: `source/firefox-${archiveVersion}.source.tar.xz`,
65
71
  };
66
72
  }
67
- //# sourceMappingURL=firefox-archive.js.map