@hominis/fireforge 0.10.1 → 0.11.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 +93 -1
- package/README.md +125 -238
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +5 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- package/package.json +1 -1
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { select, text } from '@clack/prompts';
|
|
5
|
-
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
6
|
-
import {
|
|
5
|
+
import { getProjectPaths, loadConfig, loadState } from '../../core/config.js';
|
|
6
|
+
import { createDefaultFurnaceConfig, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
7
|
+
import { resolveFtlDir } from '../../core/furnace-constants.js';
|
|
8
|
+
import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
9
|
+
import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
|
|
10
|
+
import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
|
|
7
11
|
import { getComponentDetails, scanWidgetsDirectory } from '../../core/furnace-scanner.js';
|
|
8
12
|
import { InvalidArgumentError } from '../../errors/base.js';
|
|
9
13
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
14
|
+
import { toError } from '../../utils/errors.js';
|
|
10
15
|
import { copyFile, ensureDir, pathExists, writeJson } from '../../utils/fs.js';
|
|
11
|
-
import { cancel, intro, isCancel, note, outro } from '../../utils/logger.js';
|
|
16
|
+
import { cancel, info, intro, isCancel, note, outro, warn } from '../../utils/logger.js';
|
|
17
|
+
async function loadAuthoringFurnaceConfig(projectRoot) {
|
|
18
|
+
if (await furnaceConfigExists(projectRoot)) {
|
|
19
|
+
return loadFurnaceConfig(projectRoot);
|
|
20
|
+
}
|
|
21
|
+
return createDefaultFurnaceConfig();
|
|
22
|
+
}
|
|
12
23
|
/**
|
|
13
24
|
* Copies the source files needed for a new override into the workspace.
|
|
14
25
|
* @param srcDir - Original component directory in the engine checkout
|
|
@@ -16,7 +27,7 @@ import { cancel, intro, isCancel, note, outro } from '../../utils/logger.js';
|
|
|
16
27
|
* @param overrideType - Requested override mode
|
|
17
28
|
* @returns Filenames copied into the override directory
|
|
18
29
|
*/
|
|
19
|
-
async function copyOverrideFiles(srcDir, destDir, overrideType) {
|
|
30
|
+
async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasFTL, overrideType, ftlDir, journal) {
|
|
20
31
|
await ensureDir(destDir);
|
|
21
32
|
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
22
33
|
const copiedFiles = [];
|
|
@@ -26,18 +37,30 @@ async function copyOverrideFiles(srcDir, destDir, overrideType) {
|
|
|
26
37
|
if (overrideType === 'css-only') {
|
|
27
38
|
// Only copy .css files
|
|
28
39
|
if (entry.name.endsWith('.css')) {
|
|
29
|
-
|
|
40
|
+
const dest = join(destDir, entry.name);
|
|
41
|
+
await snapshotFile(journal, dest);
|
|
42
|
+
await copyFile(join(srcDir, entry.name), dest);
|
|
30
43
|
copiedFiles.push(entry.name);
|
|
31
44
|
}
|
|
32
45
|
}
|
|
33
46
|
else {
|
|
34
47
|
// Full override: copy .mjs and .css files
|
|
35
48
|
if (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')) {
|
|
36
|
-
|
|
49
|
+
const dest = join(destDir, entry.name);
|
|
50
|
+
await snapshotFile(journal, dest);
|
|
51
|
+
await copyFile(join(srcDir, entry.name), dest);
|
|
37
52
|
copiedFiles.push(entry.name);
|
|
38
53
|
}
|
|
39
54
|
}
|
|
40
55
|
}
|
|
56
|
+
if (overrideType === 'full' && hasFTL) {
|
|
57
|
+
const ftlName = `${componentName}.ftl`;
|
|
58
|
+
const ftlSrc = join(engineDir, ftlDir, ftlName);
|
|
59
|
+
const dest = join(destDir, ftlName);
|
|
60
|
+
await snapshotFile(journal, dest);
|
|
61
|
+
await copyFile(ftlSrc, dest);
|
|
62
|
+
copiedFiles.push(ftlName);
|
|
63
|
+
}
|
|
41
64
|
return copiedFiles;
|
|
42
65
|
}
|
|
43
66
|
/**
|
|
@@ -51,22 +74,55 @@ async function copyOverrideFiles(srcDir, destDir, overrideType) {
|
|
|
51
74
|
* @param firefoxVersion - Firefox version recorded in the workspace config
|
|
52
75
|
* @param config - Mutable Furnace config object to update
|
|
53
76
|
*/
|
|
54
|
-
async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion, config) {
|
|
77
|
+
async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion, config, journal, baseCommit) {
|
|
55
78
|
const overrideJson = {
|
|
56
79
|
type: overrideType,
|
|
57
80
|
description,
|
|
58
81
|
basePath: details.sourcePath,
|
|
59
82
|
baseVersion: firefoxVersion,
|
|
83
|
+
...(baseCommit ? { baseCommit } : {}),
|
|
60
84
|
};
|
|
61
|
-
|
|
85
|
+
const overrideJsonPath = join(destDir, 'override.json');
|
|
86
|
+
await snapshotFile(journal, overrideJsonPath);
|
|
87
|
+
await writeJson(overrideJsonPath, overrideJson);
|
|
62
88
|
config.overrides[componentName] = {
|
|
63
89
|
type: overrideType,
|
|
64
90
|
description,
|
|
65
91
|
basePath: details.sourcePath,
|
|
66
92
|
baseVersion: firefoxVersion,
|
|
93
|
+
...(baseCommit ? { baseCommit } : {}),
|
|
67
94
|
};
|
|
68
95
|
await writeFurnaceConfig(projectRoot, config);
|
|
69
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Performs the transactional mutation phase of furnace override under the
|
|
99
|
+
* shared lifecycle wrapper. Extracted from `furnaceOverrideCommand` so the
|
|
100
|
+
* main function stays under the `max-lines-per-function` threshold and so
|
|
101
|
+
* the rollback contract is colocated with the writes it guards.
|
|
102
|
+
*/
|
|
103
|
+
async function performOverrideMutations(args) {
|
|
104
|
+
return runFurnaceMutation(args.projectRoot, 'override-rollback', async (ctx) => {
|
|
105
|
+
const journal = createRollbackJournal();
|
|
106
|
+
ctx.registerJournal(journal);
|
|
107
|
+
recordCreatedDir(journal, args.destDir);
|
|
108
|
+
try {
|
|
109
|
+
const filesCopied = await copyOverrideFiles(args.engineDir, args.srcDir, args.destDir, args.componentName, args.details.hasFTL, args.overrideType, args.ftlDir, journal);
|
|
110
|
+
await snapshotFile(journal, args.furnacePaths.furnaceConfig);
|
|
111
|
+
await saveOverrideConfig(args.projectRoot, args.destDir, args.componentName, args.overrideType, args.description, args.details, args.firefoxVersion, args.config, journal, args.baseCommit);
|
|
112
|
+
return filesCopied;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
try {
|
|
116
|
+
await restoreRollbackJournalOrThrow(journal, `Failed to override component "${args.componentName}"`);
|
|
117
|
+
}
|
|
118
|
+
catch (rollbackError) {
|
|
119
|
+
await recordFurnaceRollbackFailure(args.projectRoot, 'override-rollback', toError(rollbackError).message);
|
|
120
|
+
throw rollbackError;
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
70
126
|
/**
|
|
71
127
|
* Runs the furnace override command to fork an existing engine component.
|
|
72
128
|
* @param projectRoot - Root directory of the project
|
|
@@ -76,19 +132,33 @@ async function saveOverrideConfig(projectRoot, destDir, componentName, overrideT
|
|
|
76
132
|
export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
|
|
77
133
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
78
134
|
intro('Furnace Override');
|
|
79
|
-
//
|
|
80
|
-
|
|
135
|
+
// --- Validate config-independent inputs BEFORE auto-creating furnace.json
|
|
136
|
+
// so a failed authoring command never strands a fresh config in the
|
|
137
|
+
// project root. CLI-supplied name is checked here; the engine directory
|
|
138
|
+
// and component-resolution checks below also have no config dependency.
|
|
81
139
|
const paths = getProjectPaths(projectRoot);
|
|
82
|
-
|
|
83
|
-
|
|
140
|
+
if (name !== undefined && !CUSTOM_ELEMENT_TAG_PATTERN.test(name)) {
|
|
141
|
+
throw new InvalidArgumentError(`Invalid component name "${name}": ${CUSTOM_ELEMENT_TAG_RULES}`, 'name');
|
|
142
|
+
}
|
|
143
|
+
if (name === undefined && !isInteractive) {
|
|
144
|
+
throw new InvalidArgumentError('Component name is required in non-interactive mode.\n' +
|
|
145
|
+
'Usage: fireforge furnace override <name> -t <type> -d "description"', 'name');
|
|
146
|
+
}
|
|
147
|
+
// Verify engine/ exists (config-independent precondition)
|
|
84
148
|
if (!(await pathExists(paths.engine))) {
|
|
85
149
|
throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
|
|
86
150
|
}
|
|
151
|
+
// Load the current config without auto-creating a new furnace.json. A user
|
|
152
|
+
// cancelling out of the interactive prompts should not leave a fresh config
|
|
153
|
+
// behind in an otherwise untouched project.
|
|
154
|
+
const config = await loadAuthoringFurnaceConfig(projectRoot);
|
|
155
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
156
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
87
157
|
// --- Resolve component name ---
|
|
88
158
|
let componentName = name;
|
|
89
|
-
if (!componentName
|
|
90
|
-
//
|
|
91
|
-
const allComponents = await scanWidgetsDirectory(paths.engine);
|
|
159
|
+
if (!componentName) {
|
|
160
|
+
// Interactive prompt path; non-interactive missing-name was rejected above.
|
|
161
|
+
const allComponents = await scanWidgetsDirectory(paths.engine, ftlDir);
|
|
92
162
|
const available = allComponents.filter((c) => !(c.tagName in config.overrides));
|
|
93
163
|
if (available.length === 0) {
|
|
94
164
|
throw new FurnaceError('No components available to override.');
|
|
@@ -109,20 +179,12 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
|
|
|
109
179
|
}
|
|
110
180
|
componentName = selected;
|
|
111
181
|
}
|
|
112
|
-
else if (!componentName) {
|
|
113
|
-
throw new InvalidArgumentError('Component name is required in non-interactive mode.\n' +
|
|
114
|
-
'Usage: fireforge furnace override <name> -t <type> -d "description"', 'name');
|
|
115
|
-
}
|
|
116
|
-
// Validate component name to prevent path traversal
|
|
117
|
-
if (!/^[a-z][a-z0-9]*-[a-z0-9-]*$/.test(componentName)) {
|
|
118
|
-
throw new InvalidArgumentError(`Invalid component name "${componentName}": must contain a hyphen (required for custom elements), with only lowercase letters, digits, and hyphens.`, 'name');
|
|
119
|
-
}
|
|
120
182
|
// Check for existing override
|
|
121
183
|
if (componentName in config.overrides) {
|
|
122
184
|
throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
|
|
123
185
|
}
|
|
124
186
|
// Validate the component exists in engine
|
|
125
|
-
const details = await getComponentDetails(paths.engine, componentName);
|
|
187
|
+
const details = await getComponentDetails(paths.engine, componentName, ftlDir);
|
|
126
188
|
if (!details) {
|
|
127
189
|
throw new FurnaceError(`Component "${componentName}" not found in the engine source tree.`, componentName);
|
|
128
190
|
}
|
|
@@ -172,8 +234,25 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
|
|
|
172
234
|
throw new FurnaceError(`Directory already exists: components/overrides/${componentName}`, componentName);
|
|
173
235
|
}
|
|
174
236
|
const forgeConfig = await loadConfig(projectRoot);
|
|
175
|
-
const
|
|
176
|
-
|
|
237
|
+
const state = await loadState(projectRoot);
|
|
238
|
+
// All validation is done. The mutation phase runs in a helper that owns
|
|
239
|
+
// the rollback journal, the furnace-wide lock, and SIGINT/SIGTERM-driven
|
|
240
|
+
// teardown via the lifecycle wrapper.
|
|
241
|
+
const copiedFiles = await performOverrideMutations({
|
|
242
|
+
projectRoot,
|
|
243
|
+
componentName,
|
|
244
|
+
overrideType,
|
|
245
|
+
description,
|
|
246
|
+
srcDir,
|
|
247
|
+
destDir,
|
|
248
|
+
engineDir: paths.engine,
|
|
249
|
+
details,
|
|
250
|
+
config,
|
|
251
|
+
furnacePaths,
|
|
252
|
+
ftlDir,
|
|
253
|
+
firefoxVersion: forgeConfig.firefox.version,
|
|
254
|
+
...(state.baseCommit ? { baseCommit: state.baseCommit } : {}),
|
|
255
|
+
});
|
|
177
256
|
// --- Success ---
|
|
178
257
|
note(`Files copied to components/overrides/${componentName}/:\n` +
|
|
179
258
|
copiedFiles.map((f) => ` ${f}`).join('\n') +
|
|
@@ -185,4 +264,115 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
|
|
|
185
264
|
' 3. Run "fireforge build" to apply and build', componentName);
|
|
186
265
|
outro('Override created');
|
|
187
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Creates multiple overrides in a single invocation. Each component is validated
|
|
269
|
+
* and created sequentially; failures on one component do not block the rest.
|
|
270
|
+
* @param projectRoot - Root directory of the project
|
|
271
|
+
* @param names - Component tag names to override
|
|
272
|
+
* @param options - CLI options applied to all overrides
|
|
273
|
+
*/
|
|
274
|
+
export async function furnaceBatchOverrideCommand(projectRoot, names, options = {}) {
|
|
275
|
+
intro(`Furnace Override (batch: ${names.length} components)`);
|
|
276
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
277
|
+
if (!options.type && !isInteractive) {
|
|
278
|
+
throw new InvalidArgumentError('Override type is required for batch override in non-interactive mode. Use -t css-only or -t full.', 'type');
|
|
279
|
+
}
|
|
280
|
+
const paths = getProjectPaths(projectRoot);
|
|
281
|
+
if (!(await pathExists(paths.engine))) {
|
|
282
|
+
throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
|
|
283
|
+
}
|
|
284
|
+
// Validate all names upfront before any mutations
|
|
285
|
+
for (const name of names) {
|
|
286
|
+
if (!CUSTOM_ELEMENT_TAG_PATTERN.test(name)) {
|
|
287
|
+
throw new InvalidArgumentError(`Invalid component name "${name}": ${CUSTOM_ELEMENT_TAG_RULES}`, 'name');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const config = await loadAuthoringFurnaceConfig(projectRoot);
|
|
291
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
292
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
293
|
+
const forgeConfig = await loadConfig(projectRoot);
|
|
294
|
+
const state = await loadState(projectRoot);
|
|
295
|
+
// Check for duplicates and pre-existing overrides
|
|
296
|
+
const uniqueNames = [...new Set(names)];
|
|
297
|
+
for (const name of uniqueNames) {
|
|
298
|
+
if (name in config.overrides) {
|
|
299
|
+
throw new FurnaceError(`An override for "${name}" already exists in furnace.json`, name);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const succeeded = [];
|
|
303
|
+
const failed = [];
|
|
304
|
+
for (const componentName of uniqueNames) {
|
|
305
|
+
const details = await getComponentDetails(paths.engine, componentName, ftlDir);
|
|
306
|
+
if (!details) {
|
|
307
|
+
failed.push({ name: componentName, error: 'not found in engine source tree' });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
let overrideType = options.type;
|
|
311
|
+
if (!overrideType) {
|
|
312
|
+
const typeResult = await select({
|
|
313
|
+
message: `Override type for ${componentName}:`,
|
|
314
|
+
options: [
|
|
315
|
+
{ value: 'css-only', label: 'CSS only — restyle the component' },
|
|
316
|
+
{ value: 'full', label: 'Full override — modify styling and behavior' },
|
|
317
|
+
],
|
|
318
|
+
});
|
|
319
|
+
if (isCancel(typeResult)) {
|
|
320
|
+
info(`Skipping ${componentName} (cancelled)`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
overrideType = typeResult;
|
|
324
|
+
}
|
|
325
|
+
if (overrideType === 'css-only' && !details.hasCSS) {
|
|
326
|
+
failed.push({ name: componentName, error: 'no CSS files to override with --type css-only' });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const destDir = join(furnacePaths.overridesDir, componentName);
|
|
330
|
+
if (await pathExists(destDir)) {
|
|
331
|
+
failed.push({ name: componentName, error: 'directory already exists' });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
await performOverrideMutations({
|
|
336
|
+
projectRoot,
|
|
337
|
+
componentName,
|
|
338
|
+
overrideType,
|
|
339
|
+
description: options.description ?? '',
|
|
340
|
+
srcDir: join(paths.engine, details.sourcePath),
|
|
341
|
+
destDir,
|
|
342
|
+
engineDir: paths.engine,
|
|
343
|
+
details,
|
|
344
|
+
config,
|
|
345
|
+
furnacePaths,
|
|
346
|
+
ftlDir,
|
|
347
|
+
firefoxVersion: forgeConfig.firefox.version,
|
|
348
|
+
...(state.baseCommit ? { baseCommit: state.baseCommit } : {}),
|
|
349
|
+
});
|
|
350
|
+
succeeded.push(componentName);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
failed.push({
|
|
354
|
+
name: componentName,
|
|
355
|
+
error: error instanceof Error ? error.message : String(error),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (succeeded.length > 0) {
|
|
360
|
+
note(`Created ${succeeded.length} override(s):\n` +
|
|
361
|
+
succeeded.map((n) => ` components/overrides/${n}/`).join('\n') +
|
|
362
|
+
'\n\n' +
|
|
363
|
+
'Next steps:\n' +
|
|
364
|
+
' 1. Edit the copied files in each override directory\n' +
|
|
365
|
+
' 2. Run "fireforge furnace preview" to see changes\n' +
|
|
366
|
+
' 3. Run "fireforge build" to apply and build', 'Batch Override');
|
|
367
|
+
}
|
|
368
|
+
if (failed.length > 0) {
|
|
369
|
+
for (const f of failed) {
|
|
370
|
+
warn(`${f.name}: ${f.error}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (succeeded.length === 0) {
|
|
374
|
+
throw new FurnaceError(`All ${uniqueNames.length} override(s) failed.`);
|
|
375
|
+
}
|
|
376
|
+
outro(`Batch override complete: ${succeeded.length} succeeded, ${failed.length} failed`);
|
|
377
|
+
}
|
|
188
378
|
//# sourceMappingURL=override.js.map
|
|
@@ -1,12 +1,77 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getProjectPaths } from '../../core/config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { applyAllComponents } from '../../core/furnace-apply.js';
|
|
5
|
+
import { furnaceConfigExists, loadFurnaceConfig, updateFurnaceState, } from '../../core/furnace-config.js';
|
|
6
|
+
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
7
|
+
import { restoreRollbackJournal } from '../../core/furnace-rollback.js';
|
|
5
8
|
import { cleanStories, syncStories } from '../../core/furnace-stories.js';
|
|
6
9
|
import { runMach, runMachCapture } from '../../core/mach.js';
|
|
7
10
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
11
|
+
import { toError } from '../../utils/errors.js';
|
|
8
12
|
import { pathExists } from '../../utils/fs.js';
|
|
9
|
-
import { info, intro, outro, spinner } from '../../utils/logger.js';
|
|
13
|
+
import { info, intro, outro, spinner, warn } from '../../utils/logger.js';
|
|
14
|
+
/**
|
|
15
|
+
* Runs the two teardown steps — `cleanStories` and the rollback-journal
|
|
16
|
+
* restore — independently, collecting whatever errors either step throws.
|
|
17
|
+
* Both steps must run regardless of the other's outcome, because each
|
|
18
|
+
* operates on a different part of engine state and skipping one leaves
|
|
19
|
+
* the engine in a worse position than a single-step failure.
|
|
20
|
+
*
|
|
21
|
+
* Extracted from `furnacePreviewCommand` so the main function stays
|
|
22
|
+
* under the `max-lines-per-function` threshold and so the teardown
|
|
23
|
+
* contract is explicit in one place.
|
|
24
|
+
*
|
|
25
|
+
* @returns Collected teardown errors, or an empty array if both steps
|
|
26
|
+
* succeeded (or no journal was ever created).
|
|
27
|
+
*/
|
|
28
|
+
async function runPreviewTeardown(engineDir, storiesCleanupRequired, journal) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
if (storiesCleanupRequired) {
|
|
31
|
+
try {
|
|
32
|
+
await cleanStories(engineDir);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const wrapped = toError(error);
|
|
36
|
+
errors.push(new Error(`Story cleanup: ${wrapped.message}`));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (journal) {
|
|
40
|
+
try {
|
|
41
|
+
await restoreRollbackJournal(journal);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const wrapped = toError(error);
|
|
45
|
+
errors.push(new Error(`Journal restore: ${wrapped.message}`));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reports staging failures (component-level errors and per-step errors) to the
|
|
52
|
+
* user and throws a single FurnaceError summarising the failure count.
|
|
53
|
+
* Extracted from `furnacePreviewCommand` to keep that function under the
|
|
54
|
+
* `max-lines-per-function` threshold and to colocate the failure-reporting
|
|
55
|
+
* contract in one place.
|
|
56
|
+
*
|
|
57
|
+
* @returns The total failure count if there were any (always non-zero when
|
|
58
|
+
* this returns; the function throws after logging).
|
|
59
|
+
*/
|
|
60
|
+
function reportPreviewStagingFailures(stageResult) {
|
|
61
|
+
for (const err of stageResult.errors) {
|
|
62
|
+
warn(`Furnace: ${err.name} — ${err.error}`);
|
|
63
|
+
}
|
|
64
|
+
for (const applied of stageResult.applied) {
|
|
65
|
+
if (applied.stepErrors && applied.stepErrors.length > 0) {
|
|
66
|
+
for (const stepErr of applied.stepErrors) {
|
|
67
|
+
warn(`Furnace: ${applied.name} [${stepErr.step}] ${stepErr.error}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const appliedWithStepErrorsCount = stageResult.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
|
|
72
|
+
const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
|
|
73
|
+
throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to stage for preview`);
|
|
74
|
+
}
|
|
10
75
|
/**
|
|
11
76
|
* Builds a targeted Storybook failure message from captured mach output.
|
|
12
77
|
* @param output - Combined stdout and stderr from the Storybook command
|
|
@@ -55,37 +120,126 @@ export async function furnacePreviewCommand(projectRoot, options = {}) {
|
|
|
55
120
|
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.');
|
|
56
121
|
}
|
|
57
122
|
let previewResult;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
123
|
+
// True once we are about to (or have) written to engine/.../stories/furnace.
|
|
124
|
+
// Intentionally set BEFORE `syncStories` is awaited so a mid-sync failure
|
|
125
|
+
// still triggers `cleanStories` during teardown. `cleanStories` is a
|
|
126
|
+
// full-directory wipe, so it is correct to run against partial state —
|
|
127
|
+
// including state with zero files, where it is a cheap no-op.
|
|
128
|
+
let storiesCleanupRequired = false;
|
|
129
|
+
let previewJournal;
|
|
130
|
+
let primaryError;
|
|
131
|
+
// The preview command runs under runFurnaceMutation so a SIGINT during
|
|
132
|
+
// Storybook still triggers cleanStories + journal restore via the CLI
|
|
133
|
+
// entrypoint's global signal handlers consulting the lifecycle registry.
|
|
134
|
+
// The body's own try/catch + teardown path handles the normal exit case
|
|
135
|
+
// (mach storybook returns or throws).
|
|
136
|
+
await runFurnaceMutation(projectRoot, 'preview-teardown', async (ctx) => {
|
|
137
|
+
// Register the stories cleanup as an extra teardown hook so the signal
|
|
138
|
+
// handler can wipe the staged stories directory in addition to the
|
|
139
|
+
// journal restore.
|
|
140
|
+
ctx.registerCleanup(async () => {
|
|
141
|
+
if (storiesCleanupRequired) {
|
|
142
|
+
await cleanStories(paths.engine);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
// Stage workspace override/custom files into engine/ so Storybook can
|
|
147
|
+
// resolve freshly edited chrome:// imports. Stock-only projects skip
|
|
148
|
+
// this step because stock components are never written from workspace
|
|
149
|
+
// sources.
|
|
150
|
+
if (overrideCount + customCount > 0) {
|
|
151
|
+
const stageSpinner = spinner('Staging components for preview...');
|
|
152
|
+
let stageResult;
|
|
153
|
+
try {
|
|
154
|
+
stageResult = await applyAllComponents(projectRoot, false, {
|
|
155
|
+
persistState: false,
|
|
156
|
+
operationContext: ctx,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
stageSpinner.error('Failed to stage components');
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
previewJournal = stageResult.rollbackJournal;
|
|
164
|
+
if (previewJournal) {
|
|
165
|
+
ctx.registerJournal(previewJournal);
|
|
166
|
+
}
|
|
167
|
+
const appliedWithStepErrorsCount = stageResult.applied.filter((entry) => (entry.stepErrors?.length ?? 0) > 0).length;
|
|
168
|
+
const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
|
|
169
|
+
if (totalFailures > 0) {
|
|
170
|
+
stageSpinner.error('Failed to stage components');
|
|
171
|
+
reportPreviewStagingFailures(stageResult);
|
|
172
|
+
}
|
|
173
|
+
stageSpinner.stop(`Staged ${stageResult.applied.length} component${stageResult.applied.length === 1 ? '' : 's'} for preview`);
|
|
174
|
+
}
|
|
175
|
+
// Sync story files. Set the cleanup flag before the await so a partial
|
|
176
|
+
// write failure still triggers the teardown wipe — `syncStories` writes
|
|
177
|
+
// files incrementally with no internal cleanup of its own.
|
|
178
|
+
const syncSpinner = spinner('Syncing component stories...');
|
|
179
|
+
storiesCleanupRequired = true;
|
|
180
|
+
const result = await syncStories(projectRoot);
|
|
181
|
+
const created = result.created.length;
|
|
182
|
+
const updated = result.updated.length;
|
|
183
|
+
const total = created + updated;
|
|
184
|
+
syncSpinner.stop(`Synced ${total} stories (${created} new, ${updated} updated)`);
|
|
185
|
+
// Force-reinstall Storybook dependencies if requested
|
|
186
|
+
if (options.install) {
|
|
187
|
+
const installSpinner = spinner('Reinstalling Storybook dependencies...');
|
|
188
|
+
const installCode = await runMach(['storybook', 'upgrade'], paths.engine);
|
|
189
|
+
if (installCode !== 0) {
|
|
190
|
+
installSpinner.stop('Failed to reinstall Storybook dependencies');
|
|
191
|
+
throw new FurnaceError('Storybook dependency reinstallation failed. Try running "python3 ./mach storybook upgrade" manually in the engine directory.');
|
|
192
|
+
}
|
|
193
|
+
installSpinner.stop('Storybook dependencies reinstalled');
|
|
75
194
|
}
|
|
76
|
-
|
|
195
|
+
// Start Storybook
|
|
196
|
+
info('Starting Storybook...');
|
|
197
|
+
info('Press Ctrl+C to stop\n');
|
|
198
|
+
previewResult = await runMachCapture(['storybook'], paths.engine);
|
|
77
199
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
info('Press Ctrl+C to stop\n');
|
|
81
|
-
previewResult = await runMachCapture(['storybook'], paths.engine);
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
if (storiesSynced) {
|
|
85
|
-
await cleanStories(paths.engine);
|
|
200
|
+
catch (error) {
|
|
201
|
+
primaryError = error;
|
|
86
202
|
}
|
|
87
|
-
|
|
88
|
-
|
|
203
|
+
// Teardown runs unconditionally and never short-circuits: a failure in
|
|
204
|
+
// cleanStories must not prevent the journal restore, and vice versa. The
|
|
205
|
+
// previous implementation ran teardown in a `finally` block that called
|
|
206
|
+
// `restoreRollbackJournalOrThrow`, which threw synchronously — that throw
|
|
207
|
+
// bypassed the primary error and, worse, skipped downstream handling so
|
|
208
|
+
// the engine was left with staged files and the user got a teardown
|
|
209
|
+
// message with no guidance. We now collect both failures and, if anything
|
|
210
|
+
// went wrong, persist a `pendingRepair` marker that `fireforge doctor`
|
|
211
|
+
// consumes to finish the reconciliation.
|
|
212
|
+
const teardownErrors = await runPreviewTeardown(paths.engine, storiesCleanupRequired, previewJournal);
|
|
213
|
+
if (teardownErrors.length > 0) {
|
|
214
|
+
const teardownSummary = teardownErrors.map((err) => err.message).join('; ');
|
|
215
|
+
try {
|
|
216
|
+
await updateFurnaceState(projectRoot, (state) => ({
|
|
217
|
+
...state,
|
|
218
|
+
pendingRepair: {
|
|
219
|
+
operation: 'preview-teardown',
|
|
220
|
+
timestamp: new Date().toISOString(),
|
|
221
|
+
reason: teardownSummary,
|
|
222
|
+
},
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
catch (markError) {
|
|
226
|
+
warn(`Could not record pending-repair marker in .fireforge/furnace-state.json — ${toError(markError).message}. Engine may still be in a staged state; run "fireforge furnace apply" manually to reconcile.`);
|
|
227
|
+
}
|
|
228
|
+
const primarySuffix = primaryError
|
|
229
|
+
? ` (original error: ${toError(primaryError).message})`
|
|
230
|
+
: '';
|
|
231
|
+
throw new FurnaceError(`Preview teardown could not restore the engine cleanly: ${teardownSummary}. The engine may contain staged workspace files. Run "fireforge doctor --repair-furnace" to reconcile, or run "fireforge furnace apply" manually.${primarySuffix}`);
|
|
232
|
+
}
|
|
233
|
+
if (primaryError) {
|
|
234
|
+
// Re-throwing the captured error preserves its original shape. The
|
|
235
|
+
// `toError` wrap normalises non-Error throws (strings, plain objects)
|
|
236
|
+
// into real Error instances so the eslint `only-throw-error` rule
|
|
237
|
+
// holds and downstream formatters always see a message/stack pair.
|
|
238
|
+
throw toError(primaryError);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
if (previewResult &&
|
|
242
|
+
previewResult.exitCode !== 0 &&
|
|
89
243
|
previewResult.exitCode !== 130 &&
|
|
90
244
|
previewResult.exitCode !== 143) {
|
|
91
245
|
const combinedOutput = `${previewResult.stdout}\n${previewResult.stderr}`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FurnaceRefreshOptions } from '../../types/commands/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs the furnace refresh command to merge upstream Firefox changes into
|
|
4
|
+
* an override component using three-way merge.
|
|
5
|
+
*
|
|
6
|
+
* @param projectRoot - Root directory of the project
|
|
7
|
+
* @param name - Component tag name to refresh (omit when using --all)
|
|
8
|
+
* @param options - Command options
|
|
9
|
+
*/
|
|
10
|
+
export declare function furnaceRefreshCommand(projectRoot: string, name: string | undefined, options?: FurnaceRefreshOptions): Promise<void>;
|