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_file(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_file(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    manifest_from_webc_metadata(container.manifest())
155}
156
157/// Convert a webc manifest into a [`Manifest`], extracting the package metadata.
158///
159/// Note: only the package metadata (name, version, description, etc.) is
160/// extracted; modules, commands, and filesystem mappings are not, because they
161/// are already baked into the webc and are not needed to describe the package.
162pub(super) fn manifest_from_webc_metadata(
163    webc_manifest: &webc::metadata::Manifest,
164) -> anyhow::Result<Manifest> {
165    // Extract package information from the webc manifest
166    let mut manifest = Manifest::new_empty();
167
168    // Get the wapm annotation which contains package metadata
169    let wapm_annotation = webc_manifest
170        .wapm()
171        .map_err(|e| anyhow::anyhow!("Failed to read package annotation from webc: {e}"))?;
172
173    if let Some(wapm) = wapm_annotation {
174        let mut package = wasmer_config::package::Package::new_empty();
175        package.name = wapm.name;
176        package.version = if let Some(v) = wapm.version {
177            Some(v.parse()?)
178        } else {
179            None
180        };
181        package.description = wapm.description;
182        package.license = wapm.license;
183        package.homepage = wapm.homepage;
184        package.repository = wapm.repository;
185        package.private = wapm.private;
186        package.entrypoint = webc_manifest.entrypoint.clone();
187
188        // Only set the package if at least one field is populated
189        // (Package::from_manifest strips name/version/description from WAPM annotation,
190        // so these might be None even for valid packages)
191        manifest.package = Some(package);
192    } else {
193        // No WAPM annotation found - create an empty package
194        manifest.package = Some(wasmer_config::package::Package::new_empty());
195    }
196
197    // Note: We don't need to extract all the details (modules, commands, fs, etc.)
198    // because those are already in the webc and we won't be rebuilding it.
199    // We only need the package metadata for namespace/name/version extraction.
200    // If these are not present in the webc, users can provide them via CLI flags.
201
202    Ok(manifest)
203}
204
205pub(super) async fn login_user(
206    env: &WasmerEnv,
207    interactive: bool,
208    msg: &str,
209) -> anyhow::Result<WasmerClient> {
210    if let Ok(client) = env.client() {
211        return Ok(client);
212    }
213
214    let theme = dialoguer::theme::ColorfulTheme::default();
215
216    if env.token().is_none() {
217        if interactive {
218            eprintln!(
219                "{}: You need to be logged in to {msg}.",
220                "WARN".yellow().bold()
221            );
222
223            if Confirm::with_theme(&theme)
224                .with_prompt("Do you want to login now?")
225                .interact()?
226            {
227                Login {
228                    no_browser: false,
229                    wasmer_dir: env.dir().to_path_buf(),
230                    cache_dir: env.cache_dir().to_path_buf(),
231                    token: None,
232                    registry: env.registry.clone(),
233                }
234                .run_async()
235                .await?;
236            } else {
237                anyhow::bail!("Stopping the flow as the user is not logged in.")
238            }
239        } else {
240            let bin_name = self::macros::bin_name!();
241            eprintln!(
242                "You are not logged in. Use the `--token` flag or log in (use `{bin_name} login`) to {msg}."
243            );
244            anyhow::bail!("Stopping execution as the user is not logged in.")
245        }
246    }
247
248    env.client()
249}
250
251/// Resolve a registry's web frontend host from its GraphQL endpoint, falling
252/// back to the endpoint's own domain for custom registries.
253pub(super) fn registry_web_host(client: &WasmerClient) -> String {
254    let host = client.graphql_endpoint().domain().unwrap_or("wasmer.io");
255
256    // Our special cases..
257    match host {
258        _ if host.contains("wasmer.wtf") => "wasmer.wtf".to_string(),
259        _ if host.contains("wasmer.io") => "wasmer.io".to_string(),
260        _ => host.to_string(),
261    }
262}
263
264/// Build a package's web frontend URL. `version` is rendered verbatim, so pass a
265/// display string like `0.1.3`, not a parsed `VersionReq`.
266pub(super) fn package_web_url(
267    client: &WasmerClient,
268    full_name: &str,
269    version: Option<&str>,
270) -> String {
271    let host = registry_web_host(client);
272    match version {
273        Some(version) => format!("https://{host}/{full_name}@{version}"),
274        None => format!("https://{host}/{full_name}"),
275    }
276}
277
278/// Adapter over [`package_web_url`] for a [`NamedPackageIdent`].
279pub(super) fn package_web_url_for_ident(client: &WasmerClient, pkg: &NamedPackageIdent) -> String {
280    // `*` when no version; an exact requirement renders as `=x.y.z`.
281    let version = pkg.version_or_default().to_string().replace('=', "");
282    package_web_url(client, &pkg.full_name(), Some(&version))
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use anyhow::Context;
289    use humantime::Duration as HumanDuration;
290    use indicatif::ProgressBar;
291    use sha2::{Digest, Sha256};
292    use url::Url;
293    use wasmer_package::package::Package;
294
295    #[tokio::test]
296    #[ignore = "Requires WASMER_REGISTRY_URL/WASMER_TOKEN"]
297    async fn test_upload_package_r2() -> anyhow::Result<()> {
298        let registry = std::env::var("WASMER_REGISTRY_URL")
299            .context("set WASMER_REGISTRY_URL to point at the registry GraphQL endpoint")?;
300        let token = std::env::var("WASMER_TOKEN")
301            .context("set WASMER_TOKEN for the registry under test")?;
302        let client = WasmerClient::new(Url::parse(&registry)?, "wasmer-cli-upload-test")?
303            .with_auth_token(token);
304        let pkg_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
305            .join("../../wasmer-test-files/legacy/coreutils-1.0.11.tar.gz");
306        let package = Package::from_tarball_file(&pkg_path)?;
307        let bytes = package.serialize()?;
308        let hash_bytes: [u8; 32] = Sha256::digest(&bytes).into();
309        let hash = PackageHash::from_sha256_bytes(hash_bytes);
310        let pb = ProgressBar::hidden();
311
312        // Upload should succeed
313        let upload_url = upload(
314            &client,
315            &hash,
316            HumanDuration::from(std::time::Duration::from_secs(300)),
317            package.serialize().unwrap(),
318            pb,
319            None,
320        )
321        .await?;
322        assert!(
323            upload_url.starts_with("http"),
324            "upload returned non-url: {upload_url}"
325        );
326        Ok(())
327    }
328
329    #[test]
330    fn test_get_manifest_from_webc() -> anyhow::Result<()> {
331        use tempfile::TempDir;
332        use wasmer_package::package::Package;
333
334        // Create a temporary directory with a test package
335        let temp_dir = TempDir::new()?;
336        let pkg_dir = temp_dir.path();
337
338        // Create wasmer.toml
339        std::fs::write(
340            pkg_dir.join("wasmer.toml"),
341            r#"
342[package]
343name = "test/mypackage"
344version = "0.1.0"
345description = "Test package for webc manifest extraction"
346
347[fs]
348data = "data"
349"#,
350        )?;
351
352        // Create data directory
353        std::fs::create_dir(pkg_dir.join("data"))?;
354        std::fs::write(pkg_dir.join("data/test.txt"), "Hello World")?;
355
356        // Build the package
357        let pkg = Package::from_manifest(pkg_dir.join("wasmer.toml"))?;
358        let webc_bytes = pkg.serialize()?;
359
360        // Write the webc file
361        let webc_path = pkg_dir.join("test.webc");
362        std::fs::write(&webc_path, &webc_bytes)?;
363
364        // Test that we can extract the manifest from the webc file
365        let (path, manifest) = get_manifest(&webc_path)?;
366
367        assert_eq!(path, webc_path);
368        assert!(
369            manifest.package.is_some(),
370            "manifest.package should be present"
371        );
372
373        let package = manifest.package.unwrap();
374
375        // These should be None because Package strips them
376        assert_eq!(
377            package.name, None,
378            "Package name should be None in webc (stripped by Package::from_manifest)"
379        );
380        assert_eq!(
381            package.version, None,
382            "Package version should be None in webc (stripped by Package::from_manifest)"
383        );
384        assert_eq!(
385            package.description, None,
386            "Package description should be None in webc (stripped by Package::from_manifest)"
387        );
388
389        Ok(())
390    }
391}