feat: Created basic install_remote functions
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::InstallFilter;
|
||||
use crate::utils;
|
||||
|
||||
pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
|
||||
let (url, reference) = parse_url_with_ref(git_url)?;
|
||||
let temp = clone_to_temp(&url, reference.as_deref())?;
|
||||
println!("Cloned {git_url} to {}", temp.path().display());
|
||||
print_repo_tree(temp.path())?;
|
||||
let _ = (force, filter);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_remote_from_repl_args(args: &str) -> Result<()> {
|
||||
let tokens = shell_words::split(args)
|
||||
.with_context(|| format!("failed to parse '.install remote' args: {args}"))?;
|
||||
|
||||
let mut iter = tokens.into_iter();
|
||||
let url = iter.next().with_context(|| {
|
||||
format!(
|
||||
"Usage: .install remote <git-url> [--filter <{}>] [--force]",
|
||||
InstallFilter::NAMES.join("|")
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut filter: Option<InstallFilter> = None;
|
||||
let mut force = false;
|
||||
|
||||
while let Some(tok) = iter.next() {
|
||||
match tok.as_str() {
|
||||
"--force" => force = true,
|
||||
"--filter" => {
|
||||
let val = iter.next().with_context(|| {
|
||||
format!(
|
||||
"--filter requires a value (one of: {})",
|
||||
InstallFilter::NAMES.join(", ")
|
||||
)
|
||||
})?;
|
||||
filter = Some(parse_filter(&val)?);
|
||||
}
|
||||
s if s.starts_with("--filter=") => {
|
||||
filter = Some(parse_filter(&s["--filter=".len()..])?);
|
||||
}
|
||||
other => bail!("Unexpected argument to '.install remote': {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
install_remote(&url, filter, force)
|
||||
}
|
||||
|
||||
fn parse_filter(name: &str) -> Result<InstallFilter> {
|
||||
InstallFilter::parse(name).with_context(|| {
|
||||
format!(
|
||||
"Unknown filter '{name}'. Valid values: {}",
|
||||
InstallFilter::NAMES.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_url_with_ref(input: &str) -> Result<(String, Option<String>)> {
|
||||
match input.rsplit_once('#') {
|
||||
Some((url, refspec)) if !url.is_empty() => {
|
||||
if refspec.is_empty() {
|
||||
bail!("Empty ref after '#' in URL: {input}");
|
||||
}
|
||||
if refspec.contains("..") {
|
||||
bail!("Invalid ref '{refspec}': cannot contain '..'");
|
||||
}
|
||||
if refspec.starts_with('-') {
|
||||
bail!(
|
||||
"Invalid ref '{refspec}': cannot start with '-' \
|
||||
(would be parsed by git as a CLI flag)"
|
||||
);
|
||||
}
|
||||
if !refspec
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+'))
|
||||
{
|
||||
bail!("Invalid ref '{refspec}': only [A-Za-z0-9._/+-] characters allowed");
|
||||
}
|
||||
Ok((url.to_string(), Some(refspec.to_string())))
|
||||
}
|
||||
_ => Ok((input.to_string(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
struct TempRepoDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempRepoDir {
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempRepoDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_to_temp(url: &str, reference: Option<&str>) -> Result<TempRepoDir> {
|
||||
let dest = utils::temp_file("loki-remote-install-", "");
|
||||
let dest_arg: OsString = dest.as_os_str().into();
|
||||
|
||||
let is_sha = reference
|
||||
.map(|r| r.len() >= 4 && r.len() <= 40 && r.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.unwrap_or(false);
|
||||
|
||||
match reference {
|
||||
Some(r) if !is_sha => {
|
||||
run_git(vec![
|
||||
"clone".into(),
|
||||
"--depth".into(),
|
||||
"1".into(),
|
||||
"--branch".into(),
|
||||
r.into(),
|
||||
url.into(),
|
||||
dest_arg,
|
||||
])?;
|
||||
}
|
||||
Some(r) => {
|
||||
run_git(vec!["clone".into(), url.into(), dest_arg.clone()])?;
|
||||
run_git(vec!["-C".into(), dest_arg, "checkout".into(), r.into()])?;
|
||||
}
|
||||
None => {
|
||||
run_git(vec![
|
||||
"clone".into(),
|
||||
"--depth".into(),
|
||||
"1".into(),
|
||||
url.into(),
|
||||
dest_arg,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TempRepoDir { path: dest })
|
||||
}
|
||||
|
||||
fn run_git(args: Vec<OsString>) -> Result<()> {
|
||||
let output = duct::cmd("git", &args)
|
||||
.stderr_to_stdout()
|
||||
.stdout_capture()
|
||||
.unchecked()
|
||||
.run()
|
||||
.context("failed to spawn git (is it installed and on PATH?)")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let combined = String::from_utf8_lossy(&output.stdout);
|
||||
bail!("git failed: {}", combined.trim());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_repo_tree(root: &Path) -> Result<()> {
|
||||
println!("Repository contents ({}):", root.display());
|
||||
print_children(root, "")
|
||||
}
|
||||
|
||||
fn print_children(dir: &Path, prefix: &str) -> Result<()> {
|
||||
let mut entries: Vec<_> = fs::read_dir(dir)
|
||||
.with_context(|| format!("failed to read {}", dir.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name() != OsStr::new(".git"))
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
let n = entries.len();
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
let is_last = i == n - 1;
|
||||
let connector = if is_last { "└── " } else { "├── " };
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
println!("{prefix}{connector}{name}");
|
||||
|
||||
if entry.file_type()?.is_dir() {
|
||||
let extension = if is_last { " " } else { "│ " };
|
||||
let new_prefix = format!("{prefix}{extension}");
|
||||
print_children(&entry.path(), &new_prefix)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_url_no_ref() {
|
||||
let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git").unwrap();
|
||||
|
||||
assert_eq!(url, "https://github.com/foo/bar.git");
|
||||
assert_eq!(r, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_with_branch_ref() {
|
||||
let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#main").unwrap();
|
||||
|
||||
assert_eq!(url, "https://github.com/foo/bar.git");
|
||||
assert_eq!(r.as_deref(), Some("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_with_tag_ref() {
|
||||
let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#v1.2.3").unwrap();
|
||||
|
||||
assert_eq!(url, "https://github.com/foo/bar.git");
|
||||
assert_eq!(r.as_deref(), Some("v1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_with_sha_ref() {
|
||||
let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#abc1234").unwrap();
|
||||
|
||||
assert_eq!(url, "https://github.com/foo/bar.git");
|
||||
assert_eq!(r.as_deref(), Some("abc1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_with_slash_in_ref() {
|
||||
let (url, r) = parse_url_with_ref("git@github.com:foo/bar.git#release/v2").unwrap();
|
||||
|
||||
assert_eq!(url, "git@github.com:foo/bar.git");
|
||||
assert_eq!(r.as_deref(), Some("release/v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_rejects_empty_ref() {
|
||||
assert!(parse_url_with_ref("https://github.com/foo/bar.git#").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_rejects_dotdot() {
|
||||
assert!(parse_url_with_ref("https://github.com/foo/bar.git#foo..bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_rejects_leading_dash_argument_injection() {
|
||||
assert!(parse_url_with_ref("https://github.com/foo/bar.git#-evil").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_rejects_shell_metachars() {
|
||||
assert!(parse_url_with_ref("https://github.com/foo/bar.git#foo bar").is_err());
|
||||
assert!(parse_url_with_ref("https://github.com/foo/bar.git#$inject").is_err());
|
||||
assert!(parse_url_with_ref("https://github.com/foo/bar.git#;rm -rf /").is_err());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod agent;
|
||||
mod app_config;
|
||||
mod app_state;
|
||||
mod input;
|
||||
mod install_remote;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
pub(crate) mod paths;
|
||||
@@ -22,6 +23,7 @@ pub use self::app_config::AppConfig;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::app_state::AppState;
|
||||
pub use self::input::Input;
|
||||
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::request_context::{RenderMode, RequestContext};
|
||||
pub use self::role::{
|
||||
@@ -274,6 +276,31 @@ impl AssetCategory {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum InstallFilter {
|
||||
Agents,
|
||||
Roles,
|
||||
Macros,
|
||||
Functions,
|
||||
#[value(name = "mcp_config")]
|
||||
McpConfig,
|
||||
}
|
||||
|
||||
impl InstallFilter {
|
||||
pub const NAMES: [&'static str; 5] = ["agents", "roles", "macros", "functions", "mcp_config"];
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"agents" => Some(Self::Agents),
|
||||
"roles" => Some(Self::Roles),
|
||||
"macros" => Some(Self::Macros),
|
||||
"functions" => Some(Self::Functions),
|
||||
"mcp_config" => Some(Self::McpConfig),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_assets(category: AssetCategory) -> Result<()> {
|
||||
let (label, target) = match category {
|
||||
AssetCategory::Agents => ("agents", paths::agents_data_dir()),
|
||||
|
||||
Reference in New Issue
Block a user