@hominis/fireforge 0.27.0 → 0.27.2

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 (71) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +6 -6
  3. package/dist/src/cli.js +5 -1
  4. package/dist/src/commands/build.js +61 -1
  5. package/dist/src/commands/doctor/post-rebase-audit.d.ts +2 -0
  6. package/dist/src/commands/doctor/post-rebase-audit.js +86 -0
  7. package/dist/src/commands/doctor-working-tree.js +5 -1
  8. package/dist/src/commands/doctor.js +3 -0
  9. package/dist/src/commands/download.js +41 -42
  10. package/dist/src/commands/export-all.js +3 -2
  11. package/dist/src/commands/export-flow.d.ts +2 -0
  12. package/dist/src/commands/export-flow.js +2 -0
  13. package/dist/src/commands/export.js +5 -4
  14. package/dist/src/commands/import.js +2 -1
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/re-export.js +10 -8
  17. package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
  18. package/dist/src/commands/rebase/conflict-summary.js +38 -0
  19. package/dist/src/commands/rebase/continue.js +2 -0
  20. package/dist/src/commands/rebase/index.d.ts +2 -2
  21. package/dist/src/commands/rebase/index.js +9 -4
  22. package/dist/src/commands/rebase/patch-loop.js +29 -11
  23. package/dist/src/commands/rebase/summary.js +7 -2
  24. package/dist/src/commands/resolve.js +2 -1
  25. package/dist/src/commands/setup-support.js +6 -2
  26. package/dist/src/commands/setup.js +1 -0
  27. package/dist/src/commands/source.d.ts +9 -0
  28. package/dist/src/commands/source.js +92 -0
  29. package/dist/src/commands/status-output.d.ts +13 -0
  30. package/dist/src/commands/status-output.js +186 -0
  31. package/dist/src/commands/status.js +4 -247
  32. package/dist/src/commands/verify.js +32 -16
  33. package/dist/src/core/build-prepare.js +12 -4
  34. package/dist/src/core/config-validate.js +1 -1
  35. package/dist/src/core/firefox-cache.d.ts +1 -1
  36. package/dist/src/core/firefox-cache.js +10 -3
  37. package/dist/src/core/firefox-extract.d.ts +1 -1
  38. package/dist/src/core/firefox-extract.js +13 -1
  39. package/dist/src/core/firefox.d.ts +2 -1
  40. package/dist/src/core/firefox.js +3 -3
  41. package/dist/src/core/furnace-registration-validate.d.ts +7 -0
  42. package/dist/src/core/furnace-registration-validate.js +29 -12
  43. package/dist/src/core/furnace-validate-registration.js +5 -37
  44. package/dist/src/core/git.js +25 -5
  45. package/dist/src/core/ownership-table.d.ts +3 -1
  46. package/dist/src/core/ownership-table.js +31 -7
  47. package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
  48. package/dist/src/core/patch-artifact-normalize.js +13 -0
  49. package/dist/src/core/patch-export-update.js +2 -1
  50. package/dist/src/core/patch-export.d.ts +4 -0
  51. package/dist/src/core/patch-export.js +7 -2
  52. package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
  53. package/dist/src/core/patch-manifest-consistency.js +4 -2
  54. package/dist/src/core/patch-manifest-query.d.ts +4 -3
  55. package/dist/src/core/patch-manifest-query.js +12 -4
  56. package/dist/src/core/patch-manifest-validate.js +22 -4
  57. package/dist/src/core/patch-source-metadata.d.ts +8 -0
  58. package/dist/src/core/patch-source-metadata.js +17 -0
  59. package/dist/src/core/rebase-session.d.ts +8 -3
  60. package/dist/src/core/rebase-session.js +1 -1
  61. package/dist/src/core/status-classify.d.ts +4 -1
  62. package/dist/src/core/status-classify.js +4 -5
  63. package/dist/src/types/commands/index.d.ts +1 -1
  64. package/dist/src/types/commands/options.d.ts +16 -1
  65. package/dist/src/types/commands/patches.d.ts +9 -1
  66. package/dist/src/types/config.d.ts +1 -1
  67. package/dist/src/utils/elapsed.d.ts +4 -0
  68. package/dist/src/utils/elapsed.js +15 -0
  69. package/dist/src/utils/validation.d.ts +2 -2
  70. package/dist/src/utils/validation.js +5 -5
  71. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  ## 0.27.0
