algocline_app/service/pkg/
install.rs1use std::path::Path;
4
5use super::super::alc_toml::{
6 add_package_entry, load_alc_toml_document, save_alc_toml, PackageDep,
7};
8use super::super::lockfile::{load_lockfile, save_lockfile, LockFile, LockPackage};
9use super::super::manifest;
10use super::super::path::{copy_dir, ContainedPath};
11use super::super::project::resolve_project_root;
12use super::super::resolve::{
13 install_scenarios_from_dir, packages_dir, scenarios_dir, DirEntryFailures, AUTO_INSTALL_SOURCES,
14};
15use super::super::source::PackageSource;
16use super::super::AppService;
17
18impl AppService {
19 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
21 let pkg_dir = packages_dir()?;
22 let _ = std::fs::create_dir_all(&pkg_dir);
23
24 let local_path = Path::new(&url);
26 if local_path.is_absolute() && local_path.is_dir() {
27 return self
28 .install_from_local_path(local_path, &pkg_dir, name)
29 .await;
30 }
31
32 let git_url = if url.starts_with("http://")
34 || url.starts_with("https://")
35 || url.starts_with("file://")
36 || url.starts_with("git@")
37 {
38 url.clone()
39 } else {
40 format!("https://{url}")
41 };
42
43 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
45
46 let output = tokio::process::Command::new("git")
47 .args([
48 "clone",
49 "--depth",
50 "1",
51 &git_url,
52 &staging.path().to_string_lossy(),
53 ])
54 .output()
55 .await
56 .map_err(|e| format!("Failed to run git: {e}"))?;
57
58 if !output.status.success() {
59 let stderr = String::from_utf8_lossy(&output.stderr);
60 return Err(format!("git clone failed: {stderr}"));
61 }
62
63 let _ = std::fs::remove_dir_all(staging.path().join(".git"));
65
66 if staging.path().join("init.lua").exists() {
68 let name = name.unwrap_or_else(|| {
70 url.trim_end_matches('/')
71 .rsplit('/')
72 .next()
73 .unwrap_or("unknown")
74 .trim_end_matches(".git")
75 .to_string()
76 });
77
78 let dest = ContainedPath::child(&pkg_dir, &name)?;
79 if dest.as_ref().exists() {
80 return Err(format!(
81 "Package '{name}' already exists at {}. Remove it first.",
82 dest.as_ref().display()
83 ));
84 }
85
86 copy_dir(staging.path(), dest.as_ref())
87 .map_err(|e| format!("Failed to copy package: {e}"))?;
88
89 let _ = manifest::record_install(&name, None, &url);
91
92 self.update_project_files_for_install(std::slice::from_ref(&name))
94 .await;
95
96 let mut response = serde_json::json!({
97 "installed": [name],
98 "mode": "single",
99 });
100 if let Some(tp) = super::super::resolve::types_stub_path() {
101 response["types_path"] = serde_json::Value::String(tp);
102 }
103 Ok(response.to_string())
104 } else {
105 if name.is_some() {
107 return Err(
109 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
110 This repository is a collection (subdirs with init.lua)."
111 .to_string(),
112 );
113 }
114
115 let mut installed = Vec::new();
116 let mut skipped = Vec::new();
117
118 let entries = std::fs::read_dir(staging.path())
119 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
120
121 for entry in entries {
122 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
123 let path = entry.path();
124 if !path.is_dir() {
125 continue;
126 }
127 if !path.join("init.lua").exists() {
128 continue;
129 }
130 let pkg_name = entry.file_name().to_string_lossy().to_string();
131 let dest = pkg_dir.join(&pkg_name);
132 if dest.exists() {
133 skipped.push(pkg_name);
134 continue;
135 }
136 copy_dir(&path, &dest)
137 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
138 installed.push(pkg_name);
139 }
140
141 let scenarios_subdir = staging.path().join("scenarios");
143 let mut scenarios_installed: Vec<String> = Vec::new();
144 let mut scenarios_failures: DirEntryFailures = Vec::new();
145 if scenarios_subdir.is_dir() {
146 if let Ok(sc_dir) = scenarios_dir() {
147 std::fs::create_dir_all(&sc_dir)
148 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
149 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
150 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
151 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
152 scenarios_installed = arr
153 .iter()
154 .filter_map(|v| v.as_str().map(String::from))
155 .collect();
156 }
157 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
158 scenarios_failures = arr
159 .iter()
160 .filter_map(|v| v.as_str().map(String::from))
161 .collect();
162 }
163 }
164 }
165 }
166 }
167
168 if installed.is_empty() && skipped.is_empty() {
169 return Err(
170 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
171 .to_string(),
172 );
173 }
174
175 let _ = manifest::record_install_batch(&installed, &url);
177
178 self.update_project_files_for_install(&installed).await;
180
181 let mut response = serde_json::json!({
182 "installed": installed,
183 "skipped": skipped,
184 "scenarios_installed": scenarios_installed,
185 "scenarios_failures": scenarios_failures,
186 "mode": "collection",
187 });
188 if let Some(tp) = super::super::resolve::types_stub_path() {
189 response["types_path"] = serde_json::Value::String(tp);
190 }
191 Ok(response.to_string())
192 }
193 }
194
195 async fn install_from_local_path(
197 &self,
198 source: &Path,
199 pkg_dir: &Path,
200 name: Option<String>,
201 ) -> Result<String, String> {
202 if source.join("init.lua").exists() {
203 let name = name.unwrap_or_else(|| {
205 source
206 .file_name()
207 .map(|n| n.to_string_lossy().to_string())
208 .unwrap_or_else(|| "unknown".to_string())
209 });
210
211 let dest = ContainedPath::child(pkg_dir, &name)?;
212 if dest.as_ref().exists() {
213 let _ = std::fs::remove_dir_all(&dest);
215 }
216
217 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
218 let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
220
221 let _ = manifest::record_install(&name, None, &source.display().to_string());
223
224 self.update_project_files_for_install(std::slice::from_ref(&name))
226 .await;
227
228 let mut response = serde_json::json!({
229 "installed": [name],
230 "mode": "local_single",
231 });
232 if let Some(tp) = super::super::resolve::types_stub_path() {
233 response["types_path"] = serde_json::Value::String(tp);
234 }
235 Ok(response.to_string())
236 } else {
237 if name.is_some() {
239 return Err(
240 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
241 .to_string(),
242 );
243 }
244
245 let mut installed = Vec::new();
246 let mut updated = Vec::new();
247
248 let entries =
249 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
250
251 for entry in entries {
252 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
253 let path = entry.path();
254 if !path.is_dir() || !path.join("init.lua").exists() {
255 continue;
256 }
257 let pkg_name = entry.file_name().to_string_lossy().to_string();
258 let dest = pkg_dir.join(&pkg_name);
259 let existed = dest.exists();
260 if existed {
261 let _ = std::fs::remove_dir_all(&dest);
262 }
263 copy_dir(&path, &dest)
264 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
265 let _ = std::fs::remove_dir_all(dest.join(".git"));
266 if existed {
267 updated.push(pkg_name);
268 } else {
269 installed.push(pkg_name);
270 }
271 }
272
273 if installed.is_empty() && updated.is_empty() {
274 return Err(
275 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
276 .to_string(),
277 );
278 }
279
280 let source_str = source.display().to_string();
282 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
283 let _ = manifest::record_install_batch(&all_names, &source_str);
284
285 self.update_project_files_for_install(&installed).await;
287
288 let mut response = serde_json::json!({
289 "installed": installed,
290 "updated": updated,
291 "mode": "local_collection",
292 });
293 if let Some(tp) = super::super::resolve::types_stub_path() {
294 response["types_path"] = serde_json::Value::String(tp);
295 }
296 Ok(response.to_string())
297 }
298 }
299
300 async fn update_project_files_for_install(&self, names: &[String]) {
304 let root = match resolve_project_root(None) {
305 Some(r) => r,
306 None => return, };
308
309 let mut doc = match load_alc_toml_document(&root) {
311 Ok(Some(d)) => d,
312 Ok(None) => return, Err(e) => {
314 tracing::warn!("pkg_install: failed to load alc.toml: {e}");
315 return;
316 }
317 };
318
319 let mut lock = match load_lockfile(&root) {
321 Ok(Some(l)) => l,
322 Ok(None) => LockFile {
323 version: 1,
324 packages: Vec::new(),
325 },
326 Err(e) => {
327 tracing::warn!("pkg_install: failed to load alc.lock: {e}");
328 return;
329 }
330 };
331
332 for name in names {
333 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
335
336 let version = self.fetch_pkg_version(name).await;
338
339 upsert_lock_entry(&mut lock, name.clone(), version, PackageSource::Installed);
341 }
342
343 if let Err(e) = save_alc_toml(&root, &doc) {
344 tracing::warn!("pkg_install: failed to save alc.toml: {e}");
345 }
346 if let Err(e) = save_lockfile(&root, &lock) {
347 tracing::warn!("pkg_install: failed to save alc.lock: {e}");
348 }
349 }
350
351 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
353 if !is_safe_pkg_name(name) {
354 return None;
355 }
356 let code = format!(
357 r#"package.loaded["{name}"] = nil
358local pkg = require("{name}")
359return (pkg.meta or {{}}).version"#
360 );
361 match self.executor.eval_simple(code).await {
362 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
363 _ => None,
364 }
365 }
366
367 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
369 let mut errors: Vec<String> = Vec::new();
370 for url in AUTO_INSTALL_SOURCES {
371 tracing::info!("auto-installing from {url}");
372 if let Err(e) = self.pkg_install(url.to_string(), None).await {
373 tracing::warn!("failed to auto-install from {url}: {e}");
374 errors.push(format!("{url}: {e}"));
375 }
376 }
377 if errors.len() == AUTO_INSTALL_SOURCES.len() {
379 return Err(format!(
380 "Failed to auto-install bundled packages: {}",
381 errors.join("; ")
382 ));
383 }
384 Ok(())
385 }
386}
387
388fn is_safe_pkg_name(name: &str) -> bool {
392 !name.is_empty()
393 && name
394 .bytes()
395 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
396}
397
398fn upsert_lock_entry(
400 lock: &mut LockFile,
401 name: String,
402 version: Option<String>,
403 source: PackageSource,
404) {
405 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
406 existing.version = version;
407 existing.source = source;
408 } else {
409 lock.packages.push(LockPackage {
410 name,
411 version,
412 source,
413 });
414 }
415}