@hominis/fireforge 0.19.5 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +21 -8
  3. package/dist/src/commands/config.js +1 -0
  4. package/dist/src/commands/download.js +188 -185
  5. package/dist/src/commands/export-flow.js +2 -13
  6. package/dist/src/commands/furnace/create-validation.d.ts +6 -0
  7. package/dist/src/commands/furnace/create-validation.js +59 -0
  8. package/dist/src/commands/furnace/create.d.ts +7 -7
  9. package/dist/src/commands/furnace/create.js +21 -96
  10. package/dist/src/commands/furnace/index.js +2 -2
  11. package/dist/src/commands/furnace/refresh.js +11 -2
  12. package/dist/src/commands/furnace/remove-state.d.ts +5 -0
  13. package/dist/src/commands/furnace/remove-state.js +14 -0
  14. package/dist/src/commands/furnace/remove.js +30 -45
  15. package/dist/src/commands/furnace/rename-helpers.d.ts +13 -0
  16. package/dist/src/commands/furnace/rename-helpers.js +42 -0
  17. package/dist/src/commands/furnace/rename.js +27 -47
  18. package/dist/src/core/config-paths.d.ts +1 -1
  19. package/dist/src/core/config-paths.js +1 -0
  20. package/dist/src/core/config-validate.js +5 -0
  21. package/dist/src/core/config.js +11 -7
  22. package/dist/src/core/file-lock.js +2 -2
  23. package/dist/src/core/firefox-cache.d.ts +1 -1
  24. package/dist/src/core/firefox-cache.js +43 -17
  25. package/dist/src/core/firefox-download.js +12 -4
  26. package/dist/src/core/firefox.d.ts +1 -1
  27. package/dist/src/core/firefox.js +2 -2
  28. package/dist/src/core/furnace-refresh.js +16 -5
  29. package/dist/src/core/patch-lint-imports.d.ts +5 -0
  30. package/dist/src/core/patch-lint-imports.js +68 -0
  31. package/dist/src/core/patch-lint.js +2 -3
  32. package/dist/src/types/commands/options.d.ts +9 -9
  33. package/dist/src/types/config.d.ts +2 -0
  34. package/dist/src/utils/fs.d.ts +5 -0
  35. package/dist/src/utils/fs.js +54 -1
  36. package/dist/src/utils/process.js +4 -1
  37. package/package.json +1 -1
@@ -0,0 +1,6 @@
1
+ import type { FurnaceCreateOptions } from '../../types/commands/index.js';
2
+ import type { FurnaceConfig } from '../../types/furnace.js';
3
+ /**
4
+ * Validates a proposed custom component against the current furnace config.
5
+ */
6
+ export declare function validateCreateAgainstConfig(config: FurnaceConfig, componentName: string, allowPrefixMismatch: FurnaceCreateOptions['allowPrefixMismatch'], composes: string[] | undefined): void;
@@ -0,0 +1,59 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { detectComposesCycles } from '../../core/furnace-config.js';
3
+ import { InvalidArgumentError } from '../../errors/base.js';
4
+ import { FurnaceError } from '../../errors/furnace.js';
5
+ function checkNameConflict(config, name) {
6
+ if (name in config.custom) {
7
+ return `A custom component named "${name}" already exists in furnace.json`;
8
+ }
9
+ if (name in config.overrides) {
10
+ return `An override component named "${name}" already exists in furnace.json`;
11
+ }
12
+ return undefined;
13
+ }
14
+ function validateComposesTargets(config, componentName, composes) {
15
+ if (!composes || composes.length === 0)
16
+ return;
17
+ const known = new Set([
18
+ ...config.stock,
19
+ ...Object.keys(config.overrides),
20
+ ...Object.keys(config.custom),
21
+ ]);
22
+ for (const tag of composes) {
23
+ if (tag === componentName) {
24
+ throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
25
+ }
26
+ if (!known.has(tag)) {
27
+ throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
28
+ 'The referenced component must be registered as stock, override, or custom.');
29
+ }
30
+ }
31
+ detectComposesCycles({
32
+ ...config.custom,
33
+ [componentName]: {
34
+ description: '',
35
+ targetPath: `toolkit/content/widgets/${componentName}`,
36
+ register: true,
37
+ localized: false,
38
+ composes,
39
+ },
40
+ });
41
+ }
42
+ /**
43
+ * Validates a proposed custom component against the current furnace config.
44
+ */
45
+ export function validateCreateAgainstConfig(config, componentName, allowPrefixMismatch, composes) {
46
+ const conflict = checkNameConflict(config, componentName);
47
+ if (conflict) {
48
+ throw new FurnaceError(conflict, componentName);
49
+ }
50
+ if (config.componentPrefix &&
51
+ !componentName.startsWith(config.componentPrefix) &&
52
+ !allowPrefixMismatch) {
53
+ throw new InvalidArgumentError(`Name "${componentName}" does not start with the configured prefix "${config.componentPrefix}". ` +
54
+ `Use a prefixed name (e.g. "${config.componentPrefix}${componentName}"), update ` +
55
+ '`componentPrefix` in furnace.json, or pass --allow-prefix-mismatch to create the component anyway.', 'name');
56
+ }
57
+ validateComposesTargets(config, componentName, composes);
58
+ }
59
+ //# sourceMappingURL=create-validation.js.map
@@ -5,14 +5,14 @@ export type ResolvedTestStyle = 'mochikit' | 'browser-chrome' | 'xpcshell' | 'no
5
5
  * Collapses `--with-tests`, `--xpcshell`, and `--test-style` into the single
