@hominis/fireforge 0.15.7 → 0.15.9

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 (51) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +103 -12
  3. package/dist/src/commands/export-shared.d.ts +6 -1
  4. package/dist/src/commands/export-shared.js +7 -2
  5. package/dist/src/commands/furnace/create-dry-run.d.ts +7 -0
  6. package/dist/src/commands/furnace/create-dry-run.js +7 -2
  7. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  8. package/dist/src/commands/furnace/create-features.js +56 -0
  9. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  10. package/dist/src/commands/furnace/create-templates.js +14 -6
  11. package/dist/src/commands/furnace/create.js +34 -39
  12. package/dist/src/commands/furnace/index.js +1 -0
  13. package/dist/src/commands/lint.d.ts +20 -0
  14. package/dist/src/commands/lint.js +157 -44
  15. package/dist/src/commands/re-export-files.js +6 -2
  16. package/dist/src/commands/re-export.js +37 -4
  17. package/dist/src/commands/run.d.ts +15 -1
  18. package/dist/src/commands/run.js +202 -7
  19. package/dist/src/commands/test.js +97 -2
  20. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  21. package/dist/src/core/furnace-apply-ftl.js +6 -2
  22. package/dist/src/core/furnace-apply-helpers.js +14 -4
  23. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  24. package/dist/src/core/furnace-config-custom.js +64 -0
  25. package/dist/src/core/furnace-config.js +2 -39
  26. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  27. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  28. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  29. package/dist/src/core/furnace-validate-helpers.js +19 -0
  30. package/dist/src/core/furnace-validate-structure.js +6 -2
  31. package/dist/src/core/furnace-validate.js +6 -3
  32. package/dist/src/core/mach.d.ts +26 -0
  33. package/dist/src/core/mach.js +25 -1
  34. package/dist/src/core/patch-lint.d.ts +6 -1
  35. package/dist/src/core/patch-lint.js +14 -1
  36. package/dist/src/core/shared-ftl.d.ts +28 -0
  37. package/dist/src/core/shared-ftl.js +42 -0
  38. package/dist/src/core/smoke-patterns.d.ts +45 -0
  39. package/dist/src/core/smoke-patterns.js +100 -0
  40. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  41. package/dist/src/core/xpcshell-appdir.js +273 -0
  42. package/dist/src/errors/codes.d.ts +13 -0
  43. package/dist/src/errors/codes.js +13 -0
  44. package/dist/src/errors/run.d.ts +16 -0
  45. package/dist/src/errors/run.js +22 -0
  46. package/dist/src/types/commands/options.d.ts +58 -0
  47. package/dist/src/types/commands/patches.d.ts +22 -0
  48. package/dist/src/types/furnace.d.ts +39 -0
  49. package/dist/src/utils/process.d.ts +63 -0
  50. package/dist/src/utils/process.js +122 -0
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  ## 0.15.0
4
4
 
