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    InstallSource::GitUrl(prefix_git_scheme_if_missing(url))
49}
50
51/// Prepend `https://` to a Git remote-style string that lacks a scheme.
52///
53/// Accepts `http://`, `https://`, `file://`, and `git@` prefixes as-is; any
54/// other input (e.g. bare `github.com/a/b`) is prefixed with `https://`.
55/// Shared between `classify_install_url` (install path) and
56/// `pkg::repair::normalize_git_url` (repair path) — both need the same
57/// normalization when routing an already-decided Git URL through `git clone`.
58pub(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    /// Install a package from a Git URL or local path (string-typed, public MCP API).
72    ///
73    /// Classifies `url` via [`classify_install_url`] then delegates to
74    /// [`AppService::pkg_install_typed`]. Callers that already hold a
75    /// classified [`InstallSource`] (e.g. `pkg_repair`) should call the
76    /// typed API directly to avoid re-classifying a stale string.
77    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    /// Typed install dispatch. Does no string re-classification; branches
83    /// explicitly on the already-classified [`InstallSource`].
84    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        // `url` is the recorded form used for manifest/hub. Normalization
100        // happens in `classify_install_url`, so this is already the
101        // scheme-prefixed form (e.g. `https://github.com/x`).
102        let url = git_url.clone();
103
104        // Clone to temp directory first to detect single vs collection
105        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
106
107        // Bound `git clone` wall time. Without this a misconfigured remote
108        // (auth prompt, unreachable host, slow network) can block the MCP
109        // tool call indefinitely. 60s covers normal shallow clones of our
110        // bundled-packages-sized repos with margin.
111        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        // Remove .git dir from staging (best-effort; absent .git would be
131        // surprising but not fatal).
132        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        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
142        if staging.path().join("init.lua").exists() {
143            // Single package mode
144            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            // Record in manifest + hub registry. Storage failures here
165            // do not roll back the on-disk copy (install already
166            // succeeded) — they surface as `storage_warnings` so the
167            // operator notices that the metadata layer drifted.
168            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            // Update alc.toml + alc.lock if project root is found
185            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            // Collection mode: scan for subdirs containing init.lua
204            if name.is_some() {
205                // name parameter is only meaningful for single-package repos
206                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            // Dev symlinks (pkg_link scope=global) previously blocked collection
216            // install with a hard `ContainedPath::child` error because their
217            // `canonicalize` target lives outside the packages base. Collect
218            // them as a distinct "symlink-skipped" bucket, skip install for the
219            // affected pkg, and continue with the rest — the user runs
220            // `pkg_unlink <name>` if they want the git-clone copy to win.
221            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                // Pre-check: a legitimate `pkg_link` symlink at the destination
238                // points outside the packages base, which would fail
239                // `ContainedPath::child`'s canonicalize-escape check and abort
240                // the whole batch. Detect the symlink first and route to
241                // `skipped_symlinks` so the install proceeds for other pkgs.
242                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                // Go through ContainedPath::child to block path traversal from
257                // a malicious subdir name (`..`, `foo/../bar`) — the staging
258                // dir is untrusted input in the general case.
259                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            // Import bundled cards from each package's cards/ subdirectory.
270            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            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
280            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            // Record in manifest + hub registry. Same propagation
315            // discipline as the single-package branch above.
316            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            // Update alc.toml + alc.lock if project root is found
332            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    /// Install from a local directory path (supports dirty/uncommitted files).
357    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        // Reject a missing source dir up front. Without this check, a missing
365        // path falls through to the Collection branch (since `init.lua` isn't
366        // present) and surfaces as the misleading "'name' parameter is only
367        // supported for single-package dirs" error when `name` is provided —
368        // which hides the real failure mode (source gone) from the caller.
369        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            // Single package
377            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                // Overwrite for local installs (dev workflow). Log failures —
387                // silent `let _ =` used to hide Permission Denied / Busy
388                // errors and surfaced later as a confusing "File exists" from
389                // copy_dir.
390                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            // Remove .git if copied (best-effort; absent .git is the common case).
400            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            // Record in manifest. Local-path installs are recorded as
410            // `Path { path }` so the original source location is
411            // preserved in the typed form — this keeps `pkg_repair` able
412            // to re-copy from the same source, and `pkg_list` can show
413            // where the bytes came from. (Pre-typed manifests stored the
414            // path as a bare string; `infer_from_legacy_source_string`
415            // coerced it to `Installed`, which lost the path — the typed
416            // form fixes that regression by carrying `path` explicitly.)
417            // Storage failures surface as `storage_warnings` so the
418            // operator notices when metadata drifts from the on-disk copy.
419            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            // Update alc.toml + alc.lock if project root is found
436            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            // Collection mode
455            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                // Guard against traversal-shaped subdir names from an
476                // untrusted source tree, matching the git-clone branch.
477                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            // Import bundled cards from each package's cards/ subdirectory.
512            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            // Record in manifest. Batch local-path installs use
522            // `Path { path }` for the same reason as single-install
523            // (preserve the source path in the typed form). Storage
524            // failures surface via `storage_warnings`.
525            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            // Update alc.toml + alc.lock for newly installed packages
542            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    /// After a successful cache install, update `alc.toml` and `alc.lock` if a project
564    /// root (containing `alc.toml`) is found.  Failures are logged but not propagated —
565    /// the install itself already succeeded.
566    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, // No project root → skip (current-compat)
570        };
571
572        // Resolve per-package versions *before* taking the lock, so the
573        // lock-held critical section contains only synchronous I/O
574        // (load → mutate → save). `fetch_pkg_version` dispatches into the
575        // shared Lua executor and may await arbitrarily long.
576        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        // Guard the alc.toml / alc.lock load→modify→save against overlapping
583        // `pkg_install` calls that target the same project root. Without this
584        // advisory lock two concurrent installs can each load the old state,
585        // apply their own mutation, and race to save — the later writer
586        // silently overwrites the earlier's entry.
587        let lock_path = project_files_lock_path(&root);
588        let lock_result = super::super::lock::with_exclusive_lock(&lock_path, move || {
589            // Load alc.toml document (preserving comments/formatting).
590            let mut doc = match load_alc_toml_document(&root) {
591                Ok(Some(d)) => d,
592                Ok(None) => return Ok(()), // alc.toml not found → skip
593                Err(e) => {
594                    tracing::warn!("pkg_install: failed to load alc.toml: {e}");
595                    return Ok(());
596                }
597            };
598
599            // Load or create alc.lock.
600            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 to alc.toml (no-op if already present).
614                add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
615                // Upsert into alc.lock with the pre-resolved version.
616                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    /// Fetch package version via `eval_simple` (best-effort; returns `None` on failure).
639    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    /// Install all bundled sources (collections + single packages).
655    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        // Fail only if ALL sources failed
665        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
675// ─── Helpers ────────────────────────────────────────────────────────────────
676
677/// Path to the advisory lock file guarding `alc.toml` + `alc.lock` updates
678/// within a project root. The lock file sits alongside the project files so
679/// two processes working in the same checkout serialize on the same path.
680///
681/// The filename is deliberately distinct from `alc.lock` itself — the latter
682/// is the dependency lockfile users read, while `.alc-install.lock` is an
683/// internal flock companion. Consumers who share a project tree via `.gitignore`
684/// should ignore it alongside other temp files; algocline does not add it
685/// automatically today.
686fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
687    root.join(".alc-install.lock")
688}
689
690/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
691fn 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
698/// Insert or update a `LockPackage` entry in the lockfile.
699fn 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}