@hominis/fireforge 0.40.0 → 0.41.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 (253) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +13 -3
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +16 -7
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +12 -59
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/build-audit-platform.js +6 -1
  118. package/dist/src/core/build-audit-registration.js +2 -0
  119. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  120. package/dist/src/core/build-audit-resolve.js +34 -13
  121. package/dist/src/core/build-audit.js +4 -20
  122. package/dist/src/core/build-baseline-types.d.ts +29 -8
  123. package/dist/src/core/build-baseline-types.js +2 -1
  124. package/dist/src/core/build-baseline.d.ts +10 -2
  125. package/dist/src/core/build-baseline.js +39 -8
  126. package/dist/src/core/build-prepare.d.ts +4 -0
  127. package/dist/src/core/build-prepare.js +7 -1
  128. package/dist/src/core/config-paths.d.ts +1 -1
  129. package/dist/src/core/config-paths.js +2 -0
  130. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  131. package/dist/src/core/config-validate.js +34 -36
  132. package/dist/src/core/config.js +16 -3
  133. package/dist/src/core/coverage-extend.d.ts +89 -0
  134. package/dist/src/core/coverage-extend.js +188 -0
  135. package/dist/src/core/destructive.js +3 -1
  136. package/dist/src/core/discard-baseline.js +18 -4
  137. package/dist/src/core/engine-precondition.d.ts +35 -0
  138. package/dist/src/core/engine-precondition.js +50 -0
  139. package/dist/src/core/engine-session-lock.d.ts +38 -1
  140. package/dist/src/core/engine-session-lock.js +78 -4
  141. package/dist/src/core/file-lock.d.ts +7 -0
  142. package/dist/src/core/file-lock.js +20 -30
  143. package/dist/src/core/firefox.js +5 -2
  144. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  145. package/dist/src/core/furnace-apply-helpers.js +12 -3
  146. package/dist/src/core/furnace-apply-output.js +9 -0
  147. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  148. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  149. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  150. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  151. package/dist/src/core/furnace-apply.d.ts +16 -4
  152. package/dist/src/core/furnace-apply.js +39 -43
  153. package/dist/src/core/furnace-config.js +2 -0
  154. package/dist/src/core/furnace-css-fragments.js +2 -0
  155. package/dist/src/core/furnace-jsconfig.js +14 -7
  156. package/dist/src/core/furnace-registration-ast.js +8 -8
  157. package/dist/src/core/furnace-registration-remove.js +6 -3
  158. package/dist/src/core/furnace-registration.js +9 -12
  159. package/dist/src/core/furnace-rollback.js +1 -1
  160. package/dist/src/core/furnace-scanner.js +2 -1
  161. package/dist/src/core/furnace-stale-export.js +2 -1
  162. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  163. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  164. package/dist/src/core/furnace-validate-helpers.js +33 -1
  165. package/dist/src/core/furnace-validate-registration.js +16 -0
  166. package/dist/src/core/furnace-validate.js +4 -2
  167. package/dist/src/core/git-diff.js +37 -2
  168. package/dist/src/core/lint-cache.d.ts +1 -1
  169. package/dist/src/core/lint-cache.js +33 -10
  170. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  171. package/dist/src/core/mach-build-artifacts.js +108 -2
  172. package/dist/src/core/mach-resource-shim.js +1 -0
  173. package/dist/src/core/marionette-port.d.ts +16 -0
  174. package/dist/src/core/marionette-port.js +93 -8
  175. package/dist/src/core/marionette-preflight.js +6 -5
  176. package/dist/src/core/patch-apply.d.ts +20 -13
  177. package/dist/src/core/patch-apply.js +32 -33
  178. package/dist/src/core/patch-export.js +3 -5
  179. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  180. package/dist/src/core/patch-lint-checkjs.js +78 -7
  181. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  182. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  183. package/dist/src/core/patch-lint-creators.js +45 -0
  184. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  185. package/dist/src/core/patch-lint-cross.js +16 -32
  186. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  187. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  188. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  189. package/dist/src/core/patch-lint-module-registration.js +88 -0
  190. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  191. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  192. package/dist/src/core/patch-lint-reexports.js +1 -0
  193. package/dist/src/core/patch-lint.d.ts +8 -0
  194. package/dist/src/core/patch-lint.js +84 -6
  195. package/dist/src/core/patch-lock.d.ts +12 -1
  196. package/dist/src/core/patch-lock.js +40 -4
  197. package/dist/src/core/patch-manifest-consistency.js +15 -3
  198. package/dist/src/core/patch-parse.d.ts +8 -0
  199. package/dist/src/core/patch-parse.js +10 -0
  200. package/dist/src/core/patch-policy.d.ts +7 -0
  201. package/dist/src/core/patch-policy.js +4 -1
  202. package/dist/src/core/patch-transform.d.ts +10 -0
  203. package/dist/src/core/patch-transform.js +13 -2
  204. package/dist/src/core/rebase-session.d.ts +44 -2
  205. package/dist/src/core/rebase-session.js +114 -17
  206. package/dist/src/core/register-browser-content.js +2 -1
  207. package/dist/src/core/register-shared-css.js +2 -1
  208. package/dist/src/core/smoke-patterns.d.ts +0 -8
  209. package/dist/src/core/smoke-patterns.js +3 -2
  210. package/dist/src/core/status-binary.d.ts +55 -0
  211. package/dist/src/core/status-binary.js +124 -0
  212. package/dist/src/core/status-classify.d.ts +6 -1
  213. package/dist/src/core/status-classify.js +101 -73
  214. package/dist/src/core/test-harness-crash.d.ts +32 -1
  215. package/dist/src/core/test-harness-crash.js +59 -4
  216. package/dist/src/core/test-path-scope.js +2 -0
  217. package/dist/src/core/test-stale-check.js +1 -20
  218. package/dist/src/core/toolchain-preflight.js +24 -26
  219. package/dist/src/core/tree-guard.d.ts +1 -1
  220. package/dist/src/core/tree-guard.js +67 -12
  221. package/dist/src/core/tree-store.d.ts +86 -10
  222. package/dist/src/core/tree-store.js +310 -72
  223. package/dist/src/core/typecheck.js +2 -1
  224. package/dist/src/core/wire-destroy.js +14 -3
  225. package/dist/src/core/wire-init.js +37 -40
  226. package/dist/src/core/wire-subscript.js +11 -3
  227. package/dist/src/core/wire-utils.js +4 -4
  228. package/dist/src/core/xpcshell-appdir.js +3 -0
  229. package/dist/src/errors/base.d.ts +13 -0
  230. package/dist/src/errors/base.js +13 -0
  231. package/dist/src/errors/rebase.d.ts +13 -0
  232. package/dist/src/errors/rebase.js +17 -0
  233. package/dist/src/types/commands/options.d.ts +86 -1
  234. package/dist/src/types/commands/patches.d.ts +8 -0
  235. package/dist/src/types/furnace.d.ts +7 -0
  236. package/dist/src/utils/build-info.d.ts +31 -0
  237. package/dist/src/utils/build-info.js +98 -0
  238. package/dist/src/utils/concurrency.d.ts +8 -0
  239. package/dist/src/utils/concurrency.js +21 -0
  240. package/dist/src/utils/errors.d.ts +39 -0
  241. package/dist/src/utils/errors.js +55 -0
  242. package/dist/src/utils/fs.js +5 -12
  243. package/dist/src/utils/logger.d.ts +14 -2
  244. package/dist/src/utils/logger.js +43 -13
  245. package/dist/src/utils/options.d.ts +21 -4
  246. package/dist/src/utils/options.js +26 -6
  247. package/dist/src/utils/package-root.js +7 -4
  248. package/dist/src/utils/process-group.js +13 -2
  249. package/dist/src/utils/stdio-drain.d.ts +28 -0
  250. package/dist/src/utils/stdio-drain.js +54 -0
  251. package/dist/src/utils/validation.d.ts +23 -2
  252. package/dist/src/utils/validation.js +29 -3
  253. package/package.json +9 -9
