diff --git a/PORTABLE-GUIDE.md b/PORTABLE-GUIDE.md new file mode 100644 index 000000000000..b035ee91686a --- /dev/null +++ b/PORTABLE-GUIDE.md @@ -0,0 +1,156 @@ +# OpenCode Portable Guide + +Run OpenCode from a self-contained directory — no global install, no PATH changes, no files written to your home folder. Everything lives inside `.opencode_portable/` next to the wrapper script. + +## Directory Layout + +``` +.opencode_portable/ +├── bin/ # OpenCode binary +├── config/ # XDG_CONFIG_HOME (settings, skills) +├── data/ # XDG_DATA_HOME +├── cache/ # XDG_CACHE_HOME +├── state/ # XDG_STATE_HOME +└── .version # Currently cached version +``` + +## Prerequisites + +| Platform | Required | +|----------|----------| +| macOS | `curl`, `unzip` (both ship with macOS) | +| Linux (glibc) | `curl`, `tar` | +| Linux (Alpine/musl) | `curl`, `tar` — install via `apk add curl tar` | +| Windows | PowerShell 5.1+ (built into Windows 10/11) | + +All platforms need **`git`** if you plan to install external skill plugins. + +## Download the Wrapper Script + +**macOS / Linux** — requires `curl` (listed in Prerequisites above): + +```bash +curl -fLO https://raw.githubusercontent.com/anomalyco/opencode/dev/opencode-portable.sh +chmod +x opencode-portable.sh +``` + +**Windows (PowerShell)** — requires PowerShell 5.1+ (built into Windows 10/11): + +```powershell +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/anomalyco/opencode/dev/opencode-portable.ps1" -OutFile "opencode-portable.ps1" +``` + +## Quick Start + +### macOS (Apple Silicon & Intel) + +The script auto-detects your architecture, including Rosetta translation. + +```bash +chmod +x opencode-portable.sh +./opencode-portable.sh +``` + +### Linux (Ubuntu/Debian, Fedora/RHEL, Arch) + +```bash +chmod +x opencode-portable.sh +./opencode-portable.sh +``` + +The script automatically detects musl-based distros (Alpine) and selects the correct binary. On x64, it also detects AVX2 support and falls back to a baseline build if needed. + +**Alpine/musl note** — ensure `curl` and `tar` are installed: + +```bash +apk add curl tar +``` + +### Windows (PowerShell) + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\opencode-portable.ps1 +``` + +The `Scope Process` setting only applies to the current terminal session. + +## Version Pinning & Updating + +### Pin a specific version + +**Option A** — environment variable (highest priority): + +```bash +# macOS/Linux +OPENCODE_PORTABLE_VERSION=1.0.180 ./opencode-portable.sh + +# Windows +$env:OPENCODE_PORTABLE_VERSION = "1.0.180" +.\opencode-portable.ps1 +``` + +**Option B** — create a `.opencode-version` file next to the script: + +``` +1.0.180 +``` + +The leading `v` is stripped automatically, so both `1.0.180` and `v1.0.180` work. + +### Force update to the latest (or pinned) version + +```bash +# macOS/Linux +./opencode-portable.sh --portable-update + +# Windows +.\opencode-portable.ps1 -PortableUpdate +``` + +### Check the cached version + +```bash +# macOS/Linux +./opencode-portable.sh --portable-version + +# Windows +.\opencode-portable.ps1 -PortableVersion +``` + +## Troubleshooting + +**"Permission denied" when running the shell script** +```bash +chmod +x opencode-portable.sh +``` + +**PowerShell blocks script execution** +```powershell +Set-ExecutionPolicy -Scope Process Bypass +``` +This only affects the current session. For a persistent change, use `-Scope CurrentUser`. + +**"curl: command not found" (Linux)** +```bash +# Debian/Ubuntu +sudo apt install curl + +# Fedora/RHEL +sudo dnf install curl + +# Alpine +apk add curl +``` + +**"tar: command not found" (Alpine)** +```bash +apk add tar +``` + +**Binary downloads but OpenCode won't start** +Check that the download completed successfully. A partial or corrupted download can be fixed with: +```bash +./opencode-portable.sh --portable-update # macOS/Linux +.\opencode-portable.ps1 -PortableUpdate # Windows +``` \ No newline at end of file diff --git a/opencode-portable.ps1 b/opencode-portable.ps1 new file mode 100644 index 000000000000..a846c426be53 --- /dev/null +++ b/opencode-portable.ps1 @@ -0,0 +1,213 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Portable wrapper for OpenCode on Windows. +.DESCRIPTION + Downloads and runs OpenCode from a self-contained directory next to this + script. All config, data, cache and state stay within .opencode_portable/ + so nothing is written to your user profile. +.PARAMETER PortableHelp + Show wrapper usage and exit. +.PARAMETER PortableUpdate + Force re-download of the OpenCode binary. +.PARAMETER PortableVersion + Show the currently cached version and exit. +#> +param( + [switch]$PortableHelp, + [switch]$PortableUpdate, + [switch]$PortableVersion, + [Parameter(ValueFromRemainingArguments)] + [string[]]$PassthroughArgs +) + +$ErrorActionPreference = "Stop" + +# --------------------------------------------------------------------------- +# Self-location & portable directory +# --------------------------------------------------------------------------- +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$PortableDir = Join-Path $ScriptDir ".opencode_portable" +$BinPath = Join-Path $PortableDir "bin\opencode.exe" +$VersionFile = Join-Path $PortableDir ".version" + +# --------------------------------------------------------------------------- +# --PortableHelp +# --------------------------------------------------------------------------- +if ($PortableHelp) { + @" +OpenCode Portable Wrapper (PowerShell) + +Usage: .\opencode-portable.ps1 [-PortableHelp] [-PortableUpdate] [-PortableVersion] [opencode args...] + +Wrapper options (consumed by this script): + -PortableHelp Show this help message + -PortableUpdate Force re-download of the OpenCode binary + -PortableVersion Show the currently cached version + +All other arguments are passed through to the OpenCode binary. + +Environment variables: + OPENCODE_PORTABLE_VERSION Pin a specific version (e.g. 1.0.180) + +Version pinning: + You can also create a .opencode-version file next to this script + containing the desired version number (one line, e.g. "1.0.180"). + +Portable directory layout: + .opencode_portable\ + +-- bin\ Binary + +-- config\ XDG_CONFIG_HOME + +-- data\ XDG_DATA_HOME + +-- cache\ XDG_CACHE_HOME + +-- state\ XDG_STATE_HOME + +-- .version Cached version string +"@ + exit 0 +} + +# --------------------------------------------------------------------------- +# --PortableVersion +# --------------------------------------------------------------------------- +if ($PortableVersion) { + if (Test-Path $VersionFile) { + Write-Host "Cached version: $(Get-Content $VersionFile -Raw)".Trim() + } else { + Write-Host "No version cached yet." + } + exit 0 +} + +# --------------------------------------------------------------------------- +# Create portable directory structure +# --------------------------------------------------------------------------- +foreach ($sub in @("bin", "config", "data", "cache", "state")) { + $p = Join-Path $PortableDir $sub + if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } +} + +# --------------------------------------------------------------------------- +# XDG environment isolation +# --------------------------------------------------------------------------- +$env:XDG_CONFIG_HOME = Join-Path $PortableDir "config" +$env:XDG_DATA_HOME = Join-Path $PortableDir "data" +$env:XDG_CACHE_HOME = Join-Path $PortableDir "cache" +$env:XDG_STATE_HOME = Join-Path $PortableDir "state" +$env:OPENCODE_DISABLE_AUTOUPDATE = "true" + +# --------------------------------------------------------------------------- +# Determine pinned version +# --------------------------------------------------------------------------- +$PinnedVersion = $env:OPENCODE_PORTABLE_VERSION +if (-not $PinnedVersion) { + $versionFilePath = Join-Path $ScriptDir ".opencode-version" + if (Test-Path $versionFilePath) { + $PinnedVersion = (Get-Content $versionFilePath -Raw).Trim() + } +} +if ($PinnedVersion) { + $PinnedVersion = $PinnedVersion -replace '^v', '' +} + +# --------------------------------------------------------------------------- +# Decide whether a download is needed +# --------------------------------------------------------------------------- +$NeedDownload = $false +if ($PortableUpdate) { + $NeedDownload = $true +} elseif (-not (Test-Path $BinPath)) { + $NeedDownload = $true +} elseif ($PinnedVersion -and (Test-Path $VersionFile)) { + $cached = (Get-Content $VersionFile -Raw).Trim() + if ($cached -ne $PinnedVersion) { + $NeedDownload = $true + } +} + +# --------------------------------------------------------------------------- +# Download logic +# --------------------------------------------------------------------------- +if ($NeedDownload) { + # --- platform detection ------------------------------------------------- + $os = "windows" + $arch = switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { "x64" } + "x86" { "x64" } # 32-bit on 64-bit — use x64 binary + default { + Write-Host "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE" -ForegroundColor Red + exit 1 + } + } + + # AVX2 / baseline detection via IsProcessorFeaturePresent(40) + $needsBaseline = $false + try { + Add-Type -MemberDefinition @" +[DllImport("kernel32.dll")] +public static extern bool IsProcessorFeaturePresent(int ProcessorFeature); +"@ -Name Kernel32 -Namespace Win32Portable -ErrorAction Stop + $hasAVX2 = [Win32Portable.Kernel32]::IsProcessorFeaturePresent(40) + if (-not $hasAVX2) { $needsBaseline = $true } + } catch { + # If detection fails, assume baseline for safety + $needsBaseline = $true + } + + $target = "$os-$arch" + if ($needsBaseline) { $target += "-baseline" } + $filename = "opencode-$target.zip" + + # --- resolve version & URL ---------------------------------------------- + if ($PinnedVersion) { + $url = "https://github.com/anomalyco/opencode/releases/download/v$PinnedVersion/$filename" + $specificVersion = $PinnedVersion + } else { + $url = "https://github.com/anomalyco/opencode/releases/latest/download/$filename" + try { + $releaseInfo = Invoke-RestMethod -Uri "https://api.github.com/repos/anomalyco/opencode/releases/latest" -UseBasicParsing + $specificVersion = $releaseInfo.tag_name -replace '^v', '' + } catch { + Write-Host "Failed to fetch latest version information" -ForegroundColor Red + exit 1 + } + } + + # --- download & extract ------------------------------------------------- + $tmpDir = Join-Path $env:TEMP "opencode_portable_$PID" + if (Test-Path $tmpDir) { Remove-Item $tmpDir -Recurse -Force } + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + + try { + Write-Host "Downloading OpenCode v$specificVersion ($target)..." -ForegroundColor DarkGray + + $archivePath = Join-Path $tmpDir $filename + # Use .NET WebClient for better performance on large files + $wc = New-Object System.Net.WebClient + $wc.DownloadFile($url, $archivePath) + + Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force + + $srcBin = Join-Path $tmpDir "opencode.exe" + if (-not (Test-Path $srcBin)) { + Write-Host "Error: opencode.exe not found in archive" -ForegroundColor Red + exit 1 + } + Copy-Item $srcBin $BinPath -Force + + Set-Content -Path $VersionFile -Value $specificVersion -NoNewline + Write-Host "OpenCode v$specificVersion ready." -ForegroundColor Green + } finally { + Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# Execute +# --------------------------------------------------------------------------- +if (-not (Test-Path $BinPath)) { + Write-Host "Error: OpenCode binary not found at $BinPath" -ForegroundColor Red + exit 1 +} + +& $BinPath @PassthroughArgs +exit $LASTEXITCODE diff --git a/opencode-portable.sh b/opencode-portable.sh new file mode 100755 index 000000000000..a9726e60d871 --- /dev/null +++ b/opencode-portable.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP=opencode + +# Colors +RED='\033[0;31m' +MUTED='\033[0;2m' +GREEN='\033[0;32m' +NC='\033[0m' + +# --------------------------------------------------------------------------- +# Self-location & portable directory +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +PORTABLE_DIR="$SCRIPT_DIR/.opencode_portable" +BIN_PATH="$PORTABLE_DIR/bin/$APP" +VERSION_FILE="$PORTABLE_DIR/.version" + +# --------------------------------------------------------------------------- +# Parse wrapper-specific flags (--portable-*) +# Everything else is collected for passthrough to the OpenCode binary. +# --------------------------------------------------------------------------- +portable_help=false +portable_update=false +portable_version=false +passthrough_args=() + +for arg in "$@"; do + case "$arg" in + --portable-help) portable_help=true ;; + --portable-update) portable_update=true ;; + --portable-version) portable_version=true ;; + *) passthrough_args+=("$arg") ;; + esac +done + +# --------------------------------------------------------------------------- +# --portable-help +# --------------------------------------------------------------------------- +if [ "$portable_help" = true ]; then + cat < .opencode-version file > latest) +# --------------------------------------------------------------------------- +pinned_version="${OPENCODE_PORTABLE_VERSION:-}" +if [ -z "$pinned_version" ] && [ -f "$SCRIPT_DIR/.opencode-version" ]; then + pinned_version="$(tr -d '[:space:]' < "$SCRIPT_DIR/.opencode-version")" +fi +# Strip leading 'v' if present +pinned_version="${pinned_version#v}" + +# --------------------------------------------------------------------------- +# Decide whether a download is needed +# --------------------------------------------------------------------------- +need_download=false +if [ "$portable_update" = true ]; then + need_download=true +elif [ ! -x "$BIN_PATH" ]; then + need_download=true +elif [ -n "$pinned_version" ] && [ -f "$VERSION_FILE" ]; then + cached="$(cat "$VERSION_FILE")" + if [ "$cached" != "$pinned_version" ]; then + need_download=true + fi +fi + +# --------------------------------------------------------------------------- +# Download logic +# --------------------------------------------------------------------------- +if [ "$need_download" = true ]; then + # --- prerequisite checks ------------------------------------------------ + if ! command -v curl >/dev/null 2>&1; then + echo -e "${RED}Error: 'curl' is required but not installed.${NC}" >&2 + exit 1 + fi + + # --- platform detection (ported from install script) -------------------- + raw_os=$(uname -s) + os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]') + case "$raw_os" in + Darwin*) os="darwin" ;; + Linux*) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + esac + + arch=$(uname -m) + if [[ "$arch" == "aarch64" ]]; then + arch="arm64" + fi + if [[ "$arch" == "x86_64" ]]; then + arch="x64" + fi + + # Rosetta detection on macOS + if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then + rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0) + if [ "$rosetta_flag" = "1" ]; then + arch="arm64" + fi + fi + + combo="$os-$arch" + case "$combo" in + linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64) ;; + *) + echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" >&2 + exit 1 + ;; + esac + + archive_ext=".zip" + if [ "$os" = "linux" ]; then + archive_ext=".tar.gz" + fi + + # Archive tool check + if [ "$os" = "linux" ]; then + if ! command -v tar >/dev/null 2>&1; then + echo -e "${RED}Error: 'tar' is required but not installed.${NC}" >&2 + exit 1 + fi + else + if ! command -v unzip >/dev/null 2>&1; then + echo -e "${RED}Error: 'unzip' is required but not installed.${NC}" >&2 + exit 1 + fi + fi + + # musl detection + is_musl=false + if [ "$os" = "linux" ]; then + if [ -f /etc/alpine-release ]; then + is_musl=true + fi + if command -v ldd >/dev/null 2>&1; then + if ldd --version 2>&1 | grep -qi musl; then + is_musl=true + fi + fi + fi + + # AVX2 / baseline detection + needs_baseline=false + if [ "$arch" = "x64" ]; then + if [ "$os" = "linux" ]; then + if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then + needs_baseline=true + fi + fi + if [ "$os" = "darwin" ]; then + avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0) + if [ "$avx2" != "1" ]; then + needs_baseline=true + fi + fi + if [ "$os" = "windows" ]; then + ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)" + out="" + if command -v powershell.exe >/dev/null 2>&1; then + out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true) + elif command -v pwsh >/dev/null 2>&1; then + out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true) + fi + out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') + if [ "$out" != "true" ] && [ "$out" != "1" ]; then + needs_baseline=true + fi + fi + fi + + target="$os-$arch" + if [ "$needs_baseline" = "true" ]; then + target="$target-baseline" + fi + if [ "$is_musl" = "true" ]; then + target="$target-musl" + fi + + filename="$APP-$target$archive_ext" + + # --- resolve version & URL ---------------------------------------------- + if [ -n "$pinned_version" ]; then + # Verify the release exists + http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${pinned_version}") + if [ "$http_status" = "404" ]; then + echo -e "${RED}Error: Release v${pinned_version} not found${NC}" >&2 + echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}" >&2 + exit 1 + fi + url="https://github.com/anomalyco/opencode/releases/download/v${pinned_version}/$filename" + specific_version="$pinned_version" + else + url="https://github.com/anomalyco/opencode/releases/latest/download/$filename" + specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') + if [ -z "$specific_version" ]; then + echo -e "${RED}Failed to fetch latest version information${NC}" >&2 + exit 1 + fi + fi + + # --- download & extract ------------------------------------------------- + tmp_dir="${TMPDIR:-/tmp}/opencode_portable_$$" + mkdir -p "$tmp_dir" + cleanup() { rm -rf "$tmp_dir"; } + trap cleanup EXIT + + echo -e "${MUTED}Downloading OpenCode ${NC}v${specific_version}${MUTED} (${target})...${NC}" + curl -#fL -o "$tmp_dir/$filename" "$url" + + if [ "$os" = "linux" ]; then + tar -xzf "$tmp_dir/$filename" -C "$tmp_dir" + else + unzip -q "$tmp_dir/$filename" -d "$tmp_dir" + fi + + bin_name="$APP" + if [ "$os" = "windows" ]; then + bin_name="$APP.exe" + fi + + mv "$tmp_dir/$bin_name" "$BIN_PATH" + chmod 755 "$BIN_PATH" + + echo "$specific_version" > "$VERSION_FILE" + echo -e "${GREEN}OpenCode ${NC}v${specific_version}${GREEN} ready.${NC}" + + # Clean up temp dir immediately (trap will also fire, harmlessly) + rm -rf "$tmp_dir" + trap - EXIT +fi + +# --------------------------------------------------------------------------- +# Execute +# --------------------------------------------------------------------------- +exec "$BIN_PATH" "${passthrough_args[@]}"