use crate::plugin_bundle_archive::unpack_plugin_bundle_tar_gz; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use std::ffi::OsStr; use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; const NPM_PLUGIN_SOURCE_STAGING_DIR: &str = "plugins/.marketplace-plugin-source-staging"; const NPM_PLUGIN_SOURCE_MAX_ARCHIVE_BYTES: u64 = 50 * 1024 * 1024; const NPM_PLUGIN_SOURCE_MAX_EXTRACTED_BYTES: u64 = 250 * 1024 * 1024; const NPM_PACKAGE_ARCHIVE_ROOT: &str = "package"; pub(crate) fn materialize_npm_plugin_source( codex_home: &Path, package: &str, version: Option<&str>, registry: Option<&str>, ) -> Result<(AbsolutePathBuf, TempDir), String> { materialize_npm_plugin_source_with_command( codex_home, package, version, registry, OsStr::new(npm_command()), ) } fn materialize_npm_plugin_source_with_command( codex_home: &Path, package: &str, version: Option<&str>, registry: Option<&str>, npm_command: &OsStr, ) -> Result<(AbsolutePathBuf, TempDir), String> { let staging_root = codex_home.join(NPM_PLUGIN_SOURCE_STAGING_DIR); fs::create_dir_all(&staging_root).map_err(|err| { format!( "failed to create marketplace plugin source staging directory {}: {err}", staging_root.display() ) })?; let tempdir = tempfile::Builder::new() .prefix("marketplace-plugin-source-") .tempdir_in(&staging_root) .map_err(|err| { format!( "failed to create marketplace plugin source staging directory in {}: {err}", staging_root.display() ) })?; pack_npm_package(tempdir.path(), package, version, registry, npm_command)?; let archive_path = find_npm_package_archive(tempdir.path())?; let archive_bytes = read_npm_package_archive(&archive_path)?; let extraction_root = tempdir.path().join("extracted"); unpack_plugin_bundle_tar_gz( &archive_bytes, &extraction_root, NPM_PLUGIN_SOURCE_MAX_EXTRACTED_BYTES, ) .map_err(|err| format!("failed to extract npm plugin package: {err}"))?; let plugin_root = extraction_root.join(NPM_PACKAGE_ARCHIVE_ROOT); if !plugin_root.is_dir() { return Err(format!( "npm pack completed without creating plugin package directory {}", plugin_root.display() )); } validate_npm_package_metadata(&plugin_root, package)?; let plugin_root = AbsolutePathBuf::try_from(plugin_root) .map_err(|err| format!("failed to resolve materialized plugin source path: {err}"))?; Ok((plugin_root, tempdir)) } fn pack_npm_package( destination: &Path, package: &str, version: Option<&str>, registry: Option<&str>, npm_command: &OsStr, ) -> Result<(), String> { let package_spec = version.map_or_else( || package.to_string(), |version| format!("{package}@{version}"), ); let mut command = Command::new(npm_command); command .current_dir(destination) .arg("pack") .arg("--ignore-scripts") .arg("--pack-destination") .arg(destination); if let Some(registry) = registry { command.arg("--registry").arg(registry); } command.arg("--").arg(package_spec); let output = command .output() .map_err(|err| format!("failed to run npm pack: {err}"))?; if output.status.success() { return Ok(()); } Err(format!( "npm pack failed with status {}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout).trim(), String::from_utf8_lossy(&output.stderr).trim() )) } fn find_npm_package_archive(destination: &Path) -> Result { let mut archives = fs::read_dir(destination) .map_err(|err| format!("failed to read npm pack destination: {err}"))? .filter_map(std::result::Result::ok) .filter_map(|entry| { let path = entry.path(); let is_file = entry.file_type().is_ok_and(|file_type| file_type.is_file()); (is_file && path.extension() == Some(OsStr::new("tgz"))).then_some(path) }) .collect::>(); if archives.len() != 1 { return Err(format!( "npm pack completed with {} package archives; expected exactly one", archives.len() )); } Ok(archives.remove(0)) } fn read_npm_package_archive(archive_path: &Path) -> Result, String> { let archive_size = fs::metadata(archive_path) .map_err(|err| format!("failed to inspect npm package archive: {err}"))? .len(); if archive_size > NPM_PLUGIN_SOURCE_MAX_ARCHIVE_BYTES { return Err(format!( "npm package archive is {archive_size} bytes, exceeding maximum size of {NPM_PLUGIN_SOURCE_MAX_ARCHIVE_BYTES} bytes" )); } fs::read(archive_path).map_err(|err| format!("failed to read npm package archive: {err}")) } fn validate_npm_package_metadata(plugin_root: &Path, package: &str) -> Result<(), String> { #[derive(Deserialize)] struct NpmPackageMetadata { name: String, } let package_json_path = plugin_root.join("package.json"); let package_json = fs::read_to_string(&package_json_path).map_err(|err| { format!( "failed to read npm plugin package metadata {}: {err}", package_json_path.display() ) })?; let metadata: NpmPackageMetadata = serde_json::from_str(&package_json).map_err(|err| { format!( "failed to parse npm plugin package metadata {}: {err}", package_json_path.display() ) })?; if metadata.name != package { return Err(format!( "npm plugin package name '{}' does not match requested package '{package}'", metadata.name )); } Ok(()) } #[cfg(windows)] fn npm_command() -> &'static str { "npm.cmd" } #[cfg(not(windows))] fn npm_command() -> &'static str { "npm" } #[cfg(all(test, unix))] #[path = "npm_source_tests.rs"] mod tests;