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