diff --git a/package-lock.json b/package-lock.json index 499d97e..54bbebf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@actions/core": "^1.11.0", "@actions/tool-cache": "^2.0.2", + "commander": "^14.0.3", "minimatch": "^10.2.5" }, "bin": { @@ -556,6 +557,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", diff --git a/package.json b/package.json index 4b95e91..8f77115 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@actions/core": "^1.11.0", "@actions/tool-cache": "^2.0.2", + "commander": "^14.0.3", "minimatch": "^10.2.5" }, "devDependencies": { diff --git a/src/cli.ts b/src/cli.ts index 501ab4f..90d1cb1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { spawnSync } from 'child_process'; +import { Command } from 'commander'; import { getPlatformInfo } from './core/platform'; import { getMatchingAsset } from './core/matcher'; import { findBinary } from './core/finder'; @@ -19,157 +20,13 @@ interface CliOptions { listOnly: boolean; token?: string; debug: boolean; - help: boolean; - dryRunLevel: number; + dryRun: boolean; systemOverride?: string; archOverride?: string; listRepo?: string; positionals: string[]; } -function usage(): string { - return ` -Usage: install-github-release [options] - -Arguments: - repository The GitHub repository (owner/repo) - -Options: - --dry-run [level] Run in test mode (default level: 1) - -l, --list [repository] List available assets from latest release and exit - -a, --app-name Application name (optional, for output messages) - -f, --file-name Asset file name or regex pattern (prefixed with ~) - -b, --binary-name Binary name (supports source:destination form) - -t, --file-type Known: archive|package|linux|macos|targz - Or custom: ~ (end-of-string match) or extension (e.g. zip, .tar.gz) - -p, --install-path Custom installation directory - -o, --output-directory - Only download selected asset to the specified directory - -j, --releases-json Download latest release JSON only - --system Override detected system for asset matching - --arch Override detected architecture for asset matching - -k, --token GitHub token - -d, --debug Enable debug logging - -h, --help Show this help message - `; -} - -function ensureOptionValue(argv: string[], index: number, option: string): string { - const value = argv[index + 1]; - if (!value || value.startsWith('-')) { - throw new Error(`Missing value for ${option}.`); - } - return value; -} - -function parseCliArgs(argv: string[]): CliOptions { - const envDryRun = process.env.TEST_MODE; - const dryRunLevelFromEnv = envDryRun && /^\d+$/.test(envDryRun) ? parseInt(envDryRun, 10) : 0; - - const opts: CliOptions = { - releasesJsonOnly: false, - listOnly: false, - debug: false, - help: false, - dryRunLevel: dryRunLevelFromEnv, - positionals: [] - }; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - switch (arg) { - case '-h': - case '--help': - opts.help = true; - break; - case '--dry-run': { - const next = argv[i + 1]; - if (next && /^\d+$/.test(next)) { - opts.dryRunLevel = parseInt(next, 10); - i++; - } else { - opts.dryRunLevel = 1; - } - break; - } - case '-l': - case '--list': { - opts.listOnly = true; - const next = argv[i + 1]; - if (next && !next.startsWith('-')) { - opts.listRepo = next; - i++; - } - break; - } - case '-a': - case '--app-name': - opts.appName = ensureOptionValue(argv, i, arg); - i++; - break; - case '-f': - case '--file-name': - opts.fileName = ensureOptionValue(argv, i, arg); - i++; - break; - case '-b': - case '--binary-name': - opts.binaryName = ensureOptionValue(argv, i, arg); - i++; - break; - case '-t': - case '--file-type': { - const fileType = ensureOptionValue(argv, i, arg); - if (!fileType.trim()) { - throw new Error(`Unknown asset type: ${fileType}`); - } - opts.fileType = fileType; - i++; - break; - } - case '-p': - case '--install-path': - opts.installPath = ensureOptionValue(argv, i, arg); - i++; - break; - case '-o': - case '--output-directory': - opts.outputDirectory = ensureOptionValue(argv, i, arg); - i++; - break; - case '-j': - case '--releases-json': - opts.releasesJsonOnly = true; - break; - case '--system': - opts.systemOverride = ensureOptionValue(argv, i, arg); - i++; - break; - case '--arch': - opts.archOverride = ensureOptionValue(argv, i, arg); - i++; - break; - case '-k': - case '--token': - opts.token = ensureOptionValue(argv, i, arg); - i++; - break; - case '-d': - case '--debug': - opts.debug = true; - break; - default: - if (arg.startsWith('-')) { - throw new Error(`Unknown option: ${arg}`); - } - opts.positionals.push(arg); - break; - } - } - - return opts; -} - function validateOutputDirectory(outputDirectory: string): string { const resolvedPath = path.resolve(outputDirectory); if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) { @@ -228,135 +85,171 @@ function installSystemPackage(downloadPath: string): void { async function run() { let tempDir: string | undefined; - try { - const options = parseCliArgs(process.argv.slice(2)); + const program = new Command(); + program + .name('install-github-release') + .usage('[options] ') + .argument('[repository]', 'The GitHub repository (owner/repo)') + .option('--dry-run', 'Run in test mode') + .option('-l, --list [repository]', 'List available assets from latest release and exit') + .option('-a, --app-name ', 'Application name (optional, for output messages)') + .option('-f, --file-name ', 'Asset file name or regex pattern (prefixed with ~)') + .option('-b, --binary-name ', 'Binary name (supports source:destination form)') + .option('-t, --file-type ', 'Known: archive|package|linux|macos|targz; custom: ~ or extension') + .option('-p, --install-path ', 'Custom installation directory') + .option('-o, --output-directory ', 'Only download selected asset to the specified directory') + .option('-j, --releases-json', 'Download latest release JSON only') + .option('--system ', 'Override detected system for asset matching') + .option('--arch ', 'Override detected architecture for asset matching') + .option('-k, --token ', 'GitHub token') + .option('-d, --debug', 'Enable debug logging') + .allowUnknownOption(false); - const repository = options.listRepo || options.positionals[0]; - if (options.help || !repository) { - console.log(usage()); - process.exit(options.help ? 0 : 1); - } - - const token = options.token || process.env.GITHUB_TOKEN; - - if (options.listOnly) { - const release = await fetchLatestRelease(repository, token); - release.assets.forEach((asset) => console.log(`- ${asset.browser_download_url}`)); - process.exit(0); - } - - const toolName = repository.split('/').pop() || repository; - const appName = options.appName || (toolName.charAt(0).toUpperCase() + toolName.slice(1)); - - const binaryOption = options.binaryName || toolName; - const [binarySource, binaryDestination] = binaryOption.includes(':') - ? [binaryOption.split(':')[0], binaryOption.split(':')[1]] - : [binaryOption, binaryOption]; - - if (options.releasesJsonOnly) { - const rawRelease = await fetchLatestReleaseRaw(repository, token); - const outputBase = binaryDestination || toolName; - const outputName = `${outputBase}.releases.json`; - const outputPath = options.outputDirectory - ? path.join(validateOutputDirectory(options.outputDirectory), outputName) - : outputName; - - fs.writeFileSync(outputPath, rawRelease, 'utf8'); - console.log(`Downloaded GitHub releases to ${outputPath}.`); - process.exit(0); - } - - const platformInfo = getPlatformInfo({ - system: options.systemOverride, - arch: options.archOverride - }); - - console.log(`Fetching latest release for ${repository}...`); - const release = await fetchLatestRelease(repository, token); - const asset = getMatchingAsset(release.assets, platformInfo, options.fileName, options.fileType); - - const version = release.tag_name.replace(/^v/i, ''); - const downloadUrl = asset.browser_download_url; - console.log(`Will download '${appName}' version: ${version}`); - console.log(`Download URL: "${downloadUrl}".`); - - if (options.dryRunLevel > 0) { - process.exit(0); - } - - if (options.outputDirectory) { - const outputDir = validateOutputDirectory(options.outputDirectory); - const outputPath = path.join(outputDir, path.basename(downloadUrl)); - console.log(`Downloading '${appName}' version ${version} to '${outputPath}'...`); - await downloadAsset(downloadUrl, outputPath, token); - process.exit(0); - } - - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-gh-release-')); - const downloadPath = path.join(tempDir, asset.name); - await downloadAsset(downloadUrl, downloadPath, token); - - if (/\.(deb|pkg|rpm)$/i.test(asset.name)) { - installSystemPackage(downloadPath); - console.log('Installation successful!'); - process.exit(0); - } - - const extractDir = path.join(tempDir, 'extract'); - console.log(`Extracting ${asset.name}...`); - await extractAsset(downloadPath, extractDir); - - let binaryPattern: string | RegExp; - if (binarySource.startsWith('~')) { - binaryPattern = new RegExp(binarySource.substring(1), 'i'); - } else { - binaryPattern = binarySource; - } - - const binaryPath = findBinary(extractDir, binaryPattern, options.debug, console.log); - if (!binaryPath) { - throw new Error(`Could not find binary "${binarySource}" in the extracted asset.`); - } - - const installDir = getInstallDir(options.installPath); - if (!fs.existsSync(installDir)) { - fs.mkdirSync(installDir, { recursive: true }); - } - - const finalName = binaryDestination || path.basename(binaryPath); - const destPath = path.join(installDir, finalName); - - console.log(`Installing ${finalName} to ${destPath}...`); - try { - fs.copyFileSync(binaryPath, destPath); - } catch (err: any) { - if (err.code === 'EBUSY') { - throw new Error(`The file ${destPath} is currently in use. Please close any running instances and try again.`); - } - if (err.code === 'EACCES' || err.code === 'EPERM') { - throw new Error(`Permission denied while installing to ${destPath}. Try running with sudo or as administrator, or use -p to specify a custom path.`); - } - throw err; - } - - if (process.platform !== 'win32') { - fs.chmodSync(destPath, '755'); - } - - console.log('Installation successful!'); - process.exit(0); - } catch (error: any) { - if (error?.message) { - console.error(`Error: ${error.message}`); - } else { - console.error('Error: Unknown failure.'); - } - process.exit(1); - } finally { + const cleanupTempDir = () => { if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } + }; + + program.parse(process.argv); + const parsedOptions = program.opts(); + const rawFileType = parsedOptions.fileType; + const fileType = typeof rawFileType === 'string' ? rawFileType.trim() : undefined; + if (rawFileType !== undefined && !fileType) { + throw new Error(`Unknown asset type: ${rawFileType}`); } + + const listValue = parsedOptions.list as string | boolean | undefined; + const options: CliOptions = { + appName: parsedOptions.appName as string | undefined, + fileName: parsedOptions.fileName as string | undefined, + binaryName: parsedOptions.binaryName as string | undefined, + fileType, + installPath: parsedOptions.installPath as string | undefined, + outputDirectory: parsedOptions.outputDirectory as string | undefined, + releasesJsonOnly: Boolean(parsedOptions.releasesJson), + listOnly: listValue !== undefined, + token: parsedOptions.token as string | undefined, + debug: Boolean(parsedOptions.debug), + dryRun: Boolean(parsedOptions.dryRun), + systemOverride: parsedOptions.system as string | undefined, + archOverride: parsedOptions.arch as string | undefined, + listRepo: typeof listValue === 'string' ? listValue : undefined, + positionals: program.args + }; + + const repository = options.listRepo || options.positionals[0]; + if (!repository) { + program.outputHelp(); + process.exit(1); + } + + const token = options.token; + + if (options.listOnly) { + const release = await fetchLatestRelease(repository, token); + release.assets.forEach((asset) => console.log(`- ${asset.browser_download_url}`)); + process.exit(0); + } + + const toolName = repository.split('/').pop() || repository; + const appName = options.appName || (toolName.charAt(0).toUpperCase() + toolName.slice(1)); + + const binaryOption = options.binaryName || toolName; + const [binarySource, binaryDestination] = binaryOption.includes(':') + ? [binaryOption.split(':')[0], binaryOption.split(':')[1]] + : [binaryOption, binaryOption]; + + if (options.releasesJsonOnly) { + const rawRelease = await fetchLatestReleaseRaw(repository, token); + const outputBase = binaryDestination || toolName; + const outputName = `${outputBase}.releases.json`; + const outputPath = options.outputDirectory + ? path.join(validateOutputDirectory(options.outputDirectory), outputName) + : outputName; + + fs.writeFileSync(outputPath, rawRelease, 'utf8'); + console.log(`Downloaded GitHub releases to ${outputPath}.`); + process.exit(0); + } + + const platformInfo = getPlatformInfo({ + system: options.systemOverride, + arch: options.archOverride + }); + + console.log(`Fetching latest release for ${repository}...`); + const release = await fetchLatestRelease(repository, token); + const asset = getMatchingAsset(release.assets, platformInfo, options.fileName, options.fileType); + + const version = release.tag_name.replace(/^v/i, ''); + const downloadUrl = asset.browser_download_url; + console.log(`Will download '${appName}' version: ${version}`); + console.log(`Download URL: "${downloadUrl}".`); + + if (options.dryRun) { + process.exit(0); + } + + if (options.outputDirectory) { + const outputDir = validateOutputDirectory(options.outputDirectory); + const outputPath = path.join(outputDir, path.basename(downloadUrl)); + console.log(`Downloading '${appName}' version ${version} to '${outputPath}'...`); + await downloadAsset(downloadUrl, outputPath, token); + process.exit(0); + } + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-gh-release-')); + process.once('exit', cleanupTempDir); + const downloadPath = path.join(tempDir, asset.name); + await downloadAsset(downloadUrl, downloadPath, token); + + if (/\.(deb|pkg|rpm)$/i.test(asset.name)) { + installSystemPackage(downloadPath); + console.log('Installation successful!'); + process.exit(0); + } + + const extractDir = path.join(tempDir, 'extract'); + console.log(`Extracting ${asset.name}...`); + await extractAsset(downloadPath, extractDir); + + let binaryPattern: string | RegExp; + if (binarySource.startsWith('~')) { + binaryPattern = new RegExp(binarySource.substring(1), 'i'); + } else { + binaryPattern = binarySource; + } + + const binaryPath = findBinary(extractDir, binaryPattern, options.debug, console.log); + if (!binaryPath) { + throw new Error(`Could not find binary "${binarySource}" in the extracted asset.`); + } + + const installDir = getInstallDir(options.installPath); + if (!fs.existsSync(installDir)) { + fs.mkdirSync(installDir, { recursive: true }); + } + + const finalName = binaryDestination || path.basename(binaryPath); + const destPath = path.join(installDir, finalName); + + console.log(`Installing ${finalName} to ${destPath}...`); + fs.copyFileSync(binaryPath, destPath); + + if (process.platform !== 'win32') { + fs.chmodSync(destPath, '755'); + } + + console.log('Installation successful!'); + process.exit(0); } -run(); +void run().catch((error: unknown) => { + if (error instanceof Error && error.message) { + console.error(`Error: ${error.message}`); + } else { + console.error('Error: Unknown failure.'); + } + process.exit(1); +});