@hominis/fireforge 0.10.1 → 0.11.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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -3,13 +3,57 @@ 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 { getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
+ import { extractComponentChecksums, getOverrideEngineTargetPath, isOverrideCopyCandidate, restoreOverrideFileToBaseline, } from '../../core/furnace-apply-helpers.js';
7
+ import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
8
+ import { resolveFtlDir } from '../../core/furnace-constants.js';
9
+ import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
7
10
  import { removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
11
+ import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
12
+ import { isGitRepository } from '../../core/git.js';
8
13
  import { deregisterTestManifest } from '../../core/manifest-register.js';
9
14
  import { FurnaceError } from '../../errors/furnace.js';
10
15
  import { toError } from '../../utils/errors.js';
11
- import { pathExists, readText, removeDir, writeText } from '../../utils/fs.js';
16
+ import { pathExists, readText, removeDir, removeFile, writeText } from '../../utils/fs.js';
12
17
  import { cancel, info, intro, isCancel, outro, warn } from '../../utils/logger.js';
18
+ /**
19
+ * Removes an entire TOML section (header + body lines) for a given test file.
20
+ * Matches from `["filename"]` up to the next section header `[` or end-of-file,
21
+ * consuming the section's metadata keys and surrounding blank lines. This is
22
+ * more robust than a single-line regex that only removes the header.
23
+ */
24
+ function removeTomlSection(toml, testFileName) {
25
+ const lines = toml.split('\n');
26
+ const header = `["${testFileName}"]`;
27
+ const result = [];
28
+ let i = 0;
29
+ while (i < lines.length) {
30
+ if (lines[i]?.trim() === header) {
31
+ // Skip the header line
32
+ i++;
33
+ // Skip all body lines until the next section header or EOF
34
+ while (i < lines.length && !/^\s*\[/.test(lines[i] ?? '')) {
35
+ i++;
36
+ }
37
+ // Collapse any double blank line left behind
38
+ while (result.length > 0 && result[result.length - 1]?.trim() === '') {
39
+ result.pop();
40
+ }
41
+ // Re-add a single blank separator if the next line is another section
42
+ if (i < lines.length && result.length > 0) {
43
+ result.push('');
44
+ }
45
+ }
46
+ else {
47
+ result.push(lines[i] ?? '');
48
+ i++;
49
+ }
50
+ }
51
+ // Trim trailing blank lines and ensure single trailing newline
52
+ while (result.length > 0 && result[result.length - 1]?.trim() === '') {
53
+ result.pop();
54
+ }
55
+ return result.join('\n') + '\n';
56
+ }
13
57
  /**
14
58
  * Finds which section a component belongs to in the furnace config.
15
59
  * @returns The component type, or undefined if not found
@@ -23,53 +67,150 @@ function findComponentType(config, name) {
23
67
  return 'custom';
24
68
  return undefined;
25
69
  }
70
+ /**
71
+ * Restores every override-deployed file in `engine/` to its pristine HEAD
72
+ * state, inverting what `applyOverrideComponent` would have written. Files that
73
+ * existed in HEAD are restored via `git restore`; files the override
74
+ * introduced (not in HEAD) are deleted outright.
75
+ *
76
+ * The restore set is the **union** of (a) files currently in the override
77
+ * workspace directory and (b) filenames recorded in `previousChecksumKeys`
78
+ * — i.e. files we know we deployed last time, even if the developer has
79
+ * since deleted them from the workspace. Without (b), a workspace deletion
80
+ * leaves an orphaned engine copy that `furnace remove` would never see.
81
+ *
82
+ * Every touched engine file is snapshotted into the rollback journal before
83
+ * mutation so a mid-remove failure still rolls the engine back to its
84
+ * pre-command state.
85
+ */
86
+ async function restoreOverrideEngineFiles(engineDir, overrideDir, overrideConfig, previousChecksumKeys, ftlDir, journal) {
87
+ // Engine-as-git is a hard precondition for restoration: git HEAD is the only
88
+ // honest oracle for "what was there before the override". If the engine is
89
+ // not a git repo we refuse rather than silently leaving files behind — the
90
+ // previous warn-and-continue behaviour is exactly what this fix removes.
91
+ if (!(await isGitRepository(engineDir))) {
92
+ throw new FurnaceError('Cannot restore override files: engine is not a git repository. Run "fireforge download" to initialise it.');
93
+ }
94
+ // Build the union of "files we still see on disk" and "files state.json
95
+ // claims we deployed". The state set is the only authority for files that
96
+ // were deployed and later deleted from the workspace; the workspace set is
97
+ // the only authority for files added since last apply that have not yet
98
+ // been recorded in state. We need both.
99
+ const fileSet = new Set();
100
+ if (await pathExists(overrideDir)) {
101
+ const entries = await readdir(overrideDir, { withFileTypes: true });
102
+ for (const entry of entries) {
103
+ if (!entry.isFile())
104
+ continue;
105
+ if (!isOverrideCopyCandidate(entry.name, overrideConfig.type))
106
+ continue;
107
+ fileSet.add(entry.name);
108
+ }
109
+ }
110
+ for (const key of previousChecksumKeys) {
111
+ fileSet.add(key);
112
+ }
113
+ let restored = 0;
114
+ let removed = 0;
115
+ for (const fileName of fileSet) {
116
+ const enginePath = getOverrideEngineTargetPath(engineDir, overrideConfig, fileName, ftlDir);
117
+ const action = await restoreOverrideFileToBaseline(engineDir, enginePath, journal);
118
+ if (action === 'restored')
119
+ restored += 1;
120
+ else if (action === 'removed')
121
+ removed += 1;
122
+ }
123
+ return { restored, removed };
124
+ }
26
125
  /**
27
126
  * Removes generated browser mochitest files associated with a custom component.
28
127
  * @param name - Custom component tag name
29
128
  * @param projectRoot - Root directory of the project
129
+ * @param journal - Rollback journal that snapshots files before deletion
130
+ *
131
+ * The function preserves its original warn-and-continue contract: any failure
132
+ * during cleanup is reported via warn() rather than thrown. Snapshots taken
133
+ * before a failed step are still recorded so a later rollback (triggered by
134
+ * an error elsewhere in the command) can restore whatever was deleted before
135
+ * the failure.
30
136
  */
31
- async function cleanupCustomTestFiles(name, projectRoot) {
137
+ async function cleanupCustomTestFiles(name, projectRoot, journal) {
138
+ let forgeConfig;
32
139
  try {
33
- const forgeConfig = await loadConfig(projectRoot);
34
- const paths = getProjectPaths(projectRoot);
35
- const binaryName = forgeConfig.binaryName;
36
- const strippedName = name.startsWith('moz-') ? name.slice(4) : name;
37
- const withoutBinaryPrefix = strippedName.startsWith(binaryName + '-')
38
- ? strippedName.slice(binaryName.length + 1)
39
- : strippedName;
40
- const underscored = withoutBinaryPrefix.replace(/-/g, '_');
41
- const testFileName = `browser_${binaryName}_${underscored}.js`;
42
- const testDir = join(paths.engine, 'browser/base/content/test', binaryName);
43
- if (await pathExists(testDir)) {
44
- const testFilePath = join(testDir, testFileName);
45
- if (await pathExists(testFilePath)) {
46
- await unlink(testFilePath);
47
- info(`Deleted test file: ${testFileName}`);
48
- }
49
- const tomlPath = join(testDir, 'browser.toml');
50
- if (await pathExists(tomlPath)) {
51
- const toml = await readText(tomlPath);
52
- const entryPattern = `["${testFileName}"]`;
53
- if (toml.includes(entryPattern)) {
54
- const updated = toml.replace(new RegExp(`\\n?\\n?\\["${testFileName}"\\]\\n?`), '\n');
55
- await writeText(tomlPath, updated);
56
- }
140
+ forgeConfig = await loadConfig(projectRoot);
141
+ }
142
+ catch (error) {
143
+ warn(`Could not load config for test cleanup — ${toError(error).message}. Remove test files manually if needed.`);
144
+ return;
145
+ }
146
+ const paths = getProjectPaths(projectRoot);
147
+ const binaryName = forgeConfig.binaryName;
148
+ const strippedName = name.startsWith('moz-') ? name.slice(4) : name;
149
+ const withoutBinaryPrefix = strippedName.startsWith(binaryName + '-')
150
+ ? strippedName.slice(binaryName.length + 1)
151
+ : strippedName;
152
+ const underscored = withoutBinaryPrefix.replace(/-/g, '_');
153
+ const testFileName = `browser_${binaryName}_${underscored}.js`;
154
+ const testDir = join(paths.engine, 'browser/base/content/test', binaryName);
155
+ if (!(await pathExists(testDir)))
156
+ return;
157
+ // Step 1: Delete the test file itself
158
+ try {
159
+ const testFilePath = join(testDir, testFileName);
160
+ if (await pathExists(testFilePath)) {
161
+ await snapshotFile(journal, testFilePath);
162
+ await unlink(testFilePath);
163
+ info(`Deleted test file: ${testFileName}`);
164
+ }
165
+ }
166
+ catch (error) {
167
+ warn(`Could not delete test file ${testFileName} — ${toError(error).message}. Remove it manually if needed.`);
168
+ }
169
+ // Step 2: Remove the test entry from browser.toml
170
+ try {
171
+ const tomlPath = join(testDir, 'browser.toml');
172
+ if (await pathExists(tomlPath)) {
173
+ const toml = await readText(tomlPath);
174
+ const entryPattern = `["${testFileName}"]`;
175
+ if (toml.includes(entryPattern)) {
176
+ await snapshotFile(journal, tomlPath);
177
+ const updated = removeTomlSection(toml, testFileName);
178
+ await writeText(tomlPath, updated);
57
179
  }
58
- const remaining = await readdir(testDir);
59
- const hasTests = remaining.some((f) => f.startsWith('browser_') && f.endsWith('.js'));
60
- if (!hasTests) {
61
- await removeDir(testDir);
62
- info(`Deleted empty test directory: browser/base/content/test/${binaryName}/`);
63
- if (await deregisterTestManifest(paths.engine, binaryName)) {
64
- info('Deregistered test manifest from browser/base/moz.build');
65
- }
180
+ }
181
+ }
182
+ catch (error) {
183
+ warn(`Could not update browser.toml — ${toError(error).message}. Remove the test entry manually if needed.`);
184
+ }
185
+ // Step 3: Clean up empty test directory and deregister from moz.build
186
+ try {
187
+ const remaining = await readdir(testDir);
188
+ const hasTests = remaining.some((f) => f.startsWith('browser_') && f.endsWith('.js'));
189
+ if (!hasTests) {
190
+ await snapshotDir(journal, testDir);
191
+ await removeDir(testDir);
192
+ info(`Deleted empty test directory: browser/base/content/test/${binaryName}/`);
193
+ const mozBuildPath = join(paths.engine, 'browser/base/moz.build');
194
+ await snapshotFile(journal, mozBuildPath);
195
+ if (await deregisterTestManifest(paths.engine, binaryName)) {
196
+ info('Deregistered test manifest from browser/base/moz.build');
66
197
  }
67
198
  }
68
199
  }
69
200
  catch (error) {
70
- warn(`Could not clean up test files — ${toError(error).message}. Remove them manually if needed.`);
201
+ warn(`Could not clean up test directory — ${toError(error).message}. Remove it manually if needed.`);
71
202
  }
72
203
  }
204
+ function dropChecksumsByPrefix(state, prefix) {
205
+ const result = { ...state };
206
+ if (state.appliedChecksums) {
207
+ result.appliedChecksums = Object.fromEntries(Object.entries(state.appliedChecksums).filter(([k]) => !k.startsWith(prefix)));
208
+ }
209
+ if (state.engineChecksums) {
210
+ result.engineChecksums = Object.fromEntries(Object.entries(state.engineChecksums).filter(([k]) => !k.startsWith(prefix)));
211
+ }
212
+ return result;
213
+ }
73
214
  /**
74
215
  * Runs the furnace remove command to remove a component from the workspace.
75
216
  * @param projectRoot - Root directory of the project
@@ -80,18 +221,20 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
80
221
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
81
222
  intro('Furnace Remove');
82
223
  const config = await loadFurnaceConfig(projectRoot);
224
+ const state = await loadFurnaceState(projectRoot);
83
225
  const furnacePaths = getFurnacePaths(projectRoot);
226
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
84
227
  // Find which section the component belongs to
85
228
  const type = findComponentType(config, name);
86
229
  if (!type) {
87
230
  throw new FurnaceError(`Component "${name}" not found in furnace.json. Run "fireforge furnace list" to see registered components.`, name);
88
231
  }
89
- // Require --force in non-interactive mode to prevent silent removals
90
- if (!isInteractive && !options.force) {
91
- throw new FurnaceError(`Cannot remove "${name}" in non-interactive mode without --force flag.`, name);
232
+ // Require --yes in non-interactive mode to prevent silent removals
233
+ if (!isInteractive && !options.yes) {
234
+ throw new FurnaceError(`Cannot remove "${name}" in non-interactive mode without --yes flag.`, name);
92
235
  }
93
- // Confirm removal (skip if --force)
94
- if (!options.force && isInteractive) {
236
+ // Confirm removal (skip if --yes)
237
+ if (!options.yes && isInteractive) {
95
238
  const confirmed = await confirm({
96
239
  message: `Remove ${type} component "${name}"?`,
97
240
  });
@@ -100,59 +243,112 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
100
243
  return;
101
244
  }
102
245
  }
103
- // Delete component directory for override and custom types
246
+ // Begin transactional mutation: every file deleted or rewritten is first
247
+ // snapshotted in a rollback journal so any failure mid-removal restores the
248
+ // workspace and engine to their pre-command state. The mutation runs under
249
+ // the furnace-wide lock and is registered with the global SIGINT/SIGTERM
250
+ // rollback pathway.
104
251
  const paths = getProjectPaths(projectRoot);
105
- if (type === 'override') {
106
- const dir = join(furnacePaths.overridesDir, name);
107
- if (await pathExists(dir)) {
108
- await removeDir(dir);
109
- info(`Deleted components/overrides/${name}/`);
110
- }
111
- // Clean up deployed files in engine
112
- const overrideConfig = config.overrides[name];
113
- if (overrideConfig?.basePath) {
114
- const engineDir = join(paths.engine, overrideConfig.basePath);
115
- if (await pathExists(engineDir)) {
116
- warn(`Deployed files may remain in engine/${overrideConfig.basePath}. Run "fireforge reset -f && fireforge import" to clean.`);
252
+ await runFurnaceMutation(projectRoot, 'remove-rollback', async (ctx) => {
253
+ const journal = createRollbackJournal();
254
+ ctx.registerJournal(journal);
255
+ try {
256
+ if (type === 'override') {
257
+ const overrideConfig = config.overrides[name];
258
+ const dir = join(furnacePaths.overridesDir, name);
259
+ // Restore deployed engine files BEFORE removing the workspace
260
+ // directory. The restore set is the union of (a) files currently in
261
+ // the workspace and (b) files state.json says we deployed last time
262
+ // without (b), source-side deletions would orphan engine copies
263
+ // that this command can never see again.
264
+ if (overrideConfig?.basePath) {
265
+ const previousKeys = Object.keys(extractComponentChecksums(state.appliedChecksums, 'override', name));
266
+ const { restored, removed } = await restoreOverrideEngineFiles(paths.engine, dir, overrideConfig, previousKeys, ftlDir, journal);
267
+ if (restored > 0) {
268
+ info(`Restored ${restored} file${restored === 1 ? '' : 's'} in engine/${overrideConfig.basePath} to Firefox baseline`);
269
+ }
270
+ if (removed > 0) {
271
+ info(`Removed ${removed} override-introduced file${removed === 1 ? '' : 's'} from engine/${overrideConfig.basePath}`);
272
+ }
273
+ }
274
+ if (await pathExists(dir)) {
275
+ await snapshotDir(journal, dir);
276
+ await removeDir(dir);
277
+ info(`Deleted components/overrides/${name}/`);
278
+ }
117
279
  }
280
+ else if (type === 'custom') {
281
+ const customConfig = config.custom[name];
282
+ if (customConfig?.register) {
283
+ // customElements.js is the only file removeCustomElementRegistration touches.
284
+ await snapshotFile(journal, join(paths.engine, 'toolkit/content/customElements.js'));
285
+ await removeCustomElementRegistration(paths.engine, name);
286
+ info(`Deregistered ${name} from customElements.js`);
287
+ }
288
+ // jar.mn is the only file removeJarMnEntries touches.
289
+ await snapshotFile(journal, join(paths.engine, 'toolkit/content/jar.mn'));
290
+ await removeJarMnEntries(paths.engine, name);
291
+ info(`Removed ${name} entries from toolkit/content/jar.mn`);
292
+ const dir = join(furnacePaths.customDir, name);
293
+ if (await pathExists(dir)) {
294
+ await snapshotDir(journal, dir);
295
+ await removeDir(dir);
296
+ info(`Deleted components/custom/${name}/`);
297
+ }
298
+ // Clean up deployed files in engine
299
+ if (customConfig?.targetPath) {
300
+ const engineDir = join(paths.engine, customConfig.targetPath);
301
+ if (await pathExists(engineDir)) {
302
+ await snapshotDir(journal, engineDir);
303
+ await removeDir(engineDir);
304
+ info(`Deleted deployed files from engine/${customConfig.targetPath}/`);
305
+ }
306
+ }
307
+ // Localized components deploy a .ftl outside targetPath into the
308
+ // shared Fluent tree; apply writes it, so remove must delete it too
309
+ // or the locale payload is orphaned.
310
+ if (customConfig?.localized) {
311
+ const ftlRel = join(ftlDir, `${name}.ftl`);
312
+ const ftlPath = join(paths.engine, ftlRel);
313
+ if (await pathExists(ftlPath)) {
314
+ await snapshotFile(journal, ftlPath);
315
+ await removeFile(ftlPath);
316
+ info(`Deleted localized file engine/${ftlRel}`);
317
+ }
318
+ }
319
+ }
320
+ if (type === 'custom') {
321
+ await cleanupCustomTestFiles(name, projectRoot, journal);
322
+ }
323
+ // Remove entry from furnace.json
324
+ if (type === 'stock') {
325
+ config.stock = config.stock.filter((s) => s !== name);
326
+ }
327
+ else if (type === 'override') {
328
+ config.overrides = Object.fromEntries(Object.entries(config.overrides).filter(([key]) => key !== name));
329
+ }
330
+ else {
331
+ config.custom = Object.fromEntries(Object.entries(config.custom).filter(([key]) => key !== name));
332
+ }
333
+ await snapshotFile(journal, furnacePaths.furnaceConfig);
334
+ await writeFurnaceConfig(projectRoot, config);
335
+ // Drop stale per-file checksums inside the same transactional block.
336
+ // Snapshotting the state file into the rollback journal means the
337
+ // entire remove operation is a single atomic unit.
338
+ await snapshotFile(journal, furnacePaths.furnaceState);
339
+ await updateFurnaceState(projectRoot, (state) => dropChecksumsByPrefix(state, `${type}/${name}/`));
118
340
  }
119
- }
120
- else if (type === 'custom') {
121
- const customConfig = config.custom[name];
122
- if (customConfig?.register) {
123
- await removeCustomElementRegistration(paths.engine, name);
124
- info(`Deregistered ${name} from customElements.js`);
125
- }
126
- await removeJarMnEntries(paths.engine, name);
127
- info(`Removed ${name} entries from toolkit/content/jar.mn`);
128
- const dir = join(furnacePaths.customDir, name);
129
- if (await pathExists(dir)) {
130
- await removeDir(dir);
131
- info(`Deleted components/custom/${name}/`);
132
- }
133
- // Clean up deployed files in engine
134
- if (customConfig?.targetPath) {
135
- const engineDir = join(paths.engine, customConfig.targetPath);
136
- if (await pathExists(engineDir)) {
137
- await removeDir(engineDir);
138
- info(`Deleted deployed files from engine/${customConfig.targetPath}/`);
341
+ catch (error) {
342
+ try {
343
+ await restoreRollbackJournalOrThrow(journal, `Failed to remove component "${name}"`);
344
+ }
345
+ catch (rollbackError) {
346
+ await recordFurnaceRollbackFailure(projectRoot, 'remove-rollback', toError(rollbackError).message);
347
+ throw rollbackError;
139
348
  }
349
+ throw error;
140
350
  }
141
- }
142
- if (type === 'custom') {
143
- await cleanupCustomTestFiles(name, projectRoot);
144
- }
145
- // Remove entry from furnace.json
146
- if (type === 'stock') {
147
- config.stock = config.stock.filter((s) => s !== name);
148
- }
149
- else if (type === 'override') {
150
- config.overrides = Object.fromEntries(Object.entries(config.overrides).filter(([key]) => key !== name));
151
- }
152
- else {
153
- config.custom = Object.fromEntries(Object.entries(config.custom).filter(([key]) => key !== name));
154
- }
155
- await writeFurnaceConfig(projectRoot, config);
351
+ });
156
352
  info(`Removed "${name}" from furnace.json`);
157
353
  outro('Component removed');
158
354
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Renames a custom or override component atomically: updates directory name,
3
+ * file names, file contents, furnace.json, and engine registrations.
4
+ */
5
+ export declare function furnaceRenameCommand(projectRoot: string, oldName: string, newName: string): Promise<void>;