wasmer_cli/commands/package/common/
mod.rs

1use crate::{
2    commands::{AsyncCliCommand, Login},
3    config::WasmerEnv,
4    utils::load_package_manifest,
5};
6use bytes::Bytes;
7use colored::Colorize;
8use dialoguer::Confirm;
9use indicatif::{ProgressBar, ProgressStyle};
10use reqwest::Body;
11use std::path::{Path, PathBuf};
12use wasmer_backend_api::{WasmerClient, query::UploadMethod};
13use wasmer_config::package::{Manifest, NamedPackageIdent, PackageHash};
14
15pub mod macros;
16pub mod wait;
17
18pub(super) fn on_error(e: anyhow::Error) -> anyhow::Error {
19    #[cfg(feature = "telemetry")]
20    sentry::integrations::anyhow::capture_anyhow(&e);
21
22    e
23}
24
25// HACK: We want to invalidate the cache used for GraphQL queries so
26// the current user sees the results of publishing immediately. There
27// are cleaner ways to achieve this, but for now we're just going to
28// clear out the whole GraphQL query cache.
29// See https://github.com/wasmerio/wasmer/pull/3983 for more
30pub(super) fn invalidate_graphql_query_cache(cache_dir: &Path) -> Result<(), anyhow::Error> {
31    let cache_dir = cache_dir.join("queries");
32    std::fs::remove_dir_all(cache_dir)?;
33
34    Ok(())
35}
36
37// Upload a package to a signed url.
38pub(super) async fn upload(
39    client: &WasmerClient,
40    hash: &PackageHash,
41    timeout: humantime::Duration,
42    bytes: Bytes,
43    pb: ProgressBar,
44    proxy: Option<reqwest::Proxy>,
45) -> anyhow::Result<String> {
46    let hash_str = hash.to_string();
47    let hash_str = hash_str.trim_start_matches("sha256:");
48
49    let session_uri = {
50        let default_timeout_secs = Some(60 * 30);
51        let q = wasmer_backend_api::query::get_signed_url_for_package_upload(
52            client,
53            default_timeout_secs,
54            Some(hash_str),
55            None,
56            None,
57            Some(UploadMethod::R2),
58        );
59
60        match q.await? {
61            Some(u) => u.url,
62            None => anyhow::bail!(
63                "The backend did not provide a valid signed URL to upload the package"
64            ),
65        }
66    };
67
68    tracing::info!("signed url is: {session_uri}");
69
70    let client = {
71        let builder = reqwest::Client::builder()
72            .default_headers(reqwest::header::HeaderMap::default())
73            .timeout(timeout.into());
74
75        let builder = if let Some(proxy) = proxy {
76            builder.proxy(proxy)
77        } else {
78            builder
79        };
80
81        builder.build().unwrap()
82    };
83
84    let total_bytes = bytes.len();
85    pb.set_length(total_bytes.try_into().unwrap());
86    pb.set_style(ProgressStyle::with_template("{spinner:.yellow} [{elapsed_precise}] [{bar:.white}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")
87                 .unwrap()
88                 .progress_chars("█▉▊▋▌▍▎▏  ")
89                 .tick_strings(&["✶", "✸", "✹", "✺", "✹", "✷", "✶"]));
90    tracing::info!("webc is {total_bytes} bytes long");
91
92    let chunk_size = 8 * 1024;
93
94    let stream = futures::stream::unfold(0, move |offset| {
95        let pb = pb.clone();
96        let bytes = bytes.clone();
97        async move {
98            if offset >= total_bytes {
99                return None;
100            }
101
102            let start = offset;
103
104            let end = if (start + chunk_size) >= total_bytes {
105                total_bytes
106            } else {
107                start + chunk_size
108            };
109
110            let n = end - start;
111            let next_chunk = bytes.slice(start..end);
112            pb.inc(n as u64);
113
114            Some((Ok::<_, std::io::Error>(next_chunk), offset + n))
115        }
116    });
117
118    let res = client
119        .put(&session_uri)
120        .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
121        .header(reqwest::header::CONTENT_LENGTH, format!("{total_bytes}"))
122        .body(Body::wrap_stream(stream));
123
124    res.send()
125        .await
126        .map(|response| response.error_for_status())
127        .map_err(|e| anyhow::anyhow!("error uploading package to {session_uri}: {e}"))??;
128
129    Ok(session_uri)
130}
131
132/// Read and return a manifest given a path.
133///
134// The difference with the `load_package_manifest` is that
135// this function returns an error if no manifest is found.
136pub(super) fn get_manifest(path: &Path) -> anyhow::Result<(PathBuf, Manifest)> {
137    // Check if the path is a .webc file
138    if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("webc") {
139        return Ok((path.to_path_buf(), get_manifest_from_webc(path)?));
140    }
141
142    load_package_manifest(path).and_then(|j| {
143        j.ok_or_else(|| anyhow::anyhow!("No valid manifest found in path '{}'", path.display()))
144    })
145}
146
147/// Load a manifest from a .webc file
148fn get_manifest_from_webc(path: &Path) -> anyhow::Result<Manifest> {
149    use wasmer_package::utils::from_disk;
150
151    let container = from_disk(path)
152        .map_err(|e| anyhow::anyhow!("Failed to load webc file '{}': {}", path.display(), e))?;
153
154    let webc_manifest = container.manifest();
155
156    // Extract package information from the webc manifest
157    let mut manifest = Manifest::new_empty();
158
159    // Get the wapm annotation which contains package metadata
160    let wapm_annotation = webc_manifest
161        .wapm()
162        .map_err(|e| anyhow::anyhow!("Failed to read package annotation from webc: {e}"))?;
163
164    if let Some(wapm) = wapm_annotation {
165        let mut package = wasmer_config::package::Package::new_empty();
166        package.name = wapm.name;
167        package.version = if let Some(v) = wapm.version {
168            Some(v.parse()?)
169        } else {
170            None
171        };
172        package.description = wapm.description;
173        package.license = wapm.license;
174        package.homepage = wapm.homepage;
175        package.repository = wapm.repository;
176        package.private = wapm.private;
177        package.entrypoint = webc_manifest.entrypoint.clone();
178
179        // Only set the package if at least one field is populated
180        // (Package::from_manifest strips name/version/description from WAPM annotation,
181        // so these might be None even for valid packages)
182        manifest.package = Some(package);
183    } else {
184        // No WAPM annotation found - create an empty package
185        manifest.package = Some(wasmer_config::package::Package::new_empty());
186    }
187
188    // Note: We don't need to extract all the details (modules, commands, fs, etc.)
189    // because those are already in the webc and we won't be rebuilding it.
190    // We only need the package metadata for namespace/name/version extraction.
191    // If these are not present in the webc, users can provide them via CLI flags.
192
193    Ok(manifest)
194}
195
196pub(super) async fn login_user(
197    env: &WasmerEnv,
198    interactive: bool,
199    msg: &str,
200) -> anyhow::Result<WasmerClient> {
201    if let Ok(client) = env.client() {
202        return Ok(client);
203    }
204
205    let theme = dialoguer::theme::ColorfulTheme::default();
206
207    if env.token().is_none() {
208        if interactive {
209            eprintln!(
210                "{}: You need to be logged in to {msg}.",
211                "WARN".yellow().bold()
212            );
213
214            if Confirm::with_theme(&theme)
215                .with_prompt("Do you want to login now?")
216                .interact()?
217            {
218                Login {
219                    no_browser: false,
220                    wasmer_dir: env.dir().to_path_buf(),
221                    cache_dir: env.cache_dir().to_path_buf(),
222                    token: None,
223                    registry: env.registry.clone(),
224                }
225                .run_async()
226                .await?;
227            } else {
228                anyhow::bail!("Stopping the flow as the user is not logged in.")
229            }
230        } else {
231            let bin_name = self::macros::bin_name!();
232            eprintln!(
233                "You are not logged in. Use the `--token` flag or log in (use `{bin_name} login`) to {msg}."
234            );
235            anyhow::bail!("Stopping execution as the user is not logged in.")
236        }
237    }
238
239    env.client()
240}
241
242pub(super) fn make_package_url(client: &WasmerClient, pkg: &NamedPackageIdent) -> String {
243    let host = client.graphql_endpoint().domain().unwrap_or("wasmer.io");
244
245    // Our special cases..
246    let host = match host {
247        _ if host.contains("wasmer.wtf") => "wasmer.wtf",
248        _ if host.contains("wasmer.io") => "wasmer.io",
249        _ => host,
250    };
251
252    format!(
253        "https://{host}/{}@{}",
254        pkg.full_name(),
255        pkg.version_or_default().to_string().replace('=', "")
256    )
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use anyhow::Context;
263    use humantime::Duration as HumanDuration;
264    use indicatif::ProgressBar;
265    use sha2::{Digest, Sha256};
266    use url::Url;
267    use wasmer_package::package::Package;
268
269    #[tokio::test]
270    #[ignore = "Requires WASMER_REGISTRY_URL/WASMER_TOKEN"]
271    async fn test_upload_package_r2() -> anyhow::Result<()> {
272        let registry = std::env::var("WASMER_REGISTRY_URL")
273            .context("set WASMER_REGISTRY_URL to point at the registry GraphQL endpoint")?;
274        let token = std::env::var("WASMER_TOKEN")
275            .context("set WASMER_TOKEN for the registry under test")?;
276        let client = WasmerClient::new(Url::parse(&registry)?, "wasmer-cli-upload-test")?
277            .with_auth_token(token);
278        let pkg_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
279            .join("../../tests/old-tar-gz/cowsay-0.3.0.tar.gz");
280        let package = Package::from_tarball_file(&pkg_path)?;
281        let bytes = package.serialize()?;
282        let hash_bytes: [u8; 32] = Sha256::digest(&bytes).into();
283        let hash = PackageHash::from_sha256_bytes(hash_bytes);
284        let pb = ProgressBar::hidden();
285
286        // Upload should succeed
287        let upload_url = upload(
288            &client,
289            &hash,
290            HumanDuration::from(std::time::Duration::from_secs(300)),
291            package.serialize().unwrap(),
292            pb,
293            None,
294        )
295        .await?;
296        assert!(
297            upload_url.starts_with("http"),
298            "upload returned non-url: {upload_url}"
299        );
300        Ok(())
301    }
302
303    #[test]
304    fn test_get_manifest_from_webc() -> anyhow::Result<()> {
305        use tempfile::TempDir;
306        use wasmer_package::package::Package;
307
308        // Create a temporary directory with a test package
309        let temp_dir = TempDir::new()?;
310        let pkg_dir = temp_dir.path();
311
312        // Create wasmer.toml
313        std::fs::write(
314            pkg_dir.join("wasmer.toml"),
315            r#"
316[package]
317name = "test/mypackage"
318version = "0.1.0"
319description = "Test package for webc manifest extraction"
320
321[fs]
322data = "data"
323"#,
324        )?;
325
326        // Create data directory
327        std::fs::create_dir(pkg_dir.join("data"))?;
328        std::fs::write(pkg_dir.join("data/test.txt"), "Hello World")?;
329
330        // Build the package
331        let pkg = Package::from_manifest(pkg_dir.join("wasmer.toml"))?;
332        let webc_bytes = pkg.serialize()?;
333
334        // Write the webc file
335        let webc_path = pkg_dir.join("test.webc");
336        std::fs::write(&webc_path, &webc_bytes)?;
337
338        // Test that we can extract the manifest from the webc file
339        let (path, manifest) = get_manifest(&webc_path)?;
340
341        assert_eq!(path, webc_path);
342        assert!(
343            manifest.package.is_some(),
344            "manifest.package should be present"
345        );
346
347        let package = manifest.package.unwrap();
348
349        // These should be None because Package strips them
350        assert_eq!(
351            package.name, None,
352            "Package name should be None in webc (stripped by Package::from_manifest)"
353        );
354        assert_eq!(
355            package.version, None,
356            "Package version should be None in webc (stripped by Package::from_manifest)"
357        );
358        assert_eq!(
359            package.description, None,
360            "Package description should be None in webc (stripped by Package::from_manifest)"
361        );
362
363        Ok(())
364    }
365}