diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 68e0ec092..2d39c42df 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2255,6 +2255,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-install-context" +version = "0.0.0" +dependencies = [ + "codex-utils-home-dir", + "pretty_assertions", + "tempfile", +] + [[package]] name = "codex-instructions" version = "0.0.0" @@ -2896,6 +2905,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-git-utils", + "codex-install-context", "codex-login", "codex-mcp", "codex-model-provider-info", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4bb883721..47c0d348b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -13,6 +13,7 @@ members = [ "arg0", "feedback", "features", + "install-context", "codex-backend-openapi-models", "code-mode", "cloud-requirements", @@ -134,6 +135,7 @@ codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } codex-features = { path = "features" } codex-feedback = { path = "feedback" } +codex-install-context = { path = "install-context" } codex-file-search = { path = "file-search" } codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } diff --git a/codex-rs/README.md b/codex-rs/README.md index 6307668f3..2ad7158f9 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -1,6 +1,6 @@ # Codex CLI (Rust Implementation) -We provide Codex CLI as a standalone, native executable to ensure a zero-dependency install. +We provide Codex CLI as a standalone executable to ensure a zero-dependency install. ## Installing Codex diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 4e962cf09..6accb2cb0 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -516,10 +516,19 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { let status = { #[cfg(windows)] { - // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). - std::process::Command::new("cmd") - .args(["/C", &cmd_str]) - .status()? + if action == UpdateAction::StandaloneWindows { + let (cmd, args) = action.command_args(); + // Run the standalone PowerShell installer with PowerShell + // itself. Routing this through `cmd.exe /C` would parse + // PowerShell metacharacters like `|` before PowerShell sees + // the installer command. + std::process::Command::new(cmd).args(args).status()? + } else { + // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). + std::process::Command::new("cmd") + .args(["/C", &cmd_str]) + .status()? + } } #[cfg(not(windows))] { diff --git a/codex-rs/install-context/BUILD.bazel b/codex-rs/install-context/BUILD.bazel new file mode 100644 index 000000000..68254d10c --- /dev/null +++ b/codex-rs/install-context/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "install-context", + crate_name = "codex_install_context", +) diff --git a/codex-rs/install-context/Cargo.toml b/codex-rs/install-context/Cargo.toml new file mode 100644 index 000000000..ce4eeefe7 --- /dev/null +++ b/codex-rs/install-context/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-install-context" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_install_context" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-utils-home-dir = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs new file mode 100644 index 000000000..980fc3f54 --- /dev/null +++ b/codex-rs/install-context/src/lib.rs @@ -0,0 +1,258 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; + +const RELEASES_DIRNAME: &str = "releases"; +const RESOURCES_DIRNAME: &str = "codex-resources"; +const STANDALONE_PACKAGES_DIRNAME: &str = "standalone"; +static INSTALL_CONTEXT: OnceLock = OnceLock::new(); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StandalonePlatform { + Unix, + Windows, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallContext { + Standalone { + /// The managed standalone release directory, for example + /// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`. + release_dir: PathBuf, + /// The bundled resource directory that sits next to the executable when + /// this install ships managed dependencies. + resources_dir: Option, + /// The platform of the standalone release, either `Unix` or `Windows`. + platform: StandalonePlatform, + }, + /// A Codex binary launched through the npm-managed `codex.js` shim. + Npm, + /// A Codex binary launched through the bun-managed `codex.js` shim. + Bun, + /// A Codex binary that appears to come from a Homebrew install prefix. + Brew, + /// Any other execution environment. + /// + /// This commonly covers `cargo run`, app-bundled Codex binaries, custom + /// internal launchers, and tests that execute Codex from an arbitrary path. + Other, +} + +impl InstallContext { + pub fn from_exe( + is_macos: bool, + current_exe: Option<&Path>, + managed_by_npm: bool, + managed_by_bun: bool, + ) -> Self { + let codex_home = codex_utils_home_dir::find_codex_home().ok(); + Self::from_exe_with_codex_home( + is_macos, + current_exe, + managed_by_npm, + managed_by_bun, + codex_home.as_deref(), + ) + } + + fn from_exe_with_codex_home( + is_macos: bool, + current_exe: Option<&Path>, + managed_by_npm: bool, + managed_by_bun: bool, + codex_home: Option<&Path>, + ) -> Self { + if managed_by_npm { + return Self::Npm; + } + + if managed_by_bun { + return Self::Bun; + } + + if let Some(exe_path) = current_exe + && let Some(standalone_context) = standalone_install_context(exe_path, codex_home) + { + return standalone_context; + } + + if is_macos + && let Some(exe_path) = current_exe + && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) + { + return Self::Brew; + } + + Self::Other + } + + pub fn current() -> &'static Self { + INSTALL_CONTEXT.get_or_init(|| { + let current_exe = std::env::current_exe().ok(); + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + Self::from_exe( + cfg!(target_os = "macos"), + current_exe.as_deref(), + managed_by_npm, + managed_by_bun, + ) + }) + } + + pub fn rg_command(&self) -> PathBuf { + match self { + Self::Standalone { + resources_dir: Some(resources_dir), + .. + } => { + let bundled_rg = resources_dir.join(default_rg_command()); + if bundled_rg.exists() { + bundled_rg + } else { + default_rg_command() + } + } + Self::Standalone { + resources_dir: None, + .. + } + | Self::Npm + | Self::Bun + | Self::Brew + | Self::Other => default_rg_command(), + } + } +} + +fn standalone_install_context( + exe_path: &Path, + codex_home: Option<&Path>, +) -> Option { + let canonical_exe = std::fs::canonicalize(exe_path).ok()?; + let canonical_codex_home = std::fs::canonicalize(codex_home?).ok()?; + let release_dir = canonical_exe.parent()?.to_path_buf(); + let releases_root = canonical_codex_home + .join("packages") + .join(STANDALONE_PACKAGES_DIRNAME) + .join(RELEASES_DIRNAME); + if !release_dir.starts_with(releases_root) { + return None; + } + + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + Some(InstallContext::Standalone { + release_dir, + resources_dir: resources_dir.is_dir().then_some(resources_dir), + platform: standalone_platform(), + }) +} + +fn standalone_platform() -> StandalonePlatform { + if cfg!(windows) { + StandalonePlatform::Windows + } else { + StandalonePlatform::Unix + } +} + +fn default_rg_command() -> PathBuf { + if cfg!(windows) { + PathBuf::from("rg.exe") + } else { + PathBuf::from("rg") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + + #[test] + fn detects_standalone_install_from_release_layout() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir)?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(resources_dir.join(default_rg_command()), "")?; + let canonical_release_dir = release_dir.canonicalize()?; + let canonical_resources_dir = resources_dir.canonicalize()?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!( + context, + InstallContext::Standalone { + release_dir: canonical_release_dir, + resources_dir: Some(canonical_resources_dir), + platform: standalone_platform(), + } + ); + Ok(()) + } + + #[test] + fn standalone_rg_falls_back_when_resources_are_missing() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + fs::create_dir_all(&release_dir)?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!(context.rg_command(), default_rg_command()); + Ok(()) + } + + #[test] + fn npm_and_bun_take_precedence() { + let npm_context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*managed_by_npm*/ true, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!(npm_context, InstallContext::Npm); + + let bun_context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*managed_by_npm*/ false, + /*managed_by_bun*/ true, + /*codex_home*/ None, + ); + assert_eq!(bun_context, InstallContext::Bun); + } + + #[test] + fn brew_is_detected_on_macos_prefixes() { + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ true, + /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!(context, InstallContext::Brew); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 0dec8ceed..0f29fdeae 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -29,6 +29,7 @@ codex-ansi-escape = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-install-context = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index baee1662f..1ac2ff675 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,3 +1,8 @@ +#[cfg(any(not(debug_assertions), test))] +use codex_install_context::InstallContext; +#[cfg(any(not(debug_assertions), test))] +use codex_install_context::StandalonePlatform; + /// Update action the CLI should perform after the TUI exits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpdateAction { @@ -7,15 +12,41 @@ pub enum UpdateAction { BunGlobalLatest, /// Update via `brew upgrade codex`. BrewUpgrade, + /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | sh`. + StandaloneUnix, + /// Update via `irm https://chatgpt.com/codex/install.ps1|iex`. + StandaloneWindows, } impl UpdateAction { + #[cfg(any(not(debug_assertions), test))] + pub(crate) fn from_install_context(context: &InstallContext) -> Option { + match context { + InstallContext::Npm => Some(UpdateAction::NpmGlobalLatest), + InstallContext::Bun => Some(UpdateAction::BunGlobalLatest), + InstallContext::Brew => Some(UpdateAction::BrewUpgrade), + InstallContext::Standalone { platform, .. } => Some(match platform { + StandalonePlatform::Unix => UpdateAction::StandaloneUnix, + StandalonePlatform::Windows => UpdateAction::StandaloneWindows, + }), + InstallContext::Other => None, + } + } + /// Returns the list of command-line arguments for invoking the update. pub fn command_args(self) -> (&'static str, &'static [&'static str]) { match self { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), + UpdateAction::StandaloneUnix => ( + "sh", + &["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + ), + UpdateAction::StandaloneWindows => ( + "powershell", + &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"], + ), } } @@ -29,88 +60,68 @@ impl UpdateAction { #[cfg(not(debug_assertions))] pub(crate) fn get_update_action() -> Option { - let exe = std::env::current_exe().unwrap_or_default(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - - detect_update_action( - cfg!(target_os = "macos"), - &exe, - managed_by_npm, - managed_by_bun, - ) -} - -#[cfg(any(not(debug_assertions), test))] -fn detect_update_action( - is_macos: bool, - current_exe: &std::path::Path, - managed_by_npm: bool, - managed_by_bun: bool, -) -> Option { - if managed_by_npm { - Some(UpdateAction::NpmGlobalLatest) - } else if managed_by_bun { - Some(UpdateAction::BunGlobalLatest) - } else if is_macos - && (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local")) - { - Some(UpdateAction::BrewUpgrade) - } else { - None - } + UpdateAction::from_install_context(InstallContext::current()) } #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; + use std::path::PathBuf; #[test] - fn detects_update_action_without_env_mutation() { + fn maps_install_context_to_update_action() { + let native_release_dir = PathBuf::from("/tmp/native-release"); + assert_eq!( - detect_update_action( - /*is_macos*/ false, - std::path::Path::new("/any/path"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false - ), + UpdateAction::from_install_context(&InstallContext::Other), None ); assert_eq!( - detect_update_action( - /*is_macos*/ false, - std::path::Path::new("/any/path"), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false - ), + UpdateAction::from_install_context(&InstallContext::Npm), Some(UpdateAction::NpmGlobalLatest) ); assert_eq!( - detect_update_action( - /*is_macos*/ false, - std::path::Path::new("/any/path"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ true - ), + UpdateAction::from_install_context(&InstallContext::Bun), Some(UpdateAction::BunGlobalLatest) ); assert_eq!( - detect_update_action( - /*is_macos*/ true, - std::path::Path::new("/opt/homebrew/bin/codex"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false - ), + UpdateAction::from_install_context(&InstallContext::Brew), Some(UpdateAction::BrewUpgrade) ); assert_eq!( - detect_update_action( - /*is_macos*/ true, - std::path::Path::new("/usr/local/bin/codex"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false - ), - Some(UpdateAction::BrewUpgrade) + UpdateAction::from_install_context(&InstallContext::Standalone { + platform: StandalonePlatform::Unix, + release_dir: native_release_dir.clone(), + resources_dir: Some(native_release_dir.join("codex-resources")), + }), + Some(UpdateAction::StandaloneUnix) + ); + assert_eq!( + UpdateAction::from_install_context(&InstallContext::Standalone { + platform: StandalonePlatform::Windows, + release_dir: native_release_dir.clone(), + resources_dir: Some(native_release_dir.join("codex-resources")), + }), + Some(UpdateAction::StandaloneWindows) + ); + } + + #[test] + fn standalone_update_commands_rerun_latest_installer() { + assert_eq!( + UpdateAction::StandaloneUnix.command_args(), + ( + "sh", + &["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"][..], + ) + ); + assert_eq!( + UpdateAction::StandaloneWindows.command_args(), + ( + "powershell", + &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"][..], + ) ); } } diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index 375199f5c..ad5cc4e2b 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -13,6 +13,8 @@ use tempfile::NamedTempFile; use crate::logging::log_note; use crate::sandbox_bin_dir; +const RESOURCES_DIRNAME: &str = "codex-resources"; + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) enum HelperExecutable { CommandRunner, @@ -46,12 +48,9 @@ pub(crate) fn helper_bin_dir(codex_home: &Path) -> PathBuf { pub(crate) fn legacy_lookup(kind: HelperExecutable) -> PathBuf { if let Ok(exe) = std::env::current_exe() - && let Some(dir) = exe.parent() + && let Some(candidate) = source_path_for_exe(&exe, kind.file_name()) { - let candidate = dir.join(kind.file_name()); - if candidate.exists() { - return candidate; - } + return candidate; } PathBuf::from(kind.file_name()) } @@ -179,18 +178,23 @@ fn store_helper_path(cache_key: String, path: PathBuf) { fn sibling_source_path(kind: HelperExecutable) -> Result { let exe = std::env::current_exe().context("resolve current executable for helper lookup")?; - let dir = exe - .parent() - .ok_or_else(|| anyhow!("current executable has no parent directory"))?; - let candidate = dir.join(kind.file_name()); - if candidate.exists() { - Ok(candidate) - } else { - Err(anyhow!( - "helper not found next to current executable: {}", - candidate.display() - )) + source_path_for_exe(&exe, kind.file_name()).ok_or_else(|| { + anyhow!( + "helper not found next to current executable or under {RESOURCES_DIRNAME}: {}", + exe.display() + ) + }) +} + +fn source_path_for_exe(exe: &Path, file_name: &str) -> Option { + let dir = exe.parent()?; + let direct_candidate = dir.join(file_name); + if direct_candidate.exists() { + return Some(direct_candidate); } + + let resource_candidate = dir.join(RESOURCES_DIRNAME).join(file_name); + resource_candidate.exists().then_some(resource_candidate) } fn copy_from_source_if_needed(source: &Path, destination: &Path) -> Result { @@ -292,10 +296,12 @@ fn destination_is_fresh(source: &Path, destination: &Path) -> Result { #[cfg(test)] mod tests { - use super::destination_is_fresh; - use super::helper_bin_dir; use super::copy_from_source_if_needed; use super::CopyOutcome; + use super::destination_is_fresh; + use super::helper_bin_dir; + use super::RESOURCES_DIRNAME; + use super::source_path_for_exe; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -376,4 +382,40 @@ mod tests { fs::read(&runner_destination).expect("read runner") ); } + + #[test] + fn helper_source_lookup_checks_resource_dir() { + let tmp = TempDir::new().expect("tempdir"); + let release_dir = tmp.path().join("release"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir).expect("create resources dir"); + let exe = release_dir.join("codex.exe"); + let helper = resources_dir.join("codex-command-runner.exe"); + fs::write(&exe, b"codex").expect("write exe"); + fs::write(&helper, b"runner").expect("write helper"); + + let resolved = + source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path"); + + assert_eq!(resolved, helper); + } + + #[test] + fn helper_source_lookup_prefers_direct_sibling_over_resource_dir() { + let tmp = TempDir::new().expect("tempdir"); + let release_dir = tmp.path().join("release"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir).expect("create resources dir"); + let exe = release_dir.join("codex.exe"); + let sibling_helper = release_dir.join("codex-command-runner.exe"); + let resource_helper = resources_dir.join("codex-command-runner.exe"); + fs::write(&exe, b"codex").expect("write exe"); + fs::write(&sibling_helper, b"sibling runner").expect("write sibling helper"); + fs::write(&resource_helper, b"resource runner").expect("write resource helper"); + + let resolved = + source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path"); + + assert_eq!(resolved, sibling_helper); + } } diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index e0a5a063f..4effc0b70 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -589,6 +589,16 @@ fn find_setup_exe() -> PathBuf { if candidate.exists() { return candidate; } + + // Standalone installs keep Windows helper binaries under + // `codex-resources/` next to `codex.exe`, so elevation needs to probe + // that sibling folder before falling back to PATH. + let resource_candidate = dir + .join("codex-resources") + .join("codex-windows-sandbox-setup.exe"); + if resource_candidate.exists() { + return resource_candidate; + } } PathBuf::from("codex-windows-sandbox-setup.exe") } diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 40328db70..ed4a3e1a3 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -1,6 +1,5 @@ param( - [Parameter(Position=0)] - [string]$Version = "latest" + [string]$Release = "latest" ) Set-StrictMode -Version Latest @@ -15,6 +14,27 @@ function Write-Step { Write-Host "==> $Message" } +function Write-WarningStep { + param( + [string]$Message + ) + + Write-Warning $Message +} + +function Prompt-YesNo { + param( + [string]$Prompt + ) + + 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 @@ -35,13 +55,39 @@ function Normalize-Version { return $RawVersion } -function Get-ReleaseUrl { +function Get-ReleaseAssetMetadata { param( [string]$AssetName, [string]$ResolvedVersion ) - return "https://github.com/openai/codex/releases/download/rust-v$ResolvedVersion/$AssetName" + $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) { + throw "Could not find release asset $AssetName for Codex $ResolvedVersion." + } + + $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 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 release metadata. Expected $ExpectedDigest but got $actualDigest." + } } function Path-Contains { @@ -64,8 +110,46 @@ function Path-Contains { return $false } +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 $Version + $normalizedVersion = Normalize-Version -RawVersion $Release if ($normalizedVersion -ne "latest") { return $normalizedVersion } @@ -79,6 +163,414 @@ function Resolve-Version { return (Normalize-Version -RawVersion $release.tag_name) } +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 "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-ReleaseIsComplete { + param( + [string]$ReleaseDir, + [string]$ExpectedVersion, + [string]$ExpectedTarget + ) + + if (-not (Test-Path -LiteralPath $ReleaseDir -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 $ReleaseDir $name) -PathType Leaf)) { + return $false + } + } + + 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 @@ -110,87 +602,149 @@ switch ($architecture) { } } -if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { - $installDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" +$codexHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + Join-Path $env:USERPROFILE ".codex" } else { - $installDir = $env:CODEX_INSTALL_DIR + $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 } -$codexPath = Join-Path $installDir "codex.exe" -$installMode = if (Test-Path $codexPath) { "Updating" } else { "Installing" } - -Write-Step "$installMode Codex CLI" -Write-Step "Detected platform: $platformLabel" - -New-Item -ItemType Directory -Force -Path $installDir | Out-Null - +$currentVersion = Get-CurrentInstalledVersion -StandaloneCurrentDir $currentDir $resolvedVersion = Resolve-Version -Write-Step "Resolved version: $resolvedVersion" -$packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" +$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-npm-$npmTag-$resolvedVersion.tgz" $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { - $archivePath = Join-Path $tempDir $packageAsset - $extractDir = Join-Path $tempDir "extract" - $url = Get-ReleaseUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion + Invoke-WithInstallLock -LockPath $lockPath -Script { + Remove-StaleInstallArtifacts -ReleasesDir $releasesDir - Write-Step "Downloading Codex CLI" - Invoke-WebRequest -Uri $url -OutFile $archivePath + if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target)) { + if (Test-Path -LiteralPath $releaseDir) { + Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." + } - New-Item -ItemType Directory -Force -Path $extractDir | Out-Null - tar -xzf $archivePath -C $extractDir + $archivePath = Join-Path $tempDir $packageAsset + $extractDir = Join-Path $tempDir "extract" + $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID" + $assetMetadata = Get-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion - $vendorRoot = Join-Path $extractDir "package/vendor/$target" - Write-Step "Installing to $installDir" - $copyMap = @{ - "codex/codex.exe" = "codex.exe" - "codex/codex-command-runner.exe" = "codex-command-runner.exe" - "codex/codex-windows-sandbox-setup.exe" = "codex-windows-sandbox-setup.exe" - "path/rg.exe" = "rg.exe" - } + Write-Step "Downloading Codex CLI" + Invoke-WebRequest -Uri $assetMetadata.Url -OutFile $archivePath + Test-ArchiveDigest -ArchivePath $archivePath -ExpectedDigest $assetMetadata.Sha256 - foreach ($relativeSource in $copyMap.Keys) { - $sourcePath = Join-Path $vendorRoot $relativeSource - $destinationPath = Join-Path $installDir $copyMap[$relativeSource] - Move-Item -Force $sourcePath $destinationPath + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + 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 + 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 (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 + New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null + $oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir + try { + Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir -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") -$pathNeedsNewShell = $false -if (-not (Path-Contains -PathValue $userPath -Entry $installDir)) { +if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($userPath)) { - $newUserPath = $installDir + $newUserPath = $visibleBinDir } else { - $newUserPath = "$installDir;$userPath" + $newUserPath = "$visibleBinDir;$userPath" } [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") - if (-not (Path-Contains -PathValue $env:Path -Entry $installDir)) { - if ([string]::IsNullOrWhiteSpace($env:Path)) { - $env:Path = $installDir - } else { - $env:Path = "$installDir;$env:Path" - } - } Write-Step "PATH updated for future PowerShell sessions." - $pathNeedsNewShell = $true -} elseif (Path-Contains -PathValue $env:Path -Entry $installDir) { - Write-Step "$installDir is already on PATH." +} 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." - $pathNeedsNewShell = $true } -if ($pathNeedsNewShell) { - Write-Step ('Run now: $env:Path = "{0};$env:Path"; codex' -f $installDir) - Write-Step "Or open a new PowerShell window and run: codex" -} else { - Write-Step "Run: codex" +if (-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 +} diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 86e2940d5..8c225e4d3 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -2,15 +2,33 @@ set -eu -VERSION="${1:-latest}" -INSTALL_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" +RELEASE="latest" + +BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" +BIN_PATH="$BIN_DIR/codex" +CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" +STANDALONE_ROOT="$CODEX_HOME_DIR/packages/standalone" +RELEASES_DIR="$STANDALONE_ROOT/releases" +CURRENT_LINK="$STANDALONE_ROOT/current" +LOCK_FILE="$STANDALONE_ROOT/install.lock" +LOCK_DIR="$STANDALONE_ROOT/install.lock.d" +LOCK_STALE_AFTER_SECS=600 + path_action="already" path_profile="" +conflict_manager="" +conflict_path="" +lock_kind="" +tmp_dir="" step() { printf '==> %s\n' "$1" } +warn() { + printf 'WARNING: %s\n' "$1" >&2 +} + normalize_version() { case "$1" in "" | latest) @@ -28,6 +46,32 @@ normalize_version() { esac } +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --release) + if [ "$#" -lt 2 ]; then + echo "--release requires a value." >&2 + exit 1 + fi + RELEASE="$2" + shift + ;; + --help | -h) + cat <&2 + exit 1 + ;; + esac + shift + done +} + download_file() { url="$1" output="$2" @@ -63,40 +107,6 @@ download_text() { exit 1 } -add_to_path() { - path_action="already" - path_profile="" - - case ":$PATH:" in - *":$INSTALL_DIR:"*) - return - ;; - esac - - profile="$HOME/.profile" - case "${SHELL:-}" in - */zsh) - profile="$HOME/.zshrc" - ;; - */bash) - profile="$HOME/.bashrc" - ;; - esac - - path_profile="$profile" - path_line="export PATH=\"$INSTALL_DIR:\$PATH\"" - if [ -f "$profile" ] && grep -F "$path_line" "$profile" >/dev/null 2>&1; then - path_action="configured" - return - fi - - { - printf '\n# Added by Codex installer\n' - printf '%s\n' "$path_line" - } >>"$profile" - path_action="added" -} - release_url_for_asset() { asset="$1" resolved_version="$2" @@ -104,6 +114,92 @@ release_url_for_asset() { printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset" } +release_metadata_url() { + resolved_version="$1" + + printf 'https://api.github.com/repos/openai/codex/releases/tags/rust-v%s\n' "$resolved_version" +} + +release_asset_digest() { + asset="$1" + resolved_version="$2" + release_json="$(download_text "$(release_metadata_url "$resolved_version")")" + + digest="$(printf '%s\n' "$release_json" | awk -v asset="$asset" ' + { + if ($0 ~ "\"name\":[[:space:]]*\"" asset "\"") { + in_asset = 1 + asset_depth = depth + } + + if (in_asset && /"digest":[[:space:]]*"[^"]+"/) { + sub(/^.*"digest":[[:space:]]*"/, "") + sub(/".*$/, "") + digest = $0 + } + + line = $0 + opens = gsub(/\{/, "{", line) + closes = gsub(/\}/, "}", line) + depth += opens - closes + + if (in_asset && depth < asset_depth) { + in_asset = 0 + } + } + END { + if (digest != "") { + print digest + } + } + ')" + + case "$digest" in + sha256:????????????????????????????????????????????????????????????????) + printf '%s\n' "${digest#sha256:}" + ;; + *) + echo "Could not find SHA-256 digest for release asset $asset." >&2 + exit 1 + ;; + esac +} + +file_sha256() { + path="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | sed 's/^.*= //' + return + fi + + echo "sha256sum, shasum, or openssl is required to verify the Codex download." >&2 + exit 1 +} + +verify_archive_digest() { + archive_path="$1" + expected_digest="$2" + actual_digest="$(file_sha256 "$archive_path")" + + if [ "$actual_digest" != "$expected_digest" ]; then + echo "Downloaded Codex archive checksum did not match release metadata." >&2 + echo "expected: $expected_digest" >&2 + echo "actual: $actual_digest" >&2 + exit 1 + fi +} + require_command() { if ! command -v "$1" >/dev/null 2>&1; then echo "$1 is required to install Codex." >&2 @@ -111,11 +207,8 @@ require_command() { fi } -require_command mktemp -require_command tar - resolve_version() { - normalized_version="$(normalize_version "$VERSION")" + normalized_version="$(normalize_version "$RELEASE")" if [ "$normalized_version" != "latest" ]; then printf '%s\n' "$normalized_version" @@ -133,6 +226,417 @@ resolve_version() { printf '%s\n' "$resolved" } +pick_profile() { + # Use the same shell-specific split Homebrew documents because there is no + # universal startup file across macOS/Linux login and interactive shells. + case "$os:${SHELL:-}" in + darwin:*/zsh) + printf '%s\n' "$HOME/.zprofile" + ;; + darwin:*/bash) + printf '%s\n' "$HOME/.bash_profile" + ;; + linux:*/zsh) + printf '%s\n' "$HOME/.zshrc" + ;; + linux:*/bash) + printf '%s\n' "$HOME/.bashrc" + ;; + *) + printf '%s\n' "$HOME/.profile" + ;; + esac +} + +add_to_path() { + path_action="already" + path_profile="" + + case ":$PATH:" in + *":$BIN_DIR:"*) + return + ;; + esac + + profile="$(pick_profile)" + path_profile="$profile" + begin_marker="# >>> Codex installer >>>" + end_marker="# <<< Codex installer <<<" + path_line="export PATH=\"$BIN_DIR:\$PATH\"" + + if [ -f "$profile" ] && grep -F "$begin_marker" "$profile" >/dev/null 2>&1; then + if grep -F "$path_line" "$profile" >/dev/null 2>&1; then + path_action="configured" + return + fi + + if grep -F "$end_marker" "$profile" >/dev/null 2>&1; then + rewrite_path_block "$profile" "$begin_marker" "$end_marker" "$path_line" + path_action="updated" + return + fi + fi + + append_path_block "$profile" "$begin_marker" "$end_marker" "$path_line" + path_action="added" +} + +append_path_block() { + profile="$1" + begin_marker="$2" + end_marker="$3" + path_line="$4" + + { + printf '\n%s\n' "$begin_marker" + printf '%s\n' "$path_line" + printf '%s\n' "$end_marker" + } >>"$profile" +} + +rewrite_path_block() { + profile="$1" + begin_marker="$2" + end_marker="$3" + path_line="$4" + tmp_profile="$tmp_dir/profile.$$.tmp" + + awk -v begin="$begin_marker" -v end="$end_marker" -v line="$path_line" ' + BEGIN { + in_block = 0 + replaced = 0 + } + $0 == begin { + if (!replaced) { + print begin + print line + print end + replaced = 1 + } + in_block = 1 + next + } + in_block { + if ($0 == end) { + in_block = 0 + } + next + } + { + print + } + END { + if (in_block != 0) { + exit 1 + } + } + ' "$profile" >"$tmp_profile" + mv "$tmp_profile" "$profile" +} + +mkdir_lock_is_stale() { + [ -d "$LOCK_DIR" ] || return 1 + + pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" + started_at="$(cat "$LOCK_DIR/started_at" 2>/dev/null || true)" + now="$(date +%s 2>/dev/null || printf '0')" + + case "$started_at" in + ''|*[!0-9]*) + started_at=0 + ;; + esac + + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + return 1 + fi + + if [ "$started_at" -eq 0 ] || [ "$now" -eq 0 ]; then + return 0 + fi + + [ $((now - started_at)) -ge "$LOCK_STALE_AFTER_SECS" ] +} + +acquire_install_lock() { + mkdir -p "$STANDALONE_ROOT" + + if [ "$os" = "darwin" ] && command -v lockf >/dev/null 2>&1; then + : >>"$LOCK_FILE" + exec 9<>"$LOCK_FILE" + lockf 9 + lock_kind="lockf" + return + fi + + if command -v flock >/dev/null 2>&1; then + exec 9>"$LOCK_FILE" + flock 9 + lock_kind="flock" + return + fi + + while ! mkdir "$LOCK_DIR" 2>/dev/null; do + if mkdir_lock_is_stale; then + warn "Removing stale installer lock at $LOCK_DIR" + rm -rf "$LOCK_DIR" + continue + fi + sleep 1 + done + + printf '%s\n' "$$" >"$LOCK_DIR/pid" + date +%s >"$LOCK_DIR/started_at" 2>/dev/null || true + lock_kind="mkdir" +} + +release_install_lock() { + if [ "$lock_kind" = "mkdir" ]; then + rm -rf "$LOCK_DIR" 2>/dev/null || true + elif [ "$lock_kind" = "flock" ] || [ "$lock_kind" = "lockf" ]; then + exec 9>&- 2>/dev/null || true + fi + lock_kind="" +} + +cleanup_stale_install_artifacts() { + mkdir -p "$RELEASES_DIR" "$STANDALONE_ROOT" + + find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -name '.staging.*' -exec rm -rf {} + + find "$STANDALONE_ROOT" -mindepth 1 -maxdepth 1 -name '.current.*' -exec rm -f {} + + + if [ -d "$BIN_DIR" ]; then + find "$BIN_DIR" -mindepth 1 -maxdepth 1 -name '.codex.*' -exec rm -f {} + + fi +} + +replace_path_with_symlink() { + link_path="$1" + link_target="$2" + tmp_link="$3" + + rm -f "$tmp_link" + ln -s "$link_target" "$tmp_link" + + if mv -Tf "$tmp_link" "$link_path" 2>/dev/null; then + return + fi + + if mv -hf "$tmp_link" "$link_path" 2>/dev/null; then + return + fi + + rm -f "$link_path" + mv -f "$tmp_link" "$link_path" +} + +version_from_binary() { + codex_path="$1" + + if [ ! -x "$codex_path" ]; then + return 1 + fi + + "$codex_path" --version 2>/dev/null | sed -n 's/.* \([0-9][0-9A-Za-z.+-]*\)$/\1/p' | head -n 1 +} + +current_installed_version() { + version="$(version_from_binary "$CURRENT_LINK/codex" || true)" + if [ -n "$version" ]; then + printf '%s\n' "$version" + return 0 + fi + + return 0 +} + +resolve_existing_codex() { + command -v codex 2>/dev/null || true +} + +classify_existing_codex() { + existing_path="$1" + + if [ -z "$existing_path" ] || [ "$existing_path" = "$BIN_PATH" ]; then + return 1 + fi + + case "$existing_path" in + /opt/homebrew/* | /usr/local/*) + if [ "$os" = "darwin" ]; then + printf 'brew\n' + return 0 + fi + ;; + esac + + if [ -f "$existing_path" ] && grep -F "#!/usr/bin/env node" "$existing_path" >/dev/null 2>&1; then + case "$existing_path" in + *".bun"*) + printf 'bun\n' + ;; + *) + printf 'npm\n' + ;; + esac + return 0 + fi + + return 1 +} + +prompt_yes_no() { + prompt="$1" + + if ( : /dev/null; then + printf '%s [y/N] ' "$prompt" >/dev/tty + if ! IFS= read -r answer /dev/null +} + +parse_args "$@" + +require_command mktemp +require_command tar + case "$(uname -s)" in Darwin) os="darwin" @@ -187,58 +691,78 @@ else fi fi -if [ -x "$INSTALL_DIR/codex" ]; then - install_mode="Updating" -else - install_mode="Installing" -fi - -step "$install_mode Codex CLI" -step "Detected platform: $platform_label" - resolved_version="$(resolve_version)" asset="codex-npm-$npm_tag-$resolved_version.tgz" download_url="$(release_url_for_asset "$asset" "$resolved_version")" +release_name="$resolved_version-$vendor_target" +release_dir="$RELEASES_DIR/$release_name" +current_version="$(current_installed_version)" +if [ -n "$current_version" ] && [ "$current_version" != "$resolved_version" ]; then + step "Updating Codex CLI from $current_version to $resolved_version" +elif [ -n "$current_version" ]; then + step "Updating Codex CLI" +else + step "Installing Codex CLI" +fi +step "Detected platform: $platform_label" step "Resolved version: $resolved_version" +detect_conflicting_install + tmp_dir="$(mktemp -d)" cleanup() { - rm -rf "$tmp_dir" + release_install_lock + if [ -n "$tmp_dir" ]; then + rm -rf "$tmp_dir" + fi } trap cleanup EXIT INT TERM -archive_path="$tmp_dir/$asset" +acquire_install_lock +cleanup_stale_install_artifacts -step "Downloading Codex CLI" -download_file "$download_url" "$archive_path" +if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"; then + if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then + warn "Found incomplete existing release at $release_dir; reinstalling." + fi -tar -xzf "$archive_path" -C "$tmp_dir" + archive_path="$tmp_dir/$asset" + extract_dir="$tmp_dir/extract" -step "Installing to $INSTALL_DIR" -mkdir -p "$INSTALL_DIR" -cp "$tmp_dir/package/vendor/$vendor_target/codex/codex" "$INSTALL_DIR/codex" -cp "$tmp_dir/package/vendor/$vendor_target/path/rg" "$INSTALL_DIR/rg" -chmod 0755 "$INSTALL_DIR/codex" -chmod 0755 "$INSTALL_DIR/rg" + step "Downloading Codex CLI" + expected_digest="$(release_asset_digest "$asset" "$resolved_version")" + download_file "$download_url" "$archive_path" + verify_archive_digest "$archive_path" "$expected_digest" + mkdir -p "$extract_dir" + tar -xzf "$archive_path" -C "$extract_dir" + + step "Installing standalone package to $release_dir" + install_release "$release_dir" "$extract_dir/package/vendor/$vendor_target" +fi +update_current_link "$release_dir" +update_visible_command add_to_path +verify_visible_command +release_install_lock +handle_conflicting_install case "$path_action" in added) - step "PATH updated for future shells in $path_profile" - step "Run now: export PATH=\"$INSTALL_DIR:\$PATH\" && codex" - step "Or open a new terminal and run: codex" + print_launch_instructions + ;; + updated) + print_launch_instructions ;; configured) - step "PATH is already configured for future shells in $path_profile" - step "Run now: export PATH=\"$INSTALL_DIR:\$PATH\" && codex" - step "Or open a new terminal and run: codex" + print_launch_instructions ;; *) - step "$INSTALL_DIR is already on PATH" - step "Run: codex" + step "$BIN_DIR is already on PATH" + print_launch_instructions ;; esac printf 'Codex CLI %s installed successfully.\n' "$resolved_version" +maybe_launch_codex_now