@hominis/fireforge 0.16.2 → 0.16.3
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 +73 -0
- package/README.md +9 -2
- package/dist/bin/fireforge.js +11 -2
- package/dist/src/commands/doctor-furnace.js +83 -1
- package/dist/src/commands/doctor.js +18 -0
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
- package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
- package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
- package/dist/src/commands/furnace/create-templates.d.ts +17 -7
- package/dist/src/commands/furnace/create-templates.js +85 -31
- package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
- package/dist/src/commands/furnace/create-xpcshell.js +1 -1
- package/dist/src/commands/import.js +63 -11
- package/dist/src/commands/patch/delete.js +10 -1
- package/dist/src/commands/setup-support.js +60 -7
- package/dist/src/commands/status.js +28 -1
- package/dist/src/commands/test.js +20 -4
- package/dist/src/commands/token.js +7 -1
- package/dist/src/core/branding.d.ts +10 -0
- package/dist/src/core/branding.js +7 -9
- package/dist/src/core/build-prepare.js +8 -1
- package/dist/src/core/file-lock.js +49 -15
- package/dist/src/core/furnace-operation.d.ts +17 -0
- package/dist/src/core/furnace-operation.js +30 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
- package/dist/src/core/furnace-validate-helpers.js +53 -2
- package/dist/src/core/git.js +39 -10
- package/dist/src/core/manifest-rules.js +16 -0
- package/dist/src/core/marionette-preflight.js +43 -12
- package/dist/src/core/patch-files.d.ts +12 -1
- package/dist/src/core/patch-files.js +14 -11
- package/dist/src/core/patch-lint.js +62 -11
- package/package.json +1 -1
|
@@ -93,48 +93,102 @@ export function generateFtlContent(name, header) {
|
|
|
93
93
|
}
|
|
94
94
|
/** Returns the canonical xpcshell test file basename for a component. */
|
|
95
95
|
export function xpcshellTestFileName(name) {
|
|
96
|
-
return `test_${name.replace(/-/g, '_')}
|
|
96
|
+
return `test_${name.replace(/-/g, '_')}_packaged.js`;
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
99
|
* Generates an xpcshell test file for a custom component.
|
|
100
100
|
*
|
|
101
|
-
* xpcshell
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
101
|
+
* xpcshell cannot execute a component module that imports
|
|
102
|
+
* `chrome://global/content/vendor/lit.all.mjs` — the Lit bundle touches
|
|
103
|
+
* `window` at module-load time and the xpcshell harness has no `window`
|
|
104
|
+
* global. Before 0.16.0 the scaffold called `ChromeUtils.importESModule`
|
|
105
|
+
* on the component's MJS, which reliably failed with
|
|
106
|
+
* `ReferenceError: window is not defined` for every Lit-based fork
|
|
107
|
+
* component. FireForge's diagnostics then misrouted the failure to the
|
|
108
|
+
* "stale build artifacts" branch, sending operators on a rebuild loop
|
|
109
|
+
* that couldn't fix a runtime-environment incompatibility.
|
|
110
|
+
*
|
|
111
|
+
* The rewrite here mirrors the chrome-doc packaging test: XCurProcD is
|
|
112
|
+
* probed at a pair of candidate layouts (dist/bin/browser and the macOS
|
|
113
|
+
* .app-bundle / ESR layout) to confirm the `.mjs` and `.css` files
|
|
114
|
+
* landed where jar.mn promised. That's the assertion xpcshell CAN make.
|
|
115
|
+
* Functional tests that need DOM/shadow-root/keyboard behaviour belong
|
|
116
|
+
* in a browser-chrome mochitest — scaffolded via
|
|
117
|
+
* `fireforge furnace create --test-style browser-chrome`.
|
|
108
118
|
*/
|
|
109
119
|
export function generateXpcshellTestContent(name, header) {
|
|
120
|
+
const taskSuffix = name.replace(/-/g, '_');
|
|
110
121
|
return `${header}
|
|
111
122
|
|
|
112
123
|
"use strict";
|
|
113
124
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
125
|
+
// Packaging verification for the "${name}" custom component.
|
|
126
|
+
//
|
|
127
|
+
// Why this is not a module-load test:
|
|
128
|
+
// ChromeUtils.importESModule("chrome://global/content/elements/${name}.mjs")
|
|
129
|
+
// pulls in \`chrome://global/content/vendor/lit.all.mjs\`, which
|
|
130
|
+
// references \`window\` during its module body — there is no \`window\`
|
|
131
|
+
// global in xpcshell, so every attempt throws
|
|
132
|
+
// \`ReferenceError: window is not defined\`. For Lit-based components,
|
|
133
|
+
// xpcshell can only verify that the files reached the packaged tree;
|
|
134
|
+
// functional UI assertions belong in a browser-chrome mochitest
|
|
135
|
+
// (see \`fireforge furnace create --test-style browser-chrome\`).
|
|
117
136
|
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
// Out of scope: builds that pack omni.ja (MOZ_CHROME_MULTILOCALE, some
|
|
138
|
+
// release configs). The probe assumes an unpacked tree, which is what
|
|
139
|
+
// \`mach build\` produces by default. A packed build would need to unzip
|
|
140
|
+
// omni.ja to verify the same files.
|
|
141
|
+
|
|
142
|
+
add_task(async function test_${taskSuffix}_files_packaged() {
|
|
143
|
+
const appDir = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
|
|
144
|
+
|
|
145
|
+
// Two candidate layouts are probed per asset:
|
|
146
|
+
// 1) \`<AppDir>/chrome/global/elements/…\` — unpacked layout when
|
|
147
|
+
// XCurProcD honours \`firefox-appdir = "browser"\` and resolves
|
|
148
|
+
// into \`dist/bin/browser/\`.
|
|
149
|
+
// 2) \`<AppDir>/browser/chrome/global/elements/…\` — macOS .app
|
|
150
|
+
// bundle and some ESR layouts where XCurProcD sits one level
|
|
151
|
+
// above \`browser/\`.
|
|
152
|
+
function probeEither(primary, fallback, description) {
|
|
153
|
+
const primaryFile = appDir.clone();
|
|
154
|
+
for (const segment of primary) {
|
|
155
|
+
primaryFile.append(segment);
|
|
156
|
+
}
|
|
157
|
+
const fallbackFile = appDir.clone();
|
|
158
|
+
for (const segment of fallback) {
|
|
159
|
+
fallbackFile.append(segment);
|
|
160
|
+
}
|
|
161
|
+
const found = primaryFile.exists() ? primaryFile : fallbackFile.exists() ? fallbackFile : null;
|
|
162
|
+
Assert.ok(
|
|
163
|
+
found !== null,
|
|
164
|
+
description +
|
|
165
|
+
" missing at both " +
|
|
166
|
+
primaryFile.path +
|
|
167
|
+
" and " +
|
|
168
|
+
fallbackFile.path +
|
|
169
|
+
' — run "fireforge build --ui" and retry. If the file IS present at one of those paths, xpcshell is probing a stale build tree.',
|
|
170
|
+
);
|
|
171
|
+
if (found !== null) {
|
|
172
|
+
Assert.greater(
|
|
173
|
+
found.fileSize,
|
|
174
|
+
0,
|
|
175
|
+
description +
|
|
176
|
+
" is zero-length at " +
|
|
177
|
+
found.path +
|
|
178
|
+
" — packaging copied an empty file, check the source template.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
probeEither(
|
|
184
|
+
["chrome", "global", "elements", "${name}.mjs"],
|
|
185
|
+
["browser", "chrome", "global", "elements", "${name}.mjs"],
|
|
186
|
+
"${name}.mjs",
|
|
187
|
+
);
|
|
188
|
+
probeEither(
|
|
189
|
+
["chrome", "global", "elements", "${name}.css"],
|
|
190
|
+
["browser", "chrome", "global", "elements", "${name}.css"],
|
|
191
|
+
"${name}.css",
|
|
138
192
|
);
|
|
139
193
|
});
|
|
140
194
|
`;
|
|
@@ -13,7 +13,7 @@ import type { ProjectLicense } from '../../types/config.js';
|
|
|
13
13
|
* chrome mochitests require tabbrowser; xpcshell does not, so storage,
|
|
14
14
|
* observers, and ESM-loading logic can be covered headless.
|
|
15
15
|
*
|
|
16
|
-
* Writes `test_<name>
|
|
16
|
+
* Writes `test_<name>_packaged.js` and an `xpcshell.toml` manifest
|
|
17
17
|
* into `engine/browser/base/content/test/<binary-name>-xpcshell/
|
|
18
18
|
* <component-name>/`. moz.build registration is intentionally left to the
|
|
19
19
|
* operator — wiring an `XPCSHELL_TESTS_MANIFESTS` entry requires a
|
|
@@ -18,7 +18,7 @@ import { generateXpcshellManifestContent, generateXpcshellTestContent, xpcshellT
|
|
|
18
18
|
* chrome mochitests require tabbrowser; xpcshell does not, so storage,
|
|
19
19
|
* observers, and ESM-loading logic can be covered headless.
|
|
20
20
|
*
|
|
21
|
-
* Writes `test_<name>
|
|
21
|
+
* Writes `test_<name>_packaged.js` and an `xpcshell.toml` manifest
|
|
22
22
|
* into `engine/browser/base/content/test/<binary-name>-xpcshell/
|
|
23
23
|
* <component-name>/`. moz.build registration is intentionally left to the
|
|
24
24
|
* operator — wiring an `XPCSHELL_TESTS_MANIFESTS` entry requires a
|
|
@@ -165,6 +165,32 @@ async function checkEngineDrift(engineDir, baseCommit, forceImport) {
|
|
|
165
165
|
}
|
|
166
166
|
return true;
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Builds the set of patch filenames in scope when `--until <name>` is set.
|
|
170
|
+
* Accepts either the full filename (e.g. `001-foo.patch`) or the name
|
|
171
|
+
* without the `.patch` suffix (matching `applyPatchesWithContinue`'s
|
|
172
|
+
* `untilFilename` resolver).
|
|
173
|
+
*
|
|
174
|
+
* Returns an empty set when no match is found — the caller treats that as
|
|
175
|
+
* "no scope filter applies" so the import behaves identically to an
|
|
176
|
+
* unrecognised `--until` target (which `applyPatchesWithContinue` will
|
|
177
|
+
* later surface as a normal error).
|
|
178
|
+
*/
|
|
179
|
+
function buildUntilFilenameSet(patches, until) {
|
|
180
|
+
const set = new Set();
|
|
181
|
+
if (until === undefined)
|
|
182
|
+
return set;
|
|
183
|
+
const normalized = until.endsWith('.patch') ? until : `${until}.patch`;
|
|
184
|
+
const target = patches.find((p) => p.filename === until || p.filename === normalized);
|
|
185
|
+
if (!target)
|
|
186
|
+
return set;
|
|
187
|
+
for (const patch of patches) {
|
|
188
|
+
if (patch.order <= target.order) {
|
|
189
|
+
set.add(patch.filename);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return set;
|
|
193
|
+
}
|
|
168
194
|
/**
|
|
169
195
|
* Runs the import command to apply patches.
|
|
170
196
|
* @param projectRoot - Root directory of the project
|
|
@@ -200,20 +226,41 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
200
226
|
outro('Import complete (no patches)');
|
|
201
227
|
return;
|
|
202
228
|
}
|
|
203
|
-
|
|
229
|
+
// Load manifest early so we can scope the integrity / consistency checks to
|
|
230
|
+
// the `--until` subset. The manifest-consistency check stays global because
|
|
231
|
+
// structural manifest corruption (missing / duplicate rows) should block any
|
|
232
|
+
// import regardless of scope, but per-patch integrity and files-affected
|
|
233
|
+
// issues are legitimately skippable when the operator has asked to stop at
|
|
234
|
+
// an earlier patch.
|
|
235
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
236
|
+
const untilFilenameSet = buildUntilFilenameSet(manifest?.patches ?? [], options.until);
|
|
237
|
+
const scopedPatchCount = options.until !== undefined ? untilFilenameSet.size : patchCount;
|
|
238
|
+
info(`Found ${scopedPatchCount} patch${scopedPatchCount === 1 ? '' : 'es'} to apply${options.until !== undefined ? ` (up to ${options.until})` : ''}`);
|
|
204
239
|
const manifestConsistencyIssues = await validatePatchesManifestConsistency(paths.patches);
|
|
205
|
-
|
|
206
|
-
|
|
240
|
+
const scopedManifestIssues = options.until !== undefined
|
|
241
|
+
? manifestConsistencyIssues.filter((issue) =>
|
|
242
|
+
// Global (manifest-level) issues have no specific filename to scope
|
|
243
|
+
// against — a missing or unparseable patches.json blocks any
|
|
244
|
+
// import. Per-patch issues only block when the patch is in scope.
|
|
245
|
+
issue.code === 'manifest-missing' ||
|
|
246
|
+
issue.code === 'manifest-invalid' ||
|
|
247
|
+
untilFilenameSet.has(issue.filename))
|
|
248
|
+
: manifestConsistencyIssues;
|
|
249
|
+
if (scopedManifestIssues.length > 0) {
|
|
250
|
+
const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
|
|
207
251
|
throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
|
|
208
252
|
` ${issueSummary}\n\n` +
|
|
209
253
|
'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
|
|
210
254
|
}
|
|
211
|
-
//
|
|
212
|
-
const manifest = await loadPatchesManifest(paths.patches);
|
|
255
|
+
// Version compatibility warnings (advisory only)
|
|
213
256
|
if (manifest) {
|
|
214
257
|
const config = await loadConfig(projectRoot);
|
|
215
258
|
const currentVersion = config.firefox.version;
|
|
216
259
|
for (const patch of manifest.patches) {
|
|
260
|
+
// Scope the advisory warnings too: an operator running with --until
|
|
261
|
+
// doesn't need to see version warnings for patches outside the range.
|
|
262
|
+
if (options.until !== undefined && !untilFilenameSet.has(patch.filename))
|
|
263
|
+
continue;
|
|
217
264
|
const warning = checkVersionCompatibility(patch.sourceEsrVersion, currentVersion);
|
|
218
265
|
if (warning) {
|
|
219
266
|
warn(`${patch.filename}: ${warning}`);
|
|
@@ -225,7 +272,15 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
225
272
|
// warn-and-continue behaviour hid the real root cause because import
|
|
226
273
|
// would later fail during patch application with a secondary, unrelated
|
|
227
274
|
// error that made diagnosis harder.
|
|
228
|
-
|
|
275
|
+
//
|
|
276
|
+
// Scope the surfaced issues to the `--until` range: a later patch with
|
|
277
|
+
// integrity problems should not block importing an earlier good subset,
|
|
278
|
+
// which is exactly what operators reach for when the tail of the queue
|
|
279
|
+
// is broken and they want to keep working against an earlier checkpoint.
|
|
280
|
+
const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
|
|
281
|
+
const integrityIssues = options.until !== undefined
|
|
282
|
+
? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
|
|
283
|
+
: allIntegrityIssues;
|
|
229
284
|
if (integrityIssues.length > 0) {
|
|
230
285
|
warn('\nPatch integrity issues detected:');
|
|
231
286
|
for (const issue of integrityIssues) {
|
|
@@ -253,11 +308,8 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
253
308
|
// Dry-run: list patches that would be applied and exit
|
|
254
309
|
if (isDryRun) {
|
|
255
310
|
if (manifest) {
|
|
256
|
-
const patches = options.until
|
|
257
|
-
? manifest.patches.filter((p) =>
|
|
258
|
-
const untilPatch = manifest.patches.find((u) => u.filename === options.until || u.filename === `${options.until}.patch`);
|
|
259
|
-
return untilPatch ? p.order <= untilPatch.order : true;
|
|
260
|
-
})
|
|
311
|
+
const patches = options.until !== undefined
|
|
312
|
+
? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
|
|
261
313
|
: manifest.patches;
|
|
262
314
|
info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
|
|
263
315
|
for (const patch of patches) {
|
|
@@ -114,7 +114,16 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
|
|
|
114
114
|
}
|
|
115
115
|
const conflicts = dependents.length > 0
|
|
116
116
|
? {
|
|
117
|
-
|
|
117
|
+
// Wording deliberately clarifies the *runtime* impact: `git apply`
|
|
118
|
+
// doesn't resolve imports and will succeed even when a later patch
|
|
119
|
+
// imports a file the target created (the eval observed this
|
|
120
|
+
// directly — forcing the delete and re-importing the remaining
|
|
121
|
+
// 20-patch queue was clean). The breakage surfaces at browser
|
|
122
|
+
// startup when `ChromeUtils.importESModule` can't locate the
|
|
123
|
+
// deleted module. Operators who deliberately plan to re-introduce
|
|
124
|
+
// the imported files (rename, refactor) need to know this is the
|
|
125
|
+
// impact model, not a patch-application failure.
|
|
126
|
+
reason: `${dependents.length} later patch(es) contain import statements that reference files created by ${target.filename}. Patch application itself will still succeed, but runtime imports will fail at browser startup until those files are re-introduced.`,
|
|
118
127
|
details: dependents,
|
|
119
128
|
}
|
|
120
129
|
: null;
|
|
@@ -263,6 +263,59 @@ export function buildSetupConfig(inputs) {
|
|
|
263
263
|
},
|
|
264
264
|
};
|
|
265
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Creates or updates the root `package.json` so its `license` field matches
|
|
268
|
+
* the project license selected during setup. When the file already exists we
|
|
269
|
+
* ONLY touch the `license` field — preserving `name`, `description`,
|
|
270
|
+
* `dependencies`, `scripts`, and every other author-editorial field the
|
|
271
|
+
* operator may have added. Without this sync, a `fireforge setup --force`
|
|
272
|
+
* that picked a new license left the old license in `package.json`, which
|
|
273
|
+
* then disagreed with `fireforge.json` (the motivating eval finding:
|
|
274
|
+
* setup rewrote fireforge.json but left the original package.json
|
|
275
|
+
* untouched, so the two files described different projects).
|
|
276
|
+
*
|
|
277
|
+
* Preserves the file's trailing newline state so a hand-edited
|
|
278
|
+
* `package.json` with a specific EOL convention is not silently
|
|
279
|
+
* re-normalised.
|
|
280
|
+
*/
|
|
281
|
+
async function syncRootPackageJson(projectRoot, license) {
|
|
282
|
+
const rootPackageJsonPath = join(projectRoot, 'package.json');
|
|
283
|
+
if (!(await pathExists(rootPackageJsonPath))) {
|
|
284
|
+
const rootPackageJson = {
|
|
285
|
+
private: true,
|
|
286
|
+
license,
|
|
287
|
+
};
|
|
288
|
+
await writeText(rootPackageJsonPath, JSON.stringify(rootPackageJson, null, 2) + '\n');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const raw = await readText(rootPackageJsonPath);
|
|
292
|
+
let parsed;
|
|
293
|
+
try {
|
|
294
|
+
parsed = JSON.parse(raw);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// Malformed package.json is the operator's editorial responsibility to
|
|
298
|
+
// repair; rewriting it would risk clobbering hand-authored content that
|
|
299
|
+
// the parser happens to reject. Leave the file alone and rely on the
|
|
300
|
+
// doctor / lint paths that already surface invalid JSON.
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Treat the object as a typed shape with only the one field we modify.
|
|
307
|
+
// Keeping it narrowly typed rather than `Record<string, unknown>` avoids
|
|
308
|
+
// eslint's `dot-notation` / `noPropertyAccessFromIndexSignature`
|
|
309
|
+
// friction, and the rest of the package.json body is preserved via
|
|
310
|
+
// object spread at the write site so we don't lose author-editorial
|
|
311
|
+
// fields.
|
|
312
|
+
const packageJson = parsed;
|
|
313
|
+
if (packageJson.license === license) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const trailingNewline = raw.endsWith('\n') ? '\n' : '';
|
|
317
|
+
await writeText(rootPackageJsonPath, JSON.stringify({ ...packageJson, license }, null, 2) + trailingNewline);
|
|
318
|
+
}
|
|
266
319
|
/** Writes the initial project files produced by the setup workflow. */
|
|
267
320
|
export async function writeSetupProjectFiles(projectRoot, config) {
|
|
268
321
|
const paths = getProjectPaths(projectRoot);
|
|
@@ -291,13 +344,13 @@ export async function writeSetupProjectFiles(projectRoot, config) {
|
|
|
291
344
|
else {
|
|
292
345
|
await writeText(gitignorePath, requiredIgnores.join('\n') + '\n');
|
|
293
346
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
await
|
|
347
|
+
// FireForgeConfig types license as optional, but `buildSetupConfig` always
|
|
348
|
+
// fills it from the resolved setup inputs (which default to `EUPL-1.2`).
|
|
349
|
+
// Narrow explicitly so the helper takes a concrete license rather than
|
|
350
|
+
// widening its own signature for a field that is always set at this call
|
|
351
|
+
// site.
|
|
352
|
+
if (config.license !== undefined) {
|
|
353
|
+
await syncRootPackageJson(projectRoot, config.license);
|
|
301
354
|
}
|
|
302
355
|
const templatesDir = getTemplatesDir();
|
|
303
356
|
if (config.license !== undefined) {
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { isBrandingManagedPath } from '../core/branding.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
5
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
6
|
-
import { getStatusWithCodes, isGitRepository } from '../core/git.js';
|
|
6
|
+
import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
7
7
|
import { getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
8
|
import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
|
|
9
9
|
import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
|
|
@@ -262,6 +262,32 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
|
|
|
262
262
|
}));
|
|
263
263
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
264
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Detects the "unborn HEAD" aftermath of an interrupted `fireforge download`
|
|
267
|
+
* — git init succeeded but the initial Firefox source commit was never
|
|
268
|
+
* created, so every file in engine/ reads as untracked. On a ~600 MB
|
|
269
|
+
* Firefox tree this would flood the output with hundreds of thousands of
|
|
270
|
+
* entries and a truncation warning, which is technically correct but not
|
|
271
|
+
* actionable. Throws a `GeneralError` with a single recovery banner
|
|
272
|
+
* pointing at `fireforge download --force`. `raw` / `json` modes skip the
|
|
273
|
+
* banner so their consumers see the structural failure in error form
|
|
274
|
+
* only.
|
|
275
|
+
*/
|
|
276
|
+
async function assertEngineHasBaselineCommit(engineDir, options) {
|
|
277
|
+
try {
|
|
278
|
+
await getHead(engineDir);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (!isMissingHeadError(err))
|
|
282
|
+
throw err;
|
|
283
|
+
const guidance = 'Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. Re-run "fireforge download --force" to recreate the baseline repository cleanly.';
|
|
284
|
+
if (!options.raw && !options.json) {
|
|
285
|
+
warn(guidance);
|
|
286
|
+
outro('Engine baseline missing — re-run download --force');
|
|
287
|
+
}
|
|
288
|
+
throw new GeneralError(guidance);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
265
291
|
/**
|
|
266
292
|
* Runs the status command to show modified files.
|
|
267
293
|
* @param projectRoot - Root directory of the project
|
|
@@ -331,6 +357,7 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
331
357
|
if (!(await isGitRepository(paths.engine))) {
|
|
332
358
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
333
359
|
}
|
|
360
|
+
await assertEngineHasBaselineCommit(paths.engine, options);
|
|
334
361
|
const rawFiles = await getStatusWithCodes(paths.engine);
|
|
335
362
|
const { entries: expanded, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
|
|
336
363
|
// Strip atomic-write temp files (Finding #18) before every mode
|
|
@@ -36,8 +36,14 @@ function buildStaleBuildMessage() {
|
|
|
36
36
|
'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
|
|
37
37
|
}
|
|
38
38
|
function hasStaleBuildArtifactsSignal(output) {
|
|
39
|
+
// Deliberately narrow: only fire on branding-specific resource paths
|
|
40
|
+
// that are always a stale-artifact symptom. The earlier pattern also
|
|
41
|
+
// matched `resource:///modules/distribution.sys.mjs`, which surfaced on
|
|
42
|
+
// real packaging / module-resolution failures too (e.g. a fork's
|
|
43
|
+
// `HominisStore.sys.mjs` missing from the installed app dir after a
|
|
44
|
+
// successful build). That false-positive pushed operators toward
|
|
45
|
+
// "rebuild" advice for what was actually a module-registration issue.
|
|
39
46
|
return (/chrome:\/\/branding\/locale\/brand\.properties/i.test(output) ||
|
|
40
|
-
/resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
|
|
41
47
|
/browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
|
|
42
48
|
}
|
|
43
49
|
// Detects the broader xpcshell symptom where every `resource:///modules/...`
|
|
@@ -87,15 +93,25 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
87
93
|
if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
|
|
88
94
|
throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
|
|
89
95
|
}
|
|
96
|
+
// Branding-specific stale-build signals keep priority over the broader
|
|
97
|
+
// xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
|
|
98
|
+
// fails to resolve, the fix really is "rebuild", not "pass --app-path".
|
|
99
|
+
// But the stale-build check is now narrower — it no longer matches
|
|
100
|
+
// `resource:///modules/distribution.sys.mjs` alone, which was producing
|
|
101
|
+
// false-positive rebuild advice on fork-custom module-load failures
|
|
102
|
+
// (the eval saw this for `HominisStore.sys.mjs`). Cases that once
|
|
103
|
+
// landed on `distribution.sys.mjs` fall through to xpcshell-appdir,
|
|
104
|
+
// which is the more useful diagnosis in practice for `Failed to load
|
|
105
|
+
// resource:///modules/…`.
|
|
90
106
|
if (hasStaleBuildArtifactsSignal(combinedOutput)) {
|
|
91
107
|
throw new GeneralError(buildStaleBuildMessage());
|
|
92
108
|
}
|
|
93
|
-
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
94
|
-
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
95
|
-
}
|
|
96
109
|
if (hasXpcshellAppdirSignal(combinedOutput)) {
|
|
97
110
|
throw new GeneralError(buildXpcshellAppdirMessage(appdirInjectionAttempted));
|
|
98
111
|
}
|
|
112
|
+
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
113
|
+
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
114
|
+
}
|
|
99
115
|
if (/invalid filename/i.test(combinedOutput) ||
|
|
100
116
|
/chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
|
|
101
117
|
info('Hint: The test file may not be registered in browser.toml or jar.mn.');
|
|
@@ -117,7 +117,13 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
117
117
|
// valid choices up-front. The runtime check in tokenAddCommand remains
|
|
118
118
|
// as a defence-in-depth guard for programmatic callers that bypass
|
|
119
119
|
// Commander's argument parsing.
|
|
120
|
-
|
|
120
|
+
// Description ends with `(required)` because Commander's
|
|
121
|
+
// `makeOptionMandatory` does not render a required marker in `--help`
|
|
122
|
+
// output — only `.requiredOption` does that, and switching to
|
|
123
|
+
// `.requiredOption` would lose the `.choices()` enforcement. The
|
|
124
|
+
// explicit suffix keeps the runtime validation AND surfaces required
|
|
125
|
+
// status in help alongside the other options that use `.requiredOption`.
|
|
126
|
+
new Option('--mode <mode>', 'Dark mode behavior (required)')
|
|
121
127
|
.choices(['auto', 'static', 'override'])
|
|
122
128
|
.makeOptionMandatory(true))
|
|
123
129
|
.option('--description <desc>', 'Comment description for the CSS file')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FireForgeError } from '../errors/base.js';
|
|
2
|
+
import type { ProjectLicense } from '../types/config.js';
|
|
2
3
|
/**
|
|
3
4
|
* Error thrown when branding operations fail.
|
|
4
5
|
*/
|
|
@@ -41,6 +42,15 @@ export interface BrandingConfig {
|
|
|
41
42
|
appId: string;
|
|
42
43
|
/** Binary/branding directory name (e.g., "mybrowser") */
|
|
43
44
|
binaryName: string;
|
|
45
|
+
/**
|
|
46
|
+
* Project license (from fireforge.json). Used to stamp the generated
|
|
47
|
+
* `configure.sh`, `brand.properties`, and `brand.ftl` files with the
|
|
48
|
+
* matching header so `patch-lint` does not flag them for
|
|
49
|
+
* `missing-license-header` when the project is not MPL-2.0. Optional for
|
|
50
|
+
* backwards compatibility with pre-0.16 callers that did not thread the
|
|
51
|
+
* license through — falls back to {@link DEFAULT_LICENSE}.
|
|
52
|
+
*/
|
|
53
|
+
license?: ProjectLicense;
|
|
44
54
|
}
|
|
45
55
|
/**
|
|
46
56
|
* Sets up the custom branding directory for the browser.
|
|
@@ -4,6 +4,7 @@ import { FireForgeError } from '../errors/base.js';
|
|
|
4
4
|
import { ExitCode } from '../errors/codes.js';
|
|
5
5
|
import { copyDir, pathExists, readText, writeTextIfChanged } from '../utils/fs.js';
|
|
6
6
|
import { warn } from '../utils/logger.js';
|
|
7
|
+
import { DEFAULT_LICENSE, getLicenseHeader } from './license-headers.js';
|
|
7
8
|
/**
|
|
8
9
|
* Error thrown when branding operations fail.
|
|
9
10
|
*/
|
|
@@ -91,9 +92,8 @@ async function createConfigureScript(brandingDir, config) {
|
|
|
91
92
|
await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config));
|
|
92
93
|
}
|
|
93
94
|
function buildConfigureScriptContent(config) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
95
|
+
const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
|
|
96
|
+
return `${header}
|
|
97
97
|
|
|
98
98
|
MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"
|
|
99
99
|
MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"
|
|
@@ -111,9 +111,8 @@ async function updateBrandProperties(brandingDir, config) {
|
|
|
111
111
|
await writeTextIfChanged(propsPath, buildBrandPropertiesContent(config));
|
|
112
112
|
}
|
|
113
113
|
function buildBrandPropertiesContent(config) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
114
|
+
const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
|
|
115
|
+
return `${header}
|
|
117
116
|
|
|
118
117
|
brandShorterName=${escapePropertiesValue(config.name)}
|
|
119
118
|
brandShortName=${escapePropertiesValue(config.name)}
|
|
@@ -132,9 +131,8 @@ async function updateBrandFtl(brandingDir, config) {
|
|
|
132
131
|
await writeTextIfChanged(ftlPath, buildBrandFtlContent(config));
|
|
133
132
|
}
|
|
134
133
|
function buildBrandFtlContent(config) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
134
|
+
const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
|
|
135
|
+
return `${header}
|
|
138
136
|
|
|
139
137
|
## Brand names
|
|
140
138
|
##
|
|
@@ -123,12 +123,19 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
|
|
|
123
123
|
}
|
|
124
124
|
// Clean stories before build to ensure they don't leak into production binary
|
|
125
125
|
await cleanStories(paths.engine);
|
|
126
|
-
// Set up custom branding directory and patch moz.configure
|
|
126
|
+
// Set up custom branding directory and patch moz.configure. Thread the
|
|
127
|
+
// project license through so `buildConfigureScriptContent` /
|
|
128
|
+
// `buildBrandPropertiesContent` / `buildBrandFtlContent` stamp the
|
|
129
|
+
// generated files with a matching SPDX header — otherwise `patch-lint`
|
|
130
|
+
// flags them with `missing-license-header` on every subsequent export
|
|
131
|
+
// when the project is not MPL-2.0 (the eval finding: a 0BSD-licensed
|
|
132
|
+
// fork's first export failed `lint` on its own generated branding).
|
|
127
133
|
const brandingConfig = {
|
|
128
134
|
name: config.name,
|
|
129
135
|
vendor: config.vendor,
|
|
130
136
|
appId: config.appId,
|
|
131
137
|
binaryName: config.binaryName,
|
|
138
|
+
...(config.license !== undefined ? { license: config.license } : {}),
|
|
132
139
|
};
|
|
133
140
|
if (!(await isBrandingSetup(paths.engine, brandingConfig))) {
|
|
134
141
|
const brandingSpinner = spinner('Setting up branding...');
|