yosh-plugin-manager 0.2.6

Plugin manager for yosh shell
Documentation
pub mod config;
pub mod github;
pub mod install;
pub mod lockfile;
pub mod metadata_extract;
pub mod precompile;
pub mod resolve;
pub mod sync;
pub mod update;
pub mod verify;

/// wasmtime bindgen for the `plugin-world` WIT contract.
///
/// Path is `wit/` inside this crate. The canonical source lives in
/// `yosh-plugin-api/wit/`; `build.rs` verifies the bundled copy matches
/// when built inside the workspace. The copy is required because
/// `cargo install yosh-plugin-manager` extracts each crate standalone,
/// so a sibling-relative path (`../yosh-plugin-api/wit`) is unresolvable
/// from `~/.cargo/registry/src/.../yosh-plugin-manager-<ver>/`.
///
/// This is independent from the host's bindgen invocation in
/// `src/plugin/mod.rs` — the two crates produce separate generated
/// types, so we cannot share. The host needs `HostContext` as the store
/// type and full host imports; the manager needs `MetadataCtx` and
/// deny-only imports.
pub mod generated {
    wasmtime::component::bindgen!({
        path: "wit",
        world: "plugin-world",
        async: false,
    });
}

use clap::{Parser, Subcommand};

const VERSION: &str = concat!(
    env!("CARGO_PKG_VERSION"),
    " (",
    env!("YOSH_GIT_HASH"),
    " ",
    env!("YOSH_BUILD_DATE"),
    ")"
);

#[derive(Parser)]
#[command(name = "yosh-plugin", about = "Manage yosh shell plugins")]
#[command(version = VERSION)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Install plugins from plugins.toml
    Sync {
        /// Remove plugins not in plugins.toml
        #[arg(long)]
        prune: bool,
    },
    /// Update installed plugins to latest version
    Update {
        /// Only update the named plugin
        name: Option<String>,
    },
    /// List installed plugins
    List,
    /// Verify plugin integrity (SHA-256)
    Verify,
    /// Add a plugin from a GitHub URL or local path to plugins.toml
    Install {
        /// GitHub URL (https://github.com/owner/repo[@version]) or local file path
        source: String,
        /// Overwrite existing plugin with the same name
        #[arg(long)]
        force: bool,
    },
}

pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.command {
        Commands::Sync { prune } => cmd_sync(prune),
        Commands::Update { name } => cmd_update(name.as_deref()),
        Commands::List => cmd_list(),
        Commands::Verify => cmd_verify(),
        Commands::Install { source, force } => cmd_install(&source, force),
    }
}

fn cmd_install(source: &str, force: bool) -> i32 {
    let config_path = sync::config_path();
    match install::install(source, force, &config_path, None) {
        Ok(msg) => {
            eprintln!("{}", msg);
            if source.starts_with("https://github.com/") {
                eprintln!("Run 'yosh plugin sync' to download.");
            }
            0
        }
        Err(e) => {
            eprintln!("yosh-plugin: {}", e);
            1
        }
    }
}

fn cmd_sync(prune: bool) -> i32 {
    let result = match sync::sync(prune) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("yosh-plugin: {}", e);
            return 2;
        }
    };

    for name in &result.succeeded {
        eprintln!("  \u{2713} {}", name);
    }
    for (name, err) in &result.failed {
        eprintln!("  \u{2717} {}: {}", name, err);
    }

    if result.failed.is_empty() {
        eprintln!(
            "yosh-plugin: sync complete ({} plugins)",
            result.succeeded.len()
        );
        0
    } else {
        eprintln!(
            "yosh-plugin: sync partial ({} succeeded, {} failed)",
            result.succeeded.len(),
            result.failed.len()
        );
        1
    }
}

fn cmd_update(name_filter: Option<&str>) -> i32 {
    let config_path = sync::config_path();
    let client = github::GitHubClient::new();
    let outcome = match update::update(&config_path, name_filter, &client) {
        Ok(o) => o,
        Err(e) => {
            eprintln!("yosh-plugin: {}", e);
            return 2;
        }
    };

    for result in &outcome.results {
        match &result.status {
            update::UpdateStatus::Updated { from, to } => {
                eprintln!("  {} {} \u{2192} {}", result.name, from, to);
            }
            update::UpdateStatus::AlreadyLatest { current } => {
                eprintln!("  {} {} (already latest)", result.name, current);
            }
            update::UpdateStatus::Failed(e) => {
                eprintln!("  \u{2717} {}: {}", result.name, e);
            }
            update::UpdateStatus::Skipped(_) => {
                // Silent: matches HEAD's behavior of not surfacing
                // name_filter mismatches or local-source skips.
            }
        }
    }

    if outcome.any_updated {
        return cmd_sync(false);
    }

    0
}

fn cmd_list() -> i32 {
    let lock_path = sync::lock_path();
    let lockfile = match lockfile::load_lockfile(&lock_path) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("yosh-plugin: {}", e);
            return 2;
        }
    };

    if lockfile.plugin.is_empty() {
        eprintln!("no plugins installed (run 'yosh-plugin sync' first)");
        return 0;
    }

    for entry in &lockfile.plugin {
        let version = entry.version.as_deref().unwrap_or("-");
        let verified =
            match verify::verify_checksum(&config::expand_tilde_path(&entry.path), &entry.sha256) {
                Ok(true) => "\u{2713} verified",
                Ok(false) => "\u{2717} checksum mismatch",
                Err(_) => "\u{2717} file missing",
            };
        // "cached" reflects whether a precompiled cwasm is present AND
        // matches the manager's pinned wasmtime version. A mismatched
        // version means the host will fall back to in-memory precompile
        // at startup — not a hard failure, but worth surfacing here so
        // the user can re-sync.
        let cached = match (&entry.cwasm_path, &entry.wasmtime_version) {
            (Some(p), Some(wv))
                if std::path::Path::new(&config::expand_tilde_path(p)).exists()
                    && wv == precompile::WASMTIME_VERSION =>
            {
                "\u{2713} cached"
            }
            _ => "\u{2717} stale",
        };
        let caps = entry
            .required_capabilities
            .as_ref()
            .map(|v| {
                if v.is_empty() {
                    "[- (no capabilities)]".to_string()
                } else {
                    format!("[{}]", v.join(", "))
                }
            })
            .unwrap_or_else(|| "[?]".into());
        println!(
            "{:<16} {:<8} {:<48} {} {} {}",
            entry.name, version, entry.source, verified, cached, caps
        );
    }

    0
}

fn cmd_verify() -> i32 {
    let lock_path = sync::lock_path();
    let lockfile = match lockfile::load_lockfile(&lock_path) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("yosh-plugin: {}", e);
            return 2;
        }
    };

    let mut all_ok = true;
    for entry in &lockfile.plugin {
        let path = config::expand_tilde_path(&entry.path);
        match verify::verify_checksum(&path, &entry.sha256) {
            Ok(true) => {
                eprintln!("  \u{2713} {}", entry.name);
            }
            Ok(false) => {
                eprintln!("  \u{2717} {}: checksum mismatch", entry.name);
                all_ok = false;
            }
            Err(e) => {
                eprintln!("  \u{2717} {}: {}", entry.name, e);
                all_ok = false;
            }
        }
    }

    if all_ok { 0 } else { 1 }
}