use std::{
collections::{HashMap, HashSet},
env::current_dir,
fs::{create_dir_all, read_to_string, File},
io::Write,
path::PathBuf,
sync::{atomic::AtomicBool, Arc},
time::Duration,
};
use anyhow::{bail, Context};
use clap::{Args, Subcommand};
use dialoguer::Confirm;
use git2::{DescribeFormatOptions, DescribeOptions, Repository, WorktreeAddOptions};
use indicatif::{ProgressBar, ProgressStyle};
use log::{info, trace, warn};
use reqwest::Url;
use crate::{
config::{
add_plugin_to_config, get_config, remove_plugin_from_config, repository::RepositoryXml,
},
get_home,
plugin::config::{PluginToml, SdkEntry},
pom::VersionRange,
submodules::{
resolvers::GOOGLE_REPO_URL,
sdk::{
get_sdk_path, parse_repository_toml, toml_strings, Installer, InstallerTarget, Sdk,
DEFAULT_RESOURCES_URL, DEFAULT_URL, FAILED_TO_PARSE_SDK_STR, GOOGLE_REPO_NAME_STR,
SDKMANAGER_TARGET,
},
sdkmanager::{installed_list::InstalledList, ToIdLong},
},
LABT_VERSION, MULTI_PROGRESS_BAR,
};
use super::Submodule;
#[derive(Clone, Args)]
pub struct PluginArgs {
#[command(subcommand)]
command: Option<PluginSubcommands>,
#[arg(long, action)]
trust: bool,
plugin_id: Option<String>,
}
#[derive(Clone, Subcommand)]
#[clap(
group = clap::ArgGroup::new("plugin_subcommands"),
)]
pub enum PluginSubcommands {
Create(CreateArgs),
Remove(RemoveArgs),
Fetch,
}
#[derive(Clone, Args)]
pub struct CreateArgs {
name: String,
version: String,
path: Option<PathBuf>,
#[arg(short, long, action)]
local: bool,
}
#[derive(Clone, Args)]
pub struct RemoveArgs {
name: String,
}
#[derive(Clone, Args)]
pub struct UseArgs {
name: String,
version: String,
location: String,
}
pub struct Plugin<'a> {
args: &'a PluginArgs,
}
impl<'a> Plugin<'a> {
pub fn new(args: &'a PluginArgs) -> Self {
Plugin { args }
}
}
impl<'a> Submodule for Plugin<'a> {
fn run(&mut self) -> anyhow::Result<()> {
if let Some(command) = &self.args.command {
match command {
PluginSubcommands::Create(arg) => {
create_new_plugin(
arg.name.clone(),
arg.version.clone(),
arg.path.clone(),
arg.local,
)
.context("Failed to create the new plugin")?;
return Ok(());
}
PluginSubcommands::Remove(arg) => {
remove_plugin_from_config(arg.name.clone())
.context("Failed to remove plugin from config")?;
return Ok(());
}
PluginSubcommands::Fetch => {
fetch_plugins_from_config(self.args.trust)
.context("Failed to fetch plugins")?;
return Ok(());
}
}
}
if let Some(id) = &self.args.plugin_id {
let mut split = id.split('@');
let url = split.next().unwrap();
let version = split.next();
let mut iknow_what_iam_doing = self.args.trust;
fetch_plugin(url, version, true, true, &mut iknow_what_iam_doing)
.context("Failed to configure plugin.")?;
}
Ok(())
}
}
fn fetch_version<'a>(
repo: &'a Repository,
version: &str,
) -> anyhow::Result<(String, git2::Reference<'a>)> {
let version = if version.eq("latest") {
let mut describe_options = DescribeOptions::new();
describe_options.describe_tags();
describe_options.pattern("v*");
let describe = repo
.describe(&describe_options)
.context("Unable to obtain the latest tag.")?;
describe
.format(Some(DescribeFormatOptions::new().abbreviated_size(0)))
.context("Failed fo format git describe")?
} else {
if version.starts_with("v") {
version.to_string()
} else {
format!("v{}", version)
}
};
let reference_string = format!("refs/tags/{}", version);
Ok((
version,
repo.find_reference(&reference_string)
.context(format!("Failed to lookup {}", reference_string))?,
))
}
pub fn build_repo(location: &str, git_path: PathBuf) -> anyhow::Result<Repository> {
let spinner = MULTI_PROGRESS_BAR.add(ProgressBar::new_spinner());
spinner.enable_steady_tick(Duration::from_millis(100));
spinner.set_style(ProgressStyle::with_template("{spinner} {prefix:.blue} {wide_msg}").unwrap());
spinner.set_prefix("Plugin");
let repo = if !git_path.exists() {
create_dir_all(&git_path).context(format!(
"Unable to create plugin directory at {}",
git_path.to_string_lossy()
))?;
spinner.set_message(format!("Clonning {}", location));
Repository::clone(location, git_path)
.context("Failed to clone plugin to local directory")?
} else {
spinner.set_message(format!("Opening repo at {}", location));
let repo = Repository::open(&git_path).context(format!(
"Failed to open plugin repository at {}",
git_path.to_string_lossy()
))?;
spinner.set_message(format!("Fetching updates from {}", location));
let mut remote = repo
.find_remote("origin")
.context("Unable to get the repository \"origin\"")?;
if let Err(err) = remote.fetch(
&[
"refs/heads/*:refs/remotes/origin/*",
"refs/tags/*:refs/tags/*",
],
None,
None,
) {
match (err.code(), err.class()) {
(git2::ErrorCode::GenericError, git2::ErrorClass::Net) => {
warn!(target: "plugin", "A network request failed. We are unable to update the plugin git repo. We will proceed in offline mode but latest versions will be missing or incorrect.")
}
_ => {
bail!(err);
}
}
}
drop(remote);
repo
};
spinner.finish_and_clear();
Ok(repo)
}
pub fn fetch_plugin(
location: &str,
version: Option<&str>,
update_config: bool,
install_sdk: bool,
iknow_what_iam_doing: &mut bool,
) -> anyhow::Result<Option<(PluginToml, PathBuf)>> {
const LATEST: &str = "latest";
let version = version.unwrap_or(LATEST);
let mut already_installed: bool = false;
let path = if let Ok(url) = Url::parse(location) {
let mut path = get_home().context("Failed to get Labt Home")?;
path.push("plugins");
if let Some(domain) = url.domain() {
path.push(domain);
} else {
path.push("example.com"); }
let url_path = url.path();
let url_path = if let Some(p) = url_path.strip_suffix(".git") {
p
} else {
url_path
};
path.extend(url_path.split('/'));
let mut git_path = path.clone();
git_path.push("git");
let mut worktrees_path = path.clone();
worktrees_path.push("versions");
let mut worktrees_version_path = worktrees_path.clone();
if version != LATEST {
if version.starts_with("v") {
worktrees_version_path.push(version);
} else {
worktrees_version_path.push(format!("v{}", version));
}
if worktrees_version_path.exists() {
already_installed = true;
}
}
if !already_installed {
if !*iknow_what_iam_doing {
warn!(target: "plugin", "You are about to install a plugin that may run arbitrary code on your system. Please ensure that you trust the source of this plugin before proceeding. Installing unverified plugins can pose significant security risks, including data loss or unauthorized access to your system. Proceed with caution and verify the plugin's authenticity.");
let trust = Confirm::new()
.with_prompt("Proceed with installation?")
.default(false)
.interact()?;
if !trust {
info!(target: "plugin", "The installation has been canceled. Remember to stay safe by only install plugins from trusted sources. Have a wonderful day!");
return Ok(None);
}
}
let repo = build_repo(location, git_path)?;
if !worktrees_path.exists() {
create_dir_all(&worktrees_path).context(format!(
"Unable to create plugin worktree directory at {}",
path.to_string_lossy()
))?;
}
let (version, reference) = fetch_version(&repo, version)
.context("Failed to resolve version from plugin repo")?;
worktrees_path.push(&version);
if !worktrees_path.exists() && reference.is_tag() {
let id = reference
.target()
.context("Unable to obtain reference oid")?;
let commit = repo.find_commit(id)?;
let branch = match repo.branch(&version, &commit, false) {
Err(err) => {
if let git2::ErrorCode::Exists = err.code() {
repo.find_branch(&version, git2::BranchType::Local)?
} else {
return Err(err).context(format!(
"Failed to branch out from selected tag: {}",
version
));
}
}
Ok(branch) => branch,
};
let mut worktree_options = WorktreeAddOptions::new();
worktree_options.reference(Some(branch.get()));
repo.worktree(&version, &worktrees_path, Some(&worktree_options))?;
}
worktrees_path
} else {
worktrees_version_path
}
} else {
already_installed = true;
if !*iknow_what_iam_doing {
warn!(target: "plugin", "You are about to execute non standard plugin from a path. Plugins have the ability to run arbitrary code on your system. Please ensure that you trust the source of this plugin before proceeding. Executing unverified plugins can pose significant security risks, including data loss or unauthorized access to your system. Proceed with caution and verify the plugin's authenticity.");
let trust = Confirm::new()
.with_prompt("Proceed ?")
.default(false)
.interact()?;
if !trust {
info!(target: "plugin", "The execution has been canceled. Remember to stay safe by only install plugins from trusted sources. Have a wonderful day!");
return Ok(None);
}
}
let p = PathBuf::from(&location);
if !p.exists() {
bail!("The argument provided is neither a valid url nor a valid plugin directory. If you are providing a url, please include the protocol scheme e.g. https:// ");
}
p
};
let mut plugin_toml_path = path.clone();
plugin_toml_path.push("plugin.toml");
let toml_string = read_to_string(&plugin_toml_path).context(format!(
"Failed to read plugin toml string from {}",
plugin_toml_path.to_string_lossy()
))?;
let mut plugin_toml = toml_string.parse::<PluginToml>().context(format!(
"Failed to parse plugin.toml from {}",
plugin_toml_path.to_string_lossy()
))?;
if let Some(labt) = &plugin_toml.labt {
let give_err = |v: &str| {
format!(
"Failed to compare LABT ({}) version with plugin requested version ({})",
LABT_VERSION, v
)
};
let reject = match labt {
VersionRange::Gt(v) => {
!version_compare::compare_to(LABT_VERSION, v, version_compare::Cmp::Gt)
.map_err(|_| anyhow::anyhow!(give_err(v)))?
}
VersionRange::Ge(v) => {
!version_compare::compare_to(LABT_VERSION, v, version_compare::Cmp::Ge)
.map_err(|_| anyhow::anyhow!(give_err(v)))?
}
VersionRange::Lt(v) => {
!version_compare::compare_to(LABT_VERSION, v, version_compare::Cmp::Lt)
.map_err(|_| anyhow::anyhow!(give_err(v)))?
}
VersionRange::Le(v) => {
!version_compare::compare_to(LABT_VERSION, v, version_compare::Cmp::Le)
.map_err(|_| anyhow::anyhow!(give_err(v)))?
}
VersionRange::Eq(v) => {
!version_compare::compare_to(v, LABT_VERSION, version_compare::Cmp::Eq)
.map_err(|_| anyhow::anyhow!(give_err(v)))?
}
};
if reject {
bail!("{}@{} requested LABt ({}) which is not compatible with the currently available LABt version ({}). Please check for other versions of the plugin or Install the appropriate version of LABt. ",
plugin_toml.name,
plugin_toml.version,
plugin_toml.labt.unwrap(),
LABT_VERSION
);
}
}
if !plugin_toml.sdk.is_empty() && install_sdk {
let mut installed_list = InstalledList::parse_from_sdk()?;
const PLUGIN_SDK: &str = "plugin sdk";
if !installed_list
.repositories
.contains_key(GOOGLE_REPO_NAME_STR)
&& !plugin_toml.sdk_repo.contains_key(GOOGLE_REPO_NAME_STR)
{
if plugin_toml
.sdk
.iter()
.any(|sdk| sdk.repo == GOOGLE_REPO_NAME_STR)
{
plugin_toml.sdk_repo.insert(
GOOGLE_REPO_NAME_STR.to_string(),
crate::submodules::sdkmanager::installed_list::RepositoryInfo {
url: DEFAULT_RESOURCES_URL.to_string(),
accepted_licenses: HashSet::new(),
path: PathBuf::new(),
},
);
}
}
for (name, repo) in plugin_toml.sdk_repo.iter() {
info!(target: PLUGIN_SDK, "Installing {} sdk repo for plugin {}@{}.", name, plugin_toml.name, plugin_toml.version);
Sdk::add_repository(name, &repo.url, &mut installed_list).context(format!(
"Failed to install {} sdk repo requested by plugin {}@{}.",
name, plugin_toml.name, plugin_toml.version
))?;
}
let (host_os, bits) = Sdk::get_host_os_and_bits(None)?;
let running = Arc::new(AtomicBool::new(true));
let mut installer = Installer::new(
Url::parse(DEFAULT_URL)?,
bits,
host_os.clone(),
false,
running,
);
let installed_list_map = installed_list.get_hash_map_long();
let sdk_list: Vec<&SdkEntry> = plugin_toml
.sdk
.iter()
.filter(|sdk| !installed_list_map.contains_key(&sdk.to_id_long()))
.collect();
let mut repositories: HashMap<String, RepositoryXml> = HashMap::new();
for sdk in sdk_list {
let repo = if let Some(repo) = &repositories.get(&sdk.repo) {
repo
} else {
if let Some(repo_entry) = installed_list.repositories.get(&sdk.repo) {
let mut path = repo_entry.path.clone();
path.push(toml_strings::CONFIG_FILE);
let repo = parse_repository_toml(&path).context(FAILED_TO_PARSE_SDK_STR)?;
repositories.insert(sdk.repo.to_string(), repo);
repositories.get(&sdk.repo).unwrap()
} else {
bail!("The plugin config tried to install an sdk package from a repository name it did not specify in its config! ");
}
};
let package = repo
.get_remote_packages()
.iter()
.find(|p| {
if !&sdk.path.eq(p.get_path()) {
return false;
}
if !sdk.version.eq(p.get_revision()) {
return false;
}
if &sdk.channel != p.get_channel() {
return false;
}
true
})
.context(format!(
"Package {} v{}-{} does not exist on \"{}\" sdk repo.",
sdk.path, sdk.version, sdk.channel, sdk.channel
))?;
if sdk.repo == GOOGLE_REPO_NAME_STR {
installer.add_package(GOOGLE_REPO_NAME_STR, package.clone())?;
} else {
let base_url = if let Some(url) = package.get_base_url() {
Url::parse(url)?
} else {
trace!(target: "sdkmanager", "Repository did not specify its base URL. Setting google repo url as a place holder hoping that they did for whatever archive we are installing. ");
Url::parse(GOOGLE_REPO_URL)?
};
let path: PathBuf = package.get_path().split(';').collect();
let mut sdk_path = get_sdk_path()?;
sdk_path.push(&sdk.repo);
let target = InstallerTarget {
bits,
host_os: host_os.clone(),
target_path: sdk_path.join(path),
package: package.clone(),
download_url: Arc::new(base_url),
repository_name: sdk.repo.to_string(),
};
installer.add_target(target);
}
if !package.get_uses_license().is_empty() {
let mut license_path = installed_list
.repositories
.get(&sdk.repo)
.unwrap()
.path
.clone();
license_path.push("licenses");
license_path.push(package.get_uses_license());
log::info!(target: SDKMANAGER_TARGET, "Auto accepting license for {}. You can review it at {:#?}.", package.get_path(), license_path);
installed_list.accept_license(&sdk.repo, package.get_uses_license().to_string());
}
}
drop(repositories);
installer.install()?;
let install_target_count = installer.install_targets.len();
let installed_count = installer.complete_tasks.len();
for package in installer.complete_tasks {
installed_list.add_installed_package(package);
}
installed_list
.save_to_file()
.context("Failed to update installed package list with installed packages")?;
if install_target_count != installed_count {
log::error!(target: "plugin", "Failed to install all sdk packages required by {}@{} plugin. Canceling the installation. If this was due to a network error, please re-run the install command, and we will attempt to install the failed packages.", plugin_toml.name, plugin_toml.version);
return Ok(None);
}
}
if update_config {
add_plugin_to_config(
plugin_toml.name.clone(),
plugin_toml.version.clone(),
location.to_string(),
)
.context("Failed to add plugin to project config")?;
}
if !already_installed {
info!(target: "plugin", "Installed plugin: {}@{}", plugin_toml.name, plugin_toml.version);
}
Ok(Some((plugin_toml, path)))
}
pub fn fetch_plugins_from_config(iknow_what_iam_doing: bool) -> anyhow::Result<()> {
let config = get_config().context("Failed reading project configuration")?;
if let Some(plugins) = config.plugins {
let mut iknow_what_iam_doing = iknow_what_iam_doing;
for (name, plugin) in plugins {
fetch_plugin(
&plugin.location.unwrap_or(
get_home() .context("Failed to get Labt home")?
.to_str()
.unwrap_or("")
.to_string(),
),
Some(plugin.version.as_str()),
false,
false,
&mut iknow_what_iam_doing,
)
.context(format!(
"Failed to fetch plugin: {}@{}",
name, plugin.version
))?;
}
}
Ok(())
}
pub fn create_new_plugin(
name: String,
version: String,
path: Option<PathBuf>,
local_plugin: bool,
) -> anyhow::Result<()> {
warn!("This is an unstable feature, Things may not work correctly");
let plugin = PluginToml {
name: name.clone(),
version: version.clone(),
stages: HashMap::default(),
path: PathBuf::new(),
package_paths: None,
enable_unsafe: false,
sdk: Vec::new(),
labt: None,
sdk_repo: HashMap::new(),
init: None,
};
let mut path = if local_plugin {
let mut cwd = current_dir().context("Failed to get current working directory.")?;
cwd.push("plugins");
cwd.push(format!("{}-{}", name, version));
create_dir_all(&cwd).context("Failed creating plugin directory on project folder")?;
cwd
} else {
if let Some(path) = path {
path
} else {
let mut path = get_home().context("Failed to get Labt Home")?;
path.push("plugins");
path.push(format!("{}-{}", name, version));
path
}
};
let doc = plugin.to_string();
path.push("plugin.toml");
let mut file =
File::create(&path).context(format!("Failed to create plugin file at {:?}", path))?;
file.write_all(doc.to_string().as_bytes())
.context(format!("Failed to write plugin file {:?}", path))?;
info!(target: "plugin", "Created a plugin at {:?}", path);
Ok(())
}