@hominis/fireforge 0.15.7 → 0.15.8

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 (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +90 -10
  3. package/dist/src/commands/furnace/create-dry-run.d.ts +7 -0
  4. package/dist/src/commands/furnace/create-dry-run.js +7 -2
  5. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  6. package/dist/src/commands/furnace/create-features.js +56 -0
  7. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  8. package/dist/src/commands/furnace/create-templates.js +14 -6
  9. package/dist/src/commands/furnace/create.js +34 -39
  10. package/dist/src/commands/furnace/index.js +1 -0
  11. package/dist/src/commands/run.d.ts +15 -1
  12. package/dist/src/commands/run.js +202 -7
  13. package/dist/src/commands/test.js +97 -2
  14. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  15. package/dist/src/core/furnace-apply-ftl.js +6 -2
  16. package/dist/src/core/furnace-apply-helpers.js +14 -4
  17. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  18. package/dist/src/core/furnace-config-custom.js +64 -0
  19. package/dist/src/core/furnace-config.js +2 -39
  20. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  21. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  22. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  23. package/dist/src/core/furnace-validate-helpers.js +19 -0
  24. package/dist/src/core/furnace-validate-structure.js +6 -2
  25. package/dist/src/core/furnace-validate.js +6 -3
  26. package/dist/src/core/mach.d.ts +26 -0
  27. package/dist/src/core/mach.js +25 -1
  28. package/dist/src/core/shared-ftl.d.ts +28 -0
  29. package/dist/src/core/shared-ftl.js +42 -0
  30. package/dist/src/core/smoke-patterns.d.ts +45 -0
  31. package/dist/src/core/smoke-patterns.js +100 -0
  32. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  33. package/dist/src/core/xpcshell-appdir.js +273 -0
  34. package/dist/src/errors/codes.d.ts +13 -0
  35. package/dist/src/errors/codes.js +13 -0
  36. package/dist/src/errors/run.d.ts +16 -0
  37. package/dist/src/errors/run.js +22 -0
  38. package/dist/src/types/commands/options.d.ts +48 -0
  39. package/dist/src/types/furnace.d.ts +39 -0
  40. package/dist/src/utils/process.d.ts +63 -0
  41. package/dist/src/utils/process.js +122 -0
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## 0.15.0
4
4
 
5
+ ### Test — xpcshell appdir auto-injection
6
+
7
+ - `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.
8
+ - 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.
9
+ - `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.
10
+
11
+ ### Run — `--smoke-exit` for unattended chrome smoke checks
12
+
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+
18
+ ### Furnace create — `--shared-ftl` for feature-scoped Fluent bundles
19
+
20
+ - `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.
21
+ - `--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.
22
+ - `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.
23
+ - `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.
24
+ - 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`).
25
+
26
+ ### Furnace validate — `no-keyboard-handler` composition awareness
27
+
28
+ - `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.
29
+ - 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.
30
+ - 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.
31
+
32
+ ### Test — diagnostics for harness failure modes
33
+
34
+ - `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.
35
+ - 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.
36
+
5
37
  ### Furnace create
6
38
 
7
39
  - `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,25 @@ Your project now has `fireforge.json`, an `engine/` directory with Firefox sourc
56
56
 
57
57
  ### Workflow Overview
58
58
 
59
+ 1. Make changes inside the `engine/` directory.
60
+ 2. Export your changes as a patch:
61
+
59
62
  ```bash
60
- # 1. Make changes inside engine/
61
- # Edit browser/base/content/browser.js, add CSS, create new modules...
63
+ npx fireforge export browser/base/content/browser.js --name "custom-toolbar" --category ui
64
+ ```
62
65
 
63
- # 2. Export your changes as a patch
64
- npx fireforge export browser/base/content/browser.js \
65
- --name "custom-toolbar" --category ui
66
+ 3. Your patch is now in `patches/`.
66
67
 
67
- # 3. Your patch is now in patches/001-ui-custom-toolbar.patch
68
- # with metadata tracked in patches/patches.json
68
+ # 4. Reset and import to verify everything applies cleanly:
69
69
 
70
- # 4. Later, reset and replay to verify everything applies cleanly
70
+ ```bash
71
71
  npx fireforge reset --yes
72
72
  npx fireforge import # --dry-run to preview without applying
73
+ ```
74
+
75
+ 5. When Mozilla releases a new version, update fireforge.json, re-download and rebase:
73
76
 
74
- # 5. When Firefox releases a new ESR, update fireforge.json, re-download and rebase
77
+ ```bash
75
78
  npx fireforge download --force
76
79
  npx fireforge rebase
77
80
  ```
@@ -536,6 +539,83 @@ The two flags can be combined — `--with-tests --xpcshell` writes both harnesse
536
539
 
537
540
  `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
541
 
542
+ ### xpcshell appdir auto-injection on rebranded forks
543
+
544
+ `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.
545
+
546
+ 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.
547
+
548
+ ### Smoke-run mode (`fireforge run --smoke-exit`)
549
+
550
+ `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).
551
+
552
+ ```bash
553
+ # Launch, wait 60s, exit 0 unless an unallowed error fired
554
+ fireforge run --smoke-exit 60
555
+
556
+ # Same, but ignore a known async-shutdown blocker we've already triaged
557
+ fireforge run --smoke-exit 60 --console-allow 'AsyncShutdown blocker timed out'
558
+
559
+ # Allowlist file (one regex per line, # comments and blanks skipped) + capture
560
+ fireforge run --smoke-exit 60 --console-allow-file scripts/smoke-allow.txt --capture-console smoke.log
561
+ ```
562
+
563
+ Exit codes are wired distinct from `BUILD_ERROR`:
564
+
565
+ | Code | Meaning |
566
+ | ---- | ---------------------------------------------------------------------- |
567
+ | 0 | Smoke window elapsed cleanly (or only allowlisted errors fired). |
568
+ | 12 | One or more unallowed console errors fired inside the window. |
569
+ | 13 | Browser exited non-clean before the window elapsed (launch-side fail). |
570
+
571
+ 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.
572
+
573
+ ### Furnace `--shared-ftl` for feature-scoped Fluent bundles
574
+
575
+ 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:
576
+
577
+ ```bash
578
+ fireforge furnace create hominis-dock-button --localized --shared-ftl browser/hominis-dock.ftl
579
+ ```
580
+
581
+ 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:
582
+
583
+ - `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.
584
+ - `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.
585
+ - `furnace validate`'s `missing-ftl` structural check is skipped — there is no per-component `.ftl` to require.
586
+
587
+ `--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.
588
+
589
+ ### Furnace `keyboardCovered` for composed-button wrappers
590
+
591
+ `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.
592
+
593
+ 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:
594
+
595
+ ```json
596
+ "hominis-dock-button": {
597
+ "description": "Dock button wrapper",
598
+ "targetPath": "components/custom/hominis-dock-button",
599
+ "register": true,
600
+ "localized": false,
601
+ "composes": ["moz-button"],
602
+ "keyboardCovered": true
603
+ }
604
+ ```
605
+
606
+ `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).
607
+
608
+ ### Test escape valves
609
+
610
+ `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:
611
+
612
+ ```bash
613
+ fireforge test browser/base/content/test/foo --mach-arg=--keep-going --mach-arg=--verbose
614
+ fireforge test browser/components/tests/unit/test_x.js --mach-arg=--app-path=/abs/override
615
+ ```
616
+
617
+ Operator overrides for `--app-path` always win over the auto-injection described above.
618
+
539
619
  ## Roadmap
540
620
 
541
621
  Planned but not yet implemented:
@@ -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);
@@ -1,9 +1,23 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
+ import type { RunOptions } from '../types/commands/index.js';
4
+ /**
5
+ * Exit code returned by smoke-run mode when the captured console stream
6
+ * produced one or more error lines that did NOT match the operator's
7
+ * allowlist.
8
+ */
9
+ export declare const SMOKE_EXIT_FAILURE: 12;
10
+ /**
11
+ * Exit code returned by smoke-run mode when the browser itself exited
12
+ * with a non-clean status before the smoke window elapsed — i.e. a
13
+ * launch-side failure we could NOT observe as a console error line
14
+ * (crash before console wiring, missing profile, etc.).
15
+ */
16
+ export declare const SMOKE_LAUNCH_FAILURE: 13;
3
17
  /**
4
18
  * Runs the run command to launch the built browser.
5
19
  * @param projectRoot - Root directory of the project
6
20
  */
7
- export declare function runCommand(projectRoot: string): Promise<void>;
21
+ export declare function runCommand(projectRoot: string, options?: RunOptions): Promise<void>;
8
22
  /** Registers the run command on the CLI program. */
9
23
  export declare function registerRun(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;