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