@hominis/fireforge 0.13.1 → 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +12 -8
  3. package/dist/bin/fireforge.js +19 -5
  4. package/dist/src/commands/config.js +7 -1
  5. package/dist/src/commands/discard.js +6 -1
  6. package/dist/src/commands/doctor.d.ts +12 -0
  7. package/dist/src/commands/doctor.js +6 -1
  8. package/dist/src/commands/download.js +106 -7
  9. package/dist/src/commands/export-shared.js +7 -0
  10. package/dist/src/commands/export.js +5 -0
  11. package/dist/src/commands/furnace/apply.js +147 -47
  12. package/dist/src/commands/furnace/create.js +13 -2
  13. package/dist/src/commands/furnace/deploy.js +17 -2
  14. package/dist/src/commands/furnace/diff.js +3 -1
  15. package/dist/src/commands/furnace/init.js +25 -7
  16. package/dist/src/commands/furnace/list.js +15 -7
  17. package/dist/src/commands/furnace/override.js +47 -15
  18. package/dist/src/commands/furnace/remove.js +68 -20
  19. package/dist/src/commands/furnace/rename.js +31 -3
  20. package/dist/src/commands/furnace/scan.js +8 -0
  21. package/dist/src/commands/furnace/validate.js +70 -7
  22. package/dist/src/commands/import.js +65 -11
  23. package/dist/src/commands/patch/compact.d.ts +25 -0
  24. package/dist/src/commands/patch/compact.js +132 -0
  25. package/dist/src/commands/patch/index.d.ts +1 -0
  26. package/dist/src/commands/patch/index.js +4 -1
  27. package/dist/src/commands/patch/reorder.d.ts +5 -1
  28. package/dist/src/commands/patch/reorder.js +4 -2
  29. package/dist/src/commands/re-export.js +11 -4
  30. package/dist/src/commands/rebase/abort.js +26 -14
  31. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  32. package/dist/src/commands/rebase/confirm.js +2 -2
  33. package/dist/src/commands/rebase/continue.js +39 -15
  34. package/dist/src/commands/rebase/index.js +2 -1
  35. package/dist/src/commands/rebase/patch-loop.js +90 -33
  36. package/dist/src/commands/register.js +13 -0
  37. package/dist/src/commands/resolve.js +31 -10
  38. package/dist/src/commands/run.js +9 -44
  39. package/dist/src/commands/setup-support.js +25 -7
  40. package/dist/src/commands/status.js +59 -8
  41. package/dist/src/commands/test.js +13 -7
  42. package/dist/src/commands/token.js +11 -1
  43. package/dist/src/commands/watch.js +51 -1
  44. package/dist/src/commands/wire.js +23 -0
  45. package/dist/src/core/config-validate.js +15 -1
  46. package/dist/src/core/furnace-registration.d.ts +1 -1
  47. package/dist/src/core/furnace-registration.js +2 -1
  48. package/dist/src/core/furnace-staleness.d.ts +17 -0
  49. package/dist/src/core/furnace-staleness.js +58 -0
  50. package/dist/src/core/license-headers.d.ts +15 -0
  51. package/dist/src/core/license-headers.js +28 -0
  52. package/dist/src/core/manifest-rules.js +24 -3
  53. package/dist/src/core/patch-lint.d.ts +11 -0
  54. package/dist/src/core/patch-lint.js +30 -3
  55. package/dist/src/core/signal-critical.d.ts +49 -0
  56. package/dist/src/core/signal-critical.js +80 -0
  57. package/dist/src/errors/download.d.ts +1 -1
  58. package/dist/src/errors/download.js +6 -3
  59. package/dist/src/types/commands/index.d.ts +1 -1
  60. package/dist/src/types/commands/options.d.ts +9 -0
  61. package/package.json +1 -1
@@ -39,59 +39,157 @@ function checksumMapsEqual(a, b) {
39
39
  }
40
40
  return true;
41
41
  }
