@hominis/fireforge 0.19.5 → 0.20.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +21 -8
  3. package/dist/src/commands/config.js +1 -0
  4. package/dist/src/commands/download.js +188 -185
  5. package/dist/src/commands/export-flow.js +2 -13
  6. package/dist/src/commands/furnace/create-validation.d.ts +6 -0
  7. package/dist/src/commands/furnace/create-validation.js +59 -0
  8. package/dist/src/commands/furnace/create.d.ts +7 -7
  9. package/dist/src/commands/furnace/create.js +21 -96
  10. package/dist/src/commands/furnace/index.js +2 -2
  11. package/dist/src/commands/furnace/refresh.js +11 -2
  12. package/dist/src/commands/furnace/remove-state.d.ts +5 -0
  13. package/dist/src/commands/furnace/remove-state.js +14 -0
  14. package/dist/src/commands/furnace/remove.js +30 -45
  15. package/dist/src/commands/furnace/rename-helpers.d.ts +13 -0
  16. package/dist/src/commands/furnace/rename-helpers.js +42 -0
  17. package/dist/src/commands/furnace/rename.js +27 -47
  18. package/dist/src/core/config-paths.d.ts +1 -1
  19. package/dist/src/core/config-paths.js +1 -0
  20. package/dist/src/core/config-validate.js +5 -0
  21. package/dist/src/core/config.js +11 -7
  22. package/dist/src/core/file-lock.js +2 -2
  23. package/dist/src/core/firefox-cache.d.ts +1 -1
  24. package/dist/src/core/firefox-cache.js +43 -17
  25. package/dist/src/core/firefox-download.js +12 -4
  26. package/dist/src/core/firefox.d.ts +1 -1
  27. package/dist/src/core/firefox.js +2 -2
  28. package/dist/src/core/furnace-refresh.js +16 -5
  29. package/dist/src/core/patch-lint-imports.d.ts +5 -0
  30. package/dist/src/core/patch-lint-imports.js +68 -0
  31. package/dist/src/core/patch-lint.js +2 -3
  32. package/dist/src/types/commands/options.d.ts +9 -9
  33. package/dist/src/types/config.d.ts +2 -0
  34. package/dist/src/utils/fs.d.ts +5 -0
  35. package/dist/src/utils/fs.js +54 -1
  36. package/dist/src/utils/process.js +4 -1
  37. package/package.json +1 -1
@@ -14,52 +14,12 @@ import { FurnaceError } from '../../errors/furnace.js';
14
14
  import { toError } from '../../utils/errors.js';
