runner-run 0.10.0

Universal project task runner
Documentation
#!/usr/bin/env pwsh

Set-StrictMode -Version 2.0
$ErrorActionPreference = "Stop"

$Repo = "kjanat/runner"

function Show-Usage {
	@'
Install runner binaries from GitHub Releases.

Usage:
  install.ps1 [X.Y.Z|vX.Y.Z]

Arguments:
  X.Y.Z|vX.Y.Z  Optional release tag. If omitted, installs latest release.

Environment:
  RUNNER_VERSION      Release tag override (e.g. 0.1.0 or v0.1.0)
  RUNNER_INSTALL_DIR  Destination directory (highest precedence)
  XDG_BIN_HOME        Destination directory fallback before ~/.local/bin on non-Windows

Defaults:
  Windows             %LOCALAPPDATA%\Programs\runner\bin
  Other platforms     $HOME/.local/bin
'@
}

function Write-Step {
	param([string] $Message)
	Write-Output "==> $Message"
}

function Write-Item {
	param([string] $Message)
	Write-Output "  - $Message"
}

function Get-RequiredCommand {
	param([string] $Name)

	$commands = @(Get-Command -Name $Name -CommandType Application -ErrorAction SilentlyContinue)
	if ($commands.Count -eq 0) {
		throw "required command not found: $Name"
	}

	return $commands[0].Source
}

