@hominis/fireforge 0.27.1 → 0.27.3

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 (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -5
  3. package/dist/src/cli.js +5 -1
  4. package/dist/src/commands/build.js +61 -1
  5. package/dist/src/commands/doctor-working-tree.js +5 -1
  6. package/dist/src/commands/download.js +178 -112
  7. package/dist/src/commands/export-all.js +3 -2
  8. package/dist/src/commands/export-flow.d.ts +2 -0
  9. package/dist/src/commands/export-flow.js +2 -0
  10. package/dist/src/commands/export.js +5 -4
  11. package/dist/src/commands/import.js +2 -1
  12. package/dist/src/commands/re-export.js +6 -6
  13. package/dist/src/commands/rebase/continue.js +2 -0
  14. package/dist/src/commands/rebase/index.d.ts +2 -2
  15. package/dist/src/commands/rebase/index.js +9 -4
  16. package/dist/src/commands/rebase/patch-loop.js +5 -5
  17. package/dist/src/commands/rebase/summary.js +7 -2
  18. package/dist/src/commands/resolve.js +2 -1
  19. package/dist/src/commands/status-output.d.ts +13 -0
  20. package/dist/src/commands/status-output.js +186 -0
  21. package/dist/src/commands/status.js +4 -247
  22. package/dist/src/commands/verify.js +32 -16
  23. package/dist/src/core/build-prepare.js +12 -4
  24. package/dist/src/core/firefox-archive.js +7 -3
  25. package/dist/src/core/firefox-cache.d.ts +1 -1
  26. package/dist/src/core/firefox-cache.js +12 -5
  27. package/dist/src/core/firefox.js +1 -1
  28. package/dist/src/core/git.js +7 -2
  29. package/dist/src/core/ownership-table.d.ts +3 -1
  30. package/dist/src/core/ownership-table.js +31 -7
  31. package/dist/src/core/patch-export.d.ts +4 -0
  32. package/dist/src/core/patch-export.js +4 -0
  33. package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
  34. package/dist/src/core/patch-manifest-consistency.js +4 -2
  35. package/dist/src/core/patch-manifest-query.d.ts +4 -3
  36. package/dist/src/core/patch-manifest-query.js +12 -4
  37. package/dist/src/core/patch-manifest-validate.js +22 -4
  38. package/dist/src/core/patch-source-metadata.d.ts +8 -0
  39. package/dist/src/core/patch-source-metadata.js +17 -0
  40. package/dist/src/core/rebase-session.d.ts +8 -3
  41. package/dist/src/core/rebase-session.js +1 -1
  42. package/dist/src/core/status-classify.d.ts +4 -1
  43. package/dist/src/core/status-classify.js +4 -5
  44. package/dist/src/errors/download.d.ts +11 -0
  45. package/dist/src/errors/download.js +33 -1
  46. package/dist/src/types/commands/patches.d.ts +9 -1
  47. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,9 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.3
4
+
5
+ - Fixed `firefox-devedition` source downloads so archive resolution uses `/pub/devedition/releases`.
6
+ - Kept existing `engine/` trees intact during `download --force` until the replacement archive downloads, validates, and extracts successfully.
7
+ - Improved checksum mismatch diagnostics with resolved URL and product context.
8
+
3
9
  ## 0.27.0
4
10
 
5
11
  - Added first-class `firefox-devedition` source support and atomic `fireforge source set`.
12
+ - Fixed `source set --version` so the subcommand accepts both space and equals forms without colliding with the root CLI version flag.
13
+ - Added `sourceProduct` and `sourceVersion` patch metadata while preserving `sourceEsrVersion` as a deprecated compatibility alias.
14
+ - Renamed source-rebase reporting away from ESR-only wording and clarified summaries with total patch counts.
15
+ - Unified status, ownership, doctor, and verify worktree classification, including an explained patch-owned drift state for manually resolved or re-exported files.
16
+ - Hardened build diagnostics so backend regeneration success/failure and failed make/mach commands include exit codes, tails, log hints, and verbose rerun suggestions.
6
17
  - Improved `download --force` git indexing progress with phase, count, and heartbeat output.
18
+ - Added cache metadata progress for archive validation, SHA-256 calculation, and sidecar JSON writes.
7
19
  - Added elapsed progress for extraction, initial source commits, and rebase/re-export patch refreshes.
8
20
  - Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
9
21
  - Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
package/README.md CHANGED
@@ -11,12 +11,12 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
11
11
  ## What It Does
12
12
 
13
13
  - **Patch based** Edit Firefox inside the `engine/` directory, then export changes into `.patch` files with manifest metadata.
14
- - **ESR rebasing** Reapply your patches onto a newer Firefox source tree, resolve rejects and re-export the queue against the new baseline, hopefully...
14
+ - **Source rebasing** Reapply your patches onto newer Firefox source trees, including ESR, Beta, and Developer Edition archives, resolve rejects and re-export the queue against the new baseline, hopefully...
15
15
  - **Firefox source and build helpers** Download, bootstrap, build, run, test, package, smoke-check, etc.
16
16
  - **Wiring and registration** Add chrome scripts, DOM fragments, modules, styles, tests and manifest entries through commands built by learning from existing Firefox conventions.
17
17
  - **Furnace components** Create or override `MozLitElement` widgets easily to add new or adapt existing UI components to your needs.
18
18
  - **Quality** `lint`, `typecheck`, `verify` and `doctor` catch common issues early.
19
- - **Tests** Fireforge was build by taking apart and applying patches of all sorts to original Firefox ESR source code across different versions, learning what works vs doesn't and creating some quite extensive tests based on that covering all manner of scenarios. Yes, we mock quite a bit, but when building a tool that modifies a separate code base, I think it's a solid compromise for the time being. Full end-to-end runs are currently run locally on my MacBook, as they require about 30 GB of disk and significant compute for multiple full builds. Full end-to-end via Actions will be added soonishlyTM but might need a different runner...
19
+ - **Tests** Fireforge was build by taking apart and applying patches of all sorts to original Firefox source code across different versions and products, learning what works vs doesn't and creating some quite extensive tests based on that covering all manner of scenarios. Yes, we mock quite a bit, but when building a tool that modifies a separate code base, I think it's a solid compromise for the time being. Full end-to-end runs are currently run locally on my MacBook, as they require about 30 GB of disk and significant compute for multiple full builds. Full end-to-end via Actions will be added soonishlyTM but might need a different runner...
20
20
 
21
21
  ## Requirements
22
22
 
@@ -63,9 +63,9 @@ npx fireforge test browser/base/content/test/browser/
63
63
 
64
64
  Use `fireforge --help` for the full set of commands.
65
65
 
66
- ## Rebasing Firefox
66
+ ## Rebasing Firefox Source
67
67
 
68
- When Mozilla publishes a new ESR you need to update the configured Firefox version, download the new source code and reapply the patches:
68
+ When Mozilla publishes a new Firefox source release you need to update the configured version/product, download the new source code and reapply the patches:
69
69
 
70
70
  ```bash
71
71
  npx fireforge source set --version 145.0.0esr --product firefox-esr --sha256 <archive-sha256>
@@ -95,7 +95,7 @@ Use `fireforge furnace --help` for the full set of component commands.
95
95
  - **Docker builds** Reproducible builds using Docker containers.
96
96
  - **CI mode** Automated setup for continuous integration pipelines.
97
97
  - **Update manifests** Generate update server manifests for auto-updates.
98
- - **Nightly support** Requires implementing `hg clone` support via mozilla-central. Currently fireforge only downloads from the archive.
98
+ - **Nightly source support** Requires implementing `hg clone` support via mozilla-central. ESR, Beta, and Developer Edition source archives are supported through `fireforge source set`.
99
99
  - **E2E Github Actions** Requires either a higher tier of Githubs offering, an external VPS or another provider entirely. In any case, full end-to-end testing is currently run solely locally.
100
100
 
101
101
  ## Licence
package/dist/src/cli.js CHANGED
@@ -154,6 +154,7 @@ function buildGroupedHelpFormatter(manifest) {
154
154
  const desc = helper.optionDescription(opt);
155
155
  return formatHelpLine(term, desc, termWidth, helpWidth);
156
156
  });
157
+ optionLines.unshift(formatHelpLine('-V, --version', 'output the version number', termWidth, helpWidth));
157
158
  if (optionLines.length > 0) {
158
159
  output.push('Options:');
159
160
  output.push(...optionLines);
@@ -234,7 +235,6 @@ export function createProgram() {
234
235
  program
235
236
  .name('fireforge')
236
237
  .description('A build tool for customizing Firefox')
237
- .version(getPackageVersion())
238
238
  .option('-v, --verbose', 'Enable debug output')
239
239
  .hook('preAction', (thisCommand) => {
240
240
  const opts = thisCommand.opts();
@@ -256,6 +256,10 @@ export function createProgram() {
256
256
  * Main CLI entry point.
257
257
  */
258
258
  export async function main() {
259
+ if (process.argv.length === 3 && (process.argv[2] === '--version' || process.argv[2] === '-V')) {
260
+ process.stdout.write(`${getPackageVersion()}\n`);
261
+ return;
262
+ }
259
263
  const program = createProgram();
260
264
  await program.parseAsync(process.argv);
261
265
  }
@@ -69,6 +69,65 @@ function resolveJobCount(options, configJobs) {
69
69
  }
70
70
  return jobs;
71
71
  }
72
+ function tailLines(text, maxLines) {
73
+ const lines = text
74
+ .split(/\r?\n/)
75
+ .map((line) => line.trimEnd())
76
+ .filter((line) => line.length > 0);
77
+ return lines.slice(-maxLines).join('\n');
78
+ }
79
+ function extractLastMakeError(captured) {
80
+ const lines = captured.split(/\r?\n/).filter((line) => /\bmake(?:\[\d+\])?: \*\*\*/.test(line));
81
+ return lines.at(-1)?.trim();
82
+ }
83
+ function extractLikelyFailingCommand(captured) {
84
+ const lines = captured
85
+ .split(/\r?\n/)
86
+ .map((line) => line.trim())
87
+ .filter((line) => line.length > 0);
88
+ for (let index = lines.length - 1; index >= 0; index--) {
89
+ const line = lines[index];
90
+ if (!line)
91
+ continue;
92
+ if (/^make(?:\[\d+\])?:/.test(line))
93
+ continue;
94
+ if (/^g?make(?:\[\d+\])?:/.test(line))
95
+ continue;
96
+ if (/^Error running mach:/.test(line))
97
+ continue;
98
+ if (/^\d+:\d+\.\d+\s+/.test(line))
99
+ continue;
100
+ if (/\b(?:cp|clang|clang\+\+|rustc|python|node|make|install_name_tool)\b/.test(line)) {
101
+ return line;
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+ function buildFailureDiagnostics(result, engineDir, objDir, machCommand) {
107
+ const captured = `${result.stderr}\n${result.stdout}`;
108
+ const stderrTail = tailLines(result.stderr, 20);
109
+ const combinedTail = tailLines(captured, 30);
110
+ const makeError = extractLastMakeError(captured);
111
+ const failingCommand = extractLikelyFailingCommand(captured);
112
+ const logHint = objDir
113
+ ? `engine/${objDir}/ (inspect build logs, warnings, and generated make targets under this objdir)`
114
+ : 'engine/obj-* (inspect the active objdir for build logs, warnings, and generated make targets)';
115
+ const verboseRerun = objDir
116
+ ? `cd ${engineDir} && ./mach build -v; if a make target is named above, retry it with: make -C ${objDir} <target> V=1`
117
+ : `cd ${engineDir} && ./mach build -v`;
118
+ return [
119
+ `Build failed with exit code ${result.exitCode}.`,
120
+ `Mach phase: ${machCommand}`,
121
+ makeError ? `Last make error: ${makeError}` : undefined,
122
+ failingCommand ? `Final failing command/error line: ${failingCommand}` : undefined,
123
+ stderrTail ? `Captured stderr tail:\n${stderrTail}` : undefined,
124
+ `Captured output tail:\n${combinedTail}`,
125
+ `Logs/profile/warnings: ${logHint}`,
126
+ `Verbose rerun: ${verboseRerun}`,
127
+ ]
128
+ .filter((part) => part !== undefined && part.length > 0)
129
+ .join('\n\n');
130
+ }
72
131
  /**
73
132
  * Runs the build command.
74
133
  * @param projectRoot - Root directory of the project
@@ -172,7 +231,8 @@ export async function buildCommand(projectRoot, options) {
172
231
  const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
173
232
  if (result.exitCode !== 0) {
174
233
  error(`Build failed after ${timeStr}`);
175
- throw new BuildError(`Build failed with exit code ${result.exitCode}`, options.ui ? 'mach build faster' : 'mach build');
234
+ const machCommand = options.ui ? 'mach build faster' : 'mach build';
235
+ throw new BuildError(buildFailureDiagnostics(result, paths.engine, buildCheck.objDir, machCommand), machCommand);
176
236
  }
177
237
  // Tool-managed branding edits that land on `browser/moz.configure`
178
238
  // before the build cause mach's post-build guard to print one of two
@@ -20,6 +20,7 @@ function summarizeWorkingTreeChangeCount(changeCount) {
20
20
  function formatManagedDetail(counts) {
21
21
  return [
22
22
  counts.patchBacked > 0 ? `${counts.patchBacked} patch-backed` : null,
23
+ counts.patchOwnedDrift > 0 ? `${counts.patchOwnedDrift} patch-owned drift` : null,
23
24
  counts.branding > 0 ? `${counts.branding} branding` : null,
24
25
  counts.furnace > 0 ? `${counts.furnace} furnace` : null,
25
26
  ]
@@ -57,6 +58,7 @@ export async function inspectEngineWorkingTree(ctx) {
57
58
  branding: 0,
58
59
  furnace: 0,
59
60
  patchBacked: 0,
61
+ patchOwnedDrift: 0,
60
62
  conflict: 0,
61
63
  unmanaged: 0,
62
64
  };
@@ -67,6 +69,8 @@ export async function inspectEngineWorkingTree(ctx) {
67
69
  counts.furnace++;
68
70
  else if (entry.classification === 'patch-backed')
69
71
  counts.patchBacked++;
72
+ else if (entry.classification === 'patch-owned-drift')
73
+ counts.patchOwnedDrift++;
70
74
  else if (entry.classification === 'conflict')
71
75
  counts.conflict++;
72
76
  else
@@ -75,7 +79,7 @@ export async function inspectEngineWorkingTree(ctx) {
75
79
  if (counts.conflict > 0) {
76
80
  return warning('Engine working tree', `Engine working tree has ${counts.conflict} cross-patch ownership conflict${counts.conflict === 1 ? '' : 's'}. Multiple patches in patches.json claim the same file.`, 'Run "fireforge status --ownership" to see the conflicting patches, then run "fireforge verify" and resolve the overlap.');
77
81
  }
78
- const managedTotal = counts.branding + counts.furnace + counts.patchBacked;
82
+ const managedTotal = counts.branding + counts.furnace + counts.patchBacked + counts.patchOwnedDrift;
79
83
  if (counts.unmanaged === 0) {
80
84
  const managedDetail = formatManagedDetail(counts);
81
85
  return {
@@ -1,4 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { randomUUID } from 'node:crypto';
3
+ import { rename } from 'node:fs/promises';
2
4
  import { join } from 'node:path';
3
5
  import { getProjectPaths, loadConfig, updateState } from '../core/config.js';
4
6
  import { withFileLock } from '../core/file-lock.js';
@@ -111,6 +113,148 @@ function closeRestoreSpinner(restoreSpinner, result) {
111
113
  }
112
114
  restoreSpinner.stop('Patch-touched files restored');
113
115
  }
116
+ async function clearStaleFurnaceApplyState(projectRoot) {
117
+ // --force installs a new baseCommit, which invalidates every applied
118
+ // checksum in furnace-state.json. Preserve pendingRepair: authoring-side
119
+ // rollback markers describe unresolved component workspace state and
120
+ // should survive an engine refresh.
121
+ const furnacePaths = getFurnacePaths(projectRoot);
122
+ if (await pathExists(furnacePaths.furnaceState)) {
123
+ await updateFurnaceState(projectRoot, (current) => ({
124
+ ...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
125
+ }));
126
+ }
127
+ }
128
+ async function activateReplacementEngine(args) {
129
+ const { engineDir, replacementDir, backupDir } = args;
130
+ await rename(engineDir, backupDir);
131
+ try {
132
+ await rename(replacementDir, engineDir);
133
+ }
134
+ catch (error) {
135
+ try {
136
+ await rename(backupDir, engineDir);
137
+ }
138
+ catch (restoreError) {
139
+ const cause = toError(restoreError);
140
+ warn(`Could not restore previous engine after replacement activation failed. Previous engine backup remains at ${backupDir}. Remove ${engineDir} if it exists, then move the backup back to engine/.`);
141
+ verbose(`Engine restore failure detail: ${cause.message}`);
142
+ if (cause.stack) {
143
+ verbose(cause.stack);
144
+ }
145
+ }
146
+ throw error;
147
+ }
148
+ }
149
+ async function restorePreviousEngine(args) {
150
+ const { engineDir, backupDir, reason } = args;
151
+ const cause = toError(reason);
152
+ verbose(`Restoring previous engine after failed forced download: ${cause.message}`);
153
+ try {
154
+ await removeDir(engineDir);
155
+ await rename(backupDir, engineDir);
156
+ warn('Restored the previous engine/ after the forced replacement failed.');
157
+ }
158
+ catch (restoreError) {
159
+ const restoreCause = toError(restoreError);
160
+ warn(`Could not restore the previous engine automatically. Previous engine backup remains at ${backupDir}. Remove the failed engine/ and move that backup back to engine/ before retrying.`);
161
+ verbose(`Engine restore failure detail: ${restoreCause.message}`);
162
+ if (restoreCause.stack) {
163
+ verbose(restoreCause.stack);
164
+ }
165
+ }
166
+ }
167
+ async function downloadAndExtractFirefox(args) {
168
+ const { version, product, engineDir, cacheDir, sha256 } = args;
169
+ let s = spinner(`Downloading Firefox ${version}...`);
170
+ let lastPercent = 0;
171
+ const phaseState = { value: 'download' };
172
+ try {
173
+ await downloadFirefoxSource(version, product, engineDir, cacheDir, (downloaded, total) => {
174
+ if (total <= 0)
175
+ return;
176
+ const percent = Math.floor((downloaded / total) * 100);
177
+ if (percent !== lastPercent && percent % 5 === 0) {
178
+ s.message(`Downloading Firefox ${version}... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`);
179
+ lastPercent = percent;
180
+ }
181
+ }, (phase) => {
182
+ if (phase === 'extract' && phaseState.value === 'download') {
183
+ s.stop(`Firefox ${version} downloaded`);
184
+ phaseState.value = 'extract';
185
+ s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
186
+ }
187
+ }, sha256, (message) => {
188
+ s.message(message);
189
+ });
190
+ s.stop(phaseState.value === 'extract'
191
+ ? `Firefox ${version} extracted`
192
+ : `Firefox ${version} downloaded`);
193
+ }
194
+ catch (error) {
195
+ s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
196
+ throw error;
197
+ }
198
+ }
199
+ async function initializeDownloadedEngine(args) {
200
+ const { projectRoot, patchesDir, version, engineDir, replacementActivated, backupEngineDir } = args;
201
+ // Finding #17: the git indexing phase of `download` can block for
202
+ // minutes on a ~600 MB Firefox tree. Emit a one-line heads-up banner
203
+ // before the spinner starts so CI logs show the expected duration.
204
+ try {
205
+ info('Indexing downloaded source into git (one-time; typically 3–5 minutes on a ~600 MB Firefox tree)...');
206
+ info('Git phase: initializing/resetting source repository metadata.');
207
+ const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
208
+ let baseCommit;
209
+ try {
210
+ await initRepository(engineDir, 'firefox', {
211
+ onProgress: (message) => {
212
+ gitSpinner.message(message);
213
+ },
214
+ });
215
+ baseCommit = await getHead(engineDir);
216
+ gitSpinner.stop('Git repository initialized');
217
+ }
218
+ catch (error) {
219
+ gitSpinner.error('Failed to initialize git repository');
220
+ warn(replacementActivated
221
+ ? 'Replacement engine/ failed during baseline git initialization. FireForge will try to restore the previous engine.'
222
+ : 'engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
223
+ throw error;
224
+ }
225
+ const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
226
+ try {
227
+ const restoreResult = await cleanPatchTouchedFiles(engineDir, patchesDir);
228
+ closeRestoreSpinner(restoreSpinner, restoreResult);
229
+ }
230
+ catch (error) {
231
+ restoreSpinner.error('Failed to restore patch-touched files');
232
+ throw error;
233
+ }
234
+ if (replacementActivated) {
235
+ await clearStaleFurnaceApplyState(projectRoot);
236
+ }
237
+ await updateState(projectRoot, {
238
+ downloadedVersion: version,
239
+ baseCommit,
240
+ });
241
+ await noteUnappliedPatches(patchesDir);
242
+ if (backupEngineDir) {
243
+ await removeDir(backupEngineDir);
244
+ }
245
+ outro(`Firefox ${version} is ready!`);
246
+ }
247
+ catch (error) {
248
+ if (replacementActivated && backupEngineDir) {
249
+ await restorePreviousEngine({
250
+ engineDir,
251
+ backupDir: backupEngineDir,
252
+ reason: error,
253
+ });
254
+ }
255
+ throw error;
256
+ }
257
+ }
114
258
  /**
115
259
  * Runs the download command.
116
260
  * @param projectRoot - Root directory of the project
@@ -118,14 +262,15 @@ function closeRestoreSpinner(restoreSpinner, result) {
118
262
  */
119
263
  export async function downloadCommand(projectRoot, options) {
120
264
  intro('FireForge Download');
121
- // Load configuration
122
- const config = await loadConfig(projectRoot);
265
+ const config = await loadConfig(projectRoot), version = config.firefox.version;
123
266
  const paths = getProjectPaths(projectRoot);
124
- const version = config.firefox.version;
125
267
  info(`Firefox version: ${version}`);
126
- // Disk space pre-flight: Firefox source is ~5 GB
127
268
  await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
128
269
  await withFileLock(join(paths.fireforgeDir, 'download.fireforge.lock'), async () => {
270
+ let installEngineDir = paths.engine;
271
+ let replacementEngineDir;
272
+ let backupEngineDir;
273
+ let replacementActivated = false;
129
274
  // Check if engine already exists
130
275
  if (await pathExistsStrict(paths.engine)) {
131
276
  if (!options.force) {
@@ -206,126 +351,47 @@ export async function downloadCommand(projectRoot, options) {
206
351
  }
207
352
  throw new EngineExistsError(paths.engine);
208
353
  }
209
- warn('Removing existing engine directory...');
210
- await removeDir(paths.engine);
211
- // --force installs a new baseCommit, which invalidates every applied
212
- // checksum in furnace-state.json. Clearing the state now prevents a
213
- // subsequent `furnace apply` from reporting "up to date" against an
214
- // engine that no longer contains any of the deployed files. Preserve
215
- // pendingRepair: authoring-side rollback markers describe unresolved
216
- // component workspace state and should survive an engine refresh.
217
- const furnacePaths = getFurnacePaths(projectRoot);
218
- if (await pathExists(furnacePaths.furnaceState)) {
219
- await updateFurnaceState(projectRoot, (current) => ({
220
- ...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
221
- }));
222
- }
354
+ replacementEngineDir = `${paths.engine}.replacement-${randomUUID()}`;
355
+ backupEngineDir = `${paths.engine}.backup-${randomUUID()}`;
356
+ installEngineDir = replacementEngineDir;
357
+ warn('Preparing replacement engine directory; existing engine/ will remain in place until the new archive downloads, validates, and extracts.');
223
358
  }
224
359
  // Ensure cache directory exists
225
360
  const cacheDir = join(paths.fireforgeDir, 'cache');
226
361
  await ensureDir(cacheDir);
227
- // Phase-switched spinners: the download phase runs with the byte-count
228
- // progress callbacks below; the extract phase is blocking tar-xz and
229
- // has no incremental progress, but it can take 30–90s on a ~600 MB
230
- // Firefox tree, so it gets its own spinner message. Before the phase
231
- // split, a single "Downloading Firefox … 100%" spinner covered both
232
- // — the first-run setup looked hung precisely when the archive had
233
- // already reached disk and `tar` was the long pole.
234
- let s = spinner(`Downloading Firefox ${version}...`);
235
- let lastPercent = 0;
236
- const phaseState = { value: 'download' };
237
362
  try {
238
- await downloadFirefoxSource(version, config.firefox.product, paths.engine, cacheDir, (downloaded, total) => {
239
- if (total <= 0)
240
- return;
241
- const percent = Math.floor((downloaded / total) * 100);
242
- if (percent !== lastPercent && percent % 5 === 0) {
243
- s.message(`Downloading Firefox ${version}... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`);
244
- lastPercent = percent;
245
- }
246
- }, (phase) => {
247
- if (phase === 'extract' && phaseState.value === 'download') {
248
- s.stop(`Firefox ${version} downloaded`);
249
- phaseState.value = 'extract';
250
- s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
251
- }
252
- }, config.firefox.sha256, (message) => {
253
- if (phaseState.value === 'extract')
254
- s.message(message);
363
+ await downloadAndExtractFirefox({
364
+ version,
365
+ product: config.firefox.product,
366
+ engineDir: installEngineDir,
367
+ cacheDir,
368
+ ...(config.firefox.sha256 !== undefined ? { sha256: config.firefox.sha256 } : {}),
255
369
  });
256
- if (phaseState.value === 'extract') {
257
- s.stop(`Firefox ${version} extracted`);
258
- }
259
- else {
260
- s.stop(`Firefox ${version} downloaded`);
370
+ if (replacementEngineDir && backupEngineDir) {
371
+ warn('Activating replacement engine directory...');
372
+ await activateReplacementEngine({
373
+ engineDir: paths.engine,
374
+ replacementDir: replacementEngineDir,
375
+ backupDir: backupEngineDir,
376
+ });
377
+ replacementActivated = true;
378
+ installEngineDir = paths.engine;
261
379
  }
262
380
  }
263
381
  catch (error) {
264
- s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
265
- throw error;
266
- }
267
- // Finding #17: the git indexing phase of `download` can block for
268
- // minutes on a ~600 MB Firefox tree — the spinner updates less often
269
- // than operators expect during the monolithic `git add -A` pass, and
270
- // non-TTY shells see long stretches of silence. Emit a one-line
271
- // heads-up banner BEFORE the spinner starts so even a log-scraping
272
- // CI job notes the expected duration. The progress callbacks below
273
- // still fire as usual; this is an additional up-front signal, not a
274
- // replacement.
275
- info('Indexing downloaded source into git (one-time; typically 3–5 minutes on a ~600 MB Firefox tree)...');
276
- // Initialize git repository
277
- const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
278
- let baseCommit;
279
- try {
280
- await initRepository(paths.engine, 'firefox', {
281
- // Same one-authority rule as the resume path above: the non-TTY
282
- // spinner fallback already emits `step(msg)` internally, so
283
- // calling `step()` in addition to `.message()` duplicated every
284
- // git-init progress line in CI logs.
285
- onProgress: (message) => {
286
- gitSpinner.message(message);
287
- },
288
- });
289
- baseCommit = await getHead(paths.engine);
290
- gitSpinner.stop('Git repository initialized');
291
- }
292
- catch (error) {
293
- gitSpinner.error('Failed to initialize git repository');
294
- warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
295
- throw error;
296
- }
297
- // Restore any patch-touched files that ended up dirty after the initial
298
- // commit (e.g. line-ending normalisation or extraction artefacts) so that
299
- // a subsequent `fireforge import` works without --force.
300
- //
301
- // Wrapped in a dedicated spinner because the restore can itself take
302
- // tens of seconds on a ~600 MB Firefox tree: it walks every file in the
303
- // patch manifest, calls `git status` / `git checkout` for each, and the
304
- // eval's "download looks hung" report landed at least partly on this
305
- // post-commit window. An operator watching the CLI needs to see that
306
- // this phase is distinct from the preceding git-add work.
307
- //
308
- // This runs BEFORE updateState so a restore failure keeps the previous
309
- // downloadedVersion in state.json. The invariant we preserve is
310
- // "state.downloadedVersion matches a clean engine": stamping the new
311
- // version only after the restore succeeds means a failed clean-up will
312
- // re-enter the resume path on the next `fireforge download` rather than
313
- // reporting success against a dirty engine.
314
- const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
315
- try {
316
- const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
317
- closeRestoreSpinner(restoreSpinner, restoreResult);
318
- }
319
- catch (error) {
320
- restoreSpinner.error('Failed to restore patch-touched files');
382
+ if (replacementEngineDir) {
383
+ await removeDir(replacementEngineDir);
384
+ }
321
385
  throw error;
322
386
  }
323
- await updateState(projectRoot, {
324
- downloadedVersion: version,
325
- baseCommit,
387
+ await initializeDownloadedEngine({
388
+ projectRoot,
389
+ patchesDir: paths.patches,
390
+ version,
391
+ engineDir: installEngineDir,
392
+ replacementActivated,
393
+ ...(backupEngineDir !== undefined ? { backupEngineDir } : {}),
326
394
  });
327
- await noteUnappliedPatches(paths.patches);
328
- outro(`Firefox ${version} is ready!`);
329
395
  });
330
396
  }
331
397
  /** Registers the download command on the CLI program. */
@@ -8,6 +8,7 @@ import { extractAffectedFiles } from '../core/patch-apply.js';
8
8
  import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
9
9
  import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
10
10
  import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
11
+ import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
11
12
  import { GeneralError } from '../errors/base.js';
12
13
  import { ensureDir, pathExists } from '../utils/fs.js';
13
14
  import { info, intro, outro, spinner } from '../utils/logger.js';
@@ -262,7 +263,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
262
263
  name: patchName,
263
264
  description,
264
265
  filesAffected,
265
- sourceEsrVersion: config.firefox.version,
266
+ ...buildPatchSourceMetadata(config.firefox),
266
267
  explicitSupersede: options.supersede === true,
267
268
  allowOverlap: options.allowOverlap === true,
268
269
  config,
@@ -299,7 +300,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
299
300
  description,
300
301
  diff,
301
302
  filesAffected,
302
- sourceEsrVersion: config.firefox.version,
303
+ ...buildPatchSourceMetadata(config.firefox),
303
304
  config,
304
305
  policyCommand: 'export-all',
305
306
  forceUnsafe: options.forceUnsafe === true,
@@ -93,6 +93,8 @@ export interface DryRunPreviewInput {
93
93
  description: string;
94
94
  filesAffected: string[];
95
95
  sourceEsrVersion: string;
96
+ sourceProduct?: FireForgeConfig['firefox']['product'];
97
+ sourceVersion?: string;
96
98
  explicitSupersede: boolean;
97
99
  allowOverlap: boolean;
98
100
  /** Optional `PatchMetadata.tier` opt-in carried from the CLI. */
@@ -354,6 +354,8 @@ export async function renderDryRunPreview(input) {
354
354
  description: input.description,
355
355
  filesAffected: input.filesAffected,
356
356
  sourceEsrVersion: input.sourceEsrVersion,
357
+ ...(input.sourceProduct !== undefined ? { sourceProduct: input.sourceProduct } : {}),
358
+ ...(input.sourceVersion !== undefined ? { sourceVersion: input.sourceVersion } : {}),
357
359
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
358
360
  ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
359
361
  ...(input.config !== undefined ? { config: input.config } : {}),
@@ -13,6 +13,7 @@ import { extractAffectedFiles } from '../core/patch-apply.js';
13
13
  import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
14
14
  import { loadPatchesManifest } from '../core/patch-manifest.js';
15
15
  import { applyRenameMapToManifest, buildProjectedManifest, enforcePatchPolicy, } from '../core/patch-policy.js';
16
+ import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
16
17
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
17
18
  import { toError } from '../utils/errors.js';
18
19
  import { ensureDir, pathExists } from '../utils/fs.js';
@@ -211,7 +212,7 @@ export async function exportCommand(projectRoot, files, options) {
211
212
  name: patchName,
212
213
  description,
213
214
  createdAt: new Date().toISOString(),
214
- sourceEsrVersion: config.firefox.version,
215
+ ...buildPatchSourceMetadata(config.firefox),
215
216
  filesAffected,
216
217
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
217
218
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
@@ -263,7 +264,7 @@ export async function exportCommand(projectRoot, files, options) {
263
264
  name: patchName,
264
265
  description,
265
266
  filesAffected,
266
- sourceEsrVersion: config.firefox.version,
267
+ ...buildPatchSourceMetadata(config.firefox),
267
268
  explicitSupersede: options.supersede === true,
268
269
  allowOverlap: options.allowOverlap === true,
269
270
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
@@ -288,7 +289,7 @@ export async function exportCommand(projectRoot, files, options) {
288
289
  name: patchName,
289
290
  description,
290
291
  createdAt: new Date().toISOString(),
291
- sourceEsrVersion: config.firefox.version,
292
+ ...buildPatchSourceMetadata(config.firefox),
292
293
  filesAffected,
293
294
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
294
295
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
@@ -366,7 +367,7 @@ export async function exportCommand(projectRoot, files, options) {
366
367
  description,
367
368
  diff,
368
369
  filesAffected,
369
- sourceEsrVersion: config.firefox.version,
370
+ ...buildPatchSourceMetadata(config.firefox),
370
371
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
371
372
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
372
373
  ? { lintIgnore: options.lintIgnore }
@@ -6,6 +6,7 @@ import { getHead } from '../core/git.js';
6
6
  import { getDirtyFiles } from '../core/git-status.js';
7
7
  import { applyPatchesWithContinue, computePatchedContent, countPatches, discoverPatches, extractAffectedFiles, PatchError, } from '../core/patch-apply.js';
8
8
  import { checkVersionCompatibility, loadPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
9
+ import { getPatchSourceVersion } from '../core/patch-source-metadata.js';
9
10
  import { GeneralError } from '../errors/base.js';
10
11
  import { toError } from '../utils/errors.js';
11
12
  import { pathExists, readText } from '../utils/fs.js';
@@ -269,7 +270,7 @@ export async function importCommand(projectRoot, options = {}) {
269
270
  // doesn't need to see version warnings for patches outside the range.
270
271
  if (options.until !== undefined && !untilFilenameSet.has(patch.filename))
271
272
  continue;
272
- const warning = checkVersionCompatibility(patch.sourceEsrVersion, currentVersion);
273
+ const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
273
274
  if (warning) {
274
275
  warn(`${patch.filename}: ${warning}`);
275
276
  }