5
+ ### Re-export — opt-in `--stamp` and per-patch `lintIgnore`
6
+
7
+ - New `fireforge re-export --stamp` stamps `sourceEsrVersion` on every successfully re-exported patch to the current `firefox.version` from `fireforge.json`. Previously `re-export` only ever refreshed patch bodies and `filesAffected`; version stamping was exclusive to `rebase`'s `stampPatchVersions` call (plus `doctor --repair-patches-manifest`). An operator asked to "re-export targeting a new ESR" had no in-surface signal that the command could not deliver the version half of that request, and had to route through the full rebase flow (which requires a Firefox source re-download) purely to update a version string. `--stamp` closes that gap for the case where the re-export cleanly refreshes every selected patch — a partial run (any skipped or failed patch) refuses to stamp and the success line notes the refusal, so a torn "some bodies refreshed at old version, some at new" state is not representable. The command description and `--help` text now explicitly call out that `re-export` does NOT change `sourceEsrVersion` by default.
8
+ - New optional `lintIgnore: string[]` field on each patch entry in `patches.json` lists lint check IDs to suppress for that patch specifically. Surgical alternative to `--skip-lint` (which downgrades _every_ error to a warning) for the class of patch that is advisory-noisy by nature — cohesive branding bundles, localised-resource packs, auto-generated manifests — where a rule like `large-patch-lines` is not actionable. Threaded through `lintExportedPatch` as an optional `ignoreChecks` filter, honoured by `re-export`, `re-export --files`, and `lint --per-patch`. The file-level `fireforge-ignore:` comment markers for `raw-color-value` and `forward-import` are unchanged; `lintIgnore` fills the gap at the patch level where no per-line marker can exist (the `.patch` body is regenerated on every export). Unknown check IDs are a no-op so the metadata documents the _intent_ to suppress even if the rule is renamed later.
9
+ - Motivating case: re-exporting a 22-patch queue onto 140.9.0esr after `download --force` failed on `001-branding-branding-assets` with `ERROR [large-patch-lines] (patch): Patch is 15665 lines (hard limit: 3000)` — a 57-file branding bundle that genuinely cannot be split. The only escape was `--skip-lint` (downgrades 22 patches' worth of errors) or the full `rebase` flow (already-wasted Firefox download). With `lintIgnore: ["large-patch-lines", "large-patch-files"]` on that one patch, `re-export --all --scan --stamp` now completes in one call.
10
+
11
+ ### Lint — `--per-patch` scope and aggregate-mode hint
12
+
13
+ - New `fireforge lint --per-patch` scopes the lint diff to each patch's own `filesAffected` in turn rather than the aggregate `git diff HEAD` across every applied patch. Motivating case: running `fireforge lint` (no args) after `fireforge import` / `fireforge rebase` has just applied a 22-patch queue produces an aggregate diff of every patch summed, which means the patch-size advisory rules (`large-patch-lines`, `large-patch-files`) fire against the sum — e.g. `Patch is 37529 lines`, `Patch affects 126 files` — with `Lint failed` and a non-zero exit code on a repo that is actually in a good state. The aggregate framing reads as a task-specific regression when it is really an artefact of aggregation. `--per-patch` restates the scope so each patch lints as its own isolated diff, honours the patch's own `lintIgnore` entries, and runs the cross-patch rules (`duplicate-new-file-creation`, `forward-import`) once over the whole queue so queue-level findings are not lost by the rescoping. Mutually exclusive with explicit file paths (the two scope contracts are different).
14
+ - Aggregate-mode runs that would otherwise surface a `large-patch-lines` / `large-patch-files` error against a multi-patch queue now print a one-line `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.` ahead of the failure message, so the operator reaches the per-patch escape hatch without having to read the help text first. The note fires only when the patches directory has at least two entries AND the rule that fired is a patch-size rule — a single-patch queue or a non-size rule behaves identically to before.
15
+ - Per-patch output namespaces every issue with its owning patch filename (`ERROR [relative-import] 001-ui-test.patch :: browser/base/content/a.ts: …`) so triage can attribute findings without cross-referencing patches.json. The passing summary reports how many patches were actually linted (patches with no files on disk or an empty projected diff are silently skipped — they are not a finding).
16
+
17
+ ### Test — xpcshell appdir auto-injection
18
+
19
+ - `fireforge test` now auto-injects `--app-path=<absolute>` into mach test invocations whose nearest `xpcshell.toml` sets `firefox-appdir = "browser"` on a rebranded fork (appname != `firefox`). Without this, every `resource:///modules/<name>.sys.mjs` import inside the harness throws because the upstream xpcshell harness reads the appdir override under the appname-keyed manifest field (`<appname>-appdir`) — the literal `firefox-appdir = "browser"` directive is silently ignored when `appname` is anything other than `firefox`, so `appPath` falls back to `xrePath` (one level above the real app root). The resolver lives in the new `src/core/xpcshell-appdir.ts`: it walks each test path to the nearest `xpcshell.toml`, reads `mozinfo.json` for the active appname, prefers any `<appname>-appdir` already in the manifest (so an operator who already migrated is not overridden), and otherwise probes `<objDir>/dist/bin/<value>` and `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` for the absolute target. Operator overrides via `--mach-arg=--app-path=…` always win and the resolver is skipped silently when one is detected. Mismatches across multiple test paths (different manifests resolving to different app dirs) and unresolvable manifest values (no candidate under `dist/`) are surfaced as warnings rather than guessed at, so triage can reach the underlying cause instead of debugging a wrong path.
20
+ - The `xpcshell appdir` diagnostic that fires when the injection did not prevent the symptom now branches its "Likely triggers" copy on whether the auto-injection ran. When it ran and the failure persists, the message says so and points at packing or layout mismatches; when it did not run (no manifest found, or appname=`firefox`), the original appname-key explanation is preserved. Adds a fourth troubleshooting option — adding `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the manifest — so operators can adopt the durable fix instead of relying on injection on every run.
21
+ - `fireforge test` was previously diagnostic-only for this class of failure: it detected the symptom and surfaced a hint suggesting `--mach-arg` overrides. The hint remains as a fallback for cases the resolver cannot fix, but no manual flag is needed for the common case.
22
+
23
+ ### Run — `--smoke-exit` for unattended chrome smoke checks
24
+
25
+ - New `fireforge run --smoke-exit <seconds>` launches the real built browser with the dev profile, streams the merged console line-by-line, sends `SIGTERM` to the entire child process group at the deadline, and exits non-zero when any `JavaScript error:` / `console.error:` / `[JavaScript Error]` / `###!!! [Parent]` line surfaces inside the smoke window without matching the operator-supplied allowlist. Closes the headless-vs-real-chrome gap that previously forced agents to choose between `fireforge run` (no exit hook, hangs on a human) and `fireforge run --headless` (does not load `browser.xhtml`/`<custom>.xhtml`, so chrome-window constructor errors stay invisible). The motivating case was `TypeError: lazy.HominisEvents.HOMINIS_TOPICS is undefined` thrown from a chrome-document constructor on every window-open, which `--headless` never observed because the main document was never instantiated.
26
+ - Allowlist surface lives in the new `src/core/smoke-patterns.ts`: regex error patterns (`SMOKE_ERROR_PATTERNS`) anchored on the start of each line so embedded mentions of the same prefix inside descriptive prose do not trip the scanner, plus `compileAllowlistFromFile` and `compileAllowlistFromStrings` for `--console-allow-file <path>` and repeatable `--console-allow <regex>` inputs respectively. Allowlisted hits still count toward the summary so operators can see how many lines were suppressed; only unallowed errors drive the exit code. A blank or `#`-prefixed line in an allowlist file is skipped, and a malformed regex throws fast at CLI parse time rather than silently matching nothing.
27
+ - New `--capture-console <file>` mirrors the captured stream to a file path so post-exit inspection has the raw log without re-running. Mirroring is inline with line dispatch and the file is fsync'd on close.
28
+ - Smoke-run exit codes are wired distinctly from `BUILD_ERROR`: `SMOKE_EXIT_FAILURE = 12` (unallowed console errors observed inside the window), `SMOKE_LAUNCH_FAILURE = 13` (browser exited non-clean before the window elapsed — crashes before console wiring, missing profile, etc.). CI pipelines can route these distinctly from compile/config failures and from the existing FireForge exit codes. Carried by the new `SmokeRunError` in `src/errors/run.ts` so the throw site signals both the message and the exit code in one type. The `runMachSmoke()` wrapper in `src/core/mach.ts` handles the deadline-driven SIGTERM/SIGKILL escalation (default 10 s grace between SIGTERM and SIGKILL because Firefox's `AsyncShutdown` and `profileBeforeChange` blockers can take ~5–10 s to flush in-memory state; a shorter grace risks corrupting the dev profile mid-quit). POSIX-only — process-group semantics do not map cleanly onto Windows, so the flag is rejected up-front there. A smoke window shorter than 30 s also surfaces a one-line warning recommending a longer interval, since cold-start time alone can consume that budget on a debug build.
29
+
30
+ ### Furnace create — `--shared-ftl` for feature-scoped Fluent bundles
31
+
32
+ - `furnace create <tag> --localized --shared-ftl <chrome-uri>` now scaffolds a component that participates in a pre-existing feature-scoped `.ftl` bundle (e.g. `browser/hominis-dock.ftl`) instead of getting its own per-component stub. Without the flag, every `--localized` create produced a `<tag>.ftl` under `components/custom/<tag>/` plus an `insertFTLIfNeeded("toolkit/global/<tag>.ftl")` call in the `.mjs`; on a feature like a dock with eight components sharing one bundle, this stranded seven empty per-component stubs in the workspace and required hand-editing every `.mjs` to point `insertFTLIfNeeded` at the shared file (and accepting that the seven empty stubs would still get packaged into `Resources/localization/en-US/toolkit/global/`). The flag short-circuits all of that: the `.mjs` is generated with the shared path baked in via `generateMjsContent`, the per-component `.ftl` is not written, and the `furnace.json` `custom` entry carries a new `sharedFtl` field so subsequent runs (and validators) know the component participates in a shared bundle.
33
+ - `--shared-ftl` implies `--localized`; combining `--no-localized` with `--shared-ftl` is rejected fast-fail with an actionable message rather than letting the cross-field check inside the config parser surface the same error later. The interactive features prompt is suppressed when `--shared-ftl` is set so the operator is not asked to flip a flag we are about to enforce.
34
+ - `furnace apply` and `furnace remove` honour `sharedFtl` symmetrically: apply does NOT copy a per-component `.ftl` into the FTL tree nor add a locale `jar.mn` entry (the shared file is registered by whoever owns the feature bundle), and remove early-returns from `removeCustomFtlJarMnEntry` so dropping our component's reference does not orphan every other participant in the shared bundle. The dry-run path mirrors apply, so a `--dry-run` plan accurately previews the actual file set.
35
+ - `furnace validate`'s `missing-ftl` structural check is skipped for `sharedFtl` components — there is no per-component `<tag>.ftl` to require. The remaining error message also points at `sharedFtl` as the third option ("Create the file, set localized: false in furnace.json, or switch to sharedFtl") so an operator who hits the warning sees the structured way out alongside the existing two.
36
+ - The `sharedFtl` value is validated in one place (`src/core/shared-ftl.ts`) by both the CLI flag path and the `furnace.json` parser, so the two entry points cannot drift. Backticks, backslashes, and `${` are rejected because the value is interpolated verbatim into the generated `.mjs` template literal — without that check, an unsafe input would either close the template or open an executable expression. `--no-localized + --shared-ftl` and `--shared-ftl=""` (empty after trim) are rejected with structured reasons. The custom-component schema parser was extracted from `furnace-config.ts` into `src/core/furnace-config-custom.ts` to keep the main config module under the per-file LOC budget as the schema continues to gain opt-in fields (`composes`, `keyboardCovered`, `sharedFtl`).
37
+
38
+ ### Furnace validate — `no-keyboard-handler` composition awareness
39
+
40
+ - `no-keyboard-handler` no longer warns when `@click` sits on a custom-element host whose `composes` entry names a native-interactive child (e.g. `moz-button`, `moz-toggle`, `moz-checkbox`, `moz-radio`, `moz-menulist`). The wrapper's `@click` handler catches keyboard activation transitively — `<moz-button>` itself dispatches `click` on Enter/Space via the platform — so a duplicate `@keydown`/`@keypress` handler on the wrapper would either no-op or double-fire alongside the child's built-in keyboard path. Previously a `hominis-pin-section` rendering a row of `<hominis-dock-button @click=…>` instances flagged `[no-keyboard-handler] Interactive element has @click but no keyboard event handler` on every validate, even though the entire row was fully keyboard-accessible.
41
+ - New optional `keyboardCovered: true` field on a custom component's `furnace.json` entry forces the same skip when the wrapped inner element is hand-authored (e.g. a vendored `<button>`) or is a non-stock `moz-*` widget that does not appear in `composes`. Operator-asserted: setting this to `true` does not re-check the component, so it can be used to silence a genuine finding. The check's docstring calls out the preferred path of adding the wrapped tag to `composes` when that field applies, since `composes` carries semantic value beyond a11y.
42
+ - Unchanged: synthetic interactive markup (`<div @click>`) and bare `<a>` without an `href` attribute still fire the warning. Those are the real keyboard-a11y hazards the rule was written for; the new branches are narrowly scoped to the composition cases that previously trained authors to ignore validator output.
43
+
44
+ ### Test — diagnostics for harness failure modes
45
+
46
+ - `fireforge test` now detects the `MochitestDesktop.http3Server` AttributeError that crashes the mochitest cleanup path and rewrites it as an actionable hint: branding registration is the lazy-init chain that initialises `http3Server`, so the AttributeError at teardown is almost always a downstream symptom of `chrome://branding` not registering correctly in the fork. The hint enumerates the three concrete checks (branding listed in `browser/branding/moz.build`, `chrome://branding/locale/brand.properties` resolves at runtime, `BROWSER_CHROME_MANIFESTS` registers the chrome.manifest) so operators do not chase the AttributeError as if it were the root cause. Surfaces as a `GeneralError` so the operator sees the diagnostic instead of the generic `BuildError("Tests failed")`. The crash itself remains an upstream Firefox harness bug — FireForge can only diagnose it.
47
+ - New `fireforge test --mach-arg <arg>` (repeatable) forwards a single argument verbatim to `mach test`, after FireForge-managed flags. Escape valve for upstream xpcshell/mochitest flags FireForge does not model directly. Used by the xpcshell appdir hint as the operator's escape valve when auto-injection is the wrong fix; also useful for `--keep-going`, `--verbose`, or `--debugger` while iterating on a flaky test.
48
+
5
49
  ### Furnace create
6
50
 
7
51
  - `furnace create` now accepts `--dry-run` so operators can preview the planned file set, test scaffold, and `furnace.json` mutation without touching the workspace. Previously the only way to preview a create was `fireforge furnace create --help` followed by running the real command and inspecting the result after the fact — a workflow that stranded a component directory, a furnace.json entry, and (with `--with-tests`) test sources under `engine/` behind every aborted preview. The dry-run path runs every validation the real command would run (name shape, name conflicts, engine pre-existence, `--compose` target existence + cycle detection, prefix warning) BEFORE emitting the plan, so a preview that fails matches a real run that would fail. A new `src/commands/furnace/create-dry-run.ts` owns the plan formatter and the success-note formatter so the two renderings stay in lock-step when the scaffolded layout changes.
package/README.md CHANGED
@@ -44,7 +44,7 @@ mkdir mybrowser && cd mybrowser
44
44
  npm init -y
45
45
  npm install --save-dev @hominis/fireforge
46
46
 
47
- npx fireforge setup # interactive project init
47
+ npx fireforge setup # interactive project init
48
48
  npx fireforge download # fetch Firefox source (~1 GB)
49
49
  npx fireforge bootstrap # install build deps (may need sudo)
50
50
  npx fireforge import # apply your patches (if any exist)
@@ -56,22 +56,24 @@ Your project now has `fireforge.json`, an `engine/` directory with Firefox sourc
56
56
 
57
57
  ### Workflow Overview
58
58
 
59
- ```bash
60
- # 1. Make changes inside engine/
61
- # Edit browser/base/content/browser.js, add CSS, create new modules...
59
+ 1. Make changes inside the `engine/` directory.
60
+ 2. Export your changes as a patch:
62
61
 
63
- # 2. Export your changes as a patch
64
- npx fireforge export browser/base/content/browser.js \
65
- --name "custom-toolbar" --category ui
62
+ ```bash
63
+ npx fireforge export browser/base/content/browser.js --name "custom-toolbar" --category ui
64
+ ```
66
65
 
67
- # 3. Your patch is now in patches/001-ui-custom-toolbar.patch
68
- # with metadata tracked in patches/patches.json
66
+ 3. Your patch is now in `patches/`.
67
+ 4. Reset and import to verify everything applies cleanly:
69
68
 
70
- # 4. Later, reset and replay to verify everything applies cleanly
69
+ ```bash
71
70
  npx fireforge reset --yes
72
71
  npx fireforge import # --dry-run to preview without applying
72
+ ```
73
+
74
+ 5. When Mozilla releases a new version, update fireforge.json, re-download and rebase:
73
75
 
74
- # 5. When Firefox releases a new ESR, update fireforge.json, re-download and rebase
76
+ ```bash
75
77
  npx fireforge download --force
76
78
  npx fireforge rebase
77
79
  ```
@@ -136,6 +138,13 @@ fireforge export browser/base/content/browser.js --before 005-ui-sidebar.patch -
136
138
 
137
139
  # Restrict a re-export to a specific file subset
138
140
  fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
141
+
142
+ # Refresh every patch AND stamp sourceEsrVersion from fireforge.json onto each
143
+ # one. Only stamps when every selected patch refreshes cleanly — partial
144
+ # runs refuse to stamp. Use when you re-exported after a manual Firefox
145
+ # bump that did not go through `rebase`. By default `re-export` refreshes
146
+ # patch bodies and filesAffected but does NOT change sourceEsrVersion.
147
+ fireforge re-export --all --scan --stamp
139
148
  ```
140
149
 
141
150
  ### Rebasing on top of a new Firefox version
@@ -173,12 +182,15 @@ This re-exports the fixed patch and continues applying the remaining stack.
173
182
  "description": "Replaces default Firefox branding with custom logo",
174
183
  "createdAt": "2025-01-15T10:30:00Z",
175
184
  "sourceEsrVersion": "140.9.0esr",
176
- "filesAffected": ["browser/branding/official/logo.png"]
185
+ "filesAffected": ["browser/branding/official/logo.png"],
186
+ "lintIgnore": ["large-patch-lines", "large-patch-files"]
177
187
  }
178
188
  ]
