@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.
- package/CHANGELOG.md +39 -1
- package/README.md +11 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/download.js +44 -13
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/create.js +2 -0
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +40 -16
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +23 -3
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +56 -10
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +59 -6
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-destroy.js +18 -5
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- package/dist/src/core/wire-init.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/src/core/config.js
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
package/dist/src/core/mach.d.ts
CHANGED
|
@@ -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
|
package/dist/src/core/mach.js
CHANGED
|
@@ -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
|
|
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(
|
|
120
|
-
const
|
|
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
|
|
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
|
|
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
|