@hominis/fireforge 0.16.5 → 0.17.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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +5 -3
  3. package/dist/src/commands/build.js +16 -7
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor.js +14 -1
  6. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  7. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  8. package/dist/src/commands/furnace/create-templates.js +11 -2
  9. package/dist/src/commands/furnace/init.js +97 -9
  10. package/dist/src/commands/furnace/rename.js +110 -0
  11. package/dist/src/commands/lint.js +55 -4
  12. package/dist/src/commands/resolve.d.ts +25 -1
  13. package/dist/src/commands/resolve.js +25 -15
  14. package/dist/src/commands/status.js +100 -122
  15. package/dist/src/commands/test.js +15 -2
  16. package/dist/src/commands/wire.js +34 -8
  17. package/dist/src/core/config.d.ts +33 -0
  18. package/dist/src/core/config.js +43 -0
  19. package/dist/src/core/furnace-config.d.ts +23 -2
  20. package/dist/src/core/furnace-config.js +26 -3
  21. package/dist/src/core/mach.d.ts +31 -0
  22. package/dist/src/core/mach.js +45 -1
  23. package/dist/src/core/marionette-port.d.ts +50 -0
  24. package/dist/src/core/marionette-port.js +215 -0
  25. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  26. package/dist/src/core/patch-manifest-consistency.js +16 -1
  27. package/dist/src/core/status-classify.d.ts +54 -0
  28. package/dist/src/core/status-classify.js +134 -0
  29. package/dist/src/core/token-dark-mode.d.ts +49 -0
  30. package/dist/src/core/token-dark-mode.js +182 -0
  31. package/dist/src/core/token-manager.js +17 -33
  32. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  33. package/dist/src/core/wire-dom-fragment.js +40 -0
  34. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { isBrandingManagedPath } from '../core/branding.js';
4
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
6
  import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
