Skip to main content

zoi_plugins/
lib.rs

1//! Plugin management system for Zoi.
2//!
3//! This crate provides the `PluginManager` which handles loading and executing
4//! Lua plugins that extend Zoi's functionality. It also includes support for
5//! extensions that can modify registry configurations.
6
7/// Registry extension management.
8pub mod extension;
9
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Result, anyhow};
15use colored::Colorize;
16use comfy_table::Table as ComfyTable;
17use comfy_table::presets::UTF8_FULL;
18use dialoguer::theme::ColorfulTheme;
19use dialoguer::{Confirm, Select};
20use mlua::{Function, Lua, LuaSerdeExt, Table, Value};
21use sha2::{Digest, Sha256};
22use zoi_core::{types, utils};
23use zoi_project as project;
24use zoi_resolver::{local, resolve};
25
26/// Key used in the Lua globals to store environment variable overrides.
27const PLUGIN_ENV_OVERRIDES_KEY: &str = "__ZOI_ENV_OVERRIDES";
28
29/// Orchestrates Zoi's extensibility via global Lua plugins.
30///
31/// Plugins allow users to customize Zoi's behavior by:
32/// - Registering new subcommands (`zoi.register_command`).
33/// - Intercepting lifecycle events (`zoi.on_post_install`, etc.).
34/// - Overriding tool versions at runtime (shim resolution).
35///
36/// Plugins are stored in `~/.zoi/plugins/` and are verified against
37/// a `trusted_hashes.json` database to prevent unauthorized execution.
38pub struct PluginManager {
39    /// The initialized mlua Lua Virtual Machine.
40    pub lua: Lua
41}
42
43impl PluginManager {
44    /// Initializes a new `PluginManager` and sets up the global Lua API.
45    ///
46    /// This injects the entire `zoi.*` API surface into the Lua environment,
47    /// enabling plugins to interact with the filesystem, HTTP, archives,
48    /// the UI, and the hook registry.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the Lua VM cannot be initialized or if the `zoi` API
53    /// fails to be injected.
54    pub fn new() -> Result<Self> {
55        let lua = Lua::new();
56        let manager = Self { lua };
57        manager.setup_api()?;
58        Ok(manager)
59    }
60
61    /// Sets up the global `zoi` API in the Lua environment.
62    fn setup_api(&self) -> Result<()> {
63        let zoi = self
64            .lua
65            .create_table()
66            .map_err(|e| anyhow!(e.to_string()))?;
67
68        self.lua
69            .globals()
70            .set(
71                "__ZOI_COMMANDS",
72                self.lua
73                    .create_table()
74                    .map_err(|e| anyhow!(e.to_string()))?
75            )
76            .map_err(|e| anyhow!(e.to_string()))?;
77        self.lua
78            .globals()
79            .set(
80                "__ZOI_COMMAND_HELP",
81                self.lua
82                    .create_table()
83                    .map_err(|e| anyhow!(e.to_string()))?
84            )
85            .map_err(|e| anyhow!(e.to_string()))?;
86
87        let register_command = self
88            .lua
89            .create_function(|lua, arg: Value| {
90                let registry: Table = lua.globals().get("__ZOI_COMMANDS")?;
91                let help_registry: Table =
92                    lua.globals().get("__ZOI_COMMAND_HELP")?;
93                match arg {
94                    Value::Table(t) => {
95                        let name: String = t.get("name")?;
96                        let desc: String = t
97                            .get("description")
98                            .unwrap_or_else(|_| String::new());
99                        let callback: Function = t.get("callback")?;
100                        registry.set(name.clone(), callback)?;
101                        help_registry.set(name, desc)?;
102                    }
103                    _ => {
104                        return Err(mlua::Error::RuntimeError(
105                            "Invalid argument to register_command. Expected a \
106                             table {name, description, callback}"
107                                .to_string()
108                        ));
109                    }
110                }
111                Ok(())
112            })
113            .map_err(|e| anyhow!(e.to_string()))?;
114        zoi.set("register_command", register_command)
115            .map_err(|e| anyhow!(e.to_string()))?;
116
117        let register_command_simple = self
118            .lua
119            .create_function(|lua, (name, callback): (String, Function)| {
120                let registry: Table = lua.globals().get("__ZOI_COMMANDS")?;
121                registry.set(name, callback)?;
122                Ok(())
123            })
124            .map_err(|e| anyhow!(e.to_string()))?;
125        zoi.set("register_command_simple", register_command_simple)
126            .map_err(|e| anyhow!(e.to_string()))?;
127
128        self.lua
129            .globals()
130            .set(
131                "__ZOI_HOOKS",
132                self.lua
133                    .create_table()
134                    .map_err(|e| anyhow!(e.to_string()))?
135            )
136            .map_err(|e| anyhow!(e.to_string()))?;
137        self.lua
138            .globals()
139            .set(
140                PLUGIN_ENV_OVERRIDES_KEY,
141                self.lua
142                    .create_table()
143                    .map_err(|e| anyhow!(e.to_string()))?
144            )
145            .map_err(|e| anyhow!(e.to_string()))?;
146        let hooks = [
147            "on_pre_install",
148            "on_post_install",
149            "on_pre_uninstall",
150            "on_post_uninstall",
151            "on_pre_sync",
152            "on_post_sync",
153            "on_rollback",
154            "on_pre_create",
155            "on_post_create",
156            "on_pre_extension_add",
157            "on_post_extension_add",
158            "on_pre_extension_remove",
159            "on_post_extension_remove",
160            "on_resolve_shim_version",
161            "on_project_install"
162        ];
163        for hook in hooks {
164            let hook_name = hook.to_string();
165            let register_hook = self
166                .lua
167                .create_function(move |lua, callback: Function| {
168                    let registry: Table = lua.globals().get("__ZOI_HOOKS")?;
169                    let hook_list: Table =
170                        if let Ok(t) = registry.get(hook_name.as_str()) {
171                            t
172                        } else {
173                            let t = lua.create_table()?;
174                            registry.set(hook_name.as_str(), t.clone())?;
175                            t
176                        };
177                    hook_list.push(callback)?;
178                    Ok(())
179                })
180                .map_err(|e| anyhow!(e.to_string()))?;
181            zoi.set(hook, register_hook)
182                .map_err(|e| anyhow!(e.to_string()))?;
183        }
184
185        let set_data = self
186            .lua
187            .create_function(|_, (key, value): (String, Value)| {
188                let mut state = read_plugin_state().unwrap_or_default();
189                let json_val: serde_json::Value = match value {
190                    Value::String(s) => {
191                        serde_json::Value::String(s.to_str()?.to_string())
192                    }
193                    Value::Integer(i) => serde_json::Value::Number(i.into()),
194                    Value::Number(n) => {
195                        let Some(num) = serde_json::Number::from_f64(n) else {
196                            return Err(mlua::Error::RuntimeError(
197                                "Non-finite numbers are not supported for \
198                                 set_data"
199                                    .to_string()
200                            ));
201                        };
202                        serde_json::Value::Number(num)
203                    }
204                    Value::Boolean(b) => serde_json::Value::Bool(b),
205                    _ => {
206                        return Err(mlua::Error::RuntimeError(
207                            "Unsupported value type for set_data".to_string()
208                        ));
209                    }
210                };
211                state.insert(key, json_val);
212                write_plugin_state(&state)
213                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
214                Ok(())
215            })
216            .map_err(|e| anyhow!(e.to_string()))?;
217        zoi.set("set_data", set_data)
218            .map_err(|e| anyhow!(e.to_string()))?;
219
220        let get_data = self
221            .lua
222            .create_function(|lua, key: String| {
223                let state = read_plugin_state().unwrap_or_default();
224                if let Some(val) = state.get(&key) {
225                    lua.to_value(val)
226                } else {
227                    Ok(Value::Nil)
228                }
229            })
230            .map_err(|e| anyhow!(e.to_string()))?;
231        zoi.set("get_data", get_data)
232            .map_err(|e| anyhow!(e.to_string()))?;
233
234        let list_installed = self
235            .lua
236            .create_function(|lua, ()| {
237                let installed = local::get_installed_packages()
238                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
239                lua.to_value(&installed)
240            })
241            .map_err(|e| anyhow!(e.to_string()))?;
242        zoi.set("list_installed", list_installed)
243            .map_err(|e| anyhow!(e.to_string()))?;
244
245        let get_package = self
246            .lua
247            .create_function(|lua, name: String| {
248                let (pkg, _, _, _, _, _, _) =
249                    resolve::resolve_package_and_version(
250                        &name, None, true, false
251                    )
252                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
253                lua.to_value(&pkg)
254            })
255            .map_err(|e| anyhow!(e.to_string()))?;
256        zoi.set("get_package", get_package)
257            .map_err(|e| anyhow!(e.to_string()))?;
258
259        if let Ok(config) = project::config::load() {
260            let project_table = self
261                .lua
262                .create_table()
263                .map_err(|e| anyhow!(e.to_string()))?;
264            project_table
265                .set("name", config.name)
266                .map_err(|e| anyhow!(e.to_string()))?;
267            project_table
268                .set("packages", config.pkgs)
269                .map_err(|e| anyhow!(e.to_string()))?;
270            zoi.set("project", project_table)
271                .map_err(|e| anyhow!(e.to_string()))?;
272        }
273
274        let ui = self
275            .lua
276            .create_table()
277            .map_err(|e| anyhow!(e.to_string()))?;
278        let ui_print = self
279            .lua
280            .create_function(|_, (text, color): (String, Option<String>)| {
281                let colored_text = match color.as_deref() {
282                    Some("red") => text.red(),
283                    Some("green") => text.green(),
284                    Some("yellow") => text.yellow(),
285                    Some("blue") => text.blue(),
286                    Some("cyan") => text.cyan(),
287                    Some("magenta") => text.magenta(),
288                    _ => text.normal()
289                };
290                println!("{colored_text}");
291                Ok(())
292            })
293            .map_err(|e| anyhow!(e.to_string()))?;
294        ui.set("print", ui_print)
295            .map_err(|e| anyhow!(e.to_string()))?;
296
297        let ui_confirm = self
298            .lua
299            .create_function(|_, prompt: String| {
300                Ok(Confirm::with_theme(&ColorfulTheme::default())
301                    .with_prompt(prompt)
302                    .interact()
303                    .unwrap_or(false))
304            })
305            .map_err(|e| anyhow!(e.to_string()))?;
306        ui.set("confirm", ui_confirm)
307            .map_err(|e| anyhow!(e.to_string()))?;
308
309        let ui_select = self
310            .lua
311            .create_function(|_, (prompt, options): (String, Vec<String>)| {
312                let selection = Select::with_theme(&ColorfulTheme::default())
313                    .with_prompt(prompt)
314                    .items(&options)
315                    .default(0)
316                    .interact_opt()
317                    .unwrap_or(None);
318                Ok(selection.map(|s| s + 1))
319            })
320            .map_err(|e| anyhow!(e.to_string()))?;
321        ui.set("select", ui_select)
322            .map_err(|e| anyhow!(e.to_string()))?;
323
324        let ui_table = self
325            .lua
326            .create_function(
327                |_, (headers, rows): (Vec<String>, Vec<Vec<String>>)| {
328                    let mut table = ComfyTable::new();
329                    table.load_style(UTF8_FULL).set_header(headers);
330                    for row in rows {
331                        table.add_row(row);
332                    }
333                    println!("{table}");
334                    Ok(())
335                }
336            )
337            .map_err(|e| anyhow!(e.to_string()))?;
338        ui.set("table", ui_table)
339            .map_err(|e| anyhow!(e.to_string()))?;
340        zoi.set("ui", ui).map_err(|e| anyhow!(e.to_string()))?;
341
342        let system = self
343            .lua
344            .create_table()
345            .map_err(|e| anyhow!(e.to_string()))?;
346        let platform = utils::get_platform()
347            .unwrap_or_else(|_| "unknown-unknown".to_string());
348        let parts: Vec<&str> = platform.split('-').collect();
349        system
350            .set("os", parts.first().unwrap_or(&"unknown").to_string())
351            .map_err(|e| anyhow!(e.to_string()))?;
352        system
353            .set("arch", parts.get(1).unwrap_or(&"unknown").to_string())
354            .map_err(|e| anyhow!(e.to_string()))?;
355        if let Some(distro) = utils::get_linux_distribution() {
356            system
357                .set("distro", distro)
358                .map_err(|e| anyhow!(e.to_string()))?;
359        }
360        if let Some(dv) = utils::get_distro_version() {
361            system
362                .set("distro_ver", dv)
363                .map_err(|e| anyhow!(e.to_string()))?;
364        }
365
366        zoi.set("system", system)
367            .map_err(|e| anyhow!(e.to_string()))?;
368        zoi.set("version", env!("CARGO_PKG_VERSION"))
369            .map_err(|e| anyhow!(e.to_string()))?;
370        zoi.set("scope", "user") // Default
371            .map_err(|e| anyhow!(e.to_string()))?;
372
373        let shell = self
374            .lua
375            .create_function(|lua, cmd: String| {
376                let env_overrides: Table = lua
377                    .globals()
378                    .get(PLUGIN_ENV_OVERRIDES_KEY)
379                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
380
381                let mut command = if cfg!(target_os = "windows") {
382                    let mut c = std::process::Command::new("pwsh");
383                    c.arg("-Command").arg(&cmd);
384                    c
385                } else {
386                    let mut c = std::process::Command::new("bash");
387                    c.arg("-c").arg(&cmd);
388                    c
389                };
390
391                for pair in env_overrides.pairs::<String, String>() {
392                    let (key, value) = pair.map_err(|e| {
393                        mlua::Error::RuntimeError(e.to_string())
394                    })?;
395                    command.env(key, value);
396                }
397
398                if let Ok(zoi_table) = lua.globals().get::<Table>("zoi")
399                    && let Ok(scope_str) = zoi_table.get::<String>("scope")
400                {
401                    command.env("ZOI_SCOPE", scope_str);
402                }
403
404                let status = command.status();
405                match status {
406                    Ok(s) => Ok(s.code().unwrap_or(i32::from(!s.success()))),
407                    Err(e) => Err(mlua::Error::RuntimeError(e.to_string()))
408                }
409            })
410            .map_err(|e| anyhow!(e.to_string()))?;
411        zoi.set("sh", shell).map_err(|e| anyhow!(e.to_string()))?;
412
413        let fs_table = self
414            .lua
415            .create_table()
416            .map_err(|e| anyhow!(e.to_string()))?;
417        let fs_read = self
418            .lua
419            .create_function(
420                |_, path: String| Ok(fs::read_to_string(path).ok())
421            )
422            .map_err(|e| anyhow!(e.to_string()))?;
423        fs_table
424            .set("read", fs_read)
425            .map_err(|e| anyhow!(e.to_string()))?;
426
427        let fs_write = self
428            .lua
429            .create_function(|_, (path, content): (String, String)| {
430                Ok(fs::write(path, content).is_ok())
431            })
432            .map_err(|e| anyhow!(e.to_string()))?;
433        fs_table
434            .set("write", fs_write)
435            .map_err(|e| anyhow!(e.to_string()))?;
436
437        let fs_exists = self
438            .lua
439            .create_function(|_, path: String| Ok(PathBuf::from(path).exists()))
440            .map_err(|e| anyhow!(e.to_string()))?;
441        fs_table
442            .set("exists", fs_exists)
443            .map_err(|e| anyhow!(e.to_string()))?;
444
445        let fs_list = self
446            .lua
447            .create_function(|lua, path: String| {
448                let mut entries = Vec::new();
449                if let Ok(read_dir) = fs::read_dir(path) {
450                    for entry in read_dir.flatten() {
451                        entries.push(
452                            entry.file_name().to_string_lossy().to_string()
453                        );
454                    }
455                }
456                lua.to_value(&entries)
457            })
458            .map_err(|e| anyhow!(e.to_string()))?;
459        fs_table
460            .set("list", fs_list)
461            .map_err(|e| anyhow!(e.to_string()))?;
462
463        let fs_delete = self
464            .lua
465            .create_function(|_, path: String| {
466                let p = PathBuf::from(path);
467                if p.is_dir() {
468                    Ok(fs::remove_dir_all(p).is_ok())
469                } else {
470                    Ok(fs::remove_file(p).is_ok())
471                }
472            })
473            .map_err(|e| anyhow!(e.to_string()))?;
474        fs_table
475            .set("delete", fs_delete)
476            .map_err(|e| anyhow!(e.to_string()))?;
477
478        let fs_symlink = self
479            .lua
480            .create_function(
481                |_, (target, link, is_dir): (String, String, bool)| {
482                    let target_path = PathBuf::from(target);
483                    let link_path = PathBuf::from(link);
484                    if is_dir {
485                        Ok(utils::symlink_dir(&target_path, &link_path).is_ok())
486                    } else {
487                        Ok(utils::symlink_file(&target_path, &link_path)
488                            .is_ok())
489                    }
490                }
491            )
492            .map_err(|e| anyhow!(e.to_string()))?;
493        fs_table
494            .set("symlink", fs_symlink)
495            .map_err(|e| anyhow!(e.to_string()))?;
496
497        let fs_copy = self
498            .lua
499            .create_function(|_, (src, dest): (String, String)| {
500                let src_path = Path::new(&src);
501                let dest_path = Path::new(&dest);
502                if src_path.is_dir() {
503                    Ok(utils::copy_dir_all(src_path, dest_path).is_ok())
504                } else {
505                    Ok(fs::copy(src_path, dest_path).is_ok())
506                }
507            })
508            .map_err(|e| anyhow!(e.to_string()))?;
509        fs_table
510            .set("copy", fs_copy)
511            .map_err(|e| anyhow!(e.to_string()))?;
512
513        zoi.set("fs", fs_table)
514            .map_err(|e| anyhow!(e.to_string()))?;
515
516        let archive_table = self
517            .lua
518            .create_table()
519            .map_err(|e| anyhow!(e.to_string()))?;
520        let archive_extract = self
521            .lua
522            .create_function(
523                |_, (source, dest, strip): (String, String, Option<usize>)| {
524                    fn safe_stripped_relative_path(
525                        path: &Path,
526                        strip: usize
527                    ) -> Result<Option<PathBuf>, std::io::Error>
528                    {
529                        let mut sanitized = PathBuf::new();
530                        let mut has_component = false;
531                        for component in path.components().skip(strip) {
532                            match component {
533                                std::path::Component::Normal(part) => {
534                                    sanitized.push(part);
535                                    has_component = true;
536                                }
537                                std::path::Component::CurDir => {}
538                                _ => {
539                                    return Err(std::io::Error::new(
540                                        std::io::ErrorKind::InvalidInput,
541                                        format!(
542                                            "Archive entry escapes \
543                                             destination: {}",
544                                            path.display()
545                                        )
546                                    ));
547                                }
548                            }
549                        }
550                        if has_component {
551                            Ok(Some(sanitized))
552                        } else {
553                            Ok(None)
554                        }
555                    }
556
557                    fn unpack_with_strip<R: std::io::Read>(
558                        mut archive: tar::Archive<R>,
559                        dest: &Path,
560                        strip: usize
561                    ) -> Result<(), std::io::Error> {
562                        for entry in archive.entries()? {
563                            let mut entry = entry?;
564                            let path = entry.path()?.to_path_buf();
565                            let Some(stripped_path) =
566                                safe_stripped_relative_path(&path, strip)?
567                            else {
568                                continue;
569                            };
570                            entry.unpack(dest.join(stripped_path))?;
571                        }
572                        Ok(())
573                    }
574
575                    let src_path = Path::new(&source);
576                    let dest_path = Path::new(&dest);
577
578                    if !dest_path.exists() {
579                        let _ = fs::create_dir_all(dest_path);
580                    }
581
582                    let file = fs::File::open(src_path).map_err(|e| {
583                        mlua::Error::RuntimeError(e.to_string())
584                    })?;
585                    let archive_path_str = source.to_lowercase();
586
587                    let strip_val = strip.unwrap_or(0);
588
589                    if std::path::Path::new(&archive_path_str)
590                        .extension()
591                        .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
592                    {
593                        let mut archive =
594                            zip::ZipArchive::new(file).map_err(|e| {
595                                mlua::Error::RuntimeError(e.to_string())
596                            })?;
597                        if strip_val > 0 {
598                            for i in 0..archive.len() {
599                                let mut file =
600                                    archive.by_index(i).map_err(|e| {
601                                        mlua::Error::RuntimeError(e.to_string())
602                                    })?;
603                                let path = PathBuf::from(file.name());
604                                let Some(stripped_path) =
605                                    safe_stripped_relative_path(
606                                        &path, strip_val
607                                    )
608                                    .map_err(|e| {
609                                        mlua::Error::RuntimeError(e.to_string())
610                                    })?
611                                else {
612                                    continue;
613                                };
614                                let out_path = dest_path.join(stripped_path);
615                                if file.is_dir() {
616                                    fs::create_dir_all(&out_path).map_err(
617                                        |e| {
618                                            mlua::Error::RuntimeError(
619                                                e.to_string()
620                                            )
621                                        }
622                                    )?;
623                                } else {
624                                    if let Some(p) = out_path.parent() {
625                                        fs::create_dir_all(p).map_err(|e| {
626                                            mlua::Error::RuntimeError(
627                                                e.to_string()
628                                            )
629                                        })?;
630                                    }
631                                    let mut outfile = fs::File::create(
632                                        &out_path
633                                    )
634                                    .map_err(|e| {
635                                        mlua::Error::RuntimeError(e.to_string())
636                                    })?;
637                                    std::io::copy(&mut file, &mut outfile)
638                                        .map_err(|e| {
639                                            mlua::Error::RuntimeError(
640                                                e.to_string()
641                                            )
642                                        })?;
643                                }
644                            }
645                        } else {
646                            archive.extract(dest_path).map_err(|e| {
647                                mlua::Error::RuntimeError(e.to_string())
648                            })?;
649                        }
650                    } else if archive_path_str.ends_with(".tar.gz")
651                        || std::path::Path::new(&archive_path_str)
652                            .extension()
653                            .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
654                    {
655                        let tar_gz = flate2::read::GzDecoder::new(file);
656                        let archive = tar::Archive::new(tar_gz);
657                        if strip_val > 0 {
658                            unpack_with_strip(archive, dest_path, strip_val)
659                                .map_err(|e| {
660                                    mlua::Error::RuntimeError(e.to_string())
661                                })?;
662                        } else {
663                            let mut archive = archive;
664                            archive.unpack(dest_path).map_err(|e| {
665                                mlua::Error::RuntimeError(e.to_string())
666                            })?;
667                        }
668                    } else if archive_path_str.ends_with(".tar.zst") {
669                        let tar_zst = zstd::stream::read::Decoder::new(file)
670                            .map_err(|e| {
671                                mlua::Error::RuntimeError(e.to_string())
672                            })?;
673                        let archive = tar::Archive::new(tar_zst);
674                        if strip_val > 0 {
675                            unpack_with_strip(archive, dest_path, strip_val)
676                                .map_err(|e| {
677                                    mlua::Error::RuntimeError(e.to_string())
678                                })?;
679                        } else {
680                            let mut archive = archive;
681                            archive.unpack(dest_path).map_err(|e| {
682                                mlua::Error::RuntimeError(e.to_string())
683                            })?;
684                        }
685                    } else if archive_path_str.ends_with(".tar.xz") {
686                        let tar_xz = xz2::read::XzDecoder::new(file);
687                        let archive = tar::Archive::new(tar_xz);
688                        if strip_val > 0 {
689                            unpack_with_strip(archive, dest_path, strip_val)
690                                .map_err(|e| {
691                                    mlua::Error::RuntimeError(e.to_string())
692                                })?;
693                        } else {
694                            let mut archive = archive;
695                            archive.unpack(dest_path).map_err(|e| {
696                                mlua::Error::RuntimeError(e.to_string())
697                            })?;
698                        }
699                    } else {
700                        return Err(mlua::Error::RuntimeError(format!(
701                            "Unsupported archive format: {source}"
702                        )));
703                    }
704                    Ok(true)
705                }
706            )
707            .map_err(|e| anyhow!(e.to_string()))?;
708        archive_table
709            .set("extract", archive_extract)
710            .map_err(|e| anyhow!(e.to_string()))?;
711        zoi.set("archive", archive_table)
712            .map_err(|e| anyhow!(e.to_string()))?;
713
714        let http_table = self
715            .lua
716            .create_table()
717            .map_err(|e| anyhow!(e.to_string()))?;
718        let http_get = self
719            .lua
720            .create_function(|_, url: String| {
721                let client = reqwest::blocking::Client::builder()
722                    .user_agent("zoi-plugin")
723                    .build()
724                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
725                match client.get(&url).send() {
726                    Ok(resp) => Ok(resp.text().ok()),
727                    Err(_) => Ok(None)
728                }
729            })
730            .map_err(|e| anyhow!(e.to_string()))?;
731        http_table
732            .set("get", http_get)
733            .map_err(|e| anyhow!(e.to_string()))?;
734
735        let http_download = self
736            .lua
737            .create_function(|_, (url, dest): (String, String)| {
738                let mut response = reqwest::blocking::get(&url)
739                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
740                if !response.status().is_success() {
741                    return Ok(false);
742                }
743                let mut dest_file = fs::File::create(dest)
744                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
745                std::io::copy(&mut response, &mut dest_file)
746                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
747                Ok(true)
748            })
749            .map_err(|e| anyhow!(e.to_string()))?;
750        http_table
751            .set("download", http_download)
752            .map_err(|e| anyhow!(e.to_string()))?;
753
754        let http_post = self
755            .lua
756            .create_function(|_, (url, body): (String, String)| {
757                let client = reqwest::blocking::Client::builder()
758                    .user_agent("zoi-plugin")
759                    .build()
760                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
761                match client.post(&url).body(body).send() {
762                    Ok(resp) => Ok(resp.text().ok()),
763                    Err(_) => Ok(None)
764                }
765            })
766            .map_err(|e| anyhow!(e.to_string()))?;
767        http_table
768            .set("post", http_post)
769            .map_err(|e| anyhow!(e.to_string()))?;
770        zoi.set("http", http_table)
771            .map_err(|e| anyhow!(e.to_string()))?;
772
773        let json_table = self
774            .lua
775            .create_table()
776            .map_err(|e| anyhow!(e.to_string()))?;
777        let json_parse = self
778            .lua
779            .create_function(|lua, json_str: String| {
780                let parsed: serde_json::Value = serde_json::from_str(&json_str)
781                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
782                lua.to_value(&parsed)
783            })
784            .map_err(|e| anyhow!(e.to_string()))?;
785        json_table
786            .set("parse", json_parse)
787            .map_err(|e| anyhow!(e.to_string()))?;
788
789        let json_stringify = self
790            .lua
791            .create_function(|lua, value: Value| {
792                let json_val: serde_json::Value = lua
793                    .from_value(value)
794                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
795                Ok(serde_json::to_string(&json_val).unwrap_or_default())
796            })
797            .map_err(|e| anyhow!(e.to_string()))?;
798        json_table
799            .set("stringify", json_stringify)
800            .map_err(|e| anyhow!(e.to_string()))?;
801        zoi.set("json", json_table)
802            .map_err(|e| anyhow!(e.to_string()))?;
803
804        let env_table = self
805            .lua
806            .create_table()
807            .map_err(|e| anyhow!(e.to_string()))?;
808        let env_get = self
809            .lua
810            .create_function(|lua, name: String| {
811                let env_overrides: Table = lua
812                    .globals()
813                    .get(PLUGIN_ENV_OVERRIDES_KEY)
814                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
815
816                if let Some(value) = env_overrides
817                    .get::<Option<String>>(name.as_str())
818                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
819                {
820                    return Ok(Some(value));
821                }
822
823                Ok(std::env::var(name).ok())
824            })
825            .map_err(|e| anyhow!(e.to_string()))?;
826        env_table
827            .set("get", env_get)
828            .map_err(|e| anyhow!(e.to_string()))?;
829
830        let env_set = self
831            .lua
832            .create_function(|lua, (name, value): (String, String)| {
833                let env_overrides: Table = lua
834                    .globals()
835                    .get(PLUGIN_ENV_OVERRIDES_KEY)
836                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
837                env_overrides
838                    .set(name, value)
839                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
840                Ok(())
841            })
842            .map_err(|e| anyhow!(e.to_string()))?;
843        env_table
844            .set("set", env_set)
845            .map_err(|e| anyhow!(e.to_string()))?;
846        zoi.set("env", env_table)
847            .map_err(|e| anyhow!(e.to_string()))?;
848
849        self.lua
850            .globals()
851            .set("zoi", zoi)
852            .map_err(|e| anyhow!(e.to_string()))?;
853
854        let plugin_dir = get_plugin_dir()?;
855        let import_fn = self
856            .lua
857            .create_function(move |lua, file_name: String| {
858                let path = plugin_dir.join(&file_name);
859                if !path.exists() {
860                    return Err(mlua::Error::RuntimeError(format!(
861                        "File not found: {}",
862                        path.display()
863                    )));
864                }
865                let content = fs::read_to_string(&path)
866                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
867                if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
868                    match ext {
869                        "json" => {
870                            let val: serde_json::Value = serde_json::from_str(
871                                &content
872                            )
873                            .map_err(|e| {
874                                mlua::Error::RuntimeError(e.to_string())
875                            })?;
876                            return lua.to_value(&val);
877                        }
878                        _ => return lua.to_value(&content)
879                    }
880                }
881                lua.to_value(&content)
882            })
883            .map_err(|e| anyhow!(e.to_string()))?;
884        self.lua
885            .globals()
886            .set("IMPORT", import_fn)
887            .map_err(|e| anyhow!(e.to_string()))?;
888
889        Ok(())
890    }
891
892    /// Sets the operational scope for plugins.
893    ///
894    /// # Errors
895    ///
896    /// Returns an error if the `zoi` global table cannot be accessed or if the
897    /// scope cannot be set.
898    pub fn set_context(&self, scope: types::Scope) -> Result<()> {
899        let zoi: Table = self
900            .lua
901            .globals()
902            .get("zoi")
903            .map_err(|e| anyhow!(e.to_string()))?;
904        let scope_str = format!("{scope:?}").to_lowercase();
905        zoi.set("scope", scope_str)
906            .map_err(|e| anyhow!(e.to_string()))?;
907        Ok(())
908    }
909
910    /// Loads and executes all trusted Lua plugins from the plugin directory.
911    ///
912    /// # Errors
913    ///
914    /// Returns an error if the plugin directory cannot be read, if a plugin
915    /// script cannot be loaded, or if a plugin execution fails.
916    pub fn load_all(&self, yes: bool) -> Result<()> {
917        let plugin_dir = get_plugin_dir()?;
918        if !plugin_dir.exists() {
919            return Ok(());
920        }
921        let mut plugin_paths = Vec::new();
922        for entry in fs::read_dir(plugin_dir)? {
923            let entry = entry?;
924            let path = entry.path();
925            if path.extension().and_then(|s| s.to_str()) == Some("lua") {
926                plugin_paths.push(path);
927            }
928        }
929        plugin_paths.sort();
930
931        let trusted_path = get_plugin_dir()?.join("trusted_hashes.json");
932        let mut trusted: HashMap<String, String> = if trusted_path.exists() {
933            let content = fs::read_to_string(&trusted_path)?;
934            serde_json::from_str(&content).unwrap_or_default()
935        } else {
936            HashMap::new()
937        };
938        let mut trusted_changed = false;
939
940        for path in plugin_paths {
941            let script = fs::read_to_string(&path)?;
942
943            let mut hasher = Sha256::new();
944            hasher.update(script.as_bytes());
945            let hash = hex::encode(hasher.finalize());
946
947            let plugin_name = path
948                .file_name()
949                .unwrap_or_default()
950                .to_string_lossy()
951                .to_string();
952
953            let is_trusted = trusted
954                .get(&plugin_name)
955                .is_some_and(|known_hash| known_hash == &hash);
956
957            if !is_trusted {
958                if yes {
959                    println!(
960                        "\n{}: Skipping untrusted plugin: {}. Run Zoi \
961                         interactively to trust it.",
962                        "Warning".yellow().bold(),
963                        plugin_name.cyan()
964                    );
965
966                    continue;
967                }
968
969                println!(
970                    "\n{}: Untrusted plugin detected: {}",
971                    "SECURITY WARNING".yellow().bold(),
972                    plugin_name.cyan()
973                );
974                println!(
975                    "Plugins can execute arbitrary commands and modify your \
976                     system."
977                );
978                if utils::ask_for_confirmation(
979                    "Do you trust this plugin and want to execute it?",
980                    false
981                ) {
982                    trusted.insert(plugin_name.clone(), hash);
983                    trusted_changed = true;
984                } else {
985                    println!("Skipping untrusted plugin: {plugin_name}");
986                    continue;
987                }
988            }
989
990            let script_wrapper = format!(
991                "local old_reg = zoi.register_command; zoi.register_command = \
992                 function(a, b) if type(a) == 'string' then \
993                 zoi.register_command_simple(a, b) else old_reg(a) end end; \
994                 {script}"
995            );
996            self.lua.load(&script_wrapper).exec().map_err(|e| {
997                anyhow!("Plugin error in {}: {}", path.display(), e)
998            })?;
999        }
1000
1001        if trusted_changed {
1002            let content = serde_json::to_string_pretty(&trusted)?;
1003            fs::write(trusted_path, content)?;
1004        }
1005
1006        Ok(())
1007    }
1008
1009    /// Triggers a lifecycle hook, executing all registered callbacks.
1010    ///
1011    /// # Errors
1012    ///
1013    /// Returns an error if the hook registry cannot be accessed or if any
1014    /// callback execution fails.
1015    pub fn trigger_hook(
1016        &self,
1017        hook_name: &str,
1018        arg: Option<&Value>
1019    ) -> Result<()> {
1020        let registry: Table = self
1021            .lua
1022            .globals()
1023            .get("__ZOI_HOOKS")
1024            .map_err(|e| anyhow!(e.to_string()))?;
1025        if let Ok(hook_list) = registry.get::<Table>(hook_name) {
1026            for callback in hook_list.sequence_values::<Function>() {
1027                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1028                if let Some(a) = arg {
1029                    callback
1030                        .call::<()>(a.clone())
1031                        .map_err(|e| anyhow!(e.to_string()))?;
1032                } else {
1033                    callback
1034                        .call::<()>(())
1035                        .map_err(|e| anyhow!(e.to_string()))?;
1036                }
1037            }
1038        }
1039        Ok(())
1040    }
1041
1042    /// Triggers a lifecycle hook but ignores errors, printing a warning
1043    /// instead.
1044    pub fn trigger_hook_nonfatal(&self, hook_name: &str, arg: Option<&Value>) {
1045        if let Err(error) = self.trigger_hook(hook_name, arg) {
1046            eprintln!(
1047                "Warning: hook '{hook_name}' failed after the operation \
1048                 completed: {error}"
1049            );
1050        }
1051    }
1052
1053    /// Triggers the shim version resolution hook.
1054    ///
1055    /// # Errors
1056    ///
1057    /// Returns an error if the hook registry cannot be accessed or if any
1058    /// callback execution fails.
1059    pub fn trigger_resolve_shim_version(
1060        &self,
1061        bin_name: &str
1062    ) -> Result<Option<String>> {
1063        let registry: Table = self
1064            .lua
1065            .globals()
1066            .get("__ZOI_HOOKS")
1067            .map_err(|e| anyhow!(e.to_string()))?;
1068
1069        if let Ok(hook_list) = registry.get::<Table>("on_resolve_shim_version")
1070        {
1071            for callback in hook_list.sequence_values::<Function>() {
1072                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1073                let result: Option<String> = callback
1074                    .call(bin_name)
1075                    .map_err(|e| anyhow!(e.to_string()))?;
1076                if result.is_some() {
1077                    return Ok(result);
1078                }
1079            }
1080        }
1081        Ok(None)
1082    }
1083
1084    /// Triggers the project installation hook.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns an error if the hook registry cannot be accessed or if any
1089    /// callback execution fails.
1090    pub fn trigger_project_install_hook(&self) -> Result<bool> {
1091        let registry: Table = self
1092            .lua
1093            .globals()
1094            .get("__ZOI_HOOKS")
1095            .map_err(|e| anyhow!(e.to_string()))?;
1096
1097        if let Ok(hook_list) = registry.get::<Table>("on_project_install") {
1098            for callback in hook_list.sequence_values::<Function>() {
1099                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1100                let handled: bool =
1101                    callback.call(()).map_err(|e| anyhow!(e.to_string()))?;
1102                if handled {
1103                    return Ok(true);
1104                }
1105            }
1106        }
1107        Ok(false)
1108    }
1109
1110    /// Executes a custom command registered by a plugin.
1111    ///
1112    /// # Errors
1113    ///
1114    /// Returns an error if the command registry cannot be accessed or if the
1115    /// command execution fails.
1116    pub fn run_command(&self, name: &str, args: Vec<String>) -> Result<bool> {
1117        let registry: Table = self
1118            .lua
1119            .globals()
1120            .get("__ZOI_COMMANDS")
1121            .map_err(|e| anyhow!(e.to_string()))?;
1122        let callback: Value =
1123            registry.get(name).map_err(|e| anyhow!(e.to_string()))?;
1124        if let Value::Function(func) = callback {
1125            func.call::<()>(args).map_err(|e| anyhow!(e.to_string()))?;
1126            Ok(true)
1127        } else {
1128            Ok(false)
1129        }
1130    }
1131
1132    /// Lists all custom commands registered by plugins.
1133    ///
1134    /// # Errors
1135    ///
1136    /// Returns an error if the command registry cannot be accessed.
1137    pub fn list_commands(&self) -> Result<Vec<(String, String)>> {
1138        let registry: Table = self
1139            .lua
1140            .globals()
1141            .get("__ZOI_COMMANDS")
1142            .map_err(|e| anyhow!(e.to_string()))?;
1143        let help_registry: Table = self
1144            .lua
1145            .globals()
1146            .get("__ZOI_COMMAND_HELP")
1147            .map_err(|e| anyhow!(e.to_string()))?;
1148        let mut commands = Vec::new();
1149        for pair in registry.pairs::<String, Value>() {
1150            let (name, _) = pair.map_err(|e| anyhow!(e.to_string()))?;
1151            let desc: String = help_registry
1152                .get(name.clone())
1153                .unwrap_or_else(|_| String::new());
1154            commands.push((name, desc));
1155        }
1156        Ok(commands)
1157    }
1158}
1159
1160/// Returns the path to the Zoi plugin directory.
1161///
1162/// # Errors
1163///
1164/// Returns an error if the user home directory cannot be found or if the
1165/// plugin directory cannot be created.
1166pub fn get_plugin_dir() -> Result<PathBuf> {
1167    let home_dir = utils::get_user_home()
1168        .ok_or_else(|| anyhow!("Could not find home directory."))?;
1169    let plugin_dir = home_dir.join(".zoi").join("plugins");
1170    if !plugin_dir.exists() {
1171        fs::create_dir_all(&plugin_dir)?;
1172    }
1173    Ok(plugin_dir)
1174}
1175
1176/// Reads the persistent state for plugins from state.json.
1177fn read_plugin_state() -> Result<HashMap<String, serde_json::Value>> {
1178    let path = get_plugin_dir()?.join("state.json");
1179    if !path.exists() {
1180        return Ok(HashMap::new());
1181    }
1182    let content = fs::read_to_string(path)?;
1183    Ok(serde_json::from_str(&content).unwrap_or_default())
1184}
1185
1186/// Writes the persistent state for plugins to state.json.
1187fn write_plugin_state(
1188    state: &HashMap<String, serde_json::Value>
1189) -> Result<()> {
1190    let path = get_plugin_dir()?.join("state.json");
1191    let content = serde_json::to_string_pretty(state)?;
1192    fs::write(path, content)?;
1193    Ok(())
1194}