15
15
  import { copyFile, ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
16
16
  import { info, intro, note, outro, warn } from '../../utils/logger.js';
17
+ import { renameComponentFileName, updateConfigForCustomRename, updateConfigForOverrideRename, } from './rename-helpers.js';
17
18
  import { renameXpcshellTestFiles } from './rename-xpcshell.js';
18
19
  /** Escapes regex metacharacters so a user-supplied name is literal inside a RegExp. */
19
20
  function escapeRegex(input) {
20
21
  return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
22
  }
22
- /**
23
- * Applies the component rename to a filename. Only replaces the leading
24
- * component name when it is followed by `.` (extension) or equals the
25
- * filename exactly; every other filename is returned unchanged so stray
26
- * assets, editor backups, or files whose name coincidentally contains the
27
- * old component name in the middle or at the end are not accidentally
28
- * renamed.
29
- */
30
- function renameComponentFileName(fileName, oldName, newName) {
31
- if (fileName === oldName)
32
- return newName;
33
- if (fileName.startsWith(oldName + '.')) {
34
- return newName + fileName.slice(oldName.length);
35
- }
36
- return fileName;
37
- }
38
- function updateConfigForCustomRename(config, oldName, newName) {
39
- const oldConfig = config.custom[oldName];
40
- if (!oldConfig)
41
- return;
42
- config.custom[newName] = {
43
- ...oldConfig,
44
- targetPath: oldConfig.targetPath.replace(new RegExp(`(^|/)${oldName}$`), `$1${newName}`),
45
- };
46
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- idiomatic key removal from config record
47
- delete config.custom[oldName];
48
- // Update composes references in other components
49
- for (const customConfig of Object.values(config.custom)) {
50
- if (customConfig.composes) {
51
- customConfig.composes = customConfig.composes.map((ref) => (ref === oldName ? newName : ref));
52
- }
53
- }
54
- }
55
- function updateConfigForOverrideRename(config, oldName, newName) {
56
- const oldConfig = config.overrides[oldName];
57
- if (!oldConfig)
58
- return;
59
- config.overrides[newName] = { ...oldConfig };
60
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- idiomatic key removal from config record
61
- delete config.overrides[oldName];
62
- }
63
23
  /**
64
24
  * Derives the test file name for a component, matching the convention used by
65
25
  * `furnace create --with-tests`.
@@ -215,18 +175,37 @@ async function renameMochikitTestFiles(engineDir, oldName, newName, journal) {
215
175
  * Performs the transactional rename mutation inside a furnace lock.
216
176
  */
217
177
  async function performRenameMutations(args) {
218
- const { projectRoot, oldName, newName, oldDir, newDir, isCustom, componentType, config } = args;
178
+ const { projectRoot, oldName, newName } = args;
219
179
  const oldClassName = tagNameToClassName(oldName);
220
180
  const newClassName = tagNameToClassName(newName);
221
- // Capture the pre-rename deployed target path so we know what to
222
- // clean up in the engine tree. `updateConfigForCustomRename` rewrites
223
- // `targetPath` in-place once the mutation enters phase 2, so we read
224
- // it here while it still points at the old name's deployment.
225
- const oldCustomTargetPath = isCustom ? config.custom[oldName]?.targetPath : undefined;
226
181
  await runFurnaceMutation(projectRoot, 'rename-rollback', async (ctx) => {
227
182
  const journal = createRollbackJournal();
228
183
  ctx.registerJournal(journal);
184
+ let newDir = args.newDir;
229
185
  try {
186
+ const config = await loadFurnaceConfig(projectRoot);
187
+ const isCustom = oldName in config.custom;
188
+ const isOverride = oldName in config.overrides;
189
+ if (!isCustom && !isOverride) {
190
+ throw new FurnaceError(`Component "${oldName}" not found in furnace.json. Only custom and override components can be renamed.`, oldName);
191
+ }
192
+ if (newName in config.custom ||
193
+ newName in config.overrides ||
194
+ config.stock.includes(newName)) {
195
+ throw new FurnaceError(`A component named "${newName}" already exists in furnace.json.`, newName);
196
+ }
197
+ const componentType = isCustom ? 'custom' : 'override';
198
+ const componentDirLabel = isCustom ? 'custom' : 'overrides';
199
+ const baseDir = isCustom ? args.furnacePaths.customDir : args.furnacePaths.overridesDir;
200
+ const oldDir = join(baseDir, oldName);
201
+ newDir = join(baseDir, newName);
202
+ const oldCustomTargetPath = isCustom ? config.custom[oldName]?.targetPath : undefined;
203
+ if (!(await pathExists(oldDir))) {
204
+ throw new FurnaceError(`Component directory not found: components/${componentDirLabel}/${oldName}`, oldName);
205
+ }
206
+ if (await pathExists(newDir)) {
207
+ throw new FurnaceError(`Target directory already exists: components/${componentDirLabel}/${newName}`, newName);
208
+ }
230
209
  await snapshotDir(journal, oldDir);
231
210
  await snapshotFile(journal, args.furnaceConfigPath);
232
211
  // 1. Create new directory with renamed files and updated content
@@ -472,6 +451,7 @@ export async function furnaceRenameCommand(projectRoot, oldName, newName) {
472
451
  componentType,
473
452
  config,
474
453
  furnaceConfigPath: furnacePaths.furnaceConfig,
454
+ furnacePaths,
475
455
  engineDir: paths.engine,
476
456
  });
477
457
  note(`Component renamed: ${oldName} → ${newName}\n\n` +
@@ -19,7 +19,7 @@ export declare const SRC_DIR = "src";
19
19
  /** Supported top-level fireforge.json keys backed by the current schema. */
20
20
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "typecheck", "markerComment"];
21
21
  /** Supported config paths that can be read or set without --force. */
22
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
22
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
23
23
  /**
24
24
  * Gets all project paths based on a root directory.
25
25
  * @param root - Root directory of the project
@@ -41,6 +41,7 @@ export const SUPPORTED_CONFIG_PATHS = [
41
41
  'firefox',
42
42
  'firefox.version',
43
43
  'firefox.product',
44
+ 'firefox.sha256',
44
45
  'build',
45
46
  'build.jobs',
46
47
  'wire',
@@ -75,6 +75,10 @@ export function validateConfig(data) {
75
75
  if (compatError) {
76
76
  throw new ConfigError(compatError);
77
77
  }
78
+ const firefoxSha256 = optionalConfigString(firefoxRec, 'sha256', 'firefox.sha256');
79
+ if (firefoxSha256 !== undefined && !/^[a-f0-9]{64}$/i.test(firefoxSha256)) {
80
+ throw new ConfigError('Config field "firefox.sha256" must be a 64-character SHA-256 hex digest');
81
+ }
78
82
  // Optional configs
79
83
  const config = {
80
84
  name,
@@ -84,6 +88,7 @@ export function validateConfig(data) {
84
88
  firefox: {
85
89
  version: firefoxVersion,
86
90
  product: firefoxProduct,
91
+ ...(firefoxSha256 !== undefined ? { sha256: firefoxSha256.toLowerCase() } : {}),
87
92
  },
88
93
  };
89
94
  // Build
@@ -11,7 +11,7 @@
11
11
  import { basename } from 'node:path';
12
12
  import { ConfigError, ConfigNotFoundError } from '../errors/config.js';
13
13
  import { toError } from '../utils/errors.js';
14
- import { pathExists, readJson, writeJson } from '../utils/fs.js';
14
+ import * as fsUtils from '../utils/fs.js';
15
15
  import { getProjectPaths } from './config-paths.js';
16
16
  import { validateConfig } from './config-validate.js';
17
17
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
@@ -21,6 +21,10 @@ export { CONFIG_FILENAME, FIREFORGE_DIR, getProjectPaths, STATE_FILENAME, SUPPOR
21
21
  export { loadState, saveState, updateState } from './config-state.js';
22
22
  export { validateConfig } from './config-validate.js';
23
23
  // ---- config I/O (stays here because it bridges paths + validation) ----
24
+ async function configPathExists(path) {
25
+ const fs = fsUtils;
26
+ return (fs.pathExistsStrict ?? fsUtils.pathExists)(path);
27
+ }
24
28
  /**
25
29
  * Checks if a fireforge.json exists in the given directory.
26
30
  * @param root - Root directory to check
@@ -28,7 +32,7 @@ export { validateConfig } from './config-validate.js';
28
32
  */
29
33
  export async function configExists(root) {
30
34
  const paths = getProjectPaths(root);
31
- return pathExists(paths.config);
35
+ return configPathExists(paths.config);
32
36
  }
33
37
  /**
34
38
  * Loads and validates the fireforge.json configuration.
@@ -38,11 +42,11 @@ export async function configExists(root) {
38
42
  */
39
43
  export async function loadConfig(root) {
40
44
  const paths = getProjectPaths(root);
41
- if (!(await pathExists(paths.config))) {
45
+ if (!(await configPathExists(paths.config))) {
42
46
  throw new ConfigNotFoundError(paths.config);
43
47
  }
44
48
  try {
45
- const data = await readJson(paths.config);
49
+ const data = await fsUtils.readJson(paths.config);
46
50
  return validateConfig(data);
47
51
  }
48
52
  catch (error) {
@@ -70,11 +74,11 @@ export async function loadConfig(root) {
70
74
  */
71
75
  export async function loadRawConfigDocument(root) {
72
76
  const paths = getProjectPaths(root);
73
- if (!(await pathExists(paths.config))) {
77
+ if (!(await configPathExists(paths.config))) {
74
78
  throw new ConfigNotFoundError(paths.config);
75
79
  }
76
80
  try {
77
- const data = await readJson(paths.config);
81
+ const data = await fsUtils.readJson(paths.config);
78
82
  if (data === null || typeof data !== 'object' || Array.isArray(data)) {
79
83
  throw new ConfigError(`Invalid fireforge.json at ${paths.config}: expected an object`);
80
84
  }
@@ -110,7 +114,7 @@ export async function writeConfig(root, config) {
110
114
  */
111
115
  export async function writeConfigDocument(root, config) {
112
116
  const paths = getProjectPaths(root);
113
- await writeJson(paths.config, config);
117
+ await fsUtils.writeJson(paths.config, config);
114
118
  }
115
119
  /**
116
120
  * Runs an operation while holding a sidecar lock on `fireforge.json`.
@@ -35,8 +35,8 @@ function isProcessAlive(pid) {
35
35
  process.kill(pid, 0);
36
36
  return true;
37
37
  }
38
- catch {
39
- return false;
38
+ catch (error) {
39
+ return getNodeErrorCode(error) !== 'ESRCH';
40
40
  }
41
41
  }
42
42
  async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
@@ -14,7 +14,7 @@ export declare function sha256File(filePath: string): Promise<string>;
14
14
  * @param cacheDir - Cache directory
15
15
  * @param onProgress - Optional progress callback
16
16
  */
17
- export declare function ensureCachedArchive(archive: ResolvedArchive, cacheDir: string, onProgress?: ProgressCallback): Promise<void>;
17
+ export declare function ensureCachedArchive(archive: ResolvedArchive, cacheDir: string, onProgress?: ProgressCallback, expectedSha256?: string): Promise<void>;
18
18
  /**
19
19
  * Removes cached tarball, metadata, and partial download files for an archive.
20
20
  * @param archive - Resolved archive descriptor
@@ -7,9 +7,11 @@ import { createReadStream } from 'node:fs';
7
7
  import { rename } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { pipeline } from 'node:stream/promises';
10
+ import { DownloadError } from '../errors/download.js';
10
11
  import { toError } from '../utils/errors.js';
11
12
  import { pathExists, readJson, removeFile, writeJson } from '../utils/fs.js';
12
13
  import { verbose } from '../utils/logger.js';
14
+ import { createSiblingLockPath, withFileLock } from './file-lock.js';
13
15
  import { validateArchiveMetadata } from './firefox-archive.js';
14
16
  import { downloadFile } from './firefox-download.js';
15
17
  /**
@@ -28,12 +30,24 @@ export async function sha256File(filePath) {
28
30
  * @param cacheDir - Cache directory
29
31
  * @param onProgress - Optional progress callback
30
32
  */
31
- export async function ensureCachedArchive(archive, cacheDir, onProgress) {
32
- if (await validateCachedArchive(archive, cacheDir)) {
33
- return;
34
- }
35
- await invalidateArchiveCache(archive, cacheDir);
36
- await downloadToCache(archive, cacheDir, onProgress);
33
+ export async function ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256) {
34
+ const lockPath = createSiblingLockPath(join(cacheDir, archive.filename), '.fireforge-cache.lock');
35
+ await withFileLock(lockPath, async () => {
36
+ if (await validateCachedArchive(archive, cacheDir, expectedSha256)) {
37
+ return;
38
+ }
39
+ if (await cacheEntryExists(archive, cacheDir)) {
40
+ await invalidateArchiveCache(archive, cacheDir);
41
+ }
42
+ else {
43
+ await removeArchivePartFiles(archive, cacheDir);
44
+ }
45
+ await downloadToCache(archive, cacheDir, onProgress, expectedSha256);
46
+ });
47
+ }
48
+ async function cacheEntryExists(archive, cacheDir) {
49
+ return ((await pathExists(join(cacheDir, archive.filename))) ||
50
+ (await pathExists(join(cacheDir, archive.metadataFilename))));
37
51
  }
38
52
  /**
39
53
  * Validates a cached archive using sidecar metadata and SHA-256 checksum.
@@ -41,7 +55,7 @@ export async function ensureCachedArchive(archive, cacheDir, onProgress) {
41
55
  * @param cacheDir - Cache directory
42
56
  * @returns True if the cache entry is valid
43
57
  */
44
- async function validateCachedArchive(archive, cacheDir) {
58
+ async function validateCachedArchive(archive, cacheDir, expectedSha256) {
45
59
  const tarballPath = join(cacheDir, archive.filename);
46
60
  const metadataPath = join(cacheDir, archive.metadataFilename);
47
61
  if (!(await pathExists(tarballPath)) || !(await pathExists(metadataPath))) {
@@ -61,9 +75,12 @@ async function validateCachedArchive(archive, cacheDir) {
61
75
  return false;
62
76
  }
63
77
  }
64
- if (metadata.sha256) {
78
+ if (expectedSha256 || metadata.sha256) {
65
79
  const actualHash = await sha256File(tarballPath);
66
- if (actualHash !== metadata.sha256) {
80
+ if (expectedSha256 && actualHash !== expectedSha256) {
81
+ return false;
82
+ }
83
+ if (metadata.sha256 && actualHash !== metadata.sha256) {
67
84
  return false;
68
85
  }
69
86
  }
@@ -80,16 +97,21 @@ async function validateCachedArchive(archive, cacheDir) {
80
97
  * @param cacheDir - Cache directory
81
98
  * @param onProgress - Optional progress callback
82
99
  */
83
- async function downloadToCache(archive, cacheDir, onProgress) {
100
+ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256) {
84
101
  const tarballPath = join(cacheDir, archive.filename);
85
102
  // Use a unique .part path so concurrent downloads for the same archive
86
103
  // do not clobber each other's partial files.
87
104
  const partPath = `${tarballPath}.part-${randomUUID()}`;
88
105
  const metadataPath = join(cacheDir, archive.metadataFilename);
106
+ let promotedTarball = false;
89
107
  try {
90
108
  const contentLength = await downloadFile(archive.url, partPath, onProgress);
91
109
  await rename(partPath, tarballPath);
110
+ promotedTarball = true;
92
111
  const sha256 = await sha256File(tarballPath);
112
+ if (expectedSha256 && sha256 !== expectedSha256) {
113
+ throw new DownloadError(`Downloaded archive SHA-256 mismatch: expected ${expectedSha256}, got ${sha256}`, archive.url);
114
+ }
93
115
  await writeJson(metadataPath, {
94
116
  requestedVersion: archive.requestedVersion,
95
117
  product: archive.product,
@@ -102,8 +124,10 @@ async function downloadToCache(archive, cacheDir, onProgress) {
102
124
  }
103
125
  catch (error) {
104
126
  await removeFile(partPath);
105
- await removeFile(tarballPath);
106
- await removeFile(metadataPath);
127
+ if (promotedTarball) {
128
+ await removeFile(tarballPath);
129
+ await removeFile(metadataPath);
130
+ }
107
131
  throw error;
108
132
  }
109
133
  }
@@ -115,8 +139,11 @@ async function downloadToCache(archive, cacheDir, onProgress) {
115
139
  export async function invalidateArchiveCache(archive, cacheDir) {
116
140
  const tarballPath = join(cacheDir, archive.filename);
117
141
  const metadataPath = join(cacheDir, archive.metadataFilename);
118
- // Clean up any partial download files (may have unique suffixes from
119
- // concurrent download attempts).
142
+ await removeArchivePartFiles(archive, cacheDir);
143
+ await removeFile(tarballPath);
144
+ await removeFile(metadataPath);
145
+ }
146
+ async function removeArchivePartFiles(archive, cacheDir) {
120
147
  const partPrefix = `${archive.filename}.part`;
121
148
  try {
122
149
  const { readdir } = await import('node:fs/promises');
@@ -125,10 +152,9 @@ export async function invalidateArchiveCache(archive, cacheDir) {
125
152
  .filter((name) => name.startsWith(partPrefix))
126
153
  .map((name) => removeFile(join(cacheDir, name))));
127
154
  }
128
- catch {
155
+ catch (error) {
156
+ void error;
129
157
  // Cache dir may not exist yet — that's fine.
130
158
  }
131
- await removeFile(tarballPath);
132
- await removeFile(metadataPath);
133
159
  }
134
160
  //# sourceMappingURL=firefox-cache.js.map
@@ -73,20 +73,28 @@ export async function fetchWithRetry(url) {
73
73
  */
74
74
  function createStallDetector(url, timeoutMs = DOWNLOAD_STALL_TIMEOUT_MS) {
75
75
  let timer;
76
+ const clearTimer = () => {
77
+ if (timer !== undefined) {
78
+ clearTimeout(timer);
79
+ timer = undefined;
80
+ }
81
+ };
76
82
  const detector = new Transform({
77
83
  transform(chunk, _encoding, callback) {
78
- if (timer !== undefined)
79
- clearTimeout(timer);
84
+ clearTimer();
80
85
  timer = setTimeout(() => {
81
86
  detector.destroy(new DownloadError(`Download stalled: no data received for ${Math.round(timeoutMs / 1000)} seconds`, url));
82
87
  }, timeoutMs);
83
88
  callback(null, chunk);
84
89
  },
85
90
  flush(callback) {
86
- if (timer !== undefined)
87
- clearTimeout(timer);
91
+ clearTimer();
88
92
  callback();
89
93
  },
94
+ destroy(error, callback) {
95
+ clearTimer();
96
+ callback(error);
97
+ },
90
98
  });
91
99
  // Start the initial timer (covers the case where the first chunk never arrives).
92
100
  timer = setTimeout(() => {
@@ -45,4 +45,4 @@ export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
45
45
  * between phases (`'download'` → `'extract'`). Fires exactly once per
46
46
  * phase even if the cached archive path skips the wire entirely.
47
47
  */
48
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback): Promise<void>;
48
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string): Promise<void>;
@@ -44,13 +44,13 @@ export function getTarballFilename(version, product = 'firefox') {
44
44
  * between phases (`'download'` → `'extract'`). Fires exactly once per
45
45
  * phase even if the cached archive path skips the wire entirely.
46
46
  */
47
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase) {
47
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256) {
48
48
  const archive = resolveArchive(version, product);
49
49
  const tarballPath = join(cacheDir, archive.filename);
50
50
  // Ensure cache directory exists
51
51
  await ensureDir(cacheDir);
52
52
  onPhase?.('download');
53
- await ensureCachedArchive(archive, cacheDir, onProgress);
53
+ await ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256);
54
54
  // Extract to a unique temporary directory so concurrent downloads for
55
55
  // the same destination do not clobber each other.
56
56
  onPhase?.('extract');
@@ -17,6 +17,19 @@ import { readText, writeText } from '../utils/fs.js';
17
17
  import { exec } from '../utils/process.js';
18
18
  import { ensureGit } from './git-base.js';
19
19
  import { getFileContentAtRef } from './git-file-ops.js';
20
+ function isFatalMergeStderr(stderr) {
21
+ return /(?:^|\n)\s*(?:fatal|error):/i.test(stderr);
22
+ }
23
+ function classifyMergeFileResult(result) {
24
+ if (result.exitCode === 0 && !isFatalMergeStderr(result.stderr)) {
25
+ return 0;
26
+ }
27
+ if (result.exitCode >= 1 && result.exitCode <= 127 && !isFatalMergeStderr(result.stderr)) {
28
+ return result.exitCode;
29
+ }
30
+ const detail = result.stderr.trim() || `exit code ${result.exitCode}`;
31
+ throw new FurnaceError(`git merge-file failed: ${detail}`);
32
+ }
20
33
  /**
21
34
  * Performs a three-way merge on a single file.
22
35
  *
@@ -40,7 +53,8 @@ async function threeWayMergeFile(base, ours, theirs, label, strategy) {
40
53
  await writeText(tempOurs, ours);
41
54
  await writeText(tempTheirs, theirs);
42
55
  // git merge-file writes the result to the first file (ours) in-place.
43
- // Exit code 0 = clean merge, >0 = number of conflicts, <0 = error.
56
+ // Exit code 0 = clean merge, 1..127 = conflict count, shell-exposed
57
+ // fatal errors typically arrive as >=128 (for example, 255).
44
58
  const mergeArgs = [
45
59
  'merge-file',
46
60
  ...(strategy ? [`--${strategy}`] : []),
@@ -55,11 +69,8 @@ async function threeWayMergeFile(base, ours, theirs, label, strategy) {
55
69
  tempTheirs,
56
70
  ];
57
71
  const result = await exec('git', mergeArgs);
72
+ const conflicts = classifyMergeFileResult(result);
58
73
  const merged = await readText(tempOurs);
59
- const conflicts = result.exitCode > 0 ? result.exitCode : 0;
60
- if (result.exitCode < 0) {
61
- throw new FurnaceError(`git merge-file failed: ${result.stderr}`);
62
- }
63
74
  return { merged, conflicts };
64
75
  }
65
76
  finally {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Detects relative JS imports while avoiding comment/template false positives.
3
+ * Falls back to stripped-text matching for legacy chrome scripts Acorn cannot parse.
4
+ */
5
+ export declare function hasRelativeImport(content: string): boolean;
@@ -0,0 +1,68 @@
1
+ import { stripJsComments } from '../utils/regex.js';
2
+ import { parseModule, parseScript, walkAST } from './ast-utils.js';
3
+ const RELATIVE_IMPORT_FALLBACK_PATTERN = /(?:\bimport\s*(?:\(\s*)?(?:[\s\S]*?\bfrom\s*)?|\bexport\s+(?:[\s\S]*?\bfrom\s*)?|(?:ChromeUtils\.import(?:ESModule)?|Cu\.import)\s*\(\s*)["'](?:\.\.?\/)/m;
4
+ function literalString(node) {
5
+ if (!node || node.type !== 'Literal')
6
+ return undefined;
7
+ return typeof node.value === 'string' ? node.value : undefined;
8
+ }
9
+ function isRelativeSpecifier(value) {
10
+ return value !== undefined && (value.startsWith('./') || value.startsWith('../'));
11
+ }
12
+ function isChromeImportCall(node) {
13
+ const callee = node.callee;
14
+ if (callee.type !== 'MemberExpression' || callee.computed)
15
+ return false;
16
+ if (callee.object.type !== 'Identifier' || callee.property.type !== 'Identifier')
17
+ return false;
18
+ if (callee.object.name === 'Cu') {
19
+ return callee.property.name === 'import';
20
+ }
21
+ return (callee.object.name === 'ChromeUtils' &&
22
+ (callee.property.name === 'import' || callee.property.name === 'importESModule'));
23
+ }
24
+ function astHasRelativeImport(content, sourceType) {
25
+ const ast = sourceType === 'module' ? parseModule(content) : parseScript(content);
26
+ let found = false;
27
+ walkAST(ast, {
28
+ enter(node) {
29
+ if (found) {
30
+ this.skip();
31
+ return;
32
+ }
33
+ if (node.type === 'ImportDeclaration') {
34
+ found = isRelativeSpecifier(literalString(node.source));
35
+ }
36
+ else if (node.type === 'ImportExpression') {
37
+ found = isRelativeSpecifier(literalString(node.source));
38
+ }
39
+ else if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') {
40
+ found = isRelativeSpecifier(literalString(node.source));
41
+ }
42
+ else if (node.type === 'CallExpression' && isChromeImportCall(node)) {
43
+ found = isRelativeSpecifier(literalString(node.arguments[0]));
44
+ }
45
+ },
46
+ });
47
+ return found;
48
+ }
49
+ /**
50
+ * Detects relative JS imports while avoiding comment/template false positives.
51
+ * Falls back to stripped-text matching for legacy chrome scripts Acorn cannot parse.
52
+ */
53
+ export function hasRelativeImport(content) {
54
+ try {
55
+ return astHasRelativeImport(content, 'module');
56
+ }
57
+ catch (moduleError) {
58
+ void moduleError;
59
+ }
60
+ try {
61
+ return astHasRelativeImport(content, 'script');
62
+ }
63
+ catch (scriptError) {
64
+ void scriptError;
65
+ }
66
+ return RELATIVE_IMPORT_FALLBACK_PATTERN.test(stripJsComments(content));
67
+ }
68
+ //# sourceMappingURL=patch-lint-imports.js.map
@@ -10,6 +10,7 @@ import { invokePatchLintCheckJs } from './patch-lint-checkjs.js';
10
10
  import { lintChromeScriptJsDocForFile } from './patch-lint-chrome-jsdoc.js';
11
11
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
12
12
  import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
13
+ import { hasRelativeImport } from './patch-lint-imports.js';
13
14
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
14
15
  import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
15
16
  // ---------------------------------------------------------------------------
@@ -423,9 +424,7 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
423
424
  const isSysMjs = file.endsWith('.sys.mjs');
424
425
  // 1. Relative import check
425
426
  const strippedContent = stripJsComments(content);
426
- const relativeImportPattern = /(?:ChromeUtils\.import(?:ESModule)?|Cu\.import)\s*\(\s*["'](?:\.\.?\/)/gm;
427
- const esRelativePattern = /\bimport\s+.*?\s+from\s+["'](?:\.\.?\/)/gm;
428
- if (relativeImportPattern.test(strippedContent) || esRelativePattern.test(strippedContent)) {
427
+ if (hasRelativeImport(content)) {
429
428
  issues.push({
430
429
  file,
431
430
  check: 'relative-import',
@@ -426,15 +426,15 @@ export interface FurnaceCreateOptions {
426
426
  /**
427
427
  * Test harness style to scaffold when `--with-tests` is set.
428
428
  *
429
- * - `mochikit` (default when `--with-tests` is set alone) — a MochiKit
430
- * test at `engine/toolkit/content/tests/widgets/test_<tag>.html` that
431
- * loads the component module directly via `chrome://global/` and
432
- * asserts against `customElements`. Runs today on forks whose
433
- * top-level chrome document (e.g. `mybrowser.xhtml`) lacks a
434
- * `tabbrowser`, because it doesn't go through `URILoadingHelper`.
435
- * - `browser-chrome` today's browser-mochitest scaffold, requires a
436
- * working tabbrowser. Use for components that talk to the browser
437
- * window or open URLs.
429
+ * - `browser-chrome` (default when `--with-tests` is set without
430
+ * `--test-style`) browser mochitest scaffold; requires a working
431
+ * `tabbrowser`. Prefer this for interactive chrome/widget coverage
432
+ * (including on macOS).
433
+ * - `mochikit` opt-in MochiKit test at
434
+ * `engine/toolkit/content/tests/widgets/test_<tag>.html` that loads
435
+ * the component via `chrome://global/`. Use when the top-level chrome
436
+ * document lacks a `tabbrowser`; on macOS the toolkit mochitest-chrome
437
+ * flavor can be unreliable (long idle timeout).
438
438
  * - `xpcshell` — equivalent to setting `--xpcshell`; headless, storage-only.
439
439
  */
440
440
  testStyle?: 'mochikit' | 'browser-chrome' | 'xpcshell';
@@ -10,6 +10,8 @@ export interface FirefoxConfig {
10
10
  version: string;
11
11
  /** Firefox product type */
12
12
  product: FirefoxProduct;
13
+ /** Optional pinned SHA-256 for the resolved source archive */
14
+ sha256?: string;
13
15
  }
14
16
  /**
15
17
  * Supported project license SPDX identifiers.
@@ -3,6 +3,11 @@
3
3
  * @param path - Path to check
4
4
  */
5
5
  export declare function pathExists(path: string): Promise<boolean>;
6
+ /**
7
+ * Checks if a path exists while surfacing permission errors.
8
+ * @param path - Path to check
9
+ */
10
+ export declare function pathExistsStrict(path: string): Promise<boolean>;
6
11
  /**
7
12
  * Ensures a directory exists, creating it recursively if needed.
8
13
  * @param path - Directory path to ensure