diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index aec57c1e82..580834af8f 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -847,20 +847,30 @@ fn describe_install_context(context: &InstallContext) -> String { InstallContext::Standalone { release_dir, resources_dir, + package_layout, platform, } => { let platform = match platform { StandalonePlatform::Unix => "unix", StandalonePlatform::Windows => "windows", }; - let resources = resources_dir - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "none".to_string()); - format!( - "standalone ({platform}, release {}, resources {resources})", - release_dir.display() - ) + let resources = display_optional_path(resources_dir.as_deref()); + match package_layout { + Some(package_layout) => { + let path = display_optional_path(package_layout.path_dir.as_deref()); + format!( + "standalone ({platform}, package {}, bin {}, resources {resources}, path {path})", + package_layout.package_dir.display(), + package_layout.bin_dir.display() + ) + } + None => { + format!( + "standalone ({platform}, release {}, resources {resources})", + release_dir.display() + ) + } + } } InstallContext::Npm => "npm".to_string(), InstallContext::Bun => "bun".to_string(), @@ -869,6 +879,11 @@ fn describe_install_context(context: &InstallContext) -> String { } } +fn display_optional_path(path: Option<&Path>) -> String { + path.map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()) +} + #[derive(Debug, PartialEq, Eq)] enum NpmRootCheck { Match { @@ -2791,7 +2806,12 @@ fn path_readiness(details: &mut Vec, label: &str, path: &Path) { } fn standalone_release_cache_details(details: &mut Vec) { - let InstallContext::Standalone { release_dir, .. } = InstallContext::current() else { + let InstallContext::Standalone { + release_dir, + package_layout: None, + .. + } = InstallContext::current() + else { return; }; let Some(releases_dir) = release_dir.parent() else { diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs index 96e806762c..d3d65f1455 100644 --- a/codex-rs/cli/src/doctor/runtime.rs +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -3,7 +3,7 @@ //! Runtime diagnostics answer provenance questions that are hard to infer from //! user reports: which binary is running, which install channel it resembles, //! which platform it targets, and whether the search command comes from bundled -//! standalone resources or from PATH. +//! standalone package files or from PATH. use std::env; use std::process::Command; @@ -49,9 +49,10 @@ pub(super) fn runtime_check() -> DoctorCheck { /// Verifies that the search command selected by the install context is usable. /// -/// Standalone installs should point at a bundled ripgrep binary, while local or -/// package-managed installs usually resolve rg from PATH. A warning here means -/// features that depend on file search may degrade even when the CLI launches. +/// Standalone package installs should point at a bundled ripgrep binary, while +/// local or package-managed installs usually resolve rg from PATH. A warning +/// here means features that depend on file search may degrade even when the CLI +/// launches. pub(super) fn search_check() -> DoctorCheck { let current_exe = env::current_exe().ok(); let install_context = doctor_install_context(current_exe.as_deref()); @@ -109,7 +110,7 @@ pub(super) fn search_check() -> DoctorCheck { }; let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); if status != CheckStatus::Ok { - check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + check = check.remediation("Install ripgrep or repair the bundled Codex package."); } check } @@ -125,13 +126,29 @@ fn install_method_name(context: &InstallContext) -> &'static str { } fn search_provider(context: &InstallContext) -> &'static str { - match context { - InstallContext::Standalone { - resources_dir: Some(resources_dir), - .. - } if context.rg_command().starts_with(resources_dir) => "bundled", - _ => "system", + let rg_command = context.rg_command(); + if let InstallContext::Standalone { + package_layout: Some(package_layout), + .. + } = context + && package_layout + .path_dir + .as_ref() + .is_some_and(|path_dir| rg_command.starts_with(path_dir)) + { + return "bundled"; } + + if let InstallContext::Standalone { + resources_dir: Some(resources_dir), + .. + } = context + && rg_command.starts_with(resources_dir) + { + return "bundled"; + } + + "system" } fn build_commit() -> &'static str { diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 980fc3f546..6b5345d561 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -1,7 +1,11 @@ +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; +const BIN_DIRNAME: &str = "bin"; +const PACKAGE_METADATA_FILENAME: &str = "codex-package.json"; +const PATH_DIRNAME: &str = "codex-path"; const RELEASES_DIRNAME: &str = "releases"; const RESOURCES_DIRNAME: &str = "codex-resources"; const STANDALONE_PACKAGES_DIRNAME: &str = "standalone"; @@ -13,15 +17,32 @@ pub enum StandalonePlatform { Windows, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CodexPackageLayout { + /// The package root that contains the metadata file and layout directories. + pub package_dir: PathBuf, + /// Directory containing the Codex entrypoint executable. + pub bin_dir: PathBuf, + /// Directory containing managed helper binaries and data files, when present. + pub resources_dir: Option, + /// Directory containing executables that should be preferred over PATH, when present. + pub path_dir: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum InstallContext { Standalone { - /// The managed standalone release directory, for example + /// The managed standalone release directory. Legacy installs use paths + /// such as /// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`. + /// Package-layout installs use the package root that contains `bin/`, + /// `codex-resources/`, and `codex-path/`. release_dir: PathBuf, - /// The bundled resource directory that sits next to the executable when - /// this install ships managed dependencies. + /// The bundled resource directory for managed dependencies. resources_dir: Option, + /// The canonical package layout when the executable is running from a + /// package directory. + package_layout: Option, /// The platform of the standalone release, either `Unix` or `Windows`. platform: StandalonePlatform, }, @@ -102,6 +123,17 @@ impl InstallContext { pub fn rg_command(&self) -> PathBuf { match self { + Self::Standalone { + package_layout: Some(package_layout), + .. + } => package_layout + .path_dir + .as_ref() + .and_then(|path_dir| { + let bundled_rg = path_dir.join(default_rg_command()); + bundled_rg.exists().then_some(bundled_rg) + }) + .unwrap_or_else(default_rg_command), Self::Standalone { resources_dir: Some(resources_dir), .. @@ -129,6 +161,10 @@ fn standalone_install_context( exe_path: &Path, codex_home: Option<&Path>, ) -> Option { + if let Some(package_context) = standalone_package_install_context(exe_path) { + return Some(package_context); + } + 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(); @@ -144,6 +180,35 @@ fn standalone_install_context( Some(InstallContext::Standalone { release_dir, resources_dir: resources_dir.is_dir().then_some(resources_dir), + package_layout: None, + platform: standalone_platform(), + }) +} + +fn standalone_package_install_context(exe_path: &Path) -> Option { + let canonical_exe = std::fs::canonicalize(exe_path).ok()?; + let bin_dir = canonical_exe.parent()?; + if bin_dir.file_name() != Some(OsStr::new(BIN_DIRNAME)) { + return None; + } + + let package_dir = bin_dir.parent()?.to_path_buf(); + if !package_dir.join(PACKAGE_METADATA_FILENAME).is_file() { + return None; + } + + let resources_dir = existing_dir(package_dir.join(RESOURCES_DIRNAME)); + let path_dir = existing_dir(package_dir.join(PATH_DIRNAME)); + let package_layout = CodexPackageLayout { + package_dir: package_dir.clone(), + bin_dir: bin_dir.to_path_buf(), + resources_dir: resources_dir.clone(), + path_dir, + }; + Some(InstallContext::Standalone { + release_dir: package_dir, + resources_dir, + package_layout: Some(package_layout), platform: standalone_platform(), }) } @@ -156,6 +221,10 @@ fn standalone_platform() -> StandalonePlatform { } } +fn existing_dir(path: PathBuf) -> Option { + path.is_dir().then_some(path) +} + fn default_rg_command() -> PathBuf { if cfg!(windows) { PathBuf::from("rg.exe") @@ -196,6 +265,7 @@ mod tests { InstallContext::Standalone { release_dir: canonical_release_dir, resources_dir: Some(canonical_resources_dir), + package_layout: None, platform: standalone_platform(), } ); @@ -223,6 +293,73 @@ mod tests { Ok(()) } + #[test] + fn detects_standalone_package_layout() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = package_dir.path().canonicalize()?; + let canonical_bin_dir = bin_dir.canonicalize()?; + let canonical_resources_dir = resources_dir.canonicalize()?; + let canonical_path_dir = path_dir.canonicalize()?; + let package_layout = CodexPackageLayout { + package_dir: canonical_package_dir.clone(), + bin_dir: canonical_bin_dir, + resources_dir: Some(canonical_resources_dir.clone()), + path_dir: Some(canonical_path_dir.clone()), + }; + + 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*/ None, + ); + assert_eq!( + context, + InstallContext::Standalone { + release_dir: canonical_package_dir, + resources_dir: Some(canonical_resources_dir), + package_layout: Some(package_layout), + platform: standalone_platform(), + } + ); + assert_eq!( + context.rg_command(), + canonical_path_dir.join(default_rg_command()) + ); + Ok(()) + } + + #[test] + fn standalone_package_rg_falls_back_when_codex_path_is_missing() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_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*/ None, + ); + 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( diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index aca065440c..5574ccc836 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -94,6 +94,7 @@ mod tests { platform: StandalonePlatform::Unix, release_dir: native_release_dir.clone(), resources_dir: Some(native_release_dir.join("codex-resources")), + package_layout: None, }), Some(UpdateAction::StandaloneUnix) ); @@ -102,6 +103,7 @@ mod tests { platform: StandalonePlatform::Windows, release_dir: native_release_dir.clone(), resources_dir: Some(native_release_dir.join("codex-resources")), + package_layout: None, }), Some(UpdateAction::StandaloneWindows) );