Files
2026-07-13 13:12:33 +08:00

419 lines
16 KiB
PowerShell

# install_source.ps1 - user-local OpenSquilla installer (no admin).
#
# Installer contract:
# - installs into a user-owned prefix (never Program Files or system32)
# - prefers uv tool install; falls back to pip --user; errors clearly if neither exists
# - defaults to the "recommended" runtime profile (memory + bundled v4 router)
# and allows `$env:OPENSQUILLA_INSTALL_PROFILE="core"` to opt back down
# - on Windows, best-effort installs Microsoft Visual C++ Redistributable
# before the recommended router profile because onnxruntime requires it
# - prints a post-install banner documenting the default bind
# (127.0.0.1:18791) and the explicit opt-in required to expose the gateway
# on the network (-Listen 0.0.0.0 or $env:OPENSQUILLA_LISTEN="0.0.0.0")
# - adds an extra WARNING when the operator requested network exposure at
# install time via $env:OPENSQUILLA_LISTEN="0.0.0.0"
#
# Dry-run: set $env:OPENSQUILLA_INSTALL_DRY_RUN="1" to print the install plan +
# banner without touching the system.
param(
[string]$Profile = "",
[string[]]$Extras = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# --- prefix resolution ------------------------------------------------------
if ($env:OPENSQUILLA_PREFIX) {
$prefix = $env:OPENSQUILLA_PREFIX
} elseif ($env:LOCALAPPDATA) {
$prefix = Join-Path $env:LOCALAPPDATA 'opensquilla'
} else {
$prefix = Join-Path $HOME '.local'
}
$dryRun = $env:OPENSQUILLA_INSTALL_DRY_RUN -eq '1'
$script:isWindowsHost = if (Get-Variable IsWindows -ErrorAction SilentlyContinue) {
$IsWindows
} else {
$env:OS -eq 'Windows_NT'
}
$profile = if ($Profile) {
$Profile
} elseif ($env:OPENSQUILLA_INSTALL_PROFILE) {
$env:OPENSQUILLA_INSTALL_PROFILE
} else {
'recommended'
}
$validExtras = @(
'matrix',
'matrix-e2e',
'document-extras'
)
function Split-InstallExtras {
param([string[]]$Values)
$items = New-Object System.Collections.Generic.List[string]
foreach ($value in $Values) {
if (-not $value) {
continue
}
foreach ($part in ($value -split '[,\s]+')) {
$item = $part.Trim()
if ($item -and -not $items.Contains($item)) {
$items.Add($item)
}
}
}
return $items.ToArray()
}
$extraInputs = @()
if ($env:OPENSQUILLA_INSTALL_EXTRAS) {
$extraInputs += $env:OPENSQUILLA_INSTALL_EXTRAS
}
$extraInputs += $Extras
$installExtras = @(Split-InstallExtras $extraInputs)
$unknownExtras = @($installExtras | Where-Object { $_ -notin $validExtras })
if ($unknownExtras.Count -gt 0) {
Write-Error "install_source.ps1: unsupported extras: $($unknownExtras -join ', '). Supported extras: $($validExtras -join ', ')."
exit 1
}
switch ($profile) {
'core' { $targetExtras = @() }
'minimal' { $profile = 'core'; $targetExtras = @() }
'recommended' { $targetExtras = @('recommended') }
default {
Write-Error "install_source.ps1: unsupported OPENSQUILLA_INSTALL_PROFILE='$profile'. Supported profiles: core, recommended."
exit 1
}
}
$targetExtras += $installExtras
$installTarget = if ($targetExtras.Count -gt 0) {
".[$($targetExtras -join ',')]"
} else {
'.'
}
function Test-SquillaRouterAssets {
param(
[switch]$WarnOnly
)
if ($profile -ne 'recommended') {
return
}
$modelRoot = 'src/opensquilla/squilla_router/models'
$required = @(
"$modelRoot/v4.2_phase3_inference/lgbm_main.bin",
"$modelRoot/v4.2_phase3_inference/router.runtime.yaml",
"$modelRoot/v4.2_phase3_inference/mlp/model.onnx",
"$modelRoot/v4.2_phase3_inference/features/tfidf.pkl",
"$modelRoot/v4.2_phase3_inference/bge_onnx/model.onnx"
)
$pointerLine = 'version https://git-lfs.github.com/spec/v1'
$missing = New-Object System.Collections.Generic.List[string]
$pointers = New-Object System.Collections.Generic.List[string]
foreach ($path in $required) {
if (-not (Test-Path $path -PathType Leaf)) {
$missing.Add($path)
continue
}
$firstLine = Get-Content -Path $path -TotalCount 1 -ErrorAction SilentlyContinue
if ($firstLine -eq $pointerLine) {
$pointers.Add($path)
}
}
if ($missing.Count -gt 0 -or $pointers.Count -gt 0) {
if ($WarnOnly) {
Write-Host 'install_source.ps1: dry-run note — real recommended install would fail until bundled squilla-router v4 assets are available in this checkout.'
}
else {
Write-Error 'install_source.ps1: bundled squilla-router v4 assets are unavailable in this checkout.'
}
if ($missing.Count -gt 0) {
$message = "install_source.ps1: missing squilla-router assets: $($missing -join ', ')"
if ($WarnOnly) { Write-Host $message } else { Write-Error $message }
}
if ($pointers.Count -gt 0) {
$message = "install_source.ps1: Git LFS pointer files detected: $($pointers -join ', ')"
if ($WarnOnly) { Write-Host $message } else { Write-Error $message }
}
$lfsMessage = 'install_source.ps1: run `git lfs install` once, then `git lfs pull --include="src/opensquilla/squilla_router/models/**"`.'
$coreMessage = 'install_source.ps1: or retry with `$env:OPENSQUILLA_INSTALL_PROFILE="core"` for the minimal runtime.'
if ($WarnOnly) {
Write-Host $lfsMessage
Write-Host $coreMessage
return
}
Write-Error $lfsMessage
Write-Error $coreMessage
exit 1
}
}
function Test-WindowsVCRedistInstalled {
if (-not $script:isWindowsHost) {
return $true
}
$runtimeKeys = @(
'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64'
)
foreach ($key in $runtimeKeys) {
if (-not (Test-Path $key)) {
continue
}
$runtime = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue
if ($runtime -and $runtime.Installed -eq 1 -and $runtime.Major -ge 14) {
return $true
}
}
return $false
}
function Install-WindowsVCRedistIfNeeded {
if (-not $script:isWindowsHost -or $profile -ne 'recommended') {
return
}
if ($env:OPENSQUILLA_SKIP_VC_REDIST -eq '1') {
Write-Host 'install_source.ps1: skipping Microsoft Visual C++ Redistributable check because OPENSQUILLA_SKIP_VC_REDIST=1.'
return
}
if (Test-WindowsVCRedistInstalled) {
Write-Host 'install_source.ps1: Microsoft Visual C++ Redistributable is already installed.'
return
}
$redistUrl = 'https://aka.ms/vs/17/release/vc_redist.x64.exe'
$winget = Get-Command winget -ErrorAction SilentlyContinue
if ($winget) {
Write-Host 'install_source.ps1: Microsoft Visual C++ Redistributable not detected; installing with winget.'
$wingetArgs = @(
'install',
'--id',
'Microsoft.VCRedist.2015+.x64',
'--exact',
'--silent',
'--accept-package-agreements',
'--accept-source-agreements'
)
& winget @wingetArgs
if ($LASTEXITCODE -eq 0) {
Write-Host 'install_source.ps1: Microsoft Visual C++ Redistributable installation completed.'
return
}
Write-Warning "install_source.ps1: winget could not install Microsoft Visual C++ Redistributable (exit $LASTEXITCODE)."
}
Write-Warning 'OpenSquilla: Microsoft Visual C++ Redistributable 2015-2022 x64 is required for the bundled ONNX router.'
Write-Warning 'OpenSquilla can still start with safe router fallback, but bundled ONNX model routing is disabled until this runtime is installed.'
Write-Warning "If automatic installation fails, install it manually: $redistUrl"
Write-Warning 'After installing, reopen PowerShell and restart OpenSquilla.'
}
# --- installer selection ----------------------------------------------------
$installer = $null
$installArgs = @()
# Probe the ambient python version once (used only for the pip fallback gate).
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
$pythonOk = $false
if ($pythonCmd) {
& python -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' 2>$null
$pythonOk = ($LASTEXITCODE -eq 0)
}
if (Get-Command uv -ErrorAction SilentlyContinue) {
$installer = 'uv'
$installArgs = @('tool', 'install', '--python', '3.12', '--force', '--reinstall-package', 'opensquilla', $installTarget)
} elseif ($pythonOk) {
$installer = 'pip'
$installArgs = @('-m', 'pip', 'install', '--user', $installTarget)
} else {
# No uv, and the ambient python is missing or older than 3.12. Do NOT
# silently pip-install onto an unsupported interpreter: a broken
# opensquilla makes coding mode fall back to manual edits. Fail loud.
$pyver = if ($pythonCmd) { (& python -V 2>&1) } else { 'none' }
Write-Error "install_source.ps1: cannot install - uv not found and python ($pyver) is older than 3.12. OpenSquilla requires Python >= 3.12. Install uv (it brings its own 3.12): 'irm https://astral.sh/uv/install.ps1 | iex', then re-run scripts/install_source.ps1."
exit 1
}
$installCmd = if ($installer -eq 'uv') {
"uv $($installArgs -join ' ')"
} else {
"python $($installArgs -join ' ')"
}
# --- banner -----------------------------------------------------------------
function Write-Banner {
@"
----------------------------------------------------------------------------
OpenSquilla installed via $installer -> $prefix (profile: $profile)
Extras: $(if ($installExtras.Count -gt 0) { $installExtras -join ', ' } else { 'none' })
Default gateway bind: 127.0.0.1:18791 (loopback only)
Network exposure is opt-in only. To expose the gateway on the network you
must use one of:
- CLI flag: opensquilla gateway run --listen 0.0.0.0
- Env var: `$env:OPENSQUILLA_LISTEN="0.0.0.0"; opensquilla gateway run
Reminder: only expose 0.0.0.0 behind a trusted reverse proxy or VPN. The
gateway's first-class auth assumes loopback-scope by default.
----------------------------------------------------------------------------
"@ | Write-Host
}
function Write-ListenWarning {
@"
WARNING: you have selected network-exposed default - ensure you
understand the blast radius. The gateway will bind to 0.0.0.0 and be
reachable from every interface on this host.
"@ | Write-Host
}
# --- post-install PATH sanity (parity with install_source.sh) --------------
function Resolve-EntrypointDir {
# Determine where the just-installed `opensquilla`/`gateway` entry points
# landed, so we can warn when that directory is not on PATH. uv tool
# install drops entry points in `uv tool dir --bin`; pip --user puts them
# in the interpreter's Scripts dir. Both live outside the default PATH on
# a clean Windows host - the exact failure mode `opensquilla onboard`
# hits right after a "successful" install. Parity with install_source.sh,
# which does the same absolute-path lookup on POSIX.
if ($installer -eq 'uv') {
$uvBin = $null
try {
$line = (& uv tool dir --bin 2>$null | Select-Object -First 1)
if ($line) { $uvBin = $line.Trim() }
} catch { }
if ($uvBin -and (Test-Path (Join-Path $uvBin 'opensquilla.exe') -PathType Leaf)) {
return $uvBin
}
$fallback = Join-Path $HOME '.local\bin'
if (Test-Path (Join-Path $fallback 'opensquilla.exe') -PathType Leaf) {
return $fallback
}
return $null
} else {
$scriptsDir = $null
try {
$line = (& python -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null | Select-Object -First 1)
if ($line) { $scriptsDir = $line.Trim() }
} catch { }
if ($scriptsDir -and (Test-Path (Join-Path $scriptsDir 'opensquilla.exe') -PathType Leaf)) {
return $scriptsDir
}
return $null
}
}
function Test-DirOnUserPath {
param([string]$Dir)
if (-not $Dir) { return $false }
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { return $false }
$target = $Dir.TrimEnd('\')
foreach ($entry in ($userPath -split ';')) {
if ([string]::IsNullOrWhiteSpace($entry)) { continue }
if ([string]::Equals($entry.TrimEnd('\'), $target, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
function Write-PathHint {
# Verify the just-installed entry point is reachable from a fresh shell.
# install_source.sh runs the same smoke check on POSIX; this brings the
# PowerShell installer to parity so a "successful" install does not leave
# the user with an unresolvable `opensquilla` command (see issue #500).
$entryDir = Resolve-EntrypointDir
if (-not $entryDir) {
Write-Warning 'install_source.ps1: could not locate the installed `opensquilla` entry point to verify PATH.'
Write-Warning "install_source.ps1: if `opensquilla` is not recognized, run 'uv tool update-shell' and open a new terminal."
return
}
if (Test-DirOnUserPath -Dir $entryDir) {
Write-Host "install_source.ps1: entry points are on PATH ($entryDir)."
return
}
Write-Warning "install_source.ps1: entry points are NOT on PATH: $entryDir"
Write-Warning 'install_source.ps1: `opensquilla` will not be found in a new terminal until this is fixed.'
Write-Warning 'install_source.ps1: fix it with one of:'
Write-Warning ' uv tool update-shell # uv official PATH configurator (recommended)'
$oneLiner = '[Environment]::SetEnvironmentVariable(''Path'', [Environment]::GetEnvironmentVariable(''Path'',''User'') + '';{0}'', ''User'')' -f $entryDir
Write-Warning " $oneLiner # or add this dir to user PATH manually"
Write-Warning "install_source.ps1: then open a new terminal and run 'opensquilla onboard'."
}
if ($dryRun) {
Write-Host "install_source.ps1: dry-run — would run: $installCmd"
Write-Host "install_source.ps1: dry-run — prefix: $prefix"
Test-SquillaRouterAssets -WarnOnly
Write-Banner
if ($env:OPENSQUILLA_LISTEN -eq '0.0.0.0') {
Write-ListenWarning
}
exit 0
}
# --- execute ---------------------------------------------------------------
Install-WindowsVCRedistIfNeeded
Test-SquillaRouterAssets
Write-Host "install_source.ps1: installing via $installer into prefix $prefix"
Write-Host "install_source.ps1: running: $installCmd"
if ($installer -eq 'uv') {
& uv @installArgs
} else {
& python @installArgs
}
if ($LASTEXITCODE -ne 0) {
Write-Error "install_source.ps1: install command failed with exit code $LASTEXITCODE."
Write-Error 'install_source.ps1: Close any running OpenSquilla gateway or shell using the existing tool environment, then retry.'
exit $LASTEXITCODE
}
# Write an install receipt to aid `opensquilla uninstall`. Best-effort.
try {
$receiptHome = if ($env:OPENSQUILLA_STATE_DIR) { $env:OPENSQUILLA_STATE_DIR } else { Join-Path $HOME '.opensquilla' }
$receiptMethod = if ($installer -eq 'uv') { 'uv-tool' } else { 'pip' }
New-Item -ItemType Directory -Force -Path $receiptHome | Out-Null
$receipt = [ordered]@{
version = 1
install_method = $receiptMethod
installed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
entrypoints = @()
owned_paths = @()
data_root = $receiptHome
}
$receipt | ConvertTo-Json | Set-Content -Path (Join-Path $receiptHome 'install-receipt.json') -Encoding utf8
} catch {
# Receipt is optional; never fail the install over it.
}
# Smoke-check the just-installed entry point is reachable from a fresh shell.
# Runs after install only (dry-run exits above), matching install_source.sh.
Write-PathHint
Write-Banner
if ($env:OPENSQUILLA_LISTEN -eq '0.0.0.0') {
Write-ListenWarning
}