@hominis/fireforge 0.13.2 → 0.14.0

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 (47) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/bin/fireforge.js +19 -5
  3. package/dist/src/commands/config.js +7 -1
  4. package/dist/src/commands/discard.js +6 -1
  5. package/dist/src/commands/doctor.d.ts +12 -0
  6. package/dist/src/commands/doctor.js +6 -1
  7. package/dist/src/commands/download.js +106 -7
  8. package/dist/src/commands/export-shared.js +7 -0
  9. package/dist/src/commands/export.js +5 -0
  10. package/dist/src/commands/furnace/apply.js +147 -47
  11. package/dist/src/commands/furnace/create.js +13 -2
  12. package/dist/src/commands/furnace/deploy.js +17 -2
  13. package/dist/src/commands/furnace/diff.js +3 -1
  14. package/dist/src/commands/furnace/init.js +25 -7
  15. package/dist/src/commands/furnace/list.js +15 -7
  16. package/dist/src/commands/furnace/override.js +47 -15
  17. package/dist/src/commands/furnace/remove.js +68 -20
  18. package/dist/src/commands/furnace/rename.js +31 -3
  19. package/dist/src/commands/furnace/scan.js +8 -0
  20. package/dist/src/commands/furnace/validate.js +70 -7
  21. package/dist/src/commands/import.js +65 -11
  22. package/dist/src/commands/re-export.js +11 -4
  23. package/dist/src/commands/rebase/abort.js +26 -14
  24. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  25. package/dist/src/commands/rebase/confirm.js +2 -2
  26. package/dist/src/commands/rebase/continue.js +39 -15
  27. package/dist/src/commands/rebase/index.js +2 -1
  28. package/dist/src/commands/rebase/patch-loop.js +90 -33
  29. package/dist/src/commands/register.js +13 -0
  30. package/dist/src/commands/resolve.js +31 -10
  31. package/dist/src/commands/run.js +9 -44
  32. package/dist/src/commands/setup-support.js +25 -7
  33. package/dist/src/commands/status.js +59 -8
  34. package/dist/src/commands/test.js +13 -7
  35. package/dist/src/commands/token.js +11 -1
  36. package/dist/src/commands/watch.js +51 -1
  37. package/dist/src/commands/wire.js +23 -0
  38. package/dist/src/core/config-validate.js +15 -1
  39. package/dist/src/core/furnace-registration.d.ts +1 -1
  40. package/dist/src/core/furnace-registration.js +2 -1
  41. package/dist/src/core/furnace-staleness.d.ts +17 -0
  42. package/dist/src/core/furnace-staleness.js +58 -0
  43. package/dist/src/core/signal-critical.d.ts +49 -0
  44. package/dist/src/core/signal-critical.js +80 -0
  45. package/dist/src/errors/download.d.ts +1 -1
  46. package/dist/src/errors/download.js +6 -3
  47. 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');
@@ -9,20 +9,26 @@ import { pathExists } from '../utils/fs.js';
9
9
  import { info, intro, spinner } from '../utils/logger.js';
10
10
  import { pickDefined } from '../utils/options.js';
11
11
  /**
12
- * Strips the "engine/" prefix from a path if present.
12
+ * Strips a leading "engine/" or "engine\\" prefix from a path if present.
13
13
  * Users may specify paths like "engine/browser/modules/..." from the project
14
14
  * root, but mach test expects paths relative to the engine directory.
15
+ *
16
+ * The match is case-insensitive because case-insensitive filesystems
17
+ * (default macOS, Windows) treat "Engine/" and "engine/" as the same
18
+ * directory, and a literal lowercase-only check left mach with a
19
+ * non-stripped prefix that resolved to a different path under the engine
20
+ * tree. Tab and other whitespace before the prefix is also ignored.
21
+ *
15
22
  * @param testPath - Path as provided by the user
16
23
  * @returns Path relative to the engine directory
17
24
  */
