Compare commits
10
Commits
43c917e526
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc753ba00a | ||
|
|
fbe4fab4e7 | ||
|
|
dbea784721 | ||
|
|
1ab8c20f6b | ||
|
|
2d33c06573 | ||
|
|
3a84325932 | ||
|
|
3719e9ca1f | ||
|
|
bc6d44f0d9 | ||
|
|
9c3cb2115c | ||
|
|
25adff380c |
@@ -0,0 +1,42 @@
|
|||||||
|
name: Test Python CLI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/test-python.yml'
|
||||||
|
- 'python/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-python:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
|
||||||
|
- name: Check Authentication
|
||||||
|
run: python3 python/install_github_release.py --check-token -k "${{ secrets.GH_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Go ACME Setup
|
||||||
|
run: python3 python/install_github_release.py go-acme/lego -t archive -k "${{ secrets.GH_TOKEN }}" -p /tmp/python-cli-test
|
||||||
|
|
||||||
|
- name: Setup Hugo
|
||||||
|
run: python3 python/install_github_release.py gohugoio/hugo -f '~hugo_extended_[^a-z].*{{SYSTEM}}-{{ARCH}}' -t archive -k "${{ secrets.GH_TOKEN }}" -p /tmp/python-cli-test
|
||||||
|
|
||||||
|
- name: Setup RClone
|
||||||
|
run: python3 python/install_github_release.py rclone/rclone -t archive -k "${{ secrets.GH_TOKEN }}" -p /tmp/python-cli-test
|
||||||
|
|
||||||
|
- name: Verify Installation
|
||||||
|
run: |
|
||||||
|
echo "Verifying installed tools..."
|
||||||
|
printf "\nGo ACME Lego:\n"
|
||||||
|
/tmp/python-cli-test/lego -v
|
||||||
|
printf "\nHugo:\n"
|
||||||
|
/tmp/python-cli-test/hugo version
|
||||||
|
printf "\nRClone:\n"
|
||||||
|
/tmp/python-cli-test/rclone version
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Test Python CLI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/test-python.yml'
|
||||||
|
- 'python/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-python:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
|
||||||
|
- name: Check Authentication
|
||||||
|
run: python3 python/install_github_release.py --check-token -k "${{ github.token }}"
|
||||||
|
|
||||||
|
- name: Go ACME Setup
|
||||||
|
run: python3 python/install_github_release.py go-acme/lego -t archive -k "${{ github.token }}" -p /tmp/python-cli-test
|
||||||
|
|
||||||
|
- name: Setup Hugo
|
||||||
|
run: python3 python/install_github_release.py gohugoio/hugo -f '~hugo_extended_[^a-z].*{{SYSTEM}}-{{ARCH}}' -t archive -k "${{ github.token }}" -p /tmp/python-cli-test
|
||||||
|
|
||||||
|
- name: Setup RClone
|
||||||
|
run: python3 python/install_github_release.py rclone/rclone -t archive -k "${{ github.token }}" -p /tmp/python-cli-test
|
||||||
|
|
||||||
|
- name: Verify Installation
|
||||||
|
run: |
|
||||||
|
echo "Verifying installed tools..."
|
||||||
|
printf "\nGo ACME Lego:\n"
|
||||||
|
/tmp/python-cli-test/lego -v
|
||||||
|
printf "\nHugo:\n"
|
||||||
|
/tmp/python-cli-test/hugo version
|
||||||
|
printf "\nRClone:\n"
|
||||||
|
/tmp/python-cli-test/rclone version
|
||||||
@@ -20,15 +20,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Check Authentication
|
- name: Check Authentication
|
||||||
uses: ./check-token
|
uses: ./check-token
|
||||||
with:
|
|
||||||
token: ${{ secrets.GH_TOKEN }}
|
|
||||||
|
|
||||||
- name: Go ACME Setup
|
- name: Go ACME Setup
|
||||||
uses: ./
|
uses: ./
|
||||||
with:
|
with:
|
||||||
repository: 'go-acme/lego'
|
repository: 'go-acme/lego'
|
||||||
file-type: 'archive'
|
file-type: 'archive'
|
||||||
token: ${{ secrets.GH_TOKEN }}
|
|
||||||
update-cache: 'always'
|
update-cache: 'always'
|
||||||
|
|
||||||
- name: Setup Hugo
|
- name: Setup Hugo
|
||||||
@@ -37,7 +34,6 @@ jobs:
|
|||||||
repository: 'gohugoio/hugo'
|
repository: 'gohugoio/hugo'
|
||||||
file-name: '~hugo_extended_[^a-z].*{{SYSTEM}}-{{ARCH}}'
|
file-name: '~hugo_extended_[^a-z].*{{SYSTEM}}-{{ARCH}}'
|
||||||
file-type: 'archive'
|
file-type: 'archive'
|
||||||
token: ${{ secrets.GH_TOKEN }}
|
|
||||||
update-cache: 'always'
|
update-cache: 'always'
|
||||||
|
|
||||||
- name: Setup RClone
|
- name: Setup RClone
|
||||||
@@ -45,7 +41,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repository: 'rclone/rclone'
|
repository: 'rclone/rclone'
|
||||||
file-type: 'archive'
|
file-type: 'archive'
|
||||||
token: ${{ secrets.GH_TOKEN }}
|
|
||||||
update-cache: 'always'
|
update-cache: 'always'
|
||||||
|
|
||||||
- name: Verify Installation
|
- name: Verify Installation
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ lib/
|
|||||||
.github/*.md
|
.github/*.md
|
||||||
dist/cli.js
|
dist/cli.js
|
||||||
__pycache__
|
__pycache__
|
||||||
|
.vscode
|
||||||
|
|||||||
Executable
+474
@@ -0,0 +1,474 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SYSTEM_PATTERNS = {
|
||||||
|
'linux': 'linux',
|
||||||
|
'darwin': '(darwin|macos|mac|osx)',
|
||||||
|
'win32': '(windows|win)',
|
||||||
|
}
|
||||||
|
|
||||||
|
ARCH_PATTERNS = {
|
||||||
|
'x64': '(x86_64|x64|amd64|universal)',
|
||||||
|
'arm64': '(aarch64|arm64|universal)',
|
||||||
|
}
|
||||||
|
|
||||||
|
MACHINE_TO_ARCH = {
|
||||||
|
'x86_64': 'x64',
|
||||||
|
'amd64': 'x64',
|
||||||
|
'aarch64': 'arm64',
|
||||||
|
'arm64': 'arm64',
|
||||||
|
}
|
||||||
|
|
||||||
|
KNOWN_FILE_TYPES = {
|
||||||
|
'archive': ('.zip', '.tar.gz', '.tgz'),
|
||||||
|
'package': ('.deb', '.pkg', '.rpm'),
|
||||||
|
'linux': ('.deb', '.rpm'),
|
||||||
|
'macos': ('.pkg',),
|
||||||
|
'targz': ('.tgz', '.tar.gz'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformInfo:
|
||||||
|
"""Holds the detected OS/architecture and their regex patterns for asset matching."""
|
||||||
|
|
||||||
|
def __init__(self, system, arch):
|
||||||
|
self.system = system
|
||||||
|
self.arch = arch
|
||||||
|
self.system_pattern = SYSTEM_PATTERNS.get(system, system)
|
||||||
|
self.arch_pattern = ARCH_PATTERNS.get(arch, arch)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_platform_placeholders(pattern, platform):
|
||||||
|
"""Substitute {{SYSTEM}} and {{ARCH}} in a pattern with the platform's regex fragments."""
|
||||||
|
return pattern.replace('{{SYSTEM}}', platform.system_pattern).replace('{{ARCH}}', platform.arch_pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def get_github_api_headers(token=None):
|
||||||
|
"""Build the request headers for the GitHub REST API, adding an Authorization header if a token is given."""
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'install-github-release-cli',
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'token {token}'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_latest_release(repository, token=None):
|
||||||
|
"""Fetch and parse the latest release metadata for a GitHub repository."""
|
||||||
|
url = f'https://api.github.com/repos/{repository}/releases/latest'
|
||||||
|
request = urllib.request.Request(url, headers=get_github_api_headers(token))
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
return json.loads(response.read().decode('utf-8'))
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
body = error.read().decode('utf-8', errors='replace')
|
||||||
|
raise RuntimeError(f'Failed to fetch latest release for {repository}: {error.reason}. {body}') from error
|
||||||
|
|
||||||
|
|
||||||
|
def check_github_token(token):
|
||||||
|
"""Verify a GitHub token by fetching the latest release of actions/checkout."""
|
||||||
|
print('Verifying GitHub token...')
|
||||||
|
fetch_latest_release('actions/checkout', token)
|
||||||
|
print('Success: The provided GitHub token is valid and has sufficient permissions to access public repositories.')
|
||||||
|
|
||||||
|
|
||||||
|
def download_asset(url, dest_path, token=None):
|
||||||
|
"""Download a release asset from url and write it to dest_path."""
|
||||||
|
headers = {'User-Agent': 'install-github-release-cli'}
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'token {token}'
|
||||||
|
request = urllib.request.Request(url, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response, open(dest_path, 'wb') as out_file:
|
||||||
|
shutil.copyfileobj(response, out_file)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
raise RuntimeError(f'Failed to download asset: {error.reason}') from error
|
||||||
|
|
||||||
|
|
||||||
|
def validate_output_directory(output_directory):
|
||||||
|
"""Resolve output_directory to an absolute path and confirm it already exists."""
|
||||||
|
resolved = Path(output_directory).resolve()
|
||||||
|
if not resolved.is_dir():
|
||||||
|
raise RuntimeError(f'Output directory "{resolved}" does not exist.')
|
||||||
|
return str(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def install_github_release(repository, *, list_only=False, app_name=None, file_name=None, binary_name=None,
|
||||||
|
file_type=None, install_path=None, output_directory=None, releases_json=False,
|
||||||
|
system=None, arch=None, token=None, debug=False, dry_run=False):
|
||||||
|
"""Download, extract, and install a binary from a repository's latest GitHub release."""
|
||||||
|
|
||||||
|
# Normalize and validate the file-type selector.
|
||||||
|
raw_file_type = file_type
|
||||||
|
file_type = raw_file_type.strip() if raw_file_type is not None else None
|
||||||
|
if raw_file_type is not None and not file_type:
|
||||||
|
raise RuntimeError(f'Unknown asset type: {raw_file_type}')
|
||||||
|
|
||||||
|
# List mode: print and return the release's assets without downloading anything.
|
||||||
|
if list_only:
|
||||||
|
release = fetch_latest_release(repository, token)
|
||||||
|
for asset in release['assets']:
|
||||||
|
print(f"- {asset['browser_download_url']}")
|
||||||
|
return release['assets']
|
||||||
|
|
||||||
|
# Derive the display name and the binary's source/destination names from the repository or overrides.
|
||||||
|
tool_name = repository.split('/')[-1] or repository
|
||||||
|
app_name = app_name or (tool_name[0].upper() + tool_name[1:] if tool_name else tool_name)
|
||||||
|
|
||||||
|
binary_option = binary_name or tool_name
|
||||||
|
binary_parts = binary_option.split(':')
|
||||||
|
if len(binary_parts) > 1:
|
||||||
|
binary_source, binary_destination = binary_parts[0], binary_parts[1]
|
||||||
|
else:
|
||||||
|
binary_source = binary_destination = binary_option
|
||||||
|
|
||||||
|
# Releases-JSON mode: save the raw release API response to a file and return its path.
|
||||||
|
if releases_json:
|
||||||
|
url = f'https://api.github.com/repos/{repository}/releases/latest'
|
||||||
|
request = urllib.request.Request(url, headers=get_github_api_headers(token))
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
raw_release = response.read().decode('utf-8')
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
body = error.read().decode('utf-8', errors='replace')
|
||||||
|
raise RuntimeError(f'Failed to fetch latest release for {repository}: {error.reason}. {body}') from error
|
||||||
|
|
||||||
|
output_base = binary_destination or tool_name
|
||||||
|
output_name = f'{output_base}.releases.json'
|
||||||
|
if output_directory:
|
||||||
|
output_path = os.path.join(validate_output_directory(output_directory), output_name)
|
||||||
|
else:
|
||||||
|
output_path = output_name
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(raw_release)
|
||||||
|
print(f'Downloaded GitHub releases to {output_path}.')
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
# Detect (or apply overrides for) the target OS and architecture.
|
||||||
|
system = (system or sys.platform).lower()
|
||||||
|
raw_machine = arch or (os.uname().machine if hasattr(os, 'uname') else os.environ.get('PROCESSOR_ARCHITECTURE', ''))
|
||||||
|
machine = raw_machine.lower()
|
||||||
|
arch = MACHINE_TO_ARCH.get(machine, machine)
|
||||||
|
platform_info = PlatformInfo(system, arch)
|
||||||
|
|
||||||
|
# Fetch the latest release and select the one asset matching the file-name/file-type criteria.
|
||||||
|
print(f'Fetching latest release for {repository}...')
|
||||||
|
release = fetch_latest_release(repository, token)
|
||||||
|
assets = release['assets']
|
||||||
|
|
||||||
|
# A literal (non-regex) file-name is matched verbatim, bypassing file-type filtering entirely.
|
||||||
|
if file_name and not file_name.startswith('~'):
|
||||||
|
exact_matches = [a for a in assets if a['name'] == file_name]
|
||||||
|
if len(exact_matches) != 1:
|
||||||
|
raise RuntimeError(f'Expected exactly one asset to match the provided filename, matched: {len(exact_matches)}')
|
||||||
|
asset = exact_matches[0]
|
||||||
|
else:
|
||||||
|
# Narrow the candidates by file-type: a known type keyword, a trailing regex, or a plain extension.
|
||||||
|
file_type_filtered = assets
|
||||||
|
if file_type:
|
||||||
|
if file_type in KNOWN_FILE_TYPES:
|
||||||
|
suffixes = KNOWN_FILE_TYPES[file_type]
|
||||||
|
file_type_filtered = [a for a in assets if a['name'].lower().endswith(suffixes)]
|
||||||
|
elif file_type.startswith('~'):
|
||||||
|
type_regex = re.compile(file_type[1:] + '$', re.IGNORECASE)
|
||||||
|
file_type_filtered = [a for a in assets if type_regex.search(a['name'])]
|
||||||
|
else:
|
||||||
|
extension = file_type.lstrip('.')
|
||||||
|
file_type_filtered = [a for a in assets if a['name'].lower().endswith(f'.{extension}'.lower())]
|
||||||
|
|
||||||
|
# A regex file-name (with platform placeholders expanded) is matched against the type-filtered candidates.
|
||||||
|
if file_name and file_name.startswith('~'):
|
||||||
|
name_pattern = replace_platform_placeholders(file_name[1:], platform_info)
|
||||||
|
name_regex = re.compile(name_pattern, re.IGNORECASE)
|
||||||
|
matched = [a for a in file_type_filtered if name_regex.search(a['name'])]
|
||||||
|
if len(matched) != 1:
|
||||||
|
raise RuntimeError(f'Expected exactly one asset to match the filename regex, matched: {len(matched)}')
|
||||||
|
asset = matched[0]
|
||||||
|
else:
|
||||||
|
# With no file-name given, fall back to matching the detected system and architecture.
|
||||||
|
default_pattern = replace_platform_placeholders('{{SYSTEM}}[_-]{{ARCH}}', platform_info)
|
||||||
|
default_regex = re.compile(default_pattern, re.IGNORECASE)
|
||||||
|
matched = [a for a in file_type_filtered if default_regex.search(a['name'])]
|
||||||
|
if len(matched) != 1:
|
||||||
|
if len(matched) == 0:
|
||||||
|
raise RuntimeError(f'No assets matched the default criteria: {default_pattern}')
|
||||||
|
names = ', '.join(a['name'] for a in matched)
|
||||||
|
raise RuntimeError(f'Multiple assets matched the default criteria: {names}')
|
||||||
|
asset = matched[0]
|
||||||
|
|
||||||
|
version = re.sub(r'^v', '', release['tag_name'], flags=re.IGNORECASE)
|
||||||
|
download_url = asset['browser_download_url']
|
||||||
|
print(f"Will download '{app_name}' version: {version}")
|
||||||
|
print(f'Download URL: "{download_url}".')
|
||||||
|
|
||||||
|
# Dry-run mode: stop after reporting the selected asset.
|
||||||
|
if dry_run:
|
||||||
|
return {'version': version, 'download_url': download_url, 'asset': asset}
|
||||||
|
|
||||||
|
# Output-directory mode: download the asset without extracting or installing it.
|
||||||
|
if output_directory:
|
||||||
|
output_dir = validate_output_directory(output_directory)
|
||||||
|
output_path = os.path.join(output_dir, os.path.basename(download_url))
|
||||||
|
print(f"Downloading '{app_name}' version {version} to '{output_path}'...")
|
||||||
|
download_asset(download_url, output_path, token)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
# Full install: download the asset into a temporary directory that is removed on exit.
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix='setup-gh-release-')
|
||||||
|
try:
|
||||||
|
download_path = os.path.join(temp_dir, asset['name'])
|
||||||
|
download_asset(download_url, download_path, token)
|
||||||
|
asset_name_lower = asset['name'].lower()
|
||||||
|
|
||||||
|
# System-package asset: hand it to the OS package manager and stop, no binary search or copy needed.
|
||||||
|
if re.search(r'\.(deb|pkg|rpm)$', asset_name_lower):
|
||||||
|
if asset_name_lower.endswith('.deb'):
|
||||||
|
pkg_binary, pkg_args = 'dpkg', ['-i', download_path]
|
||||||
|
elif asset_name_lower.endswith('.pkg'):
|
||||||
|
pkg_binary, pkg_args = 'installer', ['-pkg', download_path, '-target', '/']
|
||||||
|
else:
|
||||||
|
pkg_binary, pkg_args = 'rpm', ['-i', download_path]
|
||||||
|
|
||||||
|
is_root = hasattr(os, 'getuid') and os.getuid() == 0
|
||||||
|
command = [pkg_binary, *pkg_args] if is_root else ['sudo', pkg_binary, *pkg_args]
|
||||||
|
result = subprocess.run(command)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f'Failed to install package using {" ".join(command)}.')
|
||||||
|
|
||||||
|
print('Installation successful!')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Archive asset: extract it into a subdirectory using the format implied by its extension.
|
||||||
|
extract_dir = os.path.join(temp_dir, 'extract')
|
||||||
|
print(f"Extracting {asset['name']}...")
|
||||||
|
os.makedirs(extract_dir, exist_ok=True)
|
||||||
|
|
||||||
|
if asset_name_lower.endswith('.tar.gz') or asset_name_lower.endswith('.tgz') or asset_name_lower.endswith('.tar'):
|
||||||
|
with tarfile.open(download_path, 'r:*') as archive:
|
||||||
|
archive.extractall(extract_dir, filter='data')
|
||||||
|
elif asset_name_lower.endswith('.zip'):
|
||||||
|
with zipfile.ZipFile(download_path) as archive:
|
||||||
|
archive.extractall(extract_dir)
|
||||||
|
elif asset_name_lower.endswith('.7z'):
|
||||||
|
result = subprocess.run(['7z', 'x', download_path, f'-o{extract_dir}', '-y'])
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError('7z failed. Make sure 7z is installed.')
|
||||||
|
else:
|
||||||
|
shutil.copy2(download_path, os.path.join(extract_dir, os.path.basename(download_path)))
|
||||||
|
|
||||||
|
# Build the binary's search pattern, expanding platform placeholders in a regex or literal name.
|
||||||
|
if binary_source.startswith('~'):
|
||||||
|
binary_regex_source = replace_platform_placeholders(binary_source[1:], platform_info)
|
||||||
|
binary_pattern = f'~{binary_regex_source}'
|
||||||
|
else:
|
||||||
|
binary_pattern = binary_source.replace('{{SYSTEM}}', platform_info.system).replace('{{ARCH}}', platform_info.arch)
|
||||||
|
|
||||||
|
binary_regex = re.compile(binary_pattern[1:], re.IGNORECASE) if binary_pattern.startswith('~') else None
|
||||||
|
|
||||||
|
# Walk the extracted tree in sorted order and stop at the first file matching the pattern.
|
||||||
|
binary_path = None
|
||||||
|
for dirpath, dirnames, filenames in os.walk(extract_dir):
|
||||||
|
dirnames.sort()
|
||||||
|
filenames.sort()
|
||||||
|
if debug:
|
||||||
|
print(f'Searching for binary in {dirpath}...')
|
||||||
|
for item in dirnames + filenames:
|
||||||
|
print(f' - {item}')
|
||||||
|
|
||||||
|
for item in filenames:
|
||||||
|
if binary_regex:
|
||||||
|
is_match = bool(binary_regex.search(item))
|
||||||
|
else:
|
||||||
|
is_match = item == binary_pattern
|
||||||
|
if not is_match and sys.platform == 'win32' and not binary_pattern.lower().endswith('.exe'):
|
||||||
|
is_match = item.lower() == f'{binary_pattern.lower()}.exe'
|
||||||
|
if is_match:
|
||||||
|
binary_path = os.path.join(dirpath, item)
|
||||||
|
break
|
||||||
|
if binary_path:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not binary_path:
|
||||||
|
raise RuntimeError(f'Could not find binary "{binary_source}" in the extracted asset.')
|
||||||
|
|
||||||
|
# Resolve the install directory: an explicit override, or an OS-appropriate default.
|
||||||
|
if install_path:
|
||||||
|
install_dir = str(Path(install_path).resolve())
|
||||||
|
elif sys.platform == 'win32':
|
||||||
|
local_app_data = os.environ.get('LOCALAPPDATA') or str(Path.home() / 'AppData' / 'Local')
|
||||||
|
install_dir = os.path.join(local_app_data, 'bin')
|
||||||
|
elif hasattr(os, 'getuid') and os.getuid() == 0:
|
||||||
|
install_dir = '/usr/local/bin'
|
||||||
|
else:
|
||||||
|
home_bin = str(Path.home() / 'bin')
|
||||||
|
install_dir = home_bin if os.path.isdir(home_bin) else '/usr/local/bin'
|
||||||
|
|
||||||
|
os.makedirs(install_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Copy the binary into place and make it executable.
|
||||||
|
final_name = binary_destination or os.path.basename(binary_path)
|
||||||
|
dest_path = os.path.join(install_dir, final_name)
|
||||||
|
|
||||||
|
print(f'Installing {final_name} to {dest_path}...')
|
||||||
|
shutil.copy2(binary_path, dest_path)
|
||||||
|
if sys.platform != 'win32':
|
||||||
|
os.chmod(dest_path, 0o755)
|
||||||
|
|
||||||
|
print('Installation successful!')
|
||||||
|
return dest_path
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
BASH_COMPLETION = '''\
|
||||||
|
_install_github_release_complete() {
|
||||||
|
local cur prev opts
|
||||||
|
COMPREPLY=()
|
||||||
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||||
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||||
|
opts="--dry-run -l --list -a --app-name -f --file-name -b --binary-name -t --file-type -p --install-path -o --output-directory -j --releases-json --system --arch -k --token -d --debug --completion --check-token -h --help"
|
||||||
|
|
||||||
|
case "$prev" in
|
||||||
|
-t|--file-type)
|
||||||
|
COMPREPLY=( $(compgen -W "archive package linux macos targz" -- "$cur") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--completion)
|
||||||
|
COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-p|--install-path|-o|--output-directory)
|
||||||
|
COMPREPLY=( $(compgen -d -- "$cur") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
|
||||||
|
}
|
||||||
|
complete -F _install_github_release_complete install_github_release.py
|
||||||
|
'''
|
||||||
|
|
||||||
|
ZSH_COMPLETION = '''\
|
||||||
|
#compdef install_github_release.py
|
||||||
|
compdef _install_github_release install_github_release.py
|
||||||
|
|
||||||
|
_install_github_release() {
|
||||||
|
local -a options
|
||||||
|
options=(
|
||||||
|
'(-l --list)'{-l,--list}'[List available assets from latest release and exit]::repository:'
|
||||||
|
'(-a --app-name)'{-a,--app-name}'[Application name]:name:'
|
||||||
|
'(-f --file-name)'{-f,--file-name}'[Asset file name or regex pattern]:name:'
|
||||||
|
'(-b --binary-name)'{-b,--binary-name}'[Binary name]:name:'
|
||||||
|
'(-t --file-type)'{-t,--file-type}'[Asset type selector]:type:(archive package linux macos targz)'
|
||||||
|
'(-p --install-path)'{-p,--install-path}'[Custom installation directory]:path:_files -/'
|
||||||
|
'(-o --output-directory)'{-o,--output-directory}'[Download-only output directory]:path:_files -/'
|
||||||
|
'(-j --releases-json)'{-j,--releases-json}'[Download latest release JSON only]'
|
||||||
|
'--system[Override detected system]:system:'
|
||||||
|
'--arch[Override detected architecture]:arch:'
|
||||||
|
'(-k --token)'{-k,--token}'[GitHub token]:token:'
|
||||||
|
'(-d --debug)'{-d,--debug}'[Enable debug logging]'
|
||||||
|
'--dry-run[Run in test mode]'
|
||||||
|
'--completion[Print shell completion script]:shell:(bash zsh)'
|
||||||
|
'--check-token[Verify the GitHub token against actions/checkout and exit]'
|
||||||
|
'(-h --help)'{-h,--help}'[Show help message]'
|
||||||
|
'1:repository:'
|
||||||
|
)
|
||||||
|
_arguments -s $options
|
||||||
|
}
|
||||||
|
|
||||||
|
# don't run the completion function when being source-ed or eval-ed
|
||||||
|
if [ "$funcstack[1]" = "_install_github_release" ]; then
|
||||||
|
_install_github_release
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Parse command-line arguments and run install_github_release, reporting errors via the exit code."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
usage='%(prog)s [options] <repository>',
|
||||||
|
allow_abbrev=False,
|
||||||
|
)
|
||||||
|
parser.add_argument('repository', nargs='?', help='The GitHub repository (owner/repo)')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='Run in test mode')
|
||||||
|
parser.add_argument('-l', '--list', nargs='?', const=True, default=None, metavar='repository',
|
||||||
|
help='List available assets from latest release and exit')
|
||||||
|
parser.add_argument('-a', '--app-name', metavar='name', help='Application name (optional, for output messages)')
|
||||||
|
parser.add_argument('-f', '--file-name', metavar='name', help='Asset file name or regex pattern (prefixed with ~)')
|
||||||
|
parser.add_argument('-b', '--binary-name', metavar='name', help='Binary name (supports source:destination form)')
|
||||||
|
parser.add_argument('-t', '--file-type', metavar='type',
|
||||||
|
help='Known: archive|package|linux|macos|targz; custom: ~<regex> or extension')
|
||||||
|
parser.add_argument('-p', '--install-path', metavar='path', help='Custom installation directory')
|
||||||
|
parser.add_argument('-o', '--output-directory', metavar='path',
|
||||||
|
help='Only download selected asset to the specified directory')
|
||||||
|
parser.add_argument('-j', '--releases-json', action='store_true', help='Download latest release JSON only')
|
||||||
|
parser.add_argument('--system', metavar='name', help='Override detected system for asset matching')
|
||||||
|
parser.add_argument('--arch', metavar='name', help='Override detected architecture for asset matching')
|
||||||
|
parser.add_argument('-k', '--token', metavar='token', help='GitHub token')
|
||||||
|
parser.add_argument('-d', '--debug', action='store_true', help='Enable debug logging')
|
||||||
|
parser.add_argument('--completion', choices=['bash', 'zsh'], help='Print a shell completion script and exit')
|
||||||
|
parser.add_argument('--check-token', action='store_true', help='Verify the GitHub token against actions/checkout and exit')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.completion:
|
||||||
|
print(BASH_COMPLETION if args.completion == 'bash' else ZSH_COMPLETION, end='')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if args.check_token:
|
||||||
|
token = args.token or os.environ.get('GITHUB_TOKEN')
|
||||||
|
if not token:
|
||||||
|
print('Error: No GitHub token provided as an argument or found in GITHUB_TOKEN environment variable.', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
check_github_token(token)
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Error: {error}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
list_repo = args.list if isinstance(args.list, str) else None
|
||||||
|
repository = list_repo or args.repository
|
||||||
|
if not repository:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
install_github_release(
|
||||||
|
repository,
|
||||||
|
list_only=args.list is not None,
|
||||||
|
app_name=args.app_name,
|
||||||
|
file_name=args.file_name,
|
||||||
|
binary_name=args.binary_name,
|
||||||
|
file_type=args.file_type,
|
||||||
|
install_path=args.install_path,
|
||||||
|
output_directory=args.output_directory,
|
||||||
|
releases_json=args.releases_json,
|
||||||
|
system=args.system,
|
||||||
|
arch=args.arch,
|
||||||
|
token=args.token,
|
||||||
|
debug=args.debug,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
print(f'Error: {error}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user