@hominis/fireforge 0.13.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +20 -1
  3. package/dist/bin/fireforge.js +19 -5
  4. package/dist/src/commands/config.js +7 -1
  5. package/dist/src/commands/discard.js +6 -1
  6. package/dist/src/commands/doctor.d.ts +12 -0
  7. package/dist/src/commands/doctor.js +6 -1
  8. package/dist/src/commands/download.js +106 -7
  9. package/dist/src/commands/export-shared.js +7 -0
  10. package/dist/src/commands/export.js +5 -0
  11. package/dist/src/commands/furnace/apply.js +147 -47
  12. package/dist/src/commands/furnace/create-templates.d.ts +26 -0
  13. package/dist/src/commands/furnace/create-templates.js +86 -0
  14. package/dist/src/commands/furnace/create.js +77 -103
  15. package/dist/src/commands/furnace/deploy.js +20 -5
  16. package/dist/src/commands/furnace/diff.js +3 -1
  17. package/dist/src/commands/furnace/init.js +25 -7
  18. package/dist/src/commands/furnace/list.js +15 -7
  19. package/dist/src/commands/furnace/override.js +47 -15
  20. package/dist/src/commands/furnace/remove.js +68 -20
  21. package/dist/src/commands/furnace/rename.js +31 -3
  22. package/dist/src/commands/furnace/scan.js +8 -0
  23. package/dist/src/commands/furnace/validate.js +70 -7
  24. package/dist/src/commands/import.js +65 -11
  25. package/dist/src/commands/re-export.js +11 -4
  26. package/dist/src/commands/rebase/abort.js +26 -14
  27. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  28. package/dist/src/commands/rebase/confirm.js +2 -2
  29. package/dist/src/commands/rebase/continue.js +39 -15
  30. package/dist/src/commands/rebase/index.js +2 -1
  31. package/dist/src/commands/rebase/patch-loop.js +90 -33
  32. package/dist/src/commands/register.js +13 -0
  33. package/dist/src/commands/resolve.js +31 -10
  34. package/dist/src/commands/run.js +9 -44
  35. package/dist/src/commands/setup-support.js +25 -7
  36. package/dist/src/commands/status.js +59 -8
  37. package/dist/src/commands/test.js +33 -7
  38. package/dist/src/commands/token.js +11 -1
  39. package/dist/src/commands/watch.js +51 -1
  40. package/dist/src/commands/wire.js +23 -0
  41. package/dist/src/core/config-paths.d.ts +2 -2
  42. package/dist/src/core/config-paths.js +2 -0
  43. package/dist/src/core/config-validate.js +47 -1
  44. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  45. package/dist/src/core/furnace-apply-ftl.js +102 -0
  46. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  47. package/dist/src/core/furnace-apply-helpers.js +16 -12
  48. package/dist/src/core/furnace-apply.js +7 -4
  49. package/dist/src/core/furnace-config-tokens.d.ts +11 -0
  50. package/dist/src/core/furnace-config-tokens.js +28 -0
  51. package/dist/src/core/furnace-config.d.ts +6 -0
  52. package/dist/src/core/furnace-config.js +8 -1
  53. package/dist/src/core/furnace-constants.d.ts +20 -0
  54. package/dist/src/core/furnace-constants.js +32 -0
  55. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  56. package/dist/src/core/furnace-registration-ast.js +58 -25
  57. package/dist/src/core/furnace-registration.d.ts +28 -1
  58. package/dist/src/core/furnace-registration.js +98 -1
  59. package/dist/src/core/furnace-staleness.d.ts +17 -0
  60. package/dist/src/core/furnace-staleness.js +58 -0
  61. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  62. package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
  63. package/dist/src/core/furnace-validate-helpers.js +81 -0
  64. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  65. package/dist/src/core/furnace-validate-registration.js +34 -9
  66. package/dist/src/core/furnace-validate.js +2 -2
  67. package/dist/src/core/marionette-preflight.d.ts +39 -0
  68. package/dist/src/core/marionette-preflight.js +210 -0
  69. package/dist/src/core/signal-critical.d.ts +49 -0
  70. package/dist/src/core/signal-critical.js +80 -0
  71. package/dist/src/errors/download.d.ts +1 -1
  72. package/dist/src/errors/download.js +6 -3
  73. package/dist/src/types/commands/options.d.ts +6 -0
  74. package/dist/src/types/config.d.ts +7 -0
  75. package/dist/src/types/furnace.d.ts +8 -0
  76. package/dist/src/utils/process.d.ts +15 -2
  77. package/dist/src/utils/process.js +73 -0
  78. package/package.json +1 -1
