@hominis/fireforge 0.20.0 → 0.21.1

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 (30) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +13 -4
  3. package/dist/src/commands/build.js +19 -1
  4. package/dist/src/commands/config.js +5 -0
  5. package/dist/src/commands/export-all.js +1 -0
  6. package/dist/src/commands/export-flow.d.ts +1 -0
  7. package/dist/src/commands/export-flow.js +21 -1
  8. package/dist/src/commands/export.js +1 -0
  9. package/dist/src/commands/furnace/chrome-doc-remove.d.ts +13 -0
  10. package/dist/src/commands/furnace/chrome-doc-remove.js +142 -0
  11. package/dist/src/commands/furnace/chrome-doc.d.ts +32 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +113 -1
  13. package/dist/src/commands/furnace/create-templates.js +10 -3
  14. package/dist/src/commands/furnace/create.js +1 -0
  15. package/dist/src/commands/furnace/deploy.js +1 -1
  16. package/dist/src/commands/furnace/index.js +14 -0
  17. package/dist/src/commands/furnace/remove.js +3 -0
  18. package/dist/src/commands/furnace/rename-browser-test.d.ts +2 -0
  19. package/dist/src/commands/furnace/rename-browser-test.js +28 -0
  20. package/dist/src/commands/furnace/rename.js +2 -1
  21. package/dist/src/commands/furnace/validation-output.d.ts +2 -2
  22. package/dist/src/commands/furnace/validation-output.js +20 -4
  23. package/dist/src/commands/status.js +22 -3
  24. package/dist/src/commands/test.js +3 -0
  25. package/dist/src/commands/watch.js +9 -2
  26. package/dist/src/core/furnace-config-order.d.ts +7 -0
  27. package/dist/src/core/furnace-config-order.js +86 -0
  28. package/dist/src/core/furnace-config.js +17 -1
  29. package/dist/src/core/furnace-validate.js +3 -0
  30. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
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
+ - **Eval 0.21.0 release-gate fixes.** `export --dry-run` now performs the same supersede and cross-patch ownership checks as real export before calling a plan safe; `furnace deploy --dry-run` validates successful custom-component plans against projected jar.mn registrations; generated Furnace components and browser-chrome test scaffolds are strict-checkJs and lazy-custom-element ready; chrome-doc packaging xpcshell tests no longer trip component-orphan validation; supported optional config keys such as `firefox.sha256` print `(not set)` when absent; and Furnace manifest writes preserve existing top-level/component ordering while appending new entries predictably.
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+ - **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.
17
+
18
+ ### Documentation
19
+
20
+ - **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.
21
+ - **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.
22
+
3
23
  ## 0.20.0
4
24
 
5
25
  ### 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}`);
@@ -99,6 +99,11 @@ export async function configCommand(projectRoot, key, value, options = {}) {
99
99
  const rawConfig = await loadRawConfigDocument(projectRoot);
100
100
  const currentValue = getNestedValue(rawConfig, key);
101
101
  if (currentValue === undefined) {
102
+ if (SUPPORTED_CONFIG_PATHS.includes(key)) {
103
+ info(`${key} = ${formatValue(currentValue)}`);
104
+ outro('');
105
+ return;
106
+ }
102
107
  throw new InvalidArgumentError(`Unknown config key: ${key}`);
103
108
  }
104
109
  else {
@@ -267,6 +267,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
267
267
  filesAffected,
268
268
  sourceEsrVersion: config.firefox.version,
269
269
  explicitSupersede: options.supersede === true,
270
+ allowOverlap: options.allowOverlap === true,
270
271
  });
271
272
  outro('Dry run complete — no changes made');
272
273
  return;
