@hominis/fireforge 0.14.0 → 0.15.2

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 (50) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +41 -3
  3. package/dist/src/commands/build.js +12 -1
  4. package/dist/src/commands/furnace/create-templates.d.ts +47 -0
  5. package/dist/src/commands/furnace/create-templates.js +135 -0
  6. package/dist/src/commands/furnace/create-xpcshell.d.ts +27 -0
  7. package/dist/src/commands/furnace/create-xpcshell.js +53 -0
  8. package/dist/src/commands/furnace/create.js +81 -109
  9. package/dist/src/commands/furnace/deploy.js +3 -3
  10. package/dist/src/commands/furnace/index.js +1 -0
  11. package/dist/src/commands/setup.d.ts +1 -1
  12. package/dist/src/commands/setup.js +3 -2
  13. package/dist/src/commands/test.js +20 -0
  14. package/dist/src/core/build-prepare.js +6 -1
  15. package/dist/src/core/config-paths.d.ts +2 -2
  16. package/dist/src/core/config-paths.js +2 -0
  17. package/dist/src/core/config-validate.js +32 -0
  18. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  19. package/dist/src/core/furnace-apply-ftl.js +102 -0
  20. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  21. package/dist/src/core/furnace-apply-helpers.js +16 -12
  22. package/dist/src/core/furnace-apply.js +7 -4
  23. package/dist/src/core/furnace-config-tokens.d.ts +17 -0
  24. package/dist/src/core/furnace-config-tokens.js +43 -0
  25. package/dist/src/core/furnace-config.d.ts +6 -0
  26. package/dist/src/core/furnace-config.js +16 -3
  27. package/dist/src/core/furnace-constants.d.ts +20 -0
  28. package/dist/src/core/furnace-constants.js +32 -0
  29. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  30. package/dist/src/core/furnace-registration-ast.js +58 -25
  31. package/dist/src/core/furnace-registration.d.ts +27 -0
  32. package/dist/src/core/furnace-registration.js +96 -0
  33. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  34. package/dist/src/core/furnace-validate-compatibility.js +18 -7
  35. package/dist/src/core/furnace-validate-helpers.d.ts +39 -1
  36. package/dist/src/core/furnace-validate-helpers.js +182 -18
  37. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  38. package/dist/src/core/furnace-validate-registration.js +34 -9
  39. package/dist/src/core/furnace-validate.js +2 -2
  40. package/dist/src/core/marionette-preflight.d.ts +46 -0
  41. package/dist/src/core/marionette-preflight.js +260 -0
  42. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  43. package/dist/src/core/patch-lint-cross.js +1 -1
  44. package/dist/src/core/patch-lint.js +29 -9
  45. package/dist/src/types/commands/options.d.ts +16 -0
  46. package/dist/src/types/config.d.ts +7 -0
  47. package/dist/src/types/furnace.d.ts +19 -0
  48. package/dist/src/utils/process.d.ts +15 -2
  49. package/dist/src/utils/process.js +73 -0
  50. package/package.json +1 -1
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { multiselect, text } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../../core/config.js';
5
5
  import { createDefaultFurnaceConfig, detectComposesCycles, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
- import { tagNameToClassName } from '../../core/furnace-constants.js';
6
+ import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-constants.js';
7
7
  import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
8
8
  import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
9
9
  import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
@@ -15,6 +15,8 @@ import { FurnaceError } from '../../errors/furnace.js';
15
15
  import { toError } from '../../utils/errors.js';
16
16
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
17
17
  import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
18
+ import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
19
+ import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
18
20
  async function loadAuthoringFurnaceConfig(projectRoot) {
19
21
  if (await furnaceConfigExists(projectRoot)) {
20
22
  return loadFurnaceConfig(projectRoot);
@@ -46,65 +48,6 @@ function checkNameConflict(config, name) {
46
48
  }
47
49
  return undefined;
48
50
  }
49
- /**
50
- * Generates the .mjs file content for a custom component.
51
- */
52
- function generateMjsContent(name, className, description, localized, header) {
53
- const connectedCallback = localized
54
- ? `
55
- connectedCallback() {
56
- super.connectedCallback();
57
- this.insertFTLIfNeeded("${name}.ftl");
58
- }
59
- `
60
- : '';
61
- return `${header}
62
-
63
- import { html } from "chrome://global/content/vendor/lit.all.mjs";
64
- import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
65
-
66
- /**
67
- * ${description || name}
68
- *
69
- * @tagname ${name}
70
- */
71
- class ${className} extends MozLitElement {
72
- static properties = {};
73
-
74
- constructor() {
75
- super();
76
- }
77
- ${connectedCallback}
78
- render() {
79
- return html\`
80
- <link rel="stylesheet" href="chrome://global/content/elements/${name}.css" />
81
- <slot></slot>
82
- \`;
83
- }
84
- }
85
- customElements.define("${name}", ${className});
86
- `;
87
- }
88
- /**
89
- * Generates the .css file content for a custom component.
90
- */
91
- function generateCssContent(header) {
92
- return `${header}
93
-
94
- :host {
95
- display: block;
96
- }
97
- `;
98
- }
99
- /**
100
- * Generates the .ftl file content for a custom component.
101
- */
102
- function generateFtlContent(name, header) {
103
- return `${header}
104
-
105
- ## Strings for the ${name} component
106
- `;
107
- }
108
51
  /**
109
52
  * Scaffolds browser mochitest files for a newly created custom component.
110
53
  * @param componentName - Custom element tag name
@@ -258,13 +201,13 @@ async function resolveCreateFeatures(isInteractive, options) {
258
201
  * @param journal - Optional rollback journal that snapshots files before writes
259
202
  * @returns Relative filenames written for the component
260
203
  */
261
- async function writeComponentFiles(componentDir, componentName, className, description, localized, license, journal) {
204
+ async function writeComponentFiles(componentDir, componentName, className, description, localized, license, ftlChromeSubPath, journal) {
262
205
  await ensureDir(componentDir);
263
206
  const files = [`${componentName}.mjs`, `${componentName}.css`];
264
207
  const mjsPath = join(componentDir, `${componentName}.mjs`);
265
208
  if (journal)
266
209
  await snapshotFile(journal, mjsPath);
267
- const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'));
210
+ const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'), ftlChromeSubPath);
268
211
  await writeText(mjsPath, mjsContent);
269
212
  const cssPath = join(componentDir, `${componentName}.css`);
270
213
  if (journal)
@@ -303,7 +246,7 @@ async function performCreateMutations(args) {
303
246
  // so signal-driven rollback can clean it up even if writeComponentFiles
304
247
  // is interrupted mid-ensureDir.
305
248
  recordCreatedDir(journal, args.componentDir);
306
- files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, journal);
249
+ files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, journal);
307
250
  const customEntry = {
308
251
  description: args.description,
309
252
  targetPath: `toolkit/content/widgets/${args.componentName}`,
@@ -320,6 +263,10 @@ async function performCreateMutations(args) {
320
263
  const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
321
264
  testFiles.push(...scafFiles);
322
265
  }
266
+ if (args.xpcshellTests) {
267
+ const xpcshellFiles = await scaffoldXpcshellTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
268
+ testFiles.push(...xpcshellFiles);
269
+ }
323
270
  }
324
271
  catch (error) {
325
272
  try {
@@ -333,6 +280,58 @@ async function performCreateMutations(args) {
333
280
  }
334
281
  return { files, testFiles };
335
282
  }
283
+ /**
284
+ * Prompts the operator for a description when the command is interactive and
285
+ * the operator did not pass `-d`. Returns the resolved description string.
286
+ */
287
+ async function resolveDescription(isInteractive, options) {
288
+ let description = options.description ?? '';
289
+ if (!description && isInteractive) {
290
+ const descResult = await text({
291
+ message: 'Description (optional):',
292
+ placeholder: 'A brief description of the component',
293
+ });
294
+ if (!isCancel(descResult)) {
295
+ description = String(descResult);
296
+ }
297
+ }
298
+ return description;
299
+ }
300
+ /**
301
+ * Validates the `--compose` targets against registered components and runs
302
+ * cycle detection if the new component is introduced into the graph. Throws
303
+ * on any failure; returns when the graph is clean.
304
+ */
305
+ function validateComposesTargets(config, componentName, composes) {
306
+ if (!composes || composes.length === 0)
307
+ return;
308
+ const known = new Set([
309
+ ...config.stock,
310
+ ...Object.keys(config.overrides),
311
+ ...Object.keys(config.custom),
312
+ ]);
313
+ for (const tag of composes) {
314
+ if (tag === componentName) {
315
+ throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
316
+ }
317
+ if (!known.has(tag)) {
318
+ throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
319
+ 'The referenced component must be registered as stock, override, or custom.');
320
+ }
321
+ }
322
+ // Check for cycles that would be introduced by adding this component.
323
+ const tempCustom = {
324
+ ...config.custom,
325
+ [componentName]: {
326
+ description: '',
327
+ targetPath: `toolkit/content/widgets/${componentName}`,
328
+ register: true,
329
+ localized: false,
330
+ composes,
331
+ },
332
+ };
333
+ detectComposesCycles(tempCustom);
334
+ }
336
335
  /**
337
336
  * Runs the furnace create command to scaffold a new custom component.
338
337
  * @param projectRoot - Root directory of the project
@@ -394,16 +393,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
394
393
  warn(`Name "${componentName}" does not start with the configured prefix "${config.componentPrefix}".`);
395
394
  }
396
395
  // --- Resolve description ---
397
- let description = options.description ?? '';
398
- if (!description && isInteractive) {
399
- const descResult = await text({
400
- message: 'Description (optional):',
401
- placeholder: 'A brief description of the component',
402
- });
403
- if (!isCancel(descResult)) {
404
- description = String(descResult);
405
- }
406
- }
396
+ const description = await resolveDescription(isInteractive, options);
407
397
  // --- Resolve features ---
408
398
  const featureSelection = await resolveCreateFeatures(isInteractive, options);
409
399
  if (!featureSelection) {
@@ -411,12 +401,14 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
411
401
  }
412
402
  const { localized, register } = featureSelection;
413
403
  // --with-tests writes files under engine/browser/base/content/test/ and
414
- // registers them in moz.build. Guard against a missing engine now rather
415
- // than letting scaffoldTestFiles fabricate a partial engine tree with
416
- // ensureDir.
404
+ // registers them in moz.build. --xpcshell is the equivalent for forks
405
+ // without a tabbrowser (storage-layer code). Guard against a missing
406
+ // engine now rather than letting the scaffolders fabricate a partial
407
+ // engine tree with ensureDir.
417
408
  const withTests = options.withTests ?? false;
418
- if (withTests && !(await pathExists(paths.engine))) {
419
- throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests.', componentName);
409
+ const xpcshellTests = options.xpcshell ?? false;
410
+ if ((withTests || xpcshellTests) && !(await pathExists(paths.engine))) {
411
+ throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests or --xpcshell.', componentName);
420
412
  }
421
413
  // --- Generate component files ---
422
414
  const className = tagNameToClassName(componentName);
@@ -428,39 +420,16 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
428
420
  // --- Validate --compose targets BEFORE any writes so a failed validation
429
421
  // does not strand component files behind.
430
422
  const composes = options.compose;
431
- if (composes && composes.length > 0) {
432
- const known = new Set([
433
- ...config.stock,
434
- ...Object.keys(config.overrides),
435
- ...Object.keys(config.custom),
436
- ]);
437
- for (const tag of composes) {
438
- if (tag === componentName) {
439
- throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
440
- }
441
- if (!known.has(tag)) {
442
- throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
443
- 'The referenced component must be registered as stock, override, or custom.');
444
- }
445
- }
446
- // Check for cycles that would be introduced by adding this component.
447
- const tempCustom = {
448
- ...config.custom,
449
- [componentName]: {
450
- description: '',
451
- targetPath: `toolkit/content/widgets/${componentName}`,
452
- register: true,
453
- localized: false,
454
- composes,
455
- },
456
- };
457
- detectComposesCycles(tempCustom);
458
- }
423
+ validateComposesTargets(config, componentName, composes);
459
424
  // All validation is done. Hand off to the transactional mutation helper
460
425
  // so any failure restores the workspace and engine to their pre-command
461
426
  // state via the shared rollback journal. The mutation runs under the
462
427
  // furnace-wide lock and is registered with the global SIGINT/SIGTERM
463
428
  // rollback pathway.
429
+ // Derive the FTL chrome sub-path from the configured ftlBasePath so the
430
+ // generated `.mjs` calls `insertFTLIfNeeded` at a URI that actually matches
431
+ // the locale jar.mn entry `furnace apply` will write.
432
+ const ftlChromeSubPath = resolveFtlChromeSubPath(config.ftlBasePath);
464
433
  const { files, testFiles } = await runFurnaceMutation(projectRoot, 'create-rollback', (ctx) => performCreateMutations({
465
434
  projectRoot,
466
435
  componentName,
@@ -476,15 +445,18 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
476
445
  paths,
477
446
  license,
478
447
  withTests,
448
+ xpcshellTests,
449
+ ftlChromeSubPath,
479
450
  operationContext: ctx,
480
451
  }));
481
452
  // --- Success ---
482
453
  let noteParts = `Files created in components/custom/${componentName}/:\n` +
483
454
  files.map((f) => ` ${f}`).join('\n');
484
455
  if (testFiles.length > 0) {
485
- noteParts +=
486
- `\n\nTest files in engine/browser/base/content/test/${forgeConfig.binaryName}/:\n` +
487
- testFiles.map((f) => ` ${f}`).join('\n');
456
+ const testRoot = xpcshellTests
457
+ ? `engine/browser/base/content/test/${forgeConfig.binaryName}-xpcshell/${componentName}/`
458
+ : `engine/browser/base/content/test/${forgeConfig.binaryName}/`;
459
+ noteParts += `\n\nTest files in ${testRoot}:\n` + testFiles.map((f) => ` ${f}`).join('\n');
488
460
  }
489
461
  noteParts +=
490
462
  '\n\n' +
@@ -162,7 +162,7 @@ async function restoreNamedDeployRollback(rollbackJournal, name, projectRoot) {
162
162
  * @param isDryRun - Whether file writes should be skipped
163
163
  * @returns Apply result for the named component, or `stock` for stock-only entries
164
164
  */
165
- async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot) {
165
+ async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot, markerComment) {
166
166
  const rollbackJournal = isDryRun ? undefined : createRollbackJournal();
167
167
  if (rollbackJournal && operationContext) {
168
168
  operationContext.registerJournal(rollbackJournal);
@@ -201,7 +201,7 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir
201
201
  throw new FurnaceError(`Component directory not found: components/custom/${name}`, name);
202
202
  }
203
203
  try {
204
- const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal);
204
+ const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal, markerComment !== undefined ? { markerComment } : {});
205
205
  if (isDryRun && actions) {
206
206
  result.actions = actions;
207
207
  }
@@ -317,7 +317,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
317
317
  // `furnace deploy` runs only contend on the actual mutation.
318
318
  const applyOutcome = await runFurnaceMutation(projectRoot, 'deploy-rollback', async (ctx) => {
319
319
  if (name) {
320
- const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot);
320
+ const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, forgeConfig.markerComment);
321
321
  if (namedApplyResult === 'stock') {
322
322
  return { kind: 'stock' };
323
323
  }
@@ -72,6 +72,7 @@ function registerFurnaceInfoCommands(furnace, context) {
72
72
  .option('--localized', 'Include Fluent l10n support')
73
73
  .option('--no-register', 'Skip customElements.js registration')
74
74
  .option('--with-tests', 'Scaffold Mochitest directory and register in moz.build')
75
+ .option('--xpcshell', 'Scaffold an xpcshell test harness (for storage-layer code on forks without tabbrowser)')
75
76
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
76
77
  .action(withErrorHandling(async (name, options) => {
77
78
  await furnaceCreateCommand(getProjectRoot(), name, options);
@@ -8,4 +8,4 @@ import type { SetupOptions } from '../types/commands/index.js';
8
8
  */
9
9
  export declare function setupCommand(projectRoot: string, options?: SetupOptions): Promise<void>;
10
10
  /** Registers the setup command on the CLI program. */
11
- export declare function registerSetup(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
11
+ export declare function registerSetup(program: Command, { withErrorHandling }: CommandContext): void;
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { resolve } from 'node:path';
2
3
  import { confirm } from '@clack/prompts';
3
4
  import { Option } from 'commander';
4
5
  import { configExists } from '../core/config.js';
@@ -57,7 +58,7 @@ export async function setupCommand(projectRoot, options = {}) {
57
58
  }
58
59
  }
59
60
  /** Registers the setup command on the CLI program. */
60
- export function registerSetup(program, { getProjectRoot, withErrorHandling }) {
61
+ export function registerSetup(program, { withErrorHandling }) {
61
62
  program
62
63
  .command('setup')
63
64
  .description('Initialize a new FireForge project')
@@ -88,7 +89,7 @@ export function registerSetup(program, { getProjectRoot, withErrorHandling }) {
88
89
  setupOptions.license = parsedLicense;
89
90
  }
90
91
  }
91
- await setupCommand(getProjectRoot(), setupOptions);
92
+ await setupCommand(resolve(process.cwd()), setupOptions);
92
93
  }));
93
94
  }
94
95
  //# sourceMappingURL=setup.js.map
@@ -3,6 +3,7 @@ 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';
@@ -117,6 +118,24 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
117
118
  s.stop('Build complete');
118
119
  info('');
119
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
+ }
120
139
  // Normalize test paths (strip engine/ prefix if present)
121
140
  const normalizedPaths = testPaths.map(normalizeTestPath);
122
141
  await assertTestPathsExist(paths.engine, normalizedPaths);
@@ -149,6 +168,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
149
168
  .description('Run tests via mach test')
150
169
  .option('--headless', 'Run tests in headless mode')
151
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.')
152
172
  .action(withErrorHandling(async (paths, options) => {
153
173
  await testCommand(getProjectRoot(), paths, pickDefined(options));
154
174
  }));
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { FurnaceError } from '../errors/furnace.js';
7
7
  import { pathExists } from '../utils/fs.js';
8
- import { spinner, warn } from '../utils/logger.js';
8
+ import { info, spinner, warn } from '../utils/logger.js';
9
9
  import { isBrandingSetup, setupBranding } from './branding.js';
10
10
  import { applyAllComponents } from './furnace-apply.js';
11
11
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
@@ -95,7 +95,12 @@ export async function prepareBuildEnvironment(projectRoot, paths, config) {
95
95
  throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
96
96
  }
97
97
  if (furnaceApplied > 0) {
98
+ const appliedNames = result.applied.map((entry) => entry.name).join(', ');
98
99
  furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
100
+ // Loud banner: the build operator needs to see that engine/ was
101
+ // updated before this build, otherwise a silent re-apply is
102
+ // indistinguishable from a build that shipped stale components.
103
+ info(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
99
104
  }
100
105
  else {
101
106
  furnaceSpinner.stop('Components up to date');
@@ -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.
@@ -121,6 +121,11 @@ export function validateConfig(data) {
121
121
  }
122
122
  config.license = licenseRaw;
123
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
+ }
124
129
  // PatchLint
125
130
  const patchLintRec = optionalConfigObject(rec, 'patchLint');
126
131
  if (patchLintRec) {
@@ -167,6 +172,33 @@ function optionalConfigString(rec, key, label) {
167
172
  }
168
173
  return value;
169
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 `*&#47;` (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
+ }
170
202
  function optionalConfigObject(rec, key) {
171
203
  const value = rec.raw(key);
172
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>;
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `.ftl` apply/undeploy helpers for custom components. Extracted from
4
+ * `furnace-apply-helpers.ts` so the main helper module stays under the
5
+ * per-file LOC budget.
6
+ *
7
+ * Every helper here degrades gracefully: if the locale jar.mn is missing or
8
+ * the FTL tree is non-standard, apply logs a `stepError` rather than
9
+ * aborting the whole command. Missing jar.mn on a fork without a locale
10
+ * package should not block a working `.mjs`/`.css` from shipping.
11
+ */
12
+ import { join, relative } from 'node:path';
13
+ import { toError } from '../utils/errors.js';
14
+ import { copyFile, pathExists } from '../utils/fs.js';
15
+ import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
16
+ import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
17
+ import { snapshotFile } from './furnace-rollback.js';
18
+ /**
19
+ * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
20
+ * in the locale jar.mn.
21
+ *
22
+ * Failure modes (missing jar.mn, regex write error) are captured as
23
+ * stepErrors rather than thrown — a well-formed `.mjs`/`.css` must never be
24
+ * blocked by a broken locale path.
25
+ */
26
+ export async function applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal) {
27
+ const ftlFile = `${name}.ftl`;
28
+ const ftlSrc = join(componentDir, ftlFile);
29
+ if (!(await pathExists(ftlSrc)))
30
+ return;
31
+ const ftlDest = join(engineDir, ftlDir, ftlFile);
32
+ if (rollbackJournal) {
33
+ await snapshotFile(rollbackJournal, ftlDest);
34
+ }
35
+ await copyFile(ftlSrc, ftlDest);
36
+ affectedPaths.push(relative(engineDir, ftlDest));
37
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
38
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
39
+ if (chromeSubPath === undefined || localeJarRel === undefined)
40
+ return;
41
+ const localeJarAbs = join(engineDir, localeJarRel);
42
+ if (!(await pathExists(localeJarAbs))) {
43
+ stepErrors.push({
44
+ step: 'locale jar.mn registration',
45
+ error: `Locale jar.mn not found at ${localeJarRel}; component "${name}" ships without a chrome URI for ${ftlFile}. Add the file manually or set furnace.json "ftlBasePath" to a tree that owns a jar.mn.`,
46
+ });
47
+ return;
48
+ }
49
+ try {
50
+ if (rollbackJournal) {
51
+ await snapshotFile(rollbackJournal, localeJarAbs);
52
+ }
53
+ const inserted = await addLocaleFtlJarMnEntry(engineDir, localeJarRel, name, chromeSubPath);
54
+ if (inserted > 0) {
55
+ affectedPaths.push(localeJarRel);
56
+ }
57
+ }
58
+ catch (error) {
59
+ stepErrors.push({
60
+ step: 'locale jar.mn registration',
61
+ error: toError(error).message,
62
+ });
63
+ }
64
+ }
65
+ /**
66
+ * Returns a dry-run action for registering a locale jar.mn entry for the
67
+ * `.ftl` that `applyCustomFtlFile` would write. `undefined` when the FTL
68
+ * tree does not expose a locale jar.mn we can confidently name.
69
+ */
70
+ export function describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile) {
71
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
72
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
73
+ if (chromeSubPath === undefined || localeJarRel === undefined)
74
+ return undefined;
75
+ return {
76
+ component: name,
77
+ action: 'register-jar',
78
+ description: `Register ${chromeSubPath}/${ftlFile} in ${localeJarRel}`,
79
+ };
80
+ }
81
+ /**
82
+ * Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
83
+ * source workspace file has been deleted. Idempotent — absent entries are a
84
+ * no-op.
85
+ */
86
+ export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal) {
87
+ if (!fileName.endsWith('.ftl'))
88
+ return;
89
+ const tagName = fileName.slice(0, -'.ftl'.length);
90
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
91
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
92
+ if (chromeSubPath === undefined || localeJarRel === undefined)
93
+ return;
94
+ const localeJarAbs = join(engineDir, localeJarRel);
95
+ if (!(await pathExists(localeJarAbs)))
96
+ return;
97
+ if (rollbackJournal) {
98
+ await snapshotFile(rollbackJournal, localeJarAbs);
99
+ }
100
+ await removeLocaleFtlJarMnEntry(engineDir, localeJarRel, tagName, chromeSubPath);
101
+ }
102
+ //# sourceMappingURL=furnace-apply-ftl.js.map
@@ -90,8 +90,17 @@ export declare function hasOverrideEngineDrift(engineDir: string, componentDir:
90
90
  * the validate command.
91
91
  */
92
92
  export declare function hasCustomEngineDrift(root: string, name: string, componentDir: string, config: CustomComponentConfig, ftlDir: string): Promise<boolean>;
93
+ /** Extra knobs threaded into `applyCustomComponent` from the project config. */
94
+ export interface CustomApplyOptions {
95
+ /**
96
+ * Trailing project marker appended to inserted `customElements.js` entries
97
+ * (e.g. `"MYBROWSER"` emits ` // MYBROWSER:` on each line). Mirrors the
98
+ * `markerComment` field in fireforge.json.
99
+ */
100
+ markerComment?: string;
101
+ }
93
102
  /** Applies a custom component into the engine tree and captures registration step errors. */
94
- export declare function applyCustomComponent(engineDir: string, name: string, componentDir: string, config: CustomComponentConfig, ftlDir: string, dryRun?: boolean, rollbackJournal?: RollbackJournal): Promise<{
103
+ export declare function applyCustomComponent(engineDir: string, name: string, componentDir: string, config: CustomComponentConfig, ftlDir: string, dryRun?: boolean, rollbackJournal?: RollbackJournal, applyOptions?: CustomApplyOptions): Promise<{
95
104
  affectedPaths: string[];
96
105
  stepErrors: StepError[];
97
106
  actions?: DryRunAction[];