this repo has no description
at main 187 lines 6.6 kB view raw
1import { access, chmod, mkdir, readdir, stat, unlink } from 'node:fs/promises' 2import { basename, extname, join, resolve } from 'node:path' 3import { extractArchive } from './extract.js' 4import { downloadAsset, fetchReleaseInfo } from './github.js' 5import { findBestAsset, getPlatformInfo } from './platform.js' 6 7export interface InstallOptions { 8 binName?: string 9 outputDir?: string 10 platformMap?: Record<string, string> 11 force?: boolean 12 verbose?: boolean 13} 14 15export interface ReleaseAsset { 16 name: string 17 download_url: string 18 size: number 19} 20 21export interface ReleaseInfo { 22 tag_name: string 23 assets: ReleaseAsset[] 24} 25 26export async function installRelease(repo: string, version: string, options: InstallOptions = {}): Promise<void> { 27 const { binName, outputDir = './bin', platformMap, force = false, verbose: v = false } = options 28 29 if (v) console.log(`Installing ${repo} ${version}...`) 30 31 // Fetch release info 32 const releaseInfo = await fetchReleaseInfo(repo, version) 33 34 if (v) console.log(`Found ${releaseInfo.assets.length} assets for ${releaseInfo.tag_name}`) 35 36 // Get platform info 37 const platformInfo = getPlatformInfo() 38 39 if (v) console.log(`Platform: ${platformInfo.combined}`) 40 41 // Find matching asset 42 const releaseAssetNames = releaseInfo.assets.map((a) => a.name) 43 let selectedReleaseAssetName: string | null = null 44 45 if (platformMap?.[platformInfo.combined]) { 46 // Use custom platform mapping 47 const templateName = platformMap[platformInfo.combined] 48 selectedReleaseAssetName = templateName.replace('{version}', version) 49 } else { 50 // Use automatic detection 51 selectedReleaseAssetName = findBestAsset(releaseAssetNames, platformInfo) 52 } 53 54 if (!selectedReleaseAssetName) { 55 console.error(`No matching release asset found for platform ${platformInfo.combined}`) 56 console.error('Available release assets:') 57 releaseAssetNames.forEach((name) => console.error(` - ${name}`)) 58 throw new Error('No matching release asset found') 59 } 60 61 const releaseAsset = releaseInfo.assets.find((a) => a.name === selectedReleaseAssetName) 62 if (!releaseAsset) throw new Error(`Release asset ${selectedReleaseAssetName} not found in release`) 63 64 if (v) console.log(`Selected release asset: ${selectedReleaseAssetName}`) 65 66 // Create output directory 67 const outputPath = resolve(outputDir) 68 await mkdir(outputPath, { recursive: true }) 69 70 // Download asset - sanitize filename to prevent path traversal 71 const sanitizedFilename = basename(selectedReleaseAssetName) 72 const tempPath = join(outputPath, sanitizedFilename) 73 74 // Check if binary already exists (unless force is enabled) 75 if (!force) { 76 const finalBinName = binName || repo.split('/')[1] 77 const potentialBinaryPaths = [ 78 join(outputPath, finalBinName), 79 join(outputPath, `${finalBinName}.exe`), 80 join(outputPath, `${finalBinName}.bin`), 81 ] 82 83 for (const binaryPath of potentialBinaryPaths) { 84 try { 85 await access(binaryPath) 86 throw new Error(`Binary ${binaryPath} already exists. Use --force to overwrite.`) 87 } catch (error) { 88 // If access throws, file doesn't exist, continue checking 89 if (error instanceof Error && error.message.includes('already exists')) { 90 throw error 91 } 92 } 93 } 94 } 95 96 if (v) console.log(`Downloading ${selectedReleaseAssetName}...`) 97 98 await downloadAsset(releaseAsset.download_url, tempPath) 99 100 let extractionSuccessful = false 101 try { 102 // Extract archive 103 if (v) console.log(`Extracting ${selectedReleaseAssetName}...`) 104 105 await extractArchive(tempPath, outputPath) 106 extractionSuccessful = true 107 108 // Find and set executable permissions on binary 109 const finalBinName = binName || repo.split('/')[1] 110 111 // Optimized binary search - try common locations first, then fallback to recursive 112 const potentialBinaryNames = [finalBinName, `${finalBinName}.exe`, `${finalBinName}.bin`] 113 114 let binaryFound = false 115 116 // First, try direct paths in the output directory (most common case) 117 for (const binaryName of potentialBinaryNames) { 118 const directPath = join(outputPath, binaryName) 119 try { 120 const fileStat = await stat(directPath) 121 if (fileStat.isFile()) { 122 await chmod(directPath, 0o755) 123 if (v) console.log(`Made ${directPath} executable`) 124 binaryFound = true 125 break 126 } 127 } catch { 128 // File doesn't exist at direct path, continue 129 } 130 } 131 132 // If not found in root, search recursively but with early termination 133 if (!binaryFound) { 134 const files = await readdir(outputPath, { recursive: true }) 135 136 for (const file of files) { 137 const filePath = join(outputPath, file as string) 138 const fileName = basename(file.toString()) 139 const fileExtension = extname(fileName) 140 141 // More precise binary detection with early termination 142 try { 143 const fileStat = await stat(filePath) 144 if (!fileStat.isFile()) continue 145 146 const isExactMatch = fileName === finalBinName 147 const isExecutableWithExt = fileName === `${finalBinName}.exe` || fileName === `${finalBinName}.bin` 148 const hasSuspiciousExt = ['.txt', '.md', '.json', '.xml', '.html', '.log'].includes(fileExtension) 149 150 if ((isExactMatch || isExecutableWithExt) && !hasSuspiciousExt) { 151 await chmod(filePath, 0o755) 152 if (v) { 153 console.log(`Made ${filePath} executable`) 154 } 155 binaryFound = true 156 break // Early termination once we find the binary 157 } 158 } catch { 159 // Ignore chmod errors on Windows or if file doesn't exist 160 } 161 } 162 } 163 164 if (!binaryFound && v) console.log(`Warning: Binary '${finalBinName}' not found in extracted files`) 165 166 // Throw if binName is specified and not found 167 if (binName && !binaryFound) { 168 const files = await readdir(outputPath, { recursive: true }) 169 console.error(`Error: Specified binName '${binName}' not found in extracted files.`) 170 console.error('Files found:') 171 for (const file of files) { 172 console.error(` - ${file}`) 173 } 174 throw new Error(`Specified binName '${binName}' not found after extraction`) 175 } 176 177 if (v) console.log(`✓ Successfully installed ${repo} ${version} to ${outputPath}`) 178 } finally { 179 // Always clean up downloaded archive, regardless of extraction success 180 try { 181 await unlink(tempPath) 182 if (v && !extractionSuccessful) console.log('Cleaned up downloaded archive after failed extraction') 183 } catch { 184 // Ignore cleanup errors 185 } 186 } 187}