File size: 6,191 Bytes
52a9af3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
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<PathBuf, String> {
    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::<Vec<_>>();
    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<Vec<u8>, 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;