Skip to main content

algocline_app/service/pkg/
install.rs

1//! `pkg_install` — install a package from a Git URL or local path.
2
3use 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/// Explicit install dispatch. Carries exactly the information `pkg_install`
20/// needs after classification so that downstream code does not re-classify
21/// a string (which is racy: the local directory may disappear between the
22/// caller's check and the installer's check).
23#[derive(Debug, Clone)]
24pub(crate) enum InstallSource {
25    /// Copy from a local directory (absolute path).
26    LocalPath(PathBuf),
27    /// Clone from a Git URL (already normalized with scheme or `git@`).
28    GitUrl(String),
29}
30
31/// Classify a caller-provided `url` string into an [`InstallSource`].
32///
33/// Must stay consistent with [`super::super::source::infer_from_legacy_source_string`]:
34/// an absolute-path-*shaped* string maps to [`InstallSource::LocalPath`]
35/// (matching `PackageSource::Installed`), everything else maps to a normalized
36/// Git URL. Classification is deliberately syntactic — no filesystem probes.
37/// Rationale: a dir that is_absolute but currently missing used to fall through
38/// to the Git arm and produce `https:///abs/path`, which git rejects with
39/// `unable to find remote helper for 'https'`. Keeping the classification
40/// syntactic gives `install_from_local_path` a chance to surface a diagnostic
41/// "Failed to read source dir" error instead.
42fn 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    /// Install a package from a Git URL or local path (string-typed, public MCP API).
62    ///
63    /// Classifies `url` via [`classify_install_url`] then delegates to
64    /// [`AppService::pkg_install_typed`]. Callers that already hold a
65    /// classified [`InstallSource`] (e.g. `pkg_repair`) should call the
66    /// typed API directly to avoid re-classifying a stale string.
67    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    /// Typed install dispatch. Does no string re-classification; branches
73    /// explicitly on the already-classified [`InstallSource`].
74    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        // `url` is the recorded form used for manifest/hub. Normalization
89        // happens in `classify_install_url`, so this is already the
90        // scheme-prefixed form (e.g. `https://github.com/x`).
91        let url = git_url.clone();
92
93        // Clone to temp directory first to detect single vs collection
94        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
95
96        // Bound `git clone` wall time. Without this a misconfigured remote
97        // (auth prompt, unreachable host, slow network) can block the MCP
98        // tool call indefinitely. 60s covers normal shallow clones of our
99        // bundled-packages-sized repos with margin.
100        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        // Remove .git dir from staging (best-effort; absent .git would be
120        // surprising but not fatal).
121        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        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
131        if staging.path().join("init.lua").exists() {
132            // Single package mode
133            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            // Record in manifest (best-effort; install itself already succeeded)
154            let _ = manifest::record_install(&name, None, &url);
155            hub::register_source(&url, "pkg_install");
156
157            // Update alc.toml + alc.lock if project root is found
158            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            // Collection mode: scan for subdirs containing init.lua
171            if name.is_some() {
172                // name parameter is only meaningful for single-package repos
173                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                // Go through ContainedPath::child to block path traversal from
197                // a malicious subdir name (`..`, `foo/../bar`) — the staging
198                // dir is untrusted input in the general case.
199                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            // Import bundled cards from each package's cards/ subdirectory.
210            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            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
221            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            // Record in manifest (best-effort)
255            let _ = manifest::record_install_batch(&installed, &url);
256            hub::register_source(&url, "pkg_install");
257
258            // Update alc.toml + alc.lock if project root is found
259            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    /// Install from a local directory path (supports dirty/uncommitted files).
277    async fn install_from_local_path(
278        &self,
279        source: &Path,
280        pkg_dir: &Path,
281        name: Option<String>,
282    ) -> Result<String, String> {
283        // Reject a missing source dir up front. Without this check, a missing
284        // path falls through to the Collection branch (since `init.lua` isn't
285        // present) and surfaces as the misleading "'name' parameter is only
286        // supported for single-package dirs" error when `name` is provided —
287        // which hides the real failure mode (source gone) from the caller.
288        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            // Single package
296            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                // Overwrite for local installs (dev workflow). Log failures —
306                // silent `let _ =` used to hide Permission Denied / Busy
307                // errors and surfaced later as a confusing "File exists" from
308                // copy_dir.
309                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            // Remove .git if copied (best-effort; absent .git is the common case).
319            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            // Record in manifest (best-effort)
329            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            // Update alc.toml + alc.lock if project root is found
334            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            // Collection mode
347            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                // Guard against traversal-shaped subdir names from an
368                // untrusted source tree, matching the git-clone branch.
369                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            // Import bundled cards from each package's cards/ subdirectory.
404            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            // Record in manifest (best-effort)
415            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            // Update alc.toml + alc.lock for newly installed packages
421            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    /// After a successful cache install, update `alc.toml` and `alc.lock` if a project
437    /// root (containing `alc.toml`) is found.  Failures are logged but not propagated —
438    /// the install itself already succeeded.
439    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, // No project root → skip (current-compat)
443        };
444
445        // Resolve per-package versions *before* taking the lock, so the
446        // lock-held critical section contains only synchronous I/O
447        // (load → mutate → save). `fetch_pkg_version` dispatches into the
448        // shared Lua executor and may await arbitrarily long.
449        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        // Guard the alc.toml / alc.lock load→modify→save against overlapping
456        // `pkg_install` calls that target the same project root. Without this
457        // advisory lock two concurrent installs can each load the old state,
458        // apply their own mutation, and race to save — the later writer
459        // silently overwrites the earlier's entry.
460        let lock_path = project_files_lock_path(&root);
461        let lock_result = super::super::lock::with_exclusive_lock(&lock_path, move || {
462            // Load alc.toml document (preserving comments/formatting).
463            let mut doc = match load_alc_toml_document(&root) {
464                Ok(Some(d)) => d,
465                Ok(None) => return Ok(()), // alc.toml not found → skip
466                Err(e) => {
467                    tracing::warn!("pkg_install: failed to load alc.toml: {e}");
468                    return Ok(());
469                }
470            };
471
472            // Load or create alc.lock.
473            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 to alc.toml (no-op if already present).
487                add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
488                // Upsert into alc.lock with the pre-resolved version.
489                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    /// Fetch package version via `eval_simple` (best-effort; returns `None` on failure).
512    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    /// Install all bundled sources (collections + single packages).
528    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        // Fail only if ALL sources failed
538        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
548// ─── Helpers ────────────────────────────────────────────────────────────────
549
550/// Path to the advisory lock file guarding `alc.toml` + `alc.lock` updates
551/// within a project root. The lock file sits alongside the project files so
552/// two processes working in the same checkout serialize on the same path.
553///
554/// The filename is deliberately distinct from `alc.lock` itself — the latter
555/// is the dependency lockfile users read, while `.alc-install.lock` is an
556/// internal flock companion. Consumers who share a project tree via `.gitignore`
557/// should ignore it alongside other temp files; algocline does not add it
558/// automatically today.
559fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
560    root.join(".alc-install.lock")
561}
562
563/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
564fn 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
571/// Insert or update a `LockPackage` entry in the lockfile.
572fn 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}