@hominis/fireforge 0.27.1 → 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 (44) hide show
  1. package/CHANGELOG.md +6 -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 +41 -45
  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-cache.d.ts +1 -1
  25. package/dist/src/core/firefox-cache.js +10 -3
  26. package/dist/src/core/firefox.js +1 -1
  27. package/dist/src/core/git.js +7 -2
  28. package/dist/src/core/ownership-table.d.ts +3 -1
  29. package/dist/src/core/ownership-table.js +31 -7
  30. package/dist/src/core/patch-export.d.ts +4 -0
  31. package/dist/src/core/patch-export.js +4 -0
  32. package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
  33. package/dist/src/core/patch-manifest-consistency.js +4 -2
  34. package/dist/src/core/patch-manifest-query.d.ts +4 -3
  35. package/dist/src/core/patch-manifest-query.js +12 -4
  36. package/dist/src/core/patch-manifest-validate.js +22 -4
  37. package/dist/src/core/patch-source-metadata.d.ts +8 -0
  38. package/dist/src/core/patch-source-metadata.js +17 -0
  39. package/dist/src/core/rebase-session.d.ts +8 -3
  40. package/dist/src/core/rebase-session.js +1 -1
  41. package/dist/src/core/status-classify.d.ts +4 -1
  42. package/dist/src/core/status-classify.js +4 -5
  43. package/dist/src/types/commands/patches.d.ts +9 -1
  44. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,7 +3,13 @@
3
3
  ## 0.27.0
4
4
 
5
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.
6
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.
7
13
  - Added elapsed progress for extraction, initial source commits, and rebase/re-export patch refreshes.
8
14
  - Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
9
15
  - 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 {
@@ -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,46 +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, (message) => {
253
- if (phaseState.value === 'extract')
254
- s.message(message);
255
- });
256
- if (phaseState.value === 'extract') {
257
- s.stop(`Firefox ${version} extracted`);
258
- }
259
- else {
260
- s.stop(`Firefox ${version} downloaded`);
261
- }
262
- }
263
- catch (error) {
264
- s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
265
- throw error;
266
- }
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
+ });
267
263
  // Finding #17: the git indexing phase of `download` can block for
268
264
  // minutes on a ~600 MB Firefox tree — the spinner updates less often
269
265
  // than operators expect during the monolithic `git add -A` pass, and
@@ -273,7 +269,7 @@ export async function downloadCommand(projectRoot, options) {
273
269
  // still fire as usual; this is an additional up-front signal, not a
274
270
  // replacement.
275
271
  info('Indexing downloaded source into git (one-time; typically 3–5 minutes on a ~600 MB Firefox tree)...');
276
- // Initialize git repository
272
+ info('Git phase: initializing/resetting source repository metadata.');
277
273
  const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
278
274
  let baseCommit;
279
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
  }
@@ -370,13 +370,13 @@ export async function reExportCommand(projectRoot, patches, options) {
370
370
  // which we refuse to version-stamp through.
371
371
  const shouldStamp = options.stamp === true && !isDryRun && reExported > 0 && reExported === selectedPatches.length;
372
372
  if (shouldStamp) {
373
- await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version);
373
+ await stampPatchVersions(paths.patches, reExportedFilenames, config.firefox.version, config.firefox.product);
374
374
  }
375
375
  if (isDryRun) {
376
376
  progress.stop('Dry run complete');
377
377
  success(`[dry-run] Would re-export ${reExported} of ${selectedPatches.length} patch(es)`);
378
378
  if (options.stamp === true) {
379
- 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)`);
380
380
  }
381
381
  outro('Dry run complete');
382
382
  }
@@ -384,7 +384,7 @@ export async function reExportCommand(projectRoot, patches, options) {
384
384
  progress.stop('Re-export complete');
385
385
  success(`Re-exported ${reExported} of ${selectedPatches.length} patch(es)`);
386
386
  if (shouldStamp) {
387
- 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)`);
388
388
  }
389
389
  else if (options.stamp === true && reExported !== selectedPatches.length) {
390
390
  warn('--stamp was requested but some patches failed or were skipped; refusing to stamp a partial set.');
@@ -397,8 +397,8 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
397
397
  program
398
398
  .command('re-export [patches...]')
399
399
  .description('Refresh existing patch bodies (and filesAffected with --scan) from the current engine ' +
400
- 'state. Does NOT change sourceEsrVersion by default — use --stamp or run rebase for ' +
401
- 'version stamping.')
400
+ 'state. Does NOT change sourceVersion/sourceProduct by default — use --stamp or run ' +
401
+ 'rebase for source metadata stamping.')
402
402
  .option('-a, --all', 'Re-export all patches')
403
403
  .option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
404
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], [])
@@ -411,7 +411,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
411
411
  .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
412
412
  .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
413
413
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
414
- .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.")
415
415
  .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
416
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], [])
417
417
  .action(withErrorHandling(async (patches, options) => {
@@ -73,6 +73,8 @@ export async function handleContinue(projectRoot, maxFuzz) {
73
73
  // v0.14.0 resolve.ts fix.
74
74
  await updatePatchAndMetadata(paths.patches, currentPatch.filename, diffContent, {
75
75
  sourceEsrVersion: session.toVersion,
76
+ sourceVersion: session.toVersion,
77
+ ...(session.toProduct !== undefined ? { sourceProduct: session.toProduct } : {}),
76
78
  });
77
79
  }
78
80
  finally {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `fireforge rebase` — semi-automated ESR version upgrade.
2
+ * `fireforge rebase` — semi-automated Firefox source version upgrade.
3
3
  *
4
4
  * Orchestrates the full patch-rebase workflow:
5
5
  * 1. Reset engine to baseline
@@ -13,7 +13,7 @@ import { Command } from 'commander';
13
13
  import type { CommandContext } from '../../types/cli.js';
14
14
  import type { RebaseOptions } from '../../types/commands/index.js';
15
15
  /**
16
- * Runs the rebase command to orchestrate an ESR version upgrade.
16
+ * Runs the rebase command to orchestrate a Firefox source version upgrade.
17
17
  * @param projectRoot - Root directory of the project
18
18
  * @param options - Rebase options
19
19
  */
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * `fireforge rebase` — semi-automated ESR version upgrade.
3
+ * `fireforge rebase` — semi-automated Firefox source version upgrade.
4
4
  *
5
5
  * Orchestrates the full patch-rebase workflow:
6
6
  * 1. Reset engine to baseline
@@ -15,6 +15,7 @@ import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.j
15
15
  import { getHead, isGitRepository, isMissingHeadError, resetChanges } from '../../core/git.js';
16
16
  import { discoverPatches } from '../../core/patch-files.js';
17
17
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
18
+ import { getPatchSourceProduct, getPatchSourceVersion } from '../../core/patch-source-metadata.js';
18
19
  import { hasActiveRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
19
20
  import { GeneralError } from '../../errors/base.js';
20
21
  import { RebaseSessionExistsError } from '../../errors/rebase.js';
@@ -65,9 +66,11 @@ async function handleFreshStart(projectRoot, options) {
65
66
  throw new GeneralError('No patches found in manifest. Nothing to rebase.');
66
67
  }
67
68
  // Determine the "from" version from the patches
68
- const patchVersions = new Set(manifest.patches.map((p) => p.sourceEsrVersion));
69
+ const patchVersions = new Set(manifest.patches.map((p) => getPatchSourceVersion(p)));
70
+ const patchProducts = new Set(manifest.patches.map((p) => getPatchSourceProduct(p)).filter(Boolean));
69
71
  const sortedVersions = [...patchVersions].sort();
70
72
  const fromVersion = sortedVersions[0] ?? currentVersion;
73
+ const fromProduct = [...patchProducts].sort()[0] ?? config.firefox.product;
71
74
  if (patchVersions.size === 1 && fromVersion === currentVersion) {
72
75
  info('All patches already match the current Firefox version. Nothing to rebase.');
73
76
  outro('Rebase not needed');
@@ -110,6 +113,8 @@ async function handleFreshStart(projectRoot, options) {
110
113
  const allPatches = await discoverPatches(paths.patches);
111
114
  const session = {
112
115
  startedAt: new Date().toISOString(),
116
+ fromProduct,
117
+ toProduct: config.firefox.product,
113
118
  fromVersion,
114
119
  toVersion: currentVersion,
115
120
  preRebaseCommit,
@@ -125,7 +130,7 @@ async function handleFreshStart(projectRoot, options) {
125
130
  }
126
131
  // ── Public API ──
127
132
  /**
128
- * Runs the rebase command to orchestrate an ESR version upgrade.
133
+ * Runs the rebase command to orchestrate a Firefox source version upgrade.
129
134
  * @param projectRoot - Root directory of the project
130
135
  * @param options - Rebase options
131
136
  */
@@ -142,7 +147,7 @@ export async function rebaseCommand(projectRoot, options = {}) {
142
147
  export function registerRebase(program, { getProjectRoot, withErrorHandling }) {
143
148
  program
144
149
  .command('rebase')
145
- .description('Semi-automated ESR version upgrade — apply patches with fuzz and re-export')
150
+ .description('Semi-automated Firefox source version upgrade — apply patches with fuzz and re-export')
146
151
  .option('--continue', 'Resume after manually resolving a failed patch')
147
152
  .option('--abort', 'Cancel the rebase and restore engine to pre-rebase state')
148
153
  .option('--dry-run', 'Show what would happen without modifying anything')
@@ -143,16 +143,16 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
143
143
  .filter((p) => p.status === 'applied-clean' || p.status === 'applied-fuzz' || p.status === 'resolved')
144
144
  .map((p) => p.filename);
145
145
  if (appliedFilenames.length > 0) {
146
- await stampPatchVersions(paths.patches, appliedFilenames, session.toVersion);
146
+ await stampPatchVersions(paths.patches, appliedFilenames, session.toVersion, session.toProduct);
147
147
  }
148
148
  // Stamp every Furnace override's `baseVersion` to match the rebased
149
- // Firefox version. Before this stamp, a successful ESR bump left
149
+ // Firefox source version. Before this stamp, a successful source bump left
150
150
  // overrides in a doctor-failing drift state (each override still
151
- // claimed the pre-rebase ESR as its baseline) and every subsequent
151
+ // claimed the pre-rebase source as its baseline) and every subsequent
152
152
  // `fireforge doctor` failed `Furnace component validation`. The
153
153
  // stamp is unconditional per the helper's contract: rebase already
154
154
  // succeeded on the patch side, so the operator is committing to the
155
- // new ESR baseline; per-component health checking stays with
155
+ // new source baseline; per-component health checking stays with
156
156
  // `fireforge furnace validate` / `doctor --repair-furnace`.
157
157
  try {
158
158
  const overridesStamped = await stampFurnaceOverrideBaseVersions(projectRoot, session.toVersion);
@@ -176,7 +176,7 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
176
176
  return next;
177
177
  });
178
178
  info('');
179
- success(`All patches re-exported with sourceEsrVersion=${session.toVersion}`);
179
+ success(`All patches re-exported with sourceVersion=${session.toVersion}`);
180
180
  outro('Rebase complete!');
181
181
  }
182
182
  async function reExportAppliedPatches(session, paths) {
@@ -27,7 +27,11 @@ export function statusLabel(status, fuzzFactor) {
27
27
  */
28
28
  export function printSummary(session) {
29
29
  info('');
30
- info(`ESR Rebase Summary: ${session.fromVersion} → ${session.toVersion}`);
30
+ const from = session.fromProduct
31
+ ? `${session.fromProduct} ${session.fromVersion}`
32
+ : session.fromVersion;
33
+ const to = session.toProduct ? `${session.toProduct} ${session.toVersion}` : session.toVersion;
34
+ info(`Source Rebase Summary: ${from} → ${to}`);
31
35
  info('='.repeat(55));
32
36
  for (const patch of session.patches) {
33
37
  const label = statusLabel(patch.status, patch.fuzzFactor);
@@ -37,7 +41,8 @@ export function printSummary(session) {
37
41
  const fuzz = session.patches.filter((p) => p.status === 'applied-fuzz').length;
38
42
  const resolved = session.patches.filter((p) => p.status === 'resolved').length;
39
43
  const failed = session.patches.filter((p) => p.status === 'failed').length;
44
+ const total = session.patches.length;
40
45
  info('');
41
- info(`Results: ${clean} clean, ${fuzz} fuzz-applied, ${resolved} manually resolved, ${failed} failed`);
46
+ info(`Results: ${total} total: ${clean} clean, ${fuzz} fuzz-applied, ${resolved} manually resolved, ${failed} failed`);
42
47
  }
43
48
  //# sourceMappingURL=summary.js.map
@@ -8,6 +8,7 @@ import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
8
8
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
10
  import { loadPatchesManifest } from '../core/patch-manifest.js';
11
+ import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
11
12
  import { GeneralError, ResolutionError } from '../errors/base.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists } from '../utils/fs.js';
@@ -132,7 +133,7 @@ export async function resolveCommand(projectRoot, options = {}) {
132
133
  const config = await loadConfig(projectRoot);
133
134
  await updatePatchAndMetadata(paths.patches, patchFilename, diffContent, {
134
135
  filesAffected: diffFilesAffected,
135
- sourceEsrVersion: config.firefox.version,
136
+ ...buildPatchSourceMetadata(config.firefox),
136
137
  });
137
138
  // Cleanup: Clear pendingResolution from state.json transactionally so
138
139
  // we don't clobber concurrent updates to unrelated keys (e.g. another
@@ -0,0 +1,13 @@
1
+ import type { ClassifiedFile } from '../core/status-classify.js';
2
+ export interface ClassifiedBuckets {
3
+ conflict: ClassifiedFile[];
4
+ unmanaged: ClassifiedFile[];
5
+ patchBacked: ClassifiedFile[];
6
+ patchOwnedDrift: ClassifiedFile[];
7
+ branding: ClassifiedFile[];
8
+ furnace: ClassifiedFile[];
9
+ }
10
+ /** Renders the unmanaged-only status view and registration hints. */
11
+ export declare function renderUnmanagedOnly(unmanagedFiles: ClassifiedFile[], totalModified: number, projectRoot: string, binaryName: string): Promise<void>;
12
+ /** Renders the default classified status buckets. */
13
+ export declare function renderDefaultStatus(totalModified: number, buckets: ClassifiedBuckets, projectRoot: string, binaryName: string): Promise<void>;