1use 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::resolve::{
13 install_scenarios_from_dir, packages_dir, scenarios_dir, DirEntryFailures, AUTO_INSTALL_SOURCES,
14};
15use super::super::source::PackageSource;
16use super::super::{AppService, ProjectFilesError};
17
18#[derive(Debug, Clone)]
23pub(crate) enum InstallSource {
24 LocalPath(PathBuf),
26 GitUrl(String),
28}
29
30fn classify_install_url(url: &str) -> InstallSource {
42 let local_path = Path::new(url);
43 if local_path.is_absolute() {
44 return InstallSource::LocalPath(local_path.to_path_buf());
45 }
46
47 InstallSource::GitUrl(prefix_git_scheme_if_missing(url))
48}
49
50pub(super) fn prefix_git_scheme_if_missing(url: &str) -> String {
58 if url.starts_with("http://")
59 || url.starts_with("https://")
60 || url.starts_with("file://")
61 || url.starts_with("git@")
62 {
63 url.to_string()
64 } else {
65 format!("https://{url}")
66 }
67}
68
69impl AppService {
70 pub async fn pkg_install(
77 &self,
78 url: String,
79 name: Option<String>,
80 force: Option<bool>,
81 ) -> Result<String, String> {
82 let source = classify_install_url(&url);
83 self.pkg_install_typed(source, name, force).await
84 }
85
86 pub(crate) async fn pkg_install_typed(
89 &self,
90 source: InstallSource,
91 name: Option<String>,
92 force: Option<bool>,
93 ) -> Result<String, String> {
94 let app_dir = self.log_config.app_dir();
95 let pkg_dir = packages_dir(&app_dir);
96 std::fs::create_dir_all(&pkg_dir)
97 .map_err(|e| ProjectFilesError::PackagesDir {
98 path: pkg_dir.display().to_string(),
99 source: e,
100 })
101 .map_err(|e| e.to_string())?;
102
103 let git_url = match source {
104 InstallSource::LocalPath(path) => {
105 return self.install_from_local_path(&path, &pkg_dir, name).await;
106 }
107 InstallSource::GitUrl(u) => u,
108 };
109 let url = git_url.clone();
113
114 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
115
116 let clone_future = tokio::process::Command::new("git")
121 .args([
122 "clone",
123 "--depth",
124 "1",
125 &git_url,
126 &staging.path().to_string_lossy(),
127 ])
128 .output();
129 let output = tokio::time::timeout(std::time::Duration::from_secs(60), clone_future)
130 .await
131 .map_err(|_| format!("git clone timed out after 60s: {git_url}"))?
132 .map_err(|e| format!("Failed to run git: {e}"))?;
133
134 if !output.status.success() {
135 let stderr = String::from_utf8_lossy(&output.stderr);
136 return Err(format!("git clone failed: {stderr}"));
137 }
138
139 if let Err(e) = std::fs::remove_dir_all(staging.path().join(".git")) {
142 if e.kind() != std::io::ErrorKind::NotFound {
143 tracing::warn!(
144 "pkg_install: failed to strip .git from staging {}: {e}",
145 staging.path().display()
146 );
147 }
148 }
149
150 {
152 if name.is_some() {
153 return Err("The 'name' parameter is no longer supported. \
154 Single-package install mode was removed in v0.36.0; \
155 package names are derived from subdirectory names in collection layout \
156 (<repo>/<name>/init.lua)."
157 .to_string());
158 }
159
160 let force = force.unwrap_or(false);
161 let mut installed = Vec::new();
162 let mut skipped = Vec::new();
163 let mut skipped_symlinks: Vec<String> = Vec::new();
170
171 let entries = std::fs::read_dir(staging.path())
172 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
173
174 for entry in entries {
175 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
176 let path = entry.path();
177 if !path.is_dir() {
178 continue;
179 }
180 if !path.join("init.lua").exists() {
181 continue;
182 }
183 let pkg_name = entry.file_name().to_string_lossy().to_string();
184
185 let candidate = pkg_dir.join(&pkg_name);
191 if candidate
192 .symlink_metadata()
193 .map(|m| m.file_type().is_symlink())
194 .unwrap_or(false)
195 {
196 tracing::warn!(
197 "pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
198 (likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
199 );
200 skipped_symlinks.push(pkg_name);
201 continue;
202 }
203
204 let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
208 if dest.as_ref().exists() {
209 if !force {
210 skipped.push(pkg_name);
211 continue;
212 }
213 std::fs::remove_dir_all(dest.as_ref()).map_err(|e| {
215 format!("Failed to remove existing package '{pkg_name}': {e}")
216 })?;
217 }
218 copy_dir(&path, dest.as_ref())
219 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
220 installed.push(pkg_name);
221 }
222
223 let mut cards_installed: Vec<String> = Vec::new();
225 for pkg_name in installed.iter().chain(skipped.iter()) {
226 let cards_subdir = staging.path().join(pkg_name).join("cards");
227 if cards_subdir.is_dir() {
228 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
229 cards_installed.extend(imported);
230 }
231 }
232
233 let scenarios_subdir = staging.path().join("scenarios");
235 let mut scenarios_installed: Vec<String> = Vec::new();
236 let mut scenarios_failures: DirEntryFailures = Vec::new();
237 if scenarios_subdir.is_dir() {
238 let sc_dir = scenarios_dir(&app_dir);
239 std::fs::create_dir_all(&sc_dir)
240 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
241 {
242 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
243 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
244 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
245 scenarios_installed = arr
246 .iter()
247 .filter_map(|v| v.as_str().map(String::from))
248 .collect();
249 }
250 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
251 scenarios_failures = arr
252 .iter()
253 .filter_map(|v| v.as_str().map(String::from))
254 .collect();
255 }
256 }
257 }
258 }
259 }
260
261 if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
262 return Err(
263 "Expected */init.lua (collection layout). Single-package mode (init.lua at root) was removed in v0.36.0."
264 .to_string(),
265 );
266 }
267
268 let mut storage_warnings: Vec<String> = Vec::new();
270 if let Err(e) = manifest::record_install_batch(
271 &app_dir,
272 &installed,
273 super::super::source::PackageSource::Git {
274 url: url.clone(),
275 rev: None,
276 },
277 ) {
278 storage_warnings.push(format!("manifest record_install_batch: {e}"));
279 }
280 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
281 storage_warnings.push(format!("hub register_source: {e}"));
282 }
283
284 let project_files_warnings =
288 match self.update_project_files_for_install(&installed).await {
289 Ok(ws) => ws,
290 Err(e) => vec![e.to_string()],
291 };
292
293 let mut response = serde_json::json!({
294 "installed": installed,
295 "skipped": skipped,
296 "skipped_symlinks": skipped_symlinks,
297 "cards_installed": cards_installed,
298 "scenarios_installed": scenarios_installed,
299 "scenarios_failures": scenarios_failures,
300 "mode": "collection",
301 });
302 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
303 response["types_path"] = serde_json::Value::String(tp);
304 }
305 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
306 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
307 }
308 if !storage_warnings.is_empty() {
309 response["storage_warnings"] = serde_json::json!(storage_warnings);
310 }
311 if !project_files_warnings.is_empty() {
312 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
313 }
314 Ok(response.to_string())
315 }
316 }
317
318 async fn install_from_local_path(
320 &self,
321 source: &Path,
322 pkg_dir: &Path,
323 name: Option<String>,
324 ) -> Result<String, String> {
325 let app_dir = self.log_config.app_dir();
326 if !source.exists() {
330 return Err(format!(
331 "Source directory does not exist: {}",
332 source.display()
333 ));
334 }
335
336 {
338 if name.is_some() {
339 return Err("The 'name' parameter is no longer supported. \
340 Single-package install mode was removed in v0.36.0; \
341 package names are derived from subdirectory names in collection layout \
342 (<repo>/<name>/init.lua)."
343 .to_string());
344 }
345
346 let mut installed = Vec::new();
347 let mut updated = Vec::new();
348
349 let entries =
350 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
351
352 for entry in entries {
353 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
354 let path = entry.path();
355 if !path.is_dir() || !path.join("init.lua").exists() {
356 continue;
357 }
358 let pkg_name = entry.file_name().to_string_lossy().to_string();
359 let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
362 let existed = dest.as_ref().exists();
363 if existed {
364 if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
365 tracing::warn!(
366 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
367 dest.as_ref().display()
368 );
369 }
370 }
371 copy_dir(&path, dest.as_ref())
372 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
373 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
374 if e.kind() != std::io::ErrorKind::NotFound {
375 tracing::warn!(
376 "pkg_install: failed to strip .git from {}: {e}",
377 dest.as_ref().display()
378 );
379 }
380 }
381 if existed {
382 updated.push(pkg_name);
383 } else {
384 installed.push(pkg_name);
385 }
386 }
387
388 if installed.is_empty() && updated.is_empty() {
389 return Err(
390 "Expected */init.lua (collection layout). Single-package mode (init.lua at root) was removed in v0.36.0."
391 .to_string(),
392 );
393 }
394
395 let mut cards_installed: Vec<String> = Vec::new();
397 for pkg_name in installed.iter().chain(updated.iter()) {
398 let cards_subdir = source.join(pkg_name).join("cards");
399 if cards_subdir.is_dir() {
400 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
401 cards_installed.extend(imported);
402 }
403 }
404
405 let source_str = source.display().to_string();
411 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
412 let mut storage_warnings: Vec<String> = Vec::new();
413 if let Err(e) = manifest::record_install_batch(
414 &app_dir,
415 &all_names,
416 super::super::source::PackageSource::Path {
417 path: source_str.clone(),
418 },
419 ) {
420 storage_warnings.push(format!("manifest record_install_batch: {e}"));
421 }
422 if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
423 storage_warnings.push(format!("hub register_source: {e}"));
424 }
425
426 let project_files_warnings =
430 match self.update_project_files_for_install(&installed).await {
431 Ok(ws) => ws,
432 Err(e) => vec![e.to_string()],
433 };
434
435 let mut response = serde_json::json!({
436 "installed": installed,
437 "updated": updated,
438 "cards_installed": cards_installed,
439 "mode": "local_collection",
440 });
441 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
442 response["types_path"] = serde_json::Value::String(tp);
443 }
444 if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
445 response["alc_shapes_types_path"] = serde_json::Value::String(tp);
446 }
447 if !storage_warnings.is_empty() {
448 response["storage_warnings"] = serde_json::json!(storage_warnings);
449 }
450 if !project_files_warnings.is_empty() {
451 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
452 }
453 Ok(response.to_string())
454 }
455 }
456
457 async fn update_project_files_for_install(
462 &self,
463 names: &[String],
464 ) -> Result<Vec<String>, ProjectFilesError> {
465 let root = match self.resolve_root(None) {
466 Some(r) => r,
467 None => return Ok(Vec::new()), };
469
470 let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
475 for name in names {
476 let version = self.fetch_pkg_version(name).await;
477 resolved.push((name.clone(), version));
478 }
479
480 let lock_path = project_files_lock_path(&root);
491 super::super::lock::with_exclusive_lock(&lock_path, move || {
492 let mut warnings: Vec<String> = Vec::new();
493
494 let mut doc = match load_alc_toml_document(&root) {
497 Ok(Some(d)) => d,
498 Ok(None) => return Ok(Vec::new()), Err(e) => return Err(ProjectFilesError::AlcTomlLoad(e)),
500 };
501
502 let mut lock = match load_lockfile(&root) {
506 Ok(Some(l)) => l,
507 Ok(None) => LockFile {
508 version: 1,
509 packages: Vec::new(),
510 },
511 Err(e) => return Err(ProjectFilesError::AlcLockLoad(e)),
512 };
513
514 for (name, version) in &resolved {
515 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
517 upsert_lock_entry(
519 &mut lock,
520 name.clone(),
521 version.clone(),
522 PackageSource::Installed,
523 );
524 }
525
526 if let Err(e) = save_alc_toml(&root, &doc) {
530 warnings.push(ProjectFilesError::AlcTomlSave(e).to_string());
531 }
532 if let Err(e) = save_lockfile(&root, &lock) {
533 warnings.push(ProjectFilesError::AlcLockSave(e).to_string());
534 }
535 Ok(warnings)
536 })
537 }
538
539 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
541 if !is_safe_pkg_name(name) {
542 return None;
543 }
544 let code = format!(
545 r#"package.loaded["{name}"] = nil
546local pkg = require("{name}")
547return (pkg.meta or {{}}).version"#
548 );
549 match self.executor.eval_simple(code).await {
550 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
551 _ => None,
552 }
553 }
554
555 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
557 let mut errors: Vec<String> = Vec::new();
558 for url in AUTO_INSTALL_SOURCES {
559 tracing::info!("auto-installing from {url}");
560 if let Err(e) = self.pkg_install(url.to_string(), None, None).await {
561 tracing::warn!("failed to auto-install from {url}: {e}");
562 errors.push(format!("{url}: {e}"));
563 }
564 }
565 if errors.len() == AUTO_INSTALL_SOURCES.len() {
567 return Err(format!(
568 "Failed to auto-install bundled packages: {}",
569 errors.join("; ")
570 ));
571 }
572 Ok(())
573 }
574}
575
576fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
588 root.join(".alc-install.lock")
589}
590
591fn is_safe_pkg_name(name: &str) -> bool {
593 !name.is_empty()
594 && name
595 .bytes()
596 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
597}
598
599fn upsert_lock_entry(
601 lock: &mut LockFile,
602 name: String,
603 version: Option<String>,
604 source: PackageSource,
605) {
606 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
607 existing.version = version;
608 existing.source = source;
609 } else {
610 lock.packages.push(LockPackage {
611 name,
612 version,
613 source,
614 });
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::super::super::alc_toml::save_alc_toml;
621 use super::super::super::lock::with_exclusive_lock;
622 use super::super::super::lockfile::save_lockfile;
623 use super::*;
624
625 #[test]
630 fn load_alc_toml_corrupt_yields_fatal_err() {
631 let tmp = tempfile::tempdir().unwrap();
632 let root = tmp.path();
633 std::fs::write(root.join("alc.toml"), b"[[not valid toml = {").unwrap();
635
636 let lock_path = root.join(".alc-install.lock");
637 let result: Result<Vec<String>, String> =
638 with_exclusive_lock(&lock_path, move || match load_alc_toml_document(root) {
639 Ok(Some(_d)) => Ok(Vec::new()),
640 Ok(None) => Ok(Vec::new()),
641 Err(e) => Err(format!("alc.toml load: {e}")),
642 });
643
644 assert!(
645 result.is_err(),
646 "Expected Err on corrupt alc.toml, got: {result:?}"
647 );
648 let msg = result.unwrap_err();
649 assert!(
650 msg.contains("alc.toml load:"),
651 "Error should contain 'alc.toml load:', got: {msg}"
652 );
653 }
654
655 #[test]
657 fn load_alc_lock_corrupt_yields_fatal_err() {
658 let tmp = tempfile::tempdir().unwrap();
659 let root = tmp.path();
660 std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
662 std::fs::write(root.join("alc.lock"), b"version = 999\n[[package]]\n").unwrap();
664
665 let lock_path = root.join(".alc-install.lock");
666 let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
667 let _doc = match load_alc_toml_document(root) {
668 Ok(Some(d)) => d,
669 Ok(None) => return Ok(Vec::new()),
670 Err(e) => return Err(format!("alc.toml load: {e}")),
671 };
672 match load_lockfile(root) {
673 Ok(Some(_l)) => Ok(Vec::new()),
674 Ok(None) => Ok(Vec::new()),
675 Err(e) => Err(format!("alc.lock load: {e}")),
676 }
677 });
678
679 assert!(
680 result.is_err(),
681 "Expected Err on corrupt alc.lock, got: {result:?}"
682 );
683 let msg = result.unwrap_err();
684 assert!(
685 msg.contains("alc.lock load:"),
686 "Error should contain 'alc.lock load:', got: {msg}"
687 );
688 }
689
690 #[test]
695 fn save_failure_produces_warning_not_fatal_err() {
696 let tmp = tempfile::tempdir().unwrap();
697 let root = tmp.path();
698 std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
700
701 let bad_root = root.join("blocked_subdir");
706 std::fs::write(&bad_root, b"this is a file, not a dir").unwrap();
707
708 let lock_path = root.join(".alc-install.lock");
709 let root_owned = root.to_path_buf();
710 let bad_root_owned = bad_root.clone();
711 let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
712 let mut warnings: Vec<String> = Vec::new();
713 let doc = match load_alc_toml_document(&root_owned) {
714 Ok(Some(d)) => d,
715 Ok(None) => return Ok(Vec::new()),
716 Err(e) => return Err(format!("alc.toml load: {e}")),
717 };
718 if let Err(e) = save_alc_toml(&bad_root_owned, &doc) {
719 warnings.push(format!("alc.toml save: {e}"));
720 }
721 let lock = LockFile {
722 version: 1,
723 packages: Vec::new(),
724 };
725 if let Err(e) = save_lockfile(&bad_root_owned, &lock) {
726 warnings.push(format!("alc.lock save: {e}"));
727 }
728 Ok(warnings)
729 });
730
731 assert!(
732 result.is_ok(),
733 "Expected Ok even with save failures, got: {result:?}"
734 );
735 let warnings = result.unwrap();
736 assert!(
737 !warnings.is_empty(),
738 "Expected at least one save warning, got empty warnings"
739 );
740 assert!(
741 warnings.iter().any(|w| w.contains("alc.toml save:")),
742 "Expected 'alc.toml save:' warning, got: {warnings:?}"
743 );
744 }
745
746 #[test]
752 fn caller_degrades_fatal_err_to_project_files_warnings() {
753 let update_result: Result<Vec<String>, String> =
755 Err("alc.toml load: TOML parse error at line 1".to_string());
756
757 let project_files_warnings = match update_result {
759 Ok(ws) => ws,
760 Err(e) => vec![e],
761 };
762
763 assert_eq!(project_files_warnings.len(), 1);
764 assert!(
765 project_files_warnings[0].contains("alc.toml load:"),
766 "Warning should contain the original error message"
767 );
768 }
769
770 #[test]
772 fn caller_passes_through_ok_warnings() {
773 let update_result: Result<Vec<String>, String> = Ok(vec![
774 "alc.toml save: permission denied".to_string(),
775 "alc.lock save: no space left".to_string(),
776 ]);
777
778 let project_files_warnings = match update_result {
779 Ok(ws) => ws,
780 Err(e) => vec![e],
781 };
782
783 assert_eq!(project_files_warnings.len(), 2);
784 }
785
786 #[test]
789 fn empty_warnings_are_not_added_to_response() {
790 let update_result: Result<Vec<String>, String> = Ok(Vec::new());
791
792 let project_files_warnings = match update_result {
793 Ok(ws) => ws,
794 Err(e) => vec![e],
795 };
796
797 let mut response = serde_json::json!({ "installed": ["mypkg"], "mode": "collection" });
799 if !project_files_warnings.is_empty() {
800 response["project_files_warnings"] = serde_json::json!(project_files_warnings);
801 }
802
803 assert!(
804 response.get("project_files_warnings").is_none(),
805 "project_files_warnings should not appear when warnings are empty"
806 );
807 }
808
809 #[test]
812 fn upsert_lock_entry_inserts_new_package() {
813 let mut lock = LockFile {
814 version: 1,
815 packages: Vec::new(),
816 };
817 upsert_lock_entry(
818 &mut lock,
819 "mypkg".to_string(),
820 Some("1.0.0".to_string()),
821 PackageSource::Installed,
822 );
823 assert_eq!(lock.packages.len(), 1);
824 assert_eq!(lock.packages[0].name, "mypkg");
825 assert_eq!(lock.packages[0].version, Some("1.0.0".to_string()));
826 }
827
828 #[test]
829 fn upsert_lock_entry_updates_existing_package() {
830 let mut lock = LockFile {
831 version: 1,
832 packages: Vec::new(),
833 };
834 upsert_lock_entry(
835 &mut lock,
836 "mypkg".to_string(),
837 Some("1.0.0".to_string()),
838 PackageSource::Installed,
839 );
840 upsert_lock_entry(
841 &mut lock,
842 "mypkg".to_string(),
843 Some("2.0.0".to_string()),
844 PackageSource::Installed,
845 );
846 assert_eq!(lock.packages.len(), 1);
847 assert_eq!(lock.packages[0].version, Some("2.0.0".to_string()));
848 }
849
850 #[test]
851 fn is_safe_pkg_name_accepts_valid_names() {
852 assert!(is_safe_pkg_name("my_pkg"));
853 assert!(is_safe_pkg_name("my-pkg"));
854 assert!(is_safe_pkg_name("mypkg123"));
855 }
856
857 #[test]
858 fn is_safe_pkg_name_rejects_invalid_names() {
859 assert!(!is_safe_pkg_name(""));
860 assert!(!is_safe_pkg_name("my pkg"));
861 assert!(!is_safe_pkg_name("../escape"));
862 assert!(!is_safe_pkg_name("pkg;rm -rf /"));
863 }
864}