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 Request-FerrenActivationKey { if ([Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { Fail "Ferren activation key is required. Run this installer in an interactive PowerShell window, pass -LicenseKey, or set FERREN_LICENSE for automation." } Write-Host "" Write-Host "Ferren activation is required to continue." $SecureLicense = $null $Bstr = [IntPtr]::Zero try { $SecureLicense = Read-Host -Prompt "Enter Ferren activation key" -AsSecureString if ($null -eq $SecureLicense -or $SecureLicense.Length -eq 0) { Fail "Ferren activation key is required to install." } $Bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureLicense) $PlainLicense = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($Bstr) if ([string]::IsNullOrWhiteSpace($PlainLicense)) { Fail "Ferren activation key is required to install." } return $PlainLicense.Trim() } finally { if ($Bstr -ne [IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Bstr) } if ($null -ne $SecureLicense) { $SecureLicense.Dispose() } } } 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 Get-KeygenArtifactRedirectLocation { param([string]$Uri, [hashtable]$Headers) $Request = [System.Net.HttpWebRequest]::Create($Uri) $Request.Method = "GET" $Request.AllowAutoRedirect = $false $Request.Accept = "application/vnd.api+json" foreach ($Key in $Headers.Keys) { $Request.Headers[$Key] = [string]$Headers[$Key] } $Response = $null try { $Response = $Request.GetResponse() } catch [System.Net.WebException] { $Response = $_.Exception.Response if ($null -eq $Response) { throw } } try { $StatusCode = [int]$Response.StatusCode $Location = [string]$Response.Headers["Location"] if ($StatusCode -in @(301, 302, 303, 307, 308) -and -not [string]::IsNullOrWhiteSpace($Location)) { return ([System.Uri]::new([System.Uri]$Uri, $Location)).AbsoluteUri } Fail "Artifact endpoint returned HTTP $StatusCode without a download redirect. Refusing to treat API response as a tarball." } finally { if ($null -ne $Response) { $Response.Close() } } } function Assert-DownloadedArtifact { param([string]$Tarball, [object]$Artifact) $Attributes = $Artifact.attributes $ExpectedSize = [int64]$Attributes.filesize if ($ExpectedSize -gt 0) { $ActualSize = (Get-Item $Tarball).Length if ($ActualSize -ne $ExpectedSize) { Fail "Artifact size mismatch. Expected $ExpectedSize bytes, downloaded $ActualSize bytes. Refusing to install." } Ok "artifact size verified" } $ExpectedSha512 = [string]$Attributes.checksum if ($ExpectedSha512 -match '^[0-9a-fA-F]{128}$') { $ActualSha512 = (Get-FileHash $Tarball -Algorithm SHA512).Hash.ToLowerInvariant() if ($ActualSha512 -ne $ExpectedSha512.ToLowerInvariant()) { Fail "Artifact SHA-512 checksum mismatch. Refusing to install." } Ok "artifact SHA-512 verified" } } 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) { $LicenseKey = Request-FerrenActivationKey } 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" $DownloadUrl = Get-KeygenArtifactRedirectLocation -Uri $ArtifactUrl -Headers $Headers Invoke-WebRequest -Uri $DownloadUrl -OutFile $Tarball -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop Ok "downloaded $([math]::Round((Get-Item $Tarball).Length / 1MB, 1)) MB" Assert-DownloadedArtifact -Tarball $Tarball -Artifact $Pick 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`""