function Get-CurrentArch {
	try {
		return [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
	} catch {
		# Fall back below for older Windows PowerShell runtimes.
	}

	if ($env:PROCESSOR_ARCHITECTURE) {
		return $env:PROCESSOR_ARCHITECTURE
	}

	$uname = @(Get-Command -Name uname -CommandType Application -ErrorAction SilentlyContinue)
	if ($uname.Count -gt 0) {
		return (& $uname[0].Source -m).Trim()
	}

	throw "unsupported architecture"
}

function Resolve-Target {
	param([string] $Arch)

	$arch = $Arch.ToLowerInvariant()

	if ($IsWindows) {
		switch ($arch) {
			{ $_ -in @("x64", "amd64", "x86_64") } { return "x86_64-pc-windows-msvc" }
			{ $_ -in @("arm64", "aarch64") } { return "aarch64-pc-windows-msvc" }
			{ $_ -in @("x86", "i386", "i686") } { return "i686-pc-windows-msvc" }
		}
	} elseif ($IsMacOS) {
		switch ($arch) {
			{ $_ -in @("x64", "amd64", "x86_64") } { return "x86_64-apple-darwin" }
			{ $_ -in @("arm64", "aarch64") } { return "aarch64-apple-darwin" }
		}
	} elseif ($IsLinux) {
		switch ($arch) {
			{ $_ -in @("x64", "amd64", "x86_64") } { return "x86_64-unknown-linux-musl" }
			{ $_ -in @("arm64", "aarch64") } { return "aarch64-unknown-linux-musl" }
			{ $_ -in @("arm", "armv7l") } { return "armv7-unknown-linux-gnueabihf" }
		}
	}

	throw "unsupported platform or architecture: $Arch"
}

function Resolve-InstallDir {
	if ($env:RUNNER_INSTALL_DIR) {
		return $env:RUNNER_INSTALL_DIR
	}

	if ($IsWindows) {
		if ($env:LOCALAPPDATA) {
			return (Join-Path $env:LOCALAPPDATA "Programs\runner\bin")
		}
		if ($HOME) {
			return (Join-Path $HOME "AppData\Local\Programs\runner\bin")
		}
		throw "LOCALAPPDATA or HOME is required"
	}

	if ($env:XDG_BIN_HOME) {
		return $env:XDG_BIN_HOME
	}
	if ($HOME) {
		return (Join-Path $HOME ".local/bin")
	}

	throw "HOME is required"
}

function Invoke-Download {
	param(
		[string] $Uri,
		[string] $OutFile
	)

	for ($attempt = 1; $attempt -le 3; $attempt++) {
		try {
			Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing
			return
		} catch {
			if ($attempt -eq 3) {
				throw
			}
			Start-Sleep -Seconds 1
		}
	}
}

function Resolve-LatestVersion {
	try {
		$response = Invoke-WebRequest `
			-Uri "https://api.github.com/repos/$Repo/releases/latest" `
			-Headers @{ Accept = "application/vnd.github+json" } `
			-UseBasicParsing
		$release = $response.Content | ConvertFrom-Json
		if ($release.tag_name) {
			return $release.tag_name
		}
	} catch {
		throw "failed to resolve latest release version: $($_.Exception.Message)"
	}

	throw "failed to resolve latest release version"
}

function Test-Checksum {
	param(
		[string] $ArchivePath,
		[string] $ChecksumPath
	)

	$line = (Get-Content -LiteralPath $ChecksumPath -TotalCount 1).Trim()
	$expected = @($line -split "\s+")[0].ToLowerInvariant()
	if ($expected -notmatch "^[0-9a-f]{64}$") {
		throw "invalid checksum file: $ChecksumPath"
	}

	$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $ArchivePath).Hash.ToLowerInvariant()
	if ($actual -ne $expected) {
		throw "checksum verification failed for $ArchivePath"
	}
}

function Get-PathWithoutTrailingSeparator {
	param([string] $Path)

	if (-not $Path) {
		return ""
	}

	[char[]] $trimChars = @([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
	return $Path.TrimEnd($trimChars)
}

function Test-PathEntry {
	param([string] $Directory)

	$pathValue = [string] $env:PATH
	$comparison = [System.StringComparison]::Ordinal
	if ($IsWindows) {
		$comparison = [System.StringComparison]::OrdinalIgnoreCase
	}

	foreach ($entry in $pathValue -split [System.IO.Path]::PathSeparator) {
		if ([string]::Equals((Get-PathWithoutTrailingSeparator -Path $entry), (Get-PathWithoutTrailingSeparator -Path $Directory), $comparison)) {
			return $true
		}
	}

	return $false
}

function Install-Runner {
	param([string[]] $ScriptArgs)

	if ($ScriptArgs.Count -eq 1 -and $ScriptArgs[0] -in @("-h", "--help")) {
		Show-Usage
		return
	}

	if ($ScriptArgs.Count -gt 1) {
		Show-Usage | ForEach-Object { [Console]::Error.WriteLine($_) }
		exit 1
	}

	try {
		[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
	} catch {
		# Older/non-Windows runtimes may not expose ServicePointManager.
	}

	if (-not ($IsWindows -or $IsLinux -or $IsMacOS)) {
		throw "unsupported operating system"
	}

	$arch = Get-CurrentArch
	$target = Resolve-Target -Arch $arch
	$installDir = Resolve-InstallDir
	$tar = Get-RequiredCommand -Name "tar"

	$version = $env:RUNNER_VERSION
	if (-not $version -and $ScriptArgs.Count -eq 1) {
		$version = $ScriptArgs[0]
	}
	if (-not $version) {
		$version = Resolve-LatestVersion
	}
	if (-not $version.StartsWith("v", [System.StringComparison]::Ordinal)) {
		$version = "v$version"
	}

	$asset = "runner-$version-$target.tar.gz"
	$checksumAsset = "runner-$version-$target.sha256"
	$baseUrl = "https://github.com/$Repo/releases/download/$version"
	$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("runner-install-" + [System.Guid]::NewGuid().ToString("N"))

	New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null

	try {
		$archivePath = Join-Path $tmpDir $asset
		$checksumPath = Join-Path $tmpDir $checksumAsset

		Write-Step "Downloading release assets"
		Write-Item "archive: $asset"
		Invoke-Download -Uri "$baseUrl/$asset" -OutFile $archivePath
		Invoke-Download -Uri "$baseUrl/$checksumAsset" -OutFile $checksumPath

		Test-Checksum -ArchivePath $archivePath -ChecksumPath $checksumPath

		& $tar -xzf $archivePath -C $tmpDir
		if ($LASTEXITCODE -ne 0) {
			throw "failed to extract $asset"
		}

		$extension = ""
		if ($IsWindows) {
			$extension = ".exe"
		}

		$binaries = @("runner$extension", "run$extension")
		foreach ($binary in $binaries) {
			$binaryPath = Join-Path $tmpDir $binary
			if (-not (Test-Path -LiteralPath $binaryPath -PathType Leaf)) {
				throw "missing binary in archive: $binary"
			}
		}

		New-Item -ItemType Directory -Path $installDir -Force | Out-Null
		foreach ($binary in $binaries) {
			Copy-Item -LiteralPath (Join-Path $tmpDir $binary) -Destination (Join-Path $installDir $binary) -Force
		}

		if (-not $IsWindows) {
			$chmod = Get-RequiredCommand -Name "chmod"
			& $chmod 755 (Join-Path $installDir "runner") (Join-Path $installDir "run")
			if ($LASTEXITCODE -ne 0) {
				throw "failed to mark installed binaries executable"
			}
		}

		Write-Step "Installation complete"
		Write-Item "location: $installDir"

		$expectedRunner = Join-Path $installDir "runner$extension"
		try {
			$installedVersion = & $expectedRunner -V
			Write-Item "version: $installedVersion"
		} catch {
			Write-Item "warning: failed to execute $expectedRunner -V"
		}

		$resolvedInstallDir = (Resolve-Path -LiteralPath $installDir).ProviderPath
		if (-not (Test-PathEntry -Directory $resolvedInstallDir)) {
			Write-Item "PATH: add $resolvedInstallDir to your PATH"
		}

		$resolvedRunner = @(Get-Command -Name "runner" -CommandType Application -ErrorAction SilentlyContinue)
		if ($resolvedRunner.Count -gt 0) {
			$comparison = [System.StringComparison]::Ordinal
			if ($IsWindows) {
				$comparison = [System.StringComparison]::OrdinalIgnoreCase
			}
			if (-not [string]::Equals($resolvedRunner[0].Source, $expectedRunner, $comparison)) {
				Write-Item "refresh shell: restart the shell if needed"
			}
		}
	} finally {
		if (Test-Path -LiteralPath $tmpDir) {
			Remove-Item -LiteralPath $tmpDir -Recurse -Force
		}
	}
}

try {
	Install-Runner -ScriptArgs $args
} catch {
	[Console]::Error.WriteLine("error: $($_.Exception.Message)")
	exit 1
}