diff --git a/.github/workflows/publish-cli-installer.yml b/.github/workflows/publish-cli-installer.yml new file mode 100644 index 0000000..06e9802 --- /dev/null +++ b/.github/workflows/publish-cli-installer.yml @@ -0,0 +1,119 @@ +name: Publish CLI installer + +on: + workflow_call: + inputs: + tag: + description: Release tag whose installer should be published + required: true + type: string + secrets: + CLOUDFLARE_R2_ACCESS_KEY_ID: + required: true + CLOUDFLARE_R2_SECRET_ACCESS_KEY: + required: true + workflow_dispatch: + inputs: + tag: + description: Existing release tag to publish or repair + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-cli-installer-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + publish: + name: Publish get.stackdome.com/cli.sh + runs-on: ubuntu-latest + environment: installer-production + env: + AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ vars.CLOUDFLARE_R2_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} + RELEASE_TAG: ${{ inputs.tag }} + steps: + - name: Validate publishing configuration + run: | + if [[ ! "${RELEASE_TAG}" =~ ^v[A-Za-z0-9._-]+$ ]]; then + echo "Invalid release tag: ${RELEASE_TAG}" >&2 + exit 1 + fi + test -n "${R2_ACCOUNT_ID}" || { + echo "Repository variable CLOUDFLARE_R2_ACCOUNT_ID is required" >&2 + exit 1 + } + test -n "${R2_BUCKET}" || { + echo "Repository variable CLOUDFLARE_R2_BUCKET is required" >&2 + exit 1 + } + + - name: Check out release tag + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: refs/tags/${{ inputs.tag }} + + - name: Validate installer syntax + run: sh -n install.sh + + - name: Publish immutable installer + env: + R2_ENDPOINT: https://${{ vars.CLOUDFLARE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + run: | + published_installer="$(mktemp)" + head_error="$(mktemp)" + trap 'rm -f "${published_installer}" "${head_error}"' EXIT + + if aws s3api head-object \ + --bucket "${R2_BUCKET}" \ + --key "cli/${RELEASE_TAG}/cli.sh" \ + --endpoint-url "${R2_ENDPOINT}" >/dev/null 2>"${head_error}"; then + aws s3 cp "s3://${R2_BUCKET}/cli/${RELEASE_TAG}/cli.sh" "${published_installer}" \ + --endpoint-url "${R2_ENDPOINT}" + if ! cmp install.sh "${published_installer}"; then + echo "Refusing to overwrite immutable installer cli/${RELEASE_TAG}/cli.sh" >&2 + exit 1 + fi + elif grep -Eq '\(404\)|Not Found|NoSuchKey' "${head_error}"; then + aws s3 cp install.sh "s3://${R2_BUCKET}/cli/${RELEASE_TAG}/cli.sh" \ + --endpoint-url "${R2_ENDPOINT}" \ + --content-type "text/x-shellscript; charset=utf-8" \ + --cache-control "public,max-age=31536000,immutable" + else + cat "${head_error}" >&2 + exit 1 + fi + + aws s3 cp "s3://${R2_BUCKET}/cli/${RELEASE_TAG}/cli.sh" "${published_installer}" \ + --endpoint-url "${R2_ENDPOINT}" + cmp install.sh "${published_installer}" + sh -n "${published_installer}" + + - name: Publish stable alias last + env: + R2_ENDPOINT: https://${{ vars.CLOUDFLARE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + run: | + aws s3 cp install.sh "s3://${R2_BUCKET}/cli.sh" \ + --endpoint-url "${R2_ENDPOINT}" \ + --content-type "text/x-shellscript; charset=utf-8" \ + --cache-control "no-store" + + - name: Verify public installer + run: | + public_installer="$(mktemp)" + response_headers="$(mktemp)" + trap 'rm -f "${public_installer}" "${response_headers}"' EXIT + curl --retry 5 --retry-all-errors -fsSL \ + -D "${response_headers}" \ + -o "${public_installer}" \ + https://get.stackdome.com/cli.sh + cmp install.sh "${public_installer}" + sh -n "${public_installer}" + grep -qi '^content-type: text/x-shellscript' "${response_headers}" + grep -qi '^cache-control: no-store' "${response_headers}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2aaa836..560c5c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,10 +85,6 @@ jobs: goarch: amd64 - goos: linux goarch: arm64 - - goos: windows - goarch: amd64 - - goos: windows - goarch: arm64 steps: - name: Check out repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -113,18 +109,11 @@ jobs: build_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" mkdir -p package dist binary="stackdome" - if [ "${GOOS}" = "windows" ]; then - binary="stackdome.exe" - fi go build -trimpath \ -ldflags "-s -w -X main.Version=${version} -X main.GitCommit=${commit} -X main.BuildDate=${build_date}" \ -o "package/${binary}" ./cmd/stackdome cp LICENSE package/LICENSE - if [ "${GOOS}" = "windows" ]; then - (cd package && zip -q "../dist/stackdome_${version}_${GOOS}_${GOARCH}.zip" "${binary}" LICENSE) - else - tar -C package -czf "dist/stackdome_${version}_${GOOS}_${GOARCH}.tar.gz" "${binary}" LICENSE - fi + tar -C package -czf "dist/stackdome_${version}_${GOOS}_${GOARCH}.tar.gz" "${binary}" LICENSE - name: Verify release metadata if: matrix.goos == 'linux' && matrix.goarch == 'amd64' @@ -169,13 +158,46 @@ jobs: - name: Generate checksum manifest working-directory: dist - run: sha256sum *.tar.gz *.zip > checksums.txt + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + expected_archives=( + "stackdome_${RELEASE_TAG}_darwin_amd64.tar.gz" + "stackdome_${RELEASE_TAG}_darwin_arm64.tar.gz" + "stackdome_${RELEASE_TAG}_linux_amd64.tar.gz" + "stackdome_${RELEASE_TAG}_linux_arm64.tar.gz" + ) + for archive in "${expected_archives[@]}"; do + test -f "${archive}" || { + echo "Missing release archive: ${archive}" >&2 + exit 1 + } + done + archive_count="$(find . -maxdepth 1 -type f -name '*.tar.gz' | wc -l | tr -d ' ')" + if [ "${archive_count}" != "4" ]; then + echo "Expected exactly 4 release archives, found ${archive_count}" >&2 + exit 1 + fi + sha256sum "${expected_archives[@]}" > checksums.txt + checksum_count="$(wc -l < checksums.txt | tr -d ' ')" + if [ "${checksum_count}" != "4" ]; then + echo "Expected exactly 4 checksums, found ${checksum_count}" >&2 + exit 1 + fi - name: Publish release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} run: | + expected_assets=( + "stackdome_${RELEASE_TAG}_darwin_amd64.tar.gz" + "stackdome_${RELEASE_TAG}_darwin_arm64.tar.gz" + "stackdome_${RELEASE_TAG}_linux_amd64.tar.gz" + "stackdome_${RELEASE_TAG}_linux_arm64.tar.gz" + "checksums.txt" + ) + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then release_is_draft="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" else @@ -183,9 +205,44 @@ jobs: release_is_draft=true fi - gh release upload "${RELEASE_TAG}" dist/*.tar.gz dist/*.zip --clobber + while IFS= read -r asset; do + is_expected=false + for expected_asset in "${expected_assets[@]}"; do + if [[ "${asset}" == "${expected_asset}" ]]; then + is_expected=true + break + fi + done + if [[ "${is_expected}" == "false" ]]; then + echo "Removing obsolete release asset: ${asset}" + gh release delete-asset "${RELEASE_TAG}" "${asset}" --yes + fi + done < <(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name') + + gh release upload "${RELEASE_TAG}" dist/*.tar.gz --clobber gh release upload "${RELEASE_TAG}" dist/checksums.txt --clobber + mapfile -t actual_assets < <( + gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort + ) + mapfile -t sorted_expected_assets < <(printf '%s\n' "${expected_assets[@]}" | sort) + if [[ "${actual_assets[*]}" != "${sorted_expected_assets[*]}" ]]; then + echo "Release assets do not match the Linux/macOS publication contract" >&2 + printf 'Expected: %s\n' "${sorted_expected_assets[*]}" >&2 + printf 'Actual: %s\n' "${actual_assets[*]}" >&2 + exit 1 + fi + if [[ "${release_is_draft}" == "true" ]]; then gh release edit "${RELEASE_TAG}" --draft=false fi + + publish-installer: + name: Publish CLI installer + needs: + - prepare + - publish + uses: ./.github/workflows/publish-cli-installer.yml + with: + tag: ${{ needs.prepare.outputs.tag }} + secrets: inherit diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml deleted file mode 100644 index 9159c1b..0000000 --- a/.github/workflows/windows-installer.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Test Windows installer - -on: - pull_request: - paths: - - "install.ps1" - - "install_windows_contract_test.go" - - "tests/install_windows/**" - - ".github/workflows/windows-installer.yml" - push: - branches: - - main - paths: - - "install.ps1" - - "install_windows_contract_test.go" - - "tests/install_windows/**" - - ".github/workflows/windows-installer.yml" - workflow_dispatch: - -permissions: - contents: read - -jobs: - test: - name: PowerShell 5.1 and PowerShell 7 - runs-on: windows-latest - env: - STACKDOME_REQUIRE_BOTH_POWERSHELLS: "1" - steps: - - name: Check out repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - - name: Set up Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 - with: - go-version: "1.25.x" - cache: true - - - name: Test Windows installer - shell: pwsh - run: go test ./tests/install_windows -count=1 -v diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 71179f6..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Repository Instructions - -## Agent-generated planning artifacts - -- Do not commit Superpowers-generated or agent-generated specifications and implementation plans. -- Keep those artifacts untracked under ignored locations such as `docs/superpowers/`, `docs/agents/`, `.agents/specs/`, or `.agents/plans/`. -- Before committing or updating a pull request, remove any such artifacts that were accidentally staged or tracked. diff --git a/INSTALL.md b/INSTALL.md index 5ef5609..16beb95 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,12 +1,12 @@ # Install the Stackdome CLI -The installers download the latest GitHub release for the current platform and -verify its SHA-256 checksum before installing it. +The installer downloads the latest GitHub release for the current platform and +verifies its SHA-256 checksum before installing it. ## macOS and Linux ```sh -curl -fsSL https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.sh | sh +curl -fsSL https://get.stackdome.com/cli.sh | sh ``` For agents and CI, download first so a network failure cannot be hidden by @@ -15,7 +15,7 @@ pipeline exit-status behavior: ```sh installer_file=$(mktemp) trap 'rm -f "$installer_file"' EXIT -curl -fsSL https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.sh -o "$installer_file" +curl -fsSL https://get.stackdome.com/cli.sh -o "$installer_file" sh "$installer_file" ``` @@ -25,32 +25,13 @@ when that directory is writable, otherwise it uses `$HOME/.local/bin`. To install a specific version or directory: ```sh -curl -fsSL https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.sh \ - | STACKDOME_VERSION=v0.0.1-alpha STACKDOME_INSTALL_DIR="$HOME/.local/bin" sh -``` - -## Windows PowerShell - -```powershell -irm https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.ps1 | iex -``` - -For agents and CI, fetch the script before evaluating it so download errors are -terminal: - -```powershell -$installer = Invoke-RestMethod -ErrorAction Stop https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.ps1 -& ([ScriptBlock]::Create([string]$installer)) +installer_file=$(mktemp) +trap 'rm -f "$installer_file"' EXIT +curl -fsSL https://get.stackdome.com/cli.sh -o "$installer_file" +sh "$installer_file" \ + --version v0.0.2-alpha \ + --install-dir "$HOME/.local/bin" ``` -The installer supports AMD64 and ARM64. By default it installs to -`%LOCALAPPDATA%\Programs\Stackdome\bin` and adds that directory to the user -`PATH`. - -To install a specific version or directory: - -```powershell -$env:STACKDOME_VERSION = 'v0.0.1-alpha' -$env:STACKDOME_INSTALL_DIR = "$env:LOCALAPPDATA\Programs\Stackdome\bin" -irm https://raw.githubusercontent.com/Stackdome/stackdome-cli/main/install.ps1 | iex -``` +`STACKDOME_VERSION` and `STACKDOME_INSTALL_DIR` provide the same settings for +automation. Explicit flags take precedence over environment variables. diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index e741674..0000000 --- a/install.ps1 +++ /dev/null @@ -1,255 +0,0 @@ -& { -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$previousProgressPreference = $ProgressPreference -$ProgressPreference = 'SilentlyContinue' - -function Get-ConfiguredValue { - param( - [Parameter(Mandatory = $true)] - [string]$Name, - - [Parameter(Mandatory = $true)] - [string]$DefaultValue - ) - - $value = [Environment]::GetEnvironmentVariable($Name) - if ([string]::IsNullOrWhiteSpace($value)) { - return $DefaultValue - } - return $value.Trim() -} - -function Get-StackdomeArchitecture { - $architectureOverride = [Environment]::GetEnvironmentVariable('STACKDOME_ARCH') - if (-not [string]::IsNullOrWhiteSpace($architectureOverride)) { - switch ($architectureOverride.Trim().ToLowerInvariant()) { - 'amd64' { return 'amd64' } - 'x64' { return 'amd64' } - 'arm64' { return 'arm64' } - default { - throw "Unsupported Windows architecture override '$architectureOverride'. Set STACKDOME_ARCH to AMD64 or ARM64." - } - } - } - - # PROCESSOR_ARCHITEW6432 reports the native machine architecture when a - # 32-bit or x64 process is emulated on ARM64 Windows. - $detectedArchitecture = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432') - if ([string]::IsNullOrWhiteSpace($detectedArchitecture)) { - $detectedArchitecture = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE') - } - if ([string]::IsNullOrWhiteSpace($detectedArchitecture)) { - try { - $detectedArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - } - catch { - throw 'Unable to detect the Windows architecture. Set STACKDOME_ARCH to AMD64 or ARM64.' - } - } - - switch ($detectedArchitecture.Trim().ToUpperInvariant()) { - 'AMD64' { 'amd64' } - 'X64' { 'amd64' } - 'ARM64' { 'arm64' } - default { - throw "Unsupported Windows architecture '$detectedArchitecture'. Stackdome provides installers for AMD64 and ARM64." - } - } -} - -function Get-LatestStackdomeVersion { - param( - [Parameter(Mandatory = $true)] - [string]$ApiBaseUrl, - - [Parameter(Mandatory = $true)] - [string]$Repository - ) - - $latestReleaseUrl = '{0}/repos/{1}/releases/latest' -f $ApiBaseUrl.TrimEnd('/'), $Repository - try { - $release = Invoke-RestMethod -Uri $latestReleaseUrl -UseBasicParsing -Headers @{ - Accept = 'application/vnd.github+json' - 'User-Agent' = 'stackdome-installer' - } - } - catch { - throw "Unable to discover the latest Stackdome release for repository '$Repository'. Check network access or set STACKDOME_VERSION explicitly." - } - - $tagNameProperty = $release.PSObject.Properties['tag_name'] - if ($null -eq $tagNameProperty) { - throw "The latest-release response for repository '$Repository' did not contain a tag_name. Set STACKDOME_VERSION explicitly." - } - $version = [string]$tagNameProperty.Value - if ([string]::IsNullOrWhiteSpace($version)) { - throw "The latest-release response for repository '$Repository' contained an empty tag_name. Set STACKDOME_VERSION explicitly." - } - return $version.Trim() -} - -function Save-StackdomeFile { - param( - [Parameter(Mandatory = $true)] - [string]$Uri, - - [Parameter(Mandatory = $true)] - [string]$Destination, - - [Parameter(Mandatory = $true)] - [string]$Description - ) - - try { - Invoke-WebRequest -Uri $Uri -OutFile $Destination -UseBasicParsing - } - catch { - throw "Unable to download $Description. Verify that the release and asset exist, then retry." - } -} - -function Test-PathContains { - param( - [AllowNull()] - [string]$PathValue, - - [Parameter(Mandatory = $true)] - [string]$Entry - ) - - if ([string]::IsNullOrWhiteSpace($PathValue)) { - return $false - } - - $normalizedEntry = $Entry.Trim().Trim('"').TrimEnd([char[]]'\/') - foreach ($candidate in ($PathValue -split [IO.Path]::PathSeparator)) { - $normalizedCandidate = $candidate.Trim().Trim('"').TrimEnd([char[]]'\/') - if ([StringComparer]::OrdinalIgnoreCase.Equals($normalizedCandidate, $normalizedEntry)) { - return $true - } - } - return $false -} - -$repository = Get-ConfiguredValue -Name 'STACKDOME_REPOSITORY' -DefaultValue 'Stackdome/stackdome-cli' -if ($repository -notmatch '^[^/\s]+/[^/\s]+$') { - throw "STACKDOME_REPOSITORY must use the owner/repository format; received '$repository'." -} - -$version = [Environment]::GetEnvironmentVariable('STACKDOME_VERSION') -if ([string]::IsNullOrWhiteSpace($version)) { - $apiBaseUrl = Get-ConfiguredValue -Name 'STACKDOME_API_BASE_URL' -DefaultValue 'https://api.github.com' - $version = Get-LatestStackdomeVersion -ApiBaseUrl $apiBaseUrl -Repository $repository -} -else { - $version = $version.Trim() -} -if ($version -notmatch '^[A-Za-z0-9._-]+$') { - throw "Invalid release version '$version'. Use a tag containing only letters, numbers, dots, underscores, or hyphens." -} - -$architecture = Get-StackdomeArchitecture -$assetName = 'stackdome_{0}_windows_{1}.zip' -f $version, $architecture -$releaseBaseUrl = Get-ConfiguredValue -Name 'STACKDOME_RELEASE_BASE_URL' -DefaultValue "https://github.com/$repository/releases/download" -$releaseBaseUrl = $releaseBaseUrl.TrimEnd('/') -$assetUrl = '{0}/{1}/{2}' -f $releaseBaseUrl, $version, $assetName -$checksumsUrl = '{0}/{1}/checksums.txt' -f $releaseBaseUrl, $version - -$configuredInstallDir = [Environment]::GetEnvironmentVariable('STACKDOME_INSTALL_DIR') -if ([string]::IsNullOrWhiteSpace($configuredInstallDir)) { - $localAppData = [Environment]::GetFolderPath('LocalApplicationData') - if ([string]::IsNullOrWhiteSpace($localAppData)) { - $localAppData = $env:LOCALAPPDATA - } - if ([string]::IsNullOrWhiteSpace($localAppData)) { - throw 'Unable to determine a user-writable install directory. Set STACKDOME_INSTALL_DIR explicitly.' - } - $installDir = Join-Path $localAppData 'Programs\Stackdome\bin' -} -else { - $installDir = [Environment]::ExpandEnvironmentVariables($configuredInstallDir.Trim()) -} - -$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ('stackdome-install-{0}' -f [Guid]::NewGuid().ToString('N')) -$archivePath = Join-Path $tempRoot $assetName -$checksumsPath = Join-Path $tempRoot 'checksums.txt' -$extractPath = Join-Path $tempRoot 'extracted' - -try { - New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null - Save-StackdomeFile -Uri $assetUrl -Destination $archivePath -Description $assetName - Save-StackdomeFile -Uri $checksumsUrl -Destination $checksumsPath -Description 'checksums.txt' - - $checksumManifest = Get-Content -LiteralPath $checksumsPath -Raw - $checksumPattern = '(?im)^(?[0-9a-f]{64})[ \t]+\*?' + [regex]::Escape($assetName) + '[ \t]*$' - $checksumMatch = [regex]::Match($checksumManifest, $checksumPattern) - if (-not $checksumMatch.Success) { - throw "checksums.txt does not contain an exact SHA-256 entry for $assetName. The release may be incomplete." - } - - $expectedHash = $checksumMatch.Groups['hash'].Value.ToLowerInvariant() - $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualHash -ne $expectedHash) { - throw "Checksum mismatch for $assetName. Expected $expectedHash but downloaded $actualHash. Delete any cached copy and retry." - } - - try { - Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force - } - catch { - throw "Unable to extract the verified archive $assetName. Check available disk space and retry." - } - - $executablePath = Join-Path $extractPath 'stackdome.exe' - if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) { - throw "The verified archive $assetName does not contain stackdome.exe." - } - - try { - New-Item -ItemType Directory -Path $installDir -Force | Out-Null - Copy-Item -LiteralPath $executablePath -Destination (Join-Path $installDir 'stackdome.exe') -Force - } - catch { - throw "Unable to install stackdome.exe in '$installDir'. Set STACKDOME_INSTALL_DIR to a writable directory and retry." - } - - $skipPathUpdate = [Environment]::GetEnvironmentVariable('STACKDOME_SKIP_PATH_UPDATE') - if ($skipPathUpdate -ne '1') { - if (-not (Test-PathContains -PathValue $env:Path -Entry $installDir)) { - if ([string]::IsNullOrWhiteSpace($env:Path)) { - $env:Path = $installDir - } - else { - $env:Path = $installDir + [IO.Path]::PathSeparator + $env:Path - } - } - - try { - $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') - if (-not (Test-PathContains -PathValue $userPath -Entry $installDir)) { - if ([string]::IsNullOrWhiteSpace($userPath)) { - $updatedUserPath = $installDir - } - else { - $updatedUserPath = $userPath + [IO.Path]::PathSeparator + $installDir - } - [Environment]::SetEnvironmentVariable('Path', $updatedUserPath, 'User') - } - } - catch { - Write-Warning "Stackdome was installed, but the user PATH could not be updated. Add '$installDir' to your user PATH manually." - } - } - - Write-Host "Stackdome CLI $version was installed to $installDir\stackdome.exe" - Write-Host "Restart your terminal, then run 'stackdome version' to verify the installation." -} -finally { - $ProgressPreference = $previousProgressPreference - if (Test-Path -LiteralPath $tempRoot) { - Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue - } -} -} diff --git a/install.sh b/install.sh index 4b0c3fd..db44068 100755 --- a/install.sh +++ b/install.sh @@ -6,7 +6,26 @@ repository=${STACKDOME_REPOSITORY:-Stackdome/stackdome-cli} release_base_url=${STACKDOME_RELEASE_BASE_URL:-https://github.com/${repository}/releases/download} api_base_url=${STACKDOME_API_BASE_URL:-https://api.github.com} version=${STACKDOME_VERSION:-} +install_dir=${STACKDOME_INSTALL_DIR:-} tmp_dir= +staged_binary= + +usage() { + cat <<'EOF' +Usage: install.sh [options] + +Install the Stackdome CLI on macOS or Linux. + +Options: + --version Install a specific release (for example, v0.0.2-alpha) + --install-dir Install stackdome into this directory + -h, --help Show this help + +Environment: + STACKDOME_VERSION Default release version + STACKDOME_INSTALL_DIR Default installation directory +EOF +} fail() { printf 'error: %s\n' "$*" >&2 @@ -14,6 +33,9 @@ fail() { } cleanup() { + if [ -n "$staged_binary" ] && [ -e "$staged_binary" ]; then + rm -f "$staged_binary" + fi if [ -n "$tmp_dir" ] && [ -d "$tmp_dir" ]; then rm -rf "$tmp_dir" fi @@ -32,6 +54,31 @@ download() { fi } +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail "--version requires a value" + version=$2 + shift 2 + ;; + --install-dir) + [ "$#" -ge 2 ] && [ -n "$2" ] || fail "--install-dir requires a value" + install_dir=$2 + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + [ "$#" -eq 0 ] || fail "unexpected argument: $1" + ;; + -*) fail "unknown option: $1" ;; + *) fail "unexpected argument: $1" ;; + esac +done + case "$(uname -s)" in Darwin) os=darwin ;; Linux) os=linux ;; @@ -44,8 +91,8 @@ case "$(uname -m)" in *) fail "unsupported architecture: $(uname -m) (supported: amd64 and arm64)" ;; esac -if [ -n "${STACKDOME_INSTALL_DIR:-}" ]; then - install_dir=$STACKDOME_INSTALL_DIR +if [ -n "$install_dir" ]; then + : elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then install_dir=/usr/local/bin else @@ -95,9 +142,12 @@ printf 'Verified checksum for %s.\n' "$asset" tar -xzf "$archive_path" -C "$tmp_dir" stackdome || fail "could not extract stackdome from $asset" mkdir -p "$install_dir" || fail "could not create install directory: $install_dir" -install -m 0755 "$tmp_dir/stackdome" "$install_dir/stackdome" || fail "could not install stackdome to $install_dir" +staged_binary=$(mktemp "$install_dir/.stackdome.install.XXXXXX") || fail "could not stage stackdome in $install_dir" +install -m 0755 "$tmp_dir/stackdome" "$staged_binary" || fail "could not stage stackdome in $install_dir" +mv -f "$staged_binary" "$install_dir/stackdome" || fail "could not install stackdome to $install_dir" +staged_binary= -printf 'Installed Stackdome CLI to %s/stackdome\n' "$install_dir" +printf 'Installed Stackdome CLI %s to %s/stackdome\n' "$version" "$install_dir" case ":${PATH:-}:" in *":$install_dir:"*) ;; *) printf 'warning: %s is not on your PATH; add it before running stackdome\n' "$install_dir" >&2 ;; diff --git a/tests/install_windows/install_test.go b/tests/install_windows/install_test.go deleted file mode 100644 index 414b3ad..0000000 --- a/tests/install_windows/install_test.go +++ /dev/null @@ -1,361 +0,0 @@ -package installwindows_test - -import ( - "archive/zip" - "bytes" - "crypto/sha256" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" -) - -const windowsTestVersion = "v1.2.3" - -func TestInstallsARM64ArchiveWhenX64PowerShellIsEmulated(t *testing.T) { - script := readInstaller(t) - assetName := "stackdome_" + windowsTestVersion + "_windows_arm64.zip" - binary := []byte("fake Windows ARM64 executable\r\n") - archive := makeZipArchive(t, binary) - digest := sha256.Sum256(archive) - checksums := fmt.Sprintf("%x %s\n", digest, assetName) - - var requestMu sync.Mutex - var requested []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestMu.Lock() - requested = append(requested, r.URL.Path) - requestMu.Unlock() - - switch r.URL.Path { - case "/install.ps1": - w.Header().Set("Content-Type", "text/plain") - _, _ = w.Write(script) - case "/downloads/" + windowsTestVersion + "/" + assetName: - _, _ = w.Write(archive) - case "/downloads/" + windowsTestVersion + "/checksums.txt": - _, _ = io.WriteString(w, checksums) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - - for _, shell := range powershells(t) { - t.Run(filepath.Base(shell), func(t *testing.T) { - installDir := t.TempDir() - result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ - "STACKDOME_VERSION": windowsTestVersion, - "STACKDOME_INSTALL_DIR": installDir, - "STACKDOME_RELEASE_BASE_URL": server.URL + "/downloads", - "STACKDOME_ARCH": "", - "STACKDOME_SKIP_PATH_UPDATE": "1", - "PROCESSOR_ARCHITEW6432": "ARM64", - "PROCESSOR_ARCHITECTURE": "AMD64", - }) - if result.err != nil { - t.Fatalf("installer failed: %v\noutput:\n%s", result.err, result.output) - } - - installed, err := os.ReadFile(filepath.Join(installDir, "stackdome.exe")) - if err != nil { - t.Fatalf("read installed executable: %v", err) - } - if !bytes.Equal(installed, binary) { - t.Fatalf("installed executable = %q, want %q", installed, binary) - } - }) - } - - requestMu.Lock() - defer requestMu.Unlock() - joinedRequests := strings.Join(requested, "\n") - for _, path := range []string{ - "/downloads/" + windowsTestVersion + "/" + assetName, - "/downloads/" + windowsTestVersion + "/checksums.txt", - } { - if !strings.Contains(joinedRequests, path) { - t.Errorf("installer did not request %s; requests:\n%s", path, joinedRequests) - } - } -} - -func TestRejectsChecksumMismatch(t *testing.T) { - script := readInstaller(t) - assetName := "stackdome_" + windowsTestVersion + "_windows_amd64.zip" - archive := makeZipArchive(t, []byte("must not be installed\r\n")) - checksums := strings.Repeat("0", 64) + " " + assetName + "\n" - - server := newInstallerServer(t, script, func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/downloads/" + windowsTestVersion + "/" + assetName: - _, _ = w.Write(archive) - case "/downloads/" + windowsTestVersion + "/checksums.txt": - _, _ = io.WriteString(w, checksums) - default: - http.NotFound(w, r) - } - }) - - for _, shell := range powershells(t) { - t.Run(filepath.Base(shell), func(t *testing.T) { - installDir := t.TempDir() - result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ - "STACKDOME_VERSION": windowsTestVersion, - "STACKDOME_INSTALL_DIR": installDir, - "STACKDOME_RELEASE_BASE_URL": server.URL + "/downloads", - "STACKDOME_ARCH": "amd64", - "STACKDOME_SKIP_PATH_UPDATE": "1", - }) - if result.err == nil { - t.Fatalf("installer accepted a mismatched checksum; output:\n%s", result.output) - } - if !strings.Contains(result.output, "Checksum mismatch") { - t.Fatalf("output %q does not explain the checksum mismatch", result.output) - } - if _, err := os.Stat(filepath.Join(installDir, "stackdome.exe")); !os.IsNotExist(err) { - t.Fatalf("executable was installed after checksum mismatch (stat error: %v)", err) - } - }) - } -} - -func TestDownloadFailureDoesNotExposeReleaseURL(t *testing.T) { - script := readInstaller(t) - const secret = "token-super-secret" - server := newInstallerServer(t, script, http.NotFoundHandler().ServeHTTP) - - for _, shell := range powershells(t) { - t.Run(filepath.Base(shell), func(t *testing.T) { - result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ - "STACKDOME_VERSION": windowsTestVersion, - "STACKDOME_INSTALL_DIR": t.TempDir(), - "STACKDOME_RELEASE_BASE_URL": server.URL + "/" + secret, - "STACKDOME_ARCH": "amd64", - "STACKDOME_SKIP_PATH_UPDATE": "1", - }) - if result.err == nil { - t.Fatalf("installer succeeded without a release asset; output:\n%s", result.output) - } - if strings.Contains(result.output, secret) { - t.Fatalf("installer exposed a secret-bearing release URL: %q", result.output) - } - if !strings.Contains(result.output, "Unable to download stackdome_v1.2.3_windows_amd64.zip") { - t.Fatalf("output %q does not identify the unavailable asset", result.output) - } - }) - } -} - -func TestMalformedLatestResponseIsActionableAndRedacted(t *testing.T) { - script := readInstaller(t) - const secret = "api-token-super-secret" - const repository = "example/stackdome-cli" - server := newInstallerServer(t, script, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/"+secret+"/repos/"+repository+"/releases/latest" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, "{}\n") - }) - - for _, shell := range powershells(t) { - t.Run(filepath.Base(shell), func(t *testing.T) { - result := runInstaller(t, shell, server.URL+"/install.ps1", map[string]string{ - "STACKDOME_VERSION": "", - "STACKDOME_INSTALL_DIR": t.TempDir(), - "STACKDOME_API_BASE_URL": server.URL + "/" + secret, - "STACKDOME_RELEASE_BASE_URL": server.URL + "/downloads", - "STACKDOME_REPOSITORY": repository, - "STACKDOME_ARCH": "amd64", - "STACKDOME_SKIP_PATH_UPDATE": "1", - }) - if result.err == nil { - t.Fatalf("installer accepted malformed latest-release metadata; output:\n%s", result.output) - } - if strings.Contains(result.output, secret) { - t.Fatalf("installer exposed a secret-bearing API URL: %q", result.output) - } - if !strings.Contains(result.output, "Set STACKDOME_VERSION explicitly") { - t.Fatalf("output %q does not suggest the version override", result.output) - } - if strings.Contains(result.output, "Property 'tag_name' cannot be found") { - t.Fatalf("strict-mode property error escaped instead of actionable remediation: %q", result.output) - } - }) - } -} - -func TestEnvironmentForShellDropsInheritedPSModulePathForWindowsPowerShell(t *testing.T) { - base := []string{ - "KEEP=original", - "PsMoDuLePaTh=C:\\Program Files\\PowerShell\\7\\Modules", - "STACKDOME_ARCH=old", - } - - environment := environmentForShell(base, "powershell.exe", map[string]string{ - "STACKDOME_ARCH": "arm64", - }) - - if _, found := environmentValue(environment, "PSModulePath"); found { - t.Fatalf("PSModulePath remained in Windows PowerShell environment: %q", environment) - } - if value, found := environmentValue(environment, "KEEP"); !found || value != "original" { - t.Fatalf("KEEP = %q, %v; want original, true", value, found) - } - if value, found := environmentValue(environment, "STACKDOME_ARCH"); !found || value != "arm64" { - t.Fatalf("STACKDOME_ARCH = %q, %v; want arm64, true", value, found) - } -} - -func TestEnvironmentForShellPreservesPSModulePathForPowerShell7(t *testing.T) { - base := []string{"PSModulePath=C:\\Program Files\\PowerShell\\7\\Modules"} - - environment := environmentForShell(base, "pwsh.exe", nil) - - value, found := environmentValue(environment, "PSModulePath") - if !found || value != `C:\Program Files\PowerShell\7\Modules` { - t.Fatalf("PSModulePath = %q, %v; want PowerShell 7 path, true", value, found) - } -} - -func environmentValue(environment []string, name string) (string, bool) { - for _, entry := range environment { - entryName, value, ok := strings.Cut(entry, "=") - if ok && strings.EqualFold(entryName, name) { - return value, true - } - } - return "", false -} - -type commandResult struct { - output string - err error -} - -func powershells(t *testing.T) []string { - t.Helper() - if runtime.GOOS != "windows" { - t.Skip("Windows PowerShell execution test") - } - - var shells []string - for _, name := range []string{"powershell.exe", "pwsh"} { - path, err := exec.LookPath(name) - if err == nil { - shells = append(shells, path) - } - } - if os.Getenv("STACKDOME_REQUIRE_BOTH_POWERSHELLS") == "1" && len(shells) != 2 { - t.Fatalf("Windows CI requires both powershell.exe 5.1 and pwsh; found %d", len(shells)) - } - if len(shells) == 0 { - t.Skip("powershell.exe and pwsh are unavailable") - } - return shells -} - -func runInstaller(t *testing.T, shell, scriptURL string, overrides map[string]string) commandResult { - t.Helper() - testEnvironment := map[string]string{ - "STACKDOME_VERSION": "", - "STACKDOME_INSTALL_DIR": "", - "STACKDOME_RELEASE_BASE_URL": "", - "STACKDOME_API_BASE_URL": "", - "STACKDOME_REPOSITORY": "Stackdome/stackdome-cli", - "STACKDOME_ARCH": "", - "STACKDOME_SKIP_PATH_UPDATE": "1", - } - for name, value := range overrides { - testEnvironment[name] = value - } - - command := fmt.Sprintf("Invoke-RestMethod -Uri '%s' | Invoke-Expression", scriptURL) - cmd := exec.Command(shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command) - cmd.Env = environmentForShell(os.Environ(), shell, testEnvironment) - output, err := cmd.CombinedOutput() - return commandResult{output: string(output), err: err} -} - -func environmentForShell(base []string, shell string, overrides map[string]string) []string { - environment := make([]string, 0, len(base)+len(overrides)) - dropModulePath := strings.EqualFold(filepath.Base(shell), "powershell.exe") - for _, entry := range base { - name, _, ok := strings.Cut(entry, "=") - if !ok { - continue - } - if dropModulePath && strings.EqualFold(name, "PSModulePath") { - continue - } - if _, overridden := lookupFold(overrides, name); !overridden { - environment = append(environment, entry) - } - } - for name, value := range overrides { - environment = append(environment, name+"="+value) - } - return environment -} - -func lookupFold(values map[string]string, name string) (string, bool) { - for key, value := range values { - if strings.EqualFold(key, name) { - return value, true - } - } - return "", false -} - -func readInstaller(t *testing.T) []byte { - t.Helper() - path, err := filepath.Abs(filepath.Join("..", "..", "install.ps1")) - if err != nil { - t.Fatalf("resolve install.ps1: %v", err) - } - script, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read install.ps1: %v", err) - } - return script -} - -func newInstallerServer(t *testing.T, script []byte, releaseHandler http.HandlerFunc) *httptest.Server { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/install.ps1" { - w.Header().Set("Content-Type", "text/plain") - _, _ = w.Write(script) - return - } - releaseHandler(w, r) - })) - t.Cleanup(server.Close) - return server -} - -func makeZipArchive(t *testing.T, binary []byte) []byte { - t.Helper() - var output bytes.Buffer - archive := zip.NewWriter(&output) - file, err := archive.Create("stackdome.exe") - if err != nil { - t.Fatalf("create executable in ZIP: %v", err) - } - if _, err := file.Write(binary); err != nil { - t.Fatalf("write executable to ZIP: %v", err) - } - if err := archive.Close(); err != nil { - t.Fatalf("close ZIP: %v", err) - } - return output.Bytes() -}