@hominis/fireforge 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/dist/bin/fireforge.js +19 -5
- package/dist/src/commands/config.js +7 -1
- package/dist/src/commands/discard.js +6 -1
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +6 -1
- package/dist/src/commands/download.js +106 -7
- package/dist/src/commands/export-shared.js +7 -0
- package/dist/src/commands/export.js +5 -0
- package/dist/src/commands/furnace/apply.js +147 -47
- package/dist/src/commands/furnace/create.js +13 -2
- package/dist/src/commands/furnace/deploy.js +17 -2
- package/dist/src/commands/furnace/diff.js +3 -1
- package/dist/src/commands/furnace/init.js +25 -7
- package/dist/src/commands/furnace/list.js +15 -7
- package/dist/src/commands/furnace/override.js +47 -15
- package/dist/src/commands/furnace/remove.js +68 -20
- package/dist/src/commands/furnace/rename.js +31 -3
- package/dist/src/commands/furnace/scan.js +8 -0
- package/dist/src/commands/furnace/validate.js +70 -7
- package/dist/src/commands/import.js +65 -11
- package/dist/src/commands/re-export.js +11 -4
- package/dist/src/commands/rebase/abort.js +26 -14
- package/dist/src/commands/rebase/confirm.d.ts +15 -2
- package/dist/src/commands/rebase/confirm.js +2 -2
- package/dist/src/commands/rebase/continue.js +39 -15
- package/dist/src/commands/rebase/index.js +2 -1
- package/dist/src/commands/rebase/patch-loop.js +90 -33
- package/dist/src/commands/register.js +13 -0
- package/dist/src/commands/resolve.js +31 -10
- package/dist/src/commands/run.js +9 -44
- package/dist/src/commands/setup-support.js +25 -7
- package/dist/src/commands/status.js +59 -8
- package/dist/src/commands/test.js +13 -7
- package/dist/src/commands/token.js +11 -1
- package/dist/src/commands/watch.js +51 -1
- package/dist/src/commands/wire.js +23 -0
- package/dist/src/core/config-validate.js +15 -1
- package/dist/src/core/furnace-registration.d.ts +1 -1
- package/dist/src/core/furnace-registration.js +2 -1
- package/dist/src/core/furnace-staleness.d.ts +17 -0
- package/dist/src/core/furnace-staleness.js +58 -0
- package/dist/src/core/signal-critical.d.ts +49 -0
- package/dist/src/core/signal-critical.js +80 -0
- package/dist/src/errors/download.d.ts +1 -1
- package/dist/src/errors/download.js +6 -3
- package/package.json +1 -1
|
@@ -44,10 +44,25 @@ function getFailedComponentNames(result) {
|
|
|
44
44
|
}
|
|
45
45
|
function getPersistableAppliedEntry(name, appliedEntry) {
|
|
46
46
|
if (!appliedEntry) {
|
|
47
|
-
throw new FurnaceError(`
|
|
47
|
+
throw new FurnaceError(`Deploy for "${name}" finished without producing an applied component entry; ` +
|
|
48
|
+
`furnace state was not modified. Run "fireforge doctor --repair-furnace" to ` +
|
|
49
|
+
`reconcile state, then retry the deploy. If this persists, file a bug with the ` +
|
|
50
|
+
`output of "fireforge doctor".`);
|
|
48
51
|
}
|
|
49
52
|
if (appliedEntry.type !== 'override' && appliedEntry.type !== 'custom') {
|
|
50
|
-
throw new FurnaceError(`
|
|
53
|
+
throw new FurnaceError(`Deploy for "${name}" returned an unsupported component type "${appliedEntry.type}"; ` +
|
|
54
|
+
`furnace state was not modified. Run "fireforge doctor --repair-furnace" to reconcile, ` +
|
|
55
|
+
`then verify the component with "fireforge furnace validate" before retrying.`);
|
|
56
|
+
}
|
|
57
|
+
// Guard against future refactors that might reorder or misroute the
|
|
58
|
+
// applied[] array: named deploy persists state under a single component
|
|
59
|
+
// name, so the first applied entry MUST be that component. Persisting a
|
|
60
|
+
// different component's checksums here would cause the next status/apply
|
|
61
|
+
// run to mis-report health for both components involved.
|
|
62
|
+
if (name !== undefined && appliedEntry.name !== name) {
|
|
63
|
+
throw new FurnaceError(`Deploy for "${name}" returned an applied entry for a different component ` +
|
|
64
|
+
`("${appliedEntry.name}"); refusing to persist mismatched state. ` +
|
|
65
|
+
`Run "fireforge doctor --repair-furnace" to reconcile, then retry the deploy.`);
|
|
51
66
|
}
|
|
52
67
|
return {
|
|
53
68
|
name: appliedEntry.name,
|
|
@@ -51,7 +51,9 @@ async function diffOverride(name, projectRoot, config) {
|
|
|
51
51
|
const state = await loadState(projectRoot);
|
|
52
52
|
const baseCommit = overrideConfig.baseCommit ?? state.baseCommit;
|
|
53
53
|
if (!baseCommit) {
|
|
54
|
-
throw new FurnaceError(
|
|
54
|
+
throw new FurnaceError(`Cannot diff "${name}": baseCommit not recorded for this override. ` +
|
|
55
|
+
`Run "fireforge furnace refresh --reset-base ${name}" to stamp the current engine HEAD as the baseline, ` +
|
|
56
|
+
`or re-run "fireforge download" to re-establish a project-wide baseline.`, name);
|
|
55
57
|
}
|
|
56
58
|
const entries = await readdir(overrideDir, { withFileTypes: true });
|
|
57
59
|
let hasDifferences = false;
|
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { isAbsolute, normalize } from 'node:path';
|
|
2
3
|
import { text } from '@clack/prompts';
|
|
3
4
|
import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
4
5
|
import { FurnaceError } from '../../errors/furnace.js';
|
|
5
6
|
import { cancel, info, intro, isCancel, note, outro, success } from '../../utils/logger.js';
|
|
7
|
+
/**
|
|
8
|
+
* Validates an FTL base path before writing it to furnace.json. Rejects
|
|
9
|
+
* absolute paths, null bytes, and any normalised segment starting with
|
|
10
|
+
* `..` — the previous `includes('..')` substring check caught the common
|
|
11
|
+
* case but missed `./../../` and absolute paths that are arguably worse.
|
|
12
|
+
*/
|
|
13
|
+
function validateFtlBasePath(value) {
|
|
14
|
+
if (value.length === 0) {
|
|
15
|
+
throw new FurnaceError('ftlBasePath must not be empty.');
|
|
16
|
+
}
|
|
17
|
+
if (value.includes('\0')) {
|
|
18
|
+
throw new FurnaceError('ftlBasePath must not contain null bytes.');
|
|
19
|
+
}
|
|
20
|
+
if (isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)) {
|
|
21
|
+
throw new FurnaceError(`ftlBasePath "${value}" must be a relative path inside the engine checkout, not absolute.`);
|
|
22
|
+
}
|
|
23
|
+
const normalized = normalize(value.replace(/\\/g, '/'));
|
|
24
|
+
if (normalized === '..' || normalized.startsWith('../')) {
|
|
25
|
+
throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
6
28
|
/**
|
|
7
29
|
* Runs the furnace init command to create a default furnace.json with
|
|
8
30
|
* user-specified settings.
|
|
@@ -15,7 +37,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
15
37
|
throw new FurnaceError('furnace.json already exists. Use --force to overwrite it.');
|
|
16
38
|
}
|
|
17
39
|
const config = createDefaultFurnaceConfig();
|
|
18
|
-
const isInteractive = process.stdin.isTTY;
|
|
40
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
19
41
|
// Resolve componentPrefix
|
|
20
42
|
if (options.prefix !== undefined) {
|
|
21
43
|
config.componentPrefix = options.prefix;
|
|
@@ -38,9 +60,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
38
60
|
}
|
|
39
61
|
// Resolve ftlBasePath
|
|
40
62
|
if (options.ftlBasePath !== undefined) {
|
|
41
|
-
|
|
42
|
-
throw new FurnaceError('ftlBasePath must not contain ".." (path traversal)');
|
|
43
|
-
}
|
|
63
|
+
validateFtlBasePath(options.ftlBasePath);
|
|
44
64
|
config.ftlBasePath = options.ftlBasePath;
|
|
45
65
|
}
|
|
46
66
|
else if (isInteractive) {
|
|
@@ -54,9 +74,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
54
74
|
}
|
|
55
75
|
const ftlValue = ftlResult.trim();
|
|
56
76
|
if (ftlValue) {
|
|
57
|
-
|
|
58
|
-
throw new FurnaceError('ftlBasePath must not contain ".." (path traversal)');
|
|
59
|
-
}
|
|
77
|
+
validateFtlBasePath(ftlValue);
|
|
60
78
|
config.ftlBasePath = ftlValue;
|
|
61
79
|
}
|
|
62
80
|
}
|
|
@@ -9,15 +9,23 @@ import { formatErrorText, formatSuccessText, info, intro, note, outro, } from '.
|
|
|
9
9
|
* its workspace checksums have changed since the last apply.
|
|
10
10
|
*/
|
|
11
11
|
async function getHealthIndicator(componentDir, type, name, appliedChecksums) {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
try {
|
|
13
|
+
if (!(await pathExists(componentDir))) {
|
|
14
|
+
return formatErrorText('missing');
|
|
15
|
+
}
|
|
16
|
+
const previous = extractComponentChecksums(appliedChecksums, type, name);
|
|
17
|
+
if (Object.keys(previous).length === 0) {
|
|
18
|
+
return formatErrorText('not applied');
|
|
19
|
+
}
|
|
20
|
+
const changed = await hasComponentChanged(componentDir, previous);
|
|
21
|
+
return changed ? formatErrorText('modified') : formatSuccessText('clean');
|
|
14
22
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
catch {
|
|
24
|
+
// A race with `furnace remove`, filesystem permission change, or a
|
|
25
|
+
// transient IO failure must not crash the entire `list -v` output —
|
|
26
|
+
// render a degraded state so the rest of the table still shows.
|
|
27
|
+
return formatErrorText('unavailable');
|
|
18
28
|
}
|
|
19
|
-
const changed = await hasComponentChanged(componentDir, previous);
|
|
20
|
-
return changed ? formatErrorText('modified') : formatSuccessText('clean');
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
23
31
|
* Runs the furnace list command to display all registered components.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import { select, text } from '@clack/prompts';
|
|
5
5
|
import { getProjectPaths, loadConfig, loadState } from '../../core/config.js';
|
|
6
6
|
import { createDefaultFurnaceConfig, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
@@ -31,6 +31,24 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
|
|
|
31
31
|
await ensureDir(destDir);
|
|
32
32
|
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
33
33
|
const copiedFiles = [];
|
|
34
|
+
// Snapshot-then-copy helper: ensures the destination's parent dir exists
|
|
35
|
+
// before snapshot + copy, and surfaces the failing filename on error so
|
|
36
|
+
// partial-state rollback has the context needed to report cleanly.
|
|
37
|
+
const snapshotAndCopy = async (from, dest, displayName) => {
|
|
38
|
+
await ensureDir(dirname(dest));
|
|
39
|
+
try {
|
|
40
|
+
await snapshotFile(journal, dest);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new FurnaceError(`Failed to snapshot "${displayName}" before override: ${toError(error).message}`, componentName);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await copyFile(from, dest);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new FurnaceError(`Failed to copy "${displayName}" into the override: ${toError(error).message}`, componentName);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
34
52
|
for (const entry of entries) {
|
|
35
53
|
if (!entry.isFile())
|
|
36
54
|
continue;
|
|
@@ -38,8 +56,7 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
|
|
|
38
56
|
// Only copy .css files
|
|
39
57
|
if (entry.name.endsWith('.css')) {
|
|
40
58
|
const dest = join(destDir, entry.name);
|
|
41
|
-
await
|
|
42
|
-
await copyFile(join(srcDir, entry.name), dest);
|
|
59
|
+
await snapshotAndCopy(join(srcDir, entry.name), dest, entry.name);
|
|
43
60
|
copiedFiles.push(entry.name);
|
|
44
61
|
}
|
|
45
62
|
}
|
|
@@ -47,8 +64,7 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
|
|
|
47
64
|
// Full override: copy .mjs and .css files
|
|
48
65
|
if (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')) {
|
|
49
66
|
const dest = join(destDir, entry.name);
|
|
50
|
-
await
|
|
51
|
-
await copyFile(join(srcDir, entry.name), dest);
|
|
67
|
+
await snapshotAndCopy(join(srcDir, entry.name), dest, entry.name);
|
|
52
68
|
copiedFiles.push(entry.name);
|
|
53
69
|
}
|
|
54
70
|
}
|
|
@@ -57,8 +73,7 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
|
|
|
57
73
|
const ftlName = `${componentName}.ftl`;
|
|
58
74
|
const ftlSrc = join(engineDir, ftlDir, ftlName);
|
|
59
75
|
const dest = join(destDir, ftlName);
|
|
60
|
-
await
|
|
61
|
-
await copyFile(ftlSrc, dest);
|
|
76
|
+
await snapshotAndCopy(ftlSrc, dest, ftlName);
|
|
62
77
|
copiedFiles.push(ftlName);
|
|
63
78
|
}
|
|
64
79
|
return copiedFiles;
|
|
@@ -123,6 +138,24 @@ async function performOverrideMutations(args) {
|
|
|
123
138
|
}
|
|
124
139
|
});
|
|
125
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Throws if `componentName` is already classified anywhere in the furnace
|
|
143
|
+
* config. Without this guard, `writeFurnaceConfig` would happily produce a
|
|
144
|
+
* file where the same tag appears under multiple categories (stock +
|
|
145
|
+
* override, custom + override) and later commands would no longer be able
|
|
146
|
+
* to reason about that component cleanly.
|
|
147
|
+
*/
|
|
148
|
+
function assertNoComponentCollision(config, componentName) {
|
|
149
|
+
if (componentName in config.overrides) {
|
|
150
|
+
throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
|
|
151
|
+
}
|
|
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
|
+
if (componentName in config.custom) {
|
|
156
|
+
throw new FurnaceError(`"${componentName}" is already registered as a custom component. Custom components cannot also be overrides.`, componentName);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
126
159
|
/**
|
|
127
160
|
* Runs the furnace override command to fork an existing engine component.
|
|
128
161
|
* @param projectRoot - Root directory of the project
|
|
@@ -179,10 +212,7 @@ export async function furnaceOverrideCommand(projectRoot, name, options = {}) {
|
|
|
179
212
|
}
|
|
180
213
|
componentName = selected;
|
|
181
214
|
}
|
|
182
|
-
|
|
183
|
-
if (componentName in config.overrides) {
|
|
184
|
-
throw new FurnaceError(`An override for "${componentName}" already exists in furnace.json`, componentName);
|
|
185
|
-
}
|
|
215
|
+
assertNoComponentCollision(config, componentName);
|
|
186
216
|
// Validate the component exists in engine
|
|
187
217
|
const details = await getComponentDetails(paths.engine, componentName, ftlDir);
|
|
188
218
|
if (!details) {
|
|
@@ -292,12 +322,14 @@ export async function furnaceBatchOverrideCommand(projectRoot, names, options =
|
|
|
292
322
|
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
293
323
|
const forgeConfig = await loadConfig(projectRoot);
|
|
294
324
|
const state = await loadState(projectRoot);
|
|
295
|
-
// Check for duplicates and pre-existing
|
|
325
|
+
// Check for duplicates and pre-existing classifications across every
|
|
326
|
+
// bucket in furnace.json. Missing these collisions silently double-
|
|
327
|
+
// classifies a tag (e.g. both stock and override) and leaves the
|
|
328
|
+
// workspace in a state that later `furnace status`/`apply` cannot
|
|
329
|
+
// reason about cleanly.
|
|
296
330
|
const uniqueNames = [...new Set(names)];
|
|
297
331
|
for (const name of uniqueNames) {
|
|
298
|
-
|
|
299
|
-
throw new FurnaceError(`An override for "${name}" already exists in furnace.json`, name);
|
|
300
|
-
}
|
|
332
|
+
assertNoComponentCollision(config, name);
|
|
301
333
|
}
|
|
302
334
|
const succeeded = [];
|
|
303
335
|
const failed = [];
|
|
@@ -135,13 +135,16 @@ async function restoreOverrideEngineFiles(engineDir, overrideDir, overrideConfig
|
|
|
135
135
|
* the failure.
|
|
136
136
|
*/
|
|
137
137
|
async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
138
|
+
const partialFailures = [];
|
|
138
139
|
let forgeConfig;
|
|
139
140
|
try {
|
|
140
141
|
forgeConfig = await loadConfig(projectRoot);
|
|
141
142
|
}
|
|
142
143
|
catch (error) {
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
const msg = `Could not load config for test cleanup — ${toError(error).message}. Remove test files manually if needed.`;
|
|
145
|
+
warn(msg);
|
|
146
|
+
partialFailures.push(msg);
|
|
147
|
+
return { partialFailures };
|
|
145
148
|
}
|
|
146
149
|
const paths = getProjectPaths(projectRoot);
|
|
147
150
|
const binaryName = forgeConfig.binaryName;
|
|
@@ -153,7 +156,7 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
153
156
|
const testFileName = `browser_${binaryName}_${underscored}.js`;
|
|
154
157
|
const testDir = join(paths.engine, 'browser/base/content/test', binaryName);
|
|
155
158
|
if (!(await pathExists(testDir)))
|
|
156
|
-
return;
|
|
159
|
+
return { partialFailures };
|
|
157
160
|
// Step 1: Delete the test file itself
|
|
158
161
|
try {
|
|
159
162
|
const testFilePath = join(testDir, testFileName);
|
|
@@ -164,7 +167,9 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
catch (error) {
|
|
167
|
-
|
|
170
|
+
const msg = `Could not delete test file ${testFileName} — ${toError(error).message}. Remove it manually if needed.`;
|
|
171
|
+
warn(msg);
|
|
172
|
+
partialFailures.push(msg);
|
|
168
173
|
}
|
|
169
174
|
// Step 2: Remove the test entry from browser.toml
|
|
170
175
|
try {
|
|
@@ -180,7 +185,9 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
catch (error) {
|
|
183
|
-
|
|
188
|
+
const msg = `Could not update browser.toml — ${toError(error).message}. Remove the test entry manually if needed.`;
|
|
189
|
+
warn(msg);
|
|
190
|
+
partialFailures.push(msg);
|
|
184
191
|
}
|
|
185
192
|
// Step 3: Clean up empty test directory and deregister from moz.build
|
|
186
193
|
try {
|
|
@@ -198,8 +205,11 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
|
|
|
198
205
|
}
|
|
199
206
|
}
|
|
200
207
|
catch (error) {
|
|
201
|
-
|
|
208
|
+
const msg = `Could not clean up test directory — ${toError(error).message}. Remove it manually if needed.`;
|
|
209
|
+
warn(msg);
|
|
210
|
+
partialFailures.push(msg);
|
|
202
211
|
}
|
|
212
|
+
return { partialFailures };
|
|
203
213
|
}
|
|
204
214
|
function dropChecksumsByPrefix(state, prefix) {
|
|
205
215
|
const result = { ...state };
|
|
@@ -211,6 +221,38 @@ function dropChecksumsByPrefix(state, prefix) {
|
|
|
211
221
|
}
|
|
212
222
|
return result;
|
|
213
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Confirms the remove operation interactively when TTY is available, or
|
|
226
|
+
* enforces the `--yes` contract in non-interactive mode. Returns `false`
|
|
227
|
+
* when the user cancelled and the caller should exit silently.
|
|
228
|
+
*/
|
|
229
|
+
async function confirmFurnaceRemove(name, type, options, isInteractive) {
|
|
230
|
+
if (!isInteractive && !options.yes) {
|
|
231
|
+
throw new FurnaceError(`Cannot remove "${name}" in non-interactive mode without --yes flag.`, name);
|
|
232
|
+
}
|
|
233
|
+
if (!options.yes && isInteractive) {
|
|
234
|
+
const confirmed = await confirm({
|
|
235
|
+
message: `Remove ${type} component "${name}"?`,
|
|
236
|
+
});
|
|
237
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
238
|
+
cancel('Remove cancelled');
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Enforces the engine-as-git precondition for both override and custom
|
|
246
|
+
* removals. Runs BEFORE the lock is acquired or a journal is registered so
|
|
247
|
+
* the failure path does not involve any rollback infrastructure.
|
|
248
|
+
*/
|
|
249
|
+
async function requireGitEngineForRemove(type, name, engineDir) {
|
|
250
|
+
if (type !== 'override' && type !== 'custom')
|
|
251
|
+
return;
|
|
252
|
+
if (!(await isGitRepository(engineDir))) {
|
|
253
|
+
throw new FurnaceError(`Cannot remove ${type} component "${name}": engine is not a git repository. Run "fireforge download" to initialise it.`, name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
214
256
|
/**
|
|
215
257
|
* Runs the furnace remove command to remove a component from the workspace.
|
|
216
258
|
* @param projectRoot - Root directory of the project
|
|
@@ -229,19 +271,8 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
229
271
|
if (!type) {
|
|
230
272
|
throw new FurnaceError(`Component "${name}" not found in furnace.json. Run "fireforge furnace list" to see registered components.`, name);
|
|
231
273
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
throw new FurnaceError(`Cannot remove "${name}" in non-interactive mode without --yes flag.`, name);
|
|
235
|
-
}
|
|
236
|
-
// Confirm removal (skip if --yes)
|
|
237
|
-
if (!options.yes && isInteractive) {
|
|
238
|
-
const confirmed = await confirm({
|
|
239
|
-
message: `Remove ${type} component "${name}"?`,
|
|
240
|
-
});
|
|
241
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
242
|
-
cancel('Remove cancelled');
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
274
|
+
if (!(await confirmFurnaceRemove(name, type, options, isInteractive))) {
|
|
275
|
+
return;
|
|
245
276
|
}
|
|
246
277
|
// Begin transactional mutation: every file deleted or rewritten is first
|
|
247
278
|
// snapshotted in a rollback journal so any failure mid-removal restores the
|
|
@@ -249,6 +280,7 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
249
280
|
// the furnace-wide lock and is registered with the global SIGINT/SIGTERM
|
|
250
281
|
// rollback pathway.
|
|
251
282
|
const paths = getProjectPaths(projectRoot);
|
|
283
|
+
await requireGitEngineForRemove(type, name, paths.engine);
|
|
252
284
|
await runFurnaceMutation(projectRoot, 'remove-rollback', async (ctx) => {
|
|
253
285
|
const journal = createRollbackJournal();
|
|
254
286
|
ctx.registerJournal(journal);
|
|
@@ -279,6 +311,12 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
279
311
|
}
|
|
280
312
|
else if (type === 'custom') {
|
|
281
313
|
const customConfig = config.custom[name];
|
|
314
|
+
// Custom-component removal mutates engine files (jar.mn,
|
|
315
|
+
// customElements.js, deployed widgets, optional .ftl) and the
|
|
316
|
+
// rollback journal is the only safety net for those edits while
|
|
317
|
+
// the command runs. The git-as-engine precondition is enforced
|
|
318
|
+
// before the lock is acquired (see furnaceRemoveCommand above)
|
|
319
|
+
// so if we reach this point, the engine is a git repository.
|
|
282
320
|
if (customConfig?.register) {
|
|
283
321
|
// customElements.js is the only file removeCustomElementRegistration touches.
|
|
284
322
|
await snapshotFile(journal, join(paths.engine, 'toolkit/content/customElements.js'));
|
|
@@ -317,8 +355,10 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
317
355
|
}
|
|
318
356
|
}
|
|
319
357
|
}
|
|
358
|
+
let testCleanupFailures = [];
|
|
320
359
|
if (type === 'custom') {
|
|
321
|
-
await cleanupCustomTestFiles(name, projectRoot, journal);
|
|
360
|
+
const result = await cleanupCustomTestFiles(name, projectRoot, journal);
|
|
361
|
+
testCleanupFailures = result.partialFailures;
|
|
322
362
|
}
|
|
323
363
|
// Remove entry from furnace.json
|
|
324
364
|
if (type === 'stock') {
|
|
@@ -337,6 +377,14 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
|
|
|
337
377
|
// entire remove operation is a single atomic unit.
|
|
338
378
|
await snapshotFile(journal, furnacePaths.furnaceState);
|
|
339
379
|
await updateFurnaceState(projectRoot, (state) => dropChecksumsByPrefix(state, `${type}/${name}/`));
|
|
380
|
+
// Test-cleanup failures are warn-and-continue by design (test files
|
|
381
|
+
// are secondary artefacts), but the caller deserves a single summary
|
|
382
|
+
// line pointing at the residue so they don't have to re-scan earlier
|
|
383
|
+
// warn output to realise the removal was partial.
|
|
384
|
+
if (testCleanupFailures.length > 0) {
|
|
385
|
+
warn(`Component "${name}" removed with ${testCleanupFailures.length} test-cleanup warning(s) above. ` +
|
|
386
|
+
`The component is deregistered, but test files may linger in the engine — review and delete manually if needed.`);
|
|
387
|
+
}
|
|
340
388
|
}
|
|
341
389
|
catch (error) {
|
|
342
390
|
try {
|
|
@@ -14,6 +14,26 @@ import { FurnaceError } from '../../errors/furnace.js';
|
|
|
14
14
|
import { toError } from '../../utils/errors.js';
|
|
15
15
|
import { copyFile, ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
|
|
16
16
|
import { info, intro, note, outro, warn } from '../../utils/logger.js';
|
|
17
|
+
/** Escapes regex metacharacters so a user-supplied name is literal inside a RegExp. */
|
|
18
|
+
function escapeRegex(input) {
|
|
19
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Applies the component rename to a filename. Only replaces the leading
|
|
23
|
+
* component name when it is followed by `.` (extension) or equals the
|
|
24
|
+
* filename exactly; every other filename is returned unchanged so stray
|
|
25
|
+
* assets, editor backups, or files whose name coincidentally contains the
|
|
26
|
+
* old component name in the middle or at the end are not accidentally
|
|
27
|
+
* renamed.
|
|
28
|
+
*/
|
|
29
|
+
function renameComponentFileName(fileName, oldName, newName) {
|
|
30
|
+
if (fileName === oldName)
|
|
31
|
+
return newName;
|
|
32
|
+
if (fileName.startsWith(oldName + '.')) {
|
|
33
|
+
return newName + fileName.slice(oldName.length);
|
|
34
|
+
}
|
|
35
|
+
return fileName;
|
|
36
|
+
}
|
|
17
37
|
function updateConfigForCustomRename(config, oldName, newName) {
|
|
18
38
|
const oldConfig = config.custom[oldName];
|
|
19
39
|
if (!oldConfig)
|
|
@@ -122,7 +142,15 @@ async function performRenameMutations(args) {
|
|
|
122
142
|
if (!entry.isFile())
|
|
123
143
|
continue;
|
|
124
144
|
const oldFileName = entry.name;
|
|
125
|
-
|
|
145
|
+
// Rename only when the filename starts with the component name — the
|
|
146
|
+
// scaffolding convention for both create and override is `${name}.ext`.
|
|
147
|
+
// A plain `replace(oldName, newName)` produced wrong results when the
|
|
148
|
+
// old name occurred more than once (e.g. `foo-foo.mjs` renamed `foo` →
|
|
149
|
+
// `bar` became `bar-foo.mjs` instead of `bar-bar.mjs`) and also when
|
|
150
|
+
// the old name appeared inside a file that was not the component
|
|
151
|
+
// scaffold itself (e.g. a sibling helper). Unrelated files (stray
|
|
152
|
+
// assets, editor backups) are copied verbatim.
|
|
153
|
+
const newFileName = renameComponentFileName(oldFileName, oldName, newName);
|
|
126
154
|
const oldPath = join(oldDir, oldFileName);
|
|
127
155
|
const newPath = join(newDir, newFileName);
|
|
128
156
|
if (isComponentSourceFile(oldFileName)) {
|
|
@@ -130,8 +158,8 @@ async function performRenameMutations(args) {
|
|
|
130
158
|
// Use word-boundary-aware patterns so substrings in other
|
|
131
159
|
// identifiers (e.g. "moz-panel" inside "moz-panel-group") are
|
|
132
160
|
// not replaced.
|
|
133
|
-
const tagPattern = new RegExp(`(?<![\\w-])${oldName
|
|
134
|
-
const classPattern = new RegExp(`\\b${oldClassName}\\b`, 'g');
|
|
161
|
+
const tagPattern = new RegExp(`(?<![\\w-])${escapeRegex(oldName)}(?![\\w-])`, 'g');
|
|
162
|
+
const classPattern = new RegExp(`\\b${escapeRegex(oldClassName)}\\b`, 'g');
|
|
135
163
|
content = content.replace(tagPattern, newName);
|
|
136
164
|
content = content.replace(classPattern, newClassName);
|
|
137
165
|
await writeText(newPath, content);
|
|
@@ -58,6 +58,14 @@ async function promptAddComponents(components, tracked, projectRoot) {
|
|
|
58
58
|
await snapshotFile(journal, furnacePaths.furnaceConfig);
|
|
59
59
|
try {
|
|
60
60
|
const config = await ensureFurnaceConfig(projectRoot);
|
|
61
|
+
// Defensive: `selected` is already filtered to exclude components
|
|
62
|
+
// currently in config.stock (see untrackedComponents above). This
|
|
63
|
+
// re-filter catches the edge case where the config on disk changed
|
|
64
|
+
// between the scan's read and the write (concurrent scan / manual
|
|
65
|
+
// edit). Without it a duplicate scan would introduce duplicate
|
|
66
|
+
// entries into stock; writeFurnaceConfig's validator would then
|
|
67
|
+
// reject the write, but the error would be less actionable than
|
|
68
|
+
// silently de-duplicating here.
|
|
61
69
|
const toAdd = selected.filter((s) => !config.stock.includes(s));
|
|
62
70
|
config.stock.push(...toAdd);
|
|
63
71
|
await writeFurnaceConfig(projectRoot, config);
|
|
@@ -43,9 +43,17 @@ async function autoFixIssues(projectRoot, issues) {
|
|
|
43
43
|
// Fix jar.mn entries
|
|
44
44
|
for (const [componentName, files] of jarMnFixesByComponent) {
|
|
45
45
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// addJarMnEntries is idempotent and reports how many entries it
|
|
47
|
+
// actually wrote. Only count + log the files that were added so the
|
|
48
|
+
// reported "fixed" number matches the on-disk change.
|
|
49
|
+
const added = await addJarMnEntries(engineDir, componentName, files);
|
|
50
|
+
fixed += added;
|
|
51
|
+
if (added > 0) {
|
|
52
|
+
info(`Fixed: added ${files.join(', ')} to jar.mn for ${componentName}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
info(`No-op: jar.mn entries for ${componentName} were already present`);
|
|
56
|
+
}
|
|
49
57
|
}
|
|
50
58
|
catch (err) {
|
|
51
59
|
warn(`Could not fix jar.mn for ${componentName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -162,13 +170,30 @@ export async function furnaceValidateCommand(projectRoot, name, options = {}) {
|
|
|
162
170
|
}
|
|
163
171
|
}
|
|
164
172
|
}
|
|
165
|
-
// Auto-fix fixable issues when --fix is passed
|
|
173
|
+
// Auto-fix fixable issues when --fix is passed. The auto-fix counter
|
|
174
|
+
// returned by `autoFixIssues` only counts function calls that did not
|
|
175
|
+
// throw — a write that succeeded but did not actually resolve the issue
|
|
176
|
+
// (e.g. addJarMnEntries appended to a file mach later ignores) would
|
|
177
|
+
// still bump the count. Re-validate the affected components and compute
|
|
178
|
+
// the *actual* drop in fixable issues so the reported number is honest.
|
|
166
179
|
if (options.fix && allIssues.length > 0) {
|
|
167
180
|
const fixableIssues = allIssues.filter((issue) => FIXABLE_CHECKS.has(issue.check));
|
|
168
181
|
if (fixableIssues.length > 0) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
await autoFixIssues(projectRoot, fixableIssues);
|
|
183
|
+
const reValidated = await reValidateComponents(projectRoot, config, furnacePaths, new Set(fixableIssues.map((issue) => issue.component)));
|
|
184
|
+
const fixableBefore = fixableIssues.length;
|
|
185
|
+
const fixableAfter = reValidated.issues.filter((issue) => FIXABLE_CHECKS.has(issue.check)).length;
|
|
186
|
+
const actuallyFixed = Math.max(0, fixableBefore - fixableAfter);
|
|
187
|
+
// Replace the pre-fix issue totals with the post-fix view so the
|
|
188
|
+
// summary reflects current reality. Issues that auto-fix could not
|
|
189
|
+
// address still count toward totalErrors / totalWarnings.
|
|
190
|
+
totalErrors = reValidated.totalErrors;
|
|
191
|
+
totalWarnings = reValidated.totalWarnings;
|
|
192
|
+
if (actuallyFixed > 0) {
|
|
193
|
+
info(`\nAuto-fixed ${actuallyFixed} issue(s).`);
|
|
194
|
+
}
|
|
195
|
+
if (fixableAfter > 0) {
|
|
196
|
+
warn(`${fixableAfter} fixable issue(s) remain after auto-fix — investigate manually.`);
|
|
172
197
|
}
|
|
173
198
|
}
|
|
174
199
|
else {
|
|
@@ -184,4 +209,42 @@ export async function furnaceValidateCommand(projectRoot, name, options = {}) {
|
|
|
184
209
|
}
|
|
185
210
|
outro('Validation passed');
|
|
186
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Re-validates a specific set of components after an auto-fix pass and
|
|
214
|
+
* returns the post-fix issue list with the recomputed error / warning
|
|
215
|
+
* totals. Used by the `--fix` path to honestly report what auto-fix
|
|
216
|
+
* actually accomplished.
|
|
217
|
+
*/
|
|
218
|
+
async function reValidateComponents(projectRoot, config, furnacePaths, componentNames) {
|
|
219
|
+
const issues = [];
|
|
220
|
+
let totalErrors = 0;
|
|
221
|
+
let totalWarnings = 0;
|
|
222
|
+
for (const componentName of componentNames) {
|
|
223
|
+
let type;
|
|
224
|
+
let componentDir;
|
|
225
|
+
if (componentName in config.overrides) {
|
|
226
|
+
type = 'override';
|
|
227
|
+
componentDir = join(furnacePaths.overridesDir, componentName);
|
|
228
|
+
}
|
|
229
|
+
else if (componentName in config.custom) {
|
|
230
|
+
type = 'custom';
|
|
231
|
+
componentDir = join(furnacePaths.customDir, componentName);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// Stock or removed components are not local-validated; skip silently.
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!(await pathExists(componentDir)))
|
|
238
|
+
continue;
|
|
239
|
+
const componentIssues = await validateComponent(componentDir, componentName, type, config, projectRoot);
|
|
240
|
+
issues.push(...componentIssues);
|
|
241
|
+
for (const issue of componentIssues) {
|
|
242
|
+
if (issue.severity === 'error')
|
|
243
|
+
totalErrors += 1;
|
|
244
|
+
else
|
|
245
|
+
totalWarnings += 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { issues, totalErrors, totalWarnings };
|
|
249
|
+
}
|
|
187
250
|
//# sourceMappingURL=validate.js.map
|