@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.
- package/CHANGELOG.md +65 -0
- package/README.md +12 -8
- 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/patch/compact.d.ts +25 -0
- package/dist/src/commands/patch/compact.js +132 -0
- package/dist/src/commands/patch/index.d.ts +1 -0
- package/dist/src/commands/patch/index.js +4 -1
- package/dist/src/commands/patch/reorder.d.ts +5 -1
- package/dist/src/commands/patch/reorder.js +4 -2
- 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/license-headers.d.ts +15 -0
- package/dist/src/core/license-headers.js +28 -0
- package/dist/src/core/manifest-rules.js +24 -3
- package/dist/src/core/patch-lint.d.ts +11 -0
- package/dist/src/core/patch-lint.js +30 -3
- 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/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +9 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
void (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
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(`
|
|
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 = [];
|