Feat: Integrate commander for improved CLI argument parsing

This commit is contained in:
2026-04-06 23:21:03 +02:00
parent e50ae03336
commit 01d844f6d9
3 changed files with 174 additions and 270 deletions

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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] <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 name (supports source:destination form)
-t, --file-type <type> Known: archive|package|linux|macos|targz
Or custom: ~<regex> (end-of-string match) or extension (e.g. zip, .tar.gz)
-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
`;
}
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,16 +85,66 @@ 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] <repository>')
.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 <name>', 'Application name (optional, for output messages)')
.option('-f, --file-name <name>', 'Asset file name or regex pattern (prefixed with ~)')
.option('-b, --binary-name <name>', 'Binary name (supports source:destination form)')
.option('-t, --file-type <type>', 'Known: archive|package|linux|macos|targz; custom: ~<regex> or extension')
.option('-p, --install-path <path>', 'Custom installation directory')
.option('-o, --output-directory <path>', 'Only download selected asset to the specified directory')
.option('-j, --releases-json', 'Download latest release JSON only')
.option('--system <name>', 'Override detected system for asset matching')
.option('--arch <name>', 'Override detected architecture for asset matching')
.option('-k, --token <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 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 token = options.token || process.env.GITHUB_TOKEN;
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);
@@ -280,7 +187,7 @@ async function run() {
console.log(`Will download '${appName}' version: ${version}`);
console.log(`Download URL: "${downloadUrl}".`);
if (options.dryRunLevel > 0) {
if (options.dryRun) {
process.exit(0);
}
@@ -293,6 +200,7 @@ async function run() {
}
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);
@@ -327,17 +235,7 @@ async function run() {
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');
@@ -345,18 +243,13 @@ async function run() {
console.log('Installation successful!');
process.exit(0);
} catch (error: any) {
if (error?.message) {
}
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);
} finally {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}
run();
});