@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
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
3
+ import { applyAllComponents } from '../../core/furnace-apply.js';
4
+ import { logApplyResult } from '../../core/furnace-apply-output.js';
5
+ import { furnaceConfigExists, loadFurnaceConfig } from '../../core/furnace-config.js';
6
+ import { runFurnaceMutation } from '../../core/furnace-operation.js';
7
+ import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
8
+ import { FurnaceError } from '../../errors/furnace.js';
9
+ import { pathExists } from '../../utils/fs.js';
10
+ import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
11
+ import { furnaceRefreshCommand } from './refresh.js';
12
+ /**
13
+ * Runs the furnace sync command: detects overrides with baseVersion drift,
14
+ * refreshes them (three-way merge), and re-applies all components.
15
+ *
16
+ * This is the recommended single command to run after `fireforge download`
17
+ * updates the Firefox source.
18
+ *
19
+ * @param projectRoot - Root directory of the project
20
+ * @param options - Sync options
21
+ */
22
+ export async function furnaceSyncCommand(projectRoot, options = {}) {
23
+ intro('Furnace Sync');
24
+ // Pre-flight checks
25
+ const paths = getProjectPaths(projectRoot);
26
+ if (!(await pathExists(paths.engine))) {
27
+ throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
28
+ }
29
+ if (!(await furnaceConfigExists(projectRoot))) {
30
+ throw new FurnaceError('No furnace.json found. Run "fireforge furnace create" or "fireforge furnace override" to get started.');
31
+ }
32
+ const config = await loadFurnaceConfig(projectRoot);
33
+ const forgeConfig = await loadConfig(projectRoot);
34
+ const overrideCount = Object.keys(config.overrides).length;
35
+ const customCount = Object.keys(config.custom).length;
36
+ if (overrideCount === 0 && customCount === 0) {
37
+ info('No components to sync.');
38
+ outro('Done');
39
+ return;
40
+ }
41
+ // Phase 1: Detect and report baseVersion drift
42
+ const driftEntries = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version);
43
+ if (driftEntries.length > 0) {
44
+ info(`Found ${driftEntries.length} override(s) with baseVersion drift:`);
45
+ for (const entry of driftEntries) {
46
+ warn(formatOverrideBaseVersionDriftWarning(entry));
47
+ }
48
+ // Phase 2: Refresh drifted overrides via three-way merge
49
+ info('\nRefreshing drifted overrides...');
50
+ await furnaceRefreshCommand(projectRoot, undefined, {
51
+ all: true,
52
+ ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}),
53
+ ...(options.strategy !== undefined ? { strategy: options.strategy } : {}),
54
+ });
55
+ }
56
+ else {
57
+ info('All overrides are up-to-date with the current Firefox version.');
58
+ }
59
+ // Phase 3: Re-apply all components to the engine
60
+ if (!options.dryRun) {
61
+ info('\nApplying all components to engine...');
62
+ const applySpinner = spinner('Applying components...');
63
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { dryRun: false });
64
+ applySpinner.stop('Components applied');
65
+ logApplyResult(result, false);
66
+ const appliedWithStepErrorsCount = result.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
67
+ const totalFailures = result.errors.length + appliedWithStepErrorsCount;
68
+ if (totalFailures > 0) {
69
+ throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to apply cleanly`);
70
+ }
71
+ outro(`Sync complete — ${result.applied.length} applied, ${result.skipped.length} skipped`);
72
+ }
73
+ else {
74
+ outro('Dry run complete');
75
+ }
76
+ }
77
+ //# sourceMappingURL=sync.js.map
@@ -2,5 +2,8 @@
2
2
  * Runs the furnace validate command to perform static analysis on components.
3
3
  * @param projectRoot - Root directory of the project
4
4
  * @param name - Optional component name to validate (validates all if omitted)
5
+ * @param options - Optional command options (e.g. --fix)
5
6
  */
6
- export declare function furnaceValidateCommand(projectRoot: string, name?: string): Promise<void>;
7
+ export declare function furnaceValidateCommand(projectRoot: string, name?: string, options?: {
8
+ fix?: boolean;
9
+ }): Promise<void>;
@@ -1,17 +1,96 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { readdir } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
4
+ import { getProjectPaths } from '../../core/config.js';
3
5
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, } from '../../core/furnace-config.js';
6
+ import { addCustomElementRegistration, addJarMnEntries } from '../../core/furnace-registration.js';
4
7
  import { validateAllComponents, validateComponent } from '../../core/furnace-validate.js';
5
8
  import { FurnaceError } from '../../errors/furnace.js';
6
9
  import { pathExists } from '../../utils/fs.js';
7
- import { info, intro, note, outro, success } from '../../utils/logger.js';
10
+ import { info, intro, note, outro, success, warn } from '../../utils/logger.js';
8
11
  import { displayValidationIssues } from './validation-output.js';
12
+ /** Checks that auto-fix can correct. */
13
+ const FIXABLE_CHECKS = new Set([
14
+ 'missing-jar-mn-mjs',
15
+ 'missing-jar-mn-css',
16
+ 'wrong-registration-pattern',
17
+ ]);
18
+ /**
19
+ * Auto-fixes registration issues that have deterministic solutions.
20
+ * @returns Number of issues fixed
21
+ */
22
+ async function autoFixIssues(projectRoot, issues) {
23
+ const { engine: engineDir } = getProjectPaths(projectRoot);
24
+ const config = await loadFurnaceConfig(projectRoot);
25
+ const furnacePaths = getFurnacePaths(projectRoot);
26
+ let fixed = 0;
27
+ // Group jar.mn fixes per component to batch them
28
+ const jarMnFixesByComponent = new Map();
29
+ for (const issue of issues) {
30
+ if (!FIXABLE_CHECKS.has(issue.check))
31
+ continue;
32
+ const customConfig = config.custom[issue.component];
33
+ if (!customConfig)
34
+ continue;
35
+ if (issue.check === 'missing-jar-mn-mjs' || issue.check === 'missing-jar-mn-css') {
36
+ const ext = issue.check === 'missing-jar-mn-mjs' ? '.mjs' : '.css';
37
+ const fileName = `${issue.component}${ext}`;
38
+ const existing = jarMnFixesByComponent.get(issue.component) ?? [];
39
+ existing.push(fileName);
40
+ jarMnFixesByComponent.set(issue.component, existing);
41
+ }
42
+ }
43
+ // Fix jar.mn entries
44
+ for (const [componentName, files] of jarMnFixesByComponent) {
45
+ try {
46
+ await addJarMnEntries(engineDir, componentName, files);
47
+ fixed += files.length;
48
+ info(`Fixed: added ${files.join(', ')} to jar.mn for ${componentName}`);
49
+ }
50
+ catch (err) {
51
+ warn(`Could not fix jar.mn for ${componentName}: ${err instanceof Error ? err.message : String(err)}`);
52
+ }
53
+ }
54
+ // Fix missing customElements.js registrations
55
+ for (const issue of issues) {
56
+ if (issue.check !== 'wrong-registration-pattern')
57
+ continue;
58
+ // wrong-registration-pattern means it IS registered, but in the wrong block.
59
+ // We don't auto-fix this as it requires moving code between blocks, which
60
+ // is too risky. Only fix truly missing registrations.
61
+ }
62
+ // Check for components that are missing from customElements.js entirely
63
+ // (detected by post-apply consistency, not by validate — but we can check here)
64
+ for (const [componentName, customConfig] of Object.entries(config.custom)) {
65
+ if (!customConfig.register)
66
+ continue;
67
+ const componentDir = join(furnacePaths.customDir, componentName);
68
+ if (!(await pathExists(componentDir)))
69
+ continue;
70
+ const entries = await readdir(componentDir, { withFileTypes: true });
71
+ const hasMjs = entries.some((e) => e.isFile() && e.name === `${componentName}.mjs`);
72
+ if (!hasMjs)
73
+ continue;
74
+ const modulePath = `chrome://global/content/elements/${componentName}.mjs`;
75
+ try {
76
+ await addCustomElementRegistration(engineDir, componentName, modulePath);
77
+ // addCustomElementRegistration is idempotent — it returns without error
78
+ // if already registered. We only count it as fixed if a matching issue
79
+ // existed in the input.
80
+ }
81
+ catch {
82
+ // Ignore — idempotent call, may already be registered
83
+ }
84
+ }
85
+ return fixed;
86
+ }
9
87
  /**
10
88
  * Runs the furnace validate command to perform static analysis on components.
11
89
  * @param projectRoot - Root directory of the project
12
90
  * @param name - Optional component name to validate (validates all if omitted)
91
+ * @param options - Optional command options (e.g. --fix)
13
92
  */
