diff --git a/scripts/build-tht.sh b/scripts/build-tht.sh index b3a15fbb..27231ff8 100755 --- a/scripts/build-tht.sh +++ b/scripts/build-tht.sh @@ -5,6 +5,36 @@ export DOCKER_BUILDKIT=1 repository_root=$(cd "$(dirname "$0")/.." && pwd) output_directory="${THT_THT_OUTPUT_DIRECTORY:-$repository_root/dist/tht}" +usage() { + cat <<'EOF' +Usage: bash scripts/build-tht.sh [--all] [--output ABSOLUTE_DIRECTORY] + +Build every supported native tht binary with the repository-pinned Docker builder. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --all) + ;; + --output) + [[ $# -ge 2 ]] || { echo "--output requires an absolute directory" >&2; exit 2; } + output_directory=$2 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + if [[ "$output_directory" != /* || "$output_directory" == / || "$output_directory" == */ || "$output_directory" == *//* || "/$output_directory/" == */../* || "/$output_directory/" == */./* ]]; then diff --git a/scripts/install-tht.ps1 b/scripts/install-tht.ps1 new file mode 100644 index 00000000..9374d0b4 --- /dev/null +++ b/scripts/install-tht.ps1 @@ -0,0 +1,104 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Sha256([string]$Path) { + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Get-UserPathValue { + if ($env:THT_USER_PATH_FILE) { + if (Test-Path -LiteralPath $env:THT_USER_PATH_FILE) { + return [System.IO.File]::ReadAllText($env:THT_USER_PATH_FILE) + } + return '' + } + return [Environment]::GetEnvironmentVariable('Path', 'User') +} + +function Set-UserPathValue([string]$Value) { + if ($env:THT_USER_PATH_FILE) { + [System.IO.File]::WriteAllText($env:THT_USER_PATH_FILE, $Value) + return + } + [Environment]::SetEnvironmentVariable('Path', $Value, 'User') +} + +function Add-InstallDirectoryToUserPath([string]$InstallDirectory) { + $currentPath = Get-UserPathValue + $entries = @($currentPath -split ';' | Where-Object { $_ -ne '' }) + if ($entries | Where-Object { $_.TrimEnd('\\') -ieq $InstallDirectory.TrimEnd('\\') }) { + return $false + } + $newPath = (@($entries) + $InstallDirectory) -join ';' + Set-UserPathValue $newPath + return $true +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$buildOutput = $null +$sourceArtifactForInstall = $null +$installDirectory = if ($env:THT_INSTALL_DIRECTORY) { + $env:THT_INSTALL_DIRECTORY +} else { + Join-Path $env:LOCALAPPDATA 'ThothII\bin' +} +$installDirectory = [System.IO.Path]::GetFullPath($installDirectory) + +if ($env:THT_BUILD_ARTIFACT) { + $sourceArtifact = $env:THT_BUILD_ARTIFACT + if (-not (Test-Path -LiteralPath $sourceArtifact -PathType Leaf)) { + throw 'THT_BUILD_ARTIFACT is not a regular file' + } + if (-not $env:THT_BUILD_ARTIFACT_SHA256 -or $env:THT_BUILD_ARTIFACT_SHA256 -notmatch '^[0-9A-Fa-f]{64}$') { + throw 'THT_BUILD_ARTIFACT_SHA256 must be a 64-character SHA-256 digest' + } + if ((Get-Sha256 $sourceArtifact) -ne $env:THT_BUILD_ARTIFACT_SHA256.ToLowerInvariant()) { + throw 'packaged tht artifact checksum does not match' + } + $sourceArtifactForInstall = $sourceArtifact +} else { + if ($env:PROCESSOR_ARCHITECTURE -notin @('AMD64', 'x86_64')) { + throw "unsupported Windows processor architecture: $env:PROCESSOR_ARCHITECTURE (supported: AMD64)" + } + $buildOutput = Join-Path ([System.IO.Path]::GetTempPath()) ("tht-build-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $buildOutput -Force | Out-Null + $previousOutput = $env:THT_THT_OUTPUT_DIRECTORY + $env:THT_THT_OUTPUT_DIRECTORY = $buildOutput + try { + & bash (Join-Path $repositoryRoot 'scripts/build-tht.sh') --all + if ($LASTEXITCODE -ne 0) { throw 'the repository-pinned Docker build failed' } + } finally { + $env:THT_THT_OUTPUT_DIRECTORY = $previousOutput + } + $sourceArtifactForInstall = Join-Path $buildOutput 'tht-windows-amd64.exe' + if (-not (Test-Path -LiteralPath $sourceArtifactForInstall -PathType Leaf)) { + throw 'the repository-pinned Docker build did not produce tht-windows-amd64.exe' + } +} + +if ((Get-Item -LiteralPath $sourceArtifactForInstall).Length -eq 0) { throw 'native artifact is empty' } +New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null +$stagedBinary = Join-Path $installDirectory ('.tht-' + [guid]::NewGuid() + '.exe') +try { + Copy-Item -LiteralPath $sourceArtifactForInstall -Destination $stagedBinary -Force + & $stagedBinary --help | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'native artifact did not pass its help check' } + Move-Item -LiteralPath $stagedBinary -Destination (Join-Path $installDirectory 'tht.exe') -Force +} finally { + Remove-Item -LiteralPath $stagedBinary -Force -ErrorAction SilentlyContinue + if ($buildOutput) { Remove-Item -LiteralPath $buildOutput -Recurse -Force -ErrorAction SilentlyContinue } +} + +$pathChanged = Add-InstallDirectoryToUserPath $installDirectory +if ($env:Path -notlike "*$installDirectory*") { + $env:Path = "$installDirectory;$env:Path" +} +& (Join-Path $installDirectory 'tht.exe') --help | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'installed tht did not pass its help check' } +Write-Output "Installed tht at $(Join-Path $installDirectory 'tht.exe')" +if ($pathChanged) { + Write-Output 'Added ThothII to your user PATH. Open a new terminal before using tht there.' +} diff --git a/scripts/install-tht.sh b/scripts/install-tht.sh new file mode 100644 index 00000000..d59db5ca --- /dev/null +++ b/scripts/install-tht.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root=$(cd "$(dirname "$0")/.." && pwd) +install_directory="${THT_INSTALL_DIRECTORY:-/usr/local/bin}" +staging_root=$(mktemp -d) +trap 'rm -rf "$staging_root"' EXIT HUP INT TERM + +fail() { + echo "install-tht.sh: $*" >&2 + exit 1 +} + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + fail "SHA-256 verification requires sha256sum or shasum" + fi +} + +platform_artifact_name() { + local system architecture + system=$(uname -s) + architecture=$(uname -m) + case "$system" in + Darwin) system=darwin ;; + Linux) system=linux ;; + *) fail "unsupported operating system: $system (supported: macOS and Linux)" ;; + esac + case "$architecture" in + x86_64|amd64) architecture=amd64 ;; + arm64|aarch64) architecture=arm64 ;; + *) fail "unsupported processor architecture: $architecture (supported: amd64 and arm64)" ;; + esac + printf 'tht-%s-%s\n' "$system" "$architecture" +} + +validate_packaged_artifact() { + local artifact=$1 expected actual + expected=${THT_BUILD_ARTIFACT_SHA256:-} + [[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || fail "THT_BUILD_ARTIFACT_SHA256 must be a 64-character SHA-256 digest" + actual=$(sha256 "$artifact") + [[ "${actual,,}" == "${expected,,}" ]] || fail "packaged tht artifact checksum does not match" +} + +build_artifact() { + local build_output="$staging_root/build" + mkdir -p "$build_output" + THT_THT_OUTPUT_DIRECTORY="$build_output" bash "$repository_root/scripts/build-tht.sh" --all >&2 + printf '%s/%s\n' "$build_output" "$(platform_artifact_name)" +} + +install_atomically() { + local destination_directory=$1 source_file=$2 + local temporary_destination + if mkdir -p "$destination_directory" 2>/dev/null && [[ -w "$destination_directory" ]]; then + temporary_destination=$(mktemp "$destination_directory/.tht.XXXXXX") + cp "$source_file" "$temporary_destination" + chmod 0755 "$temporary_destination" + mv -f "$temporary_destination" "$destination_directory/tht" + return + fi + + command -v sudo >/dev/null 2>&1 || fail "cannot write to $destination_directory and sudo is unavailable" + sudo sh -c ' + set -eu + destination_directory=$1 + source_file=$2 + mkdir -p "$destination_directory" + temporary_destination=$(mktemp "$destination_directory/.tht.XXXXXX") + trap "rm -f \"$temporary_destination\"" EXIT HUP INT TERM + cp "$source_file" "$temporary_destination" + chmod 0755 "$temporary_destination" + mv -f "$temporary_destination" "$destination_directory/tht" + ' sh "$destination_directory" "$source_file" +} + +[[ "$install_directory" == /* ]] || fail "THT_INSTALL_DIRECTORY must be an absolute path" +artifact_name=$(platform_artifact_name) +if [[ -n "${THT_BUILD_ARTIFACT:-}" ]]; then + source_artifact=$THT_BUILD_ARTIFACT + [[ -f "$source_artifact" ]] || fail "THT_BUILD_ARTIFACT is not a regular file" + validate_packaged_artifact "$source_artifact" +else + source_artifact=$(build_artifact) +fi + +[[ -s "$source_artifact" ]] || fail "native artifact is missing: $artifact_name" +staged_binary="$staging_root/tht" +cp "$source_artifact" "$staged_binary" +chmod 0755 "$staged_binary" +"$staged_binary" --help >/dev/null || fail "native artifact did not pass its help check" + +install_atomically "$install_directory" "$staged_binary" +resolved_command=$(command -v tht || true) +[[ "$resolved_command" == "$install_directory/tht" ]] || fail "installed tht is not resolvable at $install_directory/tht; add $install_directory to PATH and open a new terminal" +"$install_directory/tht" --help >/dev/null || fail "installed tht did not pass its help check" +printf 'Installed tht at %s/tht\n' "$install_directory" diff --git a/scripts/test-install-tht.ps1 b/scripts/test-install-tht.ps1 new file mode 100644 index 00000000..edd5c11c --- /dev/null +++ b/scripts/test-install-tht.ps1 @@ -0,0 +1,69 @@ +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$installer = Join-Path $repositoryRoot 'scripts/install-tht.ps1' +if (-not (Test-Path -LiteralPath $installer)) { + throw "installer is missing: $installer" +} + +$isWindowsHost = $env:OS -eq 'Windows_NT' +if (-not $isWindowsHost) { + $installerText = Get-Content -LiteralPath $installer -Raw + foreach ($requiredText in @('THT_INSTALL_DIRECTORY', 'LOCALAPPDATA', 'SetEnvironmentVariable', 'build-tht.sh', 'Move-Item')) { + if (-not $installerText.Contains($requiredText)) { + throw "Windows installer is missing required behavior: $requiredText" + } + } + Write-Output 'PowerShell installer behavior test skipped: requires Windows.' + exit 0 +} + +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("tht installer test {0}" -f [guid]::NewGuid()) +$installDirectory = Join-Path $temporaryRoot 'command directory' +$artifact = Join-Path $temporaryRoot 'tht.exe' +$userPathStore = Join-Path $temporaryRoot 'user-path.txt' +New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null +try { + $program = @' +using System; +public static class Program { + public static void Main(string[] args) { + if (args.Length == 1 && args[0] == "--help") { Console.WriteLine("Usage: tht"); return; } + if (args.Length == 1 && args[0] == "version") { Console.WriteLine("tht test version"); return; } + Environment.Exit(2); + } +} +'@ + Add-Type -TypeDefinition $program -OutputAssembly $artifact -OutputType ConsoleApplication + [System.IO.File]::WriteAllText($userPathStore, 'C:\Existing Bin') + + $env:THT_INSTALL_DIRECTORY = $installDirectory + $env:THT_BUILD_ARTIFACT = $artifact + $env:THT_BUILD_ARTIFACT_SHA256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifact).Hash.ToLowerInvariant() + $env:THT_USER_PATH_FILE = $userPathStore + & $installer + + $installed = Join-Path $installDirectory 'tht.exe' + if (-not (Test-Path -LiteralPath $installed)) { throw 'installer did not create tht.exe' } + if ((& $installed version) -ne 'tht test version') { throw 'installed tht.exe did not return version' } + $firstBytes = [System.IO.File]::ReadAllBytes($installed) + + & $installer + if ((& $installed version) -ne 'tht test version') { throw 'installer was not idempotent' } + if (-not ([System.IO.File]::ReadAllBytes($installed).Length -eq $firstBytes.Length)) { throw 'replacement changed the unexpected binary' } + + $storedPath = [System.IO.File]::ReadAllText($userPathStore) + $segments = $storedPath -split ';' | Where-Object { $_ -ne '' } + if ((@($segments | Where-Object { $_ -ieq $installDirectory }).Count) -ne 1) { + throw 'installer did not preserve a single destination PATH entry' + } + if (-not ($segments | Where-Object { $_ -ieq 'C:\Existing Bin' })) { throw 'installer did not preserve existing user PATH entries' } + Write-Output 'PowerShell tht installer tests passed.' +} +finally { + Remove-Item Env:THT_INSTALL_DIRECTORY -ErrorAction SilentlyContinue + Remove-Item Env:THT_BUILD_ARTIFACT -ErrorAction SilentlyContinue + Remove-Item Env:THT_BUILD_ARTIFACT_SHA256 -ErrorAction SilentlyContinue + Remove-Item Env:THT_USER_PATH_FILE -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/test-install-tht.sh b/scripts/test-install-tht.sh new file mode 100644 index 00000000..58b9ca5c --- /dev/null +++ b/scripts/test-install-tht.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root=$(cd "$(dirname "$0")/.." && pwd) +installer="$repository_root/scripts/install-tht.sh" +temporary_root=$(mktemp -d) +trap 'rm -rf "$temporary_root"' EXIT HUP INT TERM + +fail() { + echo "test-install-tht.sh: $*" >&2 + exit 1 +} + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +write_artifact() { + local path=$1 + local version=$2 + cat >"$path" <&2; exit 2 ;; +esac +EOF + chmod 0644 "$path" +} + +test_directory="$temporary_root/command directory" +artifact_one="$temporary_root/tht-one" +artifact_two="$temporary_root/tht-two" +write_artifact "$artifact_one" "tht test version one" +write_artifact "$artifact_two" "tht test version two" + +system_path=$PATH +run_installer() { + local artifact=$1 + THT_INSTALL_DIRECTORY="$test_directory" \ + THT_BUILD_ARTIFACT="$artifact" \ + THT_BUILD_ARTIFACT_SHA256="$(sha256 "$artifact")" \ + PATH="$test_directory:$system_path" \ + bash "$installer" +} + +run_installer "$artifact_one" + +test -x "$test_directory/tht" || fail "installed tht is not executable" +first_inode=$(stat -f '%i' "$test_directory/tht" 2>/dev/null || stat -c '%i' "$test_directory/tht") +first_version=$(PATH="$test_directory:$system_path" tht version) +test "$first_version" = "tht test version one" || fail "installed command did not run version one" + +run_installer "$artifact_two" +second_inode=$(stat -f '%i' "$test_directory/tht" 2>/dev/null || stat -c '%i' "$test_directory/tht") +second_version=$(PATH="$test_directory:$system_path" tht version) +test "$second_version" = "tht test version two" || fail "replacement did not install version two" +test "$first_inode" != "$second_inode" || fail "replacement did not atomically replace the destination" + +run_installer "$artifact_two" +third_version=$(PATH="$test_directory:$system_path" tht version) +test "$third_version" = "tht test version two" || fail "re-running installer was not idempotent" + +if THT_INSTALL_DIRECTORY="$test_directory" \ + THT_BUILD_ARTIFACT="$artifact_one" \ + THT_BUILD_ARTIFACT_SHA256="not-a-checksum" \ + PATH="$test_directory:$system_path" \ + bash "$installer" >/dev/null 2>&1; then + fail "installer accepted a checksum mismatch" +fi + +after_failed_checksum=$(PATH="$test_directory:$system_path" tht version) +test "$after_failed_checksum" = "tht test version two" || fail "failed install replaced the existing command" + +echo "shell tht installer tests passed."