42
+ /**
43
+ * Builds a watch-loop apply-failure message tailored to the error class so
44
+ * transient filesystem errors (EACCES, ENOSPC, lock timeout) look different
45
+ * from genuine apply-level failures; the previous generic "Apply failed: ..."
46
+ * collapsed all causes into one string and made diagnosis difficult.
47
+ */
48
+ function classifyWatchApplyError(err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ if (err instanceof FurnaceError) {
51
+ return `Apply failed: ${message}`;
52
+ }
53
+ const code = err instanceof Error ? err.code : undefined;
54
+ switch (code) {
55
+ case 'EACCES':
56
+ case 'EPERM':
57
+ return `Apply failed: permission denied — ${message}`;
58
+ case 'ENOSPC':
59
+ return `Apply failed: disk full — ${message}`;
60
+ case 'EBUSY':
61
+ case 'ETXTBSY':
62
+ return `Apply failed: file is in use — ${message}`;
63
+ case 'ENOENT':
64
+ return `Apply failed: missing file — ${message}`;
65
+ case 'ETIMEDOUT':
66
+ return `Apply failed: operation timed out — ${message}`;
67
+ default:
68
+ return `Apply failed: ${message}`;
69
+ }
70
+ }
42
71
  async function runWatchLoop(projectRoot) {
43
72
  const furnacePaths = getFurnacePaths(projectRoot);
73
+ // Both categories are eligible targets. The set is fixed; only existence
74
+ // varies over time — a component dir created AFTER watch started (e.g.
75
+ // the user runs `furnace create` in another terminal) must be picked up
76
+ // without restarting watch. The prior one-shot `pathExists` check at
77
+ // startup captured only the dirs that existed then, leaving any later
78
+ // creation invisible.
79
+ const candidateDirs = [furnacePaths.overridesDir, furnacePaths.customDir];
44
80
  const watchDirs = [];
45
- if (await pathExists(furnacePaths.overridesDir))
46
- watchDirs.push(furnacePaths.overridesDir);
47
- if (await pathExists(furnacePaths.customDir))
48
- watchDirs.push(furnacePaths.customDir);
49
- if (watchDirs.length === 0) {
50
- info('No component directories to watch.');
51
- return;
52
- }
81
+ const watchers = new Map();
53
82
  if (process.platform === 'linux') {
54
83
  warn('Watch mode uses fs.watch with recursive: true, which may miss changes ' +
55
84
  'in deeply nested directories on Linux. A periodic poll runs every 30s as a fallback.');
56
85
  }
57
- info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
58
86
  let debounceTimer = null;
59
87
  let applyInFlight = false;
60
- let lastChecksums = await snapshotWatchedChecksums(watchDirs);
88
+ // Coalesces changes that arrive while an apply is running. Without this
89
+ // flag, a second edit during an in-flight apply is debounced, the timer
90
+ // fires while applyInFlight is true, and the change is dropped entirely
91
+ // because the post-apply checksum snapshot already reflects the edit so
92
+ // the 30s poll also sees no diff.
93
+ let pendingChange = false;
94
+ let lastChecksums = new Map();
95
+ let pollTimer = null;
96
+ const runApplyCycle = async () => {
97
+ applyInFlight = true;
98
+ try {
99
+ info('\nChange detected — re-applying...');
100
+ const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
101
+ logApplyResult(result, false);
102
+ const applied = result.applied.length;
103
+ const skipped = result.skipped.length;
104
+ info(`Re-applied: ${applied} applied, ${skipped} skipped`);
105
+ }
106
+ catch (err) {
107
+ warn(classifyWatchApplyError(err));
108
+ }
109
+ finally {
110
+ applyInFlight = false;
111
+ // Update checksums after apply so the next poll does not re-trigger
112
+ // for changes that are already reflected in the engine.
113
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
114
+ }
115
+ // Another change arrived while we were applying — run again so the edit
116
+ // is not silently absorbed into the post-apply checksum bump.
117
+ if (pendingChange) {
118
+ pendingChange = false;
119
+ await runApplyCycle();
120
+ }
121
+ };
61
122
  const triggerApply = () => {
123
+ if (applyInFlight) {
124
+ // An apply is already running; record the change so runApplyCycle
125
+ // re-runs after it completes. Do not schedule a new debounce: the
126
+ // in-flight apply will observe this flag when it finishes.
127
+ pendingChange = true;
128
+ return;
129
+ }
62
130
  if (debounceTimer)
63
131
  clearTimeout(debounceTimer);
64
132
  debounceTimer = setTimeout(() => {
65
- if (applyInFlight)
133
+ debounceTimer = null;
134
+ if (applyInFlight) {
135
+ // Race with a change that started its own apply between the debounce
136
+ // scheduling and the timer firing. Record the pending change; the
137
+ // in-flight apply will pick it up.
138
+ pendingChange = true;
66
139
  return;
67
- applyInFlight = true;
68
- void (async () => {
69
- try {
70
- info('\nChange detected — re-applying...');
71
- const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }));
72
- logApplyResult(result, false);
73
- const applied = result.applied.length;
74
- const skipped = result.skipped.length;
75
- info(`Re-applied: ${applied} applied, ${skipped} skipped`);
76
- }
77
- catch (err) {
78
- warn(`Apply failed: ${err instanceof Error ? err.message : String(err)}`);
140
+ }
141
+ void runApplyCycle();
142
+ }, 300);
143
+ };
144
+ const installWatcher = (dir) => {
145
+ if (watchers.has(dir))
146
+ return false;
147
+ try {
148
+ const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
149
+ if (!filename)
150
+ return;
151
+ if (isComponentSourceFile(filename)) {
152
+ triggerApply();
79
153
  }
80
- finally {
81
- applyInFlight = false;
82
- // Update checksums after apply so the next poll does not re-trigger.
83
- lastChecksums = await snapshotWatchedChecksums(watchDirs);
154
+ });
155
+ watcher.on('error', (err) => {
156
+ warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
157
+ });
158
+ watchers.set(dir, watcher);
159
+ watchDirs.push(dir);
160
+ return true;
161
+ }
162
+ catch (err) {
163
+ // Directory vanished between the pathExists check and fs.watch, or
164
+ // fs.watch otherwise refused. refreshWatchers will retry on the next
165
+ // poll tick.
166
+ warn(`Could not start watcher on ${dir}: ${err instanceof Error ? err.message : String(err)}`);
167
+ return false;
168
+ }
169
+ };
170
+ // Scans candidate dirs for ones we are not yet watching and installs a
171
+ // watcher for each that now exists. Returns true when at least one new
172
+ // watcher was installed so the caller can trigger an apply cycle for
173
+ // the just-noticed content.
174
+ const refreshWatchers = async () => {
175
+ let added = false;
176
+ for (const dir of candidateDirs) {
177
+ if (watchers.has(dir))
178
+ continue;
179
+ if (await pathExists(dir)) {
180
+ if (installWatcher(dir)) {
181
+ info(`Now watching ${dir}`);
182
+ added = true;
84
183
  }
85
- })();
86
- }, 300);
184
+ }
185
+ }
186
+ return added;
87
187
  };
