diff --git a/CLAUDE.md b/CLAUDE.md index 4de1ad2..dea4ff0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Special-character rendering for the display lives in `graphics/special_char.rs` Plugins are Lua scripts (mlua, `lua54` vendored) returning a table `M`. The engine calls lifecycle/event hooks by name: `on_load`, `on_unload`, `on_serial_connect`/`on_serial_disconnect`/`on_serial_send`/`on_serial_recv` (and `on_rtt_*` equivalents), plus any `M.` the user calls via `!plugin `. Plugins reach back into the app through the `bridge`/`method_call` gates. See `plugins/README.md` for the plugin developer guide. -- **Installed plugins** (`plugin/installed.rs`, issue #36): `!plugin install ` loads a plugin *and* records its name in a TOML manifest `/scope/plugins/installed.toml` (next to the staged `.lua` files); `!plugin list` prints the set. The engine owns the manifest: `PluginEngineCommand::{InstallPlugin,ListInstalledPlugins}` handle the commands, and `load_installed_plugins` (called once at the top of `task_async`, before the command loop) auto-loads each installed plugin from its staged copy, logging per plugin like `!plugin load`. Install persists only after a successful load, dedupes, and treats a missing/malformed manifest or a broken entry as logged-and-skipped (never fatal — it's program-managed state, not the user's `config.toml`). Uninstall is issue #37. +- **Installed plugins** (`plugin/installed.rs`, issues #36/#37): `!plugin install ` loads a plugin *and* records its name in a TOML manifest `/scope/plugins/installed.toml` (next to the staged `.lua` files); `!plugin uninstall ` removes it from the manifest and deletes the staged copy; `!plugin list` prints the set. The engine owns the manifest: `PluginEngineCommand::{InstallPlugin,UninstallPlugin,ListInstalledPlugins}` handle the commands, and `load_installed_plugins` (called once at the top of `task_async`, before the command loop) auto-loads each installed plugin from its staged copy, logging per plugin like `!plugin load`. Install persists only after a successful load, dedupes, and treats a missing/malformed manifest or a broken entry as logged-and-skipped (never fatal — it's program-managed state, not the user's `config.toml`). Uninstall errors if the plugin isn't installed and is orthogonal to `unload` — it does **not** stop a running instance (that's `!plugin unload`); the name is normalized via `get_plugin_name` so `foo`/`foo.lua` both work, and it never deletes a bundled `STDLIB` file (`scope.lua`/`shell.lua`) even if a hand-edited manifest lists a reserved name. ## Logging diff --git a/README.md b/README.md index d713fea..cbb5eea 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ Anything typed on the command bar that starts with `!` is a command. A line with | `!plugin load ` | Load a Lua plugin from a file (for this session only). | | `!plugin reload ` | Reload a plugin from a file. | | `!plugin install ` | Load a plugin **and** install it, so every subsequent `Scope` session auto-loads it. | +| `!plugin uninstall ` | Uninstall a plugin: remove it from the auto-load set and delete its staged copy (a running instance stays loaded for the session — use `!plugin unload` to stop it now). | | `!plugin list` | List the installed (auto-loaded) plugins. | | `!plugin unload ` | Unload a plugin by name (for this session; does not uninstall). | | `! [args...]` | Call a command exported by a loaded plugin (see [Plugins](#plugins)). | @@ -354,7 +355,7 @@ end return M ``` -Load a plugin with `!plugin load ` (and `!plugin reload ` / `!plugin unload ` to reload or unload it). To keep a plugin across sessions, use `!plugin install `: it loads the plugin now and records it so every subsequent `Scope` session auto-loads it at start-up (see `!plugin list` to review the installed set). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md). +Load a plugin with `!plugin load ` (and `!plugin reload ` / `!plugin unload ` to reload or unload it). To keep a plugin across sessions, use `!plugin install `: it loads the plugin now and records it so every subsequent `Scope` session auto-loads it at start-up (see `!plugin list` to review the installed set, and `!plugin uninstall ` to remove one from it). To call one of your plugin's commands, type `!` followed by the plugin name, the command name and its arguments — for example `!echo hello`. Inside a plugin you can react to lifecycle and I/O events (`on_load`, `on_unload`, `on_serial_recv`/`on_serial_send`, `on_rtt_recv`/`on_rtt_send`) and interact with `Scope` and the target: connect/disconnect, send data, read RTT memory, print messages, run shell commands and more. For the full guide see the [Plugins Developer Guide](plugins/README.md). ![Plugin usage](videos/011_plugin/video.gif) diff --git a/plugins/README.md b/plugins/README.md index 580e02c..bc291a0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -34,7 +34,7 @@ return M To execute this plugin you need to load it into the `Scope`. With the `Scope` open, you could type `!plugin load hello.lua`. If you remember the analogy of the chip and the handheld tool, then you need to insert the chip into handheld to it works. Likewise, we need to "insert" (or load) our plugin into our "handheld" (or the `Scope` program). With the plugin loaded, all messages will be replied. The replied message will have the following suffix: `Hello,`. -`!plugin load` lasts only for the current session. If a plugin is part of your everyday workflow, **install** it instead with `!plugin install hello.lua`: this loads it now and records it so every subsequent `Scope` session auto-loads it at start-up (the name is stored in `installed.toml`, next to the staged plugins in `/scope/plugins/`). Use `!plugin list` to see what's installed. +`!plugin load` lasts only for the current session. If a plugin is part of your everyday workflow, **install** it instead with `!plugin install hello.lua`: this loads it now and records it so every subsequent `Scope` session auto-loads it at start-up (the name is stored in `installed.toml`, next to the staged plugins in `/scope/plugins/`). Use `!plugin list` to see what's installed, and `!plugin uninstall hello` to remove one from the auto-load set (it also deletes the staged copy; a plugin already running this session stays loaded until you `!plugin unload` it or restart). ## Hello, World diff --git a/src/inputs/inputs_task.rs b/src/inputs/inputs_task.rs index de1e3bb..cdfcb04 100644 --- a/src/inputs/inputs_task.rs +++ b/src/inputs/inputs_task.rs @@ -1324,7 +1324,7 @@ impl InputsTask { let Some(subcommand) = command_line_split.get(1).map(String::as_str) else { error!( private.logger, - "Please, use \"load\", \"reload\", \"install\", \"list\" or \"unload\" subcommands" + "Please, use \"load\", \"reload\", \"install\", \"uninstall\", \"list\" or \"unload\" subcommands" ); return; }; @@ -1356,6 +1356,11 @@ impl InputsTask { .plugin_engine_cmd_sender .send(PluginEngineCommand::InstallPlugin { filepath: arg }); } + "uninstall" => { + let _ = private + .plugin_engine_cmd_sender + .send(PluginEngineCommand::UninstallPlugin { plugin_name: arg }); + } "unload" => { let _ = private .plugin_engine_cmd_sender @@ -1364,7 +1369,7 @@ impl InputsTask { _ => { error!( private.logger, - "Invalid command. Please, choose one of these options: load, reload, install, list, unload" + "Invalid command. Please, choose one of these options: load, reload, install, uninstall, list, unload" ); } } diff --git a/src/plugin/engine.rs b/src/plugin/engine.rs index acb27e5..1807325 100644 --- a/src/plugin/engine.rs +++ b/src/plugin/engine.rs @@ -95,6 +95,12 @@ pub enum PluginEngineCommand { InstallPlugin { filepath: String, }, + /// Remove a plugin from the persistent manifest and delete its staged copy, + /// so it no longer auto-loads at start-up (issue #37). Does not unload a + /// running instance — that is `UnloadPlugin`. + UninstallPlugin { + plugin_name: String, + }, /// Log the names of the currently installed (auto-loaded) plugins. ListInstalledPlugins, UnloadPlugin { @@ -301,6 +307,64 @@ impl PluginEngine { ); } } + PluginEngineCommand::UninstallPlugin { plugin_name } => { + // Accept `foo`, `foo.lua`, or a path — normalize to the + // bare name the manifest stores. + let Some(plugin_name) = Self::get_plugin_name(&plugin_name) else { + continue 'plugin_engine_loop; + }; + + let dir = plugins_dir(); + + let mut installed = match Installed::load(&dir) { + Ok(installed) => installed, + Err(err) => { + error!(private.logger, "{}", err); + continue 'plugin_engine_loop; + } + }; + + if !installed.remove(&plugin_name) { + error!( + private.logger, + "Plugin \"{}\" is not installed", plugin_name + ); + continue 'plugin_engine_loop; + } + if let Err(err) = installed.save(&dir) { + error!(private.logger, "{}", err); + continue 'plugin_engine_loop; + } + + // Delete the staged copy so it's gone from the plugins + // folder. A missing file is fine (already gone); any other + // error is a non-fatal warning — the manifest, which drives + // auto-load, is already updated. A bundled stdlib file is + // never deleted: `!plugin install` rejects reserved names, + // so a manifest listing `scope`/`shell` can only come from a + // hand-edited file, and losing a user-customized `scope.lua` + // to it would be a nasty surprise (the manifest entry is + // still cleaned above). + let staged_name = format!("{}.lua", plugin_name); + let is_stdlib = STDLIB.iter().any(|(lib, _)| *lib == staged_name); + let staged = dir.join(&staged_name); + if !is_stdlib + && let Err(err) = std::fs::remove_file(&staged) + && err.kind() != std::io::ErrorKind::NotFound + { + warning!( + private.logger, + "Could not delete staged plugin {:?}: {}", + staged, + err + ); + } + + // A running instance is intentionally left loaded for this + // session (use `!plugin unload` to stop it now); uninstall + // only removes it from the persistent auto-load set. + success!(private.logger, "Plugin \"{}\" uninstalled", plugin_name); + } PluginEngineCommand::ListInstalledPlugins => { match Installed::load(&plugins_dir()) { Ok(installed) if installed.names().is_empty() => { diff --git a/src/plugin/installed.rs b/src/plugin/installed.rs index 0189a99..16a892e 100644 --- a/src/plugin/installed.rs +++ b/src/plugin/installed.rs @@ -77,6 +77,14 @@ impl Installed { true } + /// Remove `name` from the installed set. Returns `true` if it was present + /// (and thus removed), or `false` if it wasn't installed. + pub fn remove(&mut self, name: &str) -> bool { + let before = self.plugins.len(); + self.plugins.retain(|p| p != name); + self.plugins.len() != before + } + /// Persist the manifest to `dir`, creating the directory if needed. pub fn save(&self, dir: &Path) -> Result<(), String> { std::fs::create_dir_all(dir) @@ -134,6 +142,17 @@ mod tests { assert_eq!(installed.names(), ["analytics", "auto_test"]); } + #[test] + fn remove_reports_presence_and_deletes() { + let mut installed = Installed::default(); + installed.add("analytics"); + installed.add("auto_test"); + assert!(installed.remove("analytics")); + assert!(!installed.remove("analytics")); // already gone + assert!(!installed.remove("never_there")); + assert_eq!(installed.names(), ["auto_test"]); + } + #[test] fn save_then_load_round_trips() { let tmp = TempDir::new("roundtrip"); diff --git a/tests/tui_e2e.rs b/tests/tui_e2e.rs index 4b5c3e1..f8056c3 100644 --- a/tests/tui_e2e.rs +++ b/tests/tui_e2e.rs @@ -756,3 +756,83 @@ fn plugin_install_rejects_reserved_name() { "a reserved name must not be installed.\n{recorded}" ); } + +#[test] +fn plugin_uninstall_removes_from_manifest_and_deletes_file() { + // Issue #37: `!plugin uninstall ` drops the plugin from the manifest + // (so it no longer auto-loads) and deletes its staged copy. Pre-install one, + // uninstall it, and assert both are gone. + let tui = { + let mut tui = Tui::start_with(StartOpts { + installed_plugins: &[("uninst_plugin", "local M = {}\nreturn M\n")], + ..Default::default() + }); + tui.wait_until_ready(); + + tui.type_text("!plugin uninstall uninst_plugin"); + tui.press_enter(); + tui.wait_for("uninstalled", SETTLE); + tui + }; + + let manifest = + std::fs::read_to_string(tui.plugins_dir().join("installed.toml")).unwrap_or_default(); + assert!( + !manifest.contains("uninst_plugin"), + "manifest must no longer list the uninstalled plugin.\n{manifest}" + ); + assert!( + !tui.plugins_dir().join("uninst_plugin.lua").exists(), + "the staged plugin file must be deleted on uninstall" + ); +} + +#[test] +fn plugin_uninstall_of_not_installed_reports_error() { + // Uninstalling something that was never installed is a clear error, not a + // silent no-op. + let mut tui = Tui::start(&[]); + tui.wait_until_ready(); + + tui.type_text("!plugin uninstall ghost"); + tui.press_enter(); + tui.wait_for("is not installed", SETTLE); +} + +#[test] +fn uninstall_never_deletes_bundled_stdlib() { + // Defense-in-depth: a hand-corrupted manifest that lists a reserved stdlib + // name must never cause its file to be deleted. Seed a real plugin (so the + // stdlib is provisioned at start-up) alongside a bogus `scope` manifest + // entry, then uninstall `scope`: the manifest entry is cleaned but the + // bundled `scope.lua` survives. + let tui = { + let mut tui = Tui::start_with(StartOpts { + installed_plugins: &[("realplug", "local M = {}\nreturn M\n")], + raw_manifest: Some("plugins = [\"realplug\", \"scope\"]\n"), + ..Default::default() + }); + tui.wait_until_ready(); + tui.wait_for("realplug", SETTLE); // real plugin auto-loaded -> stdlib staged + + tui.type_text("!plugin uninstall scope"); + tui.press_enter(); + tui.wait_for("uninstalled", SETTLE); + tui + }; + + assert!( + tui.plugins_dir().join("scope.lua").exists(), + "uninstall must never delete the bundled scope.lua" + ); + let manifest = + std::fs::read_to_string(tui.plugins_dir().join("installed.toml")).unwrap_or_default(); + assert!( + !manifest.contains("scope"), + "the bogus scope entry should be cleaned from the manifest.\n{manifest}" + ); + assert!( + manifest.contains("realplug"), + "a legitimately-installed plugin must stay installed.\n{manifest}" + ); +}