@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
@@ -0,0 +1,308 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
5
+ import { getFurnacePaths, loadFurnaceConfig, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
+ import { isComponentSourceFile, resolveFtlDir, tagNameToClassName, } from '../../core/furnace-constants.js';
7
+ import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
8
+ import { addCustomElementRegistration, addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
9
+ import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
10
+ import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
11
+ import { getStoriesDir } from '../../core/furnace-stories.js';
12
+ import { InvalidArgumentError } from '../../errors/base.js';
13
+ import { FurnaceError } from '../../errors/furnace.js';
14
+ import { toError } from '../../utils/errors.js';
15
+ import { copyFile, ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
16
+ import { info, intro, note, outro, warn } from '../../utils/logger.js';
17
+ function updateConfigForCustomRename(config, oldName, newName) {
18
+ const oldConfig = config.custom[oldName];
19
+ if (!oldConfig)
20
+ return;
21
+ config.custom[newName] = {
22
+ ...oldConfig,
23
+ targetPath: oldConfig.targetPath.replace(new RegExp(`(^|/)${oldName}$`), `$1${newName}`),
24
+ };
25
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- idiomatic key removal from config record
26
+ delete config.custom[oldName];
27
+ // Update composes references in other components
28
+ for (const customConfig of Object.values(config.custom)) {
29
+ if (customConfig.composes) {
30
+ customConfig.composes = customConfig.composes.map((ref) => (ref === oldName ? newName : ref));
31
+ }
32
+ }
33
+ }
34
+ function updateConfigForOverrideRename(config, oldName, newName) {
35
+ const oldConfig = config.overrides[oldName];
36
+ if (!oldConfig)
37
+ return;
38
+ config.overrides[newName] = { ...oldConfig };
39
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- idiomatic key removal from config record
40
+ delete config.overrides[oldName];
41
+ }
42
+ /**
43
+ * Derives the test file name for a component, matching the convention used by
44
+ * `furnace create --with-tests`.
45
+ */
46
+ function deriveTestFileName(componentName, binaryName) {
47
+ const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
48
+ const withoutBinaryPrefix = strippedName.startsWith(binaryName + '-')
49
+ ? strippedName.slice(binaryName.length + 1)
50
+ : strippedName;
51
+ const underscored = withoutBinaryPrefix.replace(/-/g, '_');
52
+ return `browser_${binaryName}_${underscored}.js`;
53
+ }
54
+ /**
55
+ * Renames test files created by `furnace create --with-tests` in the engine
56
+ * test directory. Best-effort: failures are logged as warnings but do not
57
+ * block the rename.
58
+ */
59
+ async function renameTestFiles(engineDir, projectRoot, oldName, newName, journal) {
60
+ let forgeConfig;
61
+ try {
62
+ forgeConfig = await loadConfig(projectRoot);
63
+ }
64
+ catch {
65
+ return; // Cannot determine test paths without config.
66
+ }
67
+ const binaryName = forgeConfig.binaryName;
68
+ const oldTestFileName = deriveTestFileName(oldName, binaryName);
69
+ const newTestFileName = deriveTestFileName(newName, binaryName);
70
+ const testDir = join(engineDir, 'browser/base/content/test', binaryName);
71
+ if (!(await pathExists(testDir)))
72
+ return;
73
+ // Rename the test JS file
74
+ const oldTestPath = join(testDir, oldTestFileName);
75
+ const newTestPath = join(testDir, newTestFileName);
76
+ if (await pathExists(oldTestPath)) {
77
+ try {
78
+ await snapshotFile(journal, oldTestPath);
79
+ const content = await readText(oldTestPath);
80
+ await writeText(newTestPath, content);
81
+ await removeFile(oldTestPath);
82
+ info(`Renamed test file: ${oldTestFileName} → ${newTestFileName}`);
83
+ }
84
+ catch (error) {
85
+ warn(`Could not rename test file — ${toError(error).message}. Rename it manually if needed.`);
86
+ }
87
+ }
88
+ // Update browser.toml entry
89
+ const tomlPath = join(testDir, 'browser.toml');
90
+ if (await pathExists(tomlPath)) {
91
+ try {
92
+ const toml = await readText(tomlPath);
93
+ if (toml.includes(`["${oldTestFileName}"]`)) {
94
+ await snapshotFile(journal, tomlPath);
95
+ const updated = toml.replace(`["${oldTestFileName}"]`, `["${newTestFileName}"]`);
96
+ await writeText(tomlPath, updated);
97
+ info(`Updated browser.toml: ${oldTestFileName} → ${newTestFileName}`);
98
+ }
99
+ }
100
+ catch (error) {
101
+ warn(`Could not update browser.toml — ${toError(error).message}. Update it manually if needed.`);
102
+ }
103
+ }
104
+ }
105
+ /**
106
+ * Performs the transactional rename mutation inside a furnace lock.
107
+ */
108
+ async function performRenameMutations(args) {
109
+ const { projectRoot, oldName, newName, oldDir, newDir, isCustom, componentType, config } = args;
110
+ const oldClassName = tagNameToClassName(oldName);
111
+ const newClassName = tagNameToClassName(newName);
112
+ await runFurnaceMutation(projectRoot, 'rename-rollback', async (ctx) => {
113
+ const journal = createRollbackJournal();
114
+ ctx.registerJournal(journal);
115
+ try {
116
+ await snapshotDir(journal, oldDir);
117
+ await snapshotFile(journal, args.furnaceConfigPath);
118
+ // 1. Create new directory with renamed files and updated content
119
+ await ensureDir(newDir);
120
+ const entries = await readdir(oldDir, { withFileTypes: true });
121
+ for (const entry of entries) {
122
+ if (!entry.isFile())
123
+ continue;
124
+ const oldFileName = entry.name;
125
+ const newFileName = oldFileName.replace(oldName, newName);
126
+ const oldPath = join(oldDir, oldFileName);
127
+ const newPath = join(newDir, newFileName);
128
+ if (isComponentSourceFile(oldFileName)) {
129
+ let content = await readText(oldPath);
130
+ // Use word-boundary-aware patterns so substrings in other
131
+ // identifiers (e.g. "moz-panel" inside "moz-panel-group") are
132
+ // not replaced.
133
+ const tagPattern = new RegExp(`(?<![\\w-])${oldName.replace(/-/g, '\\-')}(?![\\w-])`, 'g');
134
+ const classPattern = new RegExp(`\\b${oldClassName}\\b`, 'g');
135
+ content = content.replace(tagPattern, newName);
136
+ content = content.replace(classPattern, newClassName);
137
+ await writeText(newPath, content);
138
+ }
139
+ else {
140
+ await copyFile(oldPath, newPath);
141
+ }
142
+ }
143
+ // 2. Update furnace.json
144
+ if (isCustom) {
145
+ updateConfigForCustomRename(config, oldName, newName);
146
+ }
147
+ else {
148
+ updateConfigForOverrideRename(config, oldName, newName);
149
+ }
150
+ await writeFurnaceConfig(projectRoot, config);
151
+ // 3. Update engine registrations (custom components only)
152
+ if (isCustom && config.custom[newName]?.register && (await pathExists(args.engineDir))) {
153
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
154
+ await updateEngineRegistrations(args.engineDir, oldName, newName, newDir, ftlDir, journal);
155
+ }
156
+ // 4. Re-key furnace-state.json checksums from old name to new name
157
+ await rekeyStateChecksums(args.projectRoot, componentType, oldName, newName);
158
+ // 5. Remove old directory
159
+ await removeDir(oldDir);
160
+ // 6. Clean up stale Storybook story file for the old name (if it exists
161
+ // from a previous `furnace preview` session). The next preview will
162
+ // regenerate the story under the new name via `syncStories`.
163
+ const oldStoryPath = join(getStoriesDir(args.engineDir), 'furnace', `${oldName}.stories.mjs`);
164
+ if (await pathExists(oldStoryPath)) {
165
+ await snapshotFile(journal, oldStoryPath);
166
+ await removeFile(oldStoryPath);
167
+ info(`Deleted stale story file: ${oldName}.stories.mjs`);
168
+ }
169
+ // 7. Rename test files created by `furnace create --with-tests` (custom only).
170
+ if (isCustom && (await pathExists(args.engineDir))) {
171
+ await renameTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
172
+ }
173
+ info(`Renamed ${componentType} component: ${oldName} → ${newName}`);
174
+ }
175
+ catch (error) {
176
+ try {
177
+ if (await pathExists(newDir)) {
178
+ await removeDir(newDir);
179
+ }
180
+ }
181
+ catch {
182
+ // Best effort cleanup
183
+ }
184
+ try {
185
+ await restoreRollbackJournalOrThrow(journal, `Failed to rename component "${oldName}" to "${newName}"`);
186
+ }
187
+ catch (rollbackError) {
188
+ await recordFurnaceRollbackFailure(projectRoot, 'rename-rollback', toError(rollbackError).message);
189
+ throw rollbackError;
190
+ }
191
+ throw error;
192
+ }
193
+ });
194
+ }
195
+ /**
196
+ * Re-keys checksum entries in furnace-state.json from the old component name
197
+ * to the new name so that `doctor` doesn't flag stale entries and the next
198
+ * `apply` can correctly detect whether the renamed component has changed.
199
+ */
200
+ async function rekeyStateChecksums(projectRoot, componentType, oldName, newName) {
201
+ const oldPrefix = `${componentType}/${oldName}/`;
202
+ const newPrefix = `${componentType}/${newName}/`;
203
+ await updateFurnaceState(projectRoot, (state) => {
204
+ const result = { ...state };
205
+ for (const field of ['appliedChecksums', 'engineChecksums']) {
206
+ const checksums = state[field];
207
+ if (!checksums)
208
+ continue;
209
+ const updated = {};
210
+ for (const [key, value] of Object.entries(checksums)) {
211
+ if (key.startsWith(oldPrefix)) {
212
+ updated[newPrefix + key.slice(oldPrefix.length)] = value;
213
+ }
214
+ else {
215
+ updated[key] = value;
216
+ }
217
+ }
218
+ result[field] = updated;
219
+ }
220
+ return result;
221
+ });
222
+ }
223
+ async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ftlDir, journal) {
224
+ const customElementsPath = join(engineDir, 'toolkit/content/customElements.js');
225
+ const jarMnPath = join(engineDir, 'toolkit/content/jar.mn');
226
+ if (await pathExists(customElementsPath)) {
227
+ await snapshotFile(journal, customElementsPath);
228
+ await removeCustomElementRegistration(engineDir, oldName);
229
+ await addCustomElementRegistration(engineDir, newName, `chrome://global/content/elements/${newName}.mjs`);
230
+ }
231
+ if (await pathExists(jarMnPath)) {
232
+ await snapshotFile(journal, jarMnPath);
233
+ await removeJarMnEntries(engineDir, oldName);
234
+ const files = (await readdir(newDir, { withFileTypes: true }))
235
+ .filter((e) => e.isFile() && (e.name.endsWith('.mjs') || e.name.endsWith('.css')))
236
+ .map((e) => e.name);
237
+ if (files.length > 0) {
238
+ await addJarMnEntries(engineDir, newName, files);
239
+ }
240
+ }
241
+ // Rename FTL localization files in the engine locale directory
242
+ const ftlDirPath = join(engineDir, ftlDir);
243
+ const oldFtlPath = join(ftlDirPath, `${oldName}.ftl`);
244
+ const newFtlPath = join(ftlDirPath, `${newName}.ftl`);
245
+ if (await pathExists(oldFtlPath)) {
246
+ await snapshotFile(journal, oldFtlPath);
247
+ const ftlContent = await readText(oldFtlPath);
248
+ await writeText(newFtlPath, ftlContent);
249
+ await removeFile(oldFtlPath);
250
+ }
251
+ }
252
+ /**
253
+ * Renames a custom or override component atomically: updates directory name,
254
+ * file names, file contents, furnace.json, and engine registrations.
255
+ */
256
+ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
257
+ intro('Furnace Rename');
258
+ if (!CUSTOM_ELEMENT_TAG_PATTERN.test(oldName)) {
259
+ throw new InvalidArgumentError(`Invalid source name "${oldName}": ${CUSTOM_ELEMENT_TAG_RULES}`, 'old-name');
260
+ }
261
+ if (!CUSTOM_ELEMENT_TAG_PATTERN.test(newName)) {
262
+ throw new InvalidArgumentError(`Invalid target name "${newName}": ${CUSTOM_ELEMENT_TAG_RULES}`, 'new-name');
263
+ }
264
+ if (oldName === newName) {
265
+ throw new InvalidArgumentError('Source and target names are identical.', 'new-name');
266
+ }
267
+ const config = await loadFurnaceConfig(projectRoot);
268
+ const furnacePaths = getFurnacePaths(projectRoot);
269
+ const paths = getProjectPaths(projectRoot);
270
+ const isCustom = oldName in config.custom;
271
+ const isOverride = oldName in config.overrides;
272
+ if (!isCustom && !isOverride) {
273
+ throw new FurnaceError(`Component "${oldName}" not found in furnace.json. Only custom and override components can be renamed.`, oldName);
274
+ }
275
+ if (newName in config.custom || newName in config.overrides || config.stock.includes(newName)) {
276
+ throw new FurnaceError(`A component named "${newName}" already exists in furnace.json.`, newName);
277
+ }
278
+ const componentType = isCustom ? 'custom' : 'override';
279
+ const baseDir = isCustom ? furnacePaths.customDir : furnacePaths.overridesDir;
280
+ const oldDir = join(baseDir, oldName);
281
+ const newDir = join(baseDir, newName);
282
+ if (!(await pathExists(oldDir))) {
283
+ throw new FurnaceError(`Component directory not found: components/${componentType}s/${oldName}`, oldName);
284
+ }
285
+ if (await pathExists(newDir)) {
286
+ throw new FurnaceError(`Target directory already exists: components/${componentType}s/${newName}`, newName);
287
+ }
288
+ await performRenameMutations({
289
+ projectRoot,
290
+ oldName,
291
+ newName,
292
+ oldDir,
293
+ newDir,
294
+ isCustom,
295
+ componentType,
296
+ config,
297
+ furnaceConfigPath: furnacePaths.furnaceConfig,
298
+ engineDir: paths.engine,
299
+ });
300
+ note(`Component renamed: ${oldName} → ${newName}\n\n` +
301
+ `Directory: components/${componentType}s/${newName}/\n\n` +
302
+ 'Next steps:\n' +
303
+ ' 1. Review the renamed files for any remaining references\n' +
304
+ ' 2. Run "fireforge furnace validate" to verify\n' +
305
+ ' 3. Run "fireforge furnace apply" to update the engine', newName);
306
+ outro('Rename complete');
307
+ }
308
+ //# sourceMappingURL=rename.js.map
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Runs the furnace scan command to discover MozLitElement components.
3
3
  * @param projectRoot - Root directory of the project
4
+ * @param options - Scan options
4
5
  */
5
- export declare function furnaceScanCommand(projectRoot: string): Promise<void>;
6
+ export declare function furnaceScanCommand(projectRoot: string, options?: {
7
+ deep?: boolean;
8
+ }): Promise<void>;
@@ -1,11 +1,15 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { confirm, multiselect } from '@clack/prompts';
2
+ import { confirm, multiselect, select } from '@clack/prompts';
3
3
  import { getProjectPaths } from '../../core/config.js';
4
- import { ensureFurnaceConfig, furnaceConfigExists, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
5
- import { scanWidgetsDirectory } from '../../core/furnace-scanner.js';
4
+ import { ensureFurnaceConfig, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
5
+ import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
6
+ import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
7
+ import { DEEP_SCAN_PATHS, scanWidgetsDirectory } from '../../core/furnace-scanner.js';
6
8
  import { FurnaceError } from '../../errors/furnace.js';
9
+ import { toError } from '../../utils/errors.js';
7
10
  import { pathExists } from '../../utils/fs.js';
8
11
  import { cancel, info, intro, isCancel, note, outro, spinner, success, } from '../../utils/logger.js';
12
+ import { furnaceOverrideCommand } from './override.js';
9
13
  /**
10
14
  * Prompts the user to add newly discovered stock components to furnace.json.
11
15
  * @param components - Components discovered in the engine scan
@@ -41,24 +45,81 @@ async function promptAddComponents(components, tracked, projectRoot) {
41
45
  outro('Scan complete');
42
46
  return;
43
47
  }
44
- const config = await ensureFurnaceConfig(projectRoot);
45
- const toAdd = selected.filter((s) => !config.stock.includes(s));
46
- config.stock.push(...toAdd);
47
- await writeFurnaceConfig(projectRoot, config);
48
- success(`Added ${selected.length} component${selected.length === 1 ? '' : 's'} to furnace.json`);
48
+ // Wrap the furnace.json mutation in the standard furnace lifecycle so the
49
+ // write goes through the furnace-wide lock and is visible to the global
50
+ // SIGINT/SIGTERM rollback pathway. The journal snapshots furnace.json
51
+ // *before* `ensureFurnaceConfig` runs, so a failed run after the file is
52
+ // auto-created cleans up after itself instead of leaving an unwanted
53
+ // default config behind.
54
+ await runFurnaceMutation(projectRoot, 'scan-rollback', async (ctx) => {
55
+ const journal = createRollbackJournal();
56
+ ctx.registerJournal(journal);
57
+ const furnacePaths = getFurnacePaths(projectRoot);
58
+ await snapshotFile(journal, furnacePaths.furnaceConfig);
59
+ try {
60
+ const config = await ensureFurnaceConfig(projectRoot);
61
+ const toAdd = selected.filter((s) => !config.stock.includes(s));
62
+ config.stock.push(...toAdd);
63
+ await writeFurnaceConfig(projectRoot, config);
64
+ }
65
+ catch (error) {
66
+ try {
67
+ await restoreRollbackJournalOrThrow(journal, 'Failed to update furnace.json during scan');
68
+ }
69
+ catch (rollbackError) {
70
+ await recordFurnaceRollbackFailure(projectRoot, 'scan-rollback', toError(rollbackError).message);
71
+ throw rollbackError;
72
+ }
73
+ throw error;
74
+ }
75
+ });
76
+ const addedNames = selected;
77
+ success(`Added ${addedNames.length} component${addedNames.length === 1 ? '' : 's'} to furnace.json`);
78
+ // Offer to immediately override one of the just-added stock components.
79
+ const shouldOverride = await confirm({
80
+ message: 'Override any of the newly added components?',
81
+ });
82
+ if (isCancel(shouldOverride) || !shouldOverride) {
83
+ return;
84
+ }
85
+ const overrideTarget = await select({
86
+ message: 'Select a component to override',
87
+ options: addedNames.map((name) => ({ value: name, label: name })),
88
+ });
89
+ if (isCancel(overrideTarget)) {
90
+ cancel('Cancelled');
91
+ return;
92
+ }
93
+ await furnaceOverrideCommand(projectRoot, overrideTarget);
49
94
  }
50
95
  /**
51
96
  * Runs the furnace scan command to discover MozLitElement components.
52
97
  * @param projectRoot - Root directory of the project
98
+ * @param options - Scan options
53
99
  */
54
- export async function furnaceScanCommand(projectRoot) {
55
- intro('Furnace Scan');
100
+ export async function furnaceScanCommand(projectRoot, options = {}) {
101
+ intro(options.deep ? 'Furnace Scan (deep)' : 'Furnace Scan');
56
102
  const paths = getProjectPaths(projectRoot);
57
103
  if (!(await pathExists(paths.engine))) {
58
104
  throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
59
105
  }
106
+ // Load scan paths from config if available, merge with deep paths if requested
107
+ const extraScanPaths = [];
108
+ if (await furnaceConfigExists(projectRoot)) {
109
+ const preConfig = await loadFurnaceConfig(projectRoot);
110
+ if (preConfig.scanPaths) {
111
+ extraScanPaths.push(...preConfig.scanPaths);
112
+ }
113
+ }
114
+ if (options.deep) {
115
+ for (const deepPath of DEEP_SCAN_PATHS) {
116
+ if (!extraScanPaths.includes(deepPath)) {
117
+ extraScanPaths.push(deepPath);
118
+ }
119
+ }
120
+ }
60
121
  const s = spinner('Scanning engine for components...');
61
- const components = await scanWidgetsDirectory(paths.engine);
122
+ const components = await scanWidgetsDirectory(paths.engine, undefined, extraScanPaths.length > 0 ? extraScanPaths : undefined);
62
123
  s.stop(`Found ${components.length} component${components.length === 1 ? '' : 's'}`);
63
124
  // Build tracking info from furnace.json if it exists
64
125
  const tracked = new Map();
@@ -1,9 +1,11 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
- import { getProjectPaths } from '../../core/config.js';
4
- import { extractComponentChecksums, hasComponentChanged } from '../../core/furnace-apply.js';
3
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
4
+ import { extractComponentChecksums, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, } from '../../core/furnace-apply.js';
5
5
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from '../../core/furnace-config.js';
6
+ import { resolveFtlDir } from '../../core/furnace-constants.js';
6
7
  import { checkRegistrationConsistency } from '../../core/furnace-validate-checks.js';
8
+ import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
7
9
  import { FurnaceError } from '../../errors/furnace.js';
8
10
  import { pathExists } from '../../utils/fs.js';
9
11
  import { info, intro, note, outro, warn } from '../../utils/logger.js';
@@ -13,7 +15,7 @@ import { info, intro, note, outro, warn } from '../../utils/logger.js';
13
15
  * @param config - Loaded Furnace configuration
14
16
  * @param projectRoot - Root directory of the project
15
17
  */
16
- async function showDetailedComponentStatus(name, config, projectRoot) {
18
+ async function showDetailedComponentStatus(name, config, state, projectRoot, paths, furnacePaths, ftlDir) {
17
19
  const customConfig = config.custom[name];
18
20
  const overrideConfig = config.overrides[name];
19
21
  if (!customConfig && !overrideConfig && !config.stock.includes(name)) {
@@ -23,20 +25,51 @@ async function showDetailedComponentStatus(name, config, projectRoot) {
23
25
  info(`"${name}" is an override component (${overrideConfig.type}).`);
24
26
  info(`Base path: ${overrideConfig.basePath}`);
25
27
  info(`Base version: ${overrideConfig.baseVersion}`);
26
- outro('');
28
+ // baseVersion drift is advisory but reported here alongside the other
29
+ // override metadata so the operator sees the warning before drilling
30
+ // into registration drift or file diff.
31
+ const forgeConfig = await loadConfig(projectRoot);
32
+ const scopedDrift = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version).filter((entry) => entry.name === name);
33
+ for (const entry of scopedDrift) {
34
+ warn(formatOverrideBaseVersionDriftWarning(entry));
35
+ }
36
+ const overrideDir = join(furnacePaths.overridesDir, name);
37
+ const sourceExists = await pathExists(overrideDir);
38
+ const lines = [`${sourceExists ? '\u2713' : '\u2717'} Override directory exists`];
39
+ if (!sourceExists) {
40
+ lines.push('\u2717 Workspace status unavailable (override directory missing)');
41
+ lines.push('\u2717 Engine comparison unavailable (override directory missing)');
42
+ note(lines.join('\n'), `${name} Override Status`);
43
+ outro('Status complete');
44
+ return;
45
+ }
46
+ const previous = extractComponentChecksums(state.appliedChecksums, 'override', name);
47
+ const workspaceChanged = await hasComponentChanged(overrideDir, previous);
48
+ lines.push(`${workspaceChanged ? '\u2717' : '\u2713'} Workspace unchanged since last apply`);
49
+ const engineExists = await pathExists(paths.engine);
50
+ if (!engineExists) {
51
+ lines.push('\u2717 Engine comparison unavailable (engine directory missing)');
52
+ note(lines.join('\n'), `${name} Override Status`);
53
+ outro('Status complete');
54
+ return;
55
+ }
56
+ const engineDrifted = await hasOverrideEngineDrift(paths.engine, overrideDir, overrideConfig, ftlDir);
57
+ lines.push(`${engineDrifted ? '\u2717' : '\u2713'} Engine matches override workspace`);
58
+ note(lines.join('\n'), `${name} Override Status`);
59
+ outro('Status complete');
27
60
  return;
28
61
  }
29
62
  if (config.stock.includes(name)) {
30
63
  info(`"${name}" is a stock component. No local registration to check.`);
31
- outro('');
64
+ outro('Status complete');
32
65
  return;
33
66
  }
34
67
  if (!customConfig) {
35
- outro('');
68
+ outro('Status complete');
36
69
  return;
37
70
  }
38
71
  // Custom component — run registration consistency check
39
- const status = await checkRegistrationConsistency(projectRoot, name, customConfig);
72
+ const status = await checkRegistrationConsistency(projectRoot, name, customConfig, ftlDir);
40
73
  const lines = [];
41
74
  const check = (ok, label) => {
42
75
  lines.push(`${ok ? '\u2713' : '\u2717'} ${label}`);
@@ -55,7 +88,7 @@ async function showDetailedComponentStatus(name, config, projectRoot) {
55
88
  lines.push(`Missing in engine: ${status.missingTargetFiles.join(', ')}`);
56
89
  }
57
90
  note(lines.join('\n'), `${name} Registration Status`);
58
- outro('');
91
+ outro('Status complete');
59
92
  }
60
93
  /**
61
94
  * Runs the furnace status command to show an overview of Furnace state.
@@ -74,10 +107,18 @@ export async function furnaceStatusCommand(projectRoot, name) {
74
107
  const state = await loadFurnaceState(projectRoot);
75
108
  const paths = getProjectPaths(projectRoot);
76
109
  const furnacePaths = getFurnacePaths(projectRoot);
110
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
77
111
  if (name) {
78
- await showDetailedComponentStatus(name, config, projectRoot);
112
+ await showDetailedComponentStatus(name, config, state, projectRoot, paths, furnacePaths, ftlDir);
79
113
  return;
80
114
  }
115
+ // Surface a pendingRepair marker before the normal summary so it cannot
116
+ // be missed. The marker means the last mutation could not roll back
117
+ // cleanly, so the engine and workspace may have drifted in ways apply
118
+ // cannot detect from checksums alone — doctor is the right next step.
119
+ if (state.pendingRepair) {
120
+ warn(`Furnace is in pending-repair state from ${state.pendingRepair.operation} (${state.pendingRepair.timestamp}): ${state.pendingRepair.reason}. Run \`fireforge doctor --repair-furnace\` to reconcile.`);
121
+ }
81
122
  // --- Overview mode ---
82
123
  const overrideCount = Object.keys(config.overrides).length;
83
124
  const customCount = Object.keys(config.custom).length;
@@ -103,35 +144,59 @@ export async function furnaceStatusCommand(projectRoot, name) {
103
144
  // Last apply
104
145
  lines.push(`Last apply: ${state.lastApply ?? 'never'}`);
105
146
  note(lines.join('\n'), 'Furnace Status');
106
- // Check for changes since last apply
147
+ // Surface override baseVersion drift from the project config. This check
148
+ // is cheap (no I/O besides the already-loaded fireforge.json) and catches
149
+ // the single most common silent-drift case: Firefox bumped, overrides
150
+ // still point at the old version. Advisory only — status never fails.
151
+ const forgeConfig = await loadConfig(projectRoot);
152
+ for (const entry of findOverrideBaseVersionDrift(config, forgeConfig.firefox.version)) {
153
+ warn(formatOverrideBaseVersionDriftWarning(entry));
154
+ }
155
+ // Check for both workspace changes (developer edits) and engine drift
156
+ // (reset/download/manual edits). The two have different remediation
157
+ // hints, so report them separately rather than collapsing into a single
158
+ // "something is off" message.
107
159
  if (await pathExists(paths.engine)) {
108
- let changesDetected = false;
109
- for (const oName of Object.keys(config.overrides)) {
160
+ let workspaceChanged = false;
161
+ let engineDrifted = false;
162
+ for (const [oName, overrideConfig] of Object.entries(config.overrides)) {
110
163
  const componentDir = join(furnacePaths.overridesDir, oName);
111
164
  if (!(await pathExists(componentDir)))
112
165
  continue;
113
166
  const previous = extractComponentChecksums(state.appliedChecksums, 'override', oName);
114
167
  if (await hasComponentChanged(componentDir, previous)) {
115
- changesDetected = true;
116
- break;
168
+ workspaceChanged = true;
169
+ }
170
+ else if (await hasOverrideEngineDrift(paths.engine, componentDir, overrideConfig, ftlDir)) {
171
+ engineDrifted = true;
117
172
  }
173
+ if (workspaceChanged && engineDrifted)
174
+ break;
118
175
  }
119
- if (!changesDetected) {
120
- for (const cName of Object.keys(config.custom)) {
176
+ if (!(workspaceChanged && engineDrifted)) {
177
+ for (const [cName, customConfig] of Object.entries(config.custom)) {
121
178
  const componentDir = join(furnacePaths.customDir, cName);
122
179
  if (!(await pathExists(componentDir)))
123
180
  continue;
124
181
  const previous = extractComponentChecksums(state.appliedChecksums, 'custom', cName);
125
182
  if (await hasComponentChanged(componentDir, previous)) {
126
- changesDetected = true;
127
- break;
183
+ workspaceChanged = true;
128
184
  }
185
+ else if (await hasCustomEngineDrift(projectRoot, cName, componentDir, customConfig, ftlDir)) {
186
+ engineDrifted = true;
187
+ }
188
+ if (workspaceChanged && engineDrifted)
189
+ break;
129
190
  }
130
191
  }
131
- if (changesDetected) {
192
+ if (workspaceChanged) {
132
193
  warn('Components have been modified since last apply. Run `fireforge build` or `fireforge furnace apply`.');
133
194
  }
195
+ if (engineDrifted) {
196
+ warn('Engine drift detected since last apply (reset/download/manual edit). Run `fireforge furnace apply` to re-deploy.');
197
+ }
134
198
  }
135
- outro('');
199
+ info('Tip: run `furnace status <name>` for detailed component info, or `furnace --help` for all subcommands.');
200
+ outro('Status complete');
136
201
  }
137
202
  //# sourceMappingURL=status.js.map
@@ -0,0 +1,12 @@
1
+ import type { FurnaceSyncOptions } from '../../types/commands/index.js';
2
+ /**
3
+ * Runs the furnace sync command: detects overrides with baseVersion drift,
4
+ * refreshes them (three-way merge), and re-applies all components.
5
+ *
6
+ * This is the recommended single command to run after `fireforge download`
7
+ * updates the Firefox source.
8
+ *
9
+ * @param projectRoot - Root directory of the project
10
+ * @param options - Sync options
11
+ */
12
+ export declare function furnaceSyncCommand(projectRoot: string, options?: FurnaceSyncOptions): Promise<void>;