Matched functionality of the CLI with the Bash predecessor.

This commit is contained in:
2026-04-06 12:00:09 +02:00
parent 483a1c5f13
commit dfa641afd4
13 changed files with 43002 additions and 240 deletions

View File

@@ -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 });
}
}
}

View File

@@ -1,6 +1,3 @@
import { getMatchingAsset } from './matcher';
import { PlatformInfo } from './platform';
export interface ReleaseAsset {
name: string;
browser_download_url: string;
@@ -11,8 +8,7 @@ export interface ReleaseInfo {
assets: ReleaseAsset[];
}
export async function fetchLatestRelease(repository: string, token?: string): Promise<ReleaseInfo> {
const url = `https://api.github.com/repos/${repository}/releases/latest`;
function getGithubApiHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'setup-github-release-action'
@@ -20,6 +16,12 @@ export async function fetchLatestRelease(repository: string, token?: string): Pr
if (token) {
headers['Authorization'] = `token ${token}`;
}
return headers;
}
export async function fetchLatestRelease(repository: string, token?: string): Promise<ReleaseInfo> {
const url = `https://api.github.com/repos/${repository}/releases/latest`;
const headers = getGithubApiHeaders(token);
const response = await fetch(url, { headers });
if (!response.ok) {
@@ -30,6 +32,18 @@ export async function fetchLatestRelease(repository: string, token?: string): Pr
return await response.json() as ReleaseInfo;
}
export async function fetchLatestReleaseRaw(repository: string, token?: string): Promise<string> {
const url = `https://api.github.com/repos/${repository}/releases/latest`;
const headers = getGithubApiHeaders(token);
const response = await fetch(url, { headers });
const body = await response.text();
if (!response.ok) {
throw new Error(`Failed to fetch latest release for ${repository}: ${response.statusText}. ${body}`);
}
return body;
}
export async function downloadAsset(url: string, destPath: string, token?: string): Promise<void> {
const headers: Record<string, string> = {
'User-Agent': 'setup-github-release-action'

View File

@@ -5,20 +5,67 @@ export interface MatchOptions {
fileType?: string;
}
export function getMatchingAsset(assets: any[], platform: PlatformInfo, options: MatchOptions): any {
const { fileName, fileType = 'archive' } = options;
let extPattern: string;
if (fileType === 'archive') {
extPattern = '\\.(zip|tar\\.gz|tar|tgz|7z)';
} else if (fileType === 'package') {
extPattern = '\\.(deb|rpm|pkg)';
} else {
extPattern = fileType;
function normalizeCustomExtensionPattern(fileType: string): string {
let pattern = fileType;
if (!pattern.endsWith('$')) {
pattern += '$';
}
if (!pattern.startsWith('\\.')) {
pattern = `\\.${pattern}`;
}
return pattern;
}
function getExtPattern(fileType: string | undefined, system: string): string {
const normalizedType = (fileType || '').toLowerCase();
if (!normalizedType) {
if (system === 'linux') {
return '\\.(deb|rpm|zip|tar\\.gz|tgz)$';
}
if (system === 'darwin' || system === 'macos' || system === 'mac' || system === 'osx') {
return '\\.(pkg|zip|tar\\.gz|tgz)$';
}
return '\\.(zip|tar\\.gz|tgz)$';
}
if (normalizedType === 'archive') {
return '\\.(zip|tar\\.gz|tgz)$';
}
if (normalizedType === 'package') {
return '\\.(deb|pkg|rpm)$';
}
const shorthandTypePatterns: Record<string, string> = {
zip: '\\.(zip)$',
gzip: '\\.(tar\\.gz|tgz)$',
gz: '\\.(tar\\.gz|tgz)$',
tar: '\\.(tar)$',
'tar.gz': '\\.(tar\\.gz)$',
tgz: '\\.(tgz)$',
deb: '\\.(deb)$',
pkg: '\\.(pkg)$',
rpm: '\\.(rpm)$'
};
if (shorthandTypePatterns[normalizedType]) {
return shorthandTypePatterns[normalizedType];
}
return normalizeCustomExtensionPattern(fileType || '');
}
export function getMatchingAsset(assets: any[], platform: PlatformInfo, options: MatchOptions): any {
const { fileName, fileType } = options;
const extPattern = getExtPattern(fileType, platform.system);
if (!fileName) {
// Rule 1: Default matching rule
const pattern = `${platform.systemPattern}[_-]${platform.archPattern}.*${extPattern}$`;
const pattern = `${platform.systemPattern}[_-]${platform.archPattern}.*${extPattern}`;
const regex = new RegExp(pattern, 'i');
const matchingAssets = assets.filter((a: any) => regex.test(a.name));
if (matchingAssets.length === 0) {

View File

@@ -7,6 +7,11 @@ export interface PlatformInfo {
archPattern: string;
}
export interface PlatformOverrides {
system?: string;
arch?: string;
}
export const systemPatterns: Record<string, string> = {
linux: 'linux',
darwin: '(darwin|macos|mac|osx)',
@@ -18,9 +23,9 @@ export const archPatterns: Record<string, string> = {
arm64: '(aarch64|arm64)'
};
export function getPlatformInfo(): PlatformInfo {
const system = os.platform();
const arch = os.arch();
export function getPlatformInfo(overrides?: PlatformOverrides): PlatformInfo {
const system = (overrides?.system || os.platform()).toLowerCase();
const arch = (overrides?.arch || os.arch()).toLowerCase();
return {
system,

View File

@@ -1,19 +1,71 @@
import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import { spawnSync } from 'child_process';
import { getPlatformInfo } from './core/platform';
import { getMatchingAsset } from './core/matcher';
import { findBinary } from './core/finder';
import { fetchLatestRelease } from './core/downloader';
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(' ')}.`);
}
}
function findInstalledBinary(binaryName: string): string | undefined {
const isRegex = binaryName.startsWith('~');
if (!isRegex) {
const whichResult = spawnSync('which', [binaryName], { encoding: 'utf8' });
if (whichResult.status === 0) {
const resolvedPath = (whichResult.stdout || '').trim();
if (resolvedPath) {
return resolvedPath;
}
}
}
const candidates = ['/usr/local/bin', '/usr/bin', '/opt/homebrew/bin', '/opt/local/bin'];
const pattern: string | RegExp = isRegex ? new RegExp(binaryName.substring(1), 'i') : binaryName;
for (const candidateDir of candidates) {
if (!fs.existsSync(candidateDir)) {
continue;
}
const candidatePath = findBinary(candidateDir, pattern, false, () => undefined);
if (candidatePath) {
return candidatePath;
}
}
return undefined;
}
async function run() {
try {
const repository = core.getInput('repository', { required: true });
const fileNameInput = core.getInput('file-name');
const binaryInput = core.getInput('binary-name');
const fileType = core.getInput('file-type') || 'archive';
const fileType = core.getInput('file-type');
const updateCache = core.getInput('update-cache') || 'false';
const debug = core.getBooleanInput('debug');
const token = core.getInput('token') || process.env.GITHUB_TOKEN;
@@ -67,6 +119,21 @@ async function run() {
const nameLower = asset.name.toLowerCase();
let toolDir: string;
if (/\.(deb|pkg|rpm)$/i.test(nameLower)) {
core.info(`Installing package asset ${asset.name}...`);
installSystemPackage(downloadPath);
const binaryPath = findInstalledBinary(binaryName);
if (!binaryPath) {
throw new Error(`Package installed, but binary "${binaryName}" could not be located in common executable paths.`);
}
const binaryDir = path.dirname(binaryPath);
core.addPath(binaryDir);
core.info(`Binary found at ${binaryPath}. Added ${binaryDir} to PATH.`);
return;
}
// Determine extraction method based on extension
if (/\.(tar\.gz|tar|tgz)$/i.test(nameLower)) {
toolDir = await tc.extractTar(downloadPath);