18
25
  function normalizeTestPath(testPath) {
19
- if (testPath.startsWith('engine/')) {
20
- return testPath.slice('engine/'.length);
26
+ const trimmed = testPath.trim();
27
+ const match = /^engine[/\\]/i.exec(trimmed);
28
+ if (match) {
29
+ return trimmed.slice(match[0].length);
21
30
  }
22
- if (testPath.startsWith('engine\\')) {
23
- return testPath.slice('engine\\'.length);
24
- }
25
- return testPath;
31
+ return trimmed;
26
32
  }
27
33
  async function assertTestPathsExist(engineDir, testPaths) {
28
34
  const missingPaths = [];
@@ -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;
@@ -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('\\') ||
@@ -34,7 +34,7 @@ export { addCustomElementRegistration, removeCustomElementRegistration, validate
34
34
  * @param tagName - Custom element tag name
35
35
  * @param files - Filenames to register (e.g. ["moz-widget.mjs", "moz-widget.css"])
36
36
  */
37
- export declare function addJarMnEntries(engineDir: string, tagName: string, files: string[]): Promise<void>;
37
+ export declare function addJarMnEntries(engineDir: string, tagName: string, files: string[]): Promise<number>;
38
38
  /**
39
39
  * Removes all jar.mn entries for a given tag name.
40
40
  *
@@ -69,7 +69,7 @@ export async function addJarMnEntries(engineDir, tagName, files) {
69
69
  // check so that "moz-card.css" does not match "moz-card-group.css".
70
70
  const newFiles = files.filter((f) => !new RegExp(`content/global/elements/${escapeForRegex(f)}(?:\\s|$)`, 'm').test(content));
71
71
  if (newFiles.length === 0)
72
- return;
72
+ return 0;
73
73
  // Build new entry lines using the indent detected from existing entries.
74
74
  const indent = detectJarMnIndent(lines);
75
75
  const newEntries = newFiles.map((f) => `${indent}content/global/elements/${f} (widgets/${tagName}/${f})`);
@@ -111,6 +111,7 @@ export async function addJarMnEntries(engineDir, tagName, files) {
111
111
  lines.splice(insertIndex, 0, ...newEntries);
112
112
  content = lines.join('\n');
113
113
  await writeText(filePath, content);
114
+ return newFiles.length;
114
115
  }
115
116
  /**
116
117
  * Removes all jar.mn entries for a given tag name.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Furnace staleness advisory — shared between `fireforge run` and
3
+ * `fireforge watch`. Both commands launch the built browser without
4
+ * first running `furnace apply`, so this helper surfaces a warning when
5
+ * component files have drifted from the last-applied checksums and the
6
+ * user is about to run with stale engine state.
7
+ *
8
+ * The check is advisory only: errors (broken furnace config, partial
9
+ * state, transient filesystem failure) must never block the caller.
10
+ */
11
+ /**
12
+ * Emits a warning when any tracked override or custom component has
13
+ * changed on disk since the last apply. Safe to call from any build-time
14
+ * command that does not auto-apply — a failure inside the probe is
15
+ * downgraded to a verbose log and the caller continues.
16
+ */
17
+ export declare function warnIfFurnaceStale(projectRoot: string): Promise<void>;
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Furnace staleness advisory — shared between `fireforge run` and
4
+ * `fireforge watch`. Both commands launch the built browser without
5
+ * first running `furnace apply`, so this helper surfaces a warning when
6
+ * component files have drifted from the last-applied checksums and the
7
+ * user is about to run with stale engine state.
8
+ *
9
+ * The check is advisory only: errors (broken furnace config, partial
10
+ * state, transient filesystem failure) must never block the caller.
11
+ */
12
+ import { pathExists } from '../utils/fs.js';
13
+ import { verbose, warn } from '../utils/logger.js';
14
+ import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
15
+ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
16
+ /**
17
+ * Emits a warning when any tracked override or custom component has
18
+ * changed on disk since the last apply. Safe to call from any build-time
19
+ * command that does not auto-apply — a failure inside the probe is
20
+ * downgraded to a verbose log and the caller continues.
21
+ */
22
+ export async function warnIfFurnaceStale(projectRoot) {
23
+ try {
24
+ if (!(await furnaceConfigExists(projectRoot)))
25
+ return;
26
+ const config = await loadFurnaceConfig(projectRoot);
27
+ const state = await loadFurnaceState(projectRoot);
28
+ const furnacePaths = getFurnacePaths(projectRoot);
29
+ if (!state.appliedChecksums)
30
+ return;
31
+ const stale = [];
32
+ for (const name of Object.keys(config.overrides)) {
33
+ const dir = `${furnacePaths.overridesDir}/${name}`;
34
+ if (!(await pathExists(dir)))
35
+ continue;
36
+ const prev = extractComponentChecksums(state.appliedChecksums, 'override', name);
37
+ if (await hasComponentChanged(dir, prev))
38
+ stale.push(name);
39
+ }
40
+ for (const name of Object.keys(config.custom)) {
41
+ const dir = `${furnacePaths.customDir}/${name}`;
42
+ if (!(await pathExists(dir)))
43
+ continue;
44
+ const prev = extractComponentChecksums(state.appliedChecksums, 'custom', name);
45
+ if (await hasComponentChanged(dir, prev))
46
+ stale.push(name);
47
+ }
48
+ if (stale.length > 0) {
49
+ warn(`Furnace component${stale.length === 1 ? '' : 's'} modified since last apply: ${stale.join(', ')}. ` +
50
+ 'Run "fireforge furnace apply" (or "fireforge build" which auto-applies) to update the engine.');
51
+ }
52
+ }
53
+ catch {
54
+ // Non-fatal: a broken furnace config should not block the caller.
55
+ verbose('Furnace staleness check skipped due to an error.');
56
+ }
57
+ }
58
+ //# sourceMappingURL=furnace-staleness.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Signal-deferred critical sections.
3
+ *
4
+ * Commands that perform a compound mutation (e.g. "apply a patch to the
5
+ * engine, then persist progress to a session file on disk") need to finish
6
+ * the pair atomically with respect to SIGINT / SIGTERM. The furnace rollback
7
+ * mechanism is not the right tool here: rebase-style operations intentionally
8
+ * leave the engine mutated and only need the on-disk bookkeeping write to
9
+ * complete before the process exits.
10
+ *
11
+ * `runInSignalCriticalSection(fn)` wraps a short body in a registry slot.
12
+ * While the body runs, the CLI entry point's SIGINT / SIGTERM handlers wait
13
+ * for the slot to clear before calling `process.exit`, so a signal that
14
+ * lands mid-body is held until the body's state write finishes.
15
+ *
16
+ * This module is a pure runtime registry — it installs no signal handlers
17
+ * itself. The bin entry point is responsible for awaiting
18
+ * `waitForActiveCriticalSections` before terminating.
19
+ */
20
+ /**
21
+ * Runs `fn` inside a signal-deferred critical section. The CLI entry point's
22
+ * signal handlers `await` every active section before exiting, so a SIGINT or
23
+ * SIGTERM that arrives during `fn` will hold exit until `fn` returns (or
24
+ * rejects).
25
+ *
26
+ * `fn` should be short — anything that takes longer than the bounded wait in
27
+ * the bin handler (`SIGNAL_CRITICAL_SECTION_TIMEOUT_MS`) will time out and
28
+ * the handler will exit anyway. The intent is "guard the apply + state
29
+ * persist pair," not "postpone exit indefinitely."
30
+ */
31
+ export declare function runInSignalCriticalSection<T>(label: string, fn: () => Promise<T>): Promise<T>;
32
+ /**
33
+ * Returns true while any critical section is currently running. Used by the
34
+ * bin entry point's signal handler to decide whether to await before exit.
35
+ */
36
+ export declare function hasActiveCriticalSection(): boolean;
37
+ /**
38
+ * Waits for every active critical section to complete or for `timeoutMs` to
39
+ * elapse, whichever comes first. Never rejects: a section that throws still
40
+ * resolves from the registry's perspective because `runInSignalCriticalSection`
41
+ * cleans up in `finally`.
42
+ */
43
+ export declare function waitForActiveCriticalSections(timeoutMs: number): Promise<void>;
44
+ /**
45
+ * Test-only helper: clears the critical-section registry. Production code
46
+ * must never call this — it voids the exit-ordering guarantee for any
47
+ * section still in flight.
48
+ */
49
+ export declare function resetCriticalSectionsForTests(): void;