Skip to main content

zoi_plugins/
lib.rs

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