@hominis/fireforge 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +5 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -3,42 +3,85 @@ import { join } from 'node:path';
3
3
  import { FurnaceError } from '../errors/furnace.js';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { pathExists } from '../utils/fs.js';
6
+ import { info } from '../utils/logger.js';
6
7
  import { getProjectPaths } from './config.js';
7
- import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, prefixChecksums, } from './furnace-apply-helpers.js';
8
+ import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, diffDeletedFiles, extractComponentChecksums, getOverrideEngineTargetPath, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, undeployCustomFiles, undeployOverrideFiles, } from './furnace-apply-helpers.js';
8
9
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, } from './furnace-config.js';
9
- import { createRollbackJournal, restoreRollbackJournalOrThrow, } from './furnace-rollback.js';
10
- export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, prefixChecksums, } from './furnace-apply-helpers.js';
10
+ import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from './furnace-constants.js';
11
+ import { topologicalSortCustom } from './furnace-graph-utils.js';
12
+ import { recordFurnaceRollbackFailure } from './furnace-operation.js';
13
+ import { addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from './furnace-registration.js';
14
+ import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotFile, } from './furnace-rollback.js';
15
+ import { runPostApplyConsistencyChecks } from './furnace-validate-registration.js';
16
+ export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, } from './furnace-apply-helpers.js';
11
17
  function addMissingComponentError(result, name, directoryPath) {
12
18
  result.errors.push({
13
19
  name,
14
20
  error: `Component directory not found: ${directoryPath}`,
15
21
  });
16
22
  }
