Skip to main content

zoi_install/
create.rs

1use anyhow::{Result, anyhow};
2use colored::*;
3use mlua::LuaSerdeExt;
4use std::fs;
5use std::path::Path;
6use tar::Archive;
7use tempfile::Builder;
8use zoi_core::{types, utils};
9use zoi_package as package;
10use zoi_plugins::PluginManager;
11use zoi_resolver as resolver;
12use zstd::stream::read::Decoder as ZstdDecoder;
13
14fn install_app_from_archive(archive_path: &Path, destination_dir: &Path) -> Result<()> {
15    println!(
16        "Extracting app to '{}'...",
17        destination_dir.display().to_string().cyan()
18    );
19    let file = fs::File::open(archive_path)?;
20    let decoder = ZstdDecoder::new(file)?;
21    let mut archive = Archive::new(decoder);
22
23    let temp_extract_dir = Builder::new().prefix("zoi-create-extract-").tempdir()?;
24
25    archive.unpack(temp_extract_dir.path())?;
26
27    let manifest_path = temp_extract_dir.path().join("manifest.json");
28    if !manifest_path.exists() {
29        // Fallback to legacy format
30        let create_pkg_dir = temp_extract_dir.path().join("data/createpkgdir");
31        if !create_pkg_dir.exists() {
32            return Err(anyhow!(
33                "Archive is not a valid app package: missing 'manifest.json' or legacy 'data/createpkgdir'."
34            ));
35        }
36        utils::copy_dir_all(&create_pkg_dir, destination_dir)?;
37        return Ok(());
38    }
39
40    // Pooled format
41    let content = fs::read_to_string(&manifest_path)?;
42    let pooled_manifest = serde_json::from_str::<types::PooledZpaManifest>(&content)?;
43    let pool_dir = temp_extract_dir.path().join("pool");
44
45    // App templates usually use the "" sub-package and project scope
46    if let Some(sub_mapping) = pooled_manifest.mappings.get("")
47        && let Some(scope_mapping) = sub_mapping.scopes.get(&types::Scope::Project)
48    {
49        for mapped_dir in &scope_mapping.dirs {
50            if let Some(rel) = mapped_dir.path.strip_prefix("${createpkgdir}/") {
51                fs::create_dir_all(destination_dir.join(rel))?;
52            }
53        }
54        for mapped_file in &scope_mapping.files {
55            if let Some(rel) = mapped_file.dest.strip_prefix("${createpkgdir}/") {
56                let dest_path = destination_dir.join(rel);
57                if let Some(parent) = dest_path.parent() {
58                    fs::create_dir_all(parent)?;
59                }
60                fs::copy(pool_dir.join(&mapped_file.hash), &dest_path)?;
61            }
62        }
63        for mapped_link in &scope_mapping.symlinks {
64            if let Some(rel) = mapped_link.link.strip_prefix("${createpkgdir}/") {
65                let dest_path = destination_dir.join(rel);
66                if let Some(parent) = dest_path.parent() {
67                    fs::create_dir_all(parent)?;
68                }
69                utils::symlink_file(Path::new(&mapped_link.target), &dest_path)?;
70            }
71        }
72    }
73
74    Ok(())
75}
76
77pub fn run(
78    source: &str,
79    app_name: Option<String>,
80    yes: bool,
81    plugin_manager: Option<&PluginManager>,
82) -> Result<()> {
83    let (pkg, _, _, pkg_lua_path, _, _, _) =
84        resolver::resolve::resolve_package_and_version(source, None, false, false)?;
85
86    if pkg.package_type != types::PackageType::App {
87        return Err(anyhow!(
88            "Package '{}' is not of type 'app'. Use 'zoi install' for packages and collections.",
89            pkg.name
90        ));
91    }
92
93    let mut pkg_val = None;
94    if let Some(pm) = plugin_manager {
95        let v = pm
96            .lua
97            .to_value(&pkg)
98            .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
99        pm.trigger_hook("on_pre_create", Some(v.clone()))?;
100        pkg_val = Some(v);
101    }
102
103    let dest_name = app_name.unwrap_or_else(|| pkg.name.clone());
104    let app_dir = Path::new(&dest_name);
105
106    if app_dir.exists() {
107        if app_dir.is_dir() {
108            if fs::read_dir(app_dir)?.next().is_some() {
109                println!(
110                    "{}",
111                    format!(
112                        "Warning: Directory '{}' already exists and is not empty.",
113                        dest_name
114                    )
115                    .yellow()
116                );
117                if !utils::ask_for_confirmation("Do you want to continue?", yes) {
118                    return Err(anyhow!("Operation aborted by user."));
119                }
120            }
121        } else {
122            return Err(anyhow!(
123                "A file with the name '{}' already exists.",
124                dest_name
125            ));
126        }
127    }
128
129    println!(
130        "Creating app '{}' using template '{}'...",
131        dest_name.cyan(),
132        pkg.name.green()
133    );
134
135    let build_dir = Builder::new().prefix("zoi-create-build-").tempdir()?;
136
137    package::build::run(
138        &pkg_lua_path,
139        Some("source"),
140        &[utils::get_platform()?],
141        None,
142        Some(build_dir.path()),
143        pkg.version.as_deref(),
144        None,
145        false,
146        "native",
147        None,
148        false,
149        false,
150        false,
151    )?;
152
153    let archive_filename = format!(
154        "{}-{}-{}.zpa",
155        pkg.name,
156        pkg.version.as_deref().unwrap_or_default(),
157        utils::get_platform()?,
158    );
159    let archive_path = build_dir.path().join(archive_filename);
160
161    if !archive_path.exists() {
162        return Err(anyhow!("Build failed to produce an archive."));
163    }
164
165    install_app_from_archive(&archive_path, app_dir)?;
166
167    if let (Some(pm), Some(v)) = (plugin_manager, pkg_val) {
168        pm.trigger_hook_nonfatal("on_post_create", Some(v));
169    }
170
171    println!("\n{}", "App created successfully.".green());
172
173    Ok(())
174}