@hominis/fireforge 0.15.9 → 0.16.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 (59) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +6 -2
  3. package/dist/src/cli.d.ts +4 -1
  4. package/dist/src/cli.js +6 -3
  5. package/dist/src/commands/config.js +16 -5
  6. package/dist/src/commands/download.js +31 -4
  7. package/dist/src/commands/export-all.js +96 -9
  8. package/dist/src/commands/export.js +10 -1
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
  11. package/dist/src/commands/furnace/create.js +21 -3
  12. package/dist/src/commands/furnace/diff.js +22 -2
  13. package/dist/src/commands/furnace/index.js +1 -0
  14. package/dist/src/commands/furnace/init.js +76 -2
  15. package/dist/src/commands/furnace/override.js +35 -12
  16. package/dist/src/commands/furnace/preview.js +46 -1
  17. package/dist/src/commands/furnace/rename.js +14 -3
  18. package/dist/src/commands/lint.js +26 -2
  19. package/dist/src/commands/package.js +16 -5
  20. package/dist/src/commands/re-export.js +25 -0
  21. package/dist/src/commands/rebase/patch-loop.js +19 -0
  22. package/dist/src/commands/register.js +2 -18
  23. package/dist/src/commands/run.js +23 -2
  24. package/dist/src/commands/status.js +42 -8
  25. package/dist/src/commands/test.js +6 -24
  26. package/dist/src/commands/token.js +14 -1
  27. package/dist/src/commands/watch.js +14 -2
  28. package/dist/src/commands/wire.js +35 -9
  29. package/dist/src/core/branding.d.ts +23 -0
  30. package/dist/src/core/branding.js +39 -0
  31. package/dist/src/core/browser-wire.js +68 -23
  32. package/dist/src/core/build-baseline.d.ts +14 -0
  33. package/dist/src/core/build-baseline.js +61 -1
  34. package/dist/src/core/config-mutate.d.ts +1 -1
  35. package/dist/src/core/config.d.ts +17 -0
  36. package/dist/src/core/config.js +35 -0
  37. package/dist/src/core/firefox.d.ts +16 -2
  38. package/dist/src/core/firefox.js +7 -2
  39. package/dist/src/core/furnace-config.d.ts +23 -0
  40. package/dist/src/core/furnace-config.js +38 -0
  41. package/dist/src/core/mach-build-artifacts.d.ts +41 -0
  42. package/dist/src/core/mach-build-artifacts.js +70 -0
  43. package/dist/src/core/mach-error-hints.js +38 -0
  44. package/dist/src/core/mach-mozconfig.d.ts +25 -0
  45. package/dist/src/core/mach-mozconfig.js +66 -0
  46. package/dist/src/core/mach.d.ts +12 -1
  47. package/dist/src/core/mach.js +14 -1
  48. package/dist/src/core/manifest-rules.js +22 -1
  49. package/dist/src/core/patch-lint.js +43 -20
  50. package/dist/src/core/test-stale-check.js +46 -1
  51. package/dist/src/core/token-manager.js +57 -4
  52. package/dist/src/core/token-scaffold.d.ts +36 -0
  53. package/dist/src/core/token-scaffold.js +74 -0
  54. package/dist/src/types/commands/options.d.ts +10 -0
  55. package/dist/src/utils/fs.d.ts +12 -0
  56. package/dist/src/utils/fs.js +12 -0
  57. package/dist/src/utils/paths.d.ts +19 -0
  58. package/dist/src/utils/paths.js +33 -0
  59. package/package.json +1 -1
@@ -12,7 +12,7 @@ import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/pa
12
12
  import { loadPatchesManifest } from '../core/patch-manifest.js';
13
13
  import { GeneralError } from '../errors/base.js';
14
14
  import { toError } from '../utils/errors.js';
15
- import { pathExists, readText } from '../utils/fs.js';
15
+ import { FIREFORGE_TMP_PATH_PATTERN, pathExists, readText } from '../utils/fs.js';
16
16
  import { info, intro, outro, verbose, warn } from '../utils/logger.js';
17
17
  /**
18
18
  * Status code descriptions for git status.
@@ -164,6 +164,21 @@ async function expandDirectoryEntries(files, engineDir) {
164
164
  }
165
165
  return { entries: expanded, truncations };
166
166
  }
167
+ /**
168
+ * Strips entries whose path matches the atomic-temp-file shape
169
+ * FireForge's own `writeText` produces (see
170
+ * {@link import('../utils/fs.js').FIREFORGE_TMP_PATH_PATTERN}). Those
171
+ * files only exist for the duration of a write + rename and should
172
+ * never appear in `status` output; filtering them here keeps every
173
+ * status mode (default, raw, unmanaged, ownership, json) symmetric so
174
+ * the operator never sees a `.mozconfig.fireforge-tmp-<pid>-<uuid>`
175
+ * entry mid-write. Files named for unrelated reasons (e.g. a user's
176
+ * `.bashrc.fireforge-tmp-backup` without the PID+UUID tail) do not
177
+ * match the pattern and pass through unfiltered.
178
+ */
179
+ function filterFireForgeTempFiles(files) {
180
+ return files.filter((entry) => !FIREFORGE_TMP_PATH_PATTERN.test(entry.file));
181
+ }
167
182
  /**
168
183
  * Classifies files into patch-backed, unmanaged, or branding buckets.
169
184
  */
