@hominis/fireforge 0.27.1 → 0.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -5
- package/dist/src/cli.js +5 -1
- package/dist/src/commands/build.js +61 -1
- package/dist/src/commands/doctor-working-tree.js +5 -1
- package/dist/src/commands/download.js +178 -112
- package/dist/src/commands/export-all.js +3 -2
- package/dist/src/commands/export-flow.d.ts +2 -0
- package/dist/src/commands/export-flow.js +2 -0
- package/dist/src/commands/export.js +5 -4
- package/dist/src/commands/import.js +2 -1
- package/dist/src/commands/re-export.js +6 -6
- package/dist/src/commands/rebase/continue.js +2 -0
- package/dist/src/commands/rebase/index.d.ts +2 -2
- package/dist/src/commands/rebase/index.js +9 -4
- package/dist/src/commands/rebase/patch-loop.js +5 -5
- package/dist/src/commands/rebase/summary.js +7 -2
- package/dist/src/commands/resolve.js +2 -1
- package/dist/src/commands/status-output.d.ts +13 -0
- package/dist/src/commands/status-output.js +186 -0
- package/dist/src/commands/status.js +4 -247
- package/dist/src/commands/verify.js +32 -16
- package/dist/src/core/build-prepare.js +12 -4
- package/dist/src/core/firefox-archive.js +7 -3
- package/dist/src/core/firefox-cache.d.ts +1 -1
- package/dist/src/core/firefox-cache.js +12 -5
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/git.js +7 -2
- package/dist/src/core/ownership-table.d.ts +3 -1
- package/dist/src/core/ownership-table.js +31 -7
- package/dist/src/core/patch-export.d.ts +4 -0
- package/dist/src/core/patch-export.js +4 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
- package/dist/src/core/patch-manifest-consistency.js +4 -2
- package/dist/src/core/patch-manifest-query.d.ts +4 -3
- package/dist/src/core/patch-manifest-query.js +12 -4
- package/dist/src/core/patch-manifest-validate.js +22 -4
- package/dist/src/core/patch-source-metadata.d.ts +8 -0
- package/dist/src/core/patch-source-metadata.js +17 -0
- package/dist/src/core/rebase-session.d.ts +8 -3
- package/dist/src/core/rebase-session.js +1 -1
- package/dist/src/core/status-classify.d.ts +4 -1
- package/dist/src/core/status-classify.js +4 -5
- package/dist/src/errors/download.d.ts +11 -0
- package/dist/src/errors/download.js +33 -1
- package/dist/src/types/commands/patches.d.ts +9 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.27.3
|
|
4
|
+
|
|
5
|
+
- Fixed `firefox-devedition` source downloads so archive resolution uses `/pub/devedition/releases`.
|
|
6
|
+
- Kept existing `engine/` trees intact during `download --force` until the replacement archive downloads, validates, and extracts successfully.
|
|
7
|
+
- Improved checksum mismatch diagnostics with resolved URL and product context.
|
|
8
|
+
|
|
3
9
|
## 0.27.0
|
|
4
10
|
|
|
5
11
|
- Added first-class `firefox-devedition` source support and atomic `fireforge source set`.
|
|
12
|
+
- Fixed `source set --version` so the subcommand accepts both space and equals forms without colliding with the root CLI version flag.
|
|
13
|
+
- Added `sourceProduct` and `sourceVersion` patch metadata while preserving `sourceEsrVersion` as a deprecated compatibility alias.
|
|
14
|
+
- Renamed source-rebase reporting away from ESR-only wording and clarified summaries with total patch counts.
|
|
15
|
+
- Unified status, ownership, doctor, and verify worktree classification, including an explained patch-owned drift state for manually resolved or re-exported files.
|
|
16
|
+
- Hardened build diagnostics so backend regeneration success/failure and failed make/mach commands include exit codes, tails, log hints, and verbose rerun suggestions.
|
|
6
17
|
- Improved `download --force` git indexing progress with phase, count, and heartbeat output.
|
|
18
|
+
- Added cache metadata progress for archive validation, SHA-256 calculation, and sidecar JSON writes.
|
|
7
19
|
- Added elapsed progress for extraction, initial source commits, and rebase/re-export patch refreshes.
|
|
8
20
|
- Added `re-export --files --allow-shrink` so patch ownership shrinkage is refused unless explicitly acknowledged, with clearer dry-run previews.
|
|
9
21
|
- Surfaced likely new sibling files during plain re-export and aligned verify/status ownership reporting for unowned worktree changes.
|
package/README.md
CHANGED
|
@@ -11,12 +11,12 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
|
|
|
11
11
|
## What It Does
|
|
12
12
|
|
|
13
13
|
- **Patch based** Edit Firefox inside the `engine/` directory, then export changes into `.patch` files with manifest metadata.
|
|
14
|
-
- **
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
234
|
+
const machCommand = options.ui ? 'mach build faster' : 'mach build';
|
|
235
|
+
throw new BuildError(buildFailureDiagnostics(result, paths.engine, buildCheck.objDir, machCommand), machCommand);
|
|
176
236
|
}
|
|
177
237
|
// Tool-managed branding edits that land on `browser/moz.configure`
|
|
178
238
|
// before the build cause mach's post-build guard to print one of two
|
|
@@ -20,6 +20,7 @@ function summarizeWorkingTreeChangeCount(changeCount) {
|
|
|
20
20
|
function formatManagedDetail(counts) {
|
|
21
21
|
return [
|
|
22
22
|
counts.patchBacked > 0 ? `${counts.patchBacked} patch-backed` : null,
|
|
23
|
+
counts.patchOwnedDrift > 0 ? `${counts.patchOwnedDrift} patch-owned drift` : null,
|
|
23
24
|
counts.branding > 0 ? `${counts.branding} branding` : null,
|
|
24
25
|
counts.furnace > 0 ? `${counts.furnace} furnace` : null,
|
|
25
26
|
]
|
|
@@ -57,6 +58,7 @@ export async function inspectEngineWorkingTree(ctx) {
|
|
|
57
58
|
branding: 0,
|
|
58
59
|
furnace: 0,
|
|
59
60
|
patchBacked: 0,
|
|
61
|
+
patchOwnedDrift: 0,
|
|
60
62
|
conflict: 0,
|
|
61
63
|
unmanaged: 0,
|
|
62
64
|
};
|
|
@@ -67,6 +69,8 @@ export async function inspectEngineWorkingTree(ctx) {
|
|
|
67
69
|
counts.furnace++;
|
|
68
70
|
else if (entry.classification === 'patch-backed')
|
|
69
71
|
counts.patchBacked++;
|
|
72
|
+
else if (entry.classification === 'patch-owned-drift')
|
|
73
|
+
counts.patchOwnedDrift++;
|
|
70
74
|
else if (entry.classification === 'conflict')
|
|
71
75
|
counts.conflict++;
|
|
72
76
|
else
|
|
@@ -75,7 +79,7 @@ export async function inspectEngineWorkingTree(ctx) {
|
|
|
75
79
|
if (counts.conflict > 0) {
|
|
76
80
|
return warning('Engine working tree', `Engine working tree has ${counts.conflict} cross-patch ownership conflict${counts.conflict === 1 ? '' : 's'}. Multiple patches in patches.json claim the same file.`, 'Run "fireforge status --ownership" to see the conflicting patches, then run "fireforge verify" and resolve the overlap.');
|
|
77
81
|
}
|
|
78
|
-
const managedTotal = counts.branding + counts.furnace + counts.patchBacked;
|
|
82
|
+
const managedTotal = counts.branding + counts.furnace + counts.patchBacked + counts.patchOwnedDrift;
|
|
79
83
|
if (counts.unmanaged === 0) {
|
|
80
84
|
const managedDetail = formatManagedDetail(counts);
|
|
81
85
|
return {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { rename } from 'node:fs/promises';
|
|
2
4
|
import { join } from 'node:path';
|
|
3
5
|
import { getProjectPaths, loadConfig, updateState } from '../core/config.js';
|
|
4
6
|
import { withFileLock } from '../core/file-lock.js';
|
|
@@ -111,6 +113,148 @@ function closeRestoreSpinner(restoreSpinner, result) {
|
|
|
111
113
|
}
|
|
112
114
|
restoreSpinner.stop('Patch-touched files restored');
|
|
113
115
|
}
|
|
116
|
+
async function clearStaleFurnaceApplyState(projectRoot) {
|
|
117
|
+
// --force installs a new baseCommit, which invalidates every applied
|
|
118
|
+
// checksum in furnace-state.json. Preserve pendingRepair: authoring-side
|
|
119
|
+
// rollback markers describe unresolved component workspace state and
|
|
120
|
+
// should survive an engine refresh.
|
|
121
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
122
|
+
if (await pathExists(furnacePaths.furnaceState)) {
|
|
123
|
+
await updateFurnaceState(projectRoot, (current) => ({
|
|
124
|
+
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function activateReplacementEngine(args) {
|
|
129
|
+
const { engineDir, replacementDir, backupDir } = args;
|
|
130
|
+
await rename(engineDir, backupDir);
|
|
131
|
+
try {
|
|
132
|
+
await rename(replacementDir, engineDir);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
try {
|
|
136
|
+
await rename(backupDir, engineDir);
|
|
137
|
+
}
|
|
138
|
+
catch (restoreError) {
|
|
139
|
+
const cause = toError(restoreError);
|
|
140
|
+
warn(`Could not restore previous engine after replacement activation failed. Previous engine backup remains at ${backupDir}. Remove ${engineDir} if it exists, then move the backup back to engine/.`);
|
|
141
|
+
verbose(`Engine restore failure detail: ${cause.message}`);
|
|
142
|
+
if (cause.stack) {
|
|
143
|
+
verbose(cause.stack);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function restorePreviousEngine(args) {
|
|
150
|
+
const { engineDir, backupDir, reason } = args;
|
|
151
|
+
const cause = toError(reason);
|
|
152
|
+
verbose(`Restoring previous engine after failed forced download: ${cause.message}`);
|
|
153
|
+
try {
|
|
154
|
+
await removeDir(engineDir);
|
|
155
|
+
await rename(backupDir, engineDir);
|
|
156
|
+
warn('Restored the previous engine/ after the forced replacement failed.');
|
|
157
|
+
}
|
|
158
|
+
catch (restoreError) {
|
|
159
|
+
const restoreCause = toError(restoreError);
|
|
160
|
+
warn(`Could not restore the previous engine automatically. Previous engine backup remains at ${backupDir}. Remove the failed engine/ and move that backup back to engine/ before retrying.`);
|
|
161
|
+
verbose(`Engine restore failure detail: ${restoreCause.message}`);
|
|
162
|
+
if (restoreCause.stack) {
|
|
163
|
+
verbose(restoreCause.stack);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function downloadAndExtractFirefox(args) {
|
|
168
|
+
const { version, product, engineDir, cacheDir, sha256 } = args;
|
|
169
|
+
let s = spinner(`Downloading Firefox ${version}...`);
|
|
170
|
+
let lastPercent = 0;
|
|
171
|
+
const phaseState = { value: 'download' };
|
|
172
|
+
try {
|
|
173
|
+
await downloadFirefoxSource(version, product, engineDir, cacheDir, (downloaded, total) => {
|
|
174
|
+
if (total <= 0)
|
|
175
|
+
return;
|
|
176
|
+
const percent = Math.floor((downloaded / total) * 100);
|
|
177
|
+
if (percent !== lastPercent && percent % 5 === 0) {
|
|
178
|
+
s.message(`Downloading Firefox ${version}... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`);
|
|
179
|
+
lastPercent = percent;
|
|
180
|
+
}
|
|
181
|
+
}, (phase) => {
|
|
182
|
+
if (phase === 'extract' && phaseState.value === 'download') {
|
|
183
|
+
s.stop(`Firefox ${version} downloaded`);
|
|
184
|
+
phaseState.value = 'extract';
|
|
185
|
+
s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
|
|
186
|
+
}
|
|
187
|
+
}, sha256, (message) => {
|
|
188
|
+
s.message(message);
|
|
189
|
+
});
|
|
190
|
+
s.stop(phaseState.value === 'extract'
|
|
191
|
+
? `Firefox ${version} extracted`
|
|
192
|
+
: `Firefox ${version} downloaded`);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function initializeDownloadedEngine(args) {
|
|
200
|
+
const { projectRoot, patchesDir, version, engineDir, replacementActivated, backupEngineDir } = args;
|
|
201
|
+
// Finding #17: the git indexing phase of `download` can block for
|
|
202
|
+
// minutes on a ~600 MB Firefox tree. Emit a one-line heads-up banner
|
|
203
|
+
// before the spinner starts so CI logs show the expected duration.
|
|
204
|
+
try {
|
|
205
|
+
info('Indexing downloaded source into git (one-time; typically 3–5 minutes on a ~600 MB Firefox tree)...');
|
|
206
|
+
info('Git phase: initializing/resetting source repository metadata.');
|
|
207
|
+
const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
|
|
208
|
+
let baseCommit;
|
|
209
|
+
try {
|
|
210
|
+
await initRepository(engineDir, 'firefox', {
|
|
211
|
+
onProgress: (message) => {
|
|
212
|
+
gitSpinner.message(message);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
baseCommit = await getHead(engineDir);
|
|
216
|
+
gitSpinner.stop('Git repository initialized');
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
gitSpinner.error('Failed to initialize git repository');
|
|
220
|
+
warn(replacementActivated
|
|
221
|
+
? 'Replacement engine/ failed during baseline git initialization. FireForge will try to restore the previous engine.'
|
|
222
|
+
: 'engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
226
|
+
try {
|
|
227
|
+
const restoreResult = await cleanPatchTouchedFiles(engineDir, patchesDir);
|
|
228
|
+
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
restoreSpinner.error('Failed to restore patch-touched files');
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
if (replacementActivated) {
|
|
235
|
+
await clearStaleFurnaceApplyState(projectRoot);
|
|
236
|
+
}
|
|
237
|
+
await updateState(projectRoot, {
|
|
238
|
+
downloadedVersion: version,
|
|
239
|
+
baseCommit,
|
|
240
|
+
});
|
|
241
|
+
await noteUnappliedPatches(patchesDir);
|
|
242
|
+
if (backupEngineDir) {
|
|
243
|
+
await removeDir(backupEngineDir);
|
|
244
|
+
}
|
|
245
|
+
outro(`Firefox ${version} is ready!`);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (replacementActivated && backupEngineDir) {
|
|
249
|
+
await restorePreviousEngine({
|
|
250
|
+
engineDir,
|
|
251
|
+
backupDir: backupEngineDir,
|
|
252
|
+
reason: error,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
114
258
|
/**
|
|
115
259
|
* Runs the download command.
|
|
116
260
|
* @param projectRoot - Root directory of the project
|
|
@@ -118,14 +262,15 @@ function closeRestoreSpinner(restoreSpinner, result) {
|
|
|
118
262
|
*/
|
|
119
263
|
export async function downloadCommand(projectRoot, options) {
|
|
120
264
|
intro('FireForge Download');
|
|
121
|
-
|
|
122
|
-
const config = await loadConfig(projectRoot);
|
|
265
|
+
const config = await loadConfig(projectRoot), version = config.firefox.version;
|
|
123
266
|
const paths = getProjectPaths(projectRoot);
|
|
124
|
-
const version = config.firefox.version;
|
|
125
267
|
info(`Firefox version: ${version}`);
|
|
126
|
-
// Disk space pre-flight: Firefox source is ~5 GB
|
|
127
268
|
await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
|
|
128
269
|
await withFileLock(join(paths.fireforgeDir, 'download.fireforge.lock'), async () => {
|
|
270
|
+
let installEngineDir = paths.engine;
|
|
271
|
+
let replacementEngineDir;
|
|
272
|
+
let backupEngineDir;
|
|
273
|
+
let replacementActivated = false;
|
|
129
274
|
// Check if engine already exists
|
|
130
275
|
if (await pathExistsStrict(paths.engine)) {
|
|
131
276
|
if (!options.force) {
|
|
@@ -206,126 +351,47 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
206
351
|
}
|
|
207
352
|
throw new EngineExistsError(paths.engine);
|
|
208
353
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// subsequent `furnace apply` from reporting "up to date" against an
|
|
214
|
-
// engine that no longer contains any of the deployed files. Preserve
|
|
215
|
-
// pendingRepair: authoring-side rollback markers describe unresolved
|
|
216
|
-
// component workspace state and should survive an engine refresh.
|
|
217
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
218
|
-
if (await pathExists(furnacePaths.furnaceState)) {
|
|
219
|
-
await updateFurnaceState(projectRoot, (current) => ({
|
|
220
|
-
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
221
|
-
}));
|
|
222
|
-
}
|
|
354
|
+
replacementEngineDir = `${paths.engine}.replacement-${randomUUID()}`;
|
|
355
|
+
backupEngineDir = `${paths.engine}.backup-${randomUUID()}`;
|
|
356
|
+
installEngineDir = replacementEngineDir;
|
|
357
|
+
warn('Preparing replacement engine directory; existing engine/ will remain in place until the new archive downloads, validates, and extracts.');
|
|
223
358
|
}
|
|
224
359
|
// Ensure cache directory exists
|
|
225
360
|
const cacheDir = join(paths.fireforgeDir, 'cache');
|
|
226
361
|
await ensureDir(cacheDir);
|
|
227
|
-
// Phase-switched spinners: the download phase runs with the byte-count
|
|
228
|
-
// progress callbacks below; the extract phase is blocking tar-xz and
|
|
229
|
-
// has no incremental progress, but it can take 30–90s on a ~600 MB
|
|
230
|
-
// Firefox tree, so it gets its own spinner message. Before the phase
|
|
231
|
-
// split, a single "Downloading Firefox … 100%" spinner covered both
|
|
232
|
-
// — the first-run setup looked hung precisely when the archive had
|
|
233
|
-
// already reached disk and `tar` was the long pole.
|
|
234
|
-
let s = spinner(`Downloading Firefox ${version}...`);
|
|
235
|
-
let lastPercent = 0;
|
|
236
|
-
const phaseState = { value: 'download' };
|
|
237
362
|
try {
|
|
238
|
-
await
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
lastPercent = percent;
|
|
245
|
-
}
|
|
246
|
-
}, (phase) => {
|
|
247
|
-
if (phase === 'extract' && phaseState.value === 'download') {
|
|
248
|
-
s.stop(`Firefox ${version} downloaded`);
|
|
249
|
-
phaseState.value = 'extract';
|
|
250
|
-
s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
|
|
251
|
-
}
|
|
252
|
-
}, config.firefox.sha256, (message) => {
|
|
253
|
-
if (phaseState.value === 'extract')
|
|
254
|
-
s.message(message);
|
|
363
|
+
await downloadAndExtractFirefox({
|
|
364
|
+
version,
|
|
365
|
+
product: config.firefox.product,
|
|
366
|
+
engineDir: installEngineDir,
|
|
367
|
+
cacheDir,
|
|
368
|
+
...(config.firefox.sha256 !== undefined ? { sha256: config.firefox.sha256 } : {}),
|
|
255
369
|
});
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
370
|
+
if (replacementEngineDir && backupEngineDir) {
|
|
371
|
+
warn('Activating replacement engine directory...');
|
|
372
|
+
await activateReplacementEngine({
|
|
373
|
+
engineDir: paths.engine,
|
|
374
|
+
replacementDir: replacementEngineDir,
|
|
375
|
+
backupDir: backupEngineDir,
|
|
376
|
+
});
|
|
377
|
+
replacementActivated = true;
|
|
378
|
+
installEngineDir = paths.engine;
|
|
261
379
|
}
|
|
262
380
|
}
|
|
263
381
|
catch (error) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// Finding #17: the git indexing phase of `download` can block for
|
|
268
|
-
// minutes on a ~600 MB Firefox tree — the spinner updates less often
|
|
269
|
-
// than operators expect during the monolithic `git add -A` pass, and
|
|
270
|
-
// non-TTY shells see long stretches of silence. Emit a one-line
|
|
271
|
-
// heads-up banner BEFORE the spinner starts so even a log-scraping
|
|
272
|
-
// CI job notes the expected duration. The progress callbacks below
|
|
273
|
-
// still fire as usual; this is an additional up-front signal, not a
|
|
274
|
-
// replacement.
|
|
275
|
-
info('Indexing downloaded source into git (one-time; typically 3–5 minutes on a ~600 MB Firefox tree)...');
|
|
276
|
-
// Initialize git repository
|
|
277
|
-
const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
|
|
278
|
-
let baseCommit;
|
|
279
|
-
try {
|
|
280
|
-
await initRepository(paths.engine, 'firefox', {
|
|
281
|
-
// Same one-authority rule as the resume path above: the non-TTY
|
|
282
|
-
// spinner fallback already emits `step(msg)` internally, so
|
|
283
|
-
// calling `step()` in addition to `.message()` duplicated every
|
|
284
|
-
// git-init progress line in CI logs.
|
|
285
|
-
onProgress: (message) => {
|
|
286
|
-
gitSpinner.message(message);
|
|
287
|
-
},
|
|
288
|
-
});
|
|
289
|
-
baseCommit = await getHead(paths.engine);
|
|
290
|
-
gitSpinner.stop('Git repository initialized');
|
|
291
|
-
}
|
|
292
|
-
catch (error) {
|
|
293
|
-
gitSpinner.error('Failed to initialize git repository');
|
|
294
|
-
warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
|
|
295
|
-
throw error;
|
|
296
|
-
}
|
|
297
|
-
// Restore any patch-touched files that ended up dirty after the initial
|
|
298
|
-
// commit (e.g. line-ending normalisation or extraction artefacts) so that
|
|
299
|
-
// a subsequent `fireforge import` works without --force.
|
|
300
|
-
//
|
|
301
|
-
// Wrapped in a dedicated spinner because the restore can itself take
|
|
302
|
-
// tens of seconds on a ~600 MB Firefox tree: it walks every file in the
|
|
303
|
-
// patch manifest, calls `git status` / `git checkout` for each, and the
|
|
304
|
-
// eval's "download looks hung" report landed at least partly on this
|
|
305
|
-
// post-commit window. An operator watching the CLI needs to see that
|
|
306
|
-
// this phase is distinct from the preceding git-add work.
|
|
307
|
-
//
|
|
308
|
-
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
309
|
-
// downloadedVersion in state.json. The invariant we preserve is
|
|
310
|
-
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
311
|
-
// version only after the restore succeeds means a failed clean-up will
|
|
312
|
-
// re-enter the resume path on the next `fireforge download` rather than
|
|
313
|
-
// reporting success against a dirty engine.
|
|
314
|
-
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
315
|
-
try {
|
|
316
|
-
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
317
|
-
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
318
|
-
}
|
|
319
|
-
catch (error) {
|
|
320
|
-
restoreSpinner.error('Failed to restore patch-touched files');
|
|
382
|
+
if (replacementEngineDir) {
|
|
383
|
+
await removeDir(replacementEngineDir);
|
|
384
|
+
}
|
|
321
385
|
throw error;
|
|
322
386
|
}
|
|
323
|
-
await
|
|
324
|
-
|
|
325
|
-
|
|
387
|
+
await initializeDownloadedEngine({
|
|
388
|
+
projectRoot,
|
|
389
|
+
patchesDir: paths.patches,
|
|
390
|
+
version,
|
|
391
|
+
engineDir: installEngineDir,
|
|
392
|
+
replacementActivated,
|
|
393
|
+
...(backupEngineDir !== undefined ? { backupEngineDir } : {}),
|
|
326
394
|
});
|
|
327
|
-
await noteUnappliedPatches(paths.patches);
|
|
328
|
-
outro(`Firefox ${version} is ready!`);
|
|
329
395
|
});
|
|
330
396
|
}
|
|
331
397
|
/** Registers the download command on the CLI program. */
|
|
@@ -8,6 +8,7 @@ import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
|
8
8
|
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
9
9
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
|
|
10
10
|
import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
|
|
11
|
+
import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
|
|
11
12
|
import { GeneralError } from '../errors/base.js';
|
|
12
13
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
13
14
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
@@ -262,7 +263,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
262
263
|
name: patchName,
|
|
263
264
|
description,
|
|
264
265
|
filesAffected,
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
273
|
+
const warning = checkVersionCompatibility(getPatchSourceVersion(patch), currentVersion);
|
|
273
274
|
if (warning) {
|
|
274
275
|
warn(`${patch.filename}: ${warning}`);
|
|
275
276
|
}
|