@hominis/fireforge 0.27.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 (36) hide show
  1. package/CHANGELOG.md +5 -1
  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.js +4 -2
  9. package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
  10. package/dist/src/commands/rebase/conflict-summary.js +38 -0
  11. package/dist/src/commands/rebase/patch-loop.js +24 -6
  12. package/dist/src/commands/setup-support.js +6 -2
  13. package/dist/src/commands/setup.js +1 -0
  14. package/dist/src/commands/source.d.ts +9 -0
  15. package/dist/src/commands/source.js +92 -0
  16. package/dist/src/core/config-validate.js +1 -1
  17. package/dist/src/core/firefox-extract.d.ts +1 -1
  18. package/dist/src/core/firefox-extract.js +13 -1
  19. package/dist/src/core/firefox.d.ts +2 -1
  20. package/dist/src/core/firefox.js +2 -2
  21. package/dist/src/core/furnace-registration-validate.d.ts +7 -0
  22. package/dist/src/core/furnace-registration-validate.js +29 -12
  23. package/dist/src/core/furnace-validate-registration.js +5 -37
  24. package/dist/src/core/git.js +19 -4
  25. package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
  26. package/dist/src/core/patch-artifact-normalize.js +13 -0
  27. package/dist/src/core/patch-export-update.js +2 -1
  28. package/dist/src/core/patch-export.js +3 -2
  29. package/dist/src/types/commands/index.d.ts +1 -1
  30. package/dist/src/types/commands/options.d.ts +16 -1
  31. package/dist/src/types/config.d.ts +1 -1
  32. package/dist/src/utils/elapsed.d.ts +4 -0
  33. package/dist/src/utils/elapsed.js +15 -0
  34. package/dist/src/utils/validation.d.ts +2 -2
  35. package/dist/src/utils/validation.js +5 -5
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  ## 0.27.0
4
4
 
5
+ - Added first-class `firefox-devedition` source support and atomic `fireforge source set`.
5
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.
6
8
  - Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
7
9
  - Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
8
10
  - Preserved patch-owned branding `configure.sh` settings during build preflight.
9
- - Added custom element registration support for Furnace validate/apply.
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.
10
14
 
11
15
  ## 0.26.0
