From c46adfa19abf51029bc943eaf73c713250dbcf80 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:38:08 +0530 Subject: [PATCH] feat(installer): add one-command installer flow and release smoke checks (#123) --- .github/workflows/installer-smoke.yml | 35 ++ .github/workflows/release.yml | 37 +- README.md | 16 +- docs/BUILDING.md | 70 ++-- scripts/install.ps1 | 183 ++++++++++ scripts/install.sh | 482 +++++++++++++++++++++++++- 6 files changed, 775 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/installer-smoke.yml create mode 100644 scripts/install.ps1 diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml new file mode 100644 index 000000000..c24898b59 --- /dev/null +++ b/.github/workflows/installer-smoke.yml @@ -0,0 +1,35 @@ +name: Installer Smoke + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke-unix: + name: Smoke install.sh (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run installer dry-run + run: bash scripts/install.sh --dry-run --verbose + + smoke-windows: + name: Smoke install.ps1 (windows-latest) + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run installer dry-run + shell: pwsh + run: ./scripts/install.ps1 -DryRun diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02d3940df..b70d513c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,10 +221,10 @@ jobs: args: --target x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu artifact_suffix: ubuntu - # - platform: windows-latest - # args: --target x86_64-pc-windows-msvc - # target: x86_64-pc-windows-msvc - # artifact_suffix: windows + - platform: windows-latest + args: --target x86_64-pc-windows-msvc + target: x86_64-pc-windows-msvc + artifact_suffix: windows env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -691,6 +691,35 @@ jobs: needs: [create-release, build-artifacts] if: needs.build-artifacts.result == 'success' steps: + - name: Validate required installer assets exist + uses: actions/github-script@v7 + with: + script: | + const releaseId = Number('${{ needs.create-release.outputs.release_id }}'); + const { owner, repo } = context.repo; + const { data: assets } = await github.rest.repos.listReleaseAssets({ + owner, + repo, + release_id: releaseId, + per_page: 100, + }); + + const names = assets.map((a) => a.name); + const requiredPatterns = [ + /OpenHuman_.*_aarch64\.dmg$/, + /OpenHuman_.*_x64\.dmg$/, + /OpenHuman_.*_amd64\.AppImage$/, + /OpenHuman_.*_amd64\.deb$/, + /(OpenHuman_.*_x64-setup\.exe$|OpenHuman_.*_x64.*\.msi$)/, + ]; + + const missing = requiredPatterns.filter((pattern) => !names.some((name) => pattern.test(name))); + if (missing.length > 0) { + core.setFailed(`Missing required installer assets. Got: ${names.join(', ')}`); + return; + } + core.info('All required installer assets are present.'); + - name: Publish release uses: actions/github-script@v7 with: diff --git a/README.md b/README.md index 9d309a424..5639bc53f 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,24 @@ If you need an older version, browse [all releases](https://github.com/tinyhuman If you want to build from source, see [`docs/BUILDING.md`](docs/BUILDING.md). -Or you can basically run this global install script +Install with one command: ``` -COMING SOON curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash ``` +On Windows, use PowerShell: +`irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex` + +What setup does: + +- Resolves the latest stable release for your OS/arch +- Verifies release digest when available +- Installs locally without requiring system-wide admin rights by default +- macOS: installs `OpenHuman.app` in `~/Applications` +- Linux: installs `openhuman` AppImage in `~/.local/bin/openhuman` and creates a desktop entry +- Windows: installs from latest release MSI/EXE in per-user mode where supported + # Under the hood (Architecture) OpenHuman is a **desktop monorepo**: **Rust** owns **business logic and execution**; the **UI** owns **interaction, layout, and OS integration**. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 69b204d0e..2fcb64885 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -42,55 +42,43 @@ yarn dev ## Install latest stable release (macOS/Linux) -Use this basic script to detect platform/arch and download the latest stable artifact from GitHub Releases: +Primary install command: ```bash -#!/usr/bin/env bash -set -euo pipefail - -REPO="tinyhumansai/openhuman" -BASE="https://github.com/${REPO}/releases/latest/download" -OS="$(uname -s)" -ARCH="$(uname -m)" - -if [[ "$OS" == "Darwin" ]]; then - if [[ "$ARCH" == "arm64" ]]; then - FILE="OpenHuman_0.49.32_aarch64.dmg" - else - FILE="OpenHuman_0.49.32_x64.dmg" - fi -elif [[ "$OS" == "Linux" ]]; then - # Prefer AppImage for broad compatibility. - FILE="OpenHuman_0.49.32_amd64.AppImage" -else - echo "Unsupported OS: $OS" - exit 1 -fi - -URL="${BASE}/${FILE}" -echo "Downloading: $URL" -curl -fL "$URL" -o "$FILE" - -echo "Downloaded $FILE" -echo "Install it with your platform's normal installer flow." +curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash ``` -Notes: +Installer behavior: -- The filename includes the release version and should be updated when a new stable release is cut. -- You can always manually download from: - - Website: https://tinyhuman.ai/openhuman - - Latest release: https://github.com/tinyhumansai/openhuman/releases/latest +- Resolves latest stable OpenHuman release for your platform +- Validates artifact digest when available +- Installs locally (no sudo by default) +- macOS: installs `OpenHuman.app` into `~/Applications` +- Linux: installs AppImage as `~/.local/bin/openhuman` and writes a desktop entry + +Useful flags: + +```bash +# Preview actions without writing files +curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash -s -- --dry-run +``` ## Windows (latest stable) -Download directly from the website or latest release page: +Use PowerShell: -- https://tinyhuman.ai/openhuman -- https://github.com/tinyhumansai/openhuman/releases/latest +```powershell +irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex +``` -## Future improvements +Windows installer behavior: -- Replace hardcoded filenames with `latest.json` parsing -- Add checksum/signature verification -- Publish one-step global installers for all platforms +- Resolves latest stable release +- Downloads MSI/EXE for x64 +- Verifies digest when available +- Runs per-user install where supported by installer package + +Manual download links (all platforms): + +- Website: https://tinyhuman.ai/openhuman +- Latest release: https://github.com/tinyhumansai/openhuman/releases/latest diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 000000000..0c6e85a7c --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,183 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + OpenHuman installer for Windows. + +.DESCRIPTION + Intended for: + irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex +#> + +param( + [switch]$Help, + [switch]$Version, + [string]$Channel = "stable", + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" + +$InstallerVersion = "1.0.0" +$Repo = "tinyhumansai/openhuman" +$LatestJsonUrl = "https://github.com/$Repo/releases/latest/download/latest.json" +$LatestReleaseApiUrl = "https://api.github.com/repos/$Repo/releases/latest" + +function Write-Info([string]$Message) { Write-Host "-> $Message" -ForegroundColor Cyan } +function Write-Ok([string]$Message) { Write-Host "OK $Message" -ForegroundColor Green } +function Write-WarnMsg([string]$Message) { Write-Host "! $Message" -ForegroundColor Yellow } +function Write-Err([string]$Message) { Write-Host "x $Message" -ForegroundColor Red } + +function Show-Usage { + @" +OpenHuman Installer (Windows) + +Usage: + install.ps1 [-Channel stable] [-DryRun] [-Help] [-Version] + +Examples: + irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex + .\scripts\install.ps1 -DryRun +"@ +} + +if ($Help) { + Show-Usage + exit 0 +} + +if ($Version) { + Write-Output "openhuman-installer $InstallerVersion" + exit 0 +} + +if ($Channel -ne "stable") { + Write-Err "Only -Channel stable is currently supported." + exit 1 +} + +if ($env:OS -ne "Windows_NT") { + Write-Err "This installer is for Windows only." + exit 1 +} + +$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +if ($arch -notin @("x64", "amd64")) { + Write-Err "Unsupported architecture: $arch (Windows x64 required)." + exit 1 +} + +Write-Ok "Detected platform: windows/x64" + +$release = $null +$releaseTag = "" +$assetName = "" +$assetUrl = "" +$assetDigest = "" + +function Select-WindowsAssetFromRelease([object]$Rel) { + $assets = @($Rel.assets) + if (-not $assets -or $assets.Count -eq 0) { + return $null + } + + $msi = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.msi$' } | Select-Object -First 1 + if ($msi) { return $msi } + + $exe = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.exe$' } | Select-Object -First 1 + if ($exe) { return $exe } + + return $null +} + +try { + $release = Invoke-RestMethod -Uri $LatestReleaseApiUrl + $releaseTag = ($release.tag_name -replace '^v', '') + $selected = Select-WindowsAssetFromRelease -Rel $release + if ($selected) { + $assetName = $selected.name + $assetUrl = $selected.browser_download_url + if ($selected.digest) { + $assetDigest = ($selected.digest -replace '^sha256:', '') + } + } +} catch { + Write-WarnMsg "Could not query release API: $($_.Exception.Message)" +} + +if (-not $assetUrl) { + Write-Err "No Windows x64 installer artifact found in latest release." + Write-Err "Ensure release workflow publishes Windows MSI/EXE assets." + exit 1 +} + +Write-Ok "Resolved latest release ($releaseTag): $assetName" + +$tmpFile = Join-Path $env:TEMP $assetName +if ($DryRun) { + Write-Output "DRY RUN: download $assetUrl -> $tmpFile" +} else { + Write-Info "Downloading $assetName" + Invoke-WebRequest -Uri $assetUrl -OutFile $tmpFile +} + +if ($assetDigest) { + if ($DryRun) { + Write-Output "DRY RUN: verify SHA256 $assetDigest" + } else { + $fileHash = (Get-FileHash -Path $tmpFile -Algorithm SHA256).Hash.ToLowerInvariant() + if ($fileHash -ne $assetDigest.ToLowerInvariant()) { + Write-Err "SHA256 mismatch for $assetName" + Write-Err "Expected: $assetDigest" + Write-Err "Actual: $fileHash" + exit 1 + } + Write-Ok "Integrity verified (sha256)" + } +} else { + Write-WarnMsg "No SHA256 digest available for $assetName; skipping integrity verification." +} + +if ($DryRun) { + if ($assetName -like "*.msi") { + Write-Output "DRY RUN: msiexec /i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" + } else { + Write-Output "DRY RUN: Start-Process `"$tmpFile`" -Wait" + } + exit 0 +} + +Write-Info "Installing OpenHuman" +if ($assetName -like "*.msi") { + $args = "/i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" + $proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $args -Wait -PassThru + if ($proc.ExitCode -ne 0) { + Write-Err "MSI install failed with exit code $($proc.ExitCode)." + exit 1 + } +} elseif ($assetName -like "*.exe") { + $proc = Start-Process -FilePath $tmpFile -Wait -PassThru + if ($proc.ExitCode -ne 0) { + Write-Err "Installer exited with code $($proc.ExitCode)." + exit 1 + } +} else { + Write-Err "Unsupported Windows installer type: $assetName" + exit 1 +} + +$expectedPaths = @( + "$env:LOCALAPPDATA\Programs\OpenHuman\OpenHuman.exe", + "$env:ProgramFiles\OpenHuman\OpenHuman.exe" +) +$launchPath = $expectedPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + +Write-Output "" +Write-Output "OpenHuman is ready." +if ($launchPath) { + Write-Output "Launch: `"$launchPath`"" + Write-Output "Uninstall: Settings -> Apps -> Installed apps -> OpenHuman" +} else { + Write-WarnMsg "Could not locate installed executable automatically." + Write-Output "Try launching OpenHuman from Start Menu." + Write-Output "Uninstall: Settings -> Apps -> Installed apps -> OpenHuman" +} diff --git a/scripts/install.sh b/scripts/install.sh index e2e3d6bce..605886664 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1 +1,481 @@ -echo "Coming soon. For now, you can download the latest desktop build from the website at [tinyhuman.ai/openhuman](https://tinyhuman.ai/openhuman)." +#!/usr/bin/env bash +# OpenHuman Installer (macOS/Linux) +# Usage: +# curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash + +set -euo pipefail + +INSTALLER_VERSION="1.0.0" +REPO="tinyhumansai/openhuman" +LATEST_JSON_URL="https://github.com/${REPO}/releases/latest/download/latest.json" +LATEST_RELEASE_API_URL="https://api.github.com/repos/${REPO}/releases/latest" + +CHANNEL="stable" +DRY_RUN=false +VERBOSE=false + +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + CYAN='\033[0;36m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + CYAN='' + NC='' +fi + +log_info() { echo -e "${CYAN}→${NC} $*"; } +log_ok() { echo -e "${GREEN}✓${NC} $*"; } +log_warn() { echo -e "${YELLOW}!${NC} $*"; } +log_err() { echo -e "${RED}x${NC} $*" >&2; } + +usage() { + cat <<'EOF' +OpenHuman Installer + +Usage: install.sh [OPTIONS] + +Options: + --help Show help + --version Show installer version + --channel VALUE Release channel (default: stable) + --dry-run Print actions without mutating local files + --verbose Enable verbose output + +Examples: + curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash + curl -fsSL ... | bash -s -- --dry-run +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + usage + exit 0 + ;; + --version) + echo "openhuman-installer ${INSTALLER_VERSION}" + exit 0 + ;; + --channel) + CHANNEL="${2:-}" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --verbose) + VERBOSE=true + shift + ;; + *) + log_err "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +if [ "${CHANNEL}" != "stable" ]; then + log_err "Only --channel stable is currently supported." + exit 1 +fi + +for cmd in curl mktemp tar; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + log_err "Missing required command: ${cmd}" + exit 1 + fi +done + +OS_RAW="$(uname -s)" +ARCH_RAW="$(uname -m)" +OS="" +ARCH="" +PLATFORM_KEY="" + +case "${OS_RAW}" in + Darwin) OS="darwin" ;; + Linux) OS="linux" ;; + CYGWIN*|MINGW*|MSYS*) + log_err "Windows detected. Use PowerShell installer:" + echo " irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.ps1 | iex" + exit 1 + ;; + *) + log_err "Unsupported OS: ${OS_RAW}" + exit 1 + ;; +esac + +case "${ARCH_RAW}" in + x86_64|amd64) ARCH="x86_64" ;; + arm64|aarch64) ARCH="aarch64" ;; + *) + log_err "Unsupported architecture: ${ARCH_RAW}" + exit 1 + ;; +esac + +if [ "${OS}" = "linux" ] && [ "${ARCH}" != "x86_64" ]; then + log_err "Linux installer currently supports x86_64 only." + exit 1 +fi + +if [ "${OS}" = "darwin" ] && [ "${ARCH}" = "aarch64" ]; then + PLATFORM_KEY="darwin-aarch64" +elif [ "${OS}" = "darwin" ] && [ "${ARCH}" = "x86_64" ]; then + PLATFORM_KEY="darwin-x86_64" +elif [ "${OS}" = "linux" ] && [ "${ARCH}" = "x86_64" ]; then + PLATFORM_KEY="linux-x86_64" +fi + +log_ok "Detected platform: ${OS}/${ARCH}" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +LATEST_JSON_PATH="${TMP_DIR}/latest.json" +RELEASE_JSON_PATH="${TMP_DIR}/release.json" + +LATEST_VERSION="" +ASSET_URL="" +ASSET_NAME="" +ASSET_SHA256="" + +resolve_from_latest_json() { + if ! curl -fsSL "${LATEST_JSON_URL}" -o "${LATEST_JSON_PATH}"; then + return 1 + fi + + if ! command -v python3 >/dev/null 2>&1; then + log_warn "python3 is not available; cannot parse latest.json reliably." + return 1 + fi + + local parsed + parsed="$(python3 - "${LATEST_JSON_PATH}" "${PLATFORM_KEY}" <<'PY' +import json, sys +path, key = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) +version = data.get("version", "") +platforms = data.get("platforms", {}) +entry = platforms.get(key) or platforms.get(f"{key}-appimage") or platforms.get(f"{key}-app") +url = "" +if isinstance(entry, dict): + url = entry.get("url", "") +print(version) +print(url) +PY +)" || return 1 + + LATEST_VERSION="$(echo "${parsed}" | sed -n '1p')" + ASSET_URL="$(echo "${parsed}" | sed -n '2p')" + ASSET_NAME="$(basename "${ASSET_URL}")" + + [ -n "${ASSET_URL}" ] +} + +resolve_from_release_api() { + if ! curl -fsSL "${LATEST_RELEASE_API_URL}" -o "${RELEASE_JSON_PATH}"; then + return 1 + fi + + if ! command -v python3 >/dev/null 2>&1; then + log_warn "python3 is not available; cannot parse release API fallback." + return 1 + fi + + local parsed + parsed="$(python3 - "${RELEASE_JSON_PATH}" "${OS}" "${ARCH}" <<'PY' +import json, re, sys +path, os_name, arch = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) +tag = (data.get("tag_name") or "").lstrip("v") +assets = data.get("assets", []) + +def choose_asset(): + names = [a.get("name", "") for a in assets] + chosen = None + if os_name == "darwin" and arch == "aarch64": + for n in names: + if re.search(r"aarch64.*\\.app\\.tar\\.gz$", n): + chosen = n + break + if not chosen: + for n in names: + if re.search(r"aarch64\\.dmg$", n): + chosen = n + break + elif os_name == "darwin" and arch == "x86_64": + for n in names: + if re.search(r"(x86_64-apple-darwin|x64).*\\.app\\.tar\\.gz$", n): + chosen = n + break + if not chosen: + for n in names: + if re.search(r"x64\\.dmg$", n): + chosen = n + break + elif os_name == "linux" and arch == "x86_64": + for n in names: + if n.endswith(".AppImage"): + chosen = n + break + if not chosen: + return "", "", "" + for asset in assets: + if asset.get("name") == chosen: + return chosen, asset.get("browser_download_url", ""), (asset.get("digest", "") or "").replace("sha256:", "") + return "", "", "" + +name, url, digest = choose_asset() +print(tag) +print(name) +print(url) +print(digest) +PY +)" || return 1 + + if [ -z "${LATEST_VERSION}" ]; then + LATEST_VERSION="$(echo "${parsed}" | sed -n '1p')" + fi + ASSET_NAME="$(echo "${parsed}" | sed -n '2p')" + ASSET_URL="$(echo "${parsed}" | sed -n '3p')" + ASSET_SHA256="$(echo "${parsed}" | sed -n '4p')" + + [ -n "${ASSET_URL}" ] +} + +resolve_release_digest() { + if [ -z "${ASSET_NAME}" ]; then + return 0 + fi + if [ ! -s "${RELEASE_JSON_PATH}" ]; then + if ! curl -fsSL "${LATEST_RELEASE_API_URL}" -o "${RELEASE_JSON_PATH}"; then + return 0 + fi + fi + if ! command -v python3 >/dev/null 2>&1; then + return 0 + fi + local digest + digest="$(python3 - "${RELEASE_JSON_PATH}" "${ASSET_NAME}" <<'PY' +import json, sys +path, name = sys.argv[1], sys.argv[2] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) +for asset in data.get("assets", []): + if asset.get("name") == name: + d = asset.get("digest", "") or "" + print(d.replace("sha256:", "")) + break +PY +)" + if [ -n "${digest}" ]; then + ASSET_SHA256="${digest}" + fi +} + +if resolve_from_latest_json; then + log_ok "Resolved latest release via latest.json (${LATEST_VERSION})" +else + log_warn "latest.json lookup failed. Falling back to releases API." + if ! resolve_from_release_api; then + log_err "Could not resolve a compatible asset for ${OS}/${ARCH}." + exit 1 + fi + log_ok "Resolved latest release via releases API (${LATEST_VERSION})" +fi + +resolve_release_digest + +if [ -z "${ASSET_URL}" ]; then + log_err "Could not determine download URL for ${OS}/${ARCH}." + exit 1 +fi + +DOWNLOAD_PATH="${TMP_DIR}/${ASSET_NAME}" +log_info "Downloading ${ASSET_NAME}" +if [ "${DRY_RUN}" = true ]; then + echo "DRY RUN: curl -fL ${ASSET_URL} -o ${DOWNLOAD_PATH}" +else + curl -fL "${ASSET_URL}" -o "${DOWNLOAD_PATH}" +fi + +compute_sha256() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${file}" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${file}" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "${file}" | awk '{print $2}' + else + return 1 + fi +} + +if [ -n "${ASSET_SHA256}" ]; then + if [ "${DRY_RUN}" = true ]; then + echo "DRY RUN: verify sha256 ${ASSET_SHA256} for ${DOWNLOAD_PATH}" + else + actual_sha256="$(compute_sha256 "${DOWNLOAD_PATH}" || true)" + if [ -z "${actual_sha256}" ]; then + log_warn "No checksum command available; skipping digest verification." + elif [ "${actual_sha256}" != "${ASSET_SHA256}" ]; then + log_err "SHA256 mismatch for ${ASSET_NAME}" + log_err "Expected: ${ASSET_SHA256}" + log_err "Actual: ${actual_sha256}" + exit 1 + else + log_ok "Integrity verified (sha256)" + fi + fi +else + log_warn "No SHA256 digest available for ${ASSET_NAME}; skipping integrity verification." +fi + +ensure_local_bin_path() { + local bin_dir="${HOME}/.local/bin" + if echo ":${PATH}:" | grep -q ":${bin_dir}:"; then + return 0 + fi + local shell_name config_file + shell_name="$(basename "${SHELL:-/bin/bash}")" + case "${shell_name}" in + zsh) config_file="${HOME}/.zshrc" ;; + bash) config_file="${HOME}/.bashrc" ;; + *) config_file="${HOME}/.profile" ;; + esac + + if [ "${DRY_RUN}" = true ]; then + echo "DRY RUN: ensure ${bin_dir} in PATH via ${config_file}" + return 0 + fi + + if [ ! -f "${config_file}" ]; then + touch "${config_file}" + fi + if ! grep -q '.local/bin' "${config_file}"; then + { + echo "" + echo '# OpenHuman installer - ensure local user binaries are on PATH' + echo 'export PATH="$HOME/.local/bin:$PATH"' + } >> "${config_file}" + log_ok "Added ~/.local/bin to ${config_file}" + fi +} + +install_macos() { + local apps_dir="${HOME}/Applications" + local app_path="${apps_dir}/OpenHuman.app" + mkdir -p "${apps_dir}" + + if [[ "${ASSET_NAME}" == *.app.tar.gz ]]; then + log_info "Installing OpenHuman.app into ${apps_dir}" + if [ "${DRY_RUN}" = true ]; then + echo "DRY RUN: tar -xzf ${DOWNLOAD_PATH} -C ${TMP_DIR}" + echo "DRY RUN: replace ${app_path}" + else + tar -xzf "${DOWNLOAD_PATH}" -C "${TMP_DIR}" + if [ ! -d "${TMP_DIR}/OpenHuman.app" ]; then + log_err "Archive did not contain OpenHuman.app" + exit 1 + fi + rm -rf "${app_path}" + cp -R "${TMP_DIR}/OpenHuman.app" "${app_path}" + fi + elif [[ "${ASSET_NAME}" == *.dmg ]]; then + log_info "Mounting DMG and copying OpenHuman.app" + if [ "${DRY_RUN}" = true ]; then + echo "DRY RUN: hdiutil attach ${DOWNLOAD_PATH}" + echo "DRY RUN: copy OpenHuman.app to ${app_path}" + else + if ! command -v hdiutil >/dev/null 2>&1; then + log_err "hdiutil not available, cannot install from DMG." + exit 1 + fi + mount_output="$(hdiutil attach "${DOWNLOAD_PATH}" -nobrowse)" + mount_point="$(echo "${mount_output}" | awk '/\/Volumes\// {print $NF; exit}')" + if [ -z "${mount_point}" ] || [ ! -d "${mount_point}/OpenHuman.app" ]; then + log_err "Could not find OpenHuman.app in mounted DMG." + echo "${mount_output}" + exit 1 + fi + rm -rf "${app_path}" + cp -R "${mount_point}/OpenHuman.app" "${app_path}" + hdiutil detach "${mount_point}" >/dev/null + fi + else + log_err "Unsupported macOS asset type: ${ASSET_NAME}" + exit 1 + fi + + log_ok "Installed at ${app_path}" + echo "" + echo "OpenHuman is ready." + echo "Launch: open \"${app_path}\"" + echo "Uninstall: rm -rf \"${app_path}\"" +} + +install_linux() { + local bin_dir="${HOME}/.local/bin" + local app_path="${bin_dir}/openhuman" + local desktop_dir="${HOME}/.local/share/applications" + local desktop_file="${desktop_dir}/openhuman.desktop" + + mkdir -p "${bin_dir}" "${desktop_dir}" + + if [[ "${ASSET_NAME}" != *.AppImage ]]; then + log_err "Expected AppImage for Linux install, got: ${ASSET_NAME}" + exit 1 + fi + + if [ "${DRY_RUN}" = true ]; then + echo "DRY RUN: install ${DOWNLOAD_PATH} -> ${app_path}" + else + cp "${DOWNLOAD_PATH}" "${app_path}" + chmod +x "${app_path}" + fi + + ensure_local_bin_path + + if [ "${DRY_RUN}" = true ]; then + echo "DRY RUN: write ${desktop_file}" + else + cat > "${desktop_file}" <