14
- export async function furnaceValidateCommand(projectRoot, name) {
93
+ export async function furnaceValidateCommand(projectRoot, name, options = {}) {
15
94
  intro('Furnace Validate');
16
95
  if (!(await furnaceConfigExists(projectRoot))) {
17
96
  throw new FurnaceError('No furnace.json found. Run "fireforge furnace create" or "fireforge furnace override" to get started.');
@@ -21,6 +100,7 @@ export async function furnaceValidateCommand(projectRoot, name) {
21
100
  let totalErrors = 0;
22
101
  let totalWarnings = 0;
23
102
  let componentCount;
103
+ let allIssues = [];
24
104
  if (name) {
25
105
  // --- Single component validation ---
26
106
  let type;
@@ -46,6 +126,7 @@ export async function furnaceValidateCommand(projectRoot, name) {
46
126
  }
47
127
  const issues = await validateComponent(componentDir, name, type, config, projectRoot);
48
128
  componentCount = 1;
129
+ allIssues = issues;
49
130
  if (issues.length === 0) {
50
131
  success(`${name} — all checks passed`);
51
132
  }
@@ -70,6 +151,7 @@ export async function furnaceValidateCommand(projectRoot, name) {
70
151
  const results = await validateAllComponents(projectRoot);
71
152
  componentCount = results.size;
72
153
  for (const [componentName, issues] of results) {
154
+ allIssues.push(...issues);
73
155
  if (issues.length === 0) {
74
156
  success(`${componentName} — all checks passed`);
75
157
  }
@@ -80,10 +162,24 @@ export async function furnaceValidateCommand(projectRoot, name) {
80
162
  }
81
163
  }
82
164
  }
165
+ // Auto-fix fixable issues when --fix is passed
166
+ if (options.fix && allIssues.length > 0) {
167
+ const fixableIssues = allIssues.filter((issue) => FIXABLE_CHECKS.has(issue.check));
168
+ if (fixableIssues.length > 0) {
169
+ const fixedCount = await autoFixIssues(projectRoot, fixableIssues);
170
+ if (fixedCount > 0) {
171
+ info(`\nAuto-fixed ${fixedCount} issue(s). Re-run validate to confirm.`);
172
+ }
173
+ }
174
+ else {
175
+ info('\nNo auto-fixable issues found. Remaining issues require manual resolution.');
176
+ }
177
+ }
83
178
  // Summary
84
179
  note(`${totalErrors} error(s), ${totalWarnings} warning(s) across ${componentCount} component(s)`, 'Validation Summary');
85
180
  if (totalErrors > 0) {
86
- info('Fix the errors above and run "fireforge furnace validate" again.');
181
+ const fixHint = options.fix ? '' : ' Use --fix to auto-correct registration issues.';
182
+ info(`Fix the errors above and run "fireforge furnace validate" again.${fixHint}`);
87
183
  throw new FurnaceError(`Validation failed with ${totalErrors} error(s).`);
88
184
  }
89
185
  outro('Validation passed');
@@ -1,7 +1,30 @@
1
- import type { ValidationIssue } from '../../types/furnace.js';
1
+ import type { getFurnacePaths } from '../../core/furnace-config.js';
2
+ import type { FurnaceConfig, ValidationIssue } from '../../types/furnace.js';
3
+ import { type SpinnerHandle } from '../../utils/logger.js';
2
4
  /**
3
5
  * Displays validation issues and returns aggregated error and warning counts.
4
6
  * @param issues - Validation issues to render
5
7
  * @returns Tuple of [errorCount, warningCount]
6
8
  */
7
9
  export declare function displayValidationIssues(issues: ValidationIssue[]): [number, number];
10
+ export type ValidationResult = {
11
+ done: true;
12
+ } | {
13
+ done: false;
14
+ totalErrors: number;
15
+ totalWarnings: number;
16
+ componentCount: number;
17
+ skippedValidationCount: number;
18
+ };
19
+ /**
20
+ * Runs the validation phase of a furnace deploy, checking all or a single component.
21
+ * @param validateSpinner - Active spinner handle for progress display
22
+ * @param name - Optional component name (validates all if omitted)
23
+ * @param config - Loaded Furnace configuration
24
+ * @param furnacePaths - Resolved Furnace workspace paths
25
+ * @param failedComponents - Names of components whose apply step failed
26
+ * @param isDryRun - Whether deploy is running in dry-run mode
27
+ * @param projectRoot - Root directory of the project
28
+ * @returns Validation counts, or `done: true` if the caller should early-return
29
+ */
30
+ export declare function runDeployValidation(validateSpinner: SpinnerHandle, name: string | undefined, config: FurnaceConfig, furnacePaths: ReturnType<typeof getFurnacePaths>, failedComponents: Set<string>, isDryRun: boolean, projectRoot: string): Promise<ValidationResult>;
@@ -1,4 +1,9 @@
1
- import { error, warn } from '../../utils/logger.js';
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { validateAllComponents, validateComponent } from '../../core/furnace-validate.js';
4
+ import { FurnaceError } from '../../errors/furnace.js';
5
+ import { pathExists } from '../../utils/fs.js';
6
+ import { error, info, outro, success, warn } from '../../utils/logger.js';
2
7
  /**
3
8
  * Displays validation issues and returns aggregated error and warning counts.
4
9
  * @param issues - Validation issues to render
@@ -19,4 +24,91 @@ export function displayValidationIssues(issues) {
19
24
  }
20
25
  return [errors, warnings];
21
26
  }
27
+ function resolveNamedValidationTarget(name, config, furnacePaths) {
28
+ if (name in config.overrides) {
29
+ return {
30
+ type: 'override',
31
+ componentDir: join(furnacePaths.overridesDir, name),
32
+ };
33
+ }
34
+ if (name in config.custom) {
35
+ return {
36
+ type: 'custom',
37
+ componentDir: join(furnacePaths.customDir, name),
38
+ };
39
+ }
40
+ if (config.stock.includes(name)) {
41
+ return 'stock';
42
+ }
43
+ throw new FurnaceError(`Component "${name}" not found in furnace.json.`, name);
44
+ }
45
+ /**
46
+ * Runs the validation phase of a furnace deploy, checking all or a single component.
47
+ * @param validateSpinner - Active spinner handle for progress display
48
+ * @param name - Optional component name (validates all if omitted)
49
+ * @param config - Loaded Furnace configuration
50
+ * @param furnacePaths - Resolved Furnace workspace paths
51
+ * @param failedComponents - Names of components whose apply step failed
52
+ * @param isDryRun - Whether deploy is running in dry-run mode
53
+ * @param projectRoot - Root directory of the project
54
+ * @returns Validation counts, or `done: true` if the caller should early-return
55
+ */
56
+ export async function runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot) {
57
+ let totalErrors = 0;
58
+ let totalWarnings = 0;
59
+ let componentCount = 0;
60
+ let skippedValidationCount = 0;
61
+ if (name && failedComponents.has(name)) {
62
+ skippedValidationCount = 1;
63
+ validateSpinner.stop('Validation skipped');
64
+ warn(`Skipping validation for ${name} because apply failed.`);
65
+ }
66
+ else if (name) {
67
+ const target = resolveNamedValidationTarget(name, config, furnacePaths);
68
+ if (target === 'stock') {
69
+ validateSpinner.stop('Validation skipped');
70
+ info(`"${name}" is a stock component. Stock components are not validated locally.`);
71
+ outro(isDryRun ? 'Dry run complete' : 'Deploy complete');
72
+ return { done: true };
73
+ }
74
+ if (!(await pathExists(target.componentDir))) {
75
+ validateSpinner.stop('Validation failed');
76
+ throw new FurnaceError(`Component directory not found for "${name}".`, name);
77
+ }
78
+ const issues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
79
+ componentCount = 1;
80
+ validateSpinner.stop('Validation complete');
81
+ if (issues.length === 0) {
82
+ success(`${name} — all checks passed`);
83
+ }
84
+ else {
85
+ const [errors, warnings] = displayValidationIssues(issues);
86
+ totalErrors += errors;
87
+ totalWarnings += warnings;
88
+ }
89
+ }
90
+ else {
91
+ const results = await validateAllComponents(projectRoot);
92
+ validateSpinner.stop('Validation complete');
93
+ for (const [componentName, issues] of results) {
94
+ if (failedComponents.has(componentName)) {
95
+ skippedValidationCount++;
96
+ continue;
97
+ }
98
+ componentCount++;
99
+ if (issues.length === 0) {
100
+ success(`${componentName} — all checks passed`);
101
+ }
102
+ else {
103
+ const [errors, warnings] = displayValidationIssues(issues);
104
+ totalErrors += errors;
105
+ totalWarnings += warnings;
106
+ }
107
+ }
108
+ if (skippedValidationCount > 0) {
109
+ warn(`Skipped validation for ${skippedValidationCount} component(s) because their apply step failed.`);
110
+ }
111
+ }
112
+ return { done: false, totalErrors, totalWarnings, componentCount, skippedValidationCount };
113
+ }
22
114
  //# sourceMappingURL=validation-output.js.map
@@ -138,7 +138,8 @@ async function checkEngineDrift(engineDir, baseCommit, forceImport) {
138
138
  * @param options - Import options
139
139
  */
140
140
  export async function importCommand(projectRoot, options = {}) {
141
- intro('FireForge Import');
141
+ const isDryRun = options.dryRun === true;
142
+ intro(isDryRun ? 'FireForge Import (dry run)' : 'FireForge Import');
142
143
  const continueOnFailure = options.continue ?? false;
143
144
  const forceImport = options.force ?? false;
144
145
  const paths = getProjectPaths(projectRoot);
@@ -148,7 +149,7 @@ export async function importCommand(projectRoot, options = {}) {
148
149
  }
149
150
  // Engine consistency check before applying patches
150
151
  const state = await loadState(projectRoot);
151
- if (state.baseCommit) {
152
+ if (state.baseCommit && !isDryRun) {
152
153
  const shouldContinue = await checkEngineDrift(paths.engine, state.baseCommit, forceImport);
153
154
  if (!shouldContinue)
154
155
  return;
@@ -195,10 +196,33 @@ export async function importCommand(projectRoot, options = {}) {
195
196
  }
196
197
  info('Run "fireforge doctor" for more details.\n');
197
198
  }
199
+ // Dry-run: list patches that would be applied and exit
200
+ if (isDryRun) {
201
+ if (manifest) {
202
+ const patches = options.until
203
+ ? manifest.patches.filter((p) => {
204
+ const untilPatch = manifest.patches.find((u) => u.filename === options.until || u.filename === `${options.until}.patch`);
205
+ return untilPatch ? p.order <= untilPatch.order : true;
206
+ })
207
+ : manifest.patches;
208
+ info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
209
+ for (const patch of patches) {
210
+ info(` ${patch.filename} (${patch.filesAffected.length} file${patch.filesAffected.length === 1 ? '' : 's'})`);
211
+ }
212
+ }
213
+ else {
214
+ info(`\n[dry-run] Would apply ${patchCount} patch(es)`);
215
+ }
216
+ outro('Dry run complete — no changes made');
217
+ return;
218
+ }
198
219
  await checkUncommittedPatchFiles(paths.engine, paths.patches, forceImport);
199
220
  const s = spinner('Applying patches...');
200
221
  try {
201
- const summary = await applyPatchesWithContinue(paths.patches, paths.engine, continueOnFailure);
222
+ const summary = await applyPatchesWithContinue(paths.patches, paths.engine, {
223
+ continueOnFailure,
224
+ untilFilename: options.until,
225
+ });
202
226
  // Handle failures
203
227
  if (summary.failed.length > 0) {
204
228
  s.error(`${summary.failed.length} patch(es) failed`);
@@ -234,8 +258,17 @@ export function registerImport(program, { getProjectRoot, withErrorHandling }) {
234
258
  .description('Apply patches from the patches directory')
235
259
  .option('--continue', 'Continue applying patches even if one fails')
236
260
  .option('-f, --force', 'Proceed despite engine drift and overwrite unmanaged changes in patch-touched files')
261
+ .option('--until <patch>', 'Apply patches only up to and including this patch (alias: --stop-at)')
262
+ .option('--stop-at <patch>', 'Alias for --until')
263
+ .option('--dry-run', 'Preview which patches would be applied without modifying the engine')
237
264
  .action(withErrorHandling(async (options) => {
238
- await importCommand(getProjectRoot(), pickDefined(options));
265
+ // Accept both spellings; --until wins when both are passed.
266
+ const merged = { ...options };
267
+ if (merged.until === undefined && merged.stopAt !== undefined) {
268
+ merged.until = merged.stopAt;
269
+ }
270
+ delete merged.stopAt;
271
+ await importCommand(getProjectRoot(), pickDefined(merged));
239
272
  }));
240
273
  }
241
274
  //# sourceMappingURL=import.js.map
@@ -6,7 +6,7 @@ import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js'
6
6
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
7
  import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
8
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
- import { lintExportedPatch } from '../core/patch-lint.js';
9
+ import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
10
10
  import { GeneralError } from '../errors/base.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, intro, outro, success, warn } from '../utils/logger.js';
@@ -85,7 +85,16 @@ export async function lintCommand(projectRoot, files) {
85
85
  }
86
86
  const config = await loadConfig(projectRoot);
87
87
  const filesAffected = extractAffectedFiles(diff);
88
- const issues = await lintExportedPatch(paths.engine, filesAffected, diff, config);
88
+ const issues = [
89
+ ...(await lintExportedPatch(paths.engine, filesAffected, diff, config)),
90
+ ];
91
+ // Cross-patch rules operate over the whole queue, so run them whenever a
92
+ // patches directory exists — they surface duplicate /dev/null creations
93
+ // and forward-import chains that the per-patch orchestrator cannot see.
94
+ if (await pathExists(paths.patches)) {
95
+ const ctx = await buildPatchQueueContext(paths.patches);
96
+ issues.push(...lintPatchQueue(ctx));
97
+ }
89
98
  if (issues.length === 0) {
90
99
  success('No lint issues found.');
91
100
  outro('Lint passed');
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Central manifest of every top-level FireForge command.
3
+ *
4
+ * The manifest is iterated from {@link createProgram} in cli.ts so that
5
+ * adding a new command is a one-line change here instead of a three-line
6
+ * edit (import + registration call + ordering) spread across cli.ts. It
7
+ * also gives documentation tooling and tests a single authoritative list
8
+ * of commands to enumerate.
9
+ *
10
+ * The order of entries in {@link COMMAND_MANIFEST} is the order commands
11
+ * appear in `fireforge --help`; it is intentional, not alphabetical, and
12
+ * groups related commands together.
13
+ */
14
+ import type { CommandRegistrar } from '../types/cli.js';
15
+ /**
16
+ * A single entry in the command manifest.
17
+ */
18
+ export interface CommandManifestEntry {
19
+ /**
20
+ * Human-readable command name, matching the first token of the
21
+ * command line (e.g. `build`, `furnace`). Informational only — the
22
+ * authoritative command string lives inside each registrar's
23
+ * `.command(...)` call — but useful for documentation, manifest
24
+ * introspection, and test assertions.
25
+ */
26
+ name: string;
27
+ /**
28
+ * Short one-line group label, used purely for grouping in generated
29
+ * documentation. Not surfaced in the CLI itself.
30
+ */
31
+ group: 'project' | 'workflow' | 'engine' | 'diagnostics' | 'components';
32
+ /** Registers the command (and any subcommands) on the Commander program. */
33
+ register: CommandRegistrar;
34
+ }
35
+ /**
36
+ * Ordered list of every top-level FireForge command. cli.ts iterates this
37
+ * array to register commands in a single loop.
38
+ */
39
+ export declare const COMMAND_MANIFEST: readonly CommandManifestEntry[];
@@ -0,0 +1,59 @@
1
+ import { registerBootstrap } from './bootstrap.js';
2
+ import { registerBuild } from './build.js';
3
+ import { registerConfig } from './config.js';
4
+ import { registerDiscard } from './discard.js';
5
+ import { registerDoctor } from './doctor.js';
6
+ import { registerDownload } from './download.js';
7
+ import { registerExport } from './export.js';
8
+ import { registerExportAll } from './export-all.js';
9
+ import { registerFurnace } from './furnace/index.js';
10
+ import { registerImport } from './import.js';
11
+ import { registerLint } from './lint.js';
12
+ import { registerPackage } from './package.js';
13
+ import { registerPatch } from './patch/index.js';
14
+ import { registerReExport } from './re-export.js';
15
+ import { registerRebase } from './rebase.js';
16
+ import { registerRegister } from './register.js';
17
+ import { registerReset } from './reset.js';
18
+ import { registerResolve } from './resolve.js';
19
+ import { registerRun } from './run.js';
20
+ import { registerSetup } from './setup.js';
21
+ import { registerStatus } from './status.js';
22
+ import { registerTest } from './test.js';
23
+ import { registerToken } from './token.js';
24
+ import { registerVerify } from './verify.js';
25
+ import { registerWatch } from './watch.js';
26
+ import { registerWire } from './wire.js';
27
+ /**
28
+ * Ordered list of every top-level FireForge command. cli.ts iterates this
29
+ * array to register commands in a single loop.
30
+ */
31
+ export const COMMAND_MANIFEST = [
32
+ { name: 'setup', group: 'project', register: registerSetup },
33
+ { name: 'download', group: 'engine', register: registerDownload },
34
+ { name: 'bootstrap', group: 'engine', register: registerBootstrap },
35
+ { name: 'import', group: 'workflow', register: registerImport },
36
+ { name: 'resolve', group: 'workflow', register: registerResolve },
37
+ { name: 'build', group: 'workflow', register: registerBuild },
38
+ { name: 'run', group: 'workflow', register: registerRun },
39
+ { name: 'status', group: 'workflow', register: registerStatus },
40
+ { name: 'reset', group: 'workflow', register: registerReset },
41
+ { name: 'discard', group: 'workflow', register: registerDiscard },
42
+ { name: 'export', group: 'workflow', register: registerExport },
43
+ { name: 'export-all', group: 'workflow', register: registerExportAll },
44
+ { name: 're-export', group: 'workflow', register: registerReExport },
45
+ { name: 'patch', group: 'workflow', register: registerPatch },
46
+ { name: 'rebase', group: 'workflow', register: registerRebase },
47
+ { name: 'package', group: 'workflow', register: registerPackage },
48
+ { name: 'watch', group: 'workflow', register: registerWatch },
49
+ { name: 'test', group: 'workflow', register: registerTest },
50
+ { name: 'config', group: 'project', register: registerConfig },
51
+ { name: 'doctor', group: 'diagnostics', register: registerDoctor },
52
+ { name: 'register', group: 'workflow', register: registerRegister },
53
+ { name: 'wire', group: 'workflow', register: registerWire },
54
+ { name: 'token', group: 'components', register: registerToken },
55
+ { name: 'lint', group: 'diagnostics', register: registerLint },
56
+ { name: 'verify', group: 'diagnostics', register: registerVerify },
57
+ { name: 'furnace', group: 'components', register: registerFurnace },
58
+ ];
59
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `fireforge patch delete <name>` — removes a patch from the queue.
3
+ *
4
+ * Destructive: refuses when a later patch imports a module owned by the
5
+ * target (that would leave a dangling forward import), prompts for
6
+ * confirmation interactively, requires `--yes` for non-TTY, supports
7
+ * `--dry-run`, and appends to `patches/.fireforge-history.jsonl` on success.
8
+ */
9
+ import { Command } from 'commander';
10
+ import type { CommandContext } from '../../types/cli.js';
11
+ import type { PatchDeleteOptions } from '../../types/commands/index.js';
12
+ /**
13
+ * Runs the `patch delete` command: removes a patch file and its manifest
14
+ * row atomically, refusing when a later patch imports a leaf owned by the
15
+ * target.
16
+ *
17
+ * @param projectRoot - Project root directory
18
+ * @param identifier - Patch filename or ordinal number to delete
19
+ * @param options - Command options
20
+ */
21
+ export declare function patchDeleteCommand(projectRoot: string, identifier: string, options?: PatchDeleteOptions): Promise<void>;
22
+ /**
23
+ * Registers the `patch delete` subcommand on the `patch` parent.
24
+ *
25
+ * @param parent - Parent Commander command
26
+ * @param context - Shared CLI registration context
27
+ */
28
+ export declare function registerPatchDelete(parent: Command, context: CommandContext): void;