@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,13 +2,24 @@
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { select, text } from '@clack/prompts';
5
- import { getProjectPaths, loadConfig } from '../../core/config.js';
6
- import { ensureFurnaceConfig, getFurnacePaths, writeFurnaceConfig, } from '../../core/furnace-config.js';
5
+ import { getProjectPaths, loadConfig, loadState } from '../../core/config.js';
6
+ import { createDefaultFurnaceConfig, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
7
+ import { resolveFtlDir } from '../../core/furnace-constants.js';
8
+ import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
9
+ import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
10
+ import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
7
11
  import { getComponentDetails, scanWidgetsDirectory } from '../../core/furnace-scanner.js';
8
12
  import { InvalidArgumentError } from '../../errors/base.js';
9
13
  import { FurnaceError } from '../../errors/furnace.js';
14
+ import { toError } from '../../utils/errors.js';
10
15
  import { copyFile, ensureDir, pathExists, writeJson } from '../../utils/fs.js';
11
- import { cancel, intro, isCancel, note, outro } from '../../utils/logger.js';
16
+ import { cancel, info, intro, isCancel, note, outro, warn } from '../../utils/logger.js';
17
+ async function loadAuthoringFurnaceConfig(projectRoot) {
18
+ if (await furnaceConfigExists(projectRoot)) {
19
+ return loadFurnaceConfig(projectRoot);
20
+ }
21
+ return createDefaultFurnaceConfig();
22
+ }
12
23
  /**
13
24
  * Copies the source files needed for a new override into the workspace.
14
25
  * @param srcDir - Original component directory in the engine checkout
@@ -16,7 +27,7 @@ import { cancel, intro, isCancel, note, outro } from '../../utils/logger.js';
16
27
  * @param overrideType - Requested override mode
17
28
  * @returns Filenames copied into the override directory
18
29
  */
19
- async function copyOverrideFiles(srcDir, destDir, overrideType) {
30
+ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasFTL, overrideType, ftlDir, journal) {
20
31
  await ensureDir(destDir);
21
32
  const entries = await readdir(srcDir, { withFileTypes: true });
22
33
  const copiedFiles = [];
@@ -26,18 +37,30 @@ async function copyOverrideFiles(srcDir, destDir, overrideType) {
26
37
  if (overrideType === 'css-only') {
27
38
  // Only copy .css files
28
39
  if (entry.name.endsWith('.css')) {
29
- await copyFile(join(srcDir, entry.name), join(destDir, entry.name));
40
+ const dest = join(destDir, entry.name);
41
+ await snapshotFile(journal, dest);
42
+ await copyFile(join(srcDir, entry.name), dest);
30
43
  copiedFiles.push(entry.name);
31
44
  }
32
45
  }
33
46
  else {
34
47
  // Full override: copy .mjs and .css files
35
48
  if (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')) {
36
- await copyFile(join(srcDir, entry.name), join(destDir, entry.name));
49
+ const dest = join(destDir, entry.name);
50
+ await snapshotFile(journal, dest);
51
+ await copyFile(join(srcDir, entry.name), dest);
37
52
  copiedFiles.push(entry.name);
38
53
  }
39
54
  }
40
55
  }
56
+ if (overrideType === 'full' && hasFTL) {
57
+ const ftlName = `${componentName}.ftl`;
58
+ const ftlSrc = join(engineDir, ftlDir, ftlName);
59
+ const dest = join(destDir, ftlName);
60
+ await snapshotFile(journal, dest);
61
+ await copyFile(ftlSrc, dest);
62
+ copiedFiles.push(ftlName);
63
+ }
41
64
  return copiedFiles;
42
65
  }
43
66
  /**
@@ -51,22 +74,55 @@ async function copyOverrideFiles(srcDir, destDir, overrideType) {
51
74
  * @param firefoxVersion - Firefox version recorded in the workspace config
52
75
  * @param config - Mutable Furnace config object to update
53
76
  */
54
- async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion, config) {
77
+ async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion, config, journal, baseCommit) {
55
78
  const overrideJson = {
56
79
  type: overrideType,
57
80
  description,
58
81
  basePath: details.sourcePath,
59
82
  baseVersion: firefoxVersion,
83
+ ...(baseCommit ? { baseCommit } : {}),
60
84
  };
61
- await writeJson(join(destDir, 'override.json'), overrideJson);
85
+ const overrideJsonPath = join(destDir, 'override.json');
86
+ await snapshotFile(journal, overrideJsonPath);
87
+ await writeJson(overrideJsonPath, overrideJson);
62
88
  config.overrides[componentName] = {
63
89
  type: overrideType,
64
90
  description,
65
91
  basePath: details.sourcePath,
66
92
  baseVersion: firefoxVersion,
93
+ ...(baseCommit ? { baseCommit } : {}),
67
94
  };
68
95
  await writeFurnaceConfig(projectRoot, config);
69
96
  }
