@hominis/fireforge 0.10.1 → 0.11.1

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 +6 -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
@@ -1,15 +1,18 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
- import { getProjectPaths } from '../../core/config.js';
3
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
4
4
  import { applyAllComponents, applyCustomComponent, applyOverrideComponent, computeComponentChecksums, prefixChecksums, } from '../../core/furnace-apply.js';
5
+ import { logApplyResult } from '../../core/furnace-apply-output.js';
5
6
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, updateFurnaceState, } from '../../core/furnace-config.js';
7
+ import { resolveFtlDir } from '../../core/furnace-constants.js';
8
+ import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
6
9
  import { createRollbackJournal, restoreRollbackJournalOrThrow, } from '../../core/furnace-rollback.js';
7
- import { validateAllComponents, validateComponent } from '../../core/furnace-validate.js';
10
+ import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
8
11
  import { FurnaceError } from '../../errors/furnace.js';
9
12
  import { toError } from '../../utils/errors.js';
10
13
  import { pathExists } from '../../utils/fs.js';
11
- import { error, info, intro, note, outro, spinner, success, warn } from '../../utils/logger.js';
12
- import { displayValidationIssues } from './validation-output.js';
14
+ import { info, intro, note, outro, spinner, warn } from '../../utils/logger.js';
15
+ import { runDeployValidation } from './validation-output.js';
13
16
  /**
14
17
  * Builds the final deploy failure summary from apply and validation error counts.
15
18
  * @param applyErrors - Number of component application failures
@@ -39,6 +42,38 @@ function getFailedComponentNames(result) {
39
42
  }
40
43
  return failed;
41
44
  }
45
+ function getPersistableAppliedEntry(name, appliedEntry) {
46
+ if (!appliedEntry) {
47
+ throw new FurnaceError(`Named deploy for "${name}" completed without an applied entry.`);
48
+ }
49
+ if (appliedEntry.type !== 'override' && appliedEntry.type !== 'custom') {
50
+ throw new FurnaceError(`Named deploy for "${name}" returned unsupported component type "${appliedEntry.type}".`);
51
+ }
52
+ return {
53
+ name: appliedEntry.name,
54
+ type: appliedEntry.type,
55
+ };
56
+ }
57
+ /**
58
+ * Decides whether a single-component deploy completed cleanly enough to
59
+ * persist its checksums into furnace-state.json.
60
+ *
61
+ * Named deploy is atomic: if any apply step fails, the rollback journal
62
+ * restores the engine to its pre-deploy state and this helper returns
63
+ * `false` so state is not touched. The conditions must stay in lock-step
64
+ * with the rollback-trigger in `applyNamedComponent` — both now read from
65
+ * this helper so a future refactor cannot drift them apart and accidentally
66
+ * persist partial state.
67
+ */
68
+ function shouldPersistNamedDeployState(result, isDryRun) {
69
+ if (isDryRun)
70
+ return false;
71
+ if (result.errors.length > 0)
72
+ return false;
73
+ if (getStepFailureCount(result) > 0)
74
+ return false;
75
+ return result.applied.length > 0;
76
+ }
42
77
  /**
43
78
  * Persists checksum state for a successfully applied named component.
44
79
  * @param projectRoot - Root directory of the project
@@ -51,14 +86,60 @@ async function persistSingleComponentState(projectRoot, appliedEntry, furnacePat
51
86
  : join(furnacePaths.customDir, appliedEntry.name);
52
87
  const checksums = await computeComponentChecksums(componentDir);
53
88
  const prefixed = prefixChecksums(checksums, appliedEntry.type, appliedEntry.name);
89
+ const componentPrefix = `${appliedEntry.type}/${appliedEntry.name}/`;
54
90
  await updateFurnaceState(projectRoot, (current) => ({
55
91
  ...current,
56
- appliedChecksums: { ...(current.appliedChecksums ?? {}), ...prefixed },
92
+ appliedChecksums: {
93
+ ...Object.fromEntries(Object.entries(current.appliedChecksums ?? {}).filter(([key]) => !key.startsWith(componentPrefix))),
94
+ ...prefixed,
95
+ },
96
+ engineChecksums: {
97
+ ...Object.fromEntries(Object.entries(current.engineChecksums ?? {}).filter(([key]) => !key.startsWith(componentPrefix))),
98
+ ...prefixed,
99
+ },
57
100
  lastApply: new Date().toISOString(),
58
101
  }));
59
102
  }
103
+ /**
104
+ * True when an applied-result carries any signal that the deploy did not
105
+ * complete cleanly. Any such signal must trigger the rollback journal and
106
+ * must suppress state persistence, so both call sites read from here.
107
+ *
108
+ * An apply failure on a single-component deploy means one of:
109
+ * - `result.errors` has an entry (the apply body threw)
110
+ * - an `applied[].stepErrors` entry is present (a registration step
111
+ * failed after the copy succeeded)
112
+ *
113
+ * Both are treated identically for atomicity purposes: rollback runs,
114
+ * state stays on the previous checkpoint, and the caller raises a deploy
115
+ * failure so the operator sees the error.
116
+ */
117
+ function namedDeployHasFailures(result) {
118
+ return result.errors.length > 0 || getStepFailureCount(result) > 0;
119
+ }
120
+ async function restoreNamedDeployRollback(rollbackJournal, name, projectRoot) {
121
+ if (!rollbackJournal)
122
+ return;
123
+ try {
124
+ await restoreRollbackJournalOrThrow(rollbackJournal, `Furnace deploy failed for "${name}"`);
125
+ }
126
+ catch (rollbackError) {
127
+ if (projectRoot) {
128
+ await recordFurnaceRollbackFailure(projectRoot, 'deploy-rollback', toError(rollbackError).message);
129
+ }
130
+ throw rollbackError;
131
+ }
132
+ }
60
133
  /**
61
134
  * Applies a single named override or custom component in targeted deploy mode.
135
+ *
136
+ * Atomicity contract: the helper owns a single rollback journal for the
137
+ * deploy. If any apply path fails (thrown error or step error), the journal
138
+ * restores the engine to its pre-deploy state and the returned `result` is
139
+ * still reported as failed to the caller. The caller must consult
140
+ * {@link shouldPersistNamedDeployState} before touching furnace-state.json —
141
+ * partial checksums must never be persisted on top of a rollback.
142
+ *
62
143
  * @param name - Component name to apply
63
144
  * @param engineDir - Firefox engine source directory
64
145
  * @param furnacePaths - Resolved Furnace workspace paths
@@ -66,8 +147,11 @@ async function persistSingleComponentState(projectRoot, appliedEntry, furnacePat
66
147
  * @param isDryRun - Whether file writes should be skipped
67
148
  * @returns Apply result for the named component, or `stock` for stock-only entries
68
149
  */
