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