@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
|
@@ -4,30 +4,86 @@ import { readdir } from 'node:fs/promises';
|
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { FurnaceError } from '../errors/furnace.js';
|
|
6
6
|
import { toError } from '../utils/errors.js';
|
|
7
|
-
import { copyFile, ensureDir, pathExists, readText } from '../utils/fs.js';
|
|
7
|
+
import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
|
|
8
|
+
import { verbose } from '../utils/logger.js';
|
|
8
9
|
import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
|
|
9
|
-
import { addCustomElementRegistration, addJarMnEntries } from './furnace-registration.js';
|
|
10
|
+
import { addCustomElementRegistration, addJarMnEntries, validateCustomElementRegistration, validateJarMnEntries, } from './furnace-registration.js';
|
|
10
11
|
import { recordCreatedDir, snapshotFile } from './furnace-rollback.js';
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
import { checkRegistrationConsistency } from './furnace-validate-registration.js';
|
|
13
|
+
import { isGitRepository } from './git.js';
|
|
14
|
+
import { fileExistsInHead, restoreTrackedPath } from './git-file-ops.js';
|
|
15
|
+
function isRegularFile(entry) {
|
|
16
|
+
if (!entry.isFile())
|
|
17
|
+
return false;
|
|
18
|
+
if (typeof entry.isSymbolicLink === 'function' && entry.isSymbolicLink())
|
|
19
|
+
return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
13
22
|
function isChecksummedComponentFile(name) {
|
|
14
23
|
return name.endsWith('.mjs') || name.endsWith('.css') || name.endsWith('.ftl');
|
|
15
24
|
}
|
|
16
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Filter deciding which files in an override workspace directory are candidates
|
|
27
|
+
* for copying into the engine. Exported so `furnace remove` can invert apply
|
|
28
|
+
* using the exact same file set — the "files to restore" set is defined as the
|
|
29
|
+
* inverse of the "files apply would have written" set.
|
|
30
|
+
*/
|
|
31
|
+
export function isOverrideCopyCandidate(entryName, type) {
|
|
17
32
|
if (entryName === 'override.json') {
|
|
18
33
|
return false;
|
|
19
34
|
}
|
|
20
35
|
if (type === 'css-only') {
|
|
21
36
|
return entryName.endsWith('.css');
|
|
22
37
|
}
|
|
23
|
-
return entryName.endsWith('.mjs') || entryName.endsWith('.css');
|
|
38
|
+
return entryName.endsWith('.mjs') || entryName.endsWith('.css') || entryName.endsWith('.ftl');
|
|
39
|
+
}
|
|
40
|
+
/** Resolves the engine destination path for a single override-managed file. */
|
|
41
|
+
export function getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir) {
|
|
42
|
+
return fileName.endsWith('.ftl')
|
|
43
|
+
? join(engineDir, ftlDir, fileName)
|
|
44
|
+
: join(engineDir, config.basePath, fileName);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Restores a single override-deployed engine file to its pristine HEAD state,
|
|
48
|
+
* inverting whatever apply wrote into that path.
|
|
49
|
+
*
|
|
50
|
+
* Behaviour matches the per-file branch of `restoreOverrideEngineFiles` in
|
|
51
|
+
* `furnace remove`: snapshot first, then either `git restore` (if the file
|
|
52
|
+
* exists in HEAD) or hard-delete (if the override introduced the file). The
|
|
53
|
+
* caller MUST guarantee `engineDir` is a git repository — this helper does
|
|
54
|
+
* not re-check, because both `furnace remove` and `furnace apply` already
|
|
55
|
+
* own the precondition check at their entry points and re-checking on every
|
|
56
|
+
* file would balloon git invocations.
|
|
57
|
+
*
|
|
58
|
+
* Returns the action taken so the caller can produce accurate user-facing
|
|
59
|
+
* counts (`restored` vs `removed`). `noop` means the file was neither in HEAD
|
|
60
|
+
* nor on disk, which can happen when the engine was reset out-of-band — the
|
|
61
|
+
* caller should treat that as a successful no-op rather than an error.
|
|
62
|
+
*/
|
|
63
|
+
export async function restoreOverrideFileToBaseline(engineDir, enginePath, journal) {
|
|
64
|
+
const relPath = relative(engineDir, enginePath);
|
|
65
|
+
// Snapshot before mutation so a later rollback can undo both restoration
|
|
66
|
+
// (writes whatever content we removed back) and deletion (recreates the
|
|
67
|
+
// file). Snapshotting a missing path records `{ existed: false }`, which
|
|
68
|
+
// restoreFile turns into a delete — exactly the inverse of "we just
|
|
69
|
+
// wrote a file here", which is correct for the noop case too.
|
|
70
|
+
await snapshotFile(journal, enginePath);
|
|
71
|
+
if (await fileExistsInHead(engineDir, relPath)) {
|
|
72
|
+
await restoreTrackedPath(engineDir, relPath);
|
|
73
|
+
return 'restored';
|
|
74
|
+
}
|
|
75
|
+
if (await pathExists(enginePath)) {
|
|
76
|
+
await removeFile(enginePath);
|
|
77
|
+
return 'removed';
|
|
78
|
+
}
|
|
79
|
+
return 'noop';
|
|
24
80
|
}
|
|
25
81
|
/** Computes stable checksums for the source files that define a component. */
|
|
26
82
|
export async function computeComponentChecksums(componentDir) {
|
|
27
83
|
const checksums = {};
|
|
28
84
|
const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
|
|
29
85
|
for (const entry of entries) {
|
|
30
|
-
if (!entry
|
|
86
|
+
if (!isRegularFile(entry))
|
|
31
87
|
continue;
|
|
32
88
|
if (entry.name === 'override.json')
|
|
33
89
|
continue;
|
|
@@ -40,6 +96,92 @@ export async function computeComponentChecksums(componentDir) {
|
|
|
40
96
|
}
|
|
41
97
|
return checksums;
|
|
42
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Returns the filenames present in `previous` that are absent from `current`
|
|
101
|
+
* — i.e. files we know we deployed last time but the workspace has since
|
|
102
|
+
* deleted. The order of returned names is intentionally stable
|
|
103
|
+
* (sorted alphabetically) so test snapshots and CLI output are deterministic.
|
|
104
|
+
*/
|
|
105
|
+
export function diffDeletedFiles(previous, current) {
|
|
106
|
+
const deleted = [];
|
|
107
|
+
for (const key of Object.keys(previous)) {
|
|
108
|
+
if (!(key in current)) {
|
|
109
|
+
deleted.push(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return deleted.sort();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Removes engine copies of files that the developer has deleted from a custom
|
|
116
|
+
* component's workspace since the last apply. `.ftl` files live under the
|
|
117
|
+
* shared Fluent tree (`engine/${FTL_DIR}`); everything else lives under
|
|
118
|
+
* `engine/${config.targetPath}`. Snapshots each removal into the journal so a
|
|
119
|
+
* mid-apply failure can roll the engine back to its pre-undeploy state. Files
|
|
120
|
+
* that are already missing from the engine are silently no-op (the engine
|
|
121
|
+
* may have been reset out-of-band — refusing here would surface a confusing
|
|
122
|
+
* error in a recovery path).
|
|
123
|
+
*
|
|
124
|
+
* Does **not** touch jar.mn or customElements.js: registration churn is the
|
|
125
|
+
* caller's responsibility, since it must coordinate with the new file list
|
|
126
|
+
* computed by the regular apply step that follows.
|
|
127
|
+
*/
|
|
128
|
+
export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDir, rollbackJournal) {
|
|
129
|
+
const removed = [];
|
|
130
|
+
for (const fileName of deletedFiles) {
|
|
131
|
+
const enginePath = fileName.endsWith('.ftl')
|
|
132
|
+
? join(engineDir, ftlDir, fileName)
|
|
133
|
+
: join(engineDir, config.targetPath, fileName);
|
|
134
|
+
if (rollbackJournal) {
|
|
135
|
+
await snapshotFile(rollbackJournal, enginePath);
|
|
136
|
+
}
|
|
137
|
+
if (await pathExists(enginePath)) {
|
|
138
|
+
await removeFile(enginePath);
|
|
139
|
+
removed.push(relative(engineDir, enginePath));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return removed;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Restores or removes engine copies of files that the developer has deleted
|
|
146
|
+
* from an override component's workspace since the last apply. Each file is
|
|
147
|
+
* routed through `restoreOverrideFileToBaseline`, which restores it from
|
|
148
|
+
* HEAD if it was a Firefox baseline file or hard-deletes it if the override
|
|
149
|
+
* had introduced it.
|
|
150
|
+
*
|
|
151
|
+
* Requires `engineDir` to be a git repository — overrides cannot be inverted
|
|
152
|
+
* without git HEAD as the source of truth. The caller is expected to have
|
|
153
|
+
* already validated this precondition for the apply path; we re-check here
|
|
154
|
+
* so unit tests that exercise this helper directly cannot accidentally
|
|
155
|
+
* silent-fail on a non-git fixture.
|
|
156
|
+
*/
|
|
157
|
+
export async function undeployOverrideFiles(engineDir, config, deletedFiles, ftlDir, rollbackJournal) {
|
|
158
|
+
if (deletedFiles.length === 0) {
|
|
159
|
+
return { restored: [], removed: [] };
|
|
160
|
+
}
|
|
161
|
+
if (!rollbackJournal) {
|
|
162
|
+
throw new FurnaceError('Internal: undeployOverrideFiles requires a rollback journal so deletions can be undone on failure.');
|
|
163
|
+
}
|
|
164
|
+
if (!(await isGitRepository(engineDir))) {
|
|
165
|
+
throw new FurnaceError('Cannot undeploy override files: engine is not a git repository. Run "fireforge download" to initialise it.');
|
|
166
|
+
}
|
|
167
|
+
// Note: we deliberately do not re-filter `deletedFiles` through
|
|
168
|
+
// `isOverrideCopyCandidate(fileName, config.type)`. A file recorded in
|
|
169
|
+
// `previous` was already a valid copy candidate when it was deployed, and
|
|
170
|
+
// re-filtering would block cleanup if the override type later flipped
|
|
171
|
+
// from `full` to `css-only` — exactly the case we need cleanup for.
|
|
172
|
+
const restored = [];
|
|
173
|
+
const removed = [];
|
|
174
|
+
for (const fileName of deletedFiles) {
|
|
175
|
+
const enginePath = getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir);
|
|
176
|
+
const action = await restoreOverrideFileToBaseline(engineDir, enginePath, rollbackJournal);
|
|
177
|
+
const relPath = relative(engineDir, enginePath);
|
|
178
|
+
if (action === 'restored')
|
|
179
|
+
restored.push(relPath);
|
|
180
|
+
else if (action === 'removed')
|
|
181
|
+
removed.push(relPath);
|
|
182
|
+
}
|
|
183
|
+
return { restored, removed };
|
|
184
|
+
}
|
|
43
185
|
/** Compares current component file checksums against the previously recorded state. */
|
|
44
186
|
export async function hasComponentChanged(componentDir, previousChecksums) {
|
|
45
187
|
const current = await computeComponentChecksums(componentDir);
|
|
@@ -55,10 +197,99 @@ export async function hasComponentChanged(componentDir, previousChecksums) {
|
|
|
55
197
|
}
|
|
56
198
|
return false;
|
|
57
199
|
}
|
|
58
|
-
|
|
200
|
+
function normalizeForChecksum(content) {
|
|
201
|
+
return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Detects whether an override component's deployed files are missing from the
|
|
205
|
+
* engine or differ from the source. Used as a guard before skipping apply on a
|
|
206
|
+
* checksum match, so that reset/download/manual engine edits do not leave the
|
|
207
|
+
* caller with a stale "up to date" report.
|
|
208
|
+
*
|
|
209
|
+
* When `cachedEngineChecksums` is provided (populated on last successful apply),
|
|
210
|
+
* the function computes a SHA-256 hash of the engine file and compares it
|
|
211
|
+
* against the cached value. This avoids reading the full workspace source for
|
|
212
|
+
* the comparison when the engine hash still matches, which is the common case
|
|
213
|
+
* for projects with many components.
|
|
214
|
+
*/
|
|
215
|
+
export async function hasOverrideEngineDrift(engineDir, componentDir, config, ftlDir, cachedEngineChecksums) {
|
|
216
|
+
const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (!isRegularFile(entry))
|
|
219
|
+
continue;
|
|
220
|
+
if (!isOverrideCopyCandidate(entry.name, config.type))
|
|
221
|
+
continue;
|
|
222
|
+
const enginePath = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
|
|
223
|
+
if (!(await pathExists(enginePath))) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
const engineContent = normalizeForChecksum(await readText(enginePath));
|
|
227
|
+
// Fast path: compare engine content hash against cached value from last apply
|
|
228
|
+
if (cachedEngineChecksums) {
|
|
229
|
+
const engineHash = createHash('sha256').update(engineContent).digest('hex');
|
|
230
|
+
const cachedHash = cachedEngineChecksums[entry.name];
|
|
231
|
+
if (cachedHash && engineHash !== cachedHash) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
if (cachedHash)
|
|
235
|
+
continue; // Hash match — skip full source comparison
|
|
236
|
+
}
|
|
237
|
+
// Slow path: byte-compare engine content against workspace source
|
|
238
|
+
const srcContent = normalizeForChecksum(await readText(join(componentDir, entry.name)));
|
|
239
|
+
if (srcContent !== engineContent) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Detects whether a custom component's deployed copies, jar.mn entries, or
|
|
247
|
+
* customElements.js registration are missing from the engine or out of sync.
|
|
248
|
+
* Delegates to `checkRegistrationConsistency` so the oracle stays aligned with
|
|
249
|
+
* the validate command.
|
|
250
|
+
*/
|
|
251
|
+
export async function hasCustomEngineDrift(root, name, componentDir, config, ftlDir) {
|
|
252
|
+
const status = await checkRegistrationConsistency(root, name, config, ftlDir);
|
|
253
|
+
if (!status.targetExists || !status.filesInSync) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (status.missingTargetFiles.length > 0 || status.driftedFiles.length > 0) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
if (!config.register) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
// Registration drift: only check jar.mn entries for the file types that
|
|
263
|
+
// actually exist in source. jarMn{Mjs,Css} are substring checks, so a
|
|
264
|
+
// component with only .mjs (no .css) should not be flagged when jarMnCss
|
|
265
|
+
// is false — that is the expected post-apply state, not drift.
|
|
266
|
+
const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
|
|
267
|
+
let hasMjs = false;
|
|
268
|
+
let hasCss = false;
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (!isRegularFile(entry))
|
|
271
|
+
continue;
|
|
272
|
+
if (entry.name.endsWith('.mjs'))
|
|
273
|
+
hasMjs = true;
|
|
274
|
+
else if (entry.name.endsWith('.css'))
|
|
275
|
+
hasCss = true;
|
|
276
|
+
}
|
|
277
|
+
if (!status.customElementsPresent || !status.customElementsCorrectBlock) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (hasMjs && !status.jarMnMjs) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (hasCss && !status.jarMnCss) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
async function buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir) {
|
|
59
289
|
const actions = [];
|
|
290
|
+
const stepErrors = [];
|
|
60
291
|
for (const entry of entries) {
|
|
61
|
-
if (!entry
|
|
292
|
+
if (!isRegularFile(entry))
|
|
62
293
|
continue;
|
|
63
294
|
if (!entry.name.endsWith('.mjs') && !entry.name.endsWith('.css'))
|
|
64
295
|
continue;
|
|
@@ -78,12 +309,22 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
|
|
|
78
309
|
component: name,
|
|
79
310
|
action: 'copy-ftl',
|
|
80
311
|
source: ftlSrc,
|
|
81
|
-
target: join(engineDir,
|
|
82
|
-
description: `Copy ${ftlFile} to ${
|
|
312
|
+
target: join(engineDir, ftlDir, ftlFile),
|
|
313
|
+
description: `Copy ${ftlFile} to ${ftlDir}`,
|
|
83
314
|
});
|
|
84
315
|
}
|
|
85
316
|
}
|
|
86
317
|
if (config.register) {
|
|
318
|
+
try {
|
|
319
|
+
const modulePath = `chrome://global/content/elements/${name}.mjs`;
|
|
320
|
+
await validateCustomElementRegistration(engineDir, name, modulePath);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
stepErrors.push({
|
|
324
|
+
step: 'customElements.js registration',
|
|
325
|
+
error: toError(error).message,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
87
328
|
actions.push({
|
|
88
329
|
component: name,
|
|
89
330
|
action: 'register-ce',
|
|
@@ -91,27 +332,40 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
|
|
|
91
332
|
});
|
|
92
333
|
}
|
|
93
334
|
const copiedFileNames = entries
|
|
94
|
-
.filter((entry) => entry
|
|
335
|
+
.filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')))
|
|
95
336
|
.map((entry) => entry.name);
|
|
96
337
|
if (copiedFileNames.length > 0) {
|
|
338
|
+
try {
|
|
339
|
+
await validateJarMnEntries(engineDir, name, copiedFileNames);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
stepErrors.push({
|
|
343
|
+
step: 'jar.mn registration',
|
|
344
|
+
error: toError(error).message,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
97
347
|
actions.push({
|
|
98
348
|
component: name,
|
|
99
349
|
action: 'register-jar',
|
|
100
350
|
description: `Add ${copiedFileNames.join(', ')} to jar.mn`,
|
|
101
351
|
});
|
|
102
352
|
}
|
|
103
|
-
return actions;
|
|
353
|
+
return { actions, stepErrors };
|
|
104
354
|
}
|
|
105
355
|
/** Applies a custom component into the engine tree and captures registration step errors. */
|
|
106
|
-
export async function applyCustomComponent(engineDir, name, componentDir, config, dryRun = false, rollbackJournal) {
|
|
356
|
+
export async function applyCustomComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal) {
|
|
107
357
|
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
108
358
|
throw new FurnaceError(`Invalid component name "${name}": must match /^[a-z][a-z0-9-]*$/`);
|
|
109
359
|
}
|
|
110
360
|
const targetDir = join(engineDir, config.targetPath);
|
|
111
361
|
const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
|
|
362
|
+
const customSymlinks = entries.filter((e) => typeof e.isSymbolicLink === 'function' && e.isSymbolicLink());
|
|
363
|
+
if (customSymlinks.length > 0) {
|
|
364
|
+
verbose(`Skipped ${customSymlinks.length} symlink(s) in "${name}": ${customSymlinks.map((e) => e.name).join(', ')}`);
|
|
365
|
+
}
|
|
112
366
|
if (dryRun) {
|
|
113
|
-
const actions = await buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries);
|
|
114
|
-
return { affectedPaths: [], stepErrors
|
|
367
|
+
const { actions, stepErrors } = await buildCustomDryRunActions(name, componentDir, engineDir, config, targetDir, entries, ftlDir);
|
|
368
|
+
return { affectedPaths: [], stepErrors, actions };
|
|
115
369
|
}
|
|
116
370
|
if (rollbackJournal && !(await pathExists(targetDir))) {
|
|
117
371
|
recordCreatedDir(rollbackJournal, targetDir);
|
|
@@ -120,25 +374,30 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
|
|
|
120
374
|
const affectedPaths = [];
|
|
121
375
|
const stepErrors = [];
|
|
122
376
|
const copiedFileNames = [];
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
377
|
+
// Collect copy candidates, then snapshot + copy in parallel. Snapshots
|
|
378
|
+
// must complete before any copy (they read the original content), but
|
|
379
|
+
// independent files can be processed concurrently.
|
|
380
|
+
const filesToCopy = entries.filter((entry) => isRegularFile(entry) && (entry.name.endsWith('.mjs') || entry.name.endsWith('.css')));
|
|
381
|
+
// Snapshot phase (serial — journal is not concurrent-safe for the same path)
|
|
382
|
+
for (const entry of filesToCopy) {
|
|
129
383
|
const dest = join(targetDir, entry.name);
|
|
130
384
|
if (rollbackJournal) {
|
|
131
385
|
await snapshotFile(rollbackJournal, dest);
|
|
132
386
|
}
|
|
387
|
+
}
|
|
388
|
+
// Copy phase (parallel — independent file writes to different paths)
|
|
389
|
+
await Promise.all(filesToCopy.map(async (entry) => {
|
|
390
|
+
const src = join(componentDir, entry.name);
|
|
391
|
+
const dest = join(targetDir, entry.name);
|
|
133
392
|
await copyFile(src, dest);
|
|
134
393
|
affectedPaths.push(relative(engineDir, dest));
|
|
135
394
|
copiedFileNames.push(entry.name);
|
|
136
|
-
}
|
|
395
|
+
}));
|
|
137
396
|
if (config.localized) {
|
|
138
397
|
const ftlFile = `${name}.ftl`;
|
|
139
398
|
const ftlSrc = join(componentDir, ftlFile);
|
|
140
399
|
if (await pathExists(ftlSrc)) {
|
|
141
|
-
const ftlDest = join(engineDir,
|
|
400
|
+
const ftlDest = join(engineDir, ftlDir, ftlFile);
|
|
142
401
|
if (rollbackJournal) {
|
|
143
402
|
await snapshotFile(rollbackJournal, ftlDest);
|
|
144
403
|
}
|
|
@@ -180,21 +439,25 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
|
|
|
180
439
|
return { affectedPaths, stepErrors };
|
|
181
440
|
}
|
|
182
441
|
/** Applies an override component by copying its matching files onto the engine tree. */
|
|
183
|
-
export async function applyOverrideComponent(engineDir, name, componentDir, config, dryRun = false, rollbackJournal) {
|
|
442
|
+
export async function applyOverrideComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal) {
|
|
184
443
|
const targetDir = join(engineDir, config.basePath);
|
|
185
444
|
if (!(await pathExists(targetDir))) {
|
|
186
445
|
throw new FurnaceError(`Override target path not found in engine: ${config.basePath}`, name);
|
|
187
446
|
}
|
|
188
447
|
const entries = await readdir(componentDir, { withFileTypes: true, encoding: 'utf8' });
|
|
448
|
+
const overrideSymlinks = entries.filter((e) => typeof e.isSymbolicLink === 'function' && e.isSymbolicLink());
|
|
449
|
+
if (overrideSymlinks.length > 0) {
|
|
450
|
+
verbose(`Skipped ${overrideSymlinks.length} symlink(s) in "${name}": ${overrideSymlinks.map((e) => e.name).join(', ')}`);
|
|
451
|
+
}
|
|
189
452
|
if (dryRun) {
|
|
190
453
|
const actions = entries
|
|
191
|
-
.filter((entry) => entry
|
|
454
|
+
.filter((entry) => isRegularFile(entry) && isOverrideCopyCandidate(entry.name, config.type))
|
|
192
455
|
.map((entry) => ({
|
|
193
456
|
component: name,
|
|
194
457
|
action: 'copy',
|
|
195
458
|
source: join(componentDir, entry.name),
|
|
196
|
-
target:
|
|
197
|
-
description: `Override ${entry.name} in ${config.basePath}`,
|
|
459
|
+
target: getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir),
|
|
460
|
+
description: `Override ${entry.name} in ${entry.name.endsWith('.ftl') ? ftlDir : config.basePath}`,
|
|
198
461
|
}));
|
|
199
462
|
if (actions.length === 0) {
|
|
200
463
|
throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
|
|
@@ -202,43 +465,25 @@ export async function applyOverrideComponent(engineDir, name, componentDir, conf
|
|
|
202
465
|
return { affectedPaths: [], actions };
|
|
203
466
|
}
|
|
204
467
|
const affectedPaths = [];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const src = join(componentDir, entry.name);
|
|
210
|
-
const dest = join(targetDir, entry.name);
|
|
468
|
+
const candidateEntries = entries.filter((entry) => isRegularFile(entry) && isOverrideCopyCandidate(entry.name, config.type));
|
|
469
|
+
// Snapshot phase (serial)
|
|
470
|
+
for (const entry of candidateEntries) {
|
|
471
|
+
const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
|
|
211
472
|
if (rollbackJournal) {
|
|
212
473
|
await snapshotFile(rollbackJournal, dest);
|
|
213
474
|
}
|
|
475
|
+
}
|
|
476
|
+
// Copy phase (parallel)
|
|
477
|
+
await Promise.all(candidateEntries.map(async (entry) => {
|
|
478
|
+
const src = join(componentDir, entry.name);
|
|
479
|
+
const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
|
|
214
480
|
await copyFile(src, dest);
|
|
215
481
|
affectedPaths.push(relative(engineDir, dest));
|
|
216
|
-
}
|
|
482
|
+
}));
|
|
217
483
|
if (affectedPaths.length === 0) {
|
|
218
484
|
throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
|
|
219
485
|
}
|
|
220
486
|
return { affectedPaths };
|
|
221
487
|
}
|
|
222
|
-
|
|
223
|
-
export function extractComponentChecksums(allChecksums, type, name) {
|
|
224
|
-
if (!allChecksums)
|
|
225
|
-
return {};
|
|
226
|
-
const prefix = `${type}/${name}/`;
|
|
227
|
-
const result = {};
|
|
228
|
-
for (const [key, value] of Object.entries(allChecksums)) {
|
|
229
|
-
if (key.startsWith(prefix)) {
|
|
230
|
-
result[key.slice(prefix.length)] = value;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return result;
|
|
234
|
-
}
|
|
235
|
-
/** Prefixes component checksums so they can be stored in the flattened state format. */
|
|
236
|
-
export function prefixChecksums(checksums, type, name) {
|
|
237
|
-
const prefix = `${type}/${name}/`;
|
|
238
|
-
const result = {};
|
|
239
|
-
for (const [key, value] of Object.entries(checksums)) {
|
|
240
|
-
result[`${prefix}${key}`] = value;
|
|
241
|
-
}
|
|
242
|
-
return result;
|
|
243
|
-
}
|
|
488
|
+
export { extractComponentChecksums, prefixChecksums } from './furnace-checksum-utils.js';
|
|
244
489
|
//# sourceMappingURL=furnace-apply-helpers.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ApplyResult, DryRunAction } from '../types/furnace.js';
|
|
2
|
+
type ApplyResultWithActions = ApplyResult & {
|
|
3
|
+
actions?: DryRunAction[];
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Prints a standard summary of an apply result for both normal and dry-run flows.
|
|
7
|
+
*
|
|
8
|
+
* Dry-run output lists the planned actions collected by the apply helpers.
|
|
9
|
+
* Normal output lists applied files, skipped components, and any step errors.
|
|
10
|
+
* Errors are always printed after the main body.
|
|
11
|
+
*
|
|
12
|
+
* @param result - Result returned by applyAllComponents
|
|
13
|
+
* @param isDryRun - Whether apply was invoked with dryRun=true
|
|
14
|
+
*/
|
|
15
|
+
export declare function logApplyResult(result: ApplyResultWithActions, isDryRun: boolean): void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { error, info, success, warn } from '../utils/logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Prints a standard summary of an apply result for both normal and dry-run flows.
|
|
4
|
+
*
|
|
5
|
+
* Dry-run output lists the planned actions collected by the apply helpers.
|
|
6
|
+
* Normal output lists applied files, skipped components, and any step errors.
|
|
7
|
+
* Errors are always printed after the main body.
|
|
8
|
+
*
|
|
9
|
+
* @param result - Result returned by applyAllComponents
|
|
10
|
+
* @param isDryRun - Whether apply was invoked with dryRun=true
|
|
11
|
+
*/
|
|
12
|
+
export function logApplyResult(result, isDryRun) {
|
|
13
|
+
if (isDryRun && result.actions && result.actions.length > 0) {
|
|
14
|
+
info('Planned actions:');
|
|
15
|
+
for (const action of result.actions) {
|
|
16
|
+
info(` [${action.action}] ${action.component}: ${action.description}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else if (isDryRun) {
|
|
20
|
+
info('No actions would be performed.');
|
|
21
|
+
}
|
|
22
|
+
else if (result.rolledBack) {
|
|
23
|
+
// When the rollback journal was restored, entries in `applied` reflect
|
|
24
|
+
// what was attempted but no longer exists in the engine. Print them as
|
|
25
|
+
// "attempted" rather than "success" to avoid misleading the operator.
|
|
26
|
+
if (result.applied.length > 0) {
|
|
27
|
+
warn('The following components were applied but have been rolled back due to errors:');
|
|
28
|
+
for (const applied of result.applied) {
|
|
29
|
+
warn(` ${applied.name} (${applied.type}) — rolled back`);
|
|
30
|
+
if (applied.stepErrors && applied.stepErrors.length > 0) {
|
|
31
|
+
for (const stepErr of applied.stepErrors) {
|
|
32
|
+
warn(` ${applied.name}: [${stepErr.step}] ${stepErr.error}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
for (const applied of result.applied) {
|
|
40
|
+
success(`${applied.name} (${applied.type}) → ${applied.filesAffected.length} files`);
|
|
41
|
+
}
|
|
42
|
+
for (const skipped of result.skipped) {
|
|
43
|
+
info(`${skipped.name} — ${skipped.reason}`);
|
|
44
|
+
}
|
|
45
|
+
for (const applied of result.applied) {
|
|
46
|
+
if (applied.stepErrors && applied.stepErrors.length > 0) {
|
|
47
|
+
for (const stepErr of applied.stepErrors) {
|
|
48
|
+
warn(`${applied.name}: [${stepErr.step}] ${stepErr.error}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const err of result.errors) {
|
|
54
|
+
error(`${err.name} — ${err.error}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=furnace-apply-output.js.map
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ApplyResult, DryRunAction } from '../types/furnace.js';
|
|
2
|
-
|
|
2
|
+
import { type FurnaceOperationContext } from './furnace-operation.js';
|
|
3
|
+
import { type RollbackJournal } from './furnace-rollback.js';
|
|
4
|
+
export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, extractComponentChecksums, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, } from './furnace-apply-helpers.js';
|
|
3
5
|
/**
|
|
4
6
|
* Applies all override and custom components to the engine source tree.
|
|
5
7
|
*
|
|
@@ -7,10 +9,26 @@ export { applyCustomComponent, applyOverrideComponent, computeComponentChecksums
|
|
|
7
9
|
* fails, FireForge restores only the engine files touched during this apply
|
|
8
10
|
* attempt and leaves the state file unchanged.
|
|
9
11
|
*
|
|
12
|
+
* When `options.persistState` is false, the furnace state file is left alone
|
|
13
|
+
* on success and the rollback journal is returned on the result so the caller
|
|
14
|
+
* can restore the engine later (used by `furnace preview` to stage workspace
|
|
15
|
+
* files for Storybook and then roll them back on teardown).
|
|
16
|
+
*
|
|
10
17
|
* @param root - Root directory of the project
|
|
11
18
|
* @param dryRun - If true, enumerate planned actions without writing
|
|
12
|
-
* @
|
|
19
|
+
* @param options - Optional behavior flags. `persistState` controls whether
|
|
20
|
+
* the furnace state file is updated on success (preview teardown sets this
|
|
21
|
+
* to false to keep ownership of the journal). `operationContext` is the
|
|
22
|
+
* lifecycle-wrapper hook used by `runFurnaceMutation` so a Ctrl+C mid-apply
|
|
23
|
+
* can find the in-flight rollback journal.
|
|
24
|
+
* @returns Summary of applied, skipped, and errored components (with actions
|
|
25
|
+
* when dry-run, and with rollbackJournal when persistState=false)
|
|
13
26
|
*/
|
|
14
|
-
export declare function applyAllComponents(root: string, dryRun?: boolean
|
|
27
|
+
export declare function applyAllComponents(root: string, dryRun?: boolean, options?: {
|
|
28
|
+
persistState?: boolean;
|
|
29
|
+
operationContext?: FurnaceOperationContext;
|
|
30
|
+
componentName?: string;
|
|
31
|
+
}): Promise<ApplyResult & {
|
|
15
32
|
actions?: DryRunAction[];
|
|
33
|
+
rollbackJournal?: RollbackJournal;
|
|
16
34
|
}>;
|