@hominis/fireforge 0.18.2 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +29 -16
  2. package/dist/src/commands/build.js +27 -12
  3. package/dist/src/commands/config.js +56 -3
  4. package/dist/src/commands/discard.js +93 -1
  5. package/dist/src/commands/doctor.js +17 -4
  6. package/dist/src/commands/download.js +21 -0
  7. package/dist/src/commands/export-all.js +35 -6
  8. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +59 -8
  9. package/dist/src/commands/furnace/chrome-doc-templates.js +95 -12
  10. package/dist/src/commands/furnace/chrome-doc.js +24 -2
  11. package/dist/src/commands/furnace/deploy.js +10 -1
  12. package/dist/src/commands/furnace/init.js +28 -2
  13. package/dist/src/commands/furnace/remove.js +68 -0
  14. package/dist/src/commands/import.js +9 -1
  15. package/dist/src/commands/lint.js +78 -13
  16. package/dist/src/commands/patch/delete.js +2 -4
  17. package/dist/src/commands/patch/lint-ignore.js +2 -4
  18. package/dist/src/commands/patch/reorder.js +2 -4
  19. package/dist/src/commands/patch/tier.js +2 -4
  20. package/dist/src/commands/status.js +39 -1
  21. package/dist/src/commands/test.js +20 -1
  22. package/dist/src/commands/token.js +1 -1
  23. package/dist/src/core/furnace-apply.js +11 -3
  24. package/dist/src/core/furnace-config.js +19 -0
  25. package/dist/src/core/furnace-marker.d.ts +16 -0
  26. package/dist/src/core/furnace-marker.js +23 -0
  27. package/dist/src/core/git.js +66 -10
  28. package/dist/src/core/license-headers.d.ts +8 -0
  29. package/dist/src/core/license-headers.js +15 -1
  30. package/dist/src/core/manifest-rules.js +9 -1
  31. package/dist/src/core/patch-identifier-suggest.d.ts +25 -0
  32. package/dist/src/core/patch-identifier-suggest.js +108 -0
  33. package/dist/src/core/patch-lint.js +8 -0
  34. package/dist/src/core/register-shared-css.d.ts +28 -0
  35. package/dist/src/core/register-shared-css.js +67 -3
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -143,6 +143,9 @@ fireforge re-export --all --scan
143
143
  # Preview what an export would do without writing
144
144
  fireforge export browser/base/content/browser.js --dry-run
145
145
 
146
+ # Same preview surface for the aggregate path
147
+ fireforge export-all --name "all-changes" --category ui --dry-run
148
+
146
149
  # Insert a new patch at a specific position
147
150
  fireforge export browser/base/content/browser.js --order 3 --name "inserted" --category ui
148
151
  fireforge export browser/base/content/browser.js --before 005-ui-sidebar.patch --name "prelim"
@@ -158,7 +161,7 @@ fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
158
161
  fireforge re-export --all --scan --stamp
159
162
  ```
160
163
 
161
- `export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default.
164
+ `export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default. The flag covers cross-patch _modification_ overlap, where two patches both edit the same file. It does NOT bypass the new-file creation guard: two patches creating the same path on `/dev/null` cannot coexist in any apply order, so that case stays a hard refusal regardless of `--allow-overlap`.
162
165
 
163
166
  `re-export --scan` also prompts before broadening a patch with more than a handful of newly discovered files or with files spanning multiple directories. The gate keeps the common refresh case frictionless (small, same-directory additions) while catching the failure mode where `--scan` silently pulls an adjacent feature into the wrong patch. Non-interactive mode requires `--yes` to acknowledge a broad expansion; dry-run previews never require confirmation.
164
167
 
@@ -267,16 +270,16 @@ fireforge status --json # machine-readable classified output
267
270
 
268
271
  Then fix with the appropriate primitive:
269
272
 
270
- | Problem | Fix |
271
- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
272
- | Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
273
- | A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
274
- | Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
275
- | Ordinal gaps after deletes/splits | `fireforge patch compact` |
276
- | A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
277
- | Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
278
- | Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes |
279
- | Unmanaged changes you want to discard | `fireforge discard <file>` or `fireforge reset` |
273
+ | Problem | Fix |
274
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
275
+ | Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
276
+ | A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
277
+ | Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
278
+ | Ordinal gaps after deletes/splits | `fireforge patch compact` |
279
+ | A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
280
+ | Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
281
+ | Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes |
282
+ | Unmanaged changes you want to discard | `fireforge discard <file>` (also accepts a directory path to discard everything beneath it) or `fireforge reset` |
280
283
 
281
284
  Every destructive command defaults to an interactive confirmation with a change summary. `--dry-run` previews without writing; `--yes` skips the prompt for CI; `--force-unsafe` bypasses structural refusals when you have context the linter cannot see. Do not hand-edit `patches.json` as the file is owned by FireForge — `doctor --repair-patches-manifest` reconstructs missing metadata, and `fireforge re-export <filename> --description "<text>"` overwrites recovered entries with operator-supplied metadata through the tool. `fireforge verify` cross-checks every registration hunk in each patch body against the files the queue and engine supply, so a patch that registers a widget / locale without carrying its source surfaces as a `dangling-registration` error rather than slipping through as "Verify clean"; `fireforge export-all --exclude-furnace` refuses up-front when it would produce that shape.
282
285
 
@@ -355,11 +358,11 @@ fireforge furnace chrome-doc create mybrowser --with-tests # + xpcshell packagin
355
358
 
356
359
  The command writes:
357
360
 
358
- - `engine/browser/base/content/<name>.xhtml` — XHTML shell, optional titlebar-buttonbox, Fluent `<link>`.
361
+ - `engine/browser/base/content/<name>.xhtml` — XHTML shell. `data-l10n-id` is bound on the leaf `<title>` only (binding it on the root `<window>` would let Fluent's first-paint translation pass overwrite the entire body subtree, the standard `data-l10n-id`-on-non-leaf failure mode). The `<head>` loads `chrome://global/content/customElements.js` ahead of the per-doc subscript so any `<moz-*>` widget the author drops into the body resolves through the toolkit registry instead of silently degrading to `HTMLUnknownElement` — matches the `webrtcIndicator.xhtml` shape upstream uses for non-`browser.xhtml` chrome documents. Under `--with-titlebar` (the default) the root carries the `navigator:browser` minimum attribute set: `windowtype="navigator:browser"`, `customtitlebar="true"`, default `width="1024"` / `height="640"`, and `persist="screenX screenY width height sizemode"` so XULStore remembers geometry across restarts; without these a fork shipping the scaffold verbatim opens at the OS intrinsic minimum size on first launch and forgets the user's window position.
359
362
  - `engine/browser/base/content/<name>.js` — startup-topic observer fired on first idle.
