@hominis/fireforge 0.36.0 → 0.38.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 +26 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +6 -6
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
- package/dist/src/commands/doctor-orphaned-harness.js +132 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +5 -1
- package/dist/src/commands/export-flow.js +10 -7
- package/dist/src/commands/export-placement-gate.js +3 -5
- package/dist/src/commands/export-placement-policy.d.ts +11 -3
- package/dist/src/commands/export-placement-policy.js +32 -29
- package/dist/src/commands/export.js +12 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/patch/move-files-create.d.ts +19 -0
- package/dist/src/commands/patch/move-files-create.js +116 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +49 -4
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
- package/dist/src/commands/patch/staged-dependency.js +113 -51
- package/dist/src/commands/re-export-files.js +4 -0
- package/dist/src/commands/re-export-scan.d.ts +17 -0
- package/dist/src/commands/re-export-scan.js +51 -0
- package/dist/src/commands/re-export.js +25 -6
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/test-diagnose.js +20 -1
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-stale-gate.d.ts +44 -0
- package/dist/src/commands/test-stale-gate.js +81 -0
- package/dist/src/commands/test.js +40 -23
- package/dist/src/core/build-audit.d.ts +12 -0
- package/dist/src/core/build-audit.js +14 -0
- package/dist/src/core/build-baseline-types.d.ts +58 -0
- package/dist/src/core/build-baseline.d.ts +10 -2
- package/dist/src/core/build-baseline.js +51 -7
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +6 -1
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -2
- package/dist/src/core/file-lock.d.ts +33 -0
- package/dist/src/core/file-lock.js +32 -6
- package/dist/src/core/firefox-archive.d.ts +2 -1
- package/dist/src/core/firefox-archive.js +32 -8
- package/dist/src/core/firefox.d.ts +8 -3
- package/dist/src/core/firefox.js +11 -6
- package/dist/src/core/furnace-config-custom.js +10 -0
- package/dist/src/core/furnace-stale-export.d.ts +59 -0
- package/dist/src/core/furnace-stale-export.js +123 -0
- package/dist/src/core/furnace-validate-compatibility.js +9 -2
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/furnace-validate-structure.js +3 -2
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +76 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
- package/dist/src/core/mach-known-noise-filter.js +193 -0
- package/dist/src/core/mach.d.ts +8 -0
- package/dist/src/core/mach.js +36 -3
- package/dist/src/core/patch-lint-cross.js +80 -2
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +25 -7
- package/dist/src/core/patch-manifest-io.d.ts +3 -2
- package/dist/src/core/patch-manifest-io.js +27 -16
- package/dist/src/core/patch-manifest-validate.js +24 -0
- package/dist/src/core/status-classify.d.ts +11 -2
- package/dist/src/core/status-classify.js +49 -25
- package/dist/src/core/test-harness-crash.d.ts +16 -0
- package/dist/src/core/test-harness-crash.js +55 -0
- package/dist/src/core/test-stale-check.d.ts +69 -1
- package/dist/src/core/test-stale-check.js +134 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +84 -7
- package/dist/src/types/commands/patches.d.ts +29 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +13 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/process-group.d.ts +33 -0
- package/dist/src/utils/process-group.js +161 -0
- package/dist/src/utils/process.d.ts +15 -0
- package/dist/src/utils/process.js +88 -44
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
|
@@ -35,4 +35,62 @@ export interface BuildBaseline {
|
|
|
35
35
|
* was recorded.
|
|
36
36
|
*/
|
|
37
37
|
packageableFingerprints?: Record<string, string>;
|
|
38
|
+
/**
|
|
39
|
+
* What the packaged test runtime produced by the recorded build covers.
|
|
40
|
+
*
|
|
41
|
+
* - `'full'`: the build packaged the full test set — written by
|
|
42
|
+
* `fireforge build` / `build --ui` and by a path-less
|
|
43
|
+
* `fireforge test --build` (full-suite run).
|
|
44
|
+
* - `string[]`: engine-relative POSIX request paths of the file/directory-
|
|
45
|
+
* scoped `fireforge test --build` invocation that produced the runtime.
|
|
46
|
+
* A directory entry covers everything beneath it. Support fixtures for
|
|
47
|
+
* manifests outside this list may be missing from `obj-*`/`_tests/`, so
|
|
48
|
+
* an `--allow-stale-build` run over uncovered paths is refused rather
|
|
49
|
+
* than dispatched into a hang.
|
|
50
|
+
*
|
|
51
|
+
* Missing on baselines written before 0.37.0 — only `fireforge build`
|
|
52
|
+
* wrote baselines then, so "full" is the honest historical value and the
|
|
53
|
+
* stale-check treats an absent field as full coverage.
|
|
54
|
+
*
|
|
55
|
+
* The record is project-scoped, which is also per-obj-dir: multi-objdir
|
|
56
|
+
* checkouts are refused up-front (`AmbiguousBuildArtifactsError`), so at
|
|
57
|
+
* most one obj dir exists per project.
|
|
58
|
+
*
|
|
59
|
+
* Union/"shared coverage" across successive scoped builds was evaluated
|
|
60
|
+
* and rejected as unsound — every baseline write refreshes
|
|
61
|
+
* `packageableFingerprints` for ALL dirty packageable paths, so a union
|
|
62
|
+
* would whitewash an earlier scope's edited fixtures while
|
|
63
|
+
* `obj-*`/`_tests/` still holds its stale staging; coverage therefore
|
|
64
|
+
* REPLACES.
|
|
65
|
+
*/
|
|
66
|
+
testPackagingCoverage?: TestPackagingCoverage;
|
|
67
|
+
/**
|
|
68
|
+
* Anchor for the compiled StaticComponents table: the engine HEAD SHA of
|
|
69
|
+
* the last FULL-coverage build plus content fingerprints of the
|
|
70
|
+
* `components.conf` manifests that were dirty at that moment. Written
|
|
71
|
+
* fresh only on FULL-coverage baseline writes (`fireforge build`,
|
|
72
|
+
* `build --ui`, path-less `test --build`); a scoped `test --build`
|
|
73
|
+
* carries the previous record forward verbatim, because `mach build
|
|
74
|
+
* faster` does not rebake `components.conf` registrations into the
|
|
75
|
+
* compiled table. Missing on pre-0.38.0 baselines — the
|
|
76
|
+
* static-components stale check degrades to "fresh" in that case.
|
|
77
|
+
*/
|
|
78
|
+
staticComponentsBaseline?: StaticComponentsBaseline;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* State of the engine at the last FULL build, as far as compiled-in XPCOM
|
|
82
|
+
* component registration is concerned. See
|
|
83
|
+
* {@link BuildBaseline.staticComponentsBaseline}.
|
|
84
|
+
*/
|
|
85
|
+
export interface StaticComponentsBaseline {
|
|
86
|
+
/** Engine HEAD SHA at the time of the last full build. */
|
|
87
|
+
engineHeadSha: string;
|
|
88
|
+
/**
|
|
89
|
+
* Hex-encoded SHA-256 per `components.conf` path that was dirty
|
|
90
|
+
* (modified-against-HEAD or untracked) when the full build ran. Keys are
|
|
91
|
+
* engine-relative POSIX paths.
|
|
92
|
+
*/
|
|
93
|
+
fingerprints: Record<string, string>;
|
|
38
94
|
}
|
|
95
|
+
/** Coverage claim of the packaged test runtime. See {@link BuildBaseline.testPackagingCoverage}. */
|
|
96
|
+
export type TestPackagingCoverage = 'full' | string[];
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* on successful build completion; a failed build does not update it, so a
|
|
16
16
|
* subsequent run still audits against the last known-good tree.
|
|
17
17
|
*/
|
|
18
|
-
import type { BuildBaseline } from './build-baseline-types.js';
|
|
18
|
+
import type { BuildBaseline, TestPackagingCoverage } from './build-baseline-types.js';
|
|
19
19
|
/** Name of the last-build marker file under `.fireforge/`. */
|
|
20
20
|
export declare const BUILD_BASELINE_FILENAME = "last-build.json";
|
|
21
21
|
/**
|
|
@@ -39,5 +39,13 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
|
|
|
39
39
|
* @param projectRoot - Root directory of the project
|
|
40
40
|
* @param engineDir - Path to the engine directory
|
|
41
41
|
* @param binaryName - Current `binaryName` from fireforge.json
|
|
42
|
+
* @param testPackagingCoverage - Coverage claim of the packaged test
|
|
43
|
+
* runtime this build produced (`'full'`, or the scoped request paths of
|
|
44
|
+
* a `test --build` invocation). Omitted → field left off the marker.
|
|
45
|
+
* @param previousBaseline - The baseline this write replaces, when the
|
|
46
|
+
* caller has it. A scoped (non-`'full'`) write carries its
|
|
47
|
+
* `staticComponentsBaseline` forward verbatim — `mach build faster`
|
|
48
|
+
* does not rebake `components.conf` registrations, so the last FULL
|
|
49
|
+
* build stays the honest anchor for the compiled StaticComponents table.
|
|
42
50
|
*/
|
|
43
|
-
export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string): Promise<void>;
|
|
51
|
+
export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline): Promise<void>;
|
|
@@ -22,7 +22,7 @@ import { join } from 'node:path';
|
|
|
22
22
|
import { toError } from '../utils/errors.js';
|
|
23
23
|
import { pathExists, readJson, writeJson } from '../utils/fs.js';
|
|
24
24
|
import { verbose } from '../utils/logger.js';
|
|
25
|
-
import { isPackageablePath } from './build-audit.js';
|
|
25
|
+
import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
|
|
26
26
|
import { FIREFORGE_DIR } from './config-paths.js';
|
|
27
27
|
import { getHead, hasChanges, isMissingHeadError } from './git.js';
|
|
28
28
|
import { git } from './git-base.js';
|
|
@@ -65,8 +65,16 @@ export async function readBuildBaseline(projectRoot) {
|
|
|
65
65
|
* @param projectRoot - Root directory of the project
|
|
66
66
|
* @param engineDir - Path to the engine directory
|
|
67
67
|
* @param binaryName - Current `binaryName` from fireforge.json
|
|
68
|
+
* @param testPackagingCoverage - Coverage claim of the packaged test
|
|
69
|
+
* runtime this build produced (`'full'`, or the scoped request paths of
|
|
70
|
+
* a `test --build` invocation). Omitted → field left off the marker.
|
|
71
|
+
* @param previousBaseline - The baseline this write replaces, when the
|
|
72
|
+
* caller has it. A scoped (non-`'full'`) write carries its
|
|
73
|
+
* `staticComponentsBaseline` forward verbatim — `mach build faster`
|
|
74
|
+
* does not rebake `components.conf` registrations, so the last FULL
|
|
75
|
+
* build stays the honest anchor for the compiled StaticComponents table.
|
|
68
76
|
*/
|
|
69
|
-
export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
|
|
77
|
+
export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline) {
|
|
70
78
|
let engineHeadSha = '';
|
|
71
79
|
try {
|
|
72
80
|
engineHeadSha = await getHead(engineDir);
|
|
@@ -81,11 +89,20 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
|
|
|
81
89
|
}
|
|
82
90
|
}
|
|
83
91
|
const packageableFingerprints = await collectPackageableFingerprints(engineDir);
|
|
92
|
+
// A full-coverage write (and the legacy no-claim `fireforge build` shape)
|
|
93
|
+
// just recompiled the StaticComponents table, so it anchors the table to
|
|
94
|
+
// the current engine state. A scoped write did not — carry the previous
|
|
95
|
+
// anchor forward verbatim.
|
|
96
|
+
const staticComponentsBaseline = testPackagingCoverage === undefined || testPackagingCoverage === 'full'
|
|
97
|
+
? await collectStaticComponentsBaseline(engineDir, engineHeadSha)
|
|
98
|
+
: previousBaseline?.staticComponentsBaseline;
|
|
84
99
|
const baseline = {
|
|
85
100
|
engineHeadSha,
|
|
86
101
|
builtAt: new Date().toISOString(),
|
|
87
102
|
binaryName,
|
|
88
103
|
...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
|
|
104
|
+
...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
|
|
105
|
+
...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
|
|
89
106
|
};
|
|
90
107
|
await writeJson(getBuildBaselinePath(projectRoot), baseline);
|
|
91
108
|
}
|
|
@@ -103,6 +120,33 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
|
|
|
103
120
|
* back to the pre-0.16.0 "path-only" behavior on the next test run.
|
|
104
121
|
*/
|
|
105
122
|
async function collectPackageableFingerprints(engineDir) {
|
|
123
|
+
return collectDirtyFingerprints(engineDir, isPackageablePath, 'packageable fingerprint');
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Anchor for the compiled StaticComponents table: the engine HEAD SHA of
|
|
127
|
+
* this (full) build plus content fingerprints of the `components.conf`
|
|
128
|
+
* manifests dirty right now — the same dirty-path probe family as
|
|
129
|
+
* {@link collectPackageableFingerprints}, filtered to XPCOM manifests.
|
|
130
|
+
* Returns `undefined` on probe failure so a broken probe omits the field
|
|
131
|
+
* (the static-components stale check then degrades to "fresh") rather than
|
|
132
|
+
* anchoring to a garbage record.
|
|
133
|
+
*/
|
|
134
|
+
async function collectStaticComponentsBaseline(engineDir, engineHeadSha) {
|
|
135
|
+
const fingerprints = await collectDirtyFingerprints(engineDir, isXpcomManifestPath, 'static-components fingerprint');
|
|
136
|
+
if (fingerprints === undefined) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return { engineHeadSha, fingerprints };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Shared dirty-path fingerprint probe backing
|
|
143
|
+
* {@link collectPackageableFingerprints} and
|
|
144
|
+
* {@link collectStaticComponentsBaseline}: enumerates engine workdir
|
|
145
|
+
* modifications (tracked and untracked), keeps the paths `includePath`
|
|
146
|
+
* accepts, and hashes each. Returns `undefined` on any git failure so a
|
|
147
|
+
* broken probe never corrupts the on-disk baseline with `{}`.
|
|
148
|
+
*/
|
|
149
|
+
async function collectDirtyFingerprints(engineDir, includePath, contextLabel) {
|
|
106
150
|
try {
|
|
107
151
|
const dirtyPaths = new Set();
|
|
108
152
|
if (await hasChanges(engineDir)) {
|
|
@@ -116,12 +160,12 @@ async function collectPackageableFingerprints(engineDir) {
|
|
|
116
160
|
dirtyPaths.add(untracked);
|
|
117
161
|
}
|
|
118
162
|
}
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
163
|
+
const included = [...dirtyPaths].filter(includePath);
|
|
164
|
+
if (included.length === 0) {
|
|
121
165
|
return {};
|
|
122
166
|
}
|
|
123
167
|
const fingerprints = {};
|
|
124
|
-
for (const relPath of
|
|
168
|
+
for (const relPath of included) {
|
|
125
169
|
try {
|
|
126
170
|
const buffer = await readFile(join(engineDir, relPath));
|
|
127
171
|
fingerprints[relPath] = createHash('sha256').update(buffer).digest('hex');
|
|
@@ -130,13 +174,13 @@ async function collectPackageableFingerprints(engineDir) {
|
|
|
130
174
|
// A file that disappeared between status probe and hash is
|
|
131
175
|
// expected in concurrent scenarios; skip it without failing the
|
|
132
176
|
// whole baseline write.
|
|
133
|
-
verbose(`Build baseline: skipping
|
|
177
|
+
verbose(`Build baseline: skipping ${contextLabel} for ${relPath} — ${toError(fileError).message}`);
|
|
134
178
|
}
|
|
135
179
|
}
|
|
136
180
|
return fingerprints;
|
|
137
181
|
}
|
|
138
182
|
catch (error) {
|
|
139
|
-
verbose(`Build baseline:
|
|
183
|
+
verbose(`Build baseline: ${contextLabel} probe failed — ${toError(error).message}`);
|
|
140
184
|
return undefined;
|
|
141
185
|
}
|
|
142
186
|
}
|
|
@@ -11,7 +11,7 @@ export declare const STATE_FILENAME = "state.json";
|
|
|
11
11
|
/** Supported top-level fireforge.json keys backed by the current schema. */
|
|
12
12
|
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "test", "externalToolchains", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment"];
|
|
13
13
|
/** Supported config paths that can be read or set without --force. */
|
|
14
|
-
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
|
|
14
|
+
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "firefox.candidate", "build", "build.jobs", "test", "test.canaryPath", "test.canaryTimeoutSeconds", "externalToolchains", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
|
|
15
15
|
/**
|
|
16
16
|
* Gets all project paths based on a root directory.
|
|
17
17
|
* @param root - Root directory of the project
|
|
@@ -6,7 +6,7 @@ import { ConfigError } from '../errors/config.js';
|
|
|
6
6
|
import { verbose } from '../utils/logger.js';
|
|
7
7
|
import { parseObject } from '../utils/parse.js';
|
|
8
8
|
import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
|
|
9
|
-
import { isValidAppId, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
|
|
9
|
+
import { isValidAppId, isValidFirefoxCandidate, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
|
|
10
10
|
import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
|
|
11
11
|
import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
|
|
12
12
|
import { parseExternalToolchainsBlock, parseTestBlock } from './config-validate-test-toolchains.js';
|
|
@@ -81,10 +81,15 @@ function parseFirefoxBlock(rec) {
|
|
|
81
81
|
if (firefoxSha256 !== undefined && !/^[a-f0-9]{64}$/i.test(firefoxSha256)) {
|
|
82
82
|
throw new ConfigError('Config field "firefox.sha256" must be a 64-character SHA-256 hex digest');
|
|
83
83
|
}
|
|
84
|
+
const firefoxCandidate = optionalConfigString(firefoxRec, 'candidate', 'firefox.candidate');
|
|
85
|
+
if (firefoxCandidate !== undefined && !isValidFirefoxCandidate(firefoxCandidate)) {
|
|
86
|
+
throw new ConfigError('Config field "firefox.candidate" must look like "buildN" (e.g. "build2")');
|
|
87
|
+
}
|
|
84
88
|
return {
|
|
85
89
|
version: firefoxVersion,
|
|
86
90
|
product: firefoxProduct,
|
|
87
91
|
...(firefoxSha256 !== undefined ? { sha256: firefoxSha256.toLowerCase() } : {}),
|
|
92
|
+
...(firefoxCandidate !== undefined ? { candidate: firefoxCandidate } : {}),
|
|
88
93
|
};
|
|
89
94
|
}
|
|
90
95
|
/** Parses the optional `build` block (currently just `build.jobs`). */
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
export interface EngineSessionLockOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Wait up to this many seconds for the engine session lock instead of the
|
|
4
|
+
* legacy ~1 s fail-fast. Enables exponential poll backoff (100 ms → 2 s)
|
|
5
|
+
* and a holder-identified progress line roughly every 5 s. `undefined`
|
|
6
|
+
* preserves the historical fail-fast behavior exactly.
|
|
7
|
+
*/
|
|
8
|
+
waitLockSeconds?: number | undefined;
|
|
9
|
+
}
|
|
1
10
|
/**
|
|
2
11
|
*
|
|
3
12
|
*/
|
|
4
|
-
export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T
|
|
13
|
+
export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>, options?: EngineSessionLockOptions): Promise<T>;
|
|
5
14
|
/**
|
|
6
15
|
*
|
|
7
16
|
*/
|
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { GeneralError } from '../errors/base.js';
|
|
4
|
+
import { info } from '../utils/logger.js';
|
|
4
5
|
import { withFileLock } from './file-lock.js';
|
|
5
6
|
import { git } from './git-base.js';
|
|
6
7
|
const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
|
|
8
|
+
/**
|
|
9
|
+
* Formats the periodic operator-facing waiting line printed while `--wait-lock`
|
|
10
|
+
* polls a contended engine session lock. The holder identification comes from
|
|
11
|
+
* the lock's owner-metadata lines (`command=…`, `started=…`); an unreadable
|
|
12
|
+
* owner file degrades to the anonymous form.
|
|
13
|
+
*/
|
|
14
|
+
function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
|
|
15
|
+
const progress = `${String(Math.round(waitedMs / 1000))}s of up to ${String(Math.round(timeoutMs / 1000))}s`;
|
|
16
|
+
if (holder === undefined) {
|
|
17
|
+
return `Waiting for the FireForge engine lock — ${progress}.`;
|
|
18
|
+
}
|
|
19
|
+
const details = holder.metadata.length > 0 ? ` (${holder.metadata.join(', ')})` : '';
|
|
20
|
+
return `Waiting for the FireForge engine lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
|
|
21
|
+
}
|
|
7
22
|
/**
|
|
8
23
|
*
|
|
9
24
|
*/
|
|
10
|
-
export async function withEngineSessionLock(projectRoot, command, operation) {
|
|
25
|
+
export async function withEngineSessionLock(projectRoot, command, operation, options = {}) {
|
|
11
26
|
if (process.env['FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST'] !== '1' &&
|
|
12
27
|
(process.env['NODE_ENV'] === 'test' || process.env['VITEST'] !== undefined)) {
|
|
13
28
|
return operation();
|
|
14
29
|
}
|
|
30
|
+
const { waitLockSeconds } = options;
|
|
15
31
|
return withFileLock(join(projectRoot, ENGINE_SESSION_LOCK_PATH), operation, {
|
|
16
|
-
timeoutMs: 1000,
|
|
32
|
+
timeoutMs: waitLockSeconds !== undefined ? waitLockSeconds * 1000 : 1000,
|
|
33
|
+
...(waitLockSeconds !== undefined
|
|
34
|
+
? {
|
|
35
|
+
pollMs: 100,
|
|
36
|
+
pollMaxMs: 2000,
|
|
37
|
+
waitProgressMs: 5000,
|
|
38
|
+
onWaitProgress: ({ waitedMs, timeoutMs, holder }) => {
|
|
39
|
+
info(formatWaitProgressLine(waitedMs, timeoutMs, holder));
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
: {}),
|
|
17
43
|
ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`],
|
|
18
44
|
onTimeoutMessage: `Another FireForge engine-mutating command is already running. ` +
|
|
19
45
|
`Wait for it to finish, then retry \`${command}\`.`,
|
|
@@ -1,6 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot of the current lock holder handed to {@link FileLockOptions.onWaitProgress}.
|
|
3
|
+
* `metadata` carries the pid file's human-diagnostic lines (line 3 onward,
|
|
4
|
+
* e.g. `command=build`, `started=…`) written via
|
|
5
|
+
* {@link FileLockOptions.ownerMetadata}.
|
|
6
|
+
*/
|
|
7
|
+
export interface LockHolder {
|
|
8
|
+
pid: number;
|
|
9
|
+
alive: boolean;
|
|
10
|
+
metadata: string[];
|
|
11
|
+
}
|
|
1
12
|
export interface FileLockOptions {
|
|
2
13
|
timeoutMs?: number;
|
|
3
14
|
pollMs?: number;
|
|
15
|
+
/**
|
|
16
|
+
* When set, the poll interval doubles after every contended poll (starting
|
|
17
|
+
* from `pollMs`) up to this cap. When absent, polling stays at the fixed
|
|
18
|
+
* `pollMs` interval — zero behavior change for existing callers.
|
|
19
|
+
*/
|
|
20
|
+
pollMaxMs?: number;
|
|
4
21
|
staleMs?: number;
|
|
5
22
|
/**
|
|
6
23
|
* Interval between stale-lock probes while waiting (defaults to
|
|
@@ -8,6 +25,22 @@ export interface FileLockOptions {
|
|
|
8
25
|
* the mid-wait reaping path without multi-second sleeps.
|
|
9
26
|
*/
|
|
10
27
|
staleReprobeMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Interval between {@link onWaitProgress} callbacks while the lock is
|
|
30
|
+
* contended. Progress reporting is off unless both this and
|
|
31
|
+
* `onWaitProgress` are set.
|
|
32
|
+
*/
|
|
33
|
+
waitProgressMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Invoked roughly every `waitProgressMs` while waiting for a contended
|
|
36
|
+
* lock. `holder` is `undefined` when the owner PID file is missing or
|
|
37
|
+
* unreadable.
|
|
38
|
+
*/
|
|
39
|
+
onWaitProgress?: (progress: {
|
|
40
|
+
waitedMs: number;
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
holder: LockHolder | undefined;
|
|
43
|
+
}) => void;
|
|
11
44
|
onTimeoutMessage?: string;
|
|
12
45
|
onStaleLockMessage?: (ageMs: number) => string | undefined;
|
|
13
46
|
/** Extra owner-file lines for human diagnostics; ignored by lock mechanics. */
|
|
@@ -50,7 +50,8 @@ function isProcessAlive(pid) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* Reads the owner PID (line 1)
|
|
53
|
+
* Reads the owner PID (line 1), acquisition token (line 2), and diagnostic
|
|
54
|
+
* metadata (lines 3+, see {@link FileLockOptions.ownerMetadata}) from a lock
|
|
54
55
|
* directory's PID file. Returns `{ present: false }` when the PID file is
|
|
55
56
|
* missing or the PID does not parse as a finite integer (caller falls back
|
|
56
57
|
* to the age-only staleness heuristic).
|
|
@@ -62,14 +63,15 @@ function isProcessAlive(pid) {
|
|
|
62
63
|
async function readLockOwner(lockPath) {
|
|
63
64
|
try {
|
|
64
65
|
const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
|
|
65
|
-
const [pidLine, tokenLine] = pidContent.split('\n');
|
|
66
|
+
const [pidLine, tokenLine, ...metadataLines] = pidContent.split('\n');
|
|
66
67
|
const pid = parseInt((pidLine ?? '').trim(), 10);
|
|
67
68
|
if (Number.isFinite(pid)) {
|
|
69
|
+
const metadata = metadataLines.map((line) => line.trim()).filter((line) => line.length > 0);
|
|
68
70
|
const token = tokenLine?.trim();
|
|
69
71
|
if (token !== undefined && token.length > 0) {
|
|
70
|
-
return { present: true, pid, token };
|
|
72
|
+
return { present: true, pid, token, metadata };
|
|
71
73
|
}
|
|
72
|
-
return { present: true, pid };
|
|
74
|
+
return { present: true, pid, metadata };
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
catch {
|
|
@@ -77,6 +79,18 @@ async function readLockOwner(lockPath) {
|
|
|
77
79
|
}
|
|
78
80
|
return { present: false };
|
|
79
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Reads the contended lock's owner and reports wait progress to the caller's
|
|
84
|
+
* `onWaitProgress` callback. Failure to read the owner degrades to
|
|
85
|
+
* `holder: undefined` — progress reporting must never break the wait loop.
|
|
86
|
+
*/
|
|
87
|
+
async function reportWaitProgress(lockPath, waitedMs, timeoutMs, onWaitProgress) {
|
|
88
|
+
const owner = await readLockOwner(lockPath);
|
|
89
|
+
const holder = owner.present
|
|
90
|
+
? { pid: owner.pid, alive: isProcessAlive(owner.pid), metadata: owner.metadata }
|
|
91
|
+
: undefined;
|
|
92
|
+
onWaitProgress({ waitedMs, timeoutMs, holder });
|
|
93
|
+
}
|
|
80
94
|
/**
|
|
81
95
|
* Reaps a lock directory believed to be stale, safely against concurrent
|
|
82
96
|
* reapers and a concurrent legitimate re-acquisition.
|
|
@@ -216,8 +230,11 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
216
230
|
const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
|
|
217
231
|
const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
|
|
218
232
|
const staleReprobeMs = options.staleReprobeMs ?? STALE_REPROBE_INTERVAL_MS;
|
|
219
|
-
const
|
|
233
|
+
const startedAt = Date.now();
|
|
234
|
+
const deadline = startedAt + timeoutMs;
|
|
220
235
|
let lastStaleProbeAt;
|
|
236
|
+
let currentPollMs = pollMs;
|
|
237
|
+
let lastProgressAt = startedAt;
|
|
221
238
|
await ensureDir(dirname(lockPath));
|
|
222
239
|
for (;;) {
|
|
223
240
|
try {
|
|
@@ -246,7 +263,16 @@ export async function withFileLock(lockPath, operation, options = {}) {
|
|
|
246
263
|
: ' If no other FireForge process is running, remove the lock directory and retry.';
|
|
247
264
|
throw new Error(options.onTimeoutMessage ?? `Timed out waiting for file lock ${lockPath}.${holderHint}`, { cause: error });
|
|
248
265
|
}
|
|
249
|
-
|
|
266
|
+
if (options.onWaitProgress !== undefined &&
|
|
267
|
+
options.waitProgressMs !== undefined &&
|
|
268
|
+
Date.now() - lastProgressAt >= options.waitProgressMs) {
|
|
269
|
+
lastProgressAt = Date.now();
|
|
270
|
+
await reportWaitProgress(lockPath, Date.now() - startedAt, timeoutMs, options.onWaitProgress);
|
|
271
|
+
}
|
|
272
|
+
await sleep(currentPollMs);
|
|
273
|
+
if (options.pollMaxMs !== undefined) {
|
|
274
|
+
currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
|
|
275
|
+
}
|
|
250
276
|
}
|
|
251
277
|
}
|
|
252
278
|
// Stamp ownership (PID + per-acquisition token) into the lock directory so
|
|
@@ -39,6 +39,7 @@ export declare function validateArchiveMetadata(data: unknown): ArchiveMetadata;
|
|
|
39
39
|
* Resolves archive identity for URL generation and cache storage.
|
|
40
40
|
* @param version - Requested Firefox version
|
|
41
41
|
* @param product - Firefox product type
|
|
42
|
+
* @param candidate - Optional release-candidate build directory (e.g. "build2")
|
|
42
43
|
* @returns Resolved archive descriptor
|
|
43
44
|
*/
|
|
44
|
-
export declare function resolveArchive(version: string, product?: FirefoxProduct): ResolvedArchive;
|
|
45
|
+
export declare function resolveArchive(version: string, product?: FirefoxProduct, candidate?: string): ResolvedArchive;
|
|
@@ -4,15 +4,26 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { ConfigError } from '../errors/config.js';
|
|
6
6
|
import { parseObject } from '../utils/parse.js';
|
|
7
|
-
import { isValidFirefoxProduct } from '../utils/validation.js';
|
|
7
|
+
import { isValidFirefoxCandidate, isValidFirefoxProduct } from '../utils/validation.js';
|
|
8
8
|
/**
|
|
9
9
|
* Base URLs for Firefox source archives on archive.mozilla.org.
|
|
10
10
|
*/
|
|
11
|
-
const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox
|
|
12
|
-
const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition
|
|
11
|
+
const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox';
|
|
12
|
+
const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition';
|
|
13
13
|
function getArchiveBaseUrl(product) {
|
|
14
14
|
return product === 'firefox-devedition' ? DEVEDITION_ARCHIVE_BASE_URL : FIREFOX_ARCHIVE_BASE_URL;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Directory holding the release's artifacts: `releases/<version>/` for
|
|
18
|
+
* final releases, `candidates/<version>-candidates/<buildN>/` when a
|
|
19
|
+
* release-candidate build is requested for pre-release verification.
|
|
20
|
+
*/
|
|
21
|
+
function getArchiveDirUrl(product, archiveVersion, candidate) {
|
|
22
|
+
const base = getArchiveBaseUrl(product);
|
|
23
|
+
return candidate === undefined
|
|
24
|
+
? `${base}/releases/${archiveVersion}`
|
|
25
|
+
: `${base}/candidates/${archiveVersion}-candidates/${candidate}`;
|
|
26
|
+
}
|
|
16
27
|
/**
|
|
17
28
|
* Validates raw JSON data as ArchiveMetadata.
|
|
18
29
|
* @param data - Unknown data to validate
|
|
@@ -41,13 +52,20 @@ export function validateArchiveMetadata(data) {
|
|
|
41
52
|
* Resolves archive identity for URL generation and cache storage.
|
|
42
53
|
* @param version - Requested Firefox version
|
|
43
54
|
* @param product - Firefox product type
|
|
55
|
+
* @param candidate - Optional release-candidate build directory (e.g. "build2")
|
|
44
56
|
* @returns Resolved archive descriptor
|
|
45
57
|
*/
|
|
46
|
-
export function resolveArchive(version, product = 'firefox') {
|
|
58
|
+
export function resolveArchive(version, product = 'firefox', candidate) {
|
|
47
59
|
// Reject versions containing path traversal characters
|
|
48
60
|
if (version.includes('/') || version.includes('..') || version.includes('\\')) {
|
|
49
61
|
throw new ConfigError(`Invalid Firefox version "${version}": contains disallowed characters`, 'firefox.version');
|
|
50
62
|
}
|
|
63
|
+
// Defense-in-depth: config validation enforces this shape already, but a
|
|
64
|
+
// caller-supplied candidate becomes both a URL path segment and part of
|
|
65
|
+
// the cache filename, so re-validate here like the version check above.
|
|
66
|
+
if (candidate !== undefined && !isValidFirefoxCandidate(candidate)) {
|
|
67
|
+
throw new ConfigError(`Invalid Firefox candidate "${candidate}": must look like "buildN" (e.g. "build2")`, 'firefox.candidate');
|
|
68
|
+
}
|
|
51
69
|
// ESR status is determined solely by the product field. Config validation
|
|
52
70
|
// ensures product and version are consistent, so we never need to infer
|
|
53
71
|
// ESR from the version string independently.
|
|
@@ -55,18 +73,24 @@ export function resolveArchive(version, product = 'firefox') {
|
|
|
55
73
|
const isEsr = product === 'firefox-esr';
|
|
56
74
|
const archiveVersion = isEsr ? `${cleanVersion}esr` : cleanVersion;
|
|
57
75
|
const safeProduct = product.replace(/[^a-z0-9-]/gi, '-');
|
|
76
|
+
const dirUrl = getArchiveDirUrl(product, archiveVersion, candidate);
|
|
77
|
+
// Candidate artifacts get a `-<candidate>` filename suffix so a build2
|
|
78
|
+
// archive can never collide with the final release artifact (same
|
|
79
|
+
// archiveVersion, potentially different bytes) in .fireforge/cache.
|
|
80
|
+
const candidateSuffix = candidate === undefined ? '' : `-${candidate}`;
|
|
81
|
+
const filename = `firefox-${safeProduct}-${archiveVersion}${candidateSuffix}.source.tar.xz`;
|
|
58
82
|
return {
|
|
59
83
|
requestedVersion: version,
|
|
60
84
|
product,
|
|
61
85
|
archiveVersion,
|
|
62
|
-
url: `${
|
|
63
|
-
filename
|
|
64
|
-
metadataFilename:
|
|
86
|
+
url: `${dirUrl}/source/firefox-${archiveVersion}.source.tar.xz`,
|
|
87
|
+
filename,
|
|
88
|
+
metadataFilename: `${filename}.json`,
|
|
65
89
|
// Mozilla publishes one SHA256SUMS per release directory listing every
|
|
66
90
|
// artifact by its release-relative path. Downloads verify against it by
|
|
67
91
|
// default (fail closed on mismatch), so a compromised CDN response
|
|
68
92
|
// cannot silently become the trusted git baseline.
|
|
69
|
-
checksumsUrl: `${
|
|
93
|
+
checksumsUrl: `${dirUrl}/SHA256SUMS`,
|
|
70
94
|
pathInChecksums: `source/firefox-${archiveVersion}.source.tar.xz`,
|
|
71
95
|
};
|
|
72
96
|
}
|
|
@@ -13,16 +13,18 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
|
|
|
13
13
|
* Gets the download URL for a Firefox source tarball.
|
|
14
14
|
* @param version - Firefox version (e.g., "140.9.0esr")
|
|
15
15
|
* @param product - Firefox product type
|
|
16
|
+
* @param candidate - Optional release-candidate build directory (e.g. "build2")
|
|
16
17
|
* @returns Full URL to the source tarball
|
|
17
18
|
*/
|
|
18
|
-
export declare function getDownloadUrl(version: string, product?: FirefoxProduct): string;
|
|
19
|
+
export declare function getDownloadUrl(version: string, product?: FirefoxProduct, candidate?: string): string;
|
|
19
20
|
/**
|
|
20
21
|
* Gets the filename for a Firefox source tarball.
|
|
21
22
|
* @param version - Firefox version
|
|
22
23
|
* @param product - Firefox product type
|
|
24
|
+
* @param candidate - Optional release-candidate build directory (e.g. "build2")
|
|
23
25
|
* @returns Tarball filename
|
|
24
26
|
*/
|
|
25
|
-
export declare function getTarballFilename(version: string, product?: FirefoxProduct): string;
|
|
27
|
+
export declare function getTarballFilename(version: string, product?: FirefoxProduct, candidate?: string): string;
|
|
26
28
|
/**
|
|
27
29
|
* Lifecycle phase reported by {@link downloadFirefoxSource}. The download
|
|
28
30
|
* CLI command uses this to swap spinners between the bytes-on-the-wire
|
|
@@ -57,5 +59,8 @@ export declare function sweepOrphanedEngineWorkDirs(destDir: string): Promise<st
|
|
|
57
59
|
* @param onPhase - Optional callback fired when the function transitions
|
|
58
60
|
* between phases (`'download'` → `'extract'`). Fires exactly once per
|
|
59
61
|
* phase even if the cached archive path skips the wire entirely.
|
|
62
|
+
* @param candidate - Optional release-candidate build directory (e.g.
|
|
63
|
+
* "build2") resolving the archive under `candidates/` instead of
|
|
64
|
+
* `releases/`.
|
|
60
65
|
*/
|
|
61
|
-
export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback): Promise<void>;
|
|
66
|
+
export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback, candidate?: string): Promise<void>;
|
package/dist/src/core/firefox.js
CHANGED
|
@@ -19,19 +19,21 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
|
|
|
19
19
|
* Gets the download URL for a Firefox source tarball.
|
|
20
20
|
* @param version - Firefox version (e.g., "140.9.0esr")
|
|
21
21
|
* @param product - Firefox product type
|
|
22
|
+
* @param candidate - Optional release-candidate build directory (e.g. "build2")
|
|
22
23
|
* @returns Full URL to the source tarball
|
|
23
24
|
*/
|
|
24
|
-
export function getDownloadUrl(version, product = 'firefox') {
|
|
25
|
-
return resolveArchive(version, product).url;
|
|
25
|
+
export function getDownloadUrl(version, product = 'firefox', candidate) {
|
|
26
|
+
return resolveArchive(version, product, candidate).url;
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
29
|
* Gets the filename for a Firefox source tarball.
|
|
29
30
|
* @param version - Firefox version
|
|
30
31
|
* @param product - Firefox product type
|
|
32
|
+
* @param candidate - Optional release-candidate build directory (e.g. "build2")
|
|
31
33
|
* @returns Tarball filename
|
|
32
34
|
*/
|
|
33
|
-
export function getTarballFilename(version, product = 'firefox') {
|
|
34
|
-
return resolveArchive(version, product).filename;
|
|
35
|
+
export function getTarballFilename(version, product = 'firefox', candidate) {
|
|
36
|
+
return resolveArchive(version, product, candidate).filename;
|
|
35
37
|
}
|
|
36
38
|
/**
|
|
37
39
|
* Classifies an extraction failure as archive corruption (invalidate the
|
|
@@ -100,9 +102,12 @@ export async function sweepOrphanedEngineWorkDirs(destDir) {
|
|
|
100
102
|
* @param onPhase - Optional callback fired when the function transitions
|
|
101
103
|
* between phases (`'download'` → `'extract'`). Fires exactly once per
|
|
102
104
|
* phase even if the cached archive path skips the wire entirely.
|
|
105
|
+
* @param candidate - Optional release-candidate build directory (e.g.
|
|
106
|
+
* "build2") resolving the archive under `candidates/` instead of
|
|
107
|
+
* `releases/`.
|
|
103
108
|
*/
|
|
104
|
-
export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress) {
|
|
105
|
-
const archive = resolveArchive(version, product);
|
|
109
|
+
export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress, candidate) {
|
|
110
|
+
const archive = resolveArchive(version, product, candidate);
|
|
106
111
|
const tarballPath = join(cacheDir, archive.filename);
|
|
107
112
|
// Ensure cache directory exists
|
|
108
113
|
await ensureDir(cacheDir);
|
|
@@ -35,6 +35,13 @@ export function parseCustomConfig(data, name) {
|
|
|
35
35
|
if (!isBoolean(data['localized'])) {
|
|
36
36
|
throw new FurnaceError(`Furnace config: custom "${name}.localized" must be a boolean`);
|
|
37
37
|
}
|
|
38
|
+
if (data['kind'] !== undefined && data['kind'] !== 'element' && data['kind'] !== 'library') {
|
|
39
|
+
throw new FurnaceError(`Furnace config: custom "${name}.kind" must be "element" or "library" when set`);
|
|
40
|
+
}
|
|
41
|
+
if (data['kind'] === 'library' && data['register']) {
|
|
42
|
+
throw new FurnaceError(`Furnace config: custom "${name}" has kind "library" but register: true — ` +
|
|
43
|
+
'a library exports no custom element; set register: false');
|
|
44
|
+
}
|
|
38
45
|
if (data['composes'] !== undefined) {
|
|
39
46
|
parseStringArray(data['composes'], `${name}.composes`);
|
|
40
47
|
}
|
|
@@ -58,6 +65,9 @@ export function parseCustomConfig(data, name) {
|
|
|
58
65
|
? { composes: parseStringArray(data['composes'], `${name}.composes`) }
|
|
59
66
|
: {}),
|
|
60
67
|
...(data['keyboardCovered'] === true ? { keyboardCovered: true } : {}),
|
|
68
|
+
// Explicit `kind: "element"` is the default; normalize it away so the
|
|
69
|
+
// stored config carries the field only when it changes behavior.
|
|
70
|
+
...(data['kind'] === 'library' ? { kind: 'library' } : {}),
|
|
61
71
|
...(sharedFtl !== undefined ? { sharedFtl } : {}),
|
|
62
72
|
};
|
|
63
73
|
}
|