@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.
- package/CHANGELOG.md +25 -0
- package/README.md +9 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/download.js +188 -185
- package/dist/src/commands/export-flow.js +2 -13
- package/dist/src/commands/furnace/create-validation.d.ts +6 -0
- package/dist/src/commands/furnace/create-validation.js +59 -0
- package/dist/src/commands/furnace/create.js +13 -88
- package/dist/src/commands/furnace/refresh.js +11 -2
- package/dist/src/commands/furnace/remove-state.d.ts +5 -0
- package/dist/src/commands/furnace/remove-state.js +14 -0
- package/dist/src/commands/furnace/remove.js +30 -45
- package/dist/src/commands/furnace/rename-helpers.d.ts +13 -0
- package/dist/src/commands/furnace/rename-helpers.js +42 -0
- package/dist/src/commands/furnace/rename.js +27 -47
- 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 +5 -0
- package/dist/src/core/config.js +11 -7
- package/dist/src/core/file-lock.js +2 -2
- package/dist/src/core/firefox-cache.d.ts +1 -1
- package/dist/src/core/firefox-cache.js +43 -17
- package/dist/src/core/firefox-download.js +12 -4
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +2 -2
- package/dist/src/core/furnace-refresh.js +16 -5
- package/dist/src/core/patch-lint-imports.d.ts +5 -0
- package/dist/src/core/patch-lint-imports.js +68 -0
- package/dist/src/core/patch-lint.js +2 -3
- package/dist/src/types/config.d.ts +2 -0
- package/dist/src/utils/fs.d.ts +5 -0
- package/dist/src/utils/fs.js +54 -1
- package/dist/src/utils/process.js +4 -1
- 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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 !==
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/src/core/firefox.js
CHANGED
|
@@ -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,
|
|
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,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
|
-
|
|
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',
|
package/dist/src/utils/fs.d.ts
CHANGED
|
@@ -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
|
package/dist/src/utils/fs.js
CHANGED
|
@@ -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
|
|
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
|
}
|