@hominis/fireforge 0.16.0 → 0.16.2

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 (36) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +4 -2
  3. package/dist/src/commands/config.js +16 -5
  4. package/dist/src/commands/download.js +22 -4
  5. package/dist/src/commands/export-all.js +50 -9
  6. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
  7. package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
  8. package/dist/src/commands/furnace/create.js +21 -3
  9. package/dist/src/commands/furnace/index.js +1 -0
  10. package/dist/src/commands/furnace/init.js +76 -2
  11. package/dist/src/commands/furnace/preview.js +15 -2
  12. package/dist/src/commands/lint.js +16 -1
  13. package/dist/src/commands/rebase/patch-loop.js +19 -0
  14. package/dist/src/commands/status.js +17 -5
  15. package/dist/src/commands/wire.js +47 -8
  16. package/dist/src/core/build-baseline.d.ts +14 -0
  17. package/dist/src/core/build-baseline.js +61 -1
  18. package/dist/src/core/config-mutate.d.ts +1 -1
  19. package/dist/src/core/config-mutate.js +23 -1
  20. package/dist/src/core/config.d.ts +17 -0
  21. package/dist/src/core/config.js +35 -0
  22. package/dist/src/core/firefox.d.ts +16 -2
  23. package/dist/src/core/firefox.js +7 -2
  24. package/dist/src/core/furnace-config.d.ts +23 -0
  25. package/dist/src/core/furnace-config.js +38 -0
  26. package/dist/src/core/mach-error-hints.js +23 -0
  27. package/dist/src/core/patch-lint.js +43 -20
  28. package/dist/src/core/patch-parse.d.ts +18 -7
  29. package/dist/src/core/patch-parse.js +24 -2
  30. package/dist/src/core/patch-transform.js +4 -1
  31. package/dist/src/core/test-stale-check.js +46 -1
  32. package/dist/src/core/token-manager.js +57 -4
  33. package/dist/src/core/token-scaffold.d.ts +36 -0
  34. package/dist/src/core/token-scaffold.js +74 -0
  35. package/dist/src/types/commands/options.d.ts +10 -0
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,82 @@
2
2
 
3
3
  ## 0.16.0
4
4
 
