@hominis/fireforge 0.16.2 → 0.16.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +9 -2
  3. package/dist/bin/fireforge.js +11 -2
  4. package/dist/src/commands/doctor-furnace.js +83 -1
  5. package/dist/src/commands/doctor.js +18 -0
  6. package/dist/src/commands/download.js +16 -1
  7. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
  8. package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
  9. package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
  10. package/dist/src/commands/furnace/create-templates.d.ts +17 -7
  11. package/dist/src/commands/furnace/create-templates.js +85 -31
  12. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  13. package/dist/src/commands/furnace/create-xpcshell.js +1 -1
  14. package/dist/src/commands/import.js +63 -11
  15. package/dist/src/commands/patch/delete.js +10 -1
  16. package/dist/src/commands/setup-support.js +60 -7
  17. package/dist/src/commands/status.js +28 -1
  18. package/dist/src/commands/test.js +20 -4
  19. package/dist/src/commands/token.js +7 -1
  20. package/dist/src/core/branding.d.ts +10 -0
  21. package/dist/src/core/branding.js +7 -9
  22. package/dist/src/core/build-prepare.js +8 -1
  23. package/dist/src/core/file-lock.js +49 -15
  24. package/dist/src/core/furnace-operation.d.ts +17 -0
  25. package/dist/src/core/furnace-operation.js +30 -1
  26. package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
  27. package/dist/src/core/furnace-validate-helpers.js +53 -2
  28. package/dist/src/core/git.js +39 -10
  29. package/dist/src/core/manifest-rules.js +16 -0
  30. package/dist/src/core/marionette-preflight.js +43 -12
  31. package/dist/src/core/patch-files.d.ts +12 -1
  32. package/dist/src/core/patch-files.js +14 -11
  33. package/dist/src/core/patch-lint.js +62 -11
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -149,6 +149,79 @@
149
149
 
150
150
  - `fireforge status` no longer surfaces FireForge's own in-flight atomic-write temp files in any output mode. Motivating case on `hominis/`: a `status --json` that coincided with a `brand.ftl` or `mozconfig` write briefly listed paths like `.brand.ftl.fireforge-tmp-12345-<uuid>` and `.mozconfig.fireforge-tmp-12345-<uuid>` alongside real changes. `src/utils/fs.ts` now exports `FIREFORGE_TMP_PATH_PATTERN`, a regex anchored on the exact shape `createAtomicTempPath` produces (`<dir>/.<filename>.fireforge-tmp-<pid>-<uuid>`), and `status.ts` filters every status entry through it after `expandDirectoryEntries` and before classification. All status modes — default, raw, unmanaged, ownership, json — apply the same filter, so a late `status` call during a large write produces the same output regardless of which view the operator chose. The pattern is tight enough to let an operator-named `.notes.fireforge-tmp-backup` (no PID+UUID continuation) pass through unfiltered.
151
151
 
