use std::{
io::{Cursor, Seek},
path::PathBuf,
};
use anstream::eprintln;
use anyhow::{Context, anyhow, bail, ensure};
use clap::{Args, Parser, Subcommand, ValueEnum};
use futures_util::TryStreamExt;
use tokio::io::AsyncWriteExt;
use tracing::level_filters::LevelFilter;
use wasm_pkg_client::{
Client, PackageRef, PublishOpts, Version,
caching::{CachingClient, FileCache},
};
use wasm_pkg_common::{
self,
config::{Config, RegistryMapping},
package::PackageSpec,
registry::Registry,
};
use wasm_pkg_core::{
lock::LockFile,
manifest::{Manifest, workspace::WorkspaceRootConfig},
};
use wit_component::DecodedWasm;
mod oci;
mod overlay;
mod wit;
use oci::OciCommands;
use wit::{BuildArgs, FetchArgs, UpdateArgs, WitCommands};
use crate::{overlay::PublishVerifier, wit::temp_wit_file};
#[macro_export]
macro_rules! warnln {
($($arg:tt)*) => {{
let style = ::anstyle::AnsiColor::Yellow.on_default() | ::anstyle::Effects::BOLD;
::anstream::eprintln!("{style}warning{style:#}: {}", format_args!($($arg)*));
}};
}
#[macro_export]
macro_rules! statusln {
($label:expr, $($arg:tt)*) => {{
let style = ::anstyle::AnsiColor::Cyan.on_default() | ::anstyle::Effects::BOLD;
::anstream::eprintln!(
"{style}{:>12}{style:#} {}",
$label,
format_args!($($arg)*),
);
}};
}
#[macro_export]
macro_rules! helpln {
($($arg:tt)*) => {{
let style = ::anstyle::AnsiColor::Magenta.on_default() | ::anstyle::Effects::BOLD;
::anstream::eprintln!("{style}{:>7}{style:#}: {}", "help", format_args!($($arg)*));
}};
}
#[derive(Parser, Debug)]
#[command(version)]
struct Cli {
#[command(flatten)]
color: colorchoice_clap::Color,
#[command(subcommand)]
command: Commands,
}
#[derive(Args, Debug)]
struct RegistryArgs {
#[arg(long = "registry", value_name = "REGISTRY", env = "WKG_REGISTRY")]
registry: Option<Registry>,
}
#[derive(Args, Debug, Default)]
struct Common {
#[arg(long = "config", value_name = "CONFIG", env = "WKG_CONFIG_FILE")]
config: Option<PathBuf>,
#[arg(long = "cache", value_name = "CACHE", env = "WKG_CACHE_DIR")]
cache: Option<PathBuf>,
}
impl Common {
pub async fn load_config(&self) -> anyhow::Result<Config> {
if let Some(config_file) = self.config.as_ref() {
tracing::info!(config = %config_file.display());
Config::from_file(config_file)
.await
.context(format!("error loading config file {config_file:?}"))
} else {
Config::global_defaults().await.map_err(anyhow::Error::from)
}
}
pub async fn load_cache(&self) -> anyhow::Result<FileCache> {
let dir = if let Some(dir) = self.cache.as_ref() {
dir.clone()
} else {
FileCache::global_cache_path().context("unable to find cache directory")?
};
FileCache::new(dir).await
}
pub async fn get_client(&self) -> anyhow::Result<CachingClient<FileCache>> {
let config = self.load_config().await?;
let cache = self.load_cache().await?;
let client = Client::new(config);
tracing::debug!(filecache_dir = %cache);
Ok(CachingClient::new(Some(client), cache))
}
}
#[derive(Subcommand, Debug)]
#[allow(clippy::large_enum_variant)]
enum Commands {
Config(ConfigArgs),
Get(GetArgs),
Publish(PublishArgs),
#[clap(subcommand)]
Oci(OciCommands),
Build(BuildArgs),
Fetch(FetchArgs),
Update(UpdateArgs),
#[clap(subcommand)]
Wit(WitCommands),
}
#[derive(Args, Debug)]
struct ConfigArgs {
#[arg(long = "default-registry", value_name = "DEFAULT_REGISTRY")]
default_registry: Option<Registry>,
#[arg(long, short, action)]
edit: bool,
#[command(flatten)]
common: Common,
}
impl ConfigArgs {
pub async fn run(self) -> anyhow::Result<()> {
let path = if let Some(path) = self.common.config {
path
} else {
Config::global_config_path().ok_or(anyhow!("global config path not available"))?
};
if let Some(parent) = path.parent() {
match tokio::fs::metadata(parent).await {
Ok(metadata) => {
if !metadata.is_dir() {
anyhow::bail!("parent directory is not a directory");
}
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
tokio::fs::create_dir_all(parent).await?;
} else {
anyhow::bail!("failed to check for config file directory: {}", e);
}
}
}
}
if self.edit {
let editor = std::env::var("EDITOR").or(Err(anyhow!(
"failed to read `$EDITOR` environment variable"
)))?;
if !path.is_file() {
Config::default().to_file(&path).await?;
}
tokio::process::Command::new(editor)
.arg(&path)
.status()
.await
.context("failed to launch editor")?;
return Ok(());
}
let mut config = match tokio::fs::read_to_string(&path).await {
Ok(contents) => Config::from_toml(&contents)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(),
Err(err) => return Err(anyhow!("error reading config file: {0}", err)),
};
if let Some(default) = self.default_registry {
config.set_default_registry(Some(default));
config.to_file(&path).await?;
eprintln!("Updated config file: {path}", path = path.display());
}
if let Some(registry) = config.default_registry() {
eprintln!("Default registry: {}", registry);
} else {
warnln!("Default registry is not set");
}
Ok(())
}
}
#[derive(Args, Debug)]
struct GetArgs {
#[arg(long, short, default_value = "./")]
output: PathBuf,
#[arg(long, value_enum, default_value = "auto")]
format: Format,
#[arg(long, conflicts_with = "overwrite")]
check: bool,
#[arg(long)]
overwrite: bool,
package_spec: PackageSpec,
#[command(flatten)]
registry_args: RegistryArgs,
#[command(flatten)]
common: Common,
}
#[derive(Args, Debug)]
struct PublishArgs {
paths: Vec<PathBuf>,
#[command(flatten)]
registry_args: RegistryArgs,
#[arg(long, env = "WKG_PACKAGE")]
package: Option<PackageSpec>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
workspace: bool,
#[arg(long)]
skip_semver_check: bool,
#[arg(long)]
skip_dupes: bool,
#[command(flatten)]
common: Common,
}
impl PublishArgs {
pub async fn run(mut self) -> anyhow::Result<()> {
let publish_opts = self.publish_opts()?;
let _root = self.workspace_root().await?;
let path = match &self.paths[..] {
[] => {
anyhow::bail!(
"no publish targets: pass one or more paths, or run from a workspace \
(see `[workspace] members` in `wkg.toml`)"
);
}
[path] => path,
paths => {
let mut lock_file = LockFile::load(false).await?;
let verifier = PublishVerifier::try_new(
paths,
"tmp_local_publish",
self.common.load_config().await?,
self.common.load_cache().await?,
&mut lock_file,
true,
)
.await?;
let mut plan = verifier.plan;
anstream::eprintln!("{plan}");
let client = self.common.get_client().await?;
while !plan.is_empty() {
let ready_for_publish = plan.take_ready();
for spec in &ready_for_publish {
let data = verifier
.data
.get(&spec.package)
.expect("missing package ref");
let source = Box::pin(Cursor::new(data.clone()));
let res = client
.client()?
.publish_release_data(source, publish_opts.clone())
.await;
self.handle_publish_result(res).context(spec.clone())?;
}
plan.mark_confirmed(ready_for_publish);
}
return Ok(());
}
};
let client = self.common.get_client().await?;
let (publish_path, _tmp) = if path.is_dir() {
let mut lock_file = LockFile::load(true).await?;
let prev_lock_ref = (lock_file.version, lock_file.packages.clone());
let (pkg_ref, _, bytes) =
wit::build_wit_dir(&path.clone(), client.clone(), &mut lock_file).await?;
if lock_file != prev_lock_ref && !self.dry_run {
return Err(anyhow!(
"wkg.lock would be updated during publish, aborting"
))
.with_context(|| {
format!(
"Run `wkg wit build {}` before attempting to publish",
path.display()
)
});
}
let tmp = temp_wit_file(&pkg_ref, &bytes).await?;
(tmp.path().to_path_buf(), Some(tmp))
} else {
(path.clone(), None)
};
let res = client
.client()?
.publish_release_file(&publish_path, publish_opts)
.await;
self.handle_publish_result(res)?;
Ok(())
}
fn publish_opts(&mut self) -> anyhow::Result<PublishOpts> {
let package = match self.package.clone() {
Some(_) if self.paths.len() != 1 => {
anyhow::bail!(
"`--package` is currently only supported when providing one path argument"
);
}
Some(PackageSpec {
package,
version: Some(v),
}) => Some((package, v)),
Some(PackageSpec { version: None, .. }) => {
anyhow::bail!("version is required when manually overriding the package ID");
}
None => None,
};
Ok(PublishOpts {
package,
registry: self.registry_args.registry.clone(),
dry_run: self.dry_run,
skip_semver_check: self.skip_semver_check,
})
}
fn handle_publish_result(
&self,
res: Result<(PackageRef, Version), wasm_pkg_common::Error>,
) -> Result<(), anyhow::Error> {
match res {
Err(e @ wasm_pkg_common::Error::VersionAlreadyExists(..)) if self.skip_dupes => {
warnln!("Skipping publish: {e}");
}
Ok((package, version)) => {
if self.dry_run {
warnln!("Aborting publish due to dry run: {}@{}", package, version);
} else {
eprintln!("Published {}@{}", package, version);
}
}
Err(e) => return Err(e.into()),
}
Ok(())
}
async fn workspace_root(&mut self) -> anyhow::Result<Option<WorkspaceRootConfig>> {
match self.workspace {
true if !self.paths.is_empty() => anyhow::bail!(
"`--workspace` selects every workspace member; do not also pass explicit \
path arguments"
),
true => {}
false => return Ok(None),
}
let cwd = std::env::current_dir()?;
let Some(root) = Manifest::load_root_workspace(&cwd).await? else {
bail!(
"`--workspace` called but unable to find workspace root from {}",
cwd.display(),
)
};
self.paths = root.members.clone();
Ok(Some(root))
}
}
#[derive(ValueEnum, Clone, Debug, PartialEq)]
enum Format {
Auto,
Wasm,
Wit,
}
impl GetArgs {
pub async fn run(self) -> anyhow::Result<()> {
let PackageSpec { package, version } = self.package_spec;
let mut config = self.common.load_config().await?;
if let Some(registry) = self.registry_args.registry.clone() {
tracing::debug!(%package, %registry, "overriding package registry");
config.set_package_registry_override(
package.clone(),
RegistryMapping::Registry(registry),
);
}
let client = Client::new(config);
let cache = self.common.load_cache().await?;
let client = CachingClient::new(Some(client), cache);
let version = match version {
Some(ver) => ver,
None => {
warnln!("no version specified; fetching version list...");
statusln!("Fetching", "version list");
let versions = client.list_all_versions(&package).await?;
tracing::trace!(?versions, "Fetched version list");
versions
.into_iter()
.filter_map(|vi| (!vi.yanked).then_some(vi.version))
.max()
.context("No releases found")?
}
};
statusln!("Fetching", "{package}@{version}...");
let release = client
.get_release(&package, &version)
.await
.context("Failed to get release details")?;
tracing::debug!(?release, "Fetched release details");
let output_trailing_slash = self.output.as_os_str().to_string_lossy().ends_with('/');
let parent_dir_path = if output_trailing_slash {
let parent_dir_path = self.output.as_path();
if !tokio::fs::try_exists(parent_dir_path).await? {
tokio::fs::create_dir_all(parent_dir_path)
.await
.with_context(|| {
format!(
"failed to create output dir @ [{}]",
parent_dir_path.display()
)
})?;
}
parent_dir_path
} else {
self.output
.parent()
.context("Failed to resolve non-trailing-slash output parent dir")?
};
ensure!(
parent_dir_path.exists(),
"output directory {:?} does not exist; create it first or choose a different path",
parent_dir_path
);
let (tmp_file, tmp_path) =
tempfile::NamedTempFile::with_prefix_in(".wkg-get", parent_dir_path)?.into_parts();
tracing::debug!(?tmp_path, "Created temporary file");
let mut content_stream = client.get_content(&package, &release).await?;
let mut file = tokio::fs::File::from_std(tmp_file);
while let Some(chunk) = content_stream.try_next().await? {
file.write_all(&chunk).await?;
}
let mut format = self.format;
if let (Format::Auto, Some(ext)) = (&format, self.output.extension()) {
tracing::debug!(?ext, "Inferring output format from file extension");
format = match ext.to_string_lossy().as_ref() {
"wasm" => Format::Wasm,
"wit" => Format::Wit,
_ => {
warnln!(
"couldn't infer output format from file name {:?}",
self.output.file_name().unwrap_or_default()
);
Format::Auto
}
}
}
let wit = if format == Format::Wasm {
None
} else {
let mut file = file.into_std().await;
file.rewind()?;
match wit_component::decode_reader(&mut file) {
Ok(DecodedWasm::WitPackage(resolve, pkg)) => {
tracing::debug!(?pkg, "decoded WIT package");
let mut printer = wit_component::WitPrinter::default();
printer.print(&resolve, pkg, &[])?;
Some(printer.output.to_string())
}
Ok(_) => None,
Err(err) => {
warnln!("unable to decode WIT package");
if format == Format::Wit {
return Err(err);
}
eprintln!("failed to detect package content type: {err:#}");
None
}
}
};
let output_path = if output_trailing_slash {
let ext = if wit.is_some() { "wit" } else { "wasm" };
self.output.join(format!(
"{namespace}_{name}@{version}.{ext}",
namespace = package.namespace(),
name = package.name(),
))
} else {
self.output
};
if self.check {
let existing = tokio::fs::read(&output_path)
.await
.with_context(|| format!("Failed to read {output_path:?}"))?;
let latest = if let Some(wit) = wit {
wit.into_bytes()
} else {
tokio::fs::read(&tmp_path)
.await
.with_context(|| format!("Failed to read {tmp_path:?}"))?
};
if existing != latest {
anyhow::bail!("Differences between retrieved and {output_path:?}");
}
} else {
ensure!(
self.overwrite || !output_path.exists(),
"{output_path:?} already exists; you can use '--overwrite' to overwrite it"
);
if let Some(wit) = wit {
tokio::fs::write(&output_path, wit)
.await
.with_context(|| format!("Failed to write WIT to {output_path:?}"))?
} else {
tmp_path
.persist(&output_path)
.with_context(|| format!("Failed to persist WASM to {output_path:?}"))?
}
eprintln!("Wrote '{}'", output_path.display());
}
Ok(())
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.from_env_lossy(),
)
.init();
let cli = Cli::parse();
cli.color.write_global();
match cli.command {
Commands::Config(args) => args.run().await,
Commands::Get(args) => args.run().await,
Commands::Publish(args) => args.run().await,
Commands::Oci(args) => args.run().await,
Commands::Build(args) => args.run().await,
Commands::Fetch(args) => args.run().await,
Commands::Update(args) => args.run().await,
Commands::Wit(args) => {
warnln!("`wkg wit <command>` is deprecated");
helpln!("use `wkg <command>` instead");
args.run().await
}
}
}