use std::path::{Path, PathBuf};
use super::super::alc_toml::{
add_package_entry, load_alc_toml_document, save_alc_toml, PackageDep,
};
use super::super::hub;
use super::super::lockfile::{load_lockfile, save_lockfile, LockFile, LockPackage};
use super::super::manifest;
use super::super::path::{copy_dir, ContainedPath};
use super::super::resolve::{
install_scenarios_from_dir, packages_dir, scenarios_dir, DirEntryFailures, AUTO_INSTALL_SOURCES,
};
use super::super::source::PackageSource;
use super::super::{AppService, ProjectFilesError};
#[derive(Debug, Clone)]
pub(crate) enum InstallSource {
LocalPath(PathBuf),
GitUrl(String),
}
fn classify_install_url(url: &str) -> InstallSource {
let local_path = Path::new(url);
if local_path.is_absolute() {
return InstallSource::LocalPath(local_path.to_path_buf());
}
InstallSource::GitUrl(prefix_git_scheme_if_missing(url))
}
pub(super) fn prefix_git_scheme_if_missing(url: &str) -> String {
if url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("file://")
|| url.starts_with("git@")
{
url.to_string()
} else {
format!("https://{url}")
}
}
impl AppService {
pub async fn pkg_install(
&self,
url: String,
name: Option<String>,
force: Option<bool>,
) -> Result<String, String> {
let source = classify_install_url(&url);
self.pkg_install_typed(source, name, force).await
}
pub(crate) async fn pkg_install_typed(
&self,
source: InstallSource,
name: Option<String>,
force: Option<bool>,
) -> Result<String, String> {
let app_dir = self.log_config.app_dir();
let pkg_dir = packages_dir(&app_dir);
std::fs::create_dir_all(&pkg_dir)
.map_err(|e| ProjectFilesError::PackagesDir {
path: pkg_dir.display().to_string(),
source: e,
})
.map_err(|e| e.to_string())?;
let git_url = match source {
InstallSource::LocalPath(path) => {
return self.install_from_local_path(&path, &pkg_dir, name).await;
}
InstallSource::GitUrl(u) => u,
};
let url = git_url.clone();
let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
let clone_future = tokio::process::Command::new("git")
.args([
"clone",
"--depth",
"1",
&git_url,
&staging.path().to_string_lossy(),
])
.output();
let output = tokio::time::timeout(std::time::Duration::from_secs(60), clone_future)
.await
.map_err(|_| format!("git clone timed out after 60s: {git_url}"))?
.map_err(|e| format!("Failed to run git: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {stderr}"));
}
if let Err(e) = std::fs::remove_dir_all(staging.path().join(".git")) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
"pkg_install: failed to strip .git from staging {}: {e}",
staging.path().display()
);
}
}
{
if name.is_some() {
return Err("The 'name' parameter is no longer supported. \
Single-package install mode was removed in v0.36.0; \
package names are derived from subdirectory names in collection layout \
(<repo>/<name>/init.lua)."
.to_string());
}
let force = force.unwrap_or(false);
let mut installed = Vec::new();
let mut skipped = Vec::new();
let mut skipped_symlinks: Vec<String> = Vec::new();
let entries = std::fs::read_dir(staging.path())
.map_err(|e| format!("Failed to read staging dir: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if !path.join("init.lua").exists() {
continue;
}
let pkg_name = entry.file_name().to_string_lossy().to_string();
let candidate = pkg_dir.join(&pkg_name);
if candidate
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
tracing::warn!(
"pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
(likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
);
skipped_symlinks.push(pkg_name);
continue;
}
let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
if dest.as_ref().exists() {
if !force {
skipped.push(pkg_name);
continue;
}
std::fs::remove_dir_all(dest.as_ref()).map_err(|e| {
format!("Failed to remove existing package '{pkg_name}': {e}")
})?;
}
copy_dir(&path, dest.as_ref())
.map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
installed.push(pkg_name);
}
let mut cards_installed: Vec<String> = Vec::new();
for pkg_name in installed.iter().chain(skipped.iter()) {
let cards_subdir = staging.path().join(pkg_name).join("cards");
if cards_subdir.is_dir() {
let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
cards_installed.extend(imported);
}
}
let scenarios_subdir = staging.path().join("scenarios");
let mut scenarios_installed: Vec<String> = Vec::new();
let mut scenarios_failures: DirEntryFailures = Vec::new();
if scenarios_subdir.is_dir() {
let sc_dir = scenarios_dir(&app_dir);
std::fs::create_dir_all(&sc_dir)
.map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
{
if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
scenarios_installed = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
scenarios_failures = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
}
}
}
}
}
if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
return Err(
"Expected */init.lua (collection layout). Single-package mode (init.lua at root) was removed in v0.36.0."
.to_string(),
);
}
let mut storage_warnings: Vec<String> = Vec::new();
if let Err(e) = manifest::record_install_batch(
&app_dir,
&installed,
super::super::source::PackageSource::Git {
url: url.clone(),
rev: None,
},
) {
storage_warnings.push(format!("manifest record_install_batch: {e}"));
}
if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
storage_warnings.push(format!("hub register_source: {e}"));
}
let project_files_warnings =
match self.update_project_files_for_install(&installed).await {
Ok(ws) => ws,
Err(e) => vec![e.to_string()],
};
let mut response = serde_json::json!({
"installed": installed,
"skipped": skipped,
"skipped_symlinks": skipped_symlinks,
"cards_installed": cards_installed,
"scenarios_installed": scenarios_installed,
"scenarios_failures": scenarios_failures,
"mode": "collection",
});
if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
response["types_path"] = serde_json::Value::String(tp);
}
if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
response["alc_shapes_types_path"] = serde_json::Value::String(tp);
}
if !storage_warnings.is_empty() {
response["storage_warnings"] = serde_json::json!(storage_warnings);
}
if !project_files_warnings.is_empty() {
response["project_files_warnings"] = serde_json::json!(project_files_warnings);
}
Ok(response.to_string())
}
}
async fn install_from_local_path(
&self,
source: &Path,
pkg_dir: &Path,
name: Option<String>,
) -> Result<String, String> {
let app_dir = self.log_config.app_dir();
if !source.exists() {
return Err(format!(
"Source directory does not exist: {}",
source.display()
));
}
{
if name.is_some() {
return Err("The 'name' parameter is no longer supported. \
Single-package install mode was removed in v0.36.0; \
package names are derived from subdirectory names in collection layout \
(<repo>/<name>/init.lua)."
.to_string());
}
let mut installed = Vec::new();
let mut updated = Vec::new();
let entries =
std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let path = entry.path();
if !path.is_dir() || !path.join("init.lua").exists() {
continue;
}
let pkg_name = entry.file_name().to_string_lossy().to_string();
let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
let existed = dest.as_ref().exists();
if existed {
if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
tracing::warn!(
"pkg_install: failed to remove existing dest {} before overwrite: {e}",
dest.as_ref().display()
);
}
}
copy_dir(&path, dest.as_ref())
.map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
"pkg_install: failed to strip .git from {}: {e}",
dest.as_ref().display()
);
}
}
if existed {
updated.push(pkg_name);
} else {
installed.push(pkg_name);
}
}
if installed.is_empty() && updated.is_empty() {
return Err(
"Expected */init.lua (collection layout). Single-package mode (init.lua at root) was removed in v0.36.0."
.to_string(),
);
}
let mut cards_installed: Vec<String> = Vec::new();
for pkg_name in installed.iter().chain(updated.iter()) {
let cards_subdir = source.join(pkg_name).join("cards");
if cards_subdir.is_dir() {
let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
cards_installed.extend(imported);
}
}
let source_str = source.display().to_string();
let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
let mut storage_warnings: Vec<String> = Vec::new();
if let Err(e) = manifest::record_install_batch(
&app_dir,
&all_names,
super::super::source::PackageSource::Path {
path: source_str.clone(),
},
) {
storage_warnings.push(format!("manifest record_install_batch: {e}"));
}
if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
storage_warnings.push(format!("hub register_source: {e}"));
}
let project_files_warnings =
match self.update_project_files_for_install(&installed).await {
Ok(ws) => ws,
Err(e) => vec![e.to_string()],
};
let mut response = serde_json::json!({
"installed": installed,
"updated": updated,
"cards_installed": cards_installed,
"mode": "local_collection",
});
if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
response["types_path"] = serde_json::Value::String(tp);
}
if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
response["alc_shapes_types_path"] = serde_json::Value::String(tp);
}
if !storage_warnings.is_empty() {
response["storage_warnings"] = serde_json::json!(storage_warnings);
}
if !project_files_warnings.is_empty() {
response["project_files_warnings"] = serde_json::json!(project_files_warnings);
}
Ok(response.to_string())
}
}
async fn update_project_files_for_install(
&self,
names: &[String],
) -> Result<Vec<String>, ProjectFilesError> {
let root = match self.resolve_root(None) {
Some(r) => r,
None => return Ok(Vec::new()), };
let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
for name in names {
let version = self.fetch_pkg_version(name).await;
resolved.push((name.clone(), version));
}
let lock_path = project_files_lock_path(&root);
super::super::lock::with_exclusive_lock(&lock_path, move || {
let mut warnings: Vec<String> = Vec::new();
let mut doc = match load_alc_toml_document(&root) {
Ok(Some(d)) => d,
Ok(None) => return Ok(Vec::new()), Err(e) => return Err(ProjectFilesError::AlcTomlLoad(e)),
};
let mut lock = match load_lockfile(&root) {
Ok(Some(l)) => l,
Ok(None) => LockFile {
version: 1,
packages: Vec::new(),
},
Err(e) => return Err(ProjectFilesError::AlcLockLoad(e)),
};
for (name, version) in &resolved {
add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
upsert_lock_entry(
&mut lock,
name.clone(),
version.clone(),
PackageSource::Installed,
);
}
if let Err(e) = save_alc_toml(&root, &doc) {
warnings.push(ProjectFilesError::AlcTomlSave(e).to_string());
}
if let Err(e) = save_lockfile(&root, &lock) {
warnings.push(ProjectFilesError::AlcLockSave(e).to_string());
}
Ok(warnings)
})
}
async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
if !is_safe_pkg_name(name) {
return None;
}
let code = format!(
r#"package.loaded["{name}"] = nil
local pkg = require("{name}")
return (pkg.meta or {{}}).version"#
);
match self.executor.eval_simple(code).await {
Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
_ => None,
}
}
pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
let mut errors: Vec<String> = Vec::new();
for url in AUTO_INSTALL_SOURCES {
tracing::info!("auto-installing from {url}");
if let Err(e) = self.pkg_install(url.to_string(), None, None).await {
tracing::warn!("failed to auto-install from {url}: {e}");
errors.push(format!("{url}: {e}"));
}
}
if errors.len() == AUTO_INSTALL_SOURCES.len() {
return Err(format!(
"Failed to auto-install bundled packages: {}",
errors.join("; ")
));
}
Ok(())
}
}
fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
root.join(".alc-install.lock")
}
fn is_safe_pkg_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn upsert_lock_entry(
lock: &mut LockFile,
name: String,
version: Option<String>,
source: PackageSource,
) {
if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
existing.version = version;
existing.source = source;
} else {
lock.packages.push(LockPackage {
name,
version,
source,
});
}
}
#[cfg(test)]
mod tests {
use super::super::super::alc_toml::save_alc_toml;
use super::super::super::lock::with_exclusive_lock;
use super::super::super::lockfile::save_lockfile;
use super::*;
#[test]
fn load_alc_toml_corrupt_yields_fatal_err() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("alc.toml"), b"[[not valid toml = {").unwrap();
let lock_path = root.join(".alc-install.lock");
let result: Result<Vec<String>, String> =
with_exclusive_lock(&lock_path, move || match load_alc_toml_document(root) {
Ok(Some(_d)) => Ok(Vec::new()),
Ok(None) => Ok(Vec::new()),
Err(e) => Err(format!("alc.toml load: {e}")),
});
assert!(
result.is_err(),
"Expected Err on corrupt alc.toml, got: {result:?}"
);
let msg = result.unwrap_err();
assert!(
msg.contains("alc.toml load:"),
"Error should contain 'alc.toml load:', got: {msg}"
);
}
#[test]
fn load_alc_lock_corrupt_yields_fatal_err() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
std::fs::write(root.join("alc.lock"), b"version = 999\n[[package]]\n").unwrap();
let lock_path = root.join(".alc-install.lock");
let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
let _doc = match load_alc_toml_document(root) {
Ok(Some(d)) => d,
Ok(None) => return Ok(Vec::new()),
Err(e) => return Err(format!("alc.toml load: {e}")),
};
match load_lockfile(root) {
Ok(Some(_l)) => Ok(Vec::new()),
Ok(None) => Ok(Vec::new()),
Err(e) => Err(format!("alc.lock load: {e}")),
}
});
assert!(
result.is_err(),
"Expected Err on corrupt alc.lock, got: {result:?}"
);
let msg = result.unwrap_err();
assert!(
msg.contains("alc.lock load:"),
"Error should contain 'alc.lock load:', got: {msg}"
);
}
#[test]
fn save_failure_produces_warning_not_fatal_err() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
let bad_root = root.join("blocked_subdir");
std::fs::write(&bad_root, b"this is a file, not a dir").unwrap();
let lock_path = root.join(".alc-install.lock");
let root_owned = root.to_path_buf();
let bad_root_owned = bad_root.clone();
let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
let mut warnings: Vec<String> = Vec::new();
let doc = match load_alc_toml_document(&root_owned) {
Ok(Some(d)) => d,
Ok(None) => return Ok(Vec::new()),
Err(e) => return Err(format!("alc.toml load: {e}")),
};
if let Err(e) = save_alc_toml(&bad_root_owned, &doc) {
warnings.push(format!("alc.toml save: {e}"));
}
let lock = LockFile {
version: 1,
packages: Vec::new(),
};
if let Err(e) = save_lockfile(&bad_root_owned, &lock) {
warnings.push(format!("alc.lock save: {e}"));
}
Ok(warnings)
});
assert!(
result.is_ok(),
"Expected Ok even with save failures, got: {result:?}"
);
let warnings = result.unwrap();
assert!(
!warnings.is_empty(),
"Expected at least one save warning, got empty warnings"
);
assert!(
warnings.iter().any(|w| w.contains("alc.toml save:")),
"Expected 'alc.toml save:' warning, got: {warnings:?}"
);
}
#[test]
fn caller_degrades_fatal_err_to_project_files_warnings() {
let update_result: Result<Vec<String>, String> =
Err("alc.toml load: TOML parse error at line 1".to_string());
let project_files_warnings = match update_result {
Ok(ws) => ws,
Err(e) => vec![e],
};
assert_eq!(project_files_warnings.len(), 1);
assert!(
project_files_warnings[0].contains("alc.toml load:"),
"Warning should contain the original error message"
);
}
#[test]
fn caller_passes_through_ok_warnings() {
let update_result: Result<Vec<String>, String> = Ok(vec![
"alc.toml save: permission denied".to_string(),
"alc.lock save: no space left".to_string(),
]);
let project_files_warnings = match update_result {
Ok(ws) => ws,
Err(e) => vec![e],
};
assert_eq!(project_files_warnings.len(), 2);
}
#[test]
fn empty_warnings_are_not_added_to_response() {
let update_result: Result<Vec<String>, String> = Ok(Vec::new());
let project_files_warnings = match update_result {
Ok(ws) => ws,
Err(e) => vec![e],
};
let mut response = serde_json::json!({ "installed": ["mypkg"], "mode": "collection" });
if !project_files_warnings.is_empty() {
response["project_files_warnings"] = serde_json::json!(project_files_warnings);
}
assert!(
response.get("project_files_warnings").is_none(),
"project_files_warnings should not appear when warnings are empty"
);
}
#[test]
fn upsert_lock_entry_inserts_new_package() {
let mut lock = LockFile {
version: 1,
packages: Vec::new(),
};
upsert_lock_entry(
&mut lock,
"mypkg".to_string(),
Some("1.0.0".to_string()),
PackageSource::Installed,
);
assert_eq!(lock.packages.len(), 1);
assert_eq!(lock.packages[0].name, "mypkg");
assert_eq!(lock.packages[0].version, Some("1.0.0".to_string()));
}
#[test]
fn upsert_lock_entry_updates_existing_package() {
let mut lock = LockFile {
version: 1,
packages: Vec::new(),
};
upsert_lock_entry(
&mut lock,
"mypkg".to_string(),
Some("1.0.0".to_string()),
PackageSource::Installed,
);
upsert_lock_entry(
&mut lock,
"mypkg".to_string(),
Some("2.0.0".to_string()),
PackageSource::Installed,
);
assert_eq!(lock.packages.len(), 1);
assert_eq!(lock.packages[0].version, Some("2.0.0".to_string()));
}
#[test]
fn is_safe_pkg_name_accepts_valid_names() {
assert!(is_safe_pkg_name("my_pkg"));
assert!(is_safe_pkg_name("my-pkg"));
assert!(is_safe_pkg_name("mypkg123"));
}
#[test]
fn is_safe_pkg_name_rejects_invalid_names() {
assert!(!is_safe_pkg_name(""));
assert!(!is_safe_pkg_name("my pkg"));
assert!(!is_safe_pkg_name("../escape"));
assert!(!is_safe_pkg_name("pkg;rm -rf /"));
}
}