6
6
  * scaffold dispatch used inside the mutation phase.
7
7
  *
8
- * Backwards-compat invariants:
8
+ * Invariants:
9
9
  * - `--xpcshell` alone is equivalent to `--test-style=xpcshell`.
10
- * - `--with-tests` alone (no `--test-style`) now defaults to `mochikit`
11
- * (previously it defaulted to browser-chrome; the dogfooding pass
12
- * flagged browser-chrome as unrunnable against non-tabbrowser chrome).
13
- * Operators who need the old behavior can pass
14
- * `--with-tests --test-style=browser-chrome`.
15
- * - `--xpcshell --with-tests` is rejected as ambiguous.
10
+ * - `--with-tests` alone (no `--test-style`) defaults to `browser-chrome`
11
+ * (multi-process mochitest; reliable on macOS for interactive chrome).
12
+ * Forks whose chrome document has no `tabbrowser` should pass
13
+ * `--test-style=mochikit` explicitly.
14
+ * - When both `--xpcshell` and `--with-tests` are set, `--xpcshell` wins
15
+ * (resolved style is `xpcshell` only).
16
16
  * @throws InvalidArgumentError when flags conflict.
17
17
  */
18
18
  export declare function resolveTestStyle(options: FurnaceCreateOptions): ResolvedTestStyle;
@@ -2,7 +2,7 @@
2
2
  import { join } from 'node:path';
3
3
  import { text } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../../core/config.js';
