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 !storage_warnings.is_empty() {
196                response["storage_warnings"] = serde_json::json!(storage_warnings);
197            }
198            Ok(response.to_string())
199        } else {
200            // Collection mode: scan for subdirs containing init.lua
201            if name.is_some() {
202                // name parameter is only meaningful for single-package repos
203                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            // Dev symlinks (pkg_link scope=global) previously blocked collection
213            // install with a hard `ContainedPath::child` error because their
214            // `canonicalize` target lives outside the packages base. Collect
215            // them as a distinct "symlink-skipped" bucket, skip install for the
216            // affected pkg, and continue with the rest — the user runs
217            // `pkg_unlink <name>` if they want the git-clone copy to win.
218            let mut skipped_symlinks: Vec<String> = Vec::new();
219
220            let entries = std::fs::read_dir(staging.path())
221                .map_err(|e| format!("Failed to read staging dir: {e}"))?;
222
223            for entry in entries {
224                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
225                let path = entry.path();
226                if !path.is_dir() {
227                    continue;
228                }
229                if !path.join("init.lua").exists() {
230                    continue;
231                }
232                let pkg_name = entry.file_name().to_string_lossy().to_string();
233
234                // Pre-check: a legitimate `pkg_link` symlink at the destination
235                // points outside the packages base, which would fail
236                // `ContainedPath::child`'s canonicalize-escape check and abort
237                // the whole batch. Detect the symlink first and route to
238                // `skipped_symlinks` so the install proceeds for other pkgs.
239                let candidate = pkg_dir.join(&pkg_name);
240                if candidate
241                    .symlink_metadata()
242                    .map(|m| m.file_type().is_symlink())
243                    .unwrap_or(false)
244                {
245                    tracing::warn!(
246                        "pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
247                         (likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
248                    );
249                    skipped_symlinks.push(pkg_name);
250                    continue;
251                }
252
253                // Go through ContainedPath::child to block path traversal from
254                // a malicious subdir name (`..`, `foo/../bar`) — the staging
255                // dir is untrusted input in the general case.
256                let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
257                if dest.as_ref().exists() {
258                    skipped.push(pkg_name);
259                    continue;
260                }
261                copy_dir(&path, dest.as_ref())
262                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
263                installed.push(pkg_name);
264            }
265
266            // Import bundled cards from each package's cards/ subdirectory.
267            let mut cards_installed: Vec<String> = Vec::new();
268            for pkg_name in installed.iter().chain(skipped.iter()) {
269                let cards_subdir = staging.path().join(pkg_name).join("cards");
270                if cards_subdir.is_dir() {
271                    let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
272                    cards_installed.extend(imported);
273                }
274            }
275
276            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
277            let scenarios_subdir = staging.path().join("scenarios");
278            let mut scenarios_installed: Vec<String> = Vec::new();
279            let mut scenarios_failures: DirEntryFailures = Vec::new();
280            if scenarios_subdir.is_dir() {
281                let sc_dir = scenarios_dir(&app_dir);
282                std::fs::create_dir_all(&sc_dir)
283                    .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
284                {
285                    if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
286                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
287                            if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
288                                scenarios_installed = arr
289                                    .iter()
290                                    .filter_map(|v| v.as_str().map(String::from))
291                                    .collect();
292                            }
293                            if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
294                                scenarios_failures = arr
295                                    .iter()
296                                    .filter_map(|v| v.as_str().map(String::from))
297                                    .collect();
298                            }
299                        }
300                    }
301                }
302            }
303
304            if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
305                return Err(
306                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
307                        .to_string(),
308                );
309            }
310
311            // Record in manifest + hub registry. Same propagation
312            // discipline as the single-package branch above.
313            let mut storage_warnings: Vec<String> = Vec::new();
314            if let Err(e) = manifest::record_install_batch(
315                &app_dir,
316                &installed,
317                super::super::source::PackageSource::Git {
318                    url: url.clone(),
319                    rev: None,
320                },
321            ) {
322                storage_warnings.push(format!("manifest record_install_batch: {e}"));
323            }
324            if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
325                storage_warnings.push(format!("hub register_source: {e}"));
326            }
327
328            // Update alc.toml + alc.lock if project root is found
329            self.update_project_files_for_install(&installed).await;
330
331            let mut response = serde_json::json!({
332                "installed": installed,
333                "skipped": skipped,
334                "skipped_symlinks": skipped_symlinks,
335                "cards_installed": cards_installed,
336                "scenarios_installed": scenarios_installed,
337                "scenarios_failures": scenarios_failures,
338                "mode": "collection",
339            });
340            if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
341                response["types_path"] = serde_json::Value::String(tp);
342            }
343            if !storage_warnings.is_empty() {
344                response["storage_warnings"] = serde_json::json!(storage_warnings);
345            }
346            Ok(response.to_string())
347        }
348    }
349
350    /// Install from a local directory path (supports dirty/uncommitted files).
351    async fn install_from_local_path(
352        &self,
353        source: &Path,
354        pkg_dir: &Path,
355        name: Option<String>,
356    ) -> Result<String, String> {
357        let app_dir = self.log_config.app_dir();
358        // Reject a missing source dir up front. Without this check, a missing
359        // path falls through to the Collection branch (since `init.lua` isn't
360        // present) and surfaces as the misleading "'name' parameter is only
361        // supported for single-package dirs" error when `name` is provided —
362        // which hides the real failure mode (source gone) from the caller.
363        if !source.exists() {
364            return Err(format!(
365                "Source directory does not exist: {}",
366                source.display()
367            ));
368        }
369        if source.join("init.lua").exists() {
370            // Single package
371            let name = name.unwrap_or_else(|| {
372                source
373                    .file_name()
374                    .map(|n| n.to_string_lossy().to_string())
375                    .unwrap_or_else(|| "unknown".to_string())
376            });
377
378            let dest = ContainedPath::child(pkg_dir, &name)?;
379            if dest.as_ref().exists() {
380                // Overwrite for local installs (dev workflow). Log failures —
381                // silent `let _ =` used to hide Permission Denied / Busy
382                // errors and surfaced later as a confusing "File exists" from
383                // copy_dir.
384                if let Err(e) = std::fs::remove_dir_all(&dest) {
385                    tracing::warn!(
386                        "pkg_install: failed to remove existing dest {} before overwrite: {e}",
387                        dest.as_ref().display()
388                    );
389                }
390            }
391
392            copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
393            // Remove .git if copied (best-effort; absent .git is the common case).
394            if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
395                if e.kind() != std::io::ErrorKind::NotFound {
396                    tracing::warn!(
397                        "pkg_install: failed to strip .git from {}: {e}",
398                        dest.as_ref().display()
399                    );
400                }
401            }
402
403            // Record in manifest. Local-path installs are recorded as
404            // `Path { path }` so the original source location is
405            // preserved in the typed form — this keeps `pkg_repair` able
406            // to re-copy from the same source, and `pkg_list` can show
407            // where the bytes came from. (Pre-typed manifests stored the
408            // path as a bare string; `infer_from_legacy_source_string`
409            // coerced it to `Installed`, which lost the path — the typed
410            // form fixes that regression by carrying `path` explicitly.)
411            // Storage failures surface as `storage_warnings` so the
412            // operator notices when metadata drifts from the on-disk copy.
413            let source_str_local = source.display().to_string();
414            let mut storage_warnings: Vec<String> = Vec::new();
415            if let Err(e) = manifest::record_install(
416                &app_dir,
417                &name,
418                None,
419                super::super::source::PackageSource::Path {
420                    path: source_str_local.clone(),
421                },
422            ) {
423                storage_warnings.push(format!("manifest record_install: {e}"));
424            }
425            if let Err(e) = hub::register_source(&app_dir, &source_str_local, "pkg_install") {
426                storage_warnings.push(format!("hub register_source: {e}"));
427            }
428
429            // Update alc.toml + alc.lock if project root is found
430            self.update_project_files_for_install(std::slice::from_ref(&name))
431                .await;
432
433            let mut response = serde_json::json!({
434                "installed": [name],
435                "mode": "local_single",
436            });
437            if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
438                response["types_path"] = serde_json::Value::String(tp);
439            }
440            if !storage_warnings.is_empty() {
441                response["storage_warnings"] = serde_json::json!(storage_warnings);
442            }
443            Ok(response.to_string())
444        } else {
445            // Collection mode
446            if name.is_some() {
447                return Err(
448                    "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
449                        .to_string(),
450                );
451            }
452
453            let mut installed = Vec::new();
454            let mut updated = Vec::new();
455
456            let entries =
457                std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
458
459            for entry in entries {
460                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
461                let path = entry.path();
462                if !path.is_dir() || !path.join("init.lua").exists() {
463                    continue;
464                }
465                let pkg_name = entry.file_name().to_string_lossy().to_string();
466                // Guard against traversal-shaped subdir names from an
467                // untrusted source tree, matching the git-clone branch.
468                let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
469                let existed = dest.as_ref().exists();
470                if existed {
471                    if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
472                        tracing::warn!(
473                            "pkg_install: failed to remove existing dest {} before overwrite: {e}",
474                            dest.as_ref().display()
475                        );
476                    }
477                }
478                copy_dir(&path, dest.as_ref())
479                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
480                if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
481                    if e.kind() != std::io::ErrorKind::NotFound {
482                        tracing::warn!(
483                            "pkg_install: failed to strip .git from {}: {e}",
484                            dest.as_ref().display()
485                        );
486                    }
487                }
488                if existed {
489                    updated.push(pkg_name);
490                } else {
491                    installed.push(pkg_name);
492                }
493            }
494
495            if installed.is_empty() && updated.is_empty() {
496                return Err(
497                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
498                        .to_string(),
499                );
500            }
501
502            // Import bundled cards from each package's cards/ subdirectory.
503            let mut cards_installed: Vec<String> = Vec::new();
504            for pkg_name in installed.iter().chain(updated.iter()) {
505                let cards_subdir = source.join(pkg_name).join("cards");
506                if cards_subdir.is_dir() {
507                    let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
508                    cards_installed.extend(imported);
509                }
510            }
511
512            // Record in manifest. Batch local-path installs use
513            // `Path { path }` for the same reason as single-install
514            // (preserve the source path in the typed form). Storage
515            // failures surface via `storage_warnings`.
516            let source_str = source.display().to_string();
517            let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
518            let mut storage_warnings: Vec<String> = Vec::new();
519            if let Err(e) = manifest::record_install_batch(
520                &app_dir,
521                &all_names,
522                super::super::source::PackageSource::Path {
523                    path: source_str.clone(),
524                },
525            ) {
526                storage_warnings.push(format!("manifest record_install_batch: {e}"));
527            }
528            if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
529                storage_warnings.push(format!("hub register_source: {e}"));
530            }
531
532            // Update alc.toml + alc.lock for newly installed packages
533            self.update_project_files_for_install(&installed).await;
534
535            let mut response = serde_json::json!({
536                "installed": installed,
537                "updated": updated,
538                "cards_installed": cards_installed,
539                "mode": "local_collection",
540            });
541            if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
542                response["types_path"] = serde_json::Value::String(tp);
543            }
544            if !storage_warnings.is_empty() {
545                response["storage_warnings"] = serde_json::json!(storage_warnings);
546            }
547            Ok(response.to_string())
548        }
549    }
550
551    /// After a successful cache install, update `alc.toml` and `alc.lock` if a project
552    /// root (containing `alc.toml`) is found.  Failures are logged but not propagated —
553    /// the install itself already succeeded.
554    async fn update_project_files_for_install(&self, names: &[String]) {
555        let root = match resolve_project_root(None) {
556            Some(r) => r,
557            None => return, // No project root → skip (current-compat)
558        };
559
560        // Resolve per-package versions *before* taking the lock, so the
561        // lock-held critical section contains only synchronous I/O
562        // (load → mutate → save). `fetch_pkg_version` dispatches into the
563        // shared Lua executor and may await arbitrarily long.
564        let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
565        for name in names {
566            let version = self.fetch_pkg_version(name).await;
567            resolved.push((name.clone(), version));
568        }
569
570        // Guard the alc.toml / alc.lock load→modify→save against overlapping
571        // `pkg_install` calls that target the same project root. Without this
572        // advisory lock two concurrent installs can each load the old state,
573        // apply their own mutation, and race to save — the later writer
574        // silently overwrites the earlier's entry.
575        let lock_path = project_files_lock_path(&root);
576        let lock_result = super::super::lock::with_exclusive_lock(&lock_path, move || {
577            // Load alc.toml document (preserving comments/formatting).
578            let mut doc = match load_alc_toml_document(&root) {
579                Ok(Some(d)) => d,
580                Ok(None) => return Ok(()), // alc.toml not found → skip
581                Err(e) => {
582                    tracing::warn!("pkg_install: failed to load alc.toml: {e}");
583                    return Ok(());
584                }
585            };
586
587            // Load or create alc.lock.
588            let mut lock = match load_lockfile(&root) {
589                Ok(Some(l)) => l,
590                Ok(None) => LockFile {
591                    version: 1,
592                    packages: Vec::new(),
593                },
594                Err(e) => {
595                    tracing::warn!("pkg_install: failed to load alc.lock: {e}");
596                    return Ok(());
597                }
598            };
599
600            for (name, version) in &resolved {
601                // Add to alc.toml (no-op if already present).
602                add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
603                // Upsert into alc.lock with the pre-resolved version.
604                upsert_lock_entry(
605                    &mut lock,
606                    name.clone(),
607                    version.clone(),
608                    PackageSource::Installed,
609                );
610            }
611
612            if let Err(e) = save_alc_toml(&root, &doc) {
613                tracing::warn!("pkg_install: failed to save alc.toml: {e}");
614            }
615            if let Err(e) = save_lockfile(&root, &lock) {
616                tracing::warn!("pkg_install: failed to save alc.lock: {e}");
617            }
618            Ok(())
619        });
620
621        if let Err(e) = lock_result {
622            tracing::warn!("pkg_install: project files lock failed: {e}");
623        }
624    }
625
626    /// Fetch package version via `eval_simple` (best-effort; returns `None` on failure).
627    async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
628        if !is_safe_pkg_name(name) {
629            return None;
630        }
631        let code = format!(
632            r#"package.loaded["{name}"] = nil
633local pkg = require("{name}")
634return (pkg.meta or {{}}).version"#
635        );
636        match self.executor.eval_simple(code).await {
637            Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
638            _ => None,
639        }
640    }
641
642    /// Install all bundled sources (collections + single packages).
643    pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
644        let mut errors: Vec<String> = Vec::new();
645        for url in AUTO_INSTALL_SOURCES {
646            tracing::info!("auto-installing from {url}");
647            if let Err(e) = self.pkg_install(url.to_string(), None).await {
648                tracing::warn!("failed to auto-install from {url}: {e}");
649                errors.push(format!("{url}: {e}"));
650            }
651        }
652        // Fail only if ALL sources failed
653        if errors.len() == AUTO_INSTALL_SOURCES.len() {
654            return Err(format!(
655                "Failed to auto-install bundled packages: {}",
656                errors.join("; ")
657            ));
658        }
659        Ok(())
660    }
661}
662
663// ─── Helpers ────────────────────────────────────────────────────────────────
664
665/// Path to the advisory lock file guarding `alc.toml` + `alc.lock` updates
666/// within a project root. The lock file sits alongside the project files so
667/// two processes working in the same checkout serialize on the same path.
668///
669/// The filename is deliberately distinct from `alc.lock` itself — the latter
670/// is the dependency lockfile users read, while `.alc-install.lock` is an
671/// internal flock companion. Consumers who share a project tree via `.gitignore`
672/// should ignore it alongside other temp files; algocline does not add it
673/// automatically today.
674fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
675    root.join(".alc-install.lock")
676}
677
678/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
679fn is_safe_pkg_name(name: &str) -> bool {
680    !name.is_empty()
681        && name
682            .bytes()
683            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
684}
685
686/// Insert or update a `LockPackage` entry in the lockfile.
687fn upsert_lock_entry(
688    lock: &mut LockFile,
689    name: String,
690    version: Option<String>,
691    source: PackageSource,
692) {
693    if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
694        existing.version = version;
695        existing.source = source;
696    } else {
697        lock.packages.push(LockPackage {
698            name,
699            version,
700            source,
701        });
702    }
703}