152
+ ### Lifecycle — `test --doctor` exits cleanly on passing preflight
153
+
154
+ - `src/core/marionette-preflight.ts` now spawns the browser in its own process group (`detached: true`) and sends SIGTERM / SIGKILL via `process.kill(-pid, …)` in the finally block, with an explicit `child.stderr?.destroy()` to close the local end of the stderr pipe. Before this, `fireforge test --doctor` routinely printed `Marionette preflight: PASS` and then hung indefinitely in `uv__io_poll` — the Python mach wrapper exited under SIGTERM but Firefox (a grandchild) inherited the stderr FD and kept Node's event loop alive. A process-group kill takes down the whole tree; destroying the stderr stream closes the local handle regardless of what the grandchild does with its copy. The non-win32 guard falls back to `child.kill(signal)` on Windows, where negative-PID signalling is not a supported kernel primitive.
155
+
156
+ ### Lifecycle — `download` progress visibility during git indexing and patch-touched restore
157
+
158
+ - `src/core/git.ts` adds a 15-second heartbeat during the monolithic `git add -A`: every tick reports elapsed seconds through `onProgress` so a spinner or non-TTY log-scraping CI job both see that indexing is still making progress. A ~600 MB Firefox tree takes 60–120 seconds for the monolithic add, during which git emits nothing on stdout/stderr; without the heartbeat the CLI looked hung precisely during the expected work window and an eval run consistently SIGINT'd mid-way assuming the process had stalled. `src/commands/download.ts` also wraps the post-commit `cleanPatchTouchedFiles` pass in its own spinner so the phase is visibly distinct from the preceding git-add window. Neither change alters the underlying timing budget — they surface progress that was always there.
159
+
160
+ ### Doctor — post-interrupt engine state check and watchman preflight
161
+
162
+ - `fireforge status` now surfaces a single recovery banner when the engine git repository has no HEAD, pointing at `fireforge download --force`. Before this, interrupting a `fireforge download` during the initial git-add left engine/ extracted but with an unborn HEAD; `status` then flooded the output with hundreds of thousands of untracked entries plus a truncation warning — correct, but not actionable. `src/commands/status.ts` now probes HEAD up-front (via `getHead` + `isMissingHeadError`) and throws a `GeneralError` with the recovery guidance, which matches doctor's existing row for the same state. `--raw` / `--json` modes get the same error message but no banner, so their consumers still see the structural failure.
163
+ - `src/commands/doctor.ts` adds a `Watchman available` check (warning severity when watchman is not on `PATH`). Before this, operators got through setup → download → bootstrap → build without ever seeing the requirement, then hit it only when `fireforge watch` refused to start. A warning row is the right shape: most projects never run watch, so a missing watchman should not fail `doctor` outright — but the gap is now visible during the normal onboarding sweep. The README setup requirements section lists watchman explicitly, and the check runs alongside `mach available` so the location is ergonomic.
164
+
165
+ ### Setup — package.json license kept in sync with fireforge.json
166
+
167
+ - `src/commands/setup-support.ts` now rewrites an existing root `package.json`'s `license` field to match the project license picked during `fireforge setup` (instead of only writing a new minimal `package.json` when none existed). Every other field is preserved — `name`, `description`, `dependencies`, `scripts`, `private`, author metadata. Before this, `fireforge setup --force` that selected a new license (e.g. EUPL-1.2 → 0BSD) updated `fireforge.json` but left the package.json license stale, so the two files described different projects. A malformed existing `package.json` is left alone rather than rewritten; the file's trailing-newline state is preserved so a hand-edited convention survives the sync.
168
+
169
+ ### Branding — generated files carry the project license header
170
+
171
+ - `src/core/branding.ts` now stamps `configure.sh`, `brand.properties`, and `brand.ftl` with the license header that matches the project's `fireforge.json` `license` field (via `getLicenseHeader(config.license, 'hash')` from `license-headers.ts`). Before this, the three generated files hard-coded the Mozilla MPL-2.0 header regardless of the project license, so a 0BSD / EUPL-1.2 / GPL-2.0-or-later fork's first export failed `patch-lint`'s `missing-license-header` on its own generated branding with no actionable fix. The fix threads the license through `BrandingConfig` (optional, defaults to `DEFAULT_LICENSE` for pre-0.16 callers) and `build-prepare.ts` sets it from the active project config. Copied upstream branding assets under `browser/branding/<binary>/` still carry Mozilla's MPL-2.0 headers (those files are Mozilla-copyrighted template material) and are auto-exempted by the lint rules below.
172
+
173
+ ### Patch lint — branding tier for `large-patch-lines`, auto-exemption for branding headers and colors
174
+
175
+ - `src/core/patch-lint.ts` adds a `branding` threshold tier to `lintPatchSize` (notice 3000 / warning 8000 / error 20000) and selects it when every file in a patch lives under `browser/branding/`. Before this, a first-export of setup-generated branding landed at 15,904 lines (localized `brand.ftl` across many locales + SVG path data + copied upstream CSS) and fired the general hard limit of 3000 as an error — even though the patch was already the minimum branding diff. The branding tier keeps the soft warning (8000) visible but moves the hard limit to a threshold that genuinely suggests "something other than branding is bundled in here too". Mixed patches (branding + other trees) still fall through to the general tier so an operator bundling unrelated edits into a branding change still sees the warning.
176
+ - `lintPatchedCss` now auto-exempts files under `browser/branding/` from the `raw-color-value` check. Copied-from-`unofficial` branding CSS contains hex literals (about dialogs, installer pages, branded chrome) that are legitimate Mozilla design decisions, not fork-editorial choices — listing every copied path in `patchLint.rawColorAllowlist` would add dozens of entries that the operator did not author. The narrower exemption applies by path prefix, so a fork that authors an entirely new branding tree under a different top-level directory still sees the lint fire. `lintNewFileHeaders` adds a parallel carve-out: a new file under `browser/branding/` that starts with any recognised license header (MPL-2.0, EUPL-1.2, 0BSD, GPL-2.0-or-later) passes the check, even when the header does not match the project license — an operator forking Mozilla's branding template inherits Mozilla's MPL-2.0 header and cannot legitimately rewrite it to another license without misrepresenting authorship. A file with no header at all still fails.
177
+
178
+ ### Patch manifest — binary file paths survive verify, repair, and rebuild
179
+
180
+ - `src/core/patch-files.ts` now delegates `getAllTargetFilesFromPatch` to `extractAffectedFiles` from `patch-parse.ts`, which matches both `diff --git a/… b/…` and `+++ b/…` lines. Before this, the custom `+++ b/…`-only regex missed every file in a `GIT binary patch` section (binary diffs have no `+++` line, only a diff header), so `fireforge verify` reported `files-affected-mismatch` against branding patches and `fireforge doctor --repair-patches-manifest` "repaired" the mismatch by rewriting the manifest down to the text-only subset — hiding the true scope of the patch. Three downstream callers (`patch-manifest-query.ts`, `patch-manifest-consistency.ts`, `patch-apply.ts`) inherit the fix without local changes. The returned list is alphabetically sorted (matching `extractAffectedFiles`); callers already compare `filesAffected` as a set, so the order change is API-safe.
181
+
182
+ ### Import — `--until` scopes patch-integrity checks to the targeted range
183
+
184
+ - `fireforge import --until <filename>` now filters the patch-integrity and manifest-consistency issues to patches at or before the target, so a malformed later patch does not block replaying an earlier good subset. Before this, `validatePatchIntegrity` returned issues for every patch on disk and the block / force-prompt pathway fired on all of them — an operator with `--until 001-foo.patch` who wanted to step around a broken `002-bar.patch` got "Refusing to import while 1 patch integrity issue" even though patch 2 was out of scope. The new `buildUntilFilenameSet` helper (in `src/commands/import.ts`) resolves the `--until` target via the same `.patch`-suffix-tolerant lookup `applyPatchesWithContinue` already uses, and the filter applies to version-compatibility warnings, the "Found N patches to apply" banner, and the dry-run listing too. Structural manifest issues (missing / unparseable `patches.json`) remain global — those block any import regardless of scope, because the manifest has to be valid to resolve filenames against in the first place.
185
+
186
+ ### Patch delete — dependency warning clarifies runtime-only impact
187
+
188
+ - `src/commands/patch/delete.ts`'s refusal message now reads `"N later patch(es) contain import statements that reference files created by <target>. Patch application itself will still succeed, but runtime imports will fail at browser startup until those files are re-introduced."` instead of the previous "later patches depend on files created by X". The old phrasing implied patch application itself would fail, which is incorrect — `git apply` does not resolve `ChromeUtils.importESModule` specifiers and will happily apply a queue whose imports point at deleted files. An eval run confirmed this directly: forcing `patch delete` with `--force-unsafe` and then re-importing the mutated 20-patch queue produced zero rejects. The reworded message lets operators planning a rename / refactor (the legitimate "I'll re-introduce the imported files under a new name") make the call without being scared off by a phantom apply-time risk; the runtime risk is still clearly named.
189
+
190
+ ### Token add — `--mode` option marked `(required)` in help output
191
+
192
+ - `src/commands/token.ts` appends `(required)` to the `--mode` description. Commander's `.makeOptionMandatory(true)` enforces the option at runtime, but it does not render a `(required)` marker in `--help` output the way `.requiredOption` does — a real invocation derived from the built-in help failed with `error: required option '--mode <mode>' not specified` despite help listing the option alongside normal optional flags. Switching to `.requiredOption` would have lost the `.choices(['auto', 'static', 'override'])` enforcement, so keeping the Option-object form with an explicit description suffix is the minimal fix that makes help honest. Runtime validation is unchanged.
193
+
194
+ ### Furnace validate — customized built-ins accepted as valid components
195
+
196
+ - `classExtendsMozLitElement` in `src/core/furnace-validate-helpers.ts` now accepts a class that extends `HTMLAnchorElement`, `HTMLButtonElement`, or any other `HTML<Something>Element` **when** the same module calls `customElements.define(..., ..., { extends: "<tagname>" })` with a literal `extends:` option. Before this, `fireforge furnace override moz-support-link --type full` wrote the upstream source verbatim (the toolkit's `moz-support-link` extends `HTMLAnchorElement` with `customElements.define("moz-support-link", ..., { extends: "a" })`) and `furnace validate` then rejected it with `[not-moz-lit-element] Component class must extend MozLitElement` — blocking deploy on a legitimate upstream pattern. Both halves of the shape are required: a class that extends `HTMLButtonElement` without the matching `extends:` option is almost certainly an author mistake and still fires `not-moz-lit-element`. The autonomous-element path (`extends MozLitElement`) is unchanged.
197
+
198
+ ### Furnace chrome-doc — jar.inc.mn shared path prefix and XHTML preprocessor flag fixes
199
+
200
+ - `src/commands/furnace/chrome-doc-templates.ts` emits `../shared/<name>-chrome.css` (was `shared/<name>-chrome.css`) in the `jar.inc.mn` entry. `jar.inc.mn` is included from each theme-specific manifest (`browser/themes/osx/jar.mn`, `linux/jar.mn`, `windows/jar.mn`) where every existing entry resolves source paths relative to the including manifest's directory — a bare `shared/` produced `obj-.../browser/themes/osx/shared/<name>-chrome.css`, which does not exist. The new `../shared/` prefix climbs out of the theme-specific directory and lands on the real `browser/themes/shared/` tree.
201
+ - `jarMnEntriesForChromeDoc` no longer marks the scaffolded XHTML or JS entries with the `*` preprocessor flag. The generated XHTML / JS contain no `#filter` / `#expand` / `#include` directives, and mach's `process_install_manifest.py` fails the whole package step with "no preprocessor directives found" when a `*`-flagged entry has nothing for the preprocessor to do. A fork that later needs brand substitution can reintroduce `*` alongside a top-of-file `#filter substitution` directive.
202
+
203
+ ### Furnace packaging test — probes both dist/bin/browser and app-bundle layouts
204
+
205
+ - `src/commands/furnace/chrome-doc-tests.ts`'s scaffolded packaging test now probes both candidate packaged-tree layouts per asset via a `probeEither(primary, fallback, description)` helper: `<AppDir>/chrome/browser/…` (the unpacked layout when `XCurProcD` honours `firefox-appdir = "browser"` and resolves into `dist/bin/browser/`) AND `<AppDir>/browser/chrome/browser/…` (the macOS .app-bundle and some ESR layouts where `XCurProcD` sits one level above `browser/` even when the appdir directive is set). The assertion only fails when both candidates miss, which is the actual stale-build / missing jar.mn case. Before this, the eval on macOS consistently showed the test reporting "missing" against a file that was packaged correctly, just at the bundle-layout path the probe did not walk.
206
+
207
+ ### Furnace register — xpcshell.toml manifests get correct wiring guidance
208
+
209
+ - `src/core/manifest-rules.ts`'s `getUnregistrableAdvice` now emits xpcshell-specific guidance when the path ends in `xpcshell.toml`, pointing at `XPCSHELL_TESTS_MANIFESTS` in the nearest moz.build. Before this, the generic `testMatch` branch caught the path and suggested registering a non-existent `browser.toml` — wrong manifest type, and a path that did not exist in the generated tree. The new branch mirrors the intentional design in `create-xpcshell.ts`, which scaffolds the manifest and prints the same warning so the operator knows wiring is deliberately manual. Browser-chrome test registration (`browser.toml`) is unchanged and still auto-registered.
210
+
211
+ ### Furnace xpcshell scaffold — filesystem probe replaces browser-global module-load test
212
+
213
+ - `fireforge furnace create --xpcshell` now scaffolds `test_<name>_packaged.js` (was `test_<name>_module_loads.js`) with a filesystem-probe test instead of a `ChromeUtils.importESModule` call. Lit-based components import `chrome://global/content/vendor/lit.all.mjs`, which references `window` at module-load time — xpcshell has no `window` global, so the old module-load path reliably failed with `ReferenceError: window is not defined` for every Lit-based fork component (the eval scenario). The replacement probes `XCurProcD` at both candidate layouts for `<name>.mjs` and `<name>.css`, matching the chrome-doc packaging scaffold's pattern. This tests what xpcshell CAN test (packaging) without tripping on browser-only globals; functional UI assertions still belong in a browser-chrome mochitest (`furnace create --test-style browser-chrome`) and the scaffolded test carries an inline comment pointing there.
214
+
215
+ ### Furnace lock — stale lock cleanup via PID-first check, signal handler, and doctor repair
216
+
217
+ - `src/core/file-lock.ts`'s `removeIfStaleLock` now checks the PID file BEFORE the age gate. If the lock's PID file says its owner is no longer alive, the lock is removed immediately regardless of age. Before this ordering change the age gate (5 min default) fired first, so a lock written by a process the user had just SIGINT'd sat undisturbed for the full window even though its owner was explicitly gone — the next `fireforge furnace …` / `fireforge test --build` then timed out waiting, and the only operator recovery was to `rm -rf .fireforge/furnace.lock` manually. The age-only fallback still applies when the PID file is missing (older release locks, externally-created lock directories).
218
+ - `bin/fireforge.ts`'s signal handler calls a new `forceReleaseFurnaceLocksForActiveOperations()` sweep after `rollbackActiveOperationsForSignal` and before `process.exit`. `withFileLock`'s `finally { rm }` never runs when the handler calls `process.exit`, so without the sweep a SIGINT during `furnace preview` left the lock behind and subsequent commands stalled. Errors are logged but swallowed — a slow I/O failure at shutdown cannot prevent the process from exiting.
219
+ - `src/commands/doctor-furnace.ts` adds a `Furnace lock` check that detects and (under `--repair-furnace`) removes a stale lock directory. Two signals flag a lock as stale: (1) PID file present but owner dead, (2) PID file absent AND directory older than 60s. Without `--repair-furnace` the check is warning-only; with it, the lock is `rm -rf`'d. This is the recovery path when the signal-handler sweep misses (SIGKILL, older FireForge release lock, externally-created directory).
220
+
221
+ ### Diagnostics — xpcshell-appdir wins over stale-build-artifact on generic resource failures
222
+
223
+ - `src/commands/test.ts`'s `hasStaleBuildArtifactsSignal` no longer matches `resource:///modules/distribution.sys.mjs` — the signal now requires a branding-specific path (`chrome://branding/locale/brand.properties`, `browser/branding/<name>/moz.build`). Before this narrowing, any `Failed to load resource:///modules/…` failure routed to the "rebuild" advice, which was wrong for the eval's Hominis case (`HominisStore.sys.mjs` missed because of an appdir / packaging issue, not stale artifacts — rebuilding did nothing). Branding-specific failures still win ahead of the xpcshell-appdir hint; cases that used to match the distribution literal now fall through to xpcshell-appdir, which is the right first guess for generic `resource:///modules/…` module-load failures.
224
+
152
225
  ## 0.15.0
153
226
 
154
227
  ### Re-export — opt-in `--stamp` and per-patch `lintIgnore`
package/README.md CHANGED
@@ -36,6 +36,7 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
36
36
  - **Python 3** (required by Firefox's `mach` build system).
37
37
  - **Git**
38
38
  - Platform build tools: Xcode on macOS, `build-essential` on Linux, Visual Studio Build Tools on Windows.
39
+ - **Watchman** (optional, only required by `fireforge watch`). Install via `brew install watchman` (macOS), `dnf install watchman` (Fedora), or follow the upstream [Meta docs](https://facebook.github.io/watchman/). `fireforge doctor` surfaces a warning row when it is not on `PATH` so the dependency is visible during the usual onboarding sweep rather than at the watch-mode failure site.
39
40
 
40
41
  ### Setup
41
42
 
@@ -54,6 +55,10 @@ npx fireforge run # launch it
54
55
 
55
56
  Your project now has `fireforge.json`, an `engine/` directory with Firefox source and a `patches/` directory with an empty `patches.json` manifest ready for your first customisation.
56
57
 
58
+ #### Known upstream build issues
59
+
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 (Hominis ships 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
+
57
62
  ### Workflow Overview
58
63
 
59
64
  1. Make changes inside the `engine/` directory.
@@ -544,9 +549,11 @@ Both rules compose with the existing `tokenPrefix` / `tokenAllowlist` checks and
544
549
 
545
550
  `fireforge furnace create --with-tests` scaffolds a **browser-chrome mochitest**. Use this when the component renders UI that depends on the tab strip (`openLinkIn` → `URILoadingHelper`, `gBrowser`, etc.).
546
551
 
547
- `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>_module_loads.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).
552
+ `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.
553
+
554
+ The scaffolded xpcshell test is a **packaging probe**, not a module-load test. Lit-based components import `chrome://global/content/vendor/lit.all.mjs`, which references `window` at module-load — xpcshell has no `window` global, so an earlier scaffold that used `ChromeUtils.importESModule` reliably failed with `ReferenceError: window is not defined` for every Lit-based fork component. Instead, the test reads `XCurProcD` (`Services.dirsvc.get("XCurProcD", Ci.nsIFile)`) and probes two candidate layouts per asset — `<AppDir>/chrome/global/elements/<name>.{mjs,css}` (unpacked `dist/bin/browser/`) and `<AppDir>/browser/chrome/global/elements/<name>.{mjs,css}` (macOS .app-bundle / some ESR layouts). Either match passes; only when both miss does the assertion fail, which is the actual "stale build / missing jar.mn entry" case. Functional UI assertions still belong in a browser-chrome mochitest (`--test-style=browser-chrome`); the scaffolded test carries an inline comment pointing to that path so the constraint is obvious before the operator extends it.
548
555
 
549
- 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`). The scaffolded xpcshell test carries the same note inline so the constraint is obvious before the operator extends the test.
556
+ 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`).
550
557
 
551
558
  The two flags can be combined — `--with-tests --xpcshell` writes both harnesses.
552
559
 
@@ -9,7 +9,7 @@
9
9
  *
10
10
  */
11
11
  import { installBrokenPipeHandler, main } from '../src/cli.js';
12
- import { isSignalRollbackInFlight, rollbackActiveOperationsForSignal, } from '../src/core/furnace-operation.js';
12
+ import { forceReleaseFurnaceLocksForActiveOperations, isSignalRollbackInFlight, rollbackActiveOperationsForSignal, } from '../src/core/furnace-operation.js';
13
13
  import { waitForActiveCriticalSections } from '../src/core/signal-critical.js';
14
14
  import { CommandError } from '../src/errors/base.js';
15
15
  /**
@@ -51,7 +51,16 @@ function installFurnaceSignalHandler(signal, exitCode) {
51
51
  console.error(`Furnace rollback after ${signal} failed:`, error instanceof Error ? error.message : error);
52
52
  }),
53
53
  waitForActiveCriticalSections(SIGNAL_CRITICAL_SECTION_TIMEOUT_MS),
54
- ]).finally(() => {
54
+ ])
55
+ // Force-release the furnace lock directory after rollback completes.
56
+ // `withFileLock`'s `finally { rm }` never runs when we `process.exit`
57
+ // the handler below, so without this sweep the lock survives the
58
+ // process and wedges the next `fireforge furnace …` / `fireforge
59
+ // test --build` command until the staleness window elapses. See
60
+ // `forceReleaseFurnaceLocksForActiveOperations` for why the sweep is
61
+ // best-effort (errors are logged, not thrown).
62
+ .then(() => forceReleaseFurnaceLocksForActiveOperations())
63
+ .finally(() => {
55
64
  process.exit(exitCode);
56
65
  });
57
66
  });
@@ -1,10 +1,11 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { readFile, rm, stat } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { applyAllComponents } from '../core/furnace-apply.js';
4
5
  import { hasCustomEngineDrift, hasOverrideEngineDrift } from '../core/furnace-apply-helpers.js';
5
6
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, } from '../core/furnace-config.js';
6
7
  import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from '../core/furnace-constants.js';
7
- import { runFurnaceMutation } from '../core/furnace-operation.js';
8
+ import { getFurnaceLockPath, runFurnaceMutation } from '../core/furnace-operation.js';
8
9
  import { validateAllComponents } from '../core/furnace-validate.js';
9
10
  import { toError } from '../utils/errors.js';
10
11
  import { pathExists } from '../utils/fs.js';
@@ -412,6 +413,86 @@ const furnaceComponentValidationCheck = {
412
413
  }
413
414
  },
414
415
  };
416
+ /**
417
+ * Reads the owner PID from a furnace lock directory. Returns `null` when
418
+ * the PID file is missing, unreadable, or does not parse as a finite
419
+ * integer — the caller then falls back to an age-only heuristic.
420
+ */
421
+ async function readFurnaceLockPid(lockPath) {
422
+ try {
423
+ const pidContent = await readFile(join(lockPath, 'pid'), 'utf-8');
424
+ const pid = parseInt(pidContent.trim(), 10);
425
+ return Number.isFinite(pid) ? pid : null;
426
+ }
427
+ catch {
428
+ return null;
429
+ }
430
+ }
431
+ function isProcessStillRunning(pid) {
432
+ try {
433
+ process.kill(pid, 0);
434
+ return true;
435
+ }
436
+ catch {
437
+ return false;
438
+ }
439
+ }
440
+ /**
441
+ * "Furnace stale lock" check: detect and (under `--repair-furnace`)
442
+ * remove a `.fireforge/furnace.lock` directory whose owner process is no
443
+ * longer alive.
444
+ *
445
+ * This is the recovery path when the signal-handler sweep
446
+ * (`forceReleaseFurnaceLocksForActiveOperations` in `bin/fireforge.ts`)
447
+ * misses — e.g. a SIGKILL'd process that never got to run the handler, or
448
+ * a lock created by an older FireForge release without a PID file. The
449
+ * motivating eval scenario: SIGINT'ing `furnace preview` left the lock
450
+ * behind, and the next `fireforge test --build` timed out waiting for it.
451
+ * `doctor --repair-furnace` now clears the lock explicitly so the next
452
+ * command runs immediately.
453
+ */
454
+ const furnaceStaleLockCheck = {
455
+ name: 'Furnace lock',
456
+ dependsOn: ['Furnace configuration'],
457
+ skipIf: (ctx) => !ctx.furnaceConfigExists,
458
+ run: async (ctx) => {
459
+ const lockPath = getFurnaceLockPath(ctx.projectRoot);
460
+ if (!(await pathExists(lockPath))) {
461
+ return ok('Furnace lock');
462
+ }
463
+ const pid = await readFurnaceLockPid(lockPath);
464
+ const lockStat = await stat(lockPath).catch(() => null);
465
+ const ageMs = lockStat ? Date.now() - lockStat.mtimeMs : undefined;
466
+ const ageSuffix = ageMs !== undefined ? ` (age: ${Math.round(ageMs / 1000)}s)` : '';
467
+ // Two signals mark a lock as stale:
468
+ // 1. PID file says owner is dead → unambiguous, remove immediately.
469
+ // 2. PID file absent AND lock is older than 60s → older FireForge
470
+ // releases (or an externally-created lock directory) fall into
471
+ // this bucket; the age gate avoids false positives on a lock
472
+ // that was just acquired by a concurrent process that hadn't
473
+ // written its PID yet.
474
+ const ownerDead = pid !== null && !isProcessStillRunning(pid);
475
+ const pidMissingAndOld = pid === null && (ageMs ?? 0) > 60_000;
476
+ const isStale = ownerDead || pidMissingAndOld;
477
+ if (!isStale) {
478
+ // Lock is held by a running FireForge process — nothing to report.
479
+ return ok('Furnace lock');
480
+ }
481
+ const description = ownerDead
482
+ ? `Stale furnace lock at ${lockPath}: owner PID ${pid} is no longer running${ageSuffix}.`
483
+ : `Stale furnace lock at ${lockPath}: no PID file and lock directory is older than 60s${ageSuffix}.`;
484
+ if (!ctx.options.repairFurnace) {
485
+ return warning('Furnace lock', description, 'Run "fireforge doctor --repair-furnace" to remove the stale lock.');
486
+ }
487
+ try {
488
+ await rm(lockPath, { recursive: true, force: true });
489
+ return warning('Furnace lock', `Removed stale furnace lock at ${lockPath}${ageSuffix}.`);
490
+ }
491
+ catch (err) {
492
+ return failure('Furnace lock', `Could not remove stale furnace lock at ${lockPath}: ${toError(err).message}`, 'Remove the directory manually (rm -rf .fireforge/furnace.lock) and retry.');
493
+ }
494
+ },
495
+ };
415
496
  /**
416
497
  * The ordered furnace check group. Exported as an array so `doctor.ts`
417
498
  * can splice it into the main registry at the right position. The order
@@ -423,6 +504,7 @@ export const FURNACE_DOCTOR_CHECKS = [
423
504
  furnaceStateConsistencyCheck,
424
505
  furnaceEnginePathsCheck,
425
506
  furnaceStorybookCheck,
507
+ furnaceStaleLockCheck,
426
508
  furnaceEngineStateCheck,
427
509
  furnaceComponentValidationCheck,
428
510
  ];
@@ -10,6 +10,7 @@ import { ExitCode } from '../errors/codes.js';
10
10
  import { toError } from '../utils/errors.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
13
+ import { executableExists } from '../utils/process.js';
13
14
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
14
15
  /**
15
16
  * Builds a DoctorCheck object representing a successful "OK" check.
@@ -245,6 +246,23 @@ const DOCTOR_CHECKS = [
245
246
  },
246
247
  fix: 'Firefox source may be corrupted. Re-download with "fireforge download --force"',
247
248
  },
249
+ {
250
+ // `fireforge watch` has an undeclared hard dependency on watchman —
251
+ // neither `bootstrap` nor `doctor` used to surface it, so operators
252
+ // got through setup → download → build → and only discovered the gap
253
+ // when they tried to start watch mode. A warning-severity doctor row
254
+ // is the right shape: most projects never run watch, so a missing
255
+ // watchman should not fail `doctor` outright, but the information
256
+ // needs to be visible ahead of time rather than at the watch-mode
257
+ // failure site.
258
+ name: 'Watchman available',
259
+ run: async () => {
260
+ const present = await executableExists('watchman');
261
+ if (present)
262
+ return ok('Watchman available');
263
+ return warning('Watchman available', 'watchman is not installed or not on PATH. "fireforge watch" requires it.', 'Install watchman (brew install watchman / dnf install watchman / https://facebook.github.io/watchman/), then re-run doctor.');
264
+ },
265
+ },
248
266
  {
249
267
  name: 'Patches directory exists',
250
268
  run: async (ctx) => {
@@ -242,13 +242,28 @@ export async function downloadCommand(projectRoot, options) {
242
242
  // commit (e.g. line-ending normalisation or extraction artefacts) so that
243
243
  // a subsequent `fireforge import` works without --force.
244
244
  //
245
+ // Wrapped in a dedicated spinner because the restore can itself take
246
+ // tens of seconds on a ~600 MB Firefox tree: it walks every file in the
247
+ // patch manifest, calls `git status` / `git checkout` for each, and the
248
+ // eval's "download looks hung" report landed at least partly on this
249
+ // post-commit window. An operator watching the CLI needs to see that
250
+ // this phase is distinct from the preceding git-add work.
251
+ //
245
252
  // This runs BEFORE updateState so a restore failure keeps the previous
246
253
  // downloadedVersion in state.json. The invariant we preserve is
247
254
  // "state.downloadedVersion matches a clean engine": stamping the new
248
255
  // version only after the restore succeeds means a failed clean-up will
249
256
  // re-enter the resume path on the next `fireforge download` rather than
250
257
  // reporting success against a dirty engine.
251
- await cleanPatchTouchedFiles(paths.engine, paths.patches);
258
+ const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
259
+ try {
260
+ await cleanPatchTouchedFiles(paths.engine, paths.patches);
261
+ restoreSpinner.stop('Patch-touched files restored');
262
+ }
263
+ catch (error) {
264
+ restoreSpinner.error('Failed to restore patch-touched files');
265
+ throw error;
266
+ }
252
267
  await updateState(projectRoot, {
253
268
  downloadedVersion: version,
254
269
  baseCommit,
@@ -56,11 +56,29 @@ export declare function generateChromeDocCss(name: string, withTitlebar: boolean
56
56
  export declare function generateChromeDocFtl(name: string, licenseHeader: string): string;
57
57
  /**
58
58
  * Single-line jar.mn entry that registers an xhtml + js pair under
59
- * `content/browser/`. Emits the `*` preprocessor flag so both files flow
60
- * through `#filter substitution` for FireForge brand-name substitution.
59
+ * `content/browser/`.
60
+ *
61
+ * Neither emitted line carries the `*` preprocessor flag. The scaffolded
62
+ * XHTML and JS contain no `#filter` / `#expand` / `#include` directives,
63
+ * and mach's `process_install_manifest.py` fails the whole package step
64
+ * with "no preprocessor directives found" when a preprocessed entry has
65
+ * nothing for the preprocessor to do. A fork that later needs brand
66
+ * substitution can re-introduce `*` and add a top-of-file
67
+ * `#filter substitution` directive itself.
61
68
  */
62
69
  export declare function jarMnEntriesForChromeDoc(name: string): string[];
63
- /** jar.inc.mn entry that registers the scoped CSS under `content/browser/`. */
70
+ /**
71
+ * jar.inc.mn entry that registers the scoped CSS under `content/browser/`.
72
+ *
73
+ * The source path is `../shared/<name>-chrome.css` because `jar.inc.mn`
74
+ * is included from each theme-specific manifest (`browser/themes/osx/jar.mn`,
75
+ * `browser/themes/linux/jar.mn`, `browser/themes/windows/jar.mn`), and every
76
+ * existing entry in those manifests resolves paths relative to the including
77
+ * manifest's directory. A bare `(shared/…)` path produced
78
+ * `obj-.../browser/themes/osx/shared/<name>-chrome.css` which does not exist;
79
+ * `(../shared/…)` matches the upstream pattern and resolves under
80
+ * `browser/themes/shared/`.
81
+ */
64
82
  export declare function jarIncMnEntryForChromeDoc(name: string): string;
65
83
  /**
66
84
  * locales/jar.mn entry that registers the `.ftl` under the browser locale
@@ -149,18 +149,36 @@ ${name}-window-title = ${name}
149
149
  }
150
150
  /**
151
151
  * Single-line jar.mn entry that registers an xhtml + js pair under
152
- * `content/browser/`. Emits the `*` preprocessor flag so both files flow
153
- * through `#filter substitution` for FireForge brand-name substitution.
152
+ * `content/browser/`.
153
+ *
154
+ * Neither emitted line carries the `*` preprocessor flag. The scaffolded
155
+ * XHTML and JS contain no `#filter` / `#expand` / `#include` directives,
156
+ * and mach's `process_install_manifest.py` fails the whole package step
157
+ * with "no preprocessor directives found" when a preprocessed entry has
158
+ * nothing for the preprocessor to do. A fork that later needs brand
159
+ * substitution can re-introduce `*` and add a top-of-file
160
+ * `#filter substitution` directive itself.
154
161
  */
155
162
  export function jarMnEntriesForChromeDoc(name) {
156
163
  return [
157
- `* content/browser/${name}.xhtml (content/${name}.xhtml)`,
164
+ ` content/browser/${name}.xhtml (content/${name}.xhtml)`,
158
165
  ` content/browser/${name}.js (content/${name}.js)`,
159
166
  ];
160
167
  }
161
- /** jar.inc.mn entry that registers the scoped CSS under `content/browser/`. */
168
+ /**
169
+ * jar.inc.mn entry that registers the scoped CSS under `content/browser/`.
170
+ *
171
+ * The source path is `../shared/<name>-chrome.css` because `jar.inc.mn`
172
+ * is included from each theme-specific manifest (`browser/themes/osx/jar.mn`,
173
+ * `browser/themes/linux/jar.mn`, `browser/themes/windows/jar.mn`), and every
174
+ * existing entry in those manifests resolves paths relative to the including
175
+ * manifest's directory. A bare `(shared/…)` path produced
176
+ * `obj-.../browser/themes/osx/shared/<name>-chrome.css` which does not exist;
177
+ * `(../shared/…)` matches the upstream pattern and resolves under
178
+ * `browser/themes/shared/`.
179
+ */
162
180
  export function jarIncMnEntryForChromeDoc(name) {
163
- return ` content/browser/${name}-chrome.css (shared/${name}-chrome.css)`;
181
+ return ` content/browser/${name}-chrome.css (../shared/${name}-chrome.css)`;
164
182
  }
165
183
  /**
166
184
  * locales/jar.mn entry that registers the `.ftl` under the browser locale
@@ -69,32 +69,57 @@ export function generateChromeDocPackagingTest(name, header) {
69
69
  add_task(async function test_${taskSuffix}_files_packaged() {
70
70
  const appDir = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
71
71
 
72
- function probe(segments, description) {
73
- const file = appDir.clone();
74
- for (const segment of segments) {
75
- file.append(segment);
72
+ // Probes a pair of candidate layouts for the same packaged file:
73
+ // 1) \`<AppDir>/chrome/browser/…\` the unpacked layout when
74
+ // XCurProcD honours \`firefox-appdir = "browser"\` and resolves
75
+ // into \`dist/bin/browser/\`.
76
+ // 2) \`<AppDir>/browser/chrome/browser/…\` — the macOS .app bundle
77
+ // layout and some ESR configurations, where XCurProcD sits one
78
+ // level above \`browser/\` even when the appdir directive is set.
79
+ // If either path exists the file is packaged; the assertion only fails
80
+ // when BOTH layouts miss, which is the actual stale-build / missing
81
+ // jar.mn entry case. Before this dual probe, the eval on macOS
82
+ // consistently failed against layout (2) even though the file was
83
+ // packaged correctly.
84
+ function probeEither(primary, fallback, description) {
85
+ const primaryFile = appDir.clone();
86
+ for (const segment of primary) {
87
+ primaryFile.append(segment);
76
88
  }
89
+ const fallbackFile = appDir.clone();
90
+ for (const segment of fallback) {
91
+ fallbackFile.append(segment);
92
+ }
93
+ const found = primaryFile.exists() ? primaryFile : fallbackFile.exists() ? fallbackFile : null;
77
94
  Assert.ok(
78
- file.exists(),
79
- description + " missing at " + file.path +
80
- ' run "fireforge build --ui" and retry. If the file is present under ' +
81
- "obj-*/dist/ but absent from this path, the xpcshell harness is probing " +
82
- "a stale build tree; the post-build audit should flag the same miss.",
83
- );
84
- Assert.greater(
85
- file.fileSize,
86
- 0,
87
- description + " is zero-length at " + file.path +
88
- " — packaging copied an empty file, check the source template.",
95
+ found !== null,
96
+ description +
97
+ " missing at both " +
98
+ primaryFile.path +
99
+ " and " +
100
+ fallbackFile.path +
101
+ ' — run "fireforge build --ui" and retry. If one of those paths IS populated, the xpcshell harness is probing a stale build tree; the post-build audit should flag the same miss.',
89
102
  );
103
+ if (found !== null) {
104
+ Assert.greater(
105
+ found.fileSize,
106
+ 0,
107
+ description +
108
+ " is zero-length at " +
109
+ found.path +
110
+ " — packaging copied an empty file, check the source template.",
111
+ );
112
+ }
90
113
  }
91
114
 
92
- probe(
115
+ probeEither(
93
116
  ["chrome", "browser", "content", "browser", "${name}.xhtml"],
117
+ ["browser", "chrome", "browser", "content", "browser", "${name}.xhtml"],
94
118
  "${name}.xhtml",
95
119
  );
96
- probe(
120
+ probeEither(
97
121
  ["chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
122
+ ["browser", "chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
98
123
  "${name}-chrome.css",
99
124
  );
100
125
  });
@@ -33,13 +33,23 @@ export declare function xpcshellTestFileName(name: string): string;
33
33
  /**
34
34
  * Generates an xpcshell test file for a custom component.
35
35
  *
36
- * xpcshell tests run headless without a `tabbrowser`, so they suit
37
- * storage/observer/module-loading code in forks that do not mount the
38
- * upstream browser chrome (and therefore lack `openLinkIn`
39
- * `URILoadingHelper`). The scaffold imports the component module via
40
- * `ChromeUtils.importESModule` and asserts the module resolves enough
41
- * to catch registration regressions without touching DOM rendering paths
42
- * that xpcshell cannot execute.
36
+ * xpcshell cannot execute a component module that imports
37
+ * `chrome://global/content/vendor/lit.all.mjs` the Lit bundle touches
38
+ * `window` at module-load time and the xpcshell harness has no `window`
39
+ * global. Before 0.16.0 the scaffold called `ChromeUtils.importESModule`
40
+ * on the component's MJS, which reliably failed with
41
+ * `ReferenceError: window is not defined` for every Lit-based fork
42
+ * component. FireForge's diagnostics then misrouted the failure to the
43
+ * "stale build artifacts" branch, sending operators on a rebuild loop
44
+ * that couldn't fix a runtime-environment incompatibility.
45
+ *
46
+ * The rewrite here mirrors the chrome-doc packaging test: XCurProcD is
47
+ * probed at a pair of candidate layouts (dist/bin/browser and the macOS
48
+ * .app-bundle / ESR layout) to confirm the `.mjs` and `.css` files
49
+ * landed where jar.mn promised. That's the assertion xpcshell CAN make.
50
+ * Functional tests that need DOM/shadow-root/keyboard behaviour belong
51
+ * in a browser-chrome mochitest — scaffolded via
52
+ * `fireforge furnace create --test-style browser-chrome`.
43
53
  */
44
54
  export declare function generateXpcshellTestContent(name: string, header: string): string;
45
55
  /**