Skip to main content

yosh_plugin_manager/
lib.rs

1pub mod config;
2pub mod github;
3pub mod install;
4pub mod lockfile;
5pub mod metadata_extract;
6pub mod precompile;
7pub mod resolve;
8pub mod sync;
9pub mod update;
10pub mod verify;
11
12/// wasmtime bindgen for the `plugin-world` WIT contract.
13///
14/// Path is `wit/` inside this crate. The canonical source lives in
15/// `yosh-plugin-api/wit/`; `build.rs` verifies the bundled copy matches
16/// when built inside the workspace. The copy is required because
17/// `cargo install yosh-plugin-manager` extracts each crate standalone,
18/// so a sibling-relative path (`../yosh-plugin-api/wit`) is unresolvable
19/// from `~/.cargo/registry/src/.../yosh-plugin-manager-<ver>/`.
20///
21/// This is independent from the host's bindgen invocation in
22/// `src/plugin/mod.rs` — the two crates produce separate generated
23/// types, so we cannot share. The host needs `HostContext` as the store
24/// type and full host imports; the manager needs `MetadataCtx` and
25/// deny-only imports.
26pub mod generated {
27    wasmtime::component::bindgen!({
28        path: "wit",
29        world: "plugin-world",
30        async: false,
31    });
32}
33
34use clap::{Parser, Subcommand};
35
36const VERSION: &str = concat!(
37    env!("CARGO_PKG_VERSION"),
38    " (",
39    env!("YOSH_GIT_HASH"),
40    " ",
41    env!("YOSH_BUILD_DATE"),
42    ")"
43);
44
45#[derive(Parser)]
46#[command(name = "yosh-plugin", about = "Manage yosh shell plugins")]
47#[command(version = VERSION)]
48struct Cli {
49    #[command(subcommand)]
50    command: Commands,
51}
52
53#[derive(Subcommand)]
54enum Commands {
55    /// Install plugins from plugins.toml
56    Sync {
57        /// Remove plugins not in plugins.toml
58        #[arg(long)]
59        prune: bool,
60    },
61    /// Update installed plugins to latest version
62    Update {
63        /// Only update the named plugin
64        name: Option<String>,
65    },
66    /// List installed plugins
67    List,
68    /// Verify plugin integrity (SHA-256)
69    Verify,
70    /// Add a plugin from a GitHub URL or local path to plugins.toml
71    Install {
72        /// GitHub URL (https://github.com/owner/repo[@version]) or local file path
73        source: String,
74        /// Overwrite existing plugin with the same name
75        #[arg(long)]
76        force: bool,
77    },
78}
79
80pub fn run() -> i32 {
81    let cli = Cli::parse();
82    match cli.command {
83        Commands::Sync { prune } => cmd_sync(prune),
84        Commands::Update { name } => cmd_update(name.as_deref()),
85        Commands::List => cmd_list(),
86        Commands::Verify => cmd_verify(),
87        Commands::Install { source, force } => cmd_install(&source, force),
88    }
89}
90
91fn cmd_install(source: &str, force: bool) -> i32 {
92    let config_path = sync::config_path();
93    match install::install(source, force, &config_path, None) {
94        Ok(msg) => {
95            eprintln!("{}", msg);
96            if source.starts_with("https://github.com/") {
97                eprintln!("Run 'yosh plugin sync' to download.");
98            }
99            0
100        }
101        Err(e) => {
102            eprintln!("yosh-plugin: {}", e);
103            1
104        }
105    }
106}
107
108fn cmd_sync(prune: bool) -> i32 {
109    let result = match sync::sync(prune) {
110        Ok(r) => r,
111        Err(e) => {
112            eprintln!("yosh-plugin: {}", e);
113            return 2;
114        }
115    };
116
117    for name in &result.succeeded {
118        eprintln!("  \u{2713} {}", name);
119    }
120    for (name, err) in &result.failed {
121        eprintln!("  \u{2717} {}: {}", name, err);
122    }
123
124    if result.failed.is_empty() {
125        eprintln!(
126            "yosh-plugin: sync complete ({} plugins)",
127            result.succeeded.len()
128        );
129        0
130    } else {
131        eprintln!(
132            "yosh-plugin: sync partial ({} succeeded, {} failed)",
133            result.succeeded.len(),
134            result.failed.len()
135        );
136        1
137    }
138}
139
140fn cmd_update(name_filter: Option<&str>) -> i32 {
141    let config_path = sync::config_path();
142    let client = github::GitHubClient::new();
143    let outcome = match update::update(&config_path, name_filter, &client) {
144        Ok(o) => o,
145        Err(e) => {
146            eprintln!("yosh-plugin: {}", e);
147            return 2;
148        }
149    };
150
151    for result in &outcome.results {
152        match &result.status {
153            update::UpdateStatus::Updated { from, to } => {
154                eprintln!("  {} {} \u{2192} {}", result.name, from, to);
155            }
156            update::UpdateStatus::AlreadyLatest { current } => {
157                eprintln!("  {} {} (already latest)", result.name, current);
158            }
159            update::UpdateStatus::Failed(e) => {
160                eprintln!("  \u{2717} {}: {}", result.name, e);
161            }
162            update::UpdateStatus::Skipped(_) => {
163                // Silent: matches HEAD's behavior of not surfacing
164                // name_filter mismatches or local-source skips.
165            }
166        }
167    }
168
169    if outcome.any_updated {
170        return cmd_sync(false);
171    }
172
173    0
174}
175
176fn cmd_list() -> i32 {
177    let lock_path = sync::lock_path();
178    let lockfile = match lockfile::load_lockfile(&lock_path) {
179        Ok(l) => l,
180        Err(e) => {
181            eprintln!("yosh-plugin: {}", e);
182            return 2;
183        }
184    };
185
186    if lockfile.plugin.is_empty() {
187        eprintln!("no plugins installed (run 'yosh-plugin sync' first)");
188        return 0;
189    }
190
191    for entry in &lockfile.plugin {
192        let version = entry.version.as_deref().unwrap_or("-");
193        let verified =
194            match verify::verify_checksum(&config::expand_tilde_path(&entry.path), &entry.sha256) {
195                Ok(true) => "\u{2713} verified",
196                Ok(false) => "\u{2717} checksum mismatch",
197                Err(_) => "\u{2717} file missing",
198            };
199        // "cached" reflects whether a precompiled cwasm is present AND
200        // matches the manager's pinned wasmtime version. A mismatched
201        // version means the host will fall back to in-memory precompile
202        // at startup — not a hard failure, but worth surfacing here so
203        // the user can re-sync.
204        let cached = match (&entry.cwasm_path, &entry.wasmtime_version) {
205            (Some(p), Some(wv))
206                if std::path::Path::new(&config::expand_tilde_path(p)).exists()
207                    && wv == precompile::WASMTIME_VERSION =>
208            {
209                "\u{2713} cached"
210            }
211            _ => "\u{2717} stale",
212        };
213        let caps = entry
214            .required_capabilities
215            .as_ref()
216            .map(|v| {
217                if v.is_empty() {
218                    "[- (no capabilities)]".to_string()
219                } else {
220                    format!("[{}]", v.join(", "))
221                }
222            })
223            .unwrap_or_else(|| "[?]".into());
224        println!(
225            "{:<16} {:<8} {:<48} {} {} {}",
226            entry.name, version, entry.source, verified, cached, caps
227        );
228    }
229
230    0
231}
232
233fn cmd_verify() -> i32 {
234    let lock_path = sync::lock_path();
235    let lockfile = match lockfile::load_lockfile(&lock_path) {
236        Ok(l) => l,
237        Err(e) => {
238            eprintln!("yosh-plugin: {}", e);
239            return 2;
240        }
241    };
242
243    let mut all_ok = true;
244    for entry in &lockfile.plugin {
245        let path = config::expand_tilde_path(&entry.path);
246        match verify::verify_checksum(&path, &entry.sha256) {
247            Ok(true) => {
248                eprintln!("  \u{2713} {}", entry.name);
249            }
250            Ok(false) => {
251                eprintln!("  \u{2717} {}: checksum mismatch", entry.name);
252                all_ok = false;
253            }
254            Err(e) => {
255                eprintln!("  \u{2717} {}: {}", entry.name, e);
256                all_ok = false;
257            }
258        }
259    }
260
261    if all_ok { 0 } else { 1 }
262}