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 !storage_warnings.is_empty() {
196 response["storage_warnings"] = serde_json::json!(storage_warnings);
197 }
198 Ok(response.to_string())
199 } else {
200 if name.is_some() {
202 return Err(
204 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
205 This repository is a collection (subdirs with init.lua)."
206 .to_string(),
207 );
208 }
209
210 let mut installed = Vec::new();
211 let mut skipped = Vec::new();
212 let mut skipped_symlinks: Vec<String> = Vec::new();
219
220 let entries = std::fs::read_dir(staging.path())
221 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
222
223 for entry in entries {
224 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
225 let path = entry.path();
226 if !path.is_dir() {
227 continue;
228 }
229 if !path.join("init.lua").exists() {
230 continue;
231 }
232 let pkg_name = entry.file_name().to_string_lossy().to_string();
233
234 let candidate = pkg_dir.join(&pkg_name);
240 if candidate
241 .symlink_metadata()
242 .map(|m| m.file_type().is_symlink())
243 .unwrap_or(false)
244 {
245 tracing::warn!(
246 "pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
247 (likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
248 );
249 skipped_symlinks.push(pkg_name);
250 continue;
251 }
252
253 let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
257 if dest.as_ref().exists() {
258 skipped.push(pkg_name);
259 continue;
260 }
261 copy_dir(&path, dest.as_ref())
262 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
263 installed.push(pkg_name);
264 }
265
266 let mut cards_installed: Vec<String> = Vec::new();
268 for pkg_name in installed.iter().chain(skipped.iter()) {
269 let cards_subdir = staging.path().join(pkg_name).join("cards");
270 if cards_subdir.is_dir() {
271 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
272 cards_installed.extend(imported);
273 }
274 }
275
276 let scenarios_subdir = staging.path().join("scenarios");
278 let mut scenarios_installed: Vec<String> = Vec::new();
279 let mut scenarios_failures: DirEntryFailures = Vec::new();
280 if scenarios_subdir.is_dir() {
281 let sc_dir = scenarios_dir(&app_dir);
282 std::fs::create_dir_all(&sc_dir)
283 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
284 {
285 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
286 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
287 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
288 scenarios_installed = arr
289 .iter()
290 .filter_map(|v| v.as_str().map(String::from))
291 .collect();
292 }
293 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
294 scenarios_failures = arr
295 .iter()
296 .filter_map(|v| v.as_str().map(String::from))
297 .collect();
298 }
299 }
300 }
301 }
302 }
303
304 if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
305 return Err(
306 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
307 .to_string(),
308 );
309 }
310
311 let mut storage_warnings: Vec<String> = Vec::new();
314 if let Err(e) = manifest::record_install_batch(
315 &app_dir,
316 &installed,
317 super::super::source::PackageSource::Git {
318 url: url.clone(),
319 rev: None,
320 },
321 ) {
322 storage_warnings.push(format!("manifest record_install_batch: {e}"));
323 }
324 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
325 storage_warnings.push(format!("hub register_source: {e}"));
326 }
327
328 self.update_project_files_for_install(&installed).await;
330
331 let mut response = serde_json::json!({
332 "installed": installed,
333 "skipped": skipped,
334 "skipped_symlinks": skipped_symlinks,
335 "cards_installed": cards_installed,
336 "scenarios_installed": scenarios_installed,
337 "scenarios_failures": scenarios_failures,
338 "mode": "collection",
339 });
340 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
341 response["types_path"] = serde_json::Value::String(tp);
342 }
343 if !storage_warnings.is_empty() {
344 response["storage_warnings"] = serde_json::json!(storage_warnings);
345 }
346 Ok(response.to_string())
347 }
348 }
349
350 async fn install_from_local_path(
352 &self,
353 source: &Path,
354 pkg_dir: &Path,
355 name: Option<String>,
356 ) -> Result<String, String> {
357 let app_dir = self.log_config.app_dir();
358 if !source.exists() {
364 return Err(format!(
365 "Source directory does not exist: {}",
366 source.display()
367 ));
368 }
369 if source.join("init.lua").exists() {
370 let name = name.unwrap_or_else(|| {
372 source
373 .file_name()
374 .map(|n| n.to_string_lossy().to_string())
375 .unwrap_or_else(|| "unknown".to_string())
376 });
377
378 let dest = ContainedPath::child(pkg_dir, &name)?;
379 if dest.as_ref().exists() {
380 if let Err(e) = std::fs::remove_dir_all(&dest) {
385 tracing::warn!(
386 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
387 dest.as_ref().display()
388 );
389 }
390 }
391
392 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
393 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
395 if e.kind() != std::io::ErrorKind::NotFound {
396 tracing::warn!(
397 "pkg_install: failed to strip .git from {}: {e}",
398 dest.as_ref().display()
399 );
400 }
401 }
402
403 let source_str_local = source.display().to_string();
414 let mut storage_warnings: Vec<String> = Vec::new();
415 if let Err(e) = manifest::record_install(
416 &app_dir,
417 &name,
418 None,
419 super::super::source::PackageSource::Path {
420 path: source_str_local.clone(),
421 },
422 ) {
423 storage_warnings.push(format!("manifest record_install: {e}"));
424 }
425 if let Err(e) = hub::register_source(&app_dir, &source_str_local, "pkg_install") {
426 storage_warnings.push(format!("hub register_source: {e}"));
427 }
428
429 self.update_project_files_for_install(std::slice::from_ref(&name))
431 .await;
432
433 let mut response = serde_json::json!({
434 "installed": [name],
435 "mode": "local_single",
436 });
437 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
438 response["types_path"] = serde_json::Value::String(tp);
439 }
440 if !storage_warnings.is_empty() {
441 response["storage_warnings"] = serde_json::json!(storage_warnings);
442 }
443 Ok(response.to_string())
444 } else {
445 if name.is_some() {
447 return Err(
448 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
449 .to_string(),
450 );
451 }
452
453 let mut installed = Vec::new();
454 let mut updated = Vec::new();
455
456 let entries =
457 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
458
459 for entry in entries {
460 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
461 let path = entry.path();
462 if !path.is_dir() || !path.join("init.lua").exists() {
463 continue;
464 }
465 let pkg_name = entry.file_name().to_string_lossy().to_string();
466 let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
469 let existed = dest.as_ref().exists();
470 if existed {
471 if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
472 tracing::warn!(
473 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
474 dest.as_ref().display()
475 );
476 }
477 }
478 copy_dir(&path, dest.as_ref())
479 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
480 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
481 if e.kind() != std::io::ErrorKind::NotFound {
482 tracing::warn!(
483 "pkg_install: failed to strip .git from {}: {e}",
484 dest.as_ref().display()
485 );
486 }
487 }
488 if existed {
489 updated.push(pkg_name);
490 } else {
491 installed.push(pkg_name);
492 }
493 }
494
495 if installed.is_empty() && updated.is_empty() {
496 return Err(
497 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
498 .to_string(),
499 );
500 }
501
502 let mut cards_installed: Vec<String> = Vec::new();
504 for pkg_name in installed.iter().chain(updated.iter()) {
505 let cards_subdir = source.join(pkg_name).join("cards");
506 if cards_subdir.is_dir() {
507 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
508 cards_installed.extend(imported);
509 }
510 }
511
512 let source_str = source.display().to_string();
517 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
518 let mut storage_warnings: Vec<String> = Vec::new();
519 if let Err(e) = manifest::record_install_batch(
520 &app_dir,
521 &all_names,
522 super::super::source::PackageSource::Path {
523 path: source_str.clone(),
524 },
525 ) {
526 storage_warnings.push(format!("manifest record_install_batch: {e}"));
527 }
528 if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
529 storage_warnings.push(format!("hub register_source: {e}"));
530 }
531
532 self.update_project_files_for_install(&installed).await;
534
535 let mut response = serde_json::json!({
536 "installed": installed,
537 "updated": updated,
538 "cards_installed": cards_installed,
539 "mode": "local_collection",
540 });
541 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
542 response["types_path"] = serde_json::Value::String(tp);
543 }
544 if !storage_warnings.is_empty() {
545 response["storage_warnings"] = serde_json::json!(storage_warnings);
546 }
547 Ok(response.to_string())
548 }
549 }
550
551 async fn update_project_files_for_install(&self, names: &[String]) {
555 let root = match resolve_project_root(None) {
556 Some(r) => r,
557 None => return, };
559
560 let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
565 for name in names {
566 let version = self.fetch_pkg_version(name).await;
567 resolved.push((name.clone(), version));
568 }
569
570 let lock_path = project_files_lock_path(&root);
576 let lock_result = super::super::lock::with_exclusive_lock(&lock_path, move || {
577 let mut doc = match load_alc_toml_document(&root) {
579 Ok(Some(d)) => d,
580 Ok(None) => return Ok(()), Err(e) => {
582 tracing::warn!("pkg_install: failed to load alc.toml: {e}");
583 return Ok(());
584 }
585 };
586
587 let mut lock = match load_lockfile(&root) {
589 Ok(Some(l)) => l,
590 Ok(None) => LockFile {
591 version: 1,
592 packages: Vec::new(),
593 },
594 Err(e) => {
595 tracing::warn!("pkg_install: failed to load alc.lock: {e}");
596 return Ok(());
597 }
598 };
599
600 for (name, version) in &resolved {
601 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
603 upsert_lock_entry(
605 &mut lock,
606 name.clone(),
607 version.clone(),
608 PackageSource::Installed,
609 );
610 }
611
612 if let Err(e) = save_alc_toml(&root, &doc) {
613 tracing::warn!("pkg_install: failed to save alc.toml: {e}");
614 }
615 if let Err(e) = save_lockfile(&root, &lock) {
616 tracing::warn!("pkg_install: failed to save alc.lock: {e}");
617 }
618 Ok(())
619 });
620
621 if let Err(e) = lock_result {
622 tracing::warn!("pkg_install: project files lock failed: {e}");
623 }
624 }
625
626 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
628 if !is_safe_pkg_name(name) {
629 return None;
630 }
631 let code = format!(
632 r#"package.loaded["{name}"] = nil
633local pkg = require("{name}")
634return (pkg.meta or {{}}).version"#
635 );
636 match self.executor.eval_simple(code).await {
637 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
638 _ => None,
639 }
640 }
641
642 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
644 let mut errors: Vec<String> = Vec::new();
645 for url in AUTO_INSTALL_SOURCES {
646 tracing::info!("auto-installing from {url}");
647 if let Err(e) = self.pkg_install(url.to_string(), None).await {
648 tracing::warn!("failed to auto-install from {url}: {e}");
649 errors.push(format!("{url}: {e}"));
650 }
651 }
652 if errors.len() == AUTO_INSTALL_SOURCES.len() {
654 return Err(format!(
655 "Failed to auto-install bundled packages: {}",
656 errors.join("; ")
657 ));
658 }
659 Ok(())
660 }
661}
662
663fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
675 root.join(".alc-install.lock")
676}
677
678fn is_safe_pkg_name(name: &str) -> bool {
680 !name.is_empty()
681 && name
682 .bytes()
683 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
684}
685
686fn upsert_lock_entry(
688 lock: &mut LockFile,
689 name: String,
690 version: Option<String>,
691 source: PackageSource,
692) {
693 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
694 existing.version = version;
695 existing.source = source;
696 } else {
697 lock.packages.push(LockPackage {
698 name,
699 version,
700 source,
701 });
702 }
703}