@hominis/fireforge 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -1
- package/README.md +125 -238
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +5 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- package/package.json +1 -1
|
@@ -3,42 +3,85 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { FurnaceError } from '../errors/furnace.js';
|
|
4
4
|
import { toError } from '../utils/errors.js';
|
|
5
5
|
import { pathExists } from '../utils/fs.js';
|
|
6
|
+
import { info } from '../utils/logger.js';
|
|
6
7
|
import { getProjectPaths } from './config.js';
|
|
7
|
-
import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, prefixChecksums, } from './furnace-apply-helpers.js';
|
|
8
|
+
import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, diffDeletedFiles, extractComponentChecksums, getOverrideEngineTargetPath, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, undeployCustomFiles, undeployOverrideFiles, } from './furnace-apply-helpers.js';
|
|
8
9
|
import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, } from './furnace-config.js';
|
|
9
|
-
import {
|
|
10
|
-
|
|
10
|
+
import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from './furnace-constants.js';
|
|
11
|
+
import { topologicalSortCustom } from './furnace-graph-utils.js';
|
|
12
|
+
import { recordFurnaceRollbackFailure } from './furnace-operation.js';
|
|
13
|
+
import { addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from './furnace-registration.js';
|
|
14
|
+
import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotFile, } from './furnace-rollback.js';
|
|
15
|
+
import { runPostApplyConsistencyChecks } from './furnace-validate-registration.js';
|
|
16
|
+
export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, } from './furnace-apply-helpers.js';
|
|
11
17
|
function addMissingComponentError(result, name, directoryPath) {
|
|
12
18
|
result.errors.push({
|
|
13
19
|
name,
|
|
14
20
|
error: `Component directory not found: ${directoryPath}`,
|
|
15
21
|
});
|
|
16
22
|
}
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
function buildOverrideUndeployActions(name, config, engineDir, deletedFiles, ftlDir) {
|
|
24
|
+
return deletedFiles.map((fileName) => ({
|
|
25
|
+
component: name,
|
|
26
|
+
action: 'undeploy-restore',
|
|
27
|
+
target: getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir),
|
|
28
|
+
description: `Restore engine/${fileName.endsWith('.ftl') ? `${ftlDir}/${fileName}` : `${config.basePath}/${fileName}`} to Firefox baseline`,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
async function applyOverrideBatch(config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName) {
|
|
32
|
+
const overrideEntries = Object.entries(config.overrides).filter(([name]) => !componentName || name === componentName);
|
|
33
|
+
const totalOverrides = overrideEntries.length;
|
|
34
|
+
let overrideIndex = 0;
|
|
35
|
+
for (const [name, overrideConfig] of overrideEntries) {
|
|
36
|
+
overrideIndex++;
|
|
37
|
+
if (!dryRun && totalOverrides > 1) {
|
|
38
|
+
info(`Applying override ${name} (${overrideIndex}/${totalOverrides})...`);
|
|
39
|
+
}
|
|
19
40
|
const componentDir = join(furnacePaths.overridesDir, name);
|
|
20
41
|
if (!(await pathExists(componentDir))) {
|
|
21
42
|
addMissingComponentError(result, name, `components/overrides/${name}`);
|
|
22
43
|
continue;
|
|
23
44
|
}
|
|
45
|
+
const previous = extractComponentChecksums(state.appliedChecksums, 'override', name);
|
|
24
46
|
if (!dryRun) {
|
|
25
|
-
const previous = extractComponentChecksums(state.appliedChecksums, 'override', name);
|
|
26
47
|
const changed = await hasComponentChanged(componentDir, previous);
|
|
27
48
|
if (!changed) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
// Fast path holds only if the engine still reflects what we deployed.
|
|
50
|
+
// reset/download/manual edits can silently erase engine files; the
|
|
51
|
+
// checksum record alone cannot detect that.
|
|
52
|
+
const cachedEngine = extractComponentChecksums(state.engineChecksums, 'override', name);
|
|
53
|
+
const drifted = await hasOverrideEngineDrift(engineDir, componentDir, overrideConfig, ftlDir, cachedEngine);
|
|
54
|
+
if (!drifted) {
|
|
55
|
+
result.skipped.push({ name, reason: 'No changes since last apply' });
|
|
56
|
+
Object.assign(newChecksums, prefixChecksums(previous, 'override', name));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
31
59
|
}
|
|
32
60
|
}
|
|
33
61
|
try {
|
|
34
|
-
const
|
|
62
|
+
const filesAffectedTotal = [];
|
|
63
|
+
// Compute which files (if any) were removed from the workspace since
|
|
64
|
+
// the last apply. We do this for both dry-run and real runs so the
|
|
65
|
+
// planned-actions output stays honest.
|
|
66
|
+
const currentChecksums = await computeComponentChecksums(componentDir);
|
|
67
|
+
const deletedFiles = diffDeletedFiles(previous, currentChecksums);
|
|
68
|
+
if (dryRun) {
|
|
69
|
+
if (deletedFiles.length > 0) {
|
|
70
|
+
allActions.push(...buildOverrideUndeployActions(name, overrideConfig, engineDir, deletedFiles, ftlDir));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (deletedFiles.length > 0) {
|
|
74
|
+
const { restored, removed } = await undeployOverrideFiles(engineDir, overrideConfig, deletedFiles, ftlDir, rollbackJournal);
|
|
75
|
+
filesAffectedTotal.push(...restored, ...removed);
|
|
76
|
+
}
|
|
77
|
+
const { affectedPaths: filesAffected, actions } = await applyOverrideComponent(engineDir, name, componentDir, overrideConfig, ftlDir, dryRun, rollbackJournal);
|
|
35
78
|
if (dryRun && actions) {
|
|
36
79
|
allActions.push(...actions);
|
|
37
80
|
}
|
|
38
|
-
|
|
81
|
+
filesAffectedTotal.push(...filesAffected);
|
|
82
|
+
result.applied.push({ name, type: 'override', filesAffected: filesAffectedTotal });
|
|
39
83
|
if (!dryRun) {
|
|
40
|
-
|
|
41
|
-
Object.assign(newChecksums, prefixChecksums(checksums, 'override', name));
|
|
84
|
+
Object.assign(newChecksums, prefixChecksums(currentChecksums, 'override', name));
|
|
42
85
|
}
|
|
43
86
|
}
|
|
44
87
|
catch (error) {
|
|
@@ -49,38 +92,178 @@ async function applyOverrideBatch(config, furnacePaths, state, engineDir, dryRun
|
|
|
49
92
|
}
|
|
50
93
|
}
|
|
51
94
|
}
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
function buildCustomUndeployActions(name, config, engineDir, deletedFiles, ftlDir) {
|
|
96
|
+
const actions = [];
|
|
97
|
+
for (const fileName of deletedFiles) {
|
|
98
|
+
const enginePath = fileName.endsWith('.ftl')
|
|
99
|
+
? join(engineDir, ftlDir, fileName)
|
|
100
|
+
: join(engineDir, config.targetPath, fileName);
|
|
101
|
+
actions.push({
|
|
102
|
+
component: name,
|
|
103
|
+
action: 'undeploy-remove',
|
|
104
|
+
target: enginePath,
|
|
105
|
+
description: `Remove orphaned ${fileName} from engine`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// jar.mn re-sync planned for any custom-file deletion when registered.
|
|
109
|
+
if (config.register && deletedFiles.some((f) => f.endsWith('.mjs') || f.endsWith('.css'))) {
|
|
110
|
+
actions.push({
|
|
111
|
+
component: name,
|
|
112
|
+
action: 'unregister-jar',
|
|
113
|
+
description: `Re-sync ${name} jar.mn entries to drop deleted files`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (config.register && deletedFiles.some((f) => f === `${name}.mjs`)) {
|
|
117
|
+
actions.push({
|
|
118
|
+
component: name,
|
|
119
|
+
action: 'unregister-ce',
|
|
120
|
+
description: `Deregister ${name} from customElements.js (.mjs deleted)`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return actions;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* After undeploying deleted files, the in-engine jar.mn and
|
|
127
|
+
* customElements.js still carry entries for the removed files. Re-sync them
|
|
128
|
+
* by removing all of `name`'s jar.mn entries and re-adding only those that
|
|
129
|
+
* still exist in the workspace; if the .mjs itself was deleted, also drop
|
|
130
|
+
* the customElements.js registration. Snapshots are taken under the same
|
|
131
|
+
* journal so the undo path is symmetric with apply.
|
|
132
|
+
*/
|
|
133
|
+
async function reconcileCustomRegistrationAfterUndeploy(engineDir, name, config, deletedFiles, currentChecksums, rollbackJournal, filesAffected) {
|
|
134
|
+
if (!config.register || deletedFiles.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
const deletedRegistrationFiles = deletedFiles.filter((f) => f.endsWith('.mjs') || f.endsWith('.css'));
|
|
137
|
+
if (deletedRegistrationFiles.length === 0)
|
|
138
|
+
return;
|
|
139
|
+
// jar.mn re-sync. addJarMnEntries is idempotent on duplicates but does not
|
|
140
|
+
// drop stale entries, so we need a remove-then-add cycle. The journal
|
|
141
|
+
// snapshot before each mutation gives us a clean rollback target.
|
|
142
|
+
if (rollbackJournal) {
|
|
143
|
+
await snapshotFile(rollbackJournal, join(engineDir, JAR_MN));
|
|
144
|
+
}
|
|
145
|
+
await removeJarMnEntries(engineDir, name);
|
|
146
|
+
const liveJarFiles = Object.keys(currentChecksums).filter((f) => f.endsWith('.mjs') || f.endsWith('.css'));
|
|
147
|
+
if (liveJarFiles.length > 0) {
|
|
148
|
+
// applyCustomComponent has already added live entries; the remove above
|
|
149
|
+
// dropped them too, so re-add now to leave jar.mn in the correct state.
|
|
150
|
+
await addJarMnEntries(engineDir, name, liveJarFiles);
|
|
151
|
+
}
|
|
152
|
+
filesAffected.push(JAR_MN);
|
|
153
|
+
// If the .mjs file itself was deleted, the customElements registration
|
|
154
|
+
// must go too — otherwise we leave a dangling import in the Pattern B
|
|
155
|
+
// block that fails at runtime.
|
|
156
|
+
if (deletedFiles.includes(`${name}.mjs`)) {
|
|
157
|
+
if (rollbackJournal) {
|
|
158
|
+
await snapshotFile(rollbackJournal, join(engineDir, CUSTOM_ELEMENTS_JS));
|
|
159
|
+
}
|
|
160
|
+
await removeCustomElementRegistration(engineDir, name);
|
|
161
|
+
filesAffected.push(CUSTOM_ELEMENTS_JS);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName) {
|
|
165
|
+
const allKnown = new Set([
|
|
166
|
+
...config.stock,
|
|
167
|
+
...Object.keys(config.overrides),
|
|
168
|
+
...Object.keys(config.custom),
|
|
169
|
+
]);
|
|
170
|
+
// Build a set of component names that failed during the override batch so
|
|
171
|
+
// custom components that compose them can be skipped. This includes both
|
|
172
|
+
// hard errors and step-error failures.
|
|
173
|
+
const failedDependencies = new Set();
|
|
174
|
+
for (const entry of result.errors) {
|
|
175
|
+
failedDependencies.add(entry.name);
|
|
176
|
+
}
|
|
177
|
+
for (const entry of result.applied) {
|
|
178
|
+
if (entry.stepErrors && entry.stepErrors.length > 0) {
|
|
179
|
+
failedDependencies.add(entry.name);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const sortedNames = topologicalSortCustom(config.custom).filter((name) => !componentName || name === componentName);
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- names from Object.keys
|
|
184
|
+
const customEntries = sortedNames.map((name) => [name, config.custom[name]]);
|
|
185
|
+
const totalCustom = customEntries.length;
|
|
186
|
+
let customIndex = 0;
|
|
187
|
+
for (const [name, customConfig] of customEntries) {
|
|
188
|
+
customIndex++;
|
|
189
|
+
if (!dryRun && totalCustom > 1) {
|
|
190
|
+
info(`Applying custom component ${name} (${customIndex}/${totalCustom})...`);
|
|
191
|
+
}
|
|
192
|
+
if (customConfig.composes) {
|
|
193
|
+
const missing = customConfig.composes.filter((ref) => !allKnown.has(ref));
|
|
194
|
+
if (missing.length > 0) {
|
|
195
|
+
result.errors.push({
|
|
196
|
+
name,
|
|
197
|
+
error: `Composes unknown component(s): ${missing.join(', ')}. Each reference must be registered as stock, override, or custom.`,
|
|
198
|
+
});
|
|
199
|
+
failedDependencies.add(name);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// Skip this component if any of its composed dependencies failed.
|
|
203
|
+
const failedRefs = customConfig.composes.filter((ref) => failedDependencies.has(ref));
|
|
204
|
+
if (failedRefs.length > 0) {
|
|
205
|
+
result.errors.push({
|
|
206
|
+
name,
|
|
207
|
+
error: `Skipped: composed dependency ${failedRefs.join(', ')} failed to apply.`,
|
|
208
|
+
});
|
|
209
|
+
failedDependencies.add(name);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
54
213
|
const componentDir = join(furnacePaths.customDir, name);
|
|
55
214
|
if (!(await pathExists(componentDir))) {
|
|
56
215
|
addMissingComponentError(result, name, `components/custom/${name}`);
|
|
57
216
|
continue;
|
|
58
217
|
}
|
|
218
|
+
const previous = extractComponentChecksums(state.appliedChecksums, 'custom', name);
|
|
59
219
|
if (!dryRun) {
|
|
60
|
-
const previous = extractComponentChecksums(state.appliedChecksums, 'custom', name);
|
|
61
220
|
const changed = await hasComponentChanged(componentDir, previous);
|
|
62
221
|
if (!changed) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
222
|
+
// As with overrides, the checksum record is not sufficient on its
|
|
223
|
+
// own: a reset/download that cleared the engine must trigger a
|
|
224
|
+
// re-apply even though the workspace is unchanged.
|
|
225
|
+
const drifted = await hasCustomEngineDrift(root, name, componentDir, customConfig, ftlDir);
|
|
226
|
+
if (!drifted) {
|
|
227
|
+
result.skipped.push({ name, reason: 'No changes since last apply' });
|
|
228
|
+
Object.assign(newChecksums, prefixChecksums(previous, 'custom', name));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
66
231
|
}
|
|
67
232
|
}
|
|
68
233
|
try {
|
|
69
|
-
const
|
|
234
|
+
const filesAffectedTotal = [];
|
|
235
|
+
// Diff against previous to find files the developer has deleted from
|
|
236
|
+
// the workspace since last apply. Run for both dry-run and real apply
|
|
237
|
+
// so plan output and execution stay aligned.
|
|
238
|
+
const currentChecksums = await computeComponentChecksums(componentDir);
|
|
239
|
+
const deletedFiles = diffDeletedFiles(previous, currentChecksums);
|
|
240
|
+
if (dryRun) {
|
|
241
|
+
if (deletedFiles.length > 0) {
|
|
242
|
+
allActions.push(...buildCustomUndeployActions(name, customConfig, engineDir, deletedFiles, ftlDir));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (deletedFiles.length > 0) {
|
|
246
|
+
const removed = await undeployCustomFiles(engineDir, customConfig, deletedFiles, ftlDir, rollbackJournal);
|
|
247
|
+
filesAffectedTotal.push(...removed);
|
|
248
|
+
}
|
|
249
|
+
const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, dryRun, rollbackJournal);
|
|
70
250
|
if (dryRun && actions) {
|
|
71
251
|
allActions.push(...actions);
|
|
72
252
|
}
|
|
253
|
+
if (!dryRun && deletedFiles.length > 0 && stepErrors.length === 0) {
|
|
254
|
+
await reconcileCustomRegistrationAfterUndeploy(engineDir, name, customConfig, deletedFiles, currentChecksums, rollbackJournal, filesAffectedTotal);
|
|
255
|
+
}
|
|
256
|
+
filesAffectedTotal.push(...filesAffected);
|
|
73
257
|
result.applied.push({
|
|
74
258
|
name,
|
|
75
259
|
type: 'custom',
|
|
76
|
-
filesAffected,
|
|
260
|
+
filesAffected: filesAffectedTotal,
|
|
77
261
|
...(stepErrors.length > 0 ? { stepErrors } : {}),
|
|
78
262
|
});
|
|
79
263
|
// Only store checksums when the component applied without step errors,
|
|
80
264
|
// so that partially failed components are re-applied on the next run.
|
|
81
265
|
if (!dryRun && stepErrors.length === 0) {
|
|
82
|
-
|
|
83
|
-
Object.assign(newChecksums, prefixChecksums(checksums, 'custom', name));
|
|
266
|
+
Object.assign(newChecksums, prefixChecksums(currentChecksums, 'custom', name));
|
|
84
267
|
}
|
|
85
268
|
}
|
|
86
269
|
catch (error) {
|
|
@@ -98,19 +281,36 @@ async function applyCustomBatch(config, furnacePaths, state, engineDir, dryRun,
|
|
|
98
281
|
* fails, FireForge restores only the engine files touched during this apply
|
|
99
282
|
* attempt and leaves the state file unchanged.
|
|
100
283
|
*
|
|
284
|
+
* When `options.persistState` is false, the furnace state file is left alone
|
|
285
|
+
* on success and the rollback journal is returned on the result so the caller
|
|
286
|
+
* can restore the engine later (used by `furnace preview` to stage workspace
|
|
287
|
+
* files for Storybook and then roll them back on teardown).
|
|
288
|
+
*
|
|
101
289
|
* @param root - Root directory of the project
|
|
102
290
|
* @param dryRun - If true, enumerate planned actions without writing
|
|
103
|
-
* @
|
|
291
|
+
* @param options - Optional behavior flags. `persistState` controls whether
|
|
292
|
+
* the furnace state file is updated on success (preview teardown sets this
|
|
293
|
+
* to false to keep ownership of the journal). `operationContext` is the
|
|
294
|
+
* lifecycle-wrapper hook used by `runFurnaceMutation` so a Ctrl+C mid-apply
|
|
295
|
+
* can find the in-flight rollback journal.
|
|
296
|
+
* @returns Summary of applied, skipped, and errored components (with actions
|
|
297
|
+
* when dry-run, and with rollbackJournal when persistState=false)
|
|
104
298
|
*/
|
|
105
|
-
export async function applyAllComponents(root, dryRun = false) {
|
|
299
|
+
export async function applyAllComponents(root, dryRun = false, options) {
|
|
300
|
+
const persistState = options?.persistState ?? true;
|
|
301
|
+
const operationContext = options?.operationContext;
|
|
106
302
|
const config = await loadFurnaceConfig(root);
|
|
107
303
|
const state = await loadFurnaceState(root);
|
|
108
304
|
const { engine: engineDir } = getProjectPaths(root);
|
|
109
305
|
const furnacePaths = getFurnacePaths(root);
|
|
306
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
110
307
|
if (!(await pathExists(engineDir))) {
|
|
111
308
|
throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
|
|
112
309
|
}
|
|
113
310
|
const rollbackJournal = dryRun ? undefined : createRollbackJournal();
|
|
311
|
+
if (rollbackJournal && operationContext) {
|
|
312
|
+
operationContext.registerJournal(rollbackJournal);
|
|
313
|
+
}
|
|
114
314
|
const result = {
|
|
115
315
|
applied: [],
|
|
116
316
|
skipped: [],
|
|
@@ -118,26 +318,57 @@ export async function applyAllComponents(root, dryRun = false) {
|
|
|
118
318
|
};
|
|
119
319
|
const allActions = [];
|
|
120
320
|
const newChecksums = {};
|
|
121
|
-
|
|
122
|
-
|
|
321
|
+
const componentName = options?.componentName;
|
|
322
|
+
// When a single component is requested, validate it exists before running
|
|
323
|
+
// the batch functions (which would silently skip an unknown name).
|
|
324
|
+
if (componentName) {
|
|
325
|
+
const isKnown = componentName in config.overrides || componentName in config.custom;
|
|
326
|
+
if (!isKnown) {
|
|
327
|
+
throw new FurnaceError(`Component "${componentName}" not found in furnace.json. Run "fireforge furnace list" to see registered components.`, componentName);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
await applyOverrideBatch(config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName);
|
|
331
|
+
await applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName);
|
|
123
332
|
// Check for any partial failures (step errors on applied components).
|
|
124
333
|
const hasStepErrors = result.applied.some((entry) => 'stepErrors' in entry && entry.stepErrors.length > 0);
|
|
125
334
|
// Orphaned components are implicitly cleaned up: newChecksums only
|
|
126
335
|
// contains entries for components that still exist in furnace.json,
|
|
127
336
|
// and it fully replaces state.appliedChecksums below.
|
|
337
|
+
if (!dryRun && !hasStepErrors && result.errors.length === 0) {
|
|
338
|
+
await runPostApplyConsistencyChecks(root, config, result, ftlDir);
|
|
339
|
+
}
|
|
128
340
|
// --- Rollback on failure, persist on success (skip for dry-run) ---
|
|
129
341
|
if (!dryRun) {
|
|
130
342
|
if (result.errors.length > 0 || hasStepErrors) {
|
|
131
343
|
if (rollbackJournal) {
|
|
132
|
-
|
|
344
|
+
try {
|
|
345
|
+
await restoreRollbackJournalOrThrow(rollbackJournal, 'Furnace apply failed');
|
|
346
|
+
result.rolledBack = true;
|
|
347
|
+
}
|
|
348
|
+
catch (rollbackError) {
|
|
349
|
+
// Rollback itself failed: the engine is in a partially restored
|
|
350
|
+
// state. Persist a pending-repair marker so the next `fireforge
|
|
351
|
+
// doctor --repair-furnace` run knows to reconcile.
|
|
352
|
+
await recordFurnaceRollbackFailure(root, 'apply-rollback', toError(rollbackError).message);
|
|
353
|
+
throw rollbackError;
|
|
354
|
+
}
|
|
133
355
|
}
|
|
134
356
|
}
|
|
135
|
-
else {
|
|
357
|
+
else if (persistState) {
|
|
358
|
+
// After a successful apply, workspace checksums equal the engine content
|
|
359
|
+
// (we just copied workspace → engine). Store them as engineChecksums so
|
|
360
|
+
// drift detection can compare engine files against the cached hash
|
|
361
|
+
// instead of byte-comparing against workspace sources.
|
|
136
362
|
await updateFurnaceState(root, {
|
|
137
363
|
lastApply: new Date().toISOString(),
|
|
138
364
|
appliedChecksums: newChecksums,
|
|
365
|
+
engineChecksums: { ...newChecksums },
|
|
139
366
|
});
|
|
140
367
|
}
|
|
368
|
+
else if (rollbackJournal) {
|
|
369
|
+
// Caller owns the journal and will restore on teardown.
|
|
370
|
+
result.rollbackJournal = rollbackJournal;
|
|
371
|
+
}
|
|
141
372
|
}
|
|
142
373
|
if (dryRun) {
|
|
143
374
|
result.actions = allActions;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Extracts per-component checksums from the flattened state-file checksum map. */
|
|
2
|
+
export declare function extractComponentChecksums(allChecksums: Record<string, string> | undefined, type: string, name: string): Record<string, string>;
|
|
3
|
+
/** Prefixes component checksums so they can be stored in the flattened state format. */
|
|
4
|
+
export declare function prefixChecksums(checksums: Record<string, string>, type: string, name: string): Record<string, string>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/** Extracts per-component checksums from the flattened state-file checksum map. */
|
|
3
|
+
export function extractComponentChecksums(allChecksums, type, name) {
|
|
4
|
+
if (!allChecksums)
|
|
5
|
+
return {};
|
|
6
|
+
const prefix = `${type}/${name}/`;
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const [key, value] of Object.entries(allChecksums)) {
|
|
9
|
+
if (key.startsWith(prefix)) {
|
|
10
|
+
result[key.slice(prefix.length)] = value;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
/** Prefixes component checksums so they can be stored in the flattened state format. */
|
|
16
|
+
export function prefixChecksums(checksums, type, name) {
|
|
17
|
+
const prefix = `${type}/${name}/`;
|
|
18
|
+
const result = {};
|
|
19
|
+
for (const [key, value] of Object.entries(checksums)) {
|
|
20
|
+
result[`${prefix}${key}`] = value;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=furnace-checksum-utils.js.map
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { FurnaceConfig, FurnaceState } from '../types/furnace.js';
|
|
2
|
+
import { detectComposesCycles } from './furnace-graph-utils.js';
|
|
3
|
+
export { detectComposesCycles };
|
|
2
4
|
/** Name of the furnace configuration file */
|
|
3
5
|
export declare const FURNACE_CONFIG_FILENAME = "furnace.json";
|
|
4
6
|
/** Name of the furnace state file */
|
|
@@ -36,6 +38,22 @@ export declare function getFurnacePaths(root: string): FurnacePaths;
|
|
|
36
38
|
* @returns True if furnace.json exists
|
|
37
39
|
*/
|
|
38
40
|
export declare function furnaceConfigExists(root: string): Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Migrates a furnace config from an older schema version to the current one.
|
|
43
|
+
* Returns the data unchanged if it is already at the current version.
|
|
44
|
+
*
|
|
45
|
+
* When a future version 2 is introduced, add a `case 1:` that transforms
|
|
46
|
+
* v1 data into v2 shape and falls through to validation. The pattern is:
|
|
47
|
+
*
|
|
48
|
+
* ```
|
|
49
|
+
* case 1:
|
|
50
|
+
* data = migrateV1ToV2(data);
|
|
51
|
+
* // fallthrough
|
|
52
|
+
* case 2:
|
|
53
|
+
* break;
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function migrateFurnaceConfig(data: Record<string, unknown>): Record<string, unknown>;
|
|
39
57
|
/**
|
|
40
58
|
* Validates a raw config object and returns a typed FurnaceConfig.
|
|
41
59
|
* @param data - Raw data to validate
|
|
@@ -91,4 +109,13 @@ export declare function saveFurnaceState(root: string, state: FurnaceState): Pro
|
|
|
91
109
|
* @param updates - Fields to update, or a transactional updater function
|
|
92
110
|
*/
|
|
93
111
|
export declare function updateFurnaceState(root: string, updates: Partial<FurnaceState> | ((current: FurnaceState) => FurnaceState)): Promise<void>;
|
|
94
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Collects engine-relative path prefixes that are managed by the Furnace
|
|
114
|
+
* component system (overrides, custom components, and their Fluent l10n
|
|
115
|
+
* files). Used by `status` and `export-all` to classify engine changes
|
|
116
|
+
* as Furnace-managed rather than unmanaged drift.
|
|
117
|
+
*
|
|
118
|
+
* Returns an empty set when no furnace config exists (opt-in subsystem).
|
|
119
|
+
* Prefixes always end with `/` so callers can use `startsWith()`.
|
|
120
|
+
*/
|
|
121
|
+
export declare function collectFurnaceManagedPrefixes(root: string): Promise<Set<string>>;
|