@@ -10,6 +10,7 @@ import { getPersistableAppliedEntry, persistSingleComponentState, shouldPersistS
10
10
  import { countEntriesWithBlockingStepErrors } from '../../core/furnace-step-errors.js';
11
11
  import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
12
12
  import { FurnaceError } from '../../errors/furnace.js';
13
+ import { getNodeErrorCode, toError } from '../../utils/errors.js';
13
14
  import { pathExists } from '../../utils/fs.js';
14
15
  import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
15
16
  /** Interval (ms) for the periodic checksum poll that catches events missed by fs.watch. */
@@ -48,11 +49,11 @@ function checksumMapsEqual(a, b) {
48
49
  * collapsed all causes into one string and made diagnosis difficult.
49
50
  */
50
51
  function classifyWatchApplyError(err) {
51
- const message = err instanceof Error ? err.message : String(err);
52
+ const message = toError(err).message;
52
53
  if (err instanceof FurnaceError) {
53
54
  return `Apply failed: ${message}`;
54
55
  }
55
- const code = err instanceof Error ? err.code : undefined;
56
+ const code = getNodeErrorCode(err);
56
57
  switch (code) {
57
58
  case 'EACCES':
58
59
  case 'EPERM':
@@ -165,7 +166,7 @@ async function runWatchLoop(projectRoot) {
165
166
  // Directory vanished between the pathExists check and fs.watch, or
166
167
  // fs.watch otherwise refused. refreshWatchers will retry on the next
167
168
  // poll tick.
168
- warn(`Could not start watcher on ${dir}: ${err instanceof Error ? err.message : String(err)}`);
169
+ warn(`Could not start watcher on ${dir}: ${toError(err).message}`);
169
170
  return false;
170
171
  }
171
172
  };
@@ -7,9 +7,10 @@ import { readdir } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { confirm } from '@clack/prompts';
9
9
  import { loadConfig } from '../../core/config.js';
10
- import { runFurnaceMutation } from '../../core/furnace-operation.js';
10
+ import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
11
11
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
12
12
  import { FurnaceError } from '../../errors/furnace.js';
13
+ import { toError } from '../../utils/errors.js';
13
14
  import { pathExists, readText, removeDir, removeFile, writeText } from '../../utils/fs.js';
14
15
  import { cancel, info, intro, isCancel, note, outro } from '../../utils/logger.js';
15
16
  import { buildChromeDocPlan, validateChromeDocName } from './chrome-doc.js';
@@ -86,7 +87,16 @@ async function performChromeDocRemoveMutations(args) {
86
87
  await removeEmptyDirIfPresent(join(args.engineDir, 'browser/base/content/test', `${args.binaryName}-xpcshell`), journal);
87
88
  }
88
89
  catch (error) {
89
- await restoreRollbackJournalOrThrow(journal, `Failed to remove chrome-doc "${args.name}"`);
90
+ try {
91
+ await restoreRollbackJournalOrThrow(journal, `Failed to remove chrome-doc "${args.name}"`);
92
+ }
93
+ catch (rollbackError) {
94
+ // Matches the seven sibling mutation sites: a failed rollback leaves the
95
+ // engine in an unknown state, so record the repair breadcrumb and let
96
+ // the rollback error win rather than discarding it.
97
+ await recordFurnaceRollbackFailure(args.projectRoot, 'chrome-doc-rollback', `chrome-doc "${args.name}": ${toError(rollbackError).message}`);
98
+ throw rollbackError;
99
+ }
90
100
  throw error;
91
101
  }
92
102
  return { removedFiles, removedJarEntries, removedTestDir };
@@ -126,6 +136,7 @@ export async function furnaceChromeDocRemoveCommand(projectRoot, name, options =
126
136
  }
127
137
  }
128
138
  const result = await runFurnaceMutation(projectRoot, 'chrome-doc-rollback', (ctx) => performChromeDocRemoveMutations({
139
+ projectRoot,
129
140
  name,
130
141
  engineDir,
131
142
  plan,
@@ -19,11 +19,12 @@
19
19
  import { join } from 'node:path';
20
20
  import { loadConfig } from '../../core/config.js';
21
21
  import { furnaceConfigExists, loadFurnaceConfig } from '../../core/furnace-config.js';
22
- import { runFurnaceMutation } from '../../core/furnace-operation.js';
22
+ import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
23
23
  import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
24
24
  import { DEFAULT_LICENSE, getLicenseHeader } from '../../core/license-headers.js';
25
25
  import { InvalidArgumentError } from '../../errors/base.js';
26
26
  import { FurnaceError } from '../../errors/furnace.js';
27
+ import { toError } from '../../utils/errors.js';
27
28
  import { pathExists, readText, writeText } from '../../utils/fs.js';
28
29
  import { intro, note, outro, warn } from '../../utils/logger.js';
29
30
  import { generateBrowserWindowXhtml, generateChromeDocCss, generateChromeDocFtl, generateChromeDocJs, generateChromeDocXhtml, jarIncMnEntryForChromeDoc, jarMnEntriesForChromeDoc, localeJarMnEntryForChromeDoc, localesFtlWildcardCapturesScaffoldedName, } from './chrome-doc-templates.js';
@@ -273,7 +274,16 @@ async function performChromeDocMutations(args) {
273
274
  }
274
275
  }
275
276
  catch (error) {
276
- await restoreRollbackJournalOrThrow(journal, `Failed to scaffold chrome-doc "${args.name}"`);
277
+ try {
278
+ await restoreRollbackJournalOrThrow(journal, `Failed to scaffold chrome-doc "${args.name}"`);
279
+ }
280
+ catch (rollbackError) {
281
+ // Matches the seven sibling mutation sites: when the rollback itself
282
+ // fails the engine is in an unknown state, so leave a breadcrumb for
283
+ // `doctor --repair-furnace` and let the rollback error win.
284
+ await recordFurnaceRollbackFailure(args.projectRoot, 'chrome-doc-rollback', `chrome-doc "${args.name}": ${toError(rollbackError).message}`);
285
+ throw rollbackError;
286
+ }
277
287
  throw error;
278
288
  }
279
289
  return written;
@@ -331,6 +341,7 @@ export async function furnaceChromeDocCreateCommand(projectRoot, name, options =
331
341
  return;
332
342
  }
333
343
  const written = await runFurnaceMutation(projectRoot, 'chrome-doc-rollback', (ctx) => performChromeDocMutations({
344
+ projectRoot,
334
345
  name,
335
346
  license,
336
347
  engineDir,
@@ -21,7 +21,12 @@ export declare function resolveValidatedTestDir(rawTestDir: string | undefined,
21
21
  */
22
22
  export declare function resolveTestDirOverride(raw: string): string;
23
23
  /**
24
+ * Writes the browser-mochitest scaffold for a component: the test file, its
25
+ * `browser.toml` manifest entry, and the parent `moz.build` registration.
24
26
  *
27
+ * Every write is recorded in `journal` when one is supplied, so a failure
28
+ * partway through is rolled back rather than leaving a half-registered test
29
+ * the harness will try to run.
25
30
  */
26
31
  export declare function scaffoldTestFiles(componentName: string, license: ProjectLicense, forgeConfig: {
27
32
  binaryName: string;
@@ -57,7 +57,12 @@ export function resolveTestDirOverride(raw) {
57
57
  // Exported for direct unit testing of the --test-dir / collision-safety
58
58
  // behaviour (the full create command needs a furnace project fixture).
59
59
  /**
60
+ * Writes the browser-mochitest scaffold for a component: the test file, its
61
+ * `browser.toml` manifest entry, and the parent `moz.build` registration.
60
62
  *
63
+ * Every write is recorded in `journal` when one is supplied, so a failure
64
+ * partway through is rolled back rather than leaving a half-registered test
65
+ * the harness will try to run.
61
66
  */
62
67
  export async function scaffoldTestFiles(componentName, license, forgeConfig, paths, journal, testDirOverride) {
63
68
  const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
@@ -222,7 +222,7 @@ async function resolveDescription(isInteractive, options) {
222
222
  placeholder: 'A brief description of the component',
223
223
  });
224
224
  if (!isCancel(descResult)) {
225
- description = String(descResult);
225
+ description = descResult;
226
226
  }
227
227
  }
228
228
  return description;
@@ -267,7 +267,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
267
267
  cancel('Create cancelled');
268
268
  return;
269
269
  }
270
- componentName = String(nameResult);
270
+ componentName = nameResult;
271
271
  }
272
272
  // Load the current furnace config only after the interactive name prompt
273
273
  // succeeds so a cancelled create in a fresh project does not strand a new
@@ -8,6 +8,7 @@ import { getFurnacePaths, loadFurnaceConfig } from '../../core/furnace-config.js
8
8
  import { isComponentSourceFile, resolveFtlDir } from '../../core/furnace-constants.js';
9
9
  import { getFileContentAtRef } from '../../core/git-file-ops.js';
10
10
  import { FurnaceError } from '../../errors/furnace.js';
11
+ import { toError } from '../../utils/errors.js';
11
12
  import { pathExists, readText } from '../../utils/fs.js';
12
13
  import { formatErrorText, formatSuccessText, info, intro, outro } from '../../utils/logger.js';
13
14
  /**
@@ -72,7 +73,7 @@ async function diffOverride(name, projectRoot, config) {
72
73
  }
73
74
  catch (error) {
74
75
  throw new FurnaceError(`Cannot read baseline for "${entry.name}" at commit ${baseCommit.slice(0, 8)}: ` +
75
- `${error instanceof Error ? error.message : String(error)}. ` +
76
+ `${toError(error).message}. ` +
76
77
  `The commit may no longer exist in the engine history (e.g. after a re-download). ` +
77
78
  `Run "fireforge furnace refresh --reset-base ${name}" to establish a new baseline.`, name);
78
79
  }
@@ -9,7 +9,7 @@ import { registerSharedCSS } from '../../core/register-shared-css.js';
9
9
  import { getTokensCssPath } from '../../core/token-manager.js';
10
10
  import { generateDefaultTokensCss } from '../../core/token-scaffold.js';
11
11
  import { FurnaceError } from '../../errors/furnace.js';
12
- import { toError } from '../../utils/errors.js';
12
+ import { getNodeErrorCode, toError } from '../../utils/errors.js';
13
13
  import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
14
14
  import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
15
15
  /**
@@ -77,8 +77,8 @@ async function validateFtlBasePath(value, engineDir) {
77
77
  if (engineDir) {
78
78
  const resolved = join(engineDir, value);
79
79
  try {
80
- const info = await stat(resolved);
81
- if (!info.isDirectory()) {
80
+ const stats = await stat(resolved);
81
+ if (!stats.isDirectory()) {
82
82
  throw new FurnaceError(`ftlBasePath "${value}" resolves to a non-directory at ${resolved}. FireForge expects a locale directory (for example toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser).`);
83
83
  }
84
84
  }
@@ -89,9 +89,7 @@ async function validateFtlBasePath(value, engineDir) {
89
89
  throw error;
90
90
  // ENOENT is expected on a fresh project before `fireforge
91
91
  // download` has populated engine/; only warn.
92
- const code = typeof error === 'object' && error !== null && 'code' in error
93
- ? error.code
94
- : undefined;
92
+ const code = getNodeErrorCode(error);
95
93
  if (code === 'ENOENT') {
96
94
  warn(`ftlBasePath "${value}" does not yet exist at ${resolved}. This is fine if you have not run "fireforge download" yet; rerun "fireforge furnace init --force" after the engine is extracted to re-validate.`);
97
95
  }
@@ -282,7 +282,7 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
282
282
  placeholder: 'What are you changing about this component?',
283
283
  });
284
284
  if (!isCancel(descResult)) {
285
- description = String(descResult);
285
+ description = descResult;
286
286
  }
287
287
  }
288
288
  // --- Copy original files ---
@@ -417,7 +417,7 @@ export async function furnaceBatchOverrideCommand(projectRoot, names, options =
417
417
  catch (error) {
418
418
  failed.push({
419
419
  name: componentName,
420
- error: error instanceof Error ? error.message : String(error),
420
+ error: toError(error).message,
421
421
  });
422
422
  }
423
423
  }
@@ -0,0 +1,40 @@
1
+ import type { loadFurnaceConfig } from '../../core/furnace-config.js';
2
+ import { getFurnacePaths } from '../../core/furnace-config.js';
3
+ import type { RollbackJournal } from '../../core/furnace-rollback.js';
4
+ import type { FurnaceState } from '../../types/furnace.js';
5
+ /**
6
+ * Engine-side and workspace-side removal for an OVERRIDE component.
7
+ *
8
+ * Extracted in 0.41.0: this and its custom-component sibling were the only
9
+ * large inline blocks in the furnace subsystem never wrapped in a
10
+ * `perform*Mutations` helper, and together they held `remove.ts` at 496/500
11
+ * file lines with `furnaceRemoveCommand` at 149/150 — one line from a build
12
+ * failure. Both run inside the caller's rollback journal rather than opening
13
+ * their own, so a failure anywhere still restores the whole operation.
14
+ */
15
+ export declare function performOverrideRemovalMutations(args: {
16
+ name: string;
17
+ paths: {
18
+ engine: string;
19
+ };
20
+ furnacePaths: ReturnType<typeof getFurnacePaths>;
21
+ freshConfig: Awaited<ReturnType<typeof loadFurnaceConfig>>;
22
+ freshState: FurnaceState;
23
+ ftlDir: string;
24
+ journal: RollbackJournal;
25
+ }): Promise<void>;
26
+ /**
27
+ * Engine-side and workspace-side removal for a CUSTOM component.
28
+ * Sibling of {@link performOverrideRemovalMutations}; see its note.
29
+ */
30
+ export declare function performCustomRemovalMutations(args: {
31
+ projectRoot: string;
32
+ name: string;
33
+ paths: {
34
+ engine: string;
35
+ };
36
+ furnacePaths: ReturnType<typeof getFurnacePaths>;
37
+ freshConfig: Awaited<ReturnType<typeof loadFurnaceConfig>>;
38
+ ftlDir: string;
39
+ journal: RollbackJournal;
40
+ }): Promise<void>;
@@ -0,0 +1,171 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Engine-side and workspace-side mutation bodies for `furnace remove`.
4
+ *
5
+ * Split out of `remove.ts` in 0.41.0. The two `perform*RemovalMutations`
6
+ * functions were the only large inline blocks in the furnace subsystem never
7
+ * wrapped in a helper, and together they held `remove.ts` at 496/500 file
8
+ * lines with `furnaceRemoveCommand` at 149/150 — one line from a build
9
+ * failure. Extracting them inside the same file traded a function-length
10
+ * problem for a file-length one, so they live here instead.
11
+ *
12
+ * Both run inside the CALLER's rollback journal rather than opening their
13
+ * own, so a failure anywhere in the remove still restores the whole
14
+ * operation as a single unit.
15
+ */
16
+ import { readdir } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { removeCustomFtlJarMnEntry } from '../../core/furnace-apply-ftl.js';
19
+ import { getOverrideEngineTargetPath, isOverrideCopyCandidate, restoreOverrideFileToBaseline, } from '../../core/furnace-apply-helpers.js';
20
+ import { extractComponentChecksums } from '../../core/furnace-checksum-utils.js';
21
+ import { getFurnacePaths } from '../../core/furnace-config.js';
22
+ import { removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
23
+ import { snapshotDir, snapshotFile } from '../../core/furnace-rollback.js';
24
+ import { isGitRepository } from '../../core/git.js';
25
+ import { FurnaceError } from '../../errors/furnace.js';
26
+ import { pathExists, removeDir, removeFile } from '../../utils/fs.js';
27
+ import { info } from '../../utils/logger.js';
28
+ import { removeDeployedCustomFiles } from './remove-state.js';
29
+ /**
30
+ * Restores every override-deployed file in `engine/` to its pristine HEAD
31
+ * state, inverting what `applyOverrideComponent` would have written. Files that
32
+ * existed in HEAD are restored via `git restore`; files the override
33
+ * introduced (not in HEAD) are deleted outright.
34
+ *
35
+ * The restore set is the **union** of (a) files currently in the override
36
+ * workspace directory and (b) filenames recorded in `previousChecksumKeys`
37
+ * — i.e. files we know we deployed last time, even if the developer has
38
+ * since deleted them from the workspace. Without (b), a workspace deletion
39
+ * leaves an orphaned engine copy that `furnace remove` would never see.
40
+ *
41
+ * Every touched engine file is snapshotted into the rollback journal before
42
+ * mutation so a mid-remove failure still rolls the engine back to its
43
+ * pre-command state.
44
+ */
45
+ async function restoreOverrideEngineFiles(engineDir, overrideDir, overrideConfig, previousChecksumKeys, ftlDir, journal) {
46
+ // Engine-as-git is a hard precondition for restoration: git HEAD is the only
47
+ // honest oracle for "what was there before the override". If the engine is
48
+ // not a git repo we refuse rather than silently leaving files behind — the
49
+ // previous warn-and-continue behaviour is exactly what this fix removes.
50
+ if (!(await isGitRepository(engineDir))) {
51
+ throw new FurnaceError('Cannot restore override files: engine is not a git repository. Run "fireforge download" to initialise it.');
52
+ }
53
+ // Build the union of "files we still see on disk" and "files state.json
54
+ // claims we deployed". The state set is the only authority for files that
55
+ // were deployed and later deleted from the workspace; the workspace set is
56
+ // the only authority for files added since last apply that have not yet
57
+ // been recorded in state. We need both.
58
+ const fileSet = new Set();
59
+ if (await pathExists(overrideDir)) {
60
+ const entries = await readdir(overrideDir, { withFileTypes: true });
61
+ for (const entry of entries) {
62
+ if (!entry.isFile())
63
+ continue;
64
+ if (!isOverrideCopyCandidate(entry.name, overrideConfig.type))
65
+ continue;
66
+ fileSet.add(entry.name);
67
+ }
68
+ }
69
+ for (const key of previousChecksumKeys) {
70
+ fileSet.add(key);
71
+ }
72
+ let restored = 0;
73
+ let removed = 0;
74
+ for (const fileName of fileSet) {
75
+ const enginePath = getOverrideEngineTargetPath(engineDir, overrideConfig, fileName, ftlDir);
76
+ const action = await restoreOverrideFileToBaseline(engineDir, enginePath, journal);
77
+ if (action === 'restored')
78
+ restored += 1;
79
+ else if (action === 'removed')
80
+ removed += 1;
81
+ }
82
+ return { restored, removed };
83
+ }
84
+ /**
85
+ * Engine-side and workspace-side removal for an OVERRIDE component.
86
+ *
87
+ * Extracted in 0.41.0: this and its custom-component sibling were the only
88
+ * large inline blocks in the furnace subsystem never wrapped in a
89
+ * `perform*Mutations` helper, and together they held `remove.ts` at 496/500
90
+ * file lines with `furnaceRemoveCommand` at 149/150 — one line from a build
91
+ * failure. Both run inside the caller's rollback journal rather than opening
92
+ * their own, so a failure anywhere still restores the whole operation.
93
+ */
94
+ export async function performOverrideRemovalMutations(args) {
95
+ const { name, paths, furnacePaths, freshConfig, freshState, ftlDir, journal } = args;
96
+ const overrideConfig = freshConfig.overrides[name];
97
+ const dir = join(furnacePaths.overridesDir, name);
98
+ // Restore deployed engine files BEFORE removing the workspace
99
+ // directory. The restore set is the union of (a) files currently in
100
+ // the workspace and (b) files state.json says we deployed last time
101
+ // — without (b), source-side deletions would orphan engine copies
102
+ // that this command can never see again.
103
+ if (overrideConfig?.basePath) {
104
+ const previousKeys = Object.keys(extractComponentChecksums(freshState.appliedChecksums, 'override', name));
105
+ const { restored, removed } = await restoreOverrideEngineFiles(paths.engine, dir, overrideConfig, previousKeys, ftlDir, journal);
106
+ if (restored > 0) {
107
+ info(`Restored ${restored} file${restored === 1 ? '' : 's'} in engine/${overrideConfig.basePath} to Firefox baseline`);
108
+ }
109
+ if (removed > 0) {
110
+ info(`Removed ${removed} override-introduced file${removed === 1 ? '' : 's'} from engine/${overrideConfig.basePath}`);
111
+ }
112
+ }
113
+ if (await pathExists(dir)) {
114
+ await snapshotDir(journal, dir);
115
+ await removeDir(dir);
116
+ info(`Deleted components/overrides/${name}/`);
117
+ }
118
+ }
119
+ /**
120
+ * Engine-side and workspace-side removal for a CUSTOM component.
121
+ * Sibling of {@link performOverrideRemovalMutations}; see its note.
122
+ */
123
+ export async function performCustomRemovalMutations(args) {
124
+ const { projectRoot, name, paths, furnacePaths, freshConfig, ftlDir, journal } = args;
125
+ const customConfig = freshConfig.custom[name];
126
+ // Custom-component removal mutates engine files (jar.mn,
127
+ // customElements.js, deployed widgets, optional .ftl) and the
128
+ // rollback journal is the only safety net for those edits while
129
+ // the command runs. The git-as-engine precondition is enforced
130
+ // before the lock is acquired (see furnaceRemoveCommand above)
131
+ // so if we reach this point, the engine is a git repository.
132
+ if (customConfig?.register) {
133
+ // customElements.js is the only file removeCustomElementRegistration touches.
134
+ await snapshotFile(journal, join(paths.engine, 'toolkit/content/customElements.js'));
135
+ await removeCustomElementRegistration(paths.engine, name);
136
+ info(`Deregistered ${name} from customElements.js`);
137
+ }
138
+ // jar.mn is the only file removeJarMnEntries touches.
139
+ await snapshotFile(journal, join(paths.engine, 'toolkit/content/jar.mn'));
140
+ await removeJarMnEntries(paths.engine, name);
141
+ info(`Removed ${name} entries from toolkit/content/jar.mn`);
142
+ const dir = join(furnacePaths.customDir, name);
143
+ if (await pathExists(dir)) {
144
+ await snapshotDir(journal, dir);
145
+ await removeDir(dir);
146
+ info(`Deleted components/custom/${name}/`);
147
+ }
148
+ // Clean up deployed files in engine (per-file — see helper doc).
149
+ if (customConfig?.targetPath) {
150
+ await removeDeployedCustomFiles(projectRoot, paths.engine, name, customConfig.targetPath, journal);
151
+ }
152
+ // Localized components deploy a .ftl outside targetPath into the
153
+ // shared Fluent tree; apply writes it, so remove must delete it too
154
+ // or the locale payload is orphaned.
155
+ if (customConfig?.localized) {
156
+ const ftlRel = join(ftlDir, `${name}.ftl`);
157
+ const ftlPath = join(paths.engine, ftlRel);
158
+ if (await pathExists(ftlPath)) {
159
+ await snapshotFile(journal, ftlPath);
160
+ await removeFile(ftlPath);
161
+ info(`Deleted localized file engine/${ftlRel}`);
162
+ }
163
+ // Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
164
+ // wrote during deploy — otherwise the engine is left with a
165
+ // `locale/.../${name}.ftl` entry pointing at a file we just
166
+ // deleted. 2026-04-21 eval (Finding #1): `furnace remove` left
167
+ // `browser/locales/jar.mn` referencing the missing FTL, which
168
+ // would break the next package-manifest validation.
169
+ await removeCustomFtlJarMnEntry(paths.engine, `${name}.ftl`, ftlDir, customConfig, journal);
170
+ }
171
+ }
@@ -3,20 +3,18 @@ import { readdir, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { confirm } from '@clack/prompts';
5
5
  import { getProjectPaths, loadConfig } from '../../core/config.js';
6
- import { removeCustomFtlJarMnEntry } from '../../core/furnace-apply-ftl.js';
7
- import { extractComponentChecksums, getOverrideEngineTargetPath, isOverrideCopyCandidate, restoreOverrideFileToBaseline, } from '../../core/furnace-apply-helpers.js';
8
6
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
9
7
  import { resolveFtlDir, xpcshellTestParentDir } from '../../core/furnace-constants.js';
10
8
  import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
11
- import { removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
12
9
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
13
10
  import { isGitRepository } from '../../core/git.js';
14
11
  import { deregisterTestManifest } from '../../core/manifest-register.js';
15
12
  import { FurnaceError } from '../../errors/furnace.js';
16
13
  import { toError } from '../../utils/errors.js';
17
- import { pathExists, readText, removeDir, removeFile, writeText } from '../../utils/fs.js';
14
+ import { pathExists, readText, removeDir, writeText } from '../../utils/fs.js';
18
15
  import { cancel, info, intro, isCancel, outro, warn } from '../../utils/logger.js';
19
- import { dropChecksumsByPrefix, removeDeployedCustomFiles } from './remove-state.js';
16
+ import { performCustomRemovalMutations, performOverrideRemovalMutations, } from './remove-mutations.js';
17
+ import { dropChecksumsByPrefix } from './remove-state.js';
20
18
  /**
21
19
  * Removes an entire TOML section (header + body lines) for a given test file.
22
20
  * Matches from `["filename"]` up to the next section header `[` or end-of-file,
@@ -69,61 +67,6 @@ function findComponentType(config, name) {
69
67
  return 'custom';
70
68
  return undefined;
71
69
  }
72
- /**
73
- * Restores every override-deployed file in `engine/` to its pristine HEAD
74
- * state, inverting what `applyOverrideComponent` would have written. Files that
75
- * existed in HEAD are restored via `git restore`; files the override
76
- * introduced (not in HEAD) are deleted outright.
77
- *
78
- * The restore set is the **union** of (a) files currently in the override
79
- * workspace directory and (b) filenames recorded in `previousChecksumKeys`
80
- * — i.e. files we know we deployed last time, even if the developer has
81
- * since deleted them from the workspace. Without (b), a workspace deletion
82
- * leaves an orphaned engine copy that `furnace remove` would never see.
83
- *
84
- * Every touched engine file is snapshotted into the rollback journal before
85
- * mutation so a mid-remove failure still rolls the engine back to its
86
- * pre-command state.
87
- */
88
- async function restoreOverrideEngineFiles(engineDir, overrideDir, overrideConfig, previousChecksumKeys, ftlDir, journal) {
89
- // Engine-as-git is a hard precondition for restoration: git HEAD is the only
90
- // honest oracle for "what was there before the override". If the engine is
91
- // not a git repo we refuse rather than silently leaving files behind — the
92
- // previous warn-and-continue behaviour is exactly what this fix removes.
93
- if (!(await isGitRepository(engineDir))) {
94
- throw new FurnaceError('Cannot restore override files: engine is not a git repository. Run "fireforge download" to initialise it.');
95
- }
96
- // Build the union of "files we still see on disk" and "files state.json
97
- // claims we deployed". The state set is the only authority for files that
98
- // were deployed and later deleted from the workspace; the workspace set is
99
- // the only authority for files added since last apply that have not yet
100
- // been recorded in state. We need both.
101
- const fileSet = new Set();
102
- if (await pathExists(overrideDir)) {
103
- const entries = await readdir(overrideDir, { withFileTypes: true });
104
- for (const entry of entries) {
105
- if (!entry.isFile())
106
- continue;
107
- if (!isOverrideCopyCandidate(entry.name, overrideConfig.type))
108
- continue;
109
- fileSet.add(entry.name);
110
- }
111
- }
112
- for (const key of previousChecksumKeys) {
113
- fileSet.add(key);
114
- }
115
- let restored = 0;
116
- let removed = 0;
117
- for (const fileName of fileSet) {
118
- const enginePath = getOverrideEngineTargetPath(engineDir, overrideConfig, fileName, ftlDir);
119
- const action = await restoreOverrideFileToBaseline(engineDir, enginePath, journal);
120
- if (action === 'restored')
121
- restored += 1;
122
- else if (action === 'removed')
123
- removed += 1;
124
- }
125
- return { restored, removed };
126
- }
127
70
  /**
128
71
  * Removes generated browser mochitest files associated with a custom component.
129
72
  * @param name - Custom component tag name
@@ -410,76 +353,26 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
410
353
  try {
411
354
  const { config: freshConfig, ftlDir, state: freshState, type: freshType, } = await loadFreshRemoveTarget(projectRoot, name, paths.engine);
412
355
  if (freshType === 'override') {
413
- const overrideConfig = freshConfig.overrides[name];
414
- const dir = join(furnacePaths.overridesDir, name);
415
- // Restore deployed engine files BEFORE removing the workspace
416
- // directory. The restore set is the union of (a) files currently in
417
- // the workspace and (b) files state.json says we deployed last time
418
- // — without (b), source-side deletions would orphan engine copies
419
- // that this command can never see again.
420
- if (overrideConfig?.basePath) {
421
- const previousKeys = Object.keys(extractComponentChecksums(freshState.appliedChecksums, 'override', name));
422
- const { restored, removed } = await restoreOverrideEngineFiles(paths.engine, dir, overrideConfig, previousKeys, ftlDir, journal);
423
- if (restored > 0) {
424
- info(`Restored ${restored} file${restored === 1 ? '' : 's'} in engine/${overrideConfig.basePath} to Firefox baseline`);
425
- }
426
- if (removed > 0) {
427
- info(`Removed ${removed} override-introduced file${removed === 1 ? '' : 's'} from engine/${overrideConfig.basePath}`);
428
- }
429
- }
430
- if (await pathExists(dir)) {
431
- await snapshotDir(journal, dir);
432
- await removeDir(dir);
433
- info(`Deleted components/overrides/${name}/`);
434
- }
356
+ await performOverrideRemovalMutations({
357
+ name,
358
+ paths,
359
+ furnacePaths,
360
+ freshConfig,
361
+ freshState,
362
+ ftlDir,
363
+ journal,
364
+ });
435
365
  }
436
366
  else if (freshType === 'custom') {
437
- const customConfig = freshConfig.custom[name];
438
- // Custom-component removal mutates engine files (jar.mn,
439
- // customElements.js, deployed widgets, optional .ftl) and the
440
- // rollback journal is the only safety net for those edits while
441
- // the command runs. The git-as-engine precondition is enforced
442
- // before the lock is acquired (see furnaceRemoveCommand above)
443
- // so if we reach this point, the engine is a git repository.
444
- if (customConfig?.register) {
445
- // customElements.js is the only file removeCustomElementRegistration touches.
446
- await snapshotFile(journal, join(paths.engine, 'toolkit/content/customElements.js'));
447
- await removeCustomElementRegistration(paths.engine, name);
448
- info(`Deregistered ${name} from customElements.js`);
449
- }
450
- // jar.mn is the only file removeJarMnEntries touches.
451
- await snapshotFile(journal, join(paths.engine, 'toolkit/content/jar.mn'));
452
- await removeJarMnEntries(paths.engine, name);
453
- info(`Removed ${name} entries from toolkit/content/jar.mn`);
454
- const dir = join(furnacePaths.customDir, name);
455
- if (await pathExists(dir)) {
456
- await snapshotDir(journal, dir);
457
- await removeDir(dir);
458
- info(`Deleted components/custom/${name}/`);
459
- }
460
- // Clean up deployed files in engine (per-file — see helper doc).
461
- if (customConfig?.targetPath) {
462
- await removeDeployedCustomFiles(projectRoot, paths.engine, name, customConfig.targetPath, journal);
463
- }
464
- // Localized components deploy a .ftl outside targetPath into the
465
- // shared Fluent tree; apply writes it, so remove must delete it too
466
- // or the locale payload is orphaned.
467
- if (customConfig?.localized) {
468
- const ftlRel = join(ftlDir, `${name}.ftl`);
469
- const ftlPath = join(paths.engine, ftlRel);
470
- if (await pathExists(ftlPath)) {
471
- await snapshotFile(journal, ftlPath);
472
- await removeFile(ftlPath);
473
- info(`Deleted localized file engine/${ftlRel}`);
474
- }
475
- // Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
476
- // wrote during deploy — otherwise the engine is left with a
477
- // `locale/.../${name}.ftl` entry pointing at a file we just
478
- // deleted. 2026-04-21 eval (Finding #1): `furnace remove` left
479
- // `browser/locales/jar.mn` referencing the missing FTL, which
480
- // would break the next package-manifest validation.
481
- await removeCustomFtlJarMnEntry(paths.engine, `${name}.ftl`, ftlDir, customConfig, journal);
482
- }
367
+ await performCustomRemovalMutations({
368
+ projectRoot,
369
+ name,
370
+ paths,
371
+ furnacePaths,
372
+ freshConfig,
373
+ ftlDir,
374
+ journal,
375
+ });
483
376
  }
484
377
  const testCleanupFailures = freshType === 'custom' ? await cleanupAllCustomTestFiles(name, projectRoot, journal) : [];
485
378
  // Remove entry from furnace.json