6
7
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
- import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
8
+ import { getModifiedFiles, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
8
9
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
10
  import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
10
11
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
@@ -22,8 +23,19 @@ import { stripEnginePrefix } from '../utils/paths.js';
22
23
  * per-function LOC budget as the command grows; the two file-mode and
23
24
  * aggregate-mode branches share no state with the post-lint reporting
24
25
  * pipeline, so the split is a pure rename rather than a refactor.
26
+ *
27
+ * When `binaryName` is provided, the aggregate-mode branch (no
28
+ * explicit file list) excludes paths under `browser/branding/<binaryName>/`
29
+ * from the diff. `status` classifies those paths as `branding` —
30
+ * tool-managed material the operator did not author directly — and
31
+ * the 2026-04-21 eval (Finding #2) reported that `fireforge lint` on
32
+ * a fresh project immediately failed `large-patch-lines` /
33
+ * `large-patch-files` / `missing-license-header` on the generated
34
+ * branding tree. File-list mode (explicit paths) preserves the
35
+ * previous behaviour: passing a branding file explicitly still lints
36
+ * it, so operators who need to audit branding content can do so.
25
37
  */
26
- async function resolveLintDiff(engineDir, files) {
38
+ async function resolveLintDiff(engineDir, files, binaryName) {
27
39
  if (files.length > 0) {
28
40
  const collectedFiles = new Set();
29
41
  let fileStatuses;
@@ -83,6 +95,40 @@ async function resolveLintDiff(engineDir, files) {
83
95
  outro('Nothing to lint');
84
96
  return null;
85
97
  }
98
+ // Aggregate-mode branding exclusion. A fresh-setup workspace (after
99
+ // `fireforge setup` + `download` + `bootstrap` + `build`) carries a
100
+ // large tool-managed branding diff that the operator did not
101
+ // author; running the default lint against it fires size and
102
+ // license-header rules on content that was never intended to
103
+ // survive in the patch queue as-is. The exclusion mirrors the
104
+ // `branding` bucket in `fireforge status` so the two views stay
105
+ // consistent.
106
+ if (binaryName) {
107
+ const modified = await getModifiedFiles(engineDir);
108
+ const untracked = await getUntrackedFiles(engineDir);
109
+ const allPaths = [...new Set([...modified, ...untracked])];
110
+ const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
111
+ const excludedCount = allPaths.length - nonBrandingPaths.length;
112
+ if (excludedCount > 0) {
113
+ info(`Excluded ${excludedCount} tool-managed branding file${excludedCount === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
114
+ }
115
+ if (nonBrandingPaths.length === 0) {
116
+ info('No non-branding changes to lint.');
117
+ outro('Nothing to lint');
118
+ return null;
119
+ }
120
+ const diff = await getDiffForFilesAgainstHead(engineDir, nonBrandingPaths.sort());
121
+ if (!diff.trim()) {
122
+ info('No diff content to lint.');
123
+ outro('Nothing to lint');
124
+ return null;
125
+ }
126
+ return diff;
127
+ }
128
+ // Fallback path: no binaryName available (e.g. a legacy caller
129
+ // without a loaded config). Retain the pre-0.16.0 behaviour of
130
+ // linting the full diff so the lint surface is at least as broad
131
+ // as before.
86
132
  const diff = await getAllDiff(engineDir);
87
133
  if (!diff.trim()) {
88
134
  info('No diff content to lint.');
@@ -126,10 +172,15 @@ export async function lintCommand(projectRoot, files, options = {}) {
126
172
  await lintPerPatch(projectRoot, paths);
127
173
  return;
128
174
  }
129
- const diff = await resolveLintDiff(paths.engine, files);
175
+ // Load the config before resolving the diff so we can pass
176
+ // `binaryName` into the aggregate-mode branding exclusion in
177
+ // `resolveLintDiff`. The config was previously loaded only after
178
+ // the diff was resolved; hoisting it is cheap and keeps the two
179
+ // call sites close together.
180
+ const config = await loadConfig(projectRoot);
181
+ const diff = await resolveLintDiff(paths.engine, files, config.binaryName);
130
182
  if (diff === null)
131
183
  return;
132
- const config = await loadConfig(projectRoot);
133
184
  const filesAffected = extractAffectedFiles(diff);
134
185
  // Build patch queue context once so it can be shared between the
135
186
  // per-patch ownership resolver and the cross-patch rules.
@@ -1,9 +1,33 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandContext } from '../types/cli.js';
3
+ /**
4
+ * Options accepted by {@link resolveCommand}.
5
+ */
6
+ export interface ResolveCommandOptions {
7
+ /**
8
+ * Skip the interactive "Have you finished fixing the files?"
9
+ * confirmation prompt and treat the resolution as complete.
10
+ *
11
+ * Motivating case (2026-04-21 eval, Finding #18): a scripted or
12
+ * CI-assisted recovery flow that has already completed the manual
13
+ * merge step cannot advance through `fireforge resolve` because the
14
+ * TTY guard refuses non-interactive invocations outright. `--yes`
15
+ * is the explicit opt-in for those flows: the operator is asserting
16
+ * they have already done the merge, and the command proceeds
17
+ * straight to the patch-refresh + state-clear path.
18
+ *
19
+ * The guard without `--yes` is preserved — running `resolve` with
20
+ * no TTY and no `--yes` still refuses so an accidental pipe-into
21
+ * invocation doesn't silently commit whatever the engine happens
22
+ * to contain.
23
+ */
24
+ yes?: boolean;
25
+ }
3
26
  /**
4
27
  * Runs the resolve command to fix broken patches.
5
28
  * @param projectRoot - Root directory of the project
29
+ * @param options - Optional flags; see {@link ResolveCommandOptions}.
6
30
  */
7
- export declare function resolveCommand(projectRoot: string): Promise<void>;
31
+ export declare function resolveCommand(projectRoot: string, options?: ResolveCommandOptions): Promise<void>;
8
32
  /** Registers the resolve command on the CLI program. */
9
33
  export declare function registerResolve(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
@@ -15,8 +15,9 @@ import { error as logError, info, intro, isCancel, outro, spinner, success, } fr
15
15
  /**
16
16
  * Runs the resolve command to fix broken patches.
17
17
  * @param projectRoot - Root directory of the project
18
+ * @param options - Optional flags; see {@link ResolveCommandOptions}.
18
19
  */
19
- export async function resolveCommand(projectRoot) {
20
+ export async function resolveCommand(projectRoot, options = {}) {
20
21
  intro('FireForge Resolve');
21
22
  const paths = getProjectPaths(projectRoot);
22
23
  const state = await loadState(projectRoot);
@@ -35,17 +36,25 @@ export async function resolveCommand(projectRoot) {
35
36
  if (!(await isGitRepository(paths.engine))) {
36
37
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
37
38
  }
38
- if (!process.stdin.isTTY) {
39
- throw new GeneralError('Cannot run "fireforge resolve" in non-interactive mode. Use a terminal with TTY support.');
39
+ // Non-interactive mode requires an explicit `--yes` to proceed: the
40
+ // operator is asserting the manual merge is complete and the
41
+ // refreshed diff is the one to record. Without `--yes`, an accidental
42
+ // pipe / CI shell could otherwise commit whatever the engine
43
+ // currently contains. 2026-04-21 eval (Finding #18): a scripted
44
+ // recovery flow was dead-ended by the unconditional TTY refusal.
45
+ if (!process.stdin.isTTY && !options.yes) {
46
+ throw new GeneralError('Cannot run "fireforge resolve" in non-interactive mode. Use a terminal with TTY support, or pass "--yes" to skip the interactive confirmation once the manual merge is complete.');
40
47
  }
41
- const finished = await confirm({
42
- message: 'Have you finished manually fixing the files in engine/?',
43
- initialValue: true,
44
- });
45
- if (isCancel(finished) || !finished) {
46
- info('Please fix the conflicts and run "fireforge resolve" again.');
47
- outro('Resolution paused');
48
- return;
48
+ if (!options.yes) {
49
+ const finished = await confirm({
50
+ message: 'Have you finished manually fixing the files in engine/?',
51
+ initialValue: true,
52
+ });
53
+ if (isCancel(finished) || !finished) {
54
+ info('Please fix the conflicts and run "fireforge resolve" again.');
55
+ outro('Resolution paused');
56
+ return;
57
+ }
49
58
  }
50
59
  const manifest = await loadPatchesManifest(paths.patches);
51
60
  if (!manifest) {
@@ -138,7 +147,7 @@ export async function resolveCommand(projectRoot) {
138
147
  });
139
148
  s.stop(`Updated ${patchFilename}`);
140
149
  success('Patch updated successfully and resolution state cleared.');
141
- info('Run "fireforge import" to apply the remaining patches.');
150
+ info('Patch updated. Run "fireforge import" next to resume the queue from this point — resolve only refreshes the one broken patch, it does not continue applying the remaining patches itself.');
142
151
  outro('Resolution complete');
143
152
  }
144
153
  catch (error) {
@@ -151,9 +160,10 @@ export async function resolveCommand(projectRoot) {
151
160
  export function registerResolve(program, { getProjectRoot, withErrorHandling }) {
152
161
  program
153
162
  .command('resolve')
154
- .description('Update a broken patch with manual fixes and continue')
155
- .action(withErrorHandling(async () => {
156
- await resolveCommand(getProjectRoot());
163
+ .description('Update a broken patch with manual fixes (then run "fireforge import" to resume the queue)')
164
+ .option('-y, --yes', 'Skip the interactive confirmation prompt. Use for non-interactive automation flows (CI, scripted recovery) after the manual merge is complete.')
165
+ .action(withErrorHandling(async (options) => {
166
+ await resolveCommand(getProjectRoot(), options);
157
167
  }));
158
168
  }
159
169
  //# sourceMappingURL=resolve.js.map
@@ -1,19 +1,15 @@
1
- // SPDX-License-Identifier: EUPL-1.2
2
- import { join } from 'node:path';
3
- import { isBrandingManagedPath } from '../core/branding.js';
4
1
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
2
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
3
  import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
7
4
  import { getUntrackedFilesInDir } from '../core/git-status.js';
8
5
  import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
9
6
  import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
10
- import { computePatchedContent } from '../core/patch-apply.js';
11
7
  import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
12
8
  import { loadPatchesManifest } from '../core/patch-manifest.js';
9
+ import { classifyFiles, } from '../core/status-classify.js';
13
10
  import { GeneralError } from '../errors/base.js';
14
- import { toError } from '../utils/errors.js';
15
- import { FIREFORGE_TMP_PATH_PATTERN, pathExists, readText } from '../utils/fs.js';
16
- import { info, intro, outro, verbose, warn } from '../utils/logger.js';
11
+ import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
12
+ import { info, intro, outro, warn } from '../utils/logger.js';
17
13
  /**
18
14
  * Status code descriptions for git status.
19
15
  */
@@ -179,87 +175,27 @@ async function expandDirectoryEntries(files, engineDir) {
179
175
  function filterFireForgeTempFiles(files) {
180
176
  return files.filter((entry) => !FIREFORGE_TMP_PATH_PATTERN.test(entry.file));
181
177
  }
182
- /**
183
- * Classifies files into patch-backed, unmanaged, or branding buckets.
184
- */
185
- async function classifyFiles(files, engineDir, patchesDir, binaryName, furnacePrefixes) {
186
- const manifest = await loadPatchesManifest(patchesDir);
187
- // Build set of all patch-claimed file paths
188
- const patchClaimedFiles = new Set();
189
- if (manifest) {
190
- for (const patch of manifest.patches) {
191
- for (const f of patch.filesAffected) {
192
- patchClaimedFiles.add(f);
193
- }
194
- }
195
- }
196
- const results = [];
197
- for (const entry of files) {
198
- // Branding check first
199
- if (isBrandingManagedPath(entry.file, binaryName)) {
200
- results.push({ ...entry, classification: 'branding' });
201
- continue;
202
- }
203
- // Furnace-managed component paths
204
- if (furnacePrefixes.size > 0) {
205
- let isFurnace = false;
206
- for (const prefix of furnacePrefixes) {
207
- if (entry.file.startsWith(prefix)) {
208
- isFurnace = true;
209
- break;
210
- }
211
- }
212
- if (isFurnace) {
213
- results.push({ ...entry, classification: 'furnace' });
214
- continue;
215
- }
216
- }
217
- // Not in any patch → unmanaged
218
- if (!patchClaimedFiles.has(entry.file)) {
219
- results.push({ ...entry, classification: 'unmanaged' });
220
- continue;
221
- }
222
- // File is claimed by a patch — compare content
223
- const primaryCode = getPrimaryStatusCode(entry.status);
224
- if (primaryCode === 'D') {
225
- // Deleted file: patch-backed only if patch expects deletion
226
- const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
227
- results.push({
228
- ...entry,
229
- classification: expected === null ? 'patch-backed' : 'unmanaged',
230
- });
231
- continue;
232
- }
233
- // File exists on disk — compare actual vs expected
234
- try {
235
- const [expected, actual] = await Promise.all([
236
- computePatchedContent(patchesDir, engineDir, entry.file),
237
- readText(join(engineDir, entry.file)),
238
- ]);
239
- results.push({
240
- ...entry,
241
- classification: actual === expected ? 'patch-backed' : 'unmanaged',
242
- });
243
- }
244
- catch (error) {
245
- verbose(`Treating ${entry.file} as unmanaged because patch-backed classification failed: ${toError(error).message}`);
246
- // If we can't read the file, treat as unmanaged
247
- results.push({ ...entry, classification: 'unmanaged' });
248
- }
249
- }
250
- return results;
251
- }
252
178
  /**
253
179
  * Renders classified file status as machine-readable JSON to stdout.
254
180
  */
255
181
  async function renderJsonStatus(files, paths, projectRoot, binaryName) {
256
182
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
257
183
  const classified = await classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
258
- const output = classified.map((f) => ({
259
- file: f.file,
260
- status: f.status.trim(),
261
- classification: f.classification,
262
- }));
184
+ const output = classified.map((f) => {
185
+ const entry = {
186
+ file: f.file,
187
+ status: f.status.trim(),
188
+ classification: f.classification,
189
+ };
190
+ // `claimedBy` is an optional field present only on conflict
191
+ // entries, so non-conflict output stays byte-identical to the
192
+ // pre-0.16.0 shape (no unconditional schema change for the
193
+ // 99% of entries that are not cross-patch conflicts).
194
+ if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
195
+ entry.claimedBy = [...f.claimedBy];
196
+ }
197
+ return entry;
198
+ });
263
199
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
264
200
  }
265
201
  /**
@@ -394,65 +330,107 @@ export async function statusCommand(projectRoot, options = {}) {
394
330
  // Patch-aware classification
395
331
  const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
396
332
  const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
397
- const unmanagedFiles = classified.filter((f) => f.classification === 'unmanaged');
398
- const patchBackedFiles = classified.filter((f) => f.classification === 'patch-backed');
399
- const brandingFiles = classified.filter((f) => f.classification === 'branding');
400
- const furnaceFiles = classified.filter((f) => f.classification === 'furnace');
333
+ const buckets = {
334
+ conflict: classified.filter((f) => f.classification === 'conflict'),
335
+ unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
336
+ patchBacked: classified.filter((f) => f.classification === 'patch-backed'),
337
+ branding: classified.filter((f) => f.classification === 'branding'),
338
+ furnace: classified.filter((f) => f.classification === 'furnace'),
339
+ };
401
340
  // --unmanaged mode: only show unmanaged
402
341
  if (options.unmanaged) {
403
- info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${files.length} total modified):\n`);
404
- if (unmanagedFiles.length > 0) {
405
- printStatusGroups(unmanagedFiles);
406
- await printUnregisteredWarnings(unmanagedFiles, projectRoot, config.binaryName);
407
- }
408
- else {
409
- info('No unmanaged changes');
410
- }
411
- outro(unmanagedFiles.length === 0
412
- ? 'No unmanaged changes'
413
- : `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
342
+ await renderUnmanagedOnly(buckets.unmanaged, files.length, projectRoot, config.binaryName);
414
343
  return;
415
344
  }
416
- // Default mode: three-bucket display
417
- info(`${files.length} modified file${files.length === 1 ? '' : 's'}:\n`);
345
+ await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
346
+ }
347
+ async function renderUnmanagedOnly(unmanagedFiles, totalModified, projectRoot, binaryName) {
348
+ info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${totalModified} total modified):\n`);
418
349
  if (unmanagedFiles.length > 0) {
419
- warn('Unmanaged changes:');
420
350
  printStatusGroups(unmanagedFiles);
421
- await printUnregisteredWarnings(unmanagedFiles, projectRoot, config.binaryName);
351
+ await printUnregisteredWarnings(unmanagedFiles, projectRoot, binaryName);
352
+ }
353
+ else {
354
+ info('No unmanaged changes');
355
+ }
356
+ outro(unmanagedFiles.length === 0
357
+ ? 'No unmanaged changes'
358
+ : `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
359
+ }
360
+ /**
361
+ * Renders the default five-bucket status display: conflicts first
362
+ * (they block export/import/rebase), then unmanaged, patch-backed,
363
+ * branding, and furnace-managed sections. Cross-bucket separators
364
+ * ensure the sections are visually distinct without trailing empty
365
+ * groups. Empty buckets are omitted — the very-empty case surfaces a
366
+ * single `No changes` line.
367
+ */
368
+ async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
369
+ const { conflict, unmanaged, patchBacked, branding, furnace } = buckets;
370
+ info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
371
+ if (conflict.length > 0) {
372
+ // Surface cross-patch ownership conflicts at the top of the default
373
+ // output — they block export/import/rebase and want immediate
374
+ // attention. The `--ownership` view already renders the full table;
375
+ // here we just name the files and point the operator at the
376
+ // canonical recovery path.
377
+ warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
378
+ printStatusGroups(conflict);
379
+ for (const entry of conflict) {
380
+ if (entry.claimedBy && entry.claimedBy.length > 0) {
381
+ info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
382
+ }
383
+ }
384
+ info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
422
385
  }
423
- if (patchBackedFiles.length > 0) {
424
- if (unmanagedFiles.length > 0)
386
+ if (unmanaged.length > 0) {
387
+ if (conflict.length > 0)
388
+ info('');
389
+ warn('Unmanaged changes:');
390
+ printStatusGroups(unmanaged);
391
+ await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
392
+ }
393
+ if (patchBacked.length > 0) {
394
+ if (conflict.length > 0 || unmanaged.length > 0)
425
395
  info('');
426
396
  warn('Patch-backed materialized changes:');
427
- printStatusGroups(patchBackedFiles);
397
+ printStatusGroups(patchBacked);
428
398
  }
429
- if (brandingFiles.length > 0) {
430
- if (unmanagedFiles.length > 0 || patchBackedFiles.length > 0)
399
+ if (branding.length > 0) {
400
+ if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0) {
431
401
  info('');
402
+ }
432
403
  warn('Tool-managed branding changes:');
433
- printStatusGroups(brandingFiles);
404
+ printStatusGroups(branding);
434
405
  }
435
- if (furnaceFiles.length > 0) {
436
- if (unmanagedFiles.length > 0 || patchBackedFiles.length > 0 || brandingFiles.length > 0)
406
+ if (furnace.length > 0) {
407
+ if (conflict.length > 0 ||
408
+ unmanaged.length > 0 ||
409
+ patchBacked.length > 0 ||
410
+ branding.length > 0) {
437
411
  info('');
412
+ }
438
413
  warn('Furnace-managed component changes:');
439
- printStatusGroups(furnaceFiles);
414
+ printStatusGroups(furnace);
440
415
  }
441
- if (unmanagedFiles.length === 0 &&
442
- patchBackedFiles.length === 0 &&
443
- brandingFiles.length === 0 &&
444
- furnaceFiles.length === 0) {
416
+ if (conflict.length === 0 &&
417
+ unmanaged.length === 0 &&
418
+ patchBacked.length === 0 &&
419
+ branding.length === 0 &&
420
+ furnace.length === 0) {
445
421
  info('No changes');
446
422
  }
447
423
  const parts = [];
448
- if (unmanagedFiles.length > 0)
449
- parts.push(`${unmanagedFiles.length} unmanaged`);
450
- if (patchBackedFiles.length > 0)
451
- parts.push(`${patchBackedFiles.length} patch-backed`);
452
- if (brandingFiles.length > 0)
453
- parts.push(`${brandingFiles.length} branding`);
454
- if (furnaceFiles.length > 0)
455
- parts.push(`${furnaceFiles.length} furnace`);
424
+ if (conflict.length > 0)
425
+ parts.push(`${conflict.length} conflict`);
426
+ if (unmanaged.length > 0)
427
+ parts.push(`${unmanaged.length} unmanaged`);
428
+ if (patchBacked.length > 0)
429
+ parts.push(`${patchBacked.length} patch-backed`);
430
+ if (branding.length > 0)
431
+ parts.push(`${branding.length} branding`);
432
+ if (furnace.length > 0)
433
+ parts.push(`${furnace.length} furnace`);
456
434
  outro(parts.join(', '));
457
435
  }
458
436
  /** Registers the status command on the CLI program. */
@@ -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 { assertMarionettePortAvailable } from '../core/marionette-port.js';
6
7
  import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
7
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
8
9
  import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
@@ -148,10 +149,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
148
149
  throw new GeneralError(`Tests require a completed build. ${detail}\n\n` +
149
150
  "Run 'fireforge build' first, then run 'fireforge test'.");
150
151
  }
152
+ // Load the project config once so both the build and the port
153
+ // probe have access to `binaryName` (the port probe uses it to
154
+ // recognise a fork-branded browser holding the Marionette port).
155
+ const projectConfig = await loadConfig(projectRoot);
151
156
  // Run incremental build if requested
152
157
  if (options.build) {
153
- const config = await loadConfig(projectRoot);
154
- await prepareBuildEnvironment(projectRoot, paths, config);
158
+ await prepareBuildEnvironment(projectRoot, paths, projectConfig);
155
159
  const s = spinner('Running incremental build...');
156
160
  const buildExitCode = await buildUI(paths.engine);
157
161
  if (buildExitCode !== 0) {
@@ -175,6 +179,15 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
175
179
  warn(formatStaleBuildWarning(stale));
176
180
  }
177
181
  }
182
+ // Stale-browser probe: an interrupted earlier test run can leave a
183
+ // Firefox/ForgeFresh/Hominis instance listening on the Marionette
184
+ // control port, which breaks the next mach test launch with a
185
+ // bind error that points nowhere near the real cause. Raise a
186
+ // targeted refusal up front instead of letting mach surface the
187
+ // generic bind failure. 2026-04-21 eval (Finding #20): a stale
188
+ // `-marionette` process from `fresh/` poisoned a later test run in
189
+ // the sibling `hominis/` workspace.
190
+ await assertMarionettePortAvailable(undefined, { binaryName: projectConfig.binaryName });
178
191
  // `--doctor` runs a short marionette handshake probe. When test paths are
179
192
  // supplied the probe gates the mach test invocation (a FAIL bails out). When
180
193
  // no paths are supplied this is the only step — it's the fastest way to tell
@@ -4,7 +4,7 @@ import { DEFAULT_BROWSER_SUBSCRIPT_DIR, wireSubscript } from '../core/browser-wi
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { furnaceConfigExists as checkFurnaceConfigExists, loadFurnaceConfig, } from '../core/furnace-config.js';
6
6
  import { consumeParserFallbackEvents } from '../core/parser-fallback.js';
7
- import { DEFAULT_DOM_TARGET } from '../core/wire-dom-fragment.js';
7
+ import { DEFAULT_DOM_TARGET, probeDomFragmentInsertionPoint } from '../core/wire-dom-fragment.js';
8
8
  import { coerceToCall, validateWireName as validateWireExpression } from '../core/wire-utils.js';
9
9
  import { InvalidArgumentError } from '../errors/base.js';
10
10
  import { toError } from '../utils/errors.js';
@@ -83,6 +83,34 @@ function validateWireName(name) {
83
83
  'Path separators and parent-directory segments are not permitted.', 'name');
84
84
  }
85
85
  }
86
+ /**
87
+ * Asserts that the resolved chrome document both exists on disk AND
88
+ * exposes an insertion anchor (`#include browser-sets.inc` or
89
+ * `<html:body>`) that `addDomFragment` can splice into. Fires the same
90
+ * check in dry-run and real-run mode, so the preview and execution
91
+ * agree on whether the target is wireable before any disk mutations
92
+ * happen. Before 0.16.0 this check only ran on the real branch, which
93
+ * let the dry-run produce a plausible-looking plan that the real run
94
+ * then refused with `Could not find insertion point in chrome document`.
95
+ */
96
+ async function assertDomTargetIsWireable(projectRoot, domFilePath, domTargetPath) {
97
+ const paths = getProjectPaths(projectRoot);
98
+ if (!(await pathExists(join(paths.engine, domTargetPath)))) {
99
+ throw new InvalidArgumentError(`Chrome document not found in engine: ${domTargetPath}\n` +
100
+ 'Set "tokenHostDocuments" in furnace.json (first entry is used by wire) ' +
101
+ 'or pass --target <path>.', 'target');
102
+ }
103
+ try {
104
+ await probeDomFragmentInsertionPoint(paths.engine, domFilePath, domTargetPath);
105
+ }
106
+ catch (probeError) {
107
+ throw new InvalidArgumentError(`${probeError instanceof Error ? probeError.message : String(probeError)}\n` +
108
+ `The resolved chrome document ${domTargetPath} does not expose an insertion anchor ` +
109
+ 'that `fireforge wire` recognises (`#include browser-sets.inc` or `<html:body>`). ' +
110
+ 'Add one of those anchors to the chrome doc, or target a document that has them via ' +
111
+ '`--target <path>`.', 'target');
112
+ }
113
+ }
86
114
  /**
87
115
  * Wires a chrome subscript into the browser.
88
116
  *
@@ -192,14 +220,12 @@ export async function wireCommand(projectRoot, name, options = {}) {
192
220
  }
193
221
  const domTargetPath = await resolveDomTargetPath(projectRoot, normalizedTarget);
194
222
  if (domFilePath) {
195
- const paths = getProjectPaths(projectRoot);
196
- if (!options.dryRun && !(await pathExists(join(paths.engine, domTargetPath)))) {
197
- throw new InvalidArgumentError(`Chrome document not found in engine: ${domTargetPath}\n` +
198
- 'Set "tokenHostDocuments" in furnace.json (first entry is used by wire) ' +
199
- 'or pass --target <path>.', 'target');
200
- }
223
+ await assertDomTargetIsWireable(projectRoot, domFilePath, domTargetPath);
201
224
  }
202
- // Verify the subscript file exists in engine/ (skip for dry-run)
225
+ // Verify the subscript file exists in engine/ (skip for dry-run:
226
+ // dry-run is meant to preview the mutation plan without requiring
227
+ // the subscript to already exist, matching the "plan before write"
228
+ // pattern operators rely on for setup scripts).
203
229
  if (!options.dryRun) {
204
230
  const paths = getProjectPaths(projectRoot);
205
231
  const subscriptPath = join(paths.engine, subscriptDir, `${name}.js`);
@@ -52,5 +52,38 @@ export declare function writeConfig(root: string, config: FireForgeConfig): Prom
52
52
  * Writes a raw config document to fireforge.json.
53
53
  * This is used by CLI `config --force`, where callers may intentionally write
54
54
  * keys or value shapes outside the validated FireForgeConfig schema.
55
+ *
56
+ * Individual writes are atomic via {@link writeJson} (temp file + rename),
57
+ * but atomicity alone does not prevent lost updates across concurrent
58
+ * writers: each writer reads an old copy, mutates its own in-memory view,
59
+ * and writes it back, so the second writer's rename clobbers the first
60
+ * writer's changes. Callers that do read → mutate → write must hold
61
+ * {@link withConfigFileLock} for the full round-trip to serialise
62
+ * against other writers.
55
63
  */
56
64
  export declare function writeConfigDocument(root: string, config: FireForgeConfig | Record<string, unknown>): Promise<void>;
65
+ /**
66
+ * Runs an operation while holding a sidecar lock on `fireforge.json`.
67
+ *
68
+ * Motivating case (2026-04-21 eval): two concurrent `fireforge config
69
+ * <key> <value>` invocations each ran load → mutate → writeJson against
70
+ * the same on-disk fireforge.json. The second rename landed after the
71
+ * first, silently dropping the first writer's key — both commands exited
72
+ * `0`, but only one change survived. This helper turns the same
73
+ * read-modify-write sequence into a serialised operation so a concurrent
74
+ * writer now waits for the lock rather than racing on the document.
75
+ *
76
+ * Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: writers
77
+ * always use `writeJson`'s atomic temp-file + rename, so a reader observes
78
+ * either the pre- or post-write document but never a torn file. The lock
79
+ * only serialises writers against other writers.
80
+ *
81
+ * The lock is a sidecar directory `${config}.fireforge-config.lock`, and
82
+ * `withFileLock` handles stale-lock recovery (PID-alive probe, age-based
83
+ * fallback) — a crashed writer does not permanently block future writes.
84
+ *
85
+ * @param root - Root directory of the project
86
+ * @param operation - Async function to run while holding the lock
87
+ * @returns Whatever the operation returns
88
+ */
89
+ export declare function withConfigFileLock<T>(root: string, operation: () => Promise<T>): Promise<T>;