4
4
 
5
+ - Added first-class `firefox-devedition` source support and atomic `fireforge source set`.
6
+ - Fixed `source set --version` so the subcommand accepts both space and equals forms without colliding with the root CLI version flag.
7
+ - Added `sourceProduct` and `sourceVersion` patch metadata while preserving `sourceEsrVersion` as a deprecated compatibility alias.
8
+ - Renamed source-rebase reporting away from ESR-only wording and clarified summaries with total patch counts.
9
+ - Unified status, ownership, doctor, and verify worktree classification, including an explained patch-owned drift state for manually resolved or re-exported files.
10
+ - Hardened build diagnostics so backend regeneration success/failure and failed make/mach commands include exit codes, tails, log hints, and verbose rerun suggestions.
5
11
  - Improved `download --force` git indexing progress with phase, count, and heartbeat output.
12
+ - Added cache metadata progress for archive validation, SHA-256 calculation, and sidecar JSON writes.
13
+ - Added elapsed progress for extraction, initial source commits, and rebase/re-export patch refreshes.
6
14
  - Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
7
15
  - Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
8
16
  - Preserved patch-owned branding `configure.sh` settings during build preflight.
9
- - Added custom element registration support for Furnace validate/apply.
17
+ - Added custom element registration support for Furnace validate/apply and Firefox 152-style array-backed ESM registrations.
18
+ - Normalized generated patch artifacts so blank context lines do not trip raw whitespace checks.
19
+ - Improved rebase conflict summaries and added `doctor --post-rebase-audit` for common registration surfaces.
10
20
 
11
21
  ## 0.26.0
12
22
 
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,12 +63,12 @@ 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
- npx fireforge config firefox.version 145.0.0esr
71
+ npx fireforge source set --version 145.0.0esr --product firefox-esr --sha256 <archive-sha256>
72
72
  npx fireforge download --force
73
73
  npx fireforge rebase
