@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +9 -2
  3. package/dist/bin/fireforge.js +11 -2
  4. package/dist/src/commands/doctor-furnace.js +83 -1
  5. package/dist/src/commands/doctor.js +18 -0
  6. package/dist/src/commands/download.js +16 -1
  7. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
  8. package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
  9. package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
  10. package/dist/src/commands/furnace/create-templates.d.ts +17 -7
  11. package/dist/src/commands/furnace/create-templates.js +85 -31
  12. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  13. package/dist/src/commands/furnace/create-xpcshell.js +1 -1
  14. package/dist/src/commands/import.js +63 -11
  15. package/dist/src/commands/patch/delete.js +10 -1
  16. package/dist/src/commands/setup-support.js +60 -7
  17. package/dist/src/commands/status.js +28 -1
  18. package/dist/src/commands/test.js +20 -4
  19. package/dist/src/commands/token.js +7 -1
  20. package/dist/src/core/branding.d.ts +10 -0
  21. package/dist/src/core/branding.js +7 -9
  22. package/dist/src/core/build-prepare.js +8 -1
  23. package/dist/src/core/file-lock.js +49 -15
  24. package/dist/src/core/furnace-operation.d.ts +17 -0
  25. package/dist/src/core/furnace-operation.js +30 -1
  26. package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
  27. package/dist/src/core/furnace-validate-helpers.js +53 -2
  28. package/dist/src/core/git.js +39 -10
  29. package/dist/src/core/manifest-rules.js +16 -0
  30. package/dist/src/core/marionette-preflight.js +43 -12
  31. package/dist/src/core/patch-files.d.ts +12 -1
  32. package/dist/src/core/patch-files.js +14 -11
  33. package/dist/src/core/patch-lint.js +62 -11
  34. 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, '_')}_module_loads.js`;
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 tests run headless without a `tabbrowser`, so they suit
102
- * storage/observer/module-loading code in forks that do not mount the
103
- * upstream browser chrome (and therefore lack `openLinkIn`
104
- * `URILoadingHelper`). The scaffold imports the component module via
105
- * `ChromeUtils.importESModule` and asserts the module resolves enough
106
- * to catch registration regressions without touching DOM rendering paths
107
- * that xpcshell cannot execute.
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
- // Chrome-URI access from xpcshell:
115
- // Toolkit chrome (chrome://global/*) IS registered and resolvable from
116
- // this harness that is what the smoke assertion below uses.
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
- // Browser chrome (chrome://browser/*) is NOT registered unless the
119
- // xpcshell.toml sets firefox-appdir = "browser" AND the built app bundle
120
- // has landed every packaged chrome manifest. Even then, the set of
121
- // manifests xpcshell loads lags what the real browser loads, so
122
- // NetUtil.asyncFetch("chrome://browser/content/…") can still fail with
123
- // NS_ERROR_FILE_NOT_FOUND against an artifact that IS present in
124
- // obj-*/dist/. Assertions that need browser chrome URIs belong in a
125
- // browser-chrome mochitest (fireforge furnace create --test-style=browser-chrome),
126
- // not xpcshell.
127
-
128
- add_task(async function test_${name.replace(/-/g, '_')}_module_loads() {
129
- // Module-load smoke check: resolves the ESM at its registered chrome URI.
130
- // Replace or extend with storage-layer assertions as the component grows
131
- // (Services.storage, observer topics, JSONFile, etc. are all available
132
- // here without a tabbrowser).
133
- const moduleUri = "chrome://global/content/elements/${name}.mjs";
134
- const module = await ChromeUtils.importESModule(moduleUri);
135
- Assert.ok(
136
- module,
137
- "${name}.mjs should load under xpcshell (storage-layer code path).",
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>_module_loads.js` and an `xpcshell.toml` manifest
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>_module_loads.js` and an `xpcshell.toml` manifest
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
- info(`Found ${patchCount} patch${patchCount === 1 ? '' : 'es'} to apply`);
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
- if (manifestConsistencyIssues.length > 0) {
206
- const issueSummary = manifestConsistencyIssues.map((issue) => issue.message).join('\n ');
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
- // Load manifest and check version compatibility
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
- const integrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
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
- reason: `${dependents.length} later patch(es) depend on files created by ${target.filename}`,
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
- const rootPackageJsonPath = join(projectRoot, 'package.json');
295
- if (!(await pathExists(rootPackageJsonPath))) {
296
- const rootPackageJson = {
297
- private: true,
298
- license: config.license,
299
- };
300
- await writeText(rootPackageJsonPath, JSON.stringify(rootPackageJson, null, 2) + '\n');
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
- new Option('--mode <mode>', 'Dark mode behavior')
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
- return `# This Source Code Form is subject to the terms of the Mozilla Public
95
- # License, v. 2.0. If a copy of the MPL was not distributed with this
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
- return `# This Source Code Form is subject to the terms of the Mozilla Public
115
- # License, v. 2.0. If a copy of the MPL was not distributed with this
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
- return `# This Source Code Form is subject to the terms of the Mozilla Public
136
- # License, v. 2.0. If a copy of the MPL was not distributed with this
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...');