mod component_scan;
mod manifest;
mod registry_gen_wat;
mod wac_gen;
mod wit;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use indexmap::IndexMap;
use self_update::{Status, backends::github::Update};
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};
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),
#[cfg(feature = "runtime")]
Run(RunArgs),
SelfUpdate(SelfUpdateArgs),
}
#[derive(Parser)]
struct BuildArgs {
#[arg(long, value_name = "FILE")]
manifest: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
version: Option<String>,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long = "defaults-dir", value_name = "DIR")]
defaults_dir: Option<PathBuf>,
#[arg(long = "commands-dir", value_name = "DIR")]
commands_dir: Option<PathBuf>,
#[arg(long)]
no_validate: bool,
#[arg(long)]
print_wac: bool,
#[arg(long)]
use_prebuilt_registry: bool,
}
#[derive(Parser)]
struct InitArgs {
#[arg(value_name = "DIR")]
dir: Option<PathBuf>,
#[arg(long)]
with_components: bool,
#[arg(long)]
overwrite: bool,
}
#[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>,
}
#[cfg(feature = "runtime")]
#[derive(Parser)]
struct RunArgs {
#[arg(value_name = "COMPONENT")]
component: PathBuf,
#[arg(long = "dir", value_name = "HOST[::GUEST]")]
dirs: Vec<String>,
#[arg(value_name = "ARGS", trailing_var_arg = true)]
args: Vec<String>,
}
#[derive(Parser)]
struct SelfUpdateArgs {
#[arg(long)]
version: Option<String>,
}
fn main() {
init_tracing();
let cli = Cli::parse();
if let Err(err) = dispatch(cli) {
report_error(err);
std::process::exit(1);
}
}
fn dispatch(cli: Cli) -> Result<()> {
match cli.command {
Commands::Init(args) => init(args),
Commands::Build(args) => build(args),
Commands::Compose(args) => compose(args),
Commands::Plug(args) => plug(args),
#[cfg(feature = "runtime")]
Commands::Run(args) => run(args),
Commands::SelfUpdate(args) => self_update(args),
}
}
fn report_error(err: anyhow::Error) {
if is_component_interface_mismatch(&err) {
eprintln!("Error: Component interface mismatch\n");
eprintln!("This usually happens when:");
eprintln!("1. wacli and wacli-cdk versions don't match");
eprintln!("2. Old component files remain in commands/\n");
eprintln!("Solutions:");
eprintln!("- Update: wacli self-update && update wacli-cdk in Cargo.toml");
eprintln!("- Clean: rm commands/**/*.component.wasm && rebuild");
eprintln!("- Verify: wacli --version && rg wacli-cdk commands/*/Cargo.toml\n");
eprintln!("Details: {err}");
} else {
eprintln!("Error: {err}");
}
}
fn is_component_interface_mismatch(err: &anyhow::Error) -> bool {
let needles = [
"component has no import named",
"missing import",
"unknown import",
"no import named",
];
for cause in err.chain() {
let msg = cause.to_string().to_lowercase();
if msg.contains("wacli:cli/") && needles.iter().any(|n| msg.contains(n)) {
return true;
}
}
false
}
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()))?;
write_plugin_wit(&dir, args.overwrite)?;
if args.with_components {
download_framework_components(&defaults_dir, args.overwrite)?;
}
manifest::write_default_manifest(&dir, args.overwrite)?;
eprintln!("Created:");
eprintln!(" {}", defaults_dir.display());
eprintln!(" {}", commands_dir.display());
eprintln!(" {}", dir.join("wit").display());
eprintln!(" {}", dir.join(manifest::DEFAULT_MANIFEST_NAME).display());
eprintln!();
eprintln!("Next steps:");
if args.with_components {
eprintln!(" 1. Place your command components in commands/");
eprintln!(" 2. Run: wacli build");
} else {
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(())
}
const PLUGIN_WIT: &str = wit::COMMAND_WIT;
const TYPES_WIT: &str = wit::TYPES_WIT;
const HOST_ENV_WIT: &str = wit::HOST_ENV_WIT;
const HOST_IO_WIT: &str = wit::HOST_IO_WIT;
const HOST_FS_WIT: &str = wit::HOST_FS_WIT;
const HOST_PROCESS_WIT: &str = wit::HOST_PROCESS_WIT;
const HOST_PIPES_WIT: &str = wit::HOST_PIPES_WIT;
const PIPE_RUNTIME_WIT: &str = wit::PIPE_RUNTIME_WIT;
const PIPE_WIT: &str = wit::PIPE_WIT;
const HOST_COMPONENT_URL: &str =
"https://github.com/RAKUDEJI/wacli/releases/latest/download/host.component.wasm";
const CORE_COMPONENT_URL: &str =
"https://github.com/RAKUDEJI/wacli/releases/latest/download/core.component.wasm";
fn write_plugin_wit(project_dir: &Path, overwrite: bool) -> Result<()> {
let wit_dir = project_dir.join("wit");
fs::create_dir_all(&wit_dir)
.with_context(|| format!("failed to create directory: {}", wit_dir.display()))?;
let files = [
("types.wit", TYPES_WIT),
("host-env.wit", HOST_ENV_WIT),
("host-io.wit", HOST_IO_WIT),
("host-fs.wit", HOST_FS_WIT),
("host-process.wit", HOST_PROCESS_WIT),
("host-pipes.wit", HOST_PIPES_WIT),
("pipe-runtime.wit", PIPE_RUNTIME_WIT),
("command.wit", PLUGIN_WIT),
("pipe.wit", PIPE_WIT),
];
for (name, contents) in files {
write_wit_file(&wit_dir, name, contents, overwrite)?;
}
Ok(())
}
fn write_wit_file(dir: &Path, name: &str, contents: &str, overwrite: bool) -> Result<()> {
let dest = dir.join(name);
if dest.exists() && !overwrite {
tracing::info!("{name} already exists, skipping");
return Ok(());
}
let tmp_path = dest.with_extension("tmp");
fs::write(&tmp_path, contents)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
if overwrite && dest.exists() {
fs::remove_file(&dest).with_context(|| format!("failed to remove {}", dest.display()))?;
}
fs::rename(&tmp_path, &dest)
.with_context(|| format!("failed to move {} into place", dest.display()))?;
tracing::info!("installed {} -> {}", name, dest.display());
Ok(())
}
fn download_framework_components(defaults_dir: &Path, overwrite: bool) -> Result<()> {
let host_path = defaults_dir.join("host.component.wasm");
let core_path = defaults_dir.join("core.component.wasm");
download_component(
HOST_COMPONENT_URL,
&host_path,
overwrite,
"host.component.wasm",
)?;
download_component(
CORE_COMPONENT_URL,
&core_path,
overwrite,
"core.component.wasm",
)?;
Ok(())
}
fn download_component(url: &str, dest: &Path, overwrite: bool, label: &str) -> Result<()> {
if dest.exists() && !overwrite {
tracing::info!("{} already exists, skipping download", label);
return Ok(());
}
let tmp_path = dest.with_extension("download");
let response = ureq::get(url)
.set("User-Agent", concat!("wacli/", env!("CARGO_PKG_VERSION")))
.call()
.with_context(|| format!("failed to download {}", url))?;
if response.status() >= 400 {
bail!(
"failed to download {} (status {})",
label,
response.status()
);
}
let mut reader = response.into_reader();
let mut tmp_file = fs::File::create(&tmp_path)
.with_context(|| format!("failed to create {}", tmp_path.display()))?;
std::io::copy(&mut reader, &mut tmp_file)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
if overwrite && dest.exists() {
fs::remove_file(dest).with_context(|| format!("failed to remove {}", dest.display()))?;
}
fs::rename(&tmp_path, dest).with_context(|| format!("failed to move {} into place", label))?;
tracing::info!("downloaded {} -> {}", label, dest.display());
Ok(())
}
fn build(args: BuildArgs) -> Result<()> {
tracing::debug!("executing build command");
let cwd = std::env::current_dir().context("failed to get current directory")?;
let loaded = manifest::load_manifest(args.manifest.as_deref())?;
let base_dir = loaded
.as_ref()
.map(|m| m.base_dir.as_path())
.unwrap_or(cwd.as_path());
let m_build = loaded.as_ref().and_then(|m| m.manifest.build.as_ref());
let name = args
.name
.or_else(|| m_build.and_then(|m| m.name.clone()))
.unwrap_or_else(|| "example:my-cli".to_string());
let version = args
.version
.or_else(|| m_build.and_then(|m| m.version.clone()))
.unwrap_or_else(|| "0.1.0".to_string());
let package_name = if name.contains('@') {
name.clone()
} else {
format!("{name}@{version}")
};
#[derive(Clone, Copy)]
enum PathOrigin {
Cli,
Manifest,
Default,
}
let resolve_path = |origin: PathOrigin, p: PathBuf| -> PathBuf {
if p.is_absolute() {
return p;
}
match origin {
PathOrigin::Cli => cwd.join(p),
PathOrigin::Manifest | PathOrigin::Default => base_dir.join(p),
}
};
let (defaults_raw, defaults_origin) = match args.defaults_dir {
Some(p) => (p, PathOrigin::Cli),
None => match m_build.and_then(|m| m.defaults_dir.clone()) {
Some(p) => (p, PathOrigin::Manifest),
None => (PathBuf::from("defaults"), PathOrigin::Default),
},
};
let defaults_dir = resolve_path(defaults_origin, defaults_raw);
let (commands_raw, commands_origin) = match args.commands_dir {
Some(p) => (p, PathOrigin::Cli),
None => match m_build.and_then(|m| m.commands_dir.clone()) {
Some(p) => (p, PathOrigin::Manifest),
None => (PathBuf::from("commands"), PathOrigin::Default),
},
};
let commands_dir = resolve_path(commands_origin, commands_raw);
let (output_raw, output_origin) = match args.output {
Some(p) => (p, PathOrigin::Cli),
None => match m_build.and_then(|m| m.output.clone()) {
Some(p) => (p, PathOrigin::Manifest),
None => (PathBuf::from("my-cli.component.wasm"), PathOrigin::Default),
},
};
let output_path = resolve_path(output_origin, output_raw);
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 args.use_prebuilt_registry {
get_prebuilt_registry(&defaults_dir)
.context("defaults/registry.component.wasm not found (remove --use-prebuilt-registry or add the file)")?
} 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 cache_dir = base_dir.join(".wacli");
fs::create_dir_all(&cache_dir)
.with_context(|| format!("failed to create directory: {}", cache_dir.display()))?;
let generated_path = cache_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(&package_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(base_dir, 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) = output_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
}
fs::write(&output_path, &bytes)
.with_context(|| format!("failed to write output file: {}", output_path.display()))?;
eprintln!("Built: {}", output_path.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())))
}
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(())
}
#[cfg(feature = "runtime")]
fn run(args: RunArgs) -> Result<()> {
let runner = plugin_loader::Runner::new()?;
let mut preopens = Vec::new();
for dir in &args.dirs {
preopens.push(parse_preopen_dir(dir)?);
}
let (extra_dirs, passthrough_args) = split_run_args(&args.args)?;
for dir in extra_dirs {
preopens.push(parse_preopen_dir(&dir)?);
}
let code = runner.run_component_with_preopens(&args.component, &passthrough_args, &preopens)?;
if code != 0 {
std::process::exit(code as i32);
}
Ok(())
}
#[cfg(feature = "runtime")]
fn parse_preopen_dir(value: &str) -> Result<plugin_loader::PreopenDir> {
let trimmed = value.trim();
if trimmed.is_empty() {
bail!("--dir value is empty");
}
let (host, guest) = match trimmed.split_once("::") {
Some((host, guest)) => (host.trim(), guest.trim()),
None => (trimmed, trimmed),
};
if host.is_empty() {
bail!("--dir host path is empty");
}
if guest.is_empty() {
bail!("--dir guest path is empty");
}
Ok(plugin_loader::PreopenDir::new(host, guest))
}
#[cfg(feature = "runtime")]
fn split_run_args(args: &[String]) -> Result<(Vec<String>, Vec<String>)> {
let mut preopens = Vec::new();
let mut passthrough = Vec::new();
let mut i = 0usize;
let mut stop = false;
while i < args.len() {
let arg = &args[i];
if !stop {
if arg == "--" {
stop = true;
passthrough.push(arg.clone());
i += 1;
continue;
}
if arg == "--dir" {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("--dir requires a value (HOST[::GUEST])"))?;
preopens.push(next.clone());
i += 2;
continue;
}
if let Some(rest) = arg.strip_prefix("--dir=") {
if rest.trim().is_empty() {
bail!("--dir requires a value (HOST[::GUEST])");
}
preopens.push(rest.to_string());
i += 1;
continue;
}
}
passthrough.push(arg.clone());
i += 1;
}
Ok((preopens, passthrough))
}
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();
}
fn self_update(args: SelfUpdateArgs) -> Result<()> {
let target = match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "wacli-linux-x86_64",
("linux", "aarch64") => "wacli-linux-aarch64",
("macos", "x86_64") => "wacli-macos-x86_64",
("macos", "aarch64") => "wacli-macos-aarch64",
("windows", "x86_64") => "wacli-windows-x86_64.exe",
_ => bail!(
"unsupported platform for self-update: {} {}",
std::env::consts::OS,
std::env::consts::ARCH
),
};
let mut updater = Update::configure();
if let Some(version) = &args.version {
let tag = format!("v{version}");
updater.target_version_tag(&tag);
}
let updater = updater
.repo_owner("RAKUDEJI")
.repo_name("wacli")
.bin_name("wacli")
.target(target)
.identifier(".zip")
.show_download_progress(true)
.no_confirm(true)
.current_version(env!("CARGO_PKG_VERSION"))
.build()
.context("failed to configure updater")?;
let status = updater.update().context("failed to update")?;
match status {
Status::UpToDate(version) => {
eprintln!("wacli is already up to date ({}).", version);
}
Status::Updated(version) => {
eprintln!("wacli updated to {}.", version);
}
}
Ok(())
}