@hominis/fireforge 0.27.2 → 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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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`.
|
|
@@ -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,57 @@ 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
|
+
}
|
|
114
167
|
async function downloadAndExtractFirefox(args) {
|
|
115
168
|
const { version, product, engineDir, cacheDir, sha256 } = args;
|
|
116
169
|
let s = spinner(`Downloading Firefox ${version}...`);
|
|
@@ -143,6 +196,65 @@ async function downloadAndExtractFirefox(args) {
|
|
|
143
196
|
throw error;
|
|
144
197
|
}
|
|
145
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
|
+
}
|
|
146
258
|
/**
|
|
147
259
|
* Runs the download command.
|
|
148
260
|
* @param projectRoot - Root directory of the project
|
|
@@ -155,6 +267,10 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
155
267
|
info(`Firefox version: ${version}`);
|
|
156
268
|
await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
|
|
157
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;
|
|
158
274
|
// Check if engine already exists
|
|
159
275
|
if (await pathExistsStrict(paths.engine)) {
|
|
160
276
|
if (!options.force) {
|
|
@@ -235,93 +351,47 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
235
351
|
}
|
|
236
352
|
throw new EngineExistsError(paths.engine);
|
|
237
353
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// subsequent `furnace apply` from reporting "up to date" against an
|
|
243
|
-
// engine that no longer contains any of the deployed files. Preserve
|
|
244
|
-
// pendingRepair: authoring-side rollback markers describe unresolved
|
|
245
|
-
// component workspace state and should survive an engine refresh.
|
|
246
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
247
|
-
if (await pathExists(furnacePaths.furnaceState)) {
|
|
248
|
-
await updateFurnaceState(projectRoot, (current) => ({
|
|
249
|
-
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
250
|
-
}));
|
|
251
|
-
}
|
|
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.');
|
|
252
358
|
}
|
|
253
359
|
// Ensure cache directory exists
|
|
254
360
|
const cacheDir = join(paths.fireforgeDir, 'cache');
|
|
255
361
|
await ensureDir(cacheDir);
|
|
256
|
-
await downloadAndExtractFirefox({
|
|
257
|
-
version,
|
|
258
|
-
product: config.firefox.product,
|
|
259
|
-
engineDir: paths.engine,
|
|
260
|
-
cacheDir,
|
|
261
|
-
...(config.firefox.sha256 !== undefined ? { sha256: config.firefox.sha256 } : {}),
|
|
262
|
-
});
|
|
263
|
-
// Finding #17: the git indexing phase of `download` can block for
|
|
264
|
-
// minutes on a ~600 MB Firefox tree — the spinner updates less often
|
|
265
|
-
// than operators expect during the monolithic `git add -A` pass, and
|
|
266
|
-
// non-TTY shells see long stretches of silence. Emit a one-line
|
|
267
|
-
// heads-up banner BEFORE the spinner starts so even a log-scraping
|
|
268
|
-
// CI job notes the expected duration. The progress callbacks below
|
|
269
|
-
// still fire as usual; this is an additional up-front signal, not a
|
|
270
|
-
// replacement.
|
|
271
|
-
info('Indexing downloaded source into git (one-time; typically 3–5 minutes on a ~600 MB Firefox tree)...');
|
|
272
|
-
info('Git phase: initializing/resetting source repository metadata.');
|
|
273
|
-
const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
|
|
274
|
-
let baseCommit;
|
|
275
362
|
try {
|
|
276
|
-
await
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
gitSpinner.message(message);
|
|
283
|
-
},
|
|
363
|
+
await downloadAndExtractFirefox({
|
|
364
|
+
version,
|
|
365
|
+
product: config.firefox.product,
|
|
366
|
+
engineDir: installEngineDir,
|
|
367
|
+
cacheDir,
|
|
368
|
+
...(config.firefox.sha256 !== undefined ? { sha256: config.firefox.sha256 } : {}),
|
|
284
369
|
});
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// a subsequent `fireforge import` works without --force.
|
|
296
|
-
//
|
|
297
|
-
// Wrapped in a dedicated spinner because the restore can itself take
|
|
298
|
-
// tens of seconds on a ~600 MB Firefox tree: it walks every file in the
|
|
299
|
-
// patch manifest, calls `git status` / `git checkout` for each, and the
|
|
300
|
-
// eval's "download looks hung" report landed at least partly on this
|
|
301
|
-
// post-commit window. An operator watching the CLI needs to see that
|
|
302
|
-
// this phase is distinct from the preceding git-add work.
|
|
303
|
-
//
|
|
304
|
-
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
305
|
-
// downloadedVersion in state.json. The invariant we preserve is
|
|
306
|
-
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
307
|
-
// version only after the restore succeeds means a failed clean-up will
|
|
308
|
-
// re-enter the resume path on the next `fireforge download` rather than
|
|
309
|
-
// reporting success against a dirty engine.
|
|
310
|
-
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
311
|
-
try {
|
|
312
|
-
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
313
|
-
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
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;
|
|
379
|
+
}
|
|
314
380
|
}
|
|
315
381
|
catch (error) {
|
|
316
|
-
|
|
382
|
+
if (replacementEngineDir) {
|
|
383
|
+
await removeDir(replacementEngineDir);
|
|
384
|
+
}
|
|
317
385
|
throw error;
|
|
318
386
|
}
|
|
319
|
-
await
|
|
320
|
-
|
|
321
|
-
|
|
387
|
+
await initializeDownloadedEngine({
|
|
388
|
+
projectRoot,
|
|
389
|
+
patchesDir: paths.patches,
|
|
390
|
+
version,
|
|
391
|
+
engineDir: installEngineDir,
|
|
392
|
+
replacementActivated,
|
|
393
|
+
...(backupEngineDir !== undefined ? { backupEngineDir } : {}),
|
|
322
394
|
});
|
|
323
|
-
await noteUnappliedPatches(paths.patches);
|
|
324
|
-
outro(`Firefox ${version} is ready!`);
|
|
325
395
|
});
|
|
326
396
|
}
|
|
327
397
|
/** Registers the download command on the CLI program. */
|
|
@@ -6,9 +6,13 @@ import { ConfigError } from '../errors/config.js';
|
|
|
6
6
|
import { parseObject } from '../utils/parse.js';
|
|
7
7
|
import { isValidFirefoxProduct } from '../utils/validation.js';
|
|
8
8
|
/**
|
|
9
|
-
* Base
|
|
9
|
+
* Base URLs for Firefox source archives on archive.mozilla.org.
|
|
10
10
|
*/
|
|
11
|
-
const
|
|
11
|
+
const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox/releases';
|
|
12
|
+
const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition/releases';
|
|
13
|
+
function getArchiveBaseUrl(product) {
|
|
14
|
+
return product === 'firefox-devedition' ? DEVEDITION_ARCHIVE_BASE_URL : FIREFOX_ARCHIVE_BASE_URL;
|
|
15
|
+
}
|
|
12
16
|
/**
|
|
13
17
|
* Validates raw JSON data as ArchiveMetadata.
|
|
14
18
|
* @param data - Unknown data to validate
|
|
@@ -55,7 +59,7 @@ export function resolveArchive(version, product = 'firefox') {
|
|
|
55
59
|
requestedVersion: version,
|
|
56
60
|
product,
|
|
57
61
|
archiveVersion,
|
|
58
|
-
url: `${
|
|
62
|
+
url: `${getArchiveBaseUrl(product)}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
|
|
59
63
|
filename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz`,
|
|
60
64
|
metadataFilename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz.json`,
|
|
61
65
|
};
|
|
@@ -7,7 +7,7 @@ import { createReadStream } from 'node:fs';
|
|
|
7
7
|
import { rename } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { pipeline } from 'node:stream/promises';
|
|
10
|
-
import {
|
|
10
|
+
import { ChecksumMismatchError } from '../errors/download.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
12
12
|
import { pathExists, readJson, removeFile, writeJson } from '../utils/fs.js';
|
|
13
13
|
import { verbose } from '../utils/logger.js';
|
|
@@ -115,7 +115,7 @@ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256, on
|
|
|
115
115
|
onCacheProgress?.(`Calculating source archive SHA-256 for ${archive.filename}...`);
|
|
116
116
|
const sha256 = await sha256File(tarballPath);
|
|
117
117
|
if (expectedSha256 && sha256 !== expectedSha256) {
|
|
118
|
-
throw new
|
|
118
|
+
throw new ChecksumMismatchError(archive.product, expectedSha256, sha256, archive.url);
|
|
119
119
|
}
|
|
120
120
|
onCacheProgress?.(`Writing source archive cache metadata for ${archive.metadataFilename}...`);
|
|
121
121
|
await writeJson(metadataPath, {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FirefoxProduct } from '../types/config.js';
|
|
1
2
|
import { FireForgeError } from './base.js';
|
|
2
3
|
/**
|
|
3
4
|
* Error thrown when Firefox source download fails.
|
|
@@ -8,6 +9,16 @@ export declare class DownloadError extends FireForgeError {
|
|
|
8
9
|
constructor(message: string, url?: string | undefined, cause?: Error);
|
|
9
10
|
get userMessage(): string;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Error thrown when a pinned Firefox source archive checksum does not match.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ChecksumMismatchError extends DownloadError {
|
|
16
|
+
readonly product: FirefoxProduct;
|
|
17
|
+
readonly expectedSha256: string;
|
|
18
|
+
readonly actualSha256: string;
|
|
19
|
+
constructor(product: FirefoxProduct, expectedSha256: string, actualSha256: string, url: string);
|
|
20
|
+
get userMessage(): string;
|
|
21
|
+
}
|
|
11
22
|
/**
|
|
12
23
|
* Error thrown when extraction of the downloaded archive fails.
|
|
13
24
|
*/
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// SPDX-License-Identifier: EUPL-1.2
|
|
2
1
|
import { FireForgeError } from './base.js';
|
|
3
2
|
import { ExitCode } from './codes.js';
|
|
4
3
|
/**
|
|
@@ -23,6 +22,39 @@ export class DownloadError extends FireForgeError {
|
|
|
23
22
|
return msg;
|
|
24
23
|
}
|
|
25
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Error thrown when a pinned Firefox source archive checksum does not match.
|
|
27
|
+
*/
|
|
28
|
+
export class ChecksumMismatchError extends DownloadError {
|
|
29
|
+
product;
|
|
30
|
+
expectedSha256;
|
|
31
|
+
actualSha256;
|
|
32
|
+
constructor(product, expectedSha256, actualSha256, url) {
|
|
33
|
+
super(`Downloaded archive SHA-256 mismatch: expected ${expectedSha256}, got ${actualSha256}`, url);
|
|
34
|
+
this.product = product;
|
|
35
|
+
this.expectedSha256 = expectedSha256;
|
|
36
|
+
this.actualSha256 = actualSha256;
|
|
37
|
+
}
|
|
38
|
+
get userMessage() {
|
|
39
|
+
let msg = `Download Error: Firefox source archive checksum mismatch.\n\n` +
|
|
40
|
+
`Product: ${this.product}\n` +
|
|
41
|
+
`URL: ${this.url}\n` +
|
|
42
|
+
`Expected SHA-256: ${this.expectedSha256}\n` +
|
|
43
|
+
`Actual SHA-256: ${this.actualSha256}`;
|
|
44
|
+
msg += '\n\nTo fix this:\n';
|
|
45
|
+
msg += ' 1. Verify firefox.product, firefox.version, and firefox.sha256 in fireforge.json\n';
|
|
46
|
+
msg += ' 2. Compare the pinned hash with Mozilla SHA256SUMMARY for the resolved archive\n';
|
|
47
|
+
if (this.product === 'firefox-devedition') {
|
|
48
|
+
msg +=
|
|
49
|
+
' 3. Developer Edition archives should resolve under https://archive.mozilla.org/pub/devedition/releases/\n';
|
|
50
|
+
msg += ' 4. Re-run "fireforge download --force" after correcting the source settings';
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
msg += ' 3. Re-run "fireforge download --force" after correcting the source settings';
|
|
54
|
+
}
|
|
55
|
+
return msg;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
26
58
|
/**
|
|
27
59
|
* Error thrown when extraction of the downloaded archive fails.
|
|
28
60
|
*/
|