feat(cli): install tht as a system command

This commit is contained in:
2026-08-15 22:05:42 +02:00
parent aa8a2e9278
commit 5e13396669
5 changed files with 385 additions and 0 deletions
+30
View File
@@ -5,6 +5,36 @@ export DOCKER_BUILDKIT=1
repository_root=$(cd "$(dirname "$0")/.." && pwd) repository_root=$(cd "$(dirname "$0")/.." && pwd)
output_directory="${THT_THT_OUTPUT_DIRECTORY:-$repository_root/dist/tht}" 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" == */ || if [[ "$output_directory" != /* || "$output_directory" == / || "$output_directory" == */ ||
"$output_directory" == *//* || "/$output_directory/" == */../* || "$output_directory" == *//* || "/$output_directory/" == */../* ||
"/$output_directory/" == */./* ]]; then "/$output_directory/" == */./* ]]; then
+104
View File
@@ -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.'
}
+101
View File
@@ -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"
+69
View File
@@ -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
}
+81
View File
@@ -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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
case "\${1:-}" in
--help|-h) echo "Usage: tht" ;;
version) echo "$version" ;;
*) echo "unexpected argument: \${1:-}" >&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."