@hominis/fireforge 0.15.8 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -3
  3. package/dist/src/cli.d.ts +4 -1
  4. package/dist/src/cli.js +6 -3
  5. package/dist/src/commands/download.js +9 -0
  6. package/dist/src/commands/export-all.js +46 -0
  7. package/dist/src/commands/export-shared.d.ts +6 -1
  8. package/dist/src/commands/export-shared.js +7 -2
  9. package/dist/src/commands/export.js +10 -1
  10. package/dist/src/commands/furnace/diff.js +22 -2
  11. package/dist/src/commands/furnace/override.js +35 -12
  12. package/dist/src/commands/furnace/preview.js +33 -1
  13. package/dist/src/commands/furnace/rename.js +14 -3
  14. package/dist/src/commands/lint.d.ts +20 -0
  15. package/dist/src/commands/lint.js +167 -45
  16. package/dist/src/commands/package.js +16 -5
  17. package/dist/src/commands/re-export-files.js +6 -2
  18. package/dist/src/commands/re-export.js +62 -4
  19. package/dist/src/commands/register.js +2 -18
  20. package/dist/src/commands/run.js +23 -2
  21. package/dist/src/commands/status.js +25 -3
  22. package/dist/src/commands/test.js +6 -24
  23. package/dist/src/commands/token.js +14 -1
  24. package/dist/src/commands/watch.js +14 -2
  25. package/dist/src/core/branding.d.ts +23 -0
  26. package/dist/src/core/branding.js +39 -0
  27. package/dist/src/core/browser-wire.js +68 -23
  28. package/dist/src/core/mach-build-artifacts.d.ts +41 -0
  29. package/dist/src/core/mach-build-artifacts.js +70 -0
  30. package/dist/src/core/mach-error-hints.js +15 -0
  31. package/dist/src/core/mach-mozconfig.d.ts +25 -0
  32. package/dist/src/core/mach-mozconfig.js +66 -0
  33. package/dist/src/core/mach.d.ts +12 -1
  34. package/dist/src/core/mach.js +14 -1
  35. package/dist/src/core/manifest-rules.js +22 -1
  36. package/dist/src/core/patch-lint.d.ts +6 -1
  37. package/dist/src/core/patch-lint.js +14 -1
  38. package/dist/src/types/commands/options.d.ts +10 -0
  39. package/dist/src/types/commands/patches.d.ts +22 -0
  40. package/dist/src/utils/fs.d.ts +12 -0
  41. package/dist/src/utils/fs.js +12 -0
  42. package/dist/src/utils/paths.d.ts +19 -0
  43. package/dist/src/utils/paths.js +33 -0
  44. package/package.json +1 -1
