@hominis/fireforge 0.13.2 → 0.14.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 +54 -0
- package/dist/bin/fireforge.js +19 -5
- package/dist/src/commands/config.js +7 -1
- package/dist/src/commands/discard.js +6 -1
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +6 -1
- package/dist/src/commands/download.js +106 -7
- package/dist/src/commands/export-shared.js +7 -0
- package/dist/src/commands/export.js +5 -0
- package/dist/src/commands/furnace/apply.js +147 -47
- package/dist/src/commands/furnace/create.js +13 -2
- package/dist/src/commands/furnace/deploy.js +17 -2
- package/dist/src/commands/furnace/diff.js +3 -1
- package/dist/src/commands/furnace/init.js +25 -7
- package/dist/src/commands/furnace/list.js +15 -7
- package/dist/src/commands/furnace/override.js +47 -15
- package/dist/src/commands/furnace/remove.js +68 -20
- package/dist/src/commands/furnace/rename.js +31 -3
- package/dist/src/commands/furnace/scan.js +8 -0
- package/dist/src/commands/furnace/validate.js +70 -7
- package/dist/src/commands/import.js +65 -11
- package/dist/src/commands/re-export.js +11 -4
- package/dist/src/commands/rebase/abort.js +26 -14
- package/dist/src/commands/rebase/confirm.d.ts +15 -2
- package/dist/src/commands/rebase/confirm.js +2 -2
- package/dist/src/commands/rebase/continue.js +39 -15
- package/dist/src/commands/rebase/index.js +2 -1
- package/dist/src/commands/rebase/patch-loop.js +90 -33
- package/dist/src/commands/register.js +13 -0
- package/dist/src/commands/resolve.js +31 -10
- package/dist/src/commands/run.js +9 -44
- package/dist/src/commands/setup-support.js +25 -7
- package/dist/src/commands/status.js +59 -8
- package/dist/src/commands/test.js +13 -7
- package/dist/src/commands/token.js +11 -1
- package/dist/src/commands/watch.js +51 -1
- package/dist/src/commands/wire.js +23 -0
- package/dist/src/core/config-validate.js +15 -1
- package/dist/src/core/furnace-registration.d.ts +1 -1
- package/dist/src/core/furnace-registration.js +2 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -0
- package/dist/src/core/signal-critical.d.ts +49 -0
- package/dist/src/core/signal-critical.js +80 -0
- package/dist/src/errors/download.d.ts +1 -1
- package/dist/src/errors/download.js +6 -3
- package/package.json +1 -1
package/dist/src/commands/run.js
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { getProjectPaths } from '../core/config.js';
|
|
5
|
-
import {
|
|
6
|
-
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from '../core/furnace-config.js';
|
|
5
|
+
import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
|
|
7
6
|
import { buildArtifactMismatchMessage, hasBuildArtifacts, run } from '../core/mach.js';
|
|
8
7
|
import { GeneralError } from '../errors/base.js';
|
|
9
8
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
10
9
|
import { toError } from '../utils/errors.js';
|
|
11
10
|
import { pathExists, removeDir, removeFile } from '../utils/fs.js';
|
|
12
|
-
import { info, intro, verbose
|
|
11
|
+
import { info, intro, verbose } from '../utils/logger.js';
|
|
13
12
|
/**
|
|
14
13
|
* Cleans the dev profile to prevent stale-state startup failures.
|
|
15
14
|
*
|
|
@@ -47,47 +46,6 @@ async function cleanDevProfile(engineDir) {
|
|
|
47
46
|
verbose(`Non-fatal dev profile cleanup failure: ${toError(error).message}`);
|
|
48
47
|
}
|
|
49
48
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Checks whether any Furnace component has changed since the last apply
|
|
52
|
-
* and warns the user. The build command auto-applies, but run does not,
|
|
53
|
-
* so this advisory message prevents the common "forgot to apply" mistake.
|
|
54
|
-
*/
|
|
55
|
-
async function warnIfFurnaceStale(projectRoot) {
|
|
56
|
-
try {
|
|
57
|
-
if (!(await furnaceConfigExists(projectRoot)))
|
|
58
|
-
return;
|
|
59
|
-
const config = await loadFurnaceConfig(projectRoot);
|
|
60
|
-
const state = await loadFurnaceState(projectRoot);
|
|
61
|
-
const furnacePaths = getFurnacePaths(projectRoot);
|
|
62
|
-
if (!state.appliedChecksums)
|
|
63
|
-
return;
|
|
64
|
-
const stale = [];
|
|
65
|
-
for (const name of Object.keys(config.overrides)) {
|
|
66
|
-
const dir = `${furnacePaths.overridesDir}/${name}`;
|
|
67
|
-
if (!(await pathExists(dir)))
|
|
68
|
-
continue;
|
|
69
|
-
const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
|
|
70
|
-
if (await hasComponentChanged(dir, prev))
|
|
71
|
-
stale.push(name);
|
|
72
|
-
}
|
|
73
|
-
for (const name of Object.keys(config.custom)) {
|
|
74
|
-
const dir = `${furnacePaths.customDir}/${name}`;
|
|
75
|
-
if (!(await pathExists(dir)))
|
|
76
|
-
continue;
|
|
77
|
-
const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
|
|
78
|
-
if (await hasComponentChanged(dir, prev))
|
|
79
|
-
stale.push(name);
|
|
80
|
-
}
|
|
81
|
-
if (stale.length > 0) {
|
|
82
|
-
warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
|
|
83
|
-
'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// Non-fatal: a broken furnace config should not block run.
|
|
88
|
-
verbose('Furnace staleness check skipped due to an error.');
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
49
|
/**
|
|
92
50
|
* Runs the run command to launch the built browser.
|
|
93
51
|
* @param projectRoot - Root directory of the project
|
|
@@ -120,6 +78,13 @@ export async function runCommand(projectRoot) {
|
|
|
120
78
|
await cleanDevProfile(paths.engine);
|
|
121
79
|
info('Launching browser...\n');
|
|
122
80
|
const exitCode = await run(paths.engine);
|
|
81
|
+
// Exit-code whitelist:
|
|
82
|
+
// 0 — clean shutdown
|
|
83
|
+
// 130 — SIGINT (Ctrl+C), user-initiated termination
|
|
84
|
+
// 143 — SIGTERM, graceful-shutdown termination
|
|
85
|
+
// SIGKILL (137) and other signal-induced codes are intentionally NOT
|
|
86
|
+
// whitelisted: those indicate abnormal termination the operator should
|
|
87
|
+
// see surface as a build-time error.
|
|
123
88
|
if (exitCode !== 0 && exitCode !== 130 && exitCode !== 143) {
|
|
124
89
|
throw new BuildError(`Browser exited with code ${exitCode}`, 'mach run');
|
|
125
90
|
}
|
|
@@ -178,23 +178,41 @@ async function promptSetupInputs(options) {
|
|
|
178
178
|
throw new CancellationError();
|
|
179
179
|
},
|
|
180
180
|
});
|
|
181
|
+
return finalizePromptedSetupInputs(project);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Validates the raw prompt result and resolves the canonical
|
|
185
|
+
* {@link ResolvedSetupInputs}. Extracted from {@link promptSetupInputs} so
|
|
186
|
+
* the prompt body stays under the per-function line limit.
|
|
187
|
+
*/
|
|
188
|
+
function finalizePromptedSetupInputs(project) {
|
|
181
189
|
const sanitizedName = project.name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
// Project names that contain no ASCII alphanumerics (e.g. "----", "漢字",
|
|
191
|
+
// emoji-only) collapse to an empty sanitised slug, which would silently
|
|
192
|
+
// produce an invalid `appId` ("org..browser") and an empty `binaryName`.
|
|
193
|
+
// Refuse to derive defaults from such names — the user must supply
|
|
194
|
+
// explicit appId / binaryName values instead.
|
|
195
|
+
const explicitAppId = typeof project.appId === 'string' ? project.appId.trim() : '';
|
|
196
|
+
const explicitBinaryName = typeof project.binaryName === 'string' ? project.binaryName.trim() : '';
|
|
197
|
+
if (sanitizedName === '' && (explicitAppId === '' || explicitBinaryName === '')) {
|
|
198
|
+
throw new InvalidArgumentError(`Project name "${project.name}" contains no characters that can be used to derive default appId / binaryName values. Re-run setup and supply --app-id and --binary-name explicitly.`, 'name');
|
|
199
|
+
}
|
|
200
|
+
const finalAppId = explicitAppId || `org.${sanitizedName}.browser`;
|
|
201
|
+
const finalBinaryName = explicitBinaryName || sanitizedName;
|
|
187
202
|
const finalFirefoxVersion = (typeof project.firefoxVersion === 'string' ? project.firefoxVersion.trim() : '') ||
|
|
188
203
|
'140.9.0esr';
|
|
189
204
|
if (!isValidAppId(finalAppId)) {
|
|
190
205
|
throw new InvalidArgumentError(`Derived appId "${finalAppId}" is invalid.`, 'appId');
|
|
191
206
|
}
|
|
207
|
+
if (finalBinaryName === '') {
|
|
208
|
+
throw new InvalidArgumentError('Derived binaryName is empty. Supply --binary-name explicitly.', 'binaryName');
|
|
209
|
+
}
|
|
192
210
|
if (!isValidFirefoxVersion(finalFirefoxVersion)) {
|
|
193
211
|
throw new InvalidArgumentError(`Default Firefox version "${finalFirefoxVersion}" is invalid.`, 'firefoxVersion');
|
|
194
212
|
}
|
|
195
213
|
return {
|
|
196
|
-
finalName,
|
|
197
|
-
finalVendor,
|
|
214
|
+
finalName: project.name,
|
|
215
|
+
finalVendor: project.vendor,
|
|
198
216
|
finalAppId,
|
|
199
217
|
finalBinaryName,
|
|
200
218
|
finalFirefoxVersion,
|
|
@@ -97,16 +97,64 @@ function renderRawStatus(files) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
100
|
+
* Default maximum number of files we will materialise from a single
|
|
101
|
+
* untracked directory. Pathological inputs (an accidental dump of build
|
|
102
|
+
* output, a symlink that resolves into a huge unrelated tree, etc.)
|
|
103
|
+
* should not be able to balloon `status` into multi-gigabyte memory or
|
|
104
|
+
* hang the CLI. Going over this cap surfaces a warning so the user knows
|
|
105
|
+
* the listing has been truncated, and it bounds the JSON / default
|
|
106
|
+
* rendering paths.
|
|
107
|
+
*
|
|
108
|
+
* Override via the `FIREFORGE_MAX_UNTRACKED_FILES` environment variable
|
|
109
|
+
* for monorepos or fixture-heavy projects with legitimately large
|
|
110
|
+
* untracked directories.
|
|
103
111
|
*/
|
|
112
|
+
const DEFAULT_MAX_UNTRACKED_FILES_PER_DIR = 5000;
|
|
113
|
+
function resolveMaxUntrackedFilesPerDir() {
|
|
114
|
+
const raw = process.env['FIREFORGE_MAX_UNTRACKED_FILES'];
|
|
115
|
+
if (raw === undefined || raw.length === 0)
|
|
116
|
+
return DEFAULT_MAX_UNTRACKED_FILES_PER_DIR;
|
|
117
|
+
const parsed = Number.parseInt(raw, 10);
|
|
118
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
119
|
+
warn(`Ignoring FIREFORGE_MAX_UNTRACKED_FILES="${raw}" — expected a positive integer. Falling back to ${DEFAULT_MAX_UNTRACKED_FILES_PER_DIR}.`);
|
|
120
|
+
return DEFAULT_MAX_UNTRACKED_FILES_PER_DIR;
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
const MAX_UNTRACKED_FILES_PER_DIR = resolveMaxUntrackedFilesPerDir();
|
|
125
|
+
/**
|
|
126
|
+
* Emits a prominent top-of-output warning when one or more untracked
|
|
127
|
+
* directories were truncated during expansion. Individual per-dir warnings
|
|
128
|
+
* already fired inside expandDirectoryEntries but are easily lost in
|
|
129
|
+
* scrollback for large status outputs; this banner summarises the total
|
|
130
|
+
* hidden count so the user doesn't miss that an export based on this
|
|
131
|
+
* status would be incomplete.
|
|
132
|
+
*/
|
|
133
|
+
function renderTruncationBanner(truncations) {
|
|
134
|
+
if (truncations.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
const hidden = truncations.reduce((sum, rec) => sum + (rec.total - rec.shown), 0);
|
|
137
|
+
const dirList = truncations.map((r) => `${r.dir} (${r.total - r.shown} hidden)`).join(', ');
|
|
138
|
+
warn(`⚠ Status output is truncated: ${hidden.toLocaleString()} untracked file(s) across ${truncations.length} director(y/ies) are not shown. ` +
|
|
139
|
+
`Truncated: ${dirList}. ` +
|
|
140
|
+
`Add a .gitignore entry or clean the directory before exporting, otherwise the export will omit these files.`);
|
|
141
|
+
}
|
|
104
142
|
async function expandDirectoryEntries(files, engineDir) {
|
|
105
143
|
const expanded = [];
|
|
144
|
+
const truncations = [];
|
|
106
145
|
for (const entry of files) {
|
|
107
146
|
if (entry.file.endsWith('/') && entry.status.includes('?')) {
|
|
108
147
|
const individualFiles = await getUntrackedFilesInDir(engineDir, entry.file);
|
|
109
|
-
|
|
148
|
+
if (individualFiles.length > MAX_UNTRACKED_FILES_PER_DIR) {
|
|
149
|
+
warn(`Untracked directory ${entry.file} contains ${individualFiles.length} files — only the first ${MAX_UNTRACKED_FILES_PER_DIR} will be classified. Consider adding a .gitignore entry.`);
|
|
150
|
+
truncations.push({
|
|
151
|
+
dir: entry.file,
|
|
152
|
+
total: individualFiles.length,
|
|
153
|
+
shown: MAX_UNTRACKED_FILES_PER_DIR,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const limited = individualFiles.slice(0, MAX_UNTRACKED_FILES_PER_DIR);
|
|
157
|
+
for (const f of limited) {
|
|
110
158
|
expanded.push({ status: '??', file: f });
|
|
111
159
|
}
|
|
112
160
|
}
|
|
@@ -114,7 +162,7 @@ async function expandDirectoryEntries(files, engineDir) {
|
|
|
114
162
|
expanded.push(entry);
|
|
115
163
|
}
|
|
116
164
|
}
|
|
117
|
-
return expanded;
|
|
165
|
+
return { entries: expanded, truncations };
|
|
118
166
|
}
|
|
119
167
|
/**
|
|
120
168
|
* Classifies files into patch-backed, unmanaged, or branding buckets.
|
|
@@ -226,9 +274,11 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
226
274
|
throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
|
|
227
275
|
}
|
|
228
276
|
const manifest = await loadPatchesManifest(paths.patches);
|
|
229
|
-
const
|
|
277
|
+
const ownershipExpansion = (await isGitRepository(paths.engine))
|
|
230
278
|
? await expandDirectoryEntries(await getStatusWithCodes(paths.engine), paths.engine)
|
|
231
|
-
: [];
|
|
279
|
+
: { entries: [], truncations: [] };
|
|
280
|
+
const rawFilesOwnership = ownershipExpansion.entries;
|
|
281
|
+
renderTruncationBanner(ownershipExpansion.truncations);
|
|
232
282
|
// Only walk the patch bodies when the directory actually exists.
|
|
233
283
|
// Fresh projects with no patch queue yet pass through with an empty
|
|
234
284
|
// creators map, which degrades to the old filesAffected-only
|
|
@@ -263,7 +313,8 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
263
313
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
264
314
|
}
|
|
265
315
|
const rawFiles = await getStatusWithCodes(paths.engine);
|
|
266
|
-
const files = await expandDirectoryEntries(rawFiles, paths.engine);
|
|
316
|
+
const { entries: files, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
|
|
317
|
+
renderTruncationBanner(truncations);
|
|
267
318
|
if (files.length === 0) {
|
|
268
319
|
info('No modified files');
|
|
269
320
|
outro('Working tree clean');
|
|
@@ -9,20 +9,26 @@ import { pathExists } from '../utils/fs.js';
|
|
|
9
9
|
import { info, intro, spinner } from '../utils/logger.js';
|
|
10
10
|
import { pickDefined } from '../utils/options.js';
|
|
11
11
|
/**
|
|
12
|
-
* Strips
|
|
12
|
+
* Strips a leading "engine/" or "engine\\" prefix from a path if present.
|
|
13
13
|
* Users may specify paths like "engine/browser/modules/..." from the project
|
|
14
14
|
* root, but mach test expects paths relative to the engine directory.
|
|
15
|
+
*
|
|
16
|
+
* The match is case-insensitive because case-insensitive filesystems
|
|
17
|
+
* (default macOS, Windows) treat "Engine/" and "engine/" as the same
|
|
18
|
+
* directory, and a literal lowercase-only check left mach with a
|
|
19
|
+
* non-stripped prefix that resolved to a different path under the engine
|
|
20
|
+
* tree. Tab and other whitespace before the prefix is also ignored.
|
|
21
|
+
*
|
|
15
22
|
* @param testPath - Path as provided by the user
|
|
16
23
|
* @returns Path relative to the engine directory
|
|
17
24
|
*/
|
|
18
25
|
function normalizeTestPath(testPath) {
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
const trimmed = testPath.trim();
|
|
27
|
+
const match = /^engine[/\\]/i.exec(trimmed);
|
|
28
|
+
if (match) {
|
|
29
|
+
return trimmed.slice(match[0].length);
|
|
21
30
|
}
|
|
22
|
-
|
|
23
|
-
return testPath.slice('engine\\'.length);
|
|
24
|
-
}
|
|
25
|
-
return testPath;
|
|
31
|
+
return trimmed;
|
|
26
32
|
}
|
|
27
33
|
async function assertTestPathsExist(engineDir, testPaths) {
|
|
28
34
|
const missingPaths = [];
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { Option } from 'commander';
|
|
1
3
|
import { loadConfig } from '../core/config.js';
|
|
2
4
|
import { loadFurnaceConfig } from '../core/furnace-config.js';
|
|
3
5
|
import { addToken, getTokensCssPath, validateTokenAdd, } from '../core/token-manager.js';
|
|
@@ -96,7 +98,15 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
96
98
|
.command('add <token-name> <value>')
|
|
97
99
|
.description('Add a design token to CSS and documentation')
|
|
98
100
|
.requiredOption('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing")')
|
|
99
|
-
.
|
|
101
|
+
.addOption(
|
|
102
|
+
// Use Commander's .choices() so invalid --mode values are rejected with
|
|
103
|
+
// the built-in "argument must be one of …" message and --help lists the
|
|
104
|
+
// valid choices up-front. The runtime check in tokenAddCommand remains
|
|
105
|
+
// as a defence-in-depth guard for programmatic callers that bypass
|
|
106
|
+
// Commander's argument parsing.
|
|
107
|
+
new Option('--mode <mode>', 'Dark mode behavior')
|
|
108
|
+
.choices(['auto', 'static', 'override'])
|
|
109
|
+
.makeOptionMandatory(true))
|
|
100
110
|
.option('--description <desc>', 'Comment description for the CSS file')
|
|
101
111
|
.option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
|
|
102
112
|
.option('--dry-run', 'Show what would be changed without writing')
|
|
@@ -1,10 +1,49 @@
|
|
|
1
1
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
2
|
+
import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
|
|
2
3
|
import { buildArtifactMismatchMessage, generateMozconfig, hasBuildArtifacts, watchWithOutput, } from '../core/mach.js';
|
|
3
4
|
import { GeneralError } from '../errors/base.js';
|
|
4
5
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
6
|
+
import { toError } from '../utils/errors.js';
|
|
5
7
|
import { pathExists } from '../utils/fs.js';
|
|
6
8
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
7
|
-
import { executableExists } from '../utils/process.js';
|
|
9
|
+
import { exec, executableExists } from '../utils/process.js';
|
|
10
|
+
const WATCHMAN_PROBE_TIMEOUT_MS = 5000;
|
|
11
|
+
/**
|
|
12
|
+
* Probes watchman by running `watchman --version`. A binary that exists
|
|
13
|
+
* in PATH but cannot respond (corrupt install, server crashed mid-session,
|
|
14
|
+
* permission denied on the state directory) would otherwise surface as a
|
|
15
|
+
* confusing mid-watch failure. Returns the trimmed version string when
|
|
16
|
+
* the probe succeeds; throws a {@link GeneralError} with actionable
|
|
17
|
+
* remediation when it does not.
|
|
18
|
+
*/
|
|
19
|
+
async function probeWatchman() {
|
|
20
|
+
try {
|
|
21
|
+
const result = await exec('watchman', ['--version'], {
|
|
22
|
+
timeout: WATCHMAN_PROBE_TIMEOUT_MS,
|
|
23
|
+
});
|
|
24
|
+
if (result.exitCode !== 0) {
|
|
25
|
+
throw new GeneralError(`Watchman is installed but "watchman --version" exited ${result.exitCode}.\n\n` +
|
|
26
|
+
(result.stderr.trim() ? `Output:\n${result.stderr.trim()}\n\n` : '') +
|
|
27
|
+
'Re-install or repair watchman, then rerun "fireforge watch".');
|
|
28
|
+
}
|
|
29
|
+
const version = result.stdout.trim();
|
|
30
|
+
if (!version) {
|
|
31
|
+
throw new GeneralError('Watchman is installed but "watchman --version" produced no output. ' +
|
|
32
|
+
'Re-install or repair watchman, then rerun "fireforge watch".');
|
|
33
|
+
}
|
|
34
|
+
return version;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof GeneralError)
|
|
38
|
+
throw error;
|
|
39
|
+
throw new GeneralError(`Watchman is installed but did not respond within ${WATCHMAN_PROBE_TIMEOUT_MS}ms.\n\n` +
|
|
40
|
+
`Underlying cause: ${toError(error).message}\n\n` +
|
|
41
|
+
'Common fixes:\n' +
|
|
42
|
+
' - Restart watchman: "watchman shutdown-server" then retry\n' +
|
|
43
|
+
" - Check filesystem permissions on watchman's state directory\n" +
|
|
44
|
+
' - Re-install watchman if the binary is corrupt');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
8
47
|
/**
|
|
9
48
|
* Builds remediation guidance for objdirs configured before watchman was available.
|
|
10
49
|
* @returns User-facing configure-time watchman guidance
|
|
@@ -51,6 +90,11 @@ export async function watchCommand(projectRoot) {
|
|
|
51
90
|
throw new GeneralError('Watch mode requires watchman to be installed and available in PATH.\n\n' +
|
|
52
91
|
'Install watchman first, then rerun "fireforge watch".');
|
|
53
92
|
}
|
|
93
|
+
// Verify watchman actually responds — a binary that is in PATH but
|
|
94
|
+
// unable to respond (broken install, crashed server, bad state dir
|
|
95
|
+
// permissions) would otherwise surface as a confusing mid-build failure
|
|
96
|
+
// instead of an actionable preflight error.
|
|
97
|
+
await probeWatchman();
|
|
54
98
|
// Check for build artifacts before starting watch
|
|
55
99
|
const buildCheck = await hasBuildArtifacts(paths.engine);
|
|
56
100
|
if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
|
|
@@ -71,6 +115,12 @@ export async function watchCommand(projectRoot) {
|
|
|
71
115
|
"Run 'fireforge build' first to create the initial build, then run 'fireforge watch'.");
|
|
72
116
|
}
|
|
73
117
|
info(`Using build artifacts from ${buildCheck.objDir}/`);
|
|
118
|
+
// Advisory: warn when Furnace components have drifted since the last
|
|
119
|
+
// apply so the user doesn't launch watch-mode builds with stale
|
|
120
|
+
// components baked in. Mirrors the check in `fireforge run` — without
|
|
121
|
+
// it, users editing a component then running `watch` would see their
|
|
122
|
+
// change never surface in the rebuilt browser.
|
|
123
|
+
await warnIfFurnaceStale(projectRoot);
|
|
74
124
|
// Generate mozconfig (in case it's not up to date)
|
|
75
125
|
const mozconfigSpinner = spinner('Generating mozconfig...');
|
|
76
126
|
try {
|
|
@@ -28,6 +28,21 @@ function printWireDryRun(engineDir, name, subscriptDir, domFilePath, options) {
|
|
|
28
28
|
info(` jar.mn: content/browser/${name}.js (${relPath}/${name}.js)`);
|
|
29
29
|
outro('Dry run complete');
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Validates a subscript name supplied on the command line. Subscripts are
|
|
33
|
+
* resolved into filenames under the subscript directory and registered in
|
|
34
|
+
* jar.mn by this name, so any path separator or `..` segment would let
|
|
35
|
+
* the caller write outside the intended directory or corrupt the manifest.
|
|
36
|
+
* Mirrors the validation already applied to setup's binaryName and furnace
|
|
37
|
+
* custom component targetPath.
|
|
38
|
+
*/
|
|
39
|
+
function validateWireName(name) {
|
|
40
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
|
|
41
|
+
throw new InvalidArgumentError(`Subscript name "${name}" is invalid. ` +
|
|
42
|
+
'Names must start with a letter or underscore and contain only letters, digits, underscores, or hyphens. ' +
|
|
43
|
+
'Path separators and parent-directory segments are not permitted.', 'name');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
31
46
|
/**
|
|
32
47
|
* Wires a chrome subscript into the browser.
|
|
33
48
|
*
|
|
@@ -37,6 +52,14 @@ function printWireDryRun(engineDir, name, subscriptDir, domFilePath, options) {
|
|
|
37
52
|
*/
|
|
38
53
|
export async function wireCommand(projectRoot, name, options = {}) {
|
|
39
54
|
intro('Wire');
|
|
55
|
+
validateWireName(name);
|
|
56
|
+
if (options.after !== undefined) {
|
|
57
|
+
// --after references an existing init block by its subscript name, so
|
|
58
|
+
// it must follow the same naming rules as `name` itself. Without this
|
|
59
|
+
// check, a caller could sneak a path-traversal segment in through
|
|
60
|
+
// --after and have it forwarded unchanged to the lookup layer.
|
|
61
|
+
validateWireName(options.after);
|
|
62
|
+
}
|
|
40
63
|
consumeParserFallbackEvents();
|
|
41
64
|
// Resolve subscript directory: CLI flag > fireforge.json > default
|
|
42
65
|
let subscriptDir = DEFAULT_BROWSER_SUBSCRIPT_DIR;
|
|
@@ -22,11 +22,25 @@ export function validateConfig(data) {
|
|
|
22
22
|
catch {
|
|
23
23
|
throw new ConfigError('Config must be an object');
|
|
24
24
|
}
|
|
25
|
-
// Required string fields
|
|
25
|
+
// Required string fields. Empty strings would technically pass the
|
|
26
|
+
// typeof-check below but are never valid for any of these identifier
|
|
27
|
+
// fields — rejecting them here prevents downstream code (Firefox build,
|
|
28
|
+
// launcher binary lookup, AppID assertions) from failing with confusing
|
|
29
|
+
// errors much later.
|
|
26
30
|
const name = requireConfigString(rec, 'name');
|
|
27
31
|
const vendor = requireConfigString(rec, 'vendor');
|
|
28
32
|
const appId = requireConfigString(rec, 'appId');
|
|
29
33
|
const binaryName = requireConfigString(rec, 'binaryName');
|
|
34
|
+
for (const [field, value] of [
|
|
35
|
+
['name', name],
|
|
36
|
+
['vendor', vendor],
|
|
37
|
+
['appId', appId],
|
|
38
|
+
['binaryName', binaryName],
|
|
39
|
+
]) {
|
|
40
|
+
if (value.trim() === '') {
|
|
41
|
+
throw new ConfigError(`Config field "${field}" must not be empty`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
30
44
|
if (binaryName.includes('..') ||
|
|
31
45
|
binaryName.includes('/') ||
|
|
32
46
|
binaryName.includes('\\') ||
|
|
@@ -34,7 +34,7 @@ export { addCustomElementRegistration, removeCustomElementRegistration, validate
|
|
|
34
34
|
* @param tagName - Custom element tag name
|
|
35
35
|
* @param files - Filenames to register (e.g. ["moz-widget.mjs", "moz-widget.css"])
|
|
36
36
|
*/
|
|
37
|
-
export declare function addJarMnEntries(engineDir: string, tagName: string, files: string[]): Promise<
|
|
37
|
+
export declare function addJarMnEntries(engineDir: string, tagName: string, files: string[]): Promise<number>;
|
|
38
38
|
/**
|
|
39
39
|
* Removes all jar.mn entries for a given tag name.
|
|
40
40
|
*
|
|
@@ -69,7 +69,7 @@ export async function addJarMnEntries(engineDir, tagName, files) {
|
|
|
69
69
|
// check so that "moz-card.css" does not match "moz-card-group.css".
|
|
70
70
|
const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeForRegex(f)}(?:\\s|$)`, 'm').test(content));
|
|
71
71
|
if (newFiles.length === 0)
|
|
72
|
-
return;
|
|
72
|
+
return 0;
|
|
73
73
|
// Build new entry lines using the indent detected from existing entries.
|
|
74
74
|
const indent = detectJarMnIndent(lines);
|
|
75
75
|
const newEntries = newFiles.map((f) => `${indent}content/global/elements/${f} (widgets/${tagName}/${f})`);
|
|
@@ -111,6 +111,7 @@ export async function addJarMnEntries(engineDir, tagName, files) {
|
|
|
111
111
|
lines.splice(insertIndex, 0, ...newEntries);
|
|
112
112
|
content = lines.join('\n');
|
|
113
113
|
await writeText(filePath, content);
|
|
114
|
+
return newFiles.length;
|
|
114
115
|
}
|
|
115
116
|
/**
|
|
116
117
|
* Removes all jar.mn entries for a given tag name.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Furnace staleness advisory — shared between `fireforge run` and
|
|
3
|
+
* `fireforge watch`. Both commands launch the built browser without
|
|
4
|
+
* first running `furnace apply`, so this helper surfaces a warning when
|
|
5
|
+
* component files have drifted from the last-applied checksums and the
|
|
6
|
+
* user is about to run with stale engine state.
|
|
7
|
+
*
|
|
8
|
+
* The check is advisory only: errors (broken furnace config, partial
|
|
9
|
+
* state, transient filesystem failure) must never block the caller.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Emits a warning when any tracked override or custom component has
|
|
13
|
+
* changed on disk since the last apply. Safe to call from any build-time
|
|
14
|
+
* command that does not auto-apply — a failure inside the probe is
|
|
15
|
+
* downgraded to a verbose log and the caller continues.
|
|
16
|
+
*/
|
|
17
|
+
export declare function warnIfFurnaceStale(projectRoot: string): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Furnace staleness advisory — shared between `fireforge run` and
|
|
4
|
+
* `fireforge watch`. Both commands launch the built browser without
|
|
5
|
+
* first running `furnace apply`, so this helper surfaces a warning when
|
|
6
|
+
* component files have drifted from the last-applied checksums and the
|
|
7
|
+
* user is about to run with stale engine state.
|
|
8
|
+
*
|
|
9
|
+
* The check is advisory only: errors (broken furnace config, partial
|
|
10
|
+
* state, transient filesystem failure) must never block the caller.
|
|
11
|
+
*/
|
|
12
|
+
import { pathExists } from '../utils/fs.js';
|
|
13
|
+
import { verbose, warn } from '../utils/logger.js';
|
|
14
|
+
import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
|
|
15
|
+
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
|
|
16
|
+
/**
|
|
17
|
+
* Emits a warning when any tracked override or custom component has
|
|
18
|
+
* changed on disk since the last apply. Safe to call from any build-time
|
|
19
|
+
* command that does not auto-apply — a failure inside the probe is
|
|
20
|
+
* downgraded to a verbose log and the caller continues.
|
|
21
|
+
*/
|
|
22
|
+
export async function warnIfFurnaceStale(projectRoot) {
|
|
23
|
+
try {
|
|
24
|
+
if (!(await furnaceConfigExists(projectRoot)))
|
|
25
|
+
return;
|
|
26
|
+
const config = await loadFurnaceConfig(projectRoot);
|
|
27
|
+
const state = await loadFurnaceState(projectRoot);
|
|
28
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
29
|
+
if (!state.appliedChecksums)
|
|
30
|
+
return;
|
|
31
|
+
const stale = [];
|
|
32
|
+
for (const name of Object.keys(config.overrides)) {
|
|
33
|
+
const dir = `${furnacePaths.overridesDir}/${name}`;
|
|
34
|
+
if (!(await pathExists(dir)))
|
|
35
|
+
continue;
|
|
36
|
+
const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
|
|
37
|
+
if (await hasComponentChanged(dir, prev))
|
|
38
|
+
stale.push(name);
|
|
39
|
+
}
|
|
40
|
+
for (const name of Object.keys(config.custom)) {
|
|
41
|
+
const dir = `${furnacePaths.customDir}/${name}`;
|
|
42
|
+
if (!(await pathExists(dir)))
|
|
43
|
+
continue;
|
|
44
|
+
const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
|
|
45
|
+
if (await hasComponentChanged(dir, prev))
|
|
46
|
+
stale.push(name);
|
|
47
|
+
}
|
|
48
|
+
if (stale.length > 0) {
|
|
49
|
+
warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
|
|
50
|
+
'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Non-fatal: a broken furnace config should not block the caller.
|
|
55
|
+
verbose('Furnace staleness check skipped due to an error.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=furnace-staleness.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-deferred critical sections.
|
|
3
|
+
*
|
|
4
|
+
* Commands that perform a compound mutation (e.g. "apply a patch to the
|
|
5
|
+
* engine, then persist progress to a session file on disk") need to finish
|
|
6
|
+
* the pair atomically with respect to SIGINT / SIGTERM. The furnace rollback
|
|
7
|
+
* mechanism is not the right tool here: rebase-style operations intentionally
|
|
8
|
+
* leave the engine mutated and only need the on-disk bookkeeping write to
|
|
9
|
+
* complete before the process exits.
|
|
10
|
+
*
|
|
11
|
+
* `runInSignalCriticalSection(fn)` wraps a short body in a registry slot.
|
|
12
|
+
* While the body runs, the CLI entry point's SIGINT / SIGTERM handlers wait
|
|
13
|
+
* for the slot to clear before calling `process.exit`, so a signal that
|
|
14
|
+
* lands mid-body is held until the body's state write finishes.
|
|
15
|
+
*
|
|
16
|
+
* This module is a pure runtime registry — it installs no signal handlers
|
|
17
|
+
* itself. The bin entry point is responsible for awaiting
|
|
18
|
+
* `waitForActiveCriticalSections` before terminating.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Runs `fn` inside a signal-deferred critical section. The CLI entry point's
|
|
22
|
+
* signal handlers `await` every active section before exiting, so a SIGINT or
|
|
23
|
+
* SIGTERM that arrives during `fn` will hold exit until `fn` returns (or
|
|
24
|
+
* rejects).
|
|
25
|
+
*
|
|
26
|
+
* `fn` should be short — anything that takes longer than the bounded wait in
|
|
27
|
+
* the bin handler (`SIGNAL_CRITICAL_SECTION_TIMEOUT_MS`) will time out and
|
|
28
|
+
* the handler will exit anyway. The intent is "guard the apply + state
|
|
29
|
+
* persist pair," not "postpone exit indefinitely."
|
|
30
|
+
*/
|
|
31
|
+
export declare function runInSignalCriticalSection<T>(label: string, fn: () => Promise<T>): Promise<T>;
|
|
32
|
+
/**
|
|
33
|
+
* Returns true while any critical section is currently running. Used by the
|
|
34
|
+
* bin entry point's signal handler to decide whether to await before exit.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hasActiveCriticalSection(): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Waits for every active critical section to complete or for `timeoutMs` to
|
|
39
|
+
* elapse, whichever comes first. Never rejects: a section that throws still
|
|
40
|
+
* resolves from the registry's perspective because `runInSignalCriticalSection`
|
|
41
|
+
* cleans up in `finally`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function waitForActiveCriticalSections(timeoutMs: number): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Test-only helper: clears the critical-section registry. Production code
|
|
46
|
+
* must never call this — it voids the exit-ordering guarantee for any
|
|
47
|
+
* section still in flight.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resetCriticalSectionsForTests(): void;
|