@hominis/fireforge 0.24.0 → 0.26.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 +11 -0
- package/README.md +1 -1
- package/dist/src/commands/re-export-scan.d.ts +35 -0
- package/dist/src/commands/re-export-scan.js +139 -0
- package/dist/src/commands/re-export.js +55 -124
- package/dist/src/core/branding.js +72 -25
- package/dist/src/types/commands/options.d.ts +6 -0
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.26.0
|
|
4
|
+
|
|
5
|
+
- Added targeted `re-export --scan --scan-file <path>` for reviewed single-patch new-file assignment without broad sibling collection.
|
|
6
|
+
- Added a FireForge-owned worktree whitespace gate that excludes generated `patches/*.patch` diff syntax from repository whitespace checks.
|
|
7
|
+
- Kept generated patch context lines unchanged while making release checks use the FireForge whitespace gate.
|
|
8
|
+
|
|
9
|
+
## 0.25.0
|
|
10
|
+
|
|
11
|
+
- Kept `MOZ_APP_VENDOR` in `browser/moz.configure` for Firefox ESR 140 project-flag trees instead of generated branding `configure.sh`.
|
|
12
|
+
- Added a regression for stale xpcshell install symlink repair under shared `_tests/testing/mochitest/` harness paths.
|
|
13
|
+
|
|
3
14
|
## 0.24.0
|
|
4
15
|
|
|
5
16
|
- Moved branding vendor identity into generated branding configure scripts and made `browser/moz.configure` vendor patching optional.
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
|
|
|
20
20
|
|
|
21
21
|
## Requirements
|
|
22
22
|
|
|
23
|
-
- Node.js
|
|
23
|
+
- Node.js 22.22.1+
|
|
24
24
|
- Python 3
|
|
25
25
|
- Git
|
|
26
26
|
- The normal Firefox platform build tools: Xcode command line tools on macOS, `build-essential`-style packages on Linux, Visual Studio Build Tools on Windows (never tested on Windows tbh)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { PatchesManifest } from '../types/commands/index.js';
|
|
2
|
+
export interface ScanResult {
|
|
3
|
+
updated: string[];
|
|
4
|
+
added: string[];
|
|
5
|
+
removed: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
|
|
8
|
+
export declare function normalizeScanFiles(scanFiles: readonly string[] | undefined): string[] | undefined;
|
|
9
|
+
/** Scans either broad sibling directories or explicit `--scan-file` paths for re-export. */
|
|
10
|
+
export declare function scanPatchFilesForReExport(args: {
|
|
11
|
+
currentFilesAffected: string[];
|
|
12
|
+
engineDir: string;
|
|
13
|
+
manifest: PatchesManifest;
|
|
14
|
+
patchFilename: string;
|
|
15
|
+
isDryRun: boolean;
|
|
16
|
+
scanFiles?: readonly string[];
|
|
17
|
+
}): Promise<ScanResult>;
|
|
18
|
+
/**
|
|
19
|
+
* Confirms broad directory-scan additions before a mutating re-export writes
|
|
20
|
+
* them into patch ownership.
|
|
21
|
+
*/
|
|
22
|
+
export declare function confirmBroadScanAdditions(args: {
|
|
23
|
+
patchFilename: string;
|
|
24
|
+
added: readonly string[];
|
|
25
|
+
isDryRun: boolean;
|
|
26
|
+
yes: boolean;
|
|
27
|
+
isInteractive: boolean;
|
|
28
|
+
}): Promise<boolean>;
|
|
29
|
+
/** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
|
|
30
|
+
export declare function assertScanFileAdditionsHaveDiffHunks(args: {
|
|
31
|
+
diffContent: string;
|
|
32
|
+
patchFilename: string;
|
|
33
|
+
previousFilesAffected: readonly string[];
|
|
34
|
+
scanFiles: readonly string[] | undefined;
|
|
35
|
+
}): void;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { confirm } from '@clack/prompts';
|
|
4
|
+
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
5
|
+
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
6
|
+
import { getClaimedFiles } from '../core/patch-manifest.js';
|
|
7
|
+
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
8
|
+
import { pathExists } from '../utils/fs.js';
|
|
9
|
+
import { cancel, info, isCancel, warn } from '../utils/logger.js';
|
|
10
|
+
import { isContainedRelativePath, normalizePathSlashes, stripEnginePrefix, } from '../utils/paths.js';
|
|
11
|
+
const SCAN_ADD_COUNT_THRESHOLD = 3;
|
|
12
|
+
const SCAN_DIR_COUNT_THRESHOLD = 2;
|
|
13
|
+
/** Normalizes repeatable `--scan-file` inputs into safe engine-relative paths. */
|
|
14
|
+
export function normalizeScanFiles(scanFiles) {
|
|
15
|
+
const normalized = [...new Set(scanFiles ?? [])]
|
|
16
|
+
.map((file) => normalizeEngineRelativeInput(file, '--scan-file'))
|
|
17
|
+
.sort();
|
|
18
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
19
|
+
}
|
|
20
|
+
function normalizeEngineRelativeInput(rawPath, flagName) {
|
|
21
|
+
const normalized = normalizePathSlashes(stripEnginePrefix(rawPath).trim());
|
|
22
|
+
if (normalized.length === 0) {
|
|
23
|
+
throw new InvalidArgumentError(`${flagName} requires a non-empty engine-relative path.`, flagName);
|
|
24
|
+
}
|
|
25
|
+
if (!isContainedRelativePath(normalized)) {
|
|
26
|
+
throw new InvalidArgumentError(`${flagName} path must stay within engine/: ${rawPath}`, flagName);
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
/** Scans either broad sibling directories or explicit `--scan-file` paths for re-export. */
|
|
31
|
+
export async function scanPatchFilesForReExport(args) {
|
|
32
|
+
const { scanFiles } = args;
|
|
33
|
+
if (scanFiles !== undefined) {
|
|
34
|
+
return scanPatchFilesTargeted({ ...args, scanFiles });
|
|
35
|
+
}
|
|
36
|
+
return scanPatchFiles(args);
|
|
37
|
+
}
|
|
38
|
+
async function scanPatchFiles(args) {
|
|
39
|
+
const { currentFilesAffected, engineDir, manifest, patchFilename, isDryRun } = args;
|
|
40
|
+
const parentDirs = [...new Set(currentFilesAffected.map((f) => dirname(f)))];
|
|
41
|
+
const claimedByOthers = getClaimedFiles(manifest, patchFilename);
|
|
42
|
+
const discoveredFiles = new Set();
|
|
43
|
+
for (const dir of parentDirs) {
|
|
44
|
+
const modifiedFiles = await getModifiedFilesInDir(engineDir, dir);
|
|
45
|
+
const untrackedFiles = await getUntrackedFilesInDir(engineDir, dir);
|
|
46
|
+
for (const f of [...modifiedFiles, ...untrackedFiles])
|
|
47
|
+
discoveredFiles.add(f);
|
|
48
|
+
}
|
|
49
|
+
const currentSet = new Set(currentFilesAffected);
|
|
50
|
+
const added = [...discoveredFiles]
|
|
51
|
+
.filter((f) => !currentSet.has(f) && !claimedByOthers.has(f))
|
|
52
|
+
.sort();
|
|
53
|
+
const removed = await findRemovedFiles(currentFilesAffected, engineDir);
|
|
54
|
+
return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed);
|
|
55
|
+
}
|
|
56
|
+
async function scanPatchFilesTargeted(args) {
|
|
57
|
+
const { currentFilesAffected, engineDir, manifest, patchFilename, isDryRun, scanFiles } = args;
|
|
58
|
+
const currentSet = new Set(currentFilesAffected);
|
|
59
|
+
const claimedByOthers = getClaimedFiles(manifest, patchFilename);
|
|
60
|
+
const added = [];
|
|
61
|
+
for (const file of scanFiles) {
|
|
62
|
+
if (claimedByOthers.has(file)) {
|
|
63
|
+
throw new InvalidArgumentError(`--scan-file path is already claimed by another patch: ${file}`, '--scan-file');
|
|
64
|
+
}
|
|
65
|
+
if (!(await pathExists(join(engineDir, file)))) {
|
|
66
|
+
throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}`, '--scan-file');
|
|
67
|
+
}
|
|
68
|
+
if (!currentSet.has(file))
|
|
69
|
+
added.push(file);
|
|
70
|
+
}
|
|
71
|
+
const removed = await findRemovedFiles(currentFilesAffected, engineDir);
|
|
72
|
+
return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added.sort(), removed);
|
|
73
|
+
}
|
|
74
|
+
async function findRemovedFiles(files, engineDir) {
|
|
75
|
+
const removed = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (!(await pathExists(join(engineDir, file))))
|
|
78
|
+
removed.push(file);
|
|
79
|
+
}
|
|
80
|
+
return removed.sort();
|
|
81
|
+
}
|
|
82
|
+
function reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed) {
|
|
83
|
+
for (const f of added)
|
|
84
|
+
info(` + ${f}`);
|
|
85
|
+
for (const f of removed)
|
|
86
|
+
info(` - ${f}`);
|
|
87
|
+
if (added.length === 0 && removed.length === 0) {
|
|
88
|
+
return { updated: currentFilesAffected, added: [], removed: [] };
|
|
89
|
+
}
|
|
90
|
+
const removedSet = new Set(removed);
|
|
91
|
+
const updated = [...currentFilesAffected.filter((f) => !removedSet.has(f)), ...added].sort();
|
|
92
|
+
info(` ${isDryRun ? 'Would update' : 'Updated'} ${patchFilename}: +${added.length} / -${removed.length} files`);
|
|
93
|
+
return { updated, added, removed };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Confirms broad directory-scan additions before a mutating re-export writes
|
|
97
|
+
* them into patch ownership.
|
|
98
|
+
*/
|
|
99
|
+
export async function confirmBroadScanAdditions(args) {
|
|
100
|
+
const { patchFilename, added, isDryRun, yes, isInteractive } = args;
|
|
101
|
+
if (isDryRun || yes || !scanAdditionsNeedConfirmation(added))
|
|
102
|
+
return true;
|
|
103
|
+
const dirCount = new Set(added.map((f) => dirname(f))).size;
|
|
104
|
+
warn(`${patchFilename}: --scan would add ${String(added.length)} file(s) that span ${String(dirCount)} director${dirCount === 1 ? 'y' : 'ies'}. ` +
|
|
105
|
+
'Broad scans can silently pull adjacent features into a patch — review the diff before continuing.');
|
|
106
|
+
if (!isInteractive) {
|
|
107
|
+
throw new GeneralError(`Refusing to broaden "${patchFilename}" via --scan in non-interactive mode. ` +
|
|
108
|
+
'Pass --yes to acknowledge the expansion, or run with --dry-run first to review.');
|
|
109
|
+
}
|
|
110
|
+
const confirmed = await confirm({
|
|
111
|
+
message: `Proceed and broaden ${patchFilename} with ${String(added.length)} newly discovered file(s)?`,
|
|
112
|
+
initialValue: false,
|
|
113
|
+
});
|
|
114
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
115
|
+
cancel(`Skipped ${patchFilename}`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
function scanAdditionsNeedConfirmation(added) {
|
|
121
|
+
if (added.length === 0)
|
|
122
|
+
return false;
|
|
123
|
+
if (added.length > SCAN_ADD_COUNT_THRESHOLD)
|
|
124
|
+
return true;
|
|
125
|
+
return new Set(added.map((f) => dirname(f))).size >= SCAN_DIR_COUNT_THRESHOLD;
|
|
126
|
+
}
|
|
127
|
+
/** Refuses explicit `--scan-file` additions that did not produce patch hunks. */
|
|
128
|
+
export function assertScanFileAdditionsHaveDiffHunks(args) {
|
|
129
|
+
const { diffContent, patchFilename, previousFilesAffected, scanFiles } = args;
|
|
130
|
+
if (scanFiles === undefined)
|
|
131
|
+
return;
|
|
132
|
+
const previous = new Set(previousFilesAffected);
|
|
133
|
+
const diffFiles = new Set(extractAffectedFiles(diffContent));
|
|
134
|
+
const noDiffScanFiles = scanFiles.filter((file) => !previous.has(file) && !diffFiles.has(file));
|
|
135
|
+
if (noDiffScanFiles.length === 0)
|
|
136
|
+
return;
|
|
137
|
+
throw new InvalidArgumentError(`Refusing to re-export ${patchFilename} with --scan-file because ${noDiffScanFiles.length} explicit added path${noDiffScanFiles.length === 1 ? '' : 's'} produced no diff hunks (${noDiffScanFiles.join(', ')}). Remove unchanged paths or modify them before retrying.`, '--scan-file');
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=re-export-scan.js.map
|
|
@@ -1,136 +1,53 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { multiselect } from '@clack/prompts';
|
|
4
4
|
import { Option } from 'commander';
|
|
5
5
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
6
6
|
import { isGitRepository } from '../core/git.js';
|
|
7
7
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
|
-
import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
|
|
9
8
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
10
|
-
import {
|
|
9
|
+
import { loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
|
|
11
10
|
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
12
11
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
13
12
|
import { toError } from '../utils/errors.js';
|
|
14
13
|
import { pathExists } from '../utils/fs.js';
|
|
15
14
|
import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
|
|
16
|
-
/**
|
|
17
|
-
* Threshold above which `--scan` must be explicitly confirmed. Values were
|
|
18
|
-
* picked so the common "refresh after one-or-two-file tweak" case stays
|
|
19
|
-
* frictionless while catching the eval finding #13 scenario where `--scan`
|
|
20
|
-
* silently pulled in an entire sibling feature (xhtml + tests + theme CSS).
|
|
21
|
-
*/
|
|
22
|
-
const SCAN_ADD_COUNT_THRESHOLD = 3;
|
|
23
|
-
const SCAN_DIR_COUNT_THRESHOLD = 2;
|
|
24
15
|
import { pickDefined } from '../utils/options.js';
|
|
25
16
|
import { runPatchLint } from './export-shared.js';
|
|
26
17
|
import { reExportFilesInPlace } from './re-export-files.js';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const untrackedFiles = await getUntrackedFilesInDir(engineDir, dir);
|
|
34
|
-
for (const f of [...modifiedFiles, ...untrackedFiles]) {
|
|
35
|
-
discoveredFiles.add(f);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
const currentSet = new Set(currentFilesAffected);
|
|
39
|
-
const added = [];
|
|
40
|
-
for (const f of discoveredFiles) {
|
|
41
|
-
if (!currentSet.has(f) && !claimedByOthers.has(f)) {
|
|
42
|
-
added.push(f);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
const removed = [];
|
|
46
|
-
for (const f of currentFilesAffected) {
|
|
47
|
-
const filePath = join(engineDir, f);
|
|
48
|
-
if (!(await pathExists(filePath))) {
|
|
49
|
-
removed.push(f);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
const sortedAdded = [...added].sort();
|
|
53
|
-
const sortedRemoved = [...removed].sort();
|
|
54
|
-
for (const f of sortedAdded) {
|
|
55
|
-
info(` + ${f}`);
|
|
56
|
-
}
|
|
57
|
-
for (const f of sortedRemoved) {
|
|
58
|
-
info(` - ${f}`);
|
|
59
|
-
}
|
|
60
|
-
if (added.length > 0 || removed.length > 0) {
|
|
61
|
-
const removedSet = new Set(removed);
|
|
62
|
-
const updated = [...currentFilesAffected.filter((f) => !removedSet.has(f)), ...added].sort();
|
|
63
|
-
info(` ${isDryRun ? 'Would update' : 'Updated'} ${patchFilename}: +${added.length} / -${removed.length} files`);
|
|
64
|
-
return { updated, added: sortedAdded, removed: sortedRemoved };
|
|
65
|
-
}
|
|
66
|
-
return { updated: currentFilesAffected, added: [], removed: [] };
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Returns true when the caller-confirmed threshold is exceeded for this
|
|
70
|
-
* scan's additions. The heuristic treats "small, same-directory" additions
|
|
71
|
-
* as friction-free (the common refresh case) and flags larger or
|
|
72
|
-
* multi-directory expansions so operators see them before they land.
|
|
73
|
-
*
|
|
74
|
-
* Pre-0.16.0 `--scan` silently broadened patches to include any modified or
|
|
75
|
-
* untracked file that shared a parent directory with the existing
|
|
76
|
-
* filesAffected — in practice, pulling adjacent feature code into a patch
|
|
77
|
-
* that had nothing to do with it. The gate below turns the broadening into
|
|
78
|
-
* an explicit opt-in.
|
|
79
|
-
*/
|
|
80
|
-
function scanAdditionsNeedConfirmation(added) {
|
|
81
|
-
if (added.length === 0)
|
|
82
|
-
return false;
|
|
83
|
-
if (added.length > SCAN_ADD_COUNT_THRESHOLD)
|
|
84
|
-
return true;
|
|
85
|
-
const dirs = new Set(added.map((f) => dirname(f)));
|
|
86
|
-
return dirs.size >= SCAN_DIR_COUNT_THRESHOLD;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Gate for broad `--scan` additions. Enforces explicit acknowledgement when
|
|
90
|
-
* the scan would pull in more files than a narrow refresh. Dry-run always
|
|
91
|
-
* proceeds (the preview is the whole point).
|
|
92
|
-
*
|
|
93
|
-
* @returns true if the caller should proceed; false if the user cancelled.
|
|
94
|
-
*/
|
|
95
|
-
async function confirmBroadScanAdditions(args) {
|
|
96
|
-
const { patchFilename, added, isDryRun, yes, isInteractive } = args;
|
|
97
|
-
if (isDryRun)
|
|
98
|
-
return true;
|
|
99
|
-
if (!scanAdditionsNeedConfirmation(added))
|
|
100
|
-
return true;
|
|
101
|
-
if (yes)
|
|
102
|
-
return true;
|
|
103
|
-
warn(`${patchFilename}: --scan would add ${String(added.length)} file(s) that span ${String(new Set(added.map((f) => dirname(f))).size)} director${new Set(added.map((f) => dirname(f))).size === 1 ? 'y' : 'ies'}. ` +
|
|
104
|
-
'Broad scans can silently pull adjacent features into a patch — review the diff before continuing.');
|
|
105
|
-
if (!isInteractive) {
|
|
106
|
-
throw new GeneralError(`Refusing to broaden "${patchFilename}" via --scan in non-interactive mode. ` +
|
|
107
|
-
'Pass --yes to acknowledge the expansion, or run with --dry-run first to review.');
|
|
108
|
-
}
|
|
109
|
-
const confirmed = await confirm({
|
|
110
|
-
message: `Proceed and broaden ${patchFilename} with ${String(added.length)} newly discovered file(s)?`,
|
|
111
|
-
initialValue: false,
|
|
112
|
-
});
|
|
113
|
-
if (isCancel(confirmed) || !confirmed) {
|
|
114
|
-
cancel(`Skipped ${patchFilename}`);
|
|
115
|
-
return false;
|
|
18
|
+
import { assertScanFileAdditionsHaveDiffHunks, confirmBroadScanAdditions, normalizeScanFiles, scanPatchFilesForReExport, } from './re-export-scan.js';
|
|
19
|
+
async function findMissingFiles(engineDir, files) {
|
|
20
|
+
const missingFiles = [];
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (!(await pathExists(join(engineDir, file))))
|
|
23
|
+
missingFiles.push(file);
|
|
116
24
|
}
|
|
117
|
-
return
|
|
25
|
+
return missingFiles;
|
|
118
26
|
}
|
|
119
27
|
async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
|
|
120
28
|
let currentFilesAffected = [...patch.filesAffected];
|
|
121
29
|
// --- Scan for new/removed files ---
|
|
122
30
|
if (options.scan) {
|
|
123
|
-
const scanResult = await
|
|
124
|
-
|
|
125
|
-
|
|
31
|
+
const scanResult = await scanPatchFilesForReExport({
|
|
32
|
+
currentFilesAffected,
|
|
33
|
+
engineDir: paths.engine,
|
|
34
|
+
manifest,
|
|
126
35
|
patchFilename: patch.filename,
|
|
127
|
-
added: scanResult.added,
|
|
128
36
|
isDryRun,
|
|
129
|
-
|
|
130
|
-
isInteractive,
|
|
37
|
+
...(options.scanFiles !== undefined ? { scanFiles: options.scanFiles } : {}),
|
|
131
38
|
});
|
|
132
|
-
if (
|
|
133
|
-
|
|
39
|
+
if (options.scanFiles === undefined) {
|
|
40
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
41
|
+
const proceed = await confirmBroadScanAdditions({
|
|
42
|
+
patchFilename: patch.filename,
|
|
43
|
+
added: scanResult.added,
|
|
44
|
+
isDryRun,
|
|
45
|
+
yes: options.yes === true,
|
|
46
|
+
isInteractive,
|
|
47
|
+
});
|
|
48
|
+
if (!proceed) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
134
51
|
}
|
|
135
52
|
currentFilesAffected = scanResult.updated;
|
|
136
53
|
}
|
|
@@ -144,12 +61,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
144
61
|
// warning up-front when we can detect the drift cheaply, so the
|
|
145
62
|
// operator has a chance to re-run with `--scan` or `--files`
|
|
146
63
|
// before the stale filesAffected lands in patches.json.
|
|
147
|
-
const missingFiles =
|
|
148
|
-
for (const file of currentFilesAffected) {
|
|
149
|
-
if (!(await pathExists(join(paths.engine, file)))) {
|
|
150
|
-
missingFiles.push(file);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
64
|
+
const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
|
|
153
65
|
if (missingFiles.length > 0) {
|
|
154
66
|
warn(`${patch.filename}: some files in patches.json no longer exist on disk ` +
|
|
155
67
|
`(${missingFiles.join(', ')}). Without --scan, re-export keeps the manifest's ` +
|
|
@@ -175,13 +87,7 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
175
87
|
for (const f of removed)
|
|
176
88
|
info(` - ${f}`);
|
|
177
89
|
}
|
|
178
|
-
const missingFiles =
|
|
179
|
-
for (const file of currentFilesAffected) {
|
|
180
|
-
const filePath = join(paths.engine, file);
|
|
181
|
-
if (!(await pathExists(filePath))) {
|
|
182
|
-
missingFiles.push(file);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
90
|
+
const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
|
|
185
91
|
if (missingFiles.length === currentFilesAffected.length) {
|
|
186
92
|
warn(`Skipped ${patch.filename}: all affected files missing`);
|
|
187
93
|
warn(`Missing files: ${missingFiles.join(', ')}`);
|
|
@@ -193,6 +99,12 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
|
|
|
193
99
|
const missingSet = new Set(missingFiles);
|
|
194
100
|
const existingFiles = currentFilesAffected.filter((f) => !missingSet.has(f));
|
|
195
101
|
const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
|
|
102
|
+
assertScanFileAdditionsHaveDiffHunks({
|
|
103
|
+
diffContent,
|
|
104
|
+
patchFilename: patch.filename,
|
|
105
|
+
previousFilesAffected: patch.filesAffected,
|
|
106
|
+
scanFiles: options.scanFiles,
|
|
107
|
+
});
|
|
196
108
|
if (!diffContent.trim()) {
|
|
197
109
|
warn(`Skipped ${patch.filename}: no changes (files unchanged from HEAD)`);
|
|
198
110
|
return false;
|
|
@@ -317,6 +229,15 @@ async function resolveSelectedPatches(patches, options, manifest) {
|
|
|
317
229
|
* @param options - Re-export options
|
|
318
230
|
*/
|
|
319
231
|
export async function reExportCommand(projectRoot, patches, options) {
|
|
232
|
+
const normalizedScanFiles = normalizeScanFiles(options.scanFiles);
|
|
233
|
+
if (normalizedScanFiles !== undefined) {
|
|
234
|
+
options = { ...options, scanFiles: normalizedScanFiles };
|
|
235
|
+
}
|
|
236
|
+
else if (options.scanFiles !== undefined) {
|
|
237
|
+
const cleanedOptions = { ...options };
|
|
238
|
+
delete cleanedOptions.scanFiles;
|
|
239
|
+
options = cleanedOptions;
|
|
240
|
+
}
|
|
320
241
|
const isDryRun = options.dryRun === true;
|
|
321
242
|
intro(isDryRun ? 'FireForge Re-export (dry run)' : 'FireForge Re-export');
|
|
322
243
|
// --files is mutually exclusive with --scan and --all: they select
|
|
@@ -329,6 +250,14 @@ export async function reExportCommand(projectRoot, patches, options) {
|
|
|
329
250
|
throw new InvalidArgumentError('--files operates on exactly one target patch. Pass a single patch identifier.', '--files');
|
|
330
251
|
}
|
|
331
252
|
}
|
|
253
|
+
if (options.scanFiles !== undefined) {
|
|
254
|
+
if (!options.scan) {
|
|
255
|
+
throw new InvalidArgumentError('--scan-file requires --scan.', '--scan-file');
|
|
256
|
+
}
|
|
257
|
+
if (options.all || patches.length !== 1) {
|
|
258
|
+
throw new InvalidArgumentError('--scan-file operates on exactly one target patch. Pass a single patch identifier.', '--scan-file');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
332
261
|
// --tier and --lint-ignore are per-patch metadata edits; combining them
|
|
333
262
|
// with --all silently rewrites every patch's tier/ignore list, which is
|
|
334
263
|
// virtually always wrong (different patches have different shapes).
|
|
@@ -434,6 +363,7 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
434
363
|
'version stamping.')
|
|
435
364
|
.option('-a, --all', 'Re-export all patches')
|
|
436
365
|
.option('-s, --scan', 'Scan directories for new/removed files and update filesAffected')
|
|
366
|
+
.option('--scan-file <path>', 'With --scan, add this explicit engine-relative file to one target patch without collecting adjacent files. Repeatable.', (value, prev) => [...prev, value], [])
|
|
437
367
|
.option('--files <paths>', 'Restrict the re-exported filesAffected to this comma-separated list (single target patch only)', (value) => value
|
|
438
368
|
.split(',')
|
|
439
369
|
.map((v) => v.trim())
|
|
@@ -446,9 +376,10 @@ export function registerReExport(program, { getProjectRoot, withErrorHandling })
|
|
|
446
376
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the selected patch (only "branding" recognised). Mutually exclusive with --all.').choices(['branding']))
|
|
447
377
|
.option('--lint-ignore <check-id>', 'Append a lint check ID to the patch\'s PatchMetadata.lintIgnore (union, de-duped, repeatable). Mutually exclusive with --all. Use "fireforge patch lint-ignore" for --remove / --clear.', (value, prev) => [...prev, value], [])
|
|
448
378
|
.action(withErrorHandling(async (patches, options) => {
|
|
449
|
-
const { tier, lintIgnore, ...rest } = options;
|
|
379
|
+
const { tier, lintIgnore, scanFile, ...rest } = options;
|
|
450
380
|
await reExportCommand(getProjectRoot(), patches, {
|
|
451
381
|
...pickDefined(rest),
|
|
382
|
+
...(scanFile !== undefined && scanFile.length > 0 ? { scanFiles: scanFile } : {}),
|
|
452
383
|
...(tier !== undefined ? { tier: tier } : {}),
|
|
453
384
|
...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
|
|
454
385
|
});
|
|
@@ -53,6 +53,7 @@ export class BrandingMozconfigMismatchError extends FireForgeError {
|
|
|
53
53
|
'The mismatch is caught before mach builds because resolving the build against the wrong branding tree fails deep in moz.build with a confusing "path does not exist" message.');
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
const MOZ_APP_VENDOR_IMPLY_REGEX = /imply_option\("MOZ_APP_VENDOR",\s*"[^"]*"\)/;
|
|
56
57
|
/**
|
|
57
58
|
* Sets up the custom branding directory for the browser.
|
|
58
59
|
*
|
|
@@ -76,29 +77,30 @@ export async function setupBranding(engineDir, config) {
|
|
|
76
77
|
if (!(await pathExists(brandingDir))) {
|
|
77
78
|
await copyDir(unofficialDir, brandingDir);
|
|
78
79
|
}
|
|
80
|
+
const vendorPlacement = await resolveVendorPlacement(engineDir);
|
|
79
81
|
// Create/update configure.sh with custom values
|
|
80
|
-
await createConfigureScript(brandingDir, config);
|
|
82
|
+
await createConfigureScript(brandingDir, config, vendorPlacement);
|
|
81
83
|
// Update localization files
|
|
82
84
|
await updateBrandProperties(brandingDir, config);
|
|
83
85
|
await updateBrandFtl(brandingDir, config);
|
|
84
86
|
// Patch moz.configure for MOZ_APP_VENDOR
|
|
85
|
-
await patchMozConfigure(engineDir, config);
|
|
87
|
+
await patchMozConfigure(engineDir, config, vendorPlacement);
|
|
86
88
|
}
|
|
87
89
|
/**
|
|
88
90
|
* Creates the branding configure.sh script.
|
|
89
91
|
*/
|
|
90
|
-
async function createConfigureScript(brandingDir, config) {
|
|
92
|
+
async function createConfigureScript(brandingDir, config, vendorPlacement) {
|
|
91
93
|
const configureShPath = join(brandingDir, 'configure.sh');
|
|
92
|
-
await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config));
|
|
94
|
+
await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config, vendorPlacement));
|
|
93
95
|
}
|
|
94
|
-
function buildConfigureScriptContent(config) {
|
|
96
|
+
function buildConfigureScriptContent(config, vendorPlacement) {
|
|
95
97
|
const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"
|
|
101
|
-
`;
|
|
98
|
+
const lines = [`MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"`];
|
|
99
|
+
if (vendorPlacement === 'branding-configure') {
|
|
100
|
+
lines.push(`MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"`);
|
|
101
|
+
}
|
|
102
|
+
lines.push(`MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"`);
|
|
103
|
+
return `${header}\n\n${lines.join('\n')}\n`;
|
|
102
104
|
}
|
|
103
105
|
/**
|
|
104
106
|
* Updates the brand.properties localization file.
|
|
@@ -150,30 +152,66 @@ trademarkInfo = { " " }
|
|
|
150
152
|
}
|
|
151
153
|
/**
|
|
152
154
|
* Patches browser/moz.configure to set custom vendor when the upstream
|
|
153
|
-
* configure surface
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* configure.sh instead, so an absent browser/moz.configure line is valid.
|
|
157
|
-
* Keeping this best-effort replacement preserves compatibility for queues
|
|
158
|
-
* that still carry the older process-wide registration line.
|
|
155
|
+
* configure surface owns MOZ_APP_VENDOR as a project flag. ESR 140 rejects
|
|
156
|
+
* branding configure.sh / confvars origins for that flag, so the value must
|
|
157
|
+
* come from imply_option.
|
|
159
158
|
*/
|
|
160
|
-
async function patchMozConfigure(engineDir, config) {
|
|
159
|
+
async function patchMozConfigure(engineDir, config, vendorPlacement) {
|
|
160
|
+
if (vendorPlacement !== 'moz-configure') {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
161
163
|
const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
|
|
162
164
|
if (!(await pathExists(mozConfigurePath))) {
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
165
167
|
let content = await readText(mozConfigurePath);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
if (MOZ_APP_VENDOR_IMPLY_REGEX.test(content)) {
|
|
169
|
+
content = content.replace(MOZ_APP_VENDOR_IMPLY_REGEX, buildMozConfigureVendorLine(config));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
content = insertMozConfigureVendorLine(content, buildMozConfigureVendorLine(config));
|
|
170
173
|
}
|
|
171
|
-
content = content.replace(vendorRegex, buildMozConfigureVendorLine(config));
|
|
172
174
|
await writeTextIfChanged(mozConfigurePath, content);
|
|
173
175
|
}
|
|
174
176
|
function buildMozConfigureVendorLine(config) {
|
|
175
177
|
return `imply_option("MOZ_APP_VENDOR", "${escapeString(config.vendor)}")`;
|
|
176
178
|
}
|
|
179
|
+
async function resolveVendorPlacement(engineDir) {
|
|
180
|
+
const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
|
|
181
|
+
const toolkitMozConfigurePath = join(engineDir, 'toolkit', 'moz.configure');
|
|
182
|
+
const browserMozConfigureExists = await pathExists(mozConfigurePath);
|
|
183
|
+
const browserMozConfigureContent = browserMozConfigureExists
|
|
184
|
+
? await readText(mozConfigurePath)
|
|
185
|
+
: undefined;
|
|
186
|
+
if (browserMozConfigureContent !== undefined &&
|
|
187
|
+
MOZ_APP_VENDOR_IMPLY_REGEX.test(browserMozConfigureContent)) {
|
|
188
|
+
return 'moz-configure';
|
|
189
|
+
}
|
|
190
|
+
if (await toolkitMozConfigureUsesVendorProjectFlag(toolkitMozConfigurePath)) {
|
|
191
|
+
if (!browserMozConfigureExists) {
|
|
192
|
+
throw new BrandingError('Firefox toolkit configure declares MOZ_APP_VENDOR as a project_flag, but browser/moz.configure is missing, so FireForge cannot safely set the vendor identity.');
|
|
193
|
+
}
|
|
194
|
+
return 'moz-configure';
|
|
195
|
+
}
|
|
196
|
+
return 'branding-configure';
|
|
197
|
+
}
|
|
198
|
+
async function toolkitMozConfigureUsesVendorProjectFlag(filePath) {
|
|
199
|
+
if (!(await pathExists(filePath))) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const content = await readText(filePath);
|
|
203
|
+
return /project_flag\(\s*(?:(?!\)\s*\n)[\s\S])*env\s*=\s*"MOZ_APP_VENDOR"/m.test(content);
|
|
204
|
+
}
|
|
205
|
+
function insertMozConfigureVendorLine(content, line) {
|
|
206
|
+
const includeRegex = /^include\((["'])\.\.\/toolkit\/moz\.configure\1\)\s*$/m;
|
|
207
|
+
const match = includeRegex.exec(content);
|
|
208
|
+
if (!match) {
|
|
209
|
+
return `${content.replace(/\s*$/, '')}\n\n${line}\n`;
|
|
210
|
+
}
|
|
211
|
+
const prefix = content.slice(0, match.index).replace(/\s*$/, '');
|
|
212
|
+
const suffix = content.slice(match.index);
|
|
213
|
+
return `${prefix}\n\n${line}\n${suffix}`;
|
|
214
|
+
}
|
|
177
215
|
/**
|
|
178
216
|
* Escapes a string for use in Python/configure file.
|
|
179
217
|
*/
|
|
@@ -230,8 +268,9 @@ export async function isBrandingSetup(engineDir, config) {
|
|
|
230
268
|
if (!(await pathExists(configureShPath))) {
|
|
231
269
|
return false;
|
|
232
270
|
}
|
|
271
|
+
const vendorPlacement = await resolveVendorPlacement(engineDir);
|
|
233
272
|
const configureContent = await readText(configureShPath);
|
|
234
|
-
if (configureContent !== buildConfigureScriptContent(config)) {
|
|
273
|
+
if (configureContent !== buildConfigureScriptContent(config, vendorPlacement)) {
|
|
235
274
|
return false;
|
|
236
275
|
}
|
|
237
276
|
if (await pathExists(propsPath)) {
|
|
@@ -246,7 +285,15 @@ export async function isBrandingSetup(engineDir, config) {
|
|
|
246
285
|
return false;
|
|
247
286
|
}
|
|
248
287
|
}
|
|
249
|
-
|
|
288
|
+
if (vendorPlacement === 'branding-configure') {
|
|
289
|
+
return configureContent.includes(`MOZ_APP_VENDOR="${escapeShellValue(config.vendor)}"`);
|
|
290
|
+
}
|
|
291
|
+
const mozConfigurePath = join(engineDir, 'browser', 'moz.configure');
|
|
292
|
+
if (!(await pathExists(mozConfigurePath))) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const mozConfigureContent = await readText(mozConfigurePath);
|
|
296
|
+
return mozConfigureContent.includes(buildMozConfigureVendorLine(config));
|
|
250
297
|
}
|
|
251
298
|
/**
|
|
252
299
|
* Checks whether a file path belongs to the tool-managed branding directory.
|
|
@@ -164,6 +164,12 @@ export interface ReExportOptions {
|
|
|
164
164
|
all?: boolean;
|
|
165
165
|
/** Scan directories for new/removed files and update filesAffected */
|
|
166
166
|
scan?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Explicit engine-relative files to add while scanning. Unlike broad
|
|
169
|
+
* `--scan`, this does not collect adjacent files from the same directory.
|
|
170
|
+
* Requires `--scan` and exactly one target patch.
|
|
171
|
+
*/
|
|
172
|
+
scanFiles?: string[];
|
|
167
173
|
/**
|
|
168
174
|
* Restrict the re-exported patch's filesAffected to this explicit list.
|
|
169
175
|
* Files currently in the patch but not in this list are dropped (shrink);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hominis/fireforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "FireForge — a build tool for customizing Firefox",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -29,13 +29,14 @@
|
|
|
29
29
|
"test:watch": "vitest",
|
|
30
30
|
"test:coverage": "vitest run --coverage && node ./scripts/check-coverage-thresholds.mjs",
|
|
31
31
|
"test:firefox-full": "node ./scripts/run-full-firefox-integration.mjs",
|
|
32
|
+
"whitespace:check": "node ./scripts/check-worktree-whitespace.mjs",
|
|
32
33
|
"pack:verify": "vitest run src/__tests__/wrapper-smoke.test.ts",
|
|
33
34
|
"pack:dry-run": "npm pack --dry-run --json --silent",
|
|
34
35
|
"format": "prettier --write --ignore-unknown \"src/**/*.ts\" \"bin/**/*.ts\" \"scripts/**/*.mjs\" \"package.json\" \"eslint.config.js\" \"vitest.config.ts\" \"tsconfig.json\" \"tsconfig.build.json\" \".lintstagedrc.json\" \".prettierignore\" \".prettierrc\" \".gitignore\" \"README.md\" \"CHANGELOG.md\"",
|
|
35
36
|
"format:check": "prettier --check --ignore-unknown \"src/**/*.ts\" \"bin/**/*.ts\" \"scripts/**/*.mjs\" \"package.json\" \"eslint.config.js\" \"vitest.config.ts\" \"tsconfig.json\" \"tsconfig.build.json\" \".lintstagedrc.json\" \".prettierignore\" \".prettierrc\" \".gitignore\" \"README.md\" \"CHANGELOG.md\"",
|
|
36
37
|
"prepare": "node -e \"import('./scripts/prepare.mjs').catch((error) => { if (error && typeof error === 'object' && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND') process.exit(0); throw error; })\"",
|
|
37
38
|
"prepack": "npm run build",
|
|
38
|
-
"release:check": "npm run format:check && npm run lint:ci && npm run typecheck && npm run test:coverage && npm run pack:verify && npm run pack:dry-run",
|
|
39
|
+
"release:check": "npm run format:check && npm run whitespace:check && npm run lint:ci && npm run typecheck && npm run test:coverage && npm run pack:verify && npm run pack:dry-run",
|
|
39
40
|
"prepublishOnly": "npm run release:check"
|
|
40
41
|
},
|
|
41
42
|
"files": [
|
|
@@ -79,7 +80,7 @@
|
|
|
79
80
|
"vitest": "^4.0.18"
|
|
80
81
|
},
|
|
81
82
|
"engines": {
|
|
82
|
-
"node": ">=
|
|
83
|
+
"node": ">=22.22.1"
|
|
83
84
|
},
|
|
84
85
|
"packageManager": "npm@11.12.1",
|
|
85
86
|
"license": "EUPL-1.2",
|