@@ -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
@@ -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
@@ -25,6 +25,47 @@ export interface BuildArtifactCheck {
25
25
  * @returns Build artifact check result
26
26
  */
27
27
  export declare function hasBuildArtifacts(engineDir: string): Promise<BuildArtifactCheck>;
28
+ /**
29
+ * Outcome of the `hasRunnableBundle` probe. Distinguishes "no objdir at
30
+ * all" from "objdir exists but the launchable binary is not yet written"
31
+ * so callers (notably `fireforge run`) can give the operator a specific
32
+ * message instead of the generic build-artifacts-missing line.
33
+ */
34
+ export interface RunnableBundleCheck {
35
+ /** True when an objdir is present AND the expected binary was found under it. */
36
+ runnable: boolean;
37
+ /** Repo-relative (engine-rooted) path we probed; populated even on failure for error copy. */
38
+ expectedPath?: string;
39
+ }
40
+ /**
41
+ * Checks whether the built browser's launchable binary exists under
42
+ * `<engineDir>/<objDir>/dist/...`. `hasBuildArtifacts` only confirms that
43
+ * an obj tree with a `dist/` subdir exists; a partial or in-progress build
44
+ * can satisfy that check without ever writing the executable, which is the
45
+ * failure mode that makes `fireforge run` throw `mach run` after having
46
+ * reported the build as usable. Separating the probes lets `run` fail fast
47
+ * with a precise message and `watch` stay permissive (it exists to drive
48
+ * rebuilds of incomplete trees) while still reporting the bundle state in
49
+ * its startup banner.
50
+ *
51
+ * Platform layout:
52
+ * - macOS: `<objDir>/dist/*.app/Contents/MacOS/<binaryName>` (the `.app`
53
+ * display casing can differ from `binaryName` — e.g. `Hominis.app` for
54
+ * binary `hominis`, so we enumerate the `*.app` bundles rather than
55
+ * compute the name.
56
+ * - Linux: `<objDir>/dist/bin/<binaryName>`.
57
+ * - Windows: `<objDir>/dist/bin/<binaryName>.exe`.
58
+ *
59
+ * Returns `runnable: false` with no `expectedPath` when the `objDir`
60
+ * itself cannot be scanned — same degraded contract as `hasBuildArtifacts`.
61
+ *
62
+ * @param engineDir Path to the engine directory
63
+ * @param binaryName Lowercase binary name from `fireforge.json`
64
+ * @param objDir The single matching `obj-*` directory name (caller
65
+ * resolves it; typically from `hasBuildArtifacts().objDir`)
66
+ * @returns Structured check result
67
+ */
68
+ export declare function hasRunnableBundle(engineDir: string, binaryName: string, objDir: string): Promise<RunnableBundleCheck>;
28
69
  /** Builds a user-facing explanation when detected build artifacts belong to another workspace. */
29
70
  export declare function buildArtifactMismatchMessage(engineDir: string, buildCheck: BuildArtifactCheck, commandName: string): string | undefined;
30
71
  /**
@@ -4,6 +4,7 @@ import { join, relative, resolve, sep } from 'node:path';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { verbose } from '../utils/logger.js';
7
+ import { getPlatform } from '../utils/platform.js';
7
8
  import { isObject, isString } from '../utils/validation.js';
8
9
  function validateBuildMozinfo(data) {
9
10
  if (!isObject(data)) {
@@ -94,6 +95,75 @@ export async function hasBuildArtifacts(engineDir) {
94
95
  return { exists: false };
95
96
  }
96
97
  }
98
+ /**
99
+ * Checks whether the built browser's launchable binary exists under
100
+ * `<engineDir>/<objDir>/dist/...`. `hasBuildArtifacts` only confirms that
101
+ * an obj tree with a `dist/` subdir exists; a partial or in-progress build
102
+ * can satisfy that check without ever writing the executable, which is the
103
+ * failure mode that makes `fireforge run` throw `mach run` after having
104
+ * reported the build as usable. Separating the probes lets `run` fail fast
105
+ * with a precise message and `watch` stay permissive (it exists to drive
106
+ * rebuilds of incomplete trees) while still reporting the bundle state in
107
+ * its startup banner.
108
+ *
109
+ * Platform layout:
110
+ * - macOS: `<objDir>/dist/*.app/Contents/MacOS/<binaryName>` (the `.app`
111
+ * display casing can differ from `binaryName` — e.g. `Hominis.app` for
112
+ * binary `hominis`, so we enumerate the `*.app` bundles rather than
113
+ * compute the name.
114
+ * - Linux: `<objDir>/dist/bin/<binaryName>`.
115
+ * - Windows: `<objDir>/dist/bin/<binaryName>.exe`.
116
+ *
117
+ * Returns `runnable: false` with no `expectedPath` when the `objDir`
118
+ * itself cannot be scanned — same degraded contract as `hasBuildArtifacts`.
119
+ *
120
+ * @param engineDir Path to the engine directory
121
+ * @param binaryName Lowercase binary name from `fireforge.json`
122
+ * @param objDir The single matching `obj-*` directory name (caller
123
+ * resolves it; typically from `hasBuildArtifacts().objDir`)
124
+ * @returns Structured check result
125
+ */
126
+ export async function hasRunnableBundle(engineDir, binaryName, objDir) {
127
+ const platform = getPlatform();
128
+ const distDir = join(engineDir, objDir, 'dist');
129
+ if (!(await pathExists(distDir))) {
130
+ return { runnable: false };
131
+ }
132
+ if (platform === 'darwin') {
133
+ let entries;
134
+ try {
135
+ entries = await readdir(distDir, { withFileTypes: true });
136
+ }
137
+ catch {
138
+ return { runnable: false };
139
+ }
140
+ for (const entry of entries) {
141
+ if (!entry.isDirectory())
142
+ continue;
143
+ if (!entry.name.endsWith('.app'))
144
+ continue;
145
+ const candidate = join(distDir, entry.name, 'Contents', 'MacOS', binaryName);
146
+ if (await pathExists(candidate)) {
147
+ return { runnable: true, expectedPath: relative(engineDir, candidate) };
148
+ }
149
+ }
150
+ // Report an expected-but-missing path rooted at the first .app bundle we
151
+ // can see, or a synthetic path when no bundle exists yet, so the error
152
+ // message names something the operator can look for on disk.
153
+ const firstApp = entries.find((e) => e.isDirectory() && e.name.endsWith('.app'));
154
+ const expected = firstApp
155
+ ? relative(engineDir, join(distDir, firstApp.name, 'Contents', 'MacOS', binaryName))
156
+ : relative(engineDir, join(distDir, `<AppName>.app/Contents/MacOS/${binaryName}`));
157
+ return { runnable: false, expectedPath: expected };
158
+ }
159
+ const binaryFile = platform === 'win32' ? `${binaryName}.exe` : binaryName;
160
+ const candidate = join(distDir, 'bin', binaryFile);
161
+ const expectedPath = relative(engineDir, candidate);
162
+ if (await pathExists(candidate)) {
163
+ return { runnable: true, expectedPath };
164
+ }
165
+ return { runnable: false, expectedPath };
166
+ }
97
167
  /** Builds a user-facing explanation when detected build artifacts belong to another workspace. */
98
168
  export function buildArtifactMismatchMessage(engineDir, buildCheck, commandName) {
99
169
  if (!buildCheck.metadataMismatch || !buildCheck.objDir) {
@@ -19,6 +19,21 @@ export const MACH_ERROR_HINTS = [
19
19
  hint: 'A file registered under JS_PREFERENCE_PP_FILES contains no preprocessor directives. ' +
20
20
  'Use JS_PREFERENCE_FILES instead, or add at least one #filter / #expand directive to the file.',
21
21
  },
22
+ {
23
+ // `mach package` inside `packager.py` dereferences a `None` sink when
24
+ // the packaging input set cannot resolve an entry it expected — the
25
+ // most common real-world cause is running `fireforge package` before
26
+ // a full `fireforge build` has finished, so `obj-*/dist/` is missing
27
+ // pieces the packager assumes exist. The hint points at that root
28
+ // cause specifically; the broader "build failed" path has already
29
+ // surfaced the raw traceback above this hint.
30
+ pattern: /packager\.py[\s\S]*?AttributeError: 'NoneType' object has no attribute 'open'|AttributeError: 'NoneType' object has no attribute 'open'[\s\S]*?packager\.py/,
31
+ hint: '`mach package` tripped a `NoneType.open` inside `packager.py`. This is almost always a ' +
32
+ 'symptom of the packager being handed an incomplete `obj-*/dist/` tree — e.g. running ' +
33
+ '"fireforge package" before a full "fireforge build" (not --ui) completed, or packaging ' +
34
+ 'after a build that failed late. Re-run "fireforge build" to completion, confirm the app ' +
35
+ 'bundle exists under `obj-*/dist/`, and rerun "fireforge package".',
36
+ },
22
37
  ];
23
38
  /**
24
39
  * Scans captured stderr for known mach errors and returns matching hints.
@@ -8,6 +8,31 @@ export interface MozconfigVariables {
8
8
  appId: string;
9
9
  binaryName: string;
10
10
  }
11
+ /**
12
+ * Extracts the `--with-branding=<path>` value from a rendered mozconfig
13
+ * body. Returns `undefined` when no directive is present — callers treat
14
+ * that as "mozconfig is missing branding", which is itself an actionable
15
+ * configuration error.
16
+ *
17
+ * Exported for testing.
18
+ */
19
+ export declare function extractWithBrandingPath(mozconfigContent: string): string | undefined;
20
+ /**
21
+ * Preflights the just-written mozconfig against the branding tree FireForge
22
+ * set up. A drift between the two is silent-corruption territory — the
23
+ * build runs, `mach configure` reads the stale directory name out of
24
+ * mozconfig, and then the recursive make backend errors out with a "path
25
+ * does not exist" message that names the branding dir the mozconfig
26
+ * referenced. By parsing the mozconfig here and comparing to
27
+ * `config.binaryName`, we turn that into a single-line actionable error
28
+ * before `mach` runs.
29
+ *
30
+ * @param engineDir Path to the engine directory (the branding tree lives here)
31
+ * @param mozconfigPath Path to the mozconfig just written
32
+ * @param config FireForge configuration (reads `binaryName`)
33
+ * @throws BrandingMozconfigMismatchError on drift or missing directive
34
+ */
35
+ export declare function assertBrandingMozconfigAgreement(engineDir: string, mozconfigPath: string, config: FireForgeConfig): Promise<void>;
11
36
  /**
12
37
  * Generates a mozconfig file from templates.
13
38
  * @param configsDir - Path to the configs directory
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { MozconfigError } from '../errors/build.js';
4
4
  import { pathExists, readText, writeText } from '../utils/fs.js';
5
5
  import { getPlatform } from '../utils/platform.js';
6
+ import { BrandingMozconfigMismatchError } from './branding.js';
6
7
  /**
7
8
  * Replaces template variables in a string.
8
9
  * @param content - Content with ${variable} placeholders
@@ -16,6 +17,66 @@ function replaceVariables(content, variables) {
16
17
  .replace(/\$\{appId\}/g, variables.appId)
17
18
  .replace(/\$\{binaryName\}/g, variables.binaryName);
18
19
  }
20
+ /**
21
+ * Matches an `--with-branding=<path>` directive anywhere in a rendered
22
+ * mozconfig. The directive form is the one mach reads; an optional
23
+ * `ac_add_options` prefix is the on-disk convention. `m` flag anchors the
24
+ * search per-line so a multi-line mozconfig with older directives earlier
25
+ * in the file doesn't confuse the extractor. We pick the LAST match
26
+ * because mach itself takes the last-write-wins semantics of shell
27
+ * configuration for overlapping `ac_add_options` calls.
28
+ */
29
+ const WITH_BRANDING_PATTERN = /^\s*(?:ac_add_options\s+)?--with-branding\s*=\s*(\S+)/gm;
30
+ /**
31
+ * Extracts the `--with-branding=<path>` value from a rendered mozconfig
32
+ * body. Returns `undefined` when no directive is present — callers treat
33
+ * that as "mozconfig is missing branding", which is itself an actionable
34
+ * configuration error.
35
+ *
36
+ * Exported for testing.
37
+ */
38
+ export function extractWithBrandingPath(mozconfigContent) {
39
+ const matches = [...mozconfigContent.matchAll(WITH_BRANDING_PATTERN)];
40
+ const last = matches.at(-1);
41
+ return last?.[1];
42
+ }
43
+ /**
44
+ * Preflights the just-written mozconfig against the branding tree FireForge
45
+ * set up. A drift between the two is silent-corruption territory — the
46
+ * build runs, `mach configure` reads the stale directory name out of
47
+ * mozconfig, and then the recursive make backend errors out with a "path
48
+ * does not exist" message that names the branding dir the mozconfig
49
+ * referenced. By parsing the mozconfig here and comparing to
50
+ * `config.binaryName`, we turn that into a single-line actionable error
51
+ * before `mach` runs.
52
+ *
53
+ * @param engineDir Path to the engine directory (the branding tree lives here)
54
+ * @param mozconfigPath Path to the mozconfig just written
55
+ * @param config FireForge configuration (reads `binaryName`)
56
+ * @throws BrandingMozconfigMismatchError on drift or missing directive
57
+ */
58
+ export async function assertBrandingMozconfigAgreement(engineDir, mozconfigPath, config) {
59
+ const mozconfigContent = await readText(mozconfigPath);
60
+ const found = extractWithBrandingPath(mozconfigContent);
61
+ const expected = `browser/branding/${config.binaryName}`;
62
+ if (!found) {
63
+ throw new BrandingMozconfigMismatchError(expected, '(no --with-branding directive)', 'mozconfig-missing-branding');
64
+ }
65
+ // Normalise both sides to forward slashes before compare — Windows-edited
66
+ // configs can carry backslash path separators that the build would treat
67
+ // as literal characters in a repo-relative path.
68
+ const normalizedFound = found.replace(/\\/g, '/');
69
+ if (normalizedFound !== expected) {
70
+ throw new BrandingMozconfigMismatchError(expected, found, 'name-mismatch');
71
+ }
72
+ // Last line of defence: even with matching names, a missing branding tree
73
+ // means the scaffold step hasn't run. Preflight here so the operator
74
+ // doesn't pay for a configure-through-build cycle to discover it.
75
+ const brandingMozBuild = join(engineDir, expected, 'moz.build');
76
+ if (!(await pathExists(brandingMozBuild))) {
77
+ throw new BrandingMozconfigMismatchError(expected, found, 'branding-dir-missing');
78
+ }
79
+ }
19
80
  /**
20
81
  * Generates a mozconfig file from templates.
21
82
  * @param configsDir - Path to the configs directory
@@ -46,5 +107,10 @@ export async function generateMozconfig(configsDir, engineDir, config) {
46
107
  const platformContent = await readText(platformPath);
47
108
  content += `# Platform configuration (${platform})\n${replaceVariables(platformContent, variables)}`;
48
109
  await writeText(outputPath, content);
110
+ // Preflight: the mozconfig we just wrote must reference the branding
111
+ // directory FireForge actually set up. Catching the drift here (after the
112
+ // write, before anything consumes mozconfig) keeps `generateMozconfig`
113
+ // the single source of truth for both the render and the sanity-check.
114
+ await assertBrandingMozconfigAgreement(engineDir, outputPath, config);
49
115
  }
50
116
  //# sourceMappingURL=mach-mozconfig.js.map
@@ -1,5 +1,5 @@
1
1
  import { type SmokeLineCallback, type SmokeRunResult } from '../utils/process.js';
2
- export { attemptMozinfoRewrite, type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, type MozinfoRewriteResult, } from './mach-build-artifacts.js';
2
+ export { attemptMozinfoRewrite, type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, type MozinfoRewriteResult, type RunnableBundleCheck, } from './mach-build-artifacts.js';
3
3
  export { generateMozconfig, type MozconfigVariables } from './mach-mozconfig.js';
4
4
  export { ensurePython, resetResolvedPython } from './mach-python.js';
5
5
  /**
@@ -111,6 +111,17 @@ export declare function runMachSmoke(args: string[], engineDir: string, options:
111
111
  * @returns Exit code
112
112
  */
113
113
  export declare function machPackage(engineDir: string): Promise<number>;
114
+ /**
115
+ * Creates a distribution package while streaming output to the terminal
116
+ * and capturing the stderr tail for post-run diagnostics. Callers that
117
+ * want to consult {@link explainMachError} on failure should use this
118
+ * variant; the inherit-only `machPackage` above remains for callers that
119
+ * just need an exit code.
120
+ *
121
+ * @param engineDir - Path to the engine directory
122
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
123
+ */
124
+ export declare function machPackageCapture(engineDir: string): Promise<MachCommandResult>;
114
125
  /**
115
126
  * Runs mach watch for auto-rebuilding.
116
127
  * @param engineDir - Path to the engine directory
@@ -7,7 +7,7 @@ import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from
7
7
  import { explainMachError } from './mach-error-hints.js';
8
8
  import { getPython } from './mach-python.js';
9
9
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
10
- export { attemptMozinfoRewrite, buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
10
+ export { attemptMozinfoRewrite, buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, } from './mach-build-artifacts.js';
11
11
  export { generateMozconfig } from './mach-mozconfig.js';
12
12
  export { ensurePython, resetResolvedPython } from './mach-python.js';
13
13
  /**
@@ -197,6 +197,19 @@ export async function runMachSmoke(args, engineDir, options) {
197
197
  export async function machPackage(engineDir) {
198
198
  return runMach(['package'], engineDir, { inherit: true });
199
199
  }
200
+ /**
201
+ * Creates a distribution package while streaming output to the terminal
202
+ * and capturing the stderr tail for post-run diagnostics. Callers that
203
+ * want to consult {@link explainMachError} on failure should use this
204
+ * variant; the inherit-only `machPackage` above remains for callers that
205
+ * just need an exit code.
206
+ *
207
+ * @param engineDir - Path to the engine directory
208
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
209
+ */
210
+ export async function machPackageCapture(engineDir) {
211
+ return runMachCapture(['package'], engineDir);
212
+ }
200
213
  /**
201
214
  * Runs mach watch for auto-rebuilding.
202
215
  * @param engineDir - Path to the engine directory