param( [string] $BaseUrl = "", [string] $InstallDir = "", [string] $Target = "", [switch] $NoPath, [switch] $Help ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $DefaultBaseUrl = "https://ec.bedol.la" $ReleaseManifestKeyId = "enricode-release-manifest-rsa-2026-01" $ReleaseManifestModulus = "t6bam2ZtPYtWWYPxpYEIo6E4vY0KkXluIg4f4P68piwpVV1RjR-U8qeSyQX__Qw_F-jTKPiAcwzpoEMz_1JvetqnjpXUY8xsTFPfLrmNgrCiNdUCpMVsoIPjUm41ENot7hZW1c5rqDJKYRDDTaHLAsUs9nxiivBcC3HjyaN0ZrN5Ru9kW_HoghP1wmMfWch4wjzlXsR25mTmUtQvaz2EbG1HazTt_osmT3PplhPtdWZGaWLpMpOyNrOHP70rQ7da55VTMDnWPCSIOVCZKHK-ppBvbavNF7N6nLxsPIB1cx3D7kqMP7epoBP7wIxL8NeBVa6vmc92QtfFzpJ7feDttRhAg4tZk8e6HsFPDdXwEPh8p5KoNyZVDpKCR-1uK28fOixh1LhvzK_kq6-pgefO-4uINOQjzkJsaBffslkGhxfpVCMDNRdYvamP1QQryugH4zu6Dn7FUKcciYqJcYR52JzFanP_a_l66DlBgBRDWkhp3bZDW-aFuvsR2dpJo63h" $ReleaseManifestExponent = "AQAB" function Write-InstallInfo { param([string] $Message) Write-Host "enricode install: $Message" } function Write-InstallUsage { Write-Host "Installs the verified EnriCode standalone executable for the current Windows user." Write-Host "" Write-Host "Usage:" Write-Host " `$installer = Join-Path `$env:TEMP 'enricode-install.ps1'" Write-Host " Invoke-WebRequest -Uri https://ec.bedol.la/install.ps1 -OutFile `$installer" Write-Host " Unblock-File -Path `$installer" Write-Host " powershell -NoProfile -ExecutionPolicy RemoteSigned -File `$installer" Write-Host "" Write-Host "Options:" Write-Host " -BaseUrl HTTPS site origin. Defaults to $DefaultBaseUrl" Write-Host " -InstallDir Install root. Defaults to \.EnriCode" Write-Host " -Target Standalone target. Defaults to the current Windows architecture." Write-Host " -NoPath Do not persist the EnriCode bin directory in the user PATH." Write-Host " -Help Show this help." } function Resolve-DefaultInstallDir { $homePath = [Environment]::GetFolderPath("UserProfile") if ([string]::IsNullOrWhiteSpace($homePath)) { $homePath = $HOME } return Join-Path $homePath ".EnriCode" } function Resolve-EffectiveBaseUrl { param([string] $ProvidedBaseUrl) $rawBaseUrl = $ProvidedBaseUrl if ([string]::IsNullOrWhiteSpace($rawBaseUrl)) { $rawBaseUrl = [Environment]::GetEnvironmentVariable("ENRICODE_INSTALL_BASE_URL", "Process") } if ([string]::IsNullOrWhiteSpace($rawBaseUrl)) { $rawBaseUrl = $DefaultBaseUrl } $normalized = $rawBaseUrl.Trim().TrimEnd("/") foreach ($legacySuffix in @("/releases/latest", "/releases")) { if ($normalized.EndsWith($legacySuffix, [StringComparison]::OrdinalIgnoreCase)) { $normalized = $normalized.Substring(0, $normalized.Length - $legacySuffix.Length) break } } $uri = [Uri]$normalized if ( -not $uri.IsAbsoluteUri -or $uri.Scheme -ne "https" -or -not [string]::IsNullOrEmpty($uri.UserInfo) -or ($uri.AbsolutePath -ne "/" -and -not [string]::IsNullOrEmpty($uri.AbsolutePath)) -or -not [string]::IsNullOrEmpty($uri.Query) -or -not [string]::IsNullOrEmpty($uri.Fragment) ) { throw "The EnriCode base URL must be a credential-free HTTPS origin." } return $uri.GetLeftPart([System.UriPartial]::Authority) } function Resolve-EffectiveInstallDir { param([string] $ProvidedInstallDir) if (-not [string]::IsNullOrWhiteSpace($ProvidedInstallDir)) { return [IO.Path]::GetFullPath($ProvidedInstallDir.Trim()) } $environmentInstallDir = [Environment]::GetEnvironmentVariable("ENRICODE_INSTALL", "Process") if (-not [string]::IsNullOrWhiteSpace($environmentInstallDir)) { return [IO.Path]::GetFullPath($environmentInstallDir.Trim()) } return [IO.Path]::GetFullPath((Resolve-DefaultInstallDir)) } function Resolve-CurrentTarget { $architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() switch ($architecture) { "x64" { return "windows-x64" } "arm64" { return "windows-arm64" } default { throw "Unsupported Windows architecture for EnriCode standalone install: $architecture" } } } function Resolve-EffectiveTarget { param([string] $ProvidedTarget) if (-not [string]::IsNullOrWhiteSpace($ProvidedTarget)) { return $ProvidedTarget.Trim() } $environmentTarget = [Environment]::GetEnvironmentVariable("ENRICODE_TARGET", "Process") if (-not [string]::IsNullOrWhiteSpace($environmentTarget)) { return $environmentTarget.Trim() } return Resolve-CurrentTarget } function Convert-Base64UrlToBytes { param([string] $Value) $normalized = $Value.Replace("-", "+").Replace("_", "/") switch ($normalized.Length % 4) { 2 { $normalized += "==" } 3 { $normalized += "=" } 1 { throw "Invalid release signing key material." } } return [Convert]::FromBase64String($normalized) } function Assert-ManifestSignature { param( [byte[]] $ManifestBytes, [string] $SignatureText ) if ($SignatureText.Trim() -notmatch "^[A-Za-z0-9+/]+={0,2}$") { throw "The EnriCode release manifest signature is malformed." } $parameters = New-Object System.Security.Cryptography.RSAParameters $parameters.Modulus = Convert-Base64UrlToBytes $ReleaseManifestModulus $parameters.Exponent = Convert-Base64UrlToBytes $ReleaseManifestExponent $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider try { $rsa.ImportParameters($parameters) $hashAlgorithm = [System.Security.Cryptography.CryptoConfig]::MapNameToOID("SHA256") $signatureBytes = [Convert]::FromBase64String($SignatureText.Trim()) if (-not $rsa.VerifyData($ManifestBytes, $hashAlgorithm, $signatureBytes)) { throw "The EnriCode release manifest signature is invalid." } } finally { $rsa.Dispose() } } function Get-VerifiedReleaseManifest { param( [string] $SiteBaseUrl, [string] $TemporaryRoot ) $manifestUrl = "$SiteBaseUrl/releases/manifest.json" $signatureUrl = "$manifestUrl.sig" $manifestPath = Join-Path $TemporaryRoot "manifest.json" $signaturePath = Join-Path $TemporaryRoot "manifest.json.sig" Invoke-WebRequest -Uri $manifestUrl -OutFile $manifestPath -UseBasicParsing -Headers @{ "Cache-Control" = "no-cache" } Invoke-WebRequest -Uri $signatureUrl -OutFile $signaturePath -UseBasicParsing -Headers @{ "Cache-Control" = "no-cache" } $manifestBytes = [IO.File]::ReadAllBytes($manifestPath) $signatureText = [IO.File]::ReadAllText($signaturePath, [Text.Encoding]::ASCII) Assert-ManifestSignature $manifestBytes $signatureText $manifestText = [Text.Encoding]::UTF8.GetString($manifestBytes) $manifest = $manifestText | ConvertFrom-Json if ( $manifest.schemaVersion -ne 2 -or [string]$manifest.name -ne "EnriCode" -or [string]$manifest.baseUrl -ne $SiteBaseUrl -or [string]$manifest.signing.algorithm -ne "RSA-SHA256" -or [string]$manifest.signing.keyId -ne $ReleaseManifestKeyId -or [string]$manifest.signing.signature -ne "manifest.json.sig" ) { throw "The EnriCode release manifest contract is invalid." } return $manifest } function Resolve-VerifiedArtifact { param( [object] $Manifest, [string] $SiteBaseUrl, [string] $TargetName ) if ($TargetName -notmatch "^windows-(x64|arm64)$") { throw "Invalid EnriCode Windows target: $TargetName" } $matches = @($Manifest.artifacts | Where-Object { [string]$_.target -eq $TargetName }) if ($matches.Count -ne 1) { throw "The signed EnriCode release does not contain exactly one artifact for $TargetName." } $artifact = $matches[0] $expectedPath = "releases/standalone/$TargetName/enricode.exe" $expectedUrl = "$SiteBaseUrl/$expectedPath" if ( [string]$artifact.path -ne $expectedPath -or [string]$artifact.url -ne $expectedUrl -or [string]$artifact.sha256 -notmatch "^[0-9a-f]{64}$" -or -not ($artifact.size -is [ValueType]) -or [long]$artifact.size -le 0 ) { throw "The signed EnriCode artifact contract is invalid for $TargetName." } return $artifact } function Split-PathEntries { param([string] $PathValue) if ([string]::IsNullOrWhiteSpace($PathValue)) { return @() } return $PathValue.Split([IO.Path]::PathSeparator) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() } } function Normalize-PathEntry { param([string] $PathEntry) $expandedPath = [Environment]::ExpandEnvironmentVariables($PathEntry.Trim().Trim('"')) try { return [IO.Path]::GetFullPath($expandedPath).TrimEnd("\", "/") } catch { return $expandedPath.TrimEnd("\", "/") } } function Test-PathEntryPresent { param( [string[]] $Entries, [string] $CandidateEntry ) $normalizedCandidate = Normalize-PathEntry $CandidateEntry foreach ($entry in $Entries) { if ((Normalize-PathEntry $entry).Equals($normalizedCandidate, [StringComparison]::OrdinalIgnoreCase)) { return $true } } return $false } function Add-EnriCodeToUserPath { param( [string] $BinDir, [string] $InstallRootDir ) [Environment]::SetEnvironmentVariable("ENRICODE_INSTALL", $InstallRootDir, "User") $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $userPathEntries = @(Split-PathEntries $userPath) if (-not (Test-PathEntryPresent $userPathEntries $BinDir)) { $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) { $BinDir } else { "$($userPath.TrimEnd([IO.Path]::PathSeparator))$([IO.Path]::PathSeparator)$BinDir" } [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") Write-InstallInfo "added $BinDir to the current user's PATH" } if (-not (Test-PathEntryPresent @(Split-PathEntries $env:Path) $BinDir)) { $env:Path = "$BinDir$([IO.Path]::PathSeparator)$env:Path" } } function Install-EnriCodeExecutable { param( [object] $Artifact, [string] $InstallRootDir, [string] $TargetName, [bool] $ShouldUpdatePath, [string] $TemporaryRoot ) $binDir = Join-Path $InstallRootDir "bin" New-Item -ItemType Directory -Force -Path $binDir | Out-Null $temporaryFile = Join-Path $TemporaryRoot "enricode-$TargetName.exe" Write-InstallInfo "downloading $([string]$Artifact.url)" Invoke-WebRequest -Uri ([string]$Artifact.url) -OutFile $temporaryFile -UseBasicParsing $downloadedFile = Get-Item -LiteralPath $temporaryFile if ($downloadedFile.Length -ne [long]$Artifact.size) { throw "Downloaded artifact size mismatch: expected $($Artifact.size), received $($downloadedFile.Length)." } $actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $temporaryFile).Hash.ToLowerInvariant() if ($actualSha256 -ne ([string]$Artifact.sha256).ToLowerInvariant()) { throw "The downloaded EnriCode artifact failed its signed SHA-256 integrity check." } $installedExecutablePath = Join-Path $binDir "enricode.exe" Move-Item -LiteralPath $temporaryFile -Destination $installedExecutablePath -Force Set-Content -LiteralPath (Join-Path $binDir "ec.cmd") -Encoding ASCII -Value "@echo off`r`n`"%~dp0enricode.exe`" %*`r`n" if ($ShouldUpdatePath) { Add-EnriCodeToUserPath $binDir $InstallRootDir } Write-InstallInfo "installed verified EnriCode to $installedExecutablePath" } if ($Help) { Write-InstallUsage exit 0 } $effectiveBaseUrl = Resolve-EffectiveBaseUrl $BaseUrl $effectiveInstallDir = Resolve-EffectiveInstallDir $InstallDir $effectiveTarget = Resolve-EffectiveTarget $Target $temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("enricode-install-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $temporaryRoot | Out-Null try { $manifest = Get-VerifiedReleaseManifest $effectiveBaseUrl $temporaryRoot $artifact = Resolve-VerifiedArtifact $manifest $effectiveBaseUrl $effectiveTarget Install-EnriCodeExecutable $artifact $effectiveInstallDir $effectiveTarget (-not $NoPath) $temporaryRoot Write-InstallInfo "run 'enricode version' in a new terminal to verify the PATH registration" } finally { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue }