@hominis/fireforge 0.26.0 → 0.27.1

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 (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/doctor/post-rebase-audit.d.ts +2 -0
  4. package/dist/src/commands/doctor/post-rebase-audit.js +86 -0
  5. package/dist/src/commands/doctor.js +3 -0
  6. package/dist/src/commands/download.js +4 -1
  7. package/dist/src/commands/manifest.js +2 -0
  8. package/dist/src/commands/re-export-files.js +44 -26
  9. package/dist/src/commands/re-export.js +53 -14
  10. package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
  11. package/dist/src/commands/rebase/conflict-summary.js +38 -0
  12. package/dist/src/commands/rebase/patch-loop.js +24 -6
  13. package/dist/src/commands/setup-support.js +6 -2
  14. package/dist/src/commands/setup.js +1 -0
  15. package/dist/src/commands/source.d.ts +9 -0
  16. package/dist/src/commands/source.js +92 -0
  17. package/dist/src/commands/verify.js +27 -0
  18. package/dist/src/core/branding.js +54 -7
  19. package/dist/src/core/config-validate.js +1 -1
  20. package/dist/src/core/firefox-extract.d.ts +1 -1
  21. package/dist/src/core/firefox-extract.js +13 -1
  22. package/dist/src/core/firefox.d.ts +2 -1
  23. package/dist/src/core/firefox.js +2 -2
  24. package/dist/src/core/furnace-registration-ast.js +32 -4
  25. package/dist/src/core/furnace-registration-validate.d.ts +7 -0
  26. package/dist/src/core/furnace-registration-validate.js +48 -6
  27. package/dist/src/core/furnace-validate-registration.js +8 -17
  28. package/dist/src/core/git.js +46 -16
  29. package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
  30. package/dist/src/core/patch-artifact-normalize.js +13 -0
  31. package/dist/src/core/patch-export-update.js +2 -1
  32. package/dist/src/core/patch-export.js +3 -2
  33. package/dist/src/core/status-classify.js +19 -4
  34. package/dist/src/types/commands/index.d.ts +1 -1
  35. package/dist/src/types/commands/options.d.ts +22 -1
  36. package/dist/src/types/config.d.ts +1 -1
  37. package/dist/src/utils/elapsed.d.ts +4 -0
  38. package/dist/src/utils/elapsed.js +15 -0
  39. package/dist/src/utils/validation.d.ts +2 -2
  40. package/dist/src/utils/validation.js +5 -5
  41. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0
4
+
5
+ - Added first-class `firefox-devedition` source support and atomic `fireforge source set`.
6
+ - Improved `download --force` git indexing progress with phase, count, and heartbeat output.
7
+ - Added elapsed progress for extraction, initial source commits, and rebase/re-export patch refreshes.
8
+ - Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
9
+ - Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
10
+ - Preserved patch-owned branding `configure.sh` settings during build preflight.
11
+ - Added custom element registration support for Furnace validate/apply and Firefox 152-style array-backed ESM registrations.
12
+ - Normalized generated patch artifacts so blank context lines do not trip raw whitespace checks.
13
+ - Improved rebase conflict summaries and added `doctor --post-rebase-audit` for common registration surfaces.
14
+
3
15
  ## 0.26.0
4
16
 
5
17
  - Added targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment without broad sibling collection.
package/README.md CHANGED
@@ -68,7 +68,7 @@ Use `fireforge --help` for the full set of commands.
68
68
  When Mozilla publishes a new ESR you need to update the configured Firefox version, 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
  ```
@@ -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
@@ -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) {
@@ -249,7 +249,10 @@ export async function downloadCommand(projectRoot, options) {
249
249
  phaseState.value = 'extract';
250
250
  s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
251
251
  }
252
- }, config.firefox.sha256);
252
+ }, config.firefox.sha256, (message) => {
253
+ if (phaseState.value === 'extract')
254
+ s.message(message);
255
+ });
253
256
  if (phaseState.value === 'extract') {
254
257
  s.stop(`Firefox ${version} extracted`);
255
258
  }
@@ -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 },
@@ -92,6 +92,41 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
92
92
  }
93
93
  return updates;
94
94
  }
95
+ async function confirmFilesModeProjection(args) {
96
+ const { target, retained, removed, added, actualProjectedFiles, missingFiles, options, conflicts, } = args;
97
+ const isDryRun = options.dryRun === true;
98
+ const summary = [
99
+ `re-export ${target.filename} with --files scope`,
100
+ `current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
101
+ `retained files (${retained.length}): ${retained.join(', ') || '(none)'}`,
102
+ `projected files (${actualProjectedFiles.length}): ${actualProjectedFiles.join(', ') || '(none)'}`,
103
+ ];
104
+ if (removed.length > 0) {
105
+ summary.push(`removed files (${removed.length}; become unmanaged): ${removed.join(', ')}`);
106
+ }
107
+ if (added.length > 0) {
108
+ summary.push(`newly included files (${added.length}): ${added.join(', ')}`);
109
+ }
110
+ if (missingFiles.length > 0) {
111
+ summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
112
+ }
113
+ if (!isDryRun && removed.length > 0 && options.allowShrink !== true) {
114
+ warn(`Refusing to shrink ${target.filename} without --allow-shrink.`);
115
+ for (const line of summary) {
116
+ info(` ${line}`);
117
+ }
118
+ throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because it would remove ${removed.length} existing patch-owned file${removed.length === 1 ? '' : 's'}. Run again with --allow-shrink after reviewing the dry-run output.`, '--allow-shrink');
119
+ }
120
+ return confirmDestructive({
121
+ operation: 're-export-files',
122
+ title: `Re-export ${target.filename} with --files`,
123
+ summary,
124
+ yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
125
+ dryRun: isDryRun,
126
+ unsafeOverride: options.forceUnsafe === true,
127
+ conflicts,
128
+ });
129
+ }
95
130
  /**
96
131
  * Handles `re-export --files` end-to-end: computes the projected diff,
97
132
  * runs the per-patch and cross-patch lint against a context in which the
@@ -106,7 +141,6 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
106
141
  * of the projected state.
107
142
  */
108
143
  export async function reExportFilesInPlace(paths, selectedPatches, options, config) {
109
- const isDryRun = options.dryRun === true;
110
144
  const target = selectedPatches[0];
111
145
  if (!target) {
112
146
  throw new InvalidArgumentError('--files requires a target patch.', '--files');
@@ -118,6 +152,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
118
152
  const requested = [...new Set(filesOption)].sort();
119
153
  const removed = target.filesAffected.filter((f) => !requested.includes(f));
120
154
  const added = requested.filter((f) => !target.filesAffected.includes(f));
155
+ const retained = target.filesAffected.filter((f) => requested.includes(f));
121
156
  // Filter out paths that no longer exist on disk; we cannot include
122
157
  // them in the new diff because getDiffForFilesAgainstHead would fail.
123
158
  // Missing files are still dropped from the manifest so the resulting
@@ -175,31 +210,14 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
175
210
  forceUnsafe: options.forceUnsafe === true,
176
211
  });
177
212
  }
178
- // Shrinks are destructive (previously-owned files become unmanaged), so
179
- // they keep the explicit confirmation gate. Additive-only scopes are safe
180
- // to run non-interactively after lint/policy projection because no existing
181
- // patch ownership is being dropped.
182
- const summary = [
183
- `re-export ${target.filename} with --files scope`,
184
- `current files (${target.filesAffected.length}): ${target.filesAffected.join(', ') || '(none)'}`,
185
- `new files (${actualProjectedFiles.length}): ${actualProjectedFiles.join(', ') || '(none)'}`,
186
- ];
187
- if (removed.length > 0) {
188
- summary.push(`would drop (become unmanaged): ${removed.join(', ')}`);
189
- }
190
- if (added.length > 0) {
191
- summary.push(`would add: ${added.join(', ')}`);
192
- }
193
- if (missingFiles.length > 0) {
194
- summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
195
- }
196
- const decision = await confirmDestructive({
197
- operation: 're-export-files',
198
- title: `Re-export ${target.filename} with --files`,
199
- summary,
200
- yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
201
- dryRun: isDryRun,
202
- unsafeOverride: options.forceUnsafe === true,
213
+ const decision = await confirmFilesModeProjection({
214
+ target,
215
+ retained,
216
+ removed,
217
+ added,
218
+ actualProjectedFiles,
219
+ missingFiles,
220
+ options,
203
221
  conflicts,
204
222
  });
205
223
  if (decision === 'cancelled') {
@@ -1,14 +1,16 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
3
  import { multiselect } from '@clack/prompts';
4
4
  import { Option } from 'commander';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
6
  import { isGitRepository } from '../core/git.js';
7
7
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
+ import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
8
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
9
- import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
10
+ import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
10
11
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
11
12
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
13
+ import { elapsedSince } from '../utils/elapsed.js';
12
14
  import { toError } from '../utils/errors.js';
13
15
  import { pathExists } from '../utils/fs.js';
14
16
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
@@ -24,6 +26,49 @@ async function findMissingFiles(engineDir, files) {
24
26
  }
25
27
  return missingFiles;
26
28
  }
29
+ async function findLikelyNewSiblingFiles(args) {
30
+ const { currentFilesAffected, engineDir, manifest, patchFilename } = args;
31
+ const parentDirs = [...new Set(currentFilesAffected.map((file) => dirname(file)))];
32
+ const currentSet = new Set(currentFilesAffected);
33
+ const claimedByOthers = getClaimedFiles(manifest, patchFilename);
34
+ const candidates = new Set();
35
+ for (const dir of parentDirs) {
36
+ const [modifiedFiles, untrackedFiles] = await Promise.all([
37
+ getModifiedFilesInDir(engineDir, dir),
38
+ getUntrackedFilesInDir(engineDir, dir),
39
+ ]);
40
+ for (const file of [...modifiedFiles, ...untrackedFiles]) {
41
+ if (currentSet.has(file) || claimedByOthers.has(file))
42
+ continue;
43
+ candidates.add(file);
44
+ }
45
+ }
46
+ return [...candidates].sort();
47
+ }
48
+ async function warnPlainReExportFileDrift(args) {
49
+ const { patch, paths, manifest, currentFilesAffected } = args;
50
+ const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
51
+ if (missingFiles.length > 0) {
52
+ warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
53
+ `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
54
+ `filesAffected unchanged and the missing entries will be preserved — ` +
55
+ `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
56
+ ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
57
+ `or pass --files <paths> to set the list explicitly.`);
58
+ }
59
+ const likelyNewFiles = await findLikelyNewSiblingFiles({
60
+ currentFilesAffected,
61
+ engineDir: paths.engine,
62
+ manifest,
63
+ patchFilename: patch.filename,
64
+ });
65
+ if (likelyNewFiles.length === 0)
66
+ return;
67
+ warn(`${patch.filename}: found ${likelyNewFiles.length} unowned changed sibling file${likelyNewFiles.length === 1 ? '' : 's'} near this patch. Plain re-export keeps filesAffected unchanged; add reviewed files explicitly with --scan-file.`);
68
+ for (const file of likelyNewFiles) {
69
+ info(` ${file} — fireforge re-export ${patch.filename} --scan --scan-file ${file}`);
70
+ }
71
+ }
27
72
  async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
28
73
  let currentFilesAffected = [...patch.filesAffected];
29
74
  // --- Scan for new/removed files ---
@@ -61,15 +106,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
61
106
  // warning up-front when we can detect the drift cheaply, so the
62
107
  // operator has a chance to re-run with `--scan` or `--files`
63
108
  // before the stale filesAffected lands in patches.json.
64
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
65
- if (missingFiles.length > 0) {
66
- warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
67
- `(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
68
- `filesAffected unchanged and the missing entries will be preserved — ` +
69
- `\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
70
- ` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
71
- `or pass --files <paths> to set the list explicitly.`);
72
- }
109
+ await warnPlainReExportFileDrift({ patch, paths, manifest, currentFilesAffected });
73
110
  }
