mirror of
https://github.com/SawyerHood/dev-browser.git
synced 2026-09-20 13:44:44 +08:00
604 lines
16 KiB
JavaScript
Executable File
604 lines
16 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import {
|
|
chmodSync,
|
|
createReadStream,
|
|
createWriteStream,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from 'fs';
|
|
import { createHash } from 'crypto';
|
|
import { get } from 'https';
|
|
import { arch, platform } from 'os';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { execSync } from 'child_process';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = join(__dirname, '..');
|
|
const binDir = join(projectRoot, 'bin');
|
|
const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
|
|
const packageName = packageJson.name;
|
|
const version = packageJson.version;
|
|
const checksumsRequiredFromVersion = '0.2.4';
|
|
const repoSlug = 'SawyerHood/dev-browser';
|
|
const releasesBaseUrl = `https://github.com/${repoSlug}/releases/download`;
|
|
const supportedTargets = Object.freeze({
|
|
'darwin-arm64': 'dev-browser-darwin-arm64',
|
|
'darwin-x64': 'dev-browser-darwin-x64',
|
|
'linux-arm64': 'dev-browser-linux-arm64',
|
|
'linux-musl-x64': 'dev-browser-linux-musl-x64',
|
|
'linux-x64': 'dev-browser-linux-x64',
|
|
'win32-x64': 'dev-browser-windows-x64.exe',
|
|
});
|
|
|
|
function isMusl() {
|
|
if (platform() !== 'linux') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const report = process.report?.getReport?.();
|
|
if (report?.header?.glibcVersionRuntime) {
|
|
return false;
|
|
}
|
|
} catch {
|
|
// Fall through to the ldd probe.
|
|
}
|
|
|
|
try {
|
|
const output = execSync('ldd --version 2>&1', { encoding: 'utf8' });
|
|
return output.toLowerCase().includes('musl');
|
|
} catch {
|
|
return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
|
|
}
|
|
}
|
|
|
|
function getTargetKey() {
|
|
const currentPlatform = platform();
|
|
const currentArch = arch();
|
|
|
|
if (currentPlatform === 'darwin') {
|
|
if (currentArch === 'arm64' || currentArch === 'aarch64') {
|
|
return 'darwin-arm64';
|
|
}
|
|
|
|
if (currentArch === 'x64' || currentArch === 'x86_64') {
|
|
return 'darwin-x64';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
if (currentPlatform === 'linux') {
|
|
if (currentArch === 'x64' || currentArch === 'x86_64') {
|
|
return isMusl() ? 'linux-musl-x64' : 'linux-x64';
|
|
}
|
|
|
|
if (currentArch === 'arm64' || currentArch === 'aarch64') {
|
|
return isMusl() ? null : 'linux-arm64';
|
|
}
|
|
}
|
|
|
|
if (currentPlatform === 'win32') {
|
|
if (currentArch === 'x64' || currentArch === 'x86_64') {
|
|
return 'win32-x64';
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getBinaryName() {
|
|
const targetKey = getTargetKey();
|
|
return targetKey ? supportedTargets[targetKey] : null;
|
|
}
|
|
|
|
function getSupportedPlatformsText() {
|
|
return Object.keys(supportedTargets).join(', ');
|
|
}
|
|
|
|
function shouldFailInstall() {
|
|
return !existsSync(join(projectRoot, '.git'));
|
|
}
|
|
|
|
function formatErrorMessage(error) {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
|
|
return String(error);
|
|
}
|
|
|
|
function parseVersion(versionString) {
|
|
const [coreVersion] = versionString.split(/[-+]/, 1);
|
|
const parts = coreVersion.split('.');
|
|
|
|
if (parts.length !== 3 || parts.some((part) => !/^\d+$/.test(part))) {
|
|
throw new Error(`Invalid package version: ${versionString}`);
|
|
}
|
|
|
|
return parts.map((part) => Number(part));
|
|
}
|
|
|
|
function compareVersions(leftVersion, rightVersion) {
|
|
const leftParts = parseVersion(leftVersion);
|
|
const rightParts = parseVersion(rightVersion);
|
|
|
|
for (let index = 0; index < 3; index += 1) {
|
|
const difference = leftParts[index] - rightParts[index];
|
|
if (difference !== 0) {
|
|
return difference;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function checksumFileIsRequired() {
|
|
return compareVersions(version, checksumsRequiredFromVersion) >= 0;
|
|
}
|
|
|
|
function failOrWarn(message) {
|
|
if (shouldFailInstall()) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
console.warn(`Warning: ${message}`);
|
|
console.warn('Continuing because this appears to be a local checkout, not a packaged npm install.');
|
|
}
|
|
|
|
const binaryName = getBinaryName();
|
|
const binaryPath = binaryName ? join(binDir, binaryName) : null;
|
|
const downloadUrl = binaryName
|
|
? `${releasesBaseUrl}/v${version}/${binaryName}`
|
|
: null;
|
|
const checksumsUrl = `${releasesBaseUrl}/v${version}/SHASUMS256.txt`;
|
|
|
|
function getNpmGlobalPaths() {
|
|
try {
|
|
const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
|
|
return {
|
|
prefix,
|
|
binDir: platform() === 'win32' ? prefix : join(prefix, 'bin'),
|
|
nodeModulesDir:
|
|
platform() === 'win32' ? join(prefix, 'node_modules') : join(prefix, 'lib', 'node_modules'),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizePath(path) {
|
|
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
}
|
|
|
|
function isGlobalInstall() {
|
|
if (process.env.npm_config_global === 'true') {
|
|
return true;
|
|
}
|
|
|
|
const globalPaths = getNpmGlobalPaths();
|
|
if (!globalPaths) {
|
|
return false;
|
|
}
|
|
|
|
const expectedRoot = normalizePath(join(globalPaths.nodeModulesDir, packageName));
|
|
return normalizePath(projectRoot) === expectedRoot;
|
|
}
|
|
|
|
async function downloadFile(url, destination) {
|
|
const tempPath = `${destination}.download`;
|
|
rmSync(tempPath, { force: true });
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
|
|
const rejectOnce = (error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
|
|
const resolveOnce = () => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
settled = true;
|
|
resolve();
|
|
};
|
|
|
|
const request = (currentUrl, redirectsRemaining = 10) => {
|
|
const req = get(
|
|
currentUrl,
|
|
{
|
|
headers: {
|
|
Accept: 'application/octet-stream',
|
|
'User-Agent': `${packageName}/${version}`,
|
|
},
|
|
},
|
|
(response) => {
|
|
if (
|
|
response.statusCode &&
|
|
response.statusCode >= 300 &&
|
|
response.statusCode < 400 &&
|
|
response.headers.location
|
|
) {
|
|
if (redirectsRemaining === 0) {
|
|
response.resume();
|
|
rejectOnce(new Error(`Too many redirects while downloading ${url}`));
|
|
return;
|
|
}
|
|
|
|
response.resume();
|
|
request(new URL(response.headers.location, currentUrl), redirectsRemaining - 1);
|
|
return;
|
|
}
|
|
|
|
if (response.statusCode !== 200) {
|
|
response.resume();
|
|
rejectOnce(new Error(`HTTP ${response.statusCode ?? 'unknown'} from ${currentUrl}`));
|
|
return;
|
|
}
|
|
|
|
const file = createWriteStream(tempPath);
|
|
const onStreamError = (error) => {
|
|
rejectOnce(new Error(`${currentUrl}: ${formatErrorMessage(error)}`));
|
|
};
|
|
|
|
file.on('error', onStreamError);
|
|
response.on('error', onStreamError);
|
|
response.on('aborted', () => {
|
|
rejectOnce(new Error(`Download aborted for ${currentUrl}`));
|
|
});
|
|
response.pipe(file);
|
|
file.on('finish', () => {
|
|
file.close(() => {
|
|
try {
|
|
renameSync(tempPath, destination);
|
|
resolveOnce();
|
|
} catch (error) {
|
|
rejectOnce(error);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
req.on('error', (error) => {
|
|
rejectOnce(new Error(`${currentUrl}: ${formatErrorMessage(error)}`));
|
|
});
|
|
|
|
req.setTimeout(30_000, () => {
|
|
req.destroy(new Error(`Request timed out after 30s for ${currentUrl}`));
|
|
});
|
|
};
|
|
|
|
request(url);
|
|
}).catch((error) => {
|
|
rmSync(tempPath, { force: true });
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
async function downloadText(url) {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
|
|
const rejectOnce = (error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
|
|
const resolveOnce = (value) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
settled = true;
|
|
resolve(value);
|
|
};
|
|
|
|
const request = (currentUrl, redirectsRemaining = 10) => {
|
|
const req = get(
|
|
currentUrl,
|
|
{
|
|
headers: {
|
|
Accept: 'text/plain',
|
|
'User-Agent': `${packageName}/${version}`,
|
|
},
|
|
},
|
|
(response) => {
|
|
if (
|
|
response.statusCode &&
|
|
response.statusCode >= 300 &&
|
|
response.statusCode < 400 &&
|
|
response.headers.location
|
|
) {
|
|
if (redirectsRemaining === 0) {
|
|
response.resume();
|
|
rejectOnce(new Error(`Too many redirects while downloading ${url}`));
|
|
return;
|
|
}
|
|
|
|
response.resume();
|
|
request(new URL(response.headers.location, currentUrl), redirectsRemaining - 1);
|
|
return;
|
|
}
|
|
|
|
if (response.statusCode !== 200) {
|
|
response.resume();
|
|
rejectOnce(new Error(`HTTP ${response.statusCode ?? 'unknown'} from ${currentUrl}`));
|
|
return;
|
|
}
|
|
|
|
response.setEncoding('utf8');
|
|
|
|
const chunks = [];
|
|
response.on('data', (chunk) => {
|
|
chunks.push(chunk);
|
|
});
|
|
response.on('error', (error) => {
|
|
rejectOnce(new Error(`${currentUrl}: ${formatErrorMessage(error)}`));
|
|
});
|
|
response.on('aborted', () => {
|
|
rejectOnce(new Error(`Download aborted for ${currentUrl}`));
|
|
});
|
|
response.on('end', () => {
|
|
resolveOnce(chunks.join(''));
|
|
});
|
|
},
|
|
);
|
|
|
|
req.on('error', (error) => {
|
|
rejectOnce(new Error(`${currentUrl}: ${formatErrorMessage(error)}`));
|
|
});
|
|
|
|
req.setTimeout(30_000, () => {
|
|
req.destroy(new Error(`Request timed out after 30s for ${currentUrl}`));
|
|
});
|
|
};
|
|
|
|
request(url);
|
|
});
|
|
}
|
|
|
|
function parseChecksumsFile(contents) {
|
|
const hashes = new Map();
|
|
|
|
for (const line of contents.split(/\r?\n/)) {
|
|
const trimmedLine = line.trim();
|
|
if (!trimmedLine) {
|
|
continue;
|
|
}
|
|
|
|
const match = line.match(/^([a-fA-F0-9]{64})\s{2}(.+)$/);
|
|
if (!match) {
|
|
throw new Error(`Invalid checksum entry: ${line}`);
|
|
}
|
|
|
|
const [, hash, filename] = match;
|
|
hashes.set(filename, hash.toLowerCase());
|
|
}
|
|
|
|
return hashes;
|
|
}
|
|
|
|
async function computeFileSha256(filePath) {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = createHash('sha256');
|
|
const stream = createReadStream(filePath);
|
|
|
|
stream.on('data', (chunk) => {
|
|
hash.update(chunk);
|
|
});
|
|
stream.on('error', reject);
|
|
stream.on('end', () => {
|
|
resolve(hash.digest('hex'));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function verifyBinaryChecksum(downloadedBinaryPath, downloadedBinaryName) {
|
|
let checksumsText;
|
|
|
|
try {
|
|
checksumsText = await downloadText(checksumsUrl);
|
|
} catch (error) {
|
|
if (!checksumFileIsRequired()) {
|
|
console.warn(
|
|
`Warning: Could not download SHASUMS256.txt from ${checksumsUrl}: ${formatErrorMessage(error)}`,
|
|
);
|
|
console.warn('Continuing without checksum verification for this older release.');
|
|
return;
|
|
}
|
|
|
|
rmSync(downloadedBinaryPath, { force: true });
|
|
throw new Error(
|
|
[
|
|
`Could not download SHASUMS256.txt from ${checksumsUrl}.`,
|
|
`Cause: ${formatErrorMessage(error)}`,
|
|
`Deleted ${downloadedBinaryName} because checksum verification is required for releases >= ${checksumsRequiredFromVersion}.`,
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
try {
|
|
const checksums = parseChecksumsFile(checksumsText);
|
|
const expectedHash = checksums.get(downloadedBinaryName);
|
|
|
|
if (!expectedHash) {
|
|
throw new Error(`SHASUMS256.txt does not contain an entry for ${downloadedBinaryName}.`);
|
|
}
|
|
|
|
const actualHash = await computeFileSha256(downloadedBinaryPath);
|
|
|
|
if (actualHash !== expectedHash) {
|
|
throw new Error(
|
|
[
|
|
`Checksum mismatch for ${downloadedBinaryName}.`,
|
|
`Expected: ${expectedHash}`,
|
|
`Actual: ${actualHash}`,
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
console.log(`Verified SHA-256 checksum for ${downloadedBinaryName}.`);
|
|
} catch (error) {
|
|
rmSync(downloadedBinaryPath, { force: true });
|
|
throw new Error(
|
|
[
|
|
formatErrorMessage(error),
|
|
`Deleted ${downloadedBinaryName} due to failed checksum verification.`,
|
|
].join('\n'),
|
|
);
|
|
}
|
|
}
|
|
|
|
function ensureExecutable(path) {
|
|
if (platform() !== 'win32') {
|
|
chmodSync(path, 0o755);
|
|
}
|
|
}
|
|
|
|
function showInstallReminder() {
|
|
console.log('');
|
|
console.log('Run `dev-browser install` to install Playwright + Chromium.');
|
|
console.log('');
|
|
}
|
|
|
|
async function fixGlobalInstallBin() {
|
|
if (!isGlobalInstall() || !binaryPath || !existsSync(binaryPath)) {
|
|
return;
|
|
}
|
|
|
|
if (platform() === 'win32') {
|
|
fixWindowsShims();
|
|
return;
|
|
}
|
|
|
|
fixUnixSymlink();
|
|
}
|
|
|
|
function fixUnixSymlink() {
|
|
const globalPaths = getNpmGlobalPaths();
|
|
if (!globalPaths) {
|
|
return;
|
|
}
|
|
|
|
const symlinkPath = join(globalPaths.binDir, packageName);
|
|
|
|
try {
|
|
const stat = lstatSync(symlinkPath);
|
|
if (!stat.isSymbolicLink()) {
|
|
return;
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
unlinkSync(symlinkPath);
|
|
symlinkSync(binaryPath, symlinkPath);
|
|
console.log('Optimized global install: npm bin symlink now targets the native binary.');
|
|
} catch (error) {
|
|
console.warn(`Warning: Could not optimize the global symlink: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function fixWindowsShims() {
|
|
const globalPaths = getNpmGlobalPaths();
|
|
if (!globalPaths) {
|
|
return;
|
|
}
|
|
|
|
const cmdShim = join(globalPaths.binDir, `${packageName}.cmd`);
|
|
const ps1Shim = join(globalPaths.binDir, `${packageName}.ps1`);
|
|
if (!existsSync(cmdShim)) {
|
|
return;
|
|
}
|
|
|
|
const relativeBinaryPath = `node_modules\\${packageName}\\bin\\${binaryName}`;
|
|
const absoluteBinaryPath = join(globalPaths.binDir, relativeBinaryPath);
|
|
if (!existsSync(absoluteBinaryPath)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
writeFileSync(cmdShim, `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`);
|
|
writeFileSync(
|
|
ps1Shim,
|
|
`#!/usr/bin/env pwsh\r\n$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\r\n& "$basedir\\${relativeBinaryPath}" $args\r\nexit $LASTEXITCODE\r\n`,
|
|
);
|
|
console.log('Optimized global install: Windows shims now target the native binary.');
|
|
} catch (error) {
|
|
console.warn(`Warning: Could not optimize Windows shims: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (!binaryName || !binaryPath || !downloadUrl) {
|
|
failOrWarn(
|
|
[
|
|
`Unsupported platform for native download: ${platform()}-${arch()}.`,
|
|
`Supported platforms: ${getSupportedPlatformsText()}.`,
|
|
].join('\n'),
|
|
);
|
|
return;
|
|
}
|
|
|
|
mkdirSync(binDir, { recursive: true });
|
|
|
|
if (existsSync(binaryPath)) {
|
|
ensureExecutable(binaryPath);
|
|
console.log(`Native binary already present: ${binaryName}`);
|
|
await fixGlobalInstallBin();
|
|
showInstallReminder();
|
|
return;
|
|
}
|
|
|
|
console.log(`Downloading native binary for ${platform()}-${arch()}...`);
|
|
console.log(`URL: ${downloadUrl}`);
|
|
|
|
try {
|
|
await downloadFile(downloadUrl, binaryPath);
|
|
} catch (error) {
|
|
failOrWarn(
|
|
[
|
|
`Could not download native binary "${binaryName}" for ${platform()}-${arch()}.`,
|
|
`Tried: ${downloadUrl}`,
|
|
`Cause: ${formatErrorMessage(error)}`,
|
|
'This package cannot run without the native binary.',
|
|
].join('\n'),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await verifyBinaryChecksum(binaryPath, binaryName);
|
|
ensureExecutable(binaryPath);
|
|
console.log(`Downloaded native binary: ${binaryName}`);
|
|
|
|
await fixGlobalInstallBin();
|
|
showInstallReminder();
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`Error: dev-browser postinstall failed: ${formatErrorMessage(error)}`);
|
|
process.exitCode = 1;
|
|
});
|