this repo has no description
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}