diff --git a/scripts/QUICK_INSTALL.md b/scripts/QUICK_INSTALL.md new file mode 100644 index 000000000..9a390be00 --- /dev/null +++ b/scripts/QUICK_INSTALL.md @@ -0,0 +1,90 @@ +# Quick install + +A single installer for OG-Core and its country calibrations. Pre-req: **git** installed. Nothing else. + +It installs uv if needed, clones the repo you choose, runs `uv sync --extra dev`, and verifies the import. Pick the repo from a menu, or pass `--repo` / `-Repo`. + +You can run it two ways — paste a one-line command, or download the script and run it. Both do the same thing. + +## Option 1 — One-line + +### macOS / Linux + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/PSLmodels/OG-Core/master/scripts/install.sh)" +``` + +### Windows (PowerShell) + +```powershell +$f = "$env:TEMP\og-install.ps1"; irm https://raw.githubusercontent.com/PSLmodels/OG-Core/master/scripts/install.ps1 -OutFile $f; powershell -ExecutionPolicy Bypass -File $f +``` + +(On Windows the installer is saved to a temp file and run from there, so it executes as a normal script.) + +## Option 2 — Download, then run + +Handy if you'd rather read the script first, or keep it to re-run later. + +### macOS / Linux + +```bash +curl -fsSL https://raw.githubusercontent.com/PSLmodels/OG-Core/master/scripts/install.sh -o install.sh +bash install.sh +``` + +### Windows (PowerShell) + +```powershell +Invoke-WebRequest -UseBasicParsing -Uri https://raw.githubusercontent.com/PSLmodels/OG-Core/master/scripts/install.ps1 -OutFile install.ps1 +powershell -ExecutionPolicy Bypass -File .\install.ps1 +``` + +## Choosing a repo and skipping prompts + +By default the installer shows a menu of repos and prompts for a destination. Flags let you go straight there — they work with either method above: + +- `--repo` / `-Repo` — a short key for a repo in the built-in catalog: + - `og-core` — base model ([PSLmodels/OG-Core](https://github.com/PSLmodels/OG-Core)) + - `og-eth` — Ethiopia calibration ([EAPD-DRB/OG-ETH](https://github.com/EAPD-DRB/OG-ETH)); works once its uv migration lands +- `--repo-url` / `-RepoUrl` — a full git URL, for any other uv-based repo (a fork, or a country repo not yet in the catalog). Clones the default branch. +- `--branch` / `-Branch` — **for development work**: install a non-default branch (e.g. a fork or a migration branch before it merges). +- `--dest` / `-Dest` and `--yes` / `-Yes` — set the parent directory and skip the confirmation prompt. + +```bash +# macOS / Linux -- OG-Core to ~/Projects/OG-Core, no prompts +bash install.sh --repo og-core --dest ~/Projects --yes +# any repo by URL (default branch) +bash install.sh --repo-url https://github.com/OWNER/OG-XYZ.git +# a specific branch (development) +bash install.sh --repo-url https://github.com/OWNER/OG-XYZ.git --branch my-feature +``` + +```powershell +# Windows -- OG-Core to C:\Users\\Projects\OG-Core, no prompts +powershell -ExecutionPolicy Bypass -File .\install.ps1 -Repo og-core -Dest C:\Users\$env:USERNAME\Projects -Yes +# any repo by URL (default branch) +powershell -ExecutionPolicy Bypass -File .\install.ps1 -RepoUrl https://github.com/OWNER/OG-XYZ.git +``` + +More country calibrations get added to the catalog as they migrate to uv. + +## After install + +Activate the venv and you're set: + +```bash +# macOS / Linux +cd +source .venv/bin/activate +python -W ignore -c "import ogcore; print(ogcore.__version__)" +``` + +```powershell +# Windows +cd +.\.venv\Scripts\Activate.ps1 +python -W ignore -c "import ogcore; print(ogcore.__version__)" +``` + +(Swap `ogcore` for the package of the repo you installed — e.g. `ogeth` for OG-ETH.) diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 000000000..cc070153a --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,587 @@ +<# +.SYNOPSIS + OG-* universal installer for Windows (uv-based). + +.DESCRIPTION + Takes a user from zero (only git installed) to a working OG-* model env: + 1. Install uv (if not present) + 2. Clone the chosen repo + 3. uv sync --extra dev (installs Python + project + deps) + 4. Verify import + + Only repos that have migrated to uv (pyproject.toml + uv.lock) are offered. + +.PARAMETER Repo + Skip the model menu. One of: og-core, og-eth. + +.PARAMETER RepoUrl + Use a custom Git URL (e.g. your fork or SSH URL). Bypasses the menu. + +.PARAMETER Dest + Parent directory where the clone is created. Default: current directory. + The clone always lands in \. + +.PARAMETER Branch + For development: clone a non-default branch (default: repo's default + branch). Useful for testing forks/PRs before they merge. + +.PARAMETER Yes + Auto-confirm every prompt (non-interactive). + +.PARAMETER NoDevDeps + Install runtime deps only (skip dev/test tooling). + +.PARAMETER SkipUvInstall + Don't install uv; assume it's already on PATH. + +.PARAMETER NoLog + Don't write a log file. + +.EXAMPLE + .\scripts\install.ps1 + Fully interactive: pick a model and a destination, then install. + +.EXAMPLE + .\scripts\install.ps1 -Repo og-eth -Dest C:\work -Yes + Unattended install of OG-ETH into C:\work\OG-ETH. +#> + +[CmdletBinding()] +param( + [string]$Repo = "", + [string]$RepoUrl = "", + [string]$Branch = "", + [string]$Dest = "", + [switch]$Yes, + [switch]$NoDevDeps, + [switch]$SkipUvInstall, + [switch]$NoLog +) + +$ErrorActionPreference = 'Stop' +# $PSScriptRoot is the script's directory when run from a file; it's empty when +# run via the brew-style one-liner (& ([scriptblock]::Create((irm ...)))), in +# which case we fall back to the current working directory so the log file +# lands somewhere predictable instead of crashing Split-Path on the script's +# own source text. +$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$WithDevDeps = -not $NoDevDeps + +# -- Repo catalog (only uv-migrated repos) ------------------------------------- +$Repos = @( + [pscustomobject]@{ Key="og-core"; Owner="PSLmodels"; Name="OG-Core"; Pkg="ogcore"; Desc="base model (no country calibration)" }, + [pscustomobject]@{ Key="og-eth"; Owner="EAPD-DRB"; Name="OG-ETH"; Pkg="ogeth"; Desc="Ethiopia" } +) + +# -- Colors -------------------------------------------------------------------- +$UseAnsi = $Host.UI.SupportsVirtualTerminal -or ($env:WT_SESSION -ne $null) +if ($UseAnsi) { + $E = [char]27 + $BOLD = "$E[1m"; $DIM = "$E[2m" + $RED = "$E[91m"; $GREEN = "$E[92m"; $YELLOW = "$E[93m"; $RESET = "$E[0m" +} else { + $BOLD = ""; $DIM = ""; $RED = ""; $GREEN = ""; $YELLOW = ""; $RESET = "" +} + +# -- Logging ------------------------------------------------------------------- +$Ts = Get-Date -Format "yyyyMMdd-HHmmss" +$LogFile = Join-Path $ScriptDir ".install-$Ts.log" +$WriteLog = -not $NoLog +if ($WriteLog) { + try { Start-Transcript -Path $LogFile -Append | Out-Null } + catch { + Write-Host "WARN: could not start transcript at $LogFile : $($_.Exception.Message)" + $WriteLog = $false + } +} +function Stop-TranscriptIfActive { + if ($script:WriteLog) { try { Stop-Transcript | Out-Null } catch {} } +} + +# -- Helpers ------------------------------------------------------------------- +function Write-Hr { Write-Host "--------------------------------------------------------------" } +function Write-HrThick { Write-Host "==============================================================" } + +function Write-Pass($label, $detail = "") { + $suffix = if ($detail) { " $DIM($detail)$RESET" } else { "" } + Write-Host " $GREEN[PASS]$RESET $label$suffix" +} +function Write-Fail($label, $detail = "") { + $suffix = if ($detail) { " $DIM($detail)$RESET" } else { "" } + Write-Host " $RED[FAIL]$RESET $label$suffix" +} +function Write-Warn2($label, $detail = "") { + $suffix = if ($detail) { " $DIM($detail)$RESET" } else { "" } + Write-Host " $YELLOW[WARN]$RESET $label$suffix" +} +function Write-Skip($label, $detail = "") { + $suffix = if ($detail) { " $DIM($detail)$RESET" } else { "" } + Write-Host " $YELLOW[SKIP]$RESET $label$suffix" +} +function Write-Cmd($cmd) { Write-Host " $DIM`$ $cmd$RESET" } + +$TotalSteps = 4 +function Step-Banner($n, $title) { + Write-Host "" + Write-Hr + Write-Host " ${BOLD}Step $n of ${TotalSteps}: $title${RESET}" + Write-Hr +} + +function Test-Interactive { + try { return -not [Console]::IsInputRedirected } + catch { return [Environment]::UserInteractive } +} + +function Prompt-YN($prompt, $default = 'y') { + $opts = if ($default -eq 'y') { '[Y/n/q]' } else { '[y/N/q]' } + if ($Yes) { + Write-Host "$prompt $opts ${DIM}(auto: yes)${RESET}" + return $true + } + if (-not (Test-Interactive)) { + Write-Host "${RED}ERROR:${RESET} non-interactive session and -Yes not given." -ForegroundColor Red + return $false + } + while ($true) { + $ans = Read-Host "$prompt $opts" + if ([string]::IsNullOrWhiteSpace($ans)) { $ans = $default } + switch -Regex ($ans) { + '^(y|yes)$' { return $true } + '^(n|no)$' { return $false } + '^(q|quit)$' { + Write-Host "${YELLOW}Aborted by user.${RESET}" + Stop-TranscriptIfActive + exit 130 + } + default { Write-Host " Please answer y, n, or q." } + } + } +} + +# -- Pre-flight ---------------------------------------------------------------- +if ($env:CONDA_DEFAULT_ENV) { + Write-Host "${RED}ERROR:${RESET} conda env '$($env:CONDA_DEFAULT_ENV)' is active. Run 'conda deactivate' first." -ForegroundColor Red + Stop-TranscriptIfActive; exit 1 +} +if ($env:VIRTUAL_ENV) { + Write-Host "${RED}ERROR:${RESET} virtualenv '$($env:VIRTUAL_ENV)' is active. Run 'deactivate' first." -ForegroundColor Red + Stop-TranscriptIfActive; exit 1 +} +$GitCmd = Get-Command git -ErrorAction SilentlyContinue +if (-not $GitCmd) { + Write-Host "${RED}ERROR:${RESET} 'git' is not installed (or not on PATH)." -ForegroundColor Red + Write-Host "Install Git on Windows, then re-run:" + Write-Host " winget install -e --id Git.Git" + Write-Host " choco install git -y # if you use Chocolatey" + Write-Host " https://git-scm.com/download/win # MSI installer" + Stop-TranscriptIfActive; exit 1 +} +$GitBin = $GitCmd.Source + +# -- Detect existing uv -------------------------------------------------------- +$UvBin = $null +function Detect-Uv { + $cmd = Get-Command uv -ErrorAction SilentlyContinue + if ($cmd) { $script:UvBin = $cmd.Source; return $true } + $candidates = @( + "$env:USERPROFILE\.local\bin\uv.exe", + "$env:USERPROFILE\.cargo\bin\uv.exe", + "$env:LOCALAPPDATA\Programs\uv\uv.exe" + ) + foreach ($c in $candidates) { + if (Test-Path $c) { $script:UvBin = $c; return $true } + } + return $false +} +[void](Detect-Uv) +$UvPresent = [bool]$UvBin + +# -- Pick repo ----------------------------------------------------------------- +$RepoOwner = $null; $RepoName = $null; $PkgName = $null; $RepoDesc = $null + +function Choose-RepoInteractive { + if (-not (Test-Interactive)) { + Write-Host "${RED}ERROR:${RESET} no -Repo given and session is non-interactive." -ForegroundColor Red + Stop-TranscriptIfActive; exit 2 + } + Write-Host "" + Write-HrThick + Write-Host " ${BOLD}OG-* Installer (uv-based)${RESET}" + Write-HrThick + Write-Host " Which OG country model do you want to install?" + Write-Host " ${DIM}(only repos that have migrated to uv are listed)${RESET}" + Write-Host "" + $i = 1 + foreach ($r in $script:Repos) { + Write-Host (" {0}) {1,-9} ({2,-10}) -- {3}" -f $i, $r.Name, $r.Owner, $r.Desc) + $i++ + } + Write-Host (" {0}) Other (paste a Git URL)" -f $i) + Write-Host "" + while ($true) { + $choice = Read-Host (" Choice [1-{0}]" -f $i) + if (-not ($choice -match '^[0-9]+$')) { Write-Host " Please enter a number."; continue } + $n = [int]$choice + if ($n -lt 1 -or $n -gt $i) { Write-Host " Out of range."; continue } + if ($n -eq $i) { + $url = Read-Host " Git URL" + if ([string]::IsNullOrWhiteSpace($url)) { Write-Host " No URL given."; continue } + $script:RepoUrl = $url + return + } + $r = $script:Repos[$n - 1] + $script:Repo = $r.Key + $script:RepoOwner = $r.Owner; $script:RepoName = $r.Name + $script:PkgName = $r.Pkg; $script:RepoDesc = $r.Desc + return + } +} + +if ($RepoUrl -and -not $Repo) { + # custom URL via CLI; menu skipped +} elseif ($Repo) { + $match = $Repos | Where-Object { $_.Key -eq $Repo } + if (-not $match) { + Write-Host "${RED}ERROR:${RESET} unknown -Repo '$Repo'. Use -? for the list." -ForegroundColor Red + Stop-TranscriptIfActive; exit 2 + } + $RepoOwner = $match.Owner; $RepoName = $match.Name + $PkgName = $match.Pkg; $RepoDesc = $match.Desc +} else { + Choose-RepoInteractive +} + +# Custom URL: derive repo name + package name +if ($RepoUrl -and -not $RepoName) { + $leaf = Split-Path -Leaf $RepoUrl + $RepoName = $leaf -replace '\.git$', '' + $RepoOwner = "(custom URL)"; $RepoDesc = "custom repo" + $PkgName = ($RepoName.ToLower() -replace '-', '') + $Repo = $PkgName +} +if (-not $RepoUrl) { + $RepoUrl = "https://github.com/$RepoOwner/$RepoName.git" +} + +# -- Pick destination ---------------------------------------------------------- +if (-not $Dest) { + if (-not (Test-Interactive)) { + $Dest = "." + } else { + Write-Host "" + Write-Host (" Where would you like to install {0}?" -f $RepoName) + Write-Host (" Enter the PARENT directory; {0} will be cloned as a subfolder inside." -f $RepoName) + Write-Host (" Default: current directory ({0})" -f (Get-Location).Path) + $entered = Read-Host " Parent directory [.]" + if ([string]::IsNullOrWhiteSpace($entered)) { $Dest = "." } else { $Dest = $entered } + } +} + +# Expand env vars + ~ +$Dest = [Environment]::ExpandEnvironmentVariables($Dest) +if ($Dest.StartsWith("~")) { + $Dest = Join-Path $env:USERPROFILE $Dest.Substring(1).TrimStart('\','/') +} + +# $Dest is the parent directory. Must exist; resolve to absolute. +if (-not (Test-Path $Dest)) { + Write-Host "${RED}ERROR:${RESET} parent directory does not exist: $Dest" -ForegroundColor Red + Write-Host "Create it first (mkdir $Dest) or pick a different -Dest." + Stop-TranscriptIfActive; exit 1 +} +$ParentAbs = (Resolve-Path $Dest).Path +$DestAbs = Join-Path $ParentAbs $RepoName + +# Refuse if PARENT is a dangerous system dir. (User-home is fine -- clone lands +# inside it, not overwriting it.) +$parentNorm = $ParentAbs.TrimEnd('\').ToLower() +$dangerous = @( + $env:WINDIR.ToLower(), + "${env:ProgramFiles}".ToLower(), + "${env:ProgramFiles(x86)}".ToLower() +) +if ($dangerous -contains $parentNorm -or $parentNorm -match '^[a-z]:[\\/]?$') { + Write-Host "${RED}ERROR:${RESET} refusing to install into '$ParentAbs' (system dir)." -ForegroundColor Red + Stop-TranscriptIfActive; exit 1 +} + +# -- Banner / plan ------------------------------------------------------------- +Write-Host "" +Write-HrThick +Write-Host " ${BOLD}OG-* Installer (uv-based)${RESET}" +Write-HrThick +Write-Host (" Platform : Windows {0}" -f $env:PROCESSOR_ARCHITECTURE) +Write-Host (" Model : {0}" -f $RepoName) +Write-Host (" Description : {0}" -f $RepoDesc) +Write-Host (" Source : {0}" -f $RepoUrl) +if ($Branch) { Write-Host (" Branch : {0}" -f $Branch) } +Write-Host (" Destination : {0}" -f $DestAbs) +Write-Host (" Package : {0}" -f $PkgName) +Write-Host (" Dev/test deps: {0}" -f $(if ($WithDevDeps) { "yes" } else { "no" })) +if ($UvPresent) { + Write-Host (" uv : {0} {1}detected{2}" -f $UvBin, $GREEN, $RESET) +} else { + Write-Host (" uv : {0}will install{1} (~5MB, official installer)" -f $YELLOW, $RESET) +} +if ($WriteLog) { Write-Host (" Log file : {0}" -f $LogFile) } +Write-Host "" +Write-Host " ${BOLD}Plan ($TotalSteps steps):${RESET}" +if ($UvPresent -or $SkipUvInstall) { + Write-Host " 1. Install uv ${DIM}skipped${RESET}" +} else { + Write-Host " 1. Install uv ${DIM}~5MB, seconds${RESET}" +} +Write-Host (" 2. Clone {0,-25} ${DIM}depends on network${RESET}" -f $RepoName) +Write-Host " 3. uv sync (Python + deps) ${DIM}~30s, ~500MB${RESET}" +Write-Host " 4. Verify installation ${DIM}a few seconds${RESET}" +Write-Host "" +Write-Host " You will be asked to confirm before each mutating step." +Write-Host "" + +if (-not (Prompt-YN "Proceed with installation?" 'y')) { + Write-Host "${YELLOW}Aborted by user.${RESET}" + Stop-TranscriptIfActive; exit 0 +} + +# -- Result tracking ----------------------------------------------------------- +$StepResults = New-Object System.Collections.Generic.List[object] +function Record-Step($name, $state, $detail = "") { + $script:StepResults.Add([pscustomobject]@{ Name=$name; State=$state; Detail=$detail }) +} +$StartTime = Get-Date + +# -- Step 1: Install uv -------------------------------------------------------- +Step-Banner 1 "Install uv" +if ($UvPresent) { + Write-Pass "uv already present" $UvBin + Record-Step "uv" "SKIP" "already present" +} elseif ($SkipUvInstall) { + Write-Fail "-SkipUvInstall given but no uv found" + Record-Step "uv" "FAIL" "no uv and -SkipUvInstall" + Stop-TranscriptIfActive; exit 1 +} else { + Write-Host " Will install uv via the official installer:" + Write-Host " Source : https://astral.sh/uv/install.ps1" + Write-Host " Method : irm | iex -- installs to your user profile, no admin." + Write-Host "" + if (-not (Prompt-YN "Download and install uv?" 'y')) { + Record-Step "uv" "SKIP" "declined" + Write-Host "${RED}Cannot continue without uv. Aborting.${RESET}" + Stop-TranscriptIfActive; exit 1 + } + Write-Cmd "irm https://astral.sh/uv/install.ps1 | iex" + $ProgressPreference = 'SilentlyContinue' + # Run in a -ExecutionPolicy Bypass subprocess so this works regardless of + # the current ExecutionPolicy (uv install script is unsigned). + & powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ` + "[Net.ServicePointManager]::SecurityProtocol = 'Tls12'; iwr https://astral.sh/uv/install.ps1 -UseBasicParsing | iex" + if ($LASTEXITCODE -ne 0) { + Write-Fail "uv install subprocess returned $LASTEXITCODE" + Record-Step "uv" "FAIL" "install subprocess failed" + Stop-TranscriptIfActive; exit 1 + } + [void](Detect-Uv) + if (-not $UvBin) { + # Documented default install location + $candidate = "$env:USERPROFILE\.local\bin\uv.exe" + if (Test-Path $candidate) { $UvBin = $candidate } + } + if (-not $UvBin -or -not (Test-Path $UvBin)) { + Write-Fail "uv install completed but binary not found" + Record-Step "uv" "FAIL" "binary not found post-install" + Stop-TranscriptIfActive; exit 1 + } + $uvVer = "" + try { $uvVer = ((& $UvBin --version 2>$null) | Select-Object -First 1).Trim() } catch {} + Write-Pass "uv installed" "$UvBin ($uvVer)" + Record-Step "uv" "PASS" $UvBin +} + +# -- Step 2: Clone the repo ---------------------------------------------------- +Step-Banner 2 "Clone $RepoName" + +function Normalize-RemoteUrl($u) { + return ($u -replace '\.git/?$', '').ToLower() +} + +$DestHasRepo = $false; $DestEmpty = $false +if (Test-Path $DestAbs) { + if ((Get-ChildItem -Force $DestAbs -ErrorAction SilentlyContinue | Measure-Object).Count -eq 0) { + $DestEmpty = $true + } elseif (Test-Path (Join-Path $DestAbs ".git")) { + $existingUrl = "" + try { $existingUrl = (& $GitBin -C $DestAbs config --get remote.origin.url 2>$null).Trim() } catch {} + if ((Normalize-RemoteUrl $existingUrl) -eq (Normalize-RemoteUrl $RepoUrl)) { + $DestHasRepo = $true + } else { + Write-Fail "Destination is a git repo for a different remote" $existingUrl + Write-Host " Either remove $DestAbs or pick a different destination with -Dest." + Record-Step "Clone" "FAIL" "wrong remote" + Stop-TranscriptIfActive; exit 1 + } + } else { + Write-Fail "Destination exists and is not empty (and not a git clone)" $DestAbs + Write-Host " Either remove $DestAbs or pick a different destination with -Dest." + Record-Step "Clone" "FAIL" "destination not empty" + Stop-TranscriptIfActive; exit 1 + } +} + +if ($DestHasRepo) { + $branch = "?" + try { $branch = (& $GitBin -C $DestAbs rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + Write-Host (" Existing clone of {0} found at {1} (branch: {2})." -f $RepoName, $DestAbs, $branch) + Write-Host " Will run 'git pull --ff-only' to bring it up to date." + Write-Host "" + if (Prompt-YN "Update existing clone?" 'y') { + Write-Cmd "git -C $DestAbs pull --ff-only" + & $GitBin -C $DestAbs pull --ff-only + if ($LASTEXITCODE -eq 0) { + Write-Pass "Repo updated" $DestAbs + Record-Step "Clone" "PASS" "updated ($branch)" + } else { + Write-Warn2 "git pull failed; continuing with existing state" + Record-Step "Clone" "WARN" "pull failed; existing state used" + } + } else { + Write-Skip "Update" "using existing clone as-is" + Record-Step "Clone" "SKIP" "existing clone used as-is ($branch)" + } +} else { + if ($Branch) { + Write-Host (" Will clone {0} (branch: {1}) into {2}." -f $RepoUrl, $Branch, $DestAbs) + } else { + Write-Host (" Will clone {0} into {1}." -f $RepoUrl, $DestAbs) + } + Write-Host "" + if (Prompt-YN "Clone now?" 'y') { + if ($Branch) { + Write-Cmd "git clone --branch $Branch $RepoUrl $DestAbs" + & $GitBin clone --branch $Branch $RepoUrl $DestAbs + } else { + Write-Cmd "git clone $RepoUrl $DestAbs" + & $GitBin clone $RepoUrl $DestAbs + } + if ($LASTEXITCODE -ne 0) { throw "git clone failed (exit $LASTEXITCODE)" } + $branch = "?" + try { $branch = (& $GitBin -C $DestAbs rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + Write-Pass "Cloned" "$DestAbs (branch: $branch)" + Record-Step "Clone" "PASS" $branch + } else { + Write-Fail "Clone declined; cannot continue." + Record-Step "Clone" "FAIL" "declined" + Stop-TranscriptIfActive; exit 1 + } +} + +# Verify the repo is uv-native. +if (-not (Test-Path (Join-Path $DestAbs "pyproject.toml"))) { + Write-Fail "$DestAbs has no pyproject.toml" + Write-Host " This installer requires repos that have migrated to uv." + Record-Step "Clone" "FAIL" "no pyproject.toml" + Stop-TranscriptIfActive; exit 1 +} +if (-not (Test-Path (Join-Path $DestAbs "uv.lock"))) { + Write-Warn2 "$DestAbs has no uv.lock; uv sync will create one" +} + +# -- Step 3: uv sync ----------------------------------------------------------- +Step-Banner 3 "Install $RepoName (uv sync)" +$syncArgs = @("sync") +if ($WithDevDeps) { $syncArgs += @("--extra", "dev") } +Write-Host (" Will install {0} + Python + all deps into {1}\.venv" -f $RepoName, $DestAbs) +Write-Host " Working directory : $DestAbs" +Write-Host (" Command : uv {0}" -f ($syncArgs -join ' ')) +Write-Host "" +if (Prompt-YN "Run uv sync now?" 'y') { + Push-Location $DestAbs + try { + Write-Cmd ("uv {0}" -f ($syncArgs -join ' ')) + & $UvBin @syncArgs + if ($LASTEXITCODE -ne 0) { throw "uv sync failed (exit $LASTEXITCODE)" } + Write-Pass "$RepoName installed (editable)" + Record-Step "$RepoName install" "PASS" "uv sync" + } finally { Pop-Location } +} else { + Write-Fail "uv sync declined; package will not be importable." + Record-Step "$RepoName install" "FAIL" "declined" +} + +# -- Step 4: Verify ------------------------------------------------------------ +Step-Banner 4 "Verify installation" +$VenvPython = Join-Path $DestAbs ".venv\Scripts\python.exe" +if (-not (Test-Path $VenvPython)) { + Write-Fail "Venv python not found; skipping import check." $VenvPython + Record-Step "Verification" "FAIL" "no venv python" +} else { + $pyver = "" + try { $pyver = (& $VenvPython -W ignore -c "import sys; print(sys.version.split()[0])" 2>&1 | Select-Object -Last 1).ToString().Trim() } catch {} + Write-Pass "Python in .venv" $pyver + + # Run import as a discrete process; merge stderr into stdout so PS doesn't + # treat upstream deprecation warnings (e.g. from pygam) as halting errors. + # -W ignore silences Python's own warnings. + & $VenvPython -W ignore -c "import $PkgName" 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + $ver = "" + try { $ver = (& $VenvPython -W ignore -c "import $PkgName; print(getattr($PkgName, '__version__', '?'))" 2>&1 | Select-Object -Last 1).ToString().Trim() } catch {} + Write-Pass "import $PkgName" $ver + Record-Step "Verification" "PASS" "import $PkgName ($ver)" + } else { + Write-Fail "import $PkgName" "package not importable; check log above" + Record-Step "Verification" "FAIL" "import $PkgName failed" + } +} + +# -- Summary ------------------------------------------------------------------- +$Elapsed = (Get-Date) - $StartTime +$ElapsedMin = [int]$Elapsed.TotalMinutes +$ElapsedSec = [int]($Elapsed.TotalSeconds - ($ElapsedMin * 60)) + +Write-Host "" +Write-HrThick +Write-Host (" ${BOLD}Installation Summary -- {0}${RESET}" -f $RepoName) +Write-HrThick + +$AllOk = $true +foreach ($r in $StepResults) { + switch ($r.State) { + "PASS" { Write-Pass $r.Name $r.Detail } + "SKIP" { Write-Skip $r.Name $r.Detail } + "WARN" { Write-Warn2 $r.Name $r.Detail } + "FAIL" { Write-Fail $r.Name $r.Detail; $AllOk = $false } + default { Write-Warn2 $r.Name "unknown: $($r.State)" } + } +} +Write-Host "" +Write-Host (" Elapsed : {0}m {1}s" -f $ElapsedMin, $ElapsedSec) +Write-Host (" Location : {0}" -f $DestAbs) +Write-Host (" Venv : {0}\.venv" -f $DestAbs) +if ($WriteLog) { Write-Host (" Log : {0}" -f $LogFile) } +Write-Host "" + +if ($AllOk) { + Write-Host " ${GREEN}${BOLD}All steps completed successfully.${RESET}" + Write-Host "" + Write-Host " ${BOLD}To start using ${RepoName}:${RESET}" + Write-Host " cd $DestAbs" + Write-Host " .\.venv\Scripts\Activate.ps1 # activate venv" + Write-Host (" python -W ignore -c `"import {0}; print({0}.__file__)`"" -f $PkgName) + Write-Host " Or run commands without activating:" + Write-Host (" uv run python -W ignore -c `"import {0}; print({0}.__file__)`"" -f $PkgName) + $exDir = Join-Path $DestAbs "examples" + if (Test-Path $exDir) { + Write-Host "" + Write-Host (" Example scripts: {0}" -f $exDir) + } + Stop-TranscriptIfActive + exit 0 +} else { + Write-Host " ${RED}${BOLD}One or more steps failed.${RESET}" + Write-Host "" + Write-Host " Review the [FAIL] entries above." + if ($WriteLog) { Write-Host " Full output is in: $LogFile" } + Stop-TranscriptIfActive + exit 1 +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 000000000..c2bb20437 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,562 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# OG-* universal installer for macOS and Linux (uv-based). +# +# Takes a user from zero (only git installed) to a working OG-* model env: +# 1. Install uv (if not present) +# 2. Clone the chosen repo +# 3. uv sync --extra dev (installs Python + project + deps) +# 4. Verify import +# +# Only repos that have migrated to uv (pyproject.toml + uv.lock) are offered. +# +# Usage: +# ./scripts/install.sh # interactive +# ./scripts/install.sh --repo og-eth # skip the model menu +# ./scripts/install.sh --help +# ────────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Repo catalog ────────────────────────────────────────────────────────────── +# Each entry: KEY|OWNER|REPO_NAME|PKG_NAME|DESCRIPTION +# Only repos that have migrated to uv (pyproject.toml + uv.lock). +# Add entries as other OG-* repos migrate. +REPOS=( + "og-core|PSLmodels|OG-Core|ogcore|base model (no country calibration)" + "og-eth|EAPD-DRB|OG-ETH|ogeth|Ethiopia" +) + +# ── Defaults ────────────────────────────────────────────────────────────────── +REPO_KEY="" +REPO_URL="" +BRANCH="" +DEST="" +ASSUME_YES=0 +SKIP_UV_INSTALL=0 +WITH_DEV_DEPS=1 +WRITE_LOG=1 + +usage() { + cat </\${REPO_NAME}. + --no-dev-deps Install runtime deps only (skip dev/test tooling). + --skip-uv-install Don't install uv; assume it's already on PATH. + --no-log Don't write a log file. + +Examples: + $0 # fully interactive + $0 --repo og-eth # menu skipped; prompt for dest + $0 --repo og-eth --dest ~/Projects --yes # clones to ~/Projects/OG-ETH + $0 --repo-url git@github.com:me/OG-USA.git --dest . + $0 --repo-url https://github.com/me/OG-ETH.git --branch my-branch --dest /tmp +EOF +} + +# ── Argument parsing ────────────────────────────────────────────────────────── +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0;; + -y|--yes) ASSUME_YES=1; shift;; + --repo) REPO_KEY="$2"; shift 2;; + --repo=*) REPO_KEY="${1#*=}"; shift;; + --repo-url) REPO_URL="$2"; shift 2;; + --repo-url=*) REPO_URL="${1#*=}"; shift;; + --branch) BRANCH="$2"; shift 2;; + --branch=*) BRANCH="${1#*=}"; shift;; + --dest) DEST="$2"; shift 2;; + --dest=*) DEST="${1#*=}"; shift;; + --no-dev-deps) WITH_DEV_DEPS=0; shift;; + --skip-uv-install) SKIP_UV_INSTALL=1; shift;; + --no-log) WRITE_LOG=0; shift;; + *) echo "Unknown option: $1" >&2; echo; usage >&2; exit 2;; + esac +done + +# ── Colors ──────────────────────────────────────────────────────────────────── +if [ -t 1 ]; then + BOLD=$'\033[1m'; DIM=$'\033[2m' + RED=$'\033[91m'; GREEN=$'\033[92m'; YELLOW=$'\033[93m'; RESET=$'\033[0m' +else + BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; RESET="" +fi + +# ── Logging ─────────────────────────────────────────────────────────────────── +TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="${SCRIPT_DIR}/.install-${TS}.log" +if [ "$WRITE_LOG" = 1 ]; then + if : > "$LOG_FILE" 2>/dev/null; then + exec > >(tee -a "$LOG_FILE") 2>&1 + else + printf "WARN: cannot write to %s; logging disabled.\n" "$LOG_FILE" >&2 + WRITE_LOG=0 + fi +fi + +# ── Helpers ─────────────────────────────────────────────────────────────────── +hr() { printf '%s\n' "──────────────────────────────────────────────────────────────"; } +hr_thick() { printf '%s\n' "══════════════════════════════════════════════════════════════"; } + +print_pass() { printf " ${GREEN}[PASS]${RESET} %s%s\n" "$1" "${2:+ ${DIM}($2)${RESET}}"; } +print_fail() { printf " ${RED}[FAIL]${RESET} %s%s\n" "$1" "${2:+ ${DIM}($2)${RESET}}"; } +print_warn() { printf " ${YELLOW}[WARN]${RESET} %s%s\n" "$1" "${2:+ ${DIM}($2)${RESET}}"; } +print_skip() { printf " ${YELLOW}[SKIP]${RESET} %s%s\n" "$1" "${2:+ ${DIM}($2)${RESET}}"; } +echo_cmd() { printf " ${DIM}$ %s${RESET}\n" "$*"; } + +TOTAL_STEPS=4 +step_banner() { + echo + hr + printf " ${BOLD}Step %s of %s: %s${RESET}\n" "$1" "$TOTAL_STEPS" "$2" + hr +} + +prompt_yn() { + local prompt="$1" default="${2:-y}" opts + if [ "$default" = "y" ]; then opts="[Y/n/q]"; else opts="[y/N/q]"; fi + if [ "$ASSUME_YES" = 1 ]; then + printf "%s %s ${DIM}(auto: yes)${RESET}\n" "$prompt" "$opts" + return 0 + fi + if [ ! -t 0 ]; then + printf "${RED}ERROR:${RESET} stdin is not a terminal and --yes was not given.\n" >&2 + return 2 + fi + while true; do + printf "%s %s " "$prompt" "$opts" + local ans="" + IFS= read -r ans || true + ans="${ans:-$default}" + case "$ans" in + [Yy]|[Yy][Ee][Ss]) return 0;; + [Nn]|[Nn][Oo]) return 1;; + [Qq]|[Qq][Uu][Ii][Tt]) + printf "${YELLOW}Aborted by user.${RESET}\n"; exit 130;; + *) printf " Please answer y, n, or q.\n";; + esac + done +} + +# ── Pre-flight ──────────────────────────────────────────────────────────────── +if [ "$(id -u)" = "0" ]; then + printf "${RED}ERROR:${RESET} do not run this script as root.\n" >&2 + exit 1 +fi +if [ -n "${CONDA_DEFAULT_ENV:-}" ]; then + printf "${RED}ERROR:${RESET} conda env '${CONDA_DEFAULT_ENV}' is active.\n" >&2 + printf "Run 'conda deactivate' first, then re-run.\n" >&2 + exit 1 +fi +if [ -n "${VIRTUAL_ENV:-}" ]; then + printf "${RED}ERROR:${RESET} virtualenv '${VIRTUAL_ENV}' is active.\n" >&2 + printf "Run 'deactivate' first, then re-run.\n" >&2 + exit 1 +fi +if ! command -v git >/dev/null 2>&1; then + printf "${RED}ERROR:${RESET} 'git' is not installed (or not on PATH).\n" >&2 + case "$(uname -s)" in + Darwin) printf "Install: xcode-select --install (or: brew install git)\n" >&2;; + Linux) printf "Install: sudo apt-get install -y git (or dnf/pacman equivalent)\n" >&2;; + esac + exit 1 +fi +GIT_BIN="$(command -v git)" +if ! command -v curl >/dev/null 2>&1; then + printf "${RED}ERROR:${RESET} 'curl' is required to install uv.\n" >&2 + exit 1 +fi + +case "$(uname -s)" in + Darwin) OS_NAME="macOS";; + Linux) OS_NAME="Linux";; + *) printf "${RED}ERROR:${RESET} unsupported OS '%s'.\n" "$(uname -s)" >&2; exit 1;; +esac + +# ── Detect existing uv ──────────────────────────────────────────────────────── +UV_BIN="" +detect_uv() { + if command -v uv >/dev/null 2>&1; then + UV_BIN="$(command -v uv)"; return 0 + fi + local c + for c in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv" "/usr/local/bin/uv" "/opt/homebrew/bin/uv"; do + if [ -x "$c" ]; then UV_BIN="$c"; return 0; fi + done + return 1 +} +detect_uv || true +UV_PRESENT=0; [ -n "$UV_BIN" ] && UV_PRESENT=1 + +# ── Look up repo by key ─────────────────────────────────────────────────────── +lookup_repo() { + local key="$1" entry k owner name pkg desc + for entry in "${REPOS[@]}"; do + IFS='|' read -r k owner name pkg desc <<< "$entry" + if [ "$k" = "$key" ]; then + REPO_OWNER="$owner"; REPO_NAME="$name"; PKG_NAME="$pkg"; REPO_DESC="$desc" + return 0 + fi + done + return 1 +} + +# ── Pick repo (interactive menu or from --repo flag) ────────────────────────── +choose_repo_interactive() { + if [ ! -t 0 ]; then + printf "${RED}ERROR:${RESET} no --repo given and stdin is not a terminal.\n" >&2 + exit 2 + fi + echo + hr_thick + printf " ${BOLD}OG-* Installer (uv-based)${RESET}\n" + hr_thick + printf " Which OG country model do you want to install?\n" + printf " ${DIM}(only repos that have migrated to uv are listed)${RESET}\n" + echo + local i=1 entry k owner name pkg desc + for entry in "${REPOS[@]}"; do + IFS='|' read -r k owner name pkg desc <<< "$entry" + printf " %d) %-9s (%-10s) -- %s\n" "$i" "$name" "$owner" "$desc" + i=$((i + 1)) + done + printf " %d) Other (paste a Git URL)\n" "$i" + echo + while true; do + printf " Choice [1-%d]: " "$i" + local choice="" + IFS= read -r choice || true + if ! printf '%s' "$choice" | grep -Eq '^[0-9]+$'; then + printf " Please enter a number.\n"; continue + fi + if [ "$choice" -lt 1 ] || [ "$choice" -gt "$i" ]; then + printf " Out of range.\n"; continue + fi + if [ "$choice" -eq "$i" ]; then + printf " Git URL : " + local url="" + IFS= read -r url || true + if [ -z "$url" ]; then printf " No URL given.\n"; continue; fi + REPO_URL="$url"; return 0 + fi + local idx=$((choice - 1)) + IFS='|' read -r REPO_KEY REPO_OWNER REPO_NAME PKG_NAME REPO_DESC <<< "${REPOS[$idx]}" + return 0 + done +} + +if [ -n "$REPO_URL" ] && [ -z "$REPO_KEY" ]; then + : # custom URL; menu skipped +elif [ -n "$REPO_KEY" ]; then + if ! lookup_repo "$REPO_KEY"; then + printf "${RED}ERROR:${RESET} unknown --repo '%s'. Use --help for the list.\n" "$REPO_KEY" >&2 + exit 2 + fi +else + choose_repo_interactive +fi + +# Custom URL: derive repo name + package name from it. +if [ -n "$REPO_URL" ] && [ -z "${REPO_NAME:-}" ]; then + base="$(basename "$REPO_URL")" + REPO_NAME="${base%.git}" + REPO_OWNER="(custom URL)" + REPO_DESC="custom repo" + PKG_NAME="$(printf '%s' "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | tr -d '-')" + REPO_KEY="$PKG_NAME" +fi + +if [ -z "$REPO_URL" ]; then + REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}.git" +fi + +# ── Pick destination (PARENT directory; clone lands at PARENT/REPO_NAME) ────── +if [ -z "$DEST" ]; then + if [ ! -t 0 ]; then + DEST="." + else + printf "\n Where would you like to install %s?\n" "$REPO_NAME" + printf " Enter the PARENT directory; %s will be cloned as a subfolder inside.\n" "$REPO_NAME" + printf " Default: current directory (%s)\n" "$(pwd)" + printf " Parent directory [.]: " + IFS= read -r DEST || true + DEST="${DEST:-.}" + fi +fi + +# Expand ~ +case "$DEST" in + "~") DEST="$HOME";; + "~/"*) DEST="$HOME/${DEST#~/}";; +esac + +# DEST is the parent directory. Must exist; resolve to absolute. +if [ ! -d "$DEST" ]; then + printf "${RED}ERROR:${RESET} parent directory does not exist: %s\n" "$DEST" >&2 + printf "Create it first (mkdir -p %s) or pick a different --dest.\n" "$DEST" >&2 + exit 1 +fi +PARENT_ABS="$(cd "$DEST" && pwd)" +DEST_ABS="${PARENT_ABS}/${REPO_NAME}" + +# Refuse if PARENT is a dangerous system dir. (User-home is fine -- clone lands +# inside it, not overwriting it.) +case "$PARENT_ABS" in + "/"|"/usr"|"/etc"|"/var"|"/bin"|"/sbin"|"/opt") + printf "${RED}ERROR:${RESET} refusing to install into '%s' (system dir).\n" "$PARENT_ABS" >&2 + exit 1;; +esac + +# ── Banner / plan ───────────────────────────────────────────────────────────── +echo +hr_thick +printf " ${BOLD}OG-* Installer (uv-based)${RESET}\n" +hr_thick +printf " Platform : %s %s\n" "$OS_NAME" "$(uname -m)" +printf " Model : %s\n" "$REPO_NAME" +printf " Description : %s\n" "$REPO_DESC" +printf " Source : %s\n" "$REPO_URL" +[ -n "$BRANCH" ] && printf " Branch : %s\n" "$BRANCH" +printf " Destination : %s\n" "$DEST_ABS" +printf " Package : %s\n" "$PKG_NAME" +printf " Dev/test deps: %s\n" "$([ "$WITH_DEV_DEPS" = 1 ] && echo yes || echo no)" +if [ "$UV_PRESENT" = 1 ]; then + printf " uv : %s ${GREEN}detected${RESET}\n" "$UV_BIN" +else + printf " uv : ${YELLOW}will install${RESET} (~5MB, official installer)\n" +fi +[ "$WRITE_LOG" = 1 ] && printf " Log file : %s\n" "$LOG_FILE" +echo +printf " ${BOLD}Plan (%d steps):${RESET}\n" "$TOTAL_STEPS" +if [ "$UV_PRESENT" = 1 ] || [ "$SKIP_UV_INSTALL" = 1 ]; then + printf " 1. Install uv ${DIM}skipped${RESET}\n" +else + printf " 1. Install uv ${DIM}~5MB, seconds${RESET}\n" +fi +printf " 2. Clone %-25s ${DIM}depends on network${RESET}\n" "$REPO_NAME" +printf " 3. uv sync (Python + deps) ${DIM}~30s, ~500MB${RESET}\n" +printf " 4. Verify installation ${DIM}a few seconds${RESET}\n" +echo +printf " You will be asked to confirm before each mutating step.\n" +echo + +if ! prompt_yn "Proceed with installation?" y; then + printf "${YELLOW}Aborted by user.${RESET}\n"; exit 0 +fi + +# ── Result tracking ─────────────────────────────────────────────────────────── +declare -a STEP_NAMES=() STEP_STATES=() STEP_DETAILS=() +record_step() { STEP_NAMES+=("$1"); STEP_STATES+=("$2"); STEP_DETAILS+=("$3"); } +START_TS=$(date +%s) + +# ── Step 1: Install uv ──────────────────────────────────────────────────────── +step_banner 1 "Install uv" +if [ "$UV_PRESENT" = 1 ]; then + print_pass "uv already present" "$UV_BIN" + record_step "uv" SKIP "already present" +elif [ "$SKIP_UV_INSTALL" = 1 ]; then + print_fail "--skip-uv-install given but no uv found" + record_step "uv" FAIL "no uv and --skip-uv-install" + exit 1 +else + printf " Will install uv via the official installer:\n" + printf " Source : https://astral.sh/uv/install.sh\n" + printf " Target : \$HOME/.local/bin/uv (the installer's default)\n" + printf " Method : curl | sh -- no sudo, installs to your home directory.\n" + echo + if ! prompt_yn "Download and install uv?" y; then + record_step "uv" SKIP "declined" + printf "${RED}Cannot continue without uv. Aborting.${RESET}\n"; exit 1 + fi + echo_cmd "curl -LsSf https://astral.sh/uv/install.sh | sh" + curl -LsSf https://astral.sh/uv/install.sh | sh + detect_uv || true + if [ -z "$UV_BIN" ] || [ ! -x "$UV_BIN" ]; then + if [ -x "$HOME/.local/bin/uv" ]; then UV_BIN="$HOME/.local/bin/uv" + elif [ -x "$HOME/.cargo/bin/uv" ]; then UV_BIN="$HOME/.cargo/bin/uv" + fi + fi + if [ -z "$UV_BIN" ] || [ ! -x "$UV_BIN" ]; then + print_fail "uv install completed but binary not found" + record_step "uv" FAIL "binary not found post-install" + exit 1 + fi + print_pass "uv installed" "$UV_BIN ($("$UV_BIN" --version 2>/dev/null | head -1))" + record_step "uv" PASS "$UV_BIN" +fi + +# ── Step 2: Clone the repo ──────────────────────────────────────────────────── +step_banner 2 "Clone ${REPO_NAME}" + +DEST_HAS_REPO=0; DEST_EMPTY=0 +if [ -d "$DEST_ABS" ]; then + if [ -z "$(ls -A "$DEST_ABS" 2>/dev/null || true)" ]; then + DEST_EMPTY=1 + elif [ -d "$DEST_ABS/.git" ]; then + existing_url="$("$GIT_BIN" -C "$DEST_ABS" config --get remote.origin.url 2>/dev/null || true)" + norm() { printf '%s' "$1" | sed -E 's#\.git/?$##' | tr '[:upper:]' '[:lower:]'; } + if [ "$(norm "$existing_url")" = "$(norm "$REPO_URL")" ]; then + DEST_HAS_REPO=1 + else + print_fail "Destination is a git repo for a different remote" "$existing_url" + printf " Either remove %s or pick a different destination with --dest.\n" "$DEST_ABS" + record_step "Clone" FAIL "wrong remote"; exit 1 + fi + else + print_fail "Destination exists and is not empty (and not a git clone)" "$DEST_ABS" + printf " Either remove %s or pick a different destination with --dest.\n" "$DEST_ABS" + record_step "Clone" FAIL "destination not empty"; exit 1 + fi +fi + +if [ "$DEST_HAS_REPO" = 1 ]; then + branch="$("$GIT_BIN" -C "$DEST_ABS" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")" + printf " Existing clone of %s found at %s (branch: %s).\n" "$REPO_NAME" "$DEST_ABS" "$branch" + printf " Will run 'git pull --ff-only' to bring it up to date.\n" + echo + if prompt_yn "Update existing clone?" y; then + echo_cmd "git -C $DEST_ABS pull --ff-only" + if "$GIT_BIN" -C "$DEST_ABS" pull --ff-only; then + print_pass "Repo updated" "$DEST_ABS" + record_step "Clone" PASS "updated ($branch)" + else + print_warn "git pull failed; continuing with existing state" + record_step "Clone" WARN "pull failed; existing state used" + fi + else + print_skip "Update" "using existing clone as-is" + record_step "Clone" SKIP "existing clone used as-is ($branch)" + fi +else + if [ -n "$BRANCH" ]; then + printf " Will clone %s (branch: %s) into %s.\n" "$REPO_URL" "$BRANCH" "$DEST_ABS" + else + printf " Will clone %s into %s.\n" "$REPO_URL" "$DEST_ABS" + fi + echo + if prompt_yn "Clone now?" y; then + if [ -n "$BRANCH" ]; then + echo_cmd "git clone --branch $BRANCH $REPO_URL $DEST_ABS" + "$GIT_BIN" clone --branch "$BRANCH" "$REPO_URL" "$DEST_ABS" + else + echo_cmd "git clone $REPO_URL $DEST_ABS" + "$GIT_BIN" clone "$REPO_URL" "$DEST_ABS" + fi + branch="$("$GIT_BIN" -C "$DEST_ABS" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")" + print_pass "Cloned" "$DEST_ABS (branch: $branch)" + record_step "Clone" PASS "$branch" + else + print_fail "Clone declined; cannot continue." + record_step "Clone" FAIL "declined"; exit 1 + fi +fi + +# Verify the repo is uv-native (has pyproject.toml and uv.lock). +if [ ! -f "$DEST_ABS/pyproject.toml" ]; then + print_fail "$DEST_ABS has no pyproject.toml" + printf " This installer requires repos that have migrated to uv.\n" + record_step "Clone" FAIL "no pyproject.toml"; exit 1 +fi +if [ ! -f "$DEST_ABS/uv.lock" ]; then + print_warn "$DEST_ABS has no uv.lock; uv sync will create one" +fi + +# ── Step 3: uv sync ─────────────────────────────────────────────────────────── +step_banner 3 "Install ${REPO_NAME} (uv sync)" +sync_args=("sync") +[ "$WITH_DEV_DEPS" = 1 ] && sync_args+=("--extra" "dev") +printf " Will install %s + Python + all deps into %s/.venv\n" "$REPO_NAME" "$DEST_ABS" +printf " Working directory : %s\n" "$DEST_ABS" +printf " Command : uv %s\n" "${sync_args[*]}" +echo +if prompt_yn "Run uv sync now?" y; then + cd "$DEST_ABS" + echo_cmd "uv ${sync_args[*]}" + "$UV_BIN" "${sync_args[@]}" + print_pass "${REPO_NAME} installed (editable)" + record_step "${REPO_NAME} install" PASS "uv sync" +else + print_fail "uv sync declined; package will not be importable." + record_step "${REPO_NAME} install" FAIL "declined" +fi + +# ── Step 4: Verify ──────────────────────────────────────────────────────────── +step_banner 4 "Verify installation" +if [ ! -x "$DEST_ABS/.venv/bin/python" ]; then + print_fail "Venv python not found; skipping import check." "$DEST_ABS/.venv/bin/python" + record_step "Verification" FAIL "no venv python" +else + # -W ignore silences upstream deprecation warnings (e.g. from pygam) so + # the verification output stays clean. + pyver="$("$DEST_ABS/.venv/bin/python" -W ignore -c 'import sys; print(sys.version.split()[0])' 2>/dev/null || echo unknown)" + print_pass "Python in .venv" "$pyver" + if "$DEST_ABS/.venv/bin/python" -W ignore -c "import $PKG_NAME" >/dev/null 2>&1; then + ver="$("$DEST_ABS/.venv/bin/python" -W ignore -c "import $PKG_NAME; print(getattr($PKG_NAME, '__version__', '?'))" 2>/dev/null)" + print_pass "import $PKG_NAME" "$ver" + record_step "Verification" PASS "import $PKG_NAME ($ver)" + else + print_fail "import $PKG_NAME" "package not importable; check log above" + record_step "Verification" FAIL "import $PKG_NAME failed" + fi +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +END_TS=$(date +%s); ELAPSED=$((END_TS - START_TS)) +ELAPSED_MIN=$((ELAPSED / 60)); ELAPSED_SEC=$((ELAPSED % 60)) + +echo +hr_thick +printf " ${BOLD}Installation Summary -- %s${RESET}\n" "$REPO_NAME" +hr_thick +all_ok=1 +for i in "${!STEP_NAMES[@]}"; do + name="${STEP_NAMES[$i]}"; state="${STEP_STATES[$i]}"; detail="${STEP_DETAILS[$i]}" + case "$state" in + PASS) print_pass "$name" "$detail";; + SKIP) print_skip "$name" "$detail";; + WARN) print_warn "$name" "$detail";; + FAIL) print_fail "$name" "$detail"; all_ok=0;; + *) print_warn "$name" "unknown: $state";; + esac +done +echo +printf " Elapsed : %dm %ds\n" "$ELAPSED_MIN" "$ELAPSED_SEC" +printf " Location : %s\n" "$DEST_ABS" +printf " Venv : %s/.venv\n" "$DEST_ABS" +[ "$WRITE_LOG" = 1 ] && printf " Log : %s\n" "$LOG_FILE" +echo + +if [ "$all_ok" = 1 ]; then + printf " ${GREEN}${BOLD}All steps completed successfully.${RESET}\n" + echo + printf " ${BOLD}To start using ${REPO_NAME}:${RESET}\n" + printf " cd %s\n" "$DEST_ABS" + printf " source .venv/bin/activate # activate venv\n" + printf " python -W ignore -c \"import %s; print(%s.__file__)\"\n" "$PKG_NAME" "$PKG_NAME" + printf " Or run commands without activating:\n" + printf " uv run python -W ignore -c \"import %s; print(%s.__file__)\"\n" "$PKG_NAME" "$PKG_NAME" + if [ -d "$DEST_ABS/examples" ]; then + printf "\n Example scripts: %s/examples\n" "$DEST_ABS" + fi + exit 0 +else + printf " ${RED}${BOLD}One or more steps failed.${RESET}\n" + echo + printf " Review the [FAIL] entries above.\n" + [ "$WRITE_LOG" = 1 ] && printf " Full output is in: %s\n" "$LOG_FILE" + exit 1 +fi