Matched functionality of the CLI with the Bash predecessor.
This commit is contained in:
364
src/cli.ts
364
src/cli.ts
@@ -1,123 +1,332 @@
|
||||
import { parseArgs } from 'util';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { getPlatformInfo } from './core/platform';
|
||||
import { getMatchingAsset } from './core/matcher';
|
||||
import { findBinary } from './core/finder';
|
||||
import { fetchLatestRelease, downloadAsset } from './core/downloader';
|
||||
import { fetchLatestRelease, fetchLatestReleaseRaw, downloadAsset } from './core/downloader';
|
||||
import { extractAsset } from './core/extractor';
|
||||
|
||||
async function run() {
|
||||
const { values, positionals } = parseArgs({
|
||||
options: {
|
||||
'file-name': { type: 'string', short: 'f' },
|
||||
'binary-name': { type: 'string', short: 'b' },
|
||||
'file-type': { type: 'string', short: 't', default: 'archive' },
|
||||
'install-path': { type: 'string', short: 'p' },
|
||||
'token': { type: 'string', short: 'k' },
|
||||
'debug': { type: 'boolean', short: 'd', default: false },
|
||||
'help': { type: 'boolean', short: 'h' }
|
||||
},
|
||||
allowPositionals: true
|
||||
});
|
||||
interface CliOptions {
|
||||
appName?: string;
|
||||
fileName?: string;
|
||||
binaryName?: string;
|
||||
fileType?: string;
|
||||
installPath?: string;
|
||||
outputDirectory?: string;
|
||||
releasesJsonOnly: boolean;
|
||||
listOnly: boolean;
|
||||
token?: string;
|
||||
debug: boolean;
|
||||
help: boolean;
|
||||
dryRunLevel: number;
|
||||
systemOverride?: string;
|
||||
archOverride?: string;
|
||||
listRepo?: string;
|
||||
positionals: string[];
|
||||
}
|
||||
|
||||
if (values.help || positionals.length === 0) {
|
||||
console.log(`
|
||||
function usage(): string {
|
||||
return `
|
||||
Usage: install-github-release [options] <repository>
|
||||
|
||||
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 <name> Application name (optional, for output messages)
|
||||
-f, --file-name <name> Asset file name or regex pattern (prefixed with ~)
|
||||
-b, --binary-name <name> Binary to search for (prefixed with ~ for regex)
|
||||
-t, --file-type <type> 'archive', 'package', or custom regex (default: archive)
|
||||
-b, --binary-name <name> Binary name (supports source:destination form)
|
||||
-t, --file-type <type> archive|package|zip|gzip|gz|tar|tar.gz|tgz|deb|pkg|rpm
|
||||
-p, --install-path <path> Custom installation directory
|
||||
-o, --output-directory <path>
|
||||
Only download selected asset to the specified directory
|
||||
-j, --releases-json Download latest release JSON only
|
||||
--system <name> Override detected system for asset matching
|
||||
--arch <name> Override detected architecture for asset matching
|
||||
-k, --token <token> GitHub token
|
||||
-d, --debug Enable debug logging
|
||||
-h, --help Show this help message
|
||||
`);
|
||||
process.exit(0);
|
||||
`;
|
||||
}
|
||||
|
||||
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).toLowerCase();
|
||||
const knownType = /^(archive|package|zip|gzip|gz|tar|tar\.gz|tgz|deb|pkg|rpm)$/i;
|
||||
if (!knownType.test(fileType)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const repository = positionals[0];
|
||||
if (!repository) {
|
||||
console.error('Error: Repository is required.');
|
||||
process.exit(1);
|
||||
return opts;
|
||||
}
|
||||
|
||||
function validateOutputDirectory(outputDirectory: string): string {
|
||||
const resolvedPath = path.resolve(outputDirectory);
|
||||
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
|
||||
throw new Error(`Output directory "${resolvedPath}" does not exist.`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function getInstallDir(installPath?: string): string {
|
||||
if (installPath) {
|
||||
return path.resolve(installPath);
|
||||
}
|
||||
|
||||
const fileNameInput = values['file-name'];
|
||||
const binaryInput = values['binary-name'];
|
||||
const fileType = values['file-type'];
|
||||
const debug = !!values.debug;
|
||||
const token = values.token || process.env.GITHUB_TOKEN;
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
return path.join(localAppData, 'bin');
|
||||
}
|
||||
|
||||
const isRoot = process.getuid && process.getuid() === 0;
|
||||
if (isRoot) {
|
||||
return '/usr/local/bin';
|
||||
}
|
||||
|
||||
const homeBin = path.join(os.homedir(), 'bin');
|
||||
if (fs.existsSync(homeBin)) {
|
||||
return homeBin;
|
||||
}
|
||||
|
||||
return '/usr/local/bin';
|
||||
}
|
||||
|
||||
function installSystemPackage(downloadPath: string): void {
|
||||
const fileName = path.basename(downloadPath).toLowerCase();
|
||||
|
||||
const command: { binary: string; args: string[] } | undefined = fileName.endsWith('.deb')
|
||||
? { binary: 'dpkg', args: ['-i', downloadPath] }
|
||||
: fileName.endsWith('.pkg')
|
||||
? { binary: 'installer', args: ['-pkg', downloadPath, '-target', '/'] }
|
||||
: fileName.endsWith('.rpm')
|
||||
? { binary: 'rpm', args: ['-i', downloadPath] }
|
||||
: undefined;
|
||||
|
||||
if (!command) {
|
||||
throw new Error(`Unsupported package type: ${fileName}`);
|
||||
}
|
||||
|
||||
const isRoot = process.getuid && process.getuid() === 0;
|
||||
const commandToRun = isRoot ? command.binary : 'sudo';
|
||||
const argsToRun = isRoot ? command.args : [command.binary, ...command.args];
|
||||
|
||||
const result = spawnSync(commandToRun, argsToRun, { stdio: 'inherit' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to install package using ${commandToRun} ${argsToRun.join(' ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let tempDir: string | undefined;
|
||||
try {
|
||||
const platformInfo = getPlatformInfo();
|
||||
const options = parseCliArgs(process.argv.slice(2));
|
||||
|
||||
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, {
|
||||
fileName: fileNameInput,
|
||||
fileType: fileType
|
||||
fileName: options.fileName,
|
||||
fileType: options.fileType
|
||||
});
|
||||
|
||||
console.log(`Selected asset: ${asset.name}`);
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-gh-release-'));
|
||||
const downloadPath = path.join(tempDir, asset.name);
|
||||
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}".`);
|
||||
|
||||
console.log(`Downloading ${asset.name}...`);
|
||||
await downloadAsset(asset.browser_download_url, downloadPath, token);
|
||||
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);
|
||||
|
||||
const binaryName = binaryInput || toolName;
|
||||
let binaryPattern: string | RegExp;
|
||||
if (binaryName.startsWith('~')) {
|
||||
binaryPattern = new RegExp(binaryName.substring(1), 'i');
|
||||
if (binarySource.startsWith('~')) {
|
||||
binaryPattern = new RegExp(binarySource.substring(1), 'i');
|
||||
} else {
|
||||
binaryPattern = binaryName;
|
||||
binaryPattern = binarySource;
|
||||
}
|
||||
|
||||
const binaryPath = findBinary(extractDir, binaryPattern, debug, console.log);
|
||||
const binaryPath = findBinary(extractDir, binaryPattern, options.debug, console.log);
|
||||
if (!binaryPath) {
|
||||
throw new Error(`Could not find binary "${binaryName}" in the extracted asset.`);
|
||||
}
|
||||
|
||||
// Determine install directory
|
||||
let installDir: string;
|
||||
|
||||
if (values['install-path']) {
|
||||
installDir = path.resolve(values['install-path']);
|
||||
} else {
|
||||
if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
installDir = path.join(localAppData, 'bin');
|
||||
} else {
|
||||
const isRoot = process.getuid && process.getuid() === 0;
|
||||
|
||||
if (isRoot) {
|
||||
installDir = '/usr/local/bin';
|
||||
} else {
|
||||
const homeBin = path.join(os.homedir(), 'bin');
|
||||
if (fs.existsSync(homeBin)) {
|
||||
installDir = homeBin;
|
||||
} else {
|
||||
installDir = '/usr/local/bin';
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = path.basename(binaryPath);
|
||||
const finalName = binaryDestination || path.basename(binaryPath);
|
||||
const destPath = path.join(installDir, finalName);
|
||||
|
||||
console.log(`Installing ${finalName} to ${destPath}...`);
|
||||
@@ -137,14 +346,19 @@ Options:
|
||||
fs.chmodSync(destPath, '755');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
console.log('Installation successful!');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
if (error?.message) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
} else {
|
||||
console.error('Error: Unknown failure.');
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user