@hominis/fireforge 0.15.9 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/README.md +6 -2
- package/dist/src/cli.d.ts +4 -1
- package/dist/src/cli.js +6 -3
- package/dist/src/commands/config.js +16 -5
- package/dist/src/commands/download.js +31 -4
- package/dist/src/commands/export-all.js +96 -9
- package/dist/src/commands/export.js +10 -1
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
- package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
- package/dist/src/commands/furnace/create.js +21 -3
- package/dist/src/commands/furnace/diff.js +22 -2
- package/dist/src/commands/furnace/index.js +1 -0
- package/dist/src/commands/furnace/init.js +76 -2
- package/dist/src/commands/furnace/override.js +35 -12
- package/dist/src/commands/furnace/preview.js +46 -1
- package/dist/src/commands/furnace/rename.js +14 -3
- package/dist/src/commands/lint.js +26 -2
- package/dist/src/commands/package.js +16 -5
- package/dist/src/commands/re-export.js +25 -0
- package/dist/src/commands/rebase/patch-loop.js +19 -0
- package/dist/src/commands/register.js +2 -18
- package/dist/src/commands/run.js +23 -2
- package/dist/src/commands/status.js +42 -8
- package/dist/src/commands/test.js +6 -24
- package/dist/src/commands/token.js +14 -1
- package/dist/src/commands/watch.js +14 -2
- package/dist/src/commands/wire.js +35 -9
- package/dist/src/core/branding.d.ts +23 -0
- package/dist/src/core/branding.js +39 -0
- package/dist/src/core/browser-wire.js +68 -23
- package/dist/src/core/build-baseline.d.ts +14 -0
- package/dist/src/core/build-baseline.js +61 -1
- package/dist/src/core/config-mutate.d.ts +1 -1
- package/dist/src/core/config.d.ts +17 -0
- package/dist/src/core/config.js +35 -0
- package/dist/src/core/firefox.d.ts +16 -2
- package/dist/src/core/firefox.js +7 -2
- package/dist/src/core/furnace-config.d.ts +23 -0
- package/dist/src/core/furnace-config.js +38 -0
- package/dist/src/core/mach-build-artifacts.d.ts +41 -0
- package/dist/src/core/mach-build-artifacts.js +70 -0
- package/dist/src/core/mach-error-hints.js +38 -0
- package/dist/src/core/mach-mozconfig.d.ts +25 -0
- package/dist/src/core/mach-mozconfig.js +66 -0
- package/dist/src/core/mach.d.ts +12 -1
- package/dist/src/core/mach.js +14 -1
- package/dist/src/core/manifest-rules.js +22 -1
- package/dist/src/core/patch-lint.js +43 -20
- package/dist/src/core/test-stale-check.js +46 -1
- package/dist/src/core/token-manager.js +57 -4
- package/dist/src/core/token-scaffold.d.ts +36 -0
- package/dist/src/core/token-scaffold.js +74 -0
- package/dist/src/types/commands/options.d.ts +10 -0
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +12 -0
- package/dist/src/utils/paths.d.ts +19 -0
- package/dist/src/utils/paths.js +33 -0
- package/package.json +1 -1
|
@@ -100,6 +100,14 @@ async function diffOverride(name, projectRoot, config) {
|
|
|
100
100
|
/**
|
|
101
101
|
* Diffs a custom component's workspace files against the engine-deployed copy.
|
|
102
102
|
* Shows what would change (or has changed) on the next `furnace apply`.
|
|
103
|
+
*
|
|
104
|
+
* `.ftl` files deploy to `engine/<ftlDir>/<name>.ftl` via `applyCustomFtlFile`
|
|
105
|
+
* — NOT to `customConfig.targetPath` — so the deployment-target lookup has
|
|
106
|
+
* to branch on extension. Before this branch existed, a component's
|
|
107
|
+
* localization file always reported "not yet deployed to engine (new
|
|
108
|
+
* file)" after a successful apply/deploy because diff was looking for it
|
|
109
|
+
* under the component's `targetPath` while apply had written it into the
|
|
110
|
+
* locale tree.
|
|
103
111
|
*/
|
|
104
112
|
async function diffCustom(name, projectRoot, config) {
|
|
105
113
|
const customConfig = config.custom[name];
|
|
@@ -108,6 +116,7 @@ async function diffCustom(name, projectRoot, config) {
|
|
|
108
116
|
}
|
|
109
117
|
const paths = getProjectPaths(projectRoot);
|
|
110
118
|
const furnacePaths = getFurnacePaths(projectRoot);
|
|
119
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
111
120
|
const customDir = join(furnacePaths.customDir, name);
|
|
112
121
|
if (!(await pathExists(customDir))) {
|
|
113
122
|
throw new FurnaceError(`Custom component directory not found: components/custom/${name}`, name);
|
|
@@ -121,8 +130,19 @@ async function diffCustom(name, projectRoot, config) {
|
|
|
121
130
|
if (!isComponentSourceFile(entry.name))
|
|
122
131
|
continue;
|
|
123
132
|
const workspacePath = join(customDir, entry.name);
|
|
124
|
-
const deployedPath = join(engineDir, entry.name);
|
|
125
133
|
const workspaceContent = await readText(workspacePath);
|
|
134
|
+
// `.ftl` files deploy to the locale tree, not the component's
|
|
135
|
+
// targetPath; mirror `applyCustomFtlFile`'s target computation so the
|
|
136
|
+
// diff header and the existence probe name the same path apply
|
|
137
|
+
// writes to. Any change here must stay in lock-step with
|
|
138
|
+
// `src/core/furnace-apply-ftl.ts`.
|
|
139
|
+
const isFtl = entry.name.endsWith('.ftl');
|
|
140
|
+
const deployedPath = isFtl
|
|
141
|
+
? join(paths.engine, ftlDir, entry.name)
|
|
142
|
+
: join(engineDir, entry.name);
|
|
143
|
+
const deployedDisplayPath = isFtl
|
|
144
|
+
? `engine/${ftlDir}/${entry.name}`
|
|
145
|
+
: `engine/${customConfig.targetPath}/${entry.name}`;
|
|
126
146
|
if (!(await pathExists(deployedPath))) {
|
|
127
147
|
info(`${entry.name}: not yet deployed to engine (new file)`);
|
|
128
148
|
hasDifferences = true;
|
|
@@ -133,7 +153,7 @@ async function diffCustom(name, projectRoot, config) {
|
|
|
133
153
|
continue;
|
|
134
154
|
}
|
|
135
155
|
hasDifferences = true;
|
|
136
|
-
info(`---
|
|
156
|
+
info(`--- ${deployedDisplayPath}`);
|
|
137
157
|
info(`+++ components/custom/${name}/${entry.name}`);
|
|
138
158
|
for (const line of formatUnifiedDiff(deployedContent, workspaceContent)) {
|
|
139
159
|
info(line);
|
|
@@ -83,6 +83,7 @@ function registerFurnaceInfoCommands(furnace, context) {
|
|
|
83
83
|
.option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
|
|
84
84
|
.option('--shared-ftl <path>', 'Participate in an existing feature-scoped .ftl at this path (e.g. "browser/hominis-dock.ftl"); skips the per-component .ftl scaffold (implies --localized)')
|
|
85
85
|
.option('--dry-run', 'Show the planned file set and furnace.json changes without writing')
|
|
86
|
+
.option('--allow-prefix-mismatch', 'Create the component even when its name does not start with the configured `componentPrefix` in furnace.json. Without this flag the command refuses to write anything on a prefix mismatch.')
|
|
86
87
|
.action(withErrorHandling(async (name, options) => {
|
|
87
88
|
await furnaceCreateCommand(getProjectRoot(), name, options);
|
|
88
89
|
}));
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import { isAbsolute, normalize } from 'node:path';
|
|
2
|
+
import { dirname, isAbsolute, join, normalize } from 'node:path';
|
|
3
3
|
import { text } from '@clack/prompts';
|
|
4
|
+
import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
|
|
4
5
|
import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
6
|
+
import { DEFAULT_LICENSE } from '../../core/license-headers.js';
|
|
7
|
+
import { getTokensCssPath } from '../../core/token-manager.js';
|
|
8
|
+
import { generateDefaultTokensCss } from '../../core/token-scaffold.js';
|
|
5
9
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
6
|
-
import {
|
|
10
|
+
import { toError } from '../../utils/errors.js';
|
|
11
|
+
import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
|
|
12
|
+
import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
|
|
7
13
|
/**
|
|
8
14
|
* Validates an FTL base path before writing it to furnace.json. Rejects
|
|
9
15
|
* absolute paths, null bytes, and any normalised segment starting with
|
|
@@ -80,10 +86,14 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
80
86
|
}
|
|
81
87
|
await writeFurnaceConfig(projectRoot, config);
|
|
82
88
|
success('Created furnace.json');
|
|
89
|
+
const scaffoldResult = await scaffoldTokensCss(projectRoot);
|
|
83
90
|
const lines = [`Component prefix: ${config.componentPrefix}`];
|
|
84
91
|
if (config.ftlBasePath) {
|
|
85
92
|
lines.push(`FTL base path: ${config.ftlBasePath}`);
|
|
86
93
|
}
|
|
94
|
+
if (scaffoldResult.tokensCssPath) {
|
|
95
|
+
lines.push(`Tokens CSS: ${scaffoldResult.tokensCssPath}`);
|
|
96
|
+
}
|
|
87
97
|
note(lines.join('\n'), 'Configuration');
|
|
88
98
|
info('Next steps:\n' +
|
|
89
99
|
' fireforge furnace scan — discover engine components\n' +
|
|
@@ -91,4 +101,68 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
91
101
|
' fireforge furnace override — fork an existing component');
|
|
92
102
|
outro('Init complete');
|
|
93
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Scaffolds the default tokens CSS file under the engine and registers
|
|
106
|
+
* its path in `fireforge.json`'s `patchLint.rawColorAllowlist`. Both
|
|
107
|
+
* operations are skipped silently when the engine directory does not
|
|
108
|
+
* yet exist (a fresh project that hasn't `fireforge download`ed yet);
|
|
109
|
+
* the scaffold is re-driven on the next `furnace init --force`.
|
|
110
|
+
*
|
|
111
|
+
* Returns the scaffolded path when the file was actually created, so
|
|
112
|
+
* the init command can surface it in the summary note.
|
|
113
|
+
*/
|
|
114
|
+
async function scaffoldTokensCss(projectRoot) {
|
|
115
|
+
const paths = getProjectPaths(projectRoot);
|
|
116
|
+
if (!(await pathExists(paths.engine))) {
|
|
117
|
+
info('Skipping tokens CSS scaffold: engine/ not found. Run "fireforge download" followed by "fireforge furnace init --force" to scaffold it.');
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
let forgeConfig;
|
|
121
|
+
try {
|
|
122
|
+
forgeConfig = await loadConfig(projectRoot);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
warn(`Skipping tokens CSS scaffold: fireforge.json could not be loaded (${toError(error).message}). Re-run "fireforge furnace init --force" after fixing the config.`);
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
const tokensCssPath = getTokensCssPath(forgeConfig.binaryName);
|
|
129
|
+
const tokensCssAbsPath = join(paths.engine, tokensCssPath);
|
|
130
|
+
if (!(await pathExists(tokensCssAbsPath))) {
|
|
131
|
+
try {
|
|
132
|
+
await ensureDir(dirname(tokensCssAbsPath));
|
|
133
|
+
await writeText(tokensCssAbsPath, generateDefaultTokensCss(forgeConfig.binaryName, forgeConfig.license ?? DEFAULT_LICENSE));
|
|
134
|
+
success(`Scaffolded tokens CSS at engine/${tokensCssPath}`);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
warn(`Could not scaffold tokens CSS at engine/${tokensCssPath}: ${toError(error).message}. Create the file manually before running "fireforge token add".`);
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
info(`Tokens CSS already present at engine/${tokensCssPath}; leaving it untouched.`);
|
|
143
|
+
}
|
|
144
|
+
// Registering the tokens file in `patchLint.rawColorAllowlist` is the
|
|
145
|
+
// complement to the scaffold itself: the file exists specifically to
|
|
146
|
+
// carry raw color literals, and without the allowlist entry the very
|
|
147
|
+
// first `fireforge lint` run against a post-`token add` workspace
|
|
148
|
+
// fails on raw-color-value issues for tokens the operator just
|
|
149
|
+
// created. The add is idempotent, so re-running `furnace init --force`
|
|
150
|
+
// does not duplicate the entry.
|
|
151
|
+
try {
|
|
152
|
+
const existingAllowlist = forgeConfig.patchLint?.rawColorAllowlist ?? [];
|
|
153
|
+
if (!existingAllowlist.includes(tokensCssPath)) {
|
|
154
|
+
const updatedConfig = mutateConfig(forgeConfig, 'patchLint.rawColorAllowlist', [
|
|
155
|
+
...existingAllowlist,
|
|
156
|
+
tokensCssPath,
|
|
157
|
+
]);
|
|
158
|
+
await writeConfig(projectRoot, updatedConfig);
|
|
159
|
+
info(`Added ${tokensCssPath} to patchLint.rawColorAllowlist`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
warn(`Could not register tokens CSS in patchLint.rawColorAllowlist: ${toError(error).message}. ` +
|
|
164
|
+
`Add "${tokensCssPath}" manually under patchLint.rawColorAllowlist in fireforge.json if lint flags its contents.`);
|
|
165
|
+
}
|
|
166
|
+
return { tokensCssPath };
|
|
167
|
+
}
|
|
94
168
|
//# sourceMappingURL=init.js.map
|
|
@@ -139,23 +139,37 @@ async function performOverrideMutations(args) {
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
* Throws if `componentName` is already classified
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
142
|
+
* Throws if `componentName` is already classified as something `override`
|
|
143
|
+
* cannot coexist with. A stock-bucket entry is NOT a hard conflict — the
|
|
144
|
+
* whole point of `override` is to fork a component out of the stock bucket
|
|
145
|
+
* into the overrides bucket, and requiring manual `furnace.json` surgery
|
|
146
|
+
* first was a pure footgun. `promoteStockToOverrideIfNeeded` handles the
|
|
147
|
+
* transition in-memory; this guard only rejects the other two cases where
|
|
148
|
+
* a rename actually contradicts existing state.
|
|
147
149
|
*/
|
|
148
150
|
function assertNoComponentCollision(config, componentName) {
|
|
149
151
|
if (componentName in config.overrides) {
|
|
150
152
|
throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
|
|
151
153
|
}
|
|
152
|
-
if (config.stock.includes(componentName)) {
|
|
153
|
-
throw new FurnaceError(`"${componentName}" is already registered as a stock component. Remove it from config.stock before creating an override.`, componentName);
|
|
154
|
-
}
|
|
155
154
|
if (componentName in config.custom) {
|
|
156
155
|
throw new FurnaceError(`"${componentName}" is already registered as a custom component. Custom components cannot also be overrides.`, componentName);
|
|
157
156
|
}
|
|
158
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* When the operator overrides a component that `furnace scan` previously
|
|
160
|
+
* classified as stock, splice the name out of `config.stock` in-memory so
|
|
161
|
+
* the subsequent `writeFurnaceConfig` inside the mutation phase persists
|
|
162
|
+
* the stock → override promotion atomically alongside the new override
|
|
163
|
+
* entry. Returns true when a promotion happened so the caller can emit a
|
|
164
|
+
* one-line note; false when the component was not stock.
|
|
165
|
+
*/
|
|
166
|
+
function promoteStockToOverrideIfNeeded(config, componentName) {
|
|
167
|
+
const index = config.stock.indexOf(componentName);
|
|
168
|
+
if (index === -1)
|
|
169
|
+
return false;
|
|
170
|
+
config.stock.splice(index, 1);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
159
173
|
/**
|
|
160
174
|
* Runs the furnace override command to fork an existing engine component.
|
|
161
175
|
* @param projectRoot - Root directory of the project
|
|
@@ -213,6 +227,10 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
|
|
|
213
227
|
componentName = selected;
|
|
214
228
|
}
|
|
215
229
|
assertNoComponentCollision(config, componentName);
|
|
230
|
+
const promotedFromStock = promoteStockToOverrideIfNeeded(config, componentName);
|
|
231
|
+
if (promotedFromStock) {
|
|
232
|
+
info(`Promoting "${componentName}" from stock to override.`);
|
|
233
|
+
}
|
|
216
234
|
// Validate the component exists in engine
|
|
217
235
|
const details = await getComponentDetails(paths.engine, componentName, ftlDir);
|
|
218
236
|
if (!details) {
|
|
@@ -323,13 +341,18 @@ export async function furnaceBatchOverrideCommand(projectRoot, names, options =
|
|
|
323
341
|
const forgeConfig = await loadConfig(projectRoot);
|
|
324
342
|
const state = await loadState(projectRoot);
|
|
325
343
|
// Check for duplicates and pre-existing classifications across every
|
|
326
|
-
// bucket in furnace.json.
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
344
|
+
// bucket in furnace.json. A stock-bucket entry is promoted in-memory
|
|
345
|
+
// here (see `promoteStockToOverrideIfNeeded`) rather than rejected —
|
|
346
|
+
// the operator's intent is to fork that specific stock component. The
|
|
347
|
+
// collision guard still rejects name conflicts that would double-
|
|
348
|
+
// classify a tag in a way `writeFurnaceConfig` cannot safely produce
|
|
349
|
+
// (two overrides, or an override + custom).
|
|
330
350
|
const uniqueNames = [...new Set(names)];
|
|
331
351
|
for (const name of uniqueNames) {
|
|
332
352
|
assertNoComponentCollision(config, name);
|
|
353
|
+
if (promoteStockToOverrideIfNeeded(config, name)) {
|
|
354
|
+
info(`Promoting "${name}" from stock to override.`);
|
|
355
|
+
}
|
|
333
356
|
}
|
|
334
357
|
const succeeded = [];
|
|
335
358
|
const failed = [];
|
|
@@ -6,7 +6,7 @@ import { furnaceConfigExists, loadFurnaceConfig, updateFurnaceState, } from '../
|
|
|
6
6
|
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
7
7
|
import { restoreRollbackJournal } from '../../core/furnace-rollback.js';
|
|
8
8
|
import { cleanStories, syncStories } from '../../core/furnace-stories.js';
|
|
9
|
-
import { runMach, runMachCapture } from '../../core/mach.js';
|
|
9
|
+
import { hasBuildArtifacts, runMach, runMachCapture } from '../../core/mach.js';
|
|
10
10
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
11
11
|
import { toError } from '../../utils/errors.js';
|
|
12
12
|
import { pathExists } from '../../utils/fs.js';
|
|
@@ -89,6 +89,48 @@ function buildStorybookFailureMessage(output, installRequested) {
|
|
|
89
89
|
return ('Storybook failed to start. Check the output above for the specific Firefox-side error.\n\n' +
|
|
90
90
|
installHint);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Preflights the Firefox build + toolchain prerequisites `mach storybook`
|
|
94
|
+
* quietly assumes. Pre-0.16.0 the preview staged components and launched
|
|
95
|
+
* a ~1000-package `mach storybook upgrade` npm install before the
|
|
96
|
+
* backend surfaced a "missing chrome-map.json" / Cargo-config failure;
|
|
97
|
+
* the preflight below refuses fast and leaves the workspace untouched.
|
|
98
|
+
*
|
|
99
|
+
* Extracted from `furnacePreviewCommand` so the main function stays
|
|
100
|
+
* under the per-function LOC budget as the preflight list grows.
|
|
101
|
+
*
|
|
102
|
+
* @param engineDir - Resolved engine directory
|
|
103
|
+
* @throws FurnaceError when the Firefox build hasn't produced dist/, or
|
|
104
|
+
* when `.cargo/config.toml` is absent
|
|
105
|
+
*/
|
|
106
|
+
async function assertPreviewPrerequisites(engineDir) {
|
|
107
|
+
const buildCheck = await hasBuildArtifacts(engineDir);
|
|
108
|
+
if (!buildCheck.exists) {
|
|
109
|
+
throw new FurnaceError('Furnace preview requires a completed Firefox build. ' +
|
|
110
|
+
'`mach storybook` consumes `obj-*/dist/chrome-map.json` and the packaged chrome resources under `dist/`, neither of which is present before `fireforge build` completes.\n\n' +
|
|
111
|
+
'Run "fireforge build" and wait for it to finish, then rerun "fireforge furnace preview". ' +
|
|
112
|
+
'This preflight avoids a multi-minute `mach storybook upgrade` npm install on an engine that cannot start Storybook anyway.');
|
|
113
|
+
}
|
|
114
|
+
// Accept either `.cargo/config.toml` (post-configure) or
|
|
115
|
+
// `.cargo/config.toml.in` (post-bootstrap template, consumed at
|
|
116
|
+
// `mach configure` time). Pre-0.16.0 the preflight insisted on the
|
|
117
|
+
// plain file, but `fireforge bootstrap` alone produces only `.in` —
|
|
118
|
+
// operators who followed the remediation instruction ("run bootstrap
|
|
119
|
+
// then rerun preview") hit the same refusal on the retry. Either name
|
|
120
|
+
// is sufficient to prove the Rust toolchain is registered; the stronger
|
|
121
|
+
// `hasBuildArtifacts` check above already guards against a completely
|
|
122
|
+
// un-configured tree, so relaxing this to an OR-check does not weaken
|
|
123
|
+
// the signal we care about.
|
|
124
|
+
const cargoConfigPath = join(engineDir, '.cargo', 'config.toml');
|
|
125
|
+
const cargoConfigInPath = join(engineDir, '.cargo', 'config.toml.in');
|
|
126
|
+
const cargoConfigPresent = (await pathExists(cargoConfigPath)) || (await pathExists(cargoConfigInPath));
|
|
127
|
+
if (!cargoConfigPresent) {
|
|
128
|
+
throw new FurnaceError("Furnace preview requires the engine's Rust toolchain to be bootstrapped. " +
|
|
129
|
+
'Neither `.cargo/config.toml` nor `.cargo/config.toml.in` exists under the engine directory — ' +
|
|
130
|
+
'`mach storybook` fails deep inside the Storybook backend compile without either of them.\n\n' +
|
|
131
|
+
'Run "fireforge bootstrap" (or the underlying `mach bootstrap` in the engine) to populate the toolchain config, then rerun "fireforge furnace preview".');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
92
134
|
/**
|
|
93
135
|
* Runs the furnace preview command to start Storybook for component preview.
|
|
94
136
|
* @param projectRoot - Root directory of the project
|
|
@@ -119,6 +161,9 @@ export async function furnacePreviewCommand(projectRoot, options = {}) {
|
|
|
119
161
|
if (!(await pathExists(storybookRoot))) {
|
|
120
162
|
throw new FurnaceError('This Firefox checkout does not contain browser/components/storybook. Furnace preview requires the upstream Storybook workspace to exist before stories can be synced.');
|
|
121
163
|
}
|
|
164
|
+
// Build + toolchain preflight (Finding #9). Extracted into a helper so
|
|
165
|
+
// the function below stays under the per-function LOC budget.
|
|
166
|
+
await assertPreviewPrerequisites(paths.engine);
|
|
122
167
|
let previewResult;
|
|
123
168
|
// True once we are about to (or have) written to engine/.../stories/furnace.
|
|
124
169
|
// Intentionally set BEFORE `syncStories` is awaited so a mid-sync failure
|
|
@@ -304,14 +304,25 @@ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
|
|
|
304
304
|
throw new FurnaceError(`A component named "${newName}" already exists in furnace.json.`, newName);
|
|
305
305
|
}
|
|
306
306
|
const componentType = isCustom ? 'custom' : 'override';
|
|
307
|
+
// `componentType` is the furnace-state key (singular: `custom` /
|
|
308
|
+
// `override`); the on-disk directory label differs — custom components
|
|
309
|
+
// live under `components/custom/` (singular) while overrides live under
|
|
310
|
+
// `components/overrides/` (plural). Before 0.16.0, every rename
|
|
311
|
+
// user-facing message appended an `s` to `componentType`, which
|
|
312
|
+
// produced the wrong label `components/customs/` for custom components
|
|
313
|
+
// and was technically correct for overrides only by coincidence.
|
|
314
|
+
// `componentDirLabel` centralises the singular/plural pick so every
|
|
315
|
+
// operator-facing string names the directory that actually exists on
|
|
316
|
+
// disk.
|
|
317
|
+
const componentDirLabel = isCustom ? 'custom' : 'overrides';
|
|
307
318
|
const baseDir = isCustom ? furnacePaths.customDir : furnacePaths.overridesDir;
|
|
308
319
|
const oldDir = join(baseDir, oldName);
|
|
309
320
|
const newDir = join(baseDir, newName);
|
|
310
321
|
if (!(await pathExists(oldDir))) {
|
|
311
|
-
throw new FurnaceError(`Component directory not found: components/${
|
|
322
|
+
throw new FurnaceError(`Component directory not found: components/${componentDirLabel}/${oldName}`, oldName);
|
|
312
323
|
}
|
|
313
324
|
if (await pathExists(newDir)) {
|
|
314
|
-
throw new FurnaceError(`Target directory already exists: components/${
|
|
325
|
+
throw new FurnaceError(`Target directory already exists: components/${componentDirLabel}/${newName}`, newName);
|
|
315
326
|
}
|
|
316
327
|
await performRenameMutations({
|
|
317
328
|
projectRoot,
|
|
@@ -326,7 +337,7 @@ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
|
|
|
326
337
|
engineDir: paths.engine,
|
|
327
338
|
});
|
|
328
339
|
note(`Component renamed: ${oldName} → ${newName}\n\n` +
|
|
329
|
-
`Directory: components/${
|
|
340
|
+
`Directory: components/${componentDirLabel}/${newName}/\n\n` +
|
|
330
341
|
'Next steps:\n' +
|
|
331
342
|
' 1. Review the renamed files for any remaining references\n' +
|
|
332
343
|
' 2. Run "fireforge furnace validate" to verify\n' +
|
|
@@ -12,6 +12,7 @@ import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
|
12
12
|
import { GeneralError } from '../errors/base.js';
|
|
13
13
|
import { pathExists } from '../utils/fs.js';
|
|
14
14
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
15
|
+
import { stripEnginePrefix } from '../utils/paths.js';
|
|
15
16
|
/**
|
|
16
17
|
* Resolves the diff the lint command should run against. Returns `null` when
|
|
17
18
|
* there is nothing to lint (e.g. no matching files, clean tree, or empty
|
|
@@ -27,7 +28,15 @@ async function resolveLintDiff(engineDir, files) {
|
|
|
27
28
|
const collectedFiles = new Set();
|
|
28
29
|
let fileStatuses;
|
|
29
30
|
let untrackedFiles;
|
|
30
|
-
|
|
31
|
+
// Strip a leading `engine/` segment up-front so the rest of the lookup
|
|
32
|
+
// pipeline (directory stat, modified-files-in-dir, status probe) all
|
|
33
|
+
// see the engine-relative form. Without this, passing
|
|
34
|
+
// `engine/browser/base/content/foo.js` fell through to "No modified
|
|
35
|
+
// files found in the specified paths." because git sees every path
|
|
36
|
+
// relative to engine/. The same normalization runs in `register`,
|
|
37
|
+
// `test`, and `export` via `stripEnginePrefix`.
|
|
38
|
+
const normalizedFiles = files.map((inputPath) => stripEnginePrefix(inputPath));
|
|
39
|
+
for (const inputPath of normalizedFiles) {
|
|
31
40
|
const fullInputPath = join(engineDir, inputPath);
|
|
32
41
|
let isDirectory = false;
|
|
33
42
|
try {
|
|
@@ -145,10 +154,25 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
145
154
|
// really an artefact of aggregation. Surface a one-line note pointing at
|
|
146
155
|
// `--per-patch` so the operator knows the per-patch scope exists before
|
|
147
156
|
// they read the error message as "my queue is broken".
|
|
157
|
+
//
|
|
158
|
+
// In aggregate mode over a multi-patch queue we also downgrade the two
|
|
159
|
+
// size rules from `error` to `warning`. Before this downgrade, a
|
|
160
|
+
// fresh-imported patch stack of 20+ patches hard-failed `fireforge lint`
|
|
161
|
+
// on lines-per-aggregate counts that are mathematically impossible to
|
|
162
|
+
// satisfy without splitting patches that were already split — the
|
|
163
|
+
// actionable unit is the individual patch, and `--per-patch` is the
|
|
164
|
+
// mode that matches. Per-patch mode keeps errors as errors (see
|
|
165
|
+
// `lintPerPatch` below).
|
|
148
166
|
const aggregateHintApplicable = files.length === 0 && ctx !== undefined && ctx.entries.length > 1;
|
|
149
167
|
if (aggregateHintApplicable &&
|
|
150
168
|
issues.some((i) => i.check === 'large-patch-lines' || i.check === 'large-patch-files')) {
|
|
151
|
-
info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode.');
|
|
169
|
+
info('NOTE: aggregate diff across all applied patches. Use `fireforge lint --per-patch` to lint each patch individually; patch-size rules fire against the sum in aggregate mode and are reported as warnings rather than errors here.');
|
|
170
|
+
for (const issue of issues) {
|
|
171
|
+
if ((issue.check === 'large-patch-lines' || issue.check === 'large-patch-files') &&
|
|
172
|
+
issue.severity === 'error') {
|
|
173
|
+
issue.severity = 'warning';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
152
176
|
}
|
|
153
177
|
if (issues.length === 0) {
|
|
154
178
|
success('No lint issues found.');
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { validateBrandOverride } from '../core/brand-validation.js';
|
|
2
2
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
3
3
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
4
|
-
import { buildArtifactMismatchMessage, hasBuildArtifacts,
|
|
4
|
+
import { buildArtifactMismatchMessage, hasBuildArtifacts, machPackageCapture, } from '../core/mach.js';
|
|
5
|
+
import { explainMachError } from '../core/mach-error-hints.js';
|
|
5
6
|
import { GeneralError } from '../errors/base.js';
|
|
6
7
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
7
8
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -49,9 +50,16 @@ export async function packageCommand(projectRoot, options) {
|
|
|
49
50
|
info('Creating distribution package...');
|
|
50
51
|
info('This may take a while.\n');
|
|
51
52
|
const startTime = Date.now();
|
|
52
|
-
let
|
|
53
|
+
let result;
|
|
53
54
|
try {
|
|
54
|
-
|
|
55
|
+
// `machPackageCapture` streams output live AND captures the tail for
|
|
56
|
+
// post-run diagnostics. Previously `machPackage` inherited stdio
|
|
57
|
+
// only, so a targeted hint translator could not see the failure text.
|
|
58
|
+
// The captured stderr is fed through `explainMachError` below so
|
|
59
|
+
// recognised failure modes (notably the `packager.py` NoneType trip
|
|
60
|
+
// the evaluator hit on `hominis/`) get an actionable hint prepended
|
|
61
|
+
// to the raw mach output the operator already saw.
|
|
62
|
+
result = await machPackageCapture(paths.engine);
|
|
55
63
|
}
|
|
56
64
|
catch (error) {
|
|
57
65
|
throw new BuildError('Package process failed to start', 'mach package', error instanceof Error ? error : undefined);
|
|
@@ -60,9 +68,12 @@ export async function packageCommand(projectRoot, options) {
|
|
|
60
68
|
const minutes = Math.floor(duration / 60000);
|
|
61
69
|
const seconds = Math.floor((duration % 60000) / 1000);
|
|
62
70
|
const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
63
|
-
if (exitCode !== 0) {
|
|
71
|
+
if (result.exitCode !== 0) {
|
|
64
72
|
error(`Packaging failed after ${timeStr}`);
|
|
65
|
-
|
|
73
|
+
const combinedOutput = `${result.stdout}\n${result.stderr}`;
|
|
74
|
+
const hints = explainMachError(combinedOutput);
|
|
75
|
+
const hintBlock = hints.length > 0 ? `\n\nHint:\n${hints.map((h) => ` ${h}`).join('\n')}` : '';
|
|
76
|
+
throw new BuildError(`Packaging failed with exit code ${result.exitCode}.${hintBlock}`, 'mach package');
|
|
66
77
|
}
|
|
67
78
|
info(`\nPackage created in obj-*/dist/`);
|
|
68
79
|
outro(`Packaging completed in ${timeStr}!`);
|
|
@@ -59,6 +59,31 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
59
59
|
if (options.scan) {
|
|
60
60
|
currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
|
|
61
61
|
}
|
|
62
|
+
else if (options.files === undefined) {
|
|
63
|
+
// Finding #16: when neither `--scan` nor `--files` is set and some
|
|
64
|
+
// of the manifest's claimed files no longer exist on disk, the
|
|
65
|
+
// re-export silently writes a refreshed body whose filesAffected
|
|
66
|
+
// still names the vanished paths. That is the documented contract,
|
|
67
|
+
// but it is also a footgun — a later `verify` then fails on
|
|
68
|
+
// manifest-consistency with no obvious trigger. Emit one advisory
|
|
69
|
+
// warning up-front when we can detect the drift cheaply, so the
|
|
70
|
+
// operator has a chance to re-run with `--scan` or `--files`
|
|
71
|
+
// before the stale filesAffected lands in patches.json.
|
|
72
|
+
const missingFiles = [];
|
|
73
|
+
for (const file of currentFilesAffected) {
|
|
74
|
+
if (!(await pathExists(join(paths.engine, file)))) {
|
|
75
|
+
missingFiles.push(file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (missingFiles.length > 0) {
|
|
79
|
+
warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
|
|
80
|
+
`(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
|
|
81
|
+
`filesAffected unchanged and the missing entries will be preserved — ` +
|
|
82
|
+
`\`fireforge verify\` may flag manifest inconsistency after this run.\n` +
|
|
83
|
+
` Re-run with --scan to reconcile filesAffected with the current worktree, ` +
|
|
84
|
+
`or pass --files <paths> to set the list explicitly.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
62
87
|
// --- Explicit file-subset path ---
|
|
63
88
|
// When --files is given, the target filesAffected is authoritative — drop
|
|
64
89
|
// anything not in the list, add anything new. This is the surgical repair
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { updateState } from '../../core/config.js';
|
|
7
|
+
import { stampFurnaceOverrideBaseVersions } from '../../core/furnace-config.js';
|
|
7
8
|
import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
|
|
8
9
|
import { applyPatchWithFuzz } from '../../core/patch-apply-fuzz.js';
|
|
9
10
|
import { updatePatch } from '../../core/patch-export.js';
|
|
@@ -126,6 +127,24 @@ export async function runPatchLoop(projectRoot, session, paths, maxFuzz) {
|
|
|
126
127
|
if (appliedFilenames.length > 0) {
|
|
127
128
|
await stampPatchVersions(paths.patches, appliedFilenames, session.toVersion);
|
|
128
129
|
}
|
|
130
|
+
// Stamp every Furnace override's `baseVersion` to match the rebased
|
|
131
|
+
// Firefox version. Before this stamp, a successful ESR bump left
|
|
132
|
+
// overrides in a doctor-failing drift state (each override still
|
|
133
|
+
// claimed the pre-rebase ESR as its baseline) and every subsequent
|
|
134
|
+
// `fireforge doctor` failed `Furnace component validation`. The
|
|
135
|
+
// stamp is unconditional per the helper's contract: rebase already
|
|
136
|
+
// succeeded on the patch side, so the operator is committing to the
|
|
137
|
+
// new ESR baseline; per-component health checking stays with
|
|
138
|
+
// `fireforge furnace validate` / `doctor --repair-furnace`.
|
|
139
|
+
try {
|
|
140
|
+
const overridesStamped = await stampFurnaceOverrideBaseVersions(projectRoot, session.toVersion);
|
|
141
|
+
if (overridesStamped > 0) {
|
|
142
|
+
info(`Stamped ${overridesStamped} Furnace override baseVersion(s) to ${session.toVersion}.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (furnaceStampError) {
|
|
146
|
+
warn(`Could not stamp Furnace override baseVersion(s) to ${session.toVersion}: ${toError(furnaceStampError).message}. Update baseVersion in furnace.json by hand or run "fireforge furnace refresh" if validate reports drift.`);
|
|
147
|
+
}
|
|
129
148
|
// Print summary and clean up
|
|
130
149
|
printSummary(session);
|
|
131
150
|
await clearRebaseSession(projectRoot);
|
|
@@ -6,23 +6,7 @@ import { InvalidArgumentError } from '../errors/base.js';
|
|
|
6
6
|
import { pathExists } from '../utils/fs.js';
|
|
7
7
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
8
8
|
import { pickDefined } from '../utils/options.js';
|
|
9
|
-
|
|
10
|
-
* Strips a leading `engine/` segment (either separator flavour) from a
|
|
11
|
-
* user-supplied path so operators can pass either a repo-root-relative
|
|
12
|
-
* path (`engine/browser/base/content/foo.xhtml`) or an engine-relative
|
|
13
|
-
* path (`browser/base/content/foo.xhtml`). The engine-relative form is
|
|
14
|
-
* what the manifest writers expect; without this normalisation, the
|
|
15
|
-
* former failed with a misleading "File not found in engine" pointing
|
|
16
|
-
* at a doubled path like `engine/engine/browser/...` that operators
|
|
17
|
-
* had no way to spot from the error message alone.
|
|
18
|
-
*/
|
|
19
|
-
function normalizeEngineRelativePath(filePath) {
|
|
20
|
-
if (filePath.startsWith('engine/'))
|
|
21
|
-
return filePath.slice('engine/'.length);
|
|
22
|
-
if (filePath.startsWith('engine\\'))
|
|
23
|
-
return filePath.slice('engine\\'.length);
|
|
24
|
-
return filePath;
|
|
25
|
-
}
|
|
9
|
+
import { stripEnginePrefix } from '../utils/paths.js';
|
|
26
10
|
/**
|
|
27
11
|
* Registers a file in the appropriate build manifest.
|
|
28
12
|
*
|
|
@@ -50,7 +34,7 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
|
|
|
50
34
|
// the former from the output of tab completion or `git status`, and
|
|
51
35
|
// the mismatch used to produce a "File not found" error that named
|
|
52
36
|
// the original path with no hint that dropping `engine/` would fix it.
|
|
53
|
-
const engineRelativePath =
|
|
37
|
+
const engineRelativePath = stripEnginePrefix(filePath);
|
|
54
38
|
// Verify the file exists in engine/ (skip for dry-run)
|
|
55
39
|
if (!options.dryRun) {
|
|
56
40
|
const paths = getProjectPaths(projectRoot);
|
package/dist/src/commands/run.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { createWriteStream } from 'node:fs';
|
|
3
3
|
import { readdir, readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { getProjectPaths } from '../core/config.js';
|
|
5
|
+
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
6
|
import { warnIfFurnaceStale } from '../core/furnace-staleness.js';
|
|
7
|
-
import { buildArtifactMismatchMessage, hasBuildArtifacts, run, runMachSmoke, } from '../core/mach.js';
|
|
7
|
+
import { buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, run, runMachSmoke, } from '../core/mach.js';
|
|
8
8
|
import { compileAllowlistFromFile, compileAllowlistFromStrings, matchesAllowlist, matchesSmokeError, } from '../core/smoke-patterns.js';
|
|
9
9
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
10
10
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
@@ -94,6 +94,27 @@ export async function runCommand(projectRoot, options = {}) {
|
|
|
94
94
|
throw new GeneralError(`Run requires a completed build. ${detail}\n\n` +
|
|
95
95
|
"Run 'fireforge build' first, then rerun 'fireforge run'.");
|
|
96
96
|
}
|
|
97
|
+
// `hasBuildArtifacts` only checks for an `obj-*/dist/` directory; a
|
|
98
|
+
// build that configured but hasn't yet produced the launchable binary
|
|
99
|
+
// (common in a long real Firefox compile that the operator stopped
|
|
100
|
+
// and restarted) passes that check, and `mach run` then fails on the
|
|
101
|
+
// missing binary path. `hasRunnableBundle` narrows the probe to the
|
|
102
|
+
// actual executable so `fireforge run` refuses with a targeted
|
|
103
|
+
// message before handing control to mach. `fireforge watch` stays
|
|
104
|
+
// permissive and instead surfaces the same information as a banner
|
|
105
|
+
// suffix; watch is supposed to drive rebuilds of partially-built
|
|
106
|
+
// trees, so blocking there would defeat the feature.
|
|
107
|
+
if (buildCheck.objDir) {
|
|
108
|
+
const config = await loadConfig(projectRoot);
|
|
109
|
+
const bundleCheck = await hasRunnableBundle(paths.engine, config.binaryName, buildCheck.objDir);
|
|
110
|
+
if (!bundleCheck.runnable) {
|
|
111
|
+
const expected = bundleCheck.expectedPath ?? `dist/bin/${config.binaryName}`;
|
|
112
|
+
throw new GeneralError(`Run requires a completed build that produced the launchable bundle. ` +
|
|
113
|
+
`Build artifacts exist in ${buildCheck.objDir}/ but the expected binary at ${expected} is missing — ` +
|
|
114
|
+
`the build may have aborted or is still in progress.\n\n` +
|
|
115
|
+
"Run 'fireforge build' and wait for it to finish before retrying 'fireforge run'.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
97
118
|
// Warn if Furnace components changed since the last apply
|
|
98
119
|
await warnIfFurnaceStale(projectRoot);
|
|
99
120
|
// Clean stale profile state to prevent silent startup failures
|