@hominis/fireforge 0.16.3 → 0.17.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 (56) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +11 -3
  3. package/dist/src/commands/build.js +16 -7
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor.js +14 -1
  6. package/dist/src/commands/download.js +44 -13
  7. package/dist/src/commands/export-all.js +19 -2
  8. package/dist/src/commands/export-shared.d.ts +36 -0
  9. package/dist/src/commands/export-shared.js +76 -0
  10. package/dist/src/commands/export.js +23 -2
  11. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  12. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  13. package/dist/src/commands/furnace/create-readback.js +34 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  15. package/dist/src/commands/furnace/create-templates.js +11 -2
  16. package/dist/src/commands/furnace/create.js +2 -0
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/preview.d.ts +12 -0
  19. package/dist/src/commands/furnace/preview.js +34 -2
  20. package/dist/src/commands/furnace/rename.js +110 -0
  21. package/dist/src/commands/furnace/status.js +1 -1
  22. package/dist/src/commands/lint.js +55 -4
  23. package/dist/src/commands/patch/index.js +10 -1
  24. package/dist/src/commands/re-export.js +79 -6
  25. package/dist/src/commands/resolve.d.ts +25 -1
  26. package/dist/src/commands/resolve.js +40 -16
  27. package/dist/src/commands/run.js +27 -5
  28. package/dist/src/commands/status.js +100 -122
  29. package/dist/src/commands/test.js +23 -3
  30. package/dist/src/commands/token-coverage.js +55 -1
  31. package/dist/src/commands/token.js +12 -1
  32. package/dist/src/commands/wire.js +56 -10
  33. package/dist/src/core/config.d.ts +33 -0
  34. package/dist/src/core/config.js +43 -0
  35. package/dist/src/core/furnace-config.d.ts +23 -2
  36. package/dist/src/core/furnace-config.js +26 -3
  37. package/dist/src/core/mach-error-hints.js +16 -0
  38. package/dist/src/core/mach.d.ts +31 -0
  39. package/dist/src/core/mach.js +59 -6
  40. package/dist/src/core/marionette-port.d.ts +50 -0
  41. package/dist/src/core/marionette-port.js +215 -0
  42. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  43. package/dist/src/core/patch-manifest-consistency.js +16 -1
  44. package/dist/src/core/status-classify.d.ts +54 -0
  45. package/dist/src/core/status-classify.js +134 -0
  46. package/dist/src/core/token-dark-mode.d.ts +49 -0
  47. package/dist/src/core/token-dark-mode.js +182 -0
  48. package/dist/src/core/token-manager.js +17 -33
  49. package/dist/src/core/wire-destroy.js +18 -5
  50. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  51. package/dist/src/core/wire-dom-fragment.js +40 -0
  52. package/dist/src/core/wire-init.js +20 -5
  53. package/dist/src/core/wire-utils.d.ts +15 -0
  54. package/dist/src/core/wire-utils.js +17 -0
  55. package/dist/src/types/commands/options.d.ts +7 -0
  56. package/package.json +1 -1
@@ -3,13 +3,14 @@ import { join } from 'node:path';
3
3
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
6
+ import { assertMarionettePortAvailable } from '../core/marionette-port.js';
6
7
  import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
7
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
8
9
  import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
9
10
  import { GeneralError } from '../errors/base.js';
10
11
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
11
12
  import { pathExists } from '../utils/fs.js';
12
- import { info, intro, spinner, warn } from '../utils/logger.js';
13
+ import { info, intro, outro, spinner, warn } from '../utils/logger.js';
13
14
  import { pickDefined } from '../utils/options.js';
14
15
  import { stripEnginePrefix } from '../utils/paths.js';
15
16
  async function assertTestPathsExist(engineDir, testPaths) {
@@ -148,10 +149,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
148
149
  throw new GeneralError(`Tests require a completed build. ${detail}\n\n` +
149
150
  "Run 'fireforge build' first, then run 'fireforge test'.");
150
151
  }
152
+ // Load the project config once so both the build and the port
153
+ // probe have access to `binaryName` (the port probe uses it to
154
+ // recognise a fork-branded browser holding the Marionette port).
155
+ const projectConfig = await loadConfig(projectRoot);
151
156
  // Run incremental build if requested
152
157
  if (options.build) {
153
- const config = await loadConfig(projectRoot);
154
- await prepareBuildEnvironment(projectRoot, paths, config);
158
+ await prepareBuildEnvironment(projectRoot, paths, projectConfig);
155
159
  const s = spinner('Running incremental build...');
156
160
  const buildExitCode = await buildUI(paths.engine);
157
161
  if (buildExitCode !== 0) {
@@ -175,6 +179,15 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
175
179
  warn(formatStaleBuildWarning(stale));
176
180
  }
177
181
  }