179
189
  }
180
190
  ```
181
191
 
192
+ The optional `lintIgnore` field lists lint check IDs to suppress for that patch specifically. Useful for the class of patch that is advisory-noisy by nature — a cohesive branding bundle, a localised-resource pack, an auto-generated manifest — where `--skip-lint` is too blunt and a per-line marker cannot exist (the `.patch` body is regenerated on every export). Threaded through `export`, `re-export`, `re-export --files`, and `lint --per-patch`. Unknown check IDs are a no-op.
193
+
182
194
  If the manifest drifts after an interrupted export or manual edits, `fireforge import` will stop rather then silently applying a stale stack. Use `fireforge doctor --repair-patches-manifest` to rebuild it from disk. Because the rebuild is deterministic, the result will always be consistent with what is actually on the filesystem.
183
195
 
184
196
  </details>
@@ -188,6 +200,8 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
188
200
 
189
201
  `fireforge lint` runs automatically during export, export-all and re-export. Use `--skip-lint` to downgrade errors to warnings. Errors block the export; warnings are printed but do not block.
190
202
 
203
+ By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further; the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
204
+
191
205
  | Check | Scope | Severity |
192
206
  | ------------------------------ | ------------------------------------------------------------------------- | ------------------------ |
193
207
  | `missing-license-header` | New files (JS/CSS/FTL) | error |
@@ -536,6 +550,83 @@ The two flags can be combined — `--with-tests --xpcshell` writes both harnesse
536
550
 
537
551
  `fireforge test <path>` (without `--build`) now runs a preflight that diffs engine HEAD and the workdir against the last successful `fireforge build` (recorded at `.fireforge/last-build.json`). When packageable engine files have changed since that baseline, the command prints a single up-front warning naming the paths and pointing at `fireforge test --build`. This catches the class of failure where a newly scaffolded chrome resource or pref file is registered correctly but `obj-*/dist/` still holds the pre-edit bundle, so the test reads stale packaged artifacts and errors out with a cryptic `NS_ERROR_FILE_NOT_FOUND` inside xpcshell / mach test. The preflight is warn-only — a fork that rebuilt out-of-band (direct `./mach build`, IDE plugin, separate CI stage) is not blocked. Passing `--build` skips the preflight because the rebuild just refreshed the bundle.
538
552
 
553
+ ### xpcshell appdir auto-injection on rebranded forks
554
+
555
+ `fireforge test` auto-resolves and injects `--app-path=<absolute>` into the underlying `mach test` invocation when the nearest `xpcshell.toml` sets `firefox-appdir = "browser"` and the active build's `appname` is anything other than `firefox`. Without this, every `resource:///modules/<name>.sys.mjs` import inside the harness throws `Failed to load resource:///modules/…` because the upstream xpcshell harness reads the appdir override under the appname-keyed manifest field (`<appname>-appdir`) — the literal `firefox-appdir = "browser"` directive is silently ignored on rebranded forks, `appPath` falls back to `xrePath`, and `resource:///` resolves one level above the real app root. The resolver walks each test path to its nearest manifest, reads `mozinfo.json` for the active appname, prefers any `<appname>-appdir` already in the manifest, and otherwise probes `<objDir>/dist/bin/<value>` and `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` for the absolute target. Operator overrides via `--mach-arg=--app-path=…` always win and skip the resolver silently. Mismatches across multiple test paths and unresolvable manifest values surface as warnings rather than guesses, so triage reaches the underlying cause.
556
+
557
+ The durable fix is to add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the manifest — the harness then reads the appname-keyed value directly without auto-injection. The xpcshell appdir hint that fires when the symptom persists despite injection lists this option first.
558
+
559
+ ### Smoke-run mode (`fireforge run --smoke-exit`)
560
+
561
+ `fireforge run --smoke-exit <seconds>` launches the real built browser, streams the merged console line-by-line, sends `SIGTERM` to the entire child process group at the deadline, and exits non-zero when any `JavaScript error:` / `console.error:` / `[JavaScript Error]` / `###!!! [Parent]` line surfaces inside the smoke window without matching an allowlist. Closes the headless-vs-real-chrome gap that previously forced agents to choose between `fireforge run` (no exit hook, hangs on a human) and `--headless` (does not load the chrome document, so chrome-window constructor errors stay invisible).
562
+
563
+ ```bash
564
+ # Launch, wait 60s, exit 0 unless an unallowed error fired
565
+ fireforge run --smoke-exit 60
566
+
567
+ # Same, but ignore a known async-shutdown blocker we've already triaged
568
+ fireforge run --smoke-exit 60 --console-allow 'AsyncShutdown blocker timed out'
569
+
570
+ # Allowlist file (one regex per line, # comments and blanks skipped) + capture
571
+ fireforge run --smoke-exit 60 --console-allow-file scripts/smoke-allow.txt --capture-console smoke.log
572
+ ```
573
+
574
+ Exit codes are wired distinct from `BUILD_ERROR`:
575
+
576
+ | Code | Meaning |
577
+ | ---- | ---------------------------------------------------------------------- |
578
+ | 0 | Smoke window elapsed cleanly (or only allowlisted errors fired). |
579
+ | 12 | One or more unallowed console errors fired inside the window. |
580
+ | 13 | Browser exited non-clean before the window elapsed (launch-side fail). |
581
+
582
+ 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.
583
+
584
+ ### Furnace `--shared-ftl` for feature-scoped Fluent bundles
585
+
586
+ 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:
587
+
588
+ ```bash
589
+ fireforge furnace create hominis-dock-button --localized --shared-ftl browser/hominis-dock.ftl
590
+ ```
591
+
592
+ The generated `.mjs` calls `insertFTLIfNeeded("browser/hominis-dock.ftl")` instead of the per-component path. No `<tag>.ftl` stub is written. The `furnace.json` `custom` entry carries a new `sharedFtl` field so apply, validate, and remove all honour the participation:
593
+
594
+ - `furnace apply` does not copy a per-component `.ftl` into the FTL tree nor add a locale `jar.mn` entry — the shared file is registered by whoever owns the feature bundle.
595
+ - `furnace remove` early-returns from the locale `jar.mn` cleanup, so dropping our component's reference does not orphan the bundle for every other participant.
596
+ - `furnace validate`'s `missing-ftl` structural check is skipped — there is no per-component `.ftl` to require.
597
+
598
+ `--shared-ftl` implies `--localized`. `--no-localized + --shared-ftl` is rejected fast-fail. The value is interpolated verbatim into the generated template literal, so backticks, backslashes, and `${` are rejected at parse time. Setting `sharedFtl` does not auto-migrate previous per-component FTL state — flipping an existing component leaves the prior per-component entry in the engine tree and locale `jar.mn` until cleaned up explicitly.
599
+
600
+ ### Furnace `keyboardCovered` for composed-button wrappers
601
+
602
+ `furnace validate`'s `no-keyboard-handler` rule is automatically suppressed when `@click` sits on a custom-element host whose `composes` lists a native-interactive child (e.g. `moz-button`, `moz-toggle`). The wrapper's click handler catches keyboard activation transitively because the inner element dispatches `click` on Enter/Space via the platform; a duplicate `@keydown` on the wrapper would either no-op or double-fire alongside the child's built-in path.
603
+
604
+ When the wrapped inner element is hand-authored or is a non-stock `moz-*` widget that does not appear in `composes`, the explicit `keyboardCovered: true` field on the component's `furnace.json` entry forces the same skip:
605
+
606
+ ```json
607
+ "hominis-dock-button": {
608
+ "description": "Dock button wrapper",
609
+ "targetPath": "components/custom/hominis-dock-button",
610
+ "register": true,
611
+ "localized": false,
612
+ "composes": ["moz-button"],
613
+ "keyboardCovered": true
614
+ }
615
+ ```
616
+
617
+ `keyboardCovered` is operator-asserted — it does not re-check the component, so it can be used to silence a genuine finding. Prefer adding the wrapped tag to `composes` when that field applies (it carries semantic value beyond a11y).
618
+
619
+ ### Test escape valves
620
+
621
+ `fireforge test --mach-arg <arg>` (repeatable) forwards a single argument verbatim to `mach test` after FireForge-managed flags. Escape valve for upstream xpcshell/mochitest options FireForge does not model directly:
622
+
623
+ ```bash
624
+ fireforge test browser/base/content/test/foo --mach-arg=--keep-going --mach-arg=--verbose
625
+ fireforge test browser/components/tests/unit/test_x.js --mach-arg=--app-path=/abs/override
626
+ ```
627
+
628
+ Operator overrides for `--app-path` always win over the auto-injection described above.
629
+
539
630
  ## Roadmap
