[CmdletBinding()] param( [string]$Release = $env:CODEX_RELEASE ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" if ([string]::IsNullOrWhiteSpace($Release)) { $Release = "latest" } $NonInteractive = $env:CODEX_NON_INTERACTIVE -match "^(?i:1|true|yes)$" $DefaultPreferReleasesOpenAICom = $true $PreferReleasesOpenAICom = if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALLER_USE_RELEASES_OPENAI_COM)) { $DefaultPreferReleasesOpenAICom } else { $env:CODEX_INSTALLER_USE_RELEASES_OPENAI_COM -match "^(?i:1|true|yes)$" } $ReleasesBaseUri = "https://releases.openai.com/codex" $ReleasesMetadataTimeoutSec = 30 $ReleasesAssetTimeoutSec = 300 function Write-Step { param( [string]$Message ) Write-Host "==> $Message" } function Write-WarningStep { param( [string]$Message ) Write-Warning $Message } function Prompt-YesNo { param( [string]$Prompt ) if ($NonInteractive) { return $false } if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { return $false } $choice = Read-Host "$Prompt [y/N]" return $choice -match "^(?i:y(?:es)?)$" } function Normalize-Version { param( [string]$RawVersion ) if ([string]::IsNullOrWhiteSpace($RawVersion) -or $RawVersion -eq "latest") { return "latest" } if ($RawVersion.StartsWith("rust-v")) { return $RawVersion.Substring(6) } if ($RawVersion.StartsWith("v")) { return $RawVersion.Substring(1) } return $RawVersion } function Assert-ValidReleaseVersion { param( [string]$Version ) if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-alpha(?:\.[0-9]+){0,2}|-beta(?:\.[0-9]+)?)?$") { throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N[.M]]|-beta[.N]]." } } function Find-ReleaseAssetMetadata { param( [string]$AssetName, [object]$ReleaseMetadata, [string]$Url = $null, [string]$FallbackUrl = $null ) $asset = $ReleaseMetadata.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1 if ($null -eq $asset) { return $null } $digestMatch = [regex]::Match([string]$asset.digest, "^sha256:([0-9a-fA-F]{64})$") if (-not $digestMatch.Success) { throw "Could not find SHA-256 digest for release asset $AssetName." } return [PSCustomObject]@{ Url = if ([string]::IsNullOrWhiteSpace($Url)) { $asset.browser_download_url } else { $Url } FallbackUrl = $FallbackUrl Sha256 = $digestMatch.Groups[1].Value.ToLowerInvariant() } } function Invoke-WebRequestWithFallback { param( [object]$Metadata, [string]$OutFile, [string]$ExpectedDigest, [string]$AssetName, [string]$ReleaseVersion, [string]$RequiredManifestAsset ) try { if ($Metadata.Url.StartsWith("$ReleasesBaseUri/", [System.StringComparison]::OrdinalIgnoreCase)) { Invoke-WebRequest -UseBasicParsing -Uri $Metadata.Url -OutFile $OutFile -TimeoutSec $ReleasesAssetTimeoutSec } else { Invoke-WebRequest -UseBasicParsing -Uri $Metadata.Url -OutFile $OutFile } Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $ExpectedDigest if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) { $null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset } } catch { if ([string]::IsNullOrWhiteSpace($Metadata.FallbackUrl)) { throw } Write-WarningStep "Could not download or verify $($Metadata.Url); retrying from GitHub Releases." Invoke-WebRequest -UseBasicParsing -Uri $Metadata.FallbackUrl -OutFile $OutFile try { Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $ExpectedDigest if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) { $null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset } } catch { $githubRelease = Resolve-ReleaseFromGitHub -NormalizedVersion $ReleaseVersion $githubAssetMetadata = Find-ReleaseAssetMetadata -AssetName $AssetName -ReleaseMetadata $githubRelease.Metadata if ($null -eq $githubAssetMetadata) { throw "Could not find GitHub release metadata for asset $AssetName." } Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $githubAssetMetadata.Sha256 if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) { $null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset } } } } function Resolve-ReleaseAssetSelection { param( [object]$ResolvedRelease, [string]$Target, [string]$NpmTag ) $version = $ResolvedRelease.Version $releaseMetadata = $ResolvedRelease.Metadata $packageAsset = "codex-package-$Target.tar.gz" $checksumAsset = "codex-package_SHA256SUMS" $packageUrl = $null $packageFallbackUrl = $null $checksumUrl = $null $checksumFallbackUrl = $null if ($ResolvedRelease.Source -eq "ReleasesOpenAICom") { $packageUrl = "$ReleasesBaseUri/releases/$version/$packageAsset" $packageFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$packageAsset" $checksumUrl = "$ReleasesBaseUri/releases/$version/$checksumAsset" $checksumFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$checksumAsset" } $packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata -Url $packageUrl -FallbackUrl $packageFallbackUrl $checksumMetadata = Find-ReleaseAssetMetadata -AssetName $checksumAsset -ReleaseMetadata $releaseMetadata -Url $checksumUrl -FallbackUrl $checksumFallbackUrl if ($null -ne $packageMetadata -and $null -ne $checksumMetadata) { return [PSCustomObject]@{ PackageAsset = $packageAsset PackageMetadata = $packageMetadata ChecksumMetadata = $checksumMetadata InstallLayout = "Package" } } $packageAsset = "codex-npm-$NpmTag-$version.tgz" $packageUrl = $null $packageFallbackUrl = $null if ($ResolvedRelease.Source -eq "ReleasesOpenAICom") { $packageUrl = "$ReleasesBaseUri/releases/$version/$packageAsset" $packageFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$packageAsset" } $packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata -Url $packageUrl -FallbackUrl $packageFallbackUrl if ($null -eq $packageMetadata) { throw "Could not find Codex package or platform npm release assets for Codex $version." } return [PSCustomObject]@{ PackageAsset = $packageAsset PackageMetadata = $packageMetadata ChecksumMetadata = $null InstallLayout = "LegacyPlatformNpm" } } function Test-ArchiveDigest { param( [string]$ArchivePath, [string]$ExpectedDigest ) $actualDigest = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualDigest -ne $ExpectedDigest) { throw "Downloaded Codex archive checksum did not match expected digest. Expected $ExpectedDigest but got $actualDigest." } } function Get-PackageArchiveDigest { param( [string]$ManifestPath, [string]$AssetName ) $escapedAssetName = [regex]::Escape($AssetName) foreach ($line in Get-Content -LiteralPath $ManifestPath) { $match = [regex]::Match($line, "^\s*([0-9a-fA-F]{64})\s+$escapedAssetName\s*$") if ($match.Success) { return $match.Groups[1].Value.ToLowerInvariant() } } throw "Could not find SHA-256 digest for $AssetName in codex-package_SHA256SUMS." } function Path-Contains { param( [string]$PathValue, [string]$Entry ) if ([string]::IsNullOrWhiteSpace($PathValue)) { return $false } $needle = $Entry.TrimEnd("\") foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) { if ($segment.TrimEnd("\") -ieq $needle) { return $true } } return $false } function Prepend-PathEntry { param( [string]$PathValue, [string]$Entry ) $needle = $Entry.TrimEnd("\") $segments = @($Entry) if (-not [string]::IsNullOrWhiteSpace($PathValue)) { $segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) | Where-Object { $_.TrimEnd("\") -ine $needle } } return ($segments -join ";") } function Invoke-WithInstallLock { param( [string]$LockPath, [scriptblock]$Script ) New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LockPath) | Out-Null $lock = $null while ($null -eq $lock) { try { $lock = [System.IO.File]::Open( $LockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None ) } catch [System.IO.IOException] { Start-Sleep -Milliseconds 250 } } try { & $Script } finally { $lock.Dispose() } } function Remove-StaleInstallArtifacts { param( [string]$ReleasesDir ) if (Test-Path -LiteralPath $ReleasesDir -PathType Container) { Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -Filter ".staging.*" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } } function Resolve-VersionFromReleaseMetadata { param( [object]$ReleaseMetadata ) if (-not $ReleaseMetadata.tag_name) { throw "Failed to resolve the latest Codex release version." } $resolvedVersion = Normalize-Version -RawVersion $ReleaseMetadata.tag_name Assert-ValidReleaseVersion -Version $resolvedVersion return $resolvedVersion } function Resolve-ReleaseFromGitHub { param( [string]$NormalizedVersion ) if ($NormalizedVersion -eq "latest") { $requestedRelease = "latest" $metadataUri = "https://api.github.com/repos/openai/codex/releases/latest" } else { $resolvedVersion = $NormalizedVersion $requestedRelease = $resolvedVersion $metadataUri = "https://api.github.com/repos/openai/codex/releases/tags/rust-v$resolvedVersion" } try { $releaseMetadata = Invoke-RestMethod -Uri $metadataUri } catch { throw "Could not fetch GitHub release metadata for Codex $requestedRelease. GitHub API may be unavailable or rate limited. $($_.Exception.Message)" } if ($NormalizedVersion -eq "latest") { $resolvedVersion = Resolve-VersionFromReleaseMetadata -ReleaseMetadata $releaseMetadata } return [PSCustomObject]@{ Version = $resolvedVersion Metadata = $releaseMetadata Source = "GitHub" } } function Resolve-ReleaseFromReleases { param( [string]$NormalizedVersion ) $metadataUri = if ($NormalizedVersion -eq "latest") { "$ReleasesBaseUri/channels/latest" } else { "$ReleasesBaseUri/releases/$NormalizedVersion/release.json" } try { $metadataResponse = Invoke-WebRequest -UseBasicParsing -Uri $metadataUri -TimeoutSec $ReleasesMetadataTimeoutSec $releaseMetadata = [string]$metadataResponse.Content | ConvertFrom-Json -ErrorAction Stop $resolvedVersion = Resolve-VersionFromReleaseMetadata -ReleaseMetadata $releaseMetadata if ($NormalizedVersion -ne "latest" -and $resolvedVersion -cne $NormalizedVersion) { throw "Release metadata version did not match requested Codex version $NormalizedVersion." } $resolvedRelease = [PSCustomObject]@{ Version = $resolvedVersion Metadata = $releaseMetadata Source = "ReleasesOpenAICom" } $null = Resolve-ReleaseAssetSelection -ResolvedRelease $resolvedRelease -Target $target -NpmTag $npmTag } catch { return $null } return $resolvedRelease } function Resolve-Release { $normalizedVersion = Normalize-Version -RawVersion $Release Assert-ValidReleaseVersion -Version $normalizedVersion if ($PreferReleasesOpenAICom) { $release = Resolve-ReleaseFromReleases -NormalizedVersion $normalizedVersion if ($null -ne $release) { return $release } Write-WarningStep "releases.openai.com is unavailable; falling back to GitHub Releases." } return Resolve-ReleaseFromGitHub -NormalizedVersion $normalizedVersion } function Get-VersionFromBinary { param( [string]$CodexPath ) if (-not (Test-Path -LiteralPath $CodexPath -PathType Leaf)) { return $null } try { $versionOutput = & $CodexPath --version 2>$null } catch { return $null } if ($versionOutput -match '([0-9][0-9A-Za-z.+-]*)$') { return $matches[1] } return $null } function Get-CurrentInstalledVersion { param( [string]$StandaloneCurrentDir ) $standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "bin\codex.exe") if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) { return $standaloneVersion } $standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "codex.exe") if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) { return $standaloneVersion } return $null } function Test-OldStandaloneBinLayout { param( [string]$VisibleBinDir, [string]$DefaultVisibleBinDir ) if (-not $VisibleBinDir.Equals($DefaultVisibleBinDir, [System.StringComparison]::OrdinalIgnoreCase)) { return $false } if (-not (Test-Path -LiteralPath $VisibleBinDir -PathType Container)) { return $false } $item = Get-Item -LiteralPath $VisibleBinDir -Force if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $false } $requiredFiles = @("codex.exe", "rg.exe") foreach ($fileName in $requiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $VisibleBinDir $fileName) -PathType Leaf)) { return $false } } $knownFiles = @( "codex.exe", "rg.exe", "codex-command-runner.exe", "codex-windows-sandbox.exe", "codex-windows-sandbox-setup.exe" ) foreach ($child in Get-ChildItem -LiteralPath $VisibleBinDir -Force) { if ($child.PSIsContainer) { return $false } if ($knownFiles -notcontains $child.Name) { return $false } } return $true } function Move-OldStandaloneBinIfApproved { param( [string]$VisibleBinDir, [string]$DefaultVisibleBinDir ) if (-not (Test-OldStandaloneBinLayout -VisibleBinDir $VisibleBinDir -DefaultVisibleBinDir $DefaultVisibleBinDir)) { return $null } Write-Step "We found an older Codex install at $VisibleBinDir" Write-WarningStep "To continue, Codex needs to update the install at this path." if (-not (Prompt-YesNo "Replace it with the current Codex setup now?")) { throw "Cannot replace older standalone install without confirmation: $VisibleBinDir" } $backupDir = "$VisibleBinDir.backup.$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()).$PID" Write-Step "Moving older standalone install to $backupDir" Move-Item -LiteralPath $VisibleBinDir -Destination $backupDir return $backupDir } function Add-JunctionSupportType { if (([System.Management.Automation.PSTypeName]'CodexInstaller.Junction').Type) { return } Add-Type -TypeDefinition @" using System; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; using System.Text; using Microsoft.Win32.SafeHandles; namespace CodexInstaller { public static class Junction { private const uint GENERIC_WRITE = 0x40000000; private const uint FILE_SHARE_READ = 0x00000001; private const uint FILE_SHARE_WRITE = 0x00000002; private const uint FILE_SHARE_DELETE = 0x00000004; private const uint OPEN_EXISTING = 3; private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4; private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003; private const int HeaderLength = 20; [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern SafeFileHandle CreateFileW( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool DeviceIoControl( SafeFileHandle hDevice, uint dwIoControlCode, byte[] lpInBuffer, int nInBufferSize, IntPtr lpOutBuffer, int nOutBufferSize, out int lpBytesReturned, IntPtr lpOverlapped); public static void SetTarget(string linkPath, string targetPath) { string substituteName = "\\??\\" + Path.GetFullPath(targetPath); byte[] substituteNameBytes = Encoding.Unicode.GetBytes(substituteName); if (substituteNameBytes.Length > ushort.MaxValue - HeaderLength) { throw new ArgumentException("Junction target path is too long.", "targetPath"); } byte[] reparseBuffer = new byte[substituteNameBytes.Length + HeaderLength]; WriteUInt32(reparseBuffer, 0, IO_REPARSE_TAG_MOUNT_POINT); WriteUInt16(reparseBuffer, 4, checked((ushort)(substituteNameBytes.Length + 12))); WriteUInt16(reparseBuffer, 8, 0); WriteUInt16(reparseBuffer, 10, checked((ushort)substituteNameBytes.Length)); WriteUInt16(reparseBuffer, 12, checked((ushort)(substituteNameBytes.Length + 2))); WriteUInt16(reparseBuffer, 14, 0); Buffer.BlockCopy(substituteNameBytes, 0, reparseBuffer, 16, substituteNameBytes.Length); using (SafeFileHandle handle = CreateFileW( linkPath, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero)) { if (handle.IsInvalid) { throw new Win32Exception(Marshal.GetLastWin32Error()); } int bytesReturned; if (!DeviceIoControl( handle, FSCTL_SET_REPARSE_POINT, reparseBuffer, reparseBuffer.Length, IntPtr.Zero, 0, out bytesReturned, IntPtr.Zero)) { throw new Win32Exception(Marshal.GetLastWin32Error()); } } } private static void WriteUInt16(byte[] buffer, int offset, ushort value) { buffer[offset] = (byte)value; buffer[offset + 1] = (byte)(value >> 8); } private static void WriteUInt32(byte[] buffer, int offset, uint value) { buffer[offset] = (byte)value; buffer[offset + 1] = (byte)(value >> 8); buffer[offset + 2] = (byte)(value >> 16); buffer[offset + 3] = (byte)(value >> 24); } } } "@ } function Set-JunctionTarget { param( [string]$LinkPath, [string]$TargetPath ) Add-JunctionSupportType [CodexInstaller.Junction]::SetTarget($LinkPath, $TargetPath) } function Test-IsJunction { param( [string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { return $false } $item = Get-Item -LiteralPath $Path -Force return ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $item.LinkType -eq "Junction" } function Ensure-Junction { param( [string]$LinkPath, [string]$TargetPath, [string]$InstallerOwnedTargetPrefix ) if (-not (Test-Path -LiteralPath $LinkPath)) { New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null return } $item = Get-Item -LiteralPath $LinkPath -Force if (Test-IsJunction -Path $LinkPath) { $existingTarget = [string]$item.Target if (-not [string]::IsNullOrWhiteSpace($InstallerOwnedTargetPrefix)) { $ownedTargetPrefix = $InstallerOwnedTargetPrefix.TrimEnd("\\") if (-not $existingTarget.StartsWith($ownedTargetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to retarget junction at $LinkPath because it is not managed by this installer." } } if ($existingTarget.Equals($TargetPath, [System.StringComparison]::OrdinalIgnoreCase)) { return } # Keep the path itself in place and only retarget the junction. That # avoids a gap where current or the visible bin path disappears during # an update. Set-JunctionTarget -LinkPath $LinkPath -TargetPath $TargetPath return } if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Refusing to replace non-junction reparse point at $LinkPath." } if ($item.PSIsContainer) { if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) { throw "Refusing to replace non-empty directory at $LinkPath with a junction." } Remove-Item -LiteralPath $LinkPath -Force New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null return } throw "Refusing to replace file at $LinkPath with a junction." } function Test-PackageContentsAreComplete { param( [string]$PackageDir ) if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) { return $false } $expectedFiles = @( "codex-package.json", "bin\codex.exe", "bin\codex-code-mode-host.exe", "codex-path\rg.exe", "codex-resources\codex-command-runner.exe", "codex-resources\codex-windows-sandbox-setup.exe" ) foreach ($name in $expectedFiles) { if (-not (Test-Path -LiteralPath (Join-Path $PackageDir $name) -PathType Leaf)) { return $false } } return $true } function Test-LegacyPlatformNpmContentsAreComplete { param( [string]$PackageDir ) if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) { return $false } $expectedFiles = @( "codex.exe", "codex-resources\codex-command-runner.exe", "codex-resources\codex-windows-sandbox-setup.exe", "codex-resources\rg.exe" ) foreach ($name in $expectedFiles) { if (-not (Test-Path -LiteralPath (Join-Path $PackageDir $name) -PathType Leaf)) { return $false } } return $true } function Test-ReleaseIsComplete { param( [string]$ReleaseDir, [string]$ExpectedVersion, [string]$ExpectedTarget, [string]$Layout ) switch ($Layout) { "Package" { if (-not (Test-PackageContentsAreComplete -PackageDir $ReleaseDir)) { return $false } $codexPath = Join-Path $ReleaseDir "bin\codex.exe" } "LegacyPlatformNpm" { if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $ReleaseDir)) { return $false } $codexPath = Join-Path $ReleaseDir "codex.exe" } default { throw "Unknown Codex installer layout: $Layout" } } return (Split-Path -Leaf $ReleaseDir) -eq "$ExpectedVersion-$ExpectedTarget" -and (Get-VersionFromBinary -CodexPath $codexPath) -ceq $ExpectedVersion } function Get-ExistingCodexCommand { $existing = Get-Command codex -ErrorAction SilentlyContinue if ($null -eq $existing) { return $null } return $existing.Source } function Get-ExistingCodexManager { param( [string]$ExistingPath, [string]$VisibleBinDir ) if ([string]::IsNullOrWhiteSpace($ExistingPath)) { return $null } if ($ExistingPath.StartsWith($VisibleBinDir, [System.StringComparison]::OrdinalIgnoreCase)) { return $null } if ($ExistingPath -match "\\.bun\\") { return "bun" } if ($ExistingPath -match "node_modules" -or $ExistingPath -match "\\npm\\") { return "npm" } return $null } function Get-ConflictingInstall { param( [string]$VisibleBinDir ) $existingPath = Get-ExistingCodexCommand $manager = Get-ExistingCodexManager -ExistingPath $existingPath -VisibleBinDir $VisibleBinDir if ($null -eq $manager) { return $null } Write-Step "Detected existing $manager-managed Codex at $existingPath" Write-WarningStep "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs." return [PSCustomObject]@{ Manager = $manager Path = $existingPath } } function Maybe-HandleConflictingInstall { param( [object]$Conflict ) if ($null -eq $Conflict) { return } $manager = $Conflict.Manager $uninstallArgs = if ($manager -eq "bun") { @("remove", "-g", "@openai/codex") } else { @("uninstall", "-g", "@openai/codex") } $uninstallCommand = if ($manager -eq "bun") { "bun" } else { "npm" } if (Prompt-YesNo "Uninstall the existing $manager-managed Codex now?") { Write-Step "Running: $uninstallCommand $($uninstallArgs -join ' ')" try { & $uninstallCommand @uninstallArgs } catch { Write-WarningStep "Failed to uninstall the existing $manager-managed Codex. Continuing with the standalone install." } } else { Write-WarningStep "Leaving the existing $manager-managed Codex installed. PATH order will determine which codex runs." } } function Test-VisibleCodexCommand { param( [string]$VisibleBinDir ) $codexCommand = Join-Path $VisibleBinDir "codex.exe" & $codexCommand --version *> $null if ($LASTEXITCODE -ne 0) { throw "Installed Codex command failed verification: $codexCommand --version" } } if ($env:OS -ne "Windows_NT") { Write-Error "install.ps1 supports Windows only. Use install.sh on macOS or Linux." exit 1 } if (-not [Environment]::Is64BitOperatingSystem) { Write-Error "Codex requires a 64-bit version of Windows." exit 1 } $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture $target = $null $platformLabel = $null $npmTag = $null switch ($architecture) { "Arm64" { $target = "aarch64-pc-windows-msvc" $platformLabel = "Windows (ARM64)" $npmTag = "win32-arm64" } "X64" { $target = "x86_64-pc-windows-msvc" $platformLabel = "Windows (x64)" $npmTag = "win32-x64" } default { Write-Error "Unsupported architecture: $architecture" exit 1 } } $codexHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { Join-Path $env:USERPROFILE ".codex" } else { $env:CODEX_HOME } $standaloneRoot = Join-Path $codexHome "packages\standalone" $releasesDir = Join-Path $standaloneRoot "releases" $currentDir = Join-Path $standaloneRoot "current" $lockPath = Join-Path $standaloneRoot "install.lock" $defaultVisibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { $visibleBinDir = $defaultVisibleBinDir } else { $visibleBinDir = $env:CODEX_INSTALL_DIR } $currentVersion = Get-CurrentInstalledVersion -StandaloneCurrentDir $currentDir $resolvedRelease = Resolve-Release $resolvedVersion = $resolvedRelease.Version $releaseMetadata = $resolvedRelease.Metadata $releaseName = "$resolvedVersion-$target" $releaseDir = Join-Path $releasesDir $releaseName if (-not [string]::IsNullOrWhiteSpace($currentVersion) -and $currentVersion -ne $resolvedVersion) { Write-Step "Updating Codex CLI from $currentVersion to $resolvedVersion" } elseif (-not [string]::IsNullOrWhiteSpace($currentVersion)) { Write-Step "Updating Codex CLI" } else { Write-Step "Installing Codex CLI" } Write-Step "Detected platform: $platformLabel" Write-Step "Resolved version: $resolvedVersion" $conflictingInstall = Get-ConflictingInstall -VisibleBinDir $visibleBinDir $oldStandaloneBackup = $null $checksumAsset = "codex-package_SHA256SUMS" $assetSelection = Resolve-ReleaseAssetSelection -ResolvedRelease $resolvedRelease -Target $target -NpmTag $npmTag $packageAsset = $assetSelection.PackageAsset $packageMetadata = $assetSelection.PackageMetadata $checksumMetadata = $assetSelection.ChecksumMetadata $installLayout = $assetSelection.InstallLayout $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { Invoke-WithInstallLock -LockPath $lockPath -Script { Remove-StaleInstallArtifacts -ReleasesDir $releasesDir if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target -Layout $installLayout)) { if (Test-Path -LiteralPath $releaseDir) { Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." } $archivePath = Join-Path $tempDir $packageAsset $checksumPath = Join-Path $tempDir $checksumAsset $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID" Write-Step "Downloading Codex CLI" if ($installLayout -eq "Package") { Invoke-WebRequestWithFallback -Metadata $checksumMetadata -OutFile $checksumPath -ExpectedDigest $checksumMetadata.Sha256 -AssetName $checksumAsset -ReleaseVersion $resolvedVersion -RequiredManifestAsset $packageAsset $expectedPackageDigest = Get-PackageArchiveDigest -ManifestPath $checksumPath -AssetName $packageAsset } else { $expectedPackageDigest = $packageMetadata.Sha256 } Invoke-WebRequestWithFallback -Metadata $packageMetadata -OutFile $archivePath -ExpectedDigest $expectedPackageDigest -AssetName $packageAsset -ReleaseVersion $resolvedVersion New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null if (Test-Path -LiteralPath $stagingDir) { Remove-Item -LiteralPath $stagingDir -Recurse -Force } New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null if ($installLayout -eq "Package") { tar -xzf $archivePath -C $stagingDir if (-not (Test-PackageContentsAreComplete -PackageDir $stagingDir)) { throw "Downloaded Codex package archive did not contain the expected package layout." } } else { $extractDir = Join-Path $tempDir "extract" New-Item -ItemType Directory -Force -Path $extractDir | Out-Null tar -xzf $archivePath -C $extractDir $vendorRoot = Join-Path $extractDir "package/vendor/$target" $resourcesDir = Join-Path $stagingDir "codex-resources" New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null $copyMap = @{ "codex/codex.exe" = "codex.exe" "codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe" "codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe" "path/rg.exe" = "codex-resources\rg.exe" } foreach ($relativeSource in $copyMap.Keys) { Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) } if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $stagingDir)) { throw "Downloaded Codex npm archive did not contain the expected legacy platform package layout." } } if (Test-Path -LiteralPath $releaseDir) { Remove-Item -LiteralPath $releaseDir -Recurse -Force } Move-Item -LiteralPath $stagingDir -Destination $releaseDir } if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target -Layout $installLayout)) { throw "Installed Codex command did not report expected version $resolvedVersion." } New-Item -ItemType Directory -Force -Path $standaloneRoot | Out-Null Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir -InstallerOwnedTargetPrefix $releasesDir $visibleParent = Split-Path -Parent $visibleBinDir $currentBinDir = if ($installLayout -eq "Package") { Join-Path $currentDir "bin" } else { $currentDir } New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null $oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir try { Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentBinDir -InstallerOwnedTargetPrefix $standaloneRoot Test-VisibleCodexCommand -VisibleBinDir $visibleBinDir } catch { if ($null -ne $oldStandaloneBackup -and (Test-Path -LiteralPath $oldStandaloneBackup)) { if (Test-Path -LiteralPath $visibleBinDir) { Remove-Item -LiteralPath $visibleBinDir -Recurse -Force } Move-Item -LiteralPath $oldStandaloneBackup -Destination $visibleBinDir } throw } if ($null -ne $oldStandaloneBackup) { Remove-Item -LiteralPath $oldStandaloneBackup -Recurse -Force } } } finally { Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue } Maybe-HandleConflictingInstall -Conflict $conflictingInstall $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $prioritizeVisibleBin = $null -ne $conflictingInstall if ($prioritizeVisibleBin) { $newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir if ($newUserPath -cne $userPath) { [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") Write-Step "PATH updated for future PowerShell sessions." } else { Write-Step "$visibleBinDir is already first on PATH." } } elseif (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($userPath)) { $newUserPath = $visibleBinDir } else { $newUserPath = "$visibleBinDir;$userPath" } [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") Write-Step "PATH updated for future PowerShell sessions." } elseif (Path-Contains -PathValue $env:Path -Entry $visibleBinDir) { Write-Step "$visibleBinDir is already on PATH." } else { Write-Step "PATH is already configured for future PowerShell sessions." } if ($prioritizeVisibleBin) { $env:Path = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir } elseif (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($env:Path)) { $env:Path = $visibleBinDir } else { $env:Path = "$visibleBinDir;$env:Path" } } Write-Step "Current PowerShell session: codex" Write-Step "Future PowerShell windows: open a new PowerShell window and run: codex" Write-Host "Codex CLI $resolvedVersion installed successfully." $codexCommand = Join-Path $visibleBinDir "codex.exe" if (Prompt-YesNo "Start Codex now?") { Write-Step "Launching Codex" & $codexCommand }