@hominis/fireforge 0.37.0 → 0.39.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 +33 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +13 -8
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/discard.js +75 -50
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +6 -2
- package/dist/src/commands/export-flow.js +17 -11
- package/dist/src/commands/export-placement-gate.js +4 -6
- 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-shared.js +17 -2
- package/dist/src/commands/export.js +5 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/import.js +5 -0
- package/dist/src/commands/lint-per-patch.js +15 -5
- package/dist/src/commands/lint.js +1 -1
- package/dist/src/commands/patch/lint-ignore.js +20 -1
- 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-into.d.ts +47 -0
- package/dist/src/commands/patch/move-files-into.js +245 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +74 -15
- package/dist/src/commands/patch/rename.js +4 -1
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- 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 +17 -6
- package/dist/src/commands/reset.js +5 -0
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/status-output.d.ts +9 -0
- package/dist/src/commands/status-output.js +33 -1
- package/dist/src/commands/status.js +17 -3
- package/dist/src/commands/test-diagnose.d.ts +1 -1
- package/dist/src/commands/test-diagnose.js +9 -3
- package/dist/src/commands/test-modes.d.ts +21 -0
- package/dist/src/commands/test-modes.js +42 -0
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +8 -2
- 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 +52 -81
- 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 +41 -0
- package/dist/src/core/build-baseline.d.ts +6 -1
- package/dist/src/core/build-baseline.js +48 -7
- package/dist/src/core/build-prepare.d.ts +9 -0
- package/dist/src/core/build-prepare.js +24 -0
- 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 +14 -1
- package/dist/src/core/discard-baseline.d.ts +60 -0
- package/dist/src/core/discard-baseline.js +232 -0
- 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-validate-helpers.js +28 -1
- package/dist/src/core/license-headers.d.ts +24 -0
- package/dist/src/core/license-headers.js +103 -1
- package/dist/src/core/patch-export.d.ts +10 -1
- package/dist/src/core/patch-export.js +12 -2
- package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
- package/dist/src/core/patch-lint-checkjs.js +30 -14
- package/dist/src/core/patch-lint-cross.d.ts +9 -1
- package/dist/src/core/patch-lint-cross.js +69 -9
- package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
- package/dist/src/core/patch-lint-mozbuild.js +104 -0
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +29 -8
- package/dist/src/core/patch-policy.d.ts +14 -1
- package/dist/src/core/patch-policy.js +13 -2
- 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 +10 -0
- package/dist/src/core/test-harness-crash.js +16 -0
- package/dist/src/core/test-stale-check.d.ts +56 -1
- package/dist/src/core/test-stale-check.js +114 -4
- package/dist/src/core/token-manager.js +39 -2
- package/dist/src/core/token-variant.d.ts +7 -0
- package/dist/src/core/token-variant.js +9 -0
- package/dist/src/core/typecheck-shim.d.ts +13 -0
- package/dist/src/core/typecheck-shim.js +17 -2
- package/dist/src/core/typecheck.js +14 -3
- package/dist/src/types/commands/options.d.ts +69 -3
- package/dist/src/types/config.d.ts +22 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
|
|
4
|
+
*
|
|
5
|
+
* The engine convention is: HEAD = pristine upstream Firefox, and the
|
|
6
|
+
* applied patch queue lives as UNCOMMITTED worktree changes on top of it
|
|
7
|
+
* (`git apply` without `--index` → patch-edited tracked files are unstaged
|
|
8
|
+
* modifications, patch-created files are untracked). The pre-0.39.0
|
|
9
|
+
* `discard` was purely git-mechanical — it restored to HEAD and deleted
|
|
10
|
+
* untracked files, silently reverting patch-backed files PAST their owning
|
|
11
|
+
* patch and deleting patch-created files outright.
|
|
12
|
+
*
|
|
13
|
+
* This module classifies each status entry against the patch manifest's
|
|
14
|
+
* ownership claims and plans the correct restore target: the PATCH-APPLIED
|
|
15
|
+
* baseline (HEAD content folded through every affecting patch in order) for
|
|
16
|
+
* claimed files, and the legacy git mechanics for unmanaged ones.
|
|
17
|
+
*/
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { GeneralError } from '../errors/base.js';
|
|
20
|
+
import { toError } from '../utils/errors.js';
|
|
21
|
+
import { readText, removeFile, writeText } from '../utils/fs.js';
|
|
22
|
+
import { discardStatusEntry, fileExistsInHead, listTrackedInHead, restoreTrackedPath, unstageFiles, } from './git-file-ops.js';
|
|
23
|
+
import { createPatchedContentComputer } from './patch-apply.js';
|
|
24
|
+
import { loadPatchesManifestState } from './patch-manifest-io.js';
|
|
25
|
+
import { parseDiffSections } from './patch-parse.js';
|
|
26
|
+
import { buildPatchClaims } from './status-classify.js';
|
|
27
|
+
/** Builds an all-unmanaged plan set — the `--to-upstream` (legacy) shape. */
|
|
28
|
+
export function planUpstreamDiscards(entries) {
|
|
29
|
+
return entries.map((entry) => ({ entry, kind: 'unmanaged', owners: [], conflicted: false }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Plans the restore target for each status entry. Entries claimed by the
|
|
33
|
+
* patch manifest restore to the patch-applied baseline; unclaimed entries
|
|
34
|
+
* keep the legacy git mechanics. A missing patches dir or empty manifest
|
|
35
|
+
* degrades every plan to `unmanaged` (identical to pre-0.39.0 behavior).
|
|
36
|
+
*
|
|
37
|
+
* A corrupt manifest or an unreconstructible patch baseline REFUSES with a
|
|
38
|
+
* GeneralError naming `--to-upstream` — silently falling back to pristine
|
|
39
|
+
* HEAD is exactly the data-loss path this module exists to close.
|
|
40
|
+
*/
|
|
41
|
+
export async function planDiscardBaselines(patchesDir, engineDir, entries) {
|
|
42
|
+
const state = await loadPatchesManifestState(patchesDir);
|
|
43
|
+
if (state.exists && state.parseError) {
|
|
44
|
+
throw new GeneralError('patches/patches.json is unreadable — refusing to discard while patch ownership cannot be ' +
|
|
45
|
+
'determined. Fix the manifest, or pass --to-upstream to explicitly revert to pristine ' +
|
|
46
|
+
'upstream (HEAD).');
|
|
47
|
+
}
|
|
48
|
+
const claims = state.manifest
|
|
49
|
+
? buildPatchClaims(state.manifest.patches)
|
|
50
|
+
: new Map();
|
|
51
|
+
const plans = [];
|
|
52
|
+
const claimedEntries = entries.filter((entry) => claims.has(entry.file) || (entry.originalPath !== undefined && claims.has(entry.originalPath)));
|
|
53
|
+
if (claimedEntries.length === 0) {
|
|
54
|
+
return planUpstreamDiscards(entries);
|
|
55
|
+
}
|
|
56
|
+
const computePatched = await createPatchedContentComputer(patchesDir, engineDir);
|
|
57
|
+
const claimedFiles = [
|
|
58
|
+
...new Set(claimedEntries.flatMap((entry) => entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file])),
|
|
59
|
+
];
|
|
60
|
+
const trackedInHead = await listTrackedInHead(engineDir, claimedFiles);
|
|
61
|
+
const computeExpected = async (path) => {
|
|
62
|
+
try {
|
|
63
|
+
return await computePatched(path);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new GeneralError(`Cannot reconstruct the patch baseline for ${path} (${toError(error).message}). ` +
|
|
67
|
+
'Fix the owning patch, or pass --to-upstream to explicitly revert to pristine ' +
|
|
68
|
+
'upstream (HEAD).');
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
// `computePatchedContent` folds a deletion into an empty string (an
|
|
72
|
+
// apply-to-content limitation), which is indistinguishable from a
|
|
73
|
+
// legitimately empty file. Consult the owning patches' diff sections
|
|
74
|
+
// directly: the LAST section (highest-ordered owner) naming the path
|
|
75
|
+
// decides whether the baseline is "absent".
|
|
76
|
+
const sectionCache = new Map();
|
|
77
|
+
const isDeletedAtBaseline = async (path, owners) => {
|
|
78
|
+
for (let i = owners.length - 1; i >= 0; i--) {
|
|
79
|
+
const owner = owners[i];
|
|
80
|
+
if (owner === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
let sections = sectionCache.get(owner);
|
|
83
|
+
if (!sections) {
|
|
84
|
+
try {
|
|
85
|
+
sections = parseDiffSections(await readText(join(patchesDir, owner)));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
sections = [];
|
|
89
|
+
}
|
|
90
|
+
sectionCache.set(owner, sections);
|
|
91
|
+
}
|
|
92
|
+
for (const section of sections) {
|
|
93
|
+
if (section.targetPath === path || section.sourcePath === path) {
|
|
94
|
+
return section.isDeletedFile;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const owners = [
|
|
102
|
+
...new Set([
|
|
103
|
+
...(claims.get(entry.file) ?? []),
|
|
104
|
+
...(entry.originalPath !== undefined ? (claims.get(entry.originalPath) ?? []) : []),
|
|
105
|
+
]),
|
|
106
|
+
];
|
|
107
|
+
if (owners.length === 0) {
|
|
108
|
+
plans.push({ entry, kind: 'unmanaged', owners, conflicted: false });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
let expectedContent = claims.has(entry.file) ? await computeExpected(entry.file) : null;
|
|
112
|
+
if (expectedContent !== null &&
|
|
113
|
+
(await isDeletedAtBaseline(entry.file, claims.get(entry.file) ?? []))) {
|
|
114
|
+
expectedContent = null;
|
|
115
|
+
}
|
|
116
|
+
const expectedOriginalContent = entry.originalPath !== undefined && claims.has(entry.originalPath)
|
|
117
|
+
? await computeExpected(entry.originalPath)
|
|
118
|
+
: undefined;
|
|
119
|
+
const kind = expectedContent === null
|
|
120
|
+
? 'patch-deleted'
|
|
121
|
+
: trackedInHead.has(entry.file)
|
|
122
|
+
? 'patch-backed'
|
|
123
|
+
: 'patch-created';
|
|
124
|
+
plans.push({
|
|
125
|
+
entry,
|
|
126
|
+
kind,
|
|
127
|
+
owners,
|
|
128
|
+
conflicted: owners.length > 1,
|
|
129
|
+
expectedContent,
|
|
130
|
+
...(expectedOriginalContent !== undefined ? { expectedOriginalContent } : {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return plans;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Applies one restore plan. Patch-claimed paths end up exactly as `import`
|
|
137
|
+
* leaves them: patched content as an unstaged worktree state (tracked files
|
|
138
|
+
* read ` M`, created files `??`), nothing staged. Unmanaged plans delegate
|
|
139
|
+
* to the legacy {@link discardStatusEntry} mechanics.
|
|
140
|
+
*/
|
|
141
|
+
export async function applyDiscardBaseline(engineDir, plan) {
|
|
142
|
+
if (plan.kind === 'unmanaged') {
|
|
143
|
+
await discardStatusEntry(engineDir, plan.entry);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const { entry } = plan;
|
|
147
|
+
// Drop any staged edits/adds/renames first so the rewritten content is a
|
|
148
|
+
// plain worktree state (harmless when nothing is staged; skipped for
|
|
149
|
+
// purely-untracked entries which have no index footprint).
|
|
150
|
+
if (!entry.isUntracked) {
|
|
151
|
+
const indexPaths = entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file];
|
|
152
|
+
await unstageFiles(engineDir, indexPaths);
|
|
153
|
+
}
|
|
154
|
+
if (plan.expectedContent === null || plan.expectedContent === undefined) {
|
|
155
|
+
await removeFile(join(engineDir, entry.file));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
await writeText(join(engineDir, entry.file), plan.expectedContent);
|
|
159
|
+
}
|
|
160
|
+
// A rename/copy's original side restores to ITS baseline too: the patch
|
|
161
|
+
// baseline when claimed, else the legacy HEAD restore (matching what
|
|
162
|
+
// discardStatusEntry does for that side).
|
|
163
|
+
if (entry.originalPath !== undefined) {
|
|
164
|
+
if (plan.expectedOriginalContent !== undefined) {
|
|
165
|
+
if (plan.expectedOriginalContent === null) {
|
|
166
|
+
await removeFile(join(engineDir, entry.originalPath));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
await writeText(join(engineDir, entry.originalPath), plan.expectedOriginalContent);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else if (await fileExistsInHead(engineDir, entry.originalPath)) {
|
|
173
|
+
await restoreTrackedPath(engineDir, entry.originalPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** One-line annotation for dry-run listings and confirmation summaries. */
|
|
178
|
+
export function describeDiscardBaseline(plan) {
|
|
179
|
+
const owners = plan.owners.join(' + ');
|
|
180
|
+
switch (plan.kind) {
|
|
181
|
+
case 'unmanaged':
|
|
182
|
+
return plan.entry.isUntracked ? 'unmanaged — delete' : 'unmanaged — revert to upstream';
|
|
183
|
+
case 'patch-backed':
|
|
184
|
+
return `patch baseline: ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
|
|
185
|
+
case 'patch-created':
|
|
186
|
+
return `re-materialize from ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
|
|
187
|
+
case 'patch-deleted':
|
|
188
|
+
return `delete — ${owners} removes it`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Single-file outro naming the baseline that was restored. */
|
|
192
|
+
export function describeDiscardOutcome(plan, toUpstream) {
|
|
193
|
+
if (toUpstream)
|
|
194
|
+
return 'File restored to pristine upstream (HEAD)';
|
|
195
|
+
const owners = plan.owners.join(' + ');
|
|
196
|
+
switch (plan.kind) {
|
|
197
|
+
case 'unmanaged':
|
|
198
|
+
return 'File restored to original state';
|
|
199
|
+
case 'patch-backed':
|
|
200
|
+
return `File restored to patch baseline (${owners})`;
|
|
201
|
+
case 'patch-created':
|
|
202
|
+
return `File re-materialized from patch baseline (${owners})`;
|
|
203
|
+
case 'patch-deleted':
|
|
204
|
+
return `File removed to match patch baseline (${owners} deletes it)`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Batch outro summarising how many files restored to which baseline. */
|
|
208
|
+
export function summarizeDiscardBaselines(plans, succeeded) {
|
|
209
|
+
const patchBacked = plans.filter((p) => p.kind === 'patch-backed').length;
|
|
210
|
+
const created = plans.filter((p) => p.kind === 'patch-created').length;
|
|
211
|
+
const deleted = plans.filter((p) => p.kind === 'patch-deleted').length;
|
|
212
|
+
const unmanaged = plans.filter((p) => p.kind === 'unmanaged').length;
|
|
213
|
+
if (patchBacked + created + deleted === 0) {
|
|
214
|
+
return `${String(succeeded)} file(s) restored to upstream state`;
|
|
215
|
+
}
|
|
216
|
+
const parts = [];
|
|
217
|
+
if (patchBacked > 0)
|
|
218
|
+
parts.push(`${String(patchBacked)} to patch baseline`);
|
|
219
|
+
if (created > 0)
|
|
220
|
+
parts.push(`${String(created)} re-materialized`);
|
|
221
|
+
if (deleted > 0)
|
|
222
|
+
parts.push(`${String(deleted)} removed per patch baseline`);
|
|
223
|
+
if (unmanaged > 0)
|
|
224
|
+
parts.push(`${String(unmanaged)} to upstream state`);
|
|
225
|
+
return `${String(succeeded)} file(s) restored: ${parts.join(', ')}`;
|
|
226
|
+
}
|
|
227
|
+
/** Warn text for a multi-owner (conflicted) restore. */
|
|
228
|
+
export function describeConflictWarning(plan) {
|
|
229
|
+
return (`${plan.entry.file} is claimed by ${String(plan.owners.length)} patches ` +
|
|
230
|
+
`(${plan.owners.join(', ')}). Restored the cumulative baseline; run ` +
|
|
231
|
+
'"fireforge status --ownership" to resolve ownership.');
|
|
232
|
+
}
|
|
@@ -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);
|
|
@@ -27,10 +27,33 @@ export function hasPositiveTabindex(content) {
|
|
|
27
27
|
return value !== undefined && parseInt(value, 10) > 0;
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Collects `[start, end)` spans of `<label>…</label>` elements whose content
|
|
32
|
+
* includes actual label text. Labels cannot nest per HTML, so the non-greedy
|
|
33
|
+
* inner match is sound. A span qualifies only when the inner content minus
|
|
34
|
+
* tags still contains non-whitespace — an empty or tag-only wrapper provides
|
|
35
|
+
* no accessible name. A `${…}` Lit binding counts as label text: dynamic
|
|
36
|
+
* (usually localized) text is a legitimate accessible name.
|
|
37
|
+
*/
|
|
38
|
+
function collectLabelledSpans(content) {
|
|
39
|
+
const spans = [];
|
|
40
|
+
const labelPattern = /<label\b[^>]*>([\s\S]*?)<\/label>/gi;
|
|
41
|
+
let labelMatch;
|
|
42
|
+
while ((labelMatch = labelPattern.exec(content)) !== null) {
|
|
43
|
+
const inner = labelMatch[1] ?? '';
|
|
44
|
+
if (/\S/.test(inner.replace(/<[^>]*>/g, ' '))) {
|
|
45
|
+
spans.push({ start: labelMatch.index, end: labelMatch.index + labelMatch[0].length });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return spans;
|
|
49
|
+
}
|
|
30
50
|
/** Detects form inputs without associated labels. */
|
|
31
51
|
export function hasUnlabelledFormInput(content) {
|
|
32
52
|
// Look for <input> or <select> or <textarea> without aria-label, aria-labelledby, or id
|
|
33
|
-
// (id implies an external <label for="..."> could exist)
|
|
53
|
+
// (id implies an external <label for="..."> could exist). A control wrapped
|
|
54
|
+
// in a <label> that carries actual text is implicitly associated and is
|
|
55
|
+
// exempt as well.
|
|
56
|
+
const labelledSpans = collectLabelledSpans(content);
|
|
34
57
|
const inputPattern = /<(input|select|textarea)\b([^>]*)>/gi;
|
|
35
58
|
let inputMatch;
|
|
36
59
|
while ((inputMatch = inputPattern.exec(content)) !== null) {
|
|
@@ -41,6 +64,10 @@ export function hasUnlabelledFormInput(content) {
|
|
|
41
64
|
/type\s*=\s*["']hidden["']/i.test(attrs)) {
|
|
42
65
|
continue;
|
|
43
66
|
}
|
|
67
|
+
const index = inputMatch.index;
|
|
68
|
+
if (labelledSpans.some((span) => index >= span.start && index < span.end)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
44
71
|
return true;
|
|
45
72
|
}
|
|
46
73
|
return false;
|