74
111
  // --- Explicit file-subset path ---
75
112
  // When --files is given, the target filesAffected is authoritative — drop
@@ -305,8 +342,9 @@ export async function reExportCommand(projectRoot, patches, options) {
305
342
  let reExported = 0;
306
343
  const reExportedFilenames = [];
307
344
  const progress = spinner('Preparing re-export...');
308
- for (const patch of selectedPatches) {
309
- 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)...`);
310
348
  try {
311
349
  const exported = await reExportSinglePatch(patch, paths, manifest, options, isDryRun, config);
312
350
  if (exported) {
@@ -370,7 +408,8 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
370
408
  .filter((v) => v.length > 0))
371
409
  .option('--dry-run', 'Show what would change without writing')
372
410
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
373
- .option('-y, --yes', 'Skip confirmation when --files shrinks a patch (required for non-TTY)')
411
+ .option('--allow-shrink', 'Allow --files to remove paths currently owned by the patch. Required before --yes can bypass the shrink confirmation.')
412
+ .option('-y, --yes', 'Skip confirmation prompts (required for non-TTY destructive writes)')
374
413
  .option('--force-unsafe', 'Bypass cross-patch lint refusal when --files shrinks a patch')
375
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.")
376
415
  .addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
@@ -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;
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { extractConflictingFiles } from '../../core/patch-parse.js';
3
+ function normalizeRejectFile(file) {
4
+ return file.replace(/\.rej$/, '');
5
+ }
6
+ function classifyConflict(files) {
7
+ if (files.some((file) => file.endsWith('toolkit/content/customElements.js'))) {
8
+ return 'registration context drift';
9
+ }
10
+ if (files.some((file) => file.endsWith('jar.mn') ||
11
+ file.endsWith('moz.build') ||
12
+ file.endsWith('browser.toml') ||
13
+ file.endsWith('browser/moz.configure'))) {
14
+ return 'manifest context drift';
15
+ }
16
+ return 'patch context drift';
17
+ }
18
+ /** Builds a concise operator-facing summary for a failed rebase patch. */
19
+ export function buildRebaseConflictSummary(args) {
20
+ const failedFiles = [
21
+ ...new Set([
22
+ ...extractConflictingFiles(args.error),
23
+ ...(args.rejectFiles ?? []).map(normalizeRejectFile),
24
+ ]),
25
+ ].sort();
26
+ return {
27
+ patchFilename: args.patchFilename,
28
+ failedFiles,
29
+ category: classifyConflict(failedFiles),
30
+ nextCommands: [
31
+ "find engine -name '*.rej'",
32
+ 'edit the affected engine/ files',
33
+ 'fireforge rebase --continue',
34
+ 'fireforge rebase --abort',
35
+ ],
36
+ };
37
+ }
38
+ //# sourceMappingURL=conflict-summary.js.map
@@ -15,9 +15,11 @@ import { extractConflictingFiles } from '../../core/patch-parse.js';
15
15
  import { clearRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
16
16
  import { runInSignalCriticalSection } from '../../core/signal-critical.js';
17
17
  import { RebaseError } from '../../errors/rebase.js';
18
+ import { elapsedSince } from '../../utils/elapsed.js';
18
19
  import { toError } from '../../utils/errors.js';
19
20
  import { pathExists } from '../../utils/fs.js';
20
21
  import { error, info, outro, spinner, success, warn } from '../../utils/logger.js';
22
+ import { buildRebaseConflictSummary } from './conflict-summary.js';
21
23
  import { printSummary } from './summary.js';
22
24
  /**
23
25
  * Runs the patch application loop, re-exports applied patches, and stamps versions.
@@ -37,7 +39,7 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
37
39
  await saveRebaseSession(projectRoot, session);
38
40
  continue;
39
41
  }
40
- s.message(`Applying ${entry.filename}...`);
42
+ s.message(`Applying ${i + 1}/${session.patches.length}: ${entry.filename}...`);
41
43
  // Apply + session persist is wrapped in a signal-deferred critical
42
44
  // section so a SIGINT / SIGTERM between the filesystem mutation and
43
45
  // the session-file update is held until the bookkeeping write lands.
@@ -90,8 +92,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
90
92
  },
91
93
  }));
92
94
  s.error(`${entry.filename} failed to apply`);
95
+ const summary = buildRebaseConflictSummary({
96
+ patchFilename: entry.filename,
97
+ ...(result.error !== undefined ? { error: result.error } : {}),
98
+ ...(result.rejectFiles !== undefined ? { rejectFiles: result.rejectFiles } : {}),
99
+ });
100
+ warn(`Conflict summary for ${summary.patchFilename}: ${summary.category}`);
101
+ if (summary.failedFiles.length > 0) {
102
+ warn(` Failed files: ${summary.failedFiles.join(', ')}`);
103
+ }
104
+ else {
105
+ warn(' Failed files: not detected from git output');
106
+ }
107
+ info(' Suggested next commands:');
108
+ for (const command of summary.nextCommands) {
109
+ info(` ${command}`);
110
+ }
93
111
  if (result.error) {
94
- error(` Error: ${result.error}`);
112
+ error(` Raw apply detail: ${result.error}`);
95
113
  }
96
114
  if (result.rejectFiles && result.rejectFiles.length > 0) {
97
115
  info(` .rej files created for manual resolution`);
@@ -167,19 +185,19 @@ async function reExportAppliedPatches(session, paths) {
167
185
  if (!manifest)
168
186
  return failures;
169
187
  const s = spinner('Re-exporting patches...');
170
- for (const entry of session.patches) {
171
- if (entry.status !== 'applied-clean' && entry.status !== 'applied-fuzz')
172
- continue;
188
+ const reExportable = session.patches.filter((entry) => entry.status === 'applied-clean' || entry.status === 'applied-fuzz');
189
+ const startedAt = Date.now();
190
+ for (const [index, entry] of reExportable.entries()) {
173
191
  const meta = manifest.patches.find((p) => p.filename === entry.filename);
174
192
  if (!meta)
175
193
  continue;
176
- s.message(`Re-exporting ${entry.filename}...`);
177
194
  const existingFiles = [];
178
195
  for (const f of meta.filesAffected) {
179
196
  if (await pathExists(join(paths.engine, f))) {
180
197
  existingFiles.push(f);
181
198
  }
182
199
  }
200
+ s.message(`Re-exporting ${index + 1}/${reExportable.length}: ${entry.filename} (${existingFiles.length}/${meta.filesAffected.length} file(s), ${elapsedSince(startedAt)} elapsed)...`);
183
201
  try {
184
202
  const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
185
203
  if (diffContent.trim()) {
@@ -19,10 +19,13 @@ function renderLicenseTemplate(license, template, vendor, now = new Date()) {
19
19
  return template.replace(/\[year\]/g, String(now.getFullYear())).replace(/\[fullname\]/g, vendor);
20
20
  }
21
21
  function resolveFirefoxProduct(value, field) {
22
- if (value === 'firefox' || value === 'firefox-esr' || value === 'firefox-beta') {
22
+ if (value === 'firefox' ||
23
+ value === 'firefox-esr' ||
24
+ value === 'firefox-beta' ||
25
+ value === 'firefox-devedition') {
23
26
  return value;
24
27
  }
25
- throw new InvalidArgumentError('Invalid product (use: firefox, firefox-esr, firefox-beta)', field);
28
+ throw new InvalidArgumentError('Invalid product (use: firefox, firefox-esr, firefox-beta, firefox-devedition)', field);
26
29
  }
27
30
  function resolveProjectLicense(value, field) {
28
31
  if (typeof value === 'string' && isValidProjectLicense(value)) {
@@ -153,6 +156,7 @@ async function promptSetupInputs(options) {
153
156
  { value: 'firefox', label: 'Firefox (stable releases)' },
154
157
  { value: 'firefox-esr', label: 'Firefox ESR (extended support)' },
155
158
  { value: 'firefox-beta', label: 'Firefox Beta (pre-release)' },
159
+ { value: 'firefox-devedition', label: 'Firefox Developer Edition' },
156
160
  ],
157
161
  });
158
162
  },
@@ -71,6 +71,7 @@ export function registerSetup(program, { withErrorHandling }) {
71
71
  'firefox',
72
72
  'firefox-esr',
73
73
  'firefox-beta',
74
+ 'firefox-devedition',
74
75
  ]))
75
76
  .addOption(new Option('--license <license>', 'Project license').choices([...PROJECT_LICENSES]))
76
77
  .option('-f, --force', 'Overwrite existing configuration without prompting')
@@ -0,0 +1,9 @@
1
+ import { Command } from 'commander';
2
+ import type { CommandContext } from '../types/cli.js';
3
+ import type { SourceSetOptions } from '../types/commands/index.js';
4
+ /**
5
+ * Atomically updates the Firefox source tuple in fireforge.json.
6
+ */
7
+ export declare function sourceSetCommand(projectRoot: string, options: SourceSetOptions): Promise<void>;
8
+ /** Registers the source command on the CLI program. */
9
+ export declare function registerSource(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { Option } from 'commander';
3
+ import { configExists, loadRawConfigDocument, validateConfig, withConfigFileLock, writeConfigDocument, } from '../core/config.js';
4
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
5
+ import { info, intro, outro, success } from '../utils/logger.js';
6
+ import { isValidFirefoxProduct } from '../utils/validation.js';
7
+ const SOURCE_PRODUCTS = [
8
+ 'firefox',
9
+ 'firefox-esr',
10
+ 'firefox-beta',
11
+ 'firefox-devedition',
12
+ ];
13
+ function isRecord(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ function cloneRawConfig(raw) {
17
+ const cloned = structuredClone(raw);
18
+ if (!isRecord(cloned)) {
19
+ throw new GeneralError('Cannot update fireforge.json: config clone was not an object.');
20
+ }
21
+ return cloned;
22
+ }
23
+ function parseSourceProduct(product) {
24
+ if (isValidFirefoxProduct(product)) {
25
+ return product;
26
+ }
27
+ throw new InvalidArgumentError(`--product must be one of: ${SOURCE_PRODUCTS.join(', ')}`, '--product');
28
+ }
29
+ /**
30
+ * Atomically updates the Firefox source tuple in fireforge.json.
31
+ */
32
+ export async function sourceSetCommand(projectRoot, options) {
33
+ intro('FireForge Source');
34
+ if (!(await configExists(projectRoot))) {
35
+ throw new GeneralError('No fireforge.json found. Run "fireforge setup" to create a project.');
36
+ }
37
+ if (options.sha256 !== undefined && options.clearSha256 === true) {
38
+ throw new InvalidArgumentError('--sha256 cannot be combined with --clear-sha256', '--sha256');
39
+ }
40
+ const written = await withConfigFileLock(projectRoot, async () => {
41
+ const raw = await loadRawConfigDocument(projectRoot);
42
+ const updated = cloneRawConfig(raw);
43
+ const firefox = isRecord(updated['firefox']) ? { ...updated['firefox'] } : {};
44
+ firefox['version'] = options.version;
45
+ firefox['product'] = options.product;
46
+ if (options.clearSha256 === true) {
47
+ delete firefox['sha256'];
48
+ }
49
+ else if (options.sha256 !== undefined) {
50
+ firefox['sha256'] = options.sha256;
51
+ }
52
+ updated['firefox'] = firefox;
53
+ const validated = validateConfig(updated);
54
+ if (validated.firefox.sha256 !== undefined) {
55
+ firefox['sha256'] = validated.firefox.sha256;
56
+ }
57
+ await writeConfigDocument(projectRoot, updated);
58
+ return validated.firefox;
59
+ });
60
+ success(`Set firefox.version = ${written.version}`);
61
+ success(`Set firefox.product = ${written.product}`);
62
+ if (written.sha256 !== undefined) {
63
+ success(`Set firefox.sha256 = ${written.sha256}`);
64
+ }
65
+ else if (options.clearSha256 === true) {
66
+ info('Cleared firefox.sha256');
67
+ }
68
+ outro('');
69
+ }
70
+ /** Registers the source command on the CLI program. */
71
+ export function registerSource(program, { getProjectRoot, withErrorHandling }) {
72
+ const source = program.command('source').description('Manage Firefox source configuration');
73
+ source
74
+ .command('set')
75
+ .description('Atomically set Firefox source version, product, and optional checksum')
76
+ .requiredOption('--version <version>', 'Firefox version to base on')
77
+ .addOption(new Option('--product <product>', 'Firefox product')
78
+ .choices([...SOURCE_PRODUCTS])
79
+ .makeOptionMandatory())
80
+ .option('--sha256 <hash>', 'Pinned SHA-256 for the resolved source archive')
81
+ .option('--clear-sha256', 'Clear any existing pinned SHA-256')
82
+ .action(withErrorHandling(async (options) => {
83
+ const { product, version, sha256, clearSha256 } = options;
84
+ await sourceSetCommand(getProjectRoot(), {
85
+ version,
86
+ product: parseSourceProduct(product),
87
+ ...(sha256 !== undefined ? { sha256 } : {}),
88
+ ...(clearSha256 !== undefined ? { clearSha256 } : {}),
89
+ });
90
+ }));
91
+ }
92
+ //# sourceMappingURL=source.js.map