5
- import { createDefaultFurnaceConfig, detectComposesCycles, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
5
+ import { createDefaultFurnaceConfig, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
6
  import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-constants.js';
7
7
  import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
8
8
  import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
@@ -21,6 +21,7 @@ import { resolveCreateFeatures } from './create-features.js';
21
21
  import { scaffoldMochikitTestFiles } from './create-mochikit.js';
22
22
  import { assertCustomEntryPersisted } from './create-readback.js';
23
23
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
24
+ import { validateCreateAgainstConfig } from './create-validation.js';
24
25
  import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
25
26
  async function loadAuthoringFurnaceConfig(projectRoot) {
26
27
  if (await furnaceConfigExists(projectRoot)) {
@@ -41,18 +42,6 @@ function validateTagName(name) {
41
42
  return `Name ${CUSTOM_ELEMENT_TAG_RULES}`;
42
43
  return undefined;
43
44
  }
44
- /**
45
- * Checks if a component name conflicts with existing entries in furnace.json.
46
- */
47
- function checkNameConflict(config, name) {
48
- if (name in config.custom) {
49
- return `A custom component named "${name}" already exists in furnace.json`;
50
- }
51
- if (name in config.overrides) {
52
- return `An override component named "${name}" already exists in furnace.json`;
53
- }
54
- return undefined;
55
- }
56
45
  /**
57
46
  * Scaffolds browser mochitest files for a newly created custom component.
58
47
  * @param componentName - Custom element tag name
@@ -204,14 +193,14 @@ async function writeComponentFiles(componentDir, componentName, className, descr
204
193
  * Collapses `--with-tests`, `--xpcshell`, and `--test-style` into the single
205
194
  * scaffold dispatch used inside the mutation phase.
206
195
  *
207
- * Backwards-compat invariants:
196
+ * Invariants:
208
197
  * - `--xpcshell` alone is equivalent to `--test-style=xpcshell`.
209
- * - `--with-tests` alone (no `--test-style`) now defaults to `mochikit`
210
- * (previously it defaulted to browser-chrome; the dogfooding pass
211
- * flagged browser-chrome as unrunnable against non-tabbrowser chrome).
212
- * Operators who need the old behavior can pass
213
- * `--with-tests --test-style=browser-chrome`.
214
- * - `--xpcshell --with-tests` is rejected as ambiguous.
198
+ * - `--with-tests` alone (no `--test-style`) defaults to `browser-chrome`
199
+ * (multi-process mochitest; reliable on macOS for interactive chrome).
200
+ * Forks whose chrome document has no `tabbrowser` should pass
201
+ * `--test-style=mochikit` explicitly.
202
+ * - When both `--xpcshell` and `--with-tests` are set, `--xpcshell` wins
203
+ * (resolved style is `xpcshell` only).
215
204
  * @throws InvalidArgumentError when flags conflict.
216
205
  */
217
206
  export function resolveTestStyle(options) {
@@ -226,7 +215,7 @@ export function resolveTestStyle(options) {
226
215
  if (xpcshellFlag)
227
216
  return 'xpcshell';
228
217
  if (withTests)
229
- return 'mochikit';
218
+ return 'browser-chrome';
230
219
  return 'none';
231
220
  }
232
221
  /**
@@ -247,11 +236,16 @@ async function performCreateMutations(args) {
247
236
  const testFiles = [];
248
237
  let files;
249
238
  try {
239
+ const freshConfig = await loadAuthoringFurnaceConfig(args.projectRoot);
240
+ validateCreateAgainstConfig(freshConfig, args.componentName, args.allowPrefixMismatch, args.composes);
241
+ if (await pathExists(args.componentDir)) {
242
+ throw new FurnaceError(`Directory already exists: components/custom/${args.componentName}`, args.componentName);
243
+ }
250
244
  // Record the componentDir creation entry immediately after registration
251
245
  // so signal-driven rollback can clean it up even if writeComponentFiles
252
246
  // is interrupted mid-ensureDir.
253
247
  recordCreatedDir(journal, args.componentDir);
254
- files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, args.sharedFtl, journal);
248
+ files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, resolveFtlChromeSubPath(freshConfig.ftlBasePath), args.sharedFtl, journal);
255
249
  const customEntry = {
256
250
  description: args.description,
257
251
  targetPath: `toolkit/content/widgets/${args.componentName}`,
@@ -264,9 +258,9 @@ async function performCreateMutations(args) {
264
258
  if (args.sharedFtl) {
265
259
  customEntry.sharedFtl = args.sharedFtl;
266
260
  }
267
- args.config.custom[args.componentName] = customEntry;
261
+ freshConfig.custom[args.componentName] = customEntry;
268
262
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
269
- await writeFurnaceConfig(args.projectRoot, args.config);
263
+ await writeFurnaceConfig(args.projectRoot, freshConfig);
270
264
  await assertCustomEntryPersisted(args.projectRoot, args.componentName);
271
265
  if (args.testStyle === 'browser-chrome') {
272
266
  const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
@@ -310,41 +304,6 @@ async function resolveDescription(isInteractive, options) {
310
304
  }
311
305
  return description;
312
306
  }
313
- /**
314
- * Validates the `--compose` targets against registered components and runs
315
- * cycle detection if the new component is introduced into the graph. Throws
316
- * on any failure; returns when the graph is clean.
317
- */
318
- function validateComposesTargets(config, componentName, composes) {
319
- if (!composes || composes.length === 0)
320
- return;
321
- const known = new Set([
322
- ...config.stock,
323
- ...Object.keys(config.overrides),
324
- ...Object.keys(config.custom),
325
- ]);
326
- for (const tag of composes) {
327
- if (tag === componentName) {
328
- throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
329
- }
330
- if (!known.has(tag)) {
331
- throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
332
- 'The referenced component must be registered as stock, override, or custom.');
333
- }
334
- }
335
- // Check for cycles that would be introduced by adding this component.
336
- const tempCustom = {
337
- ...config.custom,
338
- [componentName]: {
339
- description: '',
340
- targetPath: `toolkit/content/widgets/${componentName}`,
341
- register: true,
342
- localized: false,
343
- composes,
344
- },
345
- };
346
- detectComposesCycles(tempCustom);
347
- }
348
307
  /**
349
308
  * Runs the furnace create command to scaffold a new custom component.
350
309
  * @param projectRoot - Root directory of the project
@@ -391,39 +350,14 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
391
350
  // succeeds so a cancelled create in a fresh project does not strand a new
392
351
  // furnace.json behind.
393
352
  const config = await loadAuthoringFurnaceConfig(projectRoot);
394
- // Check for conflicts
395
- const conflict = checkNameConflict(config, componentName);
396
- if (conflict) {
397
- throw new FurnaceError(conflict, componentName);
398
- }
353
+ const composes = options.compose;
354
+ validateCreateAgainstConfig(config, componentName, options.allowPrefixMismatch, composes);
399
355
  // Check if it already exists in the engine source tree
400
356
  if (await pathExists(paths.engine)) {
401
357
  if (await isComponentInEngine(paths.engine, componentName)) {
402
358
  throw new FurnaceError(`"${componentName}" already exists in the engine source tree. Use "fireforge furnace override" instead.`, componentName);
403
359
  }
404
360
  }
405
- // Refuse if name doesn't match componentPrefix, unless
406
- // --allow-prefix-mismatch was explicitly passed.
407
- //
408
- // Pre-0.16.0 this was a bare `warn()` and the create flow continued,
409
- // which produced a class of validation runs where the command reported
410
- // success, scaffolded files under components/custom/<name>/, and
411
- // registered tests in browser/base/moz.build, but the component
412
- // wasn't a good citizen of the fork's convention — subsequent
413
- // follow-up commands (list, status, rename) behaved inconsistently.
414
- // Refusing up-front leaves the workspace untouched on a bad name and
415
- // forces an intentional `--allow-prefix-mismatch` for the rare case
416
- // where the mismatch is deliberate.
417
- if (config.componentPrefix &&
418
- !componentName.startsWith(config.componentPrefix) &&
419
- !options.allowPrefixMismatch) {
420
- throw new InvalidArgumentError(`Name "${componentName}" does not start with the configured prefix "${config.componentPrefix}". ` +
421
- 'Use a prefixed name (e.g. "' +
422
- config.componentPrefix +
423
- componentName +
424
- '"), update `componentPrefix` in furnace.json, ' +
425
- 'or pass --allow-prefix-mismatch to create the component anyway.', 'name');
426
- }
427
361
  // --- Resolve description ---
428
362
  const description = await resolveDescription(isInteractive, options);
429
363
  // --- Resolve features ---
@@ -447,10 +381,6 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
447
381
  if (await pathExists(componentDir)) {
448
382
  throw new FurnaceError(`Directory already exists: components/custom/${componentName}`, componentName);
449
383
  }
450
- // --- Validate --compose targets BEFORE any writes so a failed validation
451
- // does not strand component files behind.
452
- const composes = options.compose;
453
- validateComposesTargets(config, componentName, composes);
454
384
  // --- Normalize and validate --shared-ftl ahead of any writes. Shares the
455
385
  // structural rules with furnace-config.ts so the command and the on-disk
456
386
  // schema cannot diverge. Pass the resolved `localized` rather than a
@@ -492,10 +422,6 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
492
422
  // state via the shared rollback journal. The mutation runs under the
493
423
  // furnace-wide lock and is registered with the global SIGINT/SIGTERM
494
424
  // rollback pathway.
495
- // Derive the FTL chrome sub-path from the configured ftlBasePath so the
496
- // generated `.mjs` calls `insertFTLIfNeeded` at a URI that actually matches
497
- // the locale jar.mn entry `furnace apply` will write.
498
- const ftlChromeSubPath = resolveFtlChromeSubPath(config.ftlBasePath);
499
425
  const { files, testFiles } = await runFurnaceMutation(projectRoot, 'create-rollback', (ctx) => performCreateMutations({
500
426
  projectRoot,
501
427
  componentName,
@@ -507,12 +433,11 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
507
433
  sharedFtl,
508
434
  componentDir,
509
435
  furnacePaths,
510
- config,
436
+ allowPrefixMismatch: options.allowPrefixMismatch,
511
437
  forgeConfig,
512
438
  paths,
513
439
  license,
514
440
  testStyle,
515
- ftlChromeSubPath,
516
441
  operationContext: ctx,
517
442
  }));
518
443
  note(formatSuccessNote({
@@ -71,9 +71,9 @@ function registerFurnaceInfoCommands(furnace, context) {
71
71
  .option('-d, --description <desc>', 'Component description')
72
72
  .option('--localized', 'Include Fluent l10n support')
73
73
  .option('--no-register', 'Skip customElements.js registration')
74
- .option('--with-tests', 'Scaffold a test harness (defaults to MochiKit; see --test-style)')
74
+ .option('--with-tests', 'Scaffold a test harness (defaults to browser-chrome; use --test-style=mochikit for tabbrowser-less chrome — may be flaky on macOS)')
75
75
  .option('--xpcshell', 'Scaffold an xpcshell test harness (for storage-layer code on forks without tabbrowser); equivalent to --test-style=xpcshell. Note: xpcshell resolves chrome://global/* URIs but not chrome://browser/* — use --test-style=browser-chrome for browser-chrome-dependent tests.')
76
- .option('--test-style <style>', "Override the harness written by --with-tests: mochikit (default, runs against non-tabbrowser chrome), browser-chrome (today's scaffold, needs tabbrowser), or xpcshell (headless)", (value) => {
76
+ .option('--test-style <style>', 'Override the harness written by --with-tests: browser-chrome (default, needs tabbrowser), mochikit (toolkit/widgets, non-tabbrowser chrome), or xpcshell (headless)', (value) => {
77
77
  if (value !== 'mochikit' && value !== 'browser-chrome' && value !== 'xpcshell') {
78
78
  throw new Error(`--test-style must be one of: mochikit, browser-chrome, xpcshell. Got: "${value}".`);
79
79
  }
@@ -213,6 +213,7 @@ export async function furnaceRefreshCommand(projectRoot, name, options = {}) {
213
213
  let totalUnchanged = 0;
214
214
  let totalSkipped = 0;
215
215
  const conflictComponents = [];
216
+ const failedOverrides = [];
216
217
  // Snapshot furnace.json before the batch loop so an unexpected failure
217
218
  // (process crash, unhandled error) can be recovered from. Per-component
218
219
  // errors caught below are expected and do not trigger a restore — only
@@ -244,7 +245,9 @@ export async function furnaceRefreshCommand(projectRoot, name, options = {}) {
244
245
  }
245
246
  }
246
247
  catch (error) {
247
- warn(`${overrideName}: ${toError(error).message}`);
248
+ const message = toError(error).message;
249
+ warn(`${overrideName}: ${message}`);
250
+ failedOverrides.push({ name: overrideName, message });
248
251
  }
249
252
  }
250
253
  }
@@ -257,12 +260,18 @@ export async function furnaceRefreshCommand(projectRoot, name, options = {}) {
257
260
  throw error;
258
261
  }
259
262
  const summary = `${overrideNames.length} override(s) processed, ${totalSkipped} already up-to-date\n` +
260
- `${totalMerged} file(s) merged, ${totalUnchanged} unchanged, ${totalConflicts} conflict(s)`;
263
+ `${totalMerged} file(s) merged, ${totalUnchanged} unchanged, ${totalConflicts} conflict(s), ` +
264
+ `${failedOverrides.length} failed`;
261
265
  if (conflictComponents.length > 0) {
262
266
  warn(`Conflicts in: ${conflictComponents.join(', ')}. ` +
263
267
  'Resolve conflict markers, then re-run refresh for those components to update baseVersion.');
264
268
  }
265
269
  note(summary, dryRun ? 'Dry Run Summary' : 'Refresh Summary');
270
+ if (failedOverrides.length > 0) {
271
+ outro(dryRun ? 'Dry run completed with failures' : 'Refresh completed with failures');
272
+ throw new FurnaceError(`Failed to refresh ${failedOverrides.length} override(s): ` +
273
+ failedOverrides.map((failure) => `${failure.name}: ${failure.message}`).join('; '));
274
+ }
266
275
  outro(dryRun ? 'Dry run complete' : 'Refresh complete');
267
276
  }
268
277
  //# sourceMappingURL=refresh.js.map
@@ -0,0 +1,5 @@
1
+ import type { FurnaceState } from '../../types/furnace.js';
2
+ /**
3
+ * Removes every checksum entry owned by the removed component.
4
+ */
5
+ export declare function dropChecksumsByPrefix(state: FurnaceState, prefix: string): FurnaceState;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Removes every checksum entry owned by the removed component.
3
+ */
4
+ export function dropChecksumsByPrefix(state, prefix) {
5
+ const result = { ...state };
6
+ if (state.appliedChecksums) {
7
+ result.appliedChecksums = Object.fromEntries(Object.entries(state.appliedChecksums).filter(([k]) => !k.startsWith(prefix)));
8
+ }
9
+ if (state.engineChecksums) {
10
+ result.engineChecksums = Object.fromEntries(Object.entries(state.engineChecksums).filter(([k]) => !k.startsWith(prefix)));
11
+ }
12
+ return result;
13
+ }
14
+ //# sourceMappingURL=remove-state.js.map
@@ -16,6 +16,7 @@ import { FurnaceError } from '../../errors/furnace.js';
16
16
  import { toError } from '../../utils/errors.js';
17
17
  import { pathExists, readText, removeDir, removeFile, writeText } from '../../utils/fs.js';
18
18
  import { cancel, info, intro, isCancel, outro, warn } from '../../utils/logger.js';
19
+ import { dropChecksumsByPrefix } from './remove-state.js';
19
20
  /**
20
21
  * Removes an entire TOML section (header + body lines) for a given test file.
21
22
  * Matches from `["filename"]` up to the next section header `[` or end-of-file,
@@ -328,15 +329,22 @@ async function cleanupCustomXpcshellTestFiles(name, projectRoot, journal) {
328
329
  }
329
330
  return { partialFailures };
330
331
  }
331
- function dropChecksumsByPrefix(state, prefix) {
332
- const result = { ...state };
333
- if (state.appliedChecksums) {
334
- result.appliedChecksums = Object.fromEntries(Object.entries(state.appliedChecksums).filter(([k]) => !k.startsWith(prefix)));
335
- }
336
- if (state.engineChecksums) {
337
- result.engineChecksums = Object.fromEntries(Object.entries(state.engineChecksums).filter(([k]) => !k.startsWith(prefix)));
332
+ async function loadFreshRemoveTarget(projectRoot, name, engineDir) {
333
+ const config = await loadFurnaceConfig(projectRoot);
334
+ const state = await loadFurnaceState(projectRoot);
335
+ const type = findComponentType(config, name);
336
+ if (!type) {
337
+ throw new FurnaceError(`Component "${name}" not found in furnace.json. Run "fireforge furnace list" to see registered components.`, name);
338
338
  }
339
- return result;
339
+ await requireGitEngineForRemove(type, name, engineDir);
340
+ return { config, ftlDir: resolveFtlDir(config.ftlBasePath), state, type };
341
+ }
342
+ async function cleanupAllCustomTestFiles(name, projectRoot, journal) {
343
+ const result = await cleanupCustomTestFiles(name, projectRoot, journal);
344
+ const failures = [...result.partialFailures];
345
+ failures.push(...(await cleanupCustomXpcshellTestFiles(name, projectRoot, journal)).partialFailures);
346
+ failures.push(...(await cleanupCustomMochikitTestFiles(name, projectRoot, journal)).partialFailures);
347
+ return failures;
340
348
  }
341
349
  /**
342
350
  * Confirms the remove operation interactively when TTY is available, or
@@ -380,9 +388,7 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
380
388
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
381
389
  intro('Furnace Remove');
382
390
  const config = await loadFurnaceConfig(projectRoot);
383
- const state = await loadFurnaceState(projectRoot);
384
391
  const furnacePaths = getFurnacePaths(projectRoot);
385
- const ftlDir = resolveFtlDir(config.ftlBasePath);
386
392
  // Find which section the component belongs to
387
393
  const type = findComponentType(config, name);
388
394
  if (!type) {
@@ -402,8 +408,9 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
402
408
  const journal = createRollbackJournal();
403
409
  ctx.registerJournal(journal);
404
410
  try {
405
- if (type === 'override') {
406
- const overrideConfig = config.overrides[name];
411
+ const { config: freshConfig, ftlDir, state: freshState, type: freshType, } = await loadFreshRemoveTarget(projectRoot, name, paths.engine);
412
+ if (freshType === 'override') {
413
+ const overrideConfig = freshConfig.overrides[name];
407
414
  const dir = join(furnacePaths.overridesDir, name);
408
415
  // Restore deployed engine files BEFORE removing the workspace
409
416
  // directory. The restore set is the union of (a) files currently in
@@ -411,7 +418,7 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
411
418
  // — without (b), source-side deletions would orphan engine copies
412
419
  // that this command can never see again.
413
420
  if (overrideConfig?.basePath) {
414
- const previousKeys = Object.keys(extractComponentChecksums(state.appliedChecksums, 'override', name));
421
+ const previousKeys = Object.keys(extractComponentChecksums(freshState.appliedChecksums, 'override', name));
415
422
  const { restored, removed } = await restoreOverrideEngineFiles(paths.engine, dir, overrideConfig, previousKeys, ftlDir, journal);
416
423
  if (restored > 0) {
417
424
  info(`Restored ${restored} file${restored === 1 ? '' : 's'} in engine/${overrideConfig.basePath} to Firefox baseline`);
@@ -426,8 +433,8 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
426
433
  info(`Deleted components/overrides/${name}/`);
427
434
  }
428
435
  }
429
- else if (type === 'custom') {
430
- const customConfig = config.custom[name];
436
+ else if (freshType === 'custom') {
437
+ const customConfig = freshConfig.custom[name];
431
438
  // Custom-component removal mutates engine files (jar.mn,
432
439
  // customElements.js, deployed widgets, optional .ftl) and the
433
440
  // rollback journal is the only safety net for those edits while
@@ -479,46 +486,24 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
479
486
  await removeCustomFtlJarMnEntry(paths.engine, `${name}.ftl`, ftlDir, customConfig, journal);
480
487
  }
481
488
  }
482
- let testCleanupFailures = [];
483
- if (type === 'custom') {
484
- const result = await cleanupCustomTestFiles(name, projectRoot, journal);
485
- testCleanupFailures = result.partialFailures;
486
- // 2026-04-24 eval Finding 5: also clean up xpcshell scaffolds
487
- // generated by `furnace create --with-tests --xpcshell`. The
488
- // mochitest cleanup above covers `browser/base/content/test/
489
- // <binary>/`, but xpcshell scaffolds live in the sibling
490
- // `<binary>-xpcshell/` directory and were orphaned by prior
491
- // versions.
492
- const xpcshellResult = await cleanupCustomXpcshellTestFiles(name, projectRoot, journal);
493
- testCleanupFailures.push(...xpcshellResult.partialFailures);
494
- // 2026-04-25 eval Finding 13: mochikit-style scaffolds
495
- // (`--test-style mochikit`) live under
496
- // `engine/toolkit/content/tests/widgets/` with `chrome.toml`
497
- // entries — neither the browser-chrome path nor the xpcshell
498
- // path touches them. Without this pass, a `furnace create
499
- // --with-tests --test-style mochikit` followed by `furnace
500
- // remove` left the test file and its toml entry referencing a
501
- // component that no longer exists.
502
- const mochikitResult = await cleanupCustomMochikitTestFiles(name, projectRoot, journal);
503
- testCleanupFailures.push(...mochikitResult.partialFailures);
504
- }
489
+ const testCleanupFailures = freshType === 'custom' ? await cleanupAllCustomTestFiles(name, projectRoot, journal) : [];
505
490
  // Remove entry from furnace.json
506
- if (type === 'stock') {
507
- config.stock = config.stock.filter((s) => s !== name);
491
+ if (freshType === 'stock') {
492
+ freshConfig.stock = freshConfig.stock.filter((s) => s !== name);
508
493
  }
509
- else if (type === 'override') {
510
- config.overrides = Object.fromEntries(Object.entries(config.overrides).filter(([key]) => key !== name));
494
+ else if (freshType === 'override') {
495
+ freshConfig.overrides = Object.fromEntries(Object.entries(freshConfig.overrides).filter(([key]) => key !== name));
511
496
  }
512
497
  else {
513
- config.custom = Object.fromEntries(Object.entries(config.custom).filter(([key]) => key !== name));
498
+ freshConfig.custom = Object.fromEntries(Object.entries(freshConfig.custom).filter(([key]) => key !== name));
514
499
  }
515
500
  await snapshotFile(journal, furnacePaths.furnaceConfig);
516
- await writeFurnaceConfig(projectRoot, config);
501
+ await writeFurnaceConfig(projectRoot, freshConfig);
517
502
  // Drop stale per-file checksums inside the same transactional block.
518
503
  // Snapshotting the state file into the rollback journal means the
519
504
  // entire remove operation is a single atomic unit.
520
505
  await snapshotFile(journal, furnacePaths.furnaceState);
521
- await updateFurnaceState(projectRoot, (state) => dropChecksumsByPrefix(state, `${type}/${name}/`));
506
+ await updateFurnaceState(projectRoot, (state) => dropChecksumsByPrefix(state, `${freshType}/${name}/`));
522
507
  // Test-cleanup failures are warn-and-continue by design (test files
523
508
  // are secondary artefacts), but the caller deserves a single summary
524
509
  // line pointing at the residue so they don't have to re-scan earlier
@@ -0,0 +1,13 @@
1
+ import type { FurnaceConfig } from '../../types/furnace.js';
2
+ /**
3
+ * Applies a component rename to scaffold-owned filenames only.
4
+ */
5
+ export declare function renameComponentFileName(fileName: string, oldName: string, newName: string): string;
6
+ /**
7
+ * Re-keys a custom component config entry and same-config compose references.
8
+ */
9
+ export declare function updateConfigForCustomRename(config: FurnaceConfig, oldName: string, newName: string): void;
10
+ /**
11
+ * Re-keys an override component config entry.
12
+ */
13
+ export declare function updateConfigForOverrideRename(config: FurnaceConfig, oldName: string, newName: string): void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Applies a component rename to scaffold-owned filenames only.
3
+ */
4
+ export function renameComponentFileName(fileName, oldName, newName) {
5
+ if (fileName === oldName)
6
+ return newName;
7
+ if (fileName.startsWith(oldName + '.')) {
8
+ return newName + fileName.slice(oldName.length);
9
+ }
10
+ return fileName;
11
+ }
12
+ /**
13
+ * Re-keys a custom component config entry and same-config compose references.
14
+ */
15
+ export function updateConfigForCustomRename(config, oldName, newName) {
16
+ const oldConfig = config.custom[oldName];
17
+ if (!oldConfig)
18
+ return;
19
+ config.custom[newName] = {
20
+ ...oldConfig,
21
+ targetPath: oldConfig.targetPath.replace(new RegExp(`(^|/)${oldName}$`), `$1${newName}`),
22
+ };
23
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- idiomatic key removal from config record
24
+ delete config.custom[oldName];
25
+ for (const customConfig of Object.values(config.custom)) {
26
+ if (customConfig.composes) {
27
+ customConfig.composes = customConfig.composes.map((ref) => (ref === oldName ? newName : ref));
28
+ }
29
+ }
30
+ }
31
+ /**
32
+ * Re-keys an override component config entry.
33
+ */
34
+ export function updateConfigForOverrideRename(config, oldName, newName) {
35
+ const oldConfig = config.overrides[oldName];
36
+ if (!oldConfig)
37
+ return;
38
+ config.overrides[newName] = { ...oldConfig };
39
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- idiomatic key removal from config record
40
+ delete config.overrides[oldName];
41
+ }
42
+ //# sourceMappingURL=rename-helpers.js.map