standard-plugin-cli 0.1.1

standard-plugin: scaffold, build, check and pack Standard Code plugins
Documentation
//! `standard-plugin`: scaffold, build, check and pack Standard Code plugins
//! (runtime v2, `standard:plugin@2.0.0`). Also runs as `cargo standard-plugin`.

pub mod build;
pub mod check;
pub mod component;
pub mod guide;
pub mod install;
pub mod pack;
pub mod project;
pub mod scaffold;
pub mod world;

use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use clap::{Parser, Subcommand};

use crate::project::Plugin;
use crate::scaffold::{NewKind, SdkSource};

#[derive(Debug, Parser)]
#[command(name = "standard-plugin", version, about)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Create a plugin crate with a manifest whose grants carry reasons.
    New {
        /// The plugin id: lowercase letters, digits and dashes.
        name: String,
        #[arg(long, value_enum, default_value = "ui")]
        kind: NewKind,
        /// Where to create it (default: the current directory).
        #[arg(long, default_value = ".")]
        dir: PathBuf,
        /// Depend on the SDK at this path instead of the default.
        #[arg(long)]
        sdk_path: Option<PathBuf>,
    },
    /// Build the component for wasm32-wasip2 into bundle/, then check it.
    Build {
        #[arg(long, default_value = ".")]
        dir: PathBuf,
        /// Build the dev profile instead of release.
        #[arg(long)]
        debug: bool,
    },
    /// Check the manifest, its grants and the built component.
    Check {
        #[arg(long, default_value = ".")]
        dir: PathBuf,
        /// Treat warnings as errors.
        #[arg(long)]
        deny_warnings: bool,
    },
    /// Pack the plugin (or a companion pair) into the deterministic account
    /// package and print its hash.
    Pack {
        #[arg(long, default_value = ".")]
        dir: PathBuf,
        /// Output file (default: target/standard-plugin/<id>-<version>.tar
        /// in the plugin directory).
        #[arg(long)]
        out: Option<PathBuf>,
    },
    /// Check, pack and install on the signed-in account (runs `standard
    /// plugin install`, which shows the grants and asks first).
    Install {
        #[arg(long, default_value = ".")]
        dir: PathBuf,
    },
    /// List the SDK's guides, or print one: the guides of the SDK the
    /// plugin's Cargo.lock resolves, else those of this CLI's SDK.
    Guide {
        /// The guide to print (`agent-guide`, `manifest`, ...); lists them
        /// when absent.
        name: Option<String>,
        #[arg(long, default_value = ".")]
        dir: PathBuf,
    },
}

/// Runs the CLI on `args` (the program name first).
pub fn run(args: impl IntoIterator<Item = OsString>) -> ExitCode {
    let cli = match Cli::try_parse_from(args) {
        Ok(cli) => cli,
        Err(error) => {
            let _ = error.print();
            return if error.use_stderr() {
                ExitCode::from(2)
            } else {
                ExitCode::SUCCESS
            };
        }
    };
    match execute(cli.command) {
        Ok(true) => ExitCode::SUCCESS,
        Ok(false) => ExitCode::FAILURE,
        Err(error) => {
            eprintln!("error: {error}");
            ExitCode::FAILURE
        }
    }
}

fn heading(plugin: &Plugin) -> String {
    match plugin.manifest() {
        Ok(manifest) => format!(
            "{} {} ({}) in {}",
            manifest.id,
            manifest.version,
            manifest.kind,
            plugin.dir.display()
        ),
        Err(_) => plugin.dir.display().to_string(),
    }
}

/// Checks one plugin's bundle and prints the report; whether it passed.
fn check_plugin(plugin: &Plugin, deny_warnings: bool) -> Result<bool, String> {
    let bytes = plugin.manifest_bytes()?;
    let module = check::check(&bytes, None)
        .manifest
        .map(|manifest| plugin.bundle.join(manifest.module));
    let component = match &module {
        Some(path) if path.is_file() => {
            Some(std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?)
        }
        _ => None,
    };
    let report = check::check(&bytes, component.as_deref());
    println!("{}", heading(plugin));
    print!("{report}");
    if component.is_none() && report.is_ok() {
        println!("  note: not built yet; the component was not checked (standard-plugin build)");
    }
    let ok = report.is_ok() && !(deny_warnings && !report.warnings.is_empty());
    println!(
        "  {}: {} error(s), {} warning(s)",
        if ok { "ok" } else { "failed" },
        report.errors.len(),
        report.warnings.len()
    );
    Ok(ok)
}

/// Checks every plugin in `dir`, then packs them as one account package.
fn pack_dir(dir: &Path, out: Option<&Path>) -> Result<Option<(PathBuf, pack::Packed)>, String> {
    for plugin in project::plugins(dir)? {
        if !check_plugin(&plugin, false)? {
            return Ok(None);
        }
    }
    let packed = pack::package(dir)?;
    let out = out.map(Path::to_path_buf).unwrap_or_else(|| {
        dir.join("target/standard-plugin").join(format!(
            "{}-{}.tar",
            packed.manifest.id, packed.manifest.version
        ))
    });
    if let Some(parent) = out.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("{}: {error}", parent.display()))?;
    }
    std::fs::write(&out, &packed.bytes).map_err(|error| format!("{}: {error}", out.display()))?;
    let names = pack::entry_names(&packed.bytes)?;
    println!(
        "  packed {} ({} files, {} bytes)\n  sha256 {}",
        out.display(),
        names.len(),
        packed.bytes.len(),
        pack::content_hash(&packed.bytes)
    );
    Ok(Some((out, packed)))
}

fn execute(command: Command) -> Result<bool, String> {
    match command {
        Command::New {
            name,
            kind,
            dir,
            sdk_path,
        } => {
            let sdk = match sdk_path {
                Some(path) => SdkSource::Path(
                    path.canonicalize()
                        .map_err(|error| format!("{}: {error}", path.display()))?,
                ),
                None => SdkSource::default_for_this_build(),
            };
            let root = scaffold::scaffold(&dir, &name, kind, &sdk).map_err(|e| e.to_string())?;
            println!("created {}", root.display());
            println!("next: cd {} && standard-plugin build", root.display());
            Ok(true)
        }
        Command::Build { dir, debug } => {
            let mut ok = true;
            for plugin in project::plugins(&dir)? {
                let built = build::build(&plugin, !debug)?;
                println!("built {}", built.component.display());
                match &built.browser {
                    Ok(path) => println!("browser artifact: {}", path.display()),
                    Err(reason) => println!("browser artifact: {reason}"),
                }
                ok &= check_plugin(&plugin, false)?;
            }
            Ok(ok)
        }
        Command::Check { dir, deny_warnings } => {
            let mut ok = true;
            for plugin in project::plugins(&dir)? {
                ok &= check_plugin(&plugin, deny_warnings)?;
            }
            Ok(ok)
        }
        Command::Pack { dir, out } => Ok(pack_dir(&dir, out.as_deref())?.is_some()),
        Command::Install { dir } => {
            if pack_dir(&dir, None)?.is_none() {
                return Ok(false);
            }
            // `standard` uploads the package with the signed-in account's
            // credentials and asks before it installs.
            Err(install::exec(&dir))
        }
        Command::Guide { name, dir } => {
            let found = guide::guides(&dir);
            match name {
                None => {
                    print!("{}", found.listing());
                    Ok(true)
                }
                Some(name) => match found.find(&name) {
                    Some(guide) => {
                        print!("{}", guide.text);
                        Ok(true)
                    }
                    None => Err(format!(
                        "no guide named {name:?}; `standard-plugin guide` lists them"
                    )),
                },
            }
        }
    }
}