Created
November 2, 2024 15:10
-
-
Save manti/cfb90807de6fabec13f9686b72f87194 to your computer and use it in GitHub Desktop.
install_cli.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
r""" | |
This script installs the Aptos CLI. | |
It will perform the following steps: | |
- Determine what platform (OS + arch) the script is being invoked from. | |
- Download the CLI. | |
- Put it in an appropriate location. | |
This was adapted from the install script for Poetry. | |
""" | |
import argparse | |
import json | |
import os | |
import platform | |
import shutil | |
import subprocess | |
import sys | |
import sysconfig | |
import tempfile | |
import warnings | |
from contextlib import closing | |
from io import UnsupportedOperation | |
from pathlib import Path | |
from typing import Optional | |
from urllib.request import Request, urlopen, urlretrieve | |
try: | |
from packaging.version import Version | |
except ImportError: | |
try: | |
with warnings.catch_warnings(): | |
warnings.simplefilter("ignore", category=DeprecationWarning) | |
from distutils.version import StrictVersion as Version | |
except ImportError: | |
print( | |
"Couldn't find distutils or packaging. We cannot check the current version of the CLI. We will install the latest version.", | |
) | |
Version = None | |
SHELL = os.getenv("SHELL", "") | |
WINDOWS = sys.platform.startswith("win") or (sys.platform == "cli" and os.name == "nt") | |
MINGW = sysconfig.get_platform().startswith("mingw") | |
MACOS = sys.platform == "darwin" | |
SCRIPT = "aptos.exe" if WINDOWS else "aptos" | |
TEST_COMMAND = f"{SCRIPT} info" | |
X86_64 = ["x86_64", "amd64"] | |
SUPPORTED_ARCHITECTURES = { | |
"macos": X86_64 + ["arm", "arm64", "aarch64"], | |
"linux": X86_64, | |
"windows": X86_64, | |
} | |
FOREGROUND_COLORS = { | |
"black": 30, | |
"red": 31, | |
"green": 32, | |
"yellow": 33, | |
"blue": 34, | |
"magenta": 35, | |
"cyan": 36, | |
"white": 37, | |
} | |
BACKGROUND_COLORS = { | |
"black": 40, | |
"red": 41, | |
"green": 42, | |
"yellow": 43, | |
"blue": 44, | |
"magenta": 45, | |
"cyan": 46, | |
"white": 47, | |
} | |
OPTIONS = {"bold": 1, "underscore": 4, "blink": 5, "reverse": 7, "conceal": 8} | |
def style(fg, bg, options): | |
codes = [] | |
if fg: | |
codes.append(FOREGROUND_COLORS[fg]) | |
if bg: | |
codes.append(BACKGROUND_COLORS[bg]) | |
if options: | |
if not isinstance(options, (list, tuple)): | |
options = [options] | |
for option in options: | |
codes.append(OPTIONS[option]) | |
return "\033[{}m".format(";".join(map(str, codes))) | |
STYLES = { | |
"info": style("cyan", None, None), | |
"comment": style("yellow", None, None), | |
"success": style("green", None, None), | |
"error": style("red", None, None), | |
"warning": style("yellow", None, None), | |
"b": style(None, None, ("bold",)), | |
} | |
def is_decorated(): | |
if WINDOWS: | |
return ( | |
os.getenv("ANSICON") is not None | |
or "ON" == os.getenv("ConEmuANSI") | |
or "xterm" == os.getenv("Term") | |
) | |
if not hasattr(sys.stdout, "fileno"): | |
return False | |
try: | |
return os.isatty(sys.stdout.fileno()) | |
except UnsupportedOperation: | |
return False | |
def is_interactive(): | |
if not hasattr(sys.stdin, "fileno"): | |
return False | |
try: | |
return os.isatty(sys.stdin.fileno()) | |
except UnsupportedOperation: | |
return False | |
def colorize(style, text): | |
if not is_decorated(): | |
return text | |
return f"{STYLES[style]}{text}\033[0m" | |
def string_to_bool(value): | |
value = value.lower() | |
return value in {"true", "1", "y", "yes"} | |
def bin_dir() -> Path: | |
if WINDOWS and not MINGW: | |
# ~ is %USERPROFILE% on Windows | |
return Path("~/.aptoscli/bin").expanduser() | |
else: | |
return Path("~/.local/bin").expanduser() | |
PRE_MESSAGE = """Welcome to the {aptos} CLI installer! | |
This will download and install the latest version of the {aptos} CLI at this location: | |
{aptos_home_bin} | |
""" | |
POST_MESSAGE = """The {aptos} CLI ({version}) is installed now. Great! | |
You can test that everything is set up by executing this command: | |
{test_command} | |
""" | |
POST_MESSAGE_NOT_IN_PATH = """The {aptos} CLI ({version}) is installed now. Great! | |
To get started you need the {aptos} CLI's bin directory ({aptos_home_bin}) in your `PATH` | |
environment variable. | |
{configure_message} | |
Alternatively, you can call the {aptos} CLI explicitly with `{aptos_executable}`. | |
You can test that everything is set up by executing: | |
{test_command} | |
""" | |
POST_MESSAGE_CONFIGURE_UNIX = """ | |
Add the following to your shell configuration file (e.g. .bashrc): | |
export PATH="{aptos_home_bin}:$PATH" | |
After this, restart your terminal. | |
""" | |
POST_MESSAGE_CONFIGURE_FISH = """ | |
You can execute `set -U fish_user_paths {aptos_home_bin} $fish_user_paths` | |
""" | |
POST_MESSAGE_CONFIGURE_WINDOWS = """ | |
Execute the following command to update your PATH: | |
setx PATH "%PATH%;{aptos_home_bin}" | |
After this, restart your terminal. | |
""" | |
class InstallationError(RuntimeError): | |
def __init__(self, return_code: int = 0, log: Optional[str] = None): | |
super().__init__() | |
self.return_code = return_code | |
self.log = log | |
class Installer: | |
# The API returns the newest items first. Accordingly we expect the CLI release to | |
# be in the last 100 releases (the max for a single page). | |
METADATA_URL = ( | |
"https://api.github.com/repos/aptos-labs/aptos-core/releases?per_page=100" | |
) | |
def __init__( | |
self, | |
version: Optional[str] = None, | |
force: bool = False, | |
accept_all: bool = False, | |
bin_dir: Optional[str] = None, | |
) -> None: | |
self._version = version | |
self._force = force | |
self._accept_all = accept_all | |
self._bin_dir = Path(bin_dir).expanduser() if bin_dir else None | |
self._release_info = None | |
self._latest_release_info = None | |
@property | |
def bin_dir(self) -> Path: | |
if not self._bin_dir: | |
self._bin_dir = bin_dir() | |
return self._bin_dir | |
@property | |
def bin_path(self): | |
return self.bin_dir.joinpath(SCRIPT) | |
@property | |
def release_info(self): | |
if not self._release_info: | |
self._release_info = json.loads(self._get(self.METADATA_URL).decode()) | |
return self._release_info | |
@property | |
def latest_release_info(self): | |
# Iterate through the releases and find the latest CLI release. | |
for release in self.release_info: | |
if release["tag_name"].startswith("aptos-cli-"): | |
return release | |
raise RuntimeError("Failed to find latest CLI release") | |
def run(self) -> int: | |
try: | |
version, _current_version = self.get_version() | |
version = "4.2.3" | |
except ValueError: | |
return 1 | |
if version is None: | |
return 0 | |
try: | |
target = self.get_target() | |
except: | |
return 1 | |
if target is None: | |
return 0 | |
self._write(colorize("info", "Determined target to be: {}".format(target))) | |
self._write("") | |
self.display_pre_message() | |
try: | |
self.install(version, target) | |
except subprocess.CalledProcessError as e: | |
raise InstallationError(return_code=e.returncode, log=e.output.decode()) | |
self._write("") | |
self.display_post_message(version) | |
return 0 | |
def install(self, version, target): | |
self._install_comment(version, "Downloading...") | |
self.bin_dir.mkdir(parents=True, exist_ok=True) | |
if self.bin_path.exists(): | |
self.bin_path.unlink() | |
url = self.build_binary_url(version, target) | |
with tempfile.TemporaryDirectory() as tmpdirname: | |
zip_file = os.path.join(tmpdirname, "aptos-cli.zip") | |
urlretrieve(url, zip_file) | |
# This assumes that the binary within the zip file is always | |
# called `aptos` / `aptos.exe`. | |
shutil.unpack_archive(zip_file, self.bin_dir) | |
os.chmod(self.bin_path, 0o755) | |
self._install_comment(version, "Done!") | |
return 0 | |
def _install_comment(self, version: str, message: str): | |
self._write( | |
"Installing {} CLI ({}): {}".format( | |
colorize("info", "Aptos"), | |
colorize("b", version), | |
colorize("comment", message), | |
) | |
) | |
def build_binary_url(self, version: str, target: str) -> str: | |
return f"https://github.com/aptos-labs/aptos-core/releases/download/aptos-cli-v{version}/aptos-cli-{version}-{target}.zip" | |
def display_pre_message(self) -> None: | |
kwargs = { | |
"aptos": colorize("info", "Aptos"), | |
"aptos_home_bin": colorize("comment", self.bin_dir), | |
} | |
self._write(PRE_MESSAGE.format(**kwargs)) | |
def display_post_message(self, version: str) -> None: | |
if WINDOWS: | |
return self.display_post_message_windows(version) | |
if SHELL == "fish": | |
return self.display_post_message_fish(version) | |
return self.display_post_message_unix(version) | |
def get_windows_path_var(self) -> Optional[str]: | |
import winreg | |
with winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) as root: | |
with winreg.OpenKey(root, "Environment", 0, winreg.KEY_ALL_ACCESS) as key: | |
path, _ = winreg.QueryValueEx(key, "PATH") | |
return path | |
def display_post_message_windows(self, version: str) -> None: | |
path = self.get_windows_path_var() | |
message = POST_MESSAGE_NOT_IN_PATH | |
if path and str(self.bin_dir) in path: | |
message = POST_MESSAGE | |
self._write( | |
message.format( | |
aptos=colorize("info", "Aptos"), | |
version=colorize("b", version), | |
aptos_home_bin=colorize("comment", self.bin_dir), | |
aptos_executable=colorize("b", self.bin_path), | |
configure_message=POST_MESSAGE_CONFIGURE_WINDOWS.format( | |
aptos_home_bin=colorize("comment", self.bin_dir) | |
), | |
test_command=colorize("b", TEST_COMMAND), | |
) | |
) | |
def display_post_message_fish(self, version: str) -> None: | |
fish_user_paths = subprocess.check_output( | |
["fish", "-c", "echo $fish_user_paths"] | |
).decode("utf-8") | |
message = POST_MESSAGE_NOT_IN_PATH | |
if fish_user_paths and str(self.bin_dir) in fish_user_paths: | |
message = POST_MESSAGE | |
self._write( | |
message.format( | |
aptos=colorize("info", "Aptos"), | |
version=colorize("b", version), | |
aptos_home_bin=colorize("comment", self.bin_dir), | |
aptos_executable=colorize("b", self.bin_path), | |
configure_message=POST_MESSAGE_CONFIGURE_FISH.format( | |
aptos_home_bin=colorize("comment", self.bin_dir) | |
), | |
test_command=colorize("b", TEST_COMMAND), | |
) | |
) | |
def display_post_message_unix(self, version: str) -> None: | |
paths = os.getenv("PATH", "").split(":") | |
message = POST_MESSAGE_NOT_IN_PATH | |
if paths and str(self.bin_dir) in paths: | |
message = POST_MESSAGE | |
self._write( | |
message.format( | |
aptos=colorize("info", "Aptos"), | |
version=colorize("b", version), | |
aptos_home_bin=colorize("comment", self.bin_dir), | |
aptos_executable=colorize("b", self.bin_path), | |
configure_message=POST_MESSAGE_CONFIGURE_UNIX.format( | |
aptos_home_bin=colorize("comment", self.bin_dir) | |
), | |
test_command=colorize("b", TEST_COMMAND), | |
) | |
) | |
def get_version(self): | |
latest_version = self.latest_release_info["tag_name"].split("-v")[-1] | |
self._write(colorize("info", "Latest CLI release: {}".format(latest_version))) | |
if self._force: | |
return latest_version, None | |
binary_path = self.bin_path | |
try: | |
out = subprocess.check_output( | |
[binary_path, "--version"], | |
universal_newlines=True, | |
) | |
current_version = current_version = out.split(" ")[-1].rstrip().lstrip() | |
except Exception: | |
current_version = None | |
self._write( | |
colorize("info", "Currently installed CLI: {}".format(current_version)) | |
) | |
with warnings.catch_warnings(): | |
warnings.simplefilter("ignore", category=DeprecationWarning) | |
if ( | |
Version is not None | |
and current_version | |
and Version(current_version) >= Version(latest_version) | |
): | |
self._write("") | |
self._write( | |
f'The latest version ({colorize("b", latest_version)}) is already installed.' | |
) | |
return None, current_version | |
else: | |
self._write(f"Installing {colorize('b', latest_version)}") | |
return latest_version, current_version | |
# Given the OS and CPU architecture, determine the "target" to download. | |
def get_target(self): | |
# We only look this up for validation, we only need the OS to figure out which | |
# binary to download right now since we only build for x86_64 right now. | |
arch = (platform.machine() or platform.processor()).lower() | |
os = "windows" if WINDOWS else "macos" if MACOS else "linux" | |
if not arch in SUPPORTED_ARCHITECTURES[os]: | |
self._write( | |
colorize( | |
"error", | |
f"The given OS ({os}) + CPU architecture ({arch}) is not supported.", | |
) | |
) | |
return None | |
if WINDOWS: | |
return "Windows-x86_64" | |
if MACOS: | |
sys.stdout.write( | |
colorize( | |
"error", | |
"You are trying to install from macOS. Please use brew to install Aptos CLI instead - [brew install aptos]", | |
) | |
) | |
self._write("") | |
sys.exit(1) | |
# On Linux, we check what version of OpenSSL we're working with to figure out | |
# which binary to download. | |
try: | |
out = subprocess.check_output( | |
["openssl", "version"], | |
universal_newlines=True, | |
) | |
openssl_version = out.split(" ")[1].rstrip().lstrip() | |
except Exception: | |
self._write( | |
colorize( | |
"warning", | |
"Could not determine OpenSSL version, assuming older version (1.x.x)", | |
) | |
) | |
openssl_version = "1.0.0" | |
if openssl_version.startswith("3."): | |
return "Ubuntu-22.04-x86_64" | |
return "Ubuntu-x86_64" | |
def _write(self, line) -> None: | |
sys.stdout.write(line + "\n") | |
def _get(self, url): | |
request = Request(url, headers={"User-Agent": "Aptos CLI Installer"}) | |
with closing(urlopen(request)) as r: | |
return r.read() | |
def main(): | |
if sys.version_info.major < 3 or sys.version_info.minor < 6: | |
sys.stdout.write( | |
colorize("error", "This installer requires Python 3.6 or newer to run!") | |
) | |
# Return error code. | |
return 1 | |
parser = argparse.ArgumentParser( | |
description="Installs the latest version of the Aptos CLI" | |
) | |
parser.add_argument( | |
"-f", | |
"--force", | |
help="Forcibly install on top of existing version", | |
action="store_true", | |
default=False, | |
) | |
parser.add_argument( | |
"-y", | |
"--yes", | |
help="Accept all prompts", | |
dest="accept_all", | |
action="store_true", | |
default=False, | |
) | |
parser.add_argument( | |
"--bin-dir", | |
help="If given, the CLI binary will be downloaded here instead", | |
) | |
args = parser.parse_args() | |
installer = Installer( | |
force=args.force, | |
accept_all=args.accept_all or not is_interactive(), | |
bin_dir=args.bin_dir, | |
) | |
try: | |
return installer.run() | |
except InstallationError as e: | |
installer._write(colorize("error", "Aptos CLI installation failed.")) | |
if e.log is not None: | |
import traceback | |
_, path = tempfile.mkstemp( | |
suffix=".log", | |
prefix="aptos-cli-installer-error-", | |
dir=str(Path.cwd()), | |
text=True, | |
) | |
installer._write(colorize("error", f"See {path} for error logs.")) | |
text = f"{e.log}\nTraceback:\n\n{''.join(traceback.format_tb(e.__traceback__))}" | |
Path(path).write_text(text) | |
return e.return_code | |
if __name__ == "__main__": | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment