mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Significantly improve standalone installer (#17022)
## Summary
This PR significantly improves the standalone installer experience.
The main changes are:
1. We now install the codex binary and other dependencies in a
subdirectory under CODEX_HOME.
(`CODEX_HOME/packages/standalone/releases/...`)
2. We replace the `codex.js` launcher that npm/bun rely on with logic in
the Rust binary that automatically resolves its dependencies (like
ripgrep)
## Motivation
A few design constraints pushed this work.
1. Currently, the entrypoint to codex is through `codex.js`, which
forces a node dependency to kick off our rust app. We want to move away
from this so that the entrypoint to codex does not rely on node or
external package managers.
2. Right now, the native script adds codex and its dependencies directly
to user PATH. Given that codex is likely to add more binary dependencies
than ripgrep, we want a solution which does not add arbitrary binaries
to user PATH -- the only one we want to add is the `codex` command
itself.
3. We want upgrades to be atomic. We do not want scenarios where
interrupting an upgrade command can move codex into undefined state (for
example, having a new codex binary but an old ripgrep binary). This was
~possible with the old script.
4. Currently, the Rust binary uses heuristics to determine which
installer created it. These heuristics are flaky and are tied to the
`codex.js` launcher. We need a more stable/deterministic way to
determine how the binary was installed for standalone.
5. We do not want conflicting codex installations on PATH. For example,
the user installing via npm, then installing via brew, then installing
via standalone would make it unclear which version of codex is being
launched and make it tough for us to determine the right upgrade
command.
## Design
### Standalone package layout
Standalone installs now live under `CODEX_HOME/packages/standalone`:
```text
$CODEX_HOME/
packages/
standalone/
current -> releases/0.111.0-x86_64-unknown-linux-musl
releases/
0.111.0-x86_64-unknown-linux-musl/
codex
codex-resources/
rg
```
where `standalone/current` is a symlink to a release directory.
On Windows, the release directory has the same shape, with `.exe` names
and Windows helpers in `codex-resources`:
```text
%CODEX_HOME%\
packages\
standalone\
current -> releases\0.111.0-x86_64-pc-windows-msvc
releases\
0.111.0-x86_64-pc-windows-msvc\
codex.exe
codex-resources\
rg.exe
codex-command-runner.exe
codex-windows-sandbox-setup.exe
```
This gives us:
- atomic upgrades because we can fully stage a release before switching
`standalone/current`
- a stable way for the binary to recognize a standalone install from its
canonical `current_exe()` path under CODEX_HOME
- a clean place for binary dependencies like `rg`, Windows sandbox
helpers, and, in the future, our custom `zsh` etc
### Command location
On Unix, we add a symlink at `~/.local/bin/codex` which points directly
to the `$CODEX_HOME/packages/standalone/current/codex` binary. This
becomes the main entrypoint for the CLI.
On Windows, we store the link at
`%LOCALAPPDATA%\Programs\OpenAI\Codex\bin`.
### PATH persistence
This is a tricky part of the PR, as there's no ~super reliable way to
ensure that we end up on PATH without significant tradeoffs.
Most Unix variants will have `~/.local/bin` on PATH already, which means
we *should* be fine simply registering the command there in most cases.
However, there are cases where this is not the case. In these cases, we
directly edit the profile depending on the shell we're in.
- macOS zsh: `~/.zprofile`
- macOS bash: `~/.bash_profile`
- Linux zsh: `~/.zshrc`
- Linux bash: `~/.bashrc`
- fallback: `~/.profile`
On Windows, we update the User `Path` environment variable directly and
we don't need to worry about shell profiles.
### Standalone runtime detection
This PR adds a new shared crate, `codex-install-context`, which computes
install ownership once per process and caches it in a `OnceLock`.
That context includes:
- install manager (`Standalone`, `Npm`, `Bun`, `Brew`, `Other`)
- the managed standalone release directory, when applicable
- the managed standalone `codex-resources` directory, when present
- the resolved `rg_command`
The standalone path is detected by canonicalizing `current_exe()`,
canonicalizing CODEX_HOME via `find_codex_home()`, and checking whether
the binary is running from under
`$CODEX_HOME/packages/standalone/releases`.
We intentionally do not use a release metadata file. The binary path is
the source of truth.
### Dependency resolution
For standalone installs, `grep_files` now resolves bundled `rg` from
`codex-resources` next to the Codex binary.
For npm/bun/brew/other installs, `grep_files` falls back to resolving
`rg` from PATH.
For Windows standalone installs, Windows sandbox helpers are still found
as direct siblings when present. If they are not direct siblings, the
lookup also checks the sibling `codex-resources` directory.
### TUI update path
The TUI now has `UpdateAction::StandaloneUnix` and
`UpdateAction::StandaloneWindows`, which rerun the standalone install
commands.
Unix update command:
```sh
sh -c "curl -fsSL https://chatgpt.com/codex/install.sh | sh"
```
Windows update command:
```powershell
powershell -c "irm https://chatgpt.com/codex/install.ps1|iex"
```
The Windows updater runs PowerShell directly. We do this because `cmd
/C` would parse the `|iex` as a cmd pipeline instead of passing it to
PowerShell.
## Additional installer behavior
- standalone installs now warn about conflicting npm/bun/brew-managed
`codex` installs and offer to uninstall them
- same-version reruns do not redownload the release if it is already
staged locally
## Testing
Installer smoke tests run:
- macOS: fresh install into isolated `HOME` and `CODEX_HOME` with
`scripts/install/install.sh --release latest`
- macOS: reran the installer against the same isolated install to verify
the same-version/update path and PATH block idempotence
- macOS: verified the installed `codex --version` and bundled
`codex-resources/rg --version`
- Windows: parsed `scripts/install/install.ps1` with PowerShell via
`[scriptblock]::Create(...)`
- Windows: verified the standalone update action builds a direct
PowerShell command and does not route the `irm ...|iex` command through
`cmd /C`
---------
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
9e2fc31854
commit
9d1bf002c6
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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))]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "install-context",
|
||||
crate_name = "codex_install_context",
|
||||
)
|
||||
@@ -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 }
|
||||
@@ -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<InstallContext> = 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<PathBuf>,
|
||||
/// 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<InstallContext> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Self> {
|
||||
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<UpdateAction> {
|
||||
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<UpdateAction> {
|
||||
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"][..],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<CopyOutcome> {
|
||||
@@ -292,10 +296,12 @@ fn destination_is_fresh(source: &Path, destination: &Path) -> Result<bool> {
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+611
-57
@@ -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
|
||||
}
|
||||
|
||||
+592
-68
@@ -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 <<EOF
|
||||
Usage: install.sh [--release VERSION]
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&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/tty ) 2>/dev/null; then
|
||||
printf '%s [y/N] ' "$prompt" >/dev/tty
|
||||
if ! IFS= read -r answer </dev/tty; then
|
||||
return 1
|
||||
fi
|
||||
elif [ -t 0 ]; then
|
||||
printf '%s [y/N] ' "$prompt"
|
||||
if ! IFS= read -r answer; then
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$answer" in
|
||||
y | Y | yes | YES)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_launch_instructions() {
|
||||
case "$path_action" in
|
||||
added)
|
||||
step "Current terminal: export PATH=\"$BIN_DIR:\$PATH\" && codex"
|
||||
step "Future terminals: open a new terminal and run: codex"
|
||||
step "PATH was added to $path_profile"
|
||||
;;
|
||||
updated)
|
||||
step "Current terminal: export PATH=\"$BIN_DIR:\$PATH\" && codex"
|
||||
step "Future terminals: open a new terminal and run: codex"
|
||||
step "PATH was updated in $path_profile"
|
||||
;;
|
||||
configured)
|
||||
step "Current terminal: export PATH=\"$BIN_DIR:\$PATH\" && codex"
|
||||
step "Future terminals: open a new terminal and run: codex"
|
||||
step "PATH is already configured in $path_profile"
|
||||
;;
|
||||
*)
|
||||
step "Current terminal: codex"
|
||||
step "Future terminals: open a new terminal and run: codex"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
maybe_launch_codex_now() {
|
||||
if prompt_yes_no "Start Codex now?"; then
|
||||
step "Launching Codex"
|
||||
"$BIN_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_conflicting_install() {
|
||||
existing_path="$(resolve_existing_codex)"
|
||||
manager="$(classify_existing_codex "$existing_path" || true)"
|
||||
|
||||
if [ -z "$manager" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
conflict_manager="$manager"
|
||||
conflict_path="$existing_path"
|
||||
step "Detected existing $manager-managed Codex at $existing_path"
|
||||
warn "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs."
|
||||
}
|
||||
|
||||
handle_conflicting_install() {
|
||||
if [ -z "$conflict_manager" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
case "$conflict_manager" in
|
||||
brew)
|
||||
uninstall_cmd="brew uninstall --cask codex"
|
||||
;;
|
||||
bun)
|
||||
uninstall_cmd="bun remove -g @openai/codex"
|
||||
;;
|
||||
*)
|
||||
uninstall_cmd="npm uninstall -g @openai/codex"
|
||||
;;
|
||||
esac
|
||||
|
||||
if prompt_yes_no "Uninstall the existing $conflict_manager-managed Codex now?"; then
|
||||
step "Running: $uninstall_cmd"
|
||||
if ! sh -c "$uninstall_cmd"; then
|
||||
warn "Failed to uninstall the existing $conflict_manager-managed Codex. Continuing with the standalone install."
|
||||
fi
|
||||
else
|
||||
warn "Leaving the existing $conflict_manager-managed Codex installed. PATH order will determine which codex runs."
|
||||
fi
|
||||
}
|
||||
|
||||
install_release() {
|
||||
release_dir="$1"
|
||||
vendor_root="$2"
|
||||
stage_release="$RELEASES_DIR/.staging.$(basename "$release_dir").$$"
|
||||
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
rm -rf "$stage_release"
|
||||
mkdir -p "$stage_release/codex-resources"
|
||||
cp "$vendor_root/codex/codex" "$stage_release/codex"
|
||||
cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg"
|
||||
chmod 0755 "$stage_release/codex"
|
||||
chmod 0755 "$stage_release/codex-resources/rg"
|
||||
|
||||
if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then
|
||||
rm -rf "$release_dir"
|
||||
fi
|
||||
mv "$stage_release" "$release_dir"
|
||||
}
|
||||
|
||||
release_dir_is_complete() {
|
||||
release_dir="$1"
|
||||
expected_version="$2"
|
||||
expected_target="$3"
|
||||
|
||||
[ -d "$release_dir" ] &&
|
||||
[ -x "$release_dir/codex" ] &&
|
||||
[ -x "$release_dir/codex-resources/rg" ] &&
|
||||
[ "$(basename "$release_dir")" = "$expected_version-$expected_target" ]
|
||||
}
|
||||
|
||||
update_current_link() {
|
||||
release_dir="$1"
|
||||
tmp_link="$STANDALONE_ROOT/.current.$$"
|
||||
|
||||
replace_path_with_symlink "$CURRENT_LINK" "$release_dir" "$tmp_link"
|
||||
}
|
||||
|
||||
update_visible_command() {
|
||||
mkdir -p "$BIN_DIR"
|
||||
tmp_link="$BIN_DIR/.codex.$$"
|
||||
|
||||
replace_path_with_symlink "$BIN_PATH" "$CURRENT_LINK/codex" "$tmp_link"
|
||||
}
|
||||
|
||||
verify_visible_command() {
|
||||
"$BIN_PATH" --version >/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
|
||||
|
||||
Reference in New Issue
Block a user