Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id0bbdc9ed62bc8b9582ccb89158e53786a6a6964
384 lines
11 KiB
Rust
384 lines
11 KiB
Rust
use std::{collections::HashMap, time::Duration};
|
|
|
|
use crate::{
|
|
error::{MultiError, PakkerError, Result},
|
|
http,
|
|
model::{
|
|
Config,
|
|
LockFile,
|
|
PakkerCredentialsFile,
|
|
Project,
|
|
Target,
|
|
credentials::ResolvedCredentials,
|
|
set_keyring_secret,
|
|
},
|
|
platform::create_platform,
|
|
resolver::DependencyResolver,
|
|
ui_utils::prompt_curseforge_api_key,
|
|
};
|
|
|
|
fn get_loaders(lockfile: &LockFile) -> Vec<String> {
|
|
lockfile.loaders.keys().cloned().collect()
|
|
}
|
|
|
|
fn needs_curseforge(target: Option<&Target>) -> bool {
|
|
matches!(
|
|
target,
|
|
Some(Target::CurseForge) | Some(Target::Multiplatform)
|
|
)
|
|
}
|
|
|
|
async fn ensure_curseforge_credentials() -> Result<bool> {
|
|
let creds = ResolvedCredentials::load();
|
|
if creds.curseforge_api_key().is_some() {
|
|
return Ok(true);
|
|
}
|
|
|
|
if let Some(key) = prompt_curseforge_api_key(false)? {
|
|
// Verify the key before saving
|
|
let client = http::create_http_client();
|
|
let response = client
|
|
.get("https://api.curseforge.com/v1/mods/238222")
|
|
.header("x-api-key", &key)
|
|
.timeout(Duration::from_secs(10))
|
|
.send()
|
|
.await;
|
|
|
|
match response {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
let mut creds_file = PakkerCredentialsFile::load()?;
|
|
set_keyring_secret("curseforge_api_key", &key)?;
|
|
creds_file.curseforge_api_key = Some(key.clone());
|
|
creds_file.save()?;
|
|
println!("CurseForge API key verified and saved.");
|
|
Ok(true)
|
|
},
|
|
Ok(resp) => {
|
|
println!(
|
|
"Warning: CurseForge API key verification failed (HTTP {}).",
|
|
resp.status()
|
|
);
|
|
if crate::ui_utils::prompt_yes_no(
|
|
"Save this key anyway?",
|
|
false,
|
|
false,
|
|
)? {
|
|
let mut creds_file = PakkerCredentialsFile::load()?;
|
|
set_keyring_secret("curseforge_api_key", &key)?;
|
|
creds_file.curseforge_api_key = Some(key);
|
|
creds_file.save()?;
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
},
|
|
Err(e) => {
|
|
println!("Warning: Could not verify CurseForge API key: {e}");
|
|
if crate::ui_utils::prompt_yes_no(
|
|
"Save this key anyway?",
|
|
false,
|
|
false,
|
|
)? {
|
|
let mut creds_file = PakkerCredentialsFile::load()?;
|
|
set_keyring_secret("curseforge_api_key", &key)?;
|
|
creds_file.curseforge_api_key = Some(key);
|
|
creds_file.save()?;
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
},
|
|
}
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
pub fn create_all_platforms()
|
|
-> HashMap<String, Box<dyn crate::platform::PlatformClient>> {
|
|
let mut platforms = HashMap::new();
|
|
|
|
let credentials = ResolvedCredentials::load();
|
|
let curseforge_key = credentials.curseforge_api_key().map(String::from);
|
|
|
|
if let Ok(platform) = create_platform("multiplatform", curseforge_key) {
|
|
platforms.insert("multiplatform".to_owned(), platform);
|
|
} else if let Ok(platform) = create_platform("modrinth", None) {
|
|
platforms.insert("modrinth".to_owned(), platform);
|
|
}
|
|
|
|
platforms
|
|
}
|
|
|
|
async fn resolve_input(
|
|
input: &str,
|
|
platforms: &HashMap<String, Box<dyn crate::platform::PlatformClient>>,
|
|
lockfile: &LockFile,
|
|
) -> Result<Project> {
|
|
let mut projects = Vec::new();
|
|
|
|
for (platform_name, client) in platforms {
|
|
match client
|
|
.request_project_with_files(
|
|
input,
|
|
&lockfile.mc_versions,
|
|
&get_loaders(lockfile),
|
|
)
|
|
.await
|
|
{
|
|
Ok(project) => {
|
|
log::debug!("Resolved '{input}' on {platform_name}");
|
|
projects.push(project);
|
|
},
|
|
Err(e) => {
|
|
log::debug!("Could not resolve '{input}' on {platform_name}: {e}");
|
|
},
|
|
}
|
|
}
|
|
|
|
if projects.is_empty() {
|
|
return Err(PakkerError::ProjectNotFound(input.to_string()));
|
|
}
|
|
|
|
if projects.len() == 1 {
|
|
return Ok(projects.remove(0));
|
|
}
|
|
|
|
let mut merged = projects.remove(0);
|
|
for project in projects {
|
|
merged.merge(project);
|
|
}
|
|
Ok(merged)
|
|
}
|
|
|
|
use std::path::Path;
|
|
|
|
use crate::{cli::AddArgs, model::fork::LocalConfig};
|
|
|
|
#[expect(
|
|
clippy::future_not_send,
|
|
reason = "not required to be Send; only called from single-threaded context"
|
|
)]
|
|
pub async fn execute(
|
|
args: AddArgs,
|
|
global_yes: bool,
|
|
lockfile_path: &Path,
|
|
config_path: &Path,
|
|
) -> Result<()> {
|
|
let skip_prompts = global_yes;
|
|
log::info!("Adding projects: {:?}", args.inputs);
|
|
|
|
// Load lockfile
|
|
// Load expects directory path, so get parent directory
|
|
let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new("."));
|
|
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
|
|
|
|
// Check if lockfile exists (try both pakker-lock.json and pakku-lock.json)
|
|
let lockfile_exists =
|
|
lockfile_path.exists() || lockfile_dir.join("pakku-lock.json").exists();
|
|
|
|
if !lockfile_exists {
|
|
// Try to load config from both pakker.json and pakku.json
|
|
let local_config = LocalConfig::load(config_dir).or_else(|_| {
|
|
let legacy_config_path = config_dir.join("pakku.json");
|
|
if legacy_config_path.exists() {
|
|
LocalConfig::load(&config_dir.join("pakku.json"))
|
|
} else {
|
|
Err(PakkerError::IoError(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"No pakker.json found",
|
|
)))
|
|
}
|
|
})?;
|
|
|
|
if local_config.has_parent() {
|
|
log::info!("Creating minimal fork lockfile with parent metadata...");
|
|
|
|
// Check for parent lockfile (try both pakker-lock.json and
|
|
// pakku-lock.json)
|
|
let parent_paths = [
|
|
lockfile_dir.join(".pakku/parent/pakker-lock.json"),
|
|
lockfile_dir.join(".pakku/parent/pakku-lock.json"),
|
|
];
|
|
|
|
let parent_found = parent_paths.iter().any(|path| path.exists());
|
|
if !parent_found {
|
|
return Err(PakkerError::IoError(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"Fork configured but parent lockfile not found at \
|
|
.pakku/parent/pakker-lock.json or .pakku/parent/pakku-lock.json",
|
|
)));
|
|
}
|
|
|
|
// Load parent lockfile to get metadata
|
|
let parent_lock_path = parent_paths
|
|
.iter()
|
|
.find(|path| path.exists())
|
|
.ok_or_else(|| {
|
|
PakkerError::IoError(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"Parent lockfile not found at expected paths",
|
|
))
|
|
})?;
|
|
let parent_lockfile = LockFile::load_with_validation(
|
|
parent_lock_path.parent().ok_or_else(|| {
|
|
PakkerError::IoError(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"Parent lockfile path has no parent directory",
|
|
))
|
|
})?,
|
|
false,
|
|
)?;
|
|
|
|
let minimal_lockfile = LockFile {
|
|
target: parent_lockfile.target,
|
|
mc_versions: parent_lockfile.mc_versions,
|
|
loaders: parent_lockfile.loaders,
|
|
projects: Vec::new(),
|
|
lockfile_version: 1,
|
|
};
|
|
minimal_lockfile.save_without_validation(lockfile_dir)?;
|
|
} else {
|
|
return Err(PakkerError::IoError(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"pakker-lock.json not found and no fork configured. Run 'pakker init' \
|
|
first.",
|
|
)));
|
|
}
|
|
}
|
|
|
|
let mut lockfile = LockFile::load_with_validation(lockfile_dir, false)?;
|
|
|
|
// Prompt for missing CurseForge credentials when needed
|
|
if needs_curseforge(lockfile.target.as_ref()) && !skip_prompts {
|
|
let _ = ensure_curseforge_credentials().await;
|
|
}
|
|
|
|
// Load config if available
|
|
let _config = Config::load(config_dir).ok();
|
|
|
|
// Create platforms
|
|
let platforms = create_all_platforms();
|
|
|
|
let mut new_projects = Vec::new();
|
|
let mut errors = MultiError::new();
|
|
|
|
// Resolve each input
|
|
for input in &args.inputs {
|
|
let project = match resolve_input(input, &platforms, &lockfile).await {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
// Collect error but continue with other inputs
|
|
log::warn!("Failed to resolve '{input}': {e}");
|
|
errors.push(e);
|
|
continue;
|
|
},
|
|
};
|
|
|
|
// Check if already exists by matching platform IDs (not pakku_id which is
|
|
// random)
|
|
let already_exists = lockfile.projects.iter().any(|p| {
|
|
// Check if any platform ID matches
|
|
project.id.iter().any(|(platform, id)| {
|
|
p.id
|
|
.get(platform)
|
|
.is_some_and(|existing_id| existing_id == id)
|
|
})
|
|
});
|
|
|
|
if already_exists {
|
|
if args.update {
|
|
log::info!("Updating existing project: {}", project.get_name());
|
|
// Find and replace the existing project
|
|
if let Some(pos) = lockfile.projects.iter().position(|p| {
|
|
project.id.iter().any(|(platform, id)| {
|
|
p.id
|
|
.get(platform)
|
|
.is_some_and(|existing_id| existing_id == id)
|
|
})
|
|
}) {
|
|
lockfile.projects[pos] = project;
|
|
}
|
|
continue;
|
|
}
|
|
log::info!("Project already exists: {}", project.get_name());
|
|
continue;
|
|
}
|
|
|
|
// Prompt for confirmation unless --yes flag is set
|
|
if !skip_prompts {
|
|
let prompt_msg = format!("Add project '{}'?", project.get_name());
|
|
if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? {
|
|
log::info!("Skipping project: {}", project.get_name());
|
|
continue;
|
|
}
|
|
}
|
|
|
|
new_projects.push(project);
|
|
}
|
|
|
|
// Resolve dependencies unless --no-deps is specified
|
|
if !args.no_deps {
|
|
log::info!("Resolving dependencies...");
|
|
|
|
let mut resolver = DependencyResolver::new();
|
|
let mut all_new_projects = new_projects.clone();
|
|
|
|
for project in &mut new_projects {
|
|
let deps = resolver.resolve(project, &mut lockfile, &platforms).await?;
|
|
|
|
for dep in deps {
|
|
if !lockfile.projects.iter().any(|p| p.pakku_id == dep.pakku_id)
|
|
&& !all_new_projects.iter().any(|p| p.pakku_id == dep.pakku_id)
|
|
{
|
|
// Prompt user for confirmation unless --yes flag is set
|
|
if !skip_prompts {
|
|
let prompt_msg = format!(
|
|
"Add dependency '{}' required by '{}'?",
|
|
dep.get_name(),
|
|
project.get_name()
|
|
);
|
|
if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)?
|
|
{
|
|
log::info!("Skipping dependency: {}", dep.get_name());
|
|
continue;
|
|
}
|
|
}
|
|
|
|
log::info!("Adding dependency: {}", dep.get_name());
|
|
all_new_projects.push(dep);
|
|
}
|
|
}
|
|
}
|
|
|
|
new_projects = all_new_projects;
|
|
}
|
|
|
|
// Track count before moving
|
|
let added_count = new_projects.len();
|
|
|
|
// Add projects to lockfile (updates already handled above)
|
|
for project in new_projects {
|
|
lockfile.add_project(project);
|
|
}
|
|
|
|
// Save lockfile
|
|
lockfile.save(lockfile_dir)?;
|
|
|
|
log::info!("Successfully added {added_count} project(s)");
|
|
|
|
// Return aggregated errors if any occurred
|
|
if !errors.is_empty() {
|
|
let error_count = errors.len();
|
|
log::warn!(
|
|
"{error_count} project(s) failed to resolve (see warnings above)"
|
|
);
|
|
// Return success if at least some projects were added, otherwise return
|
|
// errors
|
|
if added_count == 0 && args.inputs.len() == error_count {
|
|
return errors.into_result(());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|