@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.
- package/CHANGELOG.md +19 -0
- package/README.md +5 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +25 -15
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +15 -2
- package/dist/src/commands/wire.js +34 -8
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +45 -1
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- 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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
155
|
-
.
|
|
156
|
-
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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,
|
|
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 (
|
|
424
|
-
if (
|
|
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(
|
|
397
|
+
printStatusGroups(patchBacked);
|
|
428
398
|
}
|
|
429
|
-
if (
|
|
430
|
-
if (
|
|
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(
|
|
404
|
+
printStatusGroups(branding);
|
|
434
405
|
}
|
|
435
|
-
if (
|
|
436
|
-
if (
|
|
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(
|
|
414
|
+
printStatusGroups(furnace);
|
|
440
415
|
}
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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 (
|
|
449
|
-
parts.push(`${
|
|
450
|
-
if (
|
|
451
|
-
parts.push(`${
|
|
452
|
-
if (
|
|
453
|
-
parts.push(`${
|
|
454
|
-
if (
|
|
455
|
-
parts.push(`${
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|