diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e690409..93c3d65 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,6 +21,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Test installer regressions without network access + run: python3 -m unittest discover -s tests -v + - name: Install Zsh and Fish on Ubuntu if: matrix.os == 'ubuntu-latest' run: | @@ -36,7 +39,7 @@ jobs: run: | mkdir -p ~/.config/fish mkdir -p ~/.zsh - touch ~/.bashrc + touch ~/.bashrc ~/.zshrc ~/.config/fish/config.fish - name: Test install script in Zsh if: matrix.shell == 'zsh' @@ -49,6 +52,10 @@ jobs: bash ./install.bash shell: fish {0} + - name: Test install script in Bash + if: matrix.shell == 'bash' + run: bash ./install.bash + - name: Verify installation run: | if [ "${{ matrix.shell }}" = "zsh" ]; then diff --git a/.gitignore b/.gitignore index c6bba59..7fa11fd 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Python installer regression tests +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index 90c88f0..58f7c26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,12 @@ This is the **shelltime installation** repository - a collection of shell script ## Testing -Run the CI workflow locally or on GitHub Actions: +Run the isolated regression suite with Python 3 (also run by CI): +```bash +python3 -m unittest discover -s tests -v +``` + +Run the shell installation workflow locally or on GitHub Actions: ```bash # The test workflow runs on ubuntu-latest and macos-latest # Testing shells: zsh, fish, bash diff --git a/README.md b/README.md index 3c1f8eb..4d398fd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ Welcome to shelltime! This guide will help you install the necessary tools using ## Quick Install +The installer supports macOS and Linux (including WSL). A failed binary download +stops installation. If a hook download fails, its existing hook and backup are +preserved while the remaining hooks and daemon setup continue. The installer +then exits with an error so you can retry. Upgrades migrate existing source lines +without loading hooks twice. + You can install shelltime tools by running the following command in your terminal: ```bash @@ -16,6 +22,12 @@ Once the installation script finishes successfully, you should be able to track Visit [shelltime.xyz](https://shelltime.xyz) for guides and usage documentation. +## Testing + +Run `python3 -m unittest discover -s tests` to check upgrades, download failures, +paths containing spaces, and platform detection without network requests or +changes to your shell configuration. CI runs these tests on Linux and macOS. + ## Having Issues? If you encounter any problems during the installation or have any questions, please: diff --git a/install.bash b/install.bash index 5021b7e..48b382d 100644 --- a/install.bash +++ b/install.bash @@ -1,53 +1,10 @@ #!/bin/bash +set -euo pipefail -# Determine the OS and architecture -OS=$(uname -s) -ARCH=$(uname -m) - -# Function to check if a command is available command_exists() { command -v "$1" >/dev/null 2>&1 } -# Flag to track whether Homebrew installation was used -BREW_INSTALLED=false - -# Check for required commands -if ! command_exists curl; then - echo "Error: curl is not installed." - exit 1 -fi - -# On macOS, prefer Homebrew installation if brew is available -if [[ "$OS" == "Darwin" ]] && command_exists brew; then - echo "Homebrew detected on macOS. Attempting to install via brew..." - if brew install shelltime/tap/shelltime; then - BREW_INSTALLED=true - echo "Successfully installed shelltime via Homebrew." - # Rename old manual-install binaries so the system uses the Homebrew version - if [ -f "$HOME/.shelltime/bin/shelltime" ]; then - mv "$HOME/.shelltime/bin/shelltime" "$HOME/.shelltime/bin/shelltime.bak" - echo "Renamed ~/.shelltime/bin/shelltime to shelltime.bak (now using Homebrew version)" - fi - if [ -f "$HOME/.shelltime/bin/shelltime-daemon" ]; then - mv "$HOME/.shelltime/bin/shelltime-daemon" "$HOME/.shelltime/bin/shelltime-daemon.bak" - echo "Renamed ~/.shelltime/bin/shelltime-daemon to shelltime-daemon.bak (now using Homebrew version)" - fi - else - echo "Homebrew installation failed. Falling back to manual installation..." - fi -fi - -if [ "$BREW_INSTALLED" = false ]; then - -CLI_FILE_NAME="https://github.com/shelltime/cli/releases/latest/download/cli_" -DAEMON_FILE_NAME="${CLI_FILE_NAME}daemon_" - -cd /tmp -curr_time_dir="shelltime_install_$(date +"%Y%m%d_%H%M%S")" -mkdir -p "$curr_time_dir" -cd "$curr_time_dir" - get_download_url() { local baseUrl="$1" local downloadUrl="" @@ -70,7 +27,7 @@ get_download_url() { baseUrl="${baseUrl}${OS}" if [[ "$ARCH" == "x86_64" ]]; then downloadUrl="${baseUrl}_x86_64.tar.gz" - elif [[ "$ARCH" == "aarch64" ]]; then + elif [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then downloadUrl="${baseUrl}_arm64.tar.gz" else echo "Unsupported architecture: $ARCH on Linux" @@ -80,20 +37,6 @@ get_download_url() { echo "Error: tar is not installed." exit 1 fi - elif [[ "$OS" == "MINGW64_NT" ]] || [[ "$OS" == "MSYS_NT" ]] || [[ "$OS" == "CYGWIN_NT" ]]; then - baseUrl="${baseUrl}Windows" - if [[ "$ARCH" == "x86_64" ]]; then - downloadUrl="${baseUrl}_x86_64.zip" - elif [[ "$ARCH" == "aarch64" ]]; then - downloadUrl="${baseUrl}_arm64.zip" - else - echo "Unsupported architecture: $ARCH on Windows" - exit 1 - fi - if ! command_exists unzip; then - echo "Error: unzip is not installed." - exit 1 - fi else echo "Unsupported OS: $OS" exit 1 @@ -102,204 +45,315 @@ get_download_url() { echo "$downloadUrl" } -URL=$(get_download_url "$CLI_FILE_NAME") +process_file() { + local file="$1" + local url="$2" + local pending_file + pending_file=$(mktemp "${hooks_path}/${file}.XXXXXX") || return 1 + installer_temp_files+=("$pending_file") + if ! curl -fsSL --connect-timeout 15 --max-time 60 "$url" -o "$pending_file"; then + rm -f -- "$pending_file" + echo "Error: Failed to download $file. Existing hook and backup preserved." >&2 + return 1 + fi + if ! chmod 644 "$pending_file"; then + rm -f -- "$pending_file" + return 1 + fi + if [ -f "${hooks_path}/${file}" ]; then + # Keep the active hook in place until the final atomic replacement. + local pending_backup + pending_backup=$(mktemp "${hooks_path}/${file}.bak.XXXXXX") || { + rm -f -- "$pending_file" + return 1 + } + installer_temp_files+=("$pending_backup") + if ! cp -p -- "${hooks_path}/${file}" "$pending_backup" || + ! mv -- "$pending_backup" "${hooks_path}/${file}.bak"; then + rm -f -- "$pending_file" "$pending_backup" + return 1 + fi + fi + if ! mv -- "$pending_file" "${hooks_path}/${file}"; then + rm -f -- "$pending_file" + return 1 + fi +} -# Download the file -FILENAME=$(basename "$URL") -curl -sSLO "$URL" +add_source_to_config() { + local config_file="$1" + local source_file="$2" + local target_config="$config_file" + local link_target + while [ -L "$target_config" ]; do + link_target=$(readlink "$target_config") || return 1 + case "$link_target" in + /*) target_config="$link_target" ;; + *) target_config="$(dirname "$target_config")/$link_target" ;; + esac + done + local pending_config + pending_config=$(mktemp "${target_config}.XXXXXX") || return 1 + installer_temp_files+=("$pending_config") + if ! cp -p -- "$target_config" "$pending_config"; then + rm -f -- "$pending_config" + return 1 + fi -# Check if the download was successful -if [ ! -f "$FILENAME" ]; then - echo "Error: Failed to download $FILENAME" - exit 1 -fi + # Migrate the legacy unquoted spelling and remove previously emitted duplicates. + if ! SHELLTIME_SOURCE_FILE="$source_file" awk ' + BEGIN { + legacy = "source " ENVIRON["SHELLTIME_SOURCE_FILE"] + quoted = "source \"" ENVIRON["SHELLTIME_SOURCE_FILE"] "\"" + } + { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line == legacy || line == quoted) { + if (!found) print quoted + found = 1 + } else { + print + } + } + END { if (!found) print quoted } + ' "$target_config" > "$pending_config"; then + rm -f -- "$pending_config" + return 1 + fi + # Replace the resolved target atomically, preserving permissions and symlinks. + if ! mv -- "$pending_config" "$target_config"; then + rm -f -- "$pending_config" + return 1 + fi + rm -f -- "$pending_config" +} -# Extract the file -if [[ "$FILENAME" == *.zip ]]; then - unzip "$FILENAME" > /dev/null -elif [[ "$FILENAME" == *.tar.gz ]]; then - tar zxvf "$FILENAME" > /dev/null -else - echo "Unsupported file type: $FILENAME" - exit 1 -fi +installer_temp_files=() -# Check if the shelltime file exists -if [ ! -f "shelltime" ]; then - echo "Error: shelltime binary not found after extraction" - exit 1 -fi +cleanup_installer_files() { + local file + for file in "${installer_temp_files[@]:-}"; do + if [ -n "$file" ]; then rm -f -- "$file"; fi + done + if [ -n "${curr_time_dir:-}" ]; then rm -rf -- "$curr_time_dir"; fi +} -# Check if $HOME/.shelltime/bin exists, create if not -if [ ! -d "$HOME/.shelltime/bin" ]; then - mkdir -p "$HOME/.shelltime/bin" - if [ $? -ne 0 ]; then - echo "Error: Failed to create $HOME/.shelltime/bin directory." +install_shelltime() { + trap cleanup_installer_files EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + # Determine the OS and architecture + OS=$(uname -s) + ARCH=$(uname -m) + + case "$OS" in + Darwin|Linux) ;; + *) echo "Unsupported OS: $OS. Use macOS, Linux, or WSL." >&2; exit 1 ;; + esac + + # Flag to track whether Homebrew installation was used + BREW_INSTALLED=false + + # Check for required commands + if ! command_exists curl; then + echo "Error: curl is not installed." exit 1 fi -fi -# Move the binary to the appropriate location -if [[ "$OS" == "Darwin" ]] || [[ "$OS" == "Linux" ]]; then - mv shelltime "$HOME/.shelltime/bin/" - if [ -f "shelltime-daemon" ]; then - mv shelltime-daemon "$HOME/.shelltime/bin/" + # On macOS, prefer Homebrew installation if brew is available + if [[ "$OS" == "Darwin" ]] && command_exists brew; then + echo "Homebrew detected on macOS. Attempting to install via brew..." + if brew install shelltime/tap/shelltime; then + BREW_INSTALLED=true + echo "Successfully installed shelltime via Homebrew." + # Rename old manual-install binaries so the system uses the Homebrew version + if [ -f "$HOME/.shelltime/bin/shelltime" ]; then + mv "$HOME/.shelltime/bin/shelltime" "$HOME/.shelltime/bin/shelltime.bak" + echo "Renamed ~/.shelltime/bin/shelltime to shelltime.bak (now using Homebrew version)" + fi + if [ -f "$HOME/.shelltime/bin/shelltime-daemon" ]; then + mv "$HOME/.shelltime/bin/shelltime-daemon" "$HOME/.shelltime/bin/shelltime-daemon.bak" + echo "Renamed ~/.shelltime/bin/shelltime-daemon to shelltime-daemon.bak (now using Homebrew version)" + fi + else + echo "Homebrew installation failed. Falling back to manual installation..." + fi + fi + + if [ "$BREW_INSTALLED" = false ]; then + + CLI_FILE_NAME="https://github.com/shelltime/cli/releases/latest/download/cli_" + + curr_time_dir=$(mktemp -d "${TMPDIR:-/tmp}/shelltime-install.XXXXXX") + cd "$curr_time_dir" + + URL=$(get_download_url "$CLI_FILE_NAME") + + # Download the file + FILENAME=$(basename "$URL") + curl -fsSLO --connect-timeout 15 --max-time 300 "$URL" + + # Check if the download was successful + if [ ! -f "$FILENAME" ]; then + echo "Error: Failed to download $FILENAME" + exit 1 + fi + + # Extract the file + if [[ "$FILENAME" == *.zip ]]; then + unzip "$FILENAME" > /dev/null + elif [[ "$FILENAME" == *.tar.gz ]]; then + tar zxvf "$FILENAME" > /dev/null else - echo "" >&2 - echo "WARNING: shelltime-daemon binary was NOT found in $FILENAME." >&2 - echo " The CLI will attempt to auto-download it on first" >&2 - echo " 'shelltime daemon install/reinstall'." >&2 - echo "" >&2 + echo "Unsupported file type: $FILENAME" + exit 1 fi -# elif [[ "$OS" == "MINGW64_NT" ]] || [[ "$OS" == "MSYS_NT" ]] || [[ "$OS" == "CYGWIN_NT" ]]; then - # mv shelltime /c/Windows/System32/ -fi -# Add $HOME/.shelltime/bin to user path -if [[ "$OS" == "Darwin" ]] || [[ "$OS" == "Linux" ]]; then - # For Zsh - if [ -f "$HOME/.zshrc" ]; then - if ! grep -q '$HOME/.shelltime/bin' "$HOME/.zshrc"; then - echo '# Added by shelltime' >> "$HOME/.zshrc" - echo 'export PATH="$HOME/.shelltime/bin:$PATH"' >> "$HOME/.zshrc" - fi + # Check if the shelltime file exists + if [ ! -f "shelltime" ]; then + echo "Error: shelltime binary not found after extraction" + exit 1 fi - # For Fish - if command_exists fish; then - if [ ! -d "$HOME/.config/fish" ]; then - mkdir -p "$HOME/.config/fish" - fi - if [ ! -f "$HOME/.config/fish/config.fish" ]; then - touch "$HOME/.config/fish/config.fish" - fi - if ! grep -q '$HOME/.shelltime/bin' "$HOME/.config/fish/config.fish"; then - echo '# Added by shelltime' >> "$HOME/.config/fish/config.fish" - echo 'fish_add_path $HOME/.shelltime/bin' >> "$HOME/.config/fish/config.fish" + # Check if $HOME/.shelltime/bin exists, create if not + if [ ! -d "$HOME/.shelltime/bin" ]; then + if ! mkdir -p "$HOME/.shelltime/bin"; then + echo "Error: Failed to create $HOME/.shelltime/bin directory." + exit 1 fi fi - # For Bash - if [ -f "$HOME/.bashrc" ]; then - if ! grep -q '$HOME/.shelltime/bin' "$HOME/.bashrc"; then - echo '# Added by shelltime' >> "$HOME/.bashrc" - echo 'export PATH="$HOME/.shelltime/bin:$PATH"' >> "$HOME/.bashrc" + # Move the binary to the appropriate location + if [[ "$OS" == "Darwin" ]] || [[ "$OS" == "Linux" ]]; then + chmod 755 shelltime + mv shelltime "$HOME/.shelltime/bin/" + if [ -f "shelltime-daemon" ]; then + chmod 755 shelltime-daemon + mv shelltime-daemon "$HOME/.shelltime/bin/" + else + echo "" >&2 + echo "WARNING: shelltime-daemon binary was NOT found in $FILENAME." >&2 + echo " The CLI will attempt to auto-download it on first" >&2 + echo " 'shelltime daemon install/reinstall'." >&2 + echo "" >&2 fi fi -fi -# Clean up + # Add $HOME/.shelltime/bin to user path + if [[ "$OS" == "Darwin" ]] || [[ "$OS" == "Linux" ]]; then + # For Zsh + if [ -f "$HOME/.zshrc" ]; then + if ! grep -q '$HOME/.shelltime/bin' "$HOME/.zshrc"; then + echo '# Added by shelltime' >> "$HOME/.zshrc" + echo 'export PATH="$HOME/.shelltime/bin:$PATH"' >> "$HOME/.zshrc" + fi + fi -cd /tmp -if [ -d "/tmp/$curr_time_dir" ]; then - rm -rf "/tmp/$curr_time_dir" -fi + # For Fish + if command_exists fish; then + if [ ! -d "$HOME/.config/fish" ]; then + mkdir -p "$HOME/.config/fish" + fi + if [ ! -f "$HOME/.config/fish/config.fish" ]; then + touch "$HOME/.config/fish/config.fish" + fi + if ! grep -q '$HOME/.shelltime/bin' "$HOME/.config/fish/config.fish"; then + echo '# Added by shelltime' >> "$HOME/.config/fish/config.fish" + echo 'fish_add_path $HOME/.shelltime/bin' >> "$HOME/.config/fish/config.fish" + fi + fi + # For Bash + if [ -f "$HOME/.bashrc" ]; then + if ! grep -q '$HOME/.shelltime/bin' "$HOME/.bashrc"; then + echo '# Added by shelltime' >> "$HOME/.bashrc" + echo 'export PATH="$HOME/.shelltime/bin:$PATH"' >> "$HOME/.bashrc" + fi + fi + fi -# HELP WANTED -# I don't know where the `/bin` folder in windows. so i don't know where should the binaries be installed. -# if you know, please let me know. + # Clean up -if [[ "$OS" == "MINGW64_NT" ]] || [[ "$OS" == "MSYS_NT" ]] || [[ "$OS" == "CYGWIN_NT" ]]; then - echo "Note: Please move /tmp/shelltime to your bin folder manually." - echo "If you know where binaries should be installed on Windows, please open an issue: https://github.com/shelltime/cli" -fi + cd "${TMPDIR:-/tmp}" -fi # end of manual installation block + fi # end of manual installation block -# Check if $HOME/.shelltime/daemon exists, create if not -if [ ! -d "$HOME/.shelltime/daemon" ]; then - mkdir -p "$HOME/.shelltime/daemon" - if [ $? -ne 0 ]; then - echo "Warning: Failed to create $HOME/.shelltime/daemon directory. Daemon functionality may be unavailable." + # Check if $HOME/.shelltime/daemon exists, create if not + if [ ! -d "$HOME/.shelltime/daemon" ]; then + if ! mkdir -p "$HOME/.shelltime/daemon"; then + echo "Error: Failed to create $HOME/.shelltime/daemon directory." >&2 + return 1 + fi fi -fi -# STEP 2 -# insert a preexec and postexec script to user configuration, including `zsh` and `fish` + # STEP 2 + # insert a preexec and postexec script to user configuration, including `zsh` and `fish` -# Define the path -hooks_path="$HOME/.shelltime/hooks" + # Define the path + hooks_path="$HOME/.shelltime/hooks" -# Check if the directory exists -if [ ! -d "$hooks_path" ]; then - mkdir -p "$hooks_path" - if [ $? -ne 0 ]; then - echo "Warning: Failed to create $hooks_path directory. Shell hooks may be unavailable." + # Check if the directory exists + if [ ! -d "$hooks_path" ]; then + if ! mkdir -p "$hooks_path"; then + echo "Error: Failed to create $hooks_path directory." >&2 + return 1 + fi fi -fi + installation_failed=false -# Function to check and delete .bak files -check_and_delete_bak() { - local file="$1" - if [ -f "${hooks_path}/${file}.bak" ]; then - rm "${hooks_path}/${file}.bak" + # Process zsh.zsh + if ! process_file "zsh.zsh" "https://raw.githubusercontent.com/shelltime/installation/master/hooks/zsh.zsh"; then + installation_failed=true fi -} -# Function to check, rename, and download files -process_file() { - local file="$1" - local url="$2" + # Process fish.fish + if ! process_file "fish.fish" "https://raw.githubusercontent.com/shelltime/installation/master/hooks/fish.fish"; then + installation_failed=true + fi - # Check if the file exists and rename it - if [ -f "${hooks_path}/${file}" ]; then - mv "${hooks_path}/${file}" "${hooks_path}/${file}.bak" + # Process bash.bash + if ! process_file "bash-preexec.sh" "https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh"; then + installation_failed=true + fi + if ! process_file "bash.bash" "https://raw.githubusercontent.com/shelltime/installation/master/hooks/bash.bash"; then + installation_failed=true fi - # Download the new file - curl -sSL "${url}" -o "${hooks_path}/${file}" -} + # Add source lines to config files + if [ -f "$HOME/.zshrc" ] && [ -f "${hooks_path}/zsh.zsh" ]; then + add_source_to_config "$HOME/.zshrc" "${hooks_path}/zsh.zsh" + fi + if [ -f "$HOME/.config/fish/config.fish" ] && [ -f "${hooks_path}/fish.fish" ]; then + add_source_to_config "$HOME/.config/fish/config.fish" "${hooks_path}/fish.fish" + fi + if [ -f "$HOME/.bashrc" ] && [ -f "${hooks_path}/bash.bash" ] && [ -f "${hooks_path}/bash-preexec.sh" ]; then + add_source_to_config "$HOME/.bashrc" "${hooks_path}/bash.bash" + fi -# Function to add source line to config file if not already present -add_source_to_config() { - local config_file="$1" - local source_file="$2" - local source_line="source ${source_file}" + # Reinstall daemon if shelltime is available + if command_exists shelltime; then + if ! shelltime daemon reinstall > /dev/null 2>&1; then + echo "Warning: Daemon setup failed. Run shelltime doctor after authentication." >&2 + fi + fi - if ! grep -qF "${source_line}" "${config_file}"; then - echo "${source_line}" >> "${config_file}" + echo "" + if [ "$installation_failed" = true ]; then + echo "Installation incomplete: some hooks could not be updated. Rerun the installer to retry." >&2 + return 1 fi + echo "Installation complete!" + echo "" + echo "Next steps:" + echo " 1. Reload your shell: source ~/.zshrc (or ~/.bashrc / ~/.config/fish/config.fish)" + echo " 2. Run: shelltime init" + echo "" } -# Ensure hooks_path exists -mkdir -p "$hooks_path" - -# Check and delete .bak files -check_and_delete_bak "zsh.zsh" -check_and_delete_bak "fish.fish" - -# Process zsh.zsh -process_file "zsh.zsh" "https://raw.githubusercontent.com/shelltime/installation/master/hooks/zsh.zsh" - -# Process fish.fish -process_file "fish.fish" "https://raw.githubusercontent.com/shelltime/installation/master/hooks/fish.fish" - -# Process bash.bash -process_file "bash-preexec.sh" "https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh" -process_file "bash.bash" "https://raw.githubusercontent.com/shelltime/installation/master/hooks/bash.bash" - -# Add source lines to config files -if [ -f "$HOME/.zshrc" ]; then - add_source_to_config "$HOME/.zshrc" "${hooks_path}/zsh.zsh" -fi -if [ -f "$HOME/.config/fish/config.fish" ]; then - add_source_to_config "$HOME/.config/fish/config.fish" "${hooks_path}/fish.fish" -fi -if [ -f "$HOME/.bashrc" ]; then - add_source_to_config "$HOME/.bashrc" "${hooks_path}/bash.bash" +# Also run when piped to Bash, where BASH_SOURCE is empty. +if [[ -z "${BASH_SOURCE[0]:-}" || "${BASH_SOURCE[0]}" == "$0" ]]; then + install_shelltime fi - -# Reinstall daemon if shelltime is available -if command_exists shelltime; then - shelltime daemon reinstall > /dev/null 2>&1 -fi - -echo "" -echo "Installation complete!" -echo "" -echo "Next steps:" -echo " 1. Reload your shell: source ~/.zshrc (or ~/.bashrc / ~/.config/fish/config.fish)" -echo " 2. Run: shelltime init" -echo "" diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..ddea250 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,267 @@ +"""Installer regressions using isolated homes, local archives, and network stubs.""" + +import io +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "install.bash" + + +class InstallerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="shelltime-installer-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.binary_dir = self.root / "bin" + self.binary_dir.mkdir() + self.home = self.root / "home with spaces" + self.home.mkdir() + self.hooks = self.home / ".shelltime/hooks" + self.hooks.mkdir(parents=True) + self.env = os.environ.copy() + self.env.update({ + "HOME": str(self.home), + "PATH": str(self.binary_dir) + os.pathsep + self.env["PATH"], + "TMPDIR": str(self.root), + "STUB_OS": "Linux", + "STUB_ARCH": "x86_64", + "CURL_LOG": str(self.root / "curl.log"), + "DAEMON_LOG": str(self.root / "daemon.log"), + }) + self.stub("uname", 'if [[ "$1" == -s ]]; then echo "$STUB_OS"; else echo "$STUB_ARCH"; fi') + self.stub("curl", "exit 22") + self.stub("fish", "exit 0") + self.stub("shelltime", 'echo "$*" >> "$DAEMON_LOG"') + + def stub(self, name, body): + binary = self.binary_dir / name + binary.write_text("#!/bin/bash\nset -euo pipefail\n" + body + "\n") + binary.chmod(0o755) + + def run_script(self, command=None, *args): + argv = ["bash", str(SCRIPT)] if command is None else [ + "bash", "-c", 'source "$1"; shift; ' + command, + "installer-test", str(SCRIPT), *map(str, args), + ] + return subprocess.run(argv, env=self.env, capture_output=True, text=True, timeout=10) + + def enable_downloads(self, failed_hook=""): + archive = self.root / "release.tar.gz" + with tarfile.open(archive, "w:gz") as bundle: + for name in ("shelltime", "shelltime-daemon"): + body = b"#!/bin/bash\nexit 0\n" + entry = tarfile.TarInfo(name) + entry.size = len(body) + entry.mode = 0o755 + bundle.addfile(entry, io.BytesIO(body)) + self.env["STUB_ARCHIVE"] = str(archive) + self.env["FAILED_HOOK"] = failed_hook + self.stub("curl", ''' +url="${@: -1}" +output="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) output="$2"; shift 2 ;; + https://*) url="$1"; shift ;; + *) shift ;; + esac +done +echo "$url" >> "$CURL_LOG" +if [[ -z "$output" ]]; then + cp "$STUB_ARCHIVE" "$(basename "$url")" +elif [[ -n "$FAILED_HOOK" && "$url" == */"$FAILED_HOOK" ]]; then + exit 22 +else + echo "# updated hook" > "$output" +fi''') + + def test_archive_failure_exits_and_cleans_temporary_directory(self): + result = self.run_script() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(self.root.glob("shelltime-install.*")), []) + self.assertNotIn("Installation complete!", result.stdout) + self.assertEqual(list(self.home.glob(".shelltime/bin/*")), []) + + def test_hook_failure_preserves_working_file_and_backup(self): + original = self.hooks / "bash.bash" + backup = self.hooks / "bash.bash.bak" + original.write_text("working hook") + backup.write_text("previous backup") + result = self.run_script( + 'hooks_path="$1"; process_file bash.bash https://example.invalid/hook', self.hooks, + ) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(original.read_text(), "working hook") + self.assertEqual(backup.read_text(), "previous backup") + self.assertEqual(sorted(p.name for p in self.hooks.iterdir()), ["bash.bash", "bash.bash.bak"]) + + def test_hook_success_updates_backup_and_sets_permissions(self): + self.enable_downloads() + original = self.hooks / "bash.bash" + backup = self.hooks / "bash.bash.bak" + original.write_text("working hook") + backup.write_text("previous backup") + result = self.run_script( + 'hooks_path="$1"; process_file bash.bash https://example.invalid/hook', self.hooks, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(original.read_text(), "# updated hook\n") + self.assertEqual(backup.read_text(), "working hook") + self.assertEqual(original.stat().st_mode & 0o777, 0o644) + + def test_failed_backup_copy_preserves_existing_hook_and_backup(self): + self.enable_downloads() + original = self.hooks / "bash.bash" + backup = self.hooks / "bash.bash.bak" + original.write_text("working hook") + backup.write_text("previous backup") + self.stub("cp", 'echo "partial copy" > "${@: -1}"; exit 1') + result = self.run_script( + 'hooks_path="$1"; process_file bash.bash https://example.invalid/hook', self.hooks, + ) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(original.read_text(), "working hook") + self.assertEqual(backup.read_text(), "previous backup") + self.assertEqual(sorted(p.name for p in self.hooks.iterdir()), ["bash.bash", "bash.bash.bak"]) + + def test_legacy_and_duplicate_source_lines_are_migrated_once(self): + config = self.home / ".bashrc" + hook = self.hooks / "bash.bash" + hook.write_text('loads=$(( ${loads:-0} + 1 ))\n') + config.write_text(f'# user config\nsource {hook}\nsource "{hook}"\n') + result = self.run_script( + 'add_source_to_config "$1" "$2"; add_source_to_config "$1" "$2"; ' + 'loads=0; source "$1"; echo "$loads"', config, hook, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1") + self.assertEqual(config.read_text(), f'# user config\nsource "{hook}"\n') + + def test_new_source_line_with_spaces_is_idempotent(self): + config = self.home / ".bashrc" + hook = self.hooks / "bash.bash" + config.write_text("# keep me\n") + hook.write_text('echo "hook loaded"\n') + result = self.run_script( + 'add_source_to_config "$1" "$2"; add_source_to_config "$1" "$2"; source "$1"', + config, hook, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "hook loaded") + self.assertEqual(config.read_text(), f'# keep me\nsource "{hook}"\n') + + def test_partial_hook_failure_continues_setup_and_exits_nonzero(self): + self.enable_downloads(failed_hook="fish.fish") + fish = self.hooks / "fish.fish" + fish.write_text("working fish hook") + fish.with_suffix(".fish.bak").write_text("previous fish backup") + (self.home / ".bashrc").touch() + (self.home / ".zshrc").touch() + result = self.run_script() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Installation incomplete", result.stderr) + self.assertNotIn("Installation complete!", result.stdout) + self.assertEqual(fish.read_text(), "working fish hook") + self.assertEqual(fish.with_suffix(".fish.bak").read_text(), "previous fish backup") + for name in ("zsh.zsh", "bash.bash", "bash-preexec.sh"): + self.assertEqual((self.hooks / name).read_text(), "# updated hook\n") + self.assertIn(f'source "{self.hooks}/bash.bash"', (self.home / ".bashrc").read_text()) + self.assertIn(f'source "{self.hooks}/zsh.zsh"', (self.home / ".zshrc").read_text()) + self.assertEqual((self.root / "daemon.log").read_text(), "daemon reinstall\n") + self.assertEqual(list(self.root.glob("shelltime-install.*")), []) + + def test_successful_install_is_repeatable_with_a_spaced_home(self): + self.enable_downloads() + (self.home / ".bashrc").touch() + for _ in range(2): + result = self.run_script() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Installation complete!", result.stdout) + config = (self.home / ".bashrc").read_text() + self.assertEqual(config.count(f'source "{self.hooks}/bash.bash"'), 1) + self.assertEqual((self.home / ".shelltime/bin/shelltime").stat().st_mode & 0o777, 0o755) + self.assertEqual(list(self.root.glob("shelltime-install.*")), []) + + def test_linux_arm_architectures_use_arm64_archives(self): + for arch in ("arm64", "aarch64"): + with self.subTest(arch=arch): + result = self.run_script( + 'OS=Linux; ARCH="$1"; get_download_url https://example.invalid/cli_', arch, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "https://example.invalid/cli_Linux_arm64.tar.gz") + + def test_unsupported_os_fails_before_downloads(self): + self.env["STUB_OS"] = "FreeBSD" + result = self.run_script() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported OS: FreeBSD", result.stderr) + self.assertFalse((self.root / "curl.log").exists()) + + def test_piped_install_runs_platform_checks(self): + self.env["STUB_OS"] = "FreeBSD" + result = subprocess.run( + ["bash"], input=SCRIPT.read_text(), env=self.env, + capture_output=True, text=True, timeout=10, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported OS: FreeBSD", result.stderr) + + def test_failed_new_hook_is_not_sourced(self): + self.enable_downloads(failed_hook="fish.fish") + result = self.run_script() + self.assertNotEqual(result.returncode, 0) + fish_config = self.home / ".config/fish/config.fish" + self.assertNotIn("source ", fish_config.read_text()) + self.assertFalse((self.hooks / "fish.fish").exists()) + self.assertTrue((self.hooks / "bash.bash").exists()) + + def test_config_without_trailing_newline_is_preserved(self): + config = self.home / ".bashrc" + hook = self.hooks / "bash.bash" + config.write_text("# existing config") + result = self.run_script('add_source_to_config "$1" "$2"', config, hook) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(config.read_text(), f'# existing config\nsource "{hook}"\n') + + def test_symlinked_config_target_and_permissions_are_preserved(self): + target = self.home / "shared.bashrc" + target.write_text("# shared config\n") + target.chmod(0o640) + config = self.home / ".bashrc" + config.symlink_to(target.name) + hook = self.hooks / "bash.bash" + result = self.run_script('add_source_to_config "$1" "$2"', config, hook) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(config.is_symlink()) + self.assertEqual(os.readlink(config), target.name) + self.assertEqual(target.read_text(), f'# shared config\nsource "{hook}"\n') + self.assertEqual(target.stat().st_mode & 0o777, 0o640) + + def test_interrupted_hook_download_cleans_pending_files(self): + self.enable_downloads() + original = self.hooks / "zsh.zsh" + original.write_text("working hook") + curl = self.binary_dir / "curl" + curl.write_text(curl.read_text().replace( + 'echo "# updated hook" > "$output"', 'kill -TERM "$PPID"\n exit 143', + )) + result = self.run_script() + self.assertEqual(result.returncode, 143, result.stderr) + self.assertEqual(original.read_text(), "working hook") + self.assertEqual(list(self.hooks.glob("zsh.zsh.*")), []) + self.assertEqual(list(self.root.glob("shelltime-install.*")), []) + + def test_sourcing_helpers_preserves_the_callers_exit_trap(self): + result = self.run_script('trap "echo preserved" EXIT; source "$1"', SCRIPT) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "preserved") + + +if __name__ == "__main__": + unittest.main()