release-installer
Version:
Simple GitHub release installer.
156 lines (155 loc) • 7.19 kB
JavaScript
import { access, chmod, mkdir, readdir, stat, unlink } from 'node:fs/promises';
import { basename, extname, join, resolve } from 'node:path';
import { extractArchive } from './extract.js';
import { downloadAsset, fetchReleaseInfo } from './github.js';
import { findBestAsset, getPlatformInfo } from './platform.js';
export async function installRelease(repo, version, options = {}) {
const { binName, outputDir = './bin', platformMap, force = false, verbose: v = false } = options;
if (v)
console.log(`Installing ${repo} ${version}...`);
// Fetch release info
const releaseInfo = await fetchReleaseInfo(repo, version);
if (v)
console.log(`Found ${releaseInfo.assets.length} assets for ${releaseInfo.tag_name}`);
// Get platform info
const platformInfo = getPlatformInfo();
if (v)
console.log(`Platform: ${platformInfo.combined}`);
// Find matching asset
const releaseAssetNames = releaseInfo.assets.map((a) => a.name);
let selectedReleaseAssetName = null;
if (platformMap?.[platformInfo.combined]) {
// Use custom platform mapping
const templateName = platformMap[platformInfo.combined];
selectedReleaseAssetName = templateName.replace('{version}', version);
}
else {
// Use automatic detection
selectedReleaseAssetName = findBestAsset(releaseAssetNames, platformInfo);
}
if (!selectedReleaseAssetName) {
console.error(`No matching release asset found for platform ${platformInfo.combined}`);
console.error('Available release assets:');
releaseAssetNames.forEach((name) => console.error(` - ${name}`));
throw new Error('No matching release asset found');
}
const releaseAsset = releaseInfo.assets.find((a) => a.name === selectedReleaseAssetName);
if (!releaseAsset)
throw new Error(`Release asset ${selectedReleaseAssetName} not found in release`);
if (v)
console.log(`Selected release asset: ${selectedReleaseAssetName}`);
// Create output directory
const outputPath = resolve(outputDir);
await mkdir(outputPath, { recursive: true });
// Download asset - sanitize filename to prevent path traversal
const sanitizedFilename = basename(selectedReleaseAssetName);
const tempPath = join(outputPath, sanitizedFilename);
// Check if binary already exists (unless force is enabled)
if (!force) {
const finalBinName = binName || repo.split('/')[1];
const potentialBinaryPaths = [
join(outputPath, finalBinName),
join(outputPath, `${finalBinName}.exe`),
join(outputPath, `${finalBinName}.bin`),
];
for (const binaryPath of potentialBinaryPaths) {
try {
await access(binaryPath);
throw new Error(`Binary ${binaryPath} already exists. Use --force to overwrite.`);
}
catch (error) {
// If access throws, file doesn't exist, continue checking
if (error instanceof Error && error.message.includes('already exists')) {
throw error;
}
}
}
}
if (v)
console.log(`Downloading ${selectedReleaseAssetName}...`);
await downloadAsset(releaseAsset.download_url, tempPath);
let extractionSuccessful = false;
try {
// Extract archive
if (v)
console.log(`Extracting ${selectedReleaseAssetName}...`);
await extractArchive(tempPath, outputPath);
extractionSuccessful = true;
// Find and set executable permissions on binary
const finalBinName = binName || repo.split('/')[1];
// Optimized binary search - try common locations first, then fallback to recursive
const potentialBinaryNames = [finalBinName, `${finalBinName}.exe`, `${finalBinName}.bin`];
let binaryFound = false;
// First, try direct paths in the output directory (most common case)
for (const binaryName of potentialBinaryNames) {
const directPath = join(outputPath, binaryName);
try {
const fileStat = await stat(directPath);
if (fileStat.isFile()) {
await chmod(directPath, 0o755);
if (v)
console.log(`Made ${directPath} executable`);
binaryFound = true;
break;
}
}
catch {
// File doesn't exist at direct path, continue
}
}
// If not found in root, search recursively but with early termination
if (!binaryFound) {
const files = await readdir(outputPath, { recursive: true });
for (const file of files) {
const filePath = join(outputPath, file);
const fileName = basename(file.toString());
const fileExtension = extname(fileName);
// More precise binary detection with early termination
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile())
continue;
const isExactMatch = fileName === finalBinName;
const isExecutableWithExt = fileName === `${finalBinName}.exe` || fileName === `${finalBinName}.bin`;
const hasSuspiciousExt = ['.txt', '.md', '.json', '.xml', '.html', '.log'].includes(fileExtension);
if ((isExactMatch || isExecutableWithExt) && !hasSuspiciousExt) {
await chmod(filePath, 0o755);
if (v) {
console.log(`Made ${filePath} executable`);
}
binaryFound = true;
break; // Early termination once we find the binary
}
}
catch {
// Ignore chmod errors on Windows or if file doesn't exist
}
}
}
if (!binaryFound && v)
console.log(`Warning: Binary '${finalBinName}' not found in extracted files`);
// Throw if binName is specified and not found
if (binName && !binaryFound) {
const files = await readdir(outputPath, { recursive: true });
console.error(`Error: Specified binName '${binName}' not found in extracted files.`);
console.error('Files found:');
for (const file of files) {
console.error(` - ${file}`);
}
throw new Error(`Specified binName '${binName}' not found after extraction`);
}
if (v)
console.log(`✓ Successfully installed ${repo} ${version} to ${outputPath}`);
}
finally {
// Always clean up downloaded archive, regardless of extraction success
try {
await unlink(tempPath);
if (v && !extractionSuccessful)
console.log('Cleaned up downloaded archive after failed extraction');
}
catch {
// Ignore cleanup errors
}
}
}