5
+ ### Security — eval-driven hardening for 0.16.0
6
+
7
+ - **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
+ - **`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.
9
+ - **`fireforge wire --dom` — asymmetric newline marker parser projection drift.** `parseHunksForFile` in `src/core/patch-parse.ts` tracked `` as a single `noNewlineAtEnd: boolean` — collapsing the old-side and new-side markers into one flag. Asymmetric trailing-newline changes (e.g. removing the newline from the old side while the new side keeps one, or vice versa) were indistinguishable from the symmetric case, so `applyPatchToContent` produced content that disagreed with `git apply` on whether to emit a trailing newline. The projection drift surfaced as phantom entries in `fireforge status` and wipe-safe reprojection work in `fireforge import` for patches that were otherwise clean. The fix splits the field into `noNewlineAtEndOld` / `noNewlineAtEndNew` and peeks the body line that the marker trails (`-` → old-only, `+` → new-only, ` ` context → both), and `applyPatchToContent` now reads `noNewlineAtEndNew` for the output-side newline decision because the content we emit corresponds to the new side. `extractNewFileContentFromDiff` is unchanged — new-file patches only contain `+` lines, so its separate `hasNoNewlineMarker` local is already correct by construction.
10
+ - **`fireforge wire --dom` — engine-relative inputs probed against CWD.** `src/commands/wire.ts` passed the raw `stripEnginePrefix(options.dom)` result to `pathExists` without joining `paths.engine` first, so a relative `--dom browser/base/content/foo.inc.xhtml` was probed inside the operator's shell directory and failed "DOM fragment file not found" even when the file existed in the correct engine location. The follow-on `isPathInsideRoot` and `toRootRelativePath` calls worked by coincidence — they internally `resolve(paths.engine, candidate)`, which masked the bug past the existence probe but only because those calls never hit the filesystem. The fix mirrors the pattern already in use in `src/commands/register.ts`: when the candidate is absolute probe it as-is, otherwise `join(paths.engine, candidate)` first. The error message still echoes the original operator input (not the internal joined path) so it remains copy-pasteable back into the CLI.
11
+
12
+ ### Config — `--force`-written keys are now readable
13
+
14
+ - `fireforge config <key>` (read mode) now consults the raw `fireforge.json` document instead of the validated, typed config that `loadConfig` produces. Before this change, `fireforge config totallyUnknown value --force` succeeded (the key was persisted to disk via `writeConfigDocument`), but the corresponding `fireforge config totallyUnknown` read threw `Unknown config key: totallyUnknown` because `validateConfig` rebuilds a clean object containing only the schema-known fields — the forced key survived on disk but was invisible to the typed read path. A new `loadRawConfigDocument` helper in `src/core/config.ts` returns the raw JSON record, and the command's read branch now traverses that document. Writes are unaffected: schema validation still enforces shape for known keys, and `--force` continues to be the escape hatch for unknown keys.
15
+ - The set-mode `--force` branch also now seeds the mutation from `loadRawConfigDocument`, so writing a second forced key no longer drops previously-written forced keys. Before this, the sequence `config foo 1 --force && config bar 2 --force` silently lost `foo` because the intermediate `loadConfig` stripped it out of the in-memory config.
16
+
17
+ ### Download — Extracting phase spinner
18
+
19
+ - `fireforge download` now switches its spinner message from `Downloading Firefox <ver>... 100%` to `Extracting Firefox <ver>... (decompressing ~600 MB of source; typically 30–90s)` when the byte transfer completes and `tar -xf` starts. Before this change, the download spinner stayed pinned at "Downloading… 100%" for the entire extraction window — on a 601 MB ESR archive that is ~30–90 seconds of silent tar decompression where the first-run setup looked network-stalled precisely when the payload was already on disk. The new `FirefoxSourcePhaseCallback` in `src/core/firefox.ts` fires `'extract'` right before `extractTarXz`, and the download command swaps spinners on that signal; downgrading the initial `const s = spinner(...)` to `let s` is the only mutation site in the command.
20
+
21
+ ### Status — `--json` returns `[]` on a clean tree
22
+
23
+ - `fireforge status --json` now emits a valid JSON document (`[]\n`) when there are no modified files, instead of falling through to the human-readable `No modified files` / `Working tree clean` banner. Before this change, the clean-tree early-return ran before the `--json` branch and silently printed human text, so automation that piped the command through a JSON parser broke precisely on the most common clean-workspace invocation. The `--raw` mode gets the same guard: a clean tree writes nothing to stdout in raw mode, matching what native `git status --porcelain` does on a clean repo.
24
+
25
+ ### Furnace init — tokens CSS scaffold + raw-color allowlist
26
+
27
+ - `fireforge furnace init` now scaffolds the Furnace-managed tokens CSS at `engine/browser/themes/shared/<binaryName>-tokens.css` whenever the engine directory exists, and registers that path in `fireforge.json`'s `patchLint.rawColorAllowlist`. Before this change, `furnace init` only wrote `furnace.json`, so every fresh project's first `fireforge token add` hit `Token CSS file not found: browser/themes/shared/<binaryName>-tokens.css`. The scaffold writes a `:root { … }` shell seeded with four default category headers (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`) plus a `@media (prefers-color-scheme: dark)` overrides block; all four are recognised by `assertTokenCategoryExists` so `token add --category 'Colors — General' …` works end-to-end on a fresh project. The allowlist registration is idempotent (no-op when the entry is already present) so a `furnace init --force` on an existing project doesn't duplicate it.
28
+ - `fireforge token add`'s "Category not found" error now lists the categories actually present in the file, along with a copy-pasteable header template (`/* = My Category = */`) and pointer to `fireforge furnace init --force`. Before this, the error told operators that "categories are defined by comment headers" without showing what was available — `token add` failed silently on any typo in the category name.
29
+
30
+ ### README — `token add` syntax update
31
+
32
+ - The `Additional workflow commands` block in `README.md` now reflects the `token add <token> <value>` subcommand shape shipped in 0.14+. The previous example used a `--name/--value` form that no longer exists on the CLI surface and led operators through a broken copy-paste on their first try. The updated example also points at the Furnace tokens-CSS prerequisite so first-time operators know why `fireforge furnace init` runs before `token add`.
33
+
34
+ ### Furnace create — prefix mismatch is a hard refusal
35
+
36
+ - `fireforge furnace create <name>` now refuses before any filesystem writes when `furnace.json`'s `componentPrefix` is set and `<name>` does not start with it. Before this change, the prefix check was a `warn()` that let the flow continue, producing runs where the command reported success, scaffolded files under `components/custom/<name>/`, registered tests in `browser/base/moz.build`, but the result was a second-class citizen of the fork's convention — subsequent follow-ups (list, status, rename) behaved inconsistently because the name didn't match what `fireforge furnace scan` / override workflows expected to see. A new `--allow-prefix-mismatch` flag is the intentional escape hatch when the mismatch is deliberate (e.g. a throwaway experiment); without it, the command throws `InvalidArgumentError` up-front with specific guidance about prefixing the name or editing `componentPrefix` in `furnace.json`.
37
+
38
+ ### Wire — `--dom` engine-relative normalisation
39
+
40
+ - `fireforge wire --dom <path>` now accepts repo-root-relative forms (`engine/browser/base/content/foo.inc.xhtml`) and engine-relative forms (`browser/base/content/foo.inc.xhtml`) interchangeably, matching `lint`/`export`/`register`/`test`. Before this, passing the `engine/`-prefixed form from the repo root sailed through `pathExists` but then double-rooted through `toRootRelativePath(engineDir, 'engine/…')` — `resolve(engineDir, 'engine/…')` landed at `engineDir/engine/…`, which passed `isPathInsideRoot` but produced a `safeDomFilePath` of `engine/browser/base/content/foo.inc.xhtml`. The computed `#include` then read `#include ../../../engine/browser/base/content/foo.inc.xhtml`, nonsense that would never preprocess correctly. `stripEnginePrefix` normalises the input before the path probe so both forms produce `#include foo.inc.xhtml` in `browser.xhtml`. The `--target` option gets the same normalisation for symmetry.
41
+
42
+ ### Lint — `token-prefix-violation` scoped to added lines
43
+
44
+ - `lintPatchedCss` now scopes its `token-prefix-violation` scan to the added/modified lines when diff context is available, mirroring what the `raw-color-value` rule already does. Before this change, a small CSS-only override of a stock component (e.g. `moz-card`) was flagged for every stock `var(--moz-card-*)` reference in the unchanged portion of the applied file, because the scanner saw the full applied CSS and treated every inherited reference as if the fork had introduced it. The fix ingests `addedLinesByFile.get(file)` as the scan source when present (with CSS comments stripped), de-duplicates per-prop so the same introduced var consumed five times produces one issue instead of five, and falls back to whole-file scanning only when no diff is available (matching the pre-existing contract for callers that pass raw CSS with no diff). `localDeclarations` continues to be collected from the full file so a var declared in an unchanged line is still recognised as a same-file runtime channel.
45
+
46
+ ### Lint — aggregate multi-patch size rules are warnings, not errors
47
+
48
+ - `fireforge lint` running against an aggregate diff on a multi-patch queue (the default mode, `ctx.entries.length > 1`, no file paths supplied) now emits the two size rules (`large-patch-lines`, `large-patch-files`) as warnings rather than errors. Before this, a freshly-imported patch stack of 20+ patches failed the default lint on aggregate counts that are mathematically impossible to satisfy without splitting patches that were already split — the actionable unit is the individual patch, and `--per-patch` is the right mode. Per-patch mode (`lintPerPatch`) keeps the rules as errors because the size is genuinely per-patch there. The "aggregate mode" hint line is updated to note the severity downgrade so operators see the full picture.
49
+
50
+ ### Furnace chrome-doc — locale jar.mn source path fix
51
+
52
+ - `localeJarMnEntryForChromeDoc` now emits `(%browser/<name>.ftl)` as the source-path column instead of `(%<name>.ftl)`. Before this fix, the first `fireforge build` after `fireforge furnace chrome-doc create <name>` failed with `jar.mn: Cannot find <name>.ftl` during backend generation because the FTL file is scaffolded under `engine/browser/locales/en-US/browser/<name>.ftl`, and the `%`-rooted jar path resolves relative to the per-locale root (e.g. `en-US/`), not the locale bundle root. The missing `browser/` subdirectory in the source path made the entry point at `en-US/<name>.ftl`, which doesn't exist. The extended docstring on the helper calls out the path resolution rule so a future refactor can't re-introduce the drift.
53
+
54
+ ### Build — gecko-profiler bindgen hint
55
+
56
+ - The `MACH_ERROR_HINTS` table gains a pattern that matches the distinctive `cannot find type \`\_CharT\` in this scope`+`gecko-profiler-` co-occurrence emitted by upstream bindgen on some macOS libc++ SDK versions. The generated alias (`pub type basic_string**\_self_view = root::std::**1::basic_string_view<\_CharT>;`) references a type name that is not in scope at the landing site, so the Rust compile fails partway through the build. The new hint points operators at Hominis' `990-infra-bindgen-basic-string-workaround.patch` (which strips the offending line post-generation) and also prints the exact file to edit (`<objdir>/release/build/gecko-profiler-\*/out/gecko/bindings.rs`) for operators not on Hominis' patch queue. The hint lands via the existing `surfaceMachErrorHints`plumbing in`src/core/mach.ts`—`mach build`already captures stderr and feeds it through`explainMachError`, so no new wiring is needed.
57
+
58
+ ### Export-all — `--exclude-furnace`
59
+
60
+ - `fireforge export-all --exclude-furnace` now filters Furnace-managed paths out of the aggregate diff instead of refusing the command when any are present. Before this, a mixed workspace (Furnace overrides + non-Furnace edits) could not use `export-all` at all — the operator had to fall back to `fireforge export <paths…>` with a hand-curated file list. The filter reuses the existing `collectFurnaceManagedPrefixes` helper to identify the Furnace-owned subset, rescopes the diff via `getDiffForFilesAgainstHead` over the remaining paths, and prints a one-line info (`Excluded N furnace-managed file(s) from export; exporting M remaining path(s).`). Without the flag, the existing refusal-with-guidance stays — the default still protects against accidentally capturing Furnace files as a regular patch.
61
+
62
+ ### Furnace preview — accepts `.cargo/config.toml.in`
63
+
64
+ - `assertPreviewPrerequisites` now accepts either `engine/.cargo/config.toml` OR `engine/.cargo/config.toml.in` as proof that the Rust toolchain is registered. Before this, the preflight insisted on the plain file, but `fireforge bootstrap` alone produces only the `.in` template (the plain file is generated at `mach configure` time). Operators who followed the remediation instruction ("run bootstrap then rerun preview") hit the same refusal on the retry and had no in-surface recovery. The relaxed check still catches a completely un-bootstrapped engine (neither file exists), and the separate `hasBuildArtifacts` check above remains the authoritative "no dist" guard so this relaxation doesn't weaken the signal we care about.
65
+
66
+ ### Rebase — Furnace override baseVersion stamped alongside patches
67
+
68
+ - After a successful rebase, `runPatchLoop` now calls `stampFurnaceOverrideBaseVersions` (new helper in `src/core/furnace-config.ts`) to update every override's `baseVersion` in `furnace.json` to `session.toVersion`, right after the existing `stampPatchVersions` call. Before this, a successful ESR bump from, say, `140.9.0esr` to `140.9.1esr` stamped every patch's `sourceEsrVersion` but left every override in `furnace.json` pointing at the old baseline, and the very next `fireforge doctor` failed `Furnace component validation` on every override until the operator hand-updated the file. The stamp emits a one-line info (`Stamped N Furnace override baseVersion(s) to <version>.`) and no-ops cleanly when there are zero overrides. Per-override content health is still the job of `fireforge furnace validate` / `doctor --repair-furnace`; the stamp only closes the version-drift reporting gap so a successful rebase doesn't leave the workspace in a doctor-failing state.
69
+
70
+ ### Build baseline — packageable fingerprints for `test --doctor`
71
+
72
+ - `BuildBaseline` gains a `packageableFingerprints: Record<path, sha256>` field recorded at build completion for every packageable-dirty engine path. `checkStaleBuildForTest` re-hashes each current packageable-dirty file and flags only those whose live hash differs from the baseline entry (or paths new since the baseline). Before this change, the stale probe reported every workdir-dirty file as "changed since the last build" every time, because a project with imported patches + Furnace-applied components always has a persistent workdir diff against HEAD — the engine HEAD SHA doesn't move between builds even though the workdir does, so `git diff --name-only HEAD` always returned the full post-import / post-apply set. The result was a warning that fired immediately after a successful full + UI build with no edits in between. The fingerprint layer captures "these files had this content when the build ran", so the only paths that register as stale now are ones whose content actually changed.
73
+ - Baselines written by older FireForge versions do not carry `packageableFingerprints`; the stale check falls through to the pre-0.16.0 path-only comparison in that case, so upgrading does not silently flip the semantics for already-built workspaces until the next `fireforge build` re-records the baseline.
74
+
75
+ ### Known limitations (unchanged in 0.16.0)
76
+
77
+ - **`fireforge test` against a browser-test harness fails with `ERROR_SIGNEDSTATE_REQUIRED`.** Gecko's add-on manager rejects the Marionette harness add-on as unsigned when the fork build's release settings enforce signing. Pref injection at the `fireforge test` boundary needs to plumb `--setpref xpinstall.signatures.required=false` (and a matching dev-root pref) through every harness invocation type; that work is tracked for 0.17.0. Workaround: toggle `MOZ_AUTOMATION=1` / relax signing in the launched build profile out-of-band.
78
+ - **`fireforge test --build` can loop on xpcshell tests that read packaged chrome resources.** The `--build` path drives `mach build faster`, which regenerates the fast-rebuild subset but not the chrome package xpcshell resolves through `chrome://branding/…` / `resource:///modules/…`. The stale-check then flags the same files on the retry even though the inline build ran. Workaround: run a full `fireforge build` (without `--ui`) before `fireforge test`; a proper test-type classifier + `--full-rebuild` flag is planned for 0.17.0.
79
+ - **`fireforge watch` can fail with `FasterBuildException: timed out waiting for response` while a direct `watchman watch-project <engine>` succeeds.** The timeout originates entirely inside `mozbuild.faster_daemon`; FireForge has no hook to extend or recover from it. Workaround: `watchman watch-del <engineDir>` followed by `watchman watch-project <engineDir>` to reset the watcher's internal state before rerunning `fireforge watch`.
80
+
5
81
  ### Wire — transactional rollback