97
+ /**
98
+ * Performs the transactional mutation phase of furnace override under the
99
+ * shared lifecycle wrapper. Extracted from `furnaceOverrideCommand` so the
100
+ * main function stays under the `max-lines-per-function` threshold and so
101
+ * the rollback contract is colocated with the writes it guards.
102
+ */
103
+ async function performOverrideMutations(args) {
104
+ return runFurnaceMutation(args.projectRoot, 'override-rollback', async (ctx) => {
105
+ const journal = createRollbackJournal();
106
+ ctx.registerJournal(journal);
107
+ recordCreatedDir(journal, args.destDir);
108
+ try {
109
+ const filesCopied = await copyOverrideFiles(args.engineDir, args.srcDir, args.destDir, args.componentName, args.details.hasFTL, args.overrideType, args.ftlDir, journal);
110
+ await snapshotFile(journal, args.furnacePaths.furnaceConfig);
111
+ await saveOverrideConfig(args.projectRoot, args.destDir, args.componentName, args.overrideType, args.description, args.details, args.firefoxVersion, args.config, journal, args.baseCommit);
112
+ return filesCopied;
113
+ }
114
+ catch (error) {
115
+ try {
116
+ await restoreRollbackJournalOrThrow(journal, `Failed to override component "${args.componentName}"`);
117
+ }
118
+ catch (rollbackError) {
119
+ await recordFurnaceRollbackFailure(args.projectRoot, 'override-rollback', toError(rollbackError).message);
120
+ throw rollbackError;
121
+ }
122
+ throw error;
123
+ }
124
+ });
125
+ }
70
126
  /**
71
127
  * Runs the furnace override command to fork an existing engine component.
72
128
  * @param projectRoot - Root directory of the project
@@ -76,19 +132,33 @@ async function saveOverrideConfig(projectRoot, destDir, componentName, overrideT
76
132
  export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
77
133
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
78
134
  intro('Furnace Override');
79
- // Load or create furnace.json
80
- const config = await ensureFurnaceConfig(projectRoot);
135
+ // --- Validate config-independent inputs BEFORE auto-creating furnace.json
136
+ // so a failed authoring command never strands a fresh config in the
137
+ // project root. CLI-supplied name is checked here; the engine directory
138
+ // and component-resolution checks below also have no config dependency.
81
139
  const paths = getProjectPaths(projectRoot);
82
- const furnacePaths = getFurnacePaths(projectRoot);
83
- // Verify engine/ exists
140
+ if (name !== undefined && !CUSTOM_ELEMENT_TAG_PATTERN.test(name)) {
141
+ throw new InvalidArgumentError(`Invalid component name "${name}": ${CUSTOM_ELEMENT_TAG_RULES}`, 'name');
142
+ }
143
+ if (name === undefined && !isInteractive) {
144
+ throw new InvalidArgumentError('Component name is required in non-interactive mode.\n' +
145
+ 'Usage: fireforge furnace override <name> -t <type> -d "description"', 'name');
146
+ }
147
+ // Verify engine/ exists (config-independent precondition)
84
148
  if (!(await pathExists(paths.engine))) {
85
149
  throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
86
150
  }
151
+ // Load the current config without auto-creating a new furnace.json. A user
152
+ // cancelling out of the interactive prompts should not leave a fresh config
153
+ // behind in an otherwise untouched project.
154
+ const config = await loadAuthoringFurnaceConfig(projectRoot);
155
+ const furnacePaths = getFurnacePaths(projectRoot);
156
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
87
157
  // --- Resolve component name ---
88
158
  let componentName = name;
89
- if (!componentName && isInteractive) {
90
- // Scan for available components, filtering out already-overridden ones
91
- const allComponents = await scanWidgetsDirectory(paths.engine);
159
+ if (!componentName) {
160
+ // Interactive prompt path; non-interactive missing-name was rejected above.
161
+ const allComponents = await scanWidgetsDirectory(paths.engine, ftlDir);
92
162
  const available = allComponents.filter((c) => !(c.tagName in config.overrides));
93
163
  if (available.length === 0) {
94
164
  throw new FurnaceError('No components available to override.');
@@ -109,20 +179,12 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
109
179
  }
110
180
  componentName = selected;
111
181
  }
112
- else if (!componentName) {
113
- throw new InvalidArgumentError('Component name is required in non-interactive mode.\n' +
114
- 'Usage: fireforge furnace override <name> -t <type> -d "description"', 'name');
115
- }
116
- // Validate component name to prevent path traversal
117
- if (!/^[a-z][a-z0-9]*-[a-z0-9-]*$/.test(componentName)) {
118
- throw new InvalidArgumentError(`Invalid component name "${componentName}": must contain a hyphen (required for custom elements), with only lowercase letters, digits, and hyphens.`, 'name');
119
- }
120
182
  // Check for existing override
121
183
  if (componentName in config.overrides) {
122
184
  throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
123
185
  }
124
186
  // Validate the component exists in engine
125
- const details = await getComponentDetails(paths.engine, componentName);
187
+ const details = await getComponentDetails(paths.engine, componentName, ftlDir);
126
188
  if (!details) {
127
189
  throw new FurnaceError(`Component "${componentName}" not found in the engine source tree.`, componentName);
128
190
  }
@@ -172,8 +234,25 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
172
234
  throw new FurnaceError(`Directory already exists: components/overrides/${componentName}`, componentName);
173
235
  }
174
236
  const forgeConfig = await loadConfig(projectRoot);
175
- const copiedFiles = await copyOverrideFiles(srcDir, destDir, overrideType);
176
- await saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, forgeConfig.firefox.version, config);
237
+ const state = await loadState(projectRoot);
238
+ // All validation is done. The mutation phase runs in a helper that owns
239
+ // the rollback journal, the furnace-wide lock, and SIGINT/SIGTERM-driven
240
+ // teardown via the lifecycle wrapper.
241
+ const copiedFiles = await performOverrideMutations({
242
+ projectRoot,
243
+ componentName,
244
+ overrideType,
245
+ description,
246
+ srcDir,
247
+ destDir,
248
+ engineDir: paths.engine,
249
+ details,
250
+ config,
251
+ furnacePaths,
252
+ ftlDir,
253
+ firefoxVersion: forgeConfig.firefox.version,
254
+ ...(state.baseCommit ? { baseCommit: state.baseCommit } : {}),
255
+ });
177
256
  // --- Success ---
178
257
  note(`Files copied to components/overrides/${componentName}/:\n` +
179
258
  copiedFiles.map((f) => ` ${f}`).join('\n') +
@@ -185,4 +264,115 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
185
264
  ' 3. Run "fireforge build" to apply and build', componentName);
186
265
  outro('Override created');
187
266
  }
267
+ /**
268
+ * Creates multiple overrides in a single invocation. Each component is validated
269
+ * and created sequentially; failures on one component do not block the rest.
270
+ * @param projectRoot - Root directory of the project
271
+ * @param names - Component tag names to override
272
+ * @param options - CLI options applied to all overrides
273
+ */
274
+ export async function furnaceBatchOverrideCommand(projectRoot, names, options = {}) {
275
+ intro(`Furnace Override (batch: ${names.length} components)`);
276
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
277
+ if (!options.type && !isInteractive) {
278
+ throw new InvalidArgumentError('Override type is required for batch override in non-interactive mode. Use -t css-only or -t full.', 'type');
279
+ }
280
+ const paths = getProjectPaths(projectRoot);
281
+ if (!(await pathExists(paths.engine))) {
282
+ throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
283
+ }
284
+ // Validate all names upfront before any mutations
285
+ for (const name of names) {
286
+ if (!CUSTOM_ELEMENT_TAG_PATTERN.test(name)) {
287
+ throw new InvalidArgumentError(`Invalid component name "${name}": ${CUSTOM_ELEMENT_TAG_RULES}`, 'name');
288
+ }
289
+ }
290
+ const config = await loadAuthoringFurnaceConfig(projectRoot);
291
+ const furnacePaths = getFurnacePaths(projectRoot);
292
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
293
+ const forgeConfig = await loadConfig(projectRoot);
294
+ const state = await loadState(projectRoot);
295
+ // Check for duplicates and pre-existing overrides
296
+ const uniqueNames = [...new Set(names)];
297
+ for (const name of uniqueNames) {
298
+ if (name in config.overrides) {
299
+ throw new FurnaceError(`An override for "${name}" already exists in furnace.json`, name);
300
+ }
301
+ }
302
+ const succeeded = [];
303
+ const failed = [];
304
+ for (const componentName of uniqueNames) {
305
+ const details = await getComponentDetails(paths.engine, componentName, ftlDir);
306
+ if (!details) {
307
+ failed.push({ name: componentName, error: 'not found in engine source tree' });
308
+ continue;
309
+ }
310
+ let overrideType = options.type;
311
+ if (!overrideType) {
312
+ const typeResult = await select({
313
+ message: `Override type for ${componentName}:`,
314
+ options: [
315
+ { value: 'css-only', label: 'CSS only — restyle the component' },
316
+ { value: 'full', label: 'Full override — modify styling and behavior' },
317
+ ],
318
+ });
319
+ if (isCancel(typeResult)) {
320
+ info(`Skipping ${componentName} (cancelled)`);
321
+ continue;
322
+ }
323
+ overrideType = typeResult;
324
+ }
325
+ if (overrideType === 'css-only' && !details.hasCSS) {
326
+ failed.push({ name: componentName, error: 'no CSS files to override with --type css-only' });
327
+ continue;
328
+ }
329
+ const destDir = join(furnacePaths.overridesDir, componentName);
330
+ if (await pathExists(destDir)) {
331
+ failed.push({ name: componentName, error: 'directory already exists' });
332
+ continue;
333
+ }
334
+ try {
335
+ await performOverrideMutations({
336
+ projectRoot,
337
+ componentName,
338
+ overrideType,
339
+ description: options.description ?? '',
340
+ srcDir: join(paths.engine, details.sourcePath),
341
+ destDir,
342
+ engineDir: paths.engine,
343
+ details,
344
+ config,
345
+ furnacePaths,
346
+ ftlDir,
347
+ firefoxVersion: forgeConfig.firefox.version,
348
+ ...(state.baseCommit ? { baseCommit: state.baseCommit } : {}),
349
+ });
350
+ succeeded.push(componentName);
351
+ }
352
+ catch (error) {
353
+ failed.push({
354
+ name: componentName,
355
+ error: error instanceof Error ? error.message : String(error),
356
+ });
357
+ }
358
+ }
359
+ if (succeeded.length > 0) {
360
+ note(`Created ${succeeded.length} override(s):\n` +
361
+ succeeded.map((n) => ` components/overrides/${n}/`).join('\n') +
362
+ '\n\n' +
363
+ 'Next steps:\n' +
364
+ ' 1. Edit the copied files in each override directory\n' +
365
+ ' 2. Run "fireforge furnace preview" to see changes\n' +
366
+ ' 3. Run "fireforge build" to apply and build', 'Batch Override');
367
+ }
368
+ if (failed.length > 0) {
369
+ for (const f of failed) {
370
+ warn(`${f.name}: ${f.error}`);
371
+ }
372
+ }
373
+ if (succeeded.length === 0) {
374
+ throw new FurnaceError(`All ${uniqueNames.length} override(s) failed.`);
375
+ }
376
+ outro(`Batch override complete: ${succeeded.length} succeeded, ${failed.length} failed`);
377
+ }
188
378
  //# sourceMappingURL=override.js.map
