@hominis/fireforge 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0
4
+
5
+ ### Features
6
+
7
+ - **Chrome-doc previews and cleanup.** `furnace chrome-doc create` now supports `--dry-run`, validating the same target files and jar registrations without writing. New `furnace chrome-doc remove <name>` removes scaffolded chrome-doc files, jar entries, and optional xpcshell packaging-test directories, with `--dry-run` and `--yes` support.
8
+ - **Versioned `status --json` schema.** The JSON output is now an object with `schemaVersion`, `summary`, and `files` instead of a bare array. Error paths also emit versioned JSON objects with `code` and `error`.
9
+
10
+ ### Hardening
11
+
12
+ - **Override removal demotes back to stock.** Removing a Furnace override restores engine files, deletes the override workspace, clears override checksums, and re-adds the component to `stock` tracking instead of dropping it from `furnace.json`. Optional Furnace config fields, including `platformPrefixes`, are preserved across the write.
13
+ - **Rename updates browser-chrome test bodies.** `furnace rename` now rewrites generated browser-chrome mochitest contents as well as filenames and `browser.toml`, preventing stale `waitForElement("<old>")` references after a component rename.
14
+ - **UI build preflight is stricter.** `fireforge build --ui` now refuses before `mach build faster` when the current objdir lacks a completed launchable bundle, guiding fresh imports and partial builds through a full `fireforge build` first.
15
+ - **Interrupt and diagnostics polish.** Signal-driven Furnace preview teardown has regression coverage for stale lock cleanup, `chrome-doc-rollback` markers round-trip through Furnace state validation, watch-mode permission failures name the macOS privacy remediation, and `test --doctor` prints the probed objdir, binary/app path, port, and elapsed time.
16
+
17
+ ### Documentation
18
+
19
+ - **README — Storybook first-run and audit posture.** The Furnace preview docs now call out the upstream Storybook npm install and audit output as Firefox Storybook workspace dependency state, not FireForge package dependency state.
20
+ - **README — chrome-doc lifecycle and JSON schema.** The Furnace and status sections document chrome-doc dry-runs/removal, UI-build preconditions, watch privacy guidance, and the new versioned `status --json` object.
21
+
3
22
  ## 0.20.0
4
23
 
5
24
  ### Features
