@hominis/fireforge 0.13.2 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +20 -1
- 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-templates.d.ts +26 -0
- package/dist/src/commands/furnace/create-templates.js +86 -0
- package/dist/src/commands/furnace/create.js +77 -103
- package/dist/src/commands/furnace/deploy.js +20 -5
- 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 +33 -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-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate.js +47 -1
- package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
- package/dist/src/core/furnace-apply-ftl.js +102 -0
- package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
- package/dist/src/core/furnace-apply-helpers.js +16 -12
- package/dist/src/core/furnace-apply.js +7 -4
- package/dist/src/core/furnace-config-tokens.d.ts +11 -0
- package/dist/src/core/furnace-config-tokens.js +28 -0
- package/dist/src/core/furnace-config.d.ts +6 -0
- package/dist/src/core/furnace-config.js +8 -1
- package/dist/src/core/furnace-constants.d.ts +20 -0
- package/dist/src/core/furnace-constants.js +32 -0
- package/dist/src/core/furnace-registration-ast.d.ts +13 -1
- package/dist/src/core/furnace-registration-ast.js +58 -25
- package/dist/src/core/furnace-registration.d.ts +28 -1
- package/dist/src/core/furnace-registration.js +98 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -0
- package/dist/src/core/furnace-validate-accessibility.js +8 -2
- package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
- package/dist/src/core/furnace-validate-helpers.js +81 -0
- package/dist/src/core/furnace-validate-registration.d.ts +8 -2
- package/dist/src/core/furnace-validate-registration.js +34 -9
- package/dist/src/core/furnace-validate.js +2 -2
- package/dist/src/core/marionette-preflight.d.ts +39 -0
- package/dist/src/core/marionette-preflight.js +210 -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/dist/src/types/commands/options.d.ts +6 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +8 -0
- package/dist/src/utils/process.d.ts +15 -2
- package/dist/src/utils/process.js +73 -0
- 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');
|
|
@@ -3,26 +3,33 @@ 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 { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
|
|
6
7
|
import { GeneralError } from '../errors/base.js';
|
|
7
8
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
8
9
|
import { pathExists } from '../utils/fs.js';
|
|
9
10
|
import { info, intro, spinner } from '../utils/logger.js';
|
|
10
11
|
import { pickDefined } from '../utils/options.js';
|
|
11
12
|
/**
|
|
12
|
-
* Strips
|
|
13
|
+
* Strips a leading "engine/" or "engine\\" prefix from a path if present.
|
|
13
14
|
* Users may specify paths like "engine/browser/modules/..." from the project
|
|
14
15
|
* root, but mach test expects paths relative to the engine directory.
|
|
16
|
+
*
|
|
17
|
+
* The match is case-insensitive because case-insensitive filesystems
|
|
18
|
+
* (default macOS, Windows) treat "Engine/" and "engine/" as the same
|
|
19
|
+
* directory, and a literal lowercase-only check left mach with a
|
|
20
|
+
* non-stripped prefix that resolved to a different path under the engine
|
|
21
|
+
* tree. Tab and other whitespace before the prefix is also ignored.
|
|
22
|
+
*
|
|
15
23
|
* @param testPath - Path as provided by the user
|
|
16
24
|
* @returns Path relative to the engine directory
|
|
17
25
|
*/
|
|
18
26
|
function normalizeTestPath(testPath) {
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
const trimmed = testPath.trim();
|
|
28
|
+
const match = /^engine[/\\]/i.exec(trimmed);
|
|
29
|
+
if (match) {
|
|
30
|
+
return trimmed.slice(match[0].length);
|
|
21
31
|
}
|
|
22
|
-
|
|
23
|
-
return testPath.slice('engine\\'.length);
|
|
24
|
-
}
|
|
25
|
-
return testPath;
|
|
32
|
+
return trimmed;
|
|
26
33
|
}
|
|
27
34
|
async function assertTestPathsExist(engineDir, testPaths) {
|
|
28
35
|
const missingPaths = [];
|
|
@@ -111,6 +118,24 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
111
118
|
s.stop('Build complete');
|
|
112
119
|
info('');
|
|
113
120
|
}
|
|
121
|
+
// `--doctor` runs a short marionette handshake probe. When test paths are
|
|
122
|
+
// supplied the probe gates the mach test invocation (a FAIL bails out). When
|
|
123
|
+
// no paths are supplied this is the only step — it's the fastest way to tell
|
|
124
|
+
// marionette-wedged apart from test-discovery-failure.
|
|
125
|
+
if (options.doctor) {
|
|
126
|
+
info('Running marionette preflight...');
|
|
127
|
+
const preflight = await runMarionettePreflight(paths.engine);
|
|
128
|
+
reportMarionettePreflight(preflight);
|
|
129
|
+
if (testPaths.length === 0) {
|
|
130
|
+
if (!preflight.ok) {
|
|
131
|
+
throw new GeneralError('Marionette preflight reported FAIL — see output above.');
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!preflight.ok) {
|
|
136
|
+
throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
114
139
|
// Normalize test paths (strip engine/ prefix if present)
|
|
115
140
|
const normalizedPaths = testPaths.map(normalizeTestPath);
|
|
116
141
|
await assertTestPathsExist(paths.engine, normalizedPaths);
|
|
@@ -143,6 +168,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
143
168
|
.description('Run tests via mach test')
|
|
144
169
|
.option('--headless', 'Run tests in headless mode')
|
|
145
170
|
.option('--build', 'Run incremental UI build before testing')
|
|
171
|
+
.option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
|
|
146
172
|
.action(withErrorHandling(async (paths, options) => {
|
|
147
173
|
await testCommand(getProjectRoot(), paths, pickDefined(options));
|
|
148
174
|
}));
|
|
@@ -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;
|
|
@@ -17,9 +17,9 @@ export declare const CONFIGS_DIR = "configs";
|
|
|
17
17
|
/** Name of the source directory */
|
|
18
18
|
export declare const SRC_DIR = "src";
|
|
19
19
|
/** Supported top-level fireforge.json keys backed by the current schema. */
|
|
20
|
-
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint"];
|
|
20
|
+
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "markerComment"];
|
|
21
21
|
/** Supported config paths that can be read or set without --force. */
|
|
22
|
-
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist"];
|
|
22
|
+
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist", "markerComment"];
|
|
23
23
|
/**
|
|
24
24
|
* Gets all project paths based on a root directory.
|
|
25
25
|
* @param root - Root directory of the project
|
|
@@ -28,6 +28,7 @@ export const SUPPORTED_CONFIG_ROOT_KEYS = [
|
|
|
28
28
|
'license',
|
|
29
29
|
'wire',
|
|
30
30
|
'patchLint',
|
|
31
|
+
'markerComment',
|
|
31
32
|
];
|
|
32
33
|
/** Supported config paths that can be read or set without --force. */
|
|
33
34
|
export const SUPPORTED_CONFIG_PATHS = [
|
|
@@ -46,6 +47,7 @@ export const SUPPORTED_CONFIG_PATHS = [
|
|
|
46
47
|
'patchLint',
|
|
47
48
|
'patchLint.checkJs',
|
|
48
49
|
'patchLint.rawColorAllowlist',
|
|
50
|
+
'markerComment',
|
|
49
51
|
];
|
|
50
52
|
/**
|
|
51
53
|
* Gets all project paths based on a root directory.
|
|
@@ -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('\\') ||
|
|
@@ -107,6 +121,11 @@ export function validateConfig(data) {
|
|
|
107
121
|
}
|
|
108
122
|
config.license = licenseRaw;
|
|
109
123
|
}
|
|
124
|
+
// Marker comment — appended to lines FireForge writes into upstream files.
|
|
125
|
+
const markerComment = parseMarkerComment(rec.raw('markerComment'));
|
|
126
|
+
if (markerComment !== undefined) {
|
|
127
|
+
config.markerComment = markerComment;
|
|
128
|
+
}
|
|
110
129
|
// PatchLint
|
|
111
130
|
const patchLintRec = optionalConfigObject(rec, 'patchLint');
|
|
112
131
|
if (patchLintRec) {
|
|
@@ -153,6 +172,33 @@ function optionalConfigString(rec, key, label) {
|
|
|
153
172
|
}
|
|
154
173
|
return value;
|
|
155
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Validates a raw `markerComment` value. Rejected values: non-strings, empty
|
|
177
|
+
* strings, surrounding whitespace (ambiguous format), newlines (would break
|
|
178
|
+
* source formatting), and `*/` (would terminate an enclosing block comment
|
|
179
|
+
* downstream). Control characters are rejected for the same reason.
|
|
180
|
+
*/
|
|
181
|
+
function parseMarkerComment(raw) {
|
|
182
|
+
if (raw === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (typeof raw !== 'string') {
|
|
185
|
+
throw new ConfigError('Config field "markerComment" must be a string');
|
|
186
|
+
}
|
|
187
|
+
if (raw.trim() === '') {
|
|
188
|
+
throw new ConfigError('Config field "markerComment" must not be empty');
|
|
189
|
+
}
|
|
190
|
+
if (raw !== raw.trim()) {
|
|
191
|
+
throw new ConfigError('Config field "markerComment" must not have leading or trailing whitespace');
|
|
192
|
+
}
|
|
193
|
+
if (/[\n\r]/.test(raw) || raw.includes('*/')) {
|
|
194
|
+
throw new ConfigError('Config field "markerComment" must not contain newlines or "*/"');
|
|
195
|
+
}
|
|
196
|
+
// eslint-disable-next-line no-control-regex -- intentionally rejecting control chars
|
|
197
|
+
if (/[\x00-\x1f]/.test(raw)) {
|
|
198
|
+
throw new ConfigError('Config field "markerComment" must not contain control characters');
|
|
199
|
+
}
|
|
200
|
+
return raw;
|
|
201
|
+
}
|
|
156
202
|
function optionalConfigObject(rec, key) {
|
|
157
203
|
const value = rec.raw(key);
|
|
158
204
|
if (value === undefined)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.ftl` apply/undeploy helpers for custom components. Extracted from
|
|
3
|
+
* `furnace-apply-helpers.ts` so the main helper module stays under the
|
|
4
|
+
* per-file LOC budget.
|
|
5
|
+
*
|
|
6
|
+
* Every helper here degrades gracefully: if the locale jar.mn is missing or
|
|
7
|
+
* the FTL tree is non-standard, apply logs a `stepError` rather than
|
|
8
|
+
* aborting the whole command. Missing jar.mn on a fork without a locale
|
|
9
|
+
* package should not block a working `.mjs`/`.css` from shipping.
|
|
10
|
+
*/
|
|
11
|
+
import type { DryRunAction, StepError } from '../types/furnace.js';
|
|
12
|
+
import { type RollbackJournal } from './furnace-rollback.js';
|
|
13
|
+
/**
|
|
14
|
+
* Copies a component's `.ftl` into the FTL tree and registers the chrome URI
|
|
15
|
+
* in the locale jar.mn.
|
|
16
|
+
*
|
|
17
|
+
* Failure modes (missing jar.mn, regex write error) are captured as
|
|
18
|
+
* stepErrors rather than thrown — a well-formed `.mjs`/`.css` must never be
|
|
19
|
+
* blocked by a broken locale path.
|
|
20
|
+
*/
|
|
21
|
+
export declare function applyCustomFtlFile(engineDir: string, name: string, componentDir: string, ftlDir: string, affectedPaths: string[], stepErrors: StepError[], rollbackJournal?: RollbackJournal): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Returns a dry-run action for registering a locale jar.mn entry for the
|
|
24
|
+
* `.ftl` that `applyCustomFtlFile` would write. `undefined` when the FTL
|
|
25
|
+
* tree does not expose a locale jar.mn we can confidently name.
|
|
26
|
+
*/
|
|
27
|
+
export declare function describeLocaleFtlJarMnRegistration(name: string, ftlDir: string, ftlFile: string): DryRunAction | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
|
|
30
|
+
* source workspace file has been deleted. Idempotent — absent entries are a
|
|
31
|
+
* no-op.
|
|
32
|
+
*/
|
|
33
|
+
export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, rollbackJournal?: RollbackJournal): Promise<void>;
|