@@ -1,12 +1,77 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { getProjectPaths } from '../../core/config.js';
4
- import { furnaceConfigExists, loadFurnaceConfig } from '../../core/furnace-config.js';
4
+ import { applyAllComponents } from '../../core/furnace-apply.js';
5
+ import { furnaceConfigExists, loadFurnaceConfig, updateFurnaceState, } from '../../core/furnace-config.js';
6
+ import { runFurnaceMutation } from '../../core/furnace-operation.js';
7
+ import { restoreRollbackJournal } from '../../core/furnace-rollback.js';
5
8
  import { cleanStories, syncStories } from '../../core/furnace-stories.js';
6
9
  import { runMach, runMachCapture } from '../../core/mach.js';
7
10
  import { FurnaceError } from '../../errors/furnace.js';
11
+ import { toError } from '../../utils/errors.js';
8
12
  import { pathExists } from '../../utils/fs.js';
9
- import { info, intro, outro, spinner } from '../../utils/logger.js';
13
+ import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
14
+ /**
15
+ * Runs the two teardown steps — `cleanStories` and the rollback-journal
16
+ * restore — independently, collecting whatever errors either step throws.
17
+ * Both steps must run regardless of the other's outcome, because each
18
+ * operates on a different part of engine state and skipping one leaves
19
+ * the engine in a worse position than a single-step failure.
20
+ *
21
+ * Extracted from `furnacePreviewCommand` so the main function stays
22
+ * under the `max-lines-per-function` threshold and so the teardown
23
+ * contract is explicit in one place.
24
+ *
25
+ * @returns Collected teardown errors, or an empty array if both steps
26
+ * succeeded (or no journal was ever created).
27
+ */
28
+ async function runPreviewTeardown(engineDir, storiesCleanupRequired, journal) {
29
+ const errors = [];
30
+ if (storiesCleanupRequired) {
31
+ try {
32
+ await cleanStories(engineDir);
33
+ }
34
+ catch (error) {
35
+ const wrapped = toError(error);
36
+ errors.push(new Error(`Story cleanup: ${wrapped.message}`));
37
+ }
38
+ }
39
+ if (journal) {
40
+ try {
41
+ await restoreRollbackJournal(journal);
42
+ }
43
+ catch (error) {
44
+ const wrapped = toError(error);
45
+ errors.push(new Error(`Journal restore: ${wrapped.message}`));
46
+ }
47
+ }
48
+ return errors;
49
+ }
50
+ /**
51
+ * Reports staging failures (component-level errors and per-step errors) to the
52
+ * user and throws a single FurnaceError summarising the failure count.
53
+ * Extracted from `furnacePreviewCommand` to keep that function under the
54
+ * `max-lines-per-function` threshold and to colocate the failure-reporting
55
+ * contract in one place.
56
+ *
57
+ * @returns The total failure count if there were any (always non-zero when
58
+ * this returns; the function throws after logging).
59
+ */
60
+ function reportPreviewStagingFailures(stageResult) {
61
+ for (const err of stageResult.errors) {
62
+ warn(`Furnace: ${err.name} — ${err.error}`);
63
+ }
64
+ for (const applied of stageResult.applied) {
65
+ if (applied.stepErrors && applied.stepErrors.length > 0) {
66
+ for (const stepErr of applied.stepErrors) {
67
+ warn(`Furnace: ${applied.name} [${stepErr.step}] ${stepErr.error}`);
68
+ }
69
+ }
70
+ }
71
+ const appliedWithStepErrorsCount = stageResult.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
72
+ const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
73
+ throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to stage for preview`);
74
+ }
10
75
  /**
11
76
  * Builds a targeted Storybook failure message from captured mach output.
12
77
  * @param output - Combined stdout and stderr from the Storybook command
@@ -55,37 +120,126 @@ export async function furnacePreviewCommand(projectRoot, options = {}) {
55
120
  throw new FurnaceError('This Firefox checkout does not contain browser/components/storybook. Furnace preview requires the upstream Storybook workspace to exist before stories can be synced.');
56
121
  }
57
122
  let previewResult;
58
- let storiesSynced = false;
59
- try {
60
- // Sync story files
61
- const syncSpinner = spinner('Syncing component stories...');
62
- const result = await syncStories(projectRoot);
63
- storiesSynced = true;
64
- const created = result.created.length;
65
- const updated = result.updated.length;
66
- const total = created + updated;
67
- syncSpinner.stop(`Synced ${total} stories (${created} new, ${updated} updated)`);
68
- // Force-reinstall Storybook dependencies if requested
69
- if (options.install) {
70
- const installSpinner = spinner('Reinstalling Storybook dependencies...');
71
- const installCode = await runMach(['storybook', 'upgrade'], paths.engine);
72
- if (installCode !== 0) {
73
- installSpinner.stop('Failed to reinstall Storybook dependencies');
74
- throw new FurnaceError('Storybook dependency reinstallation failed. Try running "python3 ./mach storybook upgrade" manually in the engine directory.');
123
+ // True once we are about to (or have) written to engine/.../stories/furnace.
124
+ // Intentionally set BEFORE `syncStories` is awaited so a mid-sync failure
125
+ // still triggers `cleanStories` during teardown. `cleanStories` is a
126
+ // full-directory wipe, so it is correct to run against partial state —
127
+ // including state with zero files, where it is a cheap no-op.
128
+ let storiesCleanupRequired = false;
129
+ let previewJournal;
130
+ let primaryError;
131
+ // The preview command runs under runFurnaceMutation so a SIGINT during
132
+ // Storybook still triggers cleanStories + journal restore via the CLI
133
+ // entrypoint's global signal handlers consulting the lifecycle registry.
134
+ // The body's own try/catch + teardown path handles the normal exit case
135
+ // (mach storybook returns or throws).
136
+ await runFurnaceMutation(projectRoot, 'preview-teardown', async (ctx) => {
137
+ // Register the stories cleanup as an extra teardown hook so the signal
138
+ // handler can wipe the staged stories directory in addition to the
139
+ // journal restore.
140
+ ctx.registerCleanup(async () => {
141
+ if (storiesCleanupRequired) {
142
+ await cleanStories(paths.engine);
143
+ }
144
+ });
145
+ try {
146
+ // Stage workspace override/custom files into engine/ so Storybook can
147
+ // resolve freshly edited chrome:// imports. Stock-only projects skip
148
+ // this step because stock components are never written from workspace
149
+ // sources.
150
+ if (overrideCount + customCount > 0) {
151
+ const stageSpinner = spinner('Staging components for preview...');
152
+ let stageResult;
153
+ try {
154
+ stageResult = await applyAllComponents(projectRoot, false, {
155
+ persistState: false,
156
+ operationContext: ctx,
157
+ });
158
+ }
159
+ catch (error) {
160
+ stageSpinner.error('Failed to stage components');
161
+ throw error;
162
+ }
163
+ previewJournal = stageResult.rollbackJournal;
164
+ if (previewJournal) {
165
+ ctx.registerJournal(previewJournal);
166
+ }
167
+ const appliedWithStepErrorsCount = stageResult.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
168
+ const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
169
+ if (totalFailures > 0) {
170
+ stageSpinner.error('Failed to stage components');
171
+ reportPreviewStagingFailures(stageResult);
172
+ }
173
+ stageSpinner.stop(`Staged ${stageResult.applied.length} component${stageResult.applied.length === 1 ? '' : 's'} for preview`);
174
+ }
175
+ // Sync story files. Set the cleanup flag before the await so a partial
176
+ // write failure still triggers the teardown wipe — `syncStories` writes
177
+ // files incrementally with no internal cleanup of its own.
178
+ const syncSpinner = spinner('Syncing component stories...');
179
+ storiesCleanupRequired = true;
180
+ const result = await syncStories(projectRoot);
181
+ const created = result.created.length;
182
+ const updated = result.updated.length;
183
+ const total = created + updated;
184
+ syncSpinner.stop(`Synced ${total} stories (${created} new, ${updated} updated)`);
185
+ // Force-reinstall Storybook dependencies if requested
186
+ if (options.install) {
187
+ const installSpinner = spinner('Reinstalling Storybook dependencies...');
188
+ const installCode = await runMach(['storybook', 'upgrade'], paths.engine);
189
+ if (installCode !== 0) {
190
+ installSpinner.stop('Failed to reinstall Storybook dependencies');
191
+ throw new FurnaceError('Storybook dependency reinstallation failed. Try running "python3 ./mach storybook upgrade" manually in the engine directory.');
192
+ }
193
+ installSpinner.stop('Storybook dependencies reinstalled');
75
194
  }
76
- installSpinner.stop('Storybook dependencies reinstalled');
195
+ // Start Storybook
196
+ info('Starting Storybook...');
197
+ info('Press Ctrl+C to stop\n');
198
+ previewResult = await runMachCapture(['storybook'], paths.engine);
77
199
  }
78
- // Start Storybook
79
- info('Starting Storybook...');
80
- info('Press Ctrl+C to stop\n');
81
- previewResult = await runMachCapture(['storybook'], paths.engine);
82
- }
83
- finally {
84
- if (storiesSynced) {
85
- await cleanStories(paths.engine);
200
+ catch (error) {
201
+ primaryError = error;
86
202
  }
87
- }
88
- if (previewResult.exitCode !== 0 &&
203
+ // Teardown runs unconditionally and never short-circuits: a failure in
204
+ // cleanStories must not prevent the journal restore, and vice versa. The
205
+ // previous implementation ran teardown in a `finally` block that called
206
+ // `restoreRollbackJournalOrThrow`, which threw synchronously — that throw
207
+ // bypassed the primary error and, worse, skipped downstream handling so
208
+ // the engine was left with staged files and the user got a teardown
209
+ // message with no guidance. We now collect both failures and, if anything
210
+ // went wrong, persist a `pendingRepair` marker that `fireforge doctor`
211
+ // consumes to finish the reconciliation.
212
+ const teardownErrors = await runPreviewTeardown(paths.engine, storiesCleanupRequired, previewJournal);
213
+ if (teardownErrors.length > 0) {
214
+ const teardownSummary = teardownErrors.map((err) => err.message).join('; ');
215
+ try {
216
+ await updateFurnaceState(projectRoot, (state) => ({
217
+ ...state,
218
+ pendingRepair: {
219
+ operation: 'preview-teardown',
220
+ timestamp: new Date().toISOString(),
221
+ reason: teardownSummary,
222
+ },
223
+ }));
224
+ }
225
+ catch (markError) {
226
+ warn(`Could not record pending-repair marker in .fireforge/furnace-state.json — ${toError(markError).message}. Engine may still be in a staged state; run "fireforge furnace apply" manually to reconcile.`);
227
+ }
228
+ const primarySuffix = primaryError
229
+ ? ` (original error: ${toError(primaryError).message})`
230
+ : '';
231
+ throw new FurnaceError(`Preview teardown could not restore the engine cleanly: ${teardownSummary}. The engine may contain staged workspace files. Run "fireforge doctor --repair-furnace" to reconcile, or run "fireforge furnace apply" manually.${primarySuffix}`);
232
+ }
233
+ if (primaryError) {
234
+ // Re-throwing the captured error preserves its original shape. The
235
+ // `toError` wrap normalises non-Error throws (strings, plain objects)
236
+ // into real Error instances so the eslint `only-throw-error` rule
237
+ // holds and downstream formatters always see a message/stack pair.
238
+ throw toError(primaryError);
239
+ }
240
+ });
241
+ if (previewResult &&
242
+ previewResult.exitCode !== 0 &&
89
243
  previewResult.exitCode !== 130 &&
90
244
  previewResult.exitCode !== 143) {
91
245
  const combinedOutput = `${previewResult.stdout}\n${previewResult.stderr}`;
@@ -0,0 +1,10 @@
1
+ import type { FurnaceRefreshOptions } from '../../types/commands/index.js';
2
+ /**
3
+ * Runs the furnace refresh command to merge upstream Firefox changes into
4
+ * an override component using three-way merge.
5
+ *
6
+ * @param projectRoot - Root directory of the project
7
+ * @param name - Component tag name to refresh (omit when using --all)
8
+ * @param options - Command options
9
+ */
10
+ export declare function furnaceRefreshCommand(projectRoot: string, name: string | undefined, options?: FurnaceRefreshOptions): Promise<void>;