@@ -82,6 +82,7 @@ export interface DryRunPreviewInput {
82
82
  filesAffected: string[];
83
83
  sourceEsrVersion: string;
84
84
  explicitSupersede: boolean;
85
+ allowOverlap: boolean;
85
86
  /** Optional `PatchMetadata.tier` opt-in carried from the CLI. */
86
87
  tier?: 'branding';
87
88
  /** Optional `PatchMetadata.lintIgnore` carried from the CLI. */
@@ -13,10 +13,11 @@ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFi
13
13
  import { withPatchDirectoryLock } from '../core/patch-lock.js';
14
14
  import { addPatchToManifest, loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, } from '../core/patch-manifest.js';
15
15
  import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
16
- import { InvalidArgumentError } from '../errors/base.js';
16
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
17
17
  import { toError } from '../utils/errors.js';
18
18
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
19
19
  import { info, warn } from '../utils/logger.js';
20
+ import { findPartialOwnershipOverlap } from './export-shared.js';
20
21
  function buildFilenameForPlacement(category, name, order, width) {
21
22
  const padded = String(order).padStart(Math.max(3, width), '0');
22
23
  return `${padded}-${category}-${sanitizeName(name)}.patch`;
@@ -296,6 +297,11 @@ export async function commitPlacementExport(input) {
296
297
  */
297
298
  export async function renderDryRunPreview(input) {
298
299
  const supersedeDetails = await findAllPatchesForFilesWithDetails(input.patchesDir, input.filesAffected);
300
+ const supersedingFilenames = new Set(supersedeDetails.map((detail) => detail.patch.filename));
301
+ const manifest = await loadPatchesManifest(input.patchesDir);
302
+ const overlap = manifest !== null
303
+ ? findPartialOwnershipOverlap(manifest, input.filesAffected, supersedingFilenames)
304
+ : new Map();
299
305
  const plan = await planExport({
300
306
  patchesDir: input.patchesDir,
301
307
  category: input.category,
@@ -329,5 +335,19 @@ export async function renderDryRunPreview(input) {
329
335
  else {
330
336
  info('\n[dry-run] No patches would be superseded.');
331
337
  }
338
+ if (overlap.size > 0) {
339
+ const entries = [...overlap.entries()].sort(([a], [b]) => a.localeCompare(b));
340
+ warn(`\n[dry-run] Would create cross-patch ownership overlap on ${String(entries.length)} file${entries.length === 1 ? '' : 's'}:`);
341
+ for (const [file, owners] of entries) {
342
+ warn(` - ${file} already claimed by: ${owners.join(', ')}`);
343
+ }
344
+ warn('The real export would leave the queue verify-failing. Repartition ownership with `fireforge re-export --files <paths> <existing-patch>` before exporting, or pass --allow-overlap to acknowledge the conflict.');
345
+ if (!input.allowOverlap) {
346
+ throw new GeneralError('Dry-run detected cross-patch ownership overlap. Pass --allow-overlap to preview the acknowledged conflict, or repartition ownership via `fireforge re-export --files`.');
347
+ }
348
+ }
349
+ else {
350
+ info('[dry-run] No cross-patch ownership overlap detected.');
351
+ }
332
352
  }
333
353
  //# sourceMappingURL=export-flow.js.map
@@ -233,6 +233,7 @@ export async function exportCommand(projectRoot, files, options) {
233
233
  filesAffected,
234
234
  sourceEsrVersion: config.firefox.version,
235
235
  explicitSupersede: options.supersede === true,
236
+ allowOverlap: options.allowOverlap === true,
236
237
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
237
238
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
238
239
  ? { lintIgnore: options.lintIgnore }
@@ -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,
@@ -39,12 +39,18 @@ window.MozXULElement?.insertFTLIfNeeded("${ftlPath}");
39
39
  ? `
40
40
  connectedCallback() {
41
41
  super.connectedCallback();
42
- this.ownerDocument.l10n?.connectRoot(this.shadowRoot);
42
+ const { shadowRoot } = this;
43
+ if (shadowRoot) {
44
+ this.ownerDocument.l10n?.connectRoot(shadowRoot);
45
+ }
43
46
  }
44
47
 
45
48
  disconnectedCallback() {
46
49
  super.disconnectedCallback();
47
- this.ownerDocument.l10n?.disconnectRoot(this.shadowRoot);
50
+ const { shadowRoot } = this;
51
+ if (shadowRoot) {
52
+ this.ownerDocument.l10n?.disconnectRoot(shadowRoot);
53
+ }
48
54
  }
49
55
  `
50
56
  : '';
@@ -59,6 +65,7 @@ ${ftlModulePreamble}
59
65
  * @tagname ${name}
60
66
  */
61
67
  class ${className} extends MozLitElement {
68
+ /** @type {Record<string, unknown>} */
62
69
  static properties = {};
63
70
 
64
71
  constructor() {
@@ -72,7 +79,7 @@ ${lifecycleHooks}
72
79
  \`;
73
80
  }
74
81
  }
75
- customElements.define("${name}", ${className});
82
+ customElements.define("${name}", /** @type {CustomElementConstructor} */ (${className}));
76
83
  `;
77
84
  }
78
85
  /** Generates the .css file content for a custom component. */
@@ -111,6 +111,7 @@ support-files = ["head.js"]
111
111
  * @returns {Promise<CustomElementConstructor>}
112
112
  */
113
113
  async function waitForElement(tag) {
114
+ document.createElement(tag);
114
115
  return customElements.whenDefined(tag);
115
116
  }
116
117
  `;
@@ -364,7 +364,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
364
364
  }
365
365
  const validateSpinner = spinner(isDryRun ? 'Validating (read-only)...' : 'Validating...');
366
366
  const failedComponents = getFailedComponentNames(result);
367
- const validation = await runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot);
367
+ const validation = await runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, result.actions);
368
368
  if (validation.done)
369
369
  return;
370
370
  const { totalErrors, totalWarnings, componentCount, skippedValidationCount } = validation;
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import type { getFurnacePaths } from '../../core/furnace-config.js';
2
- import type { FurnaceConfig, ValidationIssue } from '../../types/furnace.js';
2
+ import type { DryRunAction, FurnaceConfig, ValidationIssue } from '../../types/furnace.js';
3
3
  import { type SpinnerHandle } from '../../utils/logger.js';
4
4
  /**
5
5
  * Displays validation issues and returns aggregated error and warning counts.
@@ -27,4 +27,4 @@ export type ValidationResult = {
27
27
  * @param projectRoot - Root directory of the project
28
28
  * @returns Validation counts, or `done: true` if the caller should early-return
29
29
  */
30
- export declare function runDeployValidation(validateSpinner: SpinnerHandle, name: string | undefined, config: FurnaceConfig, furnacePaths: ReturnType<typeof getFurnacePaths>, failedComponents: Set<string>, isDryRun: boolean, projectRoot: string): Promise<ValidationResult>;
30
+ export declare function runDeployValidation(validateSpinner: SpinnerHandle, name: string | undefined, config: FurnaceConfig, furnacePaths: ReturnType<typeof getFurnacePaths>, failedComponents: Set<string>, isDryRun: boolean, projectRoot: string, dryRunActions?: DryRunAction[]): Promise<ValidationResult>;
@@ -24,6 +24,18 @@ export function displayValidationIssues(issues) {
24
24
  }
25
25
  return [errors, warnings];
26
26
  }
27
+ function filterProjectedDryRunIssues(issues, actions) {
28
+ if (!actions || actions.length === 0)
29
+ return issues;
30
+ const plannedJarRegistrations = new Set(actions.filter((action) => action.action === 'register-jar').map((action) => action.component));
31
+ return issues.filter((issue) => {
32
+ if (plannedJarRegistrations.has(issue.component) &&
33
+ (issue.check === 'missing-jar-mn-mjs' || issue.check === 'missing-jar-mn-css')) {
34
+ return false;
35
+ }
36
+ return true;
37
+ });
38
+ }
27
39
  function resolveNamedValidationTarget(name, config, furnacePaths) {
28
40
  if (name in config.overrides) {
29
41
  return {
@@ -53,7 +65,7 @@ function resolveNamedValidationTarget(name, config, furnacePaths) {
53
65
  * @param projectRoot - Root directory of the project
54
66
  * @returns Validation counts, or `done: true` if the caller should early-return
55
67
  */
56
- export async function runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot) {
68
+ export async function runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, dryRunActions) {
57
69
  let totalErrors = 0;
58
70
  let totalWarnings = 0;
59
71
  let componentCount = 0;
@@ -75,7 +87,8 @@ export async function runDeployValidation(validateSpinner, name, config, furnace
75
87
  validateSpinner.stop('Validation failed');
76
88
  throw new FurnaceError(`Component directory not found for "${name}".`, name);
77
89
  }
78
- const issues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
90
+ const rawIssues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
91
+ const issues = isDryRun ? filterProjectedDryRunIssues(rawIssues, dryRunActions) : rawIssues;
79
92
  componentCount = 1;
80
93
  validateSpinner.stop('Validation complete');
81
94
  if (issues.length === 0) {
@@ -96,11 +109,14 @@ export async function runDeployValidation(validateSpinner, name, config, furnace
96
109
  continue;
97
110
  }
98
111
  componentCount++;
99
- if (issues.length === 0) {
112
+ const projectedIssues = isDryRun
113
+ ? filterProjectedDryRunIssues(issues, dryRunActions)
114
+ : issues;
115
+ if (projectedIssues.length === 0) {
100
116
  success(`${componentName} — all checks passed`);
101
117
  }
102
118
  else {
103
- const [errors, warnings] = displayValidationIssues(issues);
119
+ const [errors, warnings] = displayValidationIssues(projectedIssues);
104
120
  totalErrors += errors;
105
121
  totalWarnings += warnings;
106
122
  }
@@ -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
  }
@@ -0,0 +1,7 @@
1
+ import type { FurnaceConfig } from '../types/furnace.js';
2
+ /**
3
+ * Orders furnace.json output using the existing file as the primary key
4
+ * sequence, preserving unknown extension keys and appending newly supported
5
+ * fields only when needed.
6
+ */
7
+ export declare function orderFurnaceConfigForWrite(existing: Record<string, unknown> | undefined, config: FurnaceConfig): Record<string, unknown>;
@@ -0,0 +1,86 @@
1
+ import { isObject } from '../utils/validation.js';
2
+ const FURNACE_CONFIG_TOP_LEVEL_KEYS = new Set([
3
+ 'version',
4
+ 'componentPrefix',
5
+ 'tokenPrefix',
6
+ 'tokenAllowlist',
7
+ 'platformPrefixes',
8
+ 'runtimeVariables',
9
+ 'tokenHostDocuments',
10
+ 'ftlBasePath',
11
+ 'scanPaths',
12
+ 'stock',
13
+ 'overrides',
14
+ 'custom',
15
+ ]);
16
+ function orderObjectLikeExisting(existing, next) {
17
+ if (!existing)
18
+ return next;
19
+ const ordered = {};
20
+ for (const key of Object.keys(existing)) {
21
+ if (Object.hasOwn(next, key)) {
22
+ ordered[key] = next[key];
23
+ }
24
+ }
25
+ for (const key of Object.keys(next)) {
26
+ if (!Object.hasOwn(ordered, key)) {
27
+ ordered[key] = next[key];
28
+ }
29
+ }
30
+ return ordered;
31
+ }
32
+ function orderComponentMapLikeExisting(existing, next) {
33
+ if (!isObject(next))
34
+ return next;
35
+ if (!isObject(existing))
36
+ return next;
37
+ const ordered = {};
38
+ for (const key of Object.keys(existing)) {
39
+ if (Object.hasOwn(next, key)) {
40
+ const existingValue = existing[key];
41
+ const nextValue = next[key];
42
+ ordered[key] =
43
+ isObject(existingValue) && isObject(nextValue)
44
+ ? orderObjectLikeExisting(existingValue, nextValue)
45
+ : nextValue;
46
+ }
47
+ }
48
+ for (const key of Object.keys(next)) {
49
+ if (!Object.hasOwn(ordered, key)) {
50
+ ordered[key] = next[key];
51
+ }
52
+ }
53
+ return ordered;
54
+ }
55
+ /**
56
+ * Orders furnace.json output using the existing file as the primary key
57
+ * sequence, preserving unknown extension keys and appending newly supported
58
+ * fields only when needed.
59
+ */
60
+ export function orderFurnaceConfigForWrite(existing, config) {
61
+ const next = config;
62
+ if (!existing)
63
+ return next;
64
+ const ordered = {};
65
+ for (const key of Object.keys(existing)) {
66
+ if (key === 'overrides' || key === 'custom') {
67
+ ordered[key] = orderComponentMapLikeExisting(existing[key], next[key]);
68
+ }
69
+ else if (Object.hasOwn(next, key)) {
70
+ ordered[key] = next[key];
71
+ }
72
+ else if (!FURNACE_CONFIG_TOP_LEVEL_KEYS.has(key)) {
73
+ ordered[key] = existing[key];
74
+ }
75
+ }
76
+ for (const key of Object.keys(next)) {
77
+ if (Object.hasOwn(ordered, key))
78
+ continue;
79
+ ordered[key] =
80
+ key === 'overrides' || key === 'custom'
81
+ ? orderComponentMapLikeExisting(existing[key], next[key])
82
+ : next[key];
83
+ }
84
+ return ordered;
85
+ }
86
+ //# sourceMappingURL=furnace-config-order.js.map
@@ -8,6 +8,7 @@ import { isObject, isString } from '../utils/validation.js';
8
8
  import { FIREFORGE_DIR } from './config.js';
9
9
  import { parseStringArray } from './furnace-config-array-utils.js';
10
10
  import { parseCustomConfig } from './furnace-config-custom.js';
11
+ import { orderFurnaceConfigForWrite } from './furnace-config-order.js';
11
12
  import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
12
13
  import { resolveFtlDir } from './furnace-constants.js';
13
14
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
@@ -195,6 +196,9 @@ export function validateFurnaceConfig(data) {
195
196
  if (migrated['tokenAllowlist'] !== undefined) {
196
197
  config.tokenAllowlist = parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
197
198
  }
199
+ if (migrated['platformPrefixes'] !== undefined) {
200
+ config.platformPrefixes = parseStringArray(migrated['platformPrefixes'], 'platformPrefixes');
201
+ }
198
202
  if (migrated['runtimeVariables'] !== undefined) {
199
203
  config.runtimeVariables = parseStringArray(migrated['runtimeVariables'], 'runtimeVariables');
200
204
  }
@@ -242,6 +246,7 @@ const PENDING_REPAIR_OPERATIONS = [
242
246
  'deploy-rollback',
243
247
  'remove-rollback',
244
248
  'create-rollback',
249
+ 'chrome-doc-rollback',
245
250
  'override-rollback',
246
251
  'scan-rollback',
247
252
  'rename-rollback',
@@ -406,7 +411,18 @@ export async function loadFurnaceConfig(root) {
406
411
  */
407
412
  export async function writeFurnaceConfig(root, config) {
408
413
  const paths = getFurnacePaths(root);
409
- await writeJson(paths.furnaceConfig, config);
414
+ let existing;
415
+ if (await pathExists(paths.furnaceConfig)) {
416
+ try {
417
+ const raw = await readJson(paths.furnaceConfig);
418
+ if (isObject(raw))
419
+ existing = raw;
420
+ }
421
+ catch {
422
+ existing = undefined;
423
+ }
424
+ }
425
+ await writeJson(paths.furnaceConfig, orderFurnaceConfigForWrite(existing, config));
410
426
  }
411
427
  /**
412
428
  * Stamps every override's `baseVersion` to the supplied version. Used by
@@ -233,6 +233,9 @@ async function findOrphanXpcshellScaffolds(root, config) {
233
233
  for (const entry of entries) {
234
234
  if (known.has(entry))
235
235
  continue;
236
+ const chromeDocPackagingTest = join(parentAbs, entry, `test_${entry}_packaging.js`);
237
+ if (await pathExists(chromeDocPackagingTest))
238
+ continue;
236
239
  issues.push({
237
240
  component: entry,
238
241
  severity: 'error',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
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",