360
- - `engine/browser/themes/shared/<name>-chrome.css` — scoped CSS; emits the macOS `.titlebar-button { display: none }` carve-out under `--no-titlebar`.
363
+ - `engine/browser/themes/shared/<name>-chrome.css` — scoped CSS. Under `--with-titlebar` the buttonbox container is a `-moz-window-dragging: drag` region and `.titlebar-buttonbox` opts into the platform-native `-moz-window-button-box` appearance so the OS renders traffic-light / minimize-maximize-close controls in their canonical positions; under `--no-titlebar` the macOS `.titlebar-button { display: none }` carve-out is emitted instead so frameless overlays don't inherit the platform window controls `global.css` applies by default.
361
364
  - `engine/browser/locales/en-US/browser/<name>.ftl` — Fluent stub keyed on `<name>-window-title`.
362
- - Appends the corresponding `jar.mn` / `jar.inc.mn` / `locales/jar.mn` entries.
365
+ - Appends the corresponding `jar.mn` / `jar.inc.mn` entries. The locales/jar.mn append is suppressed when the fork's existing `engine/browser/locales/jar.mn` already carries a `[localization] (%browser/**/*.ftl)` (or `(%browser/*.ftl)`) wildcard that would already pick up the scaffolded FTL — on those forks a per-file `locale/<name>.ftl` entry would be dead weight at best and an outright build break when the fork has dropped the `% locale browser …` registration the per-file entry depends on. Forks still on the legacy registration get the per-file entry as before.
363
366
  - When `--with-tests` is set, also scaffolds an xpcshell test + `xpcshell.toml` under `engine/browser/base/content/test/<binary>-xpcshell/<name>/` that probes the packaged app directory (`Services.dirsvc.get("XCurProcD")/chrome/browser/...`) directly rather than going through `chrome://` URI resolution — see "Platform module compatibility" and the xpcshell chrome-URI note further down for why direct filesystem probing is the reliable way to verify chrome-doc packaging. Registration in `XPCSHELL_TESTS_MANIFESTS` is left to the operator because the owning moz.build depends on the fork layout.
364
367
 
365
368
  Writes are transactional: a SIGINT mid-scaffold rolls back every touched file. Requires an existing engine — run `fireforge download` first.
@@ -426,6 +429,8 @@ fireforge config customKey "value" --force
426
429
 
427
430
  Writes are serialised behind a sidecar lock — two concurrent `fireforge config` invocations against the same `fireforge.json` (for example, parallel automation steps) queue instead of racing the read-modify-write. The lock is released automatically on process exit; stale locks from a crashed earlier command are reclaimed on the next invocation via the PID-alive probe.
428
431
 
432
+ Re-setting a key to its current value is a no-op: `fireforge.json` is not rewritten, key ordering is preserved, and the success log surfaces `<key> = <value> (unchanged)` instead of a fresh `Set …` line. This means automation that idempotently runs `fireforge config <key> <value>` no longer produces spurious diffs in `fireforge.json`.
433
+
429
434
  ### Patch queue management
430
435
 
431
436
  ```bash
@@ -466,10 +471,14 @@ fireforge package
466
471
  fireforge watch
467
472
 
468
473
  # Add a CSS design token (requires `fireforge furnace init` first; see the Furnace/Tokens section below)
469
- fireforge token add --category 'Colors General' -- --my-color 'light-dark(#fff, #000)'
474
+ # The `--` separator is required because the token name itself starts with `--`,
475
+ # which Commander would otherwise read as an option flag. Bare names without `--`
476
+ # are accepted directly and get the configured `tokenPrefix` prepended.
477
+ fireforge token add --category 'Colors — General' --mode static -- --my-color 'light-dark(#fff, #000)'
478
+ fireforge token add --category 'Colors — General' --mode static my-color '#fff' # bare-name form
470
479
  ```