69
- async function applyNamedComponent(name, engineDir, furnacePaths, config, isDryRun) {
150
+ async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot) {
70
151
  const rollbackJournal = isDryRun ? undefined : createRollbackJournal();
152
+ if (rollbackJournal && operationContext) {
153
+ operationContext.registerJournal(rollbackJournal);
154
+ }
71
155
  const result = {
72
156
  applied: [],
73
157
  skipped: [],
@@ -82,7 +166,7 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, isDryR
82
166
  throw new FurnaceError(`Component directory not found: components/overrides/${name}`, name);
83
167
  }
84
168
  try {
85
- const { affectedPaths: filesAffected, actions } = await applyOverrideComponent(engineDir, name, componentDir, overrideConfig, isDryRun, rollbackJournal);
169
+ const { affectedPaths: filesAffected, actions } = await applyOverrideComponent(engineDir, name, componentDir, overrideConfig, ftlDir, isDryRun, rollbackJournal);
86
170
  if (isDryRun && actions) {
87
171
  result.actions = actions;
88
172
  }
@@ -91,8 +175,8 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, isDryR
91
175
  catch (error) {
92
176
  result.errors.push({ name, error: toError(error).message });
93
177
  }
94
- if (!isDryRun && result.errors.length > 0 && rollbackJournal) {
95
- await restoreRollbackJournalOrThrow(rollbackJournal, `Furnace deploy failed for "${name}"`);
178
+ if (!isDryRun && namedDeployHasFailures(result)) {
179
+ await restoreNamedDeployRollback(rollbackJournal, name, projectRoot);
96
180
  }
97
181
  return result;
98
182
  }
@@ -102,7 +186,7 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, isDryR
102
186
  throw new FurnaceError(`Component directory not found: components/custom/${name}`, name);
103
187
  }
104
188
  try {
105
- const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, isDryRun, rollbackJournal);
189
+ const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal);
106
190
  if (isDryRun && actions) {
107
191
  result.actions = actions;
108
192
  }
