# Viko installer for Windows (x64). # # & ([scriptblock]::Create((irm https://viko.sh/install.ps1))) -Invite # # What it does, in order: # 1. Downloads the release manifest (https://viko.sh/dl/latest.json) and its signature. # 2. Verifies the manifest's ECDSA P-256 signature against the release key pinned below, using # .NET's built-in ECDsa. No valid signature, no install (fail-closed). # 3. Refuses expired manifests, manifests older than one this PC already accepted, and # downgrades of an installed viko. # 4. Downloads viko.exe (the CLI) and vikow.exe (background service build, no console window), # checks their size and SHA-256 against the signed manifest, and installs them to # %LOCALAPPDATA%\viko\bin (no admin rights). An install from before the vikod -> viko rename # is migrated once (`viko migrate-rename`: the Scheduled Task moves to Viko\viko, vikod.cmd # keeps `vikod` working for one release; the enrollment stays). # 5. Offers to add that folder to your user PATH (asks first; prints the command otherwise). # 6. With -Invite, runs `viko setup --invite ` to pair this PC with your phone. # # Parameters: -Invite CODE, -Hub URL, -NoSetup, -AddToPath, -NoModifyPath # Source: https://viko.sh/install.ps1. Read it before you run it. param( [string]$Invite = '', [string]$Hub = '', [switch]$NoSetup, [switch]$AddToPath, [switch]$NoModifyPath ) Set-StrictMode -Version 3 $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # BEGIN PINNED RELEASE KEYS (generated by `pnpm --filter @viko/site keys:embed` from keys/release-keys.json) $PinnedP256Keys = @( @{ Label = '2026-09 primary'; KeyId = 'BC116675B61FC7D7'; X = 'pX/MYphB8nJGypyLAuLoKh5rIAeOZ5goo3QbLBX/wjI='; Y = 'qIMhbBq4geJuqb5+tcZolfmenj402ew42FoSxcwgwUM=' } ) # END PINNED RELEASE KEYS # Output (docs/ux/cli-output.md): a headline, then 2-space status lines with one mark each. # Unicode marks and colour only in a modern terminal (Windows Terminal, VS Code, ConEmu) whose # output isn't redirected, never with NO_COLOR or TERM=dumb; otherwise plain ASCII. This file # stays ASCII (Windows PowerShell 5.1 reads it as ANSI), so the glyphs are built from code points. $script:Fancy = $false try { $modern = [bool]($env:WT_SESSION -or $env:TERM_PROGRAM -eq 'vscode' -or $env:ConEmuANSI -eq 'ON') $script:Fancy = $modern -and -not [Console]::IsOutputRedirected -and -not $env:NO_COLOR -and $env:TERM -ne 'dumb' } catch { $script:Fancy = $false } if ($script:Fancy) { $script:Marks = @{ Ok = [string][char]0x2713; Will = [string][char]0x2022; Warn = '!'; Fail = [string][char]0x2717 } $script:Dot = [string][char]0x00B7 } else { $script:Marks = @{ Ok = '+'; Will = '*'; Warn = '!'; Fail = 'x' } $script:Dot = '-' } $script:Colors = @{ Ok = 'Green'; Will = 'Cyan'; Warn = 'Yellow'; Fail = 'Red' } function Write-Line([string]$Text = '') { Write-Host $Text } # One indented status line: Ok (done), Will (doing / will do), Warn or Fail; the detail is dim. function Write-Mark([string]$Kind, [string]$Text, [string]$Detail = '') { Write-Host ' ' -NoNewline if ($script:Fancy) { Write-Host $script:Marks[$Kind] -NoNewline -ForegroundColor $script:Colors[$Kind] } else { Write-Host $script:Marks[$Kind] -NoNewline } if (-not $Detail) { Write-Host " $Text"; return } Write-Host " $Text " -NoNewline if ($script:Fancy) { Write-Host "$($script:Dot) $Detail" -ForegroundColor DarkGray } else { Write-Host "$($script:Dot) $Detail" } } # What happened, then why and how to fix it, one line each. Throws (rather than exit) so that a # failed install never closes the PowerShell window it was pasted into. function Stop-Install { param( [Parameter(Mandatory = $true, Position = 0)][string]$What, [Parameter(ValueFromRemainingArguments = $true)][string[]]$Lines ) if ($script:Fancy) { Write-Host "$($script:Marks.Fail) $What" -ForegroundColor Red } else { Write-Host "$($script:Marks.Fail) $What" } foreach ($l in @($Lines)) { if ($l) { Write-Host " $l" } } throw "Viko install stopped: $What" } # The manifest is signed, so a bad field means a broken or tampered release: install nothing. function Stop-BadManifest([string]$Problem) { Stop-Install "The release manifest $Problem" 'Nothing was installed.' ` 'Try again in a minute. If it keeps happening, stop and report it: the download may have been tampered with.' } function Get-Tilde([string]$Path) { if ($env:LOCALAPPDATA -and $Path.StartsWith($env:LOCALAPPDATA, [StringComparison]::OrdinalIgnoreCase)) { return '%LOCALAPPDATA%' + $Path.Substring($env:LOCALAPPDATA.Length) } return $Path } $InstallCommand = '& ([scriptblock]::Create((irm https://viko.sh/install.ps1)))' function Get-BaseUrl { $base = $env:VIKO_INSTALL_BASE_URL if (-not $base) { return 'https://viko.sh' } if ($base -notmatch '^(https://[A-Za-z0-9.-]+|http://(127\.0\.0\.1|localhost))(:[0-9]+)?$') { Stop-Install 'VIKO_INSTALL_BASE_URL must be an https:// origin' 'Fix: Remove-Item Env:VIKO_INSTALL_BASE_URL' } return $base } function Get-Remote([string]$BaseUrl, [string]$UrlPath, [string]$OutFile) { try { Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/$UrlPath" -OutFile $OutFile } catch { Stop-Install "Couldn't download $BaseUrl/$UrlPath" $_.Exception.Message ` 'Check your internet connection, then run the install command again.' } } function Test-SemVer([string]$Value) { return $Value -match '^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$' } function Compare-SemVer([string]$A, [string]$B) { $x = $A.Split('.') | ForEach-Object { [int]$_ } $y = $B.Split('.') | ForEach-Object { [int]$_ } for ($i = 0; $i -lt 3; $i++) { if ($x[$i] -lt $y[$i]) { return -1 } if ($x[$i] -gt $y[$i]) { return 1 } } return 0 } # DER ECDSA signature (SEQUENCE { INTEGER r, INTEGER s }) -> IEEE P1363 r||s (64 bytes), or $null. function ConvertFrom-DerSignature([byte[]]$Der) { if ($Der.Length -lt 8 -or $Der.Length -gt 72 -or $Der[0] -ne 0x30 -or $Der[1] -ne ($Der.Length - 2)) { return $null } $out = New-Object byte[] 64 $pos = 2 for ($part = 0; $part -lt 2; $part++) { if ($pos + 2 -gt $Der.Length -or $Der[$pos] -ne 0x02) { return $null } $len = [int]$Der[$pos + 1] $pos += 2 if ($len -lt 1 -or $len -gt 33 -or $pos + $len -gt $Der.Length) { return $null } $start = $pos $count = $len if ($len -eq 33) { if ($Der[$pos] -ne 0) { return $null } $start++ $count-- } [Array]::Copy($Der, $start, $out, $part * 32 + (32 - $count), $count) $pos += $len } if ($pos -ne $Der.Length) { return $null } return ,$out } function Test-ManifestSignature([byte[]]$Data, [byte[]]$DerSignature) { $rs = ConvertFrom-DerSignature $DerSignature if ($null -eq $rs) { return $false } foreach ($key in $PinnedP256Keys) { $point = New-Object System.Security.Cryptography.ECPoint $point.X = [Convert]::FromBase64String($key.X) $point.Y = [Convert]::FromBase64String($key.Y) $params = New-Object System.Security.Cryptography.ECParameters $params.Curve = [System.Security.Cryptography.ECCurve+NamedCurves]::nistP256 $params.Q = $point $ecdsa = [System.Security.Cryptography.ECDsa]::Create($params) try { if ($ecdsa.VerifyData($Data, $rs, [System.Security.Cryptography.HashAlgorithmName]::SHA256)) { return $true } } finally { $ecdsa.Dispose() } } return $false } function Get-FileSha256([string]$Path) { return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } function Get-ManifestFile($Manifest, [string]$Id, [string]$Version) { $prop = $Manifest.files.PSObject.Properties[$Id] if ($null -eq $prop) { Stop-BadManifest "has no $Id" } $f = $prop.Value if ($f.name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$') { Stop-BadManifest 'names an unexpected file' } if ($f.path -ne "$Version/$($f.name)") { Stop-BadManifest 'has an unexpected file path' } if ($f.sha256 -cnotmatch '^[0-9a-f]{64}$') { Stop-BadManifest 'has an invalid checksum' } if (-not ($f.size -is [int] -or $f.size -is [long]) -or $f.size -le 0) { Stop-BadManifest 'has an invalid size' } return $f } function Install-Viko { if ([Environment]::OSVersion.Platform -ne 'Win32NT') { Stop-Install 'This installer is for Windows' 'Fix: curl -fsSL https://viko.sh/install | sh' } $arch = $env:PROCESSOR_ARCHITECTURE if ($env:PROCESSOR_ARCHITEW6432) { $arch = $env:PROCESSOR_ARCHITEW6432 } if ($arch -ne 'AMD64') { Stop-Install 'Viko needs 64-bit x64 Windows' "$arch isn't supported yet." } if ($Invite -and $Invite -notmatch '^[A-Za-z0-9_-]{4,128}$') { Stop-Install "That invite code doesn't look right" 'Copy the command again from your invite page (https://viko.sh/i/...).' } if ($Hub -and $Hub -notmatch '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$') { Stop-Install '-Hub must be an https:// origin' 'For example: -Hub https://app.viko.sh' } Write-Host 'Install Viko' -NoNewline if ($script:Fancy) { Write-Host " $($script:Dot) Windows x64" -ForegroundColor DarkGray } else { Write-Host " $($script:Dot) Windows x64" } Write-Line [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $base = Get-BaseUrl $installDir = Join-Path $env:LOCALAPPDATA 'viko\bin' $stateFile = Join-Path $installDir '.viko-release' $legacyStateFile = Join-Path $installDir '.vikod-release' $serviceExe = Join-Path $installDir 'vikow.exe' $cliExe = Join-Path $installDir 'viko.exe' $consoleExe = $cliExe $legacyConsoleExe = Join-Path $installDir 'vikod-console.exe' $legacyServiceExe = Join-Path $installDir 'vikod.exe' New-Item -ItemType Directory -Force -Path $installDir | Out-Null $tmp = Join-Path $installDir (".install." + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $tmp | Out-Null try { $manifestPath = Join-Path $tmp 'latest.json' Get-Remote $base 'dl/latest.json' $manifestPath $bytes = [IO.File]::ReadAllBytes($manifestPath) $text = [Text.Encoding]::UTF8.GetString($bytes) # Only used to pick the matching immutable signature file; re-checked after verification. $hint = [regex]::Match($text, '"sequence":\s*([0-9]{1,15})[,\s]') if (-not $hint.Success) { Stop-BadManifest 'is malformed' } $seqHint = $hint.Groups[1].Value $sigPath = Join-Path $tmp 'latest.json.p256.sig' Get-Remote $base "dl/manifests/$seqHint.json.p256.sig" $sigPath if (-not (Test-ManifestSignature $bytes ([IO.File]::ReadAllBytes($sigPath)))) { Stop-Install "The release manifest's signature is not valid" 'Nothing was installed.' ` 'This can happen for a moment while a release is being published, so try again in a minute.' ` 'If it keeps happening, stop and report it: the download may have been tampered with.' } $m = $text | ConvertFrom-Json if ($m.schema -ne 'viko.release/v1') { Stop-Install 'This installer is too old for the current release' "Fix: $InstallCommand" } # The signed manifest's product id stays 'vikod' (release tooling contract); the binary is viko. if ($m.product -ne 'vikod') { Stop-BadManifest 'is not for viko' } $version = [string]$m.version if (-not (Test-SemVer $version)) { Stop-BadManifest 'has an invalid version' } $sequence = [long]$m.sequence if ([string]$sequence -ne $seqHint) { Stop-Install 'The release changed while downloading' 'Nothing was installed. Run the install command again.' } $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() if ($now -ge [long]$m.expiresAt) { Stop-Install "The release manifest has expired, so it can't be trusted" 'Nothing was installed.' ` 'Try again later. If it keeps happening, report it.' } Write-Mark Ok "viko $version is signed by the pinned Viko release key" "release $sequence" $state = if (Test-Path -LiteralPath $stateFile) { $stateFile } else { $legacyStateFile } if (Test-Path -LiteralPath $state) { $seen = (Get-Content -LiteralPath $state | Where-Object { $_ -match '^sequence=([0-9]+)$' } | Select-Object -First 1) -replace '^sequence=', '' if ($seen -and $sequence -lt [long]$seen) { Stop-Install 'Refusing to roll back' "This release ($sequence) is older than one this PC already installed ($seen), so nothing was installed." } } $installed = '' $current = if (Test-Path -LiteralPath $legacyConsoleExe) { $legacyConsoleExe } else { $cliExe } if (Test-Path -LiteralPath $current) { try { $installed = (& $current version 2>$null | Select-Object -First 1) } catch { $installed = '' } if ($installed -and (Test-SemVer $installed) -and (Compare-SemVer $installed $version) -gt 0) { Stop-Install 'Refusing to downgrade' "viko $installed is already installed, which is newer than $version." } } $targets = @( @{ Id = 'viko-windows-x64'; Dest = $serviceExe; Path = $null }, @{ Id = 'viko-windows-x64-console'; Dest = $consoleExe; Path = $null } ) foreach ($t in $targets) { $f = Get-ManifestFile $m $t.Id $version if ((Test-Path -LiteralPath $t.Dest) -and (Get-FileSha256 $t.Dest) -eq $f.sha256) { Write-Mark Ok "$(Split-Path -Leaf $t.Dest) $version is already installed" (Get-Tilde $t.Dest) continue } Write-Mark Will "Downloading $($f.name) $version" $dl = Join-Path $tmp $f.name Get-Remote $base "dl/$($f.path)" $dl if ((Get-Item -LiteralPath $dl).Length -ne [long]$f.size) { Stop-Install "The downloaded $($f.name) has the wrong size" 'Nothing was installed. Run the install command again.' } $sha = Get-FileSha256 $dl if ($sha -ne $f.sha256) { Stop-Install "The downloaded $($f.name) does not match the signed checksum" 'Nothing was installed.' ` 'If it keeps happening, stop and report it: the download may have been tampered with.' } $t.Path = $dl } $consoleNew = ($targets | Where-Object { $_.Dest -eq $consoleExe }).Path if ($consoleNew) { $reported = (& $consoleNew version 2>$null | Select-Object -First 1) if ($reported -ne $version) { Stop-Install "The downloaded viko reports version '$reported', not $version" 'Nothing was installed. Run the install command again.' } } foreach ($t in $targets) { if (-not $t.Path) { continue } if (Test-Path -LiteralPath $t.Dest) { # A running vikow.exe can't be overwritten, but it can be renamed out of the way. $old = "$($t.Dest).old-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" try { Move-Item -LiteralPath $t.Dest -Destination $old -Force } catch { Stop-Install "Couldn't replace $(Get-Tilde $t.Dest)" $_.Exception.Message ` 'Close any running viko windows, then run the install command again.' } } Move-Item -LiteralPath $t.Path -Destination $t.Dest -Force Unblock-File -LiteralPath $t.Dest -ErrorAction SilentlyContinue Write-Mark Ok "Installed $(Get-Tilde $t.Dest)" 'checksum matches the signed release' } # The binary was renamed vikod -> viko: move a pre-rename install once (Scheduled Task, # vikod.cmd compatibility shim; the enrollment stays). if ((Test-Path -LiteralPath $legacyServiceExe) -or (Test-Path -LiteralPath $legacyConsoleExe)) { Write-Mark Will 'Moving vikod to viko' 'Scheduled Task; your enrollment stays' & $cliExe migrate-rename if ($LASTEXITCODE -ne 0) { Stop-Install "Couldn't move vikod to viko" "viko migrate-rename exited with code $LASTEXITCODE." "Fix: & '$cliExe' migrate-rename" } Write-Mark Ok 'Moved vikod to viko' 'your enrollment stays' } if (Test-Path -LiteralPath $legacyStateFile) { Remove-Item -LiteralPath $legacyStateFile -Force } Get-ChildItem -LiteralPath $installDir -Filter '*.old-*' -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } Set-Content -LiteralPath $stateFile -Value @("sequence=$sequence", "version=$version") -Encoding ASCII } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $onPath = $userPath -and (($userPath.Split(';') | ForEach-Object { $_.TrimEnd('\') }) -contains $installDir.TrimEnd('\')) if (-not $onPath) { $answer = 'n' if ($AddToPath) { $answer = 'y' } elseif (-not $NoModifyPath -and [Environment]::UserInteractive -and $Host.Name -eq 'ConsoleHost') { $answer = Read-Host " Add $(Get-Tilde $installDir) to your user PATH? [y/N]" } if ($answer -match '^(y|yes)$') { $newPath = if ($userPath) { "$userPath;$installDir" } else { $installDir } [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') $env:Path = "$env:Path;$installDir" Write-Mark Ok "Added $(Get-Tilde $installDir) to your user PATH" 'open a new terminal to run viko directly' } else { Write-Mark Warn 'To run viko directly, add it to your user PATH:' Write-Line " [Environment]::SetEnvironmentVariable('Path', `"`$([Environment]::GetEnvironmentVariable('Path','User'));$installDir`", 'User')" } } if ($NoSetup) { Write-Line $next = "& '$cliExe' setup" if ($Invite) { $next += " --invite $Invite" } Write-Line "Next: $next" return } if (-not $Invite) { Write-Line Write-Host "Next: & '$cliExe' setup --invite " -NoNewline $why = " $($script:Dot) the code is in your invite link (https://viko.sh/i/...)" if ($script:Fancy) { Write-Host $why -ForegroundColor DarkGray } else { Write-Host $why } return } Write-Line Write-Line 'Pairing this PC with Viko...' $setupArgs = @('setup', '--invite', $Invite) if ($Hub) { $setupArgs += @('--hub', $Hub) } & $cliExe @setupArgs if ($LASTEXITCODE -ne 0) { Stop-Install "Couldn't pair this PC" "viko setup exited with code $LASTEXITCODE." "Fix: & '$cliExe' setup --invite $Invite" } } Install-Viko