471
480
 
472
- Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`, and derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
481
+ Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` does three more things in the same step so the file is owned end-to-end by tooling: it registers the tokens CSS path in `patchLint.rawColorAllowlist` (so raw color literals inside it are not flagged by `fireforge lint`); it adds the matching `skin/classic/browser/<binaryName>-tokens.css (../shared/<binaryName>-tokens.css)` entry to `browser/themes/shared/jar.inc.mn` (so `fireforge status` does not flag the file as unmanaged or unregistered); and it derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
473
482
 
474
483
  ### Diff-scoped lint (`lint --since`)
475
484
 
@@ -511,6 +520,10 @@ The build also auto-runs `mach configure` before the mach build step when any `m
511
520
 
512
521
  Mach build failures with known-cryptic mozbuild errors now print actionable hints. Example: a `JS_PREFERENCE_PP_FILES` entry with no `#filter` / `#expand` directives now prints `Hint: ...use JS_PREFERENCE_FILES instead, or add at least one #filter / #expand directive to the file.` alongside the raw mach traceback.
513
522
 
523
+ When mach prints a post-build `config.status is out of date …` or `Config object not found by mach. / Configure complete!` banner on a successful build, FireForge surfaces a one-line annotation immediately before its own `Build completed in Xm Ys!` outro explaining that the banner is a known side effect of tool-managed branding edits applied before the build and that the build does not need to be re-run. The FireForge exit code remains authoritative regardless of the mach guard text.
524
+
525
+ The reported `Build completed in Xm Ys!` duration is wall-clock measured with `Date.now()`, so it includes any time the host spent suspended (laptop sleep, system idle) during the build. Treat it as wall-clock-with-sleep, not active CPU time, when comparing builds across machines or sessions.
526
+
514
527
  ### Relocated workspaces: `fireforge build --rewrite-mozinfo`
515
528
 
516
529
  When a workspace is moved to a new path (e.g. the project directory was renamed or relocated on disk), `obj-*/mozinfo.json` still records the old `topsrcdir` / `topobjdir`. The pre-flight detects the mismatch and aborts with a "delete and rebuild" instruction — correct but expensive; a fresh clean build typically runs ~20 minutes and discards ~14 GB of intact obj artefacts on a moved checkout.
@@ -157,18 +157,33 @@ export async function buildCommand(projectRoot, options) {
157
157
  throw new BuildError(`Build failed with exit code ${result.exitCode}`, options.ui ? 'mach build faster' : 'mach build');
158
158
  }
159
159
  // Tool-managed branding edits that land on `browser/moz.configure`
