@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
@@ -2,7 +2,11 @@
2
2
  import { join } from 'node:path';
3
3
  import { multiselect, text } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../../core/config.js';
5
- import { ensureFurnaceConfig, getFurnacePaths, writeFurnaceConfig, } from '../../core/furnace-config.js';
5
+ import { createDefaultFurnaceConfig, detectComposesCycles, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
+ import { tagNameToClassName } from '../../core/furnace-constants.js';
7
+ import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
8
+ import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
9
+ import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
6
10
  import { isComponentInEngine } from '../../core/furnace-scanner.js';
7
11
  import { DEFAULT_LICENSE, getLicenseHeader } from '../../core/license-headers.js';
8
12
  import { registerTestManifest } from '../../core/manifest-register.js';
@@ -11,15 +15,11 @@ import { FurnaceError } from '../../errors/furnace.js';
11
15
  import { toError } from '../../utils/errors.js';
12
16
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
13
17
  import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
14
- /**
15
- * Converts a kebab-case tag name to PascalCase class name.
16
- * e.g. "moz-sidebar-panel" → "MozSidebarPanel"
17
- */
18
- function tagNameToClassName(tagName) {
19
- return tagName
20
- .split('-')
21
- .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
22
- .join('');
18
+ async function loadAuthoringFurnaceConfig(projectRoot) {
19
+ if (await furnaceConfigExists(projectRoot)) {
20
+ return loadFurnaceConfig(projectRoot);
21
+ }
22
+ return createDefaultFurnaceConfig();
23
23
  }
24
24
  /**
25
25
  * Validates a custom element tag name.
@@ -30,8 +30,8 @@ function validateTagName(name) {
30
30
  return 'Name is required';
31
31
  if (!name.includes('-'))
32
32
  return 'Custom element names must contain a hyphen (e.g., "my-widget")';
33
- if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(name))
34
- return 'Name must be lowercase, start with a letter, and use hyphens to separate words (e.g., "my-widget")';
33
+ if (!CUSTOM_ELEMENT_TAG_PATTERN.test(name))
34
+ return `Name ${CUSTOM_ELEMENT_TAG_RULES}`;
35
35
  return undefined;
36
36
  }
37
37
  /**
@@ -111,9 +111,10 @@ function generateFtlContent(name, header) {
111
111
  * @param license - Project license used for generated headers
112
112
  * @param forgeConfig - Project config fields needed for test naming
113
113
  * @param paths - Resolved project paths used to place test files
114
+ * @param journal - Optional rollback journal that snapshots files before writes
114
115
  * @returns Relative test filenames created or updated for the component
115
116
  */
116
- async function scaffoldTestFiles(componentName, license, forgeConfig, paths) {
117
+ async function scaffoldTestFiles(componentName, license, forgeConfig, paths, journal) {
117
118
  const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
118
119
  // Avoid double-prefixing: strip binaryName prefix since testDirName already uses it
119
120
  const testDirName = forgeConfig.binaryName;
@@ -123,6 +124,9 @@ async function scaffoldTestFiles(componentName, license, forgeConfig, paths) {
123
124
  const underscored = withoutBinaryPrefix.replace(/-/g, '_');
124
125
  const testFileName = `browser_${testDirName}_${underscored}.js`;
125
126
  const testDir = join(paths.engine, 'browser/base/content/test', testDirName);
127
+ if (journal && !(await pathExists(testDir))) {
128
+ recordCreatedDir(journal, testDir);
129
+ }
126
130
  await ensureDir(testDir);
127
131
  const jsHeader = getLicenseHeader(license, 'js');
128
132
  const hashHeader = getLicenseHeader(license, 'hash');
@@ -133,10 +137,14 @@ async function scaffoldTestFiles(componentName, license, forgeConfig, paths) {
133
137
  // Append the new test entry if not already present
134
138
  const existingToml = await readText(tomlPath);
135
139
  if (!existingToml.includes(`["${testFileName}"]`)) {
140
+ if (journal)
141
+ await snapshotFile(journal, tomlPath);
136
142
  await writeText(tomlPath, existingToml.trimEnd() + `\n\n["${testFileName}"]\n`);
137
143
  }
138
144
  }
139
145
  else {
146
+ if (journal)
147
+ await snapshotFile(journal, tomlPath);
140
148
  const browserToml = `${hashHeader}
141
149
 
142
150
  [DEFAULT]
@@ -150,6 +158,8 @@ support-files = ["head.js"]
150
158
  // head.js — only create if it doesn't exist (shared across components)
151
159
  const headPath = join(testDir, 'head.js');
152
160
  if (!(await pathExists(headPath))) {
161
+ if (journal)
162
+ await snapshotFile(journal, headPath);
153
163
  const headJs = `${jsHeader}
154
164
 
155
165
  "use strict";
@@ -177,10 +187,18 @@ add_task(async function test_${underscored}_defined() {
177
187
  Assert.equal(typeof ctor, "function", "Constructor should be a function");
178
188
  });
179
189
  `;
180
- await writeText(join(testDir, testFileName), testJs);
190
+ const testFilePath = join(testDir, testFileName);
191
+ if (journal)
192
+ await snapshotFile(journal, testFilePath);
193
+ await writeText(testFilePath, testJs);
181
194
  testFiles.push(testFileName);
182
- // Register in moz.build
195
+ // Register in moz.build. The registration helper edits browser/base/moz.build,
196
+ // so snapshot it first when a journal is supplied. The existing warn-and-continue
197
+ // contract is preserved so a missing/unparseable moz.build never trips rollback.
183
198
  try {
199
+ const mozBuildPath = join(paths.engine, 'browser/base/moz.build');
200
+ if (journal)
201
+ await snapshotFile(journal, mozBuildPath);
184
202
  const registerResult = await registerTestManifest(paths.engine, testDirName);
185
203
  if (!registerResult.skipped) {
186
204
  success(`Registered test manifest in ${registerResult.manifest}`);
@@ -233,22 +251,77 @@ async function resolveCreateFeatures(isInteractive, options) {
233
251
  * @param description - Human-readable component description
234
252
  * @param localized - Whether to include a Fluent file
235
253
  * @param license - Project license used for generated headers
254
+ * @param journal - Optional rollback journal that snapshots files before writes
236
255
  * @returns Relative filenames written for the component
237
256
  */
238
- async function writeComponentFiles(componentDir, componentName, className, description, localized, license) {
257
+ async function writeComponentFiles(componentDir, componentName, className, description, localized, license, journal) {
239
258
  await ensureDir(componentDir);
240
259
  const files = [`${componentName}.mjs`, `${componentName}.css`];
260
+ const mjsPath = join(componentDir, `${componentName}.mjs`);
261
+ if (journal)
262
+ await snapshotFile(journal, mjsPath);
241
263
  const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'));
242
- await writeText(join(componentDir, `${componentName}.mjs`), mjsContent);
264
+ await writeText(mjsPath, mjsContent);
265
+ const cssPath = join(componentDir, `${componentName}.css`);
266
+ if (journal)
267
+ await snapshotFile(journal, cssPath);
243
268
  const cssContent = generateCssContent(getLicenseHeader(license, 'css'));
244
- await writeText(join(componentDir, `${componentName}.css`), cssContent);
269
+ await writeText(cssPath, cssContent);
245
270
  if (localized) {
271
+ const ftlPath = join(componentDir, `${componentName}.ftl`);
272
+ if (journal)
273
+ await snapshotFile(journal, ftlPath);
246
274
  const ftlContent = generateFtlContent(componentName, getLicenseHeader(license, 'hash'));
247
- await writeText(join(componentDir, `${componentName}.ftl`), ftlContent);
275
+ await writeText(ftlPath, ftlContent);
248
276
  files.push(`${componentName}.ftl`);
249
277
  }
250
278
  return files;
251
279
  }
280
+ /**
281
+ * Performs the transactional mutation phase of furnace create. All file
282
+ * writes and the config update are recorded in a rollback journal so a
283
+ * failure mid-phase restores the workspace and engine to their pre-command
284
+ * state.
285
+ */
286
+ async function performCreateMutations(args) {
287
+ const journal = createRollbackJournal();
288
+ if (args.operationContext) {
289
+ args.operationContext.registerJournal(journal);
290
+ }
291
+ recordCreatedDir(journal, args.componentDir);
292
+ const testFiles = [];
293
+ let files;
294
+ try {
295
+ files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, journal);
296
+ const customEntry = {
297
+ description: args.description,
298
+ targetPath: `toolkit/content/widgets/${args.componentName}`,
299
+ register: args.register,
300
+ localized: args.localized,
301
+ };
302
+ if (args.composes && args.composes.length > 0) {
303
+ customEntry.composes = args.composes;
304
+ }
305
+ args.config.custom[args.componentName] = customEntry;
306
+ await snapshotFile(journal, args.furnacePaths.furnaceConfig);
307
+ await writeFurnaceConfig(args.projectRoot, args.config);
308
+ if (args.withTests) {
309
+ const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
310
+ testFiles.push(...scafFiles);
311
+ }
312
+ }
313
+ catch (error) {
314
+ try {
315
+ await restoreRollbackJournalOrThrow(journal, `Failed to create custom component "${args.componentName}"`);
316
+ }
317
+ catch (rollbackError) {
318
+ await recordFurnaceRollbackFailure(args.projectRoot, 'create-rollback', toError(rollbackError).message);
319
+ throw rollbackError;
320
+ }
321
+ throw error;
322
+ }
323
+ return { files, testFiles };
324
+ }
252
325
  /**
253
326
  * Runs the furnace create command to scaffold a new custom component.
254
327
  * @param projectRoot - Root directory of the project
@@ -258,22 +331,27 @@ async function writeComponentFiles(componentDir, componentName, className, descr
258
331
  export async function furnaceCreateCommand(projectRoot, name, options = {}) {
259
332
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
260
333
  intro('Furnace Create');
261
- // Load or create furnace.json
262
- const config = await ensureFurnaceConfig(projectRoot);
263
- const paths = getProjectPaths(projectRoot);
264
- const forgeConfig = await loadConfig(projectRoot);
265
- const license = forgeConfig.license ?? DEFAULT_LICENSE;
266
- const furnacePaths = getFurnacePaths(projectRoot);
267
334
  // --- Resolve component name ---
335
+ // Validation runs before we load/create any persisted furnace config so a
336
+ // failed authoring command never auto-creates furnace.json in a fresh
337
+ // directory.
268
338
  let componentName = name;
269
339
  if (componentName) {
270
- // Validate CLI-provided name
271
340
  const validationError = validateTagName(componentName);
272
341
  if (validationError) {
273
342
  throw new InvalidArgumentError(validationError, 'name');
274
343
  }
275
344
  }
276
- else if (isInteractive) {
345
+ else if (!isInteractive) {
346
+ throw new InvalidArgumentError('Component name is required in non-interactive mode.\n' +
347
+ 'Usage: fireforge furnace create <name> -d "description"', 'name');
348
+ }
349
+ const paths = getProjectPaths(projectRoot);
350
+ const forgeConfig = await loadConfig(projectRoot);
351
+ const license = forgeConfig.license ?? DEFAULT_LICENSE;
352
+ const furnacePaths = getFurnacePaths(projectRoot);
353
+ if (!componentName) {
354
+ // Interactive prompt path; non-interactive missing-name was rejected above.
277
355
  const nameResult = await text({
278
356
  message: 'Component tag name:',
279
357
  placeholder: 'moz-my-widget',
@@ -285,10 +363,10 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
285
363
  }
286
364
  componentName = String(nameResult);
287
365
  }
288
- else {
289
- throw new InvalidArgumentError('Component name is required in non-interactive mode.\n' +
290
- 'Usage: fireforge furnace create <name> -d "description"', 'name');
291
- }
366
+ // Load the current furnace config only after the interactive name prompt
367
+ // succeeds so a cancelled create in a fresh project does not strand a new
368
+ // furnace.json behind.
369
+ const config = await loadAuthoringFurnaceConfig(projectRoot);
292
370
  // Check for conflicts
293
371
  const conflict = checkNameConflict(config, componentName);
294
372
  if (conflict) {
@@ -321,6 +399,14 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
321
399
  return;
322
400
  }
323
401
  const { localized, register } = featureSelection;
402
+ // --with-tests writes files under engine/browser/base/content/test/ and
403
+ // registers them in moz.build. Guard against a missing engine now rather
404
+ // than letting scaffoldTestFiles fabricate a partial engine tree with
405
+ // ensureDir.
406
+ const withTests = options.withTests ?? false;
407
+ if (withTests && !(await pathExists(paths.engine))) {
408
+ throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests.', componentName);
409
+ }
324
410
  // --- Generate component files ---
325
411
  const className = tagNameToClassName(componentName);
326
412
  const componentDir = join(furnacePaths.customDir, componentName);
@@ -328,35 +414,59 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
328
414
  if (await pathExists(componentDir)) {
329
415
  throw new FurnaceError(`Directory already exists: components/custom/${componentName}`, componentName);
330
416
  }
331
- const files = await writeComponentFiles(componentDir, componentName, className, description, localized, license);
332
- // --- Validate and process --compose ---
417
+ // --- Validate --compose targets BEFORE any writes so a failed validation
418
+ // does not strand component files behind.
333
419
  const composes = options.compose;
334
420
  if (composes && composes.length > 0) {
421
+ const known = new Set([
422
+ ...config.stock,
423
+ ...Object.keys(config.overrides),
424
+ ...Object.keys(config.custom),
425
+ ]);
335
426
  for (const tag of composes) {
336
- if (!config.stock.includes(tag)) {
337
- warn(`Composed tag "${tag}" is not in the stock array of furnace.json.`);
427
+ if (tag === componentName) {
428
+ throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
429
+ }
430
+ if (!known.has(tag)) {
431
+ throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
432
+ 'The referenced component must be registered as stock, override, or custom.');
338
433
  }
339
434
  }
435
+ // Check for cycles that would be introduced by adding this component.
436
+ const tempCustom = {
437
+ ...config.custom,
438
+ [componentName]: {
439
+ description: '',
440
+ targetPath: `toolkit/content/widgets/${componentName}`,
441
+ register: true,
442
+ localized: false,
443
+ composes,
444
+ },
445
+ };
446
+ detectComposesCycles(tempCustom);
340
447
  }
341
- // --- Update furnace.json ---
342
- const customEntry = {
448
+ // All validation is done. Hand off to the transactional mutation helper
449
+ // so any failure restores the workspace and engine to their pre-command
450
+ // state via the shared rollback journal. The mutation runs under the
451
+ // furnace-wide lock and is registered with the global SIGINT/SIGTERM
452
+ // rollback pathway.
453
+ const { files, testFiles } = await runFurnaceMutation(projectRoot, 'create-rollback', (ctx) => performCreateMutations({
454
+ projectRoot,
455
+ componentName,
456
+ className,
343
457
  description,
344
- targetPath: `toolkit/content/widgets/${componentName}`,
345
- register,
346
458
  localized,
347
- };
348
- if (composes && composes.length > 0) {
349
- customEntry.composes = composes;
350
- }
351
- config.custom[componentName] = customEntry;
352
- await writeFurnaceConfig(projectRoot, config);
353
- // --- Scaffold tests if requested ---
354
- const withTests = options.withTests ?? false;
355
- const testFiles = [];
356
- if (withTests) {
357
- const scafFiles = await scaffoldTestFiles(componentName, license, forgeConfig, paths);
358
- testFiles.push(...scafFiles);
359
- }
459
+ register,
460
+ composes,
461
+ componentDir,
462
+ furnacePaths,
463
+ config,
464
+ forgeConfig,
465
+ paths,
466
+ license,
467
+ withTests,
468
+ operationContext: ctx,
469
+ }));
360
470
  // --- Success ---
361
471
  let noteParts = `Files created in components/custom/${componentName}/:\n` +
362
472
  files.map((f) => ` ${f}`).join('\n');