@hominis/fireforge 0.19.6 → 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 (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +9 -0
  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.js +13 -88
  9. package/dist/src/commands/furnace/refresh.js +11 -2
  10. package/dist/src/commands/furnace/remove-state.d.ts +5 -0
  11. package/dist/src/commands/furnace/remove-state.js +14 -0
  12. package/dist/src/commands/furnace/remove.js +30 -45
  13. package/dist/src/commands/furnace/rename-helpers.d.ts +13 -0
  14. package/dist/src/commands/furnace/rename-helpers.js +42 -0
  15. package/dist/src/commands/furnace/rename.js +27 -47
  16. package/dist/src/core/config-paths.d.ts +1 -1
  17. package/dist/src/core/config-paths.js +1 -0
  18. package/dist/src/core/config-validate.js +5 -0
  19. package/dist/src/core/config.js +11 -7
  20. package/dist/src/core/file-lock.js +2 -2
  21. package/dist/src/core/firefox-cache.d.ts +1 -1
  22. package/dist/src/core/firefox-cache.js +43 -17
  23. package/dist/src/core/firefox-download.js +12 -4
  24. package/dist/src/core/firefox.d.ts +1 -1
  25. package/dist/src/core/firefox.js +2 -2
  26. package/dist/src/core/furnace-refresh.js +16 -5
  27. package/dist/src/core/patch-lint-imports.d.ts +5 -0
  28. package/dist/src/core/patch-lint-imports.js +68 -0
  29. package/dist/src/core/patch-lint.js +2 -3
  30. package/dist/src/types/config.d.ts +2 -0
  31. package/dist/src/utils/fs.d.ts +5 -0
  32. package/dist/src/utils/fs.js +54 -1
  33. package/dist/src/utils/process.js +4 -1
  34. package/package.json +1 -1
@@ -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',
@@ -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
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { randomUUID } from 'node:crypto';
3
- import { access, copyFile as fsCopyFile, mkdir, open, readdir, readFile, rename, rm, statfs, } from 'node:fs/promises';
3
+ import { access, chmod, copyFile as fsCopyFile, mkdir, open, readdir, readFile, rename, rm, stat, statfs, } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  const RETRIABLE_REMOVE_ERRORS = new Set(['ENOTEMPTY', 'EBUSY', 'EPERM']);
6
6
  function sleep(ms) {
@@ -22,6 +22,25 @@ export async function pathExists(path) {
22
22
  return false;
23
23
  }
24
24
  }
25
+ /**
26
+ * Checks if a path exists while surfacing permission errors.
27
+ * @param path - Path to check
28
+ */
29
+ export async function pathExistsStrict(path) {
30
+ try {
31
+ await access(path);
32
+ return true;
33
+ }
34
+ catch (error) {
35
+ const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
36
+ ? error.code
37
+ : undefined;
38
+ if (code === 'ENOENT') {
39
+ return false;
40
+ }
41
+ throw error;
42
+ }
43
+ }
25
44
  /**
26
45
  * Ensures a directory exists, creating it recursively if needed.
27
46
  * @param path - Directory path to ensure
@@ -137,6 +156,18 @@ export async function writeTextIfChanged(path, content) {
137
156
  */
138
157
  export async function writeFileAtomic(path, content) {
139
158
  await ensureParentDir(path);
159
+ let existingMode;
160
+ try {
161
+ existingMode = (await stat(path)).mode;
162
+ }
163
+ catch (error) {
164
+ const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
165
+ ? error.code
166
+ : undefined;
167
+ if (code !== 'ENOENT') {
168
+ throw error;
169
+ }
170
+ }
140
171
  const tempPath = createAtomicTempPath(path);
141
172
  const handle = await open(tempPath, 'w');
142
173
  try {
@@ -150,13 +181,35 @@ export async function writeFileAtomic(path, content) {
150
181
  }
151
182
  await handle.close();
152
183
  try {
184
+ if (existingMode !== undefined) {
185
+ await chmod(tempPath, existingMode);
186
+ }
153
187
  await rename(tempPath, path);
188
+ await syncParentDir(path);
154
189
  }
155
190
  catch (error) {
156
191
  await rm(tempPath, { force: true });
157
192
  throw error;
158
193
  }
159
194
  }
195
+ async function syncParentDir(path) {
196
+ let directoryHandle;
197
+ try {
198
+ directoryHandle = await open(dirname(path), 'r');
199
+ await directoryHandle.sync();
200
+ }
201
+ catch (error) {
202
+ void error;
203
+ }
204
+ finally {
205
+ try {
206
+ await directoryHandle?.close();
207
+ }
208
+ catch (error) {
209
+ void error;
210
+ }
211
+ }
212
+ }
160
213
  /**
161
214
  * Copies a directory recursively.
162
215
  * @param src - Source directory path
@@ -367,7 +367,10 @@ export async function findExecutable(name) {
367
367
  const result = await exec(command, [name]);
368
368
  if (result.exitCode === 0 && result.stdout.trim()) {
369
369
  // Return the first line (first match)
370
- return result.stdout.trim().split('\n')[0];
370
+ return result.stdout
371
+ .split(/\r?\n/)
372
+ .map((line) => line.trim())
373
+ .find((line) => line.length > 0);
371
374
  }
372
375
  return undefined;
373
376
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.19.6",
3
+ "version": "0.20.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",