diff --git a/codex-rs/artifacts/README.md b/codex-rs/artifacts/README.md new file mode 100644 index 000000000..dae83f3e8 --- /dev/null +++ b/codex-rs/artifacts/README.md @@ -0,0 +1,36 @@ +# codex-artifacts + +Runtime and process-management helpers for Codex artifact generation. + +This crate has two main responsibilities: + +- locating, validating, and optionally downloading the pinned artifact runtime +- spawning the artifact build or render command against that runtime + +## Module layout + +- `src/client.rs` + Runs build and render commands once a runtime has been resolved. +- `src/runtime/manager.rs` + Defines the release locator and the package-manager-backed runtime installer. +- `src/runtime/installed.rs` + Loads an extracted runtime from disk and validates its manifest and entrypoints. +- `src/runtime/js_runtime.rs` + Chooses the JavaScript executable to use for artifact execution. +- `src/runtime/manifest.rs` + Manifest types for release metadata and extracted runtimes. +- `src/runtime/error.rs` + Public runtime-loading and installation errors. +- `src/tests.rs` + Crate-level tests that exercise the public API and integration seams. + +## Public API + +- `ArtifactRuntimeManager` + Resolves or installs a runtime package into `~/.codex/packages/artifacts/...`. +- `load_cached_runtime` + Reads a previously installed runtime from a caller-provided cache root without attempting a download. +- `is_js_runtime_available` + Checks whether artifact execution is possible with either a cached runtime or a host JS runtime. +- `ArtifactsClient` + Executes artifact build or render requests using either a managed or preinstalled runtime. diff --git a/codex-rs/artifacts/src/client.rs b/codex-rs/artifacts/src/client.rs index 4cb482e35..19359532a 100644 --- a/codex-rs/artifacts/src/client.rs +++ b/codex-rs/artifacts/src/client.rs @@ -14,30 +14,35 @@ use tokio::time::timeout; const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30); +/// Executes artifact build and render commands against a resolved runtime. #[derive(Clone, Debug)] pub struct ArtifactsClient { runtime_source: RuntimeSource, } #[derive(Clone, Debug)] +#[allow(clippy::large_enum_variant)] enum RuntimeSource { Managed(ArtifactRuntimeManager), Installed(InstalledArtifactRuntime), } impl ArtifactsClient { + /// Creates a client that lazily resolves or downloads the runtime on demand. pub fn from_runtime_manager(runtime_manager: ArtifactRuntimeManager) -> Self { Self { runtime_source: RuntimeSource::Managed(runtime_manager), } } + /// Creates a client pinned to an already loaded runtime. pub fn from_installed_runtime(runtime: InstalledArtifactRuntime) -> Self { Self { runtime_source: RuntimeSource::Installed(runtime), } } + /// Executes artifact-building JavaScript against the configured runtime. pub async fn execute_build( &self, request: ArtifactBuildRequest, @@ -82,6 +87,7 @@ impl ArtifactsClient { .await } + /// Executes the artifact render CLI against the configured runtime. pub async fn execute_render( &self, request: ArtifactRenderCommandRequest, @@ -117,6 +123,7 @@ impl ArtifactsClient { } } +/// Request payload for the artifact build command. #[derive(Clone, Debug, Default)] pub struct ArtifactBuildRequest { pub source: String, @@ -125,6 +132,7 @@ pub struct ArtifactBuildRequest { pub env: BTreeMap, } +/// Request payload for the artifact render CLI. #[derive(Clone, Debug)] pub struct ArtifactRenderCommandRequest { pub cwd: PathBuf, @@ -133,6 +141,7 @@ pub struct ArtifactRenderCommandRequest { pub target: ArtifactRenderTarget, } +/// Render targets supported by the packaged artifact runtime. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ArtifactRenderTarget { Presentation(PresentationRenderTarget), @@ -140,6 +149,7 @@ pub enum ArtifactRenderTarget { } impl ArtifactRenderTarget { + /// Converts a render target to the CLI args expected by `render_cli.mjs`. pub fn to_args(&self) -> Vec { match self { Self::Presentation(target) => { @@ -175,6 +185,7 @@ impl ArtifactRenderTarget { } } +/// Presentation render request parameters. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PresentationRenderTarget { pub input_path: PathBuf, @@ -182,6 +193,7 @@ pub struct PresentationRenderTarget { pub slide_number: u32, } +/// Spreadsheet render request parameters. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SpreadsheetRenderTarget { pub input_path: PathBuf, @@ -190,6 +202,7 @@ pub struct SpreadsheetRenderTarget { pub range: Option, } +/// Captured stdout, stderr, and exit status from an artifact subprocess. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ArtifactCommandOutput { pub exit_code: Option, @@ -198,11 +211,13 @@ pub struct ArtifactCommandOutput { } impl ArtifactCommandOutput { + /// Returns whether the subprocess exited successfully. pub fn success(&self) -> bool { self.exit_code == Some(0) } } +/// Errors raised while spawning or awaiting artifact subprocesses. #[derive(Debug, Error)] pub enum ArtifactsError { #[error(transparent)] @@ -302,153 +317,3 @@ async fn run_command( stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), }) } - -#[cfg(test)] -mod tests { - use super::*; - #[cfg(unix)] - use crate::ArtifactRuntimePlatform; - #[cfg(unix)] - use crate::ExtractedRuntimeManifest; - #[cfg(unix)] - use crate::RuntimeEntrypoints; - #[cfg(unix)] - use crate::RuntimePathEntry; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - - #[test] - fn wrapped_build_script_exposes_artifact_tool_surface() { - let wrapped = build_wrapped_script("console.log(Object.keys(artifactTool).length);"); - assert!(wrapped.contains("const artifactTool = await import(")); - assert!(wrapped.contains("globalThis.artifactTool = artifactTool;")); - assert!(wrapped.contains("globalThis.artifacts = artifactTool;")); - assert!(wrapped.contains("globalThis.codexArtifacts = artifactTool;")); - assert!(wrapped.contains("Object.entries(artifactTool)")); - assert!(wrapped.contains("globalThis[name] = value;")); - } - - #[test] - fn presentation_render_target_builds_expected_args() { - let args = ArtifactRenderTarget::Presentation(PresentationRenderTarget { - input_path: PathBuf::from("deck.pptx"), - output_path: PathBuf::from("slide.png"), - slide_number: 2, - }) - .to_args(); - - assert_eq!( - args, - vec![ - "pptx", - "render", - "--in", - "deck.pptx", - "--slide", - "2", - "--out", - "slide.png" - ] - ); - } - - #[test] - fn spreadsheet_render_target_builds_expected_args() { - let args = ArtifactRenderTarget::Spreadsheet(SpreadsheetRenderTarget { - input_path: PathBuf::from("book.xlsx"), - output_path: PathBuf::from("sheet.png"), - sheet_name: "Summary".to_string(), - range: Some("A1:C3".to_string()), - }) - .to_args(); - - assert_eq!( - args, - vec![ - "xlsx", - "render", - "--in", - "book.xlsx", - "--sheet", - "Summary", - "--out", - "sheet.png", - "--range", - "A1:C3" - ] - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn execute_build_invokes_runtime_node_with_expected_environment() { - let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let cwd = temp.path().join("cwd"); - fs::create_dir_all(&cwd) - .await - .unwrap_or_else(|error| panic!("{error}")); - let log_path = temp.path().join("build.log"); - let fake_node = temp.path().join("fake-node.sh"); - let build_entrypoint = temp.path().join("artifact_tool.mjs"); - let render_entrypoint = temp.path().join("render_cli.mjs"); - fs::write( - &fake_node, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$1\" > \"{}\"\nprintf '%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n", - log_path.display(), - log_path.display() - ), - ) - .await - .unwrap_or_else(|error| panic!("{error}")); - std::fs::set_permissions(&fake_node, std::fs::Permissions::from_mode(0o755)) - .unwrap_or_else(|error| panic!("{error}")); - - let runtime = InstalledArtifactRuntime::new( - temp.path().join("runtime"), - "0.1.0".to_string(), - ArtifactRuntimePlatform::LinuxX64, - sample_manifest("0.1.0"), - fake_node.clone(), - build_entrypoint.clone(), - render_entrypoint, - ); - let client = ArtifactsClient::from_installed_runtime(runtime); - - let output = client - .execute_build(ArtifactBuildRequest { - source: "console.log('hello');".to_string(), - cwd: cwd.clone(), - timeout: Some(Duration::from_secs(5)), - env: BTreeMap::new(), - }) - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert!(output.success()); - let logged = fs::read_to_string(&log_path) - .await - .unwrap_or_else(|error| panic!("{error}")); - assert!(logged.contains("artifact-build.mjs")); - assert!(logged.contains(&build_entrypoint.display().to_string())); - } - - #[cfg(unix)] - fn sample_manifest(runtime_version: &str) -> ExtractedRuntimeManifest { - ExtractedRuntimeManifest { - schema_version: 1, - runtime_version: runtime_version.to_string(), - node: RuntimePathEntry { - relative_path: "node/bin/node".to_string(), - }, - entrypoints: RuntimeEntrypoints { - build_js: RuntimePathEntry { - relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(), - }, - render_cli: RuntimePathEntry { - relative_path: "granola-render/dist/cli.mjs".to_string(), - }, - }, - } - } -} diff --git a/codex-rs/artifacts/src/lib.rs b/codex-rs/artifacts/src/lib.rs index 5c5c7121e..feeb6f960 100644 --- a/codex-rs/artifacts/src/lib.rs +++ b/codex-rs/artifacts/src/lib.rs @@ -1,5 +1,7 @@ mod client; mod runtime; +#[cfg(all(test, not(windows)))] +mod tests; pub use client::ArtifactBuildRequest; pub use client::ArtifactCommandOutput; @@ -24,5 +26,6 @@ pub use runtime::JsRuntimeKind; pub use runtime::ReleaseManifest; pub use runtime::RuntimeEntrypoints; pub use runtime::RuntimePathEntry; +pub use runtime::can_manage_artifact_runtime; pub use runtime::is_js_runtime_available; pub use runtime::load_cached_runtime; diff --git a/codex-rs/artifacts/src/runtime.rs b/codex-rs/artifacts/src/runtime.rs deleted file mode 100644 index 9d24136f5..000000000 --- a/codex-rs/artifacts/src/runtime.rs +++ /dev/null @@ -1,810 +0,0 @@ -use codex_package_manager::ManagedPackage; -use codex_package_manager::PackageManager; -use codex_package_manager::PackageManagerConfig; -use codex_package_manager::PackageManagerError; -pub use codex_package_manager::PackagePlatform as ArtifactRuntimePlatform; -use codex_package_manager::PackageReleaseArchive; -use reqwest::Client; -use serde::Deserialize; -use serde::Serialize; -use std::collections::BTreeMap; -use std::path::Component; -use std::path::Path; -use std::path::PathBuf; -use thiserror::Error; -use url::Url; -use which::which; - -pub const DEFAULT_RELEASE_TAG_PREFIX: &str = "artifact-runtime-v"; -pub const DEFAULT_CACHE_ROOT_RELATIVE: &str = "packages/artifacts"; -pub const DEFAULT_RELEASE_BASE_URL: &str = "https://github.com/openai/codex/releases/download/"; -const CODEX_APP_PRODUCT_NAMES: [&str; 6] = [ - "Codex", - "Codex (Dev)", - "Codex (Agent)", - "Codex (Nightly)", - "Codex (Alpha)", - "Codex (Beta)", -]; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum JsRuntimeKind { - Node, - Electron, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct JsRuntime { - executable_path: PathBuf, - kind: JsRuntimeKind, -} - -impl JsRuntime { - fn node(executable_path: PathBuf) -> Self { - Self { - executable_path, - kind: JsRuntimeKind::Node, - } - } - - fn electron(executable_path: PathBuf) -> Self { - Self { - executable_path, - kind: JsRuntimeKind::Electron, - } - } - - pub fn executable_path(&self) -> &Path { - &self.executable_path - } - - pub fn requires_electron_run_as_node(&self) -> bool { - self.kind == JsRuntimeKind::Electron - } -} - -pub fn is_js_runtime_available(codex_home: &Path, runtime_version: &str) -> bool { - load_cached_runtime(codex_home, runtime_version) - .ok() - .and_then(|runtime| runtime.resolve_js_runtime().ok()) - .or_else(resolve_machine_js_runtime) - .is_some() -} - -pub fn load_cached_runtime( - codex_home: &Path, - runtime_version: &str, -) -> Result { - let platform = ArtifactRuntimePlatform::detect_current()?; - let install_dir = cached_runtime_install_dir(codex_home, runtime_version, platform); - if !install_dir.exists() { - return Err(ArtifactRuntimeError::Io { - context: format!( - "artifact runtime {runtime_version} is not installed at {}", - install_dir.display() - ), - source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing artifact runtime"), - }); - } - - InstalledArtifactRuntime::load(install_dir, platform) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ArtifactRuntimeReleaseLocator { - base_url: Url, - runtime_version: String, - release_tag_prefix: String, -} - -impl ArtifactRuntimeReleaseLocator { - pub fn new(base_url: Url, runtime_version: impl Into) -> Self { - Self { - base_url, - runtime_version: runtime_version.into(), - release_tag_prefix: DEFAULT_RELEASE_TAG_PREFIX.to_string(), - } - } - - pub fn with_tag_prefix(mut self, release_tag_prefix: impl Into) -> Self { - self.release_tag_prefix = release_tag_prefix.into(); - self - } - - pub fn base_url(&self) -> &Url { - &self.base_url - } - - pub fn runtime_version(&self) -> &str { - &self.runtime_version - } - - pub fn release_tag(&self) -> String { - format!("{}{}", self.release_tag_prefix, self.runtime_version) - } - - pub fn manifest_file_name(&self) -> String { - format!("{}-manifest.json", self.release_tag()) - } - - pub fn manifest_url(&self) -> Result { - self.base_url - .join(&format!( - "{}/{}", - self.release_tag(), - self.manifest_file_name() - )) - .map_err(PackageManagerError::InvalidBaseUrl) - } - - pub fn default(runtime_version: impl Into) -> Self { - Self::new( - match Url::parse(DEFAULT_RELEASE_BASE_URL) { - Ok(url) => url, - Err(error) => { - panic!("hard-coded artifact runtime release base URL must be valid: {error}") - } - }, - runtime_version, - ) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ArtifactRuntimeManagerConfig { - package_manager: PackageManagerConfig, -} - -impl ArtifactRuntimeManagerConfig { - pub fn new(codex_home: PathBuf, release: ArtifactRuntimeReleaseLocator) -> Self { - Self { - package_manager: PackageManagerConfig::new( - codex_home, - ArtifactRuntimePackage::new(release), - ), - } - } - - pub fn with_default_release(codex_home: PathBuf, runtime_version: impl Into) -> Self { - Self::new( - codex_home, - ArtifactRuntimeReleaseLocator::default(runtime_version), - ) - } - - pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self { - self.package_manager = self.package_manager.with_cache_root(cache_root); - self - } - - pub fn cache_root(&self) -> PathBuf { - self.package_manager.cache_root() - } - - pub fn release(&self) -> &ArtifactRuntimeReleaseLocator { - &self.package_manager.package().release - } - - pub fn codex_home(&self) -> &Path { - self.package_manager.codex_home() - } -} - -#[derive(Clone, Debug)] -pub struct ArtifactRuntimeManager { - package_manager: PackageManager, - config: ArtifactRuntimeManagerConfig, -} - -impl ArtifactRuntimeManager { - pub fn new(config: ArtifactRuntimeManagerConfig) -> Self { - let package_manager = PackageManager::new(config.package_manager.clone()); - Self { - package_manager, - config, - } - } - - pub fn with_client(config: ArtifactRuntimeManagerConfig, client: Client) -> Self { - let package_manager = PackageManager::with_client(config.package_manager.clone(), client); - Self { - package_manager, - config, - } - } - - pub fn config(&self) -> &ArtifactRuntimeManagerConfig { - &self.config - } - - pub async fn resolve_cached( - &self, - ) -> Result, ArtifactRuntimeError> { - self.package_manager.resolve_cached().await - } - - pub async fn ensure_installed(&self) -> Result { - self.package_manager.ensure_installed().await - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ArtifactRuntimePackage { - release: ArtifactRuntimeReleaseLocator, -} - -impl ArtifactRuntimePackage { - fn new(release: ArtifactRuntimeReleaseLocator) -> Self { - Self { release } - } -} - -impl ManagedPackage for ArtifactRuntimePackage { - type Error = ArtifactRuntimeError; - type Installed = InstalledArtifactRuntime; - type ReleaseManifest = ReleaseManifest; - - fn default_cache_root_relative(&self) -> &str { - DEFAULT_CACHE_ROOT_RELATIVE - } - - fn version(&self) -> &str { - self.release.runtime_version() - } - - fn manifest_url(&self) -> Result { - self.release.manifest_url() - } - - fn archive_url(&self, archive: &PackageReleaseArchive) -> Result { - self.release - .base_url() - .join(&format!( - "{}/{}", - self.release.release_tag(), - archive.archive - )) - .map_err(PackageManagerError::InvalidBaseUrl) - } - - fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str { - &manifest.runtime_version - } - - fn platform_archive( - &self, - manifest: &Self::ReleaseManifest, - platform: ArtifactRuntimePlatform, - ) -> Result { - manifest - .platforms - .get(platform.as_str()) - .cloned() - .ok_or_else(|| { - PackageManagerError::MissingPlatform(platform.as_str().to_string()).into() - }) - } - - fn install_dir(&self, cache_root: &Path, platform: ArtifactRuntimePlatform) -> PathBuf { - cache_root.join(self.version()).join(platform.as_str()) - } - - fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str { - package.runtime_version() - } - - fn load_installed( - &self, - root_dir: PathBuf, - platform: ArtifactRuntimePlatform, - ) -> Result { - InstalledArtifactRuntime::load(root_dir, platform) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct ReleaseManifest { - pub schema_version: u32, - pub runtime_version: String, - pub release_tag: String, - #[serde(default)] - pub node_version: Option, - pub platforms: BTreeMap, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct ExtractedRuntimeManifest { - pub schema_version: u32, - pub runtime_version: String, - pub node: RuntimePathEntry, - pub entrypoints: RuntimeEntrypoints, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct RuntimePathEntry { - pub relative_path: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct RuntimeEntrypoints { - pub build_js: RuntimePathEntry, - pub render_cli: RuntimePathEntry, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct InstalledArtifactRuntime { - root_dir: PathBuf, - runtime_version: String, - platform: ArtifactRuntimePlatform, - manifest: ExtractedRuntimeManifest, - node_path: PathBuf, - build_js_path: PathBuf, - render_cli_path: PathBuf, -} - -impl InstalledArtifactRuntime { - pub fn new( - root_dir: PathBuf, - runtime_version: String, - platform: ArtifactRuntimePlatform, - manifest: ExtractedRuntimeManifest, - node_path: PathBuf, - build_js_path: PathBuf, - render_cli_path: PathBuf, - ) -> Self { - Self { - root_dir, - runtime_version, - platform, - manifest, - node_path, - build_js_path, - render_cli_path, - } - } - - pub fn load( - root_dir: PathBuf, - platform: ArtifactRuntimePlatform, - ) -> Result { - let manifest_path = root_dir.join("manifest.json"); - let manifest_bytes = - std::fs::read(&manifest_path).map_err(|source| ArtifactRuntimeError::Io { - context: format!("failed to read {}", manifest_path.display()), - source, - })?; - let manifest = serde_json::from_slice::(&manifest_bytes) - .map_err(|source| ArtifactRuntimeError::InvalidManifest { - path: manifest_path, - source, - })?; - let node_path = resolve_relative_runtime_path(&root_dir, &manifest.node.relative_path)?; - let build_js_path = - resolve_relative_runtime_path(&root_dir, &manifest.entrypoints.build_js.relative_path)?; - let render_cli_path = resolve_relative_runtime_path( - &root_dir, - &manifest.entrypoints.render_cli.relative_path, - )?; - verify_required_runtime_path(&build_js_path)?; - verify_required_runtime_path(&render_cli_path)?; - - Ok(Self::new( - root_dir, - manifest.runtime_version.clone(), - platform, - manifest, - node_path, - build_js_path, - render_cli_path, - )) - } - - pub fn root_dir(&self) -> &Path { - &self.root_dir - } - - pub fn runtime_version(&self) -> &str { - &self.runtime_version - } - - pub fn platform(&self) -> ArtifactRuntimePlatform { - self.platform - } - - pub fn manifest(&self) -> &ExtractedRuntimeManifest { - &self.manifest - } - - pub fn node_path(&self) -> &Path { - &self.node_path - } - - pub fn build_js_path(&self) -> &Path { - &self.build_js_path - } - - pub fn render_cli_path(&self) -> &Path { - &self.render_cli_path - } - - pub fn resolve_js_runtime(&self) -> Result { - resolve_js_runtime_from_candidates( - Some(self.node_path()), - system_node_runtime(), - system_electron_runtime(), - codex_app_runtime_candidates(), - ) - .ok_or_else(|| ArtifactRuntimeError::MissingJsRuntime { - root_dir: self.root_dir.clone(), - }) - } -} - -#[derive(Debug, Error)] -pub enum ArtifactRuntimeError { - #[error(transparent)] - PackageManager(#[from] PackageManagerError), - #[error("{context}")] - Io { - context: String, - #[source] - source: std::io::Error, - }, - #[error("invalid manifest at {path}")] - InvalidManifest { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("runtime path `{0}` is invalid")] - InvalidRuntimePath(String), - #[error( - "no compatible JavaScript runtime found for artifact runtime at {root_dir}; install Node or the Codex desktop app" - )] - MissingJsRuntime { root_dir: PathBuf }, -} - -fn cached_runtime_install_dir( - codex_home: &Path, - runtime_version: &str, - platform: ArtifactRuntimePlatform, -) -> PathBuf { - codex_home - .join(DEFAULT_CACHE_ROOT_RELATIVE) - .join(runtime_version) - .join(platform.as_str()) -} - -fn resolve_machine_js_runtime() -> Option { - resolve_js_runtime_from_candidates( - None, - system_node_runtime(), - system_electron_runtime(), - codex_app_runtime_candidates(), - ) -} - -fn resolve_js_runtime_from_candidates( - preferred_node_path: Option<&Path>, - node_runtime: Option, - electron_runtime: Option, - codex_app_candidates: Vec, -) -> Option { - preferred_node_path - .and_then(node_runtime_from_path) - .or(node_runtime) - .or(electron_runtime) - .or_else(|| { - codex_app_candidates - .into_iter() - .find_map(|candidate| electron_runtime_from_path(&candidate)) - }) -} - -fn system_node_runtime() -> Option { - which("node") - .ok() - .and_then(|path| node_runtime_from_path(&path)) -} - -fn system_electron_runtime() -> Option { - which("electron") - .ok() - .and_then(|path| electron_runtime_from_path(&path)) -} - -fn node_runtime_from_path(path: &Path) -> Option { - path.is_file().then(|| JsRuntime::node(path.to_path_buf())) -} - -fn electron_runtime_from_path(path: &Path) -> Option { - path.is_file() - .then(|| JsRuntime::electron(path.to_path_buf())) -} - -fn codex_app_runtime_candidates() -> Vec { - match std::env::consts::OS { - "macos" => { - let mut roots = vec![PathBuf::from("/Applications")]; - if let Some(home) = std::env::var_os("HOME") { - roots.push(PathBuf::from(home).join("Applications")); - } - - roots - .into_iter() - .flat_map(|root| { - CODEX_APP_PRODUCT_NAMES - .into_iter() - .map(move |product_name| { - root.join(format!("{product_name}.app")) - .join("Contents") - .join("MacOS") - .join(product_name) - }) - }) - .collect() - } - "windows" => { - let mut roots = Vec::new(); - if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { - roots.push(PathBuf::from(local_app_data).join("Programs")); - } - if let Some(program_files) = std::env::var_os("ProgramFiles") { - roots.push(PathBuf::from(program_files)); - } - if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { - roots.push(PathBuf::from(program_files_x86)); - } - - roots - .into_iter() - .flat_map(|root| { - CODEX_APP_PRODUCT_NAMES - .into_iter() - .map(move |product_name| { - root.join(product_name).join(format!("{product_name}.exe")) - }) - }) - .collect() - } - "linux" => [PathBuf::from("/opt"), PathBuf::from("/usr/lib")] - .into_iter() - .flat_map(|root| { - CODEX_APP_PRODUCT_NAMES - .into_iter() - .map(move |product_name| root.join(product_name).join(product_name)) - }) - .collect(), - _ => Vec::new(), - } -} - -fn resolve_relative_runtime_path( - root_dir: &Path, - relative_path: &str, -) -> Result { - let relative = Path::new(relative_path); - if relative.as_os_str().is_empty() || relative.is_absolute() { - return Err(ArtifactRuntimeError::InvalidRuntimePath( - relative_path.to_string(), - )); - } - if relative.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::Prefix(_) | Component::RootDir - ) - }) { - return Err(ArtifactRuntimeError::InvalidRuntimePath( - relative_path.to_string(), - )); - } - Ok(root_dir.join(relative)) -} - -fn verify_required_runtime_path(path: &Path) -> Result<(), ArtifactRuntimeError> { - if path.is_file() { - return Ok(()); - } - - Err(ArtifactRuntimeError::Io { - context: format!("required runtime file is missing: {}", path.display()), - source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing runtime file"), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use sha2::Digest; - use sha2::Sha256; - use std::io::Cursor; - use std::io::Write; - use tempfile::TempDir; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - use zip::ZipWriter; - use zip::write::SimpleFileOptions; - - #[test] - fn release_locator_builds_manifest_url() { - let locator = ArtifactRuntimeReleaseLocator::new( - Url::parse("https://example.test/releases/").unwrap_or_else(|error| panic!("{error}")), - "0.1.0", - ); - let url = locator - .manifest_url() - .unwrap_or_else(|error| panic!("{error}")); - assert_eq!( - url.as_str(), - "https://example.test/releases/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json" - ); - } - - #[test] - fn default_release_locator_uses_openai_codex_github_releases() { - let locator = ArtifactRuntimeReleaseLocator::default("0.1.0"); - let url = locator - .manifest_url() - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!( - url.as_str(), - "https://github.com/openai/codex/releases/download/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json" - ); - } - - #[tokio::test] - async fn ensure_installed_downloads_and_extracts_zip_runtime() { - let server = MockServer::start().await; - let runtime_version = "0.1.0"; - let platform = - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!( - "artifact-runtime-v{runtime_version}-{}.zip", - platform.as_str() - ); - let archive_bytes = build_zip_archive(runtime_version); - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = ReleaseManifest { - schema_version: 1, - runtime_version: runtime_version.to_string(), - release_tag: format!("artifact-runtime-v{runtime_version}"), - node_version: Some("22.0.0".to_string()), - platforms: BTreeMap::from([( - platform.as_str().to_string(), - PackageReleaseArchive { - archive: archive_name.clone(), - sha256: archive_sha, - format: codex_package_manager::ArchiveFormat::Zip, - size_bytes: Some(archive_bytes.len() as u64), - }, - )]), - }; - Mock::given(method("GET")) - .and(path(format!( - "/artifact-runtime-v{runtime_version}/artifact-runtime-v{runtime_version}-manifest.json" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!( - "/artifact-runtime-v{runtime_version}/{archive_name}" - ))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let locator = ArtifactRuntimeReleaseLocator::new( - Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")), - runtime_version, - ); - let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new( - codex_home.path().to_path_buf(), - locator, - )); - - let runtime = manager - .ensure_installed() - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!(runtime.runtime_version(), runtime_version); - assert_eq!(runtime.platform(), platform); - assert!(runtime.node_path().ends_with(Path::new("node/bin/node"))); - assert!( - runtime - .build_js_path() - .ends_with(Path::new("artifact-tool/dist/artifact_tool.mjs")) - ); - assert!( - runtime - .render_cli_path() - .ends_with(Path::new("granola-render/dist/render_cli.mjs")) - ); - assert_eq!( - runtime.resolve_js_runtime().expect("resolve js runtime"), - JsRuntime::node(runtime.node_path().to_path_buf()) - ); - } - - #[test] - fn resolve_js_runtime_uses_codex_app_electron_candidate() { - let temp_dir = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let electron_path = temp_dir.path().join("Codex"); - let missing_node = temp_dir.path().join("missing-node"); - std::fs::write(&electron_path, "#!/bin/sh\n").unwrap_or_else(|error| panic!("{error}")); - - let runtime = resolve_js_runtime_from_candidates( - Some(missing_node.as_path()), - None, - None, - vec![electron_path.clone()], - ) - .expect("resolve js runtime"); - - assert_eq!(runtime, JsRuntime::electron(electron_path)); - assert!(runtime.requires_electron_run_as_node()); - } - - fn build_zip_archive(runtime_version: &str) -> Vec { - let mut bytes = Cursor::new(Vec::new()); - { - let mut zip = ZipWriter::new(&mut bytes); - let options = SimpleFileOptions::default(); - let manifest = serde_json::to_vec(&sample_extracted_manifest(runtime_version)) - .unwrap_or_else(|error| panic!("{error}")); - zip.start_file("artifact-runtime/manifest.json", options) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(&manifest) - .unwrap_or_else(|error| panic!("{error}")); - zip.start_file( - "artifact-runtime/node/bin/node", - options.unix_permissions(0o755), - ) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(b"#!/bin/sh\n") - .unwrap_or_else(|error| panic!("{error}")); - zip.start_file( - "artifact-runtime/artifact-tool/dist/artifact_tool.mjs", - options, - ) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(b"export const ok = true;\n") - .unwrap_or_else(|error| panic!("{error}")); - zip.start_file( - "artifact-runtime/granola-render/dist/render_cli.mjs", - options, - ) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(b"export const ok = true;\n") - .unwrap_or_else(|error| panic!("{error}")); - zip.finish().unwrap_or_else(|error| panic!("{error}")); - } - bytes.into_inner() - } - - fn sample_extracted_manifest(runtime_version: &str) -> ExtractedRuntimeManifest { - ExtractedRuntimeManifest { - schema_version: 1, - runtime_version: runtime_version.to_string(), - node: RuntimePathEntry { - relative_path: "node/bin/node".to_string(), - }, - entrypoints: RuntimeEntrypoints { - build_js: RuntimePathEntry { - relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(), - }, - render_cli: RuntimePathEntry { - relative_path: "granola-render/dist/render_cli.mjs".to_string(), - }, - }, - } - } -} diff --git a/codex-rs/artifacts/src/runtime/error.rs b/codex-rs/artifacts/src/runtime/error.rs new file mode 100644 index 000000000..ef4b7ed3a --- /dev/null +++ b/codex-rs/artifacts/src/runtime/error.rs @@ -0,0 +1,28 @@ +use codex_package_manager::PackageManagerError; +use std::path::PathBuf; +use thiserror::Error; + +/// Errors raised while locating, validating, or installing an artifact runtime. +#[derive(Debug, Error)] +pub enum ArtifactRuntimeError { + #[error(transparent)] + PackageManager(#[from] PackageManagerError), + #[error("{context}")] + Io { + context: String, + #[source] + source: std::io::Error, + }, + #[error("invalid manifest at {path}")] + InvalidManifest { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("runtime path `{0}` is invalid")] + InvalidRuntimePath(String), + #[error( + "no compatible JavaScript runtime found for artifact runtime at {root_dir}; install Node or the Codex desktop app" + )] + MissingJsRuntime { root_dir: PathBuf }, +} diff --git a/codex-rs/artifacts/src/runtime/installed.rs b/codex-rs/artifacts/src/runtime/installed.rs new file mode 100644 index 000000000..514260901 --- /dev/null +++ b/codex-rs/artifacts/src/runtime/installed.rs @@ -0,0 +1,200 @@ +use super::ArtifactRuntimeError; +use super::ArtifactRuntimePlatform; +use super::ExtractedRuntimeManifest; +use super::JsRuntime; +use super::codex_app_runtime_candidates; +use super::resolve_js_runtime_from_candidates; +use super::system_electron_runtime; +use super::system_node_runtime; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +/// Loads a previously installed runtime from a caller-provided cache root. +pub fn load_cached_runtime( + cache_root: &Path, + runtime_version: &str, +) -> Result { + let platform = ArtifactRuntimePlatform::detect_current()?; + let install_dir = cached_runtime_install_dir(cache_root, runtime_version, platform); + if !install_dir.exists() { + return Err(ArtifactRuntimeError::Io { + context: format!( + "artifact runtime {runtime_version} is not installed at {}", + install_dir.display() + ), + source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing artifact runtime"), + }); + } + + InstalledArtifactRuntime::load(install_dir, platform) +} + +/// A validated runtime installation extracted into the local package cache. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstalledArtifactRuntime { + root_dir: PathBuf, + runtime_version: String, + platform: ArtifactRuntimePlatform, + manifest: ExtractedRuntimeManifest, + node_path: PathBuf, + build_js_path: PathBuf, + render_cli_path: PathBuf, +} + +impl InstalledArtifactRuntime { + /// Creates an installed-runtime value from prevalidated paths. + pub fn new( + root_dir: PathBuf, + runtime_version: String, + platform: ArtifactRuntimePlatform, + manifest: ExtractedRuntimeManifest, + node_path: PathBuf, + build_js_path: PathBuf, + render_cli_path: PathBuf, + ) -> Self { + Self { + root_dir, + runtime_version, + platform, + manifest, + node_path, + build_js_path, + render_cli_path, + } + } + + /// Loads and validates an extracted runtime directory. + pub fn load( + root_dir: PathBuf, + platform: ArtifactRuntimePlatform, + ) -> Result { + let manifest_path = root_dir.join("manifest.json"); + let manifest_bytes = + std::fs::read(&manifest_path).map_err(|source| ArtifactRuntimeError::Io { + context: format!("failed to read {}", manifest_path.display()), + source, + })?; + let manifest = serde_json::from_slice::(&manifest_bytes) + .map_err(|source| ArtifactRuntimeError::InvalidManifest { + path: manifest_path, + source, + })?; + let node_path = resolve_relative_runtime_path(&root_dir, &manifest.node.relative_path)?; + let build_js_path = + resolve_relative_runtime_path(&root_dir, &manifest.entrypoints.build_js.relative_path)?; + let render_cli_path = resolve_relative_runtime_path( + &root_dir, + &manifest.entrypoints.render_cli.relative_path, + )?; + verify_required_runtime_path(&build_js_path)?; + verify_required_runtime_path(&render_cli_path)?; + + Ok(Self::new( + root_dir, + manifest.runtime_version.clone(), + platform, + manifest, + node_path, + build_js_path, + render_cli_path, + )) + } + + /// Returns the extracted runtime root directory. + pub fn root_dir(&self) -> &Path { + &self.root_dir + } + + /// Returns the runtime version recorded in the extracted manifest. + pub fn runtime_version(&self) -> &str { + &self.runtime_version + } + + /// Returns the platform this runtime was installed for. + pub fn platform(&self) -> ArtifactRuntimePlatform { + self.platform + } + + /// Returns the parsed extracted-runtime manifest. + pub fn manifest(&self) -> &ExtractedRuntimeManifest { + &self.manifest + } + + /// Returns the bundled Node executable path advertised by the runtime manifest. + pub fn node_path(&self) -> &Path { + &self.node_path + } + + /// Returns the artifact build entrypoint path. + pub fn build_js_path(&self) -> &Path { + &self.build_js_path + } + + /// Returns the artifact render CLI entrypoint path. + pub fn render_cli_path(&self) -> &Path { + &self.render_cli_path + } + + /// Resolves the best executable to use for artifact commands. + /// + /// Preference order is the bundled Node path, then a machine Node install, + /// then Electron from the machine or a Codex desktop app bundle. + pub fn resolve_js_runtime(&self) -> Result { + resolve_js_runtime_from_candidates( + Some(self.node_path()), + system_node_runtime(), + system_electron_runtime(), + codex_app_runtime_candidates(), + ) + .ok_or_else(|| ArtifactRuntimeError::MissingJsRuntime { + root_dir: self.root_dir.clone(), + }) + } +} + +pub(crate) fn cached_runtime_install_dir( + cache_root: &Path, + runtime_version: &str, + platform: ArtifactRuntimePlatform, +) -> PathBuf { + cache_root.join(runtime_version).join(platform.as_str()) +} + +pub(crate) fn default_cached_runtime_root(codex_home: &Path) -> PathBuf { + codex_home.join(super::DEFAULT_CACHE_ROOT_RELATIVE) +} + +fn resolve_relative_runtime_path( + root_dir: &Path, + relative_path: &str, +) -> Result { + let relative = Path::new(relative_path); + if relative.as_os_str().is_empty() || relative.is_absolute() { + return Err(ArtifactRuntimeError::InvalidRuntimePath( + relative_path.to_string(), + )); + } + if relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) + }) { + return Err(ArtifactRuntimeError::InvalidRuntimePath( + relative_path.to_string(), + )); + } + Ok(root_dir.join(relative)) +} + +fn verify_required_runtime_path(path: &Path) -> Result<(), ArtifactRuntimeError> { + if path.is_file() { + return Ok(()); + } + + Err(ArtifactRuntimeError::Io { + context: format!("required runtime file is missing: {}", path.display()), + source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing runtime file"), + }) +} diff --git a/codex-rs/artifacts/src/runtime/js_runtime.rs b/codex-rs/artifacts/src/runtime/js_runtime.rs new file mode 100644 index 000000000..1c8982b92 --- /dev/null +++ b/codex-rs/artifacts/src/runtime/js_runtime.rs @@ -0,0 +1,177 @@ +use crate::ArtifactRuntimePlatform; +use crate::runtime::default_cached_runtime_root; +use crate::runtime::load_cached_runtime; +use std::path::Path; +use std::path::PathBuf; +use which::which; + +const CODEX_APP_PRODUCT_NAMES: [&str; 6] = [ + "Codex", + "Codex (Dev)", + "Codex (Agent)", + "Codex (Nightly)", + "Codex (Alpha)", + "Codex (Beta)", +]; + +/// The JavaScript runtime used to execute the artifact tool. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum JsRuntimeKind { + Node, + Electron, +} + +/// A discovered JavaScript executable and the way it should be invoked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JsRuntime { + executable_path: PathBuf, + kind: JsRuntimeKind, +} + +impl JsRuntime { + pub(crate) fn node(executable_path: PathBuf) -> Self { + Self { + executable_path, + kind: JsRuntimeKind::Node, + } + } + + pub(crate) fn electron(executable_path: PathBuf) -> Self { + Self { + executable_path, + kind: JsRuntimeKind::Electron, + } + } + + /// Returns the executable to spawn for artifact commands. + pub fn executable_path(&self) -> &Path { + &self.executable_path + } + + /// Returns whether the command must set `ELECTRON_RUN_AS_NODE=1`. + pub fn requires_electron_run_as_node(&self) -> bool { + self.kind == JsRuntimeKind::Electron + } +} + +/// Returns `true` when artifact execution can find both runtime assets and a JS executable. +pub fn is_js_runtime_available(codex_home: &Path, runtime_version: &str) -> bool { + load_cached_runtime(&default_cached_runtime_root(codex_home), runtime_version) + .ok() + .and_then(|runtime| runtime.resolve_js_runtime().ok()) + .or_else(resolve_machine_js_runtime) + .is_some() +} + +/// Returns `true` when this machine can use the managed artifact runtime flow. +/// +/// This is a platform capability check, not a cache or binary availability check. +/// Callers that rely on `ArtifactRuntimeManager::ensure_installed()` should use this +/// to decide whether the feature can be exposed on the current machine. +pub fn can_manage_artifact_runtime() -> bool { + ArtifactRuntimePlatform::detect_current().is_ok() +} + +pub(crate) fn resolve_machine_js_runtime() -> Option { + resolve_js_runtime_from_candidates( + None, + system_node_runtime(), + system_electron_runtime(), + codex_app_runtime_candidates(), + ) +} + +pub(crate) fn resolve_js_runtime_from_candidates( + preferred_node_path: Option<&Path>, + node_runtime: Option, + electron_runtime: Option, + codex_app_candidates: Vec, +) -> Option { + preferred_node_path + .and_then(node_runtime_from_path) + .or(node_runtime) + .or(electron_runtime) + .or_else(|| { + codex_app_candidates + .into_iter() + .find_map(|candidate| electron_runtime_from_path(&candidate)) + }) +} + +pub(crate) fn system_node_runtime() -> Option { + which("node") + .ok() + .and_then(|path| node_runtime_from_path(&path)) +} + +pub(crate) fn system_electron_runtime() -> Option { + which("electron") + .ok() + .and_then(|path| electron_runtime_from_path(&path)) +} + +pub(crate) fn node_runtime_from_path(path: &Path) -> Option { + path.is_file().then(|| JsRuntime::node(path.to_path_buf())) +} + +pub(crate) fn electron_runtime_from_path(path: &Path) -> Option { + path.is_file() + .then(|| JsRuntime::electron(path.to_path_buf())) +} + +pub(crate) fn codex_app_runtime_candidates() -> Vec { + match std::env::consts::OS { + "macos" => { + let mut roots = vec![PathBuf::from("/Applications")]; + if let Some(home) = std::env::var_os("HOME") { + roots.push(PathBuf::from(home).join("Applications")); + } + + roots + .into_iter() + .flat_map(|root| { + CODEX_APP_PRODUCT_NAMES + .into_iter() + .map(move |product_name| { + root.join(format!("{product_name}.app")) + .join("Contents") + .join("MacOS") + .join(product_name) + }) + }) + .collect() + } + "windows" => { + let mut roots = Vec::new(); + if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { + roots.push(PathBuf::from(local_app_data).join("Programs")); + } + if let Some(program_files) = std::env::var_os("ProgramFiles") { + roots.push(PathBuf::from(program_files)); + } + if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { + roots.push(PathBuf::from(program_files_x86)); + } + + roots + .into_iter() + .flat_map(|root| { + CODEX_APP_PRODUCT_NAMES + .into_iter() + .map(move |product_name| { + root.join(product_name).join(format!("{product_name}.exe")) + }) + }) + .collect() + } + "linux" => [PathBuf::from("/opt"), PathBuf::from("/usr/lib")] + .into_iter() + .flat_map(|root| { + CODEX_APP_PRODUCT_NAMES + .into_iter() + .map(move |product_name| root.join(product_name).join(product_name)) + }) + .collect(), + _ => Vec::new(), + } +} diff --git a/codex-rs/artifacts/src/runtime/manager.rs b/codex-rs/artifacts/src/runtime/manager.rs new file mode 100644 index 000000000..d608a0f21 --- /dev/null +++ b/codex-rs/artifacts/src/runtime/manager.rs @@ -0,0 +1,253 @@ +use super::ArtifactRuntimeError; +use super::ArtifactRuntimePlatform; +use super::InstalledArtifactRuntime; +use super::ReleaseManifest; +use codex_package_manager::ManagedPackage; +use codex_package_manager::PackageManager; +use codex_package_manager::PackageManagerConfig; +use codex_package_manager::PackageManagerError; +use codex_package_manager::PackageReleaseArchive; +use reqwest::Client; +use std::path::Path; +use std::path::PathBuf; +use url::Url; + +/// Release tag prefix used for artifact runtime assets. +pub const DEFAULT_RELEASE_TAG_PREFIX: &str = "artifact-runtime-v"; + +/// Relative cache root for installed artifact runtimes under `codex_home`. +pub const DEFAULT_CACHE_ROOT_RELATIVE: &str = "packages/artifacts"; + +/// Base URL used by default when downloading runtime assets from GitHub releases. +pub const DEFAULT_RELEASE_BASE_URL: &str = "https://github.com/openai/codex/releases/download/"; + +/// Describes where a particular artifact runtime release can be downloaded from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactRuntimeReleaseLocator { + base_url: Url, + runtime_version: String, + release_tag_prefix: String, +} + +impl ArtifactRuntimeReleaseLocator { + /// Creates a locator for a runtime version under a release base URL. + pub fn new(base_url: Url, runtime_version: impl Into) -> Self { + Self { + base_url, + runtime_version: runtime_version.into(), + release_tag_prefix: DEFAULT_RELEASE_TAG_PREFIX.to_string(), + } + } + + /// Overrides the release-tag prefix used when constructing asset names. + pub fn with_tag_prefix(mut self, release_tag_prefix: impl Into) -> Self { + self.release_tag_prefix = release_tag_prefix.into(); + self + } + + /// Returns the release asset base URL. + pub fn base_url(&self) -> &Url { + &self.base_url + } + + /// Returns the expected runtime version. + pub fn runtime_version(&self) -> &str { + &self.runtime_version + } + + /// Returns the full release tag for the runtime version. + pub fn release_tag(&self) -> String { + format!("{}{}", self.release_tag_prefix, self.runtime_version) + } + + /// Returns the expected manifest filename for the release. + pub fn manifest_file_name(&self) -> String { + format!("{}-manifest.json", self.release_tag()) + } + + /// Returns the manifest URL for this runtime release. + pub fn manifest_url(&self) -> Result { + self.base_url + .join(&format!( + "{}/{}", + self.release_tag(), + self.manifest_file_name() + )) + .map_err(PackageManagerError::InvalidBaseUrl) + } + + /// Returns the default GitHub-release locator for a runtime version. + pub fn default(runtime_version: impl Into) -> Self { + Self::new( + match Url::parse(DEFAULT_RELEASE_BASE_URL) { + Ok(url) => url, + Err(error) => { + panic!("hard-coded artifact runtime release base URL must be valid: {error}") + } + }, + runtime_version, + ) + } +} + +/// Configuration for resolving artifact runtimes under a Codex home directory. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactRuntimeManagerConfig { + package_manager: PackageManagerConfig, + release: ArtifactRuntimeReleaseLocator, +} + +impl ArtifactRuntimeManagerConfig { + /// Creates a runtime-manager config from a Codex home and explicit release locator. + pub fn new(codex_home: PathBuf, release: ArtifactRuntimeReleaseLocator) -> Self { + Self { + package_manager: PackageManagerConfig::new( + codex_home, + ArtifactRuntimePackage::new(release.clone()), + ), + release, + } + } + + /// Creates a runtime-manager config that downloads from the default GitHub release location. + pub fn with_default_release(codex_home: PathBuf, runtime_version: impl Into) -> Self { + Self::new( + codex_home, + ArtifactRuntimeReleaseLocator::default(runtime_version), + ) + } + + /// Overrides the runtime cache root. + pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self { + self.package_manager = self.package_manager.with_cache_root(cache_root); + self + } + + /// Returns the runtime cache root. + pub fn cache_root(&self) -> PathBuf { + self.package_manager.cache_root() + } + + /// Returns the release locator used by this config. + pub fn release(&self) -> &ArtifactRuntimeReleaseLocator { + &self.release + } +} + +/// Package-manager-backed artifact runtime resolver and installer. +#[derive(Clone, Debug)] +pub struct ArtifactRuntimeManager { + package_manager: PackageManager, + config: ArtifactRuntimeManagerConfig, +} + +impl ArtifactRuntimeManager { + /// Creates a runtime manager using the default `reqwest` client. + pub fn new(config: ArtifactRuntimeManagerConfig) -> Self { + let package_manager = PackageManager::new(config.package_manager.clone()); + Self { + package_manager, + config, + } + } + + /// Creates a runtime manager with a caller-provided HTTP client. + pub fn with_client(config: ArtifactRuntimeManagerConfig, client: Client) -> Self { + let package_manager = PackageManager::with_client(config.package_manager.clone(), client); + Self { + package_manager, + config, + } + } + + /// Returns the manager configuration. + pub fn config(&self) -> &ArtifactRuntimeManagerConfig { + &self.config + } + + /// Returns the installed runtime if it is already present and valid. + pub async fn resolve_cached( + &self, + ) -> Result, ArtifactRuntimeError> { + self.package_manager.resolve_cached().await + } + + /// Returns the installed runtime, downloading and caching it if necessary. + pub async fn ensure_installed(&self) -> Result { + self.package_manager.ensure_installed().await + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ArtifactRuntimePackage { + release: ArtifactRuntimeReleaseLocator, +} + +impl ArtifactRuntimePackage { + fn new(release: ArtifactRuntimeReleaseLocator) -> Self { + Self { release } + } +} + +impl ManagedPackage for ArtifactRuntimePackage { + type Error = ArtifactRuntimeError; + type Installed = InstalledArtifactRuntime; + type ReleaseManifest = ReleaseManifest; + + fn default_cache_root_relative(&self) -> &str { + DEFAULT_CACHE_ROOT_RELATIVE + } + + fn version(&self) -> &str { + self.release.runtime_version() + } + + fn manifest_url(&self) -> Result { + self.release.manifest_url() + } + + fn archive_url(&self, archive: &PackageReleaseArchive) -> Result { + self.release + .base_url() + .join(&format!( + "{}/{}", + self.release.release_tag(), + archive.archive + )) + .map_err(PackageManagerError::InvalidBaseUrl) + } + + fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str { + &manifest.runtime_version + } + + fn platform_archive( + &self, + manifest: &Self::ReleaseManifest, + platform: ArtifactRuntimePlatform, + ) -> Result { + manifest + .platforms + .get(platform.as_str()) + .cloned() + .ok_or_else(|| { + PackageManagerError::MissingPlatform(platform.as_str().to_string()).into() + }) + } + + fn install_dir(&self, cache_root: &Path, platform: ArtifactRuntimePlatform) -> PathBuf { + cache_root.join(self.version()).join(platform.as_str()) + } + + fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str { + package.runtime_version() + } + + fn load_installed( + &self, + root_dir: PathBuf, + platform: ArtifactRuntimePlatform, + ) -> Result { + InstalledArtifactRuntime::load(root_dir, platform) + } +} diff --git a/codex-rs/artifacts/src/runtime/manifest.rs b/codex-rs/artifacts/src/runtime/manifest.rs new file mode 100644 index 000000000..e2768a80a --- /dev/null +++ b/codex-rs/artifacts/src/runtime/manifest.rs @@ -0,0 +1,37 @@ +use codex_package_manager::PackageReleaseArchive; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; + +/// Release metadata published alongside the packaged artifact runtime. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ReleaseManifest { + pub schema_version: u32, + pub runtime_version: String, + pub release_tag: String, + #[serde(default)] + pub node_version: Option, + pub platforms: BTreeMap, +} + +/// Manifest shipped inside the extracted runtime payload. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ExtractedRuntimeManifest { + pub schema_version: u32, + pub runtime_version: String, + pub node: RuntimePathEntry, + pub entrypoints: RuntimeEntrypoints, +} + +/// A relative path entry inside an extracted runtime manifest. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct RuntimePathEntry { + pub relative_path: String, +} + +/// Entrypoints required to build and render artifacts. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct RuntimeEntrypoints { + pub build_js: RuntimePathEntry, + pub render_cli: RuntimePathEntry, +} diff --git a/codex-rs/artifacts/src/runtime/mod.rs b/codex-rs/artifacts/src/runtime/mod.rs new file mode 100644 index 000000000..1b143bf49 --- /dev/null +++ b/codex-rs/artifacts/src/runtime/mod.rs @@ -0,0 +1,30 @@ +mod error; +mod installed; +mod js_runtime; +mod manager; +mod manifest; + +pub use codex_package_manager::PackagePlatform as ArtifactRuntimePlatform; +pub use error::ArtifactRuntimeError; +pub use installed::InstalledArtifactRuntime; +pub use installed::load_cached_runtime; +pub use js_runtime::JsRuntime; +pub use js_runtime::JsRuntimeKind; +pub use js_runtime::can_manage_artifact_runtime; +pub use js_runtime::is_js_runtime_available; +pub use manager::ArtifactRuntimeManager; +pub use manager::ArtifactRuntimeManagerConfig; +pub use manager::ArtifactRuntimeReleaseLocator; +pub use manager::DEFAULT_CACHE_ROOT_RELATIVE; +pub use manager::DEFAULT_RELEASE_BASE_URL; +pub use manager::DEFAULT_RELEASE_TAG_PREFIX; +pub use manifest::ExtractedRuntimeManifest; +pub use manifest::ReleaseManifest; +pub use manifest::RuntimeEntrypoints; +pub use manifest::RuntimePathEntry; + +pub(crate) use installed::default_cached_runtime_root; +pub(crate) use js_runtime::codex_app_runtime_candidates; +pub(crate) use js_runtime::resolve_js_runtime_from_candidates; +pub(crate) use js_runtime::system_electron_runtime; +pub(crate) use js_runtime::system_node_runtime; diff --git a/codex-rs/artifacts/src/tests.rs b/codex-rs/artifacts/src/tests.rs new file mode 100644 index 000000000..a173a405b --- /dev/null +++ b/codex-rs/artifacts/src/tests.rs @@ -0,0 +1,521 @@ +use crate::ArtifactBuildRequest; +use crate::ArtifactCommandOutput; +use crate::ArtifactRenderCommandRequest; +use crate::ArtifactRenderTarget; +use crate::ArtifactRuntimeManager; +use crate::ArtifactRuntimeManagerConfig; +use crate::ArtifactRuntimePlatform; +use crate::ArtifactRuntimeReleaseLocator; +use crate::ArtifactsClient; +use crate::DEFAULT_CACHE_ROOT_RELATIVE; +use crate::ExtractedRuntimeManifest; +use crate::InstalledArtifactRuntime; +use crate::JsRuntime; +use crate::PresentationRenderTarget; +use crate::ReleaseManifest; +use crate::RuntimeEntrypoints; +use crate::RuntimePathEntry; +use crate::SpreadsheetRenderTarget; +use crate::load_cached_runtime; +use codex_package_manager::ArchiveFormat; +use codex_package_manager::PackageReleaseArchive; +use pretty_assertions::assert_eq; +use sha2::Digest; +use sha2::Sha256; +use std::collections::BTreeMap; +use std::fs; +use std::io::Cursor; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +#[test] +fn release_locator_builds_manifest_url() { + let locator = ArtifactRuntimeReleaseLocator::new( + url::Url::parse("https://example.test/releases/").unwrap_or_else(|error| panic!("{error}")), + "0.1.0", + ); + let url = locator + .manifest_url() + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + url.as_str(), + "https://example.test/releases/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json" + ); +} + +#[test] +fn default_release_locator_uses_openai_codex_github_releases() { + let locator = ArtifactRuntimeReleaseLocator::default("0.1.0"); + let url = locator + .manifest_url() + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!( + url.as_str(), + "https://github.com/openai/codex/releases/download/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json" + ); +} + +#[test] +fn load_cached_runtime_reads_installed_runtime() { + let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); + let runtime_version = "0.1.0"; + let platform = + ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); + let install_dir = codex_home + .path() + .join(DEFAULT_CACHE_ROOT_RELATIVE) + .join(runtime_version) + .join(platform.as_str()); + write_installed_runtime( + &install_dir, + runtime_version, + Some(PathBuf::from("node/bin/node")), + ); + + let runtime = load_cached_runtime( + &codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE), + runtime_version, + ) + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(runtime.runtime_version(), runtime_version); + assert_eq!(runtime.platform(), platform); + assert!(runtime.node_path().ends_with(Path::new("node/bin/node"))); + assert!( + runtime + .build_js_path() + .ends_with(Path::new("artifact-tool/dist/artifact_tool.mjs")) + ); +} + +#[test] +fn load_cached_runtime_rejects_parent_relative_paths() { + let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); + let runtime_version = "0.1.0"; + let platform = + ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); + let install_dir = codex_home + .path() + .join(DEFAULT_CACHE_ROOT_RELATIVE) + .join(runtime_version) + .join(platform.as_str()); + write_installed_runtime( + &install_dir, + runtime_version, + Some(PathBuf::from("../node/bin/node")), + ); + + let error = load_cached_runtime( + &codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE), + runtime_version, + ) + .unwrap_err(); + + assert_eq!( + error.to_string(), + "runtime path `../node/bin/node` is invalid" + ); +} + +#[test] +fn load_cached_runtime_requires_build_entrypoint() { + let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); + let runtime_version = "0.1.0"; + let platform = + ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); + let install_dir = codex_home + .path() + .join(DEFAULT_CACHE_ROOT_RELATIVE) + .join(runtime_version) + .join(platform.as_str()); + write_installed_runtime( + &install_dir, + runtime_version, + Some(PathBuf::from("node/bin/node")), + ); + fs::remove_file(install_dir.join("artifact-tool/dist/artifact_tool.mjs")) + .unwrap_or_else(|error| panic!("{error}")); + + let error = load_cached_runtime( + &codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE), + runtime_version, + ) + .unwrap_err(); + + assert_eq!( + error.to_string(), + format!( + "required runtime file is missing: {}", + install_dir + .join("artifact-tool/dist/artifact_tool.mjs") + .display() + ) + ); +} + +#[tokio::test] +async fn ensure_installed_downloads_and_extracts_zip_runtime() { + let server = MockServer::start().await; + let runtime_version = "0.1.0"; + let platform = + ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); + let archive_name = format!( + "artifact-runtime-v{runtime_version}-{}.zip", + platform.as_str() + ); + let archive_bytes = build_zip_archive(runtime_version); + let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); + let manifest = ReleaseManifest { + schema_version: 1, + runtime_version: runtime_version.to_string(), + release_tag: format!("artifact-runtime-v{runtime_version}"), + node_version: Some("22.0.0".to_string()), + platforms: BTreeMap::from([( + platform.as_str().to_string(), + PackageReleaseArchive { + archive: archive_name.clone(), + sha256: archive_sha, + format: ArchiveFormat::Zip, + size_bytes: Some(archive_bytes.len() as u64), + }, + )]), + }; + Mock::given(method("GET")) + .and(path(format!( + "/artifact-runtime-v{runtime_version}/artifact-runtime-v{runtime_version}-manifest.json" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/artifact-runtime-v{runtime_version}/{archive_name}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) + .mount(&server) + .await; + + let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); + let locator = ArtifactRuntimeReleaseLocator::new( + url::Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")), + runtime_version, + ); + let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new( + codex_home.path().to_path_buf(), + locator, + )); + + let runtime = manager + .ensure_installed() + .await + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(runtime.runtime_version(), runtime_version); + assert_eq!(runtime.platform(), platform); + assert!(runtime.node_path().ends_with(Path::new("node/bin/node"))); + assert_eq!( + runtime.resolve_js_runtime().expect("resolve js runtime"), + JsRuntime::node(runtime.node_path().to_path_buf()) + ); +} + +#[test] +fn load_cached_runtime_uses_custom_cache_root() { + let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); + let runtime_version = "0.1.0"; + let custom_cache_root = codex_home.path().join("runtime-cache"); + let platform = + ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); + let install_dir = custom_cache_root + .join(runtime_version) + .join(platform.as_str()); + write_installed_runtime( + &install_dir, + runtime_version, + Some(PathBuf::from("node/bin/node")), + ); + + let config = ArtifactRuntimeManagerConfig::with_default_release( + codex_home.path().to_path_buf(), + runtime_version, + ) + .with_cache_root(custom_cache_root); + + let runtime = load_cached_runtime(&config.cache_root(), runtime_version) + .unwrap_or_else(|error| panic!("{error}")); + + assert_eq!(runtime.runtime_version(), runtime_version); + assert_eq!(runtime.platform(), platform); +} + +#[tokio::test] +#[cfg(unix)] +async fn artifacts_client_execute_build_writes_wrapped_script_and_env() { + let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); + let output_path = temp.path().join("build-output.txt"); + let wrapped_script_path = temp.path().join("wrapped-script.mjs"); + let runtime = fake_installed_runtime(temp.path(), &output_path, &wrapped_script_path); + let client = ArtifactsClient::from_installed_runtime(runtime); + + let output = client + .execute_build(ArtifactBuildRequest { + source: "console.log('hello');".to_string(), + cwd: temp.path().to_path_buf(), + timeout: Some(Duration::from_secs(5)), + env: BTreeMap::from([ + ( + "CODEX_TEST_OUTPUT".to_string(), + output_path.display().to_string(), + ), + ("CUSTOM_ENV".to_string(), "custom-value".to_string()), + ]), + }) + .await + .unwrap_or_else(|error| panic!("{error}")); + + assert_success(&output); + let command_log = fs::read_to_string(&output_path).unwrap_or_else(|error| panic!("{error}")); + assert!(command_log.contains("arg0=")); + assert!(command_log.contains("CODEX_ARTIFACT_BUILD_ENTRYPOINT=")); + assert!(command_log.contains("CODEX_ARTIFACT_RENDER_ENTRYPOINT=")); + assert!(command_log.contains("CUSTOM_ENV=custom-value")); + + let wrapped_script = + fs::read_to_string(wrapped_script_path).unwrap_or_else(|error| panic!("{error}")); + assert!(wrapped_script.contains("globalThis.artifacts = artifactTool;")); + assert!(wrapped_script.contains("globalThis.codexArtifacts = artifactTool;")); + assert!(wrapped_script.contains("console.log('hello');")); +} + +#[tokio::test] +#[cfg(unix)] +async fn artifacts_client_execute_render_passes_expected_args() { + let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); + let output_path = temp.path().join("render-output.txt"); + let wrapped_script_path = temp.path().join("unused-script-copy.mjs"); + let runtime = fake_installed_runtime(temp.path(), &output_path, &wrapped_script_path); + let client = ArtifactsClient::from_installed_runtime(runtime.clone()); + let render_output = temp.path().join("slide.png"); + + let output = client + .execute_render(ArtifactRenderCommandRequest { + cwd: temp.path().to_path_buf(), + timeout: Some(Duration::from_secs(5)), + env: BTreeMap::from([( + "CODEX_TEST_OUTPUT".to_string(), + output_path.display().to_string(), + )]), + target: ArtifactRenderTarget::Presentation(PresentationRenderTarget { + input_path: temp.path().join("deck.pptx"), + output_path: render_output.clone(), + slide_number: 3, + }), + }) + .await + .unwrap_or_else(|error| panic!("{error}")); + + assert_success(&output); + let command_log = fs::read_to_string(&output_path).unwrap_or_else(|error| panic!("{error}")); + assert!(command_log.contains(&format!("arg0={}", runtime.render_cli_path().display()))); + assert!(command_log.contains("arg1=pptx")); + assert!(command_log.contains("arg2=render")); + assert!(command_log.contains("arg5=--slide")); + assert!(command_log.contains("arg6=3")); + assert!(command_log.contains("arg7=--out")); + assert!(command_log.contains(&format!("arg8={}", render_output.display()))); +} + +#[test] +fn spreadsheet_render_target_to_args_includes_optional_range() { + let target = ArtifactRenderTarget::Spreadsheet(SpreadsheetRenderTarget { + input_path: PathBuf::from("/tmp/input.xlsx"), + output_path: PathBuf::from("/tmp/output.png"), + sheet_name: "Summary".to_string(), + range: Some("A1:C8".to_string()), + }); + + assert_eq!( + target.to_args(), + vec![ + "xlsx".to_string(), + "render".to_string(), + "--in".to_string(), + "/tmp/input.xlsx".to_string(), + "--sheet".to_string(), + "Summary".to_string(), + "--out".to_string(), + "/tmp/output.png".to_string(), + "--range".to_string(), + "A1:C8".to_string(), + ] + ); +} + +fn assert_success(output: &ArtifactCommandOutput) { + assert!(output.success()); + assert_eq!(output.exit_code, Some(0)); +} + +#[cfg(unix)] +fn fake_installed_runtime( + root: &Path, + output_path: &Path, + wrapped_script_path: &Path, +) -> InstalledArtifactRuntime { + let runtime_root = root.join("runtime"); + write_installed_runtime(&runtime_root, "0.1.0", Some(PathBuf::from("node/bin/node"))); + write_fake_node_script( + &runtime_root.join("node/bin/node"), + output_path, + wrapped_script_path, + ); + InstalledArtifactRuntime::load( + runtime_root, + ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")), + ) + .unwrap_or_else(|error| panic!("{error}")) +} + +fn write_installed_runtime( + install_dir: &Path, + runtime_version: &str, + node_relative: Option, +) { + fs::create_dir_all(install_dir.join("node/bin")).unwrap_or_else(|error| panic!("{error}")); + fs::create_dir_all(install_dir.join("artifact-tool/dist")) + .unwrap_or_else(|error| panic!("{error}")); + fs::create_dir_all(install_dir.join("granola-render/dist")) + .unwrap_or_else(|error| panic!("{error}")); + let node_relative = node_relative.unwrap_or_else(|| PathBuf::from("node/bin/node")); + fs::write( + install_dir.join("manifest.json"), + serde_json::json!(sample_extracted_manifest(runtime_version, node_relative)).to_string(), + ) + .unwrap_or_else(|error| panic!("{error}")); + fs::write(install_dir.join("node/bin/node"), "#!/bin/sh\n") + .unwrap_or_else(|error| panic!("{error}")); + fs::write( + install_dir.join("artifact-tool/dist/artifact_tool.mjs"), + "export const ok = true;\n", + ) + .unwrap_or_else(|error| panic!("{error}")); + fs::write( + install_dir.join("granola-render/dist/render_cli.mjs"), + "export const ok = true;\n", + ) + .unwrap_or_else(|error| panic!("{error}")); +} + +#[cfg(unix)] +fn write_fake_node_script(script_path: &Path, output_path: &Path, wrapped_script_path: &Path) { + fs::write( + script_path, + format!( + concat!( + "#!/bin/sh\n", + "printf 'arg0=%s\\n' \"$1\" > \"{}\"\n", + "cp \"$1\" \"{}\"\n", + "shift\n", + "i=1\n", + "for arg in \"$@\"; do\n", + " printf 'arg%s=%s\\n' \"$i\" \"$arg\" >> \"{}\"\n", + " i=$((i + 1))\n", + "done\n", + "printf 'CODEX_ARTIFACT_BUILD_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n", + "printf 'CODEX_ARTIFACT_RENDER_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_RENDER_ENTRYPOINT\" >> \"{}\"\n", + "printf 'CUSTOM_ENV=%s\\n' \"$CUSTOM_ENV\" >> \"{}\"\n", + "echo stdout-ok\n", + "echo stderr-ok >&2\n" + ), + output_path.display(), + wrapped_script_path.display(), + output_path.display(), + output_path.display(), + output_path.display(), + output_path.display(), + ), + ) + .unwrap_or_else(|error| panic!("{error}")); + #[cfg(unix)] + { + let mut permissions = fs::metadata(script_path) + .unwrap_or_else(|error| panic!("{error}")) + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(script_path, permissions).unwrap_or_else(|error| panic!("{error}")); + } +} + +fn build_zip_archive(runtime_version: &str) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut bytes); + let options = SimpleFileOptions::default(); + let manifest = serde_json::to_vec(&sample_extracted_manifest( + runtime_version, + PathBuf::from("node/bin/node"), + )) + .unwrap_or_else(|error| panic!("{error}")); + zip.start_file("artifact-runtime/manifest.json", options) + .unwrap_or_else(|error| panic!("{error}")); + zip.write_all(&manifest) + .unwrap_or_else(|error| panic!("{error}")); + zip.start_file( + "artifact-runtime/node/bin/node", + options.unix_permissions(0o755), + ) + .unwrap_or_else(|error| panic!("{error}")); + zip.write_all(b"#!/bin/sh\n") + .unwrap_or_else(|error| panic!("{error}")); + zip.start_file( + "artifact-runtime/artifact-tool/dist/artifact_tool.mjs", + options, + ) + .unwrap_or_else(|error| panic!("{error}")); + zip.write_all(b"export const ok = true;\n") + .unwrap_or_else(|error| panic!("{error}")); + zip.start_file( + "artifact-runtime/granola-render/dist/render_cli.mjs", + options, + ) + .unwrap_or_else(|error| panic!("{error}")); + zip.write_all(b"export const ok = true;\n") + .unwrap_or_else(|error| panic!("{error}")); + zip.finish().unwrap_or_else(|error| panic!("{error}")); + } + bytes.into_inner() +} + +fn sample_extracted_manifest( + runtime_version: &str, + node_relative: PathBuf, +) -> ExtractedRuntimeManifest { + ExtractedRuntimeManifest { + schema_version: 1, + runtime_version: runtime_version.to_string(), + node: RuntimePathEntry { + relative_path: node_relative.display().to_string(), + }, + entrypoints: RuntimeEntrypoints { + build_js: RuntimePathEntry { + relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(), + }, + render_cli: RuntimePathEntry { + relative_path: "granola-render/dist/render_cli.mjs".to_string(), + }, + }, + } +} diff --git a/codex-rs/package-manager/src/lib.rs b/codex-rs/package-manager/src/lib.rs index 5fd906108..5491d28af 100644 --- a/codex-rs/package-manager/src/lib.rs +++ b/codex-rs/package-manager/src/lib.rs @@ -1,5 +1,3 @@ -#![doc = include_str!("../README.md")] - mod archive; mod config; mod error;