@@ -116,8 +200,8 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, isDryR
116
200
  catch (error) {
117
201
  result.errors.push({ name, error: toError(error).message });
118
202
  }
119
- if (!isDryRun && getStepFailureCount(result) > 0 && rollbackJournal) {
120
- await restoreRollbackJournalOrThrow(rollbackJournal, `Furnace deploy failed for "${name}"`);
203
+ if (!isDryRun && namedDeployHasFailures(result)) {
204
+ await restoreNamedDeployRollback(rollbackJournal, name, projectRoot);
121
205
  }
122
206
  return result;
123
207
  }
@@ -126,31 +210,6 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, isDryR
126
210
  }
127
211
  throw new FurnaceError(`Component "${name}" not found in furnace.json.`, name);
128
212
  }
129
- /**
130
- * Resolves the validation target for a single named component.
131
- * @param name - Component name to validate
132
- * @param config - Loaded Furnace configuration
133
- * @param furnacePaths - Resolved Furnace workspace paths
134
- * @returns Validation target details, or `stock` for stock-only entries
135
- */
136
- function resolveNamedValidationTarget(name, config, furnacePaths) {
137
- if (name in config.overrides) {
138
- return {
139
- type: 'override',
140
- componentDir: join(furnacePaths.overridesDir, name),
141
- };
142
- }
143
- if (name in config.custom) {
144
- return {
145
- type: 'custom',
146
- componentDir: join(furnacePaths.customDir, name),
147
- };
148
- }
149
- if (config.stock.includes(name)) {
150
- return 'stock';
151
- }
152
- throw new FurnaceError(`Component "${name}" not found in furnace.json.`, name);
153
- }
154
213
  /**
155
214
  * Prints the deploy summary after apply and validation complete.
156
215
  * @param result - Aggregate apply result
@@ -169,7 +228,8 @@ function printDeploymentSummary(result, totalErrors, totalWarnings, componentCou
169
228
  note(`Would apply ${appliedCount} component(s)\n` +
170
229
  `${result.actions?.length ?? 0} planned action(s)\n` +
171
230
  `${applyErrors} apply error(s)\n` +
172
- `${totalErrors} validation error(s), ${totalWarnings} validation warning(s) across ${componentCount} validated component(s)` +
231
+ `${totalErrors} validation error(s), ${totalWarnings} validation warning(s) across ${componentCount} validated component(s)\n` +
232
+ '(validation ran against current source files — no engine files were modified)' +
173
233
  (skippedValidationCount > 0
174
234
  ? `\nSkipped validation for ${skippedValidationCount} component(s) with apply errors`
175
235
  : ''), 'Dry Run Summary');
@@ -190,33 +250,12 @@ function printDeploymentSummary(result, totalErrors, totalWarnings, componentCou
190
250
  }
191
251
  outro(isDryRun ? 'Dry run complete (no files modified)' : 'Deploy complete');
192
252
  }
193
- function logApplyResult(result, isDryRun) {
194
- if (isDryRun && result.actions && result.actions.length > 0) {
195
- info('Planned actions:');
196
- for (const action of result.actions) {
197
- info(` [${action.action}] ${action.component}: ${action.description}`);
198
- }
253
+ function enforceScopedOverrideVersionDriftPreflight(scopedDrift, force) {
254
+ for (const entry of scopedDrift) {
255
+ warn(formatOverrideBaseVersionDriftWarning(entry));
199
256
  }
200
- else if (isDryRun) {
201
- info('No actions would be performed.');
202
- }
203
- else {
204
- for (const applied of result.applied) {
205
- success(`${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`);
206
- }
207
- for (const skipped of result.skipped) {
208
- info(`${skipped.name} — ${skipped.reason}`);
209
- }
210
- for (const applied of result.applied) {
211
- if (applied.stepErrors && applied.stepErrors.length > 0) {
212
- for (const stepErr of applied.stepErrors) {
213
- warn(`${applied.name}: [${stepErr.step}] ${stepErr.error}`);
214
- }
215
- }
216
- }
217
- }
218
- for (const err of result.errors) {
219
- error(`${err.name} — ${err.error}`);
257
+ if (!force && scopedDrift.length > 0) {
258
+ throw new FurnaceError(formatOverrideBaseVersionDriftError(scopedDrift));
220
259
  }
221
260
  }
222
261
  /**
@@ -238,7 +277,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
238
277
  throw new FurnaceError('No furnace.json found. Run "fireforge furnace create" or "fireforge furnace override" to get started.');
239
278
  }
240
279
  const config = await loadFurnaceConfig(projectRoot);
241
- const furnacePaths = getFurnacePaths(projectRoot);
280
+ const [furnacePaths, ftlDir] = [getFurnacePaths(projectRoot), resolveFtlDir(config.ftlBasePath)];
242
281
  const overrideCount = Object.keys(config.overrides).length;
243
282
  const customCount = Object.keys(config.custom).length;
244
283
  if (overrideCount === 0 && customCount === 0) {
@@ -246,92 +285,65 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
246
285
  outro('Done');
247
286
  return;
248
287
  }
288
+ // Refuse real mutation when the targeted overrides were created against a
289
+ // different Firefox version. Dry-run still proceeds so operators can inspect
290
+ // the plan before deciding whether to refresh the override or acknowledge
291
+ // the new baseline in furnace.json.
292
+ const forgeConfig = await loadConfig(projectRoot);
293
+ const driftEntries = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version);
294
+ const force = options.force ?? false;
295
+ const scopedDrift = name ? driftEntries.filter((entry) => entry.name === name) : driftEntries;
296
+ enforceScopedOverrideVersionDriftPreflight(scopedDrift, force);
249
297
  // --- Step 1: Apply ---
250
298
  const applySpinner = spinner(isDryRun ? 'Calculating planned actions...' : 'Applying components to engine...');
251
- let result;
252
- if (name) {
253
- const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, isDryRun);
254
- if (namedApplyResult === 'stock') {
255
- applySpinner.stop('Apply skipped');
256
- info(`"${name}" is a stock component. Stock components are not applied locally.`);
257
- outro(isDryRun ? 'Dry run complete (no files modified)' : 'Deploy complete');
258
- return;
259
- }
260
- result = namedApplyResult;
261
- // Persist Furnace state for the deployed component so status/change
262
- // detection stays accurate after single-component deploys.
263
- if (!isDryRun &&
264
- result.errors.length === 0 &&
265
- getStepFailureCount(result) === 0 &&
266
- result.applied.length > 0) {
267
- const applied = result.applied[0];
268
- await persistSingleComponentState(projectRoot, applied, furnacePaths);
299
+ // The apply phase is lock-protected and registered with the global
300
+ // SIGINT/SIGTERM rollback pathway via runFurnaceMutation. The validation
301
+ // phase below is read-only and runs outside the lock so two concurrent
302
+ // `furnace deploy` runs only contend on the actual mutation.
303
+ const applyOutcome = await runFurnaceMutation(projectRoot, 'deploy-rollback', async (ctx) => {
304
+ if (name) {
305
+ const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot);
306
+ if (namedApplyResult === 'stock') {
307
+ return { kind: 'stock' };
308
+ }
309
+ // Named deploy is atomic: state is persisted only when every apply
310
+ // step succeeded. Any rollback triggered by applyNamedComponent has
311
+ // already restored the engine to its pre-deploy state, so persisting
312
+ // partial checksums here would mis-report the next status/apply run
313
+ // against a workspace that was never actually deployed.
314
+ if (shouldPersistNamedDeployState(namedApplyResult, isDryRun)) {
315
+ await persistSingleComponentState(projectRoot, getPersistableAppliedEntry(name, namedApplyResult.applied[0]), furnacePaths);
316
+ }
317
+ return { kind: 'result', result: namedApplyResult };
269
318
  }
319
+ const allResult = await applyAllComponents(projectRoot, isDryRun, {
320
+ operationContext: ctx,
321
+ });
322
+ return { kind: 'result', result: allResult };
323
+ }, { dryRun: isDryRun });
324
+ if (applyOutcome.kind === 'stock') {
325
+ applySpinner.stop('Apply skipped');
326
+ warn(`"${name}" is a stock component. Stock components are not applied locally.`);
327
+ outro(isDryRun ? 'Dry run complete (no files modified)' : 'Deploy complete');
328
+ return;
270
329
  }
271
- else {
272
- result = await applyAllComponents(projectRoot, isDryRun);
273
- }
330
+ const result = applyOutcome.result;
274
331
  applySpinner.stop(isDryRun ? 'Planned actions calculated' : 'Components applied');
275
332
  logApplyResult(result, isDryRun);
276
- // --- Step 2: Validate (read-only, runs even in dry-run) ---
277
- const validateSpinner = spinner('Validating components...');
278
- const failedComponents = getFailedComponentNames(result);
279
- let totalErrors = 0;
280
- let totalWarnings = 0;
281
- let componentCount = 0;
282
- let skippedValidationCount = 0;
283
- if (name && failedComponents.has(name)) {
284
- skippedValidationCount = 1;
285
- validateSpinner.stop('Validation skipped');
286
- warn(`Skipping validation for ${name} because apply failed.`);
287
- }
288
- else if (name) {
289
- const target = resolveNamedValidationTarget(name, config, furnacePaths);
290
- if (target === 'stock') {
291
- validateSpinner.stop('Validation skipped');
292
- info(`"${name}" is a stock component. Stock components are not validated locally.`);
293
- outro(isDryRun ? 'Dry run complete' : 'Deploy complete');
294
- return;
295
- }
296
- if (!(await pathExists(target.componentDir))) {
297
- validateSpinner.stop('Validation failed');
298
- throw new FurnaceError(`Component directory not found for "${name}".`, name);
299
- }
300
- const issues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
301
- componentCount = 1;
302
- validateSpinner.stop('Validation complete');
303
- if (issues.length === 0) {
304
- success(`${name} — all checks passed`);
305
- }
306
- else {
307
- const [errors, warnings] = displayValidationIssues(issues);
308
- totalErrors += errors;
309
- totalWarnings += warnings;
310
- }
311
- }
312
- else {
313
- // Validate all components
314
- const results = await validateAllComponents(projectRoot);
315
- validateSpinner.stop('Validation complete');
316
- for (const [componentName, issues] of results) {
317
- if (failedComponents.has(componentName)) {
318
- skippedValidationCount++;
319
- continue;
320
- }
321
- componentCount++;
322
- if (issues.length === 0) {
323
- success(`${componentName} — all checks passed`);
324
- }
325
- else {
326
- const [errors, warnings] = displayValidationIssues(issues);
327
- totalErrors += errors;
328
- totalWarnings += warnings;
329
- }
330
- }
331
- if (skippedValidationCount > 0) {
332
- warn(`Skipped validation for ${skippedValidationCount} component(s) because their apply step failed.`);
333
- }
333
+ // --- Step 2: Validate (read-only, runs even in dry-run to show what would fail) ---
334
+ if (options.skipValidate) {
335
+ const applyErrors = result.errors.length + getStepFailureCount(result);
336
+ if (applyErrors > 0)
337
+ throw new FurnaceError(buildDeployFailureMessage(applyErrors, 0, isDryRun));
338
+ outro(isDryRun ? 'Dry run complete (validation skipped)' : 'Deploy complete (validation skipped)');
339
+ return;
334
340
  }
341
+ const validateSpinner = spinner(isDryRun ? 'Validating (read-only)...' : 'Validating...');
342
+ const failedComponents = getFailedComponentNames(result);
343
+ const validation = await runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot);
344
+ if (validation.done)
345
+ return;
346
+ const { totalErrors, totalWarnings, componentCount, skippedValidationCount } = validation;
335
347
  // --- Step 3: Summary ---
336
348
  printDeploymentSummary(result, totalErrors, totalWarnings, componentCount, skippedValidationCount, isDryRun);
337
349
  }
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Runs the furnace diff command to show changes vs the Firefox original.
3
- * Only works for override components.
2
+ * Runs the furnace diff command.
3
+ *
4
+ * For overrides: shows changes vs the Firefox original at baseCommit.
5
+ * For custom components: shows workspace vs engine-deployed copy.
6
+ * When no name is provided, diffs all override and custom components.
7
+ *
4
8
  * @param projectRoot - Root directory of the project
5
- * @param name - Component name to diff
9
+ * @param name - Optional component name to diff (diffs all when omitted)
6
10
  */
7
- export declare function furnaceDiffCommand(projectRoot: string, name: string): Promise<void>;
11
+ export declare function furnaceDiffCommand(projectRoot: string, name?: string): Promise<void>;