diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 5cc519418300..a307e3861023 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -11,6 +11,7 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); +const codexPackageRoot = realpathSync(path.join(__dirname, "..")); const PLATFORM_PACKAGE_BY_TARGET = { "x86_64-unknown-linux-musl": "@openai/codex-linux-x64", @@ -98,7 +99,9 @@ function findCodexExecutable() { const updateCommand = packageManager === "bun" ? "bun install -g @openai/codex@latest" - : "npm install -g @openai/codex@latest"; + : packageManager === "pnpm" + ? "pnpm add -g @openai/codex@latest" + : "npm install -g @openai/codex@latest"; throw new Error( `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`, ); @@ -112,11 +115,47 @@ const binaryPath = findCodexExecutable(); // and guarantees that when either the child terminates or the parent // receives a fatal signal, both processes exit in a predictable manner. +function isPnpmOwnedCodexInstall(nodeModulesDir) { + if (!existsSync(path.join(nodeModulesDir, ".modules.yaml"))) { + return false; + } + + try { + return ( + realpathSync(path.join(nodeModulesDir, "@openai", "codex")) === + codexPackageRoot + ); + } catch { + return false; + } +} + /** * Use heuristics to detect the package manager that was used to install Codex * in order to give the user a hint about how to update it. */ function detectPackageManager() { + // pnpm's owning node_modules directory can be several parents above the + // package in isolated global layouts. Search ancestors of both the canonical + // package root and lexical entrypoint because pnpm may link either path. + const entrypointDir = path.dirname(path.resolve(process.argv[1])); + for (const startDir of new Set([codexPackageRoot, entrypointDir])) { + const filesystemRoot = path.parse(startDir).root; + for ( + let currentDir = startDir; + currentDir !== filesystemRoot; + currentDir = path.dirname(currentDir) + ) { + if (isPnpmOwnedCodexInstall(path.join(currentDir, "node_modules"))) { + return "pnpm"; + } + } + + if (isPnpmOwnedCodexInstall(path.join(filesystemRoot, "node_modules"))) { + return "pnpm"; + } + } + const userAgent = process.env.npm_config_user_agent || ""; if (/\bbun\//.test(userAgent)) { return "bun"; @@ -137,15 +176,21 @@ function detectPackageManager() { return userAgent ? "npm" : null; } +const packageManager = detectPackageManager(); const packageManagerEnvVar = - detectPackageManager() === "bun" + packageManager === "bun" ? "CODEX_MANAGED_BY_BUN" - : "CODEX_MANAGED_BY_NPM"; + : packageManager === "pnpm" + ? "CODEX_MANAGED_BY_PNPM" + : "CODEX_MANAGED_BY_NPM"; const env = { ...process.env, - [packageManagerEnvVar]: "1", - CODEX_MANAGED_PACKAGE_ROOT: realpathSync(path.join(__dirname, "..")), + CODEX_MANAGED_PACKAGE_ROOT: codexPackageRoot, }; +delete env.CODEX_MANAGED_BY_NPM; +delete env.CODEX_MANAGED_BY_BUN; +delete env.CODEX_MANAGED_BY_PNPM; +env[packageManagerEnvVar] = "1"; const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index cfb9316e91d2..2e9c0094c4e8 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -797,6 +797,10 @@ fn installation_check(show_details: bool) -> DoctorCheck { "managed by bun: {}", env::var_os("CODEX_MANAGED_BY_BUN").is_some() )); + details.push(format!( + "managed by pnpm: {}", + env::var_os("CODEX_MANAGED_BY_PNPM").is_some() + )); push_env_path_detail( &mut details, "managed package root", @@ -885,6 +889,7 @@ fn doctor_managed_by_npm(current_exe: Option<&Path>) -> bool { fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { if env::var_os("CODEX_MANAGED_BY_NPM").is_none() && env::var_os("CODEX_MANAGED_BY_BUN").is_none() + && env::var_os("CODEX_MANAGED_BY_PNPM").is_none() { return false; } @@ -937,6 +942,9 @@ fn describe_install_context(context: &InstallContext) -> String { InstallMethod::Bun => { describe_method_with_package_layout("bun", context.package_layout.as_ref()) } + InstallMethod::Pnpm => { + describe_method_with_package_layout("pnpm", context.package_layout.as_ref()) + } InstallMethod::Brew => { describe_method_with_package_layout("brew", context.package_layout.as_ref()) } diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index 81f92314821f..4f0380a58316 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -1260,7 +1260,7 @@ Environment LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent - managed by npm: no · bun: no · package root — + managed by npm: no · bun: no · pnpm: no · package root — ✓ search search is OK (bundled) ✓ git git version 2.54.0 selected git /usr/bin/git diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs index 05e7af404e7c..0ee5add05e7e 100644 --- a/codex-rs/cli/src/doctor/output/detail.rs +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -211,13 +211,15 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec< let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); + let managed_by_pnpm = value(parsed, "managed by pnpm").unwrap_or("false"); let package_root = value(parsed, "managed package root").unwrap_or("not set"); out.push(HumanDetail::Row { label: "managed by".to_string(), value: format!( - "npm: {} · bun: {} · package root {}", + "npm: {} · bun: {} · pnpm: {} · package root {}", yes_no(managed_by_npm), yes_no(managed_by_bun), + yes_no(managed_by_pnpm), if is_falsy(package_root) { "—".to_string() } else { @@ -263,6 +265,7 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec< "install context", "managed by npm", "managed by bun", + "managed by pnpm", "managed package root", "PATH codex entries", ], diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs index 14afa70e4c53..d805c7c6b22e 100644 --- a/codex-rs/cli/src/doctor/runtime.rs +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -121,6 +121,7 @@ fn install_method_name(context: &InstallContext) -> &'static str { InstallMethod::Standalone { .. } => "standalone", InstallMethod::Npm => "npm", InstallMethod::Bun => "bun", + InstallMethod::Pnpm => "pnpm", InstallMethod::Brew => "brew", InstallMethod::Other => "local build", } diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap index b3c8d1ee78ed..2944c6c659ca 100644 --- a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -21,7 +21,7 @@ Environment LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent - managed by npm: no · bun: no · package root — + managed by npm: no · bun: no · pnpm: no · package root — ✓ search search is OK (bundled) ✓ git git version 2.54.0 selected git /usr/bin/git diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs index 246eac2b39fa..b13de06d14ce 100644 --- a/codex-rs/cli/src/doctor/updates.rs +++ b/codex-rs/cli/src/doctor/updates.rs @@ -133,6 +133,7 @@ fn update_action_label(context: &InstallContext) -> &'static str { match &context.method { InstallMethod::Npm => "npm install -g @openai/codex", InstallMethod::Bun => "bun install -g @openai/codex", + InstallMethod::Pnpm => "pnpm add -g @openai/codex", InstallMethod::Brew => "brew upgrade --cask codex", InstallMethod::Standalone { .. } => "standalone installer", InstallMethod::Other => "manual or unknown", @@ -144,6 +145,7 @@ fn fetch_latest_version(context: &InstallContext) -> Result { InstallMethod::Brew => fetch_homebrew_cask_version(), InstallMethod::Npm | InstallMethod::Bun + | InstallMethod::Pnpm | InstallMethod::Standalone { .. } | InstallMethod::Other => fetch_latest_github_release_version(), } @@ -223,6 +225,13 @@ mod tests { }), "npm install -g @openai/codex" ); + assert_eq!( + update_action_label(&InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + }), + "pnpm add -g @openai/codex" + ); assert_eq!( update_action_label(&InstallContext { method: InstallMethod::Other, diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 63694c5d6259..61dabb0e0290 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -56,6 +56,8 @@ pub enum InstallMethod { Npm, /// A Codex binary launched through the bun-managed `codex.js` shim. Bun, + /// A Codex binary launched through the pnpm-managed `codex.js` shim. + Pnpm, /// A Codex binary that appears to come from a Homebrew install prefix. Brew, /// Any other execution environment. @@ -69,15 +71,13 @@ impl InstallContext { pub fn from_exe( is_macos: bool, current_exe: Option<&Path>, - managed_by_npm: bool, - managed_by_bun: bool, + method_override: Option, ) -> 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, + method_override, codex_home.as_deref(), ) } @@ -85,15 +85,12 @@ impl InstallContext { fn from_exe_with_codex_home( is_macos: bool, current_exe: Option<&Path>, - managed_by_npm: bool, - managed_by_bun: bool, + method_override: Option, codex_home: Option<&Path>, ) -> Self { let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); - let method = if managed_by_npm { - InstallMethod::Npm - } else if managed_by_bun { - InstallMethod::Bun + let method = if let Some(method) = method_override { + method } else if let Some(exe_path) = current_exe { install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos) } else { @@ -109,13 +106,19 @@ impl InstallContext { 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(); + let method_override = if std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some() { + Some(InstallMethod::Pnpm) + } else if std::env::var_os("CODEX_MANAGED_BY_NPM").is_some() { + Some(InstallMethod::Npm) + } else if std::env::var_os("CODEX_MANAGED_BY_BUN").is_some() { + Some(InstallMethod::Bun) + } else { + None + }; Self::from_exe( cfg!(target_os = "macos"), current_exe.as_deref(), - managed_by_npm, - managed_by_bun, + method_override, ) }) } @@ -308,8 +311,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -343,8 +345,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!(context.rg_command(), default_rg_command()); @@ -386,8 +387,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!( @@ -450,8 +450,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -496,12 +495,10 @@ mod tests { fs::write(path_dir.join(default_rg_command()), "")?; let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; - let context = InstallContext::from_exe_with_codex_home( + let context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false, - /*codex_home*/ None, + /*method_override*/ Some(InstallMethod::Npm), ); assert_eq!(context.method, InstallMethod::Npm); assert!(context.package_layout.is_some()); @@ -526,8 +523,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -550,8 +546,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -560,13 +555,24 @@ mod tests { } #[test] - fn npm_and_bun_take_precedence() { - let npm_context = InstallContext::from_exe_with_codex_home( + fn package_manager_method_overrides_take_precedence() { + let pnpm_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false, - /*codex_home*/ None, + /*method_override*/ Some(InstallMethod::Pnpm), + ); + assert_eq!( + pnpm_context, + InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + } + ); + + let npm_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Npm), ); assert_eq!( npm_context, @@ -576,12 +582,10 @@ mod tests { } ); - let bun_context = InstallContext::from_exe_with_codex_home( + let bun_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_by_npm*/ false, - /*managed_by_bun*/ true, - /*codex_home*/ None, + /*method_override*/ Some(InstallMethod::Bun), ); assert_eq!( bun_context, @@ -597,8 +601,7 @@ mod tests { 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, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!( diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap new file mode 100644 index 000000000000..a9dd9cac784b --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run pnpm add -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 42dadb8535bc..f5a3a2efa237 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1137,6 +1137,15 @@ fn standalone_windows_update_available_history_cell_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn pnpm_update_available_history_cell_snapshot() { + let cell = + UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::PnpmGlobalLatest)); + let rendered = render_lines(&cell.display_lines(/*width*/ 110)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[test] fn web_search_history_cell_without_detail_snapshot() { let cell = new_web_search_call("call-1".to_string(), String::new(), WebSearchAction::Other); diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 420562f13b68..0fe4408444de 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -12,6 +12,8 @@ pub enum UpdateAction { NpmGlobalLatest, /// Update via `bun install -g @openai/codex@latest`. BunGlobalLatest, + /// Update via `pnpm add -g @openai/codex@latest`. + PnpmGlobalLatest, /// Update via `brew upgrade codex`. BrewUpgrade, /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh`. @@ -26,6 +28,7 @@ impl UpdateAction { match &context.method { InstallMethod::Npm => Some(UpdateAction::NpmGlobalLatest), InstallMethod::Bun => Some(UpdateAction::BunGlobalLatest), + InstallMethod::Pnpm => Some(UpdateAction::PnpmGlobalLatest), InstallMethod::Brew => Some(UpdateAction::BrewUpgrade), InstallMethod::Standalone { platform, .. } => Some(match platform { StandalonePlatform::Unix => UpdateAction::StandaloneUnix, @@ -40,6 +43,7 @@ impl UpdateAction { match self { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), + UpdateAction::PnpmGlobalLatest => ("pnpm", &["add", "-g", "@openai/codex"]), UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), UpdateAction::StandaloneUnix => ( "sh", @@ -106,6 +110,13 @@ mod tests { }), Some(UpdateAction::BunGlobalLatest) ); + assert_eq!( + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + }), + Some(UpdateAction::PnpmGlobalLatest) + ); assert_eq!( UpdateAction::from_install_context(&InstallContext { method: InstallMethod::Brew, diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 65a2317560ab..64e7f76344f9 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -79,7 +79,9 @@ async fn check_for_update(version_file: &Path, action: Option) -> .await?; version } - Some(UpdateAction::NpmGlobalLatest) | Some(UpdateAction::BunGlobalLatest) => { + Some(UpdateAction::NpmGlobalLatest) + | Some(UpdateAction::BunGlobalLatest) + | Some(UpdateAction::PnpmGlobalLatest) => { let latest_version = fetch_latest_github_release_version().await?; let package_info = create_client() .get(npm_registry::PACKAGE_URL)