algocline_app/service/pkg/
install.rs1use std::path::{Path, PathBuf};
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
19#[derive(Debug, Clone)]
24pub(crate) enum InstallSource {
25 LocalPath(PathBuf),
27 GitUrl(String),
29}
30
31fn classify_install_url(url: &str) -> InstallSource {
43 let local_path = Path::new(url);
44 if local_path.is_absolute() {
45 return InstallSource::LocalPath(local_path.to_path_buf());
46 }
47
48 let git_url = if url.starts_with("http://")
49 || url.starts_with("https://")
50 || url.starts_with("file://")
51 || url.starts_with("git@")
52 {
53 url.to_string()
54 } else {
55 format!("https://{url}")
56 };
57 InstallSource::GitUrl(git_url)
58}
59
60impl AppService {
61 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
68 let source = classify_install_url(&url);
69 self.pkg_install_typed(source, name).await
70 }
71
72 pub(crate) async fn pkg_install_typed(
75 &self,
76 source: InstallSource,
77 name: Option<String>,
78 ) -> Result<String, String> {
79 let pkg_dir = packages_dir()?;
80 let _ = std::fs::create_dir_all(&pkg_dir);
81
82 let git_url = match source {
83 InstallSource::LocalPath(path) => {
84 return self.install_from_local_path(&path, &pkg_dir, name).await;
85 }
86 InstallSource::GitUrl(u) => u,
87 };
88 let url = git_url.clone();
92
93 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
95
96 let clone_future = tokio::process::Command::new("git")
101 .args([
102 "clone",
103 "--depth",
104 "1",
105 &git_url,
106 &staging.path().to_string_lossy(),
107 ])
108 .output();
109 let output = tokio::time::timeout(std::time::Duration::from_secs(60), clone_future)
110 .await
111 .map_err(|_| format!("git clone timed out after 60s: {git_url}"))?
112 .map_err(|e| format!("Failed to run git: {e}"))?;
113
114 if !output.status.success() {
115 let stderr = String::from_utf8_lossy(&output.stderr);
116 return Err(format!("git clone failed: {stderr}"));
117 }
118
119 if let Err(e) = std::fs::remove_dir_all(staging.path().join(".git")) {
122 if e.kind() != std::io::ErrorKind::NotFound {
123 tracing::warn!(
124 "pkg_install: failed to strip .git from staging {}: {e}",
125 staging.path().display()
126 );
127 }
128 }
129
130 if staging.path().join("init.lua").exists() {
132 let name = name.unwrap_or_else(|| {
134 url.trim_end_matches('/')
135 .rsplit('/')
136 .next()
137 .unwrap_or("unknown")
138 .trim_end_matches(".git")
139 .to_string()
140 });
141
142 let dest = ContainedPath::child(&pkg_dir, &name)?;
143 if dest.as_ref().exists() {
144 return Err(format!(
145 "Package '{name}' already exists at {}. Remove it first.",
146 dest.as_ref().display()
147 ));
148 }
149
150 copy_dir(staging.path(), dest.as_ref())
151 .map_err(|e| format!("Failed to copy package: {e}"))?;
152
153 let _ = manifest::record_install(&name, None, &url);
155 hub::register_source(&url, "pkg_install");
156
157 self.update_project_files_for_install(std::slice::from_ref(&name))
159 .await;
160
161 let mut response = serde_json::json!({
162 "installed": [name],
163 "mode": "single",
164 });
165 if let Some(tp) = super::super::resolve::types_stub_path() {
166 response["types_path"] = serde_json::Value::String(tp);
167 }
168 Ok(response.to_string())
169 } else {
170 if name.is_some() {
172 return Err(
174 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
175 This repository is a collection (subdirs with init.lua)."
176 .to_string(),
177 );
178 }
179
180 let mut installed = Vec::new();
181 let mut skipped = Vec::new();
182
183 let entries = std::fs::read_dir(staging.path())
184 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
185
186 for entry in entries {
187 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
188 let path = entry.path();
189 if !path.is_dir() {
190 continue;
191 }
192 if !path.join("init.lua").exists() {
193 continue;
194 }
195 let pkg_name = entry.file_name().to_string_lossy().to_string();
196 let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
200 if dest.as_ref().exists() {
201 skipped.push(pkg_name);
202 continue;
203 }
204 copy_dir(&path, dest.as_ref())
205 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
206 installed.push(pkg_name);
207 }
208
209 let mut cards_installed: Vec<String> = Vec::new();
211 for pkg_name in installed.iter().chain(skipped.iter()) {
212 let cards_subdir = staging.path().join(pkg_name).join("cards");
213 if cards_subdir.is_dir() {
214 let imported =
215 crate::AppService::import_pkg_bundled_cards(pkg_name, &cards_subdir);
216 cards_installed.extend(imported);
217 }
218 }
219
220 let scenarios_subdir = staging.path().join("scenarios");
222 let mut scenarios_installed: Vec<String> = Vec::new();
223 let mut scenarios_failures: DirEntryFailures = Vec::new();
224 if scenarios_subdir.is_dir() {
225 if let Ok(sc_dir) = scenarios_dir() {
226 std::fs::create_dir_all(&sc_dir)
227 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
228 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
229 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
230 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
231 scenarios_installed = arr
232 .iter()
233 .filter_map(|v| v.as_str().map(String::from))
234 .collect();
235 }
236 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
237 scenarios_failures = arr
238 .iter()
239 .filter_map(|v| v.as_str().map(String::from))
240 .collect();
241 }
242 }
243 }
244 }
245 }
246
247 if installed.is_empty() && skipped.is_empty() {
248 return Err(
249 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
250 .to_string(),
251 );
252 }
253
254 let _ = manifest::record_install_batch(&installed, &url);
256 hub::register_source(&url, "pkg_install");
257
258 self.update_project_files_for_install(&installed).await;
260
261 let mut response = serde_json::json!({
262 "installed": installed,
263 "skipped": skipped,
264 "cards_installed": cards_installed,
265 "scenarios_installed": scenarios_installed,
266 "scenarios_failures": scenarios_failures,
267 "mode": "collection",
268 });
269 if let Some(tp) = super::super::resolve::types_stub_path() {
270 response["types_path"] = serde_json::Value::String(tp);
271 }
272 Ok(response.to_string())
273 }
274 }
275
276 async fn install_from_local_path(
278 &self,
279 source: &Path,
280 pkg_dir: &Path,
281 name: Option<String>,
282 ) -> Result<String, String> {
283 if !source.exists() {
289 return Err(format!(
290 "Source directory does not exist: {}",
291 source.display()
292 ));
293 }
294 if source.join("init.lua").exists() {
295 let name = name.unwrap_or_else(|| {
297 source
298 .file_name()
299 .map(|n| n.to_string_lossy().to_string())
300 .unwrap_or_else(|| "unknown".to_string())
301 });
302
303 let dest = ContainedPath::child(pkg_dir, &name)?;
304 if dest.as_ref().exists() {
305 if let Err(e) = std::fs::remove_dir_all(&dest) {
310 tracing::warn!(
311 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
312 dest.as_ref().display()
313 );
314 }
315 }
316
317 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
318 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
320 if e.kind() != std::io::ErrorKind::NotFound {
321 tracing::warn!(
322 "pkg_install: failed to strip .git from {}: {e}",
323 dest.as_ref().display()
324 );
325 }
326 }
327
328 let source_str_local = source.display().to_string();
330 let _ = manifest::record_install(&name, None, &source_str_local);
331 hub::register_source(&source_str_local, "pkg_install");
332
333 self.update_project_files_for_install(std::slice::from_ref(&name))
335 .await;
336
337 let mut response = serde_json::json!({
338 "installed": [name],
339 "mode": "local_single",
340 });
341 if let Some(tp) = super::super::resolve::types_stub_path() {
342 response["types_path"] = serde_json::Value::String(tp);
343 }
344 Ok(response.to_string())
345 } else {
346 if name.is_some() {
348 return Err(
349 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
350 .to_string(),
351 );
352 }
353
354 let mut installed = Vec::new();
355 let mut updated = Vec::new();
356
357 let entries =
358 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
359
360 for entry in entries {
361 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
362 let path = entry.path();
363 if !path.is_dir() || !path.join("init.lua").exists() {
364 continue;
365 }
366 let pkg_name = entry.file_name().to_string_lossy().to_string();
367 let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
370 let existed = dest.as_ref().exists();
371 if existed {
372 if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
373 tracing::warn!(
374 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
375 dest.as_ref().display()
376 );
377 }
378 }
379 copy_dir(&path, dest.as_ref())
380 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
381 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
382 if e.kind() != std::io::ErrorKind::NotFound {
383 tracing::warn!(
384 "pkg_install: failed to strip .git from {}: {e}",
385 dest.as_ref().display()
386 );
387 }
388 }
389 if existed {
390 updated.push(pkg_name);
391 } else {
392 installed.push(pkg_name);
393 }
394 }
395
396 if installed.is_empty() && updated.is_empty() {
397 return Err(
398 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
399 .to_string(),
400 );
401 }
402
403 let mut cards_installed: Vec<String> = Vec::new();
405 for pkg_name in installed.iter().chain(updated.iter()) {
406 let cards_subdir = source.join(pkg_name).join("cards");
407 if cards_subdir.is_dir() {
408 let imported =
409 crate::AppService::import_pkg_bundled_cards(pkg_name, &cards_subdir);
410 cards_installed.extend(imported);
411 }
412 }
413
414 let source_str = source.display().to_string();
416 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
417 let _ = manifest::record_install_batch(&all_names, &source_str);
418 hub::register_source(&source_str, "pkg_install");
419
420 self.update_project_files_for_install(&installed).await;
422
423 let mut response = serde_json::json!({
424 "installed": installed,
425 "updated": updated,
426 "cards_installed": cards_installed,
427 "mode": "local_collection",
428 });
429 if let Some(tp) = super::super::resolve::types_stub_path() {
430 response["types_path"] = serde_json::Value::String(tp);
431 }
432 Ok(response.to_string())
433 }
434 }
435
436 async fn update_project_files_for_install(&self, names: &[String]) {
440 let root = match resolve_project_root(None) {
441 Some(r) => r,
442 None => return, };
444
445 let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
450 for name in names {
451 let version = self.fetch_pkg_version(name).await;
452 resolved.push((name.clone(), version));
453 }
454
455 let lock_path = project_files_lock_path(&root);
461 let lock_result = super::super::lock::with_exclusive_lock(&lock_path, move || {
462 let mut doc = match load_alc_toml_document(&root) {
464 Ok(Some(d)) => d,
465 Ok(None) => return Ok(()), Err(e) => {
467 tracing::warn!("pkg_install: failed to load alc.toml: {e}");
468 return Ok(());
469 }
470 };
471
472 let mut lock = match load_lockfile(&root) {
474 Ok(Some(l)) => l,
475 Ok(None) => LockFile {
476 version: 1,
477 packages: Vec::new(),
478 },
479 Err(e) => {
480 tracing::warn!("pkg_install: failed to load alc.lock: {e}");
481 return Ok(());
482 }
483 };
484
485 for (name, version) in &resolved {
486 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
488 upsert_lock_entry(
490 &mut lock,
491 name.clone(),
492 version.clone(),
493 PackageSource::Installed,
494 );
495 }
496
497 if let Err(e) = save_alc_toml(&root, &doc) {
498 tracing::warn!("pkg_install: failed to save alc.toml: {e}");
499 }
500 if let Err(e) = save_lockfile(&root, &lock) {
501 tracing::warn!("pkg_install: failed to save alc.lock: {e}");
502 }
503 Ok(())
504 });
505
506 if let Err(e) = lock_result {
507 tracing::warn!("pkg_install: project files lock failed: {e}");
508 }
509 }
510
511 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
513 if !is_safe_pkg_name(name) {
514 return None;
515 }
516 let code = format!(
517 r#"package.loaded["{name}"] = nil
518local pkg = require("{name}")
519return (pkg.meta or {{}}).version"#
520 );
521 match self.executor.eval_simple(code).await {
522 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
523 _ => None,
524 }
525 }
526
527 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
529 let mut errors: Vec<String> = Vec::new();
530 for url in AUTO_INSTALL_SOURCES {
531 tracing::info!("auto-installing from {url}");
532 if let Err(e) = self.pkg_install(url.to_string(), None).await {
533 tracing::warn!("failed to auto-install from {url}: {e}");
534 errors.push(format!("{url}: {e}"));
535 }
536 }
537 if errors.len() == AUTO_INSTALL_SOURCES.len() {
539 return Err(format!(
540 "Failed to auto-install bundled packages: {}",
541 errors.join("; ")
542 ));
543 }
544 Ok(())
545 }
546}
547
548fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
560 root.join(".alc-install.lock")
561}
562
563fn is_safe_pkg_name(name: &str) -> bool {
565 !name.is_empty()
566 && name
567 .bytes()
568 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
569}
570
571fn upsert_lock_entry(
573 lock: &mut LockFile,
574 name: String,
575 version: Option<String>,
576 source: PackageSource,
577) {
578 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
579 existing.version = version;
580 existing.source = source;
581 } else {
582 lock.packages.push(LockPackage {
583 name,
584 version,
585 source,
586 });
587 }
588}