88
188
  // Register signal-driven cleanup BEFORE creating watchers so there is no
89
189
  // race window where a SIGINT could arrive after watchers exist but before
90
190
  // cleanup handlers are registered.
91
- const watchers = [];
92
- let pollTimer = null;
93
191
  const cleanup = () => {
94
- for (const w of watchers)
192
+ for (const w of watchers.values())
95
193
  w.close();
96
194
  if (debounceTimer)
97
195
  clearTimeout(debounceTimer);
@@ -100,26 +198,28 @@ async function runWatchLoop(projectRoot) {
100
198
  };
101
199
  process.once('SIGINT', cleanup);
102
200
  process.once('SIGTERM', cleanup);
103
- for (const dir of watchDirs) {
104
- const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
105
- if (!filename)
106
- return;
107
- if (isComponentSourceFile(filename)) {
108
- triggerApply();
109
- }
110
- });
111
- watcher.on('error', (err) => {
112
- warn(`Watcher error on ${dir}: ${err.message}. Periodic poll will continue as fallback.`);
113
- });
114
- watchers.push(watcher);
115
- }
116
- // Periodic checksum-based poll to catch events missed by fs.watch (known
117
- // issue on Linux with recursive: true and certain filesystems).
201
+ await refreshWatchers();
202
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
203
+ if (watchDirs.length === 0) {
204
+ info('No component directories exist yet — will retry every 30s. Create one with "fireforge furnace override" or "fireforge furnace create" in another terminal to begin watching.');
205
+ }
206
+ else {
207
+ info(`Watching ${watchDirs.length} directory(ies) for changes... (Ctrl+C to stop)`);
208
+ }
209
+ // Periodic checksum-based poll that also picks up newly-created component
210
+ // dirs (fs.watch was not installed for them at startup because they did
211
+ // not yet exist).
118
212
  pollTimer = setInterval(() => {
119
213
  if (applyInFlight)
120
214
  return;
121
215
  void (async () => {
122
216
  try {
217
+ const added = await refreshWatchers();
218
+ if (added) {
219
+ lastChecksums = await snapshotWatchedChecksums(watchDirs);
220
+ triggerApply();
221
+ return;
222
+ }
123
223
  const current = await snapshotWatchedChecksums(watchDirs);
124
224
  if (!checksumMapsEqual(current, lastChecksums)) {
125
225
  triggerApply();
@@ -134,7 +134,11 @@ async function scaffoldTestFiles(componentName, license, forgeConfig, paths, jou
134
134
  // browser.toml — create if missing, append entry if existing
135
135
  const tomlPath = join(testDir, 'browser.toml');
136
136
  if (await pathExists(tomlPath)) {
137
- // Append the new test entry if not already present
137
+ // Defensive guard: only append if the entry is not already present.
138
+ // With a fresh journal per create, the same test file name cannot be
139
+ // appended twice in a single run — but retaining the check protects
140
+ // against accidental re-entrance or a future refactor that reuses the
141
+ // helper with a stale test directory.
138
142
  const existingToml = await readText(tomlPath);
139
143
  if (!existingToml.includes(`["${testFileName}"]`)) {
140
144
  if (journal)
@@ -284,14 +288,21 @@ async function writeComponentFiles(componentDir, componentName, className, descr
284
288
  * state.
285
289
  */
286
290
  async function performCreateMutations(args) {
291
+ // Invariant: the journal MUST be registered with the operation context
292
+ // BEFORE any filesystem mutation (including recordCreatedDir, whose entries
293
+ // are consulted by SIGINT rollback). The try/catch below assumes signal
294
+ // handlers can find the journal for any partial write that follows.
287
295
  const journal = createRollbackJournal();
288
296
  if (args.operationContext) {
289
297
  args.operationContext.registerJournal(journal);
290
298
  }
291
- recordCreatedDir(journal, args.componentDir);
292
299
  const testFiles = [];
293
300
  let files;
294
301
  try {
302
+ // Record the componentDir creation entry immediately after registration
303
+ // so signal-driven rollback can clean it up even if writeComponentFiles
304
+ // is interrupted mid-ensureDir.
305
+ recordCreatedDir(journal, args.componentDir);
295
306
  files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, journal);
296
307
  const customEntry = {
297
308
  description: args.description,
@@ -44,10 +44,25 @@ function getFailedComponentNames(result) {
44
44
  }
45
45
  function getPersistableAppliedEntry(name, appliedEntry) {
46
46
  if (!appliedEntry) {
47
- throw new FurnaceError(`Named deploy for "${name}" completed without an applied entry.`);
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(`Named deploy for "${name}" returned unsupported component type "${appliedEntry.type}".`);
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('Cannot diff: baseCommit not found. Re-run "fireforge download" to establish a baseline.', name);
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
- if (options.ftlBasePath.includes('..')) {
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
- if (ftlValue.includes('..')) {
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
- if (!(await pathExists(componentDir))) {
13
- return formatErrorText('missing');
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
- const previous = extractComponentChecksums(appliedChecksums, type, name);
16
- if (Object.keys(previous).length === 0) {
17
- return formatErrorText('not applied');
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 snapshotFile(journal, dest);
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 snapshotFile(journal, dest);
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 snapshotFile(journal, dest);
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
- // Check for existing override
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 overrides
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
- if (name in config.overrides) {
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 = [];