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
18pub struct PluginManager {
28 pub lua: Lua,
30}
31
32impl PluginManager {
33 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") .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}