540
631
 
541
632
  Planned but not yet implemented:
@@ -11,8 +11,13 @@ import type { SpinnerHandle } from '../utils/logger.js';
11
11
  * @param config - Project configuration
12
12
  * @param skipLint - If true, downgrade errors to warnings
13
13
  * @param patchQueueCtx - Optional cross-patch context for ownership resolution
14
+ * @param ignoreChecks - Optional per-patch set of `check` IDs to suppress
15
+ * (threaded from `PatchMetadata.lintIgnore`). Surgical alternative to
16
+ * `--skip-lint` when exactly one advisory rule does not apply to a
17
+ * specific patch — e.g. `large-patch-lines` on a cohesive branding
18
+ * bundle that genuinely cannot be split.
14
19
  */
15
- export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext): Promise<void>;
20
+ export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>): Promise<void>;
16
21
  /**
17
22
  * Resolves patch metadata interactively or from flags, with shared validation.
18
23
  * @param options - Export command options
@@ -18,9 +18,14 @@ import { isValidPatchCategory, PATCH_CATEGORIES, validatePatchName } from '../ut
18
18
  * @param config - Project configuration
19
19
  * @param skipLint - If true, downgrade errors to warnings
20
20
  * @param patchQueueCtx - Optional cross-patch context for ownership resolution
21
+ * @param ignoreChecks - Optional per-patch set of `check` IDs to suppress
22
+ * (threaded from `PatchMetadata.lintIgnore`). Surgical alternative to
23
+ * `--skip-lint` when exactly one advisory rule does not apply to a
24
+ * specific patch — e.g. `large-patch-lines` on a cohesive branding
25
+ * bundle that genuinely cannot be split.
21
26
  */
