@hominis/fireforge 0.19.5 → 0.20.0
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 +30 -0
- package/README.md +21 -8
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/download.js +188 -185
- package/dist/src/commands/export-flow.js +2 -13
- package/dist/src/commands/furnace/create-validation.d.ts +6 -0
- package/dist/src/commands/furnace/create-validation.js +59 -0
- package/dist/src/commands/furnace/create.d.ts +7 -7
- package/dist/src/commands/furnace/create.js +21 -96
- package/dist/src/commands/furnace/index.js +2 -2
- package/dist/src/commands/furnace/refresh.js +11 -2
- package/dist/src/commands/furnace/remove-state.d.ts +5 -0
- package/dist/src/commands/furnace/remove-state.js +14 -0
- package/dist/src/commands/furnace/remove.js +30 -45
- package/dist/src/commands/furnace/rename-helpers.d.ts +13 -0
- package/dist/src/commands/furnace/rename-helpers.js +42 -0
- package/dist/src/commands/furnace/rename.js +27 -47
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +5 -0
- package/dist/src/core/config.js +11 -7
- package/dist/src/core/file-lock.js +2 -2
- package/dist/src/core/firefox-cache.d.ts +1 -1
- package/dist/src/core/firefox-cache.js +43 -17
- package/dist/src/core/firefox-download.js +12 -4
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +2 -2
- package/dist/src/core/furnace-refresh.js +16 -5
- package/dist/src/core/patch-lint-imports.d.ts +5 -0
- package/dist/src/core/patch-lint-imports.js +68 -0
- package/dist/src/core/patch-lint.js +2 -3
- package/dist/src/types/commands/options.d.ts +9 -9
- package/dist/src/types/config.d.ts +2 -0
- package/dist/src/utils/fs.d.ts +5 -0
- package/dist/src/utils/fs.js +54 -1
- package/dist/src/utils/process.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **Pinned Firefox archive verification.** `FirefoxConfig` now accepts optional `firefox.sha256`, and `fireforge config firefox.sha256 <digest>` is a supported config path. The digest must be a 64-character SHA-256 hex string; FireForge verifies both cached and freshly downloaded source archives against it before extraction, so reproducible workflows can fail fast on a mismatched upstream archive instead of discovering the problem after engine mutation has begun.
|
|
8
|
+
- **`fireforge patch compact`.** Patch queues with ordinal gaps after deletes, splits, or manual repair can now be compacted back to contiguous numbering under the existing patch manifest lock. The command supports dry-run previews, confirmation/`--yes`, lock-time recomputation, history entries, and the same two-phase rename/rollback safety as other patch renumbering paths.
|
|
9
|
+
- **Furnace xpcshell scaffolding and rename helpers.** Furnace test scaffolding now covers xpcshell packaging probes for storage/module-loading code paths, and rename logic is split into focused helpers for component filenames and config rewrites so custom and override renames share the same behaviour.
|
|
10
|
+
|
|
11
|
+
### Hardening
|
|
12
|
+
|
|
13
|
+
- **`fireforge download` is locked end-to-end.** The command now takes a project-level `.fireforge/` sidecar lock around the full engine mutation sequence: engine existence checks, forced removal, source download/extraction, git init or resume, patch cleanup, and state updates. Parallel invocations queue with explicit timeout/stale-lock messaging instead of racing over `engine/`.
|
|
14
|
+
- **Firefox archive cache mutation is locked per archive.** Cache validation, invalidation, download promotion, and metadata writes now run under a per-archive cache lock. A failed downloader removes only its own unique `.part-*` file unless it already promoted the tarball, so a failing peer can no longer delete another process's valid final cache entry.
|
|
15
|
+
- **Download stream timers clean up on all terminal paths.** The stall detector now clears its timer from `destroy(error, callback)` as well as normal `flush`, avoiding late timer errors and unnecessary event-loop retention after pipeline failures.
|
|
16
|
+
- **Relative import linting is AST-backed.** `fireforge lint` now uses Acorn/ESTree traversal to detect relative ES imports, side-effect imports, dynamic `import()`, relative re-exports, and `ChromeUtils` / `Cu.import` calls, with a narrow stripped-text fallback only when parsing fails. This closes the false negatives left by the old regex pass while keeping malformed-file diagnostics useful.
|
|
17
|
+
- **Cross-platform process and lock probes are stricter.** `findExecutable` now parses Windows `where` output with CRLF-safe per-line trimming and returns the first non-empty candidate. File-lock stale recovery treats only `ESRCH` from `process.kill(pid, 0)` as dead; `EPERM` and unknown errors are treated as live/unknown so FireForge does not reclaim a lock owned by another live process.
|
|
18
|
+
- **Filesystem writes get low-risk durability checks.** `writeFileAtomic` preserves the existing mode-preservation behaviour and now best-effort fsyncs the parent directory after rename where the platform supports directory handles. `pathExistsStrict` is available for user-facing probes that should surface `EACCES` / `EPERM` instead of collapsing them into "missing".
|
|
19
|
+
- **Furnace mutations use fresh locked state.** `furnace create`, `furnace remove`, and `furnace rename` re-read `furnace.json` inside the mutation lock before validation and writeback, preserving sibling entries created by concurrent commands instead of writing back stale outer snapshots. `furnace remove` also shares cleanup state for custom browser-chrome, xpcshell, and MochiKit scaffolds.
|
|
20
|
+
- **Furnace refresh distinguishes conflicts from fatal merge failures.** `git merge-file` result handling now treats normal conflict exits separately from fatal/error output or high exit codes. `furnace refresh --all` continues through later overrides after a per-component failure, reports the failed count, and exits non-zero with the failed override names once the rest of the selection has been attempted.
|
|
21
|
+
- **Shared naming and coverage drift guards.** Export placement now reuses `sanitizeName` from patch export instead of maintaining a duplicate slug helper, and the dedicated coverage-threshold script now enrols newly critical modules including Furnace refresh, patch compact, and Furnace xpcshell rename handling.
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
- **README — download locks and pinned archive checksums.** The quick-start and configuration sections now describe the project download lock, per-archive cache lock, and `firefox.sha256` workflow.
|
|
26
|
+
- **README — concurrent Furnace mutations.** The Furnace section now documents locked fresh-state writeback for create/remove/rename and the `refresh --all` failure summary behaviour.
|
|
27
|
+
|
|
3
28
|
## 0.19.0
|
|
4
29
|
|
|
5
30
|
### Features
|
|
@@ -12,9 +37,14 @@
|
|
|
12
37
|
- **`modified-file-missing-header` — standard Mozilla MPL-2.0 block headers with wrapped line breaks.** The upstream fallback scan required a contiguous `Mozilla Public License` substring in the first few lines, so files that follow Mozilla’s usual `/* … Mozilla Public` / ` * License, v. 2.0 … */` wrap (including after Emacs/vim directive blocks) were warned despite a valid notice. `containsUpstreamLicenseText` in `src/core/license-headers.ts` now normalizes common block-comment continuation prefixes before matching, so forks need not add SPDX solely to satisfy patch lint.
|
|
13
38
|
- **`fireforge test` — `--marionette-port` auto-forward matches toolkit mochitests and mixed suites.** Auto-forward of `--setpref=marionette.port=<n>` previously keyed off a path heuristic that missed `toolkit/content/tests/**` widget HTML tests (no `/mochitest/` segment), so the preflight could use the operator’s port while mach still defaulted to **2828**. Forwarding now runs whenever `--marionette-port` is set unless `--mach-arg` explicitly includes `--flavor=xpcshell` / `xpcshell-tests` (the pref is unused there). `isMarionetteFlavor` also treats `toolkit/content/tests/` paths as Marionette-relevant unless they sit under `/tests/xpcshell/`, for consistency with other callers of that helper.
|
|
14
39
|
|
|
40
|
+
### Compatibility
|
|
41
|
+
|
|
42
|
+
- **`furnace create --with-tests` defaults to `browser-chrome` again** (not `mochikit`, which was the default after the `--test-style` split). Forks whose top-level chrome document has no `tabbrowser` should pass **`--test-style=mochikit`** explicitly. On macOS, toolkit MochiKit / mochitest-chrome-style widget tests can idle until ~370s with no subtests; README documents the tradeoff.
|
|
43
|
+
|
|
15
44
|
### Documentation
|
|
16
45
|
|
|
17
46
|
- **README — mochitest timeouts vs Marionette.** The Test harness section documents long idle timeouts (~370s, `TEST_END: TIMEOUT`) on fork custom chrome, `--marionette-port` behaviour with xpcshell flavor, and pointers to fork-side prefs and investigation (for example Hominis `AGENT_RULES.md`).
|
|
47
|
+
- **README — default test harness and macOS mochitest-chrome.** The README sections “Picking a test harness for `furnace create`”, “Test harness options”, and “Known upstream build issues” describe the browser-chrome default, macOS single-process idle timeout, explicit `--test-style=mochikit`, and `--with-tests` + `--xpcshell` resolution.
|
|
18
48
|
|
|
19
49
|
## 0.18.0
|
|
20
50
|
|
package/README.md
CHANGED
|
@@ -59,6 +59,8 @@ Your project now has `fireforge.json`, an `engine/` directory with Firefox sourc
|
|
|
59
59
|
|
|
60
60
|
- **macOS 15 (Darwin 25+) — `gecko-profiler` bindgen error `cannot find type _CharT in this scope`.** An Apple toolchain update changed `std::__CharT_pointer` to `_CharT_pointer` in the libc++ headers Firefox's bindgen walks, so `toolkit/library/rust/target-objects` fails during `mach build` even on a clean `fireforge bootstrap`. This is an upstream Firefox issue, not a FireForge bug. Two workarounds: pin Xcode's command line tools to a pre-September-2025 release via `xcode-select --install` / [Apple developer downloads](https://developer.apple.com/download/all/), or apply a one-line bindgen-basic-string-workaround patch (a downstream consumer may ship one in its patch queue). If you interrupt the resulting `fireforge build` and re-run `fireforge doctor`, the download/engine state is unaffected — the failure is isolated to the Rust compile phase.
|
|
61
61
|
|
|
62
|
+
- **macOS (including Apple Silicon) — toolkit / MochiKit widget tests idle until ~370s (`TEST_END: TIMEOUT`, no subtests).** The **mochitest-chrome** flavor used for `toolkit/content/tests/widgets/test_*.html` runs **single-process** (`e10s: false`), which interacts badly with headed or headless compositing (for example SWGL) in some setups. Prefer **`furnace create --with-tests`** (defaults to **`browser-chrome`**, multi-process) for interactive chrome coverage, or place tests alongside your fork's browser modules (for example `engine/browser/modules/<fork>/test/`). If you must use **`--test-style=mochikit`**, expect possible hangs on macOS. Details: [Picking a test harness for `furnace create`](#picking-a-test-harness-for-furnace-create), [Test harness options](#test-harness-options).
|
|
63
|
+
|
|
62
64
|
### Workflow Overview
|
|
63
65
|
|
|
64
66
|
1. Make changes inside the `engine/` directory.
|
|
@@ -85,6 +87,8 @@ npx fireforge rebase
|
|
|
85
87
|
|
|
86
88
|
`fireforge download` indexes the extracted Firefox source into a fresh git repository — a one-time 1–3 minute pass on a cold SSD, longer on slow or loaded disks. The monolithic `git add -A` is capped at 10 minutes by default and falls back to a per-directory chunked pass (30 minutes per chunk) when the cap hits. If indexing still times out, the command now raises `GitIndexingTimeoutError` with recovery guidance: extend the cap via `FIREFORGE_GIT_ADD_TIMEOUT_MS` (monolithic) and/or `FIREFORGE_GIT_ADD_CHUNK_TIMEOUT_MS` (chunked) in milliseconds, e.g. `FIREFORGE_GIT_ADD_TIMEOUT_MS=1800000 fireforge download --force` for a 30-minute monolithic budget, then re-run `fireforge download --force` — the resume path picks up from the partial git state so the repeat is not wasted work.
|
|
87
89
|
|
|
90
|
+
`fireforge download` is serialised by a project lock under `.fireforge/`, covering the engine-exists check, forced replacement, extraction, git initialisation/resume, patch cleanup and state update. Archive cache entries are also guarded by per-archive locks, so parallel downloads queue instead of racing over the same `engine/` directory or deleting each other's cache results. For reproducible workflows, set `firefox.sha256` to the expected 64-character archive SHA-256; FireForge verifies both cached and freshly downloaded archives before extraction and refuses a mismatch before touching the engine.
|
|
91
|
+
|
|
88
92
|
`fireforge rebase --dry-run` refuses when the engine has no baseline commit yet (e.g. the aftermath of an aborted `download --force`), so dry-run and real-run preconditions stay in sync.
|
|
89
93
|
|
|
90
94
|
## Patch Workflow
|
|
@@ -417,20 +421,24 @@ The packaging-verification test that `--with-tests` scaffolds is what FireForge
|
|
|
417
421
|
|
|
418
422
|
### Picking a test harness for `furnace create`
|
|
419
423
|
|
|
420
|
-
`furnace create --with-tests` defaults to a
|
|
424
|
+
`furnace create --with-tests` defaults to a **browser-chrome** mochitest: `browser_<binary>_<tag>.js` under `engine/browser/base/content/test/<binary-name>/`, registered via `browser.toml` (and `browser/base/moz.build` when the scaffolder appends there). Use this when the component participates in real browser chrome (tabs, `gBrowser`, and similar).
|
|
425
|
+
|
|
426
|
+
**MochiKit** (`--test-style=mochikit`) is opt-in: it emits `test_<tag>.html` under `engine/toolkit/content/tests/widgets/`, loads the module via `chrome://global/`, and does not need a `tabbrowser` — so it is the right choice for bespoke chrome documents that omit the upstream tab strip. On **macOS**, that harness can hit a long idle timeout (see [Known upstream build issues](#known-upstream-build-issues)); prefer browser-chrome tests when your fork has a normal tabbed window.
|
|
421
427
|
|
|
422
428
|
Three styles are available via `--test-style`:
|
|
423
429
|
|
|
424
|
-
| Style | When to use
|
|
425
|
-
| ---------------- |
|
|
426
|
-
| `
|
|
427
|
-
| `
|
|
428
|
-
| `xpcshell` | Storage-layer, observer-driven, or ESM-loading code. Headless, no tabbrowser. Emits `test_<name>_module_loads.js` + `xpcshell.toml` (registration in `XPCSHELL_TESTS_MANIFESTS` is left to the operator).
|
|
430
|
+
| Style | When to use |
|
|
431
|
+
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
432
|
+
| `browser-chrome` | **Default** with `--with-tests`. Interactive chrome, tab strip, `gBrowser`. Requires a working `tabbrowser`. Emits `browser_<bin>_<tag>.js` and registers via `browser.toml` / `browser/base/moz.build`. |
|
|
433
|
+
| `mochikit` | Pure-UI custom elements on forks **without** a `tabbrowser`. Emits `test_<tag>.html` under `toolkit/content/tests/widgets/`. May be unreliable on macOS (mochitest-chrome / single-process). |
|
|
434
|
+
| `xpcshell` | Storage-layer, observer-driven, or ESM-loading code. Headless, no tabbrowser. Emits `test_<name>_module_loads.js` + `xpcshell.toml` (registration in `XPCSHELL_TESTS_MANIFESTS` is left to the operator). |
|
|
429
435
|
|
|
430
436
|
`--xpcshell` is preserved as an alias for `--test-style=xpcshell`; conflicting flag combinations (`--xpcshell --test-style=mochikit`) are rejected.
|
|
431
437
|
|
|
432
438
|
`furnace create --dry-run` previews the planned file set, test scaffold, and `furnace.json` mutation without writing anything. Every validation the real command runs (tag-name shape, name conflicts, engine pre-existence of the component, `--compose` target existence + cycle detection) fires BEFORE the plan is emitted, so a failed preview matches a failed real run.
|
|
433
439
|
|
|
440
|
+
`furnace create`, `furnace remove`, and `furnace rename` re-read `furnace.json` inside the mutation lock before writing, so concurrent component edits preserve sibling entries instead of writing back a stale outer snapshot. `furnace refresh --all` continues past per-component refresh failures, reports the failed count, and exits non-zero with the failed override names after finishing the rest of the selection.
|
|
441
|
+
|
|
434
442
|
## Additional Commands
|
|
435
443
|
|
|
436
444
|
The commands below cover project configuration, patch queue management, build packaging and development utilities. Run `fireforge <command> --help` for full option details.
|
|
@@ -444,6 +452,9 @@ fireforge config firefox.version
|
|
|
444
452
|
# Set a config value
|
|
445
453
|
fireforge config firefox.version 145.0.0esr
|
|
446
454
|
|
|
455
|
+
# Pin the resolved Firefox source archive checksum
|
|
456
|
+
fireforge config firefox.sha256 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
457
|
+
|
|
447
458
|
# Set a value at a non-standard path (requires --force)
|
|
448
459
|
fireforge config customKey "value" --force
|
|
449
460
|
```
|
|
@@ -452,6 +463,8 @@ Writes are serialised behind a sidecar lock — two concurrent `fireforge config
|
|
|
452
463
|
|
|
453
464
|
Re-setting a key to its current value is a no-op: `fireforge.json` is not rewritten, key ordering is preserved, and the success log surfaces `<key> = <value> (unchanged)` instead of a fresh `Set …` line. This means automation that idempotently runs `fireforge config <key> <value>` no longer produces spurious diffs in `fireforge.json`.
|
|
454
465
|
|
|
466
|
+
`firefox.sha256` is optional. When set, it must be a 64-character hex SHA-256 for the Firefox source archive resolved from `firefox.product` + `firefox.version`; cached archives and fresh downloads are verified against it before extraction. Omit it to keep the default cache-integrity-only behaviour.
|
|
467
|
+
|
|
455
468
|
### Patch queue management
|
|
456
469
|
|
|
457
470
|
```bash
|
|
@@ -628,7 +641,7 @@ Set this in `furnace.json` to extend the list (forks with additional platform pr
|
|
|
628
641
|
|
|
629
642
|
### Test harness options
|
|
630
643
|
|
|
631
|
-
`fireforge furnace create --with-tests` scaffolds a **browser-chrome mochitest
|
|
644
|
+
`fireforge furnace create --with-tests` scaffolds a **browser-chrome mochitest** by default. Use this when the component renders UI that depends on the tab strip (`openLinkIn` → `URILoadingHelper`, `gBrowser`, etc.). On **macOS**, avoid relying on **`--test-style=mochikit`** (toolkit `test_*.html` under `toolkit/content/tests/widgets/`) for primary chrome/widget coverage: the **mochitest-chrome** flavor runs **single-process** (`e10s: false`) and has been observed to **idle ~370s** with no subtests (headless or headed compositing / SWGL). Prefer browser-chrome tests (multi-process); for forks that organize interactive tests under `engine/browser/modules/<fork>/test/`, extend that tree rather than adding new `test_<tag>.html` scaffolds when browser-chrome is sufficient.
|
|
632
645
|
|
|
633
646
|
`fireforge furnace create --xpcshell` scaffolds an **xpcshell test harness** instead. Use this when the component's code path is storage-only, observer-driven, or module-loading logic that does not touch a `tabbrowser`. xpcshell runs headless without browser chrome, so forks without an upstream tab strip can still cover these paths. The scaffolder writes `test_<name>_packaged.js` + `xpcshell.toml` into `engine/browser/base/content/test/<binary-name>-xpcshell/<component-name>/` and prints a note: registration in `XPCSHELL_TESTS_MANIFESTS` is the operator's call (the moz.build that should own the entry depends on where the component actually lives). `fireforge register <path>/xpcshell.toml` surfaces the same guidance when run directly rather than silently routing to a browser.toml-shaped advice.
|
|
634
647
|
|
|
@@ -636,7 +649,7 @@ The scaffolded xpcshell test is a **packaging probe**, not a module-load test. L
|
|
|
636
649
|
|
|
637
650
|
xpcshell has a chrome-URI boundary that is worth knowing before writing assertions: `chrome://global/*` (toolkit chrome) IS registered and resolvable from the harness, but `chrome://browser/*` (browser chrome) is NOT — even when `firefox-appdir = "browser"` is set in the xpcshell.toml, the manifest set xpcshell loads lags what the real browser loads, so `NetUtil.asyncFetch("chrome://browser/content/…")` can still fail with `NS_ERROR_FILE_NOT_FOUND` against an artifact that IS present in `obj-*/dist/`. Assertions that need browser chrome URIs belong in a browser-chrome mochitest (`furnace create --test-style=browser-chrome`).
|
|
638
651
|
|
|
639
|
-
|
|
652
|
+
If you pass **`--with-tests` and `--xpcshell` together**, FireForge resolves the harness to **xpcshell only** (`--xpcshell` takes precedence). To get the default browser-chrome mochitest as well, run a second `furnace create` with `--with-tests` only or add the browser test files manually.
|
|
640
653
|
|
|
641
654
|
### Mochitest stalls and `--marionette-port`
|
|
642
655
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getProjectPaths, loadConfig, updateState } from '../core/config.js';
|
|
4
|
+
import { withFileLock } from '../core/file-lock.js';
|
|
4
5
|
import { downloadFirefoxSource, formatBytes } from '../core/firefox.js';
|
|
5
6
|
import { getFurnacePaths, updateFurnaceState } from '../core/furnace-config.js';
|
|
6
7
|
import { getHead, initRepository, isGitRepository, isMissingHeadError, resumeRepository, } from '../core/git.js';
|
|
@@ -9,7 +10,7 @@ import { getDirtyFiles } from '../core/git-status.js';
|
|
|
9
10
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
10
11
|
import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
|
|
11
12
|
import { toError } from '../utils/errors.js';
|
|
12
|
-
import { checkDiskSpace, ensureDir, pathExists, removeDir } from '../utils/fs.js';
|
|
13
|
+
import { checkDiskSpace, ensureDir, pathExists, pathExistsStrict, removeDir } from '../utils/fs.js';
|
|
13
14
|
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
14
15
|
import { pickDefined } from '../utils/options.js';
|
|
15
16
|
/**
|
|
@@ -124,203 +125,205 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
124
125
|
info(`Firefox version: ${version}`);
|
|
125
126
|
// Disk space pre-flight: Firefox source is ~5 GB
|
|
126
127
|
await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
128
|
+
await withFileLock(join(paths.fireforgeDir, 'download.fireforge.lock'), async () => {
|
|
129
|
+
// Check if engine already exists
|
|
130
|
+
if (await pathExistsStrict(paths.engine)) {
|
|
131
|
+
if (!options.force) {
|
|
132
|
+
if (await isGitRepository(paths.engine)) {
|
|
133
|
+
try {
|
|
134
|
+
await getHead(paths.engine);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (isMissingHeadError(error)) {
|
|
138
|
+
// Partial init detected — attempt to resume instead of requiring --force
|
|
139
|
+
info('Detected partially initialized engine. Attempting to resume...');
|
|
140
|
+
// Snapshot patch-touched files that are already dirty so we
|
|
141
|
+
// can preserve them after the resume commit.
|
|
142
|
+
const patchFiles = await getPatchTouchedFiles(paths.patches);
|
|
143
|
+
const preExistingDirty = patchFiles.size > 0
|
|
144
|
+
? new Set(await getDirtyFiles(paths.engine, [...patchFiles]))
|
|
145
|
+
: new Set();
|
|
146
|
+
const resumeSpinner = spinner('Resuming git repository initialization...');
|
|
147
|
+
try {
|
|
148
|
+
await resumeRepository(paths.engine, {
|
|
149
|
+
// The non-TTY spinner fallback in `src/utils/logger.ts`
|
|
150
|
+
// already calls `p.log.step(msg)` from `message()`, so
|
|
151
|
+
// forwarding the progress message is the single authority
|
|
152
|
+
// in both TTY and non-TTY modes. Before 0.16.0 this
|
|
153
|
+
// callback also invoked `step(message)` explicitly when
|
|
154
|
+
// stdio was not a TTY, which printed the same step line
|
|
155
|
+
// twice in CI logs (once from the fallback, once from
|
|
156
|
+
// the explicit call).
|
|
157
|
+
onProgress: (message) => {
|
|
158
|
+
resumeSpinner.message(message);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const baseCommit = await getHead(paths.engine);
|
|
162
|
+
resumeSpinner.stop('Git repository resumed successfully');
|
|
163
|
+
// Restore patch-touched files BEFORE stamping state. If this
|
|
164
|
+
// step fails (disk full, permission denied, git object issue),
|
|
165
|
+
// state.json keeps the previous downloadedVersion so the
|
|
166
|
+
// invariant "state.downloadedVersion matches a clean engine"
|
|
167
|
+
// holds. A retry of `fireforge download` then re-enters the
|
|
168
|
+
// resume path instead of declaring success against a dirty
|
|
169
|
+
// engine.
|
|
170
|
+
await cleanPatchTouchedFiles(paths.engine, paths.patches, preExistingDirty);
|
|
171
|
+
await updateState(projectRoot, {
|
|
172
|
+
downloadedVersion: version,
|
|
173
|
+
baseCommit,
|
|
174
|
+
});
|
|
175
|
+
await noteUnappliedPatches(paths.patches);
|
|
176
|
+
outro(`Firefox ${version} is ready! (resumed from partial init)`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
resumeSpinner.error('Resume failed');
|
|
181
|
+
// Preserve the underlying cause so the user sees *why* the
|
|
182
|
+
// resume failed (timeout, permission denied, corrupted object,
|
|
183
|
+
// disk full, …) instead of only the generic "partial engine
|
|
184
|
+
// exists" story. Verbose mode prints the stack for deeper
|
|
185
|
+
// triage.
|
|
186
|
+
const cause = toError(error);
|
|
187
|
+
verbose(`Resume failure detail: ${cause.message}`);
|
|
188
|
+
if (cause.stack) {
|
|
189
|
+
verbose(cause.stack);
|
|
190
|
+
}
|
|
191
|
+
throw new PartialEngineExistsError(paths.engine, cause);
|
|
188
192
|
}
|
|
189
|
-
throw new PartialEngineExistsError(paths.engine, cause);
|
|
190
193
|
}
|
|
194
|
+
// Re-throw unexpected git errors (corrupted objects, permission
|
|
195
|
+
// denied, …) wrapped in PartialEngineExistsError so the user sees
|
|
196
|
+
// both narratives: "we detected a partial engine and attempted
|
|
197
|
+
// resume" AND the underlying git failure. Without the wrap the
|
|
198
|
+
// raw git error loses the context that resume was in flight.
|
|
199
|
+
const cause = toError(error);
|
|
200
|
+
verbose(`Partial-engine probe failed with unexpected error: ${cause.message}`);
|
|
201
|
+
if (cause.stack) {
|
|
202
|
+
verbose(cause.stack);
|
|
203
|
+
}
|
|
204
|
+
throw new PartialEngineExistsError(paths.engine, cause);
|
|
191
205
|
}
|
|
192
|
-
// Re-throw unexpected git errors (corrupted objects, permission
|
|
193
|
-
// denied, …) wrapped in PartialEngineExistsError so the user sees
|
|
194
|
-
// both narratives: "we detected a partial engine and attempted
|
|
195
|
-
// resume" AND the underlying git failure. Without the wrap the
|
|
196
|
-
// raw git error loses the context that resume was in flight.
|
|
197
|
-
const cause = toError(error);
|
|
198
|
-
verbose(`Partial-engine probe failed with unexpected error: ${cause.message}`);
|
|
199
|
-
if (cause.stack) {
|
|
200
|
-
verbose(cause.stack);
|
|
201
|
-
}
|
|
202
|
-
throw new PartialEngineExistsError(paths.engine, cause);
|
|
203
206
|
}
|
|
207
|
+
throw new EngineExistsError(paths.engine);
|
|
208
|
+
}
|
|
209
|
+
warn('Removing existing engine directory...');
|
|
210
|
+
await removeDir(paths.engine);
|
|
211
|
+
// --force installs a new baseCommit, which invalidates every applied
|
|
212
|
+
// checksum in furnace-state.json. Clearing the state now prevents a
|
|
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
|
+
}));
|
|
204
222
|
}
|
|
205
|
-
throw new EngineExistsError(paths.engine);
|
|
206
|
-
}
|
|
207
|
-
warn('Removing existing engine directory...');
|
|
208
|
-
await removeDir(paths.engine);
|
|
209
|
-
// --force installs a new baseCommit, which invalidates every applied
|
|
210
|
-
// checksum in furnace-state.json. Clearing the state now prevents a
|
|
211
|
-
// subsequent `furnace apply` from reporting "up to date" against an
|
|
212
|
-
// engine that no longer contains any of the deployed files. Preserve
|
|
213
|
-
// pendingRepair: authoring-side rollback markers describe unresolved
|
|
214
|
-
// component workspace state and should survive an engine refresh.
|
|
215
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
216
|
-
if (await pathExists(furnacePaths.furnaceState)) {
|
|
217
|
-
await updateFurnaceState(projectRoot, (current) => ({
|
|
218
|
-
...(current.pendingRepair ? { pendingRepair: current.pendingRepair } : {}),
|
|
219
|
-
}));
|
|
220
223
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
224
|
+
// Ensure cache directory exists
|
|
225
|
+
const cacheDir = join(paths.fireforgeDir, 'cache');
|
|
226
|
+
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
|
+
try {
|
|
238
|
+
await downloadFirefoxSource(version, config.firefox.product, paths.engine, cacheDir, (downloaded, total) => {
|
|
239
|
+
if (total <= 0)
|
|
240
|
+
return;
|
|
241
|
+
const percent = Math.floor((downloaded / total) * 100);
|
|
242
|
+
if (percent !== lastPercent && percent % 5 === 0) {
|
|
243
|
+
s.message(`Downloading Firefox ${version}... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`);
|
|
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);
|
|
253
|
+
if (phaseState.value === 'extract') {
|
|
254
|
+
s.stop(`Firefox ${version} extracted`);
|
|
243
255
|
}
|
|
244
|
-
|
|
245
|
-
if (phase === 'extract' && phaseState.value === 'download') {
|
|
256
|
+
else {
|
|
246
257
|
s.stop(`Firefox ${version} downloaded`);
|
|
247
|
-
phaseState.value = 'extract';
|
|
248
|
-
s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
|
|
249
258
|
}
|
|
250
|
-
});
|
|
251
|
-
if (phaseState.value === 'extract') {
|
|
252
|
-
s.stop(`Firefox ${version} extracted`);
|
|
253
259
|
}
|
|
254
|
-
|
|
255
|
-
s.
|
|
260
|
+
catch (error) {
|
|
261
|
+
s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
|
|
262
|
+
throw error;
|
|
256
263
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
264
|
+
// Finding #17: the git indexing phase of `download` can block for
|
|
265
|
+
// minutes on a ~600 MB Firefox tree — the spinner updates less often
|
|
266
|
+
// than operators expect during the monolithic `git add -A` pass, and
|
|
267
|
+
// non-TTY shells see long stretches of silence. Emit a one-line
|
|
268
|
+
// heads-up banner BEFORE the spinner starts so even a log-scraping
|
|
269
|
+
// CI job notes the expected duration. The progress callbacks below
|
|
270
|
+
// still fire as usual; this is an additional up-front signal, not a
|
|
271
|
+
// replacement.
|
|
272
|
+
info('Indexing downloaded source into git (one-time; typically 1–3 minutes on a ~600 MB Firefox tree)...');
|
|
273
|
+
// Initialize git repository
|
|
274
|
+
const gitSpinner = spinner('Initializing git repository (this may take a few minutes)...');
|
|
275
|
+
let baseCommit;
|
|
276
|
+
try {
|
|
277
|
+
await initRepository(paths.engine, 'firefox', {
|
|
278
|
+
// Same one-authority rule as the resume path above: the non-TTY
|
|
279
|
+
// spinner fallback already emits `step(msg)` internally, so
|
|
280
|
+
// calling `step()` in addition to `.message()` duplicated every
|
|
281
|
+
// git-init progress line in CI logs.
|
|
282
|
+
onProgress: (message) => {
|
|
283
|
+
gitSpinner.message(message);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
baseCommit = await getHead(paths.engine);
|
|
287
|
+
gitSpinner.stop('Git repository initialized');
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
gitSpinner.error('Failed to initialize git repository');
|
|
291
|
+
warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
// Restore any patch-touched files that ended up dirty after the initial
|
|
295
|
+
// commit (e.g. line-ending normalisation or extraction artefacts) so that
|
|
296
|
+
// a subsequent `fireforge import` works without --force.
|
|
297
|
+
//
|
|
298
|
+
// Wrapped in a dedicated spinner because the restore can itself take
|
|
299
|
+
// tens of seconds on a ~600 MB Firefox tree: it walks every file in the
|
|
300
|
+
// patch manifest, calls `git status` / `git checkout` for each, and the
|
|
301
|
+
// eval's "download looks hung" report landed at least partly on this
|
|
302
|
+
// post-commit window. An operator watching the CLI needs to see that
|
|
303
|
+
// this phase is distinct from the preceding git-add work.
|
|
304
|
+
//
|
|
305
|
+
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
306
|
+
// downloadedVersion in state.json. The invariant we preserve is
|
|
307
|
+
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
308
|
+
// version only after the restore succeeds means a failed clean-up will
|
|
309
|
+
// re-enter the resume path on the next `fireforge download` rather than
|
|
310
|
+
// reporting success against a dirty engine.
|
|
311
|
+
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
312
|
+
try {
|
|
313
|
+
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
314
|
+
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
restoreSpinner.error('Failed to restore patch-touched files');
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
await updateState(projectRoot, {
|
|
321
|
+
downloadedVersion: version,
|
|
322
|
+
baseCommit,
|
|
283
323
|
});
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
catch (error) {
|
|
288
|
-
gitSpinner.error('Failed to initialize git repository');
|
|
289
|
-
warn('engine/ may now contain a partially initialized git repository. Re-run "fireforge download --force" to recreate the baseline cleanly.');
|
|
290
|
-
throw error;
|
|
291
|
-
}
|
|
292
|
-
// Restore any patch-touched files that ended up dirty after the initial
|
|
293
|
-
// commit (e.g. line-ending normalisation or extraction artefacts) so that
|
|
294
|
-
// a subsequent `fireforge import` works without --force.
|
|
295
|
-
//
|
|
296
|
-
// Wrapped in a dedicated spinner because the restore can itself take
|
|
297
|
-
// tens of seconds on a ~600 MB Firefox tree: it walks every file in the
|
|
298
|
-
// patch manifest, calls `git status` / `git checkout` for each, and the
|
|
299
|
-
// eval's "download looks hung" report landed at least partly on this
|
|
300
|
-
// post-commit window. An operator watching the CLI needs to see that
|
|
301
|
-
// this phase is distinct from the preceding git-add work.
|
|
302
|
-
//
|
|
303
|
-
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
304
|
-
// downloadedVersion in state.json. The invariant we preserve is
|
|
305
|
-
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
306
|
-
// version only after the restore succeeds means a failed clean-up will
|
|
307
|
-
// re-enter the resume path on the next `fireforge download` rather than
|
|
308
|
-
// reporting success against a dirty engine.
|
|
309
|
-
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
310
|
-
try {
|
|
311
|
-
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
312
|
-
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
313
|
-
}
|
|
314
|
-
catch (error) {
|
|
315
|
-
restoreSpinner.error('Failed to restore patch-touched files');
|
|
316
|
-
throw error;
|
|
317
|
-
}
|
|
318
|
-
await updateState(projectRoot, {
|
|
319
|
-
downloadedVersion: version,
|
|
320
|
-
baseCommit,
|
|
324
|
+
await noteUnappliedPatches(paths.patches);
|
|
325
|
+
outro(`Firefox ${version} is ready!`);
|
|
321
326
|
});
|
|
322
|
-
await noteUnappliedPatches(paths.patches);
|
|
323
|
-
outro(`Firefox ${version} is ready!`);
|
|
324
327
|
}
|
|
325
328
|
/** Registers the download command on the CLI program. */
|
|
326
329
|
export function registerDownload(program, { getProjectRoot, withErrorHandling }) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* testable without dragging the whole command harness along for the ride.
|
|
9
9
|
*/
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
-
import { findAllPatchesForFilesWithDetails, planExport } from '../core/patch-export.js';
|
|
11
|
+
import { findAllPatchesForFilesWithDetails, planExport, sanitizeName, } from '../core/patch-export.js';
|
|
12
12
|
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
|
|
13
13
|
import { withPatchDirectoryLock } from '../core/patch-lock.js';
|
|
14
14
|
import { addPatchToManifest, loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, } from '../core/patch-manifest.js';
|
|
@@ -17,20 +17,9 @@ import { InvalidArgumentError } from '../errors/base.js';
|
|
|
17
17
|
import { toError } from '../utils/errors.js';
|
|
18
18
|
import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
|
|
19
19
|
import { info, warn } from '../utils/logger.js';
|
|
20
|
-
/**
|
|
21
|
-
* Sanitizes a patch name for use in a filename. Mirrors the private helper
|
|
22
|
-
* in patch-export.ts.
|
|
23
|
-
*/
|
|
24
|
-
function sanitizeExportName(name) {
|
|
25
|
-
return name
|
|
26
|
-
.toLowerCase()
|
|
27
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
28
|
-
.replace(/^-+|-+$/g, '')
|
|
29
|
-
.slice(0, 50);
|
|
30
|
-
}
|
|
31
20
|
function buildFilenameForPlacement(category, name, order, width) {
|
|
32
21
|
const padded = String(order).padStart(Math.max(3, width), '0');
|
|
33
|
-
return `${padded}-${category}-${
|
|
22
|
+
return `${padded}-${category}-${sanitizeName(name)}.patch`;
|
|
34
23
|
}
|
|
35
24
|
function getSortedRenameEntries(renameMap) {
|
|
36
25
|
return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
|