6
82
 
7
83
  - `fireforge wire` now snapshots every file the mutation sequence may touch (`browser/base/content/browser-main.js`, conditionally `browser/base/content/browser-init.js`, the chrome document the `#include` lands in, and `browser/base/jar.mn`) before any write, and restores them when any step fails. The evaluator hit the motivating case on `hominis/`: a `wire mock-wire --init … --destroy … --dom …` run threw `Could not find insertion point in chrome document` AFTER `browser-main.js`, `browser-init.js`, and `browser/base/jar.mn` had already been mutated — the operator had to hand-revert the partial mutation. The journal plumbing reuses `createRollbackJournal` / `snapshotFile` / `restoreRollbackJournal` from Furnace's rollback module; a rollback that itself fails surfaces both the original wire failure and the rollback diagnosis in a single `GeneralError` with `review "git status" under engine/` guidance so the operator knows the engine may need manual attention.
package/README.md CHANGED
@@ -427,10 +427,12 @@ fireforge package
427
427
  # Watch for file changes and auto-rebuild
428
428
  fireforge watch
429
429
 
430
- # Add a CSS design token
431
- fireforge token --name "--my-color" --value "light-dark(#fff, #000)"
430
+ # Add a CSS design token (requires `fireforge furnace init` first; see the Furnace/Tokens section below)
431
+ fireforge token add --category 'Colors — General' -- --my-color 'light-dark(#fff, #000)'
432
432
  ```
433
433
 
434
+ Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`.
435
+
434
436
  ### Diff-scoped lint (`lint --since`)