17
- async function applyOverrideBatch(config, furnacePaths, state, engineDir, dryRun, result, allActions, newChecksums, rollbackJournal) {
18
- for (const [name, overrideConfig] of Object.entries(config.overrides)) {
23
+ function buildOverrideUndeployActions(name, config, engineDir, deletedFiles, ftlDir) {
24
+ return deletedFiles.map((fileName) => ({
25
+ component: name,
26
+ action: 'undeploy-restore',
27
+ target: getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir),
28
+ description: `Restore engine/${fileName.endsWith('.ftl') ? `${ftlDir}/${fileName}` : `${config.basePath}/${fileName}`} to Firefox baseline`,
29
+ }));
30
+ }
31
+ async function applyOverrideBatch(config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName) {
32
+ const overrideEntries = Object.entries(config.overrides).filter(([name]) => !componentName || name === componentName);
33
+ const totalOverrides = overrideEntries.length;
34
+ let overrideIndex = 0;
35
+ for (const [name, overrideConfig] of overrideEntries) {
36
+ overrideIndex++;
37
+ if (!dryRun && totalOverrides > 1) {
38
+ info(`Applying override ${name} (${overrideIndex}/${totalOverrides})...`);
39
+ }
19
40
  const componentDir = join(furnacePaths.overridesDir, name);
20
41
  if (!(await pathExists(componentDir))) {
21
42
  addMissingComponentError(result, name, `components/overrides/${name}`);
22
43
  continue;
23
44
  }
45
+ const previous = extractComponentChecksums(state.appliedChecksums, 'override', name);
24
46
  if (!dryRun) {
25
- const previous = extractComponentChecksums(state.appliedChecksums, 'override', name);
26
47
  const changed = await hasComponentChanged(componentDir, previous);
27
48
  if (!changed) {
28
- result.skipped.push({ name, reason: 'No changes since last apply' });
29
- Object.assign(newChecksums, prefixChecksums(previous, 'override', name));
30
- continue;
49
+ // Fast path holds only if the engine still reflects what we deployed.
50
+ // reset/download/manual edits can silently erase engine files; the
51
+ // checksum record alone cannot detect that.
52
+ const cachedEngine = extractComponentChecksums(state.engineChecksums, 'override', name);
53
+ const drifted = await hasOverrideEngineDrift(engineDir, componentDir, overrideConfig, ftlDir, cachedEngine);
54
+ if (!drifted) {
55
+ result.skipped.push({ name, reason: 'No changes since last apply' });
56
+ Object.assign(newChecksums, prefixChecksums(previous, 'override', name));
57
+ continue;
58
+ }
31
59
  }
32
60
  }
33
61
  try {
34
- const { affectedPaths: filesAffected, actions } = await applyOverrideComponent(engineDir, name, componentDir, overrideConfig, dryRun, rollbackJournal);
62
+ const filesAffectedTotal = [];
63
+ // Compute which files (if any) were removed from the workspace since
64
+ // the last apply. We do this for both dry-run and real runs so the
65
+ // planned-actions output stays honest.
66
+ const currentChecksums = await computeComponentChecksums(componentDir);
67
+ const deletedFiles = diffDeletedFiles(previous, currentChecksums);
68
+ if (dryRun) {
69
+ if (deletedFiles.length > 0) {
70
+ allActions.push(...buildOverrideUndeployActions(name, overrideConfig, engineDir, deletedFiles, ftlDir));
71
+ }
72
+ }
73
+ else if (deletedFiles.length > 0) {
74
+ const { restored, removed } = await undeployOverrideFiles(engineDir, overrideConfig, deletedFiles, ftlDir, rollbackJournal);
75
+ filesAffectedTotal.push(...restored, ...removed);
76
+ }
77
+ const { affectedPaths: filesAffected, actions } = await applyOverrideComponent(engineDir, name, componentDir, overrideConfig, ftlDir, dryRun, rollbackJournal);
35
78
  if (dryRun && actions) {
36
79
  allActions.push(...actions);
37
80
  }
38
- result.applied.push({ name, type: 'override', filesAffected });
81
+ filesAffectedTotal.push(...filesAffected);
82
+ result.applied.push({ name, type: 'override', filesAffected: filesAffectedTotal });
39
83
  if (!dryRun) {
40
- const checksums = await computeComponentChecksums(componentDir);
41
- Object.assign(newChecksums, prefixChecksums(checksums, 'override', name));
84
+ Object.assign(newChecksums, prefixChecksums(currentChecksums, 'override', name));
42
85
  }
43
86
  }
44
87
  catch (error) {
@@ -49,38 +92,178 @@ async function applyOverrideBatch(config, furnacePaths, state, engineDir, dryRun
49
92
  }
50
93
  }
51
94
  }
52
- async function applyCustomBatch(config, furnacePaths, state, engineDir, dryRun, result, allActions, newChecksums, rollbackJournal) {
53
- for (const [name, customConfig] of Object.entries(config.custom)) {
95
+ function buildCustomUndeployActions(name, config, engineDir, deletedFiles, ftlDir) {
96
+ const actions = [];
97
+ for (const fileName of deletedFiles) {
98
+ const enginePath = fileName.endsWith('.ftl')
99
+ ? join(engineDir, ftlDir, fileName)
100
+ : join(engineDir, config.targetPath, fileName);
101
+ actions.push({
102
+ component: name,
103
+ action: 'undeploy-remove',
104
+ target: enginePath,
105
+ description: `Remove orphaned ${fileName} from engine`,
106
+ });
107
+ }
108
+ // jar.mn re-sync planned for any custom-file deletion when registered.
109
+ if (config.register && deletedFiles.some((f) => f.endsWith('.mjs') || f.endsWith('.css'))) {
110
+ actions.push({
111
+ component: name,
112
+ action: 'unregister-jar',
113
+ description: `Re-sync ${name} jar.mn entries to drop deleted files`,
114
+ });
115
+ }
116
+ if (config.register && deletedFiles.some((f) => f === `${name}.mjs`)) {
117
+ actions.push({
118
+ component: name,
119
+ action: 'unregister-ce',
120
+ description: `Deregister ${name} from customElements.js (.mjs deleted)`,
121
+ });
122
+ }
123
+ return actions;
124
+ }
125
+ /**
126
+ * After undeploying deleted files, the in-engine jar.mn and
127
+ * customElements.js still carry entries for the removed files. Re-sync them
128
+ * by removing all of `name`'s jar.mn entries and re-adding only those that
129
+ * still exist in the workspace; if the .mjs itself was deleted, also drop
130
+ * the customElements.js registration. Snapshots are taken under the same
131
+ * journal so the undo path is symmetric with apply.
132
+ */
133
+ async function reconcileCustomRegistrationAfterUndeploy(engineDir, name, config, deletedFiles, currentChecksums, rollbackJournal, filesAffected) {
134
+ if (!config.register || deletedFiles.length === 0)
135
+ return;
136
+ const deletedRegistrationFiles = deletedFiles.filter((f) => f.endsWith('.mjs') || f.endsWith('.css'));
137
+ if (deletedRegistrationFiles.length === 0)
138
+ return;
139
+ // jar.mn re-sync. addJarMnEntries is idempotent on duplicates but does not
140
+ // drop stale entries, so we need a remove-then-add cycle. The journal
141
+ // snapshot before each mutation gives us a clean rollback target.
142
+ if (rollbackJournal) {
143
+ await snapshotFile(rollbackJournal, join(engineDir, JAR_MN));
144
+ }
145
+ await removeJarMnEntries(engineDir, name);
146
+ const liveJarFiles = Object.keys(currentChecksums).filter((f) => f.endsWith('.mjs') || f.endsWith('.css'));
147
+ if (liveJarFiles.length > 0) {
148
+ // applyCustomComponent has already added live entries; the remove above
149
+ // dropped them too, so re-add now to leave jar.mn in the correct state.
150
+ await addJarMnEntries(engineDir, name, liveJarFiles);
151
+ }
152
+ filesAffected.push(JAR_MN);
153
+ // If the .mjs file itself was deleted, the customElements registration
154
+ // must go too — otherwise we leave a dangling import in the Pattern B
155
+ // block that fails at runtime.
156
+ if (deletedFiles.includes(`${name}.mjs`)) {
157
+ if (rollbackJournal) {
158
+ await snapshotFile(rollbackJournal, join(engineDir, CUSTOM_ELEMENTS_JS));
159
+ }
160
+ await removeCustomElementRegistration(engineDir, name);
161
+ filesAffected.push(CUSTOM_ELEMENTS_JS);
162
+ }
163
+ }
164
+ async function applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName) {
165
+ const allKnown = new Set([
166
+ ...config.stock,
167
+ ...Object.keys(config.overrides),
168
+ ...Object.keys(config.custom),
169
+ ]);
170
+ // Build a set of component names that failed during the override batch so
171
+ // custom components that compose them can be skipped. This includes both
172
+ // hard errors and step-error failures.
173
+ const failedDependencies = new Set();
174
+ for (const entry of result.errors) {
175
+ failedDependencies.add(entry.name);
176
+ }
177
+ for (const entry of result.applied) {
178
+ if (entry.stepErrors && entry.stepErrors.length > 0) {
179
+ failedDependencies.add(entry.name);
180
+ }
181
+ }
182
+ const sortedNames = topologicalSortCustom(config.custom).filter((name) => !componentName || name === componentName);
183
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- names from Object.keys
184
+ const customEntries = sortedNames.map((name) => [name, config.custom[name]]);
185
+ const totalCustom = customEntries.length;
186
+ let customIndex = 0;
187
+ for (const [name, customConfig] of customEntries) {
188
+ customIndex++;
189
+ if (!dryRun && totalCustom > 1) {
190
+ info(`Applying custom component ${name} (${customIndex}/${totalCustom})...`);
191
+ }
192
+ if (customConfig.composes) {
193
+ const missing = customConfig.composes.filter((ref) => !allKnown.has(ref));
194
+ if (missing.length > 0) {
195
+ result.errors.push({
196
+ name,
197
+ error: `Composes unknown component(s): ${missing.join(', ')}. Each reference must be registered as stock, override, or custom.`,
198
+ });
199
+ failedDependencies.add(name);
200
+ continue;
201
+ }
202
+ // Skip this component if any of its composed dependencies failed.
203
+ const failedRefs = customConfig.composes.filter((ref) => failedDependencies.has(ref));
204
+ if (failedRefs.length > 0) {
205
+ result.errors.push({
206
+ name,
207
+ error: `Skipped: composed dependency ${failedRefs.join(', ')} failed to apply.`,
208
+ });
209
+ failedDependencies.add(name);
210
+ continue;
211
+ }
212
+ }
54
213
  const componentDir = join(furnacePaths.customDir, name);
55
214
  if (!(await pathExists(componentDir))) {
56
215
  addMissingComponentError(result, name, `components/custom/${name}`);
57
216
  continue;
58
217
  }
218
+ const previous = extractComponentChecksums(state.appliedChecksums, 'custom', name);
59
219
  if (!dryRun) {
60
- const previous = extractComponentChecksums(state.appliedChecksums, 'custom', name);
61
220
  const changed = await hasComponentChanged(componentDir, previous);
62
221
  if (!changed) {
63
- result.skipped.push({ name, reason: 'No changes since last apply' });
64
- Object.assign(newChecksums, prefixChecksums(previous, 'custom', name));
65
- continue;
222
+ // As with overrides, the checksum record is not sufficient on its
223
+ // own: a reset/download that cleared the engine must trigger a
224
+ // re-apply even though the workspace is unchanged.
225
+ const drifted = await hasCustomEngineDrift(root, name, componentDir, customConfig, ftlDir);
226
+ if (!drifted) {
227
+ result.skipped.push({ name, reason: 'No changes since last apply' });
228
+ Object.assign(newChecksums, prefixChecksums(previous, 'custom', name));
229
+ continue;
230
+ }
66
231
  }
67
232
  }
68
233
  try {
69
- const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, dryRun, rollbackJournal);
234
+ const filesAffectedTotal = [];
235
+ // Diff against previous to find files the developer has deleted from
236
+ // the workspace since last apply. Run for both dry-run and real apply
237
+ // so plan output and execution stay aligned.
238
+ const currentChecksums = await computeComponentChecksums(componentDir);
239
+ const deletedFiles = diffDeletedFiles(previous, currentChecksums);
240
+ if (dryRun) {
241
+ if (deletedFiles.length > 0) {
242
+ allActions.push(...buildCustomUndeployActions(name, customConfig, engineDir, deletedFiles, ftlDir));
243
+ }
244
+ }
245
+ else if (deletedFiles.length > 0) {
246
+ const removed = await undeployCustomFiles(engineDir, customConfig, deletedFiles, ftlDir, rollbackJournal);
247
+ filesAffectedTotal.push(...removed);
248
+ }
249
+ const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, dryRun, rollbackJournal);
70
250
  if (dryRun && actions) {
71
251
  allActions.push(...actions);
72
252
  }
253
+ if (!dryRun && deletedFiles.length > 0 && stepErrors.length === 0) {
254
+ await reconcileCustomRegistrationAfterUndeploy(engineDir, name, customConfig, deletedFiles, currentChecksums, rollbackJournal, filesAffectedTotal);
255
+ }
256
+ filesAffectedTotal.push(...filesAffected);
73
257
  result.applied.push({
74
258
  name,
75
259
  type: 'custom',
76
- filesAffected,
260
+ filesAffected: filesAffectedTotal,
77
261
  ...(stepErrors.length > 0 ? { stepErrors } : {}),
78
262
  });
79
263
  // Only store checksums when the component applied without step errors,
80
264
  // so that partially failed components are re-applied on the next run.
81
265
  if (!dryRun && stepErrors.length === 0) {
82
- const checksums = await computeComponentChecksums(componentDir);
83
- Object.assign(newChecksums, prefixChecksums(checksums, 'custom', name));
266
+ Object.assign(newChecksums, prefixChecksums(currentChecksums, 'custom', name));
84
267
  }
85
268
  }
86
269
  catch (error) {
@@ -98,19 +281,36 @@ async function applyCustomBatch(config, furnacePaths, state, engineDir, dryRun,
98
281
  * fails, FireForge restores only the engine files touched during this apply
99
282
  * attempt and leaves the state file unchanged.
100
283
  *
284
+ * When `options.persistState` is false, the furnace state file is left alone
285
+ * on success and the rollback journal is returned on the result so the caller
286
+ * can restore the engine later (used by `furnace preview` to stage workspace
287
+ * files for Storybook and then roll them back on teardown).
288
+ *
101
289
  * @param root - Root directory of the project
102
290
  * @param dryRun - If true, enumerate planned actions without writing
103
- * @returns Summary of applied, skipped, and errored components (with actions when dry-run)
291
+ * @param options - Optional behavior flags. `persistState` controls whether
292
+ * the furnace state file is updated on success (preview teardown sets this
293
+ * to false to keep ownership of the journal). `operationContext` is the
294
+ * lifecycle-wrapper hook used by `runFurnaceMutation` so a Ctrl+C mid-apply
295
+ * can find the in-flight rollback journal.
296
+ * @returns Summary of applied, skipped, and errored components (with actions
297
+ * when dry-run, and with rollbackJournal when persistState=false)
104
298
  */
105
- export async function applyAllComponents(root, dryRun = false) {
299
+ export async function applyAllComponents(root, dryRun = false, options) {
300
+ const persistState = options?.persistState ?? true;
301
+ const operationContext = options?.operationContext;
106
302
  const config = await loadFurnaceConfig(root);
107
303
  const state = await loadFurnaceState(root);
108
304
  const { engine: engineDir } = getProjectPaths(root);
109
305
  const furnacePaths = getFurnacePaths(root);
306
+ const ftlDir = resolveFtlDir(config.ftlBasePath);
110
307
  if (!(await pathExists(engineDir))) {
111
308
  throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
112
309
  }
113
310
  const rollbackJournal = dryRun ? undefined : createRollbackJournal();
311
+ if (rollbackJournal && operationContext) {
312
+ operationContext.registerJournal(rollbackJournal);
313
+ }
114
314
  const result = {
115
315
  applied: [],
116
316
  skipped: [],
@@ -118,26 +318,57 @@ export async function applyAllComponents(root, dryRun = false) {
118
318
  };
119
319
  const allActions = [];
120
320
  const newChecksums = {};
121
- await applyOverrideBatch(config, furnacePaths, state, engineDir, dryRun, result, allActions, newChecksums, rollbackJournal);
122
- await applyCustomBatch(config, furnacePaths, state, engineDir, dryRun, result, allActions, newChecksums, rollbackJournal);
321
+ const componentName = options?.componentName;
322
+ // When a single component is requested, validate it exists before running
323
+ // the batch functions (which would silently skip an unknown name).
324
+ if (componentName) {
325
+ const isKnown = componentName in config.overrides || componentName in config.custom;
326
+ if (!isKnown) {
327
+ throw new FurnaceError(`Component "${componentName}" not found in furnace.json. Run "fireforge furnace list" to see registered components.`, componentName);
328
+ }
329
+ }
330
+ await applyOverrideBatch(config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName);
331
+ await applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName);
123
332
  // Check for any partial failures (step errors on applied components).
124
333
  const hasStepErrors = result.applied.some((entry) => 'stepErrors' in entry && entry.stepErrors.length > 0);
125
334
  // Orphaned components are implicitly cleaned up: newChecksums only
126
335
  // contains entries for components that still exist in furnace.json,
127
336
  // and it fully replaces state.appliedChecksums below.
337
+ if (!dryRun && !hasStepErrors && result.errors.length === 0) {
338
+ await runPostApplyConsistencyChecks(root, config, result, ftlDir);
339
+ }
128
340
  // --- Rollback on failure, persist on success (skip for dry-run) ---
129
341
  if (!dryRun) {
130
342
  if (result.errors.length > 0 || hasStepErrors) {
131
343
  if (rollbackJournal) {
132
- await restoreRollbackJournalOrThrow(rollbackJournal, 'Furnace apply failed');
344
+ try {
345
+ await restoreRollbackJournalOrThrow(rollbackJournal, 'Furnace apply failed');
346
+ result.rolledBack = true;
347
+ }
348
+ catch (rollbackError) {
349
+ // Rollback itself failed: the engine is in a partially restored
350
+ // state. Persist a pending-repair marker so the next `fireforge
351
+ // doctor --repair-furnace` run knows to reconcile.
352
+ await recordFurnaceRollbackFailure(root, 'apply-rollback', toError(rollbackError).message);
353
+ throw rollbackError;
354
+ }
133
355
  }
134
356
  }
135
- else {
357
+ else if (persistState) {
358
+ // After a successful apply, workspace checksums equal the engine content
359
+ // (we just copied workspace → engine). Store them as engineChecksums so
360
+ // drift detection can compare engine files against the cached hash
361
+ // instead of byte-comparing against workspace sources.
136
362
  await updateFurnaceState(root, {
137
363
  lastApply: new Date().toISOString(),
138
364
  appliedChecksums: newChecksums,
365
+ engineChecksums: { ...newChecksums },
139
366
  });
140
367
  }
368
+ else if (rollbackJournal) {
369
+ // Caller owns the journal and will restore on teardown.
370
+ result.rollbackJournal = rollbackJournal;
371
+ }
141
372
  }
142
373
  if (dryRun) {
143
374
  result.actions = allActions;
@@ -0,0 +1,4 @@
1
+ /** Extracts per-component checksums from the flattened state-file checksum map. */
2
+ export declare function extractComponentChecksums(allChecksums: Record<string, string> | undefined, type: string, name: string): Record<string, string>;
3
+ /** Prefixes component checksums so they can be stored in the flattened state format. */
4
+ export declare function prefixChecksums(checksums: Record<string, string>, type: string, name: string): Record<string, string>;
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /** Extracts per-component checksums from the flattened state-file checksum map. */
3
+ export function extractComponentChecksums(allChecksums, type, name) {
4
+ if (!allChecksums)
5
+ return {};
6
+ const prefix = `${type}/${name}/`;
7
+ const result = {};
8
+ for (const [key, value] of Object.entries(allChecksums)) {
9
+ if (key.startsWith(prefix)) {
10
+ result[key.slice(prefix.length)] = value;
11
+ }
12
+ }
13
+ return result;
14
+ }
15
+ /** Prefixes component checksums so they can be stored in the flattened state format. */
16
+ export function prefixChecksums(checksums, type, name) {
17
+ const prefix = `${type}/${name}/`;
18
+ const result = {};
19
+ for (const [key, value] of Object.entries(checksums)) {
20
+ result[`${prefix}${key}`] = value;
21
+ }
22
+ return result;
23
+ }
24
+ //# sourceMappingURL=furnace-checksum-utils.js.map
@@ -1,4 +1,6 @@
1
1
  import type { FurnaceConfig, FurnaceState } from '../types/furnace.js';
2
+ import { detectComposesCycles } from './furnace-graph-utils.js';
3
+ export { detectComposesCycles };
2
4
  /** Name of the furnace configuration file */
3
5
  export declare const FURNACE_CONFIG_FILENAME = "furnace.json";
4
6
  /** Name of the furnace state file */
@@ -36,6 +38,22 @@ export declare function getFurnacePaths(root: string): FurnacePaths;
36
38
  * @returns True if furnace.json exists
37
39
  */
38
40
  export declare function furnaceConfigExists(root: string): Promise<boolean>;
41
+ /**
42
+ * Migrates a furnace config from an older schema version to the current one.
43
+ * Returns the data unchanged if it is already at the current version.
44
+ *
45
+ * When a future version 2 is introduced, add a `case 1:` that transforms
46
+ * v1 data into v2 shape and falls through to validation. The pattern is:
47
+ *
48
+ * ```
49
+ * case 1:
50
+ * data = migrateV1ToV2(data);
51
+ * // fallthrough
52
+ * case 2:
53
+ * break;
54
+ * ```
55
+ */
56
+ export declare function migrateFurnaceConfig(data: Record<string, unknown>): Record<string, unknown>;
39
57
  /**
40
58
  * Validates a raw config object and returns a typed FurnaceConfig.
41
59
  * @param data - Raw data to validate
@@ -91,4 +109,13 @@ export declare function saveFurnaceState(root: string, state: FurnaceState): Pro
91
109
  * @param updates - Fields to update, or a transactional updater function
92
110
  */
93
111
  export declare function updateFurnaceState(root: string, updates: Partial<FurnaceState> | ((current: FurnaceState) => FurnaceState)): Promise<void>;
94
- export {};
112
+ /**
113
+ * Collects engine-relative path prefixes that are managed by the Furnace
114
+ * component system (overrides, custom components, and their Fluent l10n
115
+ * files). Used by `status` and `export-all` to classify engine changes
116
+ * as Furnace-managed rather than unmanaged drift.
117
+ *
118
+ * Returns an empty set when no furnace config exists (opt-in subsystem).
119
+ * Prefixes always end with `/` so callers can use `startsWith()`.
120
+ */
121
+ export declare function collectFurnaceManagedPrefixes(root: string): Promise<Set<string>>;