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(file.name());
604 let Some(stripped_path) =
605 safe_stripped_relative_path(
606 &path, strip_val
607 )
608 .map_err(|e| {
609 mlua::Error::RuntimeError(e.to_string())
610 })?
611 else {
612 continue;
613 };
614 let out_path = dest_path.join(stripped_path);
615 if file.is_dir() {
616 fs::create_dir_all(&out_path).map_err(
617 |e| {
618 mlua::Error::RuntimeError(
619 e.to_string()
620 )
621 }
622 )?;
623 } else {
624 if let Some(p) = out_path.parent() {
625 fs::create_dir_all(p).map_err(|e| {
626 mlua::Error::RuntimeError(
627 e.to_string()
628 )
629 })?;
630 }
631 let mut outfile = fs::File::create(
632 &out_path
633 )
634 .map_err(|e| {
635 mlua::Error::RuntimeError(e.to_string())
636 })?;
637 std::io::copy(&mut file, &mut outfile)
638 .map_err(|e| {
639 mlua::Error::RuntimeError(
640 e.to_string()
641 )
642 })?;
643 }
644 }
645 } else {
646 archive.extract(dest_path).map_err(|e| {
647 mlua::Error::RuntimeError(e.to_string())
648 })?;
649 }
650 } else if archive_path_str.ends_with(".tar.gz")
651 || std::path::Path::new(&archive_path_str)
652 .extension()
653 .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
654 {
655 let tar_gz = flate2::read::GzDecoder::new(file);
656 let archive = tar::Archive::new(tar_gz);
657 if strip_val > 0 {
658 unpack_with_strip(archive, dest_path, strip_val)
659 .map_err(|e| {
660 mlua::Error::RuntimeError(e.to_string())
661 })?;
662 } else {
663 let mut archive = archive;
664 archive.unpack(dest_path).map_err(|e| {
665 mlua::Error::RuntimeError(e.to_string())
666 })?;
667 }
668 } else if archive_path_str.ends_with(".tar.zst") {
669 let tar_zst = zstd::stream::read::Decoder::new(file)
670 .map_err(|e| {
671 mlua::Error::RuntimeError(e.to_string())
672 })?;
673 let archive = tar::Archive::new(tar_zst);
674 if strip_val > 0 {
675 unpack_with_strip(archive, dest_path, strip_val)
676 .map_err(|e| {
677 mlua::Error::RuntimeError(e.to_string())
678 })?;
679 } else {
680 let mut archive = archive;
681 archive.unpack(dest_path).map_err(|e| {
682 mlua::Error::RuntimeError(e.to_string())
683 })?;
684 }
685 } else if archive_path_str.ends_with(".tar.xz") {
686 let tar_xz = xz2::read::XzDecoder::new(file);
687 let archive = tar::Archive::new(tar_xz);
688 if strip_val > 0 {
689 unpack_with_strip(archive, dest_path, strip_val)
690 .map_err(|e| {
691 mlua::Error::RuntimeError(e.to_string())
692 })?;
693 } else {
694 let mut archive = archive;
695 archive.unpack(dest_path).map_err(|e| {
696 mlua::Error::RuntimeError(e.to_string())
697 })?;
698 }
699 } else {
700 return Err(mlua::Error::RuntimeError(format!(
701 "Unsupported archive format: {source}"
702 )));
703 }
704 Ok(true)
705 }
706 )
707 .map_err(|e| anyhow!(e.to_string()))?;
708 archive_table
709 .set("extract", archive_extract)
710 .map_err(|e| anyhow!(e.to_string()))?;
711 zoi.set("archive", archive_table)
712 .map_err(|e| anyhow!(e.to_string()))?;
713
714 let http_table = self
715 .lua
716 .create_table()
717 .map_err(|e| anyhow!(e.to_string()))?;
718 let http_get = self
719 .lua
720 .create_function(|_, url: String| {
721 let client = reqwest::blocking::Client::builder()
722 .user_agent("zoi-plugin")
723 .build()
724 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
725 match client.get(&url).send() {
726 Ok(resp) => Ok(resp.text().ok()),
727 Err(_) => Ok(None)
728 }
729 })
730 .map_err(|e| anyhow!(e.to_string()))?;
731 http_table
732 .set("get", http_get)
733 .map_err(|e| anyhow!(e.to_string()))?;
734
735 let http_download = self
736 .lua
737 .create_function(|_, (url, dest): (String, String)| {
738 let mut response = reqwest::blocking::get(&url)
739 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
740 if !response.status().is_success() {
741 return Ok(false);
742 }
743 let mut dest_file = fs::File::create(dest)
744 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
745 std::io::copy(&mut response, &mut dest_file)
746 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
747 Ok(true)
748 })
749 .map_err(|e| anyhow!(e.to_string()))?;
750 http_table
751 .set("download", http_download)
752 .map_err(|e| anyhow!(e.to_string()))?;
753
754 let http_post = self
755 .lua
756 .create_function(|_, (url, body): (String, String)| {
757 let client = reqwest::blocking::Client::builder()
758 .user_agent("zoi-plugin")
759 .build()
760 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
761 match client.post(&url).body(body).send() {
762 Ok(resp) => Ok(resp.text().ok()),
763 Err(_) => Ok(None)
764 }
765 })
766 .map_err(|e| anyhow!(e.to_string()))?;
767 http_table
768 .set("post", http_post)
769 .map_err(|e| anyhow!(e.to_string()))?;
770 zoi.set("http", http_table)
771 .map_err(|e| anyhow!(e.to_string()))?;
772
773 let json_table = self
774 .lua
775 .create_table()
776 .map_err(|e| anyhow!(e.to_string()))?;
777 let json_parse = self
778 .lua
779 .create_function(|lua, json_str: String| {
780 let parsed: serde_json::Value = serde_json::from_str(&json_str)
781 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
782 lua.to_value(&parsed)
783 })
784 .map_err(|e| anyhow!(e.to_string()))?;
785 json_table
786 .set("parse", json_parse)
787 .map_err(|e| anyhow!(e.to_string()))?;
788
789 let json_stringify = self
790 .lua
791 .create_function(|lua, value: Value| {
792 let json_val: serde_json::Value = lua
793 .from_value(value)
794 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
795 Ok(serde_json::to_string(&json_val).unwrap_or_default())
796 })
797 .map_err(|e| anyhow!(e.to_string()))?;
798 json_table
799 .set("stringify", json_stringify)
800 .map_err(|e| anyhow!(e.to_string()))?;
801 zoi.set("json", json_table)
802 .map_err(|e| anyhow!(e.to_string()))?;
803
804 let env_table = self
805 .lua
806 .create_table()
807 .map_err(|e| anyhow!(e.to_string()))?;
808 let env_get = self
809 .lua
810 .create_function(|lua, name: String| {
811 let env_overrides: Table = lua
812 .globals()
813 .get(PLUGIN_ENV_OVERRIDES_KEY)
814 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
815
816 if let Some(value) = env_overrides
817 .get::<Option<String>>(name.as_str())
818 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
819 {
820 return Ok(Some(value));
821 }
822
823 Ok(std::env::var(name).ok())
824 })
825 .map_err(|e| anyhow!(e.to_string()))?;
826 env_table
827 .set("get", env_get)
828 .map_err(|e| anyhow!(e.to_string()))?;
829
830 let env_set = self
831 .lua
832 .create_function(|lua, (name, value): (String, String)| {
833 let env_overrides: Table = lua
834 .globals()
835 .get(PLUGIN_ENV_OVERRIDES_KEY)
836 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
837 env_overrides
838 .set(name, value)
839 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
840 Ok(())
841 })
842 .map_err(|e| anyhow!(e.to_string()))?;
843 env_table
844 .set("set", env_set)
845 .map_err(|e| anyhow!(e.to_string()))?;
846 zoi.set("env", env_table)
847 .map_err(|e| anyhow!(e.to_string()))?;
848
849 self.lua
850 .globals()
851 .set("zoi", zoi)
852 .map_err(|e| anyhow!(e.to_string()))?;
853
854 let plugin_dir = get_plugin_dir()?;
855 let import_fn = self
856 .lua
857 .create_function(move |lua, file_name: String| {
858 let path = plugin_dir.join(&file_name);
859 if !path.exists() {
860 return Err(mlua::Error::RuntimeError(format!(
861 "File not found: {}",
862 path.display()
863 )));
864 }
865 let content = fs::read_to_string(&path)
866 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
867 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
868 match ext {
869 "json" => {
870 let val: serde_json::Value = serde_json::from_str(
871 &content
872 )
873 .map_err(|e| {
874 mlua::Error::RuntimeError(e.to_string())
875 })?;
876 return lua.to_value(&val);
877 }
878 _ => return lua.to_value(&content)
879 }
880 }
881 lua.to_value(&content)
882 })
883 .map_err(|e| anyhow!(e.to_string()))?;
884 self.lua
885 .globals()
886 .set("IMPORT", import_fn)
887 .map_err(|e| anyhow!(e.to_string()))?;
888
889 Ok(())
890 }
891
892 pub fn set_context(&self, scope: types::Scope) -> Result<()> {
899 let zoi: Table = self
900 .lua
901 .globals()
902 .get("zoi")
903 .map_err(|e| anyhow!(e.to_string()))?;
904 let scope_str = format!("{scope:?}").to_lowercase();
905 zoi.set("scope", scope_str)
906 .map_err(|e| anyhow!(e.to_string()))?;
907 Ok(())
908 }
909
910 pub fn load_all(&self, yes: bool) -> Result<()> {
917 let plugin_dir = get_plugin_dir()?;
918 if !plugin_dir.exists() {
919 return Ok(());
920 }
921 let mut plugin_paths = Vec::new();
922 for entry in fs::read_dir(plugin_dir)? {
923 let entry = entry?;
924 let path = entry.path();
925 if path.extension().and_then(|s| s.to_str()) == Some("lua") {
926 plugin_paths.push(path);
927 }
928 }
929 plugin_paths.sort();
930
931 let trusted_path = get_plugin_dir()?.join("trusted_hashes.json");
932 let mut trusted: HashMap<String, String> = if trusted_path.exists() {
933 let content = fs::read_to_string(&trusted_path)?;
934 serde_json::from_str(&content).unwrap_or_default()
935 } else {
936 HashMap::new()
937 };
938 let mut trusted_changed = false;
939
940 for path in plugin_paths {
941 let script = fs::read_to_string(&path)?;
942
943 let mut hasher = Sha256::new();
944 hasher.update(script.as_bytes());
945 let hash = hex::encode(hasher.finalize());
946
947 let plugin_name = path
948 .file_name()
949 .unwrap_or_default()
950 .to_string_lossy()
951 .to_string();
952
953 let is_trusted = trusted
954 .get(&plugin_name)
955 .is_some_and(|known_hash| known_hash == &hash);
956
957 if !is_trusted {
958 if yes {
959 println!(
960 "\n{}: Skipping untrusted plugin: {}. Run Zoi \
961 interactively to trust it.",
962 "Warning".yellow().bold(),
963 plugin_name.cyan()
964 );
965
966 continue;
967 }
968
969 println!(
970 "\n{}: Untrusted plugin detected: {}",
971 "SECURITY WARNING".yellow().bold(),
972 plugin_name.cyan()
973 );
974 println!(
975 "Plugins can execute arbitrary commands and modify your \
976 system."
977 );
978 if utils::ask_for_confirmation(
979 "Do you trust this plugin and want to execute it?",
980 false
981 ) {
982 trusted.insert(plugin_name.clone(), hash);
983 trusted_changed = true;
984 } else {
985 println!("Skipping untrusted plugin: {plugin_name}");
986 continue;
987 }
988 }
989
990 let script_wrapper = format!(
991 "local old_reg = zoi.register_command; zoi.register_command = \
992 function(a, b) if type(a) == 'string' then \
993 zoi.register_command_simple(a, b) else old_reg(a) end end; \
994 {script}"
995 );
996 self.lua.load(&script_wrapper).exec().map_err(|e| {
997 anyhow!("Plugin error in {}: {}", path.display(), e)
998 })?;
999 }
1000
1001 if trusted_changed {
1002 let content = serde_json::to_string_pretty(&trusted)?;
1003 fs::write(trusted_path, content)?;
1004 }
1005
1006 Ok(())
1007 }
1008
1009 pub fn trigger_hook(
1016 &self,
1017 hook_name: &str,
1018 arg: Option<&Value>
1019 ) -> Result<()> {
1020 let registry: Table = self
1021 .lua
1022 .globals()
1023 .get("__ZOI_HOOKS")
1024 .map_err(|e| anyhow!(e.to_string()))?;
1025 if let Ok(hook_list) = registry.get::<Table>(hook_name) {
1026 for callback in hook_list.sequence_values::<Function>() {
1027 let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1028 if let Some(a) = arg {
1029 callback
1030 .call::<()>(a.clone())
1031 .map_err(|e| anyhow!(e.to_string()))?;
1032 } else {
1033 callback
1034 .call::<()>(())
1035 .map_err(|e| anyhow!(e.to_string()))?;
1036 }
1037 }
1038 }
1039 Ok(())
1040 }
1041
1042 pub fn trigger_hook_nonfatal(&self, hook_name: &str, arg: Option<&Value>) {
1045 if let Err(error) = self.trigger_hook(hook_name, arg) {
1046 eprintln!(
1047 "Warning: hook '{hook_name}' failed after the operation \
1048 completed: {error}"
1049 );
1050 }
1051 }
1052
1053 pub fn trigger_resolve_shim_version(
1060 &self,
1061 bin_name: &str
1062 ) -> Result<Option<String>> {
1063 let registry: Table = self
1064 .lua
1065 .globals()
1066 .get("__ZOI_HOOKS")
1067 .map_err(|e| anyhow!(e.to_string()))?;
1068
1069 if let Ok(hook_list) = registry.get::<Table>("on_resolve_shim_version")
1070 {
1071 for callback in hook_list.sequence_values::<Function>() {
1072 let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1073 let result: Option<String> = callback
1074 .call(bin_name)
1075 .map_err(|e| anyhow!(e.to_string()))?;
1076 if result.is_some() {
1077 return Ok(result);
1078 }
1079 }
1080 }
1081 Ok(None)
1082 }
1083
1084 pub fn trigger_project_install_hook(&self) -> Result<bool> {
1091 let registry: Table = self
1092 .lua
1093 .globals()
1094 .get("__ZOI_HOOKS")
1095 .map_err(|e| anyhow!(e.to_string()))?;
1096
1097 if let Ok(hook_list) = registry.get::<Table>("on_project_install") {
1098 for callback in hook_list.sequence_values::<Function>() {
1099 let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1100 let handled: bool =
1101 callback.call(()).map_err(|e| anyhow!(e.to_string()))?;
1102 if handled {
1103 return Ok(true);
1104 }
1105 }
1106 }
1107 Ok(false)
1108 }
1109
1110 pub fn run_command(&self, name: &str, args: Vec<String>) -> Result<bool> {
1117 let registry: Table = self
1118 .lua
1119 .globals()
1120 .get("__ZOI_COMMANDS")
1121 .map_err(|e| anyhow!(e.to_string()))?;
1122 let callback: Value =
1123 registry.get(name).map_err(|e| anyhow!(e.to_string()))?;
1124 if let Value::Function(func) = callback {
1125 func.call::<()>(args).map_err(|e| anyhow!(e.to_string()))?;
1126 Ok(true)
1127 } else {
1128 Ok(false)
1129 }
1130 }
1131
1132 pub fn list_commands(&self) -> Result<Vec<(String, String)>> {
1138 let registry: Table = self
1139 .lua
1140 .globals()
1141 .get("__ZOI_COMMANDS")
1142 .map_err(|e| anyhow!(e.to_string()))?;
1143 let help_registry: Table = self
1144 .lua
1145 .globals()
1146 .get("__ZOI_COMMAND_HELP")
1147 .map_err(|e| anyhow!(e.to_string()))?;
1148 let mut commands = Vec::new();
1149 for pair in registry.pairs::<String, Value>() {
1150 let (name, _) = pair.map_err(|e| anyhow!(e.to_string()))?;
1151 let desc: String = help_registry
1152 .get(name.clone())
1153 .unwrap_or_else(|_| String::new());
1154 commands.push((name, desc));
1155 }
1156 Ok(commands)
1157 }
1158}
1159
1160pub fn get_plugin_dir() -> Result<PathBuf> {
1167 let home_dir = utils::get_user_home()
1168 .ok_or_else(|| anyhow!("Could not find home directory."))?;
1169 let plugin_dir = home_dir.join(".zoi").join("plugins");
1170 if !plugin_dir.exists() {
1171 fs::create_dir_all(&plugin_dir)?;
1172 }
1173 Ok(plugin_dir)
1174}
1175
1176fn read_plugin_state() -> Result<HashMap<String, serde_json::Value>> {
1178 let path = get_plugin_dir()?.join("state.json");
1179 if !path.exists() {
1180 return Ok(HashMap::new());
1181 }
1182 let content = fs::read_to_string(path)?;
1183 Ok(serde_json::from_str(&content).unwrap_or_default())
1184}
1185
1186fn write_plugin_state(
1188 state: &HashMap<String, serde_json::Value>
1189) -> Result<()> {
1190 let path = get_plugin_dir()?.join("state.json");
1191 let content = serde_json::to_string_pretty(state)?;
1192 fs::write(path, content)?;
1193 Ok(())
1194}