@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
@@ -3,18 +3,21 @@ import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { Option } from 'commander';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
+ import { appendHistory, confirmDestructive } from '../core/destructive.js';
7
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
8
  import { getStatusWithCodes, isGitRepository } from '../core/git.js';
7
9
  import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff.js';
8
10
  import { isBinaryFile } from '../core/git-file-ops.js';
9
11
  import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
10
12
  import { extractAffectedFiles } from '../core/patch-apply.js';
11
13
  import { commitExportedPatch } from '../core/patch-export.js';
12
- import { GeneralError } from '../errors/base.js';
14
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
15
  import { toError } from '../utils/errors.js';
14
16
  import { ensureDir, pathExists } from '../utils/fs.js';
15
17
  import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
16
18
  import { pickDefined } from '../utils/options.js';
17
- import { PATCH_CATEGORIES } from '../utils/validation.js';
19
+ import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
20
+ import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
18
21
  import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
19
22
  async function collectExportFiles(paths, files) {
20
23
  const collectedFiles = new Set();
@@ -90,8 +93,23 @@ async function generatePatchDiff(engineDir, allFiles) {
90
93
  * @param files - File or directory paths to export (relative to engine/)
91
94
  * @param options - Export options
92
95
  */
96
+ // The command body is intentionally linear: validation → diff → placement
97
+ // gate → dry-run/placement/default write. Splitting it further would
98
+ // spread the error-handling (spinner.error, try/catch) across multiple
99
+ // helpers and hurt readability more than it would help.
100
+ // eslint-disable-next-line max-lines-per-function
93
101
  export async function exportCommand(projectRoot, files, options) {
94
- intro('FireForge Export');
102
+ const isDryRun = options.dryRun === true;
103
+ intro(isDryRun ? 'FireForge Export (dry run)' : 'FireForge Export');
104
+ // Placement flags are mutually exclusive with each other.
105
+ const placementFlagCount = [
106
+ options.order !== undefined,
107
+ options.before !== undefined,
108
+ options.after !== undefined,
109
+ ].filter(Boolean).length;
110
+ if (placementFlagCount > 1) {
111
+ throw new InvalidArgumentError('--order, --before, and --after are mutually exclusive.', 'export placement');
112
+ }
95
113
  const paths = getProjectPaths(projectRoot);
96
114
  // Check if engine exists
97
115
  if (!(await pathExists(paths.engine))) {
@@ -101,7 +119,19 @@ export async function exportCommand(projectRoot, files, options) {
101
119
  if (!(await isGitRepository(paths.engine))) {
102
120
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
103
121
  }
104
- const allFiles = await collectExportFiles(paths, files);
122
+ let allFiles = await collectExportFiles(paths, files);
123
+ // Filter out furnace-managed files when --exclude-furnace is set
124
+ if (options.excludeFurnace) {
125
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
126
+ if (furnacePrefixes.size > 0) {
127
+ const before = allFiles.length;
128
+ allFiles = allFiles.filter((file) => ![...furnacePrefixes].some((prefix) => file.startsWith(prefix)));
129
+ const excluded = before - allFiles.length;
130
+ if (excluded > 0) {
131
+ info(`Excluded ${excluded} furnace-managed file${excluded === 1 ? '' : 's'} from export`);
132
+ }
133
+ }
134
+ }
105
135
  if (allFiles.length === 0) {
106
136
  const pathList = files.join(', ');
107
137
  throw new GeneralError(`Paths "${pathList}" have no changes to export.\n\n` +
@@ -124,11 +154,120 @@ export async function exportCommand(projectRoot, files, options) {
124
154
  if (!metadata)
125
155
  return;
126
156
  const { patchName, selectedCategory, description } = metadata;
127
- const s = spinner('Exporting patch...');
157
+ const s = spinner(isDryRun ? 'Planning export...' : 'Exporting patch...');
128
158
  try {
129
159
  // Extract affected files from diff
130
160
  const filesAffected = extractAffectedFiles(diff);
131
161
  await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
162
+ // Resolve placement (if any flag was given). Placement is mutually
163
+ // exclusive with supersede — the semantics overlap confusingly.
164
+ let placementPlan = null;
165
+ if (placementFlagCount > 0) {
166
+ if (options.supersede) {
167
+ throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
168
+ }
169
+ placementPlan = await resolvePlacementPlan(paths.patches, options, selectedCategory, patchName);
170
+ const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
171
+ const summary = placementSummary(placementPlan);
172
+ const renameCount = placementPlan.renameMap.size;
173
+ // Route through confirmDestructive when the operation is destructive
174
+ // enough to warrant a prompt (more than one rename) OR when the user
175
+ // asked for a dry-run. The dry-run branch must always print the
176
+ // placement summary — previously, single-rename/no-rename dry-runs
177
+ // exited silently with no filename or projected layout.
178
+ if (renameCount > 1 || isDryRun) {
179
+ s.stop();
180
+ const decision = await confirmDestructive({
181
+ operation: 'export-order',
182
+ title: `Export with placement at order ${placementPlan.insertionOrder}`,
183
+ summary,
184
+ yes: options.yes === true,
185
+ dryRun: isDryRun,
186
+ unsafeOverride: options.forceUnsafe === true,
187
+ conflicts,
188
+ });
189
+ if (decision === 'dry-run') {
190
+ outro('Dry run complete — no changes made');
191
+ return;
192
+ }
193
+ if (decision === 'cancelled') {
194
+ outro('Export cancelled');
195
+ return;
196
+ }
197
+ }
198
+ else if (conflicts && options.forceUnsafe !== true) {
199
+ s.stop();
200
+ throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
201
+ }
202
+ }
203
+ // Dry-run path: compute the plan and print it, never write.
204
+ if (isDryRun && !placementPlan) {
205
+ s.stop('Plan ready');
206
+ await renderDryRunPreview({
207
+ patchesDir: paths.patches,
208
+ category: selectedCategory,
209
+ name: patchName,
210
+ description,
211
+ filesAffected,
212
+ sourceEsrVersion: config.firefox.version,
213
+ explicitSupersede: options.supersede === true,
214
+ });
215
+ outro('Dry run complete — no changes made');
216
+ return;
217
+ }
218
+ // Placement path (non-dry-run): run the renumber + write + manifest
219
+ // update under a single patch directory lock so concurrent exports
220
+ // cannot race into the renumber gap. Dry-runs with a placement plan
221
+ // are fully handled in the placement gate above and never reach here.
222
+ if (placementPlan) {
223
+ const placementMetadata = {
224
+ filename: placementPlan.newFilename,
225
+ order: placementPlan.insertionOrder,
226
+ category: selectedCategory,
227
+ name: patchName,
228
+ description,
229
+ createdAt: new Date().toISOString(),
230
+ sourceEsrVersion: config.firefox.version,
231
+ filesAffected,
232
+ };
233
+ const committedPlan = await commitPlacementExport({
234
+ patchesDir: paths.patches,
235
+ options,
236
+ category: selectedCategory,
237
+ name: patchName,
238
+ diff,
239
+ metadata: placementMetadata,
240
+ expectedPlan: placementPlan,
241
+ unsafeOverride: options.forceUnsafe === true,
242
+ // History append runs inside the same lock as the mutation so
243
+ // concurrent placement exports cannot interleave their records
244
+ // and a crash between mutation and record cannot orphan the
245
+ // audit entry.
246
+ onCommitted: async (finalPlan) => {
247
+ await appendHistory(paths.patches, {
248
+ operation: 'export-order',
249
+ args: {
250
+ filename: finalPlan.newFilename,
251
+ order: finalPlan.insertionOrder,
252
+ renames: Array.from(finalPlan.renameMap.entries()).map(([from, entry]) => ({
253
+ from,
254
+ to: entry.newFilename,
255
+ order: entry.newOrder,
256
+ })),
257
+ },
258
+ ...(options.yes === true ? { yes: true } : {}),
259
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
260
+ result: 'ok',
261
+ });
262
+ },
263
+ });
264
+ s.stop(`Exported to ${committedPlan.newFilename}`);
265
+ info(`\nPatch saved to: patches/${committedPlan.newFilename}`);
266
+ info(`Files affected: ${filesAffected.join(', ')}`);
267
+ outro('Export complete');
268
+ return;
269
+ }
270
+ // Default (no dry-run, no placement) path: the pre-existing behavior.
132
271
  // Check how many existing patches would be superseded
133
272
  const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
134
273
  if (!shouldProceed)
@@ -167,6 +306,13 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
167
306
  .option('-d, --description <desc>', 'Description of the patch')
168
307
  .option('--supersede', 'Allow superseding multiple existing patches')
169
308
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
309
+ .option('--dry-run', 'Print the export plan (including supersede preview) without writing')
310
+ .addOption(new Option('--order <N>', 'Place the new patch at this ordinal, shifting subsequent patches up').argParser((v) => parsePositiveIntegerFlag('--order', v)))
311
+ .option('--before <anchor>', 'Place the new patch immediately before <anchor>')
312
+ .option('--after <anchor>', 'Place the new patch immediately after <anchor>')
313
+ .option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
314
+ .option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
315
+ .option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
170
316
  .action(withErrorHandling(async (paths, options) => {
171
317
  const { category, ...rest } = options;
172
318
  await exportCommand(getProjectRoot(), paths, {
@@ -1,7 +1,8 @@
1
1
  import type { FurnaceApplyOptions } from '../../types/commands/index.js';
2
2
  /**
3
- * Runs the furnace apply command to apply all components to the engine.
3
+ * Runs the furnace apply command to apply components to the engine.
4
4
  * @param projectRoot - Root directory of the project
5
+ * @param name - Optional component name to apply a single component
5
6
  * @param options - Apply options
6
7
  */
7
- export declare function furnaceApplyCommand(projectRoot: string, options?: FurnaceApplyOptions): Promise<void>;
8
+ export declare function furnaceApplyCommand(projectRoot: string, name?: string, options?: FurnaceApplyOptions): Promise<void>;
@@ -1,17 +1,155 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { getProjectPaths } from '../../core/config.js';
3
- import { applyAllComponents } from '../../core/furnace-apply.js';
4
- import { furnaceConfigExists, loadFurnaceConfig } from '../../core/furnace-config.js';
2
+ import { watch as fsWatch } from 'node:fs';
3
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
4
+ import { applyAllComponents, computeComponentChecksums } from '../../core/furnace-apply.js';
5
+ import { logApplyResult } from '../../core/furnace-apply-output.js';
6
+ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, } from '../../core/furnace-config.js';
7
+ import { isComponentSourceFile } from '../../core/furnace-constants.js';
8
+ import { runFurnaceMutation } from '../../core/furnace-operation.js';
9
+ import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
5
10
  import { FurnaceError } from '../../errors/furnace.js';
6
11
  import { pathExists } from '../../utils/fs.js';
7
- import { error, info, intro, outro, spinner, success, warn } from '../../utils/logger.js';
12
+ import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
13
+ /** Interval (ms) for the periodic checksum poll that catches events missed by fs.watch. */
14
+ const WATCH_POLL_INTERVAL_MS = 30_000;
8
15
  /**
9
- * Runs the furnace apply command to apply all components to the engine.
16
+ * Collects a combined checksum snapshot across all watched directories.
17
+ */
18
+ async function snapshotWatchedChecksums(watchDirs) {
19
+ const combined = new Map();
20
+ for (const dir of watchDirs) {
21
+ try {
22
+ const checksums = await computeComponentChecksums(dir);
23
+ for (const [file, hash] of Object.entries(checksums)) {
24
+ combined.set(`${dir}/${file}`, hash);
25
+ }
26
+ }
27
+ catch {
28
+ // Directory may have been removed between iterations — ignore.
29
+ }
30
+ }
31
+ return combined;
32
+ }
33
+ function checksumMapsEqual(a, b) {
34
+ if (a.size !== b.size)
35
+ return false;
36
+ for (const [key, value] of a) {
37
+ if (b.get(key) !== value)
38
+ return false;
39
+ }
40
+ return true;
41
+ }
42
+ async function runWatchLoop(projectRoot) {
43
+ const furnacePaths = getFurnacePaths(projectRoot);
44
+ const watchDirs = [];
45
+ if (await pathExists(furnacePaths.overridesDir))
46
+ watchDirs.push(furnacePaths.overridesDir);
47
+ if (await pathExists(furnacePaths.customDir))
48
+ watchDirs.push(furnacePaths.customDir);
49
+ if (watchDirs.length === 0) {
50
+ info('No component directories to watch.');
51
+ return;
52
+ }
53
+ if (process.platform === 'linux') {
54
+ warn('Watch mode uses fs.watch with recursive: true, which may miss changes ' +
55
+ 'in deeply nested directories on Linux. A periodic poll runs every 30s as a fallback.');
56
+ }
57
+ info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
58
+ let debounceTimer = null;
59
+ let applyInFlight = false;
60
+ let lastChecksums = await snapshotWatchedChecksums(watchDirs);
61
+ const triggerApply = () => {
62
+ if (debounceTimer)
63
+ clearTimeout(debounceTimer);
64
+ debounceTimer = setTimeout(() => {
65
+ if (applyInFlight)
66
+ return;
67
+ applyInFlight = true;
68
+ void (async () => {
69
+ try {
70
+ info('\nChange detected — re-applying...');
71
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
72
+ logApplyResult(result, false);
73
+ const applied = result.applied.length;
74
+ const skipped = result.skipped.length;
75
+ info(`Re-applied: ${applied} applied, ${skipped} skipped`);
76
+ }
77
+ catch (err) {
78
+ warn(`Apply failed: ${err instanceof Error ? err.message : String(err)}`);
79
+ }
80
+ finally {
81
+ applyInFlight = false;
82
+ // Update checksums after apply so the next poll does not re-trigger.
83
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
84
+ }
85
+ })();
86
+ }, 300);
87
+ };
88
+ // Register signal-driven cleanup BEFORE creating watchers so there is no
89
+ // race window where a SIGINT could arrive after watchers exist but before
90
+ // cleanup handlers are registered.
91
+ const watchers = [];
92
+ let pollTimer = null;
93
+ const cleanup = () => {
94
+ for (const w of watchers)
95
+ w.close();
96
+ if (debounceTimer)
97
+ clearTimeout(debounceTimer);
98
+ if (pollTimer)
99
+ clearInterval(pollTimer);
100
+ };
101
+ process.once('SIGINT', cleanup);
102
+ process.once('SIGTERM', cleanup);
103
+ for (const dir of watchDirs) {
104
+ const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
105
+ if (!filename)
106
+ return;
107
+ if (isComponentSourceFile(filename)) {
108
+ triggerApply();
109
+ }
110
+ });
111
+ watcher.on('error', (err) => {
112
+ warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
113
+ });
114
+ watchers.push(watcher);
115
+ }
116
+ // Periodic checksum-based poll to catch events missed by fs.watch (known
117
+ // issue on Linux with recursive: true and certain filesystems).
118
+ pollTimer = setInterval(() => {
119
+ if (applyInFlight)
120
+ return;
121
+ void (async () => {
122
+ try {
123
+ const current = await snapshotWatchedChecksums(watchDirs);
124
+ if (!checksumMapsEqual(current, lastChecksums)) {
125
+ triggerApply();
126
+ }
127
+ }
128
+ catch {
129
+ // Best effort — errors here are transient filesystem issues.
130
+ }
131
+ })();
132
+ }, WATCH_POLL_INTERVAL_MS);
133
+ // Block until signal. The cleanup function registered above closes all
134
+ // watchers when SIGINT/SIGTERM arrives. The finally block is a safety net
135
+ // in case the Promise settles through some other path.
136
+ try {
137
+ await new Promise(() => { });
138
+ }
139
+ finally {
140
+ cleanup();
141
+ process.removeListener('SIGINT', cleanup);
142
+ process.removeListener('SIGTERM', cleanup);
143
+ }
144
+ }
145
+ /**
146
+ * Runs the furnace apply command to apply components to the engine.
10
147
  * @param projectRoot - Root directory of the project
148
+ * @param name - Optional component name to apply a single component
11
149
  * @param options - Apply options
12
150
  */
13
- export async function furnaceApplyCommand(projectRoot, options = {}) {
14
- intro('Furnace Apply');
151
+ export async function furnaceApplyCommand(projectRoot, name, options = {}) {
152
+ intro(name ? `Furnace Apply (${name})` : 'Furnace Apply');
15
153
  // Verify engine exists
16
154
  const paths = getProjectPaths(projectRoot);
17
155
  if (!(await pathExists(paths.engine))) {
@@ -29,42 +167,33 @@ export async function furnaceApplyCommand(projectRoot, options = {}) {
29
167
  outro('Done');
30
168
  return;
31
169
  }
170
+ // Warn on baseVersion drift before mutating the engine. This is advisory
171
+ // only: apply continues, because a silent divergence is worse than a
172
+ // noisy one and the operator is the only authority on whether the
173
+ // upstream component has actually changed shape.
174
+ const forgeConfig = await loadConfig(projectRoot);
175
+ const driftEntries = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version);
176
+ for (const entry of driftEntries) {
177
+ warn(formatOverrideBaseVersionDriftWarning(entry));
178
+ }
32
179
  const dryRun = options.dryRun ?? false;
180
+ const force = options.force ?? false;
181
+ if (!force && driftEntries.length > 0) {
182
+ throw new FurnaceError(formatOverrideBaseVersionDriftError(driftEntries));
183
+ }
33
184
  const applySpinner = dryRun ? undefined : spinner('Applying components to engine...');
34
- const result = await applyAllComponents(projectRoot, dryRun);
185
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, dryRun, {
186
+ operationContext: ctx,
187
+ ...(name !== undefined ? { componentName: name } : {}),
188
+ }), { dryRun });
35
189
  if (applySpinner) {
36
190
  applySpinner.stop('Components applied');
37
191
  }
38
- // Report applied
39
- for (const applied of result.applied) {
40
- const prefix = dryRun ? '[dry-run] Would apply' : '';
41
- const label = dryRun
42
- ? `${prefix} ${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`
43
- : `${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`;
44
- if (dryRun) {
45
- info(label);
46
- }
47
- else {
48
- success(label);
49
- }
50
- if (applied.stepErrors && applied.stepErrors.length > 0) {
51
- for (const stepErr of applied.stepErrors) {
52
- warn(`${applied.name}: [${stepErr.step}] ${stepErr.error}`);
53
- }
54
- }
55
- }
56
- // Report skipped
57
- for (const skipped of result.skipped) {
58
- info(`${skipped.name} — ${skipped.reason}`);
59
- }
60
- // Report errors
61
- for (const err of result.errors) {
62
- error(`${err.name} — ${err.error}`);
63
- }
64
- const stepFailureCount = dryRun
192
+ logApplyResult(result, dryRun);
193
+ const appliedWithStepErrorsCount = dryRun
65
194
  ? 0
66
195
  : result.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
67
- const totalApplyFailures = result.errors.length + stepFailureCount;
196
+ const totalApplyFailures = result.errors.length + appliedWithStepErrorsCount;
68
197
  if (totalApplyFailures > 0) {
69
198
  throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
70
199
  }
@@ -76,5 +205,9 @@ export async function furnaceApplyCommand(projectRoot, options = {}) {
76
205
  else {
77
206
  outro(`Applied ${appliedCount}, skipped ${skippedCount}`);
78
207
  }
208
+ // Watch mode: re-apply on file changes in component directories
209
+ if (options.watch && !dryRun) {
210
+ await runWatchLoop(projectRoot);
211
+ }
79
212
  }
80
213
  //# sourceMappingURL=apply.js.map