diff --git a/Cargo.lock b/Cargo.lock index 15d513ce..c053dc04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,6 +780,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -801,6 +836,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -1241,6 +1307,22 @@ dependencies = [ "serde", ] +[[package]] +name = "handlebars" +version = "6.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.12", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1325,6 +1407,7 @@ dependencies = [ "eyre", "flume", "futures-util", + "handlebars", "helix-db", "helix-metrics", "iota", @@ -1748,6 +1831,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -2220,6 +2309,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4236,9 +4340,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.1", diff --git a/helix-cli/Cargo.toml b/helix-cli/Cargo.toml index 52afccc2..145161f6 100644 --- a/helix-cli/Cargo.toml +++ b/helix-cli/Cargo.toml @@ -25,13 +25,8 @@ dotenvy = "0.15.7" tokio-tungstenite = "0.27.0" futures-util = "0.3.31" regex = "1.11.2" - -[dev-dependencies] -tempfile = "3.14.0" - -[lib] -name = "helix_cli" -path = "src/lib.rs" +handlebars = "6.3.2" +tempfile = "3.23.0" [[bin]] name = "helix" diff --git a/helix-cli/src/commands/init.rs b/helix-cli/src/commands/init.rs index 8fe1acee..1fac47b2 100644 --- a/helix-cli/src/commands/init.rs +++ b/helix-cli/src/commands/init.rs @@ -2,19 +2,21 @@ use crate::CloudDeploymentTypeCommand; use crate::commands::integrations::ecr::{EcrAuthType, EcrManager}; use crate::commands::integrations::fly::{FlyAuthType, FlyManager, VmSize}; use crate::commands::integrations::helix::HelixManager; +use crate::commands::templates::{TemplateFetcher, TemplateProcessor, TemplateSource}; use crate::config::{CloudConfig, HelixConfig}; use crate::docker::DockerManager; use crate::errors::project_error; use crate::project::ProjectContext; use crate::utils::{print_instructions, print_status, print_success}; use eyre::Result; +use std::collections::HashMap; use std::env; use std::fs; use std::path::Path; pub async fn run( path: Option, - _template: String, + template: Option, queries_path: String, deployment_type: Option, ) -> Result<()> { @@ -47,6 +49,29 @@ pub async fn run( // Create project directory if it doesn't exist fs::create_dir_all(&project_dir)?; + // Process template if provided + if let Some(template_spec) = template { + process_template(&template_spec, &project_dir, project_name)?; + + print_success(&format!( + "Helix project successfully initialized from template: `{template_spec}`", + )); + + print_instructions( + "Next steps:", + &[ + &format!( + "1. Explore your project files in `{}`", + project_dir.display() + ), + "2. Run `helix build dev` to compile your project", + "3. Run `helix push dev` to start your development instance", + ], + ); + + return Ok(()); + } + // Create default helix.toml with custom queries path let mut config = HelixConfig::default_config(project_name); config.project.queries = std::path::PathBuf::from(&queries_path); @@ -55,7 +80,6 @@ pub async fn run( create_project_structure(&project_dir, &queries_path)?; // Initialize deployment type based on flags - match deployment_type { Some(deployment) => { match deployment { @@ -184,6 +208,29 @@ pub async fn run( Ok(()) } +fn process_template( + template_str: &str, + project_dir: &Path, + project_name: &str, +) -> eyre::Result<()> { + // Parse template source + let template_source = TemplateSource::parse(template_str)?; + + // Prepare template variables for rendering + let mut variables = HashMap::new(); + variables.insert("project_name".to_string(), project_name.to_string()); + + // Fetch raw template from git (with caching) + print_status("TEMPLATE", &format!("Resolving template: {}", template_str)); + let raw_template_path = TemplateFetcher::fetch(&template_source)?; + + // Render and apply template to project directory. + print_status("TEMPLATE", "Rendering template..."); + TemplateProcessor::render_to_dir(&raw_template_path, project_dir, &variables)?; + + Ok(()) +} + fn create_project_structure(project_dir: &Path, queries_path: &str) -> Result<()> { // Create directories fs::create_dir_all(project_dir.join(".helix"))?; diff --git a/helix-cli/src/commands/mod.rs b/helix-cli/src/commands/mod.rs index 9fe37fc6..e012760e 100644 --- a/helix-cli/src/commands/mod.rs +++ b/helix-cli/src/commands/mod.rs @@ -14,4 +14,5 @@ pub mod push; pub mod start; pub mod status; pub mod stop; +pub mod templates; pub mod update; diff --git a/helix-cli/src/commands/templates/fetcher.rs b/helix-cli/src/commands/templates/fetcher.rs new file mode 100644 index 00000000..bf5f4f5d --- /dev/null +++ b/helix-cli/src/commands/templates/fetcher.rs @@ -0,0 +1,279 @@ +use super::TemplateSource; +use crate::project::get_helix_cache_dir; +use crate::utils::print_status; +use eyre::Result; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +/// Result of cache validation check +enum CacheStatus { + /// Cache is valid and matches upstream + Valid(PathBuf), + /// Cache is stale or doesn't exist + Invalid, + /// Network error occurred, but cache is available + NetworkError(PathBuf), + /// Network error and no cache available + NetworkErrorNoCache, +} + +/// Manages fetching and caching of templates from Git repositories +pub struct TemplateFetcher; + +impl TemplateFetcher { + /// Fetch a template from the given source, using cache when available + /// Returns a path to the unrendered cached template repository ready for rendering + pub fn fetch(source: &TemplateSource) -> Result { + Self::check_git_available()?; + + let cache_status = Self::check_cache_validity(source)?; + + match cache_status { + CacheStatus::Valid(path) => { + print_status("TEMPLATE", "Using cached template (up to date)"); + Ok(path) + } + CacheStatus::Invalid => { + print_status("TEMPLATE", "Fetching template from git..."); + Self::fetch_and_cache(source) + } + CacheStatus::NetworkError(path) => { + print_status( + "WARNING", + "Network error, using cached template (may be outdated)", + ); + Ok(path) + } + CacheStatus::NetworkErrorNoCache => Err(eyre::eyre!( + "Cannot fetch template: network error and no cache available. \ + Please check your internet connection." + )), + } + } + + /// Check if cache is valid by comparing with upstream commit hash + fn check_cache_validity(source: &TemplateSource) -> Result { + let git_url = source.to_git_url(); + let url_hash = Self::hash_url(&git_url); + let cache_base = get_helix_cache_dir()?.join("templates").join(&url_hash); + + match Self::resolve_commit_hash(source) { + Ok(Some(latest_commit)) => { + let cache_path = cache_base.join(&latest_commit); + + if cache_path.exists() { + return Ok(CacheStatus::Valid(cache_path)); + } + + Ok(CacheStatus::Invalid) + } + Ok(None) => { + if let Some(cached_commit) = Self::get_latest_cached_commit(&cache_base)? { + let cache_path = cache_base.join(&cached_commit); + return Ok(CacheStatus::NetworkError(cache_path)); + } + + Ok(CacheStatus::NetworkErrorNoCache) + } + Err(e) => Err(e), + } + } + + fn resolve_commit_hash(source: &TemplateSource) -> Result> { + let git_url = source.to_git_url(); + let git_ref = source.git_ref().unwrap_or("HEAD"); + + let output = Command::new("git") + .env("GIT_TERMINAL_PROMPT", "0") + .arg("ls-remote") + .arg(&git_url) + .arg(git_ref) + .output() + .map_err(|e| eyre::eyre!("Failed to execute git ls-remote: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Self::parse_git_error(&stderr, &git_url); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let commit_hash = stdout + .split_whitespace() + .next() + .ok_or_else(|| eyre::eyre!("Invalid git ls-remote output"))? + .to_string(); + + Ok(Some(commit_hash)) + } + + /// Fetch template from git and cache the raw unrendered repository + fn fetch_and_cache(source: &TemplateSource) -> Result { + let git_url = source.to_git_url(); + + let commit_hash = Self::resolve_commit_hash(source)? + .ok_or_else(|| eyre::eyre!("Network error: cannot fetch template"))?; + + let base_cache_dir = Self::get_cache_dir(&git_url)?; + let final_cache_path = base_cache_dir.join(&commit_hash); + + if final_cache_path.exists() { + return Ok(final_cache_path); + } + + // Create temporary directory in the same parent to ensure atomic cache. + let temp_dir = TempDir::new_in(&base_cache_dir) + .map_err(|e| eyre::eyre!("Failed to create temporary cache directory: {}", e))?; + + // Clone the template directly into the temp directory + Self::clone_to_temp(source, temp_dir.path())?; + + // Validate the template + Self::validate_template(temp_dir.path())?; + + // Keep the temporary directory in-place + let temp_path = temp_dir.keep(); + + // Attempt atomic rename + std::fs::rename(temp_path, &final_cache_path)?; + + Ok(final_cache_path) + } + + /// Get or create the cache base directory for a Git URL + fn get_cache_dir(git_url: &str) -> Result { + let url_hash = Self::hash_url(git_url); + let cache_base = get_helix_cache_dir()?.join("templates").join(&url_hash); + std::fs::create_dir_all(&cache_base)?; + Ok(cache_base) + } + + fn clone_to_temp(source: &TemplateSource, temp_dir: &Path) -> Result<()> { + let git_url = source.to_git_url(); + let mut cmd = Command::new("git"); + cmd.env("GIT_TERMINAL_PROMPT", "0") + .arg("clone") + .arg("--depth") + .arg("1"); + + if let Some(git_ref) = source.git_ref() { + cmd.arg("--branch").arg(git_ref); + } + + cmd.arg(&git_url).arg(temp_dir); + + let output = cmd + .output() + .map_err(|e| eyre::eyre!("Failed to execute git clone: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Self::parse_git_error(&stderr, &git_url).map(|_| ()); + } + + Ok(()) + } + + fn validate_template(template_path: &Path) -> Result<()> { + if !template_path.join("helix.toml").exists() + && !template_path.join("helix.toml.hbs").exists() + { + return Err(eyre::eyre!("Invalid template: missing helix.toml")); + } + Ok(()) + } + + /// Hash a URL to create a directory name + fn hash_url(url: &str) -> String { + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + let hash = hasher.finish(); + format!("{:x}", hash) + } + + /// Get the most recent cached commit for a URL + fn get_latest_cached_commit(url_cache_dir: &Path) -> Result> { + if !url_cache_dir.exists() { + return Ok(None); + } + + // Find the most recently modified commit directory + let mut entries: Vec<_> = std::fs::read_dir(url_cache_dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .collect(); + + entries.sort_by_key(|e| { + e.metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + + if let Some(latest) = entries.last() + && let Some(name) = latest.file_name().to_str() + { + return Ok(Some(name.to_string())); + } + + Ok(None) + } + + fn check_git_available() -> Result<()> { + let output = Command::new("git") + .env("GIT_TERMINAL_PROMPT", "0") + .arg("--version") + .output() + .map_err(|_| { + eyre::eyre!("git command not found. Please install git to use templates.") + })?; + + if !output.status.success() { + return Err(eyre::eyre!("git command is not working properly")); + } + + Ok(()) + } + + fn parse_git_error(stderr: &str, git_url: &str) -> Result> { + if stderr.contains("Could not resolve host") + || stderr.contains("Connection timed out") + || stderr.contains("unable to access") + { + return Ok(None); + } + + if stderr.contains("Repository not found") + || stderr.contains("not found") + || stderr.contains("could not read Username") + || stderr.contains("Authentication failed") + || stderr.contains("denied") + { + return Err(eyre::eyre!("Template '{}' not found or private", git_url)); + } + + Err(eyre::eyre!("Git operation failed: {}", stderr)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_hash_consistent() { + let url = "https://github.com/HelixDB/basic"; + assert_eq!( + TemplateFetcher::hash_url(url), + TemplateFetcher::hash_url(url) + ); + } + + #[test] + fn test_url_hash_unique() { + let hash1 = TemplateFetcher::hash_url("https://github.com/HelixDB/basic"); + let hash2 = TemplateFetcher::hash_url("https://github.com/HelixDB/advanced"); + assert_ne!(hash1, hash2); + } +} diff --git a/helix-cli/src/commands/templates/mod.rs b/helix-cli/src/commands/templates/mod.rs new file mode 100644 index 00000000..80af6b06 --- /dev/null +++ b/helix-cli/src/commands/templates/mod.rs @@ -0,0 +1,117 @@ +pub mod fetcher; +pub mod processor; + +pub use fetcher::TemplateFetcher; +pub use processor::TemplateProcessor; + +const OFFICIAL_TEMPLATES_ORG: &str = "HelixDB"; + +/// Represents different ways to reference a template +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateSource { + Official { + name: String, + git_ref: Option, + }, + GitUrl { + url: String, + git_ref: Option, + }, +} + +impl TemplateSource { + pub fn parse(s: &str) -> eyre::Result { + let s = s.trim(); + + if s.is_empty() { + return Err(eyre::eyre!("Template name cannot be empty")); + } + + if s.starts_with("https://") || s.starts_with("http://") { + let (url, git_ref) = Self::split_at_ref(s, 8); + return Ok(TemplateSource::GitUrl { url, git_ref }); + } + + if s.starts_with("git@") { + let (url, git_ref) = if s.matches('@').count() > 1 { + Self::split_at_ref(s, 0) + } else { + (s.to_string(), None) + }; + return Ok(TemplateSource::GitUrl { url, git_ref }); + } + + let (name, git_ref) = Self::split_at_ref(s, 0); + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(eyre::eyre!("Invalid template name")); + } + + Ok(TemplateSource::Official { name, git_ref }) + } + + // split the git url and git branch from the string + fn split_at_ref(s: &str, skip: usize) -> (String, Option) { + if let Some((base, git_ref)) = s.rsplit_once('@') { + if skip > 0 && !base[skip..].contains('/') { + return (s.to_string(), None); + } + (base.to_string(), Some(git_ref.to_string())) + } else { + (s.to_string(), None) + } + } + + pub fn to_git_url(&self) -> String { + match self { + TemplateSource::Official { name, .. } => { + format!("https://github.com/{}/{}", OFFICIAL_TEMPLATES_ORG, name) + } + TemplateSource::GitUrl { url, .. } => url.clone(), + } + } + + pub fn git_ref(&self) -> Option<&str> { + match self { + TemplateSource::Official { git_ref, .. } | TemplateSource::GitUrl { git_ref, .. } => { + git_ref.as_deref() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_official() { + let src = TemplateSource::parse("basic@v1.0").unwrap(); + assert_eq!( + src.to_git_url(), + format!("https://github.com/{}/basic", OFFICIAL_TEMPLATES_ORG) + ); + assert_eq!(src.git_ref(), Some("v1.0")); + } + + #[test] + fn test_parse_https_url() { + let src = TemplateSource::parse("https://github.com/user/repo@main").unwrap(); + assert_eq!(src.to_git_url(), "https://github.com/user/repo"); + assert_eq!(src.git_ref(), Some("main")); + } + + #[test] + fn test_parse_ssh_url() { + let src = TemplateSource::parse("git@github.com:user/repo.git@v2").unwrap(); + assert_eq!(src.git_ref(), Some("v2")); + } + + #[test] + fn test_parse_invalid() { + assert!(TemplateSource::parse("").is_err()); + assert!(TemplateSource::parse("bad/name").is_err()); + } +} diff --git a/helix-cli/src/commands/templates/processor.rs b/helix-cli/src/commands/templates/processor.rs new file mode 100644 index 00000000..cc82bcd6 --- /dev/null +++ b/helix-cli/src/commands/templates/processor.rs @@ -0,0 +1,88 @@ +use eyre::Result; +use handlebars::Handlebars; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Processes templates and applies variable substitution +pub struct TemplateProcessor; + +impl TemplateProcessor { + /// Render template from cache to destination + pub fn render_to_dir( + template_dir: &Path, + cache_dir: &Path, + variables: &HashMap, + ) -> Result<()> { + // Create Handlebars instance + let hbs = Handlebars::new(); + Self::render_dir_recursive(template_dir, cache_dir, &hbs, variables)?; + + Ok(()) + } + + /// Recursively render directory with variable substitution + fn render_dir_recursive( + src: &Path, + dst: &Path, + hbs: &Handlebars, + variables: &HashMap, + ) -> Result<()> { + fs::create_dir_all(dst)?; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + // Skip hidden files that start with dot (except for .gitignore) + if file_name_str.starts_with('.') && file_name_str != ".gitignore" { + continue; + } + + // Skip symlinks + if path.is_symlink() { + continue; + } + + match path.is_dir() { + true => { + let dest_dir = dst.join(&file_name); + Self::render_dir_recursive(&path, &dest_dir, hbs, variables)?; + } + false => { + let dest_file = dst.join(&file_name); + match file_name_str.ends_with(".hbs") { + true => Self::render_template_file(&path, &dest_file, hbs, variables)?, + false => { + fs::copy(&path, &dest_file)?; + } + } + } + } + } + + Ok(()) + } + + /// Render a .hbs template file to destination (removing .hbs extension) + fn render_template_file( + src: &Path, + dest: &Path, + hbs: &Handlebars, + variables: &HashMap, + ) -> Result<()> { + let content = fs::read_to_string(src)?; + + let rendered = hbs + .render_template(&content, variables) + .map_err(|e| eyre::eyre!("Template render error: {}", e))?; + + let dest_without_hbs = dest.with_extension(""); + + fs::write(&dest_without_hbs, rendered)?; + + Ok(()) + } +} diff --git a/helix-cli/src/main.rs b/helix-cli/src/main.rs index 43082630..5de6c528 100644 --- a/helix-cli/src/main.rs +++ b/helix-cli/src/main.rs @@ -27,8 +27,9 @@ enum Commands { #[clap(short, long)] path: Option, - #[clap(short, long, default_value = "empty")] - template: String, + /// Template to use for project initialization + #[clap(short, long)] + template: Option, /// Queries directory path (defaults to ./db/) #[clap(short = 'q', long = "queries-path", default_value = "./db/")]