@hominis/fireforge 0.16.2 → 0.16.5
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 +93 -1
- package/README.md +15 -2
- package/dist/bin/fireforge.js +11 -2
- package/dist/src/commands/doctor-furnace.js +83 -1
- package/dist/src/commands/doctor.js +18 -0
- package/dist/src/commands/download.js +58 -12
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
- package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
- package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- package/dist/src/commands/furnace/create-templates.d.ts +17 -7
- package/dist/src/commands/furnace/create-templates.js +85 -31
- package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
- package/dist/src/commands/furnace/create-xpcshell.js +1 -1
- package/dist/src/commands/furnace/create.js +2 -0
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/import.js +63 -11
- package/dist/src/commands/patch/delete.js +10 -1
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.js +15 -1
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/setup-support.js +60 -7
- package/dist/src/commands/status.js +28 -1
- package/dist/src/commands/test.js +28 -5
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +19 -2
- package/dist/src/commands/wire.js +22 -2
- package/dist/src/core/branding.d.ts +10 -0
- package/dist/src/core/branding.js +7 -9
- package/dist/src/core/build-prepare.js +8 -1
- package/dist/src/core/file-lock.js +49 -15
- package/dist/src/core/furnace-operation.d.ts +17 -0
- package/dist/src/core/furnace-operation.js +30 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
- package/dist/src/core/furnace-validate-helpers.js +53 -2
- package/dist/src/core/git.js +39 -10
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.js +15 -6
- package/dist/src/core/manifest-rules.js +16 -0
- package/dist/src/core/marionette-preflight.js +43 -12
- package/dist/src/core/patch-files.d.ts +12 -1
- package/dist/src/core/patch-files.js +14 -11
- package/dist/src/core/patch-lint.js +62 -11
- package/dist/src/core/wire-destroy.js +18 -5
- package/dist/src/core/wire-init.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## 0.16.0
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### UX, correctness, and consistency
|
|
6
|
+
|
|
7
|
+
- **`fireforge patch` / `fireforge token` — exit 0 with help (parent-command contract).** `fireforge furnace` exited 0 and printed its status message when run with no subcommand; `fireforge patch` and `fireforge token` silently inherited commander's default help-then-exit-1 path. Scripts probing the CLI surface therefore saw an inconsistent exit contract for three parent commands that do the same job (group related subcommands). Both `patch` and `token` now install a default `.action()` that prints their own help via `outputHelp()` and returns successfully — exit 0, same output shape, no destructive or stateful side effect. A new drift test in `src/commands/__tests__/manifest.test.ts` asserts every group-style parent has a default action installed so a future parent cannot regress back to the exit-1 contract silently.
|
|
8
|
+
- **`fireforge furnace status` — tips now prefix with `fireforge`.** The trailing `info()` line at the end of `furnace status` said `run \`furnace status <name>\``/`furnace --help`, which only worked if the operator happened to have a separate `furnace` binary on PATH. Copy-pasting the suggestion out of FireForge's own output produced a shell error on every fresh project. The message now names the real invocation (`fireforge furnace status <name>`, `fireforge furnace --help`), so the suggested commands are directly runnable.
|
|
9
|
+
- **`fireforge download` — de-duplicated git-init progress in non-TTY logs.** The resume and init `onProgress` callbacks called both `spinner.message(msg)` and `step(msg)` in non-TTY mode, producing two copies of every git-init progress line in CI logs because `src/utils/logger.ts`'s non-TTY spinner fallback already calls `p.log.step(msg)` from `message()`. The explicit `step()` sibling call is removed on both paths, so each progress message appears exactly once regardless of TTY mode. The tests in `download.test.ts` / `download.integration.test.ts` now assert the spinner-handle contract directly instead of the removed `step()` signal.
|
|
10
|
+
- **`fireforge download` — honest closing message on an empty patch queue.** `download` always stopped the restore spinner with `Patch-touched files restored`, even when the project had never exported a patch — a misleading claim of work on a fresh workspace. `cleanPatchTouchedFiles` now returns a structured `{ hadQueue, restored, preserved }` result, and a new `closeRestoreSpinner` helper picks one of three stop messages: `No patches in queue — nothing to restore` (empty queue), `Patch-touched files already match baseline` (queue present, nothing dirty), or the original `Patch-touched files restored` (work actually happened).
|
|
11
|
+
- **`fireforge build` — gecko-profiler bindgen hint now surfaces on Darwin 25.** The `_CharT` / `basic_string___self_view` hint had been registered in `mach-error-hints.ts` since 0.16.0 but never fired against the eval's Darwin 25 build log. Root cause: `build()` and `buildUI()` in `src/core/mach.ts` fed only `result.stderr` to `surfaceMachErrorHints`, but mach's timestamp-prefixing wrapper streamed the `rustc error[E0425]` lines through stdout. The hint surfacer now scans `${result.stderr}\n${result.stdout}` so the existing `_CharT` pattern matches regardless of which stream mach chose. A new regression test in `mach.test.ts` pins the stdout-only failure mode so a future refactor cannot accidentally narrow the capture again.
|
|
12
|
+
- **`fireforge build` — new hint clarifying the post-failure `Configure complete!` epilogue.** When `mach build` fails, mach's own shutdown pipeline runs a `Config object not found by mach. / Configure complete! / Be sure to run |mach build|...` block on the way out. That block is plain upstream mach output, printed after the non-zero exit code has already been established, but it looks deceptively like a success banner. A new `MACH_ERROR_HINTS` entry matches the exact `Config object not found by mach.\s*Configure complete!` signature and surfaces `Ignore the trailing "Config object not found by mach. / Configure complete!" block — that is mach's post-failure configure summary printed after the build already failed, not a sign the build succeeded.` The pattern is narrow enough that a real post-`mach configure` success (which legitimately prints "Configure complete!" alone) does not trigger it.
|
|
13
|
+
- **`fireforge wire --dry-run` — init/destroy validation now matches the real run.** Pre-0.16 the `validateWireName(expression, …)` check only ran inside `addInitToBrowserInit` / `addDestroyToBrowserInit` (the real-execution path), so `fireforge wire eval-startup --init 'void 0' --dry-run` succeeded and rendered a plausible preview — then the same arguments without `--dry-run` failed with `Invalid init expression "void 0": must contain only letters, digits, hyphens, underscores, dots, and $ signs`. Validation is now hoisted into `wireCommand` before the dry-run/real branch, so both paths enforce the identical regex. The library-level call inside addInit/addDestroy remains as defence-in-depth for programmatic callers that bypass the CLI entry point.
|
|
14
|
+
- **`fireforge wire` — coerces bare property chains into function calls.** The init/destroy validator accepted both `Foo.bar` and `Foo.bar()` shapes, but the emitted code template interpolated the expression verbatim — so `fireforge wire eval-startup --init EvalStartup.init` wrote `EvalStartup.init;` (a plain property reference) into `browser-init.js`, silently producing a lifecycle hookup that never invoked the hook. A new `coerceToCall(expression)` helper in `src/core/wire-utils.ts` appends `()` when the expression lacks trailing parens, idempotent when they are already present. Both the AST (`addInitAST`/`addDestroyAST`) and legacy fallback (`legacyAddInit`/`legacyAddDestroy`) code paths route the expression through the coercer so they agree on the emitted block shape. The idempotency regex in `addInitToBrowserInit`/`addDestroyToBrowserInit` also matches against the coerced form, so re-running `wire` with the bare form is correctly a no-op against a file that already contains the coerced call. The dry-run preview (`printWireDryRun`) applies the same coercion so the preview and the real run match.
|
|
15
|
+
- **`fireforge furnace create` — defensive read-back after writing `furnace.json`.** The eval observed a run where `furnace create eval-card --with-tests --localized --allow-prefix-mismatch` reported success and wrote component files under `components/custom/eval-card/`, but `furnace.json` ended up with `"custom": {}` and every subsequent command (`status`, `apply`, `rename`, `remove`) on `eval-card` failed with "not found in furnace.json". Local reproduction with the same source and same arguments writes the entry correctly and the unit-test coverage is green, so the bug could not be pinpointed from source alone. The defensive fix is a read-back verification in `performCreateMutations`: after `writeFurnaceConfig(…, config)` we call `loadFurnaceConfig(projectRoot)` and assert `componentName in persisted.custom`. If the entry is missing, the command throws a `FurnaceError` pointing to the prefix rule and `--allow-prefix-mismatch`, the rollback journal restores the pre-command state, and the operator sees the failure instead of a phantom success. The mock in `furnace-create.test.ts` was updated to reflect `writeFurnaceConfig` into subsequent `loadFurnaceConfig` reads so the unit-test path exercises the same round-trip.
|
|
16
|
+
- **`fireforge token coverage` — scans deployed Furnace custom-component CSS.** Coverage discovery was git-status-based: it read `getStatusWithCodes` and filtered to `.css` files. A `moz-eval-card.css` deployed into `engine/toolkit/content/widgets/moz-eval-card/` by `furnace deploy` never appeared in the scan if it was already tracked in the engine's own git repository (i.e. not dirty in status). The command now loads `furnace.json` (when present) and walks every entry in `config.custom`, probing `${targetPath}/${componentName}.css` under the engine directory. Files that exist on disk are merged with the git-status set and de-duplicated; the tokens CSS itself is still excluded. Projects without `furnace.json` keep the old git-status-only path unchanged. A new test case in `token-coverage.test.ts` pins the augmented discovery against the eval's exact scenario.
|
|
17
|
+
- **`fireforge furnace preview` — backend-artifact failure gets its own diagnosis.** When `mach storybook` failed deep inside `config.status` / `chrome-map.json` because the Firefox build backend was incomplete, the pre-0.16 heuristic's second clause matched the literal string `backend` — which does not appear in the error output — so the generic message sent the operator back to `fireforge furnace preview --install` (the wrong recovery path) after the install had already succeeded. `buildStorybookFailureMessage` is now exported and covered by a dedicated test file (`preview-failure-message.test.ts`). It first checks a narrow set of backend-artifact patterns (`chrome-map.json`, `config.status`, `obj-*/dist/bin/.lldbinit`) against a file-not-found signal; on match it produces a distinct message telling the operator to rerun `fireforge build` and wait for it to finish. The generic "missing Storybook dependencies" and fallthrough branches are preserved for the cases they actually describe.
|
|
18
|
+
- **`fireforge export` / `fireforge export-all` — new `--allow-overlap` gate against silent cross-patch ownership.** Pre-0.16 `export` only caught FULL-coverage supersedes via `findAllPatchesForFiles`. A second export targeting a shared file like `browser/themes/shared/jar.inc.mn` happily created a queue where two patches both listed the same file in `filesAffected`, and `fireforge verify` then immediately failed with "cross-patch filesAffected conflicts". A new `findPartialOwnershipOverlap` helper in `export-shared.ts` walks the manifest and maps each overlapping file to its claiming patches, excluding any patches that the caller already intends to fully supersede. The new `guardOwnershipOverlap` routes the result through a non-interactive refusal or an interactive prompt: without `--allow-overlap`, the command refuses in non-interactive mode and asks for acknowledgement in interactive mode. The refusal message points at `fireforge re-export --files <paths> <patch>` as the right primitive for repartitioning ownership, which replaces the (much more dangerous) manual `patches.json` editing the eval's operator resorted to. Both `export` and `export-all` now expose a matching `--allow-overlap` flag.
|
|
19
|
+
- **`fireforge re-export --scan` — broad expansions require explicit acknowledgement.** Pre-0.16 `--scan` blindly merged every modified or untracked file in a patch's parent directories into its `filesAffected`. The eval scenario: two small patches in adjacent-but-unrelated features shared a directory tree, and a single `re-export --all --scan` silently absorbed the entire neighbour feature (xhtml + xpcshell tests + theme CSS) into the wrong patch. The 0.16.0 gate: when `--scan` would add more than three files, or files spanning more than one directory, the command calls `confirmBroadScanAdditions` — dry-run proceeds silently (previewing is the whole point), `--yes` proceeds silently (the explicit opt-in), interactive mode prompts for confirmation, and non-interactive mode without `--yes` refuses with the expansion summary. Small same-directory additions (the common refresh case) stay frictionless.
|
|
20
|
+
- **`fireforge test --doctor` — closes the intro frame with an explicit outro.** Running `test --doctor` on its own showed `● Running marionette preflight...` and then exited silently with code 0 in the eval's non-TTY capture — the `Marionette preflight: PASS (…)` line that `reportMarionettePreflight` emitted via `info()` failed to render inside the unclosed clack intro frame. The doctor-only success branch now calls `outro(\`Marionette preflight: PASS (${durationMs}ms)\`)`before returning, which closes the tree and gives scripts a deterministic "done" marker to parse. The failing branch already throws through`GeneralError`, which is routed via the standard error pipeline and does not need the same treatment.
|
|
21
|
+
- **`fireforge run --smoke-exit` — allowlist summary shows both error-class and total counts.** The previous summary only reported `Allowlisted hits: N`, where `N` was incremented only when a line matched both `SMOKE_ERROR_PATTERNS` AND the caller-supplied allowlist. An operator whose `--console-allow RSLoader:` pattern visibly matched `console.warn: RSLoader: …` lines still saw `Allowlisted hits: 0`, because `console.warn:` is not a smoke-error class — the allowlist was never consulted for those lines. The summary now distinguishes two counters: `Allowlisted error hits (suppressed): N` (the exit-contract number: errors the allowlist kept out of the findings list) and `Allowlisted lines total: M` (the mental-model number: every console line that matched the allowlist, regardless of error class). The exit contract itself is unchanged — unallowed errors still fail the smoke window.
|
|
22
|
+
- **`fireforge resolve` — `filesAffected` is always recomputed from the new diff body.** Pre-0.16 the resolve command updated `patches.json.filesAffected` only when `activeFiles.length < existingFiles.length` (files deleted from disk). If the user's manual fix eliminated every hunk for a specific file while the file itself still existed on disk, the metadata kept claiming it — and the next `fireforge import` immediately failed the patch-manifest consistency check with "patches.json declares [...] but the patch file targets [...]". The command now threads the generated `diffContent` through `extractAffectedFiles` (the same helper `export` and the consistency checker use) and unconditionally passes the result as `filesAffected`. Resolve and consistency-check therefore always agree on the set of targeted files. A new round-trip test in `resolve.test.ts` pins the scenario where the diff shrinks but every file still exists on disk.
|
|
23
|
+
|
|
24
|
+
### Security
|
|
6
25
|
|
|
7
26
|
- **Release workflow — shell injection via `${{ inputs.version }}` interpolation.** `.github/workflows/release.yml` previously interpolated `${{ inputs.version }}` directly into `npm version "${{ inputs.version }}" --no-git-tag-version`, so anyone with `actions:write` could trigger `workflow_dispatch` with a crafted version string (e.g. `1.0.0"; <command> #`) and execute arbitrary shell inside the job. The release job carries `contents: write`, `id-token: write`, and the `npm` trusted-publishing environment, so that shell would have had an open door to the publish credentials. The fix routes `${{ inputs.version }}` through an `env: INPUT_VERSION:` block on the `Bump version` step and references `"$INPUT_VERSION"` inside the `run:` script, so GitHub Actions substitutes the value into the process environment rather than into the shell source. The `Tag and push` step gets the same treatment for `${{ steps.version.outputs.version }}` — defense-in-depth, since `npm version`'s semver check already filters that interpolation, but the pattern is consistent and cheap.
|
|
8
27
|
- **`fireforge config` — prototype pollution via sentinel key segments.** `mutateConfig` in `src/core/config-mutate.ts` walks a dot-separated key through `getOrCreateChildRecord(parent, segment)` with no filter on `__proto__`, `constructor`, or `prototype`. `fireforge config __proto__.polluted 1 --force` therefore reached `parent["__proto__"]` and wrote a plain property onto `Object.prototype`, polluting every object in the Node process for the rest of the run. `--force` was the motivating pathway (the strict path guard rejects unknown top-level keys for non-sentinels), but the raw sink was also publicly re-exported from `src/core/config.ts`, widening the blast radius to any future caller. The fix rejects sentinel segments up-front in `mutateConfig` with a `ConfigError` before any clone or mutation — a single guard at the entry point covers both the descent loop and the final leaf assignment, and surfaces to the CLI as a normal "invalid key" failure rather than a crash. `readJson`'s existing reviver already strips the sentinels from loads, so input configs can't arrive pre-polluted.
|
|
@@ -149,6 +168,79 @@
|
|
|
149
168
|
|
|
150
169
|
- `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
170
|
|
|
171
|
+
### Lifecycle — `test --doctor` exits cleanly on passing preflight
|
|
172
|
+
|
|
173
|
+
- `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.
|
|
174
|
+
|
|
175
|
+
### Lifecycle — `download` progress visibility during git indexing and patch-touched restore
|
|
176
|
+
|
|
177
|
+
- `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.
|
|
178
|
+
|
|
179
|
+
### Doctor — post-interrupt engine state check and watchman preflight
|
|
180
|
+
|
|
181
|
+
- `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.
|
|
182
|
+
- `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.
|
|
183
|
+
|
|
184
|
+
### Setup — package.json license kept in sync with fireforge.json
|
|
185
|
+
|
|
186
|
+
- `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.
|
|
187
|
+
|
|
188
|
+
### Branding — generated files carry the project license header
|
|
189
|
+
|
|
190
|
+
- `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.
|
|
191
|
+
|
|
192
|
+
### Patch lint — branding tier for `large-patch-lines`, auto-exemption for branding headers and colors
|
|
193
|
+
|
|
194
|
+
- `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.
|
|
195
|
+
- `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.
|
|
196
|
+
|
|
197
|
+
### Patch manifest — binary file paths survive verify, repair, and rebuild
|
|
198
|
+
|
|
199
|
+
- `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.
|
|
200
|
+
|
|
201
|
+
### Import — `--until` scopes patch-integrity checks to the targeted range
|
|
202
|
+
|
|
203
|
+
- `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.
|
|
204
|
+
|
|
205
|
+
### Patch delete — dependency warning clarifies runtime-only impact
|
|
206
|
+
|
|
207
|
+
- `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.
|
|
208
|
+
|
|
209
|
+
### Token add — `--mode` option marked `(required)` in help output
|
|
210
|
+
|
|
211
|
+
- `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.
|
|
212
|
+
|
|
213
|
+
### Furnace validate — customized built-ins accepted as valid components
|
|
214
|
+
|
|
215
|
+
- `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.
|
|
216
|
+
|
|
217
|
+
### Furnace chrome-doc — jar.inc.mn shared path prefix and XHTML preprocessor flag fixes
|
|
218
|
+
|
|
219
|
+
- `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.
|
|
220
|
+
- `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.
|
|
221
|
+
|
|
222
|
+
### Furnace packaging test — probes both dist/bin/browser and app-bundle layouts
|
|
223
|
+
|
|
224
|
+
- `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.
|
|
225
|
+
|
|
226
|
+
### Furnace register — xpcshell.toml manifests get correct wiring guidance
|
|
227
|
+
|
|
228
|
+
- `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.
|
|
229
|
+
|
|
230
|
+
### Furnace xpcshell scaffold — filesystem probe replaces browser-global module-load test
|
|
231
|
+
|
|
232
|
+
- `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.
|
|
233
|
+
|
|
234
|
+
### Furnace lock — stale lock cleanup via PID-first check, signal handler, and doctor repair
|
|
235
|
+
|
|
236
|
+
- `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).
|
|
237
|
+
- `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.
|
|
238
|
+
- `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).
|
|
239
|
+
|
|
240
|
+
### Diagnostics — xpcshell-appdir wins over stale-build-artifact on generic resource failures
|
|
241
|
+
|
|
242
|
+
- `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.
|
|
243
|
+
|
|
152
244
|
## 0.15.0
|
|
153
245
|
|
|
154
246
|
### 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.
|
|
@@ -149,6 +154,10 @@ fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
|
|
|
149
154
|
fireforge re-export --all --scan --stamp
|
|
150
155
|
```
|
|
151
156
|
|
|
157
|
+
`export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default.
|
|
158
|
+
|
|
159
|
+
`re-export --scan` also prompts before broadening a patch with more than a handful of newly discovered files or with files spanning multiple directories. The gate keeps the common refresh case frictionless (small, same-directory additions) while catching the failure mode where `--scan` silently pulls an adjacent feature into the wrong patch. Non-interactive mode requires `--yes` to acknowledge a broad expansion; dry-run previews never require confirmation.
|
|
160
|
+
|
|
152
161
|
### Rebasing on top of a new Firefox version
|
|
153
162
|
|
|
154
163
|
1. Update `firefox.version` in `fireforge.json`
|
|
@@ -544,9 +553,11 @@ Both rules compose with the existing `tokenPrefix` / `tokenAllowlist` checks and
|
|
|
544
553
|
|
|
545
554
|
`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
555
|
|
|
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>
|
|
556
|
+
`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.
|
|
557
|
+
|
|
558
|
+
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
559
|
|
|
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`).
|
|
560
|
+
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
561
|
|
|
551
562
|
The two flags can be combined — `--with-tests --xpcshell` writes both harnesses.
|
|
552
563
|
|
|
@@ -585,6 +596,8 @@ Exit codes are wired distinct from `BUILD_ERROR`:
|
|
|
585
596
|
|
|
586
597
|
POSIX only — process-group semantics do not map cleanly onto Windows. A smoke window shorter than 30 s warns up-front because cold-start time alone can consume that budget on a debug build; `--capture-console <file>` mirrors the captured stream so post-exit inspection has the raw log without re-running.
|
|
587
598
|
|
|
599
|
+
The summary block reports two allowlist counters so operators can tell whether a pattern actually matched anything: `Allowlisted error hits (suppressed)` is the exit-contract number (errors that would have failed the window but were dropped by the allowlist), and `Allowlisted lines total` is the mental-model number (every console line that matched the allowlist, regardless of whether it was an error-class line). A non-zero `total` with a zero `suppressed` count means the allowlist patterns matched benign info/warn lines that never counted toward the exit contract to begin with.
|
|
600
|
+
|
|
588
601
|
### Furnace `--shared-ftl` for feature-scoped Fluent bundles
|
|
589
602
|
|
|
590
603
|
A feature with multiple components (e.g. an eight-component dock) typically wants one shared `.ftl` per feature rather than eight per-component stubs. `furnace create <tag> --localized --shared-ftl <chrome-uri>` participates in an existing feature-scoped bundle:
|
package/dist/bin/fireforge.js
CHANGED
|
@@ -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
|
-
])
|
|
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) => {
|
|
@@ -10,7 +10,7 @@ import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
|
10
10
|
import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
12
12
|
import { checkDiskSpace, ensureDir, pathExists, removeDir } from '../utils/fs.js';
|
|
13
|
-
import { info, intro, outro, spinner,
|
|
13
|
+
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
14
14
|
import { pickDefined } from '../utils/options.js';
|
|
15
15
|
/**
|
|
16
16
|
* Collects the set of patch-touched files from the manifest.
|
|
@@ -39,11 +39,13 @@ async function getPatchTouchedFiles(patchesDir) {
|
|
|
39
39
|
*/
|
|
40
40
|
async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
41
41
|
const patchFiles = await getPatchTouchedFiles(patchesDir);
|
|
42
|
-
if (patchFiles.size === 0)
|
|
43
|
-
return;
|
|
42
|
+
if (patchFiles.size === 0) {
|
|
43
|
+
return { hadQueue: false, restored: 0, preserved: 0 };
|
|
44
|
+
}
|
|
44
45
|
const dirtyFiles = await getDirtyFiles(engineDir, [...patchFiles]);
|
|
45
|
-
if (dirtyFiles.length === 0)
|
|
46
|
-
return;
|
|
46
|
+
if (dirtyFiles.length === 0) {
|
|
47
|
+
return { hadQueue: true, restored: 0, preserved: 0 };
|
|
48
|
+
}
|
|
47
49
|
const toClean = preExistingDirty
|
|
48
50
|
? dirtyFiles.filter((f) => !preExistingDirty.has(f))
|
|
49
51
|
: dirtyFiles;
|
|
@@ -65,6 +67,29 @@ async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
|
65
67
|
warn(` ${file}`);
|
|
66
68
|
}
|
|
67
69
|
}
|
|
70
|
+
return { hadQueue: true, restored: toClean.length, preserved: preserved.length };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Stops `restoreSpinner` with a message that reflects what actually
|
|
74
|
+
* happened. Three branches: empty queue → explicit no-op; queue present but
|
|
75
|
+
* nothing dirty → "already clean"; queue with dirty files → the usual
|
|
76
|
+
* "Patch-touched files restored" success line.
|
|
77
|
+
*
|
|
78
|
+
* Before 0.16.0 the spinner always closed with "Patch-touched files
|
|
79
|
+
* restored", so a fresh project with zero patches saw a claim of restore
|
|
80
|
+
* work that had not happened — misleading and easy to mistake for a
|
|
81
|
+
* silent retry.
|
|
82
|
+
*/
|
|
83
|
+
function closeRestoreSpinner(restoreSpinner, result) {
|
|
84
|
+
if (!result.hadQueue) {
|
|
85
|
+
restoreSpinner.stop('No patches in queue — nothing to restore');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (result.restored === 0 && result.preserved === 0) {
|
|
89
|
+
restoreSpinner.stop('Patch-touched files already match baseline');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
restoreSpinner.stop('Patch-touched files restored');
|
|
68
93
|
}
|
|
69
94
|
/**
|
|
70
95
|
* Runs the download command.
|
|
@@ -100,11 +125,16 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
100
125
|
const resumeSpinner = spinner('Resuming git repository initialization...');
|
|
101
126
|
try {
|
|
102
127
|
await resumeRepository(paths.engine, {
|
|
128
|
+
// The non-TTY spinner fallback in `src/utils/logger.ts`
|
|
129
|
+
// already calls `p.log.step(msg)` from `message()`, so
|
|
130
|
+
// forwarding the progress message is the single authority
|
|
131
|
+
// in both TTY and non-TTY modes. Before 0.16.0 this
|
|
132
|
+
// callback also invoked `step(message)` explicitly when
|
|
133
|
+
// stdio was not a TTY, which printed the same step line
|
|
134
|
+
// twice in CI logs (once from the fallback, once from
|
|
135
|
+
// the explicit call).
|
|
103
136
|
onProgress: (message) => {
|
|
104
137
|
resumeSpinner.message(message);
|
|
105
|
-
if (!(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
106
|
-
step(message);
|
|
107
|
-
}
|
|
108
138
|
},
|
|
109
139
|
});
|
|
110
140
|
const baseCommit = await getHead(paths.engine);
|
|
@@ -223,11 +253,12 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
223
253
|
let baseCommit;
|
|
224
254
|
try {
|
|
225
255
|
await initRepository(paths.engine, 'firefox', {
|
|
256
|
+
// Same one-authority rule as the resume path above: the non-TTY
|
|
257
|
+
// spinner fallback already emits `step(msg)` internally, so
|
|
258
|
+
// calling `step()` in addition to `.message()` duplicated every
|
|
259
|
+
// git-init progress line in CI logs.
|
|
226
260
|
onProgress: (message) => {
|
|
227
261
|
gitSpinner.message(message);
|
|
228
|
-
if (!(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
229
|
-
step(message);
|
|
230
|
-
}
|
|
231
262
|
},
|
|
232
263
|
});
|
|
233
264
|
baseCommit = await getHead(paths.engine);
|
|
@@ -242,13 +273,28 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
242
273
|
// commit (e.g. line-ending normalisation or extraction artefacts) so that
|
|
243
274
|
// a subsequent `fireforge import` works without --force.
|
|
244
275
|
//
|
|
276
|
+
// Wrapped in a dedicated spinner because the restore can itself take
|
|
277
|
+
// tens of seconds on a ~600 MB Firefox tree: it walks every file in the
|
|
278
|
+
// patch manifest, calls `git status` / `git checkout` for each, and the
|
|
279
|
+
// eval's "download looks hung" report landed at least partly on this
|
|
280
|
+
// post-commit window. An operator watching the CLI needs to see that
|
|
281
|
+
// this phase is distinct from the preceding git-add work.
|
|
282
|
+
//
|
|
245
283
|
// This runs BEFORE updateState so a restore failure keeps the previous
|
|
246
284
|
// downloadedVersion in state.json. The invariant we preserve is
|
|
247
285
|
// "state.downloadedVersion matches a clean engine": stamping the new
|
|
248
286
|
// version only after the restore succeeds means a failed clean-up will
|
|
249
287
|
// re-enter the resume path on the next `fireforge download` rather than
|
|
250
288
|
// reporting success against a dirty engine.
|
|
251
|
-
|
|
289
|
+
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
290
|
+
try {
|
|
291
|
+
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
292
|
+
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
restoreSpinner.error('Failed to restore patch-touched files');
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
252
298
|
await updateState(projectRoot, {
|
|
253
299
|
downloadedVersion: version,
|
|
254
300
|
baseCommit,
|
|
@@ -7,14 +7,14 @@ import { hasChanges, isGitRepository } from '../core/git.js';
|
|
|
7
7
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
8
|
import { getWorkingTreeStatus } from '../core/git-status.js';
|
|
9
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
10
|
-
import { commitExportedPatch } from '../core/patch-export.js';
|
|
10
|
+
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
11
11
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
|
|
12
12
|
import { GeneralError } from '../errors/base.js';
|
|
13
13
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
14
14
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
15
15
|
import { pickDefined } from '../utils/options.js';
|
|
16
16
|
import { PATCH_CATEGORIES } from '../utils/validation.js';
|
|
17
|
-
import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
17
|
+
import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
18
18
|
async function checkBrandingManagedFiles(paths, config) {
|
|
19
19
|
const changedFiles = await getWorkingTreeStatus(paths.engine);
|
|
20
20
|
const brandingManagedFiles = changedFiles
|
|
@@ -177,6 +177,22 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
177
177
|
const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
|
|
178
178
|
if (!shouldProceed)
|
|
179
179
|
return;
|
|
180
|
+
// Overlap gate — see the matching comment in `export.ts`. The same
|
|
181
|
+
// cross-patch ownership problem applies to `export-all` because a
|
|
182
|
+
// mixed aggregate diff often touches shared files like manifest
|
|
183
|
+
// fragments that other patches already claim.
|
|
184
|
+
const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
|
|
185
|
+
const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
|
|
186
|
+
const shouldProceedPastOverlap = await guardOwnershipOverlap({
|
|
187
|
+
patchesDir: paths.patches,
|
|
188
|
+
filesAffected,
|
|
189
|
+
supersedingFilenames,
|
|
190
|
+
allowOverlap: options.allowOverlap === true,
|
|
191
|
+
isInteractive,
|
|
192
|
+
s,
|
|
193
|
+
});
|
|
194
|
+
if (!shouldProceedPastOverlap)
|
|
195
|
+
return;
|
|
180
196
|
// Get Firefox version for metadata
|
|
181
197
|
const { patchFilename, superseded } = await commitExportedPatch({
|
|
182
198
|
patchesDir: paths.patches,
|
|
@@ -213,6 +229,7 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
|
|
|
213
229
|
.option('--supersede', 'Allow superseding multiple existing patches')
|
|
214
230
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
215
231
|
.option('--exclude-furnace', 'Export the non-Furnace subset of the aggregate diff instead of refusing when Furnace-managed files are modified. Furnace-managed files are still deployed by "fireforge furnace apply"; this flag only changes whether export-all aborts or filters in their presence.')
|
|
232
|
+
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify)')
|
|
216
233
|
.action(withErrorHandling(async (options) => {
|
|
217
234
|
const { category, ...rest } = options;
|
|
218
235
|
await exportAllCommand(getProjectRoot(), {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PatchesManifest } from '../types/commands/index.js';
|
|
1
2
|
import type { ExportOptions, PatchCategory } from '../types/commands/index.js';
|
|
2
3
|
import type { FireForgeConfig } from '../types/config.js';
|
|
3
4
|
import type { SpinnerHandle } from '../utils/logger.js';
|
|
@@ -52,3 +53,38 @@ export declare function confirmSupersedePatches(patchesDir: string, filesAffecte
|
|
|
52
53
|
* @returns true if files were modified on disk (caller must regenerate diff)
|
|
53
54
|
*/
|
|
54
55
|
export declare function autoFixLicenseHeaders(engineDir: string, diffContent: string, config: FireForgeConfig, isInteractive: boolean): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Maps every file in `filesAffected` to the existing patches that already
|
|
58
|
+
* claim ownership of it, excluding the caller's own patch (when `newFilename`
|
|
59
|
+
* is provided) and any patches that the caller intends to fully supersede.
|
|
60
|
+
*
|
|
61
|
+
* Returns an empty map when no overlap exists. Used by the overlap gate in
|
|
62
|
+
* `export` and `export-all` to refuse a default-mode export that would
|
|
63
|
+
* silently create cross-patch ownership conflicts — the same class of
|
|
64
|
+
* conflict `verify` immediately fails with.
|
|
65
|
+
*/
|
|
66
|
+
export declare function findPartialOwnershipOverlap(manifest: PatchesManifest, filesAffected: string[], excludeFilenames: ReadonlySet<string>): Map<string, string[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Gate that refuses the default export path when the new patch would
|
|
69
|
+
* silently claim files that are already tracked by other non-superseded
|
|
70
|
+
* patches. `findAllPatchesForFiles` already catches the full-coverage
|
|
71
|
+
* supersede case — this helper fills the gap for partial overlap, which
|
|
72
|
+
* was the eval finding #12 scenario (two patches both claiming
|
|
73
|
+
* `browser/themes/shared/jar.inc.mn` after a second export with
|
|
74
|
+
* `--before`).
|
|
75
|
+
*
|
|
76
|
+
* Proceeds silently when there is no overlap, or when the caller passed
|
|
77
|
+
* `--allow-overlap`. In interactive mode the caller is prompted to
|
|
78
|
+
* acknowledge the overlap (the proper fix path is `re-export --files` to
|
|
79
|
+
* repartition ownership, so the prompt surfaces that pointer). In
|
|
80
|
+
* non-interactive mode the function throws — better to fail fast than
|
|
81
|
+
* let the queue fall out of sync with verify.
|
|
82
|
+
*/
|
|
83
|
+
export declare function guardOwnershipOverlap(args: {
|
|
84
|
+
patchesDir: string;
|
|
85
|
+
filesAffected: string[];
|
|
86
|
+
supersedingFilenames: ReadonlySet<string>;
|
|
87
|
+
allowOverlap: boolean;
|
|
88
|
+
isInteractive: boolean;
|
|
89
|
+
s: SpinnerHandle;
|
|
90
|
+
}): Promise<boolean>;
|