22
- export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx) {
23
- const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx);
27
+ export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx, ignoreChecks) {
28
+ const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks);
24
29
  if (issues.length === 0)
25
30
  return;
26
31
  const errors = issues.filter((i) => i.severity === 'error');
@@ -4,6 +4,13 @@ export interface DryRunPlanInput {
4
4
  localized: boolean;
5
5
  register: boolean;
6
6
  composes: string[] | undefined;
7
+ /**
8
+ * Feature-scoped Fluent bundle the component participates in (the same
9
+ * value that will be written to `furnace.json`'s `sharedFtl`). When set,
10
+ * the component's own `.ftl` is NOT scaffolded and the plan preview
11
+ * reflects that. Omit the key for the default per-component scaffold.
12
+ */
13
+ sharedFtl?: string;
7
14
  testStyle: ResolvedTestStyle;
8
15
  description: string;
9
16
  binaryName: string;
@@ -75,9 +75,11 @@ export function formatSuccessNote(args) {
75
75
  * `components/custom/` to match the wording of the real success note.
76
76
  */
77
77
  export function formatDryRunPlan(args) {
78
- const { componentName, localized, register, composes, testStyle, description, binaryName } = args;
78
+ const { componentName, localized, register, composes, sharedFtl, testStyle, description, binaryName, } = args;
79
79
  const componentFiles = [`${componentName}.mjs`, `${componentName}.css`];
80
- if (localized)
80
+ // A per-component .ftl is scaffolded only when the component does NOT
81
+ // opt into a shared feature-scoped bundle. Mirrors writeComponentFiles.
82
+ if (localized && !sharedFtl)
81
83
  componentFiles.push(`${componentName}.ftl`);
82
84
  let plan = `Would create files in components/custom/${componentName}/:\n` +
83
85
  componentFiles.map((f) => ` ${f}`).join('\n');
@@ -90,6 +92,9 @@ export function formatDryRunPlan(args) {
90
92
  if (composes && composes.length > 0) {
91
93
  plan += `\n composes: ${composes.join(', ')}`;
92
94
  }
95
+ if (sharedFtl) {
96
+ plan += `\n sharedFtl: ${sharedFtl}`;
97
+ }
93
98
  return plan;
94
99
  }
95
100
  //# sourceMappingURL=create-dry-run.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Feature-flag resolution for `furnace create`. Extracted from
3
+ * `create.ts` so the authoring command stays under the per-file LOC
4
+ * budget — the flag resolver has grown with each new opt-in (`--xpcshell`,
5
+ * `--test-style`, `--shared-ftl`).
6
+ */
7
+ import type { FurnaceCreateOptions } from '../../types/commands/index.js';
8
+ /**
9
+ * Resolves the localized and registration feature flags for a new component.
10
+ *
11
+ * `--shared-ftl` implies `localized` and short-circuits the interactive
12
+ * prompt so the operator is not asked to flip a flag we are about to
13
+ * enforce. `--no-localized` combined with `--shared-ftl` is rejected
14
+ * fast-fail; the cross-field check in furnace-config would catch it
15
+ * too, but later and without a clear command-line message.
16
+ *
17
+ * @param isInteractive - Whether interactive prompts are available
18
+ * @param options - CLI-provided feature flags
19
+ * @returns Final feature selections, or null when creation is cancelled
20
+ */
21
+ export declare function resolveCreateFeatures(isInteractive: boolean, options: FurnaceCreateOptions): Promise<{
22
+ localized: boolean;
23
+ register: boolean;
24
+ } | null>;
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Feature-flag resolution for `furnace create`. Extracted from
4
+ * `create.ts` so the authoring command stays under the per-file LOC
5
+ * budget — the flag resolver has grown with each new opt-in (`--xpcshell`,
6
+ * `--test-style`, `--shared-ftl`).
7
+ */
8
+ import { multiselect } from '@clack/prompts';
9
+ import { InvalidArgumentError } from '../../errors/base.js';
10
+ import { cancel, isCancel } from '../../utils/logger.js';
11
+ /**
12
+ * Resolves the localized and registration feature flags for a new component.
13
+ *
14
+ * `--shared-ftl` implies `localized` and short-circuits the interactive
15
+ * prompt so the operator is not asked to flip a flag we are about to
16
+ * enforce. `--no-localized` combined with `--shared-ftl` is rejected
17
+ * fast-fail; the cross-field check in furnace-config would catch it
18
+ * too, but later and without a clear command-line message.
19
+ *
20
+ * @param isInteractive - Whether interactive prompts are available
21
+ * @param options - CLI-provided feature flags
22
+ * @returns Final feature selections, or null when creation is cancelled
23
+ */
24
+ export async function resolveCreateFeatures(isInteractive, options) {
25
+ let localized = options.localized ?? false;
26
+ let register = options.register ?? true;
27
+ if (options.sharedFtl !== undefined) {
28
+ if (options.localized === false) {
29
+ throw new InvalidArgumentError('--shared-ftl requires localization. Remove --no-localized or drop --shared-ftl.', 'sharedFtl');
30
+ }
31
+ localized = true;
32
+ }
33
+ const featuresPromptSuppressed = options.sharedFtl !== undefined;
34
+ if (isInteractive &&
35
+ options.localized === undefined &&
36
+ options.register === undefined &&
37
+ !featuresPromptSuppressed) {
38
+ const features = await multiselect({
39
+ message: 'Component features:',
40
+ options: [
41
+ { value: 'localized', label: 'Fluent localization (data-l10n-id)' },
42
+ { value: 'register', label: 'Register in customElements.js' },
43
+ ],
44
+ initialValues: ['register'],
45
+ });
46
+ if (isCancel(features)) {
47
+ cancel('Create cancelled');
48
+ return null;
49
+ }
50
+ const selected = features;
51
+ localized = selected.includes('localized');
52
+ register = selected.includes('register');
53
+ }
54
+ return { localized, register };
55
+ }
56
+ //# sourceMappingURL=create-features.js.map
@@ -14,12 +14,16 @@
14
14
  * per-instance shadow-DOM Fluent attachment via `l10n.connectRoot`. We mirror
15
15
  * that pattern here so `--localized` produces functional code.
16
16
  *
17
- * The FTL path mirrors the locale jar.mn entry that `furnace apply` writes:
18
- * `<ftlChromeSubPath>/<name>.ftl`. For the default `toolkit/global` tree this
19
- * yields `toolkit/global/<name>.ftl`, which matches the URI upstream toolkit
20
- * widgets ship.
17
+ * Path resolution precedence (when `localized` is true):
18
+ * 1. `sharedFtl` used verbatim. The caller has resolved it from
19
+ * `--shared-ftl` / `furnace.json`; this template does no rewriting.
20
+ * Use this when the component participates in a feature-scoped
21
+ * bundle that another component owns.
22
+ * 2. `<ftlChromeSubPath>/<name>.ftl` — the default per-component path,
23
+ * matching the locale jar.mn entry that `furnace apply` writes.
24
+ * 3. `<name>.ftl` — fallback when no chrome sub-path was resolvable.
21
25
  */
22
- export declare function generateMjsContent(name: string, className: string, description: string, localized: boolean, header: string, ftlChromeSubPath: string | undefined): string;
26
+ export declare function generateMjsContent(name: string, className: string, description: string, localized: boolean, header: string, ftlChromeSubPath: string | undefined, sharedFtl: string | undefined): string;
23
27
  /** Generates the .css file content for a custom component. */
24
28
  export declare function generateCssContent(header: string): string;
25
29
  /** Generates the .ftl file content for a custom component. */
@@ -15,13 +15,21 @@
15
15
  * per-instance shadow-DOM Fluent attachment via `l10n.connectRoot`. We mirror
16
16
  * that pattern here so `--localized` produces functional code.
17
17
  *
18
- * The FTL path mirrors the locale jar.mn entry that `furnace apply` writes:
19
- * `<ftlChromeSubPath>/<name>.ftl`. For the default `toolkit/global` tree this
20
- * yields `toolkit/global/<name>.ftl`, which matches the URI upstream toolkit
21
- * widgets ship.
18
+ * Path resolution precedence (when `localized` is true):
19
+ * 1. `sharedFtl` used verbatim. The caller has resolved it from
20
+ * `--shared-ftl` / `furnace.json`; this template does no rewriting.
21
+ * Use this when the component participates in a feature-scoped
22
+ * bundle that another component owns.
23
+ * 2. `<ftlChromeSubPath>/<name>.ftl` — the default per-component path,
24
+ * matching the locale jar.mn entry that `furnace apply` writes.
25
+ * 3. `<name>.ftl` — fallback when no chrome sub-path was resolvable.
22
26
  */
23
- export function generateMjsContent(name, className, description, localized, header, ftlChromeSubPath) {
24
- const ftlPath = ftlChromeSubPath !== undefined ? `${ftlChromeSubPath}/${name}.ftl` : `${name}.ftl`;
27
+ export function generateMjsContent(name, className, description, localized, header, ftlChromeSubPath, sharedFtl) {
28
+ const ftlPath = sharedFtl !== undefined
29
+ ? sharedFtl
30
+ : ftlChromeSubPath !== undefined
31
+ ? `${ftlChromeSubPath}/${name}.ftl`
32
+ : `${name}.ftl`;
25
33
  const ftlModulePreamble = localized
26
34
  ? `
27
35
  window.MozXULElement?.insertFTLIfNeeded("${ftlPath}");
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
- import { multiselect, text } from '@clack/prompts';
3
+ import { text } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../../core/config.js';
5
5
  import { createDefaultFurnaceConfig, detectComposesCycles, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
6
  import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-constants.js';
@@ -10,12 +10,14 @@ import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow,
10
10
  import { isComponentInEngine } from '../../core/furnace-scanner.js';
11
11
  import { DEFAULT_LICENSE, getLicenseHeader } from '../../core/license-headers.js';
12
12
  import { registerTestManifest } from '../../core/manifest-register.js';
13
+ import { validateSharedFtl } from '../../core/shared-ftl.js';
13
14
  import { InvalidArgumentError } from '../../errors/base.js';
14
15
  import { FurnaceError } from '../../errors/furnace.js';
15
16
  import { toError } from '../../utils/errors.js';
16
17
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
17
18
  import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
18
19
  import { formatDryRunPlan, formatSuccessNote } from './create-dry-run.js';
20
+ import { resolveCreateFeatures } from './create-features.js';
19
21
  import { scaffoldMochikitTestFiles } from './create-mochikit.js';
20
22
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
21
23
  import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
@@ -158,40 +160,6 @@ add_task(async function test_${underscored}_defined() {
158
160
  }
159
161
  return testFiles;
160
162
  }
161
- /**
162
- * Resolves the localized and registration feature flags for a new component.
163
- * @param isInteractive - Whether interactive prompts are available
164
- * @param options - CLI-provided feature flags
165
- * @returns Final feature selections, or null when creation is cancelled
166
- */
167
- async function resolveCreateFeatures(isInteractive, options) {
168
- let localized = options.localized ?? false;
169
- let register = options.register ?? true;
170
- if (isInteractive && options.localized === undefined && options.register === undefined) {
171
- const features = await multiselect({
172
- message: 'Component features:',
173
- options: [
174
- {
175
- value: 'localized',
176
- label: 'Fluent localization (data-l10n-id)',
177
- },
178
- {
179
- value: 'register',
180
- label: 'Register in customElements.js',
181
- },
182
- ],
183
- initialValues: ['register'],
184
- });
185
- if (isCancel(features)) {
186
- cancel('Create cancelled');
187
- return null;
188
- }
189
- const selected = features;
190
- localized = selected.includes('localized');
191
- register = selected.includes('register');
192
- }
193
- return { localized, register };
194
- }
195
163
  /**
196
164
  * Writes the scaffolded component source files to disk.
197
165
  * @param componentDir - Destination component directory
@@ -203,20 +171,25 @@ async function resolveCreateFeatures(isInteractive, options) {
203
171
  * @param journal - Optional rollback journal that snapshots files before writes
204
172
  * @returns Relative filenames written for the component
205
173
  */
206
- async function writeComponentFiles(componentDir, componentName, className, description, localized, license, ftlChromeSubPath, journal) {
174
+ async function writeComponentFiles(componentDir, componentName, className, description, localized, license, ftlChromeSubPath, sharedFtl, journal) {
207
175
  await ensureDir(componentDir);
208
176
  const files = [`${componentName}.mjs`, `${componentName}.css`];
209
177
  const mjsPath = join(componentDir, `${componentName}.mjs`);
210
178
  if (journal)
211
179
  await snapshotFile(journal, mjsPath);
212
- const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'), ftlChromeSubPath);
180
+ const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'), ftlChromeSubPath, sharedFtl);
213
181
  await writeText(mjsPath, mjsContent);
214
182
  const cssPath = join(componentDir, `${componentName}.css`);
215
183
  if (journal)
216
184
  await snapshotFile(journal, cssPath);
217
185
  const cssContent = generateCssContent(getLicenseHeader(license, 'css'));
218
186
  await writeText(cssPath, cssContent);
219
- if (localized) {
187
+ // Skip the per-component .ftl stub when the component participates in a
188
+ // pre-existing feature-scoped bundle. The shared bundle is owned
189
+ // elsewhere; dropping a stub here would clutter the workspace with
190
+ // empty files that never get packaged (furnace apply also skips copying
191
+ // them in this mode).
192
+ if (localized && !sharedFtl) {
220
193
  const ftlPath = join(componentDir, `${componentName}.ftl`);
221
194
  if (journal)
222
195
  await snapshotFile(journal, ftlPath);
@@ -277,7 +250,7 @@ async function performCreateMutations(args) {
277
250
  // so signal-driven rollback can clean it up even if writeComponentFiles
278
251
  // is interrupted mid-ensureDir.
279
252
  recordCreatedDir(journal, args.componentDir);
280
- files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, journal);
253
+ files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, args.sharedFtl, journal);
281
254
  const customEntry = {
282
255
  description: args.description,
283
256
  targetPath: `toolkit/content/widgets/${args.componentName}`,
@@ -287,6 +260,9 @@ async function performCreateMutations(args) {
287
260
  if (args.composes && args.composes.length > 0) {
288
261
  customEntry.composes = args.composes;
289
262
  }
263
+ if (args.sharedFtl) {
264
+ customEntry.sharedFtl = args.sharedFtl;
265
+ }
290
266
  args.config.custom[args.componentName] = customEntry;
291
267
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
292
268
  await writeFurnaceConfig(args.projectRoot, args.config);
@@ -455,6 +431,21 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
455
431
  // does not strand component files behind.
456
432
  const composes = options.compose;
457
433
  validateComposesTargets(config, componentName, composes);
434
+ // --- Normalize and validate --shared-ftl ahead of any writes. Shares the
435
+ // structural rules with furnace-config.ts so the command and the on-disk
436
+ // schema cannot diverge. Pass the resolved `localized` rather than a
437
+ // `true` literal so the validator's cross-field check stays anchored to
438
+ // the real feature selection — `resolveCreateFeatures` promotes localized
439
+ // upstream, but hard-coding `true` here would hide a regression if that
440
+ // promotion ever moved or dropped.
441
+ let sharedFtl;
442
+ if (options.sharedFtl !== undefined) {
443
+ const result = validateSharedFtl(options.sharedFtl, { localized });
444
+ if (!result.ok) {
445
+ throw new InvalidArgumentError(`--shared-ftl ${result.reason}.`, 'sharedFtl');
446
+ }
447
+ sharedFtl = result.value;
448
+ }
458
449
  // Dry-run exits here — every validation that does not need a write has
459
450
  // already run, so the plan we render reflects exactly what the real
460
451
  // command would do. The mutation phase and its rollback journal are
@@ -465,6 +456,9 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
465
456
  localized,
466
457
  register,
467
458
  composes,
459
+ // Spread rather than assign so the key is absent when sharedFtl is
460
+ // undefined — the DryRunPlanInput type uses strict-optional shape.
461
+ ...(sharedFtl !== undefined ? { sharedFtl } : {}),
468
462
  testStyle,
469
463
  description,
470
464
  binaryName: forgeConfig.binaryName,
@@ -490,6 +484,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
490
484
  localized,
491
485
  register,
492
486
  composes,
487
+ sharedFtl,
493
488
  componentDir,
494
489
  furnacePaths,
495
490
  config,
@@ -81,6 +81,7 @@ function registerFurnaceInfoCommands(furnace, context) {
81
81
  return value;
82
82
  })
83
83
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
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)')
84
85
  .option('--dry-run', 'Show the planned file set and furnace.json changes without writing')
85
86
  .action(withErrorHandling(async (name, options) => {
86
87
  await furnaceCreateCommand(getProjectRoot(), name, options);