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 InstallSource::GitUrl(prefix_git_scheme_if_missing(url))
49}
50
51pub(super) fn prefix_git_scheme_if_missing(url: &str) -> String {
59 if url.starts_with("http://")
60 || url.starts_with("https://")
61 || url.starts_with("file://")
62 || url.starts_with("git@")
63 {
64 url.to_string()
65 } else {
66 format!("https://{url}")
67 }
68}
69
70impl AppService {
71 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
78 let source = classify_install_url(&url);
79 self.pkg_install_typed(source, name).await
80 }
81
82 pub(crate) async fn pkg_install_typed(
85 &self,
86 source: InstallSource,
87 name: Option<String>,
88 ) -> Result<String, String> {
89 let app_dir = self.log_config.app_dir();
90 let pkg_dir = packages_dir(&app_dir);
91 let _ = std::fs::create_dir_all(&pkg_dir);
92
93 let git_url = match source {
94 InstallSource::LocalPath(path) => {
95 return self.install_from_local_path(&path, &pkg_dir, name).await;
96 }
97 InstallSource::GitUrl(u) => u,
98 };
99 let url = git_url.clone();
103
104 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
106
107 let clone_future = tokio::process::Command::new("git")
112 .args([
113 "clone",
114 "--depth",
115 "1",
116 &git_url,
117 &staging.path().to_string_lossy(),
118 ])
119 .output();
120 let output = tokio::time::timeout(std::time::Duration::from_secs(60), clone_future)
121 .await
122 .map_err(|_| format!("git clone timed out after 60s: {git_url}"))?
123 .map_err(|e| format!("Failed to run git: {e}"))?;
124
125 if !output.status.success() {
126 let stderr = String::from_utf8_lossy(&output.stderr);
127 return Err(format!("git clone failed: {stderr}"));
128 }
129
130 if let Err(e) = std::fs::remove_dir_all(staging.path().join(".git")) {
133 if e.kind() != std::io::ErrorKind::NotFound {
134 tracing::warn!(
135 "pkg_install: failed to strip .git from staging {}: {e}",
136 staging.path().display()
137 );
138 }
139 }
140
141 if staging.path().join("init.lua").exists() {
143 let name = name.unwrap_or_else(|| {
145 url.trim_end_matches('/')
146 .rsplit('/')
147 .next()
148 .unwrap_or("unknown")
149 .trim_end_matches(".git")
150 .to_string()
151 });
152
153 let dest = ContainedPath::child(&pkg_dir, &name)?;
154 if dest.as_ref().exists() {
155 return Err(format!(
156 "Package '{name}' already exists at {}. Remove it first.",
157 dest.as_ref().display()
158 ));
159 }
160
161 copy_dir(staging.path(), dest.as_ref())
162 .map_err(|e| format!("Failed to copy package: {e}"))?;
163
164 let mut storage_warnings: Vec<String> = Vec::new();
169 if let Err(e) = manifest::record_install(
170 &app_dir,
171 &name,
172 None,
173 super::super::source::PackageSource::Git {
174 url: url.clone(),
175 rev: None,
176 },
177 ) {
178 storage_warnings.push(format!("manifest record_install: {e}"));
179 }
180 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
181 storage_warnings.push(format!("hub register_source: {e}"));
182 }
183
184 self.update_project_files_for_install(std::slice::from_ref(&name))
186 .await;
187
188 let mut response = serde_json::json!({
189 "installed": [name],
190 "mode": "single",
191 });
192 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
193 response["types_path"] = serde_json::Value::String(tp);
194 }
195 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
196 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
197 }
198 if !storage_warnings.is_empty() {
199 response["storage_warnings"] = serde_json::json!(storage_warnings);
200 }
201 Ok(response.to_string())
202 } else {
203 if name.is_some() {
205 return Err(
207 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
208 This repository is a collection (subdirs with init.lua)."
209 .to_string(),
210 );
211 }
212
213 let mut installed = Vec::new();
214 let mut skipped = Vec::new();
215 let mut skipped_symlinks: Vec<String> = Vec::new();
222
223 let entries = std::fs::read_dir(staging.path())
224 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
225
226 for entry in entries {
227 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
228 let path = entry.path();
229 if !path.is_dir() {
230 continue;
231 }
232 if !path.join("init.lua").exists() {
233 continue;
234 }
235 let pkg_name = entry.file_name().to_string_lossy().to_string();
236
237 let candidate = pkg_dir.join(&pkg_name);
243 if candidate
244 .symlink_metadata()
245 .map(|m| m.file_type().is_symlink())
246 .unwrap_or(false)
247 {
248 tracing::warn!(
249 "pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
250 (likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
251 );
252 skipped_symlinks.push(pkg_name);
253 continue;
254 }
255
256 let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
260 if dest.as_ref().exists() {
261 skipped.push(pkg_name);
262 continue;
263 }
264 copy_dir(&path, dest.as_ref())
265 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
266 installed.push(pkg_name);
267 }
268
269 let mut cards_installed: Vec<String> = Vec::new();
271 for pkg_name in installed.iter().chain(skipped.iter()) {
272 let cards_subdir = staging.path().join(pkg_name).join("cards");
273 if cards_subdir.is_dir() {
274 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
275 cards_installed.extend(imported);
276 }
277 }
278
279 let scenarios_subdir = staging.path().join("scenarios");
281 let mut scenarios_installed: Vec<String> = Vec::new();
282 let mut scenarios_failures: DirEntryFailures = Vec::new();
283 if scenarios_subdir.is_dir() {
284 let sc_dir = scenarios_dir(&app_dir);
285 std::fs::create_dir_all(&sc_dir)
286 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
287 {
288 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
289 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
290 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
291 scenarios_installed = arr
292 .iter()
293 .filter_map(|v| v.as_str().map(String::from))
294 .collect();
295 }
296 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
297 scenarios_failures = arr
298 .iter()
299 .filter_map(|v| v.as_str().map(String::from))
300 .collect();
301 }
302 }
303 }
304 }
305 }
306
307 if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
308 return Err(
309 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
310 .to_string(),
311 );
312 }
313
314 let mut storage_warnings: Vec<String> = Vec::new();
317 if let Err(e) = manifest::record_install_batch(
318 &app_dir,
319 &installed,
320 super::super::source::PackageSource::Git {
321 url: url.clone(),
322 rev: None,
323 },
324 ) {
325 storage_warnings.push(format!("manifest record_install_batch: {e}"));
326 }
327 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
328 storage_warnings.push(format!("hub register_source: {e}"));
329 }
330
331 self.update_project_files_for_install(&installed).await;
333
334 let mut response = serde_json::json!({
335 "installed": installed,
336 "skipped": skipped,
337 "skipped_symlinks": skipped_symlinks,
338 "cards_installed": cards_installed,
339 "scenarios_installed": scenarios_installed,
340 "scenarios_failures": scenarios_failures,
341 "mode": "collection",
342 });
343 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
344 response["types_path"] = serde_json::Value::String(tp);
345 }
346 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
347 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
348 }
349 if !storage_warnings.is_empty() {
350 response["storage_warnings"] = serde_json::json!(storage_warnings);
351 }
352 Ok(response.to_string())
353 }
354 }
355
356 async fn install_from_local_path(
358 &self,
359 source: &Path,
360 pkg_dir: &Path,
361 name: Option<String>,
362 ) -> Result<String, String> {
363 let app_dir = self.log_config.app_dir();
364 if !source.exists() {
370 return Err(format!(
371 "Source directory does not exist: {}",
372 source.display()
373 ));
374 }
375 if source.join("init.lua").exists() {
376 let name = name.unwrap_or_else(|| {
378 source
379 .file_name()
380 .map(|n| n.to_string_lossy().to_string())
381 .unwrap_or_else(|| "unknown".to_string())
382 });
383
384 let dest = ContainedPath::child(pkg_dir, &name)?;
385 if dest.as_ref().exists() {
386 if let Err(e) = std::fs::remove_dir_all(&dest) {
391 tracing::warn!(
392 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
393 dest.as_ref().display()
394 );
395 }
396 }
397
398 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
399 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
401 if e.kind() != std::io::ErrorKind::NotFound {
402 tracing::warn!(
403 "pkg_install: failed to strip .git from {}: {e}",
404 dest.as_ref().display()
405 );
406 }
407 }
408
409 let source_str_local = source.display().to_string();
420 let mut storage_warnings: Vec<String> = Vec::new();
421 if let Err(e) = manifest::record_install(
422 &app_dir,
423 &name,
424 None,
425 super::super::source::PackageSource::Path {
426 path: source_str_local.clone(),
427 },
428 ) {
429 storage_warnings.push(format!("manifest record_install: {e}"));
430 }
431 if let Err(e) = hub::register_source(&app_dir, &source_str_local, "pkg_install") {
432 storage_warnings.push(format!("hub register_source: {e}"));
433 }
434
435 self.update_project_files_for_install(std::slice::from_ref(&name))
437 .await;
438
439 let mut response = serde_json::json!({
440 "installed": [name],
441 "mode": "local_single",
442 });
443 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
444 response["types_path"] = serde_json::Value::String(tp);
445 }
446 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
447 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
448 }
449 if !storage_warnings.is_empty() {
450 response["storage_warnings"] = serde_json::json!(storage_warnings);
451 }
452 Ok(response.to_string())
453 } else {
454 if name.is_some() {
456 return Err(
457 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
458 .to_string(),
459 );
460 }
461
462 let mut installed = Vec::new();
463 let mut updated = Vec::new();
464
465 let entries =
466 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
467
468 for entry in entries {
469 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
470 let path = entry.path();
471 if !path.is_dir() || !path.join("init.lua").exists() {
472 continue;
473 }
474 let pkg_name = entry.file_name().to_string_lossy().to_string();
475 let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
478 let existed = dest.as_ref().exists();
479 if existed {
480 if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
481 tracing::warn!(
482 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
483 dest.as_ref().display()
484 );
485 }
486 }
487 copy_dir(&path, dest.as_ref())
488 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
489 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
490 if e.kind() != std::io::ErrorKind::NotFound {
491 tracing::warn!(
492 "pkg_install: failed to strip .git from {}: {e}",
493 dest.as_ref().display()
494 );
495 }
496 }
497 if existed {
498 updated.push(pkg_name);
499 } else {
500 installed.push(pkg_name);
501 }
502 }
503
504 if installed.is_empty() && updated.is_empty() {
505 return Err(
506 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
507 .to_string(),
508 );
509 }
510
511 let mut cards_installed: Vec<String> = Vec::new();
513 for pkg_name in installed.iter().chain(updated.iter()) {
514 let cards_subdir = source.join(pkg_name).join("cards");
515 if cards_subdir.is_dir() {
516 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
517 cards_installed.extend(imported);
518 }
519 }
520
521 let source_str = source.display().to_string();
526 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
527 let mut storage_warnings: Vec<String> = Vec::new();
528 if let Err(e) = manifest::record_install_batch(
529 &app_dir,
530 &all_names,
531 super::super::source::PackageSource::Path {
532 path: source_str.clone(),
533 },
534 ) {
535 storage_warnings.push(format!("manifest record_install_batch: {e}"));
536 }
537 if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
538 storage_warnings.push(format!("hub register_source: {e}"));
539 }
540
541 self.update_project_files_for_install(&installed).await;
543
544 let mut response = serde_json::json!({
545 "installed": installed,
546 "updated": updated,
547 "cards_installed": cards_installed,
548 "mode": "local_collection",
549 });
550 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
551 response["types_path"] = serde_json::Value::String(tp);
552 }
553 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
554 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
555 }
556 if !storage_warnings.is_empty() {
557 response["storage_warnings"] = serde_json::json!(storage_warnings);
558 }
559 Ok(response.to_string())
560 }
561 }
562
563 async fn update_project_files_for_install(&self, names: &[String]) {
567 let root = match resolve_project_root(None) {
568 Some(r) => r,
569 None => return, };
571
572 let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
577 for name in names {
578 let version = self.fetch_pkg_version(name).await;
579 resolved.push((name.clone(), version));
580 }
581
582 let lock_path = project_files_lock_path(&root);
588 let lock_result = super::super::lock::with_exclusive_lock(&lock_path, move || {
589 let mut doc = match load_alc_toml_document(&root) {
591 Ok(Some(d)) => d,
592 Ok(None) => return Ok(()), Err(e) => {
594 tracing::warn!("pkg_install: failed to load alc.toml: {e}");
595 return Ok(());
596 }
597 };
598
599 let mut lock = match load_lockfile(&root) {
601 Ok(Some(l)) => l,
602 Ok(None) => LockFile {
603 version: 1,
604 packages: Vec::new(),
605 },
606 Err(e) => {
607 tracing::warn!("pkg_install: failed to load alc.lock: {e}");
608 return Ok(());
609 }
610 };
611
612 for (name, version) in &resolved {
613 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
615 upsert_lock_entry(
617 &mut lock,
618 name.clone(),
619 version.clone(),
620 PackageSource::Installed,
621 );
622 }
623
624 if let Err(e) = save_alc_toml(&root, &doc) {
625 tracing::warn!("pkg_install: failed to save alc.toml: {e}");
626 }
627 if let Err(e) = save_lockfile(&root, &lock) {
628 tracing::warn!("pkg_install: failed to save alc.lock: {e}");
629 }
630 Ok(())
631 });
632
633 if let Err(e) = lock_result {
634 tracing::warn!("pkg_install: project files lock failed: {e}");
635 }
636 }
637
638 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
640 if !is_safe_pkg_name(name) {
641 return None;
642 }
643 let code = format!(
644 r#"package.loaded["{name}"] = nil
645local pkg = require("{name}")
646return (pkg.meta or {{}}).version"#
647 );
648 match self.executor.eval_simple(code).await {
649 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
650 _ => None,
651 }
652 }
653
654 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
656 let mut errors: Vec<String> = Vec::new();
657 for url in AUTO_INSTALL_SOURCES {
658 tracing::info!("auto-installing from {url}");
659 if let Err(e) = self.pkg_install(url.to_string(), None).await {
660 tracing::warn!("failed to auto-install from {url}: {e}");
661 errors.push(format!("{url}: {e}"));
662 }
663 }
664 if errors.len() == AUTO_INSTALL_SOURCES.len() {
666 return Err(format!(
667 "Failed to auto-install bundled packages: {}",
668 errors.join("; ")
669 ));
670 }
671 Ok(())
672 }
673}
674
675fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
687 root.join(".alc-install.lock")
688}
689
690fn is_safe_pkg_name(name: &str) -> bool {
692 !name.is_empty()
693 && name
694 .bytes()
695 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
696}
697
698fn upsert_lock_entry(
700 lock: &mut LockFile,
701 name: String,
702 version: Option<String>,
703 source: PackageSource,
704) {
705 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
706 existing.version = version;
707 existing.source = source;
708 } else {
709 lock.packages.push(LockPackage {
710 name,
711 version,
712 source,
713 });
714 }
715}