@hominis/fireforge 0.39.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 (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -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 +27 -9
  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 +30 -10
  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 +73 -51
  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-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -3,14 +3,14 @@ import { join } from 'node:path';
3
3
  import { confirm, select, text } from '@clack/prompts';
4
4
  import { addLicenseHeaderToFile, hasThirdPartyPermissiveBanner } from '../core/license-headers.js';
5
5
  import { findAllPatchesForFiles } from '../core/patch-export.js';
6
- import { commentStyleForFile, detectNewFilesInDiff, isAcceptableNewFileHeader, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
6
+ import { commentStyleForFile, detectNewFilesInDiff, formatPatchLintIssue, isAcceptableNewFileHeader, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
7
  import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
8
8
  import { loadPatchesManifest } from '../core/patch-manifest.js';
9
9
  import { getPatchPolicyCategories, isCategoryAllowedByConfig } from '../core/patch-policy.js';
10
10
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
11
11
  import { pathExists, readText } from '../utils/fs.js';
12
12
  import { cancel, info, isCancel, warn } from '../utils/logger.js';
13
- import { PATCH_CATEGORIES, validatePatchName } from '../utils/validation.js';
13
+ import { normalizePatchDisplayName, PATCH_CATEGORIES, validatePatchName, } from '../utils/validation.js';
14
14
  /**
15
15
  * Runs the full patch lint pipeline and reports results.
16
16
  * Warnings are always displayed. Errors block the export unless skipLint is true.
@@ -55,24 +55,33 @@ export async function runPatchLint(engineDir, filesAffected, diffContent, config
55
55
  ? resolvePatchOwnedSysMjs(detectNewFilesInDiff(diffContent))
56
56
  : undefined;
57
57
  const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks, patchTier, checkJsReportScope ? { checkJsReportScope } : undefined);
58
+ reportPatchLintOutcome(issues, skipLint);
59
+ }
60
+ /**
61
+ * Prints a patch-lint issue list with `runPatchLint`'s exact severity
62
+ * buckets and skip-lint semantics, throwing on errors unless `skipLint`.
63
+ * Factored out so `re-export`'s cached-lint path (FORGE J1) can replay
64
+ * stored issues through the identical reporter without re-linting.
65
+ */
66
+ export function reportPatchLintOutcome(issues, skipLint) {
58
67
  if (issues.length === 0)
59
68
  return;
60
69
  const errors = issues.filter((i) => i.severity === 'error');
61
70
  const warnings = issues.filter((i) => i.severity === 'warning');
62
71
  const notices = issues.filter((i) => i.severity === 'notice');
63
72
  for (const issue of notices) {
64
- info(`NOTICE [${issue.check}] ${issue.file}: ${issue.message}`);
73
+ info(`NOTICE ${formatPatchLintIssue(issue)}`);
65
74
  }
66
75
  for (const issue of warnings) {
67
- warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
76
+ warn(formatPatchLintIssue(issue));
68
77
  }
69
78
  if (errors.length > 0) {
70
79
  for (const issue of errors) {
71
80
  if (skipLint) {
72
- warn(`[${issue.check}] ${issue.file}: ${issue.message}`);
81
+ warn(formatPatchLintIssue(issue));
73
82
  }
74
83
  else {
75
- warn(`ERROR [${issue.check}] ${issue.file}: ${issue.message}`);
84
+ warn(`ERROR ${formatPatchLintIssue(issue)}`);
76
85
  }
77
86
  }
78
87
  if (!skipLint) {
@@ -101,7 +110,10 @@ export async function runPatchLint(engineDir, filesAffected, diffContent, config
101
110
  */
102
111
  export async function promptExportPatchMetadata(options, isInteractive, commandName, config) {
103
112
  const categories = config !== undefined ? getPatchPolicyCategories(config) : [...PATCH_CATEGORIES];
104
- let patchName = options.name;
113
+ // A filename-shaped `--name foo.patch` must not die on the dot rule —
114
+ // strip the extension before validation so the one-step flow works for
115
+ // both name shapes (FORGE G13).
116
+ let patchName = options.name?.trim().replace(/\.patch$/i, '');
105
117
  if (patchName) {
106
118
  const validationError = validatePatchName(patchName);
107
119
  if (validationError) {
@@ -121,7 +133,7 @@ export async function promptExportPatchMetadata(options, isInteractive, commandN
121
133
  cancel('Export cancelled');
122
134
  return null;
123
135
  }
124
- patchName = String(nameResult).trim();
136
+ patchName = nameResult.trim();
125
137
  }
126
138
  let category = options.category;
127
139
  if (category) {
@@ -153,11 +165,19 @@ export async function promptExportPatchMetadata(options, isInteractive, commandN
153
165
  placeholder: 'Brief description of what this patch does',
154
166
  });
155
167
  if (!isCancel(descResult)) {
156
- description = String(descResult);
168
+ description = descResult;
157
169
  }
158
170
  }
171
+ // The filename slug pipeline strips redundant category prefixes; the
172
+ // manifest display name must agree, or every `export --name ui-foo`
173
+ // needs a follow-up `patch rename` to satisfy bare-slug naming policy
174
+ // (FORGE G13).
175
+ const normalized = normalizePatchDisplayName(patchName, category);
176
+ if (normalized !== patchName) {
177
+ info(`Patch name normalized: "${patchName}" → "${normalized}" (the filename carries the order and category prefix).`);
178
+ }
159
179
  return {
160
- patchName,
180
+ patchName: normalized,
161
181
  selectedCategory: category,
162
182
  description,
163
183
  };
@@ -20,7 +20,7 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
20
20
  import { toError } from '../utils/errors.js';
21
21
  import { ensureDir, pathExists } from '../utils/fs.js';
22
22
  import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
23
- import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
23
+ import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, stringListOption, } from '../utils/options.js';
24
24
  import { stripEnginePrefix } from '../utils/paths.js';
25
25
  import { parsePositiveIntegerFlag } from '../utils/validation.js';
26
26
  import { commitPlacementExport, renderDryRunPreview } from './export-flow.js';
@@ -418,7 +418,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
418
418
  .option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
419
419
  .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
420
420
  .addOption(new Option('--tier <tier>', 'Force a tier override on the new patch (only "branding" recognised)').choices(['branding']))
421
- .option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], []);
421
+ .option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', ...stringListOption());
422
422
  addWaitLockOption(exportCmd).action(withErrorHandling(async (paths, options) => {
423
423
  const { category, tier, lintIgnore, ...rest } = options;
424
424
  const projectRoot = getProjectRoot();
@@ -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
+ }