12
16
 
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 },
@@ -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) {
@@ -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
@@ -67,7 +67,7 @@ export function validateConfig(data) {
67
67
  throw new ConfigError('Config field "firefox.version" must be a valid Firefox version (e.g., "145.0")');
68
68
  }
69
69
  const firefoxProduct = requireConfigString(firefoxRec, 'product', 'firefox.product');
70
- const validProducts = ['firefox', 'firefox-esr', 'firefox-beta'];
70
+ const validProducts = ['firefox', 'firefox-esr', 'firefox-beta', 'firefox-devedition'];
71
71
  if (!validProducts.includes(firefoxProduct)) {
72
72
  throw new ConfigError(`Config field "firefox.product" must be one of: ${validProducts.join(', ')}`);
73
73
  }
@@ -6,7 +6,7 @@
6
6
  * @param archivePath - Path to the archive
7
7
  * @param destDir - Destination directory
8
8
  */
9
- export declare function extractTarXz(archivePath: string, destDir: string): Promise<void>;
9
+ export declare function extractTarXz(archivePath: string, destDir: string, onProgress?: (message: string) => void): Promise<void>;
10
10
  /**
11
11
  * Gets the Firefox version from an existing source directory.
12
12
  * @param engineDir - Path to the engine directory
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { join } from 'node:path';
6
6
  import { ExtractionError } from '../errors/download.js';
7
+ import { elapsedSince } from '../utils/elapsed.js';
7
8
  import { ensureDir, pathExists } from '../utils/fs.js';
8
9
  import { exec, executableExists } from '../utils/process.js';
9
10
  /**
@@ -11,15 +12,26 @@ import { exec, executableExists } from '../utils/process.js';
11
12
  * @param archivePath - Path to the archive
12
13
  * @param destDir - Destination directory
13
14
  */
14
- export async function extractTarXz(archivePath, destDir) {
15
+ export async function extractTarXz(archivePath, destDir, onProgress) {
15
16
  if (!(await executableExists('tar'))) {
16
17
  throw new ExtractionError(archivePath, new Error('The "tar" command was not found. Please install tar (or ensure it is on your PATH) and try again.'));
17
18
  }
18
19
  await ensureDir(destDir);
20
+ const startedAt = Date.now();
21
+ onProgress?.(`Extracting source archive (${elapsedSince(startedAt)} elapsed)...`);
22
+ const heartbeat = onProgress
23
+ ? setInterval(() => {
24
+ onProgress(`Extracting source archive (${elapsedSince(startedAt)} elapsed)...`);
25
+ }, 15_000)
26
+ : null;
27
+ heartbeat?.unref();
19
28
  const result = await exec('tar', ['-xf', archivePath, '-C', destDir]);
29
+ if (heartbeat)
30
+ clearInterval(heartbeat);
20
31
  if (result.exitCode !== 0) {
21
32
  throw new ExtractionError(archivePath, new Error(`tar exited with code ${result.exitCode}:\n${result.stderr}`));
22
33
  }
34
+ onProgress?.(`Source archive extracted (${elapsedSince(startedAt)} elapsed)`);
23
35
  }
24
36
  /**
25
37
  * Gets the Firefox version from an existing source directory.
@@ -34,6 +34,7 @@ export declare function getTarballFilename(version: string, product?: FirefoxPro
34
34
  export type FirefoxSourcePhase = 'download' | 'extract';
35
35
  /** Callback fired at phase transitions during {@link downloadFirefoxSource}. */
36
36
  export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
37
+ export type FirefoxSourceProgressCallback = (message: string) => void;
37
38
  /**
38
39
  * Downloads and extracts Firefox source.
39
40
  * @param version - Firefox version to download
@@ -45,4 +46,4 @@ export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
45
46
  * between phases (`'download'` → `'extract'`). Fires exactly once per
46
47
  * phase even if the cached archive path skips the wire entirely.
47
48
  */
48
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string): Promise<void>;
49
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback): Promise<void>;
@@ -44,7 +44,7 @@ export function getTarballFilename(version, product = 'firefox') {
44
44
  * between phases (`'download'` → `'extract'`). Fires exactly once per
45
45
  * phase even if the cached archive path skips the wire entirely.
46
46
  */
47
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256) {
47
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress) {
48
48
  const archive = resolveArchive(version, product);
49
49
  const tarballPath = join(cacheDir, archive.filename);
50
50
  // Ensure cache directory exists
@@ -56,7 +56,7 @@ export async function downloadFirefoxSource(version, product, destDir, cacheDir,
56
56
  onPhase?.('extract');
57
57
  const tempDir = `${destDir}.tmp-${randomUUID()}`;
58
58
  try {
59
- await extractTarXz(tarballPath, tempDir);
59
+ await extractTarXz(tarballPath, tempDir, onPhaseProgress);
60
60
  }
61
61
  catch (error) {
62
62
  await removeDir(tempDir);
@@ -30,3 +30,10 @@ export declare function validateTagName(tagName: string): void;
30
30
  * @param isESModule - Whether the module uses ESM (Pattern B) or not (Pattern A)
31
31
  */
32
32
  export declare function validateRegistrationPlacement(result: string, tagName: string, isESModule: boolean): void;
33
+ /**
34
+ * Returns whether a tag appears in the correct customElements.js placement.
35
+ * ESM entries may either appear textually inside/after DOMContentLoaded or
36
+ * inside an array declared before DOMContentLoaded and consumed by a for-of
37
+ * loop inside the listener, as Firefox 152 Beta does for acornElements.
38
+ */
39
+ export declare function isTagInCorrectCustomElementsPlacement(content: string, tagName: string, isESModule: boolean): boolean;
@@ -4,6 +4,7 @@
4
4
  * Used after both AST and legacy code paths to avoid duplicating logic.
5
5
  */
6
6
  import { FurnaceError } from '../errors/furnace.js';
7
+ import { stripJsComments } from '../utils/regex.js';
7
8
  /**
8
9
  * Regex for valid custom element tag names. A valid name is lowercase, starts
9
10
  * with a letter, and contains one or more hyphen-separated groups where each
@@ -36,25 +37,41 @@ export function validateTagName(tagName) {
36
37
  * @param isESModule - Whether the module uses ESM (Pattern B) or not (Pattern A)
37
38
  */
38
39
  export function validateRegistrationPlacement(result, tagName, isESModule) {
39
- const dclPattern = /document\.addEventListener\(\s*["']DOMContentLoaded["']/;
40
40
  const insertedPos = result.lastIndexOf(`"${tagName}"`);
41
41
  if (insertedPos === -1)
42
42
  return;
43
- const contentBeforeTag = result.slice(0, insertedPos);
44
- const hasDCLBefore = dclPattern.test(contentBeforeTag);
45
- if (isESModule && !hasDCLBefore && !isTagInArrayConsumedInsideDOMContentLoaded(result, tagName)) {
43
+ if (!isTagInCorrectCustomElementsPlacement(result, tagName, isESModule)) {
44
+ if (!isESModule) {
45
+ throw new FurnaceError(`${tagName} was registered in the DOMContentLoaded/importESModule block (Pattern B) instead of the loadSubScript block (Pattern A). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
46
+ }
46
47
  throw new FurnaceError(`${tagName} was registered in the loadSubScript block (Pattern A) instead of the DOMContentLoaded/importESModule block (Pattern B). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
47
48
  }
48
- if (!isESModule && hasDCLBefore) {
49
- throw new FurnaceError(`${tagName} was registered in the DOMContentLoaded/importESModule block (Pattern B) instead of the loadSubScript block (Pattern A). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
49
+ }
50
+ /**
51
+ * Returns whether a tag appears in the correct customElements.js placement.
52
+ * ESM entries may either appear textually inside/after DOMContentLoaded or
53
+ * inside an array declared before DOMContentLoaded and consumed by a for-of
54
+ * loop inside the listener, as Firefox 152 Beta does for acornElements.
55
+ */
56
+ export function isTagInCorrectCustomElementsPlacement(content, tagName, isESModule) {
57
+ const stripped = stripJsComments(content);
58
+ const tagPattern = new RegExp(`["']${escapeRegex(tagName)}["']`);
59
+ const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(stripped);
60
+ if (!dclMatch) {
61
+ return !isESModule && tagPattern.test(stripped);
62
+ }
63
+ const beforeDcl = stripped.slice(0, dclMatch.index);
64
+ const afterDcl = stripped.slice(dclMatch.index);
65
+ const tagBeforeDcl = tagPattern.test(beforeDcl);
66
+ const tagAfterDcl = tagPattern.test(afterDcl);
67
+ if (!isESModule) {
68
+ return tagBeforeDcl && !tagAfterDcl;
50
69
  }
70
+ return (tagAfterDcl || isTagInArrayConsumedInsideDOMContentLoaded(stripped, dclMatch.index, tagName));
51
71
  }
52
- function isTagInArrayConsumedInsideDOMContentLoaded(content, tagName) {
53
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(content);
54
- if (!dclMatch)
55
- return false;
56
- const beforeDcl = content.slice(0, dclMatch.index);
57
- const afterDcl = content.slice(dclMatch.index);
72
+ function isTagInArrayConsumedInsideDOMContentLoaded(content, domContentLoadedIdx, tagName) {
73
+ const beforeDcl = content.slice(0, domContentLoadedIdx);
74
+ const afterDcl = content.slice(domContentLoadedIdx);
58
75
  const consumedArrays = new Set();
59
76
  const forOfPattern = /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g;
60
77
  let match;
@@ -9,6 +9,7 @@ import { stripJsComments } from '../utils/regex.js';
9
9
  import { getProjectPaths, loadConfig } from './config.js';
10
10
  import { getFurnacePaths } from './furnace-config.js';
11
11
  import { CUSTOM_ELEMENTS_JS, FTL_DIR, JAR_MN } from './furnace-constants.js';
12
+ import { isTagInCorrectCustomElementsPlacement } from './furnace-registration-validate.js';
12
13
  import { getTokensCssPath } from './token-manager.js';
13
14
  /**
14
15
  * Validates that all Furnace-managed .mjs components are registered in the
@@ -27,21 +28,13 @@ export async function validateRegistrationPatterns(root, config) {
27
28
  return issues;
28
29
  }
29
30
  const content = await readText(filePath);
30
- // Find the DOMContentLoaded block boundary (handles multi-line addEventListener)
31
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(content);
32
- if (!dclMatch) {
33
- return issues;
34
- }
35
- const domContentLoadedIdx = dclMatch.index;
36
31
  // Get all custom component tag names that use .mjs (all custom components do)
37
32
  for (const [name, customConfig] of Object.entries(config.custom)) {
38
33
  if (!customConfig.register)
39
34
  continue;
40
- // Check if this tag is referenced before the DOMContentLoaded block
41
- const contentBeforeDCL = stripJsComments(content.slice(0, domContentLoadedIdx));
42
- const tagPattern = new RegExp(`"${name}"`);
43
- if (tagPattern.test(contentBeforeDCL) &&
44
- !isArrayDeclaredBeforeDclAndConsumedInsideDcl(content, domContentLoadedIdx, name)) {
35
+ const stripped = stripJsComments(content);
36
+ const tagPattern = new RegExp(`["']${escapeRegex(name)}["']`);
37
+ if (tagPattern.test(stripped) && !isTagInCorrectCustomElementsPlacement(content, name, true)) {
45
38
  issues.push({
46
39
  component: name,
47
40
  severity: 'error',
@@ -52,25 +45,6 @@ export async function validateRegistrationPatterns(root, config) {
52
45
  }
53
46
  return issues;
54
47
  }
55
- function isArrayDeclaredBeforeDclAndConsumedInsideDcl(content, domContentLoadedIdx, tagName) {
56
- const contentBeforeDCL = stripJsComments(content.slice(0, domContentLoadedIdx));
57
- const contentAfterDCL = stripJsComments(content.slice(domContentLoadedIdx));
58
- const consumedArrays = new Set();
59
- const forOfPattern = /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g;
60
- let match;
61
- while ((match = forOfPattern.exec(contentAfterDCL)) !== null) {
62
- if (match[1])
63
- consumedArrays.add(match[1]);
64
- }
65
- for (const arrayName of consumedArrays) {
66
- const declarationPattern = new RegExp(`(?:const|let|var)\\s+${escapeRegex(arrayName)}\\s*=\\s*\\[([\\s\\S]*?)\\];`);
67
- const declaration = declarationPattern.exec(contentBeforeDCL);
68
- if (declaration?.[1] && new RegExp(`["']${escapeRegex(tagName)}["']`).test(declaration[1])) {
69
- return true;
70
- }
71
- }
72
- return false;
73
- }
74
48
  function escapeRegex(value) {
75
49
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
50
  }
@@ -173,13 +147,7 @@ export async function checkRegistrationConsistency(root, name, config, ftlDir) {
173
147
  status.customElementsPresent =
174
148
  ceContent.includes(`"${name}"`) || ceContent.includes(`'${name}'`);
175
149
  if (status.customElementsPresent) {
176
- // Check it's in the correct block (after DOMContentLoaded)
177
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(ceContent);
178
- if (dclMatch) {
179
- const afterDcl = ceContent.slice(dclMatch.index);
180
- status.customElementsCorrectBlock =
181
- afterDcl.includes(`"${name}"`) || afterDcl.includes(`'${name}'`);
182
- }
150
+ status.customElementsCorrectBlock = isTagInCorrectCustomElementsPlacement(ceContent, name, true);
183
151
  }
184
152
  }
185
153
  return status;
@@ -2,6 +2,7 @@
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { GitError, GitIndexingTimeoutError, GitIndexLockError, PatchApplyError, } from '../errors/git.js';
5
+ import { elapsedSince } from '../utils/elapsed.js';
5
6
  import { toError } from '../utils/errors.js';
6
7
  import { pathExists, removeFile } from '../utils/fs.js';
7
8
  import { verbose } from '../utils/logger.js';
@@ -158,6 +159,7 @@ async function stageAllFilesChunked(dir, scan, options = {}) {
158
159
  * SIGINT'd mid-way assuming the process had stalled.
159
160
  */
160
161
  const GIT_ADD_HEARTBEAT_MS = 15_000;
162
+ const GIT_COMMIT_HEARTBEAT_MS = 15_000;
161
163
  async function scanTopLevelSource(dir) {
162
164
  const entries = await readdir(dir, { withFileTypes: true });
163
165
  return {
@@ -243,6 +245,21 @@ export async function stageAllFiles(dir, options = {}) {
243
245
  clearInterval(heartbeatTimer);
244
246
  }
245
247
  }
248
+ async function createInitialSourceCommit(dir, reportProgress) {
249
+ const startedAt = Date.now();
250
+ reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
251
+ const heartbeat = setInterval(() => {
252
+ reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
253
+ }, GIT_COMMIT_HEARTBEAT_MS);
254
+ heartbeat.unref();
255
+ try {
256
+ await git(['commit', '-m', 'Initial Firefox source'], dir);
257
+ }
258
+ finally {
259
+ clearInterval(heartbeat);
260
+ }
261
+ reportProgress(`Initial Firefox source commit created (${elapsedSince(startedAt)} elapsed).`);
262
+ }
246
263
  /**
247
264
  * Initializes a new git repository with an orphan branch.
248
265
  * @param dir - Directory to initialize
@@ -279,9 +296,8 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
279
296
  throw await maybeWrapIndexLockError(dir, error);
280
297
  }
281
298
  // Create initial commit
282
- reportProgress('Creating initial Firefox source commit...');
283
299
  try {
284
- await git(['commit', '-m', 'Initial Firefox source'], dir);
300
+ await createInitialSourceCommit(dir, reportProgress);
285
301
  }
286
302
  catch (error) {
287
303
  throw await maybeWrapIndexLockError(dir, error);
@@ -316,9 +332,8 @@ export async function resumeRepository(dir, options = {}) {
316
332
  throw await maybeWrapIndexLockError(dir, error);
317
333
  }
318
334
  // Create initial commit
319
- reportProgress('Creating initial Firefox source commit...');
320
335
  try {
321
- await git(['commit', '-m', 'Initial Firefox source'], dir);
336
+ await createInitialSourceCommit(dir, reportProgress);
322
337
  }
323
338
  catch (error) {
324
339
  throw await maybeWrapIndexLockError(dir, error);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Normalizes generated patch files for repository whitespace checks.
3
+ *
4
+ * Unified diffs conventionally encode a blank context line as a physical line
5
+ * containing one space. `git apply` also accepts the same hunk as an empty
6
+ * physical line, while repository-level `git diff --check` flags the
7
+ * single-space artifact as trailing whitespace in `patches/*.patch`.
8
+ */
9
+ export declare function normalizePatchArtifact(content: string): string;
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Normalizes generated patch files for repository whitespace checks.
4
+ *
5
+ * Unified diffs conventionally encode a blank context line as a physical line
6
+ * containing one space. `git apply` also accepts the same hunk as an empty
7
+ * physical line, while repository-level `git diff --check` flags the
8
+ * single-space artifact as trailing whitespace in `patches/*.patch`.
9
+ */
10
+ export function normalizePatchArtifact(content) {
11
+ return content.replace(/^ $/gm, '');
12
+ }
13
+ //# sourceMappingURL=patch-artifact-normalize.js.map
@@ -4,6 +4,7 @@ import { toError } from '../utils/errors.js';
4
4
  import { pathExists, readText, writeText } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
6
  import { withPatchDirectoryLock } from './patch-apply.js';
7
+ import { normalizePatchArtifact } from './patch-artifact-normalize.js';
7
8
  import { loadPatchesManifest, savePatchesManifest } from './patch-manifest.js';
8
9
  import { buildProjectedManifest, enforcePatchPolicy } from './patch-policy.js';
9
10
  /**
@@ -38,7 +39,7 @@ export async function updatePatchAndMetadata(patchesDir, filename, newContent, u
38
39
  }
39
40
  let patchWritten = false;
40
41
  try {
41
- await writeText(patchPath, newContent);
42
+ await writeText(patchPath, normalizePatchArtifact(newContent));
42
43
  patchWritten = true;
43
44
  await savePatchesManifest(patchesDir, manifest);
44
45
  }
@@ -6,6 +6,7 @@ import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
7
  import { PATCH_CATEGORIES } from '../utils/validation.js';
8
8
  import { discoverPatches, withPatchDirectoryLock } from './patch-apply.js';
9
+ import { normalizePatchArtifact } from './patch-artifact-normalize.js';
9
10
  import { findAllPatchesForFilesWithDetails, } from './patch-export-coverage.js';
10
11
  import { addPatchToManifest, loadPatchesManifest, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest.js';
11
12
  import { allocatePolicyOrder, enforcePatchPolicy } from './patch-policy.js';
@@ -95,7 +96,7 @@ export async function commitExportedPatch(input) {
95
96
  }
96
97
  }
97
98
  try {
98
- await writeText(patchPath, input.diff);
99
+ await writeText(patchPath, normalizePatchArtifact(input.diff));
99
100
  await addPatchToManifest(input.patchesDir, plan.metadata, plan.supersededPatches.map((p) => p.filename));
100
101
  for (const oldPatch of plan.supersededPatches) {
101
102
  await removeFile(oldPatch.path);
@@ -195,7 +196,7 @@ export async function findExistingPatchForFile(patchesDir, filePath) {
195
196
  * @param newContent - New patch content
196
197
  */
197
198
  export async function updatePatch(patchPath, newContent) {
198
- await writeText(patchPath, newContent);
199
+ await writeText(patchPath, normalizePatchArtifact(newContent));
199
200
  }
200
201
  /**
201
202
  * Deletes a patch file and removes it from the manifest.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Re-exports all command-related types from focused sub-modules.
3
3
  */
4
- export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchLintIgnoreOptions, PatchMoveFilesOptions, PatchRenameOptions, PatchReorderOptions, PatchStagedDependencyOptions, PatchTierOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
4
+ export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchLintIgnoreOptions, PatchMoveFilesOptions, PatchRenameOptions, PatchReorderOptions, PatchStagedDependencyOptions, PatchTierOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, SourceSetOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
5
5
  export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, PatchStagedDependencies, PatchStagedForwardImport, } from './patches.js';
6
6
  export type { DoctorCheck, ProjectStatus, TokenCoverageFileEntry, TokenCoverageReport, } from './project.js';
@@ -17,7 +17,7 @@ export interface SetupOptions {
17
17
  binaryName?: string;
18
18
  /** Firefox version to base on */
19
19
  firefoxVersion?: string;
20
- /** Firefox product type (firefox, firefox-esr, firefox-beta) */
20
+ /** Firefox product type (firefox, firefox-esr, firefox-beta, firefox-devedition) */
21
21
  product?: FirefoxProduct;
22
22
  /** Overwrite existing configuration without prompting */
23
23
  force?: boolean;
@@ -31,6 +31,19 @@ export interface DownloadOptions {
31
31
  /** Force re-download, deleting existing engine/ */
32
32
  force?: boolean;
33
33
  }
34
+ /**
35
+ * Options for the source command.
36
+ */
37
+ export interface SourceSetOptions {
38
+ /** Firefox version to set */
39
+ version: string;
40
+ /** Firefox product type */
41
+ product: FirefoxProduct;
42
+ /** Optional pinned SHA-256 for the resolved source archive */
43
+ sha256?: string;
44
+ /** Clear any existing pinned SHA-256 */
45
+ clearSha256?: boolean;
46
+ }
34
47
  /**
35
48
  * Options for the build command.
36
49
  */
@@ -651,6 +664,8 @@ export interface DoctorOptions {
651
664
  * and side-effect-free.
652
665
  */
653
666
  repairFurnace?: boolean;
667
+ /** Run extra post-rebase checks for common Firefox registration surfaces. */
668
+ postRebaseAudit?: boolean;
654
669
  }
655
670
  /**
656
671
  * Global CLI options available to all commands.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Firefox product type for downloads.
3
3
  */
4
- export type FirefoxProduct = 'firefox' | 'firefox-esr' | 'firefox-beta';
4
+ export type FirefoxProduct = 'firefox' | 'firefox-esr' | 'firefox-beta' | 'firefox-devedition';
5
5
  /**
6
6
  * Firefox version configuration.
7
7
  */
@@ -0,0 +1,4 @@
1
+ /** Formats elapsed milliseconds for progress messages. */
2
+ export declare function formatElapsed(ms: number): string;
3
+ /** Returns formatted elapsed time since a start timestamp from Date.now(). */
4
+ export declare function elapsedSince(startedAt: number): string;
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /** Formats elapsed milliseconds for progress messages. */
3
+ export function formatElapsed(ms) {
4
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
5
+ const minutes = Math.floor(totalSeconds / 60);
6
+ const seconds = totalSeconds % 60;
7
+ if (minutes === 0)
8
+ return `${seconds}s`;
9
+ return `${minutes}m ${seconds}s`;
10
+ }
11
+ /** Returns formatted elapsed time since a start timestamp from Date.now(). */
12
+ export function elapsedSince(startedAt) {
13
+ return formatElapsed(Date.now() - startedAt);
14
+ }
15
+ //# sourceMappingURL=elapsed.js.map
@@ -71,7 +71,7 @@ export declare function assertObject(value: unknown, name: string): asserts valu
71
71
  export declare function isValidFirefoxVersion(version: string): boolean;
72
72
  /**
73
73
  * Validates a Firefox product string.
74
- * Accepts: firefox, firefox-esr, firefox-beta
74
+ * Accepts: firefox, firefox-esr, firefox-beta, firefox-devedition
75
75
  */
76
76
  export declare function isValidFirefoxProduct(product: string): boolean;
77
77
  /**
@@ -108,7 +108,7 @@ export declare function inferProductFromVersion(version: string): 'firefox' | 'f
108
108
  *
109
109
  * Rules:
110
110
  * - `firefox-esr` requires an ESR version (e.g. "140.9.0esr", "128.0.1esr").
111
- * - `firefox-beta` requires a beta version (e.g. "147.0b1").
111
+ * - `firefox-beta` and `firefox-devedition` require a beta version (e.g. "147.0b1").
112
112
  * - `firefox` (stable) rejects both ESR and beta version strings.
113
113
  *
114
114
  * @returns An error message if incompatible, or undefined if valid.
@@ -106,10 +106,10 @@ export function isValidFirefoxVersion(version) {
106
106
  }
107
107
  /**
108
108
  * Validates a Firefox product string.
109
- * Accepts: firefox, firefox-esr, firefox-beta
109
+ * Accepts: firefox, firefox-esr, firefox-beta, firefox-devedition
110
110
  */
111
111
  export function isValidFirefoxProduct(product) {
112
- return ['firefox', 'firefox-esr', 'firefox-beta'].includes(product);
112
+ return ['firefox', 'firefox-esr', 'firefox-beta', 'firefox-devedition'].includes(product);
113
113
  }
114
114
  /**
115
115
  * Valid project license SPDX identifiers.
@@ -161,7 +161,7 @@ export function inferProductFromVersion(version) {
161
161
  *
162
162
  * Rules:
163
163
  * - `firefox-esr` requires an ESR version (e.g. "140.9.0esr", "128.0.1esr").
164
- * - `firefox-beta` requires a beta version (e.g. "147.0b1").
164
+ * - `firefox-beta` and `firefox-devedition` require a beta version (e.g. "147.0b1").
165
165
  * - `firefox` (stable) rejects both ESR and beta version strings.
166
166
  *
167
167
  * @returns An error message if incompatible, or undefined if valid.
@@ -177,9 +177,9 @@ export function validateFirefoxProductVersionCompatibility(version, product) {
177
177
  }
178
178
  break;
179
179
  case 'firefox-beta':
180
+ case 'firefox-devedition':
180
181
  if (!versionIsBeta) {
181
- return (`Product "firefox-beta" requires a beta version (e.g. "147.0b1"), ` +
182
- `but got "${version}"`);
182
+ return (`Product "${product}" requires a beta version (e.g. "147.0b1"), ` + `but got "${version}"`);
183
183
  }
184
184
  break;
185
185
  case 'firefox':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",