182
+ // Stale-browser probe: an interrupted earlier test run can leave a
183
+ // Firefox/ForgeFresh/Hominis instance listening on the Marionette
184
+ // control port, which breaks the next mach test launch with a
185
+ // bind error that points nowhere near the real cause. Raise a
186
+ // targeted refusal up front instead of letting mach surface the
187
+ // generic bind failure. 2026-04-21 eval (Finding #20): a stale
188
+ // `-marionette` process from `fresh/` poisoned a later test run in
189
+ // the sibling `hominis/` workspace.
190
+ await assertMarionettePortAvailable(undefined, { binaryName: projectConfig.binaryName });
178
191
  // `--doctor` runs a short marionette handshake probe. When test paths are
179
192
  // supplied the probe gates the mach test invocation (a FAIL bails out). When
180
193
  // no paths are supplied this is the only step — it's the fastest way to tell
@@ -187,6 +200,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
187
200
  if (!preflight.ok) {
188
201
  throw new GeneralError('Marionette preflight reported FAIL — see output above.');
189
202
  }
203
+ // Close the intro frame explicitly. Without an outro, clack's
204
+ // grouped-output mode left the PASS line hanging inside an
205
+ // unclosed tree — in the eval's non-TTY capture the info line
206
+ // itself failed to render, so `test --doctor` looked like it had
207
+ // exited silently after the spinner start line. The outro also
208
+ // gives scripts a deterministic "done" marker to parse.
209
+ outro(`Marionette preflight: PASS (${preflight.durationMs}ms)`);
190
210
  return;
191
211
  }
