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
213 let entries = std::fs::read_dir(staging.path())
214 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
215
216 for entry in entries {
217 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
218 let path = entry.path();
219 if !path.is_dir() {
220 continue;
221 }
222 if !path.join("init.lua").exists() {
223 continue;
224 }
225 let pkg_name = entry.file_name().to_string_lossy().to_string();
226 let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
230 if dest.as_ref().exists() {
231 skipped.push(pkg_name);
232 continue;
233 }
234 copy_dir(&path, dest.as_ref())
235 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
236 installed.push(pkg_name);
237 }
238
239 let mut cards_installed: Vec<String> = Vec::new();
241 for pkg_name in installed.iter().chain(skipped.iter()) {
242 let cards_subdir = staging.path().join(pkg_name).join("cards");
243 if cards_subdir.is_dir() {
244 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
245 cards_installed.extend(imported);
246 }
247 }
248
249 let scenarios_subdir = staging.path().join("scenarios");
251 let mut scenarios_installed: Vec<String> = Vec::new();
252 let mut scenarios_failures: DirEntryFailures = Vec::new();
253 if scenarios_subdir.is_dir() {
254 let sc_dir = scenarios_dir(&app_dir);
255 std::fs::create_dir_all(&sc_dir)
256 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
257 {
258 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
259 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
260 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
261 scenarios_installed = arr
262 .iter()
263 .filter_map(|v| v.as_str().map(String::from))
264 .collect();
265 }
266 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
267 scenarios_failures = arr
268 .iter()
269 .filter_map(|v| v.as_str().map(String::from))
270 .collect();
271 }
272 }
273 }
274 }
275 }
276
277 if installed.is_empty() && skipped.is_empty() {
278 return Err(
279 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
280 .to_string(),
281 );
282 }
283
284 let mut storage_warnings: Vec<String> = Vec::new();
287 if let Err(e) = manifest::record_install_batch(
288 &app_dir,
289 &installed,
290 super::super::source::PackageSource::Git {
291 url: url.clone(),
292 rev: None,
293 },
294 ) {
295 storage_warnings.push(format!("manifest record_install_batch: {e}"));
296 }
297 if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
298 storage_warnings.push(format!("hub register_source: {e}"));
299 }
300
301 self.update_project_files_for_install(&installed).await;
303
304 let mut response = serde_json::json!({
305 "installed": installed,
306 "skipped": skipped,
307 "cards_installed": cards_installed,
308 "scenarios_installed": scenarios_installed,
309 "scenarios_failures": scenarios_failures,
310 "mode": "collection",
311 });
312 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
313 response["types_path"] = serde_json::Value::String(tp);
314 }
315 if !storage_warnings.is_empty() {
316 response["storage_warnings"] = serde_json::json!(storage_warnings);
317 }
318 Ok(response.to_string())
319 }
320 }
321
322 async fn install_from_local_path(
324 &self,
325 source: &Path,
326 pkg_dir: &Path,
327 name: Option<String>,
328 ) -> Result<String, String> {
329 let app_dir = self.log_config.app_dir();
330 if !source.exists() {
336 return Err(format!(
337 "Source directory does not exist: {}",
338 source.display()
339 ));
340 }
341 if source.join("init.lua").exists() {
342 let name = name.unwrap_or_else(|| {
344 source
345 .file_name()
346 .map(|n| n.to_string_lossy().to_string())
347 .unwrap_or_else(|| "unknown".to_string())
348 });
349
350 let dest = ContainedPath::child(pkg_dir, &name)?;
351 if dest.as_ref().exists() {
352 if let Err(e) = std::fs::remove_dir_all(&dest) {
357 tracing::warn!(
358 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
359 dest.as_ref().display()
360 );
361 }
362 }
363
364 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
365 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
367 if e.kind() != std::io::ErrorKind::NotFound {
368 tracing::warn!(
369 "pkg_install: failed to strip .git from {}: {e}",
370 dest.as_ref().display()
371 );
372 }
373 }
374
375 let source_str_local = source.display().to_string();
386 let mut storage_warnings: Vec<String> = Vec::new();
387 if let Err(e) = manifest::record_install(
388 &app_dir,
389 &name,
390 None,
391 super::super::source::PackageSource::Path {
392 path: source_str_local.clone(),
393 },
394 ) {
395 storage_warnings.push(format!("manifest record_install: {e}"));
396 }
397 if let Err(e) = hub::register_source(&app_dir, &source_str_local, "pkg_install") {
398 storage_warnings.push(format!("hub register_source: {e}"));
399 }
400
401 self.update_project_files_for_install(std::slice::from_ref(&name))
403 .await;
404
405 let mut response = serde_json::json!({
406 "installed": [name],
407 "mode": "local_single",
408 });
409 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
410 response["types_path"] = serde_json::Value::String(tp);
411 }
412 if !storage_warnings.is_empty() {
413 response["storage_warnings"] = serde_json::json!(storage_warnings);
414 }
415 Ok(response.to_string())
416 } else {
417 if name.is_some() {
419 return Err(
420 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
421 .to_string(),
422 );
423 }
424
425 let mut installed = Vec::new();
426 let mut updated = Vec::new();
427
428 let entries =
429 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
430
431 for entry in entries {
432 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
433 let path = entry.path();
434 if !path.is_dir() || !path.join("init.lua").exists() {
435 continue;
436 }
437 let pkg_name = entry.file_name().to_string_lossy().to_string();
438 let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
441 let existed = dest.as_ref().exists();
442 if existed {
443 if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
444 tracing::warn!(
445 "pkg_install: failed to remove existing dest {} before overwrite: {e}",
446 dest.as_ref().display()
447 );
448 }
449 }
450 copy_dir(&path, dest.as_ref())
451 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
452 if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
453 if e.kind() != std::io::ErrorKind::NotFound {
454 tracing::warn!(
455 "pkg_install: failed to strip .git from {}: {e}",
456 dest.as_ref().display()
457 );
458 }
459 }
460 if existed {
461 updated.push(pkg_name);
462 } else {
463 installed.push(pkg_name);
464 }
465 }
466
467 if installed.is_empty() && updated.is_empty() {
468 return Err(
469 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
470 .to_string(),
471 );
472 }
473
474 let mut cards_installed: Vec<String> = Vec::new();
476 for pkg_name in installed.iter().chain(updated.iter()) {
477 let cards_subdir = source.join(pkg_name).join("cards");
478 if cards_subdir.is_dir() {
479 let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
480 cards_installed.extend(imported);
481 }
482 }
483
484 let source_str = source.display().to_string();
489 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
490 let mut storage_warnings: Vec<String> = Vec::new();
491 if let Err(e) = manifest::record_install_batch(
492 &app_dir,
493 &all_names,
494 super::super::source::PackageSource::Path {
495 path: source_str.clone(),
496 },
497 ) {
498 storage_warnings.push(format!("manifest record_install_batch: {e}"));
499 }
500 if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
501 storage_warnings.push(format!("hub register_source: {e}"));
502 }
503
504 self.update_project_files_for_install(&installed).await;
506
507 let mut response = serde_json::json!({
508 "installed": installed,
509 "updated": updated,
510 "cards_installed": cards_installed,
511 "mode": "local_collection",
512 });
513 if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
514 response["types_path"] = serde_json::Value::String(tp);
515 }
516 if !storage_warnings.is_empty() {
517 response["storage_warnings"] = serde_json::json!(storage_warnings);
518 }
519 Ok(response.to_string())
520 }
521 }
522
523 async fn update_project_files_for_install(&self, names: &[String]) {
527 let root = match resolve_project_root(None) {
528 Some(r) => r,
529 None => return, };
531
532 let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
537 for name in names {
538 let version = self.fetch_pkg_version(name).await;
539 resolved.push((name.clone(), version));
540 }
541
542 let lock_path = project_files_lock_path(&root);
548 let lock_result = super::super::lock::with_exclusive_lock(&lock_path, move || {
549 let mut doc = match load_alc_toml_document(&root) {
551 Ok(Some(d)) => d,
552 Ok(None) => return Ok(()), Err(e) => {
554 tracing::warn!("pkg_install: failed to load alc.toml: {e}");
555 return Ok(());
556 }
557 };
558
559 let mut lock = match load_lockfile(&root) {
561 Ok(Some(l)) => l,
562 Ok(None) => LockFile {
563 version: 1,
564 packages: Vec::new(),
565 },
566 Err(e) => {
567 tracing::warn!("pkg_install: failed to load alc.lock: {e}");
568 return Ok(());
569 }
570 };
571
572 for (name, version) in &resolved {
573 add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
575 upsert_lock_entry(
577 &mut lock,
578 name.clone(),
579 version.clone(),
580 PackageSource::Installed,
581 );
582 }
583
584 if let Err(e) = save_alc_toml(&root, &doc) {
585 tracing::warn!("pkg_install: failed to save alc.toml: {e}");
586 }
587 if let Err(e) = save_lockfile(&root, &lock) {
588 tracing::warn!("pkg_install: failed to save alc.lock: {e}");
589 }
590 Ok(())
591 });
592
593 if let Err(e) = lock_result {
594 tracing::warn!("pkg_install: project files lock failed: {e}");
595 }
596 }
597
598 async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
600 if !is_safe_pkg_name(name) {
601 return None;
602 }
603 let code = format!(
604 r#"package.loaded["{name}"] = nil
605local pkg = require("{name}")
606return (pkg.meta or {{}}).version"#
607 );
608 match self.executor.eval_simple(code).await {
609 Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
610 _ => None,
611 }
612 }
613
614 pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
616 let mut errors: Vec<String> = Vec::new();
617 for url in AUTO_INSTALL_SOURCES {
618 tracing::info!("auto-installing from {url}");
619 if let Err(e) = self.pkg_install(url.to_string(), None).await {
620 tracing::warn!("failed to auto-install from {url}: {e}");
621 errors.push(format!("{url}: {e}"));
622 }
623 }
624 if errors.len() == AUTO_INSTALL_SOURCES.len() {
626 return Err(format!(
627 "Failed to auto-install bundled packages: {}",
628 errors.join("; ")
629 ));
630 }
631 Ok(())
632 }
633}
634
635fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
647 root.join(".alc-install.lock")
648}
649
650fn is_safe_pkg_name(name: &str) -> bool {
652 !name.is_empty()
653 && name
654 .bytes()
655 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
656}
657
658fn upsert_lock_entry(
660 lock: &mut LockFile,
661 name: String,
662 version: Option<String>,
663 source: PackageSource,
664) {
665 if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
666 existing.version = version;
667 existing.source = source;
668 } else {
669 lock.packages.push(LockPackage {
670 name,
671 version,
672 source,
673 });
674 }
675}