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::{types, 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        if let Some(distro) = utils::get_linux_distribution() {
317            system
318                .set("distro", distro)
319                .map_err(|e| anyhow!(e.to_string()))?;
320        }
321        if let Some(dv) = utils::get_distro_version() {
322            system
323                .set("distro_ver", dv)
324                .map_err(|e| anyhow!(e.to_string()))?;
325        }
326
327        zoi.set("system", system)
328            .map_err(|e| anyhow!(e.to_string()))?;
329        zoi.set("version", env!("CARGO_PKG_VERSION"))
330            .map_err(|e| anyhow!(e.to_string()))?;
331        zoi.set("scope", "user") // Default
332            .map_err(|e| anyhow!(e.to_string()))?;
333
334        let shell = self
335            .lua
336            .create_function(|lua, cmd: String| {
337                let env_overrides: Table = lua
338                    .globals()
339                    .get(PLUGIN_ENV_OVERRIDES_KEY)
340                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
341
342                let mut command = if cfg!(target_os = "windows") {
343                    let mut c = std::process::Command::new("pwsh");
344                    c.arg("-Command").arg(&cmd);
345                    c
346                } else {
347                    let mut c = std::process::Command::new("bash");
348                    c.arg("-c").arg(&cmd);
349                    c
350                };
351
352                for pair in env_overrides.pairs::<String, String>() {
353                    let (key, value) =
354                        pair.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
355                    command.env(key, value);
356                }
357
358                if let Ok(zoi_table) = lua.globals().get::<Table>("zoi")
359                    && let Ok(scope_str) = zoi_table.get::<String>("scope")
360                {
361                    command.env("ZOI_SCOPE", scope_str);
362                }
363
364                let status = command.status();
365                match status {
366                    Ok(s) => Ok(s.code().unwrap_or(if s.success() { 0 } else { 1 })),
367                    Err(e) => Err(mlua::Error::RuntimeError(e.to_string())),
368                }
369            })
370            .map_err(|e| anyhow!(e.to_string()))?;
371        zoi.set("sh", shell).map_err(|e| anyhow!(e.to_string()))?;
372
373        let fs_table = self
374            .lua
375            .create_table()
376            .map_err(|e| anyhow!(e.to_string()))?;
377        let fs_read = self
378            .lua
379            .create_function(|_, path: String| Ok(fs::read_to_string(path).ok()))
380            .map_err(|e| anyhow!(e.to_string()))?;
381        fs_table
382            .set("read", fs_read)
383            .map_err(|e| anyhow!(e.to_string()))?;
384
385        let fs_write = self
386            .lua
387            .create_function(|_, (path, content): (String, String)| {
388                Ok(fs::write(path, content).is_ok())
389            })
390            .map_err(|e| anyhow!(e.to_string()))?;
391        fs_table
392            .set("write", fs_write)
393            .map_err(|e| anyhow!(e.to_string()))?;
394
395        let fs_exists = self
396            .lua
397            .create_function(|_, path: String| Ok(PathBuf::from(path).exists()))
398            .map_err(|e| anyhow!(e.to_string()))?;
399        fs_table
400            .set("exists", fs_exists)
401            .map_err(|e| anyhow!(e.to_string()))?;
402
403        let fs_list = self
404            .lua
405            .create_function(|lua, path: String| {
406                let mut entries = Vec::new();
407                if let Ok(read_dir) = fs::read_dir(path) {
408                    for entry in read_dir.flatten() {
409                        entries.push(entry.file_name().to_string_lossy().to_string());
410                    }
411                }
412                lua.to_value(&entries)
413            })
414            .map_err(|e| anyhow!(e.to_string()))?;
415        fs_table
416            .set("list", fs_list)
417            .map_err(|e| anyhow!(e.to_string()))?;
418
419        let fs_delete = self
420            .lua
421            .create_function(|_, path: String| {
422                let p = PathBuf::from(path);
423                if p.is_dir() {
424                    Ok(fs::remove_dir_all(p).is_ok())
425                } else {
426                    Ok(fs::remove_file(p).is_ok())
427                }
428            })
429            .map_err(|e| anyhow!(e.to_string()))?;
430        fs_table
431            .set("delete", fs_delete)
432            .map_err(|e| anyhow!(e.to_string()))?;
433
434        let fs_symlink = self
435            .lua
436            .create_function(|_, (target, link, is_dir): (String, String, bool)| {
437                let target_path = PathBuf::from(target);
438                let link_path = PathBuf::from(link);
439                if is_dir {
440                    Ok(utils::symlink_dir(&target_path, &link_path).is_ok())
441                } else {
442                    Ok(utils::symlink_file(&target_path, &link_path).is_ok())
443                }
444            })
445            .map_err(|e| anyhow!(e.to_string()))?;
446        fs_table
447            .set("symlink", fs_symlink)
448            .map_err(|e| anyhow!(e.to_string()))?;
449
450        let fs_copy = self
451            .lua
452            .create_function(|_, (src, dest): (String, String)| {
453                let src_path = Path::new(&src);
454                let dest_path = Path::new(&dest);
455                if src_path.is_dir() {
456                    Ok(utils::copy_dir_all(src_path, dest_path).is_ok())
457                } else {
458                    Ok(fs::copy(src_path, dest_path).is_ok())
459                }
460            })
461            .map_err(|e| anyhow!(e.to_string()))?;
462        fs_table
463            .set("copy", fs_copy)
464            .map_err(|e| anyhow!(e.to_string()))?;
465
466        zoi.set("fs", fs_table)
467            .map_err(|e| anyhow!(e.to_string()))?;
468
469        let archive_table = self
470            .lua
471            .create_table()
472            .map_err(|e| anyhow!(e.to_string()))?;
473        let archive_extract = self
474            .lua
475            .create_function(
476                |_, (source, dest, strip): (String, String, Option<usize>)| {
477                    let src_path = Path::new(&source);
478                    let dest_path = Path::new(&dest);
479
480                    if !dest_path.exists() {
481                        let _ = fs::create_dir_all(dest_path);
482                    }
483
484                    let file = fs::File::open(src_path)
485                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
486                    let archive_path_str = source.to_lowercase();
487
488                    let strip_val = strip.unwrap_or(0);
489
490                    fn safe_stripped_relative_path(
491                        path: &Path,
492                        strip: usize,
493                    ) -> Result<Option<PathBuf>, std::io::Error> {
494                        let mut sanitized = PathBuf::new();
495                        let mut has_component = false;
496                        for component in path.components().skip(strip) {
497                            match component {
498                                std::path::Component::Normal(part) => {
499                                    sanitized.push(part);
500                                    has_component = true;
501                                }
502                                std::path::Component::CurDir => {}
503                                _ => {
504                                    return Err(std::io::Error::new(
505                                        std::io::ErrorKind::InvalidInput,
506                                        format!(
507                                            "Archive entry escapes destination: {}",
508                                            path.display()
509                                        ),
510                                    ));
511                                }
512                            }
513                        }
514                        if has_component {
515                            Ok(Some(sanitized))
516                        } else {
517                            Ok(None)
518                        }
519                    }
520
521                    fn unpack_with_strip<R: std::io::Read>(
522                        mut archive: tar::Archive<R>,
523                        dest: &Path,
524                        strip: usize,
525                    ) -> Result<(), std::io::Error> {
526                        for entry in archive.entries()? {
527                            let mut entry = entry?;
528                            let path = entry.path()?.to_path_buf();
529                            let Some(stripped_path) = safe_stripped_relative_path(&path, strip)?
530                            else {
531                                continue;
532                            };
533                            entry.unpack(dest.join(stripped_path))?;
534                        }
535                        Ok(())
536                    }
537
538                    if archive_path_str.ends_with(".zip") {
539                        let mut archive = zip::ZipArchive::new(file)
540                            .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
541                        if strip_val > 0 {
542                            for i in 0..archive.len() {
543                                let mut file = archive
544                                    .by_index(i)
545                                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
546                                let path = PathBuf::from(file.name());
547                                let Some(stripped_path) =
548                                    safe_stripped_relative_path(&path, strip_val)
549                                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
550                                else {
551                                    continue;
552                                };
553                                let out_path = dest_path.join(stripped_path);
554                                if file.is_dir() {
555                                    fs::create_dir_all(&out_path)
556                                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
557                                } else {
558                                    if let Some(p) = out_path.parent() {
559                                        fs::create_dir_all(p).map_err(|e| {
560                                            mlua::Error::RuntimeError(e.to_string())
561                                        })?;
562                                    }
563                                    let mut outfile = fs::File::create(&out_path)
564                                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
565                                    std::io::copy(&mut file, &mut outfile)
566                                        .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
567                                }
568                            }
569                        } else {
570                            archive
571                                .extract(dest_path)
572                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
573                        }
574                    } else if archive_path_str.ends_with(".tar.gz")
575                        || archive_path_str.ends_with(".tgz")
576                    {
577                        let tar_gz = flate2::read::GzDecoder::new(file);
578                        let archive = tar::Archive::new(tar_gz);
579                        if strip_val > 0 {
580                            unpack_with_strip(archive, dest_path, strip_val)
581                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
582                        } else {
583                            let mut archive = archive;
584                            archive
585                                .unpack(dest_path)
586                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
587                        }
588                    } else if archive_path_str.ends_with(".tar.zst") {
589                        let tar_zst = zstd::stream::read::Decoder::new(file)
590                            .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
591                        let archive = tar::Archive::new(tar_zst);
592                        if strip_val > 0 {
593                            unpack_with_strip(archive, dest_path, strip_val)
594                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
595                        } else {
596                            let mut archive = archive;
597                            archive
598                                .unpack(dest_path)
599                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
600                        }
601                    } else if archive_path_str.ends_with(".tar.xz") {
602                        let tar_xz = xz2::read::XzDecoder::new(file);
603                        let archive = tar::Archive::new(tar_xz);
604                        if strip_val > 0 {
605                            unpack_with_strip(archive, dest_path, strip_val)
606                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
607                        } else {
608                            let mut archive = archive;
609                            archive
610                                .unpack(dest_path)
611                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
612                        }
613                    } else {
614                        return Err(mlua::Error::RuntimeError(format!(
615                            "Unsupported archive format: {}",
616                            source
617                        )));
618                    }
619                    Ok(true)
620                },
621            )
622            .map_err(|e| anyhow!(e.to_string()))?;
623        archive_table
624            .set("extract", archive_extract)
625            .map_err(|e| anyhow!(e.to_string()))?;
626        zoi.set("archive", archive_table)
627            .map_err(|e| anyhow!(e.to_string()))?;
628
629        let http_table = self
630            .lua
631            .create_table()
632            .map_err(|e| anyhow!(e.to_string()))?;
633        let http_get = self
634            .lua
635            .create_function(|_, url: String| {
636                let client = reqwest::blocking::Client::builder()
637                    .user_agent("zoi-plugin")
638                    .build()
639                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
640                match client.get(&url).send() {
641                    Ok(resp) => Ok(resp.text().ok()),
642                    Err(_) => Ok(None),
643                }
644            })
645            .map_err(|e| anyhow!(e.to_string()))?;
646        http_table
647            .set("get", http_get)
648            .map_err(|e| anyhow!(e.to_string()))?;
649
650        let http_download = self
651            .lua
652            .create_function(|_, (url, dest): (String, String)| {
653                let mut response = reqwest::blocking::get(&url)
654                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
655                if !response.status().is_success() {
656                    return Ok(false);
657                }
658                let mut dest_file =
659                    fs::File::create(dest).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
660                std::io::copy(&mut response, &mut dest_file)
661                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
662                Ok(true)
663            })
664            .map_err(|e| anyhow!(e.to_string()))?;
665        http_table
666            .set("download", http_download)
667            .map_err(|e| anyhow!(e.to_string()))?;
668
669        let http_post = self
670            .lua
671            .create_function(|_, (url, body): (String, String)| {
672                let client = reqwest::blocking::Client::builder()
673                    .user_agent("zoi-plugin")
674                    .build()
675                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
676                match client.post(&url).body(body).send() {
677                    Ok(resp) => Ok(resp.text().ok()),
678                    Err(_) => Ok(None),
679                }
680            })
681            .map_err(|e| anyhow!(e.to_string()))?;
682        http_table
683            .set("post", http_post)
684            .map_err(|e| anyhow!(e.to_string()))?;
685        zoi.set("http", http_table)
686            .map_err(|e| anyhow!(e.to_string()))?;
687
688        let json_table = self
689            .lua
690            .create_table()
691            .map_err(|e| anyhow!(e.to_string()))?;
692        let json_parse = self
693            .lua
694            .create_function(|lua, json_str: String| {
695                let parsed: serde_json::Value = serde_json::from_str(&json_str)
696                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
697                lua.to_value(&parsed)
698            })
699            .map_err(|e| anyhow!(e.to_string()))?;
700        json_table
701            .set("parse", json_parse)
702            .map_err(|e| anyhow!(e.to_string()))?;
703
704        let json_stringify = self
705            .lua
706            .create_function(|lua, value: Value| {
707                let json_val: serde_json::Value = lua
708                    .from_value(value)
709                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
710                Ok(serde_json::to_string(&json_val).unwrap_or_default())
711            })
712            .map_err(|e| anyhow!(e.to_string()))?;
713        json_table
714            .set("stringify", json_stringify)
715            .map_err(|e| anyhow!(e.to_string()))?;
716        zoi.set("json", json_table)
717            .map_err(|e| anyhow!(e.to_string()))?;
718
719        let env_table = self
720            .lua
721            .create_table()
722            .map_err(|e| anyhow!(e.to_string()))?;
723        let env_get = self
724            .lua
725            .create_function(|lua, name: String| {
726                let env_overrides: Table = lua
727                    .globals()
728                    .get(PLUGIN_ENV_OVERRIDES_KEY)
729                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
730
731                if let Some(value) = env_overrides
732                    .get::<Option<String>>(name.as_str())
733                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
734                {
735                    return Ok(Some(value));
736                }
737
738                Ok(std::env::var(name).ok())
739            })
740            .map_err(|e| anyhow!(e.to_string()))?;
741        env_table
742            .set("get", env_get)
743            .map_err(|e| anyhow!(e.to_string()))?;
744
745        let env_set = self
746            .lua
747            .create_function(|lua, (name, value): (String, String)| {
748                let env_overrides: Table = lua
749                    .globals()
750                    .get(PLUGIN_ENV_OVERRIDES_KEY)
751                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
752                env_overrides
753                    .set(name, value)
754                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
755                Ok(())
756            })
757            .map_err(|e| anyhow!(e.to_string()))?;
758        env_table
759            .set("set", env_set)
760            .map_err(|e| anyhow!(e.to_string()))?;
761        zoi.set("env", env_table)
762            .map_err(|e| anyhow!(e.to_string()))?;
763
764        self.lua
765            .globals()
766            .set("zoi", zoi)
767            .map_err(|e| anyhow!(e.to_string()))?;
768
769        let plugin_dir = get_plugin_dir()?;
770        let import_fn = self
771            .lua
772            .create_function(move |lua, file_name: String| {
773                let path = plugin_dir.join(&file_name);
774                if !path.exists() {
775                    return Err(mlua::Error::RuntimeError(format!(
776                        "File not found: {}",
777                        path.display()
778                    )));
779                }
780                let content = fs::read_to_string(&path)
781                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
782                if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
783                    match ext {
784                        "json" => {
785                            let val: serde_json::Value = serde_json::from_str(&content)
786                                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
787                            return lua.to_value(&val);
788                        }
789                        _ => return lua.to_value(&content),
790                    }
791                }
792                lua.to_value(&content)
793            })
794            .map_err(|e| anyhow!(e.to_string()))?;
795        self.lua
796            .globals()
797            .set("IMPORT", import_fn)
798            .map_err(|e| anyhow!(e.to_string()))?;
799
800        Ok(())
801    }
802
803    pub fn set_context(&self, scope: types::Scope) -> Result<()> {
804        let zoi: Table = self
805            .lua
806            .globals()
807            .get("zoi")
808            .map_err(|e| anyhow!(e.to_string()))?;
809        let scope_str = format!("{:?}", scope).to_lowercase();
810        zoi.set("scope", scope_str)
811            .map_err(|e| anyhow!(e.to_string()))?;
812        Ok(())
813    }
814
815    pub fn load_all(&self, yes: bool) -> Result<()> {
816        let plugin_dir = get_plugin_dir()?;
817        if !plugin_dir.exists() {
818            return Ok(());
819        }
820        let mut plugin_paths = Vec::new();
821        for entry in fs::read_dir(plugin_dir)? {
822            let entry = entry?;
823            let path = entry.path();
824            if path.extension().and_then(|s| s.to_str()) == Some("lua") {
825                plugin_paths.push(path);
826            }
827        }
828        plugin_paths.sort();
829
830        let trusted_path = get_plugin_dir()?.join("trusted_hashes.json");
831        let mut trusted: HashMap<String, String> = if trusted_path.exists() {
832            let content = fs::read_to_string(&trusted_path)?;
833            serde_json::from_str(&content).unwrap_or_default()
834        } else {
835            HashMap::new()
836        };
837        let mut trusted_changed = false;
838
839        for path in plugin_paths {
840            let script = fs::read_to_string(&path)?;
841
842            let mut hasher = Sha256::new();
843            hasher.update(script.as_bytes());
844            let hash = hex::encode(hasher.finalize());
845
846            let plugin_name = path
847                .file_name()
848                .unwrap_or_default()
849                .to_string_lossy()
850                .to_string();
851
852            let is_trusted = if let Some(known_hash) = trusted.get(&plugin_name) {
853                known_hash == &hash
854            } else {
855                false
856            };
857
858            if !is_trusted {
859                if yes {
860                    println!(
861                        "\n{}: Skipping untrusted plugin: {}. Run Zoi interactively to trust it.",
862                        "Warning".yellow().bold(),
863                        plugin_name.cyan()
864                    );
865                    continue;
866                } else {
867                    println!(
868                        "\n{}: Untrusted plugin detected: {}",
869                        "SECURITY WARNING".yellow().bold(),
870                        plugin_name.cyan()
871                    );
872                    println!("Plugins can execute arbitrary commands and modify your system.");
873                    if utils::ask_for_confirmation(
874                        "Do you trust this plugin and want to execute it?",
875                        false,
876                    ) {
877                        trusted.insert(plugin_name.clone(), hash);
878                        trusted_changed = true;
879                    } else {
880                        println!("Skipping untrusted plugin: {}", plugin_name);
881                        continue;
882                    }
883                }
884            }
885
886            let script_wrapper = format!(
887                "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; {}",
888                script
889            );
890            self.lua
891                .load(&script_wrapper)
892                .exec()
893                .map_err(|e| anyhow!("Plugin error in {}: {}", path.display(), e))?;
894        }
895
896        if trusted_changed {
897            let content = serde_json::to_string_pretty(&trusted)?;
898            fs::write(trusted_path, content)?;
899        }
900
901        Ok(())
902    }
903
904    pub fn trigger_hook(&self, hook_name: &str, arg: Option<Value>) -> Result<()> {
905        let registry: Table = self
906            .lua
907            .globals()
908            .get("__ZOI_HOOKS")
909            .map_err(|e| anyhow!(e.to_string()))?;
910        if let Ok(hook_list) = registry.get::<Table>(hook_name) {
911            for callback in hook_list.sequence_values::<Function>() {
912                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
913                if let Some(a) = &arg {
914                    callback
915                        .call::<()>(a.clone())
916                        .map_err(|e| anyhow!(e.to_string()))?;
917                } else {
918                    callback
919                        .call::<()>(())
920                        .map_err(|e| anyhow!(e.to_string()))?;
921                }
922            }
923        }
924        Ok(())
925    }
926
927    pub fn trigger_hook_nonfatal(&self, hook_name: &str, arg: Option<Value>) {
928        if let Err(error) = self.trigger_hook(hook_name, arg) {
929            eprintln!(
930                "Warning: hook '{}' failed after the operation completed: {}",
931                hook_name, error
932            );
933        }
934    }
935
936    pub fn trigger_resolve_shim_version(&self, bin_name: &str) -> Result<Option<String>> {
937        let registry: Table = self
938            .lua
939            .globals()
940            .get("__ZOI_HOOKS")
941            .map_err(|e| anyhow!(e.to_string()))?;
942
943        if let Ok(hook_list) = registry.get::<Table>("on_resolve_shim_version") {
944            for callback in hook_list.sequence_values::<Function>() {
945                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
946                let result: Option<String> = callback
947                    .call(bin_name)
948                    .map_err(|e| anyhow!(e.to_string()))?;
949                if result.is_some() {
950                    return Ok(result);
951                }
952            }
953        }
954        Ok(None)
955    }
956
957    pub fn trigger_project_install_hook(&self) -> Result<bool> {
958        let registry: Table = self
959            .lua
960            .globals()
961            .get("__ZOI_HOOKS")
962            .map_err(|e| anyhow!(e.to_string()))?;
963
964        if let Ok(hook_list) = registry.get::<Table>("on_project_install") {
965            for callback in hook_list.sequence_values::<Function>() {
966                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
967                let handled: bool = callback.call(()).map_err(|e| anyhow!(e.to_string()))?;
968                if handled {
969                    return Ok(true);
970                }
971            }
972        }
973        Ok(false)
974    }
975
976    pub fn run_command(&self, name: &str, args: Vec<String>) -> Result<bool> {
977        let registry: Table = self
978            .lua
979            .globals()
980            .get("__ZOI_COMMANDS")
981            .map_err(|e| anyhow!(e.to_string()))?;
982        let callback: Value = registry.get(name).map_err(|e| anyhow!(e.to_string()))?;
983        if let Value::Function(func) = callback {
984            func.call::<()>(args).map_err(|e| anyhow!(e.to_string()))?;
985            Ok(true)
986        } else {
987            Ok(false)
988        }
989    }
990
991    pub fn list_commands(&self) -> Result<Vec<(String, String)>> {
992        let registry: Table = self
993            .lua
994            .globals()
995            .get("__ZOI_COMMANDS")
996            .map_err(|e| anyhow!(e.to_string()))?;
997        let help_registry: Table = self
998            .lua
999            .globals()
1000            .get("__ZOI_COMMAND_HELP")
1001            .map_err(|e| anyhow!(e.to_string()))?;
1002        let mut commands = Vec::new();
1003        for pair in registry.pairs::<String, Value>() {
1004            let (name, _) = pair.map_err(|e| anyhow!(e.to_string()))?;
1005            let desc: String = help_registry
1006                .get(name.clone())
1007                .unwrap_or_else(|_| "".to_string());
1008            commands.push((name, desc));
1009        }
1010        Ok(commands)
1011    }
1012}
1013
1014pub fn get_plugin_dir() -> Result<PathBuf> {
1015    let home_dir =
1016        utils::get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
1017    let plugin_dir = home_dir.join(".zoi").join("plugins");
1018    if !plugin_dir.exists() {
1019        fs::create_dir_all(&plugin_dir)?;
1020    }
1021    Ok(plugin_dir)
1022}
1023
1024fn read_plugin_state() -> Result<HashMap<String, serde_json::Value>> {
1025    let path = get_plugin_dir()?.join("state.json");
1026    if !path.exists() {
1027        return Ok(HashMap::new());
1028    }
1029    let content = fs::read_to_string(path)?;
1030    Ok(serde_json::from_str(&content).unwrap_or_default())
1031}
1032
1033fn write_plugin_state(state: &HashMap<String, serde_json::Value>) -> Result<()> {
1034    let path = get_plugin_dir()?.join("state.json");
1035    let content = serde_json::to_string_pretty(state)?;
1036    fs::write(path, content)?;
1037    Ok(())
1038}