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