From a7f3d55e9931c0a8867747478a0e8c9e9f39e46b Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 14:00:21 +0300 Subject: [PATCH 01/10] chore: add handlebars to process templates --- Cargo.lock | 104 +++++++++++++++++++++++++++++++++++++++++++ helix-cli/Cargo.toml | 2 + 2 files changed, 106 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a0cc43d9d..7de172b0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,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" @@ -708,6 +743,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" @@ -1123,6 +1189,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" @@ -1207,6 +1289,7 @@ dependencies = [ "eyre", "flume", "futures-util", + "handlebars", "helix-db", "helix-metrics", "iota", @@ -1629,6 +1712,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" @@ -2086,6 +2175,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" diff --git a/helix-cli/Cargo.toml b/helix-cli/Cargo.toml index ac655e5a1..59e8bc1ae 100644 --- a/helix-cli/Cargo.toml +++ b/helix-cli/Cargo.toml @@ -25,6 +25,8 @@ dotenvy = "0.15.7" tokio-tungstenite = "0.27.0" futures-util = "0.3.31" regex = "1.11.2" +handlebars = "6.3.2" + [[bin]] name = "helix" path = "src/main.rs" From 8fd51006a7f3461235e6a06701e23b9ba13cb6d5 Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 14:05:41 +0300 Subject: [PATCH 02/10] feat(cli): add template system with cache and templating --- helix-cli/src/commands/mod.rs | 1 + helix-cli/src/commands/templates/fetcher.rs | 297 ++++++++++++++++++ helix-cli/src/commands/templates/mod.rs | 117 +++++++ helix-cli/src/commands/templates/processor.rs | 122 +++++++ 4 files changed, 537 insertions(+) create mode 100644 helix-cli/src/commands/templates/fetcher.rs create mode 100644 helix-cli/src/commands/templates/mod.rs create mode 100644 helix-cli/src/commands/templates/processor.rs diff --git a/helix-cli/src/commands/mod.rs b/helix-cli/src/commands/mod.rs index 9fe37fc62..e012760e4 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 000000000..b1c6f2c46 --- /dev/null +++ b/helix-cli/src/commands/templates/fetcher.rs @@ -0,0 +1,297 @@ +use super::{TemplateProcessor, TemplateSource}; +use crate::project::get_helix_cache_dir; +use crate::utils::print_status; +use eyre::Result; +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// 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 a fully rendered template ready to copy + pub fn fetch(source: &TemplateSource, variables: &HashMap) -> 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_render(source, variables) + } + 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)); + } + return 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)); + } + return Ok(CacheStatus::NetworkErrorNoCache); + } + Err(e) => { + return 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); + + 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 it's private. Check the name or URL.", + git_url + )); + } + + return Err(eyre::eyre!("Failed to resolve template: {}", stderr)); + } + + 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, render it, and cache the rendered version + fn fetch_and_render( + source: &TemplateSource, + variables: &HashMap, + ) -> 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 temp_dir = Self::create_temp_dir()?; + + Self::clone_to_temp(source, &temp_dir)?; + + let cache_path = Self::get_cache_path_for_commit(&git_url, &commit_hash)?; + + print_status("TEMPLATE", "Rendering template..."); + TemplateProcessor::render_to_cache(&temp_dir, &cache_path, variables)?; + + std::fs::remove_dir_all(&temp_dir).ok(); + + Ok(cache_path) + } + + 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() { + // TODO: duplicate logic move to separate function + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.contains("could not read Username") + || stderr.contains("Authentication failed") + || stderr.contains("denied") + || stderr.contains("not found") + { + return Err(eyre::eyre!( + "Template '{}' not found or it's private. Check the name or URL.", + git_url + )); + } + + return Err(eyre::eyre!("Git clone failed: {}", stderr)); + } + + Ok(()) + } + + /// Get cache path for a specific commit hash + fn get_cache_path_for_commit(url: &str, commit_hash: &str) -> Result { + let cache_base = get_helix_cache_dir()?; + let templates_dir = cache_base.join("templates"); + let url_hash = Self::hash_url(url); + + let cache_path = templates_dir.join(url_hash).join(commit_hash); + + Ok(cache_path) + } + + /// 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() { + if let Some(name) = latest.file_name().to_str() { + return Ok(Some(name.to_string())); + } + } + + Ok(None) + } + + /// Create a temporary directory for cloning + fn create_temp_dir() -> Result { + let temp_base = std::env::temp_dir(); + let unique_name = format!("helix-template-{}", uuid::Uuid::new_v4()); + let temp_dir = temp_base.join(unique_name); + std::fs::create_dir_all(&temp_dir)?; + Ok(temp_dir) + } + + 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_hash_consistent() { + let url = "https://github.com/helix-db/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/helix-db/basic"); + let hash2 = TemplateFetcher::hash_url("https://github.com/helix-db/advanced"); + assert_ne!(hash1, hash2); + } + + #[test] + fn test_cache_path_structure() { + let path = TemplateFetcher::get_cache_path_for_commit( + "https://github.com/helix-db/basic", + "abc123", + ) + .unwrap(); + assert!(path.to_string_lossy().contains("templates")); + assert!(path.to_string_lossy().ends_with("abc123")); + } +} diff --git a/helix-cli/src/commands/templates/mod.rs b/helix-cli/src/commands/templates/mod.rs new file mode 100644 index 000000000..1cb03a189 --- /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 = "helix-db"; + +/// 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 000000000..53fc090a9 --- /dev/null +++ b/helix-cli/src/commands/templates/processor.rs @@ -0,0 +1,122 @@ +use crate::utils::print_status; +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 { + /// Copy already-rendered template files from cache to destination + pub fn process(cache_dir: &Path, project_dir: &Path) -> Result<()> { + print_status("TEMPLATE", "Copying template files..."); + + Self::copy_to_dir(cache_dir, project_dir)?; + + print_status("TEMPLATE", "Template applied successfully"); + + Ok(()) + } + + /// Render template from source to cache directory + pub fn render_to_cache( + 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(()) + } + + /// copy cached template files to destination + fn copy_to_dir(src: &Path, dst: &Path) -> 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 .git directory + if file_name_str == ".git" { + continue; + } + + if path.is_dir() { + let dest_dir = dst.join(&file_name); + Self::copy_to_dir(&path, &dest_dir)?; + } else { + let dest_file = dst.join(&file_name); + fs::copy(&path, &dest_file)?; + } + } + + 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; + } + + 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(()) + } +} From d4bdc19f4a572ab414e5ba8b5e360ecee1af23c6 Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 14:32:20 +0300 Subject: [PATCH 03/10] feat(cli): integrate template to the cli --- helix-cli/src/commands/init.rs | 50 ++++++++++++++++++++- helix-cli/src/commands/templates/fetcher.rs | 20 ++++----- helix-cli/src/main.rs | 5 ++- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/helix-cli/src/commands/init.rs b/helix-cli/src/commands/init.rs index 8fe1aceef..07c52e448 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,28 @@ 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 + let mut variables = HashMap::new(); + variables.insert("project_name".to_string(), project_name.to_string()); + + // Fetch and render template from git (with caching) + print_status("TEMPLATE", &format!("Resolving template: {}", template_str)); + let cache_dir = TemplateFetcher::fetch(&template_source, &variables)?; + + // Copy rendered template to project directory + TemplateProcessor::process(&cache_dir, project_dir)?; + + 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/templates/fetcher.rs b/helix-cli/src/commands/templates/fetcher.rs index b1c6f2c46..4883daa9e 100644 --- a/helix-cli/src/commands/templates/fetcher.rs +++ b/helix-cli/src/commands/templates/fetcher.rs @@ -2,8 +2,8 @@ use super::{TemplateProcessor, TemplateSource}; use crate::project::get_helix_cache_dir; use crate::utils::print_status; use eyre::Result; -use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -67,18 +67,18 @@ impl TemplateFetcher { if cache_path.exists() { return Ok(CacheStatus::Valid(cache_path)); } - return Ok(CacheStatus::Invalid); + + 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)); } - return Ok(CacheStatus::NetworkErrorNoCache); - } - Err(e) => { - return Err(e); + + Ok(CacheStatus::NetworkErrorNoCache) } + Err(e) => Err(e), } } @@ -229,10 +229,10 @@ impl TemplateFetcher { .unwrap_or(std::time::SystemTime::UNIX_EPOCH) }); - if let Some(latest) = entries.last() { - if let Some(name) = latest.file_name().to_str() { - return Ok(Some(name.to_string())); - } + if let Some(latest) = entries.last() + && let Some(name) = latest.file_name().to_str() + { + return Ok(Some(name.to_string())); } Ok(None) diff --git a/helix-cli/src/main.rs b/helix-cli/src/main.rs index 18e279fd1..41139d951 100644 --- a/helix-cli/src/main.rs +++ b/helix-cli/src/main.rs @@ -26,8 +26,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/")] From b1d6deaf589b982ceed4861d0c817036bd56bb61 Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 14:49:13 +0300 Subject: [PATCH 04/10] feat(cli): add template validation --- helix-cli/src/commands/templates/fetcher.rs | 64 ++++++++++----------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/helix-cli/src/commands/templates/fetcher.rs b/helix-cli/src/commands/templates/fetcher.rs index 4883daa9e..61a65fc90 100644 --- a/helix-cli/src/commands/templates/fetcher.rs +++ b/helix-cli/src/commands/templates/fetcher.rs @@ -96,27 +96,7 @@ impl TemplateFetcher { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - - 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 it's private. Check the name or URL.", - git_url - )); - } - - return Err(eyre::eyre!("Failed to resolve template: {}", stderr)); + return Self::parse_git_error(&stderr, &git_url); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -143,6 +123,8 @@ impl TemplateFetcher { Self::clone_to_temp(source, &temp_dir)?; + Self::validate_template(&temp_dir)?; + let cache_path = Self::get_cache_path_for_commit(&git_url, &commit_hash)?; print_status("TEMPLATE", "Rendering template..."); @@ -172,23 +154,17 @@ impl TemplateFetcher { .map_err(|e| eyre::eyre!("Failed to execute git clone: {}", e))?; if !output.status.success() { - // TODO: duplicate logic move to separate function let stderr = String::from_utf8_lossy(&output.stderr); + return Self::parse_git_error(&stderr, &git_url).map(|_| ()); + } - if stderr.contains("could not read Username") - || stderr.contains("Authentication failed") - || stderr.contains("denied") - || stderr.contains("not found") - { - return Err(eyre::eyre!( - "Template '{}' not found or it's private. Check the name or URL.", - git_url - )); - } + Ok(()) + } - return Err(eyre::eyre!("Git clone failed: {}", stderr)); + fn validate_template(template_path: &Path) -> Result<()> { + if !template_path.join("helix.toml").exists() { + return Err(eyre::eyre!("Invalid template: missing helix.toml")); } - Ok(()) } @@ -262,6 +238,26 @@ impl TemplateFetcher { 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)] From 0d995117390cdde14426d0245f368b3eabe3733c Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 14:50:31 +0300 Subject: [PATCH 05/10] fix(cli): change template org name --- helix-cli/src/commands/templates/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helix-cli/src/commands/templates/mod.rs b/helix-cli/src/commands/templates/mod.rs index 1cb03a189..80af6b065 100644 --- a/helix-cli/src/commands/templates/mod.rs +++ b/helix-cli/src/commands/templates/mod.rs @@ -4,7 +4,7 @@ pub mod processor; pub use fetcher::TemplateFetcher; pub use processor::TemplateProcessor; -const OFFICIAL_TEMPLATES_ORG: &str = "helix-db"; +const OFFICIAL_TEMPLATES_ORG: &str = "HelixDB"; /// Represents different ways to reference a template #[derive(Debug, Clone, PartialEq, Eq)] From f132a7207010b5fa53460706db7fb2bb13f3fb87 Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 15:13:08 +0300 Subject: [PATCH 06/10] fix(cli): handle helix.toml.hbs in template validation --- helix-cli/src/commands/templates/fetcher.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helix-cli/src/commands/templates/fetcher.rs b/helix-cli/src/commands/templates/fetcher.rs index 61a65fc90..429c0024c 100644 --- a/helix-cli/src/commands/templates/fetcher.rs +++ b/helix-cli/src/commands/templates/fetcher.rs @@ -162,7 +162,9 @@ impl TemplateFetcher { } fn validate_template(template_path: &Path) -> Result<()> { - if !template_path.join("helix.toml").exists() { + if !template_path.join("helix.toml").exists() + && !template_path.join("helix.toml.hbs").exists() + { return Err(eyre::eyre!("Invalid template: missing helix.toml")); } Ok(()) From 723a8865555d0f2e007747fb319184238d811a76 Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 19:56:25 +0300 Subject: [PATCH 07/10] fix(cli): skip symlinks --- helix-cli/src/commands/templates/processor.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/helix-cli/src/commands/templates/processor.rs b/helix-cli/src/commands/templates/processor.rs index 53fc090a9..74c53d992 100644 --- a/helix-cli/src/commands/templates/processor.rs +++ b/helix-cli/src/commands/templates/processor.rs @@ -80,6 +80,11 @@ impl TemplateProcessor { continue; } + // Skip symlinks + if path.is_symlink() { + continue; + } + match path.is_dir() { true => { let dest_dir = dst.join(&file_name); From 86ee04e5a6d5fffb2b3a19d347df29230b25cb16 Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 21:48:17 +0300 Subject: [PATCH 08/10] chore(cli): add tempfile dep --- Cargo.lock | 5 +++-- helix-cli/Cargo.toml | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7de172b0c..601cc2fc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1298,6 +1298,7 @@ dependencies = [ "self_update", "serde", "serde_json", + "tempfile", "tokio", "tokio-tungstenite", "toml", @@ -4115,9 +4116,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 59e8bc1ae..145161f6a 100644 --- a/helix-cli/Cargo.toml +++ b/helix-cli/Cargo.toml @@ -26,6 +26,7 @@ tokio-tungstenite = "0.27.0" futures-util = "0.3.31" regex = "1.11.2" handlebars = "6.3.2" +tempfile = "3.23.0" [[bin]] name = "helix" From db4d905cee59d2c2fc6b26ad1a69a1d9ae27c35d Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 29 Oct 2025 21:49:02 +0300 Subject: [PATCH 09/10] fix(cli): atomic template rendering and caching --- helix-cli/src/commands/templates/fetcher.rs | 76 ++++++++++----------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/helix-cli/src/commands/templates/fetcher.rs b/helix-cli/src/commands/templates/fetcher.rs index 429c0024c..ea51041b2 100644 --- a/helix-cli/src/commands/templates/fetcher.rs +++ b/helix-cli/src/commands/templates/fetcher.rs @@ -7,6 +7,7 @@ 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 { @@ -119,20 +120,44 @@ impl TemplateFetcher { let commit_hash = Self::resolve_commit_hash(source)? .ok_or_else(|| eyre::eyre!("Network error: cannot fetch template"))?; - let temp_dir = Self::create_temp_dir()?; + let template_dir = TempDir::new()?; - Self::clone_to_temp(source, &temp_dir)?; + // Clone the template to a temporary directory + Self::clone_to_temp(source, template_dir.path())?; - Self::validate_template(&temp_dir)?; + // Validate the template + Self::validate_template(template_dir.path())?; - let cache_path = Self::get_cache_path_for_commit(&git_url, &commit_hash)?; + // Render the templates and save to cache + Self::render_and_cache(template_dir.path(), &git_url, &commit_hash, variables) + } + + /// Atomically save rendered template to cache + fn render_and_cache( + template_dir: &Path, + git_url: &str, + commit_hash: &str, + variables: &HashMap, + ) -> Result { + let base_cache_dir = Self::get_cache_dir(git_url)?; + let temp_cache_dir = TempDir::new_in(&base_cache_dir)?; print_status("TEMPLATE", "Rendering template..."); - TemplateProcessor::render_to_cache(&temp_dir, &cache_path, variables)?; + TemplateProcessor::render_to_cache(template_dir, temp_cache_dir.path(), variables)?; - std::fs::remove_dir_all(&temp_dir).ok(); + let final_cache_path = base_cache_dir.join(commit_hash); + let temp_path = temp_cache_dir.keep(); + std::fs::rename(&temp_path, &final_cache_path)?; - Ok(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<()> { @@ -170,17 +195,6 @@ impl TemplateFetcher { Ok(()) } - /// Get cache path for a specific commit hash - fn get_cache_path_for_commit(url: &str, commit_hash: &str) -> Result { - let cache_base = get_helix_cache_dir()?; - let templates_dir = cache_base.join("templates"); - let url_hash = Self::hash_url(url); - - let cache_path = templates_dir.join(url_hash).join(commit_hash); - - Ok(cache_path) - } - /// Hash a URL to create a directory name fn hash_url(url: &str) -> String { let mut hasher = DefaultHasher::new(); @@ -216,15 +230,6 @@ impl TemplateFetcher { Ok(None) } - /// Create a temporary directory for cloning - fn create_temp_dir() -> Result { - let temp_base = std::env::temp_dir(); - let unique_name = format!("helix-template-{}", uuid::Uuid::new_v4()); - let temp_dir = temp_base.join(unique_name); - std::fs::create_dir_all(&temp_dir)?; - Ok(temp_dir) - } - fn check_git_available() -> Result<()> { let output = Command::new("git") .env("GIT_TERMINAL_PROMPT", "0") @@ -268,7 +273,7 @@ mod tests { #[test] fn test_url_hash_consistent() { - let url = "https://github.com/helix-db/basic"; + let url = "https://github.com/HelixDB/basic"; assert_eq!( TemplateFetcher::hash_url(url), TemplateFetcher::hash_url(url) @@ -277,19 +282,8 @@ mod tests { #[test] fn test_url_hash_unique() { - let hash1 = TemplateFetcher::hash_url("https://github.com/helix-db/basic"); - let hash2 = TemplateFetcher::hash_url("https://github.com/helix-db/advanced"); + let hash1 = TemplateFetcher::hash_url("https://github.com/HelixDB/basic"); + let hash2 = TemplateFetcher::hash_url("https://github.com/HelixDB/advanced"); assert_ne!(hash1, hash2); } - - #[test] - fn test_cache_path_structure() { - let path = TemplateFetcher::get_cache_path_for_commit( - "https://github.com/helix-db/basic", - "abc123", - ) - .unwrap(); - assert!(path.to_string_lossy().contains("templates")); - assert!(path.to_string_lossy().ends_with("abc123")); - } } From ddb80db900b107c69de185e6253c7543bf6a611a Mon Sep 17 00:00:00 2001 From: ishaksebsib Date: Wed, 5 Nov 2025 15:54:18 +0300 Subject: [PATCH 10/10] feat(cli): update unrendered templates to be cached --- helix-cli/src/commands/init.rs | 11 ++-- helix-cli/src/commands/templates/fetcher.rs | 56 ++++++++----------- helix-cli/src/commands/templates/processor.rs | 43 +------------- 3 files changed, 31 insertions(+), 79 deletions(-) diff --git a/helix-cli/src/commands/init.rs b/helix-cli/src/commands/init.rs index 07c52e448..1fac47b28 100644 --- a/helix-cli/src/commands/init.rs +++ b/helix-cli/src/commands/init.rs @@ -216,16 +216,17 @@ fn process_template( // Parse template source let template_source = TemplateSource::parse(template_str)?; - // Prepare template variables + // Prepare template variables for rendering let mut variables = HashMap::new(); variables.insert("project_name".to_string(), project_name.to_string()); - // Fetch and render template from git (with caching) + // Fetch raw template from git (with caching) print_status("TEMPLATE", &format!("Resolving template: {}", template_str)); - let cache_dir = TemplateFetcher::fetch(&template_source, &variables)?; + let raw_template_path = TemplateFetcher::fetch(&template_source)?; - // Copy rendered template to project directory - TemplateProcessor::process(&cache_dir, project_dir)?; + // Render and apply template to project directory. + print_status("TEMPLATE", "Rendering template..."); + TemplateProcessor::render_to_dir(&raw_template_path, project_dir, &variables)?; Ok(()) } diff --git a/helix-cli/src/commands/templates/fetcher.rs b/helix-cli/src/commands/templates/fetcher.rs index ea51041b2..bf5f4f5dd 100644 --- a/helix-cli/src/commands/templates/fetcher.rs +++ b/helix-cli/src/commands/templates/fetcher.rs @@ -1,8 +1,7 @@ -use super::{TemplateProcessor, TemplateSource}; +use super::TemplateSource; use crate::project::get_helix_cache_dir; use crate::utils::print_status; use eyre::Result; -use std::collections::HashMap; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; @@ -26,8 +25,8 @@ pub struct TemplateFetcher; impl TemplateFetcher { /// Fetch a template from the given source, using cache when available - /// Returns a path to a fully rendered template ready to copy - pub fn fetch(source: &TemplateSource, variables: &HashMap) -> Result { + /// 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)?; @@ -39,7 +38,7 @@ impl TemplateFetcher { } CacheStatus::Invalid => { print_status("TEMPLATE", "Fetching template from git..."); - Self::fetch_and_render(source, variables) + Self::fetch_and_cache(source) } CacheStatus::NetworkError(path) => { print_status( @@ -110,44 +109,35 @@ impl TemplateFetcher { Ok(Some(commit_hash)) } - /// Fetch template, render it, and cache the rendered version - fn fetch_and_render( - source: &TemplateSource, - variables: &HashMap, - ) -> Result { + /// 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 template_dir = TempDir::new()?; + let base_cache_dir = Self::get_cache_dir(&git_url)?; + let final_cache_path = base_cache_dir.join(&commit_hash); - // Clone the template to a temporary directory - Self::clone_to_temp(source, template_dir.path())?; + 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(template_dir.path())?; + Self::validate_template(temp_dir.path())?; - // Render the templates and save to cache - Self::render_and_cache(template_dir.path(), &git_url, &commit_hash, variables) - } + // Keep the temporary directory in-place + let temp_path = temp_dir.keep(); - /// Atomically save rendered template to cache - fn render_and_cache( - template_dir: &Path, - git_url: &str, - commit_hash: &str, - variables: &HashMap, - ) -> Result { - let base_cache_dir = Self::get_cache_dir(git_url)?; - let temp_cache_dir = TempDir::new_in(&base_cache_dir)?; - - print_status("TEMPLATE", "Rendering template..."); - TemplateProcessor::render_to_cache(template_dir, temp_cache_dir.path(), variables)?; - - let final_cache_path = base_cache_dir.join(commit_hash); - let temp_path = temp_cache_dir.keep(); - std::fs::rename(&temp_path, &final_cache_path)?; + // Attempt atomic rename + std::fs::rename(temp_path, &final_cache_path)?; Ok(final_cache_path) } diff --git a/helix-cli/src/commands/templates/processor.rs b/helix-cli/src/commands/templates/processor.rs index 74c53d992..cc82bcd69 100644 --- a/helix-cli/src/commands/templates/processor.rs +++ b/helix-cli/src/commands/templates/processor.rs @@ -1,4 +1,3 @@ -use crate::utils::print_status; use eyre::Result; use handlebars::Handlebars; use std::collections::HashMap; @@ -9,19 +8,8 @@ use std::path::Path; pub struct TemplateProcessor; impl TemplateProcessor { - /// Copy already-rendered template files from cache to destination - pub fn process(cache_dir: &Path, project_dir: &Path) -> Result<()> { - print_status("TEMPLATE", "Copying template files..."); - - Self::copy_to_dir(cache_dir, project_dir)?; - - print_status("TEMPLATE", "Template applied successfully"); - - Ok(()) - } - - /// Render template from source to cache directory - pub fn render_to_cache( + /// Render template from cache to destination + pub fn render_to_dir( template_dir: &Path, cache_dir: &Path, variables: &HashMap, @@ -33,33 +21,6 @@ impl TemplateProcessor { Ok(()) } - /// copy cached template files to destination - fn copy_to_dir(src: &Path, dst: &Path) -> 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 .git directory - if file_name_str == ".git" { - continue; - } - - if path.is_dir() { - let dest_dir = dst.join(&file_name); - Self::copy_to_dir(&path, &dest_dir)?; - } else { - let dest_file = dst.join(&file_name); - fs::copy(&path, &dest_file)?; - } - } - - Ok(()) - } - /// Recursively render directory with variable substitution fn render_dir_recursive( src: &Path,