435
437
 
436
438
  `fireforge lint --since <git-rev>` tags each issue as `[introduced]` or `[cumulative]` based on whether its file changed since `<git-rev>`:
@@ -1,4 +1,4 @@
1
- import { configExists, loadConfig, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, writeConfig, writeConfigDocument, } from '../core/config.js';
1
+ import { configExists, loadConfig, loadRawConfigDocument, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, writeConfig, writeConfigDocument, } from '../core/config.js';
2
2
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
3
3
  import { toError } from '../utils/errors.js';
4
4
  import { info, intro, outro, success, warn } from '../utils/logger.js';
@@ -83,10 +83,15 @@ export async function configCommand(projectRoot, key, value, options = {}) {
83
83
  if (!(await configExists(projectRoot))) {
84
84
  throw new GeneralError('No fireforge.json found. Run "fireforge setup" to create a project.');
85
85
  }
86
- const config = await loadConfig(projectRoot);
87
86
  if (value === undefined) {
88
- // Get mode
89
- const currentValue = getNestedValue(config, key);
87
+ // Get mode — read the raw document rather than the validated config so
88
+ // keys persisted via `fireforge config <key> --force` remain readable.
89
+ // `validateConfig` builds a typed clone containing only the known
90
+ // schema fields; relying on it here would silently hide forced-write
91
+ // keys and surface "Unknown config key" on the read even though the
92
+ // key is sitting plainly inside fireforge.json.
93
+ const rawConfig = await loadRawConfigDocument(projectRoot);
94
+ const currentValue = getNestedValue(rawConfig, key);
90
95
  if (currentValue === undefined) {
91
96
  throw new InvalidArgumentError(`Unknown config key: ${key}`);
92
97
  }
@@ -113,10 +118,16 @@ export async function configCommand(projectRoot, key, value, options = {}) {
113
118
  // listed in SUPPORTED_CONFIG_PATHS, regardless of --force, and only
114
119
  // skip validation for genuinely unknown key paths.
115
120
  if (options.force && !keyIsKnown) {
116
- const updatedConfig = mutateConfig(config, key, parsedValue, true);
121
+ // Seed mutation from the raw on-disk document so previously-forced
122
+ // keys (which `validateConfig` would strip) survive the round-trip.
123
+ // Without this, writing a second --force key would silently drop
124
+ // every earlier forced key from fireforge.json.
125
+ const rawConfig = await loadRawConfigDocument(projectRoot);
126
+ const updatedConfig = mutateConfig(rawConfig, key, parsedValue, true);
117
127
  await writeConfigDocument(projectRoot, updatedConfig);
118
128
  }
119
129
  else {
130
+ const config = await loadConfig(projectRoot);
120
131
  const updatedConfig = mutateConfig(config, key, parsedValue);
121
132
  await writeConfig(projectRoot, updatedConfig);
122
133
  }
@@ -172,9 +172,16 @@ export async function downloadCommand(projectRoot, options) {
172
172
  // Ensure cache directory exists
173
173
  const cacheDir = join(paths.fireforgeDir, 'cache');
174
174
  await ensureDir(cacheDir);
175
- // Download with progress
176
- const s = spinner(`Downloading Firefox ${version}...`);
175
+ // Phase-switched spinners: the download phase runs with the byte-count
176
+ // progress callbacks below; the extract phase is blocking tar-xz and
177
+ // has no incremental progress, but it can take 30–90s on a ~600 MB
178
+ // Firefox tree, so it gets its own spinner message. Before the phase
179
+ // split, a single "Downloading Firefox … 100%" spinner covered both
180
+ // — the first-run setup looked hung precisely when the archive had
181
+ // already reached disk and `tar` was the long pole.
182
+ let s = spinner(`Downloading Firefox ${version}...`);
177
183
  let lastPercent = 0;
184
+ const phaseState = { value: 'download' };
178
185
  try {
179
186
  await downloadFirefoxSource(version, config.firefox.product, paths.engine, cacheDir, (downloaded, total) => {
180
187
  if (total <= 0)
@@ -184,11 +191,22 @@ export async function downloadCommand(projectRoot, options) {
184
191
  s.message(`Downloading Firefox ${version}... ${percent}% (${formatBytes(downloaded)} / ${formatBytes(total)})`);
185
192
  lastPercent = percent;
186
193
  }
194
+ }, (phase) => {
195
+ if (phase === 'extract' && phaseState.value === 'download') {
196
+ s.stop(`Firefox ${version} downloaded`);
197
+ phaseState.value = 'extract';
198
+ s = spinner(`Extracting Firefox ${version}... (decompressing ~600 MB of source; typically 30–90s)`);
199
+ }
187
200
  });
188
- s.stop(`Firefox ${version} downloaded`);
201
+ if (phaseState.value === 'extract') {
202
+ s.stop(`Firefox ${version} extracted`);
203
+ }
204
+ else {
205
+ s.stop(`Firefox ${version} downloaded`);
206
+ }
189
207
  }
190
208
  catch (error) {
191
- s.error('Download failed');
209
+ s.error(phaseState.value === 'extract' ? 'Extraction failed' : 'Download failed');
192
210
  throw error;
193
211
  }
194
212
  // Finding #17: the git indexing phase of `download` can block for
@@ -4,7 +4,7 @@ import { isBrandingManagedPath } from '../core/branding.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
6
  import { hasChanges, isGitRepository } from '../core/git.js';
7
- import { getAllDiff } from '../core/git-diff.js';
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
10
  import { commitExportedPatch } from '../core/patch-export.js';
@@ -25,18 +25,35 @@ async function checkBrandingManagedFiles(paths, config) {
25
25
  'Review these files with "fireforge status" first. If you intentionally want a branding patch, export the specific branding paths explicitly with "fireforge export ...".');
26
26
  }
27
27
  }
28
- async function checkFurnaceManagedFiles(paths, projectRoot) {
28
+ /**
29
+ * Policy around Furnace-managed files in the aggregate diff.
30
+ *
31
+ * Default behavior refuses the export (Furnace paths belong to
32
+ * `furnace apply`). `--exclude-furnace` flips the policy from refusal to
33
+ * filtering: the command still runs, but the Furnace-managed paths are
34
+ * dropped from the diff and counted in an info line so operators in
35
+ * mixed workspaces can capture only the non-Furnace subset.
36
+ *
37
+ * Returns the set of Furnace-managed paths to exclude (empty when the
38
+ * policy is "refuse" and nothing is in the working tree).
39
+ */
40
+ async function resolveFurnaceExclusionPolicy(paths, projectRoot, excludeFurnace) {
29
41
  const prefixes = await collectFurnaceManagedPrefixes(projectRoot);
30
42
  if (prefixes.size === 0)
31
- return;
43
+ return new Set();
32
44
  const changedFiles = await getWorkingTreeStatus(paths.engine);
33
45
  const furnaceManagedFiles = changedFiles
34
46
  .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
35
47
  .filter((file) => [...prefixes].some((prefix) => file.startsWith(prefix)));
36
- if (furnaceManagedFiles.length > 0) {
37
- throw new GeneralError('Export-all refuses to capture Furnace-managed component changes.\n\n' +
38
- 'These files are deployed by "fireforge furnace apply" and should be managed through the Furnace workflow. Review them with "fireforge status" or "fireforge furnace status".');
48
+ if (furnaceManagedFiles.length === 0)
49
+ return new Set();
50
+ if (excludeFurnace) {
51
+ return new Set(furnaceManagedFiles);
39
52
  }
53
+ throw new GeneralError('Export-all refuses to capture Furnace-managed component changes.\n\n' +
54
+ 'These files are deployed by "fireforge furnace apply" and should be managed through the Furnace workflow. ' +
55
+ 'Review them with "fireforge status" or "fireforge furnace status", ' +
56
+ 'or pass --exclude-furnace to export the non-Furnace subset of the diff.');
40
57
  }
41
58
  /**
42
59
  * Refuses the export when the aggregate diff would create (new-file-mode) a
@@ -103,9 +120,32 @@ export async function exportAllCommand(projectRoot, options = {}) {
103
120
  }
104
121
  const config = await loadConfig(projectRoot);
105
122
  await checkBrandingManagedFiles(paths, config);
106
- await checkFurnaceManagedFiles(paths, projectRoot);
107
- // Get the full diff
108
- let diff = await getAllDiff(paths.engine);
123
+ const furnaceExcluded = await resolveFurnaceExclusionPolicy(paths, projectRoot, options.excludeFurnace);
124
+ // Get the full diff. When --exclude-furnace is set and furnaceExcluded
125
+ // is non-empty, rescope the diff to the non-Furnace path subset so the
126
+ // resulting patch does not contain any Furnace-managed hunks. We use
127
+ // `getDiffForFilesAgainstHead` over the filtered path list rather than
128
+ // post-hoc string surgery on the aggregate diff, which keeps the
129
+ // output shape aligned with the single-file `export` command.
130
+ let diff;
131
+ if (furnaceExcluded.size > 0) {
132
+ const allChanged = await getWorkingTreeStatus(paths.engine);
133
+ const nonFurnacePaths = [
134
+ ...new Set(allChanged
135
+ .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
136
+ .filter((file) => !furnaceExcluded.has(file))),
137
+ ].sort();
138
+ if (nonFurnacePaths.length === 0) {
139
+ info(`Excluded ${furnaceExcluded.size} furnace-managed file(s) from export; no non-Furnace changes remain.`);
140
+ outro('Nothing to export');
141
+ return;
142
+ }
143
+ diff = await getDiffForFilesAgainstHead(paths.engine, nonFurnacePaths);
144
+ info(`Excluded ${furnaceExcluded.size} furnace-managed file(s) from export; exporting ${nonFurnacePaths.length} remaining path(s).`);
145
+ }
146
+ else {
147
+ diff = await getAllDiff(paths.engine);
148
+ }
109
149
  if (!diff.trim()) {
110
150
  info('No diff content to export');
111
151
  outro('Nothing to export');
@@ -172,6 +212,7 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
172
212
  .option('-d, --description <desc>', 'Description of the patch')
173
213
  .option('--supersede', 'Allow superseding multiple existing patches')
174
214
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
215
+ .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.')
175
216
  .action(withErrorHandling(async (options) => {
176
217
  const { category, ...rest } = options;
177
218
  await exportAllCommand(getProjectRoot(), {
@@ -62,5 +62,15 @@ export declare function generateChromeDocFtl(name: string, licenseHeader: string
62
62
  export declare function jarMnEntriesForChromeDoc(name: string): string[];
63
63
  /** jar.inc.mn entry that registers the scoped CSS under `content/browser/`. */
64
64
  export declare function jarIncMnEntryForChromeDoc(name: string): string;
65
- /** locales/jar.mn entry that registers the `.ftl` under the browser locale bundle. */
65
+ /**
66
+ * locales/jar.mn entry that registers the `.ftl` under the browser locale
67
+ * bundle. The source path is resolved by mach-locale-jar relative to the
68
+ * per-locale root (e.g. `engine/browser/locales/en-US/`), and the FTL
69
+ * file is scaffolded at `browser/${name}.ftl` under that root — the `%`
70
+ * prefix means "per-locale content" and the `browser/` subdirectory
71
+ * matches the subdir the scaffolder writes into. Before this fix the
72
+ * entry emitted `(%${name}.ftl)`, which pointed at `en-US/${name}.ftl`
73
+ * and broke the first post-scaffold `fireforge build` with
74
+ * "jar.mn: Cannot find ${name}.ftl".
75
+ */
66
76
  export declare function localeJarMnEntryForChromeDoc(name: string): string;
@@ -162,8 +162,18 @@ export function jarMnEntriesForChromeDoc(name) {
162
162
  export function jarIncMnEntryForChromeDoc(name) {
163
163
  return ` content/browser/${name}-chrome.css (shared/${name}-chrome.css)`;
164
164
  }
165
- /** locales/jar.mn entry that registers the `.ftl` under the browser locale bundle. */
165
+ /**
166
+ * locales/jar.mn entry that registers the `.ftl` under the browser locale
167
+ * bundle. The source path is resolved by mach-locale-jar relative to the
168
+ * per-locale root (e.g. `engine/browser/locales/en-US/`), and the FTL
169
+ * file is scaffolded at `browser/${name}.ftl` under that root — the `%`
170
+ * prefix means "per-locale content" and the `browser/` subdirectory
171
+ * matches the subdir the scaffolder writes into. Before this fix the
172
+ * entry emitted `(%${name}.ftl)`, which pointed at `en-US/${name}.ftl`
173
+ * and broke the first post-scaffold `fireforge build` with
174
+ * "jar.mn: Cannot find ${name}.ftl".
175
+ */
166
176
  export function localeJarMnEntryForChromeDoc(name) {
167
- return ` locale/browser/${name}.ftl (%${name}.ftl)`;
177
+ return ` locale/browser/${name}.ftl (%browser/${name}.ftl)`;
168
178
  }
169
179
  //# sourceMappingURL=chrome-doc-templates.js.map
@@ -400,9 +400,27 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
400
400
  throw new FurnaceError(`"${componentName}" already exists in the engine source tree. Use "fireforge furnace override" instead.`, componentName);
401
401
  }
402
402
  }
403
- // Warn if name doesn't match componentPrefix
404
- if (config.componentPrefix && !componentName.startsWith(config.componentPrefix)) {
405
- warn(`Name "${componentName}" does not start with the configured prefix "${config.componentPrefix}".`);
403
+ // Refuse if name doesn't match componentPrefix, unless
404
+ // --allow-prefix-mismatch was explicitly passed.
405
+ //
406
+ // Pre-0.16.0 this was a bare `warn()` and the create flow continued,
407
+ // which produced a class of validation runs where the command reported
408
+ // success, scaffolded files under components/custom/<name>/, and
409
+ // registered tests in browser/base/moz.build, but the component
410
+ // wasn't a good citizen of the fork's convention — subsequent
411
+ // follow-up commands (list, status, rename) behaved inconsistently.
412
+ // Refusing up-front leaves the workspace untouched on a bad name and
413
+ // forces an intentional `--allow-prefix-mismatch` for the rare case
414
+ // where the mismatch is deliberate.
415
+ if (config.componentPrefix &&
416
+ !componentName.startsWith(config.componentPrefix) &&
417
+ !options.allowPrefixMismatch) {
418
+ throw new InvalidArgumentError(`Name "${componentName}" does not start with the configured prefix "${config.componentPrefix}". ` +
419
+ 'Use a prefixed name (e.g. "' +
420
+ config.componentPrefix +
421
+ componentName +
422
+ '"), update `componentPrefix` in furnace.json, ' +
423
+ 'or pass --allow-prefix-mismatch to create the component anyway.', 'name');
406
424
  }
407
425
  // --- Resolve description ---
408
426
  const description = await resolveDescription(isInteractive, options);
@@ -83,6 +83,7 @@ function registerFurnaceInfoCommands(furnace, context) {
83
83
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
84
84
  .option('--shared-ftl <path>', 'Participate in an existing feature-scoped .ftl at this path (e.g. "browser/hominis-dock.ftl"); skips the per-component .ftl scaffold (implies --localized)')
85
85
  .option('--dry-run', 'Show the planned file set and furnace.json changes without writing')
86
+ .option('--allow-prefix-mismatch', 'Create the component even when its name does not start with the configured `componentPrefix` in furnace.json. Without this flag the command refuses to write anything on a prefix mismatch.')
86
87
  .action(withErrorHandling(async (name, options) => {
87
88
  await furnaceCreateCommand(getProjectRoot(), name, options);
88
89
  }));
@@ -1,9 +1,15 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { isAbsolute, normalize } from 'node:path';
2
+ import { dirname, isAbsolute, join, normalize } from 'node:path';
3
3
  import { text } from '@clack/prompts';
4
+ import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
4
5
  import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
+ import { DEFAULT_LICENSE } from '../../core/license-headers.js';
7
+ import { getTokensCssPath } from '../../core/token-manager.js';
8
+ import { generateDefaultTokensCss } from '../../core/token-scaffold.js';
5
9
  import { FurnaceError } from '../../errors/furnace.js';
6
- import { cancel, info, intro, isCancel, note, outro, success } from '../../utils/logger.js';
10
+ import { toError } from '../../utils/errors.js';
11
+ import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
12
+ import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
7
13
  /**
8
14
  * Validates an FTL base path before writing it to furnace.json. Rejects
9
15
  * absolute paths, null bytes, and any normalised segment starting with
@@ -80,10 +86,14 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
80
86
  }
81
87
  await writeFurnaceConfig(projectRoot, config);
82
88
  success('Created furnace.json');
89
+ const scaffoldResult = await scaffoldTokensCss(projectRoot);
83
90
  const lines = [`Component prefix: ${config.componentPrefix}`];
84
91
  if (config.ftlBasePath) {
85
92
  lines.push(`FTL base path: ${config.ftlBasePath}`);
86
93
  }
94
+ if (scaffoldResult.tokensCssPath) {
95
+ lines.push(`Tokens CSS: ${scaffoldResult.tokensCssPath}`);
96
+ }
87
97
  note(lines.join('\n'), 'Configuration');
88
98
  info('Next steps:\n' +
89
99
  ' fireforge furnace scan — discover engine components\n' +
@@ -91,4 +101,68 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
91
101
  ' fireforge furnace override — fork an existing component');
92
102
  outro('Init complete');
93
103
  }
104
+ /**
105
+ * Scaffolds the default tokens CSS file under the engine and registers
106
+ * its path in `fireforge.json`'s `patchLint.rawColorAllowlist`. Both
107
+ * operations are skipped silently when the engine directory does not
108
+ * yet exist (a fresh project that hasn't `fireforge download`ed yet);
109
+ * the scaffold is re-driven on the next `furnace init --force`.
110
+ *
111
+ * Returns the scaffolded path when the file was actually created, so
112
+ * the init command can surface it in the summary note.
113
+ */
114
+ async function scaffoldTokensCss(projectRoot) {
115
+ const paths = getProjectPaths(projectRoot);
116
+ if (!(await pathExists(paths.engine))) {
117
+ info('Skipping tokens CSS scaffold: engine/ not found. Run "fireforge download" followed by "fireforge furnace init --force" to scaffold it.');
118
+ return {};
119
+ }
120
+ let forgeConfig;
121
+ try {
122
+ forgeConfig = await loadConfig(projectRoot);
123
+ }
124
+ catch (error) {
125
+ warn(`Skipping tokens CSS scaffold: fireforge.json could not be loaded (${toError(error).message}). Re-run "fireforge furnace init --force" after fixing the config.`);
126
+ return {};
127
+ }
128
+ const tokensCssPath = getTokensCssPath(forgeConfig.binaryName);
129
+ const tokensCssAbsPath = join(paths.engine, tokensCssPath);
130
+ if (!(await pathExists(tokensCssAbsPath))) {
131
+ try {
132
+ await ensureDir(dirname(tokensCssAbsPath));
133
+ await writeText(tokensCssAbsPath, generateDefaultTokensCss(forgeConfig.binaryName, forgeConfig.license ?? DEFAULT_LICENSE));
134
+ success(`Scaffolded tokens CSS at engine/${tokensCssPath}`);
135
+ }
136
+ catch (error) {
137
+ warn(`Could not scaffold tokens CSS at engine/${tokensCssPath}: ${toError(error).message}. Create the file manually before running "fireforge token add".`);
138
+ return {};
139
+ }
140
+ }
141
+ else {
142
+ info(`Tokens CSS already present at engine/${tokensCssPath}; leaving it untouched.`);
143
+ }
144
+ // Registering the tokens file in `patchLint.rawColorAllowlist` is the
145
+ // complement to the scaffold itself: the file exists specifically to
146
+ // carry raw color literals, and without the allowlist entry the very
147
+ // first `fireforge lint` run against a post-`token add` workspace
148
+ // fails on raw-color-value issues for tokens the operator just
149
+ // created. The add is idempotent, so re-running `furnace init --force`
150
+ // does not duplicate the entry.
151
+ try {
152
+ const existingAllowlist = forgeConfig.patchLint?.rawColorAllowlist ?? [];
153
+ if (!existingAllowlist.includes(tokensCssPath)) {
154
+ const updatedConfig = mutateConfig(forgeConfig, 'patchLint.rawColorAllowlist', [
155
+ ...existingAllowlist,
156
+ tokensCssPath,
157
+ ]);
158
+ await writeConfig(projectRoot, updatedConfig);
159
+ info(`Added ${tokensCssPath} to patchLint.rawColorAllowlist`);
160
+ }
161
+ }
162
+ catch (error) {
163
+ warn(`Could not register tokens CSS in patchLint.rawColorAllowlist: ${toError(error).message}. ` +
164
+ `Add "${tokensCssPath}" manually under patchLint.rawColorAllowlist in fireforge.json if lint flags its contents.`);
165
+ }
166
+ return { tokensCssPath };
167
+ }
94
168
  //# sourceMappingURL=init.js.map
@@ -111,10 +111,23 @@ async function assertPreviewPrerequisites(engineDir) {
111
111
  'Run "fireforge build" and wait for it to finish, then rerun "fireforge furnace preview". ' +
112
112
  'This preflight avoids a multi-minute `mach storybook upgrade` npm install on an engine that cannot start Storybook anyway.');
113
113
  }
114
+ // Accept either `.cargo/config.toml` (post-configure) or
115
+ // `.cargo/config.toml.in` (post-bootstrap template, consumed at
116
+ // `mach configure` time). Pre-0.16.0 the preflight insisted on the
117
+ // plain file, but `fireforge bootstrap` alone produces only `.in` —
118
+ // operators who followed the remediation instruction ("run bootstrap
119
+ // then rerun preview") hit the same refusal on the retry. Either name
120
+ // is sufficient to prove the Rust toolchain is registered; the stronger
121
+ // `hasBuildArtifacts` check above already guards against a completely
122
+ // un-configured tree, so relaxing this to an OR-check does not weaken
123
+ // the signal we care about.
114
124
  const cargoConfigPath = join(engineDir, '.cargo', 'config.toml');
115
- if (!(await pathExists(cargoConfigPath))) {
125
+ const cargoConfigInPath = join(engineDir, '.cargo', 'config.toml.in');
126
+ const cargoConfigPresent = (await pathExists(cargoConfigPath)) || (await pathExists(cargoConfigInPath));
127
+ if (!cargoConfigPresent) {
116
128
  throw new FurnaceError("Furnace preview requires the engine's Rust toolchain to be bootstrapped. " +
117
- '`.cargo/config.toml` is missing under the engine directory — `mach storybook` fails deep inside the Storybook backend compile without it.\n\n' +
129
+ 'Neither `.cargo/config.toml` nor `.cargo/config.toml.in` exists under the engine directory — ' +
130
+ '`mach storybook` fails deep inside the Storybook backend compile without either of them.\n\n' +
118
131
  'Run "fireforge bootstrap" (or the underlying `mach bootstrap` in the engine) to populate the toolchain config, then rerun "fireforge furnace preview".');
119
132
  }
120
133
  }
@@ -154,10 +154,25 @@ export async function lintCommand(projectRoot, files, options = {}) {
154
154
  // really an artefact of aggregation. Surface a one-line note pointing at
155
155
  // `--per-patch` so the operator knows the per-patch scope exists before
156
156
  // they read the error message as "my queue is broken".
157
+ //
158
+ // In aggregate mode over a multi-patch queue we also downgrade the two
159
+ // size rules from `error` to `warning`. Before this downgrade, a
160
+ // fresh-imported patch stack of 20+ patches hard-failed `fireforge lint`
161
+ // on lines-per-aggregate counts that are mathematically impossible to
162
+ // satisfy without splitting patches that were already split — the
163
+ // actionable unit is the individual patch, and `--per-patch` is the
164
+ // mode that matches. Per-patch mode keeps errors as errors (see
165
+ // `lintPerPatch` below).
157
166
  const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
158
167
  if (aggregateHintApplicable &&
159
168
  issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
160
- info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode.');
169
+ info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode and are reported as warnings rather than errors here.');
170
+ for (const issue of issues) {
171
+ if ((issue.check === 'large-patch-lines' || issue.check === 'large-patch-files') &&
172
+ issue.severity === 'error') {
173
+ issue.severity = 'warning';
174
+ }
175
+ }
161
176
  }
162
177
  if (issues.length === 0) {
163
178
  success('No lint issues found.');
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { join } from 'node:path';
6
6
  import { updateState } from '../../core/config.js';
7
+ import { stampFurnaceOverrideBaseVersions } from '../../core/furnace-config.js';
7
8
  import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
8
9
  import { applyPatchWithFuzz } from '../../core/patch-apply-fuzz.js';
9
10
  import { updatePatch } from '../../core/patch-export.js';
@@ -126,6 +127,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
126
127
  if (appliedFilenames.length > 0) {
127
128
  await stampPatchVersions(paths.patches, appliedFilenames, session.toVersion);
128
129
  }
130
+ // Stamp every Furnace override's `baseVersion` to match the rebased
131
+ // Firefox version. Before this stamp, a successful ESR bump left
132
+ // overrides in a doctor-failing drift state (each override still
133
+ // claimed the pre-rebase ESR as its baseline) and every subsequent
134
+ // `fireforge doctor` failed `Furnace component validation`. The
135
+ // stamp is unconditional per the helper's contract: rebase already
136
+ // succeeded on the patch side, so the operator is committing to the
137
+ // new ESR baseline; per-component health checking stays with
138
+ // `fireforge furnace validate` / `doctor --repair-furnace`.
139
+ try {
140
+ const overridesStamped = await stampFurnaceOverrideBaseVersions(projectRoot, session.toVersion);
141
+ if (overridesStamped > 0) {
142
+ info(`Stamped ${overridesStamped} Furnace override baseVersion(s) to ${session.toVersion}.`);
143
+ }
144
+ }
145
+ catch (furnaceStampError) {
146
+ warn(`Could not stamp Furnace override baseVersion(s) to ${session.toVersion}: ${toError(furnaceStampError).message}. Update baseVersion in furnace.json by hand or run "fireforge furnace refresh" if validate reports drift.`);
147
+ }
129
148
  // Print summary and clean up
130
149
  printSummary(session);
131
150
  await clearRebaseSession(projectRoot);
@@ -337,6 +337,23 @@ export async function statusCommand(projectRoot, options = {}) {
337
337
  // branch so raw / unmanaged / default / json all agree.
338
338
  const files = filterFireForgeTempFiles(expanded);
339
339
  renderTruncationBanner(truncations);
340
+ // `--json` callers expect machine-parseable output on every invocation,
341
+ // including the clean-tree case. Before this ordering fix a clean tree
342
+ // printed "No modified files" / "Working tree clean" via the human
343
+ // branch below and `--json` was silently ignored, so scripts that piped
344
+ // the output through a JSON parser broke precisely when there was
345
+ // nothing to report. Emit `[]` here and return before the human fallback.
346
+ if (options.json) {
347
+ await renderJsonStatus(files, paths, projectRoot, config.binaryName);
348
+ return;
349
+ }
350
+ // `--raw` consumers parse the native `git status --porcelain` output
351
+ // directly. On a clean tree the raw mode should produce nothing on
352
+ // stdout — the human "Working tree clean" banner would contaminate the
353
+ // pipe. Short-circuit before the human clean-tree branch below.
354
+ if (options.raw && files.length === 0) {
355
+ return;
356
+ }
340
357
  if (files.length === 0) {
341
358
  info('No modified files');
342
359
  outro('Working tree clean');
@@ -347,11 +364,6 @@ export async function statusCommand(projectRoot, options = {}) {
347
364
  renderRawStatus(files);
348
365
  return;
349
366
  }
350
- // JSON mode and default mode both need classification
351
- if (options.json) {
352
- await renderJsonStatus(files, paths, projectRoot, config.binaryName);
353
- return;
354
- }
355
367
  // Patch-aware classification
356
368
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
357
369
  const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);