Skip to main content

zoi_plugins/
extension.rs

1use std::fs;
2use std::path::PathBuf;
3
4use anyhow::{Result, anyhow};
5use mlua::LuaSerdeExt;
6use zoi_core::{config, pgp, types};
7use zoi_hooks as hooks;
8use zoi_lua;
9use zoi_resolver::{local, resolve};
10
11use crate::PluginManager;
12
13/// Filename used to store the persistent state of an extension.
14const EXTENSION_STATE_FILE: &str = "extension-state.yaml";
15
16/// Represents the persistent state of an installed extension, allowing for
17/// rollbacks.
18#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
19struct ExtensionState {
20    /// The default registry configuration before the extension was added.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    previous_default_registry: Option<types::Registry>,
23    /// The path to the project file created by the extension, if any.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    project_file_path: Option<PathBuf>,
26    /// Metadata about the installed extension itself.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    installed_extension: Option<types::ExtensionInfo>
29}
30
31/// Returns the path to the extension state file for a given manifest.
32fn get_extension_state_path(
33    manifest: &types::InstallManifest
34) -> Result<PathBuf> {
35    let version_dir = local::get_package_version_dir(
36        manifest.scope,
37        &manifest.registry_handle,
38        &manifest.repo,
39        &manifest.name,
40        &manifest.version
41    )?;
42    Ok(version_dir.join(EXTENSION_STATE_FILE))
43}
44
45/// Writes the extension state to disk.
46fn write_extension_state(
47    manifest: &types::InstallManifest,
48    extension_state: &ExtensionState
49) -> Result<()> {
50    let state_path = get_extension_state_path(manifest)?;
51    fs::write(state_path, serde_yaml::to_string(extension_state)?)?;
52    Ok(())
53}
54
55/// Reads the extension state from disk, if it exists.
56fn read_extension_state(
57    manifest: &types::InstallManifest
58) -> Result<Option<ExtensionState>> {
59    let state_path = get_extension_state_path(manifest)?;
60    if !state_path.exists() {
61        return Ok(None);
62    }
63    let content = fs::read_to_string(state_path)?;
64    Ok(Some(serde_yaml::from_str(&content)?))
65}
66
67/// Restores the default registry to its previous state.
68fn restore_default_registry(
69    saved_state: Option<&ExtensionState>,
70    added_registry_url: &str
71) -> Result<()> {
72    if let Some(saved_state) = saved_state {
73        return config::set_user_default_registry(
74            saved_state.previous_default_registry.clone()
75        );
76    }
77
78    let user_config = config::read_user_config()?;
79    let should_clear = user_config
80        .default_registry
81        .as_ref()
82        .is_some_and(|registry| registry.url == added_registry_url);
83    if should_clear {
84        config::set_user_default_registry(None)?;
85    }
86    Ok(())
87}
88
89/// Checks if the extension state contains any data that needs to be persisted.
90fn extension_state_requires_persistence(
91    extension_state: &ExtensionState
92) -> bool {
93    extension_state.previous_default_registry.is_some()
94        || extension_state.project_file_path.is_some()
95        || extension_state.installed_extension.is_some()
96}
97
98/// Returns the path to the project file, either from saved state or the
99/// default.
100fn get_project_file_path(saved_state: Option<&ExtensionState>) -> PathBuf {
101    saved_state
102        .and_then(|state| state.project_file_path.clone())
103        .unwrap_or_else(|| PathBuf::from("zoi.lua"))
104}
105
106/// Extracts the repository name from a git URL.
107fn get_repo_name_from_url(url: &str) -> &str {
108    url.trim_end_matches('/')
109        .split('/')
110        .next_back()
111        .unwrap_or_default()
112        .trim_end_matches(".git")
113}
114
115/// Reverts a specific change made by an extension.
116fn revert_extension_change(
117    change: &types::ExtensionChange,
118    saved_state: Option<&ExtensionState>
119) -> Result<()> {
120    match change {
121        types::ExtensionChange::RepoGit { add } => {
122            let repo_name = get_repo_name_from_url(add);
123            if !repo_name.is_empty() {
124                config::remove_git_repo(repo_name)?;
125            }
126        }
127        types::ExtensionChange::RegistryRepo { add } => {
128            restore_default_registry(saved_state, add)?;
129        }
130        types::ExtensionChange::RegistryAdd { add } => {
131            config::remove_added_registry(add)?;
132        }
133        types::ExtensionChange::RepoAdd { add } => {
134            config::remove_repo(add)?;
135        }
136        types::ExtensionChange::Project { add: _ } => {
137            let project_file_path = get_project_file_path(saved_state);
138            if project_file_path.exists() {
139                fs::remove_file(project_file_path)?;
140            }
141        }
142        types::ExtensionChange::Pgp { name, key: _ } => {
143            pgp::remove_key_by_name(name)?;
144        }
145        types::ExtensionChange::Plugin { name, script: _ } => {
146            let plugin_dir = crate::get_plugin_dir()?;
147            let plugin_path = plugin_dir.join(format!("{name}.lua"));
148            if plugin_path.exists() {
149                fs::remove_file(plugin_path)?;
150            }
151        }
152        types::ExtensionChange::Hook { name, content: _ } => {
153            let hooks_dir = hooks::global::get_user_hooks_dir()?;
154            let hook_path = hooks_dir.join(format!("{name}.hook.yaml"));
155            if hook_path.exists() {
156                fs::remove_file(hook_path)?;
157            }
158        }
159    }
160    Ok(())
161}
162
163/// Installs an extension, applying its declared changes to the system and
164/// registry configuration.
165///
166/// # Errors
167///
168/// Returns an error if the extension package cannot be resolved, if it's not an
169/// extension package, or if applying any of the changes fails.
170pub fn add(
171    ext_name: &str,
172    yes: bool,
173    plugin_manager: Option<&PluginManager>
174) -> Result<()> {
175    println!("Adding extension: {ext_name}");
176
177    let (pkg, _, _, pkg_lua_path, registry_handle, repo_type, _) =
178        resolve::resolve_package_and_version(ext_name, None, false, yes)?;
179
180    if pkg.package_type != types::PackageType::Extension {
181        return Err(anyhow!("'{ext_name}' is not an extension package."));
182    }
183
184    let mut pkg_val = None;
185    if let Some(pm) = plugin_manager {
186        let v = pm
187            .lua
188            .to_value(&pkg)
189            .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
190        pm.trigger_hook("on_pre_extension_add", Some(&v.clone()))?;
191        pkg_val = Some(v);
192    }
193
194    let Some(extension_info) = pkg.extension else {
195        return Err(anyhow!(
196            "'{ext_name}' is an extension package but contains no extension \
197             data."
198        ));
199    };
200    if extension_info.extension_type != "zoi" {
201        return Err(anyhow!(
202            "Unsupported extension type: {}",
203            extension_info.extension_type
204        ));
205    }
206    let has_registry_repo_change =
207        extension_info.changes.iter().any(|change| {
208            matches!(change, types::ExtensionChange::RegistryRepo { .. })
209        });
210    let has_project_change = extension_info
211        .changes
212        .iter()
213        .any(|change| matches!(change, types::ExtensionChange::Project { .. }));
214    let previous_default_registry = if has_registry_repo_change {
215        config::read_user_config()?.default_registry.clone()
216    } else {
217        None
218    };
219    let project_file_path = if has_project_change {
220        Some(std::env::current_dir()?.join("zoi.lua"))
221    } else {
222        None
223    };
224    let extension_state = ExtensionState {
225        previous_default_registry,
226        project_file_path,
227        installed_extension: Some(extension_info.clone())
228    };
229
230    let manifest = types::InstallManifest {
231        name: pkg.name.clone(),
232        version: pkg.version.clone().unwrap_or_default(),
233        epoch: pkg.epoch,
234        revision: pkg.revision.clone(),
235        sub_package: None,
236        repo: pkg.repo.clone(),
237        repo_type: repo_type.unwrap_or_else(|| "unofficial".to_string()),
238        registry_handle: registry_handle.unwrap_or_default(),
239        package_type: pkg.package_type,
240        description: pkg.description.clone(),
241        reason: types::InstallReason::Direct,
242        scope: pkg.scope,
243        bins: None,
244        alternatives: None,
245        conflicts: None,
246        replaces: None,
247        provides: None,
248        backup: None,
249        installed_dependencies: vec![],
250        dependencies_v2: None,
251        chosen_options: vec![],
252        chosen_optionals: vec![],
253        install_method: None,
254        platform: zoi_core::utils::get_platform().unwrap_or_default(),
255        service: None,
256        installed_files: vec![],
257        file_digests: None,
258        installed_size: pkg.installed_size,
259        sandbox: None,
260        completions: None
261    };
262    let mut wrote_manifest = false;
263    let mut applied_changes = Vec::new();
264    let add_result = (|| -> Result<()> {
265        if extension_state_requires_persistence(&extension_state) {
266            local::write_manifest(&manifest)?;
267            local::persist_package_source(&manifest, &pkg_lua_path)?;
268            wrote_manifest = true;
269            write_extension_state(&manifest, &extension_state)?;
270        }
271
272        println!("Applying extension changes...");
273        for change in &extension_info.changes {
274            match change {
275                types::ExtensionChange::RepoGit { add } => {
276                    println!("Adding git repository: {add}");
277                    config::clone_git_repo(add)?;
278                }
279                types::ExtensionChange::RegistryRepo { add } => {
280                    println!("Setting registry to: {add}");
281                    config::set_default_registry(add)?;
282                }
283                types::ExtensionChange::RegistryAdd { add } => {
284                    println!("Adding registry: {add}");
285                    config::add_added_registry(add)?;
286                }
287                types::ExtensionChange::RepoAdd { add } => {
288                    println!("Adding repository: {add}");
289                    config::add_repo(add)?;
290                }
291                types::ExtensionChange::Project { add } => {
292                    let project_file_path =
293                        get_project_file_path(Some(&extension_state));
294                    println!("Creating {}...", project_file_path.display());
295                    if project_file_path.exists() {
296                        return Err(anyhow!(
297                            "A 'zoi.lua' file already exists at '{}'. Please \
298                             remove it first.",
299                            project_file_path.display()
300                        ));
301                    }
302                    fs::write(&project_file_path, add)?;
303                }
304                types::ExtensionChange::Pgp { name, key } => {
305                    println!("Adding PGP key: {name} from {key}");
306                    if key.starts_with("http") {
307                        pgp::add_key_from_url(key, name, false)?;
308                    } else {
309                        pgp::add_key_from_fingerprint(key, name, false)?;
310                    }
311                }
312                types::ExtensionChange::Plugin { name, script } => {
313                    println!("Adding plugin: {name}");
314                    let plugin_dir = crate::get_plugin_dir()?;
315                    let plugin_path = plugin_dir.join(format!("{name}.lua"));
316                    fs::write(plugin_path, script)?;
317                }
318                types::ExtensionChange::Hook { name, content } => {
319                    println!("Adding global hook: {name}");
320                    let hooks_dir = hooks::global::get_user_hooks_dir()?;
321                    let hook_path = hooks_dir.join(format!("{name}.hook.yaml"));
322                    fs::write(hook_path, content)?;
323                }
324            }
325            applied_changes.push(change.clone());
326        }
327        if !wrote_manifest {
328            local::write_manifest(&manifest)?;
329            local::persist_package_source(&manifest, &pkg_lua_path)?;
330            wrote_manifest = true;
331        }
332        Ok(())
333    })();
334    if let Err(error) = add_result {
335        for change in applied_changes.iter().rev() {
336            if let Err(rollback_error) =
337                revert_extension_change(change, Some(&extension_state))
338            {
339                eprintln!(
340                    "Warning: failed to roll back extension change \
341                     {change:?}: {rollback_error}"
342                );
343            }
344        }
345        if wrote_manifest
346            && let Ok(package_dir) = local::get_package_dir(
347                manifest.scope,
348                &manifest.registry_handle,
349                &manifest.repo,
350                &manifest.name
351            )
352        {
353            let _ = fs::remove_dir_all(package_dir);
354        }
355        return Err(error);
356    }
357
358    if let (Some(pm), Some(v)) = (plugin_manager, pkg_val) {
359        pm.trigger_hook_nonfatal("on_post_extension_add", Some(&v));
360    }
361
362    println!("Successfully added extension '{ext_name}'.");
363
364    Ok(())
365}
366
367/// Uninstalls an extension and reverts all changes it made to the system and
368/// configuration.
369///
370/// # Errors
371///
372/// Returns an error if the extension is not installed, if it's not an extension
373/// package, or if reverting any of the changes fails.
374pub fn remove(
375    ext_name: &str,
376    yes: bool,
377    plugin_manager: Option<&PluginManager>
378) -> Result<()> {
379    println!("Removing extension: {ext_name}");
380
381    let request = resolve::parse_source_string(ext_name)?;
382    let mut candidates = Vec::new();
383    for scope in [
384        types::Scope::Project,
385        types::Scope::User,
386        types::Scope::System
387    ] {
388        candidates
389            .extend(local::find_installed_manifests_matching(&request, scope)?);
390    }
391
392    if candidates.is_empty() {
393        return Err(anyhow!("Extension '{ext_name}' is not installed."));
394    }
395
396    let manifest = select_candidate(ext_name, candidates, yes)?;
397    let scope = manifest.scope;
398
399    let mut manifest_val = None;
400    if let Some(pm) = plugin_manager {
401        let v = pm
402            .lua
403            .to_value(&manifest)
404            .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
405        pm.trigger_hook("on_pre_extension_remove", Some(&v.clone()))?;
406        manifest_val = Some(v);
407    }
408
409    if manifest.package_type != types::PackageType::Extension {
410        return Err(anyhow!("'{ext_name}' is not an extension package."));
411    }
412
413    let installed_source_path = local::get_package_source_path(&manifest)?;
414    let pkg = if installed_source_path.exists() {
415        let path = installed_source_path.to_str().ok_or_else(|| {
416            anyhow!("Stored package source path contains invalid UTF-8")
417        })?;
418        zoi_lua::parser::parse_lua_package(
419            path,
420            Some(&manifest.version),
421            Some(manifest.scope),
422            true
423        )?
424    } else {
425        let source = local::installed_manifest_source(&manifest);
426        let (pkg, _, _, _, _, _, _) = resolve::resolve_package_and_version(
427            &source,
428            Some(manifest.scope),
429            true,
430            yes
431        )?;
432        pkg
433    };
434
435    let extension_state = read_extension_state(&manifest)?;
436    let extension_info = extension_state
437        .as_ref()
438        .and_then(|state| state.installed_extension.clone())
439        .or(pkg.extension);
440
441    if let Some(extension_info) = extension_info {
442        if extension_info.extension_type != "zoi" {
443            return Err(anyhow!(
444                "Unsupported extension type: {}",
445                extension_info.extension_type
446            ));
447        }
448
449        println!("Reverting extension changes...");
450        for change in extension_info.changes.iter().rev() {
451            match change {
452                types::ExtensionChange::RepoGit { add } => {
453                    let repo_name = get_repo_name_from_url(add);
454                    if !repo_name.is_empty() {
455                        println!("Removing git repository: {repo_name}");
456                        if let Err(e) = revert_extension_change(
457                            change,
458                            extension_state.as_ref()
459                        ) {
460                            eprintln!(
461                                "Warning: failed to remove git repo \
462                                 '{repo_name}': {e}"
463                            );
464                        }
465                    }
466                }
467                types::ExtensionChange::RegistryRepo { add: _ } => {
468                    println!("Restoring previous default registry");
469                    if let Err(e) = revert_extension_change(
470                        change,
471                        extension_state.as_ref()
472                    ) {
473                        eprintln!(
474                            "Warning: failed to restore default registry: {e}"
475                        );
476                    }
477                }
478                types::ExtensionChange::RegistryAdd { add } => {
479                    println!("Removing registry: {add}");
480                    if let Err(e) = revert_extension_change(
481                        change,
482                        extension_state.as_ref()
483                    ) {
484                        eprintln!(
485                            "Warning: failed to remove registry '{add}': {e}"
486                        );
487                    }
488                }
489                types::ExtensionChange::RepoAdd { add } => {
490                    println!("Removing repository: {add}");
491                    if let Err(e) = revert_extension_change(
492                        change,
493                        extension_state.as_ref()
494                    ) {
495                        eprintln!(
496                            "Warning: failed to remove repo '{add}': {e}"
497                        );
498                    }
499                }
500                types::ExtensionChange::Project { add: _ } => {
501                    let project_file_path =
502                        get_project_file_path(extension_state.as_ref());
503                    println!("Removing {}...", project_file_path.display());
504                    if let Err(e) = revert_extension_change(
505                        change,
506                        extension_state.as_ref()
507                    ) {
508                        eprintln!(
509                            "Warning: failed to remove '{}': {e}",
510                            project_file_path.display()
511                        );
512                    }
513                }
514                types::ExtensionChange::Pgp { name, key: _ } => {
515                    println!("Removing PGP key: {name}");
516                    if let Err(e) = revert_extension_change(
517                        change,
518                        extension_state.as_ref()
519                    ) {
520                        eprintln!(
521                            "Warning: failed to remove PGP key '{name}': {e}"
522                        );
523                    }
524                }
525                types::ExtensionChange::Plugin { name, script: _ } => {
526                    println!("Removing plugin: {name}");
527                    if let Err(e) = revert_extension_change(
528                        change,
529                        extension_state.as_ref()
530                    ) {
531                        eprintln!(
532                            "Warning: failed to remove plugin '{name}': {e}"
533                        );
534                    }
535                }
536                types::ExtensionChange::Hook { name, content: _ } => {
537                    println!("Removing global hook: {name}");
538                    if let Err(e) = revert_extension_change(
539                        change,
540                        extension_state.as_ref()
541                    ) {
542                        eprintln!(
543                            "Warning: failed to remove global hook '{name}': \
544                             {e}"
545                        );
546                    }
547                }
548            }
549        }
550    } else {
551        return Err(anyhow!(
552            "'{ext_name}' is an extension package but contains no extension \
553             data."
554        ));
555    }
556
557    let package_dir = local::get_package_dir(
558        scope,
559        &manifest.registry_handle,
560        &manifest.repo,
561        &manifest.name
562    )?;
563
564    if package_dir.exists() {
565        fs::remove_dir_all(&package_dir)?;
566    }
567
568    if let (Some(pm), Some(v)) = (plugin_manager, manifest_val) {
569        pm.trigger_hook_nonfatal("on_post_extension_remove", Some(&v));
570    }
571
572    println!("Successfully removed extension '{ext_name}'.");
573
574    Ok(())
575}
576
577/// Prompts the user to select one extension if multiple candidates match the
578/// request.
579fn select_candidate(
580    package_name: &str,
581    candidates: Vec<types::InstallManifest>,
582    yes: bool
583) -> Result<types::InstallManifest> {
584    use colored::Colorize;
585    use comfy_table::Table;
586    use comfy_table::presets::UTF8_FULL;
587    use dialoguer::Select;
588    use dialoguer::theme::ColorfulTheme;
589
590    if candidates.is_empty() {
591        return Err(anyhow!("Package '{package_name}' is not installed."));
592    }
593    if candidates.len() == 1 {
594        return Ok(candidates
595            .into_iter()
596            .next()
597            .expect("Already checked length"));
598    }
599    if yes {
600        return Err(anyhow!(
601            "Package '{package_name}' matches multiple installed packages. \
602             Use an explicit source like '#handle@repo/name[:sub]@version'."
603        ));
604    }
605
606    let displays: Vec<_> = candidates
607        .iter()
608        .map(|m| {
609            let source = local::installed_manifest_source(m);
610            let scope_label = match m.scope {
611                types::Scope::User => "user",
612                types::Scope::System => "system",
613                types::Scope::Project => "project"
614            };
615            format!("{} ({}, v{})", source, scope_label, m.version)
616        })
617        .collect();
618
619    let mut table = Table::new();
620    table.load_style(UTF8_FULL);
621    table.set_header(vec!["#", "Source", "Version"]);
622    for (i, m) in candidates.iter().enumerate() {
623        table.add_row(vec![
624            (i + 1).to_string(),
625            local::installed_manifest_source(m),
626            m.version.clone(),
627        ]);
628    }
629    println!(
630        "Found multiple installed packages matching '{}'. Please choose one:",
631        package_name.cyan()
632    );
633    println!("{table}");
634
635    let selection = Select::with_theme(&ColorfulTheme::default())
636        .with_prompt("Select an installed package")
637        .items(&displays)
638        .default(0)
639        .interact()?;
640
641    Ok(candidates
642        .get(selection)
643        .cloned()
644        .expect("Invalid selection"))
645}