192
212
  if (!preflight.ok) {
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
2
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
+ import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
3
5
  import { getStatusWithCodes, isGitRepository } from '../core/git.js';
4
6
  import { measureTokenCoverage } from '../core/token-coverage.js';
5
7
  import { getTokensCssPath } from '../core/token-manager.js';
@@ -22,9 +24,19 @@ export async function tokenCoverageCommand(projectRoot) {
22
24
  const config = await loadConfig(projectRoot);
23
25
  const tokensCssPath = getTokensCssPath(config.binaryName);
24
26
  const files = await getStatusWithCodes(paths.engine);
25
- const cssFiles = files
27
+ const statusCssFiles = files
26
28
  .filter((f) => f.file.endsWith('.css') && f.file !== tokensCssPath)
27
29
  .map((f) => f.file);
30
+ // Also scan CSS files deployed by Furnace custom components. Deployed
31
+ // files can be committed (and therefore absent from `git status`) while
32
+ // still being the primary surface where token adoption matters. Before
33
+ // 0.16.0, coverage only looked at modified files, which silently
34
+ // undercounted projects where Furnace writes many component-CSS files
35
+ // into the engine and they are already tracked.
36
+ const furnaceCssFiles = await collectFurnaceCustomCssFiles(projectRoot, paths.engine, tokensCssPath);
37
+ // De-dupe so a file that is both a custom deploy target AND modified is
38
+ // scanned exactly once.
39
+ const cssFiles = [...new Set([...statusCssFiles, ...furnaceCssFiles])];
28
40
  if (cssFiles.length === 0) {
29
41
  info('No modified CSS files');
30
42
  outro('Nothing to measure');
@@ -54,4 +66,46 @@ export async function tokenCoverageCommand(projectRoot) {
54
66
  }
55
67
  outro(`${report.filesScanned} CSS file${report.filesScanned === 1 ? '' : 's'} scanned`);
56
68
  }
69
+ /**
70
+ * Returns engine-relative `.css` paths deployed by every Furnace custom
71
+ * component registered in `furnace.json`. Only files that actually exist
72
+ * on disk are included — a component whose deploy target is missing (e.g.
73
+ * `furnace apply` has not run yet) is skipped silently so a fresh
74
+ * `furnace init` followed immediately by `token coverage` does not error.
75
+ *
76
+ * Returns an empty array when the project has no furnace.json, no custom
77
+ * components, or when loading the config fails (a warn is emitted in the
78
+ * last case so the user can diagnose a broken furnace.json without losing
79
+ * coverage results on the non-furnace CSS files).
80
+ */
81
+ async function collectFurnaceCustomCssFiles(projectRoot, engineDir, tokensCssPath) {
82
+ if (!(await furnaceConfigExists(projectRoot))) {
83
+ return [];
84
+ }
85
+ let furnaceConfig;
86
+ try {
87
+ furnaceConfig = await loadFurnaceConfig(projectRoot);
88
+ }
89
+ catch (error) {
90
+ warn(`Could not load furnace.json for token coverage — scanning modified files only (${error.message})`);
91
+ return [];
92
+ }
93
+ const results = [];
94
+ for (const [componentName, customConfig] of Object.entries(furnaceConfig.custom)) {
95
+ // Upstream Firefox widget layout: every component lives at
96
+ // `toolkit/content/widgets/<tagName>/` and ships at least
97
+ // `<tagName>.css`. `targetPath` already resolves to that directory
98
+ // (the create command writes `toolkit/content/widgets/<name>` into
99
+ // furnace.json) so we can probe the default layout directly without
100
+ // walking the whole tree.
101
+ const candidate = `${customConfig.targetPath}/${componentName}.css`;
102
+ if (candidate === tokensCssPath)
103
+ continue;
104
+ const absolutePath = join(engineDir, candidate);
105
+ if (await pathExists(absolutePath)) {
106
+ results.push(candidate);
107
+ }
108
+ }
109
+ return results;
110
+ }
57
111
  //# sourceMappingURL=token-coverage.js.map
@@ -106,7 +106,18 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
106
106
  }
107
107
  /** Registers token management commands on the CLI program. */
108
108
  export function registerToken(program, { getProjectRoot, withErrorHandling }) {
109
- const token = program.command('token').description('Design token management');
109
+ const token = program
110
+ .command('token')
111
+ .description('Design token management')
112
+ // Match `fireforge furnace`'s no-args contract: print the group's help and
113
+ // exit 0. Without this default action, commander routes `fireforge token`
114
+ // (no subcommand) through its own help-then-exit-1 path, so scripts that
115
+ // probe the CLI surface see a misleading non-zero exit for a purely
116
+ // informational invocation. The action prints the exact same help commander
117
+ // would otherwise print, but returns successfully.
118
+ .action(() => {
119
+ token.outputHelp();
120
+ });
110
121
  token
111
122
  .command('add <token-name> <value>')
112
123
  .description('Add a design token to CSS and documentation')
@@ -4,7 +4,8 @@ import { DEFAULT_BROWSER_SUBSCRIPT_DIR, wireSubscript } from '../core/browser-wi
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { furnaceConfigExists as checkFurnaceConfigExists, loadFurnaceConfig, } from '../core/furnace-config.js';
6
6
  import { consumeParserFallbackEvents } from '../core/parser-fallback.js';
7
- import { DEFAULT_DOM_TARGET } from '../core/wire-dom-fragment.js';
7
+ import { DEFAULT_DOM_TARGET, probeDomFragmentInsertionPoint } from '../core/wire-dom-fragment.js';
8
+ import { coerceToCall, validateWireName as validateWireExpression } from '../core/wire-utils.js';
8
9
  import { InvalidArgumentError } from '../errors/base.js';
9
10
  import { toError } from '../utils/errors.js';
10
11
  import { pathExists } from '../utils/fs.js';
@@ -17,10 +18,14 @@ function printWireDryRun(engineDir, name, subscriptDir, domFilePath, domTargetPa
17
18
  info(` source: ${subscriptDir}/${name}.js`);
18
19
  info(` browser-main.js: loadSubScript("chrome://browser/content/${name}.js")`);
19
20
  if (options.init) {
20
- info(` browser-init.js: ${options.init}`);
21
+ // Show the coerced form so the preview matches the emitted block.
22
+ // Before 0.16.0 the preview echoed the raw input ("EvalStartup.init"),
23
+ // which did not reflect that the real run writes `EvalStartup.init();`
24
+ // to browser-init.js.
25
+ info(` browser-init.js: ${coerceToCall(options.init)}`);
21
26
  }
22
27
  if (options.destroy) {
23
- info(` browser-init.js onUnload(): ${options.destroy}`);
28
+ info(` browser-init.js onUnload(): ${coerceToCall(options.destroy)}`);
24
29
  }
25
30
  if (domFilePath) {
26
31
  const includePath = relative(join(engineDir, subscriptDir), join(engineDir, domFilePath)).replace(/\\/g, '/');
@@ -78,6 +83,34 @@ function validateWireName(name) {
78
83
  'Path separators and parent-directory segments are not permitted.', 'name');
79
84
  }
80
85
  }
86
+ /**
87
+ * Asserts that the resolved chrome document both exists on disk AND
88
+ * exposes an insertion anchor (`#include browser-sets.inc` or
89
+ * `<html:body>`) that `addDomFragment` can splice into. Fires the same
90
+ * check in dry-run and real-run mode, so the preview and execution
91
+ * agree on whether the target is wireable before any disk mutations
92
+ * happen. Before 0.16.0 this check only ran on the real branch, which
93
+ * let the dry-run produce a plausible-looking plan that the real run
94
+ * then refused with `Could not find insertion point in chrome document`.
95
+ */
96
+ async function assertDomTargetIsWireable(projectRoot, domFilePath, domTargetPath) {
97
+ const paths = getProjectPaths(projectRoot);
98
+ if (!(await pathExists(join(paths.engine, domTargetPath)))) {
99
+ throw new InvalidArgumentError(`Chrome document not found in engine: ${domTargetPath}\n` +
100
+ 'Set "tokenHostDocuments" in furnace.json (first entry is used by wire) ' +
101
+ 'or pass --target <path>.', 'target');
102
+ }
103
+ try {
104
+ await probeDomFragmentInsertionPoint(paths.engine, domFilePath, domTargetPath);
105
+ }
106
+ catch (probeError) {
107
+ throw new InvalidArgumentError(`${probeError instanceof Error ? probeError.message : String(probeError)}\n` +
108
+ `The resolved chrome document ${domTargetPath} does not expose an insertion anchor ` +
109
+ 'that `fireforge wire` recognises (`#include browser-sets.inc` or `<html:body>`). ' +
110
+ 'Add one of those anchors to the chrome doc, or target a document that has them via ' +
111
+ '`--target <path>`.', 'target');
112
+ }
113
+ }
81
114
  /**
82
115
  * Wires a chrome subscript into the browser.
83
116
  *
@@ -95,6 +128,21 @@ export async function wireCommand(projectRoot, name, options = {}) {
95
128
  // --after and have it forwarded unchanged to the lookup layer.
96
129
  validateWireName(options.after);
97
130
  }
131
+ // Validate init/destroy expressions BEFORE the dry-run/real fork so
132
+ // both paths enforce the same contract. Pre-0.16.0, validation only
133
+ // ran inside `addInitToBrowserInit`/`addDestroyToBrowserInit` (the
134
+ // real-execution path), so `--dry-run --init 'void 0'` succeeded and
135
+ // rendered a plausible-looking preview even though the real run would
136
+ // reject the same arguments. Dropping `void 0` into the template
137
+ // silently (or breaking out of the string literal) was already
138
+ // prevented downstream — this hoist just makes the failure surface
139
+ // identical in preview mode.
140
+ if (options.init !== undefined) {
141
+ validateWireExpression(options.init, 'init expression');
142
+ }
143
+ if (options.destroy !== undefined) {
144
+ validateWireExpression(options.destroy, 'destroy expression');
145
+ }
98
146
  consumeParserFallbackEvents();
99
147
  // Resolve subscript directory: CLI flag > fireforge.json > default
100
148
  let subscriptDir = DEFAULT_BROWSER_SUBSCRIPT_DIR;
@@ -172,14 +220,12 @@ export async function wireCommand(projectRoot, name, options = {}) {
172
220
  }
173
221
  const domTargetPath = await resolveDomTargetPath(projectRoot, normalizedTarget);
174
222
  if (domFilePath) {
175
- const paths = getProjectPaths(projectRoot);
176
- if (!options.dryRun && !(await pathExists(join(paths.engine, domTargetPath)))) {
177
- throw new InvalidArgumentError(`Chrome document not found in engine: ${domTargetPath}\n` +
178
- 'Set "tokenHostDocuments" in furnace.json (first entry is used by wire) ' +
179
- 'or pass --target <path>.', 'target');
180
- }
223
+ await assertDomTargetIsWireable(projectRoot, domFilePath, domTargetPath);
181
224
  }
182
- // Verify the subscript file exists in engine/ (skip for dry-run)
225
+ // Verify the subscript file exists in engine/ (skip for dry-run:
226
+ // dry-run is meant to preview the mutation plan without requiring
227
+ // the subscript to already exist, matching the "plan before write"
228
+ // pattern operators rely on for setup scripts).
183
229
  if (!options.dryRun) {
184
230
  const paths = getProjectPaths(projectRoot);
185
231
  const subscriptPath = join(paths.engine, subscriptDir, `${name}.js`);
@@ -52,5 +52,38 @@ export declare function writeConfig(root: string, config: FireForgeConfig): Prom
52
52
  * Writes a raw config document to fireforge.json.
53
53
  * This is used by CLI `config --force`, where callers may intentionally write
54
54
  * keys or value shapes outside the validated FireForgeConfig schema.
55
+ *
56
+ * Individual writes are atomic via {@link writeJson} (temp file + rename),
57
+ * but atomicity alone does not prevent lost updates across concurrent
58
+ * writers: each writer reads an old copy, mutates its own in-memory view,
59
+ * and writes it back, so the second writer's rename clobbers the first
60
+ * writer's changes. Callers that do read → mutate → write must hold
61
+ * {@link withConfigFileLock} for the full round-trip to serialise
62
+ * against other writers.
55
63
  */
56
64
  export declare function writeConfigDocument(root: string, config: FireForgeConfig | Record<string, unknown>): Promise<void>;
65
+ /**
66
+ * Runs an operation while holding a sidecar lock on `fireforge.json`.
67
+ *
68
+ * Motivating case (2026-04-21 eval): two concurrent `fireforge config
69
+ * <key> <value>` invocations each ran load → mutate → writeJson against
70
+ * the same on-disk fireforge.json. The second rename landed after the
71
+ * first, silently dropping the first writer's key — both commands exited
72
+ * `0`, but only one change survived. This helper turns the same
73
+ * read-modify-write sequence into a serialised operation so a concurrent
74
+ * writer now waits for the lock rather than racing on the document.
75
+ *
76
+ * Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: writers
77
+ * always use `writeJson`'s atomic temp-file + rename, so a reader observes
78
+ * either the pre- or post-write document but never a torn file. The lock
79
+ * only serialises writers against other writers.
80
+ *
81
+ * The lock is a sidecar directory `${config}.fireforge-config.lock`, and
82
+ * `withFileLock` handles stale-lock recovery (PID-alive probe, age-based
83
+ * fallback) — a crashed writer does not permanently block future writes.
84
+ *
85
+ * @param root - Root directory of the project
86
+ * @param operation - Async function to run while holding the lock
87
+ * @returns Whatever the operation returns
88
+ */
89
+ export declare function withConfigFileLock<T>(root: string, operation: () => Promise<T>): Promise<T>;
@@ -8,11 +8,13 @@
8
8
  * config-mutate.ts — immutable config mutation
9
9
  * config-state.ts — state file management
10
10
  */
11
+ import { basename } from 'node:path';
11
12
  import { ConfigError, ConfigNotFoundError } from '../errors/config.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
14
15
  import { getProjectPaths } from './config-paths.js';
15
16
  import { validateConfig } from './config-validate.js';
17
+ import { createSiblingLockPath, withFileLock } from './file-lock.js';
16
18
  // ---- re-exports ----
17
19
  export { mutateConfig } from './config-mutate.js';
18
20
  export { CONFIG_FILENAME, CONFIGS_DIR, ENGINE_DIR, FIREFORGE_DIR, getProjectPaths, PATCHES_DIR, SRC_DIR, STATE_FILENAME, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, } from './config-paths.js';
@@ -97,9 +99,50 @@ export async function writeConfig(root, config) {
97
99
  * Writes a raw config document to fireforge.json.
98
100
  * This is used by CLI `config --force`, where callers may intentionally write
99
101
  * keys or value shapes outside the validated FireForgeConfig schema.
102
+ *
103
+ * Individual writes are atomic via {@link writeJson} (temp file + rename),
104
+ * but atomicity alone does not prevent lost updates across concurrent
105
+ * writers: each writer reads an old copy, mutates its own in-memory view,
106
+ * and writes it back, so the second writer's rename clobbers the first
107
+ * writer's changes. Callers that do read → mutate → write must hold
108
+ * {@link withConfigFileLock} for the full round-trip to serialise
109
+ * against other writers.
100
110
  */
101
111
  export async function writeConfigDocument(root, config) {
102
112
  const paths = getProjectPaths(root);
103
113
  await writeJson(paths.config, config);
104
114
  }
115
+ /**
116
+ * Runs an operation while holding a sidecar lock on `fireforge.json`.
117
+ *
118
+ * Motivating case (2026-04-21 eval): two concurrent `fireforge config
119
+ * <key> <value>` invocations each ran load → mutate → writeJson against
120
+ * the same on-disk fireforge.json. The second rename landed after the
121
+ * first, silently dropping the first writer's key — both commands exited
122
+ * `0`, but only one change survived. This helper turns the same
123
+ * read-modify-write sequence into a serialised operation so a concurrent
124
+ * writer now waits for the lock rather than racing on the document.
125
+ *
126
+ * Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: writers
127
+ * always use `writeJson`'s atomic temp-file + rename, so a reader observes
128
+ * either the pre- or post-write document but never a torn file. The lock
129
+ * only serialises writers against other writers.
130
+ *
131
+ * The lock is a sidecar directory `${config}.fireforge-config.lock`, and
132
+ * `withFileLock` handles stale-lock recovery (PID-alive probe, age-based
133
+ * fallback) — a crashed writer does not permanently block future writes.
134
+ *
135
+ * @param root - Root directory of the project
136
+ * @param operation - Async function to run while holding the lock
137
+ * @returns Whatever the operation returns
138
+ */
139
+ export async function withConfigFileLock(root, operation) {
140
+ const paths = getProjectPaths(root);
141
+ return withFileLock(createSiblingLockPath(paths.config, '.fireforge-config.lock'), operation, {
142
+ onTimeoutMessage: `Timed out waiting to update ${basename(paths.config)}. ` +
143
+ 'If no other fireforge process is running, remove the stale lock directory and retry.',
144
+ onStaleLockMessage: (ageMs) => `Removing stale FireForge config lock for ${basename(paths.config)} ` +
145
+ `(age: ${Math.round(ageMs / 1000)}s). A previous fireforge process may have crashed.`,
146
+ });
147
+ }
105
148
  //# sourceMappingURL=config.js.map
@@ -111,9 +111,30 @@ export declare function writeFurnaceConfig(root: string, config: FurnaceConfig):
111
111
  export declare function stampFurnaceOverrideBaseVersions(root: string, version: string): Promise<number>;
112
112
  /**
113
113
  * Creates a default furnace configuration.
114
- * @returns A valid empty FurnaceConfig
114
+ *
115
+ * When a `binaryName` is provided, the default config carries a
116
+ * `tokenPrefix` derived as `--<binaryName>-`. Without that default,
117
+ * `fireforge token coverage` on a fresh project reports `0 tokens` and
118
+ * labels every custom-property reference as `unknown` — the scan has
119
+ * no prefix to key off. The 2026-04-21 eval walked directly into this
120
+ * state (`furnace init` → `token add` → `token coverage` → zero
121
+ * tokens), and only recovered after hand-editing furnace.json. Deriving
122
+ * the prefix from the binary name matches the convention the scaffolded
123
+ * tokens CSS already uses for its `--<binaryName>-*` declarations.
124
+ *
125
+ * `validateFurnaceConfig` treats `tokenPrefix` as optional, so callers
126
+ * on the legacy no-arg call shape (existing tests, programmatic callers
127
+ * bootstrapping from a not-yet-loaded config) still get a valid config
128
+ * without a prefix; the CLI init path always has a `binaryName` from
129
+ * `fireforge.json` and always sets one.
130
+ *
131
+ * @param options - Optional init context; pass `{ binaryName }` to
132
+ * derive the token prefix.
133
+ * @returns A valid FurnaceConfig
115
134
  */
116
- export declare function createDefaultFurnaceConfig(): FurnaceConfig;
135
+ export declare function createDefaultFurnaceConfig(options?: {
136
+ binaryName?: string;
137
+ }): FurnaceConfig;
117
138
  /**
118
139
  * Loads furnace config if it exists, or creates and writes a default config.
119
140
  * @param root - Root directory of the project
@@ -460,16 +460,39 @@ export async function stampFurnaceOverrideBaseVersions(root, version) {
460
460
  }
461
461
  /**
462
462
  * Creates a default furnace configuration.
463
- * @returns A valid empty FurnaceConfig
463
+ *
464
+ * When a `binaryName` is provided, the default config carries a
465
+ * `tokenPrefix` derived as `--<binaryName>-`. Without that default,
466
+ * `fireforge token coverage` on a fresh project reports `0 tokens` and
467
+ * labels every custom-property reference as `unknown` — the scan has
468
+ * no prefix to key off. The 2026-04-21 eval walked directly into this
469
+ * state (`furnace init` → `token add` → `token coverage` → zero
470
+ * tokens), and only recovered after hand-editing furnace.json. Deriving
471
+ * the prefix from the binary name matches the convention the scaffolded
472
+ * tokens CSS already uses for its `--<binaryName>-*` declarations.
473
+ *
474
+ * `validateFurnaceConfig` treats `tokenPrefix` as optional, so callers
475
+ * on the legacy no-arg call shape (existing tests, programmatic callers
476
+ * bootstrapping from a not-yet-loaded config) still get a valid config
477
+ * without a prefix; the CLI init path always has a `binaryName` from
478
+ * `fireforge.json` and always sets one.
479
+ *
480
+ * @param options - Optional init context; pass `{ binaryName }` to
481
+ * derive the token prefix.
482
+ * @returns A valid FurnaceConfig
464
483
  */
465
- export function createDefaultFurnaceConfig() {
466
- return {
484
+ export function createDefaultFurnaceConfig(options = {}) {
485
+ const config = {
467
486
  version: 1,
468
487
  componentPrefix: 'moz-',
469
488
  stock: [],
470
489
  overrides: {},
471
490
  custom: {},
472
491
  };
492
+ if (options.binaryName && options.binaryName.length > 0) {
493
+ config.tokenPrefix = `--${options.binaryName}-`;
494
+ }
495
+ return config;
473
496
  }
474
497
  /**
475
498
  * Loads furnace config if it exists, or creates and writes a default config.
@@ -57,6 +57,22 @@ export const MACH_ERROR_HINTS = [
57
57
  'remove any `pub type basic_string___self_view = …<_CharT>;` line from ' +
58
58
  '`<objdir>/release/build/gecko-profiler-*/out/gecko/bindings.rs`.',
59
59
  },
60
+ {
61
+ // When `mach build` fails mid-compile, mach's own shutdown pipeline still
62
+ // runs its trailing "Config object not found by mach. / Configure
63
+ // complete! / Be sure to run |mach build|..." summary on the way out.
64
+ // Those three lines are plain upstream mach output, printed AFTER the
65
+ // non-zero exit code has already been established, and they look
66
+ // deceptively like a success banner — the eval's Darwin 25 log had
67
+ // operators double-checking whether `make` had actually failed. We do
68
+ // not own those lines, but we can give the operator a specific nudge
69
+ // that they are cosmetic post-failure output rather than a mixed
70
+ // success/failure signal.
71
+ pattern: /Config object not found by mach\.[\s\S]*?Configure complete!/,
72
+ hint: 'Ignore the trailing "Config object not found by mach. / Configure complete!" block — ' +
73
+ "that is mach's post-failure configure summary printed after the build already failed, " +
74
+ 'not a sign the build succeeded. The real failure is the error above this block.',
75
+ },
60
76
  ];
61
77
  /**
62
78
  * Scans captured stderr for known mach errors and returns matching hints.
@@ -73,6 +73,37 @@ export declare function build(engineDir: string, jobs?: number): Promise<number>
73
73
  * @returns Exit code
74
74
  */
75
75
  export declare function buildUI(engineDir: string): Promise<number>;
76
+ /**
77
+ * Runs an operation while holding a sidecar build lock keyed on the
78
+ * project root. Concurrent `fireforge build` / `fireforge build --ui`
79
+ * invocations against the same tree serialise instead of racing through
80
+ * the mach obj-dir.
81
+ *
82
+ * Motivating case (2026-04-21 eval): a `fireforge build --ui` run
83
+ * kicked off while a full `fireforge build` was still in flight against
84
+ * the same engine tree accepted the command and handed off to `mach
85
+ * build faster`, which failed almost immediately with `No rule to make
86
+ * target 'XUL'`. The real problem is that the first build had not yet
87
+ * materialised the full backend; the operator was left staring at a
88
+ * low-level make error with no link to the actual cause (a concurrent
89
+ * build in flight). The lock intercepts the second invocation before
90
+ * it touches mach, and the refusal message names the PID currently
91
+ * holding the lock so the operator can decide whether to wait or
92
+ * investigate a hung process.
93
+ *
94
+ * Stale-lock recovery: the lock stores the owner PID; a crashed build
95
+ * (SIGINT, SIGTERM, or a kernel kill) leaves the lock dir behind but
96
+ * not the owning process, and `withFileLock` removes the lock on the
97
+ * next attempt when `process.kill(pid, 0)` shows the owner is gone.
98
+ *
99
+ * The project-root variant is the right granularity: a single machine
100
+ * may have several FireForge projects side by side, and nothing says
101
+ * they cannot build in parallel. The lock serialises *within* one
102
+ * project, not across unrelated ones.
103
+ *
104
+ * Returns whatever the inner operation returns.
105
+ */
106
+ export declare function withBuildLock<T>(projectRoot: string, operation: () => Promise<T>): Promise<T>;
76
107
  /**
77
108
  * Runs the built browser.
78
109
  * @param engineDir - Path to the engine directory
@@ -1,9 +1,10 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
2
+ import { basename, join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
6
  import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
+ import { createSiblingLockPath, withFileLock } from './file-lock.js';
7
8
  import { explainMachError } from './mach-error-hints.js';
8
9
  import { getPython } from './mach-python.js';
9
10
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
@@ -111,13 +112,22 @@ export async function bootstrapWithOutput(engineDir) {
111
112
  return runMachInheritCapture(['bootstrap', '--application-choice', 'browser'], engineDir);
112
113
  }
113
114
  /**
114
- * Prints any matched {@link MachErrorHint} hints for the captured stderr.
115
+ * Prints any matched {@link MachErrorHint} hints for the captured mach output.
115
116
  * No-op when nothing matches. Always called before a non-zero exit propagates
116
117
  * so the hint sits immediately below the raw mach error in the operator's
117
118
  * terminal.
119
+ *
120
+ * The scanner is passed the concatenation of stderr AND stdout because mach
121
+ * streams its subcommand output through a timestamp-prefixing wrapper that
122
+ * writes both streams to whatever FD the subprocess chose — in practice,
123
+ * `rustc` errors from `mach build` can land on stdout rather than stderr,
124
+ * and the eval run's Darwin 25 `_CharT` hint pattern matched the captured
125
+ * text but our pre-0.16 code only fed `result.stderr` into the scanner, so
126
+ * the hint never fired.
118
127
  */
119
- function surfaceMachErrorHints(stderr) {
120
- const hints = explainMachError(stderr);
128
+ function surfaceMachErrorHints(result) {
129
+ const combined = `${result.stderr}\n${result.stdout}`;
130
+ const hints = explainMachError(combined);
121
131
  if (hints.length === 0)
122
132
  return;
123
133
  for (const hint of hints) {
@@ -139,7 +149,7 @@ export async function build(engineDir, jobs) {
139
149
  }
140
150
  const result = await runMachInheritCapture(args, engineDir);
141
151
  if (result.exitCode !== 0) {
142
- surfaceMachErrorHints(result.stderr);
152
+ surfaceMachErrorHints(result);
143
153
  }
144
154
  return result.exitCode;
145
155
  }
@@ -152,10 +162,53 @@ export async function build(engineDir, jobs) {
152
162
  export async function buildUI(engineDir) {
153
163
  const result = await runMachInheritCapture(['build', 'faster'], engineDir);
154
164
  if (result.exitCode !== 0) {
155
- surfaceMachErrorHints(result.stderr);
165
+ surfaceMachErrorHints(result);
156
166
  }
157
167
  return result.exitCode;
158
168
  }
169
+ /**
170
+ * Runs an operation while holding a sidecar build lock keyed on the
171
+ * project root. Concurrent `fireforge build` / `fireforge build --ui`
172
+ * invocations against the same tree serialise instead of racing through
173
+ * the mach obj-dir.
174
+ *
175
+ * Motivating case (2026-04-21 eval): a `fireforge build --ui` run
176
+ * kicked off while a full `fireforge build` was still in flight against
177
+ * the same engine tree accepted the command and handed off to `mach
178
+ * build faster`, which failed almost immediately with `No rule to make
179
+ * target 'XUL'`. The real problem is that the first build had not yet
180
+ * materialised the full backend; the operator was left staring at a
181
+ * low-level make error with no link to the actual cause (a concurrent
182
+ * build in flight). The lock intercepts the second invocation before
183
+ * it touches mach, and the refusal message names the PID currently
184
+ * holding the lock so the operator can decide whether to wait or
185
+ * investigate a hung process.
186
+ *
187
+ * Stale-lock recovery: the lock stores the owner PID; a crashed build
188
+ * (SIGINT, SIGTERM, or a kernel kill) leaves the lock dir behind but
189
+ * not the owning process, and `withFileLock` removes the lock on the
190
+ * next attempt when `process.kill(pid, 0)` shows the owner is gone.
191
+ *
192
+ * The project-root variant is the right granularity: a single machine
193
+ * may have several FireForge projects side by side, and nothing says
194
+ * they cannot build in parallel. The lock serialises *within* one
195
+ * project, not across unrelated ones.
196
+ *
197
+ * Returns whatever the inner operation returns.
198
+ */
199
+ export async function withBuildLock(projectRoot, operation) {
200
+ const lockPath = createSiblingLockPath(join(projectRoot, '.fireforge-build'), '.lock');
201
+ return withFileLock(lockPath, operation, {
202
+ // Default lock timeout is 30s; bump to 24h so a slow full build does
203
+ // not trip the timeout while the second invocation waits. A real
204
+ // operator will ^C long before 24h elapses; the ceiling is there
205
+ // purely so a forgotten lock cannot wedge the command forever.
206
+ timeoutMs: 24 * 60 * 60 * 1000,
207
+ onTimeoutMessage: `Timed out waiting for the FireForge build lock at ${lockPath}. ` +
208
+ 'If no other `fireforge build` is running, remove the lock directory and retry.',
209
+ onStaleLockMessage: (ageMs) => `Removing stale FireForge build lock ${basename(lockPath)} (age: ${Math.round(ageMs / 1000)}s). A previous build process may have crashed.`,
210
+ });
211
+ }
159
212
  /**
160
213
  * Runs the built browser.
161
214
  * @param engineDir - Path to the engine directory