Skip to main content

zoi_install/
create.rs

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