mod component_scan;
mod registry_gen_wat;
mod wac_gen;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use indexmap::IndexMap;
use std::{
collections::HashMap,
fs,
io::{IsTerminal, Write},
path::{Path, PathBuf},
};
use tracing_subscriber::{EnvFilter, fmt};
use wac_graph::{CompositionGraph, EncodeOptions};
use wac_parser::Document;
use wac_resolver::{FileSystemPackageResolver, packages};
use wac_types::{BorrowedPackageKey, Package};
use crate::component_scan::{scan_commands, verify_defaults};
use crate::registry_gen_wat::{
generate_registry_wat, get_prebuilt_registry, should_use_prebuilt_registry,
};
use crate::wac_gen::generate_wac;
#[derive(Parser)]
#[command(name = "wacli")]
#[command(version, about = "WebAssembly Component composition CLI", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init(InitArgs),
Build(BuildArgs),
Compose(ComposeArgs),
Plug(PlugArgs),
}
#[derive(Parser)]
struct BuildArgs {
#[arg(long, default_value = "example:my-cli")]
name: String,
#[arg(long, default_value = "0.1.0")]
version: String,
#[arg(short, long, default_value = "my-cli.component.wasm")]
output: PathBuf,
#[arg(long)]
no_validate: bool,
#[arg(long)]
print_wac: bool,
}
#[derive(Parser)]
struct InitArgs {
#[arg(value_name = "DIR")]
dir: Option<PathBuf>,
}
#[derive(Parser)]
struct ComposeArgs {
#[arg(value_name = "FILE")]
path: PathBuf,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long, default_value = "deps")]
deps_dir: PathBuf,
#[arg(short = 'd', long = "dep", value_name = "PKG=PATH")]
deps: Vec<String>,
#[arg(long)]
no_validate: bool,
}
#[derive(Parser)]
struct PlugArgs {
#[arg(value_name = "SOCKET")]
socket: PathBuf,
#[arg(long = "plug", value_name = "FILE", required = true)]
plugs: Vec<PathBuf>,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
}
fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(async {
match cli.command {
Commands::Init(args) => init(args),
Commands::Build(args) => build(args).await,
Commands::Compose(args) => compose(args).await,
Commands::Plug(args) => plug(args),
}
})
}
fn init(args: InitArgs) -> Result<()> {
let dir = args.dir.unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&dir)
.with_context(|| format!("failed to create directory: {}", dir.display()))?;
let defaults_dir = dir.join("defaults");
let commands_dir = dir.join("commands");
fs::create_dir_all(&defaults_dir)
.with_context(|| format!("failed to create directory: {}", defaults_dir.display()))?;
fs::create_dir_all(&commands_dir)
.with_context(|| format!("failed to create directory: {}", commands_dir.display()))?;
eprintln!("Created:");
eprintln!(" {}", defaults_dir.display());
eprintln!(" {}", commands_dir.display());
eprintln!();
eprintln!("Next steps:");
eprintln!(" 1. Place host.component.wasm and core.component.wasm in defaults/");
eprintln!(" 2. Place your command components in commands/");
eprintln!(" 3. Run: wacli build");
Ok(())
}
async fn build(args: BuildArgs) -> Result<()> {
tracing::debug!("executing build command");
let defaults_dir = PathBuf::from("defaults");
let commands_dir = PathBuf::from("commands");
let (host_path, core_path) = verify_defaults(&defaults_dir)?;
let commands = scan_commands(&commands_dir)?;
tracing::info!("found {} command(s)", commands.len());
for cmd in &commands {
tracing::debug!(" - {}: {}", cmd.name, cmd.path.display());
}
let registry_path = if should_use_prebuilt_registry(&defaults_dir) {
get_prebuilt_registry(&defaults_dir).unwrap()
} else {
tracing::info!("generating registry component...");
tracing::info!("using WAT template registry generator");
let registry_bytes =
generate_registry_wat(&commands).context("failed to generate registry (WAT)")?;
let generated_path = defaults_dir.join("registry.component.wasm");
fs::write(&generated_path, ®istry_bytes)
.context("failed to write generated registry")?;
tracing::info!("generated: {}", generated_path.display());
generated_path
};
let wac_source = generate_wac(&args.name, &commands);
if args.print_wac {
println!("{}", wac_source);
return Ok(());
}
let mut deps: HashMap<String, PathBuf> = HashMap::new();
deps.insert("wacli:host".to_string(), host_path);
deps.insert("wacli:core".to_string(), core_path);
deps.insert("wacli:registry".to_string(), registry_path);
for cmd in &commands {
deps.insert(cmd.package_name(), cmd.path.clone());
}
let wac_path = PathBuf::from("<generated>");
let document = Document::parse(&wac_source).map_err(|e| fmt_err(e, &wac_path))?;
let resolver = FileSystemPackageResolver::new(".", deps, false);
let keys = packages(&document).map_err(|e| fmt_err(e, &wac_path))?;
let resolved_packages: IndexMap<BorrowedPackageKey<'_>, Vec<u8>> = resolver.resolve(&keys)?;
let mut missing: Vec<_> = keys
.keys()
.filter(|k| !resolved_packages.contains_key(*k))
.collect();
if !missing.is_empty() {
missing.sort_by_key(|k| k.name);
let names: Vec<_> = missing.iter().map(|k| k.name).collect();
bail!("unresolved packages: {}", names.join(", "));
}
let resolution = document
.resolve(resolved_packages)
.map_err(|e| fmt_err(e, &wac_path))?;
let bytes = resolution.encode(EncodeOptions {
define_components: true,
validate: !args.no_validate,
..Default::default()
})?;
if let Some(parent) = args.output.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
}
fs::write(&args.output, &bytes)
.with_context(|| format!("failed to write output file: {}", args.output.display()))?;
eprintln!("Built: {}", args.output.display());
Ok(())
}
fn fmt_err(e: impl std::fmt::Display, path: &Path) -> anyhow::Error {
anyhow::Error::msg(format!("{}: {}", path.display(), e))
}
fn parse_dep(s: &str) -> Result<(String, PathBuf)> {
let (k, v) = s
.split_once('=')
.context("dependency format should be PKG=PATH")?;
Ok((k.trim().to_string(), PathBuf::from(v.trim())))
}
async fn compose(args: ComposeArgs) -> Result<()> {
tracing::debug!("executing compose command");
let contents = fs::read_to_string(&args.path)
.with_context(|| format!("failed to read file `{}`", args.path.display()))?;
let document = Document::parse(&contents).map_err(|e| fmt_err(e, &args.path))?;
let overrides: HashMap<String, PathBuf> = args
.deps
.iter()
.map(|s| parse_dep(s))
.collect::<Result<_>>()?;
let resolver = FileSystemPackageResolver::new(&args.deps_dir, overrides, false);
let keys = packages(&document).map_err(|e| fmt_err(e, &args.path))?;
let resolved_packages: IndexMap<BorrowedPackageKey<'_>, Vec<u8>> = resolver.resolve(&keys)?;
let mut missing: Vec<_> = keys
.keys()
.filter(|k| !resolved_packages.contains_key(*k))
.collect();
if !missing.is_empty() {
missing.sort_by_key(|k| k.name);
let names: Vec<_> = missing.iter().map(|k| k.name).collect();
bail!(
"unresolved packages: {}. Use --dep or place in deps directory.",
names.join(", ")
);
}
let resolution = document
.resolve(resolved_packages)
.map_err(|e| fmt_err(e, &args.path))?;
if args.output.is_none() && std::io::stdout().is_terminal() {
bail!("cannot print binary wasm output to terminal; use -o to specify output file");
}
let bytes = resolution.encode(EncodeOptions {
define_components: true,
validate: !args.no_validate,
..Default::default()
})?;
match args.output {
Some(path) => {
fs::write(&path, &bytes)
.with_context(|| format!("failed to write output file `{}`", path.display()))?;
eprintln!("Composed: {}", path.display());
}
None => {
std::io::stdout()
.write_all(&bytes)
.context("failed to write to stdout")?;
}
}
Ok(())
}
fn plug(args: PlugArgs) -> Result<()> {
tracing::debug!("executing plug command");
let mut graph = CompositionGraph::new();
let socket_bytes = fs::read(&args.socket)
.with_context(|| format!("failed to read socket `{}`", args.socket.display()))?;
let socket_pkg = Package::from_bytes("socket", None, socket_bytes, graph.types_mut())?;
let socket = graph.register_package(socket_pkg)?;
let mut plug_ids = Vec::new();
for (i, plug_path) in args.plugs.iter().enumerate() {
let plug_bytes = fs::read(plug_path)
.with_context(|| format!("failed to read plug `{}`", plug_path.display()))?;
let name = format!("plug{}", i);
let plug_pkg = Package::from_bytes(&name, None, plug_bytes, graph.types_mut())?;
let plug_id = graph.register_package(plug_pkg)?;
plug_ids.push(plug_id);
}
wac_graph::plug(&mut graph, plug_ids, socket)?;
let bytes = graph.encode(EncodeOptions::default())?;
if args.output.is_none() && std::io::stdout().is_terminal() {
bail!("cannot print binary wasm output to terminal; use -o to specify output file");
}
match args.output {
Some(path) => {
fs::write(&path, &bytes)
.with_context(|| format!("failed to write output file `{}`", path.display()))?;
eprintln!("Plugged: {}", path.display());
}
None => {
std::io::stdout()
.write_all(&bytes)
.context("failed to write to stdout")?;
}
}
Ok(())
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}