param( [string]$LicenseKey = "", [string]$InstallRoot = "", [string]$Channel = "stable", [switch]$DryRun, [switch]$SkipOnboarding ) $ErrorActionPreference = "Stop" $KeygenBase = "https://api.keygen.sh" $KeygenVersion = "1.8" $KeygenAccountId = "6eed6705-4310-4810-9bea-a5963f72607b" $KeygenProductId = "083a75af-4311-4df8-b5c5-96bc6b0d72ca" $ArtifactVerifyKeyHex = "8c36d1a0a1f07cdaca8f3905491a7d95a058080430e38c1187e145e4615f331e" $MinimumDiskFreeGb = 20 if (-not $LicenseKey) { if ($env:FERREN_LICENSE) { $LicenseKey = $env:FERREN_LICENSE } elseif ($env:SAMANTHA_LICENSE) { $LicenseKey = $env:SAMANTHA_LICENSE } } if (-not $InstallRoot) { $InstallRoot = if ($env:FERREN_HOME) { $env:FERREN_HOME } elseif ($env:SAMANTHA_HOME) { $env:SAMANTHA_HOME } else { Join-Path $env:USERPROFILE "ferren" } } function Step { param([string]$Message) Write-Host ""; Write-Host "==> $Message" -ForegroundColor Green } function Ok { param([string]$Message) Write-Host " [OK] $Message" -ForegroundColor Green } function Warn { param([string]$Message) Write-Host " [WARN] $Message" -ForegroundColor Yellow } function Info { param([string]$Message) Write-Host " $Message" -ForegroundColor DarkGray } function Fail { param([string]$Message) throw $Message } function Get-MachineFingerprint { try { $MachineGuid = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Cryptography" -Name MachineGuid -ErrorAction Stop).MachineGuid if ($MachineGuid) { return $MachineGuid } } catch {} $Basis = "$env:COMPUTERNAME-$env:PROCESSOR_IDENTIFIER" $Sha = [System.Security.Cryptography.SHA256]::Create() $Bytes = [System.Text.Encoding]::UTF8.GetBytes($Basis) return (($Sha.ComputeHash($Bytes) | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 32) } function Invoke-KeygenJson { param([string]$Method, [string]$Path, [hashtable]$Headers, [object]$Body = $null) $Uri = "$KeygenBase/v1/accounts/$KeygenAccountId$Path" $AllHeaders = @{ "Accept" = "application/vnd.api+json" "Content-Type" = "application/vnd.api+json" "Keygen-Version" = $KeygenVersion } foreach ($Key in $Headers.Keys) { $AllHeaders[$Key] = $Headers[$Key] } $JsonBody = if ($null -ne $Body) { $Body | ConvertTo-Json -Depth 20 -Compress } else { $null } if ($JsonBody) { return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $AllHeaders -Body $JsonBody } return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $AllHeaders } function Assert-Prereqs { Step "Preflight checks" if ($env:OS -and $env:OS -ne "Windows_NT") { Fail "Windows required. Detected OS=$($env:OS)" } Ok "Windows host" foreach ($Command in @("python", "node", "npm", "tar")) { if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { Fail "$Command not found. Install Python 3.10+, Node.js 22 LTS, and Git for Windows or Windows bsdtar before bootstrap." } Ok "$Command present" } $NodeVersion = (& node --version) if ($NodeVersion -notmatch "v(\d+)\." -or [int]$Matches[1] -lt 22) { Fail "Node.js 22+ required. Found $NodeVersion" } Ok "Node $NodeVersion" $Drive = Get-PSDrive -Name ([System.IO.Path]::GetPathRoot($InstallRoot).Substring(0, 1)) $FreeGb = [math]::Floor($Drive.Free / 1GB) if ($FreeGb -lt $MinimumDiskFreeGb) { Fail "Need $MinimumDiskFreeGb GB free. Available: ${FreeGb}GB" } Ok "Disk: ${FreeGb}GB free" } function Verify-ArtifactSignature { param([string]$Tarball, [string]$SignatureHex) if (-not $SignatureHex) { Fail "Release has no signature. Refusing to install unverified tarball." } $VerifyScript = Join-Path ([System.IO.Path]::GetTempPath()) ("ferren-verify-" + [guid]::NewGuid() + ".py") @" import sys from pathlib import Path try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature except ImportError: sys.exit(2) pk = Ed25519PublicKey.from_public_bytes(bytes.fromhex("$ArtifactVerifyKeyHex")) sig = bytes.fromhex("$SignatureHex") data = Path(r"$Tarball").read_bytes() try: pk.verify(sig, data) sys.exit(0) except InvalidSignature: sys.exit(1) "@ | Set-Content -Path $VerifyScript -Encoding UTF8 & python $VerifyScript $Code = $LASTEXITCODE if ($Code -eq 2) { $TempVenv = Join-Path ([System.IO.Path]::GetTempPath()) ("ferren-crypto-" + [guid]::NewGuid()) & python -m venv $TempVenv if ($LASTEXITCODE -ne 0) { Remove-Item $VerifyScript -Force; Fail "Python venv support is required for signature verification." } $TempPython = Join-Path $TempVenv "Scripts\python.exe" & $TempPython -m pip install --quiet --upgrade pip cryptography if ($LASTEXITCODE -ne 0) { Remove-Item $VerifyScript -Force; Remove-Item -Recurse -Force $TempVenv; Fail "Could not install cryptography for signature verification." } & $TempPython $VerifyScript $Code = $LASTEXITCODE Remove-Item -Recurse -Force $TempVenv } Remove-Item $VerifyScript -Force if ($Code -ne 0) { Fail "Ed25519 signature verification failed. Refusing to install unverified tarball." } Ok "Ed25519 signature verified" } if (-not $LicenseKey) { Write-Host "License key required. Example:" Write-Host ' powershell -ExecutionPolicy Bypass -File .\ferren.ps1 -LicenseKey ' exit 64 } Write-Host "" Write-Host "Ferren Windows Bootstrap" if ($DryRun) { Write-Host "DRY RUN - no changes will be made" -ForegroundColor Yellow } Write-Host "Install root: $InstallRoot" if ($DryRun) { Step "Dry-run preview" Info "Would validate KeyGen license, select platform=windows artifact, verify Ed25519, extract to $InstallRoot, persist license, run installer, and launch onboarding." exit 0 } Step "Validating license" $Fingerprint = Get-MachineFingerprint Info "machine fingerprint: $Fingerprint" $Validation = Invoke-KeygenJson -Method "POST" -Path "/licenses/actions/validate-key" -Headers @{} -Body @{ meta = @{ key = $LicenseKey scope = @{ fingerprint = $Fingerprint } } } $Valid = [bool]$Validation.meta.valid $Code = [string]$Validation.meta.code if ($Valid) { Ok "License valid (code=$Code)" } elseif ($Code -eq "NO_MACHINE" -or $Code -eq "NO_MACHINES") { Ok "License found; machine activation will complete during install" } else { Fail "License invalid (code=$Code)" } Assert-Prereqs Step "Installing OpenClaw" & npm list -g openclaw *> $null if ($LASTEXITCODE -ne 0) { & npm install -g openclaw if ($LASTEXITCODE -ne 0) { Fail "openclaw install failed" } Ok "openclaw installed" } else { Ok "openclaw already present" } Step "Resolving Ferren Windows release" $Releases = Invoke-KeygenJson -Method "GET" -Path "/releases?channel=$Channel&product=$KeygenProductId&status=PUBLISHED&limit=1" -Headers @{ Authorization = "License $LicenseKey" } if (-not $Releases.data -or $Releases.data.Count -lt 1) { Fail "No published Ferren release on channel '$Channel'." } $Release = $Releases.data[0] $ReleaseId = $Release.id $ReleaseVersion = $Release.attributes.version Ok "release v$ReleaseVersion" $Artifacts = Invoke-KeygenJson -Method "GET" -Path "/releases/$ReleaseId/artifacts" -Headers @{ Authorization = "License $LicenseKey" } $Uploaded = @($Artifacts.data | Where-Object { $_.attributes.status -eq "UPLOADED" }) $WindowsArtifacts = @($Uploaded | Where-Object { $_.attributes.platform -eq "windows" }) $Pick = if ($WindowsArtifacts.Count -gt 0) { $WindowsArtifacts[0] } else { $null } if (-not $Pick) { Fail "Release v$ReleaseVersion has no UPLOADED platform=windows artifact." } $ArtifactId = $Pick.id $ArtifactSignature = $Pick.attributes.signature Ok "artifact $ArtifactId" Step "Downloading Ferren artifact" $Tarball = Join-Path ([System.IO.Path]::GetTempPath()) ("ferren-windows-" + [guid]::NewGuid() + ".tar.gz") $Headers = @{ "Authorization" = "License $LicenseKey" "Keygen-Version" = $KeygenVersion } $ArtifactUrl = "$KeygenBase/v1/accounts/$KeygenAccountId/artifacts/$ArtifactId" try { Invoke-WebRequest -Uri $ArtifactUrl -Headers $Headers -OutFile $Tarball -MaximumRedirection 0 -UseBasicParsing -ErrorAction Stop } catch { $Resp = $_.Exception.Response $StatusCode = if ($Resp) { [int]$Resp.StatusCode } else { 0 } $Location = if ($Resp) { $Resp.Headers["Location"] } else { $null } if ($StatusCode -notin @(301, 302, 303, 307, 308) -or [string]::IsNullOrWhiteSpace($Location)) { throw } Invoke-WebRequest -Uri $Location -OutFile $Tarball -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop } Ok "downloaded $([math]::Round((Get-Item $Tarball).Length / 1MB, 1)) MB" Step "Verifying signature" Verify-ArtifactSignature -Tarball $Tarball -SignatureHex $ArtifactSignature Step "Extracting to $InstallRoot" if ((Test-Path $InstallRoot) -and (Get-ChildItem $InstallRoot -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) { if ($env:SAMANTHA_FORCE_INSTALL -ne "1" -and $env:FERREN_FORCE_INSTALL -ne "1") { Fail "$InstallRoot is not empty. Set FERREN_FORCE_INSTALL=1 to overwrite." } } New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null & tar -xzf $Tarball -C $InstallRoot if ($LASTEXITCODE -ne 0) { Fail "Tar extraction failed" } Remove-Item $Tarball -Force Ok "extracted" New-Item -ItemType Directory -Force -Path (Join-Path $InstallRoot "config") | Out-Null $LicenseKey | Set-Content -Path (Join-Path $InstallRoot "config\keygen.license") -Encoding ASCII Ok "license persisted" Step "Running Windows installer" $env:FERREN_LICENSE = $LicenseKey $env:SAMANTHA_LICENSE = $LicenseKey & (Join-Path $InstallRoot "scripts\install_samantha.ps1") -InstallRoot $InstallRoot if ($LASTEXITCODE -ne 0) { Fail "install_samantha.ps1 failed" } if (-not $SkipOnboarding) { Step "Onboarding wizard" & (Join-Path $InstallRoot "scripts\ferren.ps1") onboard start if ($LASTEXITCODE -ne 0) { Fail "onboard wizard exited with error. Resume with scripts\ferren.ps1 onboard resume" } } Write-Host "" Write-Host "Ferren is installed on Windows." Write-Host "Mission Control: http://localhost:3000" Write-Host "Update: powershell -ExecutionPolicy Bypass -File `"$InstallRoot\scripts\update_samantha.ps1`"" Write-Host "Health: powershell -ExecutionPolicy Bypass -File `"$InstallRoot\scripts\doctor.ps1`""