74
74
  ```
@@ -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
@@ -0,0 +1,2 @@
1
+ import type { DoctorCheckDefinition } from '../doctor-check-core.js';
2
+ export declare const POST_REBASE_AUDIT_CHECK: DoctorCheckDefinition;
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { pathExists, readText } from '../../utils/fs.js';
5
+ import { ok, warning } from '../doctor-check-core.js';
6
+ async function readEngineText(engineDir, relativePath) {
7
+ const fullPath = join(engineDir, relativePath);
8
+ if (!(await pathExists(fullPath)))
9
+ return null;
10
+ return readText(fullPath);
11
+ }
12
+ async function collectBrowserTomlFiles(root) {
13
+ const testRoot = join(root, 'browser/base/content/test');
14
+ if (!(await pathExists(testRoot)))
15
+ return [];
16
+ const result = [];
17
+ async function walk(absDir, relDir) {
18
+ let entries;
19
+ try {
20
+ entries = await readdir(absDir, { withFileTypes: true });
21
+ }
22
+ catch {
23
+ return;
24
+ }
25
+ for (const entry of entries) {
26
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
27
+ const absPath = join(absDir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ await walk(absPath, relPath);
30
+ }
31
+ else if (entry.isFile() && entry.name === 'browser.toml') {
32
+ result.push(`browser/base/content/test/${relPath}`);
33
+ }
34
+ }
35
+ }
36
+ await walk(testRoot, '');
37
+ return result.sort();
38
+ }
39
+ async function runPostRebaseAudit(ctx) {
40
+ const issues = [];
41
+ const engineDir = ctx.paths.engine;
42
+ const mozConfigure = await readEngineText(engineDir, 'browser/moz.configure');
43
+ if (mozConfigure === null) {
44
+ issues.push('browser/moz.configure is missing');
45
+ }
46
+ else if (!mozConfigure.includes('BROWSER_CHROME_URL')) {
47
+ issues.push('browser/moz.configure does not mention BROWSER_CHROME_URL');
48
+ }
49
+ const browserJar = await readEngineText(engineDir, 'browser/base/jar.mn');
50
+ if (browserJar === null) {
51
+ issues.push('browser/base/jar.mn is missing');
52
+ }
53
+ else if (!/\.xhtml\b/.test(browserJar)) {
54
+ issues.push('browser/base/jar.mn has no chrome document .xhtml entries');
55
+ }
56
+ const customElements = await readEngineText(engineDir, 'toolkit/content/customElements.js');
57
+ if (customElements === null) {
58
+ issues.push('toolkit/content/customElements.js is missing');
59
+ }
60
+ else if (!customElements.includes('customElements')) {
61
+ issues.push('toolkit/content/customElements.js does not contain customElements registrations');
62
+ }
63
+ const toolkitJar = await readEngineText(engineDir, 'toolkit/content/jar.mn');
64
+ if (toolkitJar === null) {
65
+ issues.push('toolkit/content/jar.mn is missing');
66
+ }
67
+ else if (!toolkitJar.includes('content/global/widgets/') &&
68
+ !toolkitJar.includes('content/global/elements/')) {
69
+ issues.push('toolkit/content/jar.mn has no widget/element exposure entries');
70
+ }
71
+ const browserTomls = await collectBrowserTomlFiles(engineDir);
72
+ if (browserTomls.length === 0) {
73
+ issues.push('no browser.toml files found under browser/base/content/test');
74
+ }
75
+ if (issues.length === 0) {
76
+ return ok('Post-rebase registration audit');
77
+ }
78
+ return warning('Post-rebase registration audit', `${issues.length} suspicious registration surface${issues.length === 1 ? '' : 's'}: ${issues.join('; ')}.`, 'Inspect the named engine paths, refresh any drifted registration patches, then re-run "fireforge doctor --post-rebase-audit".');
79
+ }
80
+ export const POST_REBASE_AUDIT_CHECK = {
81
+ name: 'Post-rebase registration audit',
82
+ skipIf: (ctx) => !ctx.options.postRebaseAudit || !ctx.engineExists,
83
+ dependsOn: ['fireforge.json is valid'],
84
+ run: runPostRebaseAudit,
85
+ };
86
+ //# sourceMappingURL=post-rebase-audit.js.map
@@ -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 {
@@ -10,6 +10,7 @@ import { toError } from '../utils/errors.js';
10
10
  import { pathExists } from '../utils/fs.js';
11
11
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
12
12
  import { findExecutable } from '../utils/process.js';
13
+ import { POST_REBASE_AUDIT_CHECK } from './doctor/post-rebase-audit.js';
13
14
  import { failure, ok, warning } from './doctor-check-core.js';
14
15
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
15
16
  import { inspectEngineWorkingTree } from './doctor-working-tree.js';
@@ -358,6 +359,7 @@ const DOCTOR_CHECKS = [
358
359
  },
359
360
  fix: 'Re-export affected files with "fireforge export <paths...>" to create full-file patches',
360
361
  },
362
+ POST_REBASE_AUDIT_CHECK,
361
363
  // Furnace checks live in a sibling module so this file stays under the
362
364
  // max-lines threshold. Splicing them in as an array preserves the
363
365
  // declarative registry contract — each entry remains a single
@@ -466,6 +468,7 @@ export function registerDoctor(program, { getProjectRoot, withErrorHandling }) {
466
468
  .option('--repair-patches-manifest', 'Rebuild patches/patches.json from the current patch files before reporting results')
467
469
  .option('--repair-furnace', 'Reconcile furnace state: clear stale furnace-state.json entries, re-run furnace apply to fix engine drift, and clear the pending-repair marker set by a failed preview teardown')
468
470
  .option('--clear-resolution', 'Clear stale pendingResolution state after the patch queue health check reports no errors')
471
+ .option('--post-rebase-audit', 'Check common registration surfaces after a Firefox source rebase')
469
472
  .action(withErrorHandling(async (options) => {
470
473
  const result = await doctorCommand(getProjectRoot(), options);
471
474
  if (result.exitCode !== 0) {
@@ -111,6 +111,38 @@ function closeRestoreSpinner(restoreSpinner, result) {
111
111
  }
112
112
  restoreSpinner.stop('Patch-touched files restored');
113
113
  }
114
+ async function downloadAndExtractFirefox(args) {
115
+ const { version, product, engineDir, cacheDir, sha256 } = args;
116
+ let s = spinner(`Downloading Firefox ${version}...`);
117
+ let lastPercent = 0;
118
+ const phaseState = { value: 'download' };
119
+ try {
120
+ await downloadFirefoxSource(version, product, engineDir, cacheDir, (downloaded, total) => {
121
+ if (total <= 0)
122
+ return;
123
+ const percent = Math.floor((downloaded / total) * 100);
124
+ if (percent !== lastPercent && percent % 5 === 0) {
125
+ s.message(`Downloading Firefox ${version}... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`);
126
+ lastPercent = percent;
127
+ }
128
+ }, (phase) => {
129
+ if (phase === 'extract' && phaseState.value === 'download') {
130
+ s.stop(`Firefox ${version} downloaded`);
131
+ phaseState.value = 'extract';
132
+ s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
133
+ }
134
+ }, sha256, (message) => {
135
+ s.message(message);
136
+ });
137
+ s.stop(phaseState.value === 'extract'
138
+ ? `Firefox ${version} extracted`
139
+ : `Firefox ${version} downloaded`);
140
+ }
141
+ catch (error) {
142
+ s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
143
+ throw error;
144
+ }
145
+ }
114
146
  /**
115
147
  * Runs the download command.
116
148
  * @param projectRoot - Root directory of the project
@@ -118,12 +150,9 @@ function closeRestoreSpinner(restoreSpinner, result) {
118
150
  */
119
151
  export async function downloadCommand(projectRoot, options) {
120
152
  intro('FireForge Download');
121
- // Load configuration
122
- const config = await loadConfig(projectRoot);
153
+ const config = await loadConfig(projectRoot), version = config.firefox.version;
123
154
  const paths = getProjectPaths(projectRoot);
124
- const version = config.firefox.version;
125
155
  info(`Firefox version: ${version}`);
126
- // Disk space pre-flight: Firefox source is ~5 GB
127
156
  await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
128
157
  await withFileLock(join(paths.fireforgeDir, 'download.fireforge.lock'), async () => {
129
158
  // Check if engine already exists
@@ -224,43 +253,13 @@ export async function downloadCommand(projectRoot, options) {
224
253
  // Ensure cache directory exists
225
254
  const cacheDir = join(paths.fireforgeDir, 'cache');
226
255
  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
- 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);
253
- if (phaseState.value === 'extract') {
254
- s.stop(`Firefox ${version} extracted`);
255
- }
256
- else {
257
- s.stop(`Firefox ${version} downloaded`);
258
- }
259
- }
260
- catch (error) {
261
- s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
262
- throw error;
263
- }
256
+ await downloadAndExtractFirefox({
257
+ version,
258
+ product: config.firefox.product,
259
+ engineDir: paths.engine,
260
+ cacheDir,
261
+ ...(config.firefox.sha256 !== undefined ? { sha256: config.firefox.sha256 } : {}),
262
+ });
264
263
  // Finding #17: the git indexing phase of `download` can block for
265
264
  // minutes on a ~600 MB Firefox tree — the spinner updates less often
266
265
  // than operators expect during the monolithic `git add -A` pass, and
@@ -270,7 +269,7 @@ export async function downloadCommand(projectRoot, options) {
270
269
  // still fire as usual; this is an additional up-front signal, not a
271
270
  // replacement.
272
271
  info('Indexing downloaded source into git (one-time; typically 3–5 minutes on a ~600 MB Firefox tree)...');
273
- // Initialize git repository
272
+ info('Git phase: initializing/resetting source repository metadata.');
274
273
  const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
275
274
  let baseCommit;
276
275
  try {
@@ -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
  }
@@ -18,6 +18,7 @@ import { registerReset } from './reset.js';
18
18
  import { registerResolve } from './resolve.js';
19
19
  import { registerRun } from './run.js';
20
20
  import { registerSetup } from './setup.js';
21
+ import { registerSource } from './source.js';
21
22
  import { registerStatus } from './status.js';
22
23
  import { registerTest } from './test.js';
23
24
  import { registerToken } from './token.js';
@@ -31,6 +32,7 @@ import { registerWire } from './wire.js';
31
32
  */
32
33
  export const COMMAND_MANIFEST = [
33
34
  { name: 'setup', group: 'project', register: registerSetup },
35
+ { name: 'source', group: 'project', register: registerSource },
34
36
  { name: 'download', group: 'engine', register: registerDownload },
35
37
  { name: 'bootstrap', group: 'engine', register: registerBootstrap },
36
38
  { name: 'import', group: 'workflow', register: registerImport },
@@ -10,6 +10,7 @@ import { updatePatchAndMetadata } from '../core/patch-export.js';
10
10
  import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
11
11
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
12
12
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
+ import { elapsedSince } from '../utils/elapsed.js';
13
14
  import { toError } from '../utils/errors.js';
14
15
  import { pathExists } from '../utils/fs.js';
15
16
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
@@ -341,8 +342,9 @@ export async function reExportCommand(projectRoot, patches, options) {
341
342
  let reExported = 0;
342
343
  const reExportedFilenames = [];
343
344
  const progress = spinner('Preparing re-export...');
344
- for (const patch of selectedPatches) {
345
- progress.message(`Re-exporting ${patch.filename}...`);
345
+ const startedAt = Date.now();
346
+ for (const [index, patch] of selectedPatches.entries()) {
347
+ progress.message(`Re-exporting ${index + 1}/${selectedPatches.length}: ${patch.filename} (${patch.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
346
348
  try {
347
349
  const exported = await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config);
348
350
  if (exported) {
@@ -368,13 +370,13 @@ export async function reExportCommand(projectRoot, patches, options) {
368
370
  // which we refuse to version-stamp through.
369
371
  const shouldStamp = options.stamp === true && !isDryRun && reExported > 0 && reExported === selectedPatches.length;
370
372
  if (shouldStamp) {
371
- await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version);
373
+ await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version, config.firefox.product);
372
374
  }
373
375
  if (isDryRun) {
374
376
  progress.stop('Dry run complete');
375
377
  success(`[dry-run] Would re-export ${reExported} of ${selectedPatches.length} patch(es)`);
376
378
  if (options.stamp === true) {
377
- info(`[dry-run] Would stamp sourceEsrVersion=${config.firefox.version} on ${reExported} patch(es)`);
379
+ info(`[dry-run] Would stamp sourceVersion=${config.firefox.version} (${config.firefox.product}) on ${reExported} patch(es)`);
378
380
  }
379
381
  outro('Dry run complete');
380
382
  }
@@ -382,7 +384,7 @@ export async function reExportCommand(projectRoot, patches, options) {
382
384
  progress.stop('Re-export complete');
383
385
  success(`Re-exported ${reExported} of ${selectedPatches.length} patch(es)`);
384
386
  if (shouldStamp) {
385
- success(`Stamped sourceEsrVersion=${config.firefox.version} on ${reExportedFilenames.length} patch(es)`);
387
+ success(`Stamped sourceVersion=${config.firefox.version} (${config.firefox.product}) on ${reExportedFilenames.length} patch(es)`);
386
388
  }
387
389
  else if (options.stamp === true && reExported !== selectedPatches.length) {
388
390
  warn('--stamp was requested but some patches failed or were skipped; refusing to stamp a partial set.');
@@ -395,8 +397,8 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
395
397
  program
396
398
  .command('re-export [patches...]')
397
399
  .description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
398
- 'state. Does NOT change sourceEsrVersion by default — use --stamp or run rebase for ' +
399
- 'version stamping.')
400
+ 'state. Does NOT change sourceVersion/sourceProduct by default — use --stamp or run ' +
401
+ 'rebase for source metadata stamping.')
400
402
  .option('-a, --all', 'Re-export all patches')
401
403
  .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
402
404
  .option('--scan-file <path>', 'With --scan, add this explicit engine-relative file to one target patch without collecting adjacent files. Repeatable.', (value, prev) => [...prev, value], [])
@@ -409,7 +411,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
409
411
  .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
410
412
  .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
411
413
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
412
- .option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceEsrVersion in patches.json to firefox.version from fireforge.json. No effect on a partial run.")
414
+ .option('--stamp', "After every selected patch refreshes cleanly, stamp each re-exported patch's sourceVersion/sourceProduct in patches.json to firefox.version/firefox.product from fireforge.json. No effect on a partial run.")
413
415
  .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
414
416
  .option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], [])
415
417
  .action(withErrorHandling(async (patches, options) => {
@@ -0,0 +1,12 @@
1
+ export interface RebaseConflictSummary {
2
+ patchFilename: string;
3
+ failedFiles: string[];
4
+ category: string;
5
+ nextCommands: string[];
6
+ }
7
+ /** Builds a concise operator-facing summary for a failed rebase patch. */
8
+ export declare function buildRebaseConflictSummary(args: {
9
+ patchFilename: string;
10
+ error?: string;
11
+ rejectFiles?: string[];
12
+ }): RebaseConflictSummary;