1use 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
17fn 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
33fn 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 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 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 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
113pub 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}