@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,17 @@
1
+ import { getProjectPaths } from '../core/config.js';
2
+ import type { PatchMetadata, ReExportOptions } from '../types/commands/index.js';
3
+ import type { FireForgeConfig } from '../types/config.js';
4
+ /**
5
+ * Handles `re-export --files` end-to-end: computes the projected diff,
6
+ * runs the per-patch and cross-patch lint against a context in which the
7
+ * target patch has been replaced with the projected state, gates on
8
+ * confirmDestructive, and writes atomically.
9
+ *
10
+ * Lives outside reExportSinglePatch because the --files path has strictly
11
+ * different semantics (authoritative file list, destructive shrink
12
+ * confirmation, cross-patch projection lint) and shoehorning it through
13
+ * the generic single-patch helper is what led to the earlier bug where
14
+ * the projection lint ran against the current (unchanged) queue instead
15
+ * of the projected state.
16
+ */
17
+ export declare function reExportFilesInPlace(paths: ReturnType<typeof getProjectPaths>, selectedPatches: PatchMetadata[], options: ReExportOptions, config: FireForgeConfig): Promise<void>;
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { appendHistory, confirmDestructive } from '../core/destructive.js';
4
+ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
+ import { computeProjectedLintRegressions } from '../core/lint-projection.js';
6
+ import { extractAffectedFiles } from '../core/patch-apply.js';
7
+ import { updatePatchAndMetadata } from '../core/patch-export.js';
8
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
9
+ import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
10
+ import { InvalidArgumentError } from '../errors/base.js';
11
+ import { pathExists } from '../utils/fs.js';
12
+ import { info, outro, success, warn } from '../utils/logger.js';
13
+ import { runPatchLint } from './export-shared.js';
14
+ /**
15
+ * Handles `re-export --files` end-to-end: computes the projected diff,
16
+ * runs the per-patch and cross-patch lint against a context in which the
17
+ * target patch has been replaced with the projected state, gates on
18
+ * confirmDestructive, and writes atomically.
19
+ *
20
+ * Lives outside reExportSinglePatch because the --files path has strictly
21
+ * different semantics (authoritative file list, destructive shrink
22
+ * confirmation, cross-patch projection lint) and shoehorning it through
23
+ * the generic single-patch helper is what led to the earlier bug where
24
+ * the projection lint ran against the current (unchanged) queue instead
25
+ * of the projected state.
26
+ */
27
+ export async function reExportFilesInPlace(paths, selectedPatches, options, config) {
28
+ const isDryRun = options.dryRun === true;
29
+ const target = selectedPatches[0];
30
+ if (!target) {
31
+ throw new InvalidArgumentError('--files requires a target patch.', '--files');
32
+ }
33
+ const filesOption = options.files;
34
+ if (filesOption === undefined) {
35
+ throw new InvalidArgumentError('reExportFilesInPlace called with no --files.', '--files');
36
+ }
37
+ const requested = [...new Set(filesOption)].sort();
38
+ const removed = target.filesAffected.filter((f) => !requested.includes(f));
39
+ const added = requested.filter((f) => !target.filesAffected.includes(f));
40
+ // Filter out paths that no longer exist on disk; we cannot include
41
+ // them in the new diff because getDiffForFilesAgainstHead would fail.
42
+ // Missing files are still dropped from the manifest so the resulting
43
+ // filesAffected reflects reality.
44
+ const missingFiles = [];
45
+ for (const file of requested) {
46
+ const filePath = join(paths.engine, file);
47
+ if (!(await pathExists(filePath))) {
48
+ missingFiles.push(file);
49
+ }
50
+ }
51
+ const missingSet = new Set(missingFiles);
52
+ const diffableFiles = requested.filter((f) => !missingSet.has(f));
53
+ for (const file of missingFiles) {
54
+ warn(`${target.filename}: requested file is missing on disk and will be dropped: ${file}`);
55
+ }
56
+ // Compute the projected diff up front. This is the same diff the real
57
+ // write would produce, so we get an exact preview through the lint
58
+ // gate and avoid computing it twice.
59
+ const projectedDiff = diffableFiles.length > 0 ? await getDiffForFilesAgainstHead(paths.engine, diffableFiles) : '';
60
+ if (!projectedDiff.trim()) {
61
+ throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because the projected scope ` +
62
+ 'produces an empty patch. FireForge does not write zero-hunk patch files; ' +
63
+ `use "fireforge patch delete ${target.filename}" if this patch should be removed entirely.`, '--files');
64
+ }
65
+ const actualProjectedFiles = extractAffectedFiles(projectedDiff);
66
+ const actualProjectedSet = new Set(actualProjectedFiles);
67
+ const noDiffFiles = diffableFiles.filter((file) => !actualProjectedSet.has(file));
68
+ if (noDiffFiles.length > 0) {
69
+ throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because ${noDiffFiles.length} requested path${noDiffFiles.length === 1 ? '' : 's'} produced no diff hunks (${noDiffFiles.join(', ')}). ` +
70
+ 'Keeping them in filesAffected would desync patches.json from the patch body. ' +
71
+ 'Remove those paths from --files or modify them before retrying.', '--files');
72
+ }
73
+ // Run the per-patch lint against the projected diff. This mirrors what
74
+ // runPatchLint does in the standard re-export path.
75
+ await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint);
76
+ // Project the cross-patch context: replace the target entry with its
77
+ // would-be shrunken self (new diff + new newFiles + new
78
+ // modifiedFileAdditions). The projected entry must repopulate both
79
+ // source-site maps so the forward-import rule sees imports the
80
+ // shrunken diff would add — or stop adding — consistently with how a
81
+ // real rebuild would see them.
82
+ const baseCtx = await buildPatchQueueContext(paths.patches);
83
+ const projectedNewFiles = new Map();
84
+ for (const path of detectNewFilesInDiff(projectedDiff)) {
85
+ projectedNewFiles.set(path, extractNewFileContentFromDiff(projectedDiff, path));
86
+ }
87
+ const projectedModifiedFileAdditions = buildModifiedFileAdditionsFromDiff(projectedDiff);
88
+ const projectedEntries = baseCtx.entries.map((entry) => {
89
+ if (entry.filename !== target.filename)
90
+ return entry;
91
+ return {
92
+ ...entry,
93
+ diff: projectedDiff,
94
+ newFiles: projectedNewFiles,
95
+ modifiedFileAdditions: projectedModifiedFileAdditions,
96
+ };
97
+ });
98
+ // Baseline-vs-projected diffing: only regressions introduced *by* this
99
+ // shrink should block. A pre-existing cross-patch error elsewhere in
100
+ // the queue must not prevent the user from shrinking an unrelated
101
+ // patch (which is often exactly the tool they reach for to repair
102
+ // such a queue).
103
+ const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
104
+ const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
105
+ const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
106
+ const conflicts = regressions.length > 0
107
+ ? {
108
+ reason: `projected --files state introduces ${regressions.length} new cross-patch lint error(s)`,
109
+ details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
110
+ }
111
+ : null;
112
+ // Surface pre-existing errors as a non-blocking warning so the user
113
+ // doesn't walk away thinking the queue is clean.
114
+ if (baselineIssues.length > 0 && regressions.length === 0) {
115
+ warn(`Note: projected queue still has ${baselineIssues.length} pre-existing ` +
116
+ `cross-patch error(s) unrelated to this shrink. Run "fireforge verify" to list them.`);
117
+ }
118
+ // Shrinks are destructive (previously-owned files become unmanaged).
119
+ // Additive-only changes still deserve a prompt because --files asserts
120
+ // an authoritative file set.
121
+ const summary = [
122
+ `re-export ${target.filename} with --files scope`,
123
+ `current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
124
+ `new files (${actualProjectedFiles.length}): ${actualProjectedFiles.join(', ') || '(none)'}`,
125
+ ];
126
+ if (removed.length > 0) {
127
+ summary.push(`would drop (become unmanaged): ${removed.join(', ')}`);
128
+ }
129
+ if (added.length > 0) {
130
+ summary.push(`would add: ${added.join(', ')}`);
131
+ }
132
+ if (missingFiles.length > 0) {
133
+ summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
134
+ }
135
+ const decision = await confirmDestructive({
136
+ operation: 're-export-files',
137
+ title: `Re-export ${target.filename} with --files`,
138
+ summary,
139
+ yes: options.yes === true,
140
+ dryRun: isDryRun,
141
+ unsafeOverride: options.forceUnsafe === true,
142
+ conflicts,
143
+ });
144
+ if (decision === 'cancelled') {
145
+ outro('Re-export cancelled');
146
+ return;
147
+ }
148
+ if (decision === 'dry-run') {
149
+ info(`[dry-run] ${target.filename}: ${actualProjectedFiles.length} file(s) in projected scope`);
150
+ outro('Dry run complete — no changes made');
151
+ return;
152
+ }
153
+ // Execute the write. At this point the projected diff is guaranteed to
154
+ // be non-empty and `actualProjectedFiles` is guaranteed to match the
155
+ // paths the body really touches, so the manifest cannot drift from the
156
+ // regenerated patch body. The history append runs inside the same patch
157
+ // directory lock as the mutation (via the onCommitted hook) so two
158
+ // concurrent re-exports cannot interleave records and a crash between
159
+ // mutation and append cannot orphan the audit trail.
160
+ await updatePatchAndMetadata(paths.patches, target.filename, projectedDiff, { filesAffected: actualProjectedFiles }, async () => {
161
+ await appendHistory(paths.patches, {
162
+ operation: 're-export-files',
163
+ args: {
164
+ filename: target.filename,
165
+ files: actualProjectedFiles,
166
+ previousFiles: target.filesAffected,
167
+ missingFilesDropped: missingFiles,
168
+ },
169
+ ...(options.yes === true ? { yes: true } : {}),
170
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
171
+ result: 'ok',
172
+ });
173
+ });
174
+ success(`Re-exported ${target.filename}`);
175
+ outro('Re-export complete');
176
+ }
177
+ //# sourceMappingURL=re-export-files.js.map
@@ -13,6 +13,7 @@ import { pathExists } from '../utils/fs.js';
13
13
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
14
14
  import { pickDefined } from '../utils/options.js';
15
15
  import { runPatchLint } from './export-shared.js';
16
+ import { reExportFilesInPlace } from './re-export-files.js';
16
17
  /**
17
18
  * Resolves patch identifiers (numbers or filenames) to manifest entries.
18
19
  * @param identifier - Patch number (e.g. "005") or filename (e.g. "005-ui-storage-modules.patch")
@@ -74,6 +75,22 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
74
75
  if (options.scan) {
75
76
  currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
76
77
  }
78
+ // --- Explicit file-subset path ---
79
+ // When --files is given, the target filesAffected is authoritative — drop
80
+ // anything not in the list, add anything new. This is the surgical repair
81
+ // primitive that replaces hand-editing patches.json; the user has already
82
+ // acknowledged via confirmDestructive (done in the caller) that any drop
83
+ // is intentional.
84
+ if (options.files !== undefined) {
85
+ const requested = [...new Set(options.files)].sort();
86
+ currentFilesAffected = requested;
87
+ const removed = patch.filesAffected.filter((f) => !requested.includes(f));
88
+ const added = requested.filter((f) => !patch.filesAffected.includes(f));
89
+ for (const f of added)
90
+ info(` + ${f}`);
91
+ for (const f of removed)
92
+ info(` - ${f}`);
93
+ }
77
94
  const missingFiles = [];
78
95
  for (const file of currentFilesAffected) {
79
96
  const filePath = join(paths.engine, file);
@@ -165,6 +182,16 @@ async function resolveSelectedPatches(patches, options, manifest) {
165
182
  export async function reExportCommand(projectRoot, patches, options) {
166
183
  const isDryRun = options.dryRun === true;
167
184
  intro(isDryRun ? 'FireForge Re-export (dry run)' : 'FireForge Re-export');
185
+ // --files is mutually exclusive with --scan and --all: they select
186
+ // different scope contracts.
187
+ if (options.files !== undefined) {
188
+ if (options.all || options.scan) {
189
+ throw new InvalidArgumentError('--files cannot be combined with --scan or --all.', '--files');
190
+ }
191
+ if (patches.length !== 1) {
192
+ throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
193
+ }
194
+ }
168
195
  const paths = getProjectPaths(projectRoot);
169
196
  // Check if engine exists
170
197
  if (!(await pathExists(paths.engine))) {
@@ -188,6 +215,17 @@ export async function reExportCommand(projectRoot, patches, options) {
188
215
  outro('Nothing to re-export');
189
216
  return;
190
217
  }
218
+ // --files path: handled end-to-end here so we can lint the *projected*
219
+ // shrunken state (not the current queue) and skip the generic re-export
220
+ // loop. The projection substitutes the target patch's diff and newFiles
221
+ // with the freshly computed content, then runs lintPatchQueue so any
222
+ // forward-import introduced or uncovered by the shrink is caught before
223
+ // we write anything.
224
+ if (options.files !== undefined) {
225
+ const filesConfig = await loadConfig(projectRoot);
226
+ await reExportFilesInPlace(paths, selectedPatches, options, filesConfig);
227
+ return;
228
+ }
191
229
  const config = await loadConfig(projectRoot);
192
230
  let reExported = 0;
193
231
  const progress = spinner('Preparing re-export...');
@@ -225,8 +263,14 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
225
263
  .description('Re-export existing patches from current engine state')
226
264
  .option('-a, --all', 'Re-export all patches')
227
265
  .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
266
+ .option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
267
+ .split(',')
268
+ .map((v) => v.trim())
269
+ .filter((v) => v.length > 0))
228
270
  .option('--dry-run', 'Show what would change without writing')
229
271
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
272
+ .option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
273
+ .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
230
274
  .action(withErrorHandling(async (patches, options) => {
231
275
  await reExportCommand(getProjectRoot(), patches, pickDefined(options));
232
276
  }));
@@ -4,4 +4,4 @@
4
4
  /**
5
5
  * Handles `fireforge rebase --abort`.
6
6
  */
7
- export declare function handleAbort(projectRoot: string, force?: boolean): Promise<void>;
7
+ export declare function handleAbort(projectRoot: string, yes?: boolean): Promise<void>;
@@ -3,15 +3,17 @@
3
3
  * Rebase abort flow.
4
4
  */
5
5
  import { getProjectPaths, loadState, saveState } from '../../core/config.js';
6
+ import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.js';
6
7
  import { resetChanges } from '../../core/git.js';
7
8
  import { clearRebaseSession, loadRebaseSession } from '../../core/rebase-session.js';
8
9
  import { NoRebaseSessionError } from '../../errors/rebase.js';
10
+ import { pathExists } from '../../utils/fs.js';
9
11
  import { intro, outro, spinner, success } from '../../utils/logger.js';
10
12
  import { confirmDirtyEngineReset } from './confirm.js';
11
13
  /**
12
14
  * Handles `fireforge rebase --abort`.
13
15
  */
14
- export async function handleAbort(projectRoot, force) {
16
+ export async function handleAbort(projectRoot, yes) {
15
17
  intro('FireForge Rebase — Abort');
16
18
  const session = await loadRebaseSession(projectRoot);
17
19
  if (!session)
@@ -19,8 +21,8 @@ export async function handleAbort(projectRoot, force) {
19
21
  const paths = getProjectPaths(projectRoot);
20
22
  if (!(await confirmDirtyEngineReset({
21
23
  engineDir: paths.engine,
22
- force: force ?? false,
23
- nonInteractiveHint: 'Use: fireforge rebase --abort --force',
24
+ yes: yes ?? false,
25
+ nonInteractiveHint: 'Use: fireforge rebase --abort --yes',
24
26
  warningMessage: 'The engine directory has uncommitted changes that will be lost.',
25
27
  promptMessage: 'Discard uncommitted changes and abort rebase?',
26
28
  cancelMessage: 'Abort cancelled',
@@ -31,6 +33,13 @@ export async function handleAbort(projectRoot, force) {
31
33
  try {
32
34
  await resetChanges(paths.engine);
33
35
  s.stop('Engine restored');
36
+ // Clear Furnace state — the engine has been rolled back to pre-rebase state.
37
+ const furnacePaths = getFurnacePaths(projectRoot);
38
+ if (await pathExists(furnacePaths.furnaceState)) {
39
+ await updateFurnaceState(projectRoot, (current) => ({
40
+ ...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
41
+ }));
42
+ }
34
43
  }
35
44
  catch (error) {
36
45
  s.error('Failed to restore engine');
@@ -4,7 +4,7 @@
4
4
  /** Options for the dirty-engine confirmation prompt. */
5
5
  export interface DirtyEngineConfirmationOptions {
6
6
  engineDir: string;
7
- force: boolean;
7
+ yes: boolean;
8
8
  nonInteractiveHint: string;
9
9
  warningMessage: string;
10
10
  promptMessage: string;
@@ -13,6 +13,6 @@ export interface DirtyEngineConfirmationOptions {
13
13
  /**
14
14
  * Checks if the engine has uncommitted changes and prompts for confirmation.
15
15
  * Returns true if safe to proceed, false if the user cancelled.
16
- * Throws in non-interactive mode without --force.
16
+ * Throws in non-interactive mode without --yes.
17
17
  */
18
- export declare function confirmDirtyEngineReset({ engineDir, force, nonInteractiveHint, warningMessage, promptMessage, cancelMessage, }: DirtyEngineConfirmationOptions): Promise<boolean>;
18
+ export declare function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveHint, warningMessage, promptMessage, cancelMessage, }: DirtyEngineConfirmationOptions): Promise<boolean>;
@@ -9,15 +9,15 @@ import { cancel, isCancel, warn } from '../../utils/logger.js';
9
9
  /**
10
10
  * Checks if the engine has uncommitted changes and prompts for confirmation.
11
11
  * Returns true if safe to proceed, false if the user cancelled.
12
- * Throws in non-interactive mode without --force.
12
+ * Throws in non-interactive mode without --yes.
13
13
  */
14
- export async function confirmDirtyEngineReset({ engineDir, force, nonInteractiveHint, warningMessage, promptMessage, cancelMessage, }) {
15
- if (!(await hasChanges(engineDir)) || force) {
14
+ export async function confirmDirtyEngineReset({ engineDir, yes, nonInteractiveHint, warningMessage, promptMessage, cancelMessage, }) {
15
+ if (!(await hasChanges(engineDir)) || yes) {
16
16
  return true;
17
17
  }
18
18
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
19
19
  if (!isInteractive) {
20
- throw new InvalidArgumentError('Engine has uncommitted changes and interactive confirmation is not available. Use --force to proceed.', nonInteractiveHint);
20
+ throw new InvalidArgumentError('Engine has uncommitted changes and interactive confirmation is not available. Use --yes to proceed.', nonInteractiveHint);
21
21
  }
22
22
  warn(warningMessage);
23
23
  const confirmed = await confirm({
@@ -11,6 +11,7 @@
11
11
  * Supports `--continue` (resume after manual fix) and `--abort` (cancel).
12
12
  */
13
13
  import { getProjectPaths, loadConfig } from '../../core/config.js';
14
+ import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.js';
14
15
  import { getHead, isGitRepository, resetChanges } from '../../core/git.js';
15
16
  import { discoverPatches } from '../../core/patch-files.js';
16
17
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
@@ -64,8 +65,8 @@ async function handleFreshStart(projectRoot, options) {
64
65
  }
65
66
  if (!(await confirmDirtyEngineReset({
66
67
  engineDir: paths.engine,
67
- force: options.force ?? false,
68
- nonInteractiveHint: 'Use: fireforge rebase --force',
68
+ yes: options.yes ?? false,
69
+ nonInteractiveHint: 'Use: fireforge rebase --yes',
69
70
  warningMessage: 'The engine directory has uncommitted changes that will be lost by the rebase.',
70
71
  promptMessage: 'Discard uncommitted changes and start rebase?',
71
72
  cancelMessage: 'Rebase cancelled',
@@ -78,6 +79,14 @@ async function handleFreshStart(projectRoot, options) {
78
79
  const resetSpinner = spinner('Resetting engine to baseline...');
79
80
  await resetChanges(paths.engine);
80
81
  resetSpinner.stop('Engine reset to baseline');
82
+ // Clear Furnace state — the engine no longer contains deployed components.
83
+ // Preserve pendingRepair since that tracks authoring-side rollback issues.
84
+ const furnacePaths = getFurnacePaths(projectRoot);
85
+ if (await pathExists(furnacePaths.furnaceState)) {
86
+ await updateFurnaceState(projectRoot, (current) => ({
87
+ ...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
88
+ }));
89
+ }
81
90
  // Create rebase session
82
91
  const allPatches = await discoverPatches(paths.patches);
83
92
  const session = {
@@ -103,7 +112,7 @@ async function handleFreshStart(projectRoot, options) {
103
112
  */
104
113
  export async function rebaseCommand(projectRoot, options = {}) {
105
114
  if (options.abort) {
106
- return handleAbort(projectRoot, options.force);
115
+ return handleAbort(projectRoot, options.yes);
107
116
  }
108
117
  if (options.continue) {
109
118
  return handleContinue(projectRoot, options.maxFuzz ?? 3);
@@ -119,7 +128,7 @@ export function registerRebase(program, { getProjectRoot, withErrorHandling }) {
119
128
  .option('--abort', 'Cancel the rebase and restore engine to pre-rebase state')
120
129
  .option('--dry-run', 'Show what would happen without modifying anything')
121
130
  .option('--max-fuzz <n>', 'Maximum fuzz factor for git apply (default: 3)', parseInt)
122
- .option('-f, --force', 'Skip dirty-tree confirmation prompt')
131
+ .option('-y, --yes', 'Skip dirty-tree confirmation prompt')
123
132
  .action(withErrorHandling(async (options) => {
124
133
  await rebaseCommand(getProjectRoot(), pickDefined(options));
125
134
  }));
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { confirm } from '@clack/prompts';
3
3
  import { getProjectPaths } from '../core/config.js';
4
+ import { getFurnacePaths, updateFurnaceState } from '../core/furnace-config.js';
4
5
  import { hasChanges, isGitRepository, resetChanges } from '../core/git.js';
5
6
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
6
7
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
@@ -41,12 +42,12 @@ export async function resetCommand(projectRoot, options) {
41
42
  outro('Dry run complete — no changes made');
42
43
  return;
43
44
  }
44
- // Confirm reset unless --force is specified
45
- if (!options.force) {
45
+ // Confirm reset unless --yes is specified
46
+ if (!options.yes) {
46
47
  // Check for non-interactive mode
47
48
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
48
49
  if (!isInteractive) {
49
- throw new InvalidArgumentError('Interactive confirmation not available. Use --force flag to reset without confirmation.', 'Use: fireforge reset --force');
50
+ throw new InvalidArgumentError('Interactive confirmation not available. Use --yes flag to reset without confirmation.', 'Use: fireforge reset --yes');
50
51
  }
51
52
  warn('This will discard all uncommitted changes in the engine directory, including staged additions and untracked files.');
52
53
  const confirmed = await confirm({
@@ -61,6 +62,21 @@ export async function resetCommand(projectRoot, options) {
61
62
  const s = spinner('Resetting changes...');
62
63
  try {
63
64
  await resetChanges(paths.engine);
65
+ // Clearing furnace-state.json is the honest representation of what just
66
+ // happened: any previously deployed Furnace files have been discarded
67
+ // with the engine reset. Without this, a subsequent `furnace apply`
68
+ // would match on workspace checksums and report "up to date" against
69
+ // an engine that no longer contains the deployed copies. (The drift
70
+ // check in apply also catches this, but clearing here keeps state
71
+ // consistent regardless of the drift oracle.) Preserve pendingRepair:
72
+ // authoring-side rollback markers are about the workspace/component
73
+ // tree, not the engine checkout, so reset must not silently forget them.
74
+ const furnacePaths = getFurnacePaths(projectRoot);
75
+ if (await pathExists(furnacePaths.furnaceState)) {
76
+ await updateFurnaceState(projectRoot, (current) => ({
77
+ ...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
78
+ }));
79
+ }
64
80
  s.stop('Changes reset');
65
81
  outro('Working tree restored to clean state');
66
82
  }
@@ -74,7 +90,7 @@ export function registerReset(program, { getProjectRoot, withErrorHandling }) {
74
90
  program
75
91
  .command('reset')
76
92
  .description('Reset engine/ to clean state')
77
- .option('-f, --force', 'Skip confirmation prompt (required for scripts/CI)')
93
+ .option('-y, --yes', 'Skip confirmation prompt (required for scripts/CI)')
78
94
  .option('--dry-run', 'Show what would be reset without doing it')
79
95
  .action(withErrorHandling(async (options) => {
80
96
  await resetCommand(getProjectRoot(), pickDefined(options));
@@ -2,12 +2,14 @@
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { getProjectPaths } from '../core/config.js';
5
+ import { extractComponentChecksums, hasComponentChanged } from '../core/furnace-apply-helpers.js';
6
+ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from '../core/furnace-config.js';
5
7
  import { buildArtifactMismatchMessage, hasBuildArtifacts, run } from '../core/mach.js';
6
8
  import { GeneralError } from '../errors/base.js';
7
9
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
8
10
  import { toError } from '../utils/errors.js';
9
11
  import { pathExists, removeDir, removeFile } from '../utils/fs.js';
10
- import { info, intro, verbose } from '../utils/logger.js';
12
+ import { info, intro, verbose, warn } from '../utils/logger.js';
11
13
  /**
12
14
  * Cleans the dev profile to prevent stale-state startup failures.
13
15
  *
@@ -45,6 +47,47 @@ async function cleanDevProfile(engineDir) {
45
47
  verbose(`Non-fatal dev profile cleanup failure: ${toError(error).message}`);
46
48
  }
47
49
  }
50
+ /**
51
+ * Checks whether any Furnace component has changed since the last apply
52
+ * and warns the user. The build command auto-applies, but run does not,
53
+ * so this advisory message prevents the common "forgot to apply" mistake.
54
+ */
55
+ async function warnIfFurnaceStale(projectRoot) {
56
+ try {
57
+ if (!(await furnaceConfigExists(projectRoot)))
58
+ return;
59
+ const config = await loadFurnaceConfig(projectRoot);
60
+ const state = await loadFurnaceState(projectRoot);
61
+ const furnacePaths = getFurnacePaths(projectRoot);
62
+ if (!state.appliedChecksums)
63
+ return;
64
+ const stale = [];
65
+ for (const name of Object.keys(config.overrides)) {
66
+ const dir = `${furnacePaths.overridesDir}/${name}`;
67
+ if (!(await pathExists(dir)))
68
+ continue;
69
+ const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
70
+ if (await hasComponentChanged(dir, prev))
71
+ stale.push(name);
72
+ }
73
+ for (const name of Object.keys(config.custom)) {
74
+ const dir = `${furnacePaths.customDir}/${name}`;
75
+ if (!(await pathExists(dir)))
76
+ continue;
77
+ const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
78
+ if (await hasComponentChanged(dir, prev))
79
+ stale.push(name);
80
+ }
81
+ if (stale.length > 0) {
82
+ warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
83
+ 'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');
84
+ }
85
+ }
86
+ catch {
87
+ // Non-fatal: a broken furnace config should not block run.
88
+ verbose('Furnace staleness check skipped due to an error.');
89
+ }
90
+ }
48
91
  /**
49
92
  * Runs the run command to launch the built browser.
50
93
  * @param projectRoot - Root directory of the project
@@ -71,6 +114,8 @@ export async function runCommand(projectRoot) {
71
114
  throw new GeneralError(`Run requires a completed build. ${detail}\n\n` +
72
115
  "Run 'fireforge build' first, then rerun 'fireforge run'.");
73
116
  }
117
+ // Warn if Furnace components changed since the last apply
118
+ await warnIfFurnaceStale(projectRoot);
74
119
  // Clean stale profile state to prevent silent startup failures
75
120
  await cleanDevProfile(paths.engine);
76
121
  info('Launching browser...\n');
@@ -63,7 +63,7 @@ export function validateSetupOptions(options) {
63
63
  throw new InvalidArgumentError('Binary name must start with a letter and contain only lowercase letters, numbers, and hyphens', '--binary-name');
64
64
  }
65
65
  if (options.firefoxVersion !== undefined && !isValidFirefoxVersion(options.firefoxVersion)) {
66
- throw new InvalidArgumentError('Invalid Firefox version format (e.g., 146.0, 140.0esr, or 147.0b1)', '--firefox-version');
66
+ throw new InvalidArgumentError('Invalid Firefox version format (e.g., 146.0, 146.0esr, or 147.0b1)', '--firefox-version');
67
67
  }
68
68
  if (options.product !== undefined) {
69
69
  resolveFirefoxProduct(options.product, '--product');
@@ -127,10 +127,10 @@ async function promptSetupInputs(options) {
127
127
  ? Promise.resolve(options.firefoxVersion)
128
128
  : text({
129
129
  message: 'Firefox version to base on',
130
- placeholder: '140.0esr',
130
+ placeholder: '146.0esr',
131
131
  validate: (value) => {
132
132
  if (value && !isValidFirefoxVersion(value)) {
133
- return 'Invalid Firefox version format (e.g., 146.0, 140.0esr, or 147.0b1)';
133
+ return 'Invalid Firefox version format (e.g., 146.0, 146.0esr, or 147.0b1)';
134
134
  }
135
135
  return undefined;
136
136
  },
@@ -141,7 +141,7 @@ async function promptSetupInputs(options) {
141
141
  }
142
142
  const effectiveVersion = (typeof results.firefoxVersion === 'string' && results.firefoxVersion.trim()) ||
143
143
  options.firefoxVersion ||
144
- '140.0esr';
144
+ '146.0esr';
145
145
  const inferredProduct = inferProductFromVersion(effectiveVersion);
146
146
  if (inferredProduct) {
147
147
  return Promise.resolve(inferredProduct);
@@ -183,7 +183,7 @@ async function promptSetupInputs(options) {
183
183
  const finalAppId = (typeof project.appId === 'string' ? project.appId.trim() : '') ||
184
184
  `org.${sanitizedName}.browser`;
185
185
  const finalBinaryName = (typeof project.binaryName === 'string' ? project.binaryName.trim() : '') || sanitizedName;
186
- const finalFirefoxVersion = (typeof project.firefoxVersion === 'string' ? project.firefoxVersion.trim() : '') || '140.0esr';
186
+ const finalFirefoxVersion = (typeof project.firefoxVersion === 'string' ? project.firefoxVersion.trim() : '') || '146.0esr';
187
187
  if (!isValidAppId(finalAppId)) {
188
188
  throw new InvalidArgumentError(`Derived appId "${finalAppId}" is invalid.`, 'appId');
189
189
  }