mirror of
https://github.com/openai/codex.git
synced 2026-05-28 06:55:01 +00:00
# Summary The Codex standalone installers can pause after installation to ask about an older managed install or launching Codex. That makes unattended bootstrap and update flows hard to complete reliably. This PR adds noninteractive installer control on macOS/Linux and Windows through `CODEX_NON_INTERACTIVE=1`. Noninteractive operation is environment-only, which gives automated callers one stable way to suppress prompts. When a noninteractive install leaves an older npm, bun, or brew-managed Codex installed, the standalone bin is configured ahead of that command on `PATH` so the newly installed Codex is the one future launches select. It also supports `CODEX_RELEASE` for callers that select a release through environment variables while retaining the existing explicit release inputs. Release selection accepts `latest`, stable `x.y.z` versions, and Codex prereleases written as `rust-v0.134.0-alpha.3`, `v0.134.0-alpha.3`, or `0.134.0-alpha.3`; it validates that shape before constructing release requests. # Stack 1. [#21567](https://github.com/openai/codex/pull/21567) - Adds release and noninteractive environment controls to the installers. (current) 2. [#24637](https://github.com/openai/codex/pull/24637) - Runs standalone updater installs with `CODEX_NON_INTERACTIVE=1`. 3. [#24639](https://github.com/openai/codex/pull/24639) - Removes explicit release argument inputs in favor of `CODEX_RELEASE`. # Evidence | Before | After | | --- | --- | |  |  | Environment-controlled macOS install with an existing npm-managed Codex on `PATH`: https://github.com/user-attachments/assets/442e0b5b-4a32-4bf5-996b-68784777380d # Design decisions Windows installs using the older standalone bin layout still require an interactive migration confirmation. Noninteractive mode does not auto-migrate that existing directory because replacing it is a destructive transition for an early, limited-use layout; unattended installs on that layout fail with an instruction to rerun interactively. # Testing Tests: installer syntax validation, release-selector acceptance and rejection coverage including PowerShell `Latest` compatibility, macOS live-terminal installer smoke testing with environment-controlled stable and prerelease installation and competing PATH precedence, shell rejection of the omitted noninteractive flag, and Windows ARM64 PowerShell smoke testing with environment-only noninteractive behavior, retained release input, and competing PATH precedence through Parallels.
925 lines
29 KiB
PowerShell
925 lines
29 KiB
PowerShell
[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)$"
|
|
|
|
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|beta)(?:\.[0-9]+)?)?$") {
|
|
throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]."
|
|
}
|
|
}
|
|
|
|
function Find-ReleaseAssetMetadata {
|
|
param(
|
|
[string]$AssetName,
|
|
[string]$ResolvedVersion
|
|
)
|
|
|
|
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/tags/rust-v$ResolvedVersion"
|
|
$asset = $release.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 = $asset.browser_download_url
|
|
Sha256 = $digestMatch.Groups[1].Value.ToLowerInvariant()
|
|
}
|
|
}
|
|
|
|
function Get-ReleaseAssetMetadata {
|
|
param(
|
|
[string]$AssetName,
|
|
[string]$ResolvedVersion
|
|
)
|
|
|
|
$metadata = Find-ReleaseAssetMetadata -AssetName $AssetName -ResolvedVersion $ResolvedVersion
|
|
if ($null -eq $metadata) {
|
|
throw "Could not find release asset $AssetName for Codex $ResolvedVersion."
|
|
}
|
|
|
|
return $metadata
|
|
}
|
|
|
|
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-Version {
|
|
$normalizedVersion = Normalize-Version -RawVersion $Release
|
|
Assert-ValidReleaseVersion -Version $normalizedVersion
|
|
if ($normalizedVersion -ne "latest") {
|
|
return $normalizedVersion
|
|
}
|
|
|
|
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/latest"
|
|
if (-not $release.tag_name) {
|
|
Write-Error "Failed to resolve the latest Codex release version."
|
|
exit 1
|
|
}
|
|
|
|
$resolvedVersion = Normalize-Version -RawVersion $release.tag_name
|
|
Assert-ValidReleaseVersion -Version $resolvedVersion
|
|
return $resolvedVersion
|
|
}
|
|
|
|
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",
|
|
"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
|
|
}
|
|
}
|
|
"LegacyPlatformNpm" {
|
|
if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $ReleaseDir)) {
|
|
return $false
|
|
}
|
|
}
|
|
default {
|
|
throw "Unknown Codex installer layout: $Layout"
|
|
}
|
|
}
|
|
|
|
return (Split-Path -Leaf $ReleaseDir) -eq "$ExpectedVersion-$ExpectedTarget"
|
|
}
|
|
|
|
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
|
|
$resolvedVersion = Resolve-Version
|
|
$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
|
|
|
|
$packageAsset = "codex-package-$target.tar.gz"
|
|
$checksumAsset = "codex-package_SHA256SUMS"
|
|
$packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion
|
|
$checksumMetadata = Find-ReleaseAssetMetadata -AssetName $checksumAsset -ResolvedVersion $resolvedVersion
|
|
$installLayout = "Package"
|
|
if ($null -eq $packageMetadata -or $null -eq $checksumMetadata) {
|
|
$packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz"
|
|
$packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion
|
|
if ($null -ne $packageMetadata) {
|
|
$installLayout = "LegacyPlatformNpm"
|
|
} else {
|
|
throw "Could not find Codex package or platform npm release assets for Codex $resolvedVersion."
|
|
}
|
|
$checksumMetadata = $null
|
|
}
|
|
$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-WebRequest -Uri $checksumMetadata.Url -OutFile $checksumPath
|
|
Test-ArchiveDigest -ArchivePath $checksumPath -ExpectedDigest $checksumMetadata.Sha256
|
|
$expectedPackageDigest = Get-PackageArchiveDigest -ManifestPath $checksumPath -AssetName $packageAsset
|
|
} else {
|
|
$expectedPackageDigest = $packageMetadata.Sha256
|
|
}
|
|
Invoke-WebRequest -Uri $packageMetadata.Url -OutFile $archivePath
|
|
Test-ArchiveDigest -ArchivePath $archivePath -ExpectedDigest $expectedPackageDigest
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|