@@ -2,14 +2,13 @@
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { getProjectPaths } from '../core/config.js';
5
- import { extractComponentChecksums, hasComponentChanged } from '../core/furnace-apply-helpers.js';
6
- import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from '../core/furnace-config.js';
5
+ import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
7
6
  import { buildArtifactMismatchMessage, hasBuildArtifacts, run } from '../core/mach.js';
8
7
  import { GeneralError } from '../errors/base.js';
9
8
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
10
9
  import { toError } from '../utils/errors.js';
11
10
  import { pathExists, removeDir, removeFile } from '../utils/fs.js';
12
- import { info, intro, verbose, warn } from '../utils/logger.js';
11
+ import { info, intro, verbose } from '../utils/logger.js';
13
12
  /**
14
13
  * Cleans the dev profile to prevent stale-state startup failures.
15
14
  *
@@ -47,47 +46,6 @@ async function cleanDevProfile(engineDir) {
47
46
  verbose(`Non-fatal dev profile cleanup failure: ${toError(error).message}`);
48
47
  }
49
48
  }
50
- /**
51
- * Checks whether any Furnace component has changed since the last apply
52
- * and warns the user. The build command auto-applies, but run does not,
53
- * so this advisory message prevents the common "forgot to apply" mistake.
54
- */
55
- async function warnIfFurnaceStale(projectRoot) {
56
- try {
57
- if (!(await furnaceConfigExists(projectRoot)))
58
- return;
59
- const config = await loadFurnaceConfig(projectRoot);
60
- const state = await loadFurnaceState(projectRoot);
61
- const furnacePaths = getFurnacePaths(projectRoot);
62
- if (!state.appliedChecksums)
63
- return;
64
- const stale = [];
65
- for (const name of Object.keys(config.overrides)) {
66
- const dir = `${furnacePaths.overridesDir}/${name}`;
67
- if (!(await pathExists(dir)))
68
- continue;
69
- const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
70
- if (await hasComponentChanged(dir, prev))
71
- stale.push(name);
72
- }
73
- for (const name of Object.keys(config.custom)) {
74
- const dir = `${furnacePaths.customDir}/${name}`;
75
- if (!(await pathExists(dir)))
76
- continue;
77
- const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
78
- if (await hasComponentChanged(dir, prev))
79
- stale.push(name);
80
- }
81
- if (stale.length > 0) {
82
- warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
83
- 'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');
84
- }
85
- }
86
- catch {
87
- // Non-fatal: a broken furnace config should not block run.
88
- verbose('Furnace staleness check skipped due to an error.');
89
- }
90
- }
91
49
  /**
92
50
  * Runs the run command to launch the built browser.
93
51
  * @param projectRoot - Root directory of the project
@@ -120,6 +78,13 @@ export async function runCommand(projectRoot) {
120
78
  await cleanDevProfile(paths.engine);
121
79
  info('Launching browser...\n');
122
80
  const exitCode = await run(paths.engine);
81
+ // Exit-code whitelist:
82
+ // 0 — clean shutdown
83
+ // 130 — SIGINT (Ctrl+C), user-initiated termination
84
+ // 143 — SIGTERM, graceful-shutdown termination
85
+ // SIGKILL (137) and other signal-induced codes are intentionally NOT
86
+ // whitelisted: those indicate abnormal termination the operator should
87
+ // see surface as a build-time error.
123
88
  if (exitCode !== 0 && exitCode !== 130 && exitCode !== 143) {
124
89
  throw new BuildError(`Browser exited with code ${exitCode}`, 'mach run');
125
90
  }
@@ -178,23 +178,41 @@ async function promptSetupInputs(options) {
178
178
  throw new CancellationError();
179
179
  },
180
180
  });
181
+ return finalizePromptedSetupInputs(project);
182
+ }
183
+ /**
184
+ * Validates the raw prompt result and resolves the canonical
185
+ * {@link ResolvedSetupInputs}. Extracted from {@link promptSetupInputs} so
186
+ * the prompt body stays under the per-function line limit.
187
+ */
188
+ function finalizePromptedSetupInputs(project) {
181
189
  const sanitizedName = project.name.toLowerCase().replace(/[^a-z0-9]/g, '');
182
- const finalName = project.name;
183
- const finalVendor = project.vendor;
184
- const finalAppId = (typeof project.appId === 'string' ? project.appId.trim() : '') ||
185
- `org.${sanitizedName}.browser`;
186
- const finalBinaryName = (typeof project.binaryName === 'string' ? project.binaryName.trim() : '') || sanitizedName;
190
+ // Project names that contain no ASCII alphanumerics (e.g. "----", "漢字",
191
+ // emoji-only) collapse to an empty sanitised slug, which would silently
192
+ // produce an invalid `appId` ("org..browser") and an empty `binaryName`.
193
+ // Refuse to derive defaults from such names — the user must supply
194
+ // explicit appId / binaryName values instead.
195
+ const explicitAppId = typeof project.appId === 'string' ? project.appId.trim() : '';
196
+ const explicitBinaryName = typeof project.binaryName === 'string' ? project.binaryName.trim() : '';
197
+ if (sanitizedName === '' && (explicitAppId === '' || explicitBinaryName === '')) {
198
+ throw new InvalidArgumentError(`Project name "${project.name}" contains no characters that can be used to derive default appId / binaryName values. Re-run setup and supply --app-id and --binary-name explicitly.`, 'name');
199
+ }
200
+ const finalAppId = explicitAppId || `org.${sanitizedName}.browser`;
201
+ const finalBinaryName = explicitBinaryName || sanitizedName;
187
202
  const finalFirefoxVersion = (typeof project.firefoxVersion === 'string' ? project.firefoxVersion.trim() : '') ||
188
203
  '140.9.0esr';
189
204
  if (!isValidAppId(finalAppId)) {
190
205
  throw new InvalidArgumentError(`Derived appId "${finalAppId}" is invalid.`, 'appId');
191
206
  }
207
+ if (finalBinaryName === '') {
208
+ throw new InvalidArgumentError('Derived binaryName is empty. Supply --binary-name explicitly.', 'binaryName');
209
+ }
192
210
  if (!isValidFirefoxVersion(finalFirefoxVersion)) {
193
211
  throw new InvalidArgumentError(`Default Firefox version "${finalFirefoxVersion}" is invalid.`, 'firefoxVersion');
194
212
  }
195
213
  return {
196
- finalName,
197
- finalVendor,
214
+ finalName: project.name,
215
+ finalVendor: project.vendor,
198
216
  finalAppId,
199
217
  finalBinaryName,
200
218
  finalFirefoxVersion,
@@ -97,16 +97,64 @@ function renderRawStatus(files) {
97
97
  }
98
98
  }
99
99
  /**
100
- * Expands collapsed untracked directory entries into individual file entries.
101
- * Git status may report an entire untracked directory as a single entry (e.g. "?? dir/").
102
- * This function expands those into individual file entries so each file can be classified.
100
+ * Default maximum number of files we will materialise from a single
101
+ * untracked directory. Pathological inputs (an accidental dump of build
102
+ * output, a symlink that resolves into a huge unrelated tree, etc.)
103
+ * should not be able to balloon `status` into multi-gigabyte memory or
104
+ * hang the CLI. Going over this cap surfaces a warning so the user knows
105
+ * the listing has been truncated, and it bounds the JSON / default
106
+ * rendering paths.
107
+ *
108
+ * Override via the `FIREFORGE_MAX_UNTRACKED_FILES` environment variable
109
+ * for monorepos or fixture-heavy projects with legitimately large
110
+ * untracked directories.
103
111
  */
112
+ const DEFAULT_MAX_UNTRACKED_FILES_PER_DIR = 5000;
113
+ function resolveMaxUntrackedFilesPerDir() {
114
+ const raw = process.env['FIREFORGE_MAX_UNTRACKED_FILES'];
115
+ if (raw === undefined || raw.length === 0)
116
+ return DEFAULT_MAX_UNTRACKED_FILES_PER_DIR;
117
+ const parsed = Number.parseInt(raw, 10);
118
+ if (!Number.isFinite(parsed) || parsed <= 0) {
119
+ warn(`Ignoring FIREFORGE_MAX_UNTRACKED_FILES="${raw}" — expected a positive integer. Falling back to ${DEFAULT_MAX_UNTRACKED_FILES_PER_DIR}.`);
120
+ return DEFAULT_MAX_UNTRACKED_FILES_PER_DIR;
121
+ }
122
+ return parsed;
123
+ }
124
+ const MAX_UNTRACKED_FILES_PER_DIR = resolveMaxUntrackedFilesPerDir();
125
+ /**
126
+ * Emits a prominent top-of-output warning when one or more untracked
127
+ * directories were truncated during expansion. Individual per-dir warnings
128
+ * already fired inside expandDirectoryEntries but are easily lost in
129
+ * scrollback for large status outputs; this banner summarises the total
130
+ * hidden count so the user doesn't miss that an export based on this
131
+ * status would be incomplete.
132
+ */
133
+ function renderTruncationBanner(truncations) {
134
+ if (truncations.length === 0)
135
+ return;
136
+ const hidden = truncations.reduce((sum, rec) => sum + (rec.total - rec.shown), 0);
137
+ const dirList = truncations.map((r) => `${r.dir} (${r.total - r.shown} hidden)`).join(', ');
138
+ warn(`⚠ Status output is truncated: ${hidden.toLocaleString()} untracked file(s) across ${truncations.length} director(y/ies) are not shown. ` +
139
+ `Truncated: ${dirList}. ` +
140
+ `Add a .gitignore entry or clean the directory before exporting, otherwise the export will omit these files.`);
141
+ }
104
142
  async function expandDirectoryEntries(files, engineDir) {
105
143
  const expanded = [];
144
+ const truncations = [];
106
145
  for (const entry of files) {
107
146
  if (entry.file.endsWith('/') && entry.status.includes('?')) {
108
147
  const individualFiles = await getUntrackedFilesInDir(engineDir, entry.file);
109
- for (const f of individualFiles) {
148
+ if (individualFiles.length > MAX_UNTRACKED_FILES_PER_DIR) {
149
+ warn(`Untracked directory ${entry.file} contains ${individualFiles.length} files — only the first ${MAX_UNTRACKED_FILES_PER_DIR} will be classified. Consider adding a .gitignore entry.`);
150
+ truncations.push({
151
+ dir: entry.file,
152
+ total: individualFiles.length,
153
+ shown: MAX_UNTRACKED_FILES_PER_DIR,
154
+ });
155
+ }
156
+ const limited = individualFiles.slice(0, MAX_UNTRACKED_FILES_PER_DIR);
157
+ for (const f of limited) {
110
158
  expanded.push({ status: '??', file: f });
111
159
  }
112
160
  }
@@ -114,7 +162,7 @@ async function expandDirectoryEntries(files, engineDir) {
114
162
  expanded.push(entry);
115
163
  }
116
164
  }
117
- return expanded;
165
+ return { entries: expanded, truncations };
118
166
  }
119
167
  /**
120
168
  * Classifies files into patch-backed, unmanaged, or branding buckets.
@@ -226,9 +274,11 @@ export async function statusCommand(projectRoot, options = {}) {
226
274
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
227
275
  }
228
276
  const manifest = await loadPatchesManifest(paths.patches);
229
- const rawFilesOwnership = (await isGitRepository(paths.engine))
277
+ const ownershipExpansion = (await isGitRepository(paths.engine))
230
278
  ? await expandDirectoryEntries(await getStatusWithCodes(paths.engine), paths.engine)
231
- : [];
279
+ : { entries: [], truncations: [] };
280
+ const rawFilesOwnership = ownershipExpansion.entries;
281
+ renderTruncationBanner(ownershipExpansion.truncations);
232
282
  // Only walk the patch bodies when the directory actually exists.
233
283
  // Fresh projects with no patch queue yet pass through with an empty
234
284
  // creators map, which degrades to the old filesAffected-only
@@ -263,7 +313,8 @@ export async function statusCommand(projectRoot, options = {}) {
263
313
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
264
314
  }
265
315
  const rawFiles = await getStatusWithCodes(paths.engine);
266
- const files = await expandDirectoryEntries(rawFiles, paths.engine);
316
+ const { entries: files, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
317
+ renderTruncationBanner(truncations);
267
318
  if (files.length === 0) {
268
319
  info('No modified files');
269
320
  outro('Working tree clean');
@@ -3,26 +3,33 @@ import { join } from 'node:path';
3
3
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
6
+ import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
6
7
  import { GeneralError } from '../errors/base.js';
7
8
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
8
9
  import { pathExists } from '../utils/fs.js';
9
10
  import { info, intro, spinner } from '../utils/logger.js';
10
11
  import { pickDefined } from '../utils/options.js';
11
12
  /**
12
- * Strips the "engine/" prefix from a path if present.
13
+ * Strips a leading "engine/" or "engine\\" prefix from a path if present.
13
14
  * Users may specify paths like "engine/browser/modules/..." from the project
14
15
  * root, but mach test expects paths relative to the engine directory.
16
+ *
17
+ * The match is case-insensitive because case-insensitive filesystems
18
+ * (default macOS, Windows) treat "Engine/" and "engine/" as the same
19
+ * directory, and a literal lowercase-only check left mach with a
20
+ * non-stripped prefix that resolved to a different path under the engine
21
+ * tree. Tab and other whitespace before the prefix is also ignored.
22
+ *
15
23
  * @param testPath - Path as provided by the user
16
24
  * @returns Path relative to the engine directory
17
25
  */
18
26
  function normalizeTestPath(testPath) {
19
- if (testPath.startsWith('engine/')) {
20
- return testPath.slice('engine/'.length);
27
+ const trimmed = testPath.trim();
28
+ const match = /^engine[/\\]/i.exec(trimmed);
29
+ if (match) {
30
+ return trimmed.slice(match[0].length);
21
31
  }
22
- if (testPath.startsWith('engine\\')) {
23
- return testPath.slice('engine\\'.length);
24
- }
25
- return testPath;
32
+ return trimmed;
26
33
  }
27
34
  async function assertTestPathsExist(engineDir, testPaths) {
28
35
  const missingPaths = [];
@@ -111,6 +118,24 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
111
118
  s.stop('Build complete');
112
119
  info('');
113
120
  }
121
+ // `--doctor` runs a short marionette handshake probe. When test paths are
122
+ // supplied the probe gates the mach test invocation (a FAIL bails out). When
123
+ // no paths are supplied this is the only step — it's the fastest way to tell
124
+ // marionette-wedged apart from test-discovery-failure.
125
+ if (options.doctor) {
126
+ info('Running marionette preflight...');
127
+ const preflight = await runMarionettePreflight(paths.engine);
128
+ reportMarionettePreflight(preflight);
129
+ if (testPaths.length === 0) {
130
+ if (!preflight.ok) {
131
+ throw new GeneralError('Marionette preflight reported FAIL — see output above.');
132
+ }
133
+ return;
134
+ }
135
+ if (!preflight.ok) {
136
+ throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
137
+ }
138
+ }
114
139
  // Normalize test paths (strip engine/ prefix if present)
115
140
  const normalizedPaths = testPaths.map(normalizeTestPath);
116
141
  await assertTestPathsExist(paths.engine, normalizedPaths);
@@ -143,6 +168,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
143
168
  .description('Run tests via mach test')
144
169
  .option('--headless', 'Run tests in headless mode')
145
170
  .option('--build', 'Run incremental UI build before testing')
171
+ .option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
146
172
  .action(withErrorHandling(async (paths, options) => {
147
173
  await testCommand(getProjectRoot(), paths, pickDefined(options));
148
174
  }));
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { Option } from 'commander';
1
3
  import { loadConfig } from '../core/config.js';
2
4
  import { loadFurnaceConfig } from '../core/furnace-config.js';
3
5
  import { addToken, getTokensCssPath, validateTokenAdd, } from '../core/token-manager.js';
@@ -96,7 +98,15 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
96
98
  .command('add <token-name> <value>')
97
99
  .description('Add a design token to CSS and documentation')
98
100
  .requiredOption('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing")')
99
- .requiredOption('--mode <mode>', 'Dark mode behavior: auto, static, or override')
101
+ .addOption(
102
+ // Use Commander's .choices() so invalid --mode values are rejected with
103
+ // the built-in "argument must be one of …" message and --help lists the
104
+ // valid choices up-front. The runtime check in tokenAddCommand remains
105
+ // as a defence-in-depth guard for programmatic callers that bypass
106
+ // Commander's argument parsing.
107
+ new Option('--mode <mode>', 'Dark mode behavior')
108
+ .choices(['auto', 'static', 'override'])
109
+ .makeOptionMandatory(true))
100
110
  .option('--description <desc>', 'Comment description for the CSS file')
101
111
  .option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
102
112
  .option('--dry-run', 'Show what would be changed without writing')
@@ -1,10 +1,49 @@
1
1
  import { getProjectPaths, loadConfig } from '../core/config.js';
2
+ import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
2
3
  import { buildArtifactMismatchMessage, generateMozconfig, hasBuildArtifacts, watchWithOutput, } from '../core/mach.js';
3
4
  import { GeneralError } from '../errors/base.js';
4
5
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
6
+ import { toError } from '../utils/errors.js';
5
7
  import { pathExists } from '../utils/fs.js';
6
8
  import { info, intro, outro, spinner } from '../utils/logger.js';
7
- import { executableExists } from '../utils/process.js';
9
+ import { exec, executableExists } from '../utils/process.js';
10
+ const WATCHMAN_PROBE_TIMEOUT_MS = 5000;
11
+ /**
12
+ * Probes watchman by running `watchman --version`. A binary that exists
13
+ * in PATH but cannot respond (corrupt install, server crashed mid-session,
14
+ * permission denied on the state directory) would otherwise surface as a
15
+ * confusing mid-watch failure. Returns the trimmed version string when
16
+ * the probe succeeds; throws a {@link GeneralError} with actionable
17
+ * remediation when it does not.
18
+ */
19
+ async function probeWatchman() {
20
+ try {
21
+ const result = await exec('watchman', ['--version'], {
22
+ timeout: WATCHMAN_PROBE_TIMEOUT_MS,
23
+ });
24
+ if (result.exitCode !== 0) {
25
+ throw new GeneralError(`Watchman is installed but "watchman --version" exited ${result.exitCode}.\n\n` +
26
+ (result.stderr.trim() ? `Output:\n${result.stderr.trim()}\n\n` : '') +
27
+ 'Re-install or repair watchman, then rerun "fireforge watch".');
28
+ }
29
+ const version = result.stdout.trim();
30
+ if (!version) {
31
+ throw new GeneralError('Watchman is installed but "watchman --version" produced no output. ' +
32
+ 'Re-install or repair watchman, then rerun "fireforge watch".');
33
+ }
34
+ return version;
35
+ }
36
+ catch (error) {
37
+ if (error instanceof GeneralError)
38
+ throw error;
39
+ throw new GeneralError(`Watchman is installed but did not respond within ${WATCHMAN_PROBE_TIMEOUT_MS}ms.\n\n` +
40
+ `Underlying cause: ${toError(error).message}\n\n` +
41
+ 'Common fixes:\n' +
42
+ ' - Restart watchman: "watchman shutdown-server" then retry\n' +
43
+ " - Check filesystem permissions on watchman's state directory\n" +
44
+ ' - Re-install watchman if the binary is corrupt');
45
+ }
46
+ }
8
47
  /**
9
48
  * Builds remediation guidance for objdirs configured before watchman was available.
10
49
  * @returns User-facing configure-time watchman guidance
@@ -51,6 +90,11 @@ export async function watchCommand(projectRoot) {
51
90
  throw new GeneralError('Watch mode requires watchman to be installed and available in PATH.\n\n' +
52
91
  'Install watchman first, then rerun "fireforge watch".');
53
92
  }
93
+ // Verify watchman actually responds — a binary that is in PATH but
94
+ // unable to respond (broken install, crashed server, bad state dir
95
+ // permissions) would otherwise surface as a confusing mid-build failure
96
+ // instead of an actionable preflight error.
97
+ await probeWatchman();
54
98
  // Check for build artifacts before starting watch
55
99
  const buildCheck = await hasBuildArtifacts(paths.engine);
56
100
  if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
@@ -71,6 +115,12 @@ export async function watchCommand(projectRoot) {
71
115
  "Run 'fireforge build' first to create the initial build, then run 'fireforge watch'.");
72
116
  }
73
117
  info(`Using build artifacts from ${buildCheck.objDir}/`);
118
+ // Advisory: warn when Furnace components have drifted since the last
119
+ // apply so the user doesn't launch watch-mode builds with stale
120
+ // components baked in. Mirrors the check in `fireforge run` — without
121
+ // it, users editing a component then running `watch` would see their
122
+ // change never surface in the rebuilt browser.
123
+ await warnIfFurnaceStale(projectRoot);
74
124
  // Generate mozconfig (in case it's not up to date)
75
125
  const mozconfigSpinner = spinner('Generating mozconfig...');
76
126
  try {
@@ -28,6 +28,21 @@ function printWireDryRun(engineDir, name, subscriptDir, domFilePath, options) {
28
28
  info(` jar.mn: content/browser/${name}.js (${relPath}/${name}.js)`);
29
29
  outro('Dry run complete');
30
30
  }
31
+ /**
32
+ * Validates a subscript name supplied on the command line. Subscripts are
33
+ * resolved into filenames under the subscript directory and registered in
34
+ * jar.mn by this name, so any path separator or `..` segment would let
35
+ * the caller write outside the intended directory or corrupt the manifest.
36
+ * Mirrors the validation already applied to setup's binaryName and furnace
37
+ * custom component targetPath.
38
+ */
39
+ function validateWireName(name) {
40
+ if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
41
+ throw new InvalidArgumentError(`Subscript name "${name}" is invalid. ` +
42
+ 'Names must start with a letter or underscore and contain only letters, digits, underscores, or hyphens. ' +
43
+ 'Path separators and parent-directory segments are not permitted.', 'name');
44
+ }
45
+ }
31
46
  /**
32
47
  * Wires a chrome subscript into the browser.
33
48
  *
@@ -37,6 +52,14 @@ function printWireDryRun(engineDir, name, subscriptDir, domFilePath, options) {
37
52
  */
38
53
  export async function wireCommand(projectRoot, name, options = {}) {
39
54
  intro('Wire');
55
+ validateWireName(name);
56
+ if (options.after !== undefined) {
57
+ // --after references an existing init block by its subscript name, so
58
+ // it must follow the same naming rules as `name` itself. Without this
59
+ // check, a caller could sneak a path-traversal segment in through
60
+ // --after and have it forwarded unchanged to the lookup layer.
61
+ validateWireName(options.after);
62
+ }
40
63
  consumeParserFallbackEvents();
41
64
  // Resolve subscript directory: CLI flag > fireforge.json > default
42
65
  let subscriptDir = DEFAULT_BROWSER_SUBSCRIPT_DIR;
@@ -17,9 +17,9 @@ export declare const CONFIGS_DIR = "configs";
17
17
  /** Name of the source directory */
18
18
  export declare const SRC_DIR = "src";
19
19
  /** Supported top-level fireforge.json keys backed by the current schema. */
20
- export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint"];
20
+ export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "markerComment"];
21
21
  /** Supported config paths that can be read or set without --force. */
22
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist"];
22
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist", "markerComment"];
23
23
  /**
24
24
  * Gets all project paths based on a root directory.
25
25
  * @param root - Root directory of the project
@@ -28,6 +28,7 @@ export const SUPPORTED_CONFIG_ROOT_KEYS = [
28
28
  'license',
29
29
  'wire',
30
30
  'patchLint',
31
+ 'markerComment',
31
32
  ];
32
33
  /** Supported config paths that can be read or set without --force. */
33
34
  export const SUPPORTED_CONFIG_PATHS = [
@@ -46,6 +47,7 @@ export const SUPPORTED_CONFIG_PATHS = [
46
47
  'patchLint',
47
48
  'patchLint.checkJs',
48
49
  'patchLint.rawColorAllowlist',
50
+ 'markerComment',
49
51
  ];
50
52
  /**
51
53
  * Gets all project paths based on a root directory.
@@ -22,11 +22,25 @@ export function validateConfig(data) {
22
22
  catch {
23
23
  throw new ConfigError('Config must be an object');
24
24
  }
25
- // Required string fields
25
+ // Required string fields. Empty strings would technically pass the
26
+ // typeof-check below but are never valid for any of these identifier
27
+ // fields — rejecting them here prevents downstream code (Firefox build,
28
+ // launcher binary lookup, AppID assertions) from failing with confusing
29
+ // errors much later.
26
30
  const name = requireConfigString(rec, 'name');
27
31
  const vendor = requireConfigString(rec, 'vendor');
28
32
  const appId = requireConfigString(rec, 'appId');
29
33
  const binaryName = requireConfigString(rec, 'binaryName');
34
+ for (const [field, value] of [
35
+ ['name', name],
36
+ ['vendor', vendor],
37
+ ['appId', appId],
38
+ ['binaryName', binaryName],
39
+ ]) {
40
+ if (value.trim() === '') {
41
+ throw new ConfigError(`Config field "${field}" must not be empty`);
42
+ }
43
+ }
30
44
  if (binaryName.includes('..') ||
31
45
  binaryName.includes('/') ||
32
46
  binaryName.includes('\\') ||
@@ -107,6 +121,11 @@ export function validateConfig(data) {
107
121
  }
108
122
  config.license = licenseRaw;
109
123
  }
124
+ // Marker comment — appended to lines FireForge writes into upstream files.
125
+ const markerComment = parseMarkerComment(rec.raw('markerComment'));
126
+ if (markerComment !== undefined) {
127
+ config.markerComment = markerComment;
128
+ }
110
129
  // PatchLint
111
130
  const patchLintRec = optionalConfigObject(rec, 'patchLint');
112
131
  if (patchLintRec) {
@@ -153,6 +172,33 @@ function optionalConfigString(rec, key, label) {
153
172
  }
154
173
  return value;
155
174
  }
175
+ /**
176
+ * Validates a raw `markerComment` value. Rejected values: non-strings, empty
177
+ * strings, surrounding whitespace (ambiguous format), newlines (would break
178
+ * source formatting), and `*&#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
+ }
156
202
  function optionalConfigObject(rec, key) {
157
203
  const value = rec.raw(key);
158
204
  if (value === undefined)
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `.ftl` apply/undeploy helpers for custom components. Extracted from
3
+ * `furnace-apply-helpers.ts` so the main helper module stays under the
4
+ * per-file LOC budget.
5
+ *
6
+ * Every helper here degrades gracefully: if the locale jar.mn is missing or
7
+ * the FTL tree is non-standard, apply logs a `stepError` rather than
8
+ * aborting the whole command. Missing jar.mn on a fork without a locale
9
+ * package should not block a working `.mjs`/`.css` from shipping.
10
+ */
11
+ import type { DryRunAction, StepError } from '../types/furnace.js';
12
+ import { type RollbackJournal } from './furnace-rollback.js';
13
+ /**
14
+ * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
15
+ * in the locale jar.mn.
16
+ *
17
+ * Failure modes (missing jar.mn, regex write error) are captured as
18
+ * stepErrors rather than thrown — a well-formed `.mjs`/`.css` must never be
19
+ * blocked by a broken locale path.
20
+ */
21
+ export declare function applyCustomFtlFile(engineDir: string, name: string, componentDir: string, ftlDir: string, affectedPaths: string[], stepErrors: StepError[], rollbackJournal?: RollbackJournal): Promise<void>;
22
+ /**
23
+ * Returns a dry-run action for registering a locale jar.mn entry for the
24
+ * `.ftl` that `applyCustomFtlFile` would write. `undefined` when the FTL
25
+ * tree does not expose a locale jar.mn we can confidently name.
26
+ */
27
+ export declare function describeLocaleFtlJarMnRegistration(name: string, ftlDir: string, ftlFile: string): DryRunAction | undefined;
28
+ /**
29
+ * Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
30
+ * source workspace file has been deleted. Idempotent — absent entries are a
31
+ * no-op.
32
+ */
33
+ export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, rollbackJournal?: RollbackJournal): Promise<void>;