@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
- warn('Removing existing engine directory...');
239
- await removeDir(paths.engine);
240
- // --force installs a new baseCommit, which invalidates every applied
241
- // checksum in furnace-state.json. Clearing the state now prevents a
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 initRepository(paths.engine, 'firefox', {
277
- // Same one-authority rule as the resume path above: the non-TTY
278
- // spinner fallback already emits `step(msg)` internally, so
279
- // calling `step()` in addition to `.message()` duplicated every
280
- // git-init progress line in CI logs.
281
- onProgress: (message) => {
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
- baseCommit = await getHead(paths.engine);
286
- gitSpinner.stop('Git repository initialized');
287
- }
288
- catch (error) {
289
- gitSpinner.error('Failed to initialize git repository');
290
- warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
291
- throw error;
292
- }
293
- // Restore any patch-touched files that ended up dirty after the initial
294
- // commit (e.g. line-ending normalisation or extraction artefacts) so that
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
- restoreSpinner.error('Failed to restore patch-touched files');
382
+ if (replacementEngineDir) {
383
+ await removeDir(replacementEngineDir);
384
+ }
317
385
  throw error;
318
386
  }
319
- await updateState(projectRoot, {
320
- downloadedVersion: version,
321
- baseCommit,
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 URL for Firefox releases on archive.mozilla.org.
9
+ * Base URLs for Firefox source archives on archive.mozilla.org.
10
10
  */
11
- const ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox/releases';
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: `${ARCHIVE_BASE_URL}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
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 { DownloadError } from '../errors/download.js';
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 DownloadError(`Downloaded archive SHA-256 mismatch: expected ${expectedSha256}, got ${sha256}`, archive.url);
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
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",