160
- // before the build cause mach's post-build guard to print
161
- // "config.status is out of date Be sure to run |mach build|" even
162
- // though the build itself completed cleanly. 2026-04-21 eval finding:
163
- // operators read that as "your build is stale" and either rebuilt
164
- // (wasting ~10 minutes) or doubted the Fireforge "Build completed"
165
- // footer. Annotate the captured output so the operator knows the
166
- // warning is expected and not actionable.
167
- const staleConfigurePattern = /config\.status is out of date/i;
168
- if (staleConfigurePattern.test(result.stdout) || staleConfigurePattern.test(result.stderr)) {
169
- info('Note: mach reported "config.status is out of date" after this build. ' +
170
- 'That notice is a known side effect of tool-managed branding edits applied before the build ' +
171
- 'and does not require a rebuild the Fireforge exit code is authoritative.');
160
+ // before the build cause mach's post-build guard to print one of two
161
+ // banners that read like build failures even though the build
162
+ // completed cleanly:
163
+ //
164
+ // 1) "config.status is out of date with respect to ..."
165
+ // 2) "Config object not found by mach. / Configure complete! /
166
+ // Be sure to run |mach build| to pick up any changes."
167
+ //
168
+ // 2026-04-21 eval covered (1); 2026-04-26 eval Finding 8 reproduced
169
+ // (2) on a successful build. The pre-fix pattern only matched (1),
170
+ // so operators on the (2) path saw mach's own "Configure complete!"
171
+ // and "run |mach build|" lines unexplained between mach's
172
+ // "Your build was successful!" and FireForge's own "Build completed
173
+ // in Xm Ys" outro — a contradictory tail. Both shapes now route
174
+ // through the same annotation, emitted BEFORE FireForge's outro so
175
+ // the operator's last terminal line is the explanation, not the
176
+ // confusing mach guard text.
177
+ const staleConfigurePatterns = [
178
+ /config\.status is out of date/i,
179
+ /Config object not found by mach\.[\s\S]*Configure complete!/i,
180
+ ];
181
+ const captured = `${result.stdout}\n${result.stderr}`;
182
+ if (staleConfigurePatterns.some((p) => p.test(captured))) {
183
+ info('Note: mach printed a post-build "Configure complete!" / "config.status is out of date" ' +
184
+ 'banner. That is a known side effect of tool-managed branding edits applied before the ' +
185
+ 'build and does not mean the build is stale or that you need to rerun mach — the FireForge ' +
186
+ 'exit code is authoritative.');
172
187
  }
173
188
  // Warn-only post-build audit: surfaces silent packaging drops (files
174
189
  // edited in engine/ but never registered for packaging) against the
@@ -111,6 +111,7 @@ export async function configCommand(projectRoot, key, value, options = {}) {
111
111
  }
112
112
  const parsedValue = parseValue(value, key);
113
113
  const keyIsKnown = SUPPORTED_CONFIG_PATHS.includes(key);
114
+ let unchanged;
114
115
  try {
115
116
  // Serialise the read → mutate → write round-trip behind the sidecar
116
117
  // config lock so two concurrent `fireforge config` invocations can't
@@ -122,7 +123,21 @@ export async function configCommand(projectRoot, key, value, options = {}) {
122
123
  // were never enough on their own — the lost update happens before
123
124
  // the rename, inside the read-modify step. Readers stay lock-free
124
125
  // (see `withConfigFileLock` docstring).
125
- await withConfigFileLock(projectRoot, async () => {
126
+ unchanged = await withConfigFileLock(projectRoot, async () => {
127
+ // 2026-04-26 eval Finding 11: short-circuit when the new value
128
+ // matches the current on-disk value. Pre-fix, every set ran
129
+ // through `mutateConfig` + `writeConfig`, which round-trips
130
+ // through `JSON.stringify` and rewrites the file even when no
131
+ // semantic change happened — the rewrite reorders top-level
132
+ // keys (`license`, `markerComment`, etc.) on every harmless
133
+ // re-set, producing diff churn for no reason. The check uses
134
+ // the raw on-disk document so forced-keys round-trip the same
135
+ // as known keys.
136
+ const rawConfig = await loadRawConfigDocument(projectRoot);
137
+ const currentValue = getNestedValue(rawConfig, key);
138
+ if (deepEqual(currentValue, parsedValue)) {
139
+ return true;
140
+ }
126
141
  // `--force` is intended as an escape hatch for *unknown* keys; it
127
142
  // should not also let the user write a structurally invalid value
128
143
  // for a *known* key. Apply strict validation whenever the key is
@@ -133,7 +148,6 @@ export async function configCommand(projectRoot, key, value, options = {}) {
133
148
  // keys (which `validateConfig` would strip) survive the round-trip.
134
149
  // Without this, writing a second --force key would silently drop
135
150
  // every earlier forced key from fireforge.json.
136
- const rawConfig = await loadRawConfigDocument(projectRoot);
137
151
  const updatedConfig = mutateConfig(rawConfig, key, parsedValue, true);
138
152
  await writeConfigDocument(projectRoot, updatedConfig);
139
153
  }
@@ -142,15 +156,54 @@ export async function configCommand(projectRoot, key, value, options = {}) {
142
156
  const updatedConfig = mutateConfig(config, key, parsedValue);
143
157
  await writeConfig(projectRoot, updatedConfig);
144
158
  }
159
+ return false;
145
160
  });
146
161
  }
147
162
  catch (error) {
148
163
  throw new InvalidArgumentError(`Invalid value for "${key}": ${toError(error).message}`, key);
149
164
  }
150
- success(`Set ${key} = ${formatValue(parsedValue)}`);
165
+ if (unchanged) {
166
+ info(`${key} = ${formatValue(parsedValue)} (unchanged)`);
167
+ }
168
+ else {
169
+ success(`Set ${key} = ${formatValue(parsedValue)}`);
170
+ }
151
171
  }
152
172
  outro('');
153
173
  }
174
+ /**
175
+ * Structural equality check covering the shapes that
176
+ * `fireforge config` accepts: primitives (strings, numbers, booleans),
177
+ * `null`, arrays of primitives, and nested objects. Used to short-circuit
178
+ * no-op writes (Finding 11) — when the parsed value matches the current
179
+ * on-disk value, skip the mutate + write step entirely.
180
+ */
181
+ function deepEqual(a, b) {
182
+ if (a === b)
183
+ return true;
184
+ if (a === null || b === null)
185
+ return a === b;
186
+ if (typeof a !== typeof b)
187
+ return false;
188
+ if (typeof a !== 'object')
189
+ return false;
190
+ if (Array.isArray(a)) {
191
+ if (!Array.isArray(b))
192
+ return false;
193
+ if (a.length !== b.length)
194
+ return false;
195
+ return a.every((v, i) => deepEqual(v, b[i]));
196
+ }
197
+ if (Array.isArray(b))
198
+ return false;
199
+ const ar = a;
200
+ const br = b;
201
+ const keysA = Object.keys(ar);
202
+ const keysB = Object.keys(br);
203
+ if (keysA.length !== keysB.length)
204
+ return false;
205
+ return keysA.every((k) => deepEqual(ar[k], br[k]));
206
+ }
154
207
  /** Registers the config command on the CLI program. */
155
208
  export function registerConfig(program, { getProjectRoot, withErrorHandling }) {
156
209
  program
@@ -11,6 +11,82 @@ import { toError } from '../utils/errors.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, intro, isCancel, outro, spinner, warn } from '../utils/logger.js';
13
13
  import { pickDefined } from '../utils/options.js';
14
+ /**
15
+ * Discards every status entry whose path lives under `dirPath`. Used by
16
+ * `discardCommand` as a directory-recursion fallback when the operator
17
+ * passed a directory path that contains modified or untracked entries
18
+ * but is not itself a status entry.
19
+ *
20
+ * Mirrors the single-file path's confirmation, dry-run, and Furnace-aware
21
+ * warning behaviour so the contract stays consistent. Each per-entry
22
+ * discard runs sequentially under its own try/catch so a failure on one
23
+ * file is reported but does not block the remaining files in the batch.
24
+ */
25
+ async function discardDirectoryEntries(projectRoot, engineDir, dirPath, entries, options) {
26
+ if (!options.yes && !options.dryRun) {
27
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
28
+ if (!isInteractive) {
29
+ throw new InvalidArgumentError('Interactive confirmation not available. Use --yes flag to discard without confirmation.', 'Use: fireforge discard <directory> --yes');
30
+ }
31
+ const confirmed = await confirm({
32
+ message: `Discard changes to ${entries.length} file${entries.length === 1 ? '' : 's'} under ${dirPath}/?`,
33
+ initialValue: false,
34
+ });
35
+ if (isCancel(confirmed) || !confirmed) {
36
+ outro('Discard cancelled');
37
+ return;
38
+ }
39
+ }
40
+ if (options.dryRun) {
41
+ info(`Would discard changes to ${entries.length} file(s) under ${dirPath}/:`);
42
+ for (const entry of entries) {
43
+ const target = entry.originalPath && entry.originalPath !== entry.file
44
+ ? `${entry.originalPath} -> ${entry.file}`
45
+ : entry.file;
46
+ info(` ${target}`);
47
+ }
48
+ outro('Dry run complete — no changes made');
49
+ return;
50
+ }
51
+ const s = spinner(`Discarding ${entries.length} file(s) under ${dirPath}/...`);
52
+ let succeeded = 0;
53
+ const failures = [];
54
+ try {
55
+ for (const entry of entries) {
56
+ try {
57
+ await discardStatusEntry(engineDir, entry);
58
+ succeeded += 1;
59
+ }
60
+ catch (error) {
61
+ failures.push(`${entry.file}: ${toError(error).message}`);
62
+ }
63
+ }
64
+ s.stop(`Discarded ${succeeded} of ${entries.length} file(s) under ${dirPath}/${failures.length > 0 ? ` (${failures.length} failed)` : ''}`);
65
+ for (const failure of failures) {
66
+ warn(` ${failure}`);
67
+ }
68
+ try {
69
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
70
+ const dirIsFurnace = [...furnacePrefixes].some((prefix) => `${dirPath}/`.startsWith(prefix) || prefix.startsWith(`${dirPath}/`));
71
+ if (dirIsFurnace) {
72
+ warn('These paths are managed by Furnace. Run "fireforge furnace apply" to redeploy components if needed.');
73
+ }
74
+ }
75
+ catch {
76
+ // Furnace config may not exist — skip silently
77
+ }
78
+ if (failures.length > 0) {
79
+ throw new GeneralError(`Failed to discard ${failures.length} file(s) under ${dirPath}/. See warnings above.`);
80
+ }
81
+ outro(`${succeeded} file(s) restored to original state`);
82
+ }
83
+ catch (error) {
84
+ if (!(error instanceof GeneralError)) {
85
+ s.error('Discard failed');
86
+ }
87
+ throw error;
88
+ }
89
+ }
14
90
  /**
15
91
  * Runs the discard command to revert changes to a specific file.
16
92
  * @param projectRoot - Root directory of the project
@@ -31,7 +107,23 @@ export async function discardCommand(projectRoot, file, options = {}) {
31
107
  // Check if the file has changes
32
108
  const statusEntries = await expandUntrackedDirectoryEntries(paths.engine, await getWorkingTreeStatus(paths.engine));
33
109
  const statusEntry = statusEntries.find((entry) => entry.file === file || entry.originalPath === file);
110
+ // Directory recursion fallback: when the explicit path does not match a
111
+ // single status entry but DOES correspond to one or more entries below
112
+ // it, treat the input as a directory and discard everything inside.
113
+ // 2026-04-25 eval Finding 20: `discard browser/components/storybook/
114
+ // stories/furnace --yes` failed with "no changes to discard" even
115
+ // though `status --unmanaged` listed 23 files under that directory —
116
+ // operators were forced to discard each file individually or fall
117
+ // back to non-FireForge cleanup commands. Match against the
118
+ // directory-with-trailing-slash form so a path like `foo/bar` doesn't
119
+ // accidentally match `foo/bar2/file`.
34
120
  if (!statusEntry) {
121
+ const dirPrefix = file.endsWith('/') ? file : `${file}/`;
122
+ const dirEntries = statusEntries.filter((entry) => entry.file.startsWith(dirPrefix) || entry.originalPath?.startsWith(dirPrefix));
123
+ if (dirEntries.length > 0) {
124
+ await discardDirectoryEntries(projectRoot, paths.engine, file, dirEntries, options);
125
+ return;
126
+ }
35
127
  throw new GeneralError(`File "${file}" has no changes to discard.`);
36
128
  }
37
129
  if (!options.yes && !options.dryRun) {
@@ -91,7 +183,7 @@ export async function discardCommand(projectRoot, file, options = {}) {
91
183
  export function registerDiscard(program, { getProjectRoot, withErrorHandling }) {
92
184
  program
93
185
  .command('discard <file>')
94
- .description('Discard changes to a specific file (deletes untracked files)')
186
+ .description('Discard changes to a specific file (deletes untracked files). Pass a directory path to discard every modified or untracked file beneath it; the operation walks the status output and reverts each match individually.')
95
187
  .option('--dry-run', 'Show what would be discarded without doing it')
96
188
  .option('-y, --yes', 'Skip confirmation prompt')
97
189
  .action(withErrorHandling(async (file, options) => {
@@ -9,7 +9,7 @@ import { ExitCode } from '../errors/codes.js';
9
9
  import { toError } from '../utils/errors.js';
10
10
  import { pathExists } from '../utils/fs.js';
11
11
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
12
- import { executableExists } from '../utils/process.js';
12
+ import { findExecutable } from '../utils/process.js';
13
13
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
14
14
  import { inspectEngineWorkingTree } from './doctor-working-tree.js';
15
15
  /**
@@ -255,9 +255,22 @@ const DOCTOR_CHECKS = [
255
255
  // failure site.
256
256
  name: 'Watchman available',
257
257
  run: async () => {
258
- const present = await executableExists('watchman');
259
- if (present)
260
- return ok('Watchman available');
258
+ // Resolve the absolute path so the OK row names what doctor actually
259
+ // found. The 2026-04-25 eval flagged a confusing case where the
260
+ // operator's interactive shell returned no result for `which
261
+ // watchman` but doctor still printed "OK" — the cause was a
262
+ // PATH-export discrepancy between the shell and the spawned
263
+ // subprocess, and surfacing the resolved path makes the discrepancy
264
+ // visible without users having to re-run with a verbose flag.
265
+ const path = await findExecutable('watchman');
266
+ if (path) {
267
+ return {
268
+ name: 'Watchman available',
269
+ passed: true,
270
+ severity: 'ok',
271
+ message: `OK (${path})`,
272
+ };
273
+ }
261
274
  return warning('Watchman available', 'watchman is not installed or not on PATH. "fireforge watch" requires it.', 'Install watchman (brew install watchman / dnf install watchman / https://facebook.github.io/watchman/), then re-run doctor.');
262
275
  },
263
276
  },
@@ -69,6 +69,25 @@ async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
69
69
  }
70
70
  return { hadQueue: true, restored: toClean.length, preserved: preserved.length };
71
71
  }
72
+ /**
73
+ * Prints a one-line nudge pointing at `fireforge import` when the project
74
+ * carries a non-empty patch queue but the just-downloaded engine has not
75
+ * yet had any patches applied. The post-download spinner closes with
76
+ * "Patch-touched files already match baseline" because a fresh tree IS at
77
+ * baseline, but the 2026-04-25 eval saw operators read that as "patches
78
+ * are restored" and skip the import step. The note is suppressed when
79
+ * patches/ is missing or the manifest is empty so unconfigured projects
80
+ * stay quiet.
81
+ */
82
+ async function noteUnappliedPatches(patchesDir) {
83
+ if (!(await pathExists(patchesDir)))
84
+ return;
85
+ const manifest = await loadPatchesManifest(patchesDir);
86
+ if (!manifest || manifest.patches.length === 0)
87
+ return;
88
+ const n = manifest.patches.length;
89
+ info(`Note: ${n} patch${n === 1 ? '' : 'es'} in patches/ have not been applied to this fresh engine. Run "fireforge import" to apply them.`);
90
+ }
72
91
  /**
73
92
  * Stops `restoreSpinner` with a message that reflects what actually
74
93
  * happened. Three branches: empty queue → explicit no-op; queue present but
@@ -151,6 +170,7 @@ export async function downloadCommand(projectRoot, options) {
151
170
  downloadedVersion: version,
152
171
  baseCommit,
153
172
  });
173
+ await noteUnappliedPatches(paths.patches);
154
174
  outro(`Firefox ${version} is ready! (resumed from partial init)`);
155
175
  return;
156
176
  }
@@ -299,6 +319,7 @@ export async function downloadCommand(projectRoot, options) {
299
319
  downloadedVersion: version,
300
320
  baseCommit,
301
321
  });
322
+ await noteUnappliedPatches(paths.patches);
302
323
  outro(`Firefox ${version} is ready!`);
303
324
  }
304
325
  /** Registers the download command on the CLI program. */
@@ -15,6 +15,7 @@ import { ensureDir, pathExists } from '../utils/fs.js';
15
15
  import { info, intro, outro, spinner } from '../utils/logger.js';
16
16
  import { pickDefined } from '../utils/options.js';
17
17
  import { PATCH_CATEGORIES } from '../utils/validation.js';
18
+ import { renderDryRunPreview } from './export-flow.js';
18
19
  import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
19
20
  async function checkBrandingManagedFiles(paths, config) {
20
21
  const changedFiles = await getWorkingTreeStatus(paths.engine);
@@ -158,7 +159,8 @@ async function checkDuplicateNewFileCreations(paths, diff) {
158
159
  .join('\n');
159
160
  throw new GeneralError('Export-all refuses to capture new-file creations that are already claimed by existing patches.\n\n' +
160
161
  `Conflicting creations:\n${conflictList}\n\n` +
161
- 'Only one patch may create a given path. Run "fireforge export <path> [...]" with an explicit file list that omits the already-claimed path(s), or resolve the conflict via "fireforge patch delete" / "fireforge re-export --files" before retrying export-all.');
162
+ 'Only one patch may create a given path two creation hunks on /dev/null cannot coexist in any apply order, so this case is structurally unrecoverable rather than verify-failing. The --allow-overlap escape hatch covers cross-patch MODIFICATION overlap (which yields a queue that fails verify but still applies); it deliberately does NOT cover this case. ' +
163
+ 'Run "fireforge export <path> [...]" with an explicit file list that omits the already-claimed path(s), or resolve the conflict via "fireforge patch delete" / "fireforge re-export --files" before retrying export-all.');
162
164
  }
163
165
  /**
164
166
  * Runs the export-all command to export all changes as a patch.
@@ -166,7 +168,8 @@ async function checkDuplicateNewFileCreations(paths, diff) {
166
168
  * @param options - Export options
167
169
  */
168
170
  export async function exportAllCommand(projectRoot, options = {}) {
169
- intro('FireForge Export All');
171
+ const isDryRun = options.dryRun === true;
172
+ intro(isDryRun ? 'FireForge Export All (dry run)' : 'FireForge Export All');
170
173
  const paths = getProjectPaths(projectRoot);
171
174
  // Check if engine exists
172
175
  if (!(await pathExists(paths.engine))) {
@@ -236,13 +239,38 @@ export async function exportAllCommand(projectRoot, options = {}) {
236
239
  if (!metadata)
237
240
  return;
238
241
  const { patchName, selectedCategory, description } = metadata;
239
- // Ensure patches directory exists
240
- await ensureDir(paths.patches);
241
- const s = spinner('Exporting all changes...');
242
+ // Ensure patches directory exists. Skip during a dry-run so the command
243
+ // is purely read-only — `--dry-run` callers should be safe to invoke
244
+ // against a project that has never exported a patch without leaving the
245
+ // empty `patches/` directory behind.
246
+ if (!isDryRun) {
247
+ await ensureDir(paths.patches);
248
+ }
249
+ const s = spinner(isDryRun ? 'Planning export-all...' : 'Exporting all changes...');
242
250
  try {
243
251
  // Extract affected files from diff
244
252
  const filesAffected = extractAffectedFiles(diff);
245
253
  await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
254
+ // Dry-run: enumerate filename, metadata, and supersede coverage without
255
+ // writing. Mirrors `fireforge export --dry-run` so the same preview
256
+ // surface is available for both targeted and aggregate exports. Runs
257
+ // AFTER lint so the operator sees the same lint output they would on
258
+ // a real run; runs BEFORE the supersede confirmation prompt because
259
+ // confirming a dry-run is meaningless.
260
+ if (isDryRun) {
261
+ s.stop('Plan ready');
262
+ await renderDryRunPreview({
263
+ patchesDir: paths.patches,
264
+ category: selectedCategory,
265
+ name: patchName,
266
+ description,
267
+ filesAffected,
268
+ sourceEsrVersion: config.firefox.version,
269
+ explicitSupersede: options.supersede === true,
270
+ });
271
+ outro('Dry run complete — no changes made');
272
+ return;
273
+ }
246
274
  // Check how many existing patches would be superseded
247
275
  const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
248
276
  if (!shouldProceed)
@@ -299,7 +327,8 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
299
327
  .option('--supersede', 'Allow superseding multiple existing patches')
300
328
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
301
329
  .option('--exclude-furnace', 'Export the non-Furnace subset of the aggregate diff instead of refusing when Furnace-managed files are modified. Furnace-managed files are still deployed by "fireforge furnace apply"; this flag only changes whether export-all aborts or filters in their presence.')
302
- .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify)')
330
+ .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify). Does not bypass the new-file creation guard — two patches creating the same path is structurally unrecoverable, so that case still refuses regardless of this flag.')
331
+ .option('--dry-run', 'Print the export-all plan (filename, metadata, files affected, supersede preview) without writing anything to patches/. Lint still runs so the operator sees the same lint output a real run would produce.')
303
332
  .action(withErrorHandling(async (options) => {
304
333
  const { category, ...rest } = options;
305
334
  await exportAllCommand(getProjectRoot(), {
@@ -24,12 +24,29 @@ export declare const FURNACE_CHROME_DOC_SENTINEL = "data-furnace-chrome-doc";
24
24
  * XHTML shell for a top-level chrome document.
25
25
  *
26
26
  * The emitted document:
27
- * - Declares `windowtype="navigator:browser"` when `withTitlebar` is true
28
- * so chrome-wide stylesheets that target the browser window still apply.
29
- * - Emits a titlebar-buttonbox placeholder when `withTitlebar` is true so
30
- * platform-native window controls render.
27
+ * - When `withTitlebar` is true, declares the `navigator:browser` minimum
28
+ * set: `windowtype`, `customtitlebar`, default `width`/`height`, and a
29
+ * `persist` allowlist for screen position + size + sizemode. Without
30
+ * these, a fork-owned chrome doc that ships as the main window opens
31
+ * at the OS intrinsic minimum size on first launch and forgets the
32
+ * user's last-known geometry across restarts. The titlebar-buttonbox
33
+ * placeholder is emitted alongside so platform-native window controls
34
+ * render with the matching CSS rules from `generateChromeDocCss`.
35
+ * - Loads `chrome://global/content/customElements.js` in `<head>` ahead
36
+ * of the per-doc subscript. Without it, every `<moz-*>` widget the
37
+ * author drops into the body silently degrades to `HTMLUnknownElement`
38
+ * and the upstream a11y/keyboard semantics that motivated the use of
39
+ * the toolkit widget in the first place are lost. Matches the
40
+ * `webrtcIndicator.xhtml` shape upstream uses for non-`browser.xhtml`
41
+ * chrome documents.
31
42
  * - Links the per-document CSS at `chrome://browser/content/<name>-chrome.css`
32
43
  * and the Fluent bundle `browser/<name>.ftl`.
44
+ * - Keeps `data-l10n-id` on the leaf `<title>` only. Binding the same key
45
+ * on the root `<window>` would cause Fluent's first-paint translation
46
+ * pass to overwrite the entire body subtree with the message's text
47
+ * value (the standard `data-l10n-id`-on-non-leaf failure mode), since
48
+ * the FTL stub gives `<name>-window-title` a value rather than an
49
+ * attribute-only message.
33
50
  * - Carries the `data-furnace-chrome-doc="<name>"` sentinel so fork-side
34
51
  * patches to upstream platform modules (DevToolsStartup, PageActions, …)
35
52
  * that assume `browser.xhtml`'s DOM can guard against it cheaply. See
@@ -46,10 +63,21 @@ export declare function generateChromeDocXhtml(name: string, withTitlebar: boole
46
63
  */
47
64
  export declare function generateChromeDocJs(name: string, licenseHeader: string): string;
48
65
  /**
49
- * Scoped CSS for a chrome document. When `withTitlebar` is false the
50
- * macOS `.titlebar-button { display: none }` carve-out is emitted so
51
- * frameless overlay-style documents don't inherit the platform window
52
- * controls that `global.css` applies by default.
66
+ * Scoped CSS for a chrome document.
67
+ *
68
+ * When `withTitlebar` is true, the matching navigator:browser minimum
69
+ * CSS is emitted alongside the layout rules: the buttonbox container is
70
+ * a draggable region (`-moz-window-dragging: drag`) so the user can drag
71
+ * the window from the title bar, and the buttonbox itself opts into the
72
+ * platform-native window-button-box appearance so the OS renders the
73
+ * traffic-light / minimize-maximize-close controls in their canonical
74
+ * positions. Without these rules the buttonbox markup still draws but
75
+ * is unstyled and non-draggable, which is the failure mode a fork that
76
+ * ships the scaffold verbatim hits on first launch.
77
+ *
78
+ * When `withTitlebar` is false the macOS `.titlebar-button { display: none }`
79
+ * carve-out is emitted so frameless overlay-style documents don't inherit
80
+ * the platform window controls that `global.css` applies by default.
53
81
  */
54
82
  export declare function generateChromeDocCss(name: string, withTitlebar: boolean, licenseHeader: string): string;
55
83
  /** Fluent stub — one placeholder message keyed to the window title. */
@@ -92,3 +120,26 @@ export declare function jarIncMnEntryForChromeDoc(name: string): string;
92
120
  * "jar.mn: Cannot find ${name}.ftl".
93
121
  */
94
122
  export declare function localeJarMnEntryForChromeDoc(name: string): string;
123
+ /**
124
+ * Returns true when `jarMnContents` already carries a `[localization]`-style
125
+ * wildcard rooted at `%browser/` whose pattern would already pick up a
126
+ * scaffolded `browser/<name>.ftl` file. Recognises:
127
+ *
128
+ * - `(%browser/**\/*.ftl)` — recursive (the upstream shape).
129
+ * - `(%browser/*.ftl)` — flat.
130
+ *
131
+ * Forks that have migrated entirely to `[localization]` wildcards typically
132
+ * keep no per-file `locale/...` entries for FTL at all; appending one
133
+ * there is dead weight at best, and an outright build break when the fork
134
+ * has also dropped the `% locale browser …` registration. The chrome-doc
135
+ * scaffolder consults this predicate before its locales/jar.mn append and
136
+ * skips the per-file write when the wildcard already covers the scaffold's
137
+ * target path.
138
+ *
139
+ * Conservative by design: only wildcards rooted at `%browser/` count, and
140
+ * a `(%browser/foo.ftl)`-style explicit reference (no `*`) is not treated
141
+ * as a capture. A fork with a narrower wildcard (e.g. `(%browser/about/*.ftl)`)
142
+ * is correctly NOT captured by this predicate, because that wildcard would
143
+ * not pick up the top-level `browser/<name>.ftl` the scaffold writes.
144
+ */
145
+ export declare function localesFtlWildcardCapturesScaffoldedName(jarMnContents: string): boolean;