@@ -277,7 +292,11 @@ export async function statusCommand(projectRoot, options = {}) {
277
292
  const ownershipExpansion = (await isGitRepository(paths.engine))
278
293
  ? await expandDirectoryEntries(await getStatusWithCodes(paths.engine), paths.engine)
279
294
  : { entries: [], truncations: [] };
280
- const rawFilesOwnership = ownershipExpansion.entries;
295
+ // Filter atomic-write temp files (Finding #18) so a mid-flight
296
+ // `.fireforge-tmp-<pid>-<uuid>` artefact never shows up in any
297
+ // status mode. The pattern is tight enough to let legitimately
298
+ // similar names through.
299
+ const rawFilesOwnership = filterFireForgeTempFiles(ownershipExpansion.entries);
281
300
  renderTruncationBanner(ownershipExpansion.truncations);
282
301
  // Only walk the patch bodies when the directory actually exists.
283
302
  // Fresh projects with no patch queue yet pass through with an empty
@@ -313,8 +332,28 @@ export async function statusCommand(projectRoot, options = {}) {
313
332
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
314
333
  }
315
334
  const rawFiles = await getStatusWithCodes(paths.engine);
316
- const { entries: files, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
335
+ const { entries: expanded, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
336
+ // Strip atomic-write temp files (Finding #18) before every mode
337
+ // branch so raw / unmanaged / default / json all agree.
338
+ const files = filterFireForgeTempFiles(expanded);
317
339
  renderTruncationBanner(truncations);
340
+ // `--json` callers expect machine-parseable output on every invocation,
341
+ // including the clean-tree case. Before this ordering fix a clean tree
342
+ // printed "No modified files" / "Working tree clean" via the human
343
+ // branch below and `--json` was silently ignored, so scripts that piped
344
+ // the output through a JSON parser broke precisely when there was
345
+ // nothing to report. Emit `[]` here and return before the human fallback.
346
+ if (options.json) {
347
+ await renderJsonStatus(files, paths, projectRoot, config.binaryName);
348
+ return;
349
+ }
350
+ // `--raw` consumers parse the native `git status --porcelain` output
351
+ // directly. On a clean tree the raw mode should produce nothing on
352
+ // stdout — the human "Working tree clean" banner would contaminate the
353
+ // pipe. Short-circuit before the human clean-tree branch below.
354
+ if (options.raw && files.length === 0) {
355
+ return;
356
+ }
318
357
  if (files.length === 0) {
319
358
  info('No modified files');
320
359
  outro('Working tree clean');
@@ -325,11 +364,6 @@ export async function statusCommand(projectRoot, options = {}) {
325
364
  renderRawStatus(files);
326
365
  return;
327
366
  }
328
- // JSON mode and default mode both need classification
329
- if (options.json) {
330
- await renderJsonStatus(files, paths, projectRoot, config.binaryName);
331
- return;
332
- }
333
367
  // Patch-aware classification
334
368
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
335
369
  const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
@@ -11,28 +11,7 @@ import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, intro, spinner, warn } from '../utils/logger.js';
13
13
  import { pickDefined } from '../utils/options.js';
14
- /**
15
- * Strips a leading "engine/" or "engine\\" prefix from a path if present.
16
- * Users may specify paths like "engine/browser/modules/..." from the project
17
- * root, but mach test expects paths relative to the engine directory.
18
- *
19
- * The match is case-insensitive because case-insensitive filesystems
20
- * (default macOS, Windows) treat "Engine/" and "engine/" as the same
21
- * directory, and a literal lowercase-only check left mach with a
22
- * non-stripped prefix that resolved to a different path under the engine
23
- * tree. Tab and other whitespace before the prefix is also ignored.
24
- *
25
- * @param testPath - Path as provided by the user
26
- * @returns Path relative to the engine directory
27
- */
28
- function normalizeTestPath(testPath) {
29
- const trimmed = testPath.trim();
30
- const match = /^engine[/\\]/i.exec(trimmed);
31
- if (match) {
32
- return trimmed.slice(match[0].length);
33
- }
34
- return trimmed;
35
- }
14
+ import { stripEnginePrefix } from '../utils/paths.js';
36
15
  async function assertTestPathsExist(engineDir, testPaths) {
37
16
  const missingPaths = [];
38
17
  for (const testPath of testPaths) {
@@ -198,8 +177,11 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
198
177
  throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
199
178
  }
200
179
  }
201
- // Normalize test paths (strip engine/ prefix if present)
202
- const normalizedPaths = testPaths.map(normalizeTestPath);
180
+ // Normalize test paths (strip engine/ prefix if present). Uses the
181
+ // shared `stripEnginePrefix` helper so `test`, `register`, `lint`, and
182
+ // `export` all accept the same prefix forms. Also trim to match the
183
+ // previous case-insensitive + leading-whitespace-tolerant contract.
184
+ const normalizedPaths = testPaths.map((p) => stripEnginePrefix(p).trim());
203
185
  await assertTestPathsExist(paths.engine, normalizedPaths);
204
186
  // Build extra args
205
187
  const extraArgs = [];
@@ -1,9 +1,10 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { Option } from 'commander';
3
3
  import { loadConfig } from '../core/config.js';
4
- import { loadFurnaceConfig } from '../core/furnace-config.js';
4
+ import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
5
5
  import { addToken, getTokensCssPath, validateTokenAdd, } from '../core/token-manager.js';
6
6
  import { InvalidArgumentError } from '../errors/base.js';
7
+ import { FurnaceError } from '../errors/furnace.js';
7
8
  import { toError } from '../utils/errors.js';
8
9
  import { info, intro, outro, success, warn } from '../utils/logger.js';
9
10
  import { pickDefined } from '../utils/options.js';
@@ -36,6 +37,18 @@ async function normalizeTokenNameForProject(projectRoot, rawTokenName) {
36
37
  */
37
38
  export async function tokenAddCommand(projectRoot, tokenName, value, options) {
38
39
  intro('Token Add');
40
+ // Finding #15: a fresh project without furnace.json failed deep inside
41
+ // the token-manager's `assertTokenCategoryExists` with "Token CSS file
42
+ // not found: browser/themes/shared/<binary>-tokens.css" — technically
43
+ // correct, but the operator's actual next step is to initialize
44
+ // Furnace (which scaffolds the tokens CSS file among other things).
45
+ // Catching the uninitialized case here gives the right guidance up-
46
+ // front before the generic "file not found" error fires.
47
+ if (!(await furnaceConfigExists(projectRoot))) {
48
+ throw new FurnaceError('Token management requires Furnace to be initialized. ' +
49
+ 'Tokens live in the Furnace-managed tokens CSS file, which `fireforge furnace init` scaffolds alongside the rest of the Furnace workspace.\n\n' +
50
+ 'Run "fireforge furnace init" first, then rerun "fireforge token add ...".');
51
+ }
39
52
  // Normalize token name using the configured Furnace token prefix when the
40
53
  // user supplied a bare token name like "canvas-gap".
41
54
  tokenName = await normalizeTokenNameForProject(projectRoot, tokenName);
@@ -1,6 +1,6 @@
1
1
  import { getProjectPaths, loadConfig } from '../core/config.js';
2
2
  import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
3
- import { buildArtifactMismatchMessage, generateMozconfig, hasBuildArtifacts, watchWithOutput, } from '../core/mach.js';
3
+ import { buildArtifactMismatchMessage, generateMozconfig, hasBuildArtifacts, hasRunnableBundle, watchWithOutput, } from '../core/mach.js';
4
4
  import { GeneralError } from '../errors/base.js';
5
5
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
6
6
  import { toError } from '../utils/errors.js';
@@ -114,7 +114,19 @@ export async function watchCommand(projectRoot) {
114
114
  throw new GeneralError(`Watch mode requires a completed build. ${detail}\n\n` +
115
115
  "Run 'fireforge build' first to create the initial build, then run 'fireforge watch'.");
116
116
  }
117
- info(`Using build artifacts from ${buildCheck.objDir}/`);
117
+ // Report bundle state alongside the "Using build artifacts..." banner
118
+ // so an operator watching a mid-build tree can see why `fireforge run`
119
+ // would refuse right now while watch is still going. Watch remains
120
+ // permissive (it exists to drive rebuilds) — this is informational.
121
+ // The `hasBuildArtifacts` check already passed at this point, so
122
+ // `objDir` is always defined.
123
+ const bundleCheck = buildCheck.objDir
124
+ ? await hasRunnableBundle(paths.engine, config.binaryName, buildCheck.objDir)
125
+ : { runnable: false };
126
+ const bundleSuffix = bundleCheck.runnable
127
+ ? ' (bundle: runnable)'
128
+ : ' (bundle: pending — watch will rebuild)';
129
+ info(`Using build artifacts from ${buildCheck.objDir}/${bundleSuffix}`);
118
130
  // Advisory: warn when Furnace components have drifted since the last
119
131
  // apply so the user doesn't launch watch-mode builds with stale
120
132
  // components baked in. Mirrors the check in `fireforge run` — without
@@ -10,7 +10,7 @@ import { toError } from '../utils/errors.js';
10
10
  import { pathExists } from '../utils/fs.js';
11
11
  import { info, intro, outro, success, warn } from '../utils/logger.js';
12
12
  import { pickDefined } from '../utils/options.js';
13
- import { isContainedRelativePath, isPathInsideRoot, toRootRelativePath } from '../utils/paths.js';
13
+ import { isContainedRelativePath, isExplicitAbsolutePath, isPathInsideRoot, stripEnginePrefix, toRootRelativePath, } from '../utils/paths.js';
14
14
  const BROWSER_BASE_DIR = 'browser/base';
15
15
  function printWireDryRun(engineDir, name, subscriptDir, domFilePath, domTargetPath, options) {
16
16
  info('[dry-run] Would wire subscript:');
@@ -113,25 +113,51 @@ export async function wireCommand(projectRoot, name, options = {}) {
113
113
  }
114
114
  subscriptDir = options.subscriptDir;
115
115
  }
116
- // Validate DOM fragment file exists and compute path relative to engine root
116
+ // Validate DOM fragment file exists and compute path relative to engine root.
117
+ //
118
+ // Accepts three shapes:
119
+ // - Absolute paths (`/project/engine/browser/base/content/foo.inc.xhtml`)
120
+ // - Repo-root-relative forms (`engine/browser/base/content/foo.inc.xhtml`)
121
+ // - Engine-relative forms (`browser/base/content/foo.inc.xhtml`)
122
+ //
123
+ // Before the engine-prefix normalization, passing an `engine/…`-prefixed
124
+ // relative path from the repo root double-rooted through
125
+ // `toRootRelativePath(engineDir, …)` — `resolve(engineDir, 'engine/…')`
126
+ // landed at `engineDir/engine/…`, which is still "inside" engineDir but
127
+ // named as a second-level `engine/…` entry. The computed `#include`
128
+ // then read `../../../engine/browser/base/content/foo.inc.xhtml`,
129
+ // packaging-breaking nonsense. For absolute inputs this pre-existing
130
+ // contract was fine — `toRootRelativePath` handles absolute candidates
131
+ // correctly — so we only strip the prefix when the input is relative.
117
132
  let domFilePath;
118
133
  if (options.dom) {
119
134
  const paths = getProjectPaths(projectRoot);
120
- if (!(await pathExists(options.dom))) {
135
+ const domCandidate = isExplicitAbsolutePath(options.dom)
136
+ ? options.dom
137
+ : stripEnginePrefix(options.dom);
138
+ if (!(await pathExists(domCandidate))) {
121
139
  throw new InvalidArgumentError(`DOM fragment file not found: ${options.dom}`, 'dom');
122
140
  }
123
- if (!isPathInsideRoot(paths.engine, options.dom)) {
141
+ if (!isPathInsideRoot(paths.engine, domCandidate)) {
124
142
  throw new InvalidArgumentError(`DOM fragment file must stay within engine/: ${options.dom}`, 'dom');
125
143
  }
126
- domFilePath = toRootRelativePath(paths.engine, options.dom);
144
+ domFilePath = toRootRelativePath(paths.engine, domCandidate);
127
145
  }
128
146
  // Resolve the chrome document the `#include` directive will land in.
129
147
  // Only consulted when `--dom` is supplied — we still resolve it here so
130
148
  // the dry-run plan can print the target accurately.
131
- if (options.target !== undefined && !isContainedRelativePath(options.target)) {
132
- throw new InvalidArgumentError(`Target chrome document must stay within engine/: ${options.target}`, 'target');
133
- }
134
- const domTargetPath = await resolveDomTargetPath(projectRoot, options.target);
149
+ //
150
+ // `stripEnginePrefix` is applied so `--target engine/browser/base/browser.xhtml`
151
+ // and `--target browser/base/browser.xhtml` are treated identically,
152
+ // matching the `--dom` normalization above. Absolute `--target` paths
153
+ // stay absolute (the containment check downstream rejects them).
154
+ const normalizedTarget = options.target !== undefined && !isExplicitAbsolutePath(options.target)
155
+ ? stripEnginePrefix(options.target)
156
+ : options.target;
157
+ if (normalizedTarget !== undefined && !isContainedRelativePath(normalizedTarget)) {
158
+ throw new InvalidArgumentError(`Target chrome document must stay within engine/: ${options.target ?? ''}`, 'target');
159
+ }
160
+ const domTargetPath = await resolveDomTargetPath(projectRoot, normalizedTarget);
135
161
  if (domFilePath) {
136
162
  const paths = getProjectPaths(projectRoot);
137
163
  if (!options.dryRun && !(await pathExists(join(paths.engine, domTargetPath)))) {
@@ -6,6 +6,29 @@ export declare class BrandingError extends FireForgeError {
6
6
  readonly code: 6;
7
7
  get userMessage(): string;
8
8
  }
9
+ /**
10
+ * Error thrown when the generated `mozconfig` references a `--with-branding`
11
+ * directory that does not match the branding tree FireForge set up. The
12
+ * mismatch is a silent-corruption hazard — `mach configure` picks the value
13
+ * from mozconfig but the scaffolded branding lives elsewhere, so the build
14
+ * fails deep inside moz.build resolution with a confusing "path does not
15
+ * exist" message. Surface it as an actionable preflight instead.
16
+ *
17
+ * The root cause is that setup renders templates under `configs/` with
18
+ * `${binaryName}` baked in at setup time; a subsequent edit to
19
+ * `fireforge.json`'s `binaryName` (or a re-setup without re-templating)
20
+ * leaves those baked-in names stale while `setupBranding` continues to use
21
+ * the current `config.binaryName`. Both directions (mozconfig ahead of
22
+ * config, config ahead of mozconfig) produce the same class of build break.
23
+ */
24
+ export declare class BrandingMozconfigMismatchError extends FireForgeError {
25
+ readonly expectedBrandingDir: string;
26
+ readonly mozconfigBrandingDir: string;
27
+ readonly reason: 'mozconfig-missing-branding' | 'name-mismatch' | 'branding-dir-missing';
28
+ readonly code: 6;
29
+ constructor(expectedBrandingDir: string, mozconfigBrandingDir: string, reason: 'mozconfig-missing-branding' | 'name-mismatch' | 'branding-dir-missing');
30
+ get userMessage(): string;
31
+ }
9
32
  /**
10
33
  * Full branding configuration.
11
34
  */
@@ -13,6 +13,45 @@ export class BrandingError extends FireForgeError {
13
13
  return `Branding Error: ${this.message}\n\nBranding is required to set MOZ_APP_VENDOR, MOZ_MACBUNDLE_ID, and other Firefox identity values.`;
14
14
  }
15
15
  }
16
+ /**
17
+ * Error thrown when the generated `mozconfig` references a `--with-branding`
18
+ * directory that does not match the branding tree FireForge set up. The
19
+ * mismatch is a silent-corruption hazard — `mach configure` picks the value
20
+ * from mozconfig but the scaffolded branding lives elsewhere, so the build
21
+ * fails deep inside moz.build resolution with a confusing "path does not
22
+ * exist" message. Surface it as an actionable preflight instead.
23
+ *
24
+ * The root cause is that setup renders templates under `configs/` with
25
+ * `${binaryName}` baked in at setup time; a subsequent edit to
26
+ * `fireforge.json`'s `binaryName` (or a re-setup without re-templating)
27
+ * leaves those baked-in names stale while `setupBranding` continues to use
28
+ * the current `config.binaryName`. Both directions (mozconfig ahead of
29
+ * config, config ahead of mozconfig) produce the same class of build break.
30
+ */
31
+ export class BrandingMozconfigMismatchError extends FireForgeError {
32
+ expectedBrandingDir;
33
+ mozconfigBrandingDir;
34
+ reason;
35
+ code = ExitCode.PATCH_ERROR;
36
+ constructor(expectedBrandingDir, mozconfigBrandingDir, reason) {
37
+ super(`Generated mozconfig references "${mozconfigBrandingDir}" but the active branding directory is "${expectedBrandingDir}".`);
38
+ this.expectedBrandingDir = expectedBrandingDir;
39
+ this.mozconfigBrandingDir = mozconfigBrandingDir;
40
+ this.reason = reason;
41
+ }
42
+ get userMessage() {
43
+ const diagnosis = this.reason === 'mozconfig-missing-branding'
44
+ ? `The generated mozconfig does not contain a --with-branding directive (found "${this.mozconfigBrandingDir}"). FireForge expected to write one for binaryName "${this.expectedBrandingDir}".`
45
+ : this.reason === 'name-mismatch'
46
+ ? `The generated mozconfig sets --with-branding="${this.mozconfigBrandingDir}" but FireForge set up branding under "${this.expectedBrandingDir}".`
47
+ : `The generated mozconfig sets --with-branding="${this.mozconfigBrandingDir}" but no moz.build exists under engine/${this.mozconfigBrandingDir}/.`;
48
+ return (`Branding Error: ${diagnosis}\n\n` +
49
+ 'This usually means the rendered configs/ templates drifted from fireforge.json. Fix one of:\n' +
50
+ ' 1. Edit configs/common.mozconfig so --with-branding uses ${binaryName} (or the current binaryName), then re-run "fireforge build".\n' +
51
+ ' 2. Update fireforge.json so binaryName matches the --with-branding value baked into configs/.\n\n' +
52
+ 'The mismatch is caught before mach builds because resolving the build against the wrong branding tree fails deep in moz.build with a confusing "path does not exist" message.');
53
+ }
54
+ }
16
55
  /**
17
56
  * Sets up the custom branding directory for the browser.
18
57
  *
@@ -1,8 +1,12 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join, relative } from 'node:path';
3
+ import { GeneralError } from '../errors/base.js';
4
+ import { toError } from '../utils/errors.js';
3
5
  import { toRootRelativePath } from '../utils/paths.js';
4
6
  import { getProjectPaths } from './config.js';
7
+ import { createRollbackJournal, restoreRollbackJournal, snapshotFile } from './furnace-rollback.js';
5
8
  import { registerBrowserContent } from './manifest-register.js';
9
+ import { DEFAULT_DOM_TARGET } from './wire-dom-fragment.js';
6
10
  import { addDestroyToBrowserInit, addDomFragment, addInitToBrowserInit, addSubscriptToBrowserMain, } from './wire-targets.js';
7
11
  export const DEFAULT_BROWSER_SUBSCRIPT_DIR = 'browser/base/content';
8
12
  const BROWSER_BASE_DIR = 'browser/base';
@@ -36,31 +40,72 @@ export async function wireSubscript(root, name, options = {}) {
36
40
  },
37
41
  };
38
42
  }
39
- // 1. Add subscript to browser-main.js
40
- const subscriptAdded = await addSubscriptToBrowserMain(engineDir, name);
41
- // 2. Add init expression to browser-init.js (if provided)
42
- let initAdded = false;
43
- if (options.init) {
44
- initAdded = await addInitToBrowserInit(engineDir, options.init, options.after);
43
+ // Snapshot every file the five mutation steps might touch so a mid-sequence
44
+ // failure (most commonly the chrome-document insertion not finding an
45
+ // anchor) does not leave a half-wired browser behind. Before the rollback
46
+ // journal landed here, a failed `wire` would still have written new
47
+ // `loadSubScript` calls into browser-main.js, new init/destroy expressions
48
+ // into browser-init.js, and a new entry into browser/base/jar.mn — the
49
+ // operator then had to grep the engine tree for the partial mutation and
50
+ // hand-revert, or re-download. The snapshots cover the targets on every
51
+ // code path (init/destroy/DOM are conditional, so we snapshot only when
52
+ // the corresponding option would fire a write) plus the two files every
53
+ // wire touches.
54
+ const journal = createRollbackJournal();
55
+ const effectiveDomTargetPath = options.domFilePath
56
+ ? toRootRelativePath(engineDir, options.domTargetPath ?? DEFAULT_DOM_TARGET)
57
+ : undefined;
58
+ await snapshotFile(journal, join(engineDir, 'browser/base/content/browser-main.js'));
59
+ if (options.init !== undefined || options.destroy !== undefined) {
60
+ await snapshotFile(journal, join(engineDir, 'browser/base/content/browser-init.js'));
45
61
  }
46
- // 3. Add destroy expression to browser-init.js onUnload() (if provided)
47
- let destroyAdded = false;
48
- if (options.destroy) {
49
- destroyAdded = await addDestroyToBrowserInit(engineDir, options.destroy);
62
+ if (effectiveDomTargetPath) {
63
+ await snapshotFile(journal, join(engineDir, effectiveDomTargetPath));
50
64
  }
51
- // 4. Add #include directive to the top-level chrome document (if provided)
52
- let domInserted = false;
53
- if (options.domFilePath) {
54
- domInserted = await addDomFragment(engineDir, toRootRelativePath(engineDir, options.domFilePath), options.domTargetPath);
65
+ await snapshotFile(journal, join(engineDir, 'browser/base/jar.mn'));
66
+ try {
67
+ // 1. Add subscript to browser-main.js
68
+ const subscriptAdded = await addSubscriptToBrowserMain(engineDir, name);
69
+ // 2. Add init expression to browser-init.js (if provided)
70
+ let initAdded = false;
71
+ if (options.init) {
72
+ initAdded = await addInitToBrowserInit(engineDir, options.init, options.after);
73
+ }
74
+ // 3. Add destroy expression to browser-init.js onUnload() (if provided)
75
+ let destroyAdded = false;
76
+ if (options.destroy) {
77
+ destroyAdded = await addDestroyToBrowserInit(engineDir, options.destroy);
78
+ }
79
+ // 4. Add #include directive to the top-level chrome document (if provided)
80
+ let domInserted = false;
81
+ if (options.domFilePath) {
82
+ domInserted = await addDomFragment(engineDir, toRootRelativePath(engineDir, options.domFilePath), options.domTargetPath);
83
+ }
84
+ // 5. Register in jar.mn
85
+ const jarMnResult = await registerBrowserContent(engineDir, `${name}.js`, undefined, jarMnSourcePath);
86
+ return {
87
+ subscriptAdded,
88
+ initAdded,
89
+ destroyAdded,
90
+ domInserted,
91
+ jarMnResult,
92
+ };
93
+ }
94
+ catch (error) {
95
+ // Best-effort rollback: if the restore itself fails, surface both the
96
+ // original wire failure and the rollback failure so the operator knows
97
+ // the engine may be in a partially-wired state that needs manual
98
+ // attention. The original error's message is preserved so the user sees
99
+ // *why* the wire failed (e.g. "Could not find insertion point in chrome
100
+ // document") alongside any rollback diagnosis.
101
+ const originalMessage = toError(error).message;
102
+ try {
103
+ await restoreRollbackJournal(journal);
104
+ }
105
+ catch (rollbackError) {
106
+ throw new GeneralError(`Wire failed: ${originalMessage}. Automatic rollback also failed: ${toError(rollbackError).message}. The engine may contain partially-applied wire mutations; review "git status" under engine/ and revert manually.`);
107
+ }
108
+ throw error;
55
109
  }
56
- // 5. Register in jar.mn
57
- const jarMnResult = await registerBrowserContent(engineDir, `${name}.js`, undefined, jarMnSourcePath);
58
- return {
59
- subscriptAdded,
60
- initAdded,
61
- destroyAdded,
62
- domInserted,
63
- jarMnResult,
64
- };
65
110
  }
66
111
  //# sourceMappingURL=browser-wire.js.map
@@ -31,6 +31,20 @@ export interface BuildBaseline {
31
31
  * the project has since been renamed.
32
32
  */
33
33
  binaryName: string;
34
+ /**
35
+ * Content hash per packageable engine path that was dirty at build
36
+ * time (modified-against-HEAD or untracked). Used by
37
+ * `checkStaleBuildForTest` to distinguish "this file's content was
38
+ * already in `dist/` when the build completed" from "this file has
39
+ * been edited since". Missing on baselines written before 0.16.0; the
40
+ * stale-check falls back to the path-only comparison in that case,
41
+ * so older baselines retain their existing behavior.
42
+ *
43
+ * Keys are engine-relative POSIX paths. Values are hex-encoded
44
+ * SHA-256 digests of the file contents at the moment the baseline
45
+ * was recorded.
46
+ */
47
+ packageableFingerprints?: Record<string, string>;
34
48
  }
35
49
  /** Name of the last-build marker file under `.fireforge/`. */
36
50
  export declare const BUILD_BASELINE_FILENAME = "last-build.json";
@@ -16,10 +16,17 @@
16
16
  * on successful build completion; a failed build does not update it, so a
17
17
  * subsequent run still audits against the last known-good tree.
18
18
  */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFile } from 'node:fs/promises';
19
21
  import { join } from 'node:path';
22
+ import { toError } from '../utils/errors.js';
20
23
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
24
+ import { verbose } from '../utils/logger.js';
25
+ import { isPackageablePath } from './build-audit.js';
21
26
  import { FIREFORGE_DIR } from './config-paths.js';
22
- import { getHead, isMissingHeadError } from './git.js';
27
+ import { getHead, hasChanges, isMissingHeadError } from './git.js';
28
+ import { git } from './git-base.js';
29
+ import { getUntrackedFiles } from './git-status.js';
23
30
  /** Name of the last-build marker file under `.fireforge/`. */
24
31
  export const BUILD_BASELINE_FILENAME = 'last-build.json';
25
32
  /**
@@ -73,11 +80,64 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
73
80
  throw error;
74
81
  }
75
82
  }
83
+ const packageableFingerprints = await collectPackageableFingerprints(engineDir);
76
84
  const baseline = {
77
85
  engineHeadSha,
78
86
  builtAt: new Date().toISOString(),
79
87
  binaryName,
88
+ ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
80
89
  };
81
90
  await writeJson(getBuildBaselinePath(projectRoot), baseline);
82
91
  }
92
+ /**
93
+ * Reads the current engine workdir and computes a SHA-256 fingerprint
94
+ * for every packageable path that is either modified against HEAD or
95
+ * untracked. The stale-build preflight (`checkStaleBuildForTest`)
96
+ * compares the live fingerprint for each packageable-dirty file to
97
+ * the baseline's entry — paths where the hash matches are "the build
98
+ * already saw this exact content", paths where it differs (or that
99
+ * are new since the baseline) are genuinely stale.
100
+ *
101
+ * Returns `undefined` on any git failure so a broken probe never
102
+ * corrupts the on-disk baseline with `{}`; the stale-check then falls
103
+ * back to the pre-0.16.0 "path-only" behavior on the next test run.
104
+ */
105
+ async function collectPackageableFingerprints(engineDir) {
106
+ try {
107
+ const dirtyPaths = new Set();
108
+ if (await hasChanges(engineDir)) {
109
+ const worktreeDiff = await git(['diff', '--name-only', 'HEAD'], engineDir);
110
+ for (const line of worktreeDiff.split('\n')) {
111
+ const trimmed = line.trim();
112
+ if (trimmed)
113
+ dirtyPaths.add(trimmed);
114
+ }
115
+ for (const untracked of await getUntrackedFiles(engineDir)) {
116
+ dirtyPaths.add(untracked);
117
+ }
118
+ }
119
+ const packageable = [...dirtyPaths].filter(isPackageablePath);
120
+ if (packageable.length === 0) {
121
+ return {};
122
+ }
123
+ const fingerprints = {};
124
+ for (const relPath of packageable) {
125
+ try {
126
+ const buffer = await readFile(join(engineDir, relPath));
127
+ fingerprints[relPath] = createHash('sha256').update(buffer).digest('hex');
128
+ }
129
+ catch (fileError) {
130
+ // A file that disappeared between status probe and hash is
131
+ // expected in concurrent scenarios; skip it without failing the
132
+ // whole baseline write.
133
+ verbose(`Build baseline: skipping fingerprint for ${relPath} — ${toError(fileError).message}`);
134
+ }
135
+ }
136
+ return fingerprints;
137
+ }
138
+ catch (error) {
139
+ verbose(`Build baseline: packageable fingerprint probe failed — ${toError(error).message}`);
140
+ return undefined;
141
+ }
142
+ }
83
143
  //# sourceMappingURL=build-baseline.js.map
@@ -12,4 +12,4 @@ import type { FireForgeConfig } from '../types/config.js';
12
12
  * @returns The mutated config
13
13
  */
14
14
  export declare function mutateConfig(config: FireForgeConfig, key: string, value: unknown, skipValidation?: false): FireForgeConfig;
15
- export declare function mutateConfig(config: FireForgeConfig, key: string, value: unknown, skipValidation: true): Record<string, unknown>;
15
+ export declare function mutateConfig(config: FireForgeConfig | Record<string, unknown>, key: string, value: unknown, skipValidation: true): Record<string, unknown>;
@@ -25,6 +25,23 @@ export declare function configExists(root: string): Promise<boolean>;
25
25
  * @throws Error if config doesn't exist or is invalid
26
26
  */
27
27
  export declare function loadConfig(root: string): Promise<FireForgeConfig>;
28
+ /**
29
+ * Reads the raw `fireforge.json` document without running it through
30
+ * {@link validateConfig}. Returns every persisted key — including keys
31
+ * written via `fireforge config <key> --force` that `validateConfig`
32
+ * would strip from the typed result.
33
+ *
34
+ * Callers that need the validated, typed shape must still use
35
+ * {@link loadConfig}; this helper exists specifically for the `config`
36
+ * read path so `fireforge config <key>` can surface keys the write path
37
+ * accepted under `--force`.
38
+ *
39
+ * @param root - Root directory of the project
40
+ * @returns Raw config object as persisted on disk
41
+ * @throws ConfigNotFoundError when fireforge.json is missing
42
+ * @throws ConfigError when the file is not valid JSON
43
+ */
44
+ export declare function loadRawConfigDocument(root: string): Promise<Record<string, unknown>>;
28
45
  /**
29
46
  * Writes a configuration to fireforge.json.
30
47
  * @param root - Root directory of the project
@@ -50,6 +50,41 @@ export async function loadConfig(root) {
50
50
  throw new ConfigError(`Invalid fireforge.json at ${paths.config}: ${toError(error).message}`);
51
51
  }
52
52
  }
53
+ /**
54
+ * Reads the raw `fireforge.json` document without running it through
55
+ * {@link validateConfig}. Returns every persisted key — including keys
56
+ * written via `fireforge config <key> --force` that `validateConfig`
57
+ * would strip from the typed result.
58
+ *
59
+ * Callers that need the validated, typed shape must still use
60
+ * {@link loadConfig}; this helper exists specifically for the `config`
61
+ * read path so `fireforge config <key>` can surface keys the write path
62
+ * accepted under `--force`.
63
+ *
64
+ * @param root - Root directory of the project
65
+ * @returns Raw config object as persisted on disk
66
+ * @throws ConfigNotFoundError when fireforge.json is missing
67
+ * @throws ConfigError when the file is not valid JSON
68
+ */
69
+ export async function loadRawConfigDocument(root) {
70
+ const paths = getProjectPaths(root);
71
+ if (!(await pathExists(paths.config))) {
72
+ throw new ConfigNotFoundError(paths.config);
73
+ }
74
+ try {
75
+ const data = await readJson(paths.config);
76
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
77
+ throw new ConfigError(`Invalid fireforge.json at ${paths.config}: expected an object`);
78
+ }
79
+ return data;
80
+ }
81
+ catch (error) {
82
+ if (error instanceof ConfigError || error instanceof ConfigNotFoundError) {
83
+ throw error;
84
+ }
85
+ throw new ConfigError(`Invalid fireforge.json at ${paths.config}: ${toError(error).message}`);
86
+ }
87
+ }
53
88
  /**
54
89
  * Writes a configuration to fireforge.json.
55
90
  * @param root - Root directory of the project