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