package/README.md CHANGED
@@ -36,7 +36,7 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
36
36
  - **Python 3** (required by Firefox's `mach` build system).
37
37
  - **Git**
38
38
  - Platform build tools: Xcode on macOS, `build-essential` on Linux, Visual Studio Build Tools on Windows.
39
- - **Watchman** (optional, only required by `fireforge watch`). Install via `brew install watchman` (macOS), `dnf install watchman` (Fedora), or follow the upstream [Meta docs](https://facebook.github.io/watchman/). `fireforge doctor` surfaces a warning row when it is not on `PATH` so the dependency is visible during the usual onboarding sweep rather than at the watch-mode failure site. `fireforge watch` resolves watchman's absolute path via `which` / `where` and prepends its directory to the subprocess `PATH` it hands mach, so a homebrew-installed watchman at `/opt/homebrew/bin/watchman` (absent from the Node subprocess's default `PATH` on macOS) is still visible to `mach watch` without the operator having to re-export `PATH` manually.
39
+ - **Watchman** (optional, only required by `fireforge watch`). Install via `brew install watchman` (macOS), `dnf install watchman` (Fedora), or follow the upstream [Meta docs](https://facebook.github.io/watchman/). `fireforge doctor` surfaces a warning row when it is not on `PATH` so the dependency is visible during the usual onboarding sweep rather than at the watch-mode failure site. `fireforge watch` resolves watchman's absolute path via `which` / `where` and prepends its directory to the subprocess `PATH` it hands mach, so a homebrew-installed watchman at `/opt/homebrew/bin/watchman` (absent from the Node subprocess's default `PATH` on macOS) is still visible to `mach watch` without the operator having to re-export `PATH` manually. If `mach watch` reports `Operation not permitted` / `EPERM` on macOS, FireForge points at the usual privacy fix: grant Full Disk Access or Files and Folders access to the terminal/Codex app and watchman, then restart watchman with `watchman shutdown-server`.
40
40
 
41
41
  ### Setup
42
42
 
@@ -290,9 +290,11 @@ When a patch queue drifts, e.g. due to overlapping new-file creations, forward i
290
290
  fireforge verify # fsck: manifest + cross-patch lint
291
291
  fireforge lint # includes the same cross-patch rules
292
292
  fireforge status --ownership # flat path → owning patch table
293
- fireforge status --json # machine-readable classified output
293
+ fireforge status --json # machine-readable classified object
294
294
  ```
295
295
 
296
+ `status --json` emits a versioned object: `{ "schemaVersion": 1, "summary": { "total": <n>, "byClassification": { ... } }, "files": [...] }`. Error paths also emit a JSON object with `schemaVersion`, `code`, and `error` before exiting non-zero, so scripts can parse both clean and failing runs.
297
+
296
298
  Then fix with the appropriate primitive:
297
299
 
298
300
  | Problem | Fix |
@@ -379,6 +381,9 @@ Custom elements live under `toolkit/content/widgets`, but a fork's top-level chr
379
381
  fireforge furnace chrome-doc create mybrowser # full chrome (titlebar + windowtype)
380
382
  fireforge furnace chrome-doc create overlay --no-titlebar # frameless overlay
381
383
  fireforge furnace chrome-doc create mybrowser --with-tests # + xpcshell packaging-verification test
384
+ fireforge furnace chrome-doc create mybrowser --dry-run # preview without writing
385
+ fireforge furnace chrome-doc remove mybrowser --dry-run # preview cleanup
386
+ fireforge furnace chrome-doc remove mybrowser --yes # remove files + registrations
382
387
  ```
383
388
 
384
389
  The command writes:
@@ -390,7 +395,7 @@ The command writes:
390
395
  - 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.
391
396
  - 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.
392
397
 
393
- Writes are transactional: a SIGINT mid-scaffold rolls back every touched file. Requires an existing engine — run `fireforge download` first.
398
+ Writes are transactional: a SIGINT mid-scaffold rolls back every touched file. `--dry-run` validates the same paths and registrations without acquiring the mutation lock or writing. `furnace chrome-doc remove <name>` removes the scaffolded source files, jar registrations, and optional xpcshell packaging-test directory; use its `--dry-run` first when cleaning an experimental document. Requires an existing engine — run `fireforge download` first.
394
399
 
395
400
  #### Platform module compatibility
396
401
 
@@ -439,6 +444,8 @@ Three styles are available via `--test-style`:
439
444
 
440
445
  `furnace create`, `furnace remove`, and `furnace rename` re-read `furnace.json` inside the mutation lock before writing, so concurrent component edits preserve sibling entries instead of writing back a stale outer snapshot. `furnace refresh --all` continues past per-component refresh failures, reports the failed count, and exits non-zero with the failed override names after finishing the rest of the selection.
441
446
 
447
+ `furnace preview` starts Firefox's upstream Storybook workspace. On the first run, `mach storybook` may install roughly a thousand npm packages under `engine/browser/components/storybook/` and may print npm audit counts from Storybook's transitive dependencies. FireForge frames that output as upstream Storybook dependency state; it does not mean FireForge's own package dependencies were installed into the project.
448
+
442
449
  ## Additional Commands
443
450
 
444
451
  The commands below cover project configuration, patch queue management, build packaging and development utilities. Run `fireforge <command> --help` for full option details.
@@ -540,6 +547,8 @@ Aggregate patch-size findings (`large-patch-files`, `large-patch-lines`) describ
540
547
 
541
548
  `fireforge build` is a transactional step: after a successful mach build it audits the dist bundle against engine-relative paths touched since the last successful build, and warns per file that is packageable-by-convention (`.js`/`.mjs`/`.css`/`.ftl`/`.xhtml`/`app/profile/…`) but has no matching artifact or whose dist mtime is older than the source. Ends every build with a `Packaged: N updated, M stale, K missing, S skipped` summary. The audit is warn-only — it never fails a build that mach reported green.
542
549
 
550
+ `fireforge build --ui` is intentionally a fast rebuild path, not a bootstrap path. It now refuses before invoking `mach build faster` unless the current objdir has a completed launchable bundle; fresh imports and partial builds should run a full `fireforge build` first.
551
+
543
552
  The audit applies seven routing rules to suppress false positives that previously trained operators to ignore its warnings:
544
553
 
545
554
  - **jar.mn registrations are authoritative.** When the source under audit is claimed by a `(source)` reference in an ancestor `jar.mn`, the audit walks the registration to compute the expected target path (e.g. `content/browser/mybrowser.js`) and probes the dist tree for a candidate whose absolute path ends with that suffix. Picking the correct artifact from a same-basename collision no longer depends on path-similarity scoring. If the registration target is missing from dist, the warning names the `jar.mn` entry so "registration is intact, packaging dropped the file" is distinguishable from "source is unregistered". This is the fix for the class of false positive where `engine/browser/base/content/<name>.js` (registered in `browser/base/jar.mn`) collided with an unrelated `browser/defaults/preferences/<name>.js` added by a separate patch; the heuristic could not distinguish them, so the audit falsely reported the correctly-packaged chrome resource as missing.
@@ -614,7 +623,7 @@ fireforge test --doctor
614
623
  fireforge test --doctor browser/base/content/test/foo/browser_bar.js
615
624
  ```
616
625
 
617
- Spawns the built browser headless, waits for a marionette handshake on `127.0.0.1:2828`, and reports PASS/FAIL with the tail of the browser's stderr on FAIL. Distinguishes "marionette wedged" (socket silent) from "mach test discovery failed" — both otherwise surface as a silent 360-second hang followed by `Passed: 0, Failed: 0`. Useful as a prefix on routine `fireforge test` invocations when marionette has been flaky.
626
+ Spawns the built browser headless, waits for a marionette handshake on `127.0.0.1:2828`, and reports PASS/FAIL with the tail of the browser's stderr on FAIL. The success output also names the objdir, binary, app path, port, and elapsed probe time so CI logs show exactly what was probed. Distinguishes "marionette wedged" (socket silent) from "mach test discovery failed" — both otherwise surface as a silent 360-second hang followed by `Passed: 0, Failed: 0`. Useful as a prefix on routine `fireforge test` invocations when marionette has been flaky.
618
627
 
619
628
  The probe is a cascade of six layered checks — engine-present → mach-available → python-available → profile-creatable → browser-spawns → marionette-handshake. Each failure is tagged `[layer N/6: <name>]` so the first broken layer is surfaced immediately instead of the whole cascade blocking on the final socket poll. When the browser binary crashes at startup (missing dylib, wrong CPU arch, corrupt profile) the cascade fails at layer 5 within the settle window, not after the full socket timeout.
620
629
 
@@ -5,7 +5,7 @@ import { auditBuildArtifacts } from '../core/build-audit.js';
5
5
  import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
6
6
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
7
7
  import { getProjectPaths, loadConfig } from '../core/config.js';
8
- import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, runMach, withBuildLock, } from '../core/mach.js';
8
+ import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, runMach, withBuildLock, } from '../core/mach.js';
9
9
  import { GeneralError } from '../errors/base.js';
10
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
11
11
  import { toError } from '../utils/errors.js';
@@ -106,6 +106,24 @@ export async function buildCommand(projectRoot, options) {
106
106
  throw new GeneralError(mismatchMessage);
107
107
  }
108
108
  }
109
+ if (options.ui) {
110
+ if (!buildCheck.exists || !buildCheck.objDir) {
111
+ const detail = buildCheck.objDir
112
+ ? `Build artifacts incomplete in ${buildCheck.objDir}/`
113
+ : 'No completed obj-* build artifacts found.';
114
+ throw new GeneralError(`UI-only builds require a completed full build first. ${detail}\n\n` +
115
+ 'Run "fireforge build" and let it finish, then retry "fireforge build --ui".');
116
+ }
117
+ const bundleCheck = await hasRunnableBundle(paths.engine, config.binaryName, buildCheck.objDir);
118
+ if (!bundleCheck.runnable) {
119
+ const expectedSuffix = bundleCheck.expectedPath
120
+ ? ` Expected launchable binary at engine/${bundleCheck.expectedPath}.`
121
+ : '';
122
+ throw new GeneralError(`UI-only builds require a completed full build first.${expectedSuffix}\n\n` +
123
+ 'Freshly imported or partially built trees cannot use `mach build faster` yet. ' +
124
+ 'Run "fireforge build" and let it finish, then retry "fireforge build --ui".');
125
+ }
126
+ }
109
127
  // Log brand info if specified
110
128
  if (options.brand) {
111
129
  verbose(`Building with brand: ${options.brand}`);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `fireforge furnace chrome-doc remove <name>` — removes the files and
3
+ * registrations created by `furnace chrome-doc create`.
4
+ */
5
+ /** Options for `furnace chrome-doc remove`. */
6
+ export interface FurnaceChromeDocRemoveOptions {
7
+ /** Skip confirmation. Required for real non-interactive removal. */
8
+ yes?: boolean;
9
+ /** Print the removal plan without writing files. */
10
+ dryRun?: boolean;
11
+ }
12
+ /** Runs `furnace chrome-doc remove <name>`. */
13
+ export declare function furnaceChromeDocRemoveCommand(projectRoot: string, name: string, options?: FurnaceChromeDocRemoveOptions): Promise<void>;
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge furnace chrome-doc remove <name>` — removes the files and
4
+ * registrations created by `furnace chrome-doc create`.
5
+ */
6
+ import { readdir } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { confirm } from '@clack/prompts';
9
+ import { loadConfig } from '../../core/config.js';
10
+ import { runFurnaceMutation } from '../../core/furnace-operation.js';
11
+ import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
12
+ import { FurnaceError } from '../../errors/furnace.js';
13
+ import { pathExists, readText, removeDir, removeFile, writeText } from '../../utils/fs.js';
14
+ import { cancel, info, intro, isCancel, note, outro } from '../../utils/logger.js';
15
+ import { buildChromeDocPlan, validateChromeDocName } from './chrome-doc.js';
16
+ function removeExactLine(content, line) {
17
+ const lines = content.split('\n');
18
+ const filtered = lines.filter((candidate) => candidate !== line);
19
+ return filtered.join('\n').replace(/\n*$/, '\n');
20
+ }
21
+ async function removeChromeDocJarEntryIfPresent(engineDir, file, entry, journal) {
22
+ const jarPath = join(engineDir, file);
23
+ if (!(await pathExists(jarPath))) {
24
+ throw new FurnaceError(`Required jar file ${jarPath} does not exist; cannot remove chrome-doc entry. Check that the fork's engine layout matches the expected browser/ and locales/ tree.`);
25
+ }
26
+ const existing = await readText(jarPath);
27
+ if (!existing.includes(entry)) {
28
+ return false;
29
+ }
30
+ await snapshotFile(journal, jarPath);
31
+ await writeText(jarPath, removeExactLine(existing, entry));
32
+ return true;
33
+ }
34
+ async function removeEmptyDirIfPresent(dirPath, journal) {
35
+ if (!(await pathExists(dirPath)))
36
+ return false;
37
+ const entries = await readdir(dirPath);
38
+ if (entries.length > 0)
39
+ return false;
40
+ await snapshotDir(journal, dirPath);
41
+ await removeDir(dirPath);
42
+ return true;
43
+ }
44
+ function renderChromeDocRemoveDryRun(name, plan) {
45
+ const jarLines = plan.jarEntries.map(({ file, entry, present }) => ` engine/${file}: ${present ? 'would remove' : 'not present'} ${entry.trim()}`);
46
+ const testLines = plan.testDir !== undefined
47
+ ? ['', 'Would remove test directory if present:', ` engine/${plan.testDir}/`]
48
+ : [];
49
+ return [
50
+ `[dry-run] Chrome document "${name}" removal plan`,
51
+ '',
52
+ 'Would remove source files if present:',
53
+ ...plan.files.map((f) => ` engine/${f}`),
54
+ ...testLines,
55
+ '',
56
+ 'Jar registrations:',
57
+ ...jarLines,
58
+ ].join('\n');
59
+ }
60
+ async function performChromeDocRemoveMutations(args) {
61
+ const journal = createRollbackJournal();
62
+ args.operationContext.registerJournal(journal);
63
+ let removedFiles = 0;
64
+ let removedJarEntries = 0;
65
+ let removedTestDir = false;
66
+ try {
67
+ for (const file of args.plan.files) {
68
+ const filePath = join(args.engineDir, file);
69
+ if (await pathExists(filePath)) {
70
+ await snapshotFile(journal, filePath);
71
+ await removeFile(filePath);
72
+ removedFiles++;
73
+ }
74
+ }
75
+ for (const { file, entry } of args.plan.jarEntries) {
76
+ if (await removeChromeDocJarEntryIfPresent(args.engineDir, file, entry, journal)) {
77
+ removedJarEntries++;
78
+ }
79
+ }
80
+ const testDir = join(args.engineDir, 'browser/base/content/test', `${args.binaryName}-xpcshell`, args.name);
81
+ if (await pathExists(testDir)) {
82
+ await snapshotDir(journal, testDir);
83
+ await removeDir(testDir);
84
+ removedTestDir = true;
85
+ }
86
+ await removeEmptyDirIfPresent(join(args.engineDir, 'browser/base/content/test', `${args.binaryName}-xpcshell`), journal);
87
+ }
88
+ catch (error) {
89
+ await restoreRollbackJournalOrThrow(journal, `Failed to remove chrome-doc "${args.name}"`);
90
+ throw error;
91
+ }
92
+ return { removedFiles, removedJarEntries, removedTestDir };
93
+ }
94
+ /** Runs `furnace chrome-doc remove <name>`. */
95
+ export async function furnaceChromeDocRemoveCommand(projectRoot, name, options = {}) {
96
+ intro('Furnace chrome-doc remove');
97
+ validateChromeDocName(name);
98
+ const forgeConfig = await loadConfig(projectRoot);
99
+ const engineDir = join(projectRoot, 'engine');
100
+ if (!(await pathExists(engineDir))) {
101
+ throw new FurnaceError('Engine directory not found. Run "fireforge download" first before removing a chrome-doc.');
102
+ }
103
+ const plan = await buildChromeDocPlan({
104
+ engineDir,
105
+ name,
106
+ withTests: true,
107
+ binaryName: forgeConfig.binaryName,
108
+ includeLocaleEntryWhenWildcard: true,
109
+ });
110
+ if (options.dryRun) {
111
+ note(renderChromeDocRemoveDryRun(name, plan), name);
112
+ outro('Dry run complete');
113
+ return;
114
+ }
115
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
116
+ if (!options.yes && !isInteractive) {
117
+ throw new FurnaceError(`Cannot remove chrome-doc "${name}" in non-interactive mode without --yes flag.`, name);
118
+ }
119
+ if (!options.yes && isInteractive) {
120
+ const confirmed = await confirm({
121
+ message: `Remove chrome document "${name}" and its scaffolded registrations?`,
122
+ });
123
+ if (isCancel(confirmed) || !confirmed) {
124
+ cancel('Remove cancelled');
125
+ return;
126
+ }
127
+ }
128
+ const result = await runFurnaceMutation(projectRoot, 'chrome-doc-rollback', (ctx) => performChromeDocRemoveMutations({
129
+ name,
130
+ engineDir,
131
+ plan,
132
+ binaryName: forgeConfig.binaryName,
133
+ operationContext: ctx,
134
+ }));
135
+ info(`Removed ${result.removedFiles} source file${result.removedFiles === 1 ? '' : 's'} and ` +
136
+ `${result.removedJarEntries} jar registration${result.removedJarEntries === 1 ? '' : 's'} for "${name}".`);
137
+ if (result.removedTestDir) {
138
+ info('Removed xpcshell packaging test directory.');
139
+ }
140
+ outro('Chrome document removed');
141
+ }
142
+ //# sourceMappingURL=chrome-doc-remove.js.map
@@ -35,7 +35,39 @@ export interface FurnaceChromeDocCreateOptions {
35
35
  * because the owning moz.build depends on the fork's layout.
36
36
  */
37
37
  withTests?: boolean;
38
+ /** Print the scaffold plan without writing files. */
39
+ dryRun?: boolean;
38
40
  }
41
+ /**
42
+ * Validates a chrome-doc name. Lowercase ASCII, optional hyphens, no
43
+ * leading digit — the name is used verbatim in CSS selectors, jar.mn
44
+ * entries, FTL keys, and file basenames, so anything outside that
45
+ * character set would break at least one downstream consumer.
46
+ * @param name Chrome-doc name (file basename without extension).
47
+ * @throws InvalidArgumentError when the name is unusable.
48
+ */
49
+ export declare function validateChromeDocName(name: string): void;
50
+ export interface ChromeDocPlan {
51
+ files: string[];
52
+ dirs: string[];
53
+ jarEntries: Array<{
54
+ file: string;
55
+ entry: string;
56
+ present: boolean;
57
+ }>;
58
+ localeWildcardCapturesFtl: boolean;
59
+ testDir?: string;
60
+ testFiles: string[];
61
+ }
62
+ /** Builds the shared create/remove plan for a top-level chrome document. */
63
+ export declare function buildChromeDocPlan(args: {
64
+ engineDir: string;
65
+ name: string;
66
+ withTests: boolean;
67
+ binaryName: string;
68
+ validateCreateConflicts?: boolean;
69
+ includeLocaleEntryWhenWildcard?: boolean;
70
+ }): Promise<ChromeDocPlan>;
39
71
  /**
40
72
  * Runs `furnace chrome-doc create <name>`.
41
73
  * @param projectRoot Root directory of the project.
@@ -37,7 +37,7 @@ const CHROME_DOC_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
37
37
  * @param name Chrome-doc name (file basename without extension).
38
38
  * @throws InvalidArgumentError when the name is unusable.
39
39
  */
40
- function validateChromeDocName(name) {
40
+ export function validateChromeDocName(name) {
41
41
  if (!name.trim()) {
42
42
  throw new InvalidArgumentError('Chrome-doc name is required', 'name');
43
43
  }
@@ -45,6 +45,106 @@ function validateChromeDocName(name) {
45
45
  throw new InvalidArgumentError('Chrome-doc name must be lowercase ASCII, may contain hyphens, and must not start with a digit (e.g. mybrowser, about-onboarding).', 'name');
46
46
  }
47
47
  }
48
+ /** Builds the shared create/remove plan for a top-level chrome document. */
49
+ export async function buildChromeDocPlan(args) {
50
+ const contentDir = join(args.engineDir, 'browser/base/content');
51
+ const sharedThemeDir = join(args.engineDir, 'browser/themes/shared');
52
+ const localeDir = join(args.engineDir, 'browser/locales/en-US/browser');
53
+ const dirs = [contentDir, sharedThemeDir, localeDir];
54
+ const xhtmlPath = join(contentDir, `${args.name}.xhtml`);
55
+ if (args.validateCreateConflicts && (await pathExists(xhtmlPath))) {
56
+ throw new FurnaceError(`${args.name}.xhtml already exists at ${xhtmlPath}. Remove it or choose a different name.`);
57
+ }
58
+ const jarMnPath = join(args.engineDir, 'browser/base/jar.mn');
59
+ const jarIncMnPath = join(args.engineDir, 'browser/themes/shared/jar.inc.mn');
60
+ const localeJarMnPath = join(args.engineDir, 'browser/locales/jar.mn');
61
+ for (const requiredJarPath of [jarMnPath, jarIncMnPath, localeJarMnPath]) {
62
+ if (!(await pathExists(requiredJarPath))) {
63
+ throw new FurnaceError(`Required jar file ${requiredJarPath} does not exist; cannot register chrome-doc entry. Check that the fork's engine layout matches the expected browser/ and locales/ tree.`);
64
+ }
65
+ }
66
+ const [jarMn, jarIncMn, localeJarMn] = await Promise.all([
67
+ readText(jarMnPath),
68
+ readText(jarIncMnPath),
69
+ readText(localeJarMnPath),
70
+ ]);
71
+ const localeWildcardCapturesFtl = localesFtlWildcardCapturesScaffoldedName(localeJarMn);
72
+ const jarEntries = [];
73
+ for (const entry of jarMnEntriesForChromeDoc(args.name)) {
74
+ jarEntries.push({
75
+ file: 'browser/base/jar.mn',
76
+ entry,
77
+ present: jarMn.includes(entry),
78
+ });
79
+ }
80
+ const cssEntry = jarIncMnEntryForChromeDoc(args.name);
81
+ jarEntries.push({
82
+ file: 'browser/themes/shared/jar.inc.mn',
83
+ entry: cssEntry,
84
+ present: jarIncMn.includes(cssEntry),
85
+ });
86
+ if (!localeWildcardCapturesFtl || args.includeLocaleEntryWhenWildcard) {
87
+ const ftlEntry = localeJarMnEntryForChromeDoc(args.name);
88
+ jarEntries.push({
89
+ file: 'browser/locales/jar.mn',
90
+ entry: ftlEntry,
91
+ present: localeJarMn.includes(ftlEntry),
92
+ });
93
+ }
94
+ const files = [
95
+ `browser/base/content/${args.name}.xhtml`,
96
+ `browser/base/content/${args.name}.js`,
97
+ `browser/themes/shared/${args.name}-chrome.css`,
98
+ `browser/locales/en-US/browser/${args.name}.ftl`,
99
+ ];
100
+ const testFiles = [];
101
+ let testDir;
102
+ if (args.withTests) {
103
+ const testParentDir = `${args.binaryName}-xpcshell`;
104
+ testDir = `browser/base/content/test/${testParentDir}/${args.name}`;
105
+ testFiles.push(`${testDir}/${chromeDocPackagingTestFileName(args.name)}`, `${testDir}/xpcshell.toml`);
106
+ }
107
+ return {
108
+ files,
109
+ dirs,
110
+ jarEntries,
111
+ localeWildcardCapturesFtl,
112
+ ...(testDir ? { testDir } : {}),
113
+ testFiles,
114
+ };
115
+ }
116
+ function renderChromeDocCreateDryRun(name, plan) {
117
+ const dirLines = plan.dirs.map((dir) => ` ${dir}`);
118
+ const jarLines = plan.jarEntries.map(({ file, entry, present }) => ` engine/${file}: ${present ? 'already present' : 'would add'} ${entry.trim()}`);
119
+ const localeLine = plan.localeWildcardCapturesFtl
120
+ ? [
121
+ '',
122
+ 'Locale jar.mn already has a [localization] wildcard that captures the FTL;',
123
+ 'no per-file locale entry would be added.',
124
+ ]
125
+ : [];
126
+ const testLines = plan.testFiles.length > 0
127
+ ? [
128
+ '',
129
+ 'Would create xpcshell packaging test files:',
130
+ ...plan.testFiles.map((f) => ` engine/${f}`),
131
+ ]
132
+ : [];
133
+ return [
134
+ `[dry-run] Chrome document "${name}" scaffold plan`,
135
+ '',
136
+ 'Directories checked/created as needed:',
137
+ ...dirLines,
138
+ '',
139
+ 'Would create source files:',
140
+ ...plan.files.map((f) => ` engine/${f}`),
141
+ ...testLines,
142
+ '',
143
+ 'Jar registrations:',
144
+ ...jarLines,
145
+ ...localeLine,
146
+ ].join('\n');
147
+ }
48
148
  /**
49
149
  * Appends a line to a jar.mn-style file when that exact line is not
50
150
  * already present. Captures the pre-write contents in the journal so a
@@ -192,6 +292,18 @@ export async function furnaceChromeDocCreateCommand(projectRoot, name, options =
192
292
  }
193
293
  const withTitlebar = options.titlebar ?? true;
194
294
  const withTests = options.withTests ?? false;
295
+ const plan = await buildChromeDocPlan({
296
+ engineDir,
297
+ name,
298
+ withTests,
299
+ binaryName: forgeConfig.binaryName,
300
+ validateCreateConflicts: true,
301
+ });
302
+ if (options.dryRun) {
303
+ note(renderChromeDocCreateDryRun(name, plan), name);
304
+ outro('Dry run complete');
305
+ return;
306
+ }
195
307
  const written = await runFurnaceMutation(projectRoot, 'chrome-doc-rollback', (ctx) => performChromeDocMutations({
196
308
  name,
197
309
  license,
@@ -3,6 +3,7 @@ import { Option } from 'commander';
3
3
  import { pickDefined } from '../../utils/options.js';
4
4
  import { furnaceApplyCommand } from './apply.js';
5
5
  import { furnaceChromeDocCreateCommand } from './chrome-doc.js';
6
+ import { furnaceChromeDocRemoveCommand } from './chrome-doc-remove.js';
6
7
  import { furnaceCreateCommand } from './create.js';
7
8
  import { furnaceDeployCommand } from './deploy.js';
8
9
  import { furnaceDiffCommand } from './diff.js';
@@ -86,6 +87,10 @@ function registerFurnaceInfoCommands(furnace, context) {
86
87
  .action(withErrorHandling(async (name, options) => {
87
88
  await furnaceCreateCommand(getProjectRoot(), name, options);
88
89
  }));
90
+ registerChromeDocCommands(furnace, context);
91
+ }
92
+ function registerChromeDocCommands(furnace, context) {
93
+ const { getProjectRoot, withErrorHandling } = context;
89
94
  const chromeDoc = furnace
90
95
  .command('chrome-doc')
91
96
  .description('Scaffold top-level chrome documents (xhtml + js + css + ftl + jar.mn)');
@@ -94,9 +99,18 @@ function registerFurnaceInfoCommands(furnace, context) {
94
99
  .description('Scaffold a new top-level chrome document')
95
100
  .option('--no-titlebar', 'Frameless overlay-style document (omits titlebar-buttonbox)')
96
101
  .option('--with-tests', 'Scaffold an xpcshell packaging-verification test that probes XCurProcD/chrome/browser/... directly (bypasses the xpcshell chrome:// URI limitation).')
102
+ .option('--dry-run', 'Show the chrome-doc scaffold plan without writing')
97
103
  .action(withErrorHandling(async (name, options) => {
98
104
  await furnaceChromeDocCreateCommand(getProjectRoot(), name, pickDefined(options));
99
105
  }));
106
+ chromeDoc
107
+ .command('remove <name>')
108
+ .description('Remove a scaffolded top-level chrome document')
109
+ .option('-y, --yes', 'Skip confirmation')
110
+ .option('--dry-run', 'Show the chrome-doc removal plan without writing')
111
+ .action(withErrorHandling(async (name, options) => {
112
+ await furnaceChromeDocRemoveCommand(getProjectRoot(), name, pickDefined(options));
113
+ }));
100
114
  }
101
115
  /**
102
116
  * Registers Furnace commands for authoring, inspection, and maintenance:
@@ -493,6 +493,9 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
493
493
  }
494
494
  else if (freshType === 'override') {
495
495
  freshConfig.overrides = Object.fromEntries(Object.entries(freshConfig.overrides).filter(([key]) => key !== name));
496
+ if (!freshConfig.stock.includes(name)) {
497
+ freshConfig.stock.push(name);
498
+ }
496
499
  }
497
500
  else {
498
501
  freshConfig.custom = Object.fromEntries(Object.entries(freshConfig.custom).filter(([key]) => key !== name));
@@ -0,0 +1,2 @@
1
+ /** Rewrites scaffolded browser-chrome test literals after a component rename. */
2
+ export declare function updateBrowserChromeTestContent(content: string, oldName: string, newName: string, binaryName: string): string;
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { tagNameToClassName } from '../../core/furnace-constants.js';
3
+ /** Escapes regex metacharacters so a user-supplied name is literal inside a RegExp. */
4
+ function escapeRegex(input) {
5
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+ }
7
+ function deriveTestStem(componentName, binaryName) {
8
+ const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
9
+ const withoutBinaryPrefix = strippedName.startsWith(binaryName + '-')
10
+ ? strippedName.slice(binaryName.length + 1)
11
+ : strippedName;
12
+ return withoutBinaryPrefix.replace(/-/g, '_');
13
+ }
14
+ /** Rewrites scaffolded browser-chrome test literals after a component rename. */
15
+ export function updateBrowserChromeTestContent(content, oldName, newName, binaryName) {
16
+ const oldClassName = tagNameToClassName(oldName);
17
+ const newClassName = tagNameToClassName(newName);
18
+ const oldUnderscored = oldName.replace(/-/g, '_');
19
+ const newUnderscored = newName.replace(/-/g, '_');
20
+ const oldTestStem = deriveTestStem(oldName, binaryName);
21
+ const newTestStem = deriveTestStem(newName, binaryName);
22
+ return content
23
+ .replace(new RegExp(escapeRegex(oldName), 'g'), newName)
24
+ .replace(new RegExp(escapeRegex(oldClassName), 'g'), newClassName)
25
+ .replace(new RegExp(escapeRegex(oldUnderscored), 'g'), newUnderscored)
26
+ .replace(new RegExp(escapeRegex(oldTestStem), 'g'), newTestStem);
27
+ }
28
+ //# sourceMappingURL=rename-browser-test.js.map
@@ -14,6 +14,7 @@ import { FurnaceError } from '../../errors/furnace.js';
14
14
  import { toError } from '../../utils/errors.js';
15
15
  import { copyFile, ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
16
16
  import { info, intro, note, outro, warn } from '../../utils/logger.js';
17
+ import { updateBrowserChromeTestContent } from './rename-browser-test.js';
17
18
  import { renameComponentFileName, updateConfigForCustomRename, updateConfigForOverrideRename, } from './rename-helpers.js';
18
19
  import { renameXpcshellTestFiles } from './rename-xpcshell.js';
19
20
  /** Escapes regex metacharacters so a user-supplied name is literal inside a RegExp. */
@@ -58,7 +59,7 @@ async function renameTestFiles(engineDir, projectRoot, oldName, newName, journal
58
59
  try {
59
60
  await snapshotFile(journal, oldTestPath);
60
61
  const content = await readText(oldTestPath);
61
- await writeText(newTestPath, content);
62
+ await writeText(newTestPath, updateBrowserChromeTestContent(content, oldName, newName, binaryName));
62
63
  await removeFile(oldTestPath);
63
64
  info(`Renamed test file: ${oldTestFileName} → ${newTestFileName}`);
64
65
  }
@@ -221,7 +221,7 @@ function filterFireForgeTempFiles(files) {
221
221
  async function renderJsonStatus(files, paths, projectRoot, binaryName) {
222
222
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
223
223
  const classified = await classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
224
- const output = classified.map((f) => {
224
+ const outputFiles = classified.map((f) => {
225
225
  const entry = {
226
226
  file: f.file,
227
227
  status: f.status.trim(),
@@ -236,6 +236,24 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
236
236
  }
237
237
  return entry;
238
238
  });
239
+ const byClassification = {
240
+ unmanaged: 0,
241
+ 'patch-backed': 0,
242
+ branding: 0,
243
+ furnace: 0,
244
+ conflict: 0,
245
+ };
246
+ for (const file of outputFiles) {
247
+ byClassification[file.classification]++;
248
+ }
249
+ const output = {
250
+ schemaVersion: 1,
251
+ summary: {
252
+ total: outputFiles.length,
253
+ byClassification,
254
+ },
255
+ files: outputFiles,
256
+ };
239
257
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
240
258
  }
241
259
  /**
@@ -267,7 +285,8 @@ async function assertEngineHasBaselineCommit(engineDir, options) {
267
285
  // throw still falls through to the styled error renderer in
268
286
  // withErrorHandling, leaving JSON consumers with non-JSON output on
269
287
  // exactly the failure mode they care about catching.
270
- process.stdout.write(JSON.stringify({ error: guidance, code: 'engine-baseline-missing' }) + '\n');
288
+ process.stdout.write(JSON.stringify({ schemaVersion: 1, error: guidance, code: 'engine-baseline-missing' }) +
289
+ '\n');
271
290
  }
272
291
  throw new GeneralError(guidance);
273
292
  }
@@ -307,7 +326,7 @@ export async function statusCommand(projectRoot, options = {}) {
307
326
  // `withErrorHandling` does not log: bin/fireforge.ts catches it,
308
327
  // exits with the carried code, and stdout stays a single JSON line.
309
328
  const emitJsonError = (code, message) => {
310
- process.stdout.write(JSON.stringify({ error: message, code }) + '\n');
329
+ process.stdout.write(JSON.stringify({ schemaVersion: 1, error: message, code }) + '\n');
311
330
  throw new CommandError(ExitCode.GENERAL_ERROR);
312
331
  };
313
332
  // Ownership mode is a flat file→patch table; sources are the manifest's
@@ -201,8 +201,10 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
201
201
  // here so `test --doctor` against an incomplete build surfaces the
202
202
  // missing-bundle path instead of a cryptic `Browser process exited
203
203
  // during spawn (exit code 1, signal none). stderr tail: (empty)`.
204
+ let launchablePath;
204
205
  if (buildCheck.objDir) {
205
206
  const bundleCheck = await hasRunnableBundle(paths.engine, projectConfig.binaryName, buildCheck.objDir);
207
+ launchablePath = bundleCheck.expectedPath;
206
208
  if (!bundleCheck.runnable) {
207
209
  const expectedSuffix = bundleCheck.expectedPath
208
210
  ? ` (expected at engine/${bundleCheck.expectedPath})`
@@ -287,6 +289,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
287
289
  // (`info`/`warn`) is retained so TTY users keep the visual framing.
288
290
  const directLine = formatMarionettePreflightLine(preflight);
289
291
  process.stdout.write(`${directLine}\n`);
292
+ process.stdout.write(`Marionette preflight environment: objdir=${buildCheck.objDir ?? '(none)'}; binary=${projectConfig.binaryName}; app=${launchablePath ? `engine/${launchablePath}` : '(unknown)'}; port=${effectivePort ?? 2828}; elapsed=${preflight.durationMs}ms\n`);
290
293
  reportMarionettePreflight(preflight);
291
294
  if (testPaths.length === 0) {
292
295
  if (!preflight.ok) {
@@ -60,14 +60,21 @@ function buildWatchmanConfigureTimeMessage() {
60
60
  * @param watchmanPath - Optional absolute path to the resolved watchman binary; surfaced in the guidance so the operator can see whether FireForge actually found one.
61
61
  * @returns User-facing failure guidance
62
62
  */
63
- function buildUnsupportedWatchMessage(exitCode, watchmanPath) {
63
+ function hasWatchPermissionFailure(output) {
64
+ return /Operation not permitted|EPERM|EACCES/i.test(output);
65
+ }
66
+ function buildUnsupportedWatchMessage(exitCode, watchmanPath, output = '') {
64
67
  const watchmanLine = watchmanPath
65
68
  ? ` - FireForge resolved watchman at ${watchmanPath} and prepended its directory to the mach subprocess PATH. If mach still did not see it, ensure that path is stable between runs.\n`
66
69
  : '';
70
+ const permissionLine = hasWatchPermissionFailure(output)
71
+ ? ' - macOS may be blocking watchman or Terminal/Codex from reading the engine directory. Grant Full Disk Access or Files and Folders access to your terminal app and watchman, then restart watchman with "watchman shutdown-server".\n'
72
+ : '';
67
73
  return (`Watch failed with exit code ${exitCode}. Check the output above for details.\n\n` +
68
74
  'Common causes:\n' +
69
75
  ' - watchman is not installed or not in PATH right now\n' +
70
76
  ' - watchman was installed only after the current obj-* directory was configured; delete obj-* and rebuild\n' +
77
+ permissionLine +
71
78
  ' - mach watch is unsupported in the current objdir or build environment\n' +
72
79
  watchmanLine +
73
80
  '\n' +
@@ -194,7 +201,7 @@ export async function watchCommand(projectRoot) {
194
201
  throw new GeneralError(buildWatchmanConfigureTimeMessage());
195
202
  }
196
203
  // 130 is SIGINT (Ctrl+C), which is expected
197
- throw new BuildError(buildUnsupportedWatchMessage(result.exitCode, watchmanPath), 'mach watch');
204
+ throw new BuildError(buildUnsupportedWatchMessage(result.exitCode, watchmanPath, combinedOutput), 'mach watch');
198
205
  }
199
206
  outro('Watch mode stopped');
200
207
  }
@@ -195,6 +195,9 @@ export function validateFurnaceConfig(data) {
195
195
  if (migrated['tokenAllowlist'] !== undefined) {
196
196
  config.tokenAllowlist = parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
197
197
  }
198
+ if (migrated['platformPrefixes'] !== undefined) {
199
+ config.platformPrefixes = parseStringArray(migrated['platformPrefixes'], 'platformPrefixes');
200
+ }
198
201
  if (migrated['runtimeVariables'] !== undefined) {
199
202
  config.runtimeVariables = parseStringArray(migrated['runtimeVariables'], 'runtimeVariables');
200
203
  }
@@ -242,6 +245,7 @@ const PENDING_REPAIR_OPERATIONS = [
242
245
  'deploy-rollback',
243
246
  'remove-rollback',
244
247
  'create-rollback',
248
+ 'chrome-doc-rollback',
245
249
  'override-rollback',
246
250
  'scan-rollback',
247
251
  'rename-rollback',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -70,7 +70,7 @@
70
70
  "eslint-plugin-simple-import-sort": "^13.0.0",
71
71
  "fast-check": "^4.6.0",
72
72
  "husky": "^9.1.7",
73
- "lint-staged": "^16.2.7",
73
+ "lint-staged": "^17.0.4",
74
74
  "prettier": "^3.7.4",
75
75
  "tsx": "^4.7.0",
76
76
  "typescript": "~6.0.0",