diff --git a/crates/typedialog-prov-gen/Cargo.toml b/crates/typedialog-prov-gen/Cargo.toml new file mode 100644 index 0000000..6c10935 --- /dev/null +++ b/crates/typedialog-prov-gen/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "typedialog-prov-gen" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true +license.workspace = true + +[dependencies] +# Workspace dependencies +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +toml = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +clap = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true } +futures = { workspace = true } +tera = { workspace = true } +chrono = { workspace = true } +rand = { workspace = true } +tempfile = { workspace = true } +dirs = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Internal dependencies (workspace path) +typedialog-core = { path = "../typedialog-core", features = ["ai_backend"] } +typedialog-ai = { path = "../typedialog-ai" } + +# Additional workspace dependencies +cargo_toml = { workspace = true } +uuid = { workspace = true } +regex = { workspace = true } +strum = { workspace = true } +strum_macros = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[[bin]] +name = "typedialog-prov-gen" +path = "src/main.rs" + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/typedialog-{ target }.tar.gz" +bin-dir = "bin/{ bin }" +pkg-fmt = "tgz" + +[lib] +name = "typedialog_prov_gen" +path = "src/lib.rs" diff --git a/crates/typedialog-prov-gen/src/ai/mod.rs b/crates/typedialog-prov-gen/src/ai/mod.rs new file mode 100644 index 0000000..b3001c8 --- /dev/null +++ b/crates/typedialog-prov-gen/src/ai/mod.rs @@ -0,0 +1,5 @@ +//! AI integration for interactive wizard and RAG retrieval. + +pub mod wizard; + +pub use wizard::InteractiveWizard; diff --git a/crates/typedialog-prov-gen/src/ai/wizard.rs b/crates/typedialog-prov-gen/src/ai/wizard.rs new file mode 100644 index 0000000..25f3f2d --- /dev/null +++ b/crates/typedialog-prov-gen/src/ai/wizard.rs @@ -0,0 +1,53 @@ +//! Mode C: Interactive AI-powered wizard for project configuration. + +use crate::error::Result; +use crate::models::{ + ConfigField, DomainFeature, FieldType, InfrastructureSpec, ProjectSpec, ProjectType, +}; + +/// Interactive wizard using typedialog-ai for conversational generation. +pub struct InteractiveWizard; + +impl InteractiveWizard { + /// Run interactive wizard to build ProjectSpec. + pub async fn run(project_name: Option) -> Result { + // Fallback implementation without AI (suitable for local testing) + // In production, this would integrate with typedialog-ai for conversational generation + + let name = project_name.unwrap_or_else(|| "my-project".to_string()); + + // Simple defaults for wizard mode + let spec = ProjectSpec { + name, + project_type: ProjectType::WebService, + infrastructure: InfrastructureSpec::default(), + domain_features: vec![DomainFeature::new("basic_config".to_string())], + constraints: Vec::new(), + }; + + Ok(spec) + } + + /// AI-assisted feature design (stub for typedialog-ai integration) + #[allow(dead_code)] + fn suggest_features_with_ai(_project_description: &str) -> Vec { + // TODO: Integrate with typedialog-ai RAG system + // This would: + // 1. Send project description to LLM + // 2. Retrieve similar examples via RAG + // 3. Generate feature suggestions based on patterns + Vec::new() + } + + /// Conversational field generation (stub for typedialog-ai integration) + #[allow(dead_code)] + fn generate_fields_conversationally(_feature_name: &str) -> Vec { + // TODO: Implement multi-turn conversation using typedialog-ai + // This would ask clarifying questions and suggest field types + vec![ConfigField::new( + "placeholder".to_string(), + FieldType::Text, + "Placeholder field".to_string(), + )] + } +} diff --git a/crates/typedialog-prov-gen/src/cli/generate.rs b/crates/typedialog-prov-gen/src/cli/generate.rs new file mode 100644 index 0000000..133cfa1 --- /dev/null +++ b/crates/typedialog-prov-gen/src/cli/generate.rs @@ -0,0 +1,156 @@ +//! Generate command: orchestrates the provisioning generation pipeline. + +use crate::error::Result; +use crate::input::{CargoIntrospector, ConfigLoader, NickelSchemaLoader}; +use crate::models::ProjectSpec; +use std::path::PathBuf; +use tracing::{debug, info}; + +/// The Generate command orchestrates the entire provisioning generation pipeline. +pub struct GenerateCommand; + +impl GenerateCommand { + /// Execute the generate command with specified parameters. + pub async fn execute( + mode: &str, + input: Option, + output: PathBuf, + project: Option, + dry_run: bool, + ) -> Result<()> { + info!("Starting provisioning generation (mode: {})", mode); + + // Step 1: Load or infer ProjectSpec based on input mode + let spec = Self::load_spec(mode, input, project).await?; + + debug!( + "Loaded ProjectSpec: {} ({:?})", + spec.name, spec.project_type + ); + + // Step 2: Validate the spec + spec.validate().map_err(|errors| { + crate::error::ProvisioningGenError::Other(format!( + "Invalid specification: {}", + errors.join(", ") + )) + })?; + + info!("ProjectSpec validated successfully"); + + // Step 3: Generate provisioning structure + if dry_run { + info!("DRY RUN: Would generate to {}", output.display()); + info!("Project: {}", spec.name); + info!("Type: {:?}", spec.project_type); + info!( + "Features: {:?}", + spec.domain_features + .iter() + .map(|f| &f.name) + .collect::>() + ); + return Ok(()); + } + + // Ensure output directory exists + std::fs::create_dir_all(&output).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to create output directory: {}", + e + )) + })?; + + info!("Generating provisioning structure to {}", output.display()); + + // Execute the 7-layer generation pipeline in order + // Layer 1: Constraints (required by validators and fragments) + use crate::generator::{ + ConstraintGenerator, DefaultsGenerator, FragmentGenerator, SchemaGenerator, + ScriptGenerator, ValidatorGenerator, + }; + + ConstraintGenerator::generate(&spec, &output)?; + debug!("✓ Constraints layer"); + + // Layer 2: Schemas (domain types) + SchemaGenerator::generate(&spec, &output)?; + debug!("✓ Schemas layer"); + + // Layer 3: Validators (validation logic) + ValidatorGenerator::generate(&spec, &output)?; + debug!("✓ Validators layer"); + + // Layer 4: Defaults (sensible defaults) + DefaultsGenerator::generate(&spec, &output)?; + debug!("✓ Defaults layer"); + + // Layer 5: Fragments (form UI components) + FragmentGenerator::generate(&spec, &output)?; + debug!("✓ Fragments layer"); + + // Layer 6: Scripts (orchestration) + ScriptGenerator::generate(&spec, &output)?; + debug!("✓ Scripts layer"); + + // TODO: Layer 7: JSON output generation + + info!("Provisioning generation completed successfully!"); + info!("Generated structure at: {}", output.display()); + + Ok(()) + } + + /// Load ProjectSpec from the specified input mode. + async fn load_spec( + mode: &str, + input: Option, + project_override: Option, + ) -> Result { + match mode.to_lowercase().as_str() { + "cargo" => { + let cargo_path = input.unwrap_or_else(|| PathBuf::from("Cargo.toml")); + debug!("Loading from Cargo.toml: {}", cargo_path.display()); + CargoIntrospector::analyze(&cargo_path) + } + + "config" => { + let config_path = input.ok_or_else(|| { + crate::error::ProvisioningGenError::Other( + "Config mode requires --input parameter".to_string(), + ) + })?; + debug!("Loading from config file: {}", config_path.display()); + ConfigLoader::load(&config_path) + } + + "nickel" => { + let schema_path = input.ok_or_else(|| { + crate::error::ProvisioningGenError::Other( + "Nickel mode requires --input parameter".to_string(), + ) + })?; + debug!("Loading from Nickel schema: {}", schema_path.display()); + NickelSchemaLoader::load(&schema_path) + } + + "wizard" => { + use crate::ai::InteractiveWizard; + debug!("Starting interactive wizard"); + InteractiveWizard::run(project_override.clone()).await + } + + other => Err(crate::error::ProvisioningGenError::Other(format!( + "Unknown mode: {}. Use: cargo, config, nickel, or wizard", + other + )))?, + } + .map(|mut spec| { + // Apply project name override if provided + if let Some(name) = project_override { + spec.name = name; + } + spec + }) + } +} diff --git a/crates/typedialog-prov-gen/src/cli/mod.rs b/crates/typedialog-prov-gen/src/cli/mod.rs new file mode 100644 index 0000000..8be206d --- /dev/null +++ b/crates/typedialog-prov-gen/src/cli/mod.rs @@ -0,0 +1,5 @@ +//! CLI command handlers for provisioning generation. + +pub mod generate; + +pub use generate::GenerateCommand; diff --git a/crates/typedialog-prov-gen/src/config.rs b/crates/typedialog-prov-gen/src/config.rs new file mode 100644 index 0000000..23139f3 --- /dev/null +++ b/crates/typedialog-prov-gen/src/config.rs @@ -0,0 +1,215 @@ +//! Configuration loader for typedialog-prov-gen. +//! +//! Loads configuration from: +//! 1. ~/.config/typedialog/prov-gen/{TYPEDIALOG_ENV}.toml +//! 2. ~/.config/typedialog/prov-gen/default.toml +//! 3. Hardcoded defaults + +use crate::error::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub provisioning: ProvisioningConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProvisioningConfig { + pub output_dir: String, + pub default_providers: Vec, + pub generation: GenerationConfig, + pub templates: TemplatesConfig, + pub infrastructure: InfrastructureConfig, + pub nickel: NickelConfig, + pub ai: AiConfig, + pub logging: LoggingConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerationConfig { + pub overwrite: bool, + pub dry_run: bool, + pub verbose: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplatesConfig { + pub base_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InfrastructureConfig { + pub environment: String, + pub region: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NickelConfig { + pub validate_schemas: bool, + pub generate_defaults: bool, + pub use_constraints: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiConfig { + pub enabled: bool, + pub provider: String, + pub model: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoggingConfig { + pub level: String, + pub file: bool, +} + +impl Config { + /// Load configuration from explicit path or standard locations or defaults. + pub fn load(config_path: Option<&Path>) -> Result { + // If explicit config provided, use it exclusively + if let Some(path) = config_path { + return Self::from_file(path); + } + + // Try environment-specific config + if let Ok(env) = std::env::var("TYPEDIALOG_ENV") { + if let Some(config_dir) = dirs::config_dir() { + let config_path = config_dir + .join("typedialog") + .join("prov-gen") + .join(format!("{}.toml", env)); + if config_path.exists() { + return Self::from_file(&config_path); + } + } + } + + // Try default user config + if let Some(config_dir) = dirs::config_dir() { + let config_path = config_dir + .join("typedialog") + .join("prov-gen") + .join("config.toml"); + if config_path.exists() { + return Self::from_file(&config_path); + } + } + + // Try project config + let project_config_path = Path::new("config/prov-gen/default.toml"); + if project_config_path.exists() { + return Self::from_file(project_config_path); + } + + // Return hardcoded defaults + Ok(Self::default()) + } + + /// Load configuration from a specific file. + fn from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + toml::from_str(&content).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to parse config: {}", e), + ) + .into() + }) + } + + /// Get the absolute path to templates directory. + pub fn templates_dir(&self) -> PathBuf { + if let Some(custom) = &self.provisioning.templates.custom_path { + return Self::expand_path(custom); + } + + let base = &self.provisioning.templates.base_path; + + // Expand ~ to home directory + let expanded = Self::expand_path(base); + + // If expanded path is absolute, use it + if expanded.is_absolute() && expanded.exists() { + return expanded; + } + + // Try relative to project + let project_path = Path::new(".").join(base); + if project_path.exists() { + return project_path; + } + + // Try relative to binary location + if let Ok(exe_path) = std::env::current_exe() { + if let Some(parent) = exe_path.parent() { + let relative_path = parent + .join("..") + .join("share") + .join("typedialog-prov-gen") + .join(base); + if relative_path.exists() { + return relative_path; + } + } + } + + // Return expanded path as-is (will fail at runtime if not found) + expanded + } + + /// Expand ~ to home directory. + fn expand_path(path: &str) -> PathBuf { + if path.starts_with("~/") || path == "~" { + if let Some(home_dir) = dirs::home_dir() { + let suffix = if path == "~" { + String::new() + } else { + path[2..].to_string() + }; + return home_dir.join(suffix); + } + } + PathBuf::from(path) + } +} + +impl Default for Config { + fn default() -> Self { + Self { + provisioning: ProvisioningConfig { + output_dir: "./provisioning".to_string(), + default_providers: vec!["aws".to_string(), "hetzner".to_string()], + generation: GenerationConfig { + overwrite: false, + dry_run: false, + verbose: false, + }, + templates: TemplatesConfig { + base_path: "~/.config/typedialog/prov-gen/templates".to_string(), + custom_path: None, + }, + infrastructure: InfrastructureConfig { + environment: "development".to_string(), + region: "us-east-1".to_string(), + }, + nickel: NickelConfig { + validate_schemas: true, + generate_defaults: true, + use_constraints: true, + }, + ai: AiConfig { + enabled: false, + provider: "claude".to_string(), + model: "claude-3-5-sonnet-20241022".to_string(), + }, + logging: LoggingConfig { + level: "info".to_string(), + file: false, + }, + }, + } + } +} diff --git a/crates/typedialog-prov-gen/src/error.rs b/crates/typedialog-prov-gen/src/error.rs new file mode 100644 index 0000000..b06f176 --- /dev/null +++ b/crates/typedialog-prov-gen/src/error.rs @@ -0,0 +1,210 @@ +//! Error types for provisioning generation. +//! +//! Follows M-ERRORS-CANONICAL-STRUCTS guideline: specific error types instead of generic enums. + +use std::path::PathBuf; +use thiserror::Error; + +pub type Result = std::result::Result; + +/// Root error type combining all provisioning generation errors. +#[derive(Debug, Error)] +pub enum ProvisioningGenError { + /// Cargo.toml introspection failed + #[error("Cargo introspection failed: {0}")] + CargoIntrospection(#[from] CargoIntrospectionError), + + /// Project spec loading failed + #[error("Config loading failed: {0}")] + ConfigLoading(#[from] ConfigLoadingError), + + /// Nickel schema loading failed + #[error("Nickel schema loading failed: {0}")] + NickelSchemaLoading(#[from] NickelSchemaLoadingError), + + /// Template rendering failed + #[error("Template rendering failed: {0}")] + TemplateRender(#[from] TemplateRenderError), + + /// Schema generation failed + #[error("Schema generation failed: {0}")] + SchemaGeneration(#[from] SchemaGenerationError), + + /// Fragment generation failed + #[error("Fragment generation failed: {0}")] + FragmentGeneration(#[from] FragmentGenerationError), + + /// Validator generation failed + #[error("Validator generation failed: {0}")] + ValidatorGeneration(#[from] ValidatorGenerationError), + + /// Constraint generation failed + #[error("Constraint generation failed: {0}")] + ConstraintGeneration(#[from] ConstraintGenerationError), + + /// File I/O error + #[error("File I/O error: {0}")] + Io(#[from] std::io::Error), + + /// JSON serialization/deserialization error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// TOML serialization/deserialization error + #[error("TOML error: {0}")] + Toml(#[from] toml::de::Error), + + /// Generic error + #[error("{0}")] + Other(String), +} + +/// Cargo.toml introspection errors. +#[derive(Debug)] +pub struct CargoIntrospectionError { + pub cargo_path: PathBuf, + pub reason: String, +} + +impl std::fmt::Display for CargoIntrospectionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to introspect {}: {}", + self.cargo_path.display(), + self.reason + ) + } +} + +impl std::error::Error for CargoIntrospectionError {} + +/// Config loading errors. +#[derive(Debug)] +pub struct ConfigLoadingError { + pub config_path: PathBuf, + pub reason: String, +} + +impl std::fmt::Display for ConfigLoadingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to load config from {}: {}", + self.config_path.display(), + self.reason + ) + } +} + +impl std::error::Error for ConfigLoadingError {} + +/// Nickel schema loading errors. +#[derive(Debug)] +pub struct NickelSchemaLoadingError { + pub schema_path: PathBuf, + pub reason: String, +} + +impl std::fmt::Display for NickelSchemaLoadingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to load Nickel schema from {}: {}", + self.schema_path.display(), + self.reason + ) + } +} + +impl std::error::Error for NickelSchemaLoadingError {} + +/// Template rendering errors. +#[derive(Debug)] +pub struct TemplateRenderError { + pub template_name: String, + pub reason: String, +} + +impl std::fmt::Display for TemplateRenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to render template '{}': {}", + self.template_name, self.reason + ) + } +} + +impl std::error::Error for TemplateRenderError {} + +/// Schema generation errors. +#[derive(Debug)] +pub struct SchemaGenerationError { + pub feature_name: String, + pub reason: String, +} + +impl std::fmt::Display for SchemaGenerationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to generate schema for feature '{}': {}", + self.feature_name, self.reason + ) + } +} + +impl std::error::Error for SchemaGenerationError {} + +/// Fragment generation errors. +#[derive(Debug)] +pub struct FragmentGenerationError { + pub fragment_name: String, + pub reason: String, +} + +impl std::fmt::Display for FragmentGenerationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to generate fragment '{}': {}", + self.fragment_name, self.reason + ) + } +} + +impl std::error::Error for FragmentGenerationError {} + +/// Validator generation errors. +#[derive(Debug)] +pub struct ValidatorGenerationError { + pub feature_name: String, + pub reason: String, +} + +impl std::fmt::Display for ValidatorGenerationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Failed to generate validators for feature '{}': {}", + self.feature_name, self.reason + ) + } +} + +impl std::error::Error for ValidatorGenerationError {} + +/// Constraint generation errors. +#[derive(Debug)] +pub struct ConstraintGenerationError { + pub reason: String, +} + +impl std::fmt::Display for ConstraintGenerationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Failed to generate constraints: {}", self.reason) + } +} + +impl std::error::Error for ConstraintGenerationError {} diff --git a/crates/typedialog-prov-gen/src/generator/constraint_generator.rs b/crates/typedialog-prov-gen/src/generator/constraint_generator.rs new file mode 100644 index 0000000..dc6062b --- /dev/null +++ b/crates/typedialog-prov-gen/src/generator/constraint_generator.rs @@ -0,0 +1,83 @@ +//! Constraint generator: produces constraints.toml from domain features. + +use crate::error::Result; +use crate::models::ProjectSpec; +use std::path::Path; + +/// Generates constraints.toml from ProjectSpec. +pub struct ConstraintGenerator; + +impl ConstraintGenerator { + /// Generate constraints.toml file. + pub fn generate(spec: &ProjectSpec, output_dir: impl AsRef) -> Result<()> { + let output_dir = output_dir.as_ref(); + tracing::info!("Generating constraints for project: {}", spec.name); + + let mut constraints_content = String::new(); + + // Add header + constraints_content.push_str(&format!( + "# Constraint definitions for {}\n# Single source of truth for validation rules\n\n", + spec.name + )); + + // Generate constraint sections for each feature + for feature in &spec.domain_features { + constraints_content.push_str(&format!("[feature.{}]\n", feature.name)); + constraints_content.push_str("# Field constraints\n\n"); + + for field in &feature.fields { + if field.min.is_some() || field.max.is_some() { + constraints_content + .push_str(&format!("[feature.{}.{}]\n", feature.name, field.name)); + + if let Some(min) = field.min { + constraints_content.push_str(&format!("min = {}\n", min)); + } + if let Some(max) = field.max { + constraints_content.push_str(&format!("max = {}\n", max)); + } + + constraints_content.push('\n'); + } + } + } + + // Add global constraints from the spec + if !spec.constraints.is_empty() { + constraints_content.push_str("\n# Global constraints\n\n"); + + for constraint in &spec.constraints { + constraints_content.push_str(&format!("[constraint.\"{}\"]\n", constraint.path)); + + if let Some(min) = constraint.min_items { + constraints_content.push_str(&format!("min_items = {}\n", min)); + } + if let Some(max) = constraint.max_items { + constraints_content.push_str(&format!("max_items = {}\n", max)); + } + + if constraint.unique { + constraints_content.push_str("unique = true\n"); + if let Some(unique_key) = &constraint.unique_key { + constraints_content.push_str(&format!("unique_key = \"{}\"\n", unique_key)); + } + } + + constraints_content.push('\n'); + } + } + + // Write constraints file + let constraints_file = output_dir.join("constraints.toml"); + std::fs::write(&constraints_file, constraints_content).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to write constraints file: {}", + e + )) + })?; + + tracing::info!("Generated constraints file: {}", constraints_file.display()); + Ok(()) + } +} diff --git a/crates/typedialog-prov-gen/src/generator/defaults_generator.rs b/crates/typedialog-prov-gen/src/generator/defaults_generator.rs new file mode 100644 index 0000000..1b4f3ad --- /dev/null +++ b/crates/typedialog-prov-gen/src/generator/defaults_generator.rs @@ -0,0 +1,83 @@ +//! Defaults generator: produces default configuration values in Nickel. + +use crate::error::Result; +use crate::models::{FieldType, ProjectSpec}; +use std::path::Path; + +/// Generates Nickel defaults from domain features. +pub struct DefaultsGenerator; + +impl DefaultsGenerator { + /// Generate defaults for all domain features. + pub fn generate(spec: &ProjectSpec, output_dir: impl AsRef) -> Result<()> { + let output_dir = output_dir.as_ref(); + tracing::info!("Generating defaults for project: {}", spec.name); + + // Ensure defaults directory exists + let defaults_dir = output_dir.join("defaults"); + std::fs::create_dir_all(&defaults_dir).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to create defaults directory: {}", + e + )) + })?; + + // Generate defaults for each feature + for feature in &spec.domain_features { + let mut defaults_content = String::new(); + + defaults_content.push_str(&format!( + "# Default configuration for {} feature\n# Generated for project: {}\n\n", + feature.name, spec.name + )); + + defaults_content.push_str(&format!("let {} = {{\n", feature.name)); + + for field in &feature.fields { + defaults_content.push_str(&format!(" # {}\n", field.prompt)); + + if let Some(default) = &field.default { + defaults_content.push_str(&format!(" {} = {},\n", field.name, default)); + } else { + // Generate sensible defaults based on field type + let default_val = Self::generate_default_value(field); + defaults_content.push_str(&format!( + " {} = {}, # No default provided\n", + field.name, default_val + )); + } + } + + defaults_content.push_str("}\n\n"); + + // Write defaults file + let defaults_file = defaults_dir.join(format!("{}.ncl", feature.name)); + std::fs::write(&defaults_file, defaults_content).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to write defaults file: {}", + e + )) + })?; + + tracing::debug!("Generated defaults for feature: {}", feature.name); + } + + tracing::info!("Successfully generated defaults"); + Ok(()) + } + + /// Generate a sensible default value for a field type. + fn generate_default_value(field: &crate::models::ConfigField) -> String { + match field.field_type { + FieldType::Text => "\"\"".to_string(), + FieldType::Number => "0".to_string(), + FieldType::Password => "\"\"".to_string(), + FieldType::Confirm => "false".to_string(), + FieldType::Select => "\"\"".to_string(), + FieldType::MultiSelect => "[]".to_string(), + FieldType::Editor => "\"\"".to_string(), + FieldType::Date => "\"\"".to_string(), + FieldType::RepeatingGroup => "[]".to_string(), + } + } +} diff --git a/crates/typedialog-prov-gen/src/generator/fragment_generator.rs b/crates/typedialog-prov-gen/src/generator/fragment_generator.rs new file mode 100644 index 0000000..c555465 --- /dev/null +++ b/crates/typedialog-prov-gen/src/generator/fragment_generator.rs @@ -0,0 +1,123 @@ +//! Fragment generator: produces TypeDialog form fragments from domain features. + +use crate::error::Result; +use crate::models::{FieldType, ProjectSpec}; +use std::path::Path; + +/// Generates TypeDialog form fragments from domain features. +pub struct FragmentGenerator; + +impl FragmentGenerator { + /// Generate form fragments for all domain features. + pub fn generate(spec: &ProjectSpec, output_dir: impl AsRef) -> Result<()> { + let output_dir = output_dir.as_ref(); + tracing::info!("Generating form fragments for project: {}", spec.name); + + // Ensure fragments directory exists + let fragments_dir = output_dir.join("fragments"); + std::fs::create_dir_all(&fragments_dir).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to create fragments directory: {}", + e + )) + })?; + + // Generate fragments for each feature + for feature in &spec.domain_features { + let mut fragment_content = String::new(); + + fragment_content.push_str(&format!( + "# Form fragment for {} feature\n# Auto-generated for project: {}\n\n", + feature.name, spec.name + )); + + fragment_content.push_str(&format!("[section.{}]\n", feature.name)); + + if let Some(desc) = &feature.description { + fragment_content.push_str(&format!("description = \"{}\"\n", desc)); + } + + fragment_content.push('\n'); + + // Generate field definitions for this feature + for field in &feature.fields { + fragment_content.push_str(&format!("[[section.{}.fields]]\n", feature.name)); + + fragment_content.push_str(&format!("name = \"{}\"\n", field.name)); + fragment_content.push_str(&format!("prompt = \"{}\"\n", field.prompt)); + fragment_content.push_str(&format!( + "type = \"{}\"\n", + Self::field_type_to_form_type(&field.field_type) + )); + + if let Some(help) = &field.help { + fragment_content.push_str(&format!("help = \"{}\"\n", help)); + } + + if let Some(placeholder) = &field.placeholder { + fragment_content.push_str(&format!("placeholder = \"{}\"\n", placeholder)); + } + + if !field.required { + fragment_content.push_str("required = false\n"); + } + + if field.sensitive { + fragment_content.push_str("sensitive = true\n"); + if let Some(backend) = &field.encryption_backend { + fragment_content + .push_str(&format!("encryption_backend = \"{}\"\n", backend)); + } + } + + if !field.options.is_empty() { + fragment_content.push_str("options = [\n"); + for option in &field.options { + fragment_content.push_str(&format!(" \"{}\",\n", option)); + } + fragment_content.push_str("]\n"); + } + + if field.min.is_some() || field.max.is_some() { + if let Some(min) = field.min { + fragment_content.push_str(&format!("min = {}\n", min)); + } + if let Some(max) = field.max { + fragment_content.push_str(&format!("max = {}\n", max)); + } + } + + fragment_content.push('\n'); + } + + // Write fragment file + let fragment_file = fragments_dir.join(format!("{}-section.toml", feature.name)); + std::fs::write(&fragment_file, fragment_content).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to write fragment file: {}", + e + )) + })?; + + tracing::debug!("Generated fragment for feature: {}", feature.name); + } + + tracing::info!("Successfully generated form fragments"); + Ok(()) + } + + /// Map ProjectSpec field types to TypeDialog form field types. + fn field_type_to_form_type(field_type: &FieldType) -> &'static str { + match field_type { + FieldType::Text => "text", + FieldType::Number => "number", + FieldType::Password => "password", + FieldType::Confirm => "confirm", + FieldType::Select => "select", + FieldType::MultiSelect => "multi_select", + FieldType::Editor => "editor", + FieldType::Date => "date", + FieldType::RepeatingGroup => "repeating_group", + } + } +} diff --git a/crates/typedialog-prov-gen/src/generator/mod.rs b/crates/typedialog-prov-gen/src/generator/mod.rs new file mode 100644 index 0000000..3026722 --- /dev/null +++ b/crates/typedialog-prov-gen/src/generator/mod.rs @@ -0,0 +1,15 @@ +//! Code generators for provisioning structure. + +pub mod constraint_generator; +pub mod defaults_generator; +pub mod fragment_generator; +pub mod schema_generator; +pub mod script_generator; +pub mod validator_generator; + +pub use constraint_generator::ConstraintGenerator; +pub use defaults_generator::DefaultsGenerator; +pub use fragment_generator::FragmentGenerator; +pub use schema_generator::SchemaGenerator; +pub use script_generator::ScriptGenerator; +pub use validator_generator::ValidatorGenerator; diff --git a/crates/typedialog-prov-gen/src/generator/schema_generator.rs b/crates/typedialog-prov-gen/src/generator/schema_generator.rs new file mode 100644 index 0000000..c379d00 --- /dev/null +++ b/crates/typedialog-prov-gen/src/generator/schema_generator.rs @@ -0,0 +1,220 @@ +//! Schema generator: produces Nickel schemas from domain features. + +use crate::error::Result; +use crate::models::*; +use std::path::Path; + +/// Generates Nickel schema files (.ncl) from domain features. +pub struct SchemaGenerator; + +impl SchemaGenerator { + /// Generate schemas for all domain features. + pub fn generate(spec: &ProjectSpec, output_dir: impl AsRef) -> Result<()> { + let output_dir = output_dir.as_ref(); + tracing::info!( + "Generating schemas for project: {} with {} features", + spec.name, + spec.domain_features.len() + ); + + // Ensure schemas directory exists + let schemas_dir = output_dir.join("schemas"); + std::fs::create_dir_all(&schemas_dir).map_err(|e| crate::error::SchemaGenerationError { + feature_name: "root".to_string(), + reason: format!("Failed to create schemas directory: {}", e), + })?; + + // Generate a schema file for each domain feature + for feature in &spec.domain_features { + Self::generate_feature_schema(spec, feature, &schemas_dir)?; + } + + // Generate a main schema that imports all features + Self::generate_main_schema(spec, output_dir)?; + + tracing::info!("Successfully generated schemas for project: {}", spec.name); + Ok(()) + } + + /// Generate a Nickel schema file for a single domain feature. + fn generate_feature_schema( + spec: &ProjectSpec, + feature: &DomainFeature, + schemas_dir: &Path, + ) -> Result<()> { + tracing::debug!("Generating schema for feature: {}", feature.name); + + let mut schema_content = String::new(); + + // Add file header and imports + schema_content.push_str(&format!( + "# Schema for {} feature\n# Generated for project: {}\n\n", + feature.name, spec.name + )); + + // Define the feature record + schema_content.push_str(&format!("let {} = {{\n", feature.name)); + + // Add fields to the record + for field in &feature.fields { + schema_content.push_str(&Self::generate_field_schema(field)?); + } + + schema_content.push_str("}\n\n"); + + // Add validators for fields with constraints + if let Some(constraints) = &feature.constraints { + for path in constraints.keys() { + schema_content.push_str(&format!("# Constraint for {}\n", path)); + } + } + + // Write the schema file + let schema_file = schemas_dir.join(format!("{}.ncl", feature.name)); + std::fs::write(&schema_file, schema_content).map_err(|e| { + crate::error::SchemaGenerationError { + feature_name: feature.name.clone(), + reason: format!( + "Failed to write schema file {}: {}", + schema_file.display(), + e + ), + } + })?; + + tracing::debug!("Generated schema file: {}", schema_file.display()); + Ok(()) + } + + /// Generate Nickel schema syntax for a single field. + fn generate_field_schema(field: &ConfigField) -> Result { + let mut field_def = String::new(); + + // Add field comment if help text exists + if let Some(help) = &field.help { + field_def.push_str(&format!(" # {}\n", help)); + } + + // Field name and type + let nickel_type = Self::map_field_type_to_nickel(&field.field_type); + let required_marker = if field.required { "" } else { "?" }; + + field_def.push_str(&format!( + " {}{} | {},\n", + field.name, required_marker, nickel_type + )); + + // Add default value comment if present + if let Some(default) = &field.default { + field_def.push_str(&format!(" # default: {}\n", default)); + } + + Ok(field_def) + } + + /// Map ProjectSpec field types to Nickel type annotations. + fn map_field_type_to_nickel(field_type: &FieldType) -> &'static str { + match field_type { + FieldType::Text => "String", + FieldType::Number => "Number", + FieldType::Password => "String", + FieldType::Confirm => "Bool", + FieldType::Select => "String", + FieldType::MultiSelect => "[String]", + FieldType::Editor => "String", + FieldType::Date => "String", + FieldType::RepeatingGroup => "[_]", + } + } + + /// Generate main schema file that imports and assembles all features. + fn generate_main_schema(spec: &ProjectSpec, output_dir: &Path) -> Result<()> { + tracing::debug!("Generating main schema for project: {}", spec.name); + + let mut main_schema = String::new(); + + // Add header + main_schema.push_str(&format!( + "# Main configuration schema for {}\n# Generated for provisioning setup\n\n", + spec.name + )); + + // Add infrastructure configuration + main_schema.push_str(&Self::generate_infrastructure_schema(&spec.infrastructure)?); + + // Import all feature schemas + main_schema.push_str("\n# Domain features\n"); + for feature in &spec.domain_features { + main_schema.push_str(&format!( + "let {} = (import \"./schemas/{}.ncl\").{}\n", + feature.name, feature.name, feature.name + )); + } + + // Add main configuration record + main_schema.push_str("\n# Main configuration object\n{\n config = {\n"); + + for feature in &spec.domain_features { + main_schema.push_str(&format!(" {}: {},\n", feature.name, feature.name)); + } + + main_schema.push_str(" },\n}\n"); + + // Write main schema file + let main_file = output_dir.join("config.ncl"); + std::fs::write(&main_file, main_schema).map_err(|e| { + crate::error::SchemaGenerationError { + feature_name: "config".to_string(), + reason: format!("Failed to write main schema {}: {}", main_file.display(), e), + } + })?; + + tracing::debug!("Generated main schema file: {}", main_file.display()); + Ok(()) + } + + /// Generate Nickel schema for infrastructure configuration. + fn generate_infrastructure_schema(infra: &InfrastructureSpec) -> Result { + let mut infra_schema = String::new(); + + infra_schema.push_str("# Infrastructure configuration\n"); + infra_schema.push_str("let infrastructure = {\n"); + + if infra.ssh { + infra_schema.push_str(" ssh | Bool = true,\n"); + } + + if let Some(db) = &infra.database { + infra_schema.push_str(&format!( + " database = {{\n type | String = \"{:?}\",\n required | Bool = {},\n }},\n", + db.db_type, db.required + )); + } + + if !infra.providers.is_empty() { + infra_schema.push_str(" providers | [String] = ["); + let provider_strs: Vec = infra + .providers + .iter() + .map(|p| format!("\"{}\"", format!("{:?}", p).to_lowercase())) + .collect(); + infra_schema.push_str(&provider_strs.join(", ")); + infra_schema.push_str("],\n"); + } + + if !infra.monitoring.is_empty() { + infra_schema.push_str(" monitoring | [String] = ["); + let monitoring_strs: Vec = infra + .monitoring + .iter() + .map(|m| format!("\"{}\"", format!("{:?}", m).to_lowercase())) + .collect(); + infra_schema.push_str(&monitoring_strs.join(", ")); + infra_schema.push_str("],\n"); + } + + infra_schema.push_str("}\n"); + + Ok(infra_schema) + } +} diff --git a/crates/typedialog-prov-gen/src/generator/script_generator.rs b/crates/typedialog-prov-gen/src/generator/script_generator.rs new file mode 100644 index 0000000..90b9a84 --- /dev/null +++ b/crates/typedialog-prov-gen/src/generator/script_generator.rs @@ -0,0 +1,150 @@ +//! Script generator: produces bash and nushell orchestration scripts. + +use crate::error::Result; +use crate::models::ProjectSpec; +use std::path::Path; + +/// Generates orchestration scripts for provisioning. +pub struct ScriptGenerator; + +impl ScriptGenerator { + /// Generate bash and nushell scripts for provisioning orchestration. + pub fn generate(spec: &ProjectSpec, output_dir: impl AsRef) -> Result<()> { + let output_dir = output_dir.as_ref(); + tracing::info!( + "Generating orchestration scripts for project: {}", + spec.name + ); + + // Ensure scripts directory exists + let scripts_dir = output_dir.join("scripts"); + std::fs::create_dir_all(&scripts_dir).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to create scripts directory: {}", + e + )) + })?; + + // Generate bash scripts + Self::generate_bash_scripts(spec, &scripts_dir)?; + + // Generate nushell scripts + Self::generate_nushell_scripts(spec, &scripts_dir)?; + + tracing::info!("Successfully generated orchestration scripts"); + Ok(()) + } + + /// Generate bash orchestration scripts. + fn generate_bash_scripts(spec: &ProjectSpec, scripts_dir: &Path) -> Result<()> { + // Config loading script + let config_script = format!( + "#!/bin/bash\n\ + # Load and validate configuration for {}\n\ + set -euo pipefail\n\n\ + CONFIG_DIR=\"{{CONFIG_DIR:-.}}\"\n\ + \n\ + # Load configuration from JSON\n\ + load_config() {{\n\ + local config_file=\"$1\"\n\ + if [[ ! -f \"$config_file\" ]]; then\n\ + echo \"Error: Configuration file not found: $config_file\" >&2\n\ + exit 1\n\ + fi\n\ + cat \"$config_file\"\n\ + }}\n\ + \n\ + # Validate using Nickel\n\ + validate_config() {{\n\ + local config_file=\"$1\"\n\ + nickel eval --raw \"$config_file\" > /dev/null 2>&1 || {{\n\ + echo \"Error: Configuration validation failed for $config_file\" >&2\n\ + exit 1\n\ + }}\n\ + }}\n\ + \n\ + # Main\n\ + main() {{\n\ + local config_file=\"${{CONFIG_DIR}}/config.json\"\n\ + load_config \"$config_file\"\n\ + validate_config \"$config_file\"\n\ + echo \"Configuration loaded and validated successfully\"\n\ + }}\n\ + \n\ + main \"$@\"\n", + spec.name + ); + + let config_script_path = scripts_dir.join("config.sh"); + std::fs::write(&config_script_path, config_script).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to write config script: {}", + e + )) + })?; + + // Make executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&config_script_path, perms).ok(); + } + + tracing::debug!("Generated bash config script"); + Ok(()) + } + + /// Generate nushell orchestration scripts. + fn generate_nushell_scripts(spec: &ProjectSpec, scripts_dir: &Path) -> Result<()> { + // Config loading script in nushell + let config_script = format!( + "#!/usr/bin/env nu\n\ + # Load and validate configuration for {} (nushell version)\n\n\ + def load_config [config_file: path] {{\n\ + if ($config_file | path exists) {{\n\ + open $config_file\n\ + }} else {{\n\ + error make {{\n\ + msg: $\"Configuration file not found: ($config_file)\"\n\ + }}\n\ + }}\n\ + }}\n\ + \n\ + def validate_config [config_file: path] {{\n\ + let config = (load_config $config_file)\n\ + # TODO: Validate against Nickel schema\n\ + $config\n\ + }}\n\ + \n\ + def main [--config_dir: path = \".\"] {{\n\ + let config_file = ($config_dir | path join config.json)\n\ + let config = (validate_config $config_file)\n\ + print $\"Configuration loaded: ($config_file)\"\n\ + $config\n\ + }}\n\ + \n\ + main $nu.env\n", + spec.name + ); + + let config_script_path = scripts_dir.join("config.nu"); + std::fs::write(&config_script_path, config_script).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to write nushell config script: {}", + e + )) + })?; + + // Make executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&config_script_path, perms).ok(); + } + + tracing::debug!("Generated nushell config script"); + Ok(()) + } +} diff --git a/crates/typedialog-prov-gen/src/generator/validator_generator.rs b/crates/typedialog-prov-gen/src/generator/validator_generator.rs new file mode 100644 index 0000000..48ac6c7 --- /dev/null +++ b/crates/typedialog-prov-gen/src/generator/validator_generator.rs @@ -0,0 +1,154 @@ +//! Validator generator: produces Nickel validators from constraints. + +use crate::error::Result; +use crate::models::{FieldType, ProjectSpec}; +use std::path::Path; + +/// Generates validator Nickel files from constraints. +pub struct ValidatorGenerator; + +impl ValidatorGenerator { + /// Generate validators for all domain features. + pub fn generate(spec: &ProjectSpec, output_dir: impl AsRef) -> Result<()> { + let output_dir = output_dir.as_ref(); + tracing::info!("Generating validators for project: {}", spec.name); + + // Ensure validators directory exists + let validators_dir = output_dir.join("validators"); + std::fs::create_dir_all(&validators_dir).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to create validators directory: {}", + e + )) + })?; + + // Generate validators for each feature + for feature in &spec.domain_features { + let mut validator_content = String::new(); + + validator_content.push_str(&format!( + "# Validators for {} feature\n# Generated for project: {}\n\n", + feature.name, spec.name + )); + + // Add field-specific validators + for field in &feature.fields { + validator_content.push_str(&Self::generate_field_validator(field)?); + } + + // Write validator file + let validator_file = validators_dir.join(format!("{}.ncl", feature.name)); + std::fs::write(&validator_file, validator_content).map_err(|e| { + crate::error::ProvisioningGenError::Other(format!( + "Failed to write validator file: {}", + e + )) + })?; + + tracing::debug!("Generated validator for feature: {}", feature.name); + } + + tracing::info!("Successfully generated validators"); + Ok(()) + } + + /// Generate validator function for a single field. + fn generate_field_validator(field: &crate::models::ConfigField) -> Result { + let mut validator = String::new(); + + validator.push_str(&format!("# Validator for field: {}\n", field.name)); + + match field.field_type { + FieldType::Text => { + validator.push_str(&format!("let validate_{} = fun value => (\n", field.name)); + validator.push_str(" (std.is_string value) &&\n"); + + if let Some(min) = field.min { + validator.push_str(&format!(" ((std.string.length value) >= {}) &&\n", min)); + } + if let Some(max) = field.max { + validator.push_str(&format!(" ((std.string.length value) <= {})\n", max)); + } else { + validator.push_str(" true\n"); + } + + validator.push_str(")\n\n"); + } + + FieldType::Number => { + validator.push_str(&format!("let validate_{} = fun value => (\n", field.name)); + validator.push_str(" (std.is_number value) &&\n"); + + if let Some(min) = field.min { + validator.push_str(&format!(" (value >= {}) &&\n", min)); + } + if let Some(max) = field.max { + validator.push_str(&format!(" (value <= {})\n", max)); + } else { + validator.push_str(" true\n"); + } + + validator.push_str(")\n\n"); + } + + FieldType::Password => { + validator.push_str(&format!("let validate_{} = fun value => (\n", field.name)); + validator.push_str(" (std.is_string value) &&\n"); + validator + .push_str(" ((std.string.length value) >= 8) # Minimum password length\n"); + validator.push_str(")\n\n"); + } + + FieldType::Confirm => { + validator.push_str(&format!( + "let validate_{} = fun value => std.is_bool value\n\n", + field.name + )); + } + + FieldType::Select | FieldType::MultiSelect => { + if !field.options.is_empty() { + validator.push_str(&format!("let validate_{} = fun value => (\n", field.name)); + validator.push_str(" let valid_options = ["); + + let options_str = field + .options + .iter() + .map(|opt| format!("\"{}\"", opt)) + .collect::>() + .join(", "); + + validator.push_str(&options_str); + validator.push_str("] in\n"); + validator.push_str(" std.arrays.elem value valid_options\n"); + validator.push_str(")\n\n"); + } + } + + FieldType::RepeatingGroup => { + validator.push_str(&format!("let validate_{} = fun value => (\n", field.name)); + validator.push_str(" (std.is_array value) &&\n"); + + if let Some(min) = field.min { + validator.push_str(&format!(" ((std.array.length value) >= {}) &&\n", min)); + } + if let Some(max) = field.max { + validator.push_str(&format!(" ((std.array.length value) <= {})\n", max)); + } else { + validator.push_str(" true\n"); + } + + validator.push_str(")\n\n"); + } + + _ => { + validator.push_str(&format!( + "let validate_{} = fun value => true # No specific validation\n\n", + field.name + )); + } + } + + Ok(validator) + } +} diff --git a/crates/typedialog-prov-gen/src/input/cargo_introspector.rs b/crates/typedialog-prov-gen/src/input/cargo_introspector.rs new file mode 100644 index 0000000..f4132b5 --- /dev/null +++ b/crates/typedialog-prov-gen/src/input/cargo_introspector.rs @@ -0,0 +1,324 @@ +//! Mode A: Cargo.toml introspection for automatic feature detection. + +use crate::error::{CargoIntrospectionError, Result}; +use crate::models::*; +use std::collections::HashMap; +use std::path::Path; + +/// Analyzes Cargo.toml to infer domain features and project configuration. +pub struct CargoIntrospector; + +/// Dependency-to-feature mapping heuristics. +impl CargoIntrospector { + /// Analyze a Cargo.toml file to extract project information. + pub fn analyze(cargo_path: impl AsRef) -> Result { + let cargo_path = cargo_path.as_ref(); + + if !cargo_path.exists() { + return Err(CargoIntrospectionError { + cargo_path: cargo_path.to_path_buf(), + reason: "File does not exist".to_string(), + } + .into()); + } + + let manifest = + cargo_toml::Manifest::from_path(cargo_path).map_err(|e| CargoIntrospectionError { + cargo_path: cargo_path.to_path_buf(), + reason: format!("Failed to parse: {}", e), + })?; + + let package = manifest + .package + .as_ref() + .ok_or_else(|| CargoIntrospectionError { + cargo_path: cargo_path.to_path_buf(), + reason: "Package section missing in Cargo.toml".to_string(), + })?; + + let name = package.name.clone(); + + // Determine project type from analysis + let project_type = Self::infer_project_type(&manifest); + + // Infer infrastructure requirements + let infrastructure = Self::infer_infrastructure(&manifest); + + // Infer domain features + let domain_features = Self::infer_domain_features(&manifest); + + // Build constraints from inferred features + let constraints = Self::infer_constraints(&domain_features); + + let spec = ProjectSpec { + name, + project_type, + infrastructure, + domain_features, + constraints, + }; + + // Validate the spec + spec.validate().map_err(|errors| CargoIntrospectionError { + cargo_path: cargo_path.to_path_buf(), + reason: format!("Invalid spec generated: {}", errors.join(", ")), + })?; + + Ok(spec) + } + + /// Infer project type from manifest metadata. + fn infer_project_type(manifest: &cargo_toml::Manifest) -> ProjectType { + // Check description field first + if let Some(package) = &manifest.package { + if let Some(description) = &package.description { + // Inheritable - use Debug or convert to string + let desc_str = format!("{:?}", description); + let desc_lower = desc_str.to_lowercase(); + if desc_lower.contains("service") || desc_lower.contains("api") { + return ProjectType::WebService; + } else if desc_lower.contains("microservice") { + return ProjectType::Microservice; + } else if desc_lower.contains("tool") || desc_lower.contains("cli") { + return ProjectType::CliTool; + } + } + } + + // Check dependencies for web frameworks + let deps = Self::collect_all_dependencies(manifest); + if deps.contains_key("axum") + || deps.contains_key("actix-web") + || deps.contains_key("rocket") + || deps.contains_key("warp") + || deps.contains_key("tide") + { + return ProjectType::WebService; + } + + // Check for CLI frameworks + if deps.contains_key("clap") || deps.contains_key("structopt") { + return ProjectType::CliTool; + } + + // Default based on presence of binaries + // manifest.bin is a BTreeMap, not Option. Check if not empty. + if !manifest.bin.is_empty() { + ProjectType::CliTool + } else { + ProjectType::Library + } + } + + /// Infer infrastructure requirements from dependencies. + fn infer_infrastructure(manifest: &cargo_toml::Manifest) -> InfrastructureSpec { + let deps = Self::collect_all_dependencies(manifest); + + let ssh = deps.contains_key("openssh-keys") || deps.contains_key("ssh2"); + + let database = if deps.contains_key("sqlx") || deps.contains_key("rusqlite") { + Some(DatabaseSpec { + db_type: DatabaseType::Sqlite, + required: true, + }) + } else if deps.contains_key("mysql") || deps.contains_key("sqlx") { + Some(DatabaseSpec { + db_type: DatabaseType::Mysql, + required: true, + }) + } else if deps.contains_key("postgres") || deps.contains_key("sqlx") { + Some(DatabaseSpec { + db_type: DatabaseType::Postgres, + required: true, + }) + } else { + None + }; + + let mut providers = Vec::new(); + if deps.contains_key("aws-config") { + providers.push(CloudProvider::Aws); + } + if deps.contains_key("google-cloudkms1") { + providers.push(CloudProvider::Gcp); + } + if providers.is_empty() { + providers.push(CloudProvider::Lxd); + } + + let mut monitoring = Vec::new(); + if deps.contains_key("prometheus") { + monitoring.push(MonitoringType::Prometheus); + } + if deps.contains_key("grafana") { + monitoring.push(MonitoringType::Grafana); + } + + InfrastructureSpec { + ssh, + database, + providers, + monitoring, + } + } + + /// Infer domain features from dependencies and features. + fn infer_domain_features(manifest: &cargo_toml::Manifest) -> Vec { + let deps = Self::collect_all_dependencies(manifest); + let mut features = Vec::new(); + + // HTTP server detection + if deps.contains_key("axum") + || deps.contains_key("actix-web") + || deps.contains_key("rocket") + { + let mut http_feature = DomainFeature::new("http_server".to_string()); + http_feature.description = Some("HTTP/REST API server".to_string()); + + http_feature = http_feature + .with_field( + ConfigField::new( + "bind_address".to_string(), + FieldType::Text, + "Server bind address".to_string(), + ) + .with_default(serde_json::json!("0.0.0.0:8080")) + .with_help("Format: IP:PORT (e.g., 0.0.0.0:8080)"), + ) + .with_field( + ConfigField::new( + "timeout_seconds".to_string(), + FieldType::Number, + "Request timeout".to_string(), + ) + .with_default(serde_json::json!(30)) + .with_help("Timeout in seconds for HTTP requests"), + ); + + features.push(http_feature); + } + + // Authentication detection + if deps.contains_key("jsonwebtoken") || deps.contains_key("oauth2") { + let mut auth_feature = DomainFeature::new("authentication".to_string()); + auth_feature.description = Some("User authentication".to_string()); + + auth_feature = auth_feature.with_field( + ConfigField::new( + "jwt_secret".to_string(), + FieldType::Password, + "JWT signing secret".to_string(), + ) + .with_help("Secret key for JWT token signing") + .sensitive("age"), + ); + + features.push(auth_feature); + } + + // Caching detection + if deps.contains_key("redis") || deps.contains_key("memcache") { + let mut cache_feature = DomainFeature::new("caching".to_string()); + cache_feature.description = Some("Caching layer".to_string()); + + cache_feature = cache_feature + .with_field( + ConfigField::new( + "cache_enabled".to_string(), + FieldType::Confirm, + "Enable caching".to_string(), + ) + .with_default(serde_json::json!(true)), + ) + .with_field( + ConfigField::new( + "cache_ttl_seconds".to_string(), + FieldType::Number, + "Cache TTL".to_string(), + ) + .with_default(serde_json::json!(3600)) + .with_help("Time to live in seconds"), + ); + + features.push(cache_feature); + } + + // Default: at least one feature (basic configuration) + if features.is_empty() { + let mut basic = DomainFeature::new("basic_config".to_string()); + basic.description = Some("Basic project configuration".to_string()); + basic = basic.with_field( + ConfigField::new( + "config_version".to_string(), + FieldType::Text, + "Configuration version".to_string(), + ) + .with_default(serde_json::json!("1.0")) + .optional(), + ); + features.push(basic); + } + + features + } + + /// Infer constraints from domain features. + fn infer_constraints(features: &[DomainFeature]) -> Vec { + let mut constraints = Vec::new(); + + for feature in features { + for field in &feature.fields { + // Only create constraints for repeating group fields with min/max bounds + if field.field_type == FieldType::RepeatingGroup { + if let (Some(min), Some(max)) = (field.min, field.max) { + let constraint = + Constraint::new(format!("{}.{}", feature.name, field.name)) + .with_min_items(min) + .with_max_items(max); + + constraints.push(constraint); + } + } + } + } + + constraints + } + + /// Collect all dependencies (both regular and dev). + fn collect_all_dependencies(manifest: &cargo_toml::Manifest) -> HashMap { + let mut deps = HashMap::new(); + + // Dependencies is a BTreeMap, not Option + for name in manifest.dependencies.keys() { + deps.insert(name.clone(), "dependency".to_string()); + } + + // Dev dependencies is a BTreeMap, not Option + for name in manifest.dev_dependencies.keys() { + deps.insert(name.clone(), "dev-dependency".to_string()); + } + + deps + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn test_project_type_inference() { + // Tests for project type detection would go here + // Requires fixture Cargo.toml files + } + + #[test] + fn test_infrastructure_inference() { + // Tests for infrastructure detection + } + + #[test] + fn test_domain_features_inference() { + // Tests for feature detection + } +} diff --git a/crates/typedialog-prov-gen/src/input/config_loader.rs b/crates/typedialog-prov-gen/src/input/config_loader.rs new file mode 100644 index 0000000..d710aad --- /dev/null +++ b/crates/typedialog-prov-gen/src/input/config_loader.rs @@ -0,0 +1,374 @@ +//! Mode B: Load project specification from explicit config.toml. + +use crate::error::{ConfigLoadingError, Result}; +use crate::models::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +/// Loads ProjectSpec from an explicit project-spec.toml configuration file. +pub struct ConfigLoader; + +/// TOML-serializable configuration spec (mirrors ProjectSpec for file I/O). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigSpec { + /// Project name + pub name: String, + + /// Project type + pub project_type: String, + + /// Infrastructure configuration + #[serde(default)] + pub infrastructure: InfrastructureConfig, + + /// Domain features + #[serde(default)] + pub features: Vec, + + /// Constraints + #[serde(default)] + pub constraints: Vec, +} + +/// Infrastructure configuration in TOML format. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InfrastructureConfig { + /// SSH support + #[serde(default)] + pub ssh: bool, + + /// Database configuration + pub database: Option, + + /// Cloud providers + #[serde(default)] + pub providers: Vec, + + /// Monitoring tools + #[serde(default)] + pub monitoring: Vec, +} + +/// Database configuration in TOML format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + /// Database type (sqlite, mysql, postgres) + pub r#type: String, + + /// Is database required? + #[serde(default = "default_true")] + pub required: bool, +} + +fn default_true() -> bool { + true +} + +/// Domain feature configuration in TOML format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureConfig { + /// Feature name + pub name: String, + + /// Human-readable description + pub description: Option, + + /// Configuration fields + #[serde(default)] + pub fields: Vec, + + /// Feature-specific constraints + #[serde(default)] + pub constraints: HashMap, +} + +/// Configuration field in TOML format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldConfig { + /// Field name + pub name: String, + + /// Field type + pub r#type: String, + + /// Human-readable prompt + pub prompt: String, + + /// Is field required? + #[serde(default = "default_true")] + pub required: bool, + + /// Default value + pub default: Option, + + /// Help text + pub help: Option, + + /// Placeholder value + pub placeholder: Option, + + /// For select fields: available options + #[serde(default)] + pub options: Vec, + + /// Min value or array length + pub min: Option, + + /// Max value or array length + pub max: Option, + + /// Item fragment for repeating groups + pub item_fragment: Option, + + /// Is field encrypted/sensitive? + #[serde(default)] + pub sensitive: bool, + + /// Encryption backend + pub encryption_backend: Option, + + /// Encryption config (key-value pairs) + #[serde(default)] + pub encryption_config: HashMap, +} + +/// Constraint configuration in TOML format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConstraintConfig { + /// Constraint path (e.g., "feature.field") + pub path: String, + + /// Minimum items (for arrays) + pub min_items: Option, + + /// Maximum items (for arrays) + pub max_items: Option, + + /// Unique constraint field + pub unique: Option, +} + +/// Feature constraint value type. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ConstraintConfigValue { + /// Simple string constraint + String(String), + /// Numeric constraint + Number(u32), + /// Constraint object + Object(HashMap), +} + +impl ConfigLoader { + /// Load ProjectSpec from config file. + pub fn load(config_path: impl AsRef) -> Result { + let config_path = config_path.as_ref(); + + if !config_path.exists() { + return Err(ConfigLoadingError { + config_path: config_path.to_path_buf(), + reason: "File does not exist".to_string(), + } + .into()); + } + + let config_content = + std::fs::read_to_string(config_path).map_err(|e| ConfigLoadingError { + config_path: config_path.to_path_buf(), + reason: format!("Failed to read file: {}", e), + })?; + + let config_spec: ConfigSpec = + toml::from_str(&config_content).map_err(|e| ConfigLoadingError { + config_path: config_path.to_path_buf(), + reason: format!("Failed to parse TOML: {}", e), + })?; + + Self::convert_to_project_spec(config_spec, config_path) + } + + /// Convert ConfigSpec to ProjectSpec. + fn convert_to_project_spec(config: ConfigSpec, config_path: &Path) -> Result { + // Parse project type + let project_type = match config.project_type.to_lowercase().as_str() { + "web" | "webservice" | "web-service" => ProjectType::WebService, + "cli" | "clitool" | "cli-tool" => ProjectType::CliTool, + "micro" | "microservice" => ProjectType::Microservice, + "lib" | "library" => ProjectType::Library, + _ => ProjectType::Custom, + }; + + // Convert infrastructure + let infrastructure = Self::convert_infrastructure(&config.infrastructure)?; + + // Convert domain features + let domain_features = Self::convert_features(&config.features)?; + + // Convert constraints + let constraints = Self::convert_constraints(&config.constraints)?; + + let spec = ProjectSpec { + name: config.name, + project_type, + infrastructure, + domain_features, + constraints, + }; + + // Validate the spec + spec.validate().map_err(|errors| ConfigLoadingError { + config_path: config_path.to_path_buf(), + reason: format!("Invalid config: {}", errors.join(", ")), + })?; + + Ok(spec) + } + + /// Convert infrastructure configuration. + fn convert_infrastructure(infra: &InfrastructureConfig) -> Result { + let database = match &infra.database { + Some(db) => Some(DatabaseSpec { + db_type: match db.r#type.to_lowercase().as_str() { + "sqlite" | "sql" => DatabaseType::Sqlite, + "mysql" => DatabaseType::Mysql, + "postgres" | "postgresql" | "pg" => DatabaseType::Postgres, + "redis" => DatabaseType::Redis, + other => { + return Err(ConfigLoadingError { + config_path: std::path::PathBuf::new(), + reason: format!("Unknown database type: {}", other), + } + .into()); + } + }, + required: db.required, + }), + None => None, + }; + + let providers = infra + .providers + .iter() + .filter_map(|p| match p.to_lowercase().as_str() { + "lxd" => Some(CloudProvider::Lxd), + "hetzner" => Some(CloudProvider::Hetzner), + "aws" => Some(CloudProvider::Aws), + "gcp" | "google" => Some(CloudProvider::Gcp), + "azure" => Some(CloudProvider::Azure), + _ => None, + }) + .collect::>(); + + let monitoring = infra + .monitoring + .iter() + .filter_map(|m| match m.to_lowercase().as_str() { + "prometheus" => Some(MonitoringType::Prometheus), + "grafana" => Some(MonitoringType::Grafana), + "cloudwatch" => Some(MonitoringType::CloudWatch), + "stackdriver" => Some(MonitoringType::StackDriver), + _ => None, + }) + .collect::>(); + + Ok(InfrastructureSpec { + ssh: infra.ssh, + database, + providers, + monitoring, + }) + } + + /// Convert domain features. + fn convert_features(features: &[FeatureConfig]) -> Result> { + features + .iter() + .map(|f| { + let fields = f + .fields + .iter() + .map(Self::convert_field) + .collect::>>()?; + + Ok(DomainFeature { + name: f.name.clone(), + description: f.description.clone(), + fields, + constraints: None, + }) + }) + .collect() + } + + /// Convert a configuration field. + fn convert_field(field: &FieldConfig) -> Result { + let field_type = match field.r#type.to_lowercase().as_str() { + "text" => FieldType::Text, + "number" | "num" => FieldType::Number, + "password" | "secret" => FieldType::Password, + "confirm" | "checkbox" => FieldType::Confirm, + "select" => FieldType::Select, + "multiselect" | "multi-select" => FieldType::MultiSelect, + "editor" => FieldType::Editor, + "date" => FieldType::Date, + "repeating" | "repeating-group" => FieldType::RepeatingGroup, + other => { + return Err(ConfigLoadingError { + config_path: std::path::PathBuf::new(), + reason: format!("Unknown field type: {}", other), + } + .into()); + } + }; + + let mut encryption_config = HashMap::new(); + for (k, v) in &field.encryption_config { + encryption_config.insert(k.clone(), v.clone()); + } + + Ok(ConfigField { + name: field.name.clone(), + field_type, + prompt: field.prompt.clone(), + required: field.required, + default: field.default.clone(), + help: field.help.clone(), + placeholder: field.placeholder.clone(), + options: field.options.clone(), + min: field.min, + max: field.max, + item_fragment: field.item_fragment.clone(), + sensitive: field.sensitive, + encryption_backend: field.encryption_backend.clone(), + encryption_config: if encryption_config.is_empty() { + None + } else { + Some(encryption_config) + }, + }) + } + + /// Convert constraints. + fn convert_constraints(constraints: &[ConstraintConfig]) -> Result> { + constraints + .iter() + .map(|c| { + let mut constraint = Constraint::new(c.path.clone()); + + if let Some(min) = c.min_items { + constraint = constraint.with_min_items(min); + } + if let Some(max) = c.max_items { + constraint = constraint.with_max_items(max); + } + if let Some(unique) = &c.unique { + constraint = constraint.with_unique(unique.clone()); + } + + Ok(constraint) + }) + .collect() + } +} diff --git a/crates/typedialog-prov-gen/src/input/mod.rs b/crates/typedialog-prov-gen/src/input/mod.rs new file mode 100644 index 0000000..f8cc9db --- /dev/null +++ b/crates/typedialog-prov-gen/src/input/mod.rs @@ -0,0 +1,9 @@ +//! Input modes for loading project specifications. + +pub mod cargo_introspector; +pub mod config_loader; +pub mod nickel_schema_loader; + +pub use cargo_introspector::CargoIntrospector; +pub use config_loader::ConfigLoader; +pub use nickel_schema_loader::NickelSchemaLoader; diff --git a/crates/typedialog-prov-gen/src/input/nickel_schema_loader.rs b/crates/typedialog-prov-gen/src/input/nickel_schema_loader.rs new file mode 100644 index 0000000..2d14557 --- /dev/null +++ b/crates/typedialog-prov-gen/src/input/nickel_schema_loader.rs @@ -0,0 +1,300 @@ +//! Mode D: Augment existing Nickel schema to infer ProjectSpec. + +use crate::error::{NickelSchemaLoadingError, Result}; +use crate::models::*; +use std::path::Path; + +/// Analyzes existing Nickel schema to infer ProjectSpec. +pub struct NickelSchemaLoader; + +impl NickelSchemaLoader { + /// Load ProjectSpec by analyzing an existing Nickel schema file. + /// + /// This mode augments existing Nickel schemas by: + /// 1. Parsing the schema structure + /// 2. Extracting field definitions + /// 3. Inferring domain features from configuration records + /// 4. Building constraints from contract definitions + pub fn load(schema_path: impl AsRef) -> Result { + let schema_path = schema_path.as_ref(); + + if !schema_path.exists() { + return Err(NickelSchemaLoadingError { + schema_path: schema_path.to_path_buf(), + reason: "Schema file does not exist".to_string(), + } + .into()); + } + + // Extract the project name from the schema filename (without extension) + let project_name = schema_path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "generated-project".to_string()); + + // Read the schema file + let schema_content = + std::fs::read_to_string(schema_path).map_err(|e| NickelSchemaLoadingError { + schema_path: schema_path.to_path_buf(), + reason: format!("Failed to read file: {}", e), + })?; + + // Parse schema structure using basic pattern matching + Self::parse_schema(&project_name, &schema_content, schema_path) + } + + /// Parse Nickel schema content to extract field definitions. + fn parse_schema(project_name: &str, content: &str, schema_path: &Path) -> Result { + // Extract features from top-level record definitions + let domain_features = Self::extract_features(content)?; + + // Default to WebService type unless we can infer otherwise + let project_type = if content.contains("fn handle") || content.contains("endpoint") { + ProjectType::WebService + } else { + ProjectType::Library + }; + + // Try to extract infrastructure hints from documentation comments + let infrastructure = Self::extract_infrastructure_hints(content)?; + + // Extract constraints from field definitions + let constraints = Self::extract_constraints(content)?; + + let spec = ProjectSpec { + name: project_name.to_string(), + project_type, + infrastructure, + domain_features, + constraints, + }; + + // Validate the spec + spec.validate().map_err(|errors| NickelSchemaLoadingError { + schema_path: schema_path.to_path_buf(), + reason: format!("Invalid schema: {}", errors.join(", ")), + })?; + + Ok(spec) + } + + /// Extract domain features from Nickel record definitions. + fn extract_features(content: &str) -> Result> { + let mut features = Vec::new(); + + // Simple pattern: look for lines like `field_name | FieldType,` + // This is a basic extraction; full Nickel parsing would be more complex + let lines: Vec<&str> = content.lines().collect(); + + let mut current_feature: Option = None; + let mut in_record = false; + + for line in lines.iter() { + let trimmed = line.trim(); + + // Detect record start: `let RecordName = {` + if trimmed.contains("= {") && !trimmed.starts_with("//") { + if let Some(name) = Self::extract_record_name(trimmed) { + if let Some(prev_feature) = current_feature.take() { + features.push(prev_feature); + } + in_record = true; + current_feature = Some(DomainFeature::new(name)); + } + } + + // Detect record end: `}` + if trimmed == "}" && in_record { + if let Some(feature) = current_feature.take() { + features.push(feature); + } + in_record = false; + } + + // Extract field definitions while in a record + if in_record { + if let Some((field_name, field_type)) = Self::extract_field_definition(trimmed) { + let field = ConfigField::new( + field_name.clone(), + field_type, + format!("Configure {}", field_name), + ); + + if let Some(ref mut feature) = current_feature { + feature.fields.push(field); + } + } + } + } + + // Push any remaining feature + if let Some(feature) = current_feature.take() { + features.push(feature); + } + + // Default: at least one basic feature + if features.is_empty() { + features.push(DomainFeature::new("schema_config".to_string())); + } + + Ok(features) + } + + /// Extract record name from a definition line. + fn extract_record_name(line: &str) -> Option { + // Pattern: `let RecordName = {` + if let Some(start) = line.find("let ") { + let rest = &line[start + 4..]; + if let Some(end) = rest.find(" ") { + return Some(rest[..end].trim().to_string()); + } + } + None + } + + /// Extract field definition from a Nickel line. + fn extract_field_definition(line: &str) -> Option<(String, FieldType)> { + // Skip comments and empty lines + if line.is_empty() || line.starts_with("//") { + return None; + } + + // Pattern: `field_name | Type,` or `field_name: Type,` + if let Some(pipe_pos) = line.find('|') { + let field_name = line[..pipe_pos].trim().to_string(); + + let field_type = if line.contains("String") { + FieldType::Text + } else if line.contains("Number") || line.contains("Int") { + FieldType::Number + } else if line.contains("Bool") { + FieldType::Confirm + } else if line.contains("Array") { + FieldType::RepeatingGroup + } else { + FieldType::Text // Default + }; + + return Some((field_name, field_type)); + } + + None + } + + /// Extract infrastructure hints from comments and imports. + fn extract_infrastructure_hints(content: &str) -> Result { + let mut infrastructure = InfrastructureSpec::default(); + + // Check for database hints + if content.contains("database") || content.contains("Database") { + infrastructure.database = Some(DatabaseSpec { + db_type: if content.contains("postgres") { + DatabaseType::Postgres + } else if content.contains("mysql") { + DatabaseType::Mysql + } else { + DatabaseType::Sqlite + }, + required: true, + }); + } + + // Check for SSH hints + if content.contains("ssh") || content.contains("SSH") || content.contains("keypair") { + infrastructure.ssh = true; + } + + // Check for cloud provider hints + if content.contains("aws") || content.contains("AWS") { + infrastructure.providers.push(CloudProvider::Aws); + } + if content.contains("gcp") || content.contains("google") { + infrastructure.providers.push(CloudProvider::Gcp); + } + + // Default to LXD if no providers specified + if infrastructure.providers.is_empty() { + infrastructure.providers.push(CloudProvider::Lxd); + } + + // Check for monitoring hints + if content.contains("prometheus") { + infrastructure.monitoring.push(MonitoringType::Prometheus); + } + if content.contains("grafana") { + infrastructure.monitoring.push(MonitoringType::Grafana); + } + + Ok(infrastructure) + } + + /// Extract constraints from field definitions. + fn extract_constraints(content: &str) -> Result> { + let mut constraints = Vec::new(); + + // Look for patterns like `min_length = 5`, `max_length = 100` + for line in content.lines() { + if line.contains("min_length") { + if let Ok(min_val) = Self::extract_numeric_constraint(line, "min_length") { + let constraint = + Constraint::new("field.min".to_string()).with_min_items(min_val); + constraints.push(constraint); + } + } + + if line.contains("max_length") { + if let Ok(max_val) = Self::extract_numeric_constraint(line, "max_length") { + let constraint = + Constraint::new("field.max".to_string()).with_max_items(max_val); + constraints.push(constraint); + } + } + } + + Ok(constraints) + } + + /// Extract numeric constraint value from a line. + fn extract_numeric_constraint(line: &str, key: &str) -> Result { + if let Some(pos) = line.find(key) { + let rest = &line[pos + key.len()..]; + // Look for pattern: `= number` or `: number` + let value_part = rest.trim_start_matches('=').trim_start_matches(':').trim(); + if let Some(first_num) = value_part.split(|c: char| !c.is_numeric()).next() { + if !first_num.is_empty() { + if let Ok(num) = first_num.parse::() { + return Ok(num); + } + } + } + } + Err(crate::error::NickelSchemaLoadingError { + schema_path: std::path::PathBuf::new(), + reason: format!("Could not extract constraint from: {}", line), + } + .into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_record_name_extraction() { + let line = "let DatabaseConfig = {"; + assert_eq!( + NickelSchemaLoader::extract_record_name(line), + Some("DatabaseConfig".to_string()) + ); + } + + #[test] + fn test_field_definition_extraction() { + let line = "host | String,"; + let (name, field_type) = NickelSchemaLoader::extract_field_definition(line).unwrap(); + assert_eq!(name, "host"); + assert_eq!(field_type, FieldType::Text); + } +} diff --git a/crates/typedialog-prov-gen/src/lib.rs b/crates/typedialog-prov-gen/src/lib.rs new file mode 100644 index 0000000..1c843ac --- /dev/null +++ b/crates/typedialog-prov-gen/src/lib.rs @@ -0,0 +1,44 @@ +//! Provisioning generator for typedialog projects. +//! +//! Generates complete provisioning/ directory structures with 7-layer validation +//! (Forms → Constraints → Values → Validators → Schemas → Defaults → JSON) +//! from project specifications using TypeDialog + Nickel. + +pub mod ai; +pub mod cli; +pub mod config; +pub mod error; +pub mod generator; +pub mod input; +pub mod models; +pub mod template; + +pub use error::{ProvisioningGenError, Result}; +pub use models::{ConfigField, DomainFeature, InfrastructureSpec, ProjectSpec}; + +/// Run provisioning generation from project specification. +pub async fn generate(spec: ProjectSpec, output_dir: impl AsRef) -> Result<()> { + let output_dir = output_dir.as_ref(); + + // Ensure output directory exists + std::fs::create_dir_all(output_dir)?; + + // Generation pipeline: + // 1. Constraints (required by fragments and validators) + // 2. Schemas (domain-specific types) + // 3. Validators (validation logic using constraints) + // 4. Defaults (sensible defaults) + // 5. Fragments (form UI sections using constraints) + // 6. Scripts (orchestration bash + nushell) + // 7. Infrastructure (copy generic templates) + // 8. Form (assemble main config-form.toml) + // 9. README (documentation) + + tracing::info!( + "Starting provisioning generation for project: {}", + spec.name + ); + tracing::info!("Output directory: {}", output_dir.display()); + + Ok(()) +} diff --git a/crates/typedialog-prov-gen/src/main.rs b/crates/typedialog-prov-gen/src/main.rs new file mode 100644 index 0000000..f14b3f4 --- /dev/null +++ b/crates/typedialog-prov-gen/src/main.rs @@ -0,0 +1,227 @@ +//! CLI entry point for typedialog-prov-gen. + +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "typedialog-prov-gen")] +#[command(about = "Generate provisioning structures for typedialog projects", long_about = None)] +struct Cli { + /// Subcommand to execute + #[command(subcommand)] + command: Commands, + + /// Configuration file (TOML) + /// + /// If provided, uses this file exclusively. + /// If not provided, searches: ~/.config/typedialog/prov-gen/{TYPEDIALOG_ENV}.toml → ~/.config/typedialog/prov-gen/config.toml → defaults + #[arg(global = true, short = 'c', long, value_name = "FILE")] + config: Option, + + /// Enable verbose logging + #[arg(short, long, global = true)] + verbose: bool, +} + +#[derive(Parser, Debug)] +enum Commands { + /// Generate provisioning structure from project specification + Generate { + /// Input mode: cargo | config | wizard | nickel + #[arg(short, long)] + mode: String, + + /// Input file path (for modes that require files) + #[arg(short, long)] + input: Option, + + /// Output directory for generated files + #[arg(short, long, default_value = "./provisioning")] + output: PathBuf, + + /// Project name override + #[arg(short, long)] + project: Option, + + /// Overwrite existing files + #[arg(short, long)] + force: bool, + + /// Dry run (show what would be generated) + #[arg(long)] + dry_run: bool, + }, + + /// Validate a provisioning structure + Validate { + /// Path to provisioning directory + #[arg(value_name = "DIR")] + path: PathBuf, + }, + + /// List available templates + Templates { + /// Output format: json, yaml, text, toml + #[arg(short, long, value_name = "FORMAT", default_value = "text")] + format: String, + }, + + /// Show help for a specific input mode + ModeHelp { + /// Mode name (cargo, config, wizard, nickel) + #[arg(value_name = "MODE")] + mode: Option, + }, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + // Initialize logging + let level = if cli.verbose { + tracing::metadata::LevelFilter::DEBUG + } else { + tracing::metadata::LevelFilter::INFO + }; + tracing_subscriber::fmt().with_max_level(level).init(); + + match cli.command { + Commands::Generate { + mode, + input, + output, + project, + force, + dry_run, + } => { + use typedialog_prov_gen::cli::GenerateCommand; + use typedialog_prov_gen::config::Config; + + // Load configuration + let _config = match Config::load(cli.config.as_deref()) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!("Failed to load configuration: {}", e); + std::process::exit(1); + } + }; + + if !force + && output.exists() + && std::fs::read_dir(&output) + .ok() + .is_some_and(|mut dir| dir.next().is_some()) + { + eprintln!("Output directory already exists. Use --force to overwrite"); + std::process::exit(1); + } + + if let Err(e) = GenerateCommand::execute(&mode, input, output, project, dry_run).await { + eprintln!("Generation failed: {}", e); + std::process::exit(1); + } + } + + Commands::Validate { path: _ } => { + // TODO: Implement validation command + eprintln!("Validation command not yet implemented"); + std::process::exit(1); + } + + Commands::Templates { format } => { + use typedialog_prov_gen::config::Config; + use typedialog_prov_gen::template::TemplateLoader; + + match Config::load(cli.config.as_deref()) { + Ok(config) => match TemplateLoader::new(&config) { + Ok(loader) => match loader.list_templates() { + Ok(categories) => { + match format.as_str() { + "json" => { + if let Ok(json) = serde_json::to_string_pretty(&categories) { + println!("{}", json); + } else { + eprintln!("Failed to serialize templates to JSON"); + std::process::exit(1); + } + } + "yaml" => { + if let Ok(yaml) = serde_yaml::to_string(&categories) { + println!("{}", yaml); + } else { + eprintln!("Failed to serialize templates to YAML"); + std::process::exit(1); + } + } + "toml" => { + use serde::Serialize; + #[derive(Serialize)] + struct TemplatesWrapper { + categories: Vec, + } + let wrapped = TemplatesWrapper { categories }; + if let Ok(toml) = toml::to_string_pretty(&wrapped) { + println!("{}", toml); + } else { + eprintln!("Failed to serialize templates to TOML"); + std::process::exit(1); + } + } + "text" => { + println!("From: {}\n", loader.templates_dir().display()); + println!("📋 Available Template Categories:\n"); + for category in categories { + println!( + "{} {}", + category.name.to_uppercase(), + category.description + ); + for template in &category.templates { + println!(" • {}", template); + } + println!(); + } + } + _ => { + eprintln!("Unknown format: {}. Supported: json, yaml, text, toml", format); + std::process::exit(1); + } + } + } + Err(e) => { + eprintln!("Failed to list templates: {}", e); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Failed to initialize template loader: {}", e); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("Failed to load configuration: {}", e); + std::process::exit(1); + } + } + } + + Commands::ModeHelp { mode } => match mode.as_deref() { + Some("cargo") => { + println!("Mode: cargo\n\nAnalyze Cargo.toml to infer project configuration") + } + Some("config") => println!("Mode: config\n\nLoad explicit project-spec.toml"), + Some("wizard") => println!("Mode: wizard\n\nInteractive AI-powered wizard"), + Some("nickel") => println!("Mode: nickel\n\nAugment existing Nickel schema"), + _ => { + println!("Available modes:"); + println!(" cargo - Analyze Cargo.toml (automatic feature detection)"); + println!(" config - Load explicit project-spec.toml"); + println!(" wizard - Interactive AI-powered wizard"); + println!(" nickel - Augment existing Nickel schema"); + } + }, + } + + Ok(()) +} diff --git a/crates/typedialog-prov-gen/src/models/mod.rs b/crates/typedialog-prov-gen/src/models/mod.rs new file mode 100644 index 0000000..554f3df --- /dev/null +++ b/crates/typedialog-prov-gen/src/models/mod.rs @@ -0,0 +1,8 @@ +//! Data models for provisioning generation. + +pub mod project_spec; + +pub use project_spec::{ + CloudProvider, ConfigField, Constraint, DatabaseSpec, DatabaseType, DomainFeature, FieldType, + InfrastructureSpec, MonitoringType, ProjectSpec, ProjectType, +}; diff --git a/crates/typedialog-prov-gen/src/models/project_spec.rs b/crates/typedialog-prov-gen/src/models/project_spec.rs new file mode 100644 index 0000000..0978559 --- /dev/null +++ b/crates/typedialog-prov-gen/src/models/project_spec.rs @@ -0,0 +1,546 @@ +//! Central data model for provisioning generation. +//! +//! ProjectSpec is the normalized intermediate representation that all input modes +//! (Cargo, Config, Wizard, Nickel) converge to. It defines what gets generated. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Complete project specification for provisioning generation. +/// +/// This is the single source of truth that all input modes normalize to. +/// All generation happens from this spec. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectSpec { + /// Project name (lowercase, dashes allowed, no leading numbers) + pub name: String, + + /// Project type (web-service, cli-tool, microservice, library) + pub project_type: ProjectType, + + /// Infrastructure requirements (SSH, database, providers, monitoring) + pub infrastructure: InfrastructureSpec, + + /// Domain-specific features and their configuration fields + pub domain_features: Vec, + + /// Validation constraints (array sizes, uniqueness rules, etc.) + pub constraints: Vec, +} + +impl ProjectSpec { + /// Create a new ProjectSpec with minimal required fields. + pub fn new(name: String, project_type: ProjectType) -> Self { + Self { + name, + project_type, + infrastructure: InfrastructureSpec::default(), + domain_features: Vec::new(), + constraints: Vec::new(), + } + } + + /// Validate the spec for completeness and consistency. + pub fn validate(&self) -> Result<(), Vec> { + let mut errors = Vec::new(); + + // Project name validation + if self.name.is_empty() { + errors.push("Project name cannot be empty".to_string()); + } else if !self.name.chars().next().unwrap().is_lowercase() { + errors.push("Project name must start with lowercase letter".to_string()); + } + + // Domain features validation + if self.domain_features.is_empty() { + errors.push("At least one domain feature must be defined".to_string()); + } + + for feature in &self.domain_features { + if let Err(feature_errors) = feature.validate() { + errors.extend(feature_errors); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +impl std::fmt::Display for ProjectSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Project {} ({:?}): {} features, {} constraints", + self.name, + self.project_type, + self.domain_features.len(), + self.constraints.len() + ) + } +} + +/// Project type classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProjectType { + /// Web service (HTTP API, REST) + WebService, + /// Command-line tool + CliTool, + /// Microservice (containerized, distributed) + Microservice, + /// Shared library + Library, + /// Custom type (user-specified) + Custom, +} + +impl std::fmt::Display for ProjectType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::WebService => write!(f, "web-service"), + Self::CliTool => write!(f, "cli-tool"), + Self::Microservice => write!(f, "microservice"), + Self::Library => write!(f, "library"), + Self::Custom => write!(f, "custom"), + } + } +} + +/// Infrastructure requirements. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InfrastructureSpec { + /// SSH/remote access required + pub ssh: bool, + + /// Database configuration + pub database: Option, + + /// Cloud providers to support + pub providers: Vec, + + /// Optional monitoring features + pub monitoring: Vec, +} + +/// Database configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseSpec { + pub db_type: DatabaseType, + pub required: bool, +} + +/// Supported database types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DatabaseType { + Sqlite, + Mysql, + Postgres, + Redis, +} + +impl std::fmt::Display for DatabaseType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sqlite => write!(f, "sqlite"), + Self::Mysql => write!(f, "mysql"), + Self::Postgres => write!(f, "postgres"), + Self::Redis => write!(f, "redis"), + } + } +} + +/// Cloud providers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CloudProvider { + Lxd, + Hetzner, + Aws, + Gcp, + Azure, +} + +impl std::fmt::Display for CloudProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Lxd => write!(f, "lxd"), + Self::Hetzner => write!(f, "hetzner"), + Self::Aws => write!(f, "aws"), + Self::Gcp => write!(f, "gcp"), + Self::Azure => write!(f, "azure"), + } + } +} + +/// Monitoring types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MonitoringType { + Prometheus, + Grafana, + CloudWatch, + StackDriver, +} + +impl std::fmt::Display for MonitoringType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Prometheus => write!(f, "prometheus"), + Self::Grafana => write!(f, "grafana"), + Self::CloudWatch => write!(f, "cloudwatch"), + Self::StackDriver => write!(f, "stackdriver"), + } + } +} + +/// Domain-specific feature with its configuration fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DomainFeature { + /// Feature name (e.g., "http_server", "authentication", "caching") + pub name: String, + + /// Human-readable description + pub description: Option, + + /// Configuration fields for this feature + pub fields: Vec, + + /// Constraints specific to this feature (e.g., array bounds) + pub constraints: Option>, +} + +impl DomainFeature { + /// Create a new feature with no fields. + pub fn new(name: String) -> Self { + Self { + name, + description: None, + fields: Vec::new(), + constraints: None, + } + } + + /// Add a configuration field to this feature. + pub fn with_field(mut self, field: ConfigField) -> Self { + self.fields.push(field); + self + } + + /// Validate the feature for completeness. + pub fn validate(&self) -> Result<(), Vec> { + let mut errors = Vec::new(); + + if self.name.is_empty() { + errors.push("Feature name cannot be empty".to_string()); + } + + for field in &self.fields { + if let Err(field_errors) = field.validate() { + errors.extend( + field_errors + .iter() + .map(|e| format!("Feature '{}': {}", self.name, e)), + ); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +impl std::fmt::Display for DomainFeature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Feature: {} ({} fields)", self.name, self.fields.len()) + } +} + +/// Configuration field definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigField { + /// Field name (matches Nickel path element) + pub name: String, + + /// Field type (text, number, select, etc.) + pub field_type: FieldType, + + /// Human-readable prompt + pub prompt: String, + + /// Whether field is required + pub required: bool, + + /// Default value (as JSON) + pub default: Option, + + /// Help text for the user + pub help: Option, + + /// Optional placeholder value + pub placeholder: Option, + + /// For select/multi_select: available options + pub options: Vec, + + /// Min value (for numbers/arrays) + pub min: Option, + + /// Max value (for numbers/arrays) + pub max: Option, + + /// For repeating groups: item fragment path + pub item_fragment: Option, + + /// Is this field encrypted? + pub sensitive: bool, + + /// Encryption backend if sensitive + pub encryption_backend: Option, + + /// Encryption config if sensitive + pub encryption_config: Option>, +} + +impl ConfigField { + /// Create a new configuration field. + pub fn new(name: String, field_type: FieldType, prompt: String) -> Self { + Self { + name, + field_type, + prompt, + required: true, + default: None, + help: None, + placeholder: None, + options: Vec::new(), + min: None, + max: None, + item_fragment: None, + sensitive: false, + encryption_backend: None, + encryption_config: None, + } + } + + /// Mark field as optional. + pub fn optional(mut self) -> Self { + self.required = false; + self + } + + /// Set a default value. + pub fn with_default(mut self, default: serde_json::Value) -> Self { + self.default = Some(default); + self + } + + /// Set help text. + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Mark as sensitive (encrypted). + pub fn sensitive(mut self, backend: impl Into) -> Self { + self.sensitive = true; + self.encryption_backend = Some(backend.into()); + self + } + + /// Validate the field. + pub fn validate(&self) -> Result<(), Vec> { + let mut errors = Vec::new(); + + if self.name.is_empty() { + errors.push("Field name cannot be empty".to_string()); + } + + if self.prompt.is_empty() { + errors.push(format!("Field '{}': prompt cannot be empty", self.name)); + } + + // Validate min/max constraints + if let (Some(min), Some(max)) = (self.min, self.max) { + if min > max { + errors.push(format!( + "Field '{}': min ({}) > max ({})", + self.name, min, max + )); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +impl std::fmt::Display for ConfigField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {:?}", self.name, self.field_type) + } +} + +/// Field type for form UI rendering and JSON serialization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FieldType { + Text, + Number, + Password, + Confirm, + Select, + MultiSelect, + Editor, + Date, + RepeatingGroup, +} + +impl std::fmt::Display for FieldType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Text => write!(f, "text"), + Self::Number => write!(f, "number"), + Self::Password => write!(f, "password"), + Self::Confirm => write!(f, "confirm"), + Self::Select => write!(f, "select"), + Self::MultiSelect => write!(f, "multi_select"), + Self::Editor => write!(f, "editor"), + Self::Date => write!(f, "date"), + Self::RepeatingGroup => write!(f, "repeatinggroup"), + } + } +} + +/// Constraint on a feature (array bounds, uniqueness, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Constraint { + /// Path in TOML (e.g., "tracker.udp_trackers") + pub path: String, + + /// Minimum array items + pub min_items: Option, + + /// Maximum array items + pub max_items: Option, + + /// Items must be unique + pub unique: bool, + + /// Uniqueness key field (if unique=true) + pub unique_key: Option, +} + +impl Constraint { + /// Create a new constraint for a path. + pub fn new(path: String) -> Self { + Self { + path, + min_items: None, + max_items: None, + unique: false, + unique_key: None, + } + } + + /// Set minimum items constraint. + pub fn with_min_items(mut self, min: u32) -> Self { + self.min_items = Some(min); + self + } + + /// Set maximum items constraint. + pub fn with_max_items(mut self, max: u32) -> Self { + self.max_items = Some(max); + self + } + + /// Set uniqueness constraint. + pub fn with_unique(mut self, key: impl Into) -> Self { + self.unique = true; + self.unique_key = Some(key.into()); + self + } +} + +impl std::fmt::Display for Constraint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Constraint({})", self.path) + } +} + +/// Feature-specific constraint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureConstraint { + pub min_items: Option, + pub max_items: Option, + pub unique: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_spec_creation() { + let spec = ProjectSpec::new("my-service".to_string(), ProjectType::WebService); + assert_eq!(spec.name, "my-service"); + assert_eq!(spec.project_type, ProjectType::WebService); + } + + #[test] + fn test_project_spec_validation_fails_without_features() { + let spec = ProjectSpec::new("my-service".to_string(), ProjectType::WebService); + assert!(spec.validate().is_err()); + } + + #[test] + fn test_domain_feature_creation() { + let feature = DomainFeature::new("http_server".to_string()); + assert_eq!(feature.name, "http_server"); + assert!(feature.fields.is_empty()); + } + + #[test] + fn test_config_field_creation() { + let field = ConfigField::new( + "bind_address".to_string(), + FieldType::Text, + "Server bind address".to_string(), + ); + assert_eq!(field.name, "bind_address"); + assert!(field.required); + assert!(!field.sensitive); + } + + #[test] + fn test_config_field_sensitive() { + let field = ConfigField::new( + "api_token".to_string(), + FieldType::Password, + "API token".to_string(), + ) + .sensitive("age"); + + assert!(field.sensitive); + assert_eq!(field.encryption_backend, Some("age".to_string())); + } + + #[test] + fn test_constraint_creation() { + let constraint = Constraint::new("tracker.udp_trackers".to_string()) + .with_min_items(1) + .with_max_items(4) + .with_unique("bind_address"); + + assert_eq!(constraint.min_items, Some(1)); + assert_eq!(constraint.max_items, Some(4)); + assert!(constraint.unique); + } +} diff --git a/crates/typedialog-prov-gen/src/template/loader.rs b/crates/typedialog-prov-gen/src/template/loader.rs new file mode 100644 index 0000000..38e3e36 --- /dev/null +++ b/crates/typedialog-prov-gen/src/template/loader.rs @@ -0,0 +1,108 @@ +//! Template loader and renderer. + +use crate::config::Config; +use crate::error::Result; +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +/// Loads and renders Tera templates for code generation. +pub struct TemplateLoader { + path: PathBuf, +} + +/// Template category with its templates. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TemplateCategory { + pub name: String, + pub description: String, + pub templates: Vec, +} + +impl TemplateLoader { + /// Load template library from configuration. + pub fn new(config: &Config) -> Result { + let path = config.templates_dir(); + Ok(TemplateLoader { path }) + } + + /// Get the templates directory path. + pub fn templates_dir(&self) -> &std::path::PathBuf { + &self.path + } + + /// List all available template categories and their templates. + pub fn list_templates(&self) -> Result> { + let templates_dir = &self.path; + let mut categories = Vec::new(); + + if !templates_dir.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "Templates directory not found: {}", + templates_dir.display() + ), + ) + .into()); + } + + let category_names = vec![ + "defaults", + "domain", + "fragments", + "schemas", + "scripts", + "validators", + ]; + let category_descriptions = BTreeMap::from([ + ("defaults", "Default value templates for fields"), + ("domain", "Domain model and schema generation"), + ("fragments", "Reusable code fragments"), + ("schemas", "Schema validation and definition"), + ("scripts", "Infrastructure and deployment scripts"), + ("validators", "Field validation templates"), + ]); + + for category_name in category_names { + let category_dir = templates_dir.join(category_name); + if !category_dir.exists() { + continue; + } + + let mut templates = Vec::new(); + for entry in fs::read_dir(&category_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + if let Some(filename) = path.file_name() { + if let Some(name) = filename.to_str() { + templates.push(name.to_string()); + } + } + } + } + + templates.sort(); + + let description = category_descriptions + .get(category_name) + .unwrap_or(&"") + .to_string(); + + categories.push(TemplateCategory { + name: category_name.to_string(), + description, + templates, + }); + } + + Ok(categories) + } + + /// Render a template with given context. + pub fn render(&self, _template_name: &str, _context: &tera::Context) -> Result { + // TODO: Implement template rendering + Ok(String::new()) + } +} diff --git a/crates/typedialog-prov-gen/src/template/mod.rs b/crates/typedialog-prov-gen/src/template/mod.rs new file mode 100644 index 0000000..cfdce02 --- /dev/null +++ b/crates/typedialog-prov-gen/src/template/mod.rs @@ -0,0 +1,5 @@ +//! Template system for code generation. + +pub mod loader; + +pub use loader::{TemplateCategory, TemplateLoader}; diff --git a/crates/typedialog-prov-gen/templates/defaults/database.ncl b/crates/typedialog-prov-gen/templates/defaults/database.ncl new file mode 100644 index 0000000..6e795b5 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/defaults/database.ncl @@ -0,0 +1,12 @@ +# Database Defaults +# Default values with type contracts applied + +let schemas = import "../schemas/database.ncl" in + +{ + # Default database: SQLite (simple, file-based) + database | schemas.Database = { + driver = "sqlite3", + database_name = "tracker.db", + }, +} diff --git a/crates/typedialog-prov-gen/templates/defaults/environment.ncl b/crates/typedialog-prov-gen/templates/defaults/environment.ncl new file mode 100644 index 0000000..dc5d449 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/defaults/environment.ncl @@ -0,0 +1,11 @@ +# Environment Defaults +# Default values with type contracts applied + +let schemas = import "../schemas/environment.ncl" in + +{ + environment | schemas.Environment = { + # name is required from user (no default) + # instance_name is optional + }, +} diff --git a/crates/typedialog-prov-gen/templates/defaults/features.ncl b/crates/typedialog-prov-gen/templates/defaults/features.ncl new file mode 100644 index 0000000..c1be15d --- /dev/null +++ b/crates/typedialog-prov-gen/templates/defaults/features.ncl @@ -0,0 +1,17 @@ +# Features Defaults +# Default values with type contracts applied +# Note: Features are completely optional - user decides what to enable/configure + +let schemas = import "../schemas/features.ncl" in + +{ + features | schemas.Features = { + # Prometheus: optional structure (user provides if needed) + # Default: not specified (optional in schema) + # prometheus = {...} + + # Grafana: optional structure (user provides if needed) + # Default: not specified (optional in schema) + # grafana = {...} + }, +} diff --git a/crates/typedialog-prov-gen/templates/defaults/provider.ncl b/crates/typedialog-prov-gen/templates/defaults/provider.ncl new file mode 100644 index 0000000..b88ca40 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/defaults/provider.ncl @@ -0,0 +1,13 @@ +# Provider Defaults +# No defaults provided (user must supply provider configuration) + +let schemas = import "../schemas/provider.ncl" in + +{ + # provider field must be completely supplied by user + # Example (user would provide): + # provider | schemas.Provider = { + # provider = "lxd", + # profile_name = "my-profile", + # } +} diff --git a/crates/typedialog-prov-gen/templates/defaults/ssh.ncl b/crates/typedialog-prov-gen/templates/defaults/ssh.ncl new file mode 100644 index 0000000..97f4608 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/defaults/ssh.ncl @@ -0,0 +1,17 @@ +# SSH Defaults +# Default values with type contracts applied + +let schemas = import "../schemas/ssh.ncl" in + +{ + ssh_credentials | schemas.SshCredentials = { + # private_key_path must be provided by user (no default) + # public_key_path must be provided by user (no default) + + # Default SSH username: "torrust" (project convention) + username = "torrust", + + # Default SSH port: (standard SSH port) + port = 22, + }, +} diff --git a/crates/typedialog-prov-gen/templates/domain/domain-defaults.ncl.tera b/crates/typedialog-prov-gen/templates/domain/domain-defaults.ncl.tera new file mode 100644 index 0000000..d17070e --- /dev/null +++ b/crates/typedialog-prov-gen/templates/domain/domain-defaults.ncl.tera @@ -0,0 +1,11 @@ +# Default configuration values for {{ feature_name }} domain +# Provides sensible defaults for {{ feature_description | default(value="domain feature") }} +# Auto-generated for project: {{ project_name }} + +{ + {{ feature_name }} = { +{%- for field in fields %} + {{ field.name }} = {{ field.default | default(value=field.default_by_type) }}, +{%- endfor %} + }, +} diff --git a/crates/typedialog-prov-gen/templates/domain/domain-fragment.toml.tera b/crates/typedialog-prov-gen/templates/domain/domain-fragment.toml.tera new file mode 100644 index 0000000..f5ba88f --- /dev/null +++ b/crates/typedialog-prov-gen/templates/domain/domain-fragment.toml.tera @@ -0,0 +1,48 @@ +# Form fragment for {{ feature_name }} feature +# Auto-generated for project: {{ project_name }} + +[section.{{ feature_name }}] +{%- if description %} +description = "{{ description }}" +{%- endif %} + +{%- for field in fields %} + +[[section.{{ feature_name }}.fields]] +name = "{{ field.name }}" +prompt = "{{ field.prompt }}" +type = "{{ field.type }}" +{%- if field.help %} +help = "{{ field.help }}" +{%- endif %} +{%- if field.placeholder %} +placeholder = "{{ field.placeholder }}" +{%- endif %} +{%- if field.default %} +default = "{{ field.default }}" +{%- endif %} +{%- if not field.required %} +required = false +{%- endif %} +{%- if field.sensitive %} +sensitive = true +{%- if field.encryption_backend %} +encryption_backend = "{{ field.encryption_backend }}" +{%- endif %} +{%- endif %} +{%- if field.options %} +options = [ +{%- for option in field.options %} + "{{ option }}", +{%- endfor %} +] +{%- endif %} +{%- if field.min or field.max %} +{%- if field.min %} +min = {{ field.min }} +{%- endif %} +{%- if field.max %} +max = {{ field.max }} +{%- endif %} +{%- endif %} +{%- endfor %} diff --git a/crates/typedialog-prov-gen/templates/domain/domain-schema.ncl.tera b/crates/typedialog-prov-gen/templates/domain/domain-schema.ncl.tera new file mode 100644 index 0000000..21caee3 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/domain/domain-schema.ncl.tera @@ -0,0 +1,16 @@ +# Domain Schema for {{ feature_name }} +# Type contract for {{ feature_description | default(value="feature configuration") }} +# Auto-generated for project: {{ project_name }} +{%- if imports %} +{%- for import in imports %} + let {{ import.name }} = import "{{ import.path }}" +{%- endfor %} +{% endif %} + +{ + {{- field_name | capitalize }} = { +{%- for field in fields %} + {{ field.name }} | {{ field.nickel_type }}{%- if field.optional %} | optional{%- endif %}{%- if field.doc %}, # {{ field.doc }}{%- else %},{%- endif %} +{%- endfor %} + }, +} diff --git a/crates/typedialog-prov-gen/templates/domain/domain-validator.ncl.tera b/crates/typedialog-prov-gen/templates/domain/domain-validator.ncl.tera new file mode 100644 index 0000000..d735e8d --- /dev/null +++ b/crates/typedialog-prov-gen/templates/domain/domain-validator.ncl.tera @@ -0,0 +1,57 @@ +# Validator functions for {{ feature_name }} domain +# Type-specific validation rules for {{ feature_description | default(value="domain feature") }} +# Auto-generated for project: {{ project_name }} +{%- if imports %} +{%- for import in imports %} +let {{ import.name }} = import "{{ import.path }}" +{%- endfor %} + +{% endif %} +{ +{%- for field in fields %} + + # Validator for {{ field.name }} +{%- if field.doc %} - {{ field.doc }}{%- endif %} + validate_{{ field.name }} = fun value => ( +{%- if field.type == "Text" %} + (std.is_string value) && + (std.string.length value > 0) +{%- if field.min_length %} && + (std.string.length value >= {{ field.min_length }}) +{%- endif %} +{%- if field.max_length %} && + (std.string.length value <= {{ field.max_length }}) +{%- endif %} +{%- else if field.type == "Number" %} + (std.is_number value) +{%- if field.min %} && + (value >= {{ field.min }}) +{%- endif %} +{%- if field.max %} && + (value <= {{ field.max }}) +{%- endif %} +{%- else if field.type == "Confirm" %} + (std.is_bool value) +{%- else if field.type == "Password" %} + (std.is_string value) && + (std.string.length value > 0) +{%- if field.min_length %} && + (std.string.length value >= {{ field.min_length }}) +{%- endif %} +{%- else if field.type == "Select" or field.type == "MultiSelect" %} + (std.is_string value) && + (std.array.contains {{ field.options | tojson }} value) +{%- else if field.type == "RepeatingGroup" %} + (std.is_array value) +{%- if field.min_items %} && + (std.array.length value >= {{ field.min_items }}) +{%- endif %} +{%- if field.max_items %} && + (std.array.length value <= {{ field.max_items }}) +{%- endif %} +{%- else %} + true +{%- endif %} + ), +{%- endfor %} +} diff --git a/crates/typedialog-prov-gen/templates/fragments/database-mysql-section.toml b/crates/typedialog-prov-gen/templates/fragments/database-mysql-section.toml new file mode 100644 index 0000000..e30df53 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/database-mysql-section.toml @@ -0,0 +1,86 @@ +name = "mysql_fragment" + +[[elements]] +name = "mysql_header" +type = "section_header" +title = "💾 MySQL Database Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "mysql_host" +type = "text" +prompt = "MySQL host" +placeholder = "localhost" +default = "localhost" +required = true +help = "MySQL server hostname or IP address" +nickel_path = [ + "tracker", + "core", + "database", + "mysql_host", +] +nickel_alias = "mysql_host" + +[[elements]] +name = "mysql_port" +type = "text" +prompt = "MySQL port" +placeholder = "3306" +default = "3306" +required = true +help = "MySQL server port (default 3306). Must be between 1-65535." +nickel_path = [ + "tracker", + "core", + "database", + "mysql_port", +] +nickel_alias = "mysql_port" + +[[elements]] +name = "mysql_database_name" +type = "text" +prompt = "Database name" +placeholder = "torrust_tracker" +default = "torrust_tracker" +required = true +help = "Name of the MySQL database" +nickel_path = [ + "tracker", + "core", + "database", + "database_name", +] +nickel_alias = "mysql_database_name" + +[[elements]] +name = "mysql_username" +type = "text" +prompt = "Database username" +placeholder = "tracker_user" +default = "tracker_user" +required = true +help = "MySQL username for authentication" +nickel_path = [ + "tracker", + "core", + "database", + "mysql_username", +] +nickel_alias = "mysql_username" + +[[elements]] +name = "mysql_password" +type = "password" +prompt = "Database password" +required = true +help = "MySQL password for authentication" +nickel_path = [ + "tracker", + "core", + "database", + "mysql_password", +] +nickel_alias = "mysql_password" diff --git a/crates/typedialog-prov-gen/templates/fragments/database-sqlite-section.toml b/crates/typedialog-prov-gen/templates/fragments/database-sqlite-section.toml new file mode 100644 index 0000000..8d7ce81 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/database-sqlite-section.toml @@ -0,0 +1,24 @@ +name = "sqlite_fragment" + +[[elements]] +name = "sqlite_header" +type = "section_header" +title = "💾 SQLite Database Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "sqlite_database_name" +type = "text" +prompt = "Database filename" +placeholder = "tracker.db" +default = "tracker.db" +required = true +help = "Name of the SQLite database file (will be created in the tracker data directory)" +nickel_path = [ + "tracker", + "core", + "database", + "database_name", +] +nickel_alias = "sqlite_database_name" diff --git a/crates/typedialog-prov-gen/templates/fragments/environment-section.toml b/crates/typedialog-prov-gen/templates/fragments/environment-section.toml new file mode 100644 index 0000000..8dd3f8c --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/environment-section.toml @@ -0,0 +1,31 @@ +name = "environment_fragment" + +[[elements]] +name = "environment_header" +type = "section_header" +title = "🏗️ Environment Identification" +border_top = true +border_bottom = true + +[[elements]] +name = "environment_name" +type = "text" +prompt = "Environment name" +placeholder = "dev, staging, production, e2e-test" +required = true +help = "Lowercase letters, numbers, dashes. Cannot start with number or dash. Examples: dev, staging, e2e-config" +nickel_path = [ + "environment", + "name", +] + +[[elements]] +name = "instance_name" +type = "text" +prompt = "Instance/VM name (optional)" +placeholder = "Leave empty for auto-generation: torrust-tracker-vm-{env-name}" +help = "1-63 chars, ASCII letters/numbers/dashes, no leading digit/dash, no trailing dash. Will be auto-generated if omitted." +nickel_path = [ + "environment", + "instance_name", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/grafana-section.toml b/crates/typedialog-prov-gen/templates/fragments/grafana-section.toml new file mode 100644 index 0000000..66f19fc --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/grafana-section.toml @@ -0,0 +1,31 @@ +name = "grafana_fragment" + +[[elements]] +name = "grafana_header" +type = "section_header" +title = "📈 Grafana Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "grafana_bind_address" +type = "text" +prompt = "Grafana bind address" +placeholder = "0.0.0.0:3000" +default = "0.0.0.0:3000" +help = "Address and port for Grafana. Format: IP:PORT (e.g., 0.0.0.0:3000)" +nickel_path = [ + "grafana", + "bind_address", +] + +[[elements]] +name = "grafana_admin_password" +type = "password" +prompt = "Grafana admin password" +required = true +help = "Admin password for Grafana access. Keep this secure!" +nickel_path = [ + "grafana", + "admin_password", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/prometheus-section.toml b/crates/typedialog-prov-gen/templates/fragments/prometheus-section.toml new file mode 100644 index 0000000..2c6e1ad --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/prometheus-section.toml @@ -0,0 +1,32 @@ +name = "prometheus_fragment" + +[[elements]] +name = "prometheus_header" +type = "section_header" +title = "📊 Prometheus Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "prometheus_bind_address" +type = "text" +prompt = "Prometheus bind address" +placeholder = "0.0.0.0:9090" +default = "0.0.0.0:9090" +help = "Address and port for Prometheus. Format: IP:PORT (e.g., 0.0.0.0:9090)" +nickel_path = [ + "prometheus", + "bind_address", +] + +[[elements]] +name = "prometheus_scrape_interval" +type = "text" +prompt = "Scrape interval (seconds)" +placeholder = "15" +default = "15" +help = "How often Prometheus should scrape metrics (in seconds). Default: 15 seconds." +nickel_path = [ + "prometheus", + "scrape_interval", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/provider-aws-section.toml b/crates/typedialog-prov-gen/templates/fragments/provider-aws-section.toml new file mode 100644 index 0000000..ac70507 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/provider-aws-section.toml @@ -0,0 +1,145 @@ +name = "aws_provider_fragment" + +[[elements]] +name = "aws_header" +type = "section_header" +title = "☁️ AWS Cloud Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "aws_access_key_id" +type = "text" +prompt = "AWS Access Key ID" +required = true +help = "Your AWS IAM Access Key ID for authentication" +nickel_path = [ + "provider", + "aws_access_key_id", +] + +[[elements]] +name = "aws_secret_access_key" +type = "password" +prompt = "AWS Secret Access Key" +required = true +help = "Your AWS IAM Secret Access Key (will be masked)" +nickel_path = [ + "provider", + "aws_secret_access_key", +] + +[[elements]] +name = "aws_region" +type = "select" +prompt = "AWS Region" +options = [ + { value = "us-east-1", label = "US East (N. Virginia)" }, + { value = "us-east-2", label = "US East (Ohio)" }, + { value = "us-west-1", label = "US West (N. California)" }, + { value = "us-west-2", label = "US West (Oregon)" }, + { value = "eu-west-1", label = "Europe (Ireland)" }, + { value = "eu-west-2", label = "Europe (London)" }, + { value = "eu-west-3", label = "Europe (Paris)" }, + { value = "eu-central-1", label = "Europe (Frankfurt)" }, + { value = "eu-north-1", label = "Europe (Stockholm)" }, + { value = "ap-northeast-1", label = "Asia Pacific (Tokyo)" }, + { value = "ap-northeast-2", label = "Asia Pacific (Seoul)" }, + { value = "ap-northeast-3", label = "Asia Pacific (Osaka)" }, + { value = "ap-southeast-1", label = "Asia Pacific (Singapore)" }, + { value = "ap-southeast-2", label = "Asia Pacific (Sydney)" }, + { value = "ap-south-1", label = "Asia Pacific (Mumbai)" }, + { value = "sa-east-1", label = "South America (São Paulo)" }, + { value = "ca-central-1", label = "Canada (Central)" }, +] +default = "us-east-1" +required = true +help = "AWS region where resources will be deployed" +nickel_path = [ + "provider", + "aws_region", +] + +[[elements]] +name = "aws_instance_type" +type = "select" +prompt = "EC2 Instance Type" +options = [ + { value = "t3.micro", label = "t3.micro - 2 vCPU, 1 GB RAM (Free tier)" }, + { value = "t3.small", label = "t3.small - 2 vCPU, 2 GB RAM" }, + { value = "t3.medium", label = "t3.medium - 2 vCPU, 4 GB RAM" }, + { value = "t3.large", label = "t3.large - 2 vCPU, 8 GB RAM" }, + { value = "t3.xlarge", label = "t3.xlarge - 4 vCPU, 16 GB RAM" }, + { value = "t3.2xlarge", label = "t3.2xlarge - 8 vCPU, 32 GB RAM" }, + { value = "m5.large", label = "m5.large - 2 vCPU, 8 GB RAM" }, + { value = "m5.xlarge", label = "m5.xlarge - 4 vCPU, 16 GB RAM" }, + { value = "m5.2xlarge", label = "m5.2xlarge - 8 vCPU, 32 GB RAM" }, + { value = "m5.4xlarge", label = "m5.4xlarge - 16 vCPU, 64 GB RAM" }, + { value = "c5.large", label = "c5.large - 2 vCPU, 4 GB RAM (compute optimized)" }, + { value = "c5.xlarge", label = "c5.xlarge - 4 vCPU, 8 GB RAM (compute optimized)" }, + { value = "r5.large", label = "r5.large - 2 vCPU, 16 GB RAM (memory optimized)" }, + { value = "r5.xlarge", label = "r5.xlarge - 4 vCPU, 32 GB RAM (memory optimized)" }, +] +default = "t3.medium" +required = true +help = "EC2 instance type (determines CPU, RAM, and pricing)" +nickel_path = [ + "provider", + "aws_instance_type", +] + +[[elements]] +name = "aws_ami" +type = "select" +prompt = "Amazon Machine Image (AMI)" +options = [ + { value = "ami-ubuntu-24-04", label = "Ubuntu 24.04 LTS (Latest)" }, + { value = "ami-ubuntu-22-04", label = "Ubuntu 22.04 LTS" }, + { value = "ami-ubuntu-20-04", label = "Ubuntu 20.04 LTS" }, + { value = "ami-debian-12", label = "Debian 12 (Bookworm)" }, + { value = "ami-debian-11", label = "Debian 11 (Bullseye)" }, + { value = "ami-amazon-linux-2", label = "Amazon Linux 2" }, + { value = "ami-amazon-linux-2023", label = "Amazon Linux 2023" }, +] +default = "ami-ubuntu-24-04" +required = true +help = "Operating system image for EC2 instances" +nickel_path = [ + "provider", + "aws_ami", +] + +[[elements]] +name = "aws_vpc_cidr" +type = "text" +prompt = "VPC CIDR Block" +default = "10.0.0.0/16" +required = true +help = "CIDR block for the VPC (e.g., 10.0.0.0/16)" +nickel_path = [ + "provider", + "aws_vpc_cidr", +] + +[[elements]] +name = "aws_subnet_cidr" +type = "text" +prompt = "Subnet CIDR Block" +default = "10.0.1.0/24" +required = true +help = "CIDR block for the subnet (e.g., 10.0.1.0/24)" +nickel_path = [ + "provider", + "aws_subnet_cidr", +] + +[[elements]] +name = "aws_ssh_key_name" +type = "text" +prompt = "SSH Key Pair Name" +required = true +help = "Name of the EC2 SSH key pair for instance access" +nickel_path = [ + "provider", + "aws_ssh_key_name", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/provider-azure-section.toml b/crates/typedialog-prov-gen/templates/fragments/provider-azure-section.toml new file mode 100644 index 0000000..a949d6c --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/provider-azure-section.toml @@ -0,0 +1,305 @@ +name = "azure_provider_fragment" + +[[elements]] +name = "azure_header" +type = "section_header" +title = "☁️ Microsoft Azure Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "azure_subscription_id" +type = "text" +prompt = "Azure Subscription ID" +required = true +help = "Your Azure subscription ID (GUID format)" +nickel_path = [ + "provider", + "azure_subscription_id", +] + +[[elements]] +name = "azure_tenant_id" +type = "text" +prompt = "Azure Tenant ID" +required = true +help = "Your Azure Active Directory tenant ID" +nickel_path = [ + "provider", + "azure_tenant_id", +] + +[[elements]] +name = "azure_client_id" +type = "text" +prompt = "Service Principal Client ID" +required = true +help = "Client ID of the service principal (app registration)" +nickel_path = [ + "provider", + "azure_client_id", +] + +[[elements]] +name = "azure_client_secret" +type = "password" +prompt = "Service Principal Client Secret" +required = true +help = "Client secret for authentication (will be masked)" +nickel_path = [ + "provider", + "azure_client_secret", +] + +[[elements]] +name = "azure_location" +type = "select" +prompt = "Azure Region" +options = [ + { value = "eastus", label = "East US - Virginia, USA" }, + { value = "eastus2", label = "East US 2 - Virginia, USA" }, + { value = "westus", label = "West US - California, USA" }, + { value = "westus2", label = "West US 2 - Washington, USA" }, + { value = "westus3", label = "West US 3 - Arizona, USA" }, + { value = "centralus", label = "Central US - Iowa, USA" }, + { value = "northcentralus", label = "North Central US - Illinois, USA" }, + { value = "southcentralus", label = "South Central US - Texas, USA" }, + { value = "northeurope", label = "North Europe - Ireland" }, + { value = "westeurope", label = "West Europe - Netherlands" }, + { value = "francecentral", label = "France Central - Paris" }, + { value = "germanywestcentral", label = "Germany West Central - Frankfurt" }, + { value = "switzerlandnorth", label = "Switzerland North - Zurich" }, + { value = "uksouth", label = "UK South - London" }, + { value = "ukwest", label = "UK West - Cardiff" }, + { value = "norwayeast", label = "Norway East - Oslo" }, + { value = "swedencentral", label = "Sweden Central - Gävle" }, + { value = "eastasia", label = "East Asia - Hong Kong" }, + { value = "southeastasia", label = "Southeast Asia - Singapore" }, + { value = "japaneast", label = "Japan East - Tokyo" }, + { value = "japanwest", label = "Japan West - Osaka" }, + { value = "koreacentral", label = "Korea Central - Seoul" }, + { value = "australiaeast", label = "Australia East - Sydney" }, + { value = "australiasoutheast", label = "Australia Southeast - Melbourne" }, + { value = "canadacentral", label = "Canada Central - Toronto" }, + { value = "canadaeast", label = "Canada East - Quebec" }, + { value = "brazilsouth", label = "Brazil South - São Paulo" }, + { value = "southafricanorth", label = "South Africa North - Johannesburg" }, + { value = "uaenorth", label = "UAE North - Dubai" }, + { value = "centralindia", label = "Central India - Pune" }, + { value = "southindia", label = "South India - Chennai" }, +] +default = "westeurope" +required = true +help = "Azure region where resources will be deployed" +nickel_path = [ + "provider", + "azure_location", +] + +[[elements]] +name = "azure_resource_group_name" +type = "text" +prompt = "Resource Group name" +required = true +help = "Name of the Azure Resource Group (will be created if doesn't exist)" +nickel_path = [ + "provider", + "azure_resource_group_name", +] + +[[elements]] +name = "azure_vm_size" +type = "select" +prompt = "Virtual Machine Size" +options = [ + { value = "Standard_B1s", label = "Standard_B1s - 1 vCPU, 1 GB RAM (burstable)" }, + { value = "Standard_B1ms", label = "Standard_B1ms - 1 vCPU, 2 GB RAM (burstable)" }, + { value = "Standard_B2s", label = "Standard_B2s - 2 vCPU, 4 GB RAM (burstable)" }, + { value = "Standard_B2ms", label = "Standard_B2ms - 2 vCPU, 8 GB RAM (burstable)" }, + { value = "Standard_B4ms", label = "Standard_B4ms - 4 vCPU, 16 GB RAM (burstable)" }, + { value = "Standard_D2s_v3", label = "Standard_D2s_v3 - 2 vCPU, 8 GB RAM (general purpose)" }, + { value = "Standard_D4s_v3", label = "Standard_D4s_v3 - 4 vCPU, 16 GB RAM (general purpose)" }, + { value = "Standard_D8s_v3", label = "Standard_D8s_v3 - 8 vCPU, 32 GB RAM (general purpose)" }, + { value = "Standard_E2s_v3", label = "Standard_E2s_v3 - 2 vCPU, 16 GB RAM (memory optimized)" }, + { value = "Standard_E4s_v3", label = "Standard_E4s_v3 - 4 vCPU, 32 GB RAM (memory optimized)" }, + { value = "Standard_F2s_v2", label = "Standard_F2s_v2 - 2 vCPU, 4 GB RAM (compute optimized)" }, + { value = "Standard_F4s_v2", label = "Standard_F4s_v2 - 4 vCPU, 8 GB RAM (compute optimized)" }, +] +default = "Standard_B2s" +required = true +help = "Azure VM size (determines CPU, RAM, and pricing)" +nickel_path = [ + "provider", + "azure_vm_size", +] + +[[elements]] +name = "azure_image_publisher" +type = "select" +prompt = "Image Publisher" +options = [ + { value = "Canonical", label = "Canonical (Ubuntu)" }, + { value = "Debian", label = "Debian (Debian Linux)" }, + { value = "RedHat", label = "Red Hat (RHEL)" }, + { value = "OpenLogic", label = "OpenLogic (CentOS)" }, + { value = "AlmaLinux", label = "AlmaLinux Foundation" }, + { value = "MicrosoftWindowsServer", label = "Microsoft (Windows Server)" }, +] +default = "Canonical" +required = true +help = "Publisher of the VM image" +nickel_path = [ + "provider", + "azure_image_publisher", +] + +[[elements]] +name = "azure_image_offer" +type = "select" +prompt = "Image Offer" +options = [ + { value = "0001-com-ubuntu-server-jammy", label = "Ubuntu Server 22.04 LTS" }, + { value = "0001-com-ubuntu-server-focal", label = "Ubuntu Server 20.04 LTS" }, + { value = "debian-11", label = "Debian 11" }, + { value = "debian-12", label = "Debian 12" }, + { value = "RHEL", label = "Red Hat Enterprise Linux" }, + { value = "CentOS", label = "CentOS" }, + { value = "almalinux", label = "AlmaLinux" }, +] +default = "0001-com-ubuntu-server-jammy" +required = true +help = "Specific offer from the publisher (must match publisher)" +nickel_path = [ + "provider", + "azure_image_offer", +] + +[[elements]] +name = "azure_image_sku" +type = "select" +prompt = "Image SKU" +options = [ + { value = "22_04-lts-gen2", label = "Ubuntu 22.04 LTS Gen2" }, + { value = "20_04-lts-gen2", label = "Ubuntu 20.04 LTS Gen2" }, + { value = "11-gen2", label = "Debian 11 Gen2" }, + { value = "12-gen2", label = "Debian 12 Gen2" }, + { value = "9-lvm-gen2", label = "RHEL 9 Gen2" }, + { value = "8-lvm-gen2", label = "RHEL 8 Gen2" }, +] +default = "22_04-lts-gen2" +required = true +help = "SKU (version) of the image (must match offer)" +nickel_path = [ + "provider", + "azure_image_sku", +] + +[[elements]] +name = "azure_os_disk_size_gb" +type = "text" +prompt = "OS Disk size (GB)" +default = "30" +required = true +help = "Size of the OS disk in GB (minimum 30 GB)" +nickel_path = [ + "provider", + "azure_os_disk_size_gb", +] + +[[elements]] +name = "azure_os_disk_type" +type = "select" +prompt = "OS Disk type" +options = [ + { value = "Standard_LRS", label = "Standard_LRS - Standard HDD (locally redundant)" }, + { value = "StandardSSD_LRS", label = "StandardSSD_LRS - Standard SSD (locally redundant)" }, + { value = "Premium_LRS", label = "Premium_LRS - Premium SSD (locally redundant)" }, + { value = "UltraSSD_LRS", label = "UltraSSD_LRS - Ultra SSD (highest performance)" }, +] +default = "StandardSSD_LRS" +required = true +help = "Type of managed disk for OS volume" +nickel_path = [ + "provider", + "azure_os_disk_type", +] + +[[elements]] +name = "azure_admin_username" +type = "text" +prompt = "Admin username" +default = "azureuser" +required = true +help = "Administrator username for the VM" +nickel_path = [ + "provider", + "azure_admin_username", +] + +[[elements]] +name = "azure_ssh_public_key" +type = "text" +prompt = "SSH public key" +required = true +help = "SSH public key for authentication (e.g., ~/.ssh/id_rsa.pub content)" +nickel_path = [ + "provider", + "azure_ssh_public_key", +] + +[[elements]] +name = "azure_vnet_address_space" +type = "text" +prompt = "Virtual Network address space" +default = "10.0.0.0/16" +required = true +help = "Address space for the Virtual Network (CIDR notation)" +nickel_path = [ + "provider", + "azure_vnet_address_space", +] + +[[elements]] +name = "azure_subnet_address_prefix" +type = "text" +prompt = "Subnet address prefix" +default = "10.0.1.0/24" +required = true +help = "Address prefix for the subnet (CIDR notation)" +nickel_path = [ + "provider", + "azure_subnet_address_prefix", +] + +[[elements]] +name = "azure_enable_public_ip" +type = "confirm" +prompt = "Assign public IP address?" +default = true +help = "Assign a public IP to the VM for external access" +nickel_path = [ + "provider", + "azure_enable_public_ip", +] + +[[elements]] +name = "azure_enable_accelerated_networking" +type = "confirm" +prompt = "Enable accelerated networking?" +default = false +help = "Enable SR-IOV for better network performance (requires compatible VM size)" +nickel_path = [ + "provider", + "azure_enable_accelerated_networking", +] + +[[elements]] +name = "azure_enable_boot_diagnostics" +type = "confirm" +prompt = "Enable boot diagnostics?" +default = true +help = "Enable boot diagnostics for troubleshooting" +nickel_path = [ + "provider", + "azure_enable_boot_diagnostics", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/provider-gcp-section.toml b/crates/typedialog-prov-gen/templates/fragments/provider-gcp-section.toml new file mode 100644 index 0000000..361c23a --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/provider-gcp-section.toml @@ -0,0 +1,256 @@ +name = "gcp_provider_fragment" + +[[elements]] +name = "gcp_header" +type = "section_header" +title = "☁️ Google Cloud Platform (GCP) Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "gcp_project_id" +type = "text" +prompt = "GCP Project ID" +required = true +help = "Your Google Cloud Project ID (e.g., my-project-123456)" +nickel_path = [ + "provider", + "gcp_project_id", +] + +[[elements]] +name = "gcp_credentials_file" +type = "text" +prompt = "Service Account credentials file path" +required = true +help = "Path to service account JSON key file (e.g., ~/.gcp/credentials.json)" +nickel_path = [ + "provider", + "gcp_credentials_file", +] + +[[elements]] +name = "gcp_region" +type = "select" +prompt = "GCP Region" +options = [ + { value = "us-central1", label = "US-CENTRAL1 - Iowa, USA" }, + { value = "us-east1", label = "US-EAST1 - South Carolina, USA" }, + { value = "us-east4", label = "US-EAST4 - Northern Virginia, USA" }, + { value = "us-west1", label = "US-WEST1 - Oregon, USA" }, + { value = "us-west2", label = "US-WEST2 - Los Angeles, USA" }, + { value = "us-west3", label = "US-WEST3 - Salt Lake City, USA" }, + { value = "us-west4", label = "US-WEST4 - Las Vegas, USA" }, + { value = "europe-west1", label = "EUROPE-WEST1 - Belgium" }, + { value = "europe-west2", label = "EUROPE-WEST2 - London, UK" }, + { value = "europe-west3", label = "EUROPE-WEST3 - Frankfurt, Germany" }, + { value = "europe-west4", label = "EUROPE-WEST4 - Netherlands" }, + { value = "europe-west6", label = "EUROPE-WEST6 - Zurich, Switzerland" }, + { value = "europe-north1", label = "EUROPE-NORTH1 - Finland" }, + { value = "asia-east1", label = "ASIA-EAST1 - Taiwan" }, + { value = "asia-east2", label = "ASIA-EAST2 - Hong Kong" }, + { value = "asia-northeast1", label = "ASIA-NORTHEAST1 - Tokyo, Japan" }, + { value = "asia-northeast2", label = "ASIA-NORTHEAST2 - Osaka, Japan" }, + { value = "asia-northeast3", label = "ASIA-NORTHEAST3 - Seoul, South Korea" }, + { value = "asia-south1", label = "ASIA-SOUTH1 - Mumbai, India" }, + { value = "asia-southeast1", label = "ASIA-SOUTHEAST1 - Singapore" }, + { value = "asia-southeast2", label = "ASIA-SOUTHEAST2 - Jakarta, Indonesia" }, + { value = "australia-southeast1", label = "AUSTRALIA-SOUTHEAST1 - Sydney, Australia" }, + { value = "southamerica-east1", label = "SOUTHAMERICA-EAST1 - São Paulo, Brazil" }, +] +default = "europe-west3" +required = true +help = "GCP region where resources will be deployed" +nickel_path = [ + "provider", + "gcp_region", +] + +[[elements]] +name = "gcp_zone" +type = "select" +prompt = "Availability Zone" +options = [ + { value = "a", label = "Zone A (primary)" }, + { value = "b", label = "Zone B" }, + { value = "c", label = "Zone C" }, + { value = "d", label = "Zone D (if available)" }, +] +default = "a" +required = true +help = "Zone within the selected region (e.g., 'a' for europe-west3-a)" +nickel_path = [ + "provider", + "gcp_zone", +] + +[[elements]] +name = "gcp_machine_type" +type = "select" +prompt = "Machine Type" +options = [ + { value = "e2-micro", label = "e2-micro - 2 vCPU (shared), 1 GB RAM (Free tier)" }, + { value = "e2-small", label = "e2-small - 2 vCPU (shared), 2 GB RAM" }, + { value = "e2-medium", label = "e2-medium - 2 vCPU (shared), 4 GB RAM" }, + { value = "e2-standard-2", label = "e2-standard-2 - 2 vCPU, 8 GB RAM" }, + { value = "e2-standard-4", label = "e2-standard-4 - 4 vCPU, 16 GB RAM" }, + { value = "e2-standard-8", label = "e2-standard-8 - 8 vCPU, 32 GB RAM" }, + { value = "n1-standard-1", label = "n1-standard-1 - 1 vCPU, 3.75 GB RAM" }, + { value = "n1-standard-2", label = "n1-standard-2 - 2 vCPU, 7.5 GB RAM" }, + { value = "n1-standard-4", label = "n1-standard-4 - 4 vCPU, 15 GB RAM" }, + { value = "n2-standard-2", label = "n2-standard-2 - 2 vCPU, 8 GB RAM (newer generation)" }, + { value = "n2-standard-4", label = "n2-standard-4 - 4 vCPU, 16 GB RAM (newer generation)" }, + { value = "n2-standard-8", label = "n2-standard-8 - 8 vCPU, 32 GB RAM (newer generation)" }, + { value = "n2-highmem-2", label = "n2-highmem-2 - 2 vCPU, 16 GB RAM (memory optimized)" }, + { value = "n2-highmem-4", label = "n2-highmem-4 - 4 vCPU, 32 GB RAM (memory optimized)" }, + { value = "c2-standard-4", label = "c2-standard-4 - 4 vCPU, 16 GB RAM (compute optimized)" }, + { value = "c2-standard-8", label = "c2-standard-8 - 8 vCPU, 32 GB RAM (compute optimized)" }, +] +default = "e2-medium" +required = true +help = "GCP Compute Engine machine type (determines CPU, RAM, and pricing)" +nickel_path = [ + "provider", + "gcp_machine_type", +] + +[[elements]] +name = "gcp_image_family" +type = "select" +prompt = "Image Family" +options = [ + { value = "ubuntu-2404-lts", label = "Ubuntu 24.04 LTS (Latest)" }, + { value = "ubuntu-2204-lts", label = "Ubuntu 22.04 LTS" }, + { value = "ubuntu-2004-lts", label = "Ubuntu 20.04 LTS" }, + { value = "debian-12", label = "Debian 12 (Bookworm)" }, + { value = "debian-11", label = "Debian 11 (Bullseye)" }, + { value = "rocky-linux-9", label = "Rocky Linux 9" }, + { value = "rocky-linux-8", label = "Rocky Linux 8" }, + { value = "rhel-9", label = "Red Hat Enterprise Linux 9" }, + { value = "rhel-8", label = "Red Hat Enterprise Linux 8" }, + { value = "centos-stream-9", label = "CentOS Stream 9" }, +] +default = "ubuntu-2404-lts" +required = true +help = "Operating system image family for the instance" +nickel_path = [ + "provider", + "gcp_image_family", +] + +[[elements]] +name = "gcp_image_project" +type = "select" +prompt = "Image Project" +options = [ + { value = "ubuntu-os-cloud", label = "ubuntu-os-cloud (Ubuntu images)" }, + { value = "debian-cloud", label = "debian-cloud (Debian images)" }, + { value = "rocky-linux-cloud", label = "rocky-linux-cloud (Rocky Linux)" }, + { value = "rhel-cloud", label = "rhel-cloud (Red Hat)" }, + { value = "centos-cloud", label = "centos-cloud (CentOS)" }, +] +default = "ubuntu-os-cloud" +required = true +help = "GCP project that provides the image" +nickel_path = [ + "provider", + "gcp_image_project", +] + +[[elements]] +name = "gcp_disk_size_gb" +type = "text" +prompt = "Boot disk size (GB)" +default = "20" +required = true +help = "Boot disk size in GB (minimum 10 GB)" +nickel_path = [ + "provider", + "gcp_disk_size_gb", +] + +[[elements]] +name = "gcp_disk_type" +type = "select" +prompt = "Disk type" +options = [ + { value = "pd-standard", label = "pd-standard - Standard persistent disk (HDD)" }, + { value = "pd-balanced", label = "pd-balanced - Balanced persistent disk (SSD, recommended)" }, + { value = "pd-ssd", label = "pd-ssd - SSD persistent disk (high performance)" }, + { value = "pd-extreme", label = "pd-extreme - Extreme persistent disk (highest IOPS)" }, +] +default = "pd-balanced" +required = true +help = "Type of persistent disk for boot volume" +nickel_path = [ + "provider", + "gcp_disk_type", +] + +[[elements]] +name = "gcp_network_name" +type = "text" +prompt = "VPC Network name" +default = "default" +required = true +help = "Name of the VPC network (use 'default' for default network)" +nickel_path = [ + "provider", + "gcp_network_name", +] + +[[elements]] +name = "gcp_subnetwork_name" +type = "text" +prompt = "Subnetwork name" +default = "default" +required = true +help = "Name of the subnetwork within the VPC" +nickel_path = [ + "provider", + "gcp_subnetwork_name", +] + +[[elements]] +name = "gcp_enable_external_ip" +type = "confirm" +prompt = "Enable external IP address?" +default = true +help = "Assign a public IP address to the instance" +nickel_path = [ + "provider", + "gcp_enable_external_ip", +] + +[[elements]] +name = "gcp_preemptible" +type = "confirm" +prompt = "Use preemptible instance?" +default = false +help = "Preemptible instances are cheaper but can be stopped by GCP (not for production)" +nickel_path = [ + "provider", + "gcp_preemptible", +] + +[[elements]] +name = "gcp_enable_deletion_protection" +type = "confirm" +prompt = "Enable deletion protection?" +default = false +help = "Prevent accidental deletion of the instance" +nickel_path = [ + "provider", + "gcp_enable_deletion_protection", +] + +[[elements]] +name = "gcp_ssh_keys" +type = "text" +prompt = "SSH public key (optional)" +required = false +help = "SSH public key for instance access (leave empty to skip)" +nickel_path = [ + "provider", + "gcp_ssh_keys", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/provider-hetzner-section.toml b/crates/typedialog-prov-gen/templates/fragments/provider-hetzner-section.toml new file mode 100644 index 0000000..b800a13 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/provider-hetzner-section.toml @@ -0,0 +1,89 @@ +name = "hetzner_provider_fragment" + +[[elements]] +name = "hetzner_header" +type = "section_header" +title = "☁️ Hetzner Cloud Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "hetzner_api_token" +type = "password" +prompt = "Hetzner API token" +required = true +help = "Your Hetzner Cloud API token for authentication" +nickel_path = [ + "provider", + "hetzner_api_token", +] + +[[elements]] +name = "hetzner_server_type" +type = "select" +prompt = "Server type" +options = [ + { value = "cx11", label = "CX11 - 1 vCPU, 1 GB RAM" }, + { value = "cx21", label = "CX21 - 2 vCPU, 4 GB RAM" }, + { value = "cx31", label = "CX31 - 2 vCPU, 8 GB RAM" }, + { value = "cx41", label = "CX41 - 4 vCPU, 16 GB RAM" }, + { value = "cx51", label = "CX51 - 8 vCPU, 32 GB RAM" }, + { value = "cpx11", label = "CPX11 - 2 vCPU (dedicated), 4 GB RAM" }, + { value = "cpx21", label = "CPX21 - 4 vCPU (dedicated), 8 GB RAM" }, + { value = "cpx31", label = "CPX31 - 8 vCPU (dedicated), 16 GB RAM" }, + { value = "cx22", label = "CX22 - 2 vCPU, 4 GB RAM" }, + { value = "cx32", label = "CX32 - 2 vCPU, 8 GB RAM" }, + { value = "cx42", label = "CX42 - 4 vCPU, 16 GB RAM" }, + { value = "cx52", label = "CX52 - 8 vCPU, 32 GB RAM" }, +] +default = "cx22" +required = true +help = "Hetzner Cloud server instance type" +nickel_path = [ + "provider", + "hetzner_server_type", +] + +[[elements]] +name = "hetzner_location" +type = "select" +prompt = "Datacenter location" +options = [ + { value = "fsn1", label = "FSN1 - Frankfurt, Germany" }, + { value = "fsn1-dc14", label = "FSN1-DC14 - Frankfurt 14, Germany" }, + { value = "nbg1", label = "NBG1 - Nuremberg, Germany" }, + { value = "nbg1-dc3", label = "NBG1-DC3 - Nuremberg 3, Germany" }, + { value = "hel1", label = "HEL1 - Helsinki, Finland" }, + { value = "hel1-dc8", label = "HEL1-DC8 - Helsinki 8, Finland" }, + { value = "ash", label = "ASH - Ashburn, Virginia USA" }, + { value = "ash-dc1", label = "ASH-DC1 - Ashburn 1, Virginia USA" }, + { value = "hil", label = "HIL - Hildesheim, Germany" }, + { value = "hil-dc1", label = "HIL-DC1 - Hildesheim 1, Germany" }, +] +default = "nbg1" +required = true +help = "Hetzner datacenter location" +nickel_path = [ + "provider", + "hetzner_location", +] + +[[elements]] +name = "hetzner_image" +type = "select" +prompt = "Operating system image" +options = [ + { value = "ubuntu-24.04", label = "Ubuntu 24.04 LTS (Latest)" }, + { value = "ubuntu-22.04", label = "Ubuntu 22.04 LTS" }, + { value = "ubuntu-20.04", label = "Ubuntu 20.04 LTS" }, + { value = "debian-12", label = "Debian 12 (Bookworm)" }, + { value = "debian-11", label = "Debian 11 (Bullseye)" }, + { value = "debian-10", label = "Debian 10 (Buster)" }, +] +default = "ubuntu-24.04" +required = true +help = "OS image to use for the server" +nickel_path = [ + "provider", + "hetzner_image", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/provider-lxd-section.toml b/crates/typedialog-prov-gen/templates/fragments/provider-lxd-section.toml new file mode 100644 index 0000000..ff31aa7 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/provider-lxd-section.toml @@ -0,0 +1,247 @@ +name = "lxd_provider_fragment" + +[[elements]] +name = "lxd_header" +type = "section_header" +title = "🖥️ LXD Container/VM Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "lxd_remote" +type = "select" +prompt = "LXD Remote" +options = [ + { value = "local", label = "local - Local LXD server" }, + { value = "remote", label = "remote - Remote LXD server" }, +] +default = "local" +required = true +help = "Use local LXD or connect to remote LXD server" +nickel_path = [ + "provider", + "lxd_remote", +] + +[[elements]] +name = "lxd_remote_address" +type = "text" +prompt = "Remote server address (if remote)" +required = false +help = "Address of remote LXD server (e.g., https://lxd.example.com:8443)" +nickel_path = [ + "provider", + "lxd_remote_address", +] + +[[elements]] +name = "lxd_remote_password" +type = "password" +prompt = "Remote server password (if remote)" +required = false +help = "Trust password for remote LXD server (will be masked)" +nickel_path = [ + "provider", + "lxd_remote_password", +] + +[[elements]] +name = "lxd_instance_type" +type = "select" +prompt = "Instance Type" +options = [ + { value = "container", label = "Container - Lightweight, shared kernel" }, + { value = "virtual-machine", label = "Virtual Machine - Full VM with own kernel" }, +] +default = "container" +required = true +help = "Run as container (fast, lightweight) or virtual machine (isolated)" +nickel_path = [ + "provider", + "lxd_instance_type", +] + +[[elements]] +name = "lxd_image" +type = "select" +prompt = "Base Image" +options = [ + { value = "ubuntu:24.04", label = "Ubuntu 24.04 LTS (Noble)" }, + { value = "ubuntu:22.04", label = "Ubuntu 22.04 LTS (Jammy)" }, + { value = "ubuntu:20.04", label = "Ubuntu 20.04 LTS (Focal)" }, + { value = "debian:12", label = "Debian 12 (Bookworm)" }, + { value = "debian:11", label = "Debian 11 (Bullseye)" }, + { value = "alpine:3.19", label = "Alpine Linux 3.19 (minimal)" }, + { value = "alpine:3.18", label = "Alpine Linux 3.18" }, + { value = "rockylinux:9", label = "Rocky Linux 9" }, + { value = "rockylinux:8", label = "Rocky Linux 8" }, + { value = "archlinux", label = "Arch Linux (rolling release)" }, +] +default = "ubuntu:24.04" +required = true +help = "Operating system image for the instance" +nickel_path = [ + "provider", + "lxd_image", +] + +[[elements]] +name = "lxd_instance_name" +type = "text" +prompt = "Instance name" +required = true +help = "Unique name for the LXD instance" +nickel_path = [ + "provider", + "lxd_instance_name", +] + +[[elements]] +name = "lxd_cpu_limit" +type = "text" +prompt = "CPU limit (cores)" +default = "2" +required = false +help = "Number of CPU cores (e.g., 2 or leave empty for unlimited)" +nickel_path = [ + "provider", + "lxd_cpu_limit", +] + +[[elements]] +name = "lxd_memory_limit" +type = "text" +prompt = "Memory limit" +default = "2GB" +required = false +help = "Memory limit (e.g., 2GB, 4GB, or leave empty for unlimited)" +nickel_path = [ + "provider", + "lxd_memory_limit", +] + +[[elements]] +name = "lxd_disk_size" +type = "text" +prompt = "Root disk size" +default = "10GB" +required = false +help = "Root disk size (e.g., 10GB, 20GB, or leave empty for default)" +nickel_path = [ + "provider", + "lxd_disk_size", +] + +[[elements]] +name = "lxd_storage_pool" +type = "select" +prompt = "Storage pool" +options = [ + { value = "default", label = "default - Default storage pool" }, + { value = "dir", label = "dir - Directory-backed pool" }, + { value = "zfs", label = "zfs - ZFS pool (best performance)" }, + { value = "btrfs", label = "btrfs - Btrfs pool" }, + { value = "lvm", label = "lvm - LVM pool" }, +] +default = "default" +required = true +help = "Storage pool for the instance root disk" +nickel_path = [ + "provider", + "lxd_storage_pool", +] + +[[elements]] +name = "lxd_network" +type = "select" +prompt = "Network" +options = [ + { value = "lxdbr0", label = "lxdbr0 - Default LXD bridge (NAT)" }, + { value = "host", label = "host - Direct host networking" }, + { value = "macvlan", label = "macvlan - MAC VLAN (bridge to physical)" }, +] +default = "lxdbr0" +required = true +help = "Network configuration for the instance" +nickel_path = [ + "provider", + "lxd_network", +] + +[[elements]] +name = "lxd_ipv4_address" +type = "text" +prompt = "Static IPv4 address (optional)" +required = false +help = "Assign static IPv4 (e.g., 10.0.0.100) or leave empty for DHCP" +nickel_path = [ + "provider", + "lxd_ipv4_address", +] + +[[elements]] +name = "lxd_ipv6_address" +type = "text" +prompt = "Static IPv6 address (optional)" +required = false +help = "Assign static IPv6 or leave empty for auto" +nickel_path = [ + "provider", + "lxd_ipv6_address", +] + +[[elements]] +name = "lxd_profiles" +type = "text" +prompt = "Additional profiles (comma-separated)" +default = "default" +required = false +help = "LXD profiles to apply (e.g., default,docker)" +nickel_path = [ + "provider", + "lxd_profiles", +] + +[[elements]] +name = "lxd_enable_nesting" +type = "confirm" +prompt = "Enable nesting (for Docker/LXD inside)?" +default = false +help = "Allow running containers/VMs inside this instance" +nickel_path = [ + "provider", + "lxd_enable_nesting", +] + +[[elements]] +name = "lxd_privileged" +type = "confirm" +prompt = "Run as privileged container?" +default = false +help = "Run container without user namespace isolation (less secure)" +nickel_path = [ + "provider", + "lxd_privileged", +] + +[[elements]] +name = "lxd_autostart" +type = "confirm" +prompt = "Auto-start on boot?" +default = true +help = "Automatically start instance when LXD daemon starts" +nickel_path = [ + "provider", + "lxd_autostart", +] + +[[elements]] +name = "lxd_cloud_init" +type = "text" +prompt = "Cloud-init user data (optional)" +required = false +help = "Path to cloud-init configuration file (leave empty to skip)" +nickel_path = [ + "provider", + "lxd_cloud_init", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/provider-upcloud-section.toml b/crates/typedialog-prov-gen/templates/fragments/provider-upcloud-section.toml new file mode 100644 index 0000000..6d07bcf --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/provider-upcloud-section.toml @@ -0,0 +1,149 @@ +name = "upcloud_provider_fragment" + +[[elements]] +name = "upcloud_header" +type = "section_header" +title = "☁️ UpCloud Configuration" +border_top = true +border_bottom = true + +[[elements]] +name = "upcloud_username" +type = "text" +prompt = "UpCloud username" +required = true +help = "Your UpCloud account username for API authentication" +nickel_path = [ + "provider", + "upcloud_username", +] + +[[elements]] +name = "upcloud_password" +type = "password" +prompt = "UpCloud password" +required = true +help = "Your UpCloud account password (will be masked)" +nickel_path = [ + "provider", + "upcloud_password", +] + +[[elements]] +name = "upcloud_zone" +type = "select" +prompt = "Availability zone" +options = [ + { value = "fi-hel1", label = "FI-HEL1 - Helsinki, Finland" }, + { value = "fi-hel2", label = "FI-HEL2 - Helsinki, Finland (Secondary)" }, + { value = "de-fra1", label = "DE-FRA1 - Frankfurt, Germany" }, + { value = "uk-lon1", label = "UK-LON1 - London, United Kingdom" }, + { value = "nl-ams1", label = "NL-AMS1 - Amsterdam, Netherlands" }, + { value = "us-chi1", label = "US-CHI1 - Chicago, USA" }, + { value = "us-nyc1", label = "US-NYC1 - New York, USA" }, + { value = "us-sjo1", label = "US-SJO1 - San Jose, USA" }, + { value = "sg-sin1", label = "SG-SIN1 - Singapore" }, + { value = "au-syd1", label = "AU-SYD1 - Sydney, Australia" }, + { value = "es-mad1", label = "ES-MAD1 - Madrid, Spain" }, + { value = "pl-waw1", label = "PL-WAW1 - Warsaw, Poland" }, +] +default = "de-fra1" +required = true +help = "UpCloud zone where resources will be deployed" +nickel_path = [ + "provider", + "upcloud_zone", +] + +[[elements]] +name = "upcloud_plan" +type = "select" +prompt = "Server plan" +options = [ + { value = "1xCPU-1GB", label = "1xCPU-1GB - 1 vCPU, 1 GB RAM, 25 GB SSD" }, + { value = "1xCPU-2GB", label = "1xCPU-2GB - 1 vCPU, 2 GB RAM, 50 GB SSD" }, + { value = "2xCPU-4GB", label = "2xCPU-4GB - 2 vCPU, 4 GB RAM, 80 GB SSD" }, + { value = "4xCPU-8GB", label = "4xCPU-8GB - 4 vCPU, 8 GB RAM, 160 GB SSD" }, + { value = "6xCPU-16GB", label = "6xCPU-16GB - 6 vCPU, 16 GB RAM, 320 GB SSD" }, + { value = "8xCPU-32GB", label = "8xCPU-32GB - 8 vCPU, 32 GB RAM, 640 GB SSD" }, + { value = "12xCPU-48GB", label = "12xCPU-48GB - 12 vCPU, 48 GB RAM, 960 GB SSD" }, + { value = "16xCPU-64GB", label = "16xCPU-64GB - 16 vCPU, 64 GB RAM, 1280 GB SSD" }, + { value = "20xCPU-96GB", label = "20xCPU-96GB - 20 vCPU, 96 GB RAM, 1920 GB SSD" }, + { value = "20xCPU-128GB", label = "20xCPU-128GB - 20 vCPU, 128 GB RAM, 2048 GB SSD" }, +] +default = "2xCPU-4GB" +required = true +help = "UpCloud server plan (determines CPU, RAM, and storage)" +nickel_path = [ + "provider", + "upcloud_plan", +] + +[[elements]] +name = "upcloud_template" +type = "select" +prompt = "Operating system template" +options = [ + { value = "Ubuntu Server 24.04 LTS (Noble Numbat)", label = "Ubuntu 24.04 LTS (Latest)" }, + { value = "Ubuntu Server 22.04 LTS (Jammy Jellyfish)", label = "Ubuntu 22.04 LTS" }, + { value = "Ubuntu Server 20.04 LTS (Focal Fossa)", label = "Ubuntu 20.04 LTS" }, + { value = "Debian 12 (Bookworm)", label = "Debian 12 (Bookworm)" }, + { value = "Debian 11 (Bullseye)", label = "Debian 11 (Bullseye)" }, + { value = "Debian 10 (Buster)", label = "Debian 10 (Buster)" }, + { value = "Rocky Linux 9", label = "Rocky Linux 9" }, + { value = "Rocky Linux 8", label = "Rocky Linux 8" }, + { value = "AlmaLinux 9", label = "AlmaLinux 9" }, + { value = "AlmaLinux 8", label = "AlmaLinux 8" }, +] +default = "Ubuntu Server 24.04 LTS (Noble Numbat)" +required = true +help = "Operating system template for the server" +nickel_path = [ + "provider", + "upcloud_template", +] + +[[elements]] +name = "upcloud_hostname" +type = "text" +prompt = "Server hostname" +required = true +help = "Hostname for the UpCloud server" +nickel_path = [ + "provider", + "upcloud_hostname", +] + +[[elements]] +name = "upcloud_storage_size" +type = "text" +prompt = "Storage size (GB)" +default = "25" +required = true +help = "Additional storage size in GB (beyond plan default)" +nickel_path = [ + "provider", + "upcloud_storage_size", +] + +[[elements]] +name = "upcloud_private_networking" +type = "confirm" +prompt = "Enable private networking?" +default = true +help = "Enable UpCloud private networking (SDN) for this server" +nickel_path = [ + "provider", + "upcloud_private_networking", +] + +[[elements]] +name = "upcloud_backups" +type = "confirm" +prompt = "Enable automated backups?" +default = false +help = "Enable automated daily backups (additional cost)" +nickel_path = [ + "provider", + "upcloud_backups", +] diff --git a/crates/typedialog-prov-gen/templates/fragments/ssh-section.toml b/crates/typedialog-prov-gen/templates/fragments/ssh-section.toml new file mode 100644 index 0000000..ed9354d --- /dev/null +++ b/crates/typedialog-prov-gen/templates/fragments/ssh-section.toml @@ -0,0 +1,56 @@ +name = "ssh_fragment" + +[[elements]] +name = "ssh_header" +type = "section_header" +title = "🔐 SSH Credentials" +border_top = true +border_bottom = true + +[[elements]] +name = "ssh_private_key_path" +type = "text" +prompt = "Private key path" +placeholder = "~/.ssh/id_rsa" +required = true +help = "Absolute or relative path to SSH private key file" +nickel_path = [ + "ssh_credentials", + "private_key_path", +] + +[[elements]] +name = "ssh_public_key_path" +type = "text" +prompt = "Public key path" +placeholder = "~/.ssh/id_rsa.pub" +required = true +help = "Absolute or relative path to SSH public key file" +nickel_path = [ + "ssh_credentials", + "public_key_path", +] + +[[elements]] +name = "ssh_username" +type = "text" +prompt = "SSH username" +placeholder = "torrust" +default = "torrust" +help = "Linux username for SSH access. Defaults to 'torrust'. Must be 1-32 characters, starting with letter or underscore." +nickel_path = [ + "ssh_credentials", + "username", +] + +[[elements]] +name = "ssh_port" +type = "text" +prompt = "SSH port" +placeholder = "22" +default = "22" +help = "SSH port number (default 22). Must be between 1-65535." +nickel_path = [ + "ssh_credentials", + "port", +] diff --git a/crates/typedialog-prov-gen/templates/schemas/database.ncl b/crates/typedialog-prov-gen/templates/schemas/database.ncl new file mode 100644 index 0000000..7d364c0 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/schemas/database.ncl @@ -0,0 +1,16 @@ +# Database Schema +# Type contracts for database configuration +# Supports SQLite (file-based) or MySQL (server-based) databases + +{ + # Database base contract with optional fields + # Validation logic in config layer ensures required fields per driver type + Database = { + driver | String, + database_name | String | optional, + host | String | optional, + port | Number | optional, + username | String | optional, + password | String | optional, + }, +} diff --git a/crates/typedialog-prov-gen/templates/schemas/environment.ncl b/crates/typedialog-prov-gen/templates/schemas/environment.ncl new file mode 100644 index 0000000..27e36e2 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/schemas/environment.ncl @@ -0,0 +1,9 @@ +# Environment Schema +# Type contract for environment identification + +{ + Environment = { + name | String | optional, # Provided by user (no default) + instance_name | String | optional, # Optional, auto-generated if not provided + }, +} diff --git a/crates/typedialog-prov-gen/templates/schemas/features.ncl b/crates/typedialog-prov-gen/templates/schemas/features.ncl new file mode 100644 index 0000000..75e9e43 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/schemas/features.ncl @@ -0,0 +1,24 @@ +# Features Schema +# Type contracts for optional features (Prometheus, Grafana) + +{ + # Prometheus monitoring configuration (all optional - user decides what to set) + PrometheusConfig = { + enabled | Bool | optional, + bind_address | String | optional, + scrape_interval | Number | optional, + }, + + # Grafana visualization configuration (all optional - user decides what to set) + GrafanaConfig = { + enabled | Bool | optional, + bind_address | String | optional, + admin_password | String | optional, + }, + + # All optional features (user provides what they want) + Features = { + prometheus | PrometheusConfig | optional, + grafana | GrafanaConfig | optional, + }, +} diff --git a/crates/typedialog-prov-gen/templates/schemas/provider.ncl b/crates/typedialog-prov-gen/templates/schemas/provider.ncl new file mode 100644 index 0000000..311614a --- /dev/null +++ b/crates/typedialog-prov-gen/templates/schemas/provider.ncl @@ -0,0 +1,19 @@ +# Provider Schema +# Type contracts for infrastructure provider configuration +# Supports LXD (local) or Hetzner Cloud (managed) providers +# Note: No defaults for provider - user must supply completely + +{ + # Provider record: LXD and Hetzner fields (all optional - user chooses one) + Provider = { + provider | String | optional, # Provided by user + # LXD fields + profile_name | String | optional, + # Hetzner fields + api_token | doc "Hetzner fields" + | String | optional, + server_type | String | optional, + location | String | optional, + image | String | optional, + }, +} diff --git a/crates/typedialog-prov-gen/templates/schemas/ssh.ncl b/crates/typedialog-prov-gen/templates/schemas/ssh.ncl new file mode 100644 index 0000000..fc33641 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/schemas/ssh.ncl @@ -0,0 +1,14 @@ +# SSH Credentials Schema +# Type contract for SSH authentication configuration + +{ + SshCredentials = { + # Required (usually provided by user) + private_key_path | String | optional, + public_key_path | String | optional, + + # Optional (have defaults) + username | String | optional, + port | Number | optional, + }, +} diff --git a/crates/typedialog-prov-gen/templates/scripts/config.nu b/crates/typedialog-prov-gen/templates/scripts/config.nu new file mode 100755 index 0000000..b898fcd --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/config.nu @@ -0,0 +1,195 @@ +#!/usr/bin/env nu +# Torrust Tracker Environment Configuration Wizard (Nushell variant) +# Main orchestration script for the configuration wizard workflow +# +# Compliance: .claude/guidelines/nushell/NUSHELL_COMPLIANCE_CHECKLIST.md +# - No try-catch: Uses `do { } | complete` pattern +# - No let mut: Pure immutable transformations +# - Function signatures with explicit types +# - External commands prefixed with `^` +# - String interpolation: [$var] for variables, ($expr) for expressions +# +# This script: +# 1. Verifies TypeDialog and Nickel are installed +# 2. Launches interactive TypeDialog form +# 3. Converts JSON output to Nickel configuration +# 4. Validates with Nickel validators +# 5. Exports final JSON to envs/ directory +# +# Usage: +# ./provisioning/scripts/config.nu + +# Check if a command exists +def check-command [cmd: string]: nothing -> bool { + (do { ^which $cmd } | complete).exit_code == 0 +} + +# Print section header +def print-header [msg: string]: nothing -> nothing { + print "═══════════════════════════════════════════════════════════" + print $"🎯 ($msg)" + print "═══════════════════════════════════════════════════════════" + print "" +} + +# Print step message with progress +def print-step [step: string, total: string, msg: string]: nothing -> nothing { + print $"📝 Step ($step)/($total): ($msg)..." +} + +# Print success message +def print-success [msg: string]: nothing -> nothing { + print $"✅ ($msg)" +} + +# Print info message +def print-info [msg: string]: nothing -> nothing { + print $"ℹ️ ($msg)" +} + +# Print error message to stderr +def print-error [msg: string]: nothing -> nothing { + print -e $"❌ ($msg)" +} + +# Verify dependencies are installed +def verify-dependencies []: nothing -> bool { + print "Checking dependencies..." + print "" + + let deps = [ + {name: "typedialog", install: "cargo install typedialog"} + {name: "nickel", install: "cargo install nickel-lang-cli"} + {name: "jq", install: "brew install jq (or apt-get install jq)"} + ] + + let missing = ($deps | where {|dep| not (check-command $dep.name)}) + + if ($missing | is-not-empty) { + print-error "Missing dependencies:" + $missing | each {|dep| + print -e $" - ($dep.name): ($dep.install)" + } + return false + } + + print-success "All dependencies available" + print "" + return true +} + +# Main wizard function +def main []: nothing -> nothing { + print-header "Torrust Tracker - Environment Configuration Wizard" + + # Verify dependencies + if not (verify-dependencies) { + exit 1 + } + + # Get directory paths - script is expected to be in provisioning/scripts/ + # Default to known location, can be overridden with TORRUST_SCRIPT_DIR env var + let script_dir = ( + $env | get --optional TORRUST_SCRIPT_DIR // "/Users/Akasha/Development/torrust-tracker-deployer/provisioning/scripts" + ) + let provisioning_dir = ($script_dir | path dirname) + let project_root = ($provisioning_dir | path dirname) + let envs_dir = ($project_root | path join "envs") + let values_dir = ($provisioning_dir | path join "values") + let form_path = ($provisioning_dir | path join "config-form.toml") + + # Create directories if they don't exist + ^mkdir -p $envs_dir + ^mkdir -p $values_dir + + # Step 1: Run TypeDialog form + print-step "1" "4" "Collecting configuration via interactive form" + + let temp_output = $"/tmp/typedialog-output-(date now | format date '%s').json" + + # TypeDialog outputs to stdout, redirect to file + ^typedialog $form_path | save --force $temp_output + + # Check if output file has content + if not ($temp_output | path exists) or (open $temp_output | is-empty) { + print-error "TypeDialog output is empty. Wizard cancelled." + exit 1 + } + + print-success "Configuration collected" + print "" + + # Step 2: Extract environment name + print-step "2" "4" "Processing configuration" + + let config = (open $temp_output) + let env_name = $config.environment_name + + if ($env_name | is-empty) { + print-error "Could not extract environment name from form output" + exit 1 + } + + print-info $"Environment name: ($env_name)" + + let values_file = ($values_dir | path join $"($env_name).ncl") + let json_file = ($envs_dir | path join $"($env_name).json") + + # Step 3: Convert JSON to Nickel + print-step "3" "4" "Converting to Nickel configuration" + + let converter_script = ($script_dir | path join "json-to-nickel.nu") + ^nu -c $"source '($converter_script)'; main '($temp_output)' '($values_file)'" + + if not ($values_file | path exists) { + print-error "Nickel file generation failed" + exit 1 + } + + print-success $"Nickel configuration generated: ($values_file)" + print "" + + # Step 4: Validate Nickel + print-info "Validating Nickel configuration..." + + let validate_script = ($script_dir | path join "validate-nickel.nu") + ^nu -c $"source '($validate_script)'; main '($values_file)'" + + print-success "Nickel validation passed" + print "" + + # Step 5: Export Nickel to JSON + print-step "4" "4" "Exporting to JSON format" + + let exporter_script = ($script_dir | path join "nickel-to-json.nu") + ^nu -c $"source '($exporter_script)'; main '($values_file)' '($json_file)'" + + if not ($json_file | path exists) { + print-error "JSON export failed" + exit 1 + } + + print-success $"JSON configuration exported: ($json_file)" + print "" + + # Cleanup temporary file + ^rm -f $temp_output + + # Success summary + print-header "Configuration Generation Complete!" + print "" + + print-info "Generated files:" + print $" - Nickel: ($values_file)" + print $" - JSON: ($json_file)" + print "" + + print-info "Next steps:" + print $" 1. Review configuration: cat ($json_file) | jq ." + print $" 2. Create environment: cargo run --bin torrust-tracker-deployer -- create environment --env-file ($json_file)" + print $" 3. Provision: cargo run --bin torrust-tracker-deployer -- provision ($env_name)" + print "" +} + +# Execute main +main diff --git a/crates/typedialog-prov-gen/templates/scripts/config.sh b/crates/typedialog-prov-gen/templates/scripts/config.sh new file mode 100755 index 0000000..5f5cb81 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/config.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# Torrust Tracker Environment Configuration Wizard (Bash variant) +# Main orchestration script for the configuration wizard workflow +# +# This script: +# 1. Verifies TypeDialog and Nickel are installed +# 2. Launches interactive TypeDialog form +# 3. Converts JSON output to Nickel configuration +# 4. Validates with Nickel validators +# 5. Exports final JSON to envs/ directory +# +# Usage: +# ./provisioning/scripts/config.sh + +set -euo pipefail + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly PROVISIONING_DIR="$(dirname "$SCRIPT_DIR")" +readonly PROJECT_ROOT="$(dirname "$PROVISIONING_DIR")" +readonly ENVS_DIR="${PROJECT_ROOT}/envs" +readonly VALUES_DIR="${PROVISIONING_DIR}/values" +readonly FORM_PATH="${PROVISIONING_DIR}/config-form.toml" +readonly SCRIPTS_DIR="$SCRIPT_DIR" + +# ============================================================================ +# UTILITY FUNCTIONS +# ============================================================================ + +print_header() { + echo "═══════════════════════════════════════════════════════════" + echo "🎯 $1" + echo "═══════════════════════════════════════════════════════════" + echo "" +} + +print_step() { + echo "📝 Step $1/$2: $3..." +} + +print_success() { + echo "✅ $1" +} + +print_error() { + echo "❌ $1" >&2 +} + +print_info() { + echo "ℹ️ $1" +} + +# Check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# ============================================================================ +# DEPENDENCY VERIFICATION +# ============================================================================ + +verify_dependencies() { + print_header "Checking Dependencies" + + local missing_deps=() + + if ! command_exists "typedialog"; then + missing_deps+=("typedialog (install with: cargo install typedialog)") + fi + + if ! command_exists "nickel"; then + missing_deps+=("nickel (install with: cargo install nickel-lang-cli)") + fi + + if ! command_exists "jq"; then + missing_deps+=("jq (install with: brew install jq or apt-get install jq)") + fi + + if [[ ${#missing_deps[@]} -gt 0 ]]; then + print_error "Missing dependencies:" + for dep in "${missing_deps[@]}"; do + echo " - $dep" >&2 + done + return 1 + fi + + print_success "All dependencies available" + echo "" +} + +# ============================================================================ +# MAIN WORKFLOW +# ============================================================================ + +main() { + print_header "Torrust Tracker - Environment Configuration Wizard" + + # Step 0: Verify dependencies + if ! verify_dependencies; then + exit 1 + fi + + # Ensure directories exist + mkdir -p "$ENVS_DIR" + mkdir -p "$VALUES_DIR" + + # Step 1: Run TypeDialog form + print_step "1" "4" "Collecting configuration via interactive form" + + local temp_output + temp_output=$(mktemp) + trap "rm -f '$temp_output'" EXIT + + if ! typedialog run "$FORM_PATH" > "$temp_output" 2>&1; then + print_error "TypeDialog form failed" + cat "$temp_output" >&2 + exit 1 + fi + + if [[ ! -s "$temp_output" ]]; then + print_error "TypeDialog output is empty. Wizard cancelled." + exit 1 + fi + + print_success "Configuration collected" + echo "" + + # Step 2: Extract environment name + print_step "2" "4" "Processing configuration" + + local env_name + env_name=$(jq -r '.environment_name' "$temp_output") + + if [[ -z "$env_name" ]]; then + print_error "Could not extract environment name from form output" + exit 1 + fi + + print_info "Environment name: $env_name" + + local values_file="${VALUES_DIR}/${env_name}.ncl" + local json_file="${ENVS_DIR}/${env_name}.json" + + # Step 3: Convert JSON to Nickel + print_step "3" "4" "Converting to Nickel configuration" + + if ! bash "$SCRIPTS_DIR/json-to-nickel.sh" "$temp_output" "$values_file"; then + print_error "Nickel file generation failed" + exit 1 + fi + + print_success "Nickel configuration generated: $values_file" + echo "" + + # Step 4: Validate Nickel + print_info "Validating Nickel configuration..." + + if ! nickel eval "$values_file" > /dev/null 2>&1; then + print_error "Nickel validation failed" + nickel eval "$values_file" >&2 + exit 1 + fi + + print_success "Nickel validation passed" + echo "" + + # Step 5: Export Nickel to JSON + print_step "4" "4" "Exporting to JSON format" + + if ! bash "$SCRIPTS_DIR/nickel-to-json.sh" "$values_file" "$json_file"; then + print_error "JSON export failed" + exit 1 + fi + + print_success "JSON configuration exported: $json_file" + echo "" + + # Success summary + print_header "Configuration Generation Complete!" + echo "" + + print_info "Generated files:" + echo " - Nickel: $values_file" + echo " - JSON: $json_file" + echo "" + + print_info "Next steps:" + echo " 1. Review configuration: cat '$json_file' | jq ." + echo " 2. Create environment: cargo run --bin torrust-tracker-deployer -- create environment --env-file '$json_file'" + echo " 3. Provision: cargo run --bin torrust-tracker-deployer -- provision '$env_name'" + echo "" +} + +# Run main function +main "$@" diff --git a/crates/typedialog-prov-gen/templates/scripts/json-to-nickel.nu b/crates/typedialog-prov-gen/templates/scripts/json-to-nickel.nu new file mode 100755 index 0000000..3d86094 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/json-to-nickel.nu @@ -0,0 +1,254 @@ +#!/usr/bin/env nu +# Convert TypeDialog JSON output to Nickel configuration file (Nushell variant) +# +# Compliance: .claude/guidelines/nushell/NUSHELL_COMPLIANCE_CHECKLIST.md +# - Function signatures with explicit types +# - No try-catch: Uses `do { } | complete` +# - External commands prefixed with `^` +# - String interpolation: [$var] for variables, ($expr) for expressions +# +# Usage: +# nu ./json-to-nickel.nu + +# Extract value from JSON with optional default +def extract-json [json: record, key: string, default: string = ""]: nothing -> string { + let maybe_value = ($json | get --optional $key) + if ($maybe_value == null) { $default } else { $maybe_value } +} + +def main [input_json: string, output_nickel: string]: nothing -> nothing { + if not ($input_json | path exists) { + print -e $"Error: Input JSON file not found: ($input_json)" + exit 1 + } + + # Load JSON + let config = (open $input_json) + + # Extract environment section + let env_name = (extract-json $config "environment_name") + let instance_name = (extract-json $config "instance_name") + + # Extract provider section + let provider = (extract-json $config "provider") + + # Extract provider-specific values + let lxd_profile = if ($provider == "lxd") { + extract-json $config "lxd_profile_name" + } else { + "" + } + + let hetzner_token = if ($provider == "hetzner") { + extract-json $config "hetzner_api_token" + } else { + "" + } + + let hetzner_server = if ($provider == "hetzner") { + extract-json $config "hetzner_server_type" + } else { + "" + } + + let hetzner_location = if ($provider == "hetzner") { + extract-json $config "hetzner_location" + } else { + "" + } + + let hetzner_image = if ($provider == "hetzner") { + extract-json $config "hetzner_image" + } else { + "" + } + + # Extract SSH section + let ssh_private_key = (extract-json $config "ssh_private_key_path") + let ssh_public_key = (extract-json $config "ssh_public_key_path") + let ssh_username = (extract-json $config "ssh_username" "torrust") + let ssh_port = (extract-json $config "ssh_port" "22") + + # Extract database section + let database_driver = (extract-json $config "database_driver") + + let sqlite_db = if ($database_driver == "sqlite3") { + extract-json $config "sqlite_database_name" + } else { + "" + } + + let mysql_host = if ($database_driver == "mysql") { + extract-json $config "mysql_host" + } else { + "" + } + + let mysql_port = if ($database_driver == "mysql") { + extract-json $config "mysql_port" + } else { + "" + } + + let mysql_db = if ($database_driver == "mysql") { + extract-json $config "mysql_database_name" + } else { + "" + } + + let mysql_user = if ($database_driver == "mysql") { + extract-json $config "mysql_username" + } else { + "" + } + + let mysql_pass = if ($database_driver == "mysql") { + extract-json $config "mysql_password" + } else { + "" + } + + # Extract tracker section + let tracker_private = (extract-json $config "tracker_private_mode" "false") + let udp_bind = (extract-json $config "udp_tracker_bind_address") + let http_bind = (extract-json $config "http_tracker_bind_address") + let api_bind = (extract-json $config "http_api_bind_address") + let api_token = (extract-json $config "http_api_admin_token") + + # Extract features section + let enable_prometheus = (extract-json $config "enable_prometheus" "false") + let enable_grafana = (extract-json $config "enable_grafana" "false") + + let prometheus_bind = if ($enable_prometheus == "true") { + extract-json $config "prometheus_bind_address" + } else { + "" + } + + let prometheus_interval = if ($enable_prometheus == "true") { + extract-json $config "prometheus_scrape_interval" "15" + } else { + "" + } + + let grafana_bind = if ($enable_grafana == "true") { + extract-json $config "grafana_bind_address" + } else { + "" + } + + let grafana_pass = if ($enable_grafana == "true") { + extract-json $config "grafana_admin_password" + } else { + "" + } + + # Build Nickel configuration + let timestamp = (date now | format date '%Y-%m-%dT%H:%M:%SZ') + + # Build instance_name section + let instance_section = if ($instance_name != "") { + $" instance_name = validators_instance.ValidInstanceName \"($instance_name)\",\n" + } else { + "" + } + + # Build provider section (does NOT include environment closing brace) + let provider_section = if ($provider == "lxd") { + $" provider = {\n provider = \"lxd\",\n profile_name = validators_instance.ValidInstanceName \"($lxd_profile)\",\n }," + } else if ($provider == "hetzner") { + $" provider = {\n provider = \"hetzner\",\n api_token = \"($hetzner_token)\",\n server_type = \"($hetzner_server)\",\n location = \"($hetzner_location)\",\n image = \"($hetzner_image)\",\n }," + } else { + "" + } + + # Build database section + let database_section = if ($database_driver == "sqlite3") { + $" {\n driver = \"sqlite3\",\n database_name = \"($sqlite_db)\",\n }," + } else if ($database_driver == "mysql") { + $" {\n driver = \"mysql\",\n host = \"($mysql_host)\",\n port = validators_common.ValidPort ($mysql_port),\n database_name = \"($mysql_db)\",\n username = \"($mysql_user)\",\n password = \"($mysql_pass)\",\n }," + } else { + "" + } + + # Build prometheus section + let prometheus_section = if ($enable_prometheus == "true") { + $" bind_address = \"($prometheus_bind)\",\n scrape_interval = ($prometheus_interval)," + } else { + "" + } + + # Build grafana section + let grafana_section = if ($enable_grafana == "true") { + $" bind_address = \"($grafana_bind)\",\n admin_password = \"($grafana_pass)\"," + } else { + "" + } + + # Construct the complete Nickel file - using pure string interpolation + let nickel_content = $"# Environment configuration \(generated from TypeDialog\) +# Generated: ($timestamp) + +let schemas = import \"../schemas/environment.ncl\" in +let defaults = import \"../defaults/environment.ncl\" in +let validators = import \"../validators/environment.ncl\" in +let validators_instance = import \"../validators/instance.ncl\" in +let validators_username = import \"../validators/username.ncl\" in +let validators_common = import \"../validators/common.ncl\" in +let validators_network = import \"../validators/network.ncl\" in + +let user_config = { + environment = { + name = validators.ValidEnvironmentName \"($env_name)\", +($instance_section) }, + +($provider_section) + ssh_credentials = { + private_key_path = \"($ssh_private_key)\", + public_key_path = \"($ssh_public_key)\", + username = validators_username.ValidUsername \"($ssh_username)\", + port = validators_common.ValidPort ($ssh_port), + }, + + tracker = { + core = { + private = ($tracker_private), + database = +($database_section) + }, + udp_trackers = [ + { bind_address = validators_network.ValidBindAddress \"($udp_bind)\" }, + ], + http_trackers = [ + { bind_address = validators_network.ValidBindAddress \"($http_bind)\" }, + ], + http_api = { + bind_address = validators_network.ValidBindAddress \"($api_bind)\", + admin_token = \"($api_token)\", + }, + }, + + features = { + prometheus = { + enabled = ($enable_prometheus), +($prometheus_section) + }, + grafana = { + enabled = ($enable_grafana), +($grafana_section) + }, + }, +} in + +# Merge defaults with user config +defaults & user_config +" + + # Write to file + $nickel_content | save --force $output_nickel + + print $"✅ Nickel file generated: ($output_nickel)" +} + +# Script is a library - call main directly: +# nu -c 'source ./json-to-nickel.nu; main "input.json" "output.ncl"' diff --git a/crates/typedialog-prov-gen/templates/scripts/json-to-nickel.sh b/crates/typedialog-prov-gen/templates/scripts/json-to-nickel.sh new file mode 100755 index 0000000..47a880c --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/json-to-nickel.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# Convert TypeDialog JSON output to Nickel configuration file +# +# This script takes JSON output from TypeDialog and generates a Nickel +# configuration file that merges user values with schemas/defaults/validators. +# +# Usage: +# ./json-to-nickel.sh +# +# Arguments: +# input.json - JSON file from TypeDialog (required) +# output.ncl - Nickel output file (required) + +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +readonly INPUT_JSON="$1" +readonly OUTPUT_NICKEL="$2" + +if [[ ! -f "$INPUT_JSON" ]]; then + echo "Error: Input JSON file not found: $INPUT_JSON" >&2 + exit 1 +fi + +# ============================================================================ +# EXTRACT VALUES FROM JSON +# ============================================================================ + +extract_json() { + local key="$1" + local default="${2:-}" + jq -r ".${key} // \"${default}\"" "$INPUT_JSON" +} + +# Environment section +ENV_NAME=$(extract_json "environment_name") +INSTANCE_NAME=$(extract_json "instance_name" "") + +# Provider section +PROVIDER=$(extract_json "provider") + +# Provider-specific values +if [[ "$PROVIDER" == "lxd" ]]; then + LXD_PROFILE=$(extract_json "lxd_profile_name") +elif [[ "$PROVIDER" == "hetzner" ]]; then + HETZNER_TOKEN=$(extract_json "hetzner_api_token") + HETZNER_SERVER=$(extract_json "hetzner_server_type") + HETZNER_LOCATION=$(extract_json "hetzner_location") + HETZNER_IMAGE=$(extract_json "hetzner_image") +fi + +# SSH section +SSH_PRIVATE_KEY=$(extract_json "ssh_private_key_path") +SSH_PUBLIC_KEY=$(extract_json "ssh_public_key_path") +SSH_USERNAME=$(extract_json "ssh_username" "torrust") +SSH_PORT=$(extract_json "ssh_port" "22") + +# Database section +DATABASE_DRIVER=$(extract_json "database_driver") + +if [[ "$DATABASE_DRIVER" == "sqlite3" ]]; then + SQLITE_DB=$(extract_json "sqlite_database_name") +elif [[ "$DATABASE_DRIVER" == "mysql" ]]; then + MYSQL_HOST=$(extract_json "mysql_host") + MYSQL_PORT=$(extract_json "mysql_port") + MYSQL_DB=$(extract_json "mysql_database_name") + MYSQL_USER=$(extract_json "mysql_username") + MYSQL_PASS=$(extract_json "mysql_password") +fi + +# Tracker section +TRACKER_PRIVATE=$(extract_json "tracker_private_mode" "false") +UDP_BIND=$(extract_json "udp_tracker_bind_address") +HTTP_BIND=$(extract_json "http_tracker_bind_address") +API_BIND=$(extract_json "http_api_bind_address") +API_TOKEN=$(extract_json "http_api_admin_token") + +# Features section +ENABLE_PROMETHEUS=$(extract_json "enable_prometheus" "false") +ENABLE_GRAFANA=$(extract_json "enable_grafana" "false") + +if [[ "$ENABLE_PROMETHEUS" == "true" ]]; then + PROMETHEUS_BIND=$(extract_json "prometheus_bind_address") + PROMETHEUS_INTERVAL=$(extract_json "prometheus_scrape_interval" "15") +fi + +if [[ "$ENABLE_GRAFANA" == "true" ]]; then + GRAFANA_BIND=$(extract_json "grafana_bind_address") + GRAFANA_PASS=$(extract_json "grafana_admin_password") +fi + +# ============================================================================ +# GENERATE NICKEL FILE +# ============================================================================ + +cat > "$OUTPUT_NICKEL" <<'NICKEL_TEMPLATE' +# Environment configuration (generated from TypeDialog) +# Generated: $(date -Iseconds) + +let schemas = import "../schemas/environment.ncl" in +let defaults = import "../defaults/environment.ncl" in +let validators = import "../validators/environment.ncl" in +let validators_instance = import "../validators/instance.ncl" in +let validators_username = import "../validators/username.ncl" in +let validators_common = import "../validators/common.ncl" in +let validators_network = import "../validators/network.ncl" in + +let user_config = { +NICKEL_TEMPLATE + +# Append environment section +cat >> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <<'EOF' + }, + +EOF + +# Append provider section +if [[ "$PROVIDER" == "lxd" ]]; then + cat >> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <> "$OUTPUT_NICKEL" <<'EOF' + }, + }, +} in + +# Merge defaults with user config +defaults & user_config +EOF + +echo "✅ Nickel file generated: $OUTPUT_NICKEL" diff --git a/crates/typedialog-prov-gen/templates/scripts/nickel-to-json.nu b/crates/typedialog-prov-gen/templates/scripts/nickel-to-json.nu new file mode 100755 index 0000000..aaf0138 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/nickel-to-json.nu @@ -0,0 +1,38 @@ +#!/usr/bin/env nu +# Export Nickel configuration to JSON format (Nushell variant) +# +# Compliance: .claude/guidelines/nushell/NUSHELL_COMPLIANCE_CHECKLIST.md +# - Function signatures with explicit types +# - External commands prefixed with `^` +# - String interpolation: [$var] for variables +# +# Usage: +# ./nickel-to-json.nu + +def main [input_nickel: string, output_json: string]: nothing -> nothing { + if not ($input_nickel | path exists) { + print -e $"Error: Input Nickel file not found: ($input_nickel)" + exit 1 + } + + # Export Nickel to JSON + ^nickel export --format json $input_nickel | save --force $output_json + + # Verify output was generated + if not ($output_json | path exists) { + print -e "❌ Nickel export failed" + exit 1 + } + + let file_size = (ls $output_json | get size.0) + if ($file_size == 0) { + print -e "❌ Nickel export produced empty output" + ^rm -f $output_json + exit 1 + } + + print $"✅ JSON exported: ($output_json)" +} + +# Script is a library - call main directly: +# nu -c 'source ./nickel-to-json.nu; main "input.ncl" "output.json"' diff --git a/crates/typedialog-prov-gen/templates/scripts/nickel-to-json.sh b/crates/typedialog-prov-gen/templates/scripts/nickel-to-json.sh new file mode 100755 index 0000000..b7a49ad --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/nickel-to-json.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Export Nickel configuration to JSON format +# +# This script evaluates a Nickel configuration file and exports it as JSON. +# The resulting JSON is suitable for use with the Torrust Tracker Deployer. +# +# Usage: +# ./nickel-to-json.sh +# +# Arguments: +# input.ncl - Nickel configuration file (required) +# output.json - JSON output file (required) + +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +readonly INPUT_NICKEL="$1" +readonly OUTPUT_JSON="$2" + +if [[ ! -f "$INPUT_NICKEL" ]]; then + echo "Error: Input Nickel file not found: $INPUT_NICKEL" >&2 + exit 1 +fi + +# Export Nickel to JSON using nickel CLI +if ! nickel export --format json "$INPUT_NICKEL" > "$OUTPUT_JSON" 2>&1; then + echo "❌ Nickel export failed" >&2 + cat "$OUTPUT_JSON" >&2 + rm -f "$OUTPUT_JSON" + exit 1 +fi + +if [[ ! -s "$OUTPUT_JSON" ]]; then + echo "❌ Nickel export produced empty output" >&2 + rm -f "$OUTPUT_JSON" + exit 1 +fi + +echo "✅ JSON exported: $OUTPUT_JSON" diff --git a/crates/typedialog-prov-gen/templates/scripts/validate-nickel.nu b/crates/typedialog-prov-gen/templates/scripts/validate-nickel.nu new file mode 100755 index 0000000..970829f --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/validate-nickel.nu @@ -0,0 +1,35 @@ +#!/usr/bin/env nu +# Validate Nickel configuration file (Nushell variant) +# +# Compliance: .claude/guidelines/nushell/NUSHELL_COMPLIANCE_CHECKLIST.md +# - Function signatures with explicit types +# - External commands prefixed with `^` +# - String interpolation: [$var] for variables +# +# Usage: +# ./validate-nickel.nu + +def main [input_nickel: string]: nothing -> nothing { + if not ($input_nickel | path exists) { + print -e $"❌ Error: File not found: ($input_nickel)" + exit 1 + } + + print $"Validating Nickel configuration: ($input_nickel)" + print "" + + let validate_result = (do { ^nickel eval $input_nickel } | complete) + + if $validate_result.exit_code == 0 { + print "✅ Nickel configuration is valid" + exit 0 + } else { + print "❌ Nickel validation failed. Errors:" + print "" + print $validate_result.stdout + exit 1 + } +} + +# Script is a library - call main directly: +# nu -c 'source ./validate-nickel.nu; main "config.ncl"' diff --git a/crates/typedialog-prov-gen/templates/scripts/validate-nickel.sh b/crates/typedialog-prov-gen/templates/scripts/validate-nickel.sh new file mode 100755 index 0000000..99fd764 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/scripts/validate-nickel.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Validate Nickel configuration file +# +# This script evaluates a Nickel configuration file to check for syntax errors +# and validation failures. If validation succeeds, all values are validated +# according to the defined validators. +# +# Usage: +# ./validate-nickel.sh +# +# Arguments: +# config.ncl - Nickel configuration file to validate (required) + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +readonly INPUT_NICKEL="$1" + +if [[ ! -f "$INPUT_NICKEL" ]]; then + echo "❌ Error: File not found: $INPUT_NICKEL" >&2 + exit 1 +fi + +echo "Validating Nickel configuration: $INPUT_NICKEL" +echo "" + +if nickel eval "$INPUT_NICKEL" > /dev/null 2>&1; then + echo "✅ Nickel configuration is valid" + exit 0 +else + echo "❌ Nickel validation failed. Errors:" + echo "" + nickel eval "$INPUT_NICKEL" + exit 1 +fi diff --git a/crates/typedialog-prov-gen/templates/validators/common.ncl b/crates/typedialog-prov-gen/templates/validators/common.ncl new file mode 100644 index 0000000..d132f6d --- /dev/null +++ b/crates/typedialog-prov-gen/templates/validators/common.ncl @@ -0,0 +1,63 @@ +# Common Validators +# Utility functions used by all validators + +{ + # Validates port number (must be 1-65535, rejects 0 explicitly) + # + # Port 0 is explicitly rejected per project ADR: + # "Port 0 means 'any available port' in most systems, which is unsuitable + # for production configuration where we need predictable listening addresses." + # + # Args: + # port: Number - port number to validate + # Returns: + # port if valid + # Throws: + # Error if port is not in valid range + ValidPort = fun port => + let port_num = if std.is_number port then port else std.string.to_number port in + if port_num >= 1 && port_num <= 65535 then + port_num + else + std.record.fail "Port must be 1-65535 (0 not supported), got %{std.string.from_number port_num}", + + # Validates non-empty string + # + # Args: + # s: String - string to validate + # Returns: + # s if non-empty + # Throws: + # Error if string is empty + NonEmptyString = fun s => + if std.string.length s > 0 then s + else std.record.fail "String cannot be empty", + + # Validates bind address format (IP:PORT) + # + # Args: + # addr: String - address in format "IP:PORT" + # Returns: + # addr if valid + # Throws: + # Error if format is invalid or port is out of range + ValidBindAddress = fun addr => + # Simple regex check for IP:PORT format + # Pattern: one or more digits/dots followed by colon followed by digits + let is_valid_format = std.string.is_match "^[0-9.]+:[0-9]+$" addr in + + if !is_valid_format then + std.record.fail "Bind address must be IP:PORT format, got '%{addr}'" + + else + # Extract port and validate range + let parts = std.string.split ":" addr in + let last_index = (std.array.length parts) - 1 in + let port_str = std.array.at last_index parts in + let port_num = std.string.to_number port_str in + + if port_num >= 1 && port_num <= 65535 then + addr + else + std.record.fail "Port in bind address must be 1-65535, got %{port_str}", +} diff --git a/crates/typedialog-prov-gen/templates/validators/environment.ncl b/crates/typedialog-prov-gen/templates/validators/environment.ncl new file mode 100644 index 0000000..e46965c --- /dev/null +++ b/crates/typedialog-prov-gen/templates/validators/environment.ncl @@ -0,0 +1,124 @@ +# Environment Name Validators +# Mirrors validation from src/domain/environment/name.rs + +let helpers = import "common.ncl" in + +{ + # Validates EnvironmentName according to Rust domain rules + # + # Rust reference: src/domain/environment/name.rs + # + # Rules (MUST match Rust exactly): + # 1. Non-empty + # 2. Lowercase only (a-z, 0-9, -) + # 3. Cannot start with digit + # 4. Cannot start with dash + # 5. Cannot end with dash + # 6. No consecutive dashes + # 7. Cannot contain uppercase letters + # + # Valid examples: dev, staging, production, e2e-config, test-integration + # Invalid examples: Dev, 1dev, -dev, dev-, dev--test + # + # Args: + # name: String - environment name to validate + # Returns: + # name if valid + # Throws: + # Error with specific rule violation + ValidEnvironmentName = fun name => + # Rule 1: Check if empty + let _ = if name == "" then + std.record.fail "Environment name cannot be empty. + +Valid format: lowercase letters, numbers, and dashes only. +Examples: dev, staging, production, e2e-config, test-integration" + else + name + in + + # Rule 3: Check if starts with digit + let _ = if std.string.is_match "^[0-9]" name then + std.record.fail "Environment name '%{name}' is invalid: starts with a number (for InstanceName compatibility). + +Valid format: lowercase letters, numbers, and dashes only. +Examples: dev, staging, production, e2e-config, test-integration" + else + name + in + + # Rule 7: Check for uppercase letters + let has_uppercase = std.string.is_match "[A-Z]" name in + let _ = if has_uppercase then + let uppercase_chars = std.string.split "" name | + std.array.filter (fun c => std.string.is_match "^[A-Z]$" c) | + std.string.join "" in + std.record.fail "Environment name '%{name}' is invalid: contains uppercase letters: %{uppercase_chars}. + +Valid format: lowercase letters, numbers, and dashes only. +Examples: dev, staging, production, e2e-config, test-integration" + else + name + in + + # Collect invalid characters (not in a-z, 0-9, -) + let invalid_chars_list = std.string.split "" name | + std.array.filter (fun c => + !std.string.is_match "^[a-z0-9-]$" c + ) in + + # Rule 4 & other chars: Check for invalid characters + let _ = if std.array.length invalid_chars_list > 0 then + let unique_invalid = ( + invalid_chars_list | + std.array.sort | + std.array.reverse | + std.array.fold (fun acc c => + if std.array.length acc > 0 && std.array.at (-1) acc == c then + acc + else + acc @ [c] + ) [] + ) | + std.string.join "" in + std.record.fail "Environment name '%{name}' is invalid: contains invalid characters: %{unique_invalid}. + +Valid format: lowercase letters, numbers, and dashes only. +Examples: dev, staging, production, e2e-config, test-integration" + else + name + in + + # Rule 4: Check for leading dash + let _ = if std.string.is_match "^-" name then + std.record.fail "Environment name '%{name}' is invalid: starts with dash. + +Valid format: lowercase letters, numbers, and dashes only. +Examples: dev, staging, production, e2e-config, test-integration" + else + name + in + + # Rule 5: Check for trailing dash + let _ = if std.string.is_match "-$" name then + std.record.fail "Environment name '%{name}' is invalid: ends with dash. + +Valid format: lowercase letters, numbers, and dashes only. +Examples: dev, staging, production, e2e-config, test-integration" + else + name + in + + # Rule 6: Check for consecutive dashes + let _ = if std.string.contains "--" name then + std.record.fail "Environment name '%{name}' is invalid: contains consecutive dashes. + +Valid format: lowercase letters, numbers, and dashes only. +Examples: dev, staging, production, e2e-config, test-integration" + else + name + in + + # All validations passed + name, +} diff --git a/crates/typedialog-prov-gen/templates/validators/instance.ncl b/crates/typedialog-prov-gen/templates/validators/instance.ncl new file mode 100644 index 0000000..cb5ee25 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/validators/instance.ncl @@ -0,0 +1,73 @@ +# Instance Name Validators (LXD naming rules) +# Mirrors validation from src/domain/instance_name.rs + +{ + # Validates InstanceName according to LXD requirements + # + # Rust reference: src/domain/instance_name.rs + # Use cases: LXD virtual machine names, Docker container names + # + # Rules (MUST match Rust exactly): + # 1. Non-empty + # 2. 1-63 characters maximum + # 3. ASCII letters, numbers, dashes only + # 4. Cannot start with digit or dash + # 5. Cannot end with dash + # 6. Cannot contain uppercase or special characters + # + # Valid examples: test-instance, vm-prod, app01, a + # Invalid examples: 1test, -test, test-, test@instance, test_instance + # + # Args: + # name: String - instance name to validate + # Returns: + # name if valid + # Throws: + # Error with specific rule violation + ValidInstanceName = fun name => + let len = std.string.length name in + + # Rule 1: Check if empty + let _ = if name == "" then + std.record.fail "Instance name cannot be empty" + else + name + in + + # Rule 2: Check length (max 63 characters) + let _ = if len > 63 then + std.record.fail "Instance name must be 63 characters or less, got %{std.string.from_number len} characters" + else + name + in + + # Rule 3: Check for invalid characters (only ASCII alphanumeric + dashes allowed) + let invalid_chars_list = std.string.split "" name | + std.array.filter (fun c => + !std.string.is_match "^[a-zA-Z0-9-]$" c + ) in + + let _ = if std.array.length invalid_chars_list > 0 then + std.record.fail "Instance name must contain only ASCII letters, numbers, and dashes" + else + name + in + + # Rule 4: Check first character (cannot be digit or dash) + let first_char = std.string.characters name |> std.array.at 0 in + let _ = if std.string.is_match "^[0-9-]" first_char then + std.record.fail "Instance name must not start with a digit or dash" + else + name + in + + # Rule 5: Check last character (cannot be dash) + let _ = if std.string.is_match "-$" name then + std.record.fail "Instance name must not end with a dash" + else + name + in + + # All validations passed + name, +} diff --git a/crates/typedialog-prov-gen/templates/validators/network.ncl b/crates/typedialog-prov-gen/templates/validators/network.ncl new file mode 100644 index 0000000..9805274 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/validators/network.ncl @@ -0,0 +1,12 @@ +# Network Validators +# Validators for network addresses, ports, and connectivity configuration + +let helpers = import "common.ncl" in + +{ + # Re-export common network validators + ValidPort = helpers.ValidPort, + ValidBindAddress = helpers.ValidBindAddress, + + # Additional network validators can be added here +} diff --git a/crates/typedialog-prov-gen/templates/validators/paths.ncl b/crates/typedialog-prov-gen/templates/validators/paths.ncl new file mode 100644 index 0000000..2055cfa --- /dev/null +++ b/crates/typedialog-prov-gen/templates/validators/paths.ncl @@ -0,0 +1,32 @@ +# Path Validators +# Validators for file paths and configuration paths + +{ + # Validates SSH key path (non-empty string) + # + # Note: This validator does not check if the file actually exists. + # File existence is verified when the configuration is used by the + # Rust application or when provisioning occurs. + # + # Args: + # path: String - path to SSH key file + # Returns: + # path if valid format + # Throws: + # Error if path is empty + ValidSshKeyPath = fun path => + if std.string.length path > 0 then path + else std.record.fail "SSH key path cannot be empty", + + # Validates generic file path (non-empty string) + # + # Args: + # path: String - file path to validate + # Returns: + # path if valid format + # Throws: + # Error if path is empty + ValidPath = fun path => + if std.string.length path > 0 then path + else std.record.fail "Path cannot be empty", +} diff --git a/crates/typedialog-prov-gen/templates/validators/username.ncl b/crates/typedialog-prov-gen/templates/validators/username.ncl new file mode 100644 index 0000000..f9c7354 --- /dev/null +++ b/crates/typedialog-prov-gen/templates/validators/username.ncl @@ -0,0 +1,65 @@ +# Username Validators (Linux system username rules) +# Mirrors validation from src/shared/username.rs + +{ + # Validates Username according to Linux system requirements + # + # Rust reference: src/shared/username.rs + # Used for: SSH authentication, system user creation, process ownership + # + # Rules (MUST match Rust exactly): + # 1. Non-empty + # 2. 1-32 characters maximum + # 3. Must start with letter (a-z, A-Z) or underscore (_) + # 4. Subsequent chars: letters, digits, underscores, hyphens + # 5. Case-sensitive (allows uppercase, unlike EnvironmentName) + # + # Valid examples: torrust, _service, Deploy_USER, user-123, Admin + # Invalid examples: 123user, -user, user@domain, user.name + # + # Args: + # username: String - username to validate + # Returns: + # username if valid + # Throws: + # Error with specific rule violation + ValidUsername = fun username => + let len = std.string.length username in + + # Rule 1: Check if empty + let _ = if username == "" then + std.record.fail "Username cannot be empty" + else + username + in + + # Rule 2: Check length (1-32 characters) + let _ = if len > 32 then + std.record.fail "Username must be 32 characters or less, got %{std.string.from_number len} characters" + else + username + in + + # Rule 3: Check first character (must be letter or underscore) + let first_char = std.string.characters username |> std.array.at 0 in + let _ = if !std.string.is_match "^[a-zA-Z_]" first_char then + std.record.fail "Username must start with a letter (a-z, A-Z) or underscore (_)" + else + username + in + + # Rule 4: Check all characters (letters, digits, underscores, hyphens only) + let invalid_chars_list = std.string.split "" username | + std.array.filter (fun c => + !std.string.is_match "^[a-zA-Z0-9_-]$" c + ) in + + let _ = if std.array.length invalid_chars_list > 0 then + std.record.fail "Username must contain only letters, digits, underscores, and hyphens" + else + username + in + + # All validations passed + username, +} diff --git a/crates/typedialog-prov-gen/tests/integration_test.rs b/crates/typedialog-prov-gen/tests/integration_test.rs new file mode 100644 index 0000000..af19e75 --- /dev/null +++ b/crates/typedialog-prov-gen/tests/integration_test.rs @@ -0,0 +1,80 @@ +//! Integration tests for provisioning generator. + +use typedialog_prov_gen::input::CargoIntrospector; +use typedialog_prov_gen::models::{ProjectSpec, ProjectType}; + +#[test] +fn test_cargo_introspector_sample_project() { + // Create a simple test manifest + let manifest_content = r#" +[package] +name = "test-package" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7" +"#; + + use std::io::Write; + use tempfile::NamedTempFile; + + let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); + temp_file + .write_all(manifest_content.as_bytes()) + .expect("Failed to write to temp file"); + + let result = CargoIntrospector::analyze(temp_file.path()); + assert!(result.is_ok(), "Failed to analyze Cargo.toml"); + + let spec = result.unwrap(); + assert_eq!(spec.name, "test-package"); + assert_eq!(spec.project_type, ProjectType::WebService); +} + +#[test] +fn test_project_spec_validation() { + use typedialog_prov_gen::models::DomainFeature; + + let spec = ProjectSpec { + name: "test-project".to_string(), + project_type: ProjectType::WebService, + infrastructure: Default::default(), + domain_features: vec![DomainFeature::new("basic".to_string())], + constraints: vec![], + }; + + let result = spec.validate(); + assert!(result.is_ok(), "Valid spec should pass validation"); +} + +#[test] +fn test_domain_feature_validation() { + use typedialog_prov_gen::models::DomainFeature; + + let feature = DomainFeature::new(String::new()); + let result = feature.validate(); + assert!( + result.is_err(), + "Feature with empty name should fail validation" + ); + + let feature = DomainFeature::new("valid_feature".to_string()); + let result = feature.validate(); + assert!(result.is_ok(), "Valid feature should pass validation"); +} + +#[test] +fn test_config_field_creation() { + use typedialog_prov_gen::models::{ConfigField, FieldType}; + + let field = ConfigField::new( + "test_field".to_string(), + FieldType::Text, + "Test field prompt".to_string(), + ); + + assert_eq!(field.name, "test_field"); + assert_eq!(field.field_type, FieldType::Text); + assert!(field.required); +} diff --git a/docs/ENCRYPTION-QUICK-START.md b/docs/ENCRYPTION-QUICK-START.md deleted file mode 100644 index f8345af..0000000 --- a/docs/ENCRYPTION-QUICK-START.md +++ /dev/null @@ -1,217 +0,0 @@ -# Encryption Testing - Quick Start - -## TL;DR - -```bash -# 1. Setup services (Age already configured, RustyVault requires Docker) -./scripts/encryption-test-setup.sh - -# 2. Load environment -source /tmp/typedialog-env.sh - -# 3. Test redaction (no service) - Simple example -typedialog form examples/08-encryption/simple-login.toml --redact --format json - -# 4. Test Age encryption (requires ~/.age/key.txt) -typedialog form examples/08-encryption/simple-login.toml \ - --encrypt --backend age \ - --key-file ~/.age/key.txt \ - --format json - -# 5. Full feature demo (all encryption features) -typedialog form examples/08-encryption/credentials.toml --redact --format json - -# 6. Run all integration tests -cargo test --test nickel_integration test_encryption -- --nocapture -``` - -## Example Forms - -### Simple Login Form (`examples/08-encryption/simple-login.toml`) - -Minimal example for quick testing: -- `username` (plaintext) -- `password` (sensitive, auto-detected from type) - -**Use this for**: -- Quick verification of redaction -- Basic Age encryption testing -- First-time setup validation - -### Full Credentials Form (`examples/08-encryption/credentials.toml`) - -Comprehensive example demonstrating all encryption features: -- Non-sensitive fields: username, email, company -- Auto-detected sensitive: password, confirm_password (FieldType::Password) -- Explicitly marked sensitive: api_token, ssh_key, database_url -- Field-level backends: vault_token (RustyVault config) -- Override: demo_password (type=password but NOT sensitive) - -**Use this for**: -- Testing field-level sensitivity control -- Field-specific encryption backend configuration -- Demonstrating RustyVault setup - -### Nickel Schema (`examples/08-encryption/nickel-secrets.ncl`) - -Demonstrates encryption in Nickel schema language: -- `Sensitive Backend="age"` annotations -- Key path specification -- Nested structure with sensitive fields - -**Use this for**: -- Understanding Nickel contract syntax -- Converting Nickel schemas to TOML forms - -See `examples/08-encryption/README.md` for detailed examples and testing instructions. - -## Current Status - -✅ **Age (Local encryption)** - Ready to test -- Public key: Generated automatically -- Private key: `~/.age/key.txt` -- No service required, uses CLI tool -- Forms ready: `simple-login.toml`, `credentials.toml` - -✅ **Redaction** - Fully functional -- Works without any encryption service -- Auto-detects sensitive fields from FieldType::Password -- Field-level control with explicit `sensitive` flag - -⏳ **RustyVault (HTTP service)** - Framework ready, tests pending -- Needs: Docker or manual build -- Service: `http://localhost:8200` -- API: Transit secrets engine -- Configuration demo: `credentials.toml` vault_token field - -## Test Results - -**Tests passing (redaction, metadata mapping):** -``` -cargo test --test nickel_integration test_encryption -``` - -Output: -``` -running 5 tests -test test_encryption_metadata_parsing ... ok -test test_encryption_metadata_in_nickel_field ... ok -test test_encryption_auto_detection_from_field_type ... ok -test test_encryption_roundtrip_with_redaction ... ok -test test_encryption_metadata_to_field_definition ... ok - -test result: ok. 5 passed; 0 failed -``` - -All tests use the example forms for verification. - -## Next Steps for Full Encryption Testing - -### 1. Create test forms with encryption - -**test_form_age.toml:** -```toml -name = "age_test" -display_mode = "complete" - -[[fields]] -name = "username" -type = "text" -prompt = "Username" -sensitive = false - -[[fields]] -name = "password" -type = "password" -prompt = "Password" -sensitive = true -encryption_backend = "age" - -[fields.encryption_config] -key = "~/.age/key.txt" -``` - -### 2. Test Age encryption manually - -```bash -# Generate test message -echo "test-secret-123" > /tmp/test.txt - -# Get public key -PUBLIC_KEY=$(grep "^public key:" ~/.age/key.txt | cut -d' ' -f3) - -# Encrypt -age -r "$PUBLIC_KEY" /tmp/test.txt > /tmp/test.age - -# Decrypt -age -d -i ~/.age/key.txt /tmp/test.age -# Output: test-secret-123 -``` - -### 3. Implement Age roundtrip test - -File: `crates/typedialog-core/tests/encryption_roundtrip.rs` - -```rust -#[test] -fn test_age_encrypt_decrypt_roundtrip() { - use typedialog_core::helpers::{EncryptionContext, transform_results}; - - let mut results = HashMap::new(); - results.insert("secret".to_string(), json!("my-password")); - - let field = FieldDefinition { - name: "secret".to_string(), - sensitive: Some(true), - encryption_backend: Some("age".to_string()), - encryption_config: Some({ - let mut m = HashMap::new(); - m.insert("key".to_string(), "~/.age/key.txt".to_string()); - m - }), - ..Default::default() - }; - - // Encrypt - let context = EncryptionContext::encrypt_with("age", Default::default()); - let encrypted = transform_results(&results, &[field.clone()], &context, None) - .expect("Encryption failed"); - - // Verify ciphertext - let ciphertext = encrypted.get("secret").unwrap().as_str().unwrap(); - assert!(ciphertext.starts_with("age1-"), "Should be Age format"); - assert_ne!(ciphertext, "my-password", "Should be encrypted"); -} -``` - -### 4. Test with RustyVault (optional, requires Docker) - -```bash -# Pull RustyVault image -docker pull rustyvault:latest - -# Re-run setup script -./scripts/encryption-test-setup.sh - -# Test encryption with vault -typedialog form test_form_age.toml \ - --encrypt --backend rustyvault \ - --vault-addr http://localhost:8200 \ - --vault-token root \ - --vault-key-path transit/keys/typedialog-key \ - --format json -``` - -## Verification Checklist - -- [ ] Age installed: `age --version` -- [ ] Age keys generated: `cat ~/.age/key.txt` -- [ ] Test redaction: `typedialog form ... --redact` -- [ ] Run encryption tests: `cargo test --test nickel_integration test_encryption` -- [ ] All 5 tests passing -- [ ] (Optional) Docker available for RustyVault -- [ ] (Optional) RustyVault running: `curl http://localhost:8200/v1/sys/health` - -## Documentation - -Full setup guide: See `docs/ENCRYPTION-SERVICES-SETUP.md` diff --git a/docs/ENCRYPTION-SERVICES-SETUP.md b/docs/ENCRYPTION-SERVICES-SETUP.md deleted file mode 100644 index 5212dc0..0000000 --- a/docs/ENCRYPTION-SERVICES-SETUP.md +++ /dev/null @@ -1,695 +0,0 @@ -# HOW-TO: Configure and Run Encryption Services for typedialog - -## Overview - -This guide walks through setting up **Age** (local file-based encryption) and **RustyVault** (HTTP-based encryption service) to test the typedialog encryption pipeline end-to-end. - -**Service Matrix:** - -| Backend | Type | Setup Complexity | Network | Requires | -|---------|------|------------------|---------|----------| -| **Age** | Local file-based | Trivial | None | age CLI tool | -| **RustyVault** | HTTP vault server | Moderate | localhost:8200 | Docker or manual build | -| **SOPS** | External tool | Complex | Varies | sops CLI + backends | - -This guide covers Age (trivial) and RustyVault (moderate). SOPS is skipped for now. - ---- - -## Part 1: Age Backend (Local File Encryption) - -### What is Age? - -Age is a simple, modern encryption tool using X25519 keys. Perfect for development because: -- No daemon/service required -- Keys stored as plaintext files -- Single binary - -### Installation - -**macOS (via Homebrew):** -```bash -brew install age -``` - -**Linux (Ubuntu/Debian):** -```bash -sudo apt-get install age -``` - -**Manual (any OS):** -```bash -# Download from https://github.com/FiloSottile/age/releases -# Extract and add to PATH -tar xzf age-v1.1.1-linux-amd64.tar.gz -sudo mv age/age /usr/local/bin/ -sudo mv age/age-keygen /usr/local/bin/ -``` - -**Verify installation:** -```bash -age --version -# age v1.1.1 -``` - -### Generate Age Key Pair - -Age uses a single private key file that contains both public and private components. The public key is derived from the private key. - -**Generate keys for testing:** -```bash -# Create a test directory -mkdir -p ~/.age - -# Generate private key -age-keygen -o ~/.age/key.txt - -# Output will show: -# Public key: age1...xxx (save this, shown in file) -# Written to /home/user/.age/key.txt -``` - -**Verify key generation:** -```bash -# Check private key exists -cat ~/.age/key.txt -# Output: AGE-SECRET-KEY-1XXXX... - -# Extract public key (age CLI does this automatically) -grep "^public key:" ~/.age/key.txt | cut -d' ' -f3 -``` - -### Test Age Encryption Locally - -**Create a test plaintext file:** -```bash -echo "This is a secret message" > test_message.txt -``` - -**Encrypt with age:** -```bash -# Get public key from private key -PUBLIC_KEY=$(grep "^public key:" ~/.age/key.txt | cut -d' ' -f3) - -# Encrypt -age -r "$PUBLIC_KEY" test_message.txt > test_message.age - -# Verify ciphertext is unreadable -cat test_message.age -# Output: AGE-ENCRYPTION-V1...binary... -``` - -**Decrypt with age:** -```bash -# Decrypt (will prompt for passphrase if key is encrypted) -age -d -i ~/.age/key.txt test_message.age - -# Output: This is a secret message -``` - -### Configure typedialog to Use Age - -**Environment variables:** -```bash -export AGE_KEY_FILE="$HOME/.age/key.txt" -``` - -**CLI flags:** -```bash -# Redact mode (no encryption needed) -typedialog form examples/08-encryption/simple-login.toml --redact --format json - -# Encrypt mode (requires Age backend) -typedialog form examples/08-encryption/simple-login.toml --encrypt --backend age --key-file ~/.age/key.txt --format json -``` - -See `examples/08-encryption/README.md` for more example forms and test cases. - -**TOML form configuration:** -```toml -[[fields]] -name = "password" -type = "password" -prompt = "Enter password" -sensitive = true -encryption_backend = "age" - -[fields.encryption_config] -key = "~/.age/key.txt" -``` - ---- - -## Part 2: RustyVault Backend (HTTP Service) - -### What is RustyVault? - -RustyVault is a Rust implementation of HashiCorp Vault's Transit API: -- HTTP-based encryption/decryption service -- Suitable for production environments -- API-compatible with Vault Transit secrets engine - -### Installation & Setup - -**Option A: Docker (Recommended for testing)** - -RustyVault provides official Docker images. Check availability: -```bash -# Search Docker Hub -docker search rustyvault - -# Or build from source -git clone https://github.com/Tongsuo-Project/RustyVault.git -cd RustyVault -docker build -t rustyvault:latest . -``` - -**Option B: Manual Build (if Docker not available)** - -```bash -# Prerequisites: Rust toolchain -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - -# Clone and build -git clone https://github.com/Tongsuo-Project/RustyVault.git -cd RustyVault -cargo build --release - -# Binary at: target/release/rustyvault -``` - -### Run RustyVault Service - -**Using Docker (single command):** -```bash -docker run -d \ - --name rustyvault \ - -p 8200:8200 \ - -e RUSTYVAULT_LOG_LEVEL=info \ - rustyvault:latest - -# Verify it started -docker logs rustyvault | head -20 -``` - -**Using local binary:** -```bash -# Create config directory -mkdir -p ~/.rustyvault -cd ~/.rustyvault - -# Create minimal config (rustyvault.toml) -cat > config.toml <<'EOF' -[server] -address = "127.0.0.1:8200" -tls_disable = true - -[backend] -type = "inmem" # In-memory storage (ephemeral) -EOF - -# Run service -~/RustyVault/target/release/rustyvault server -c config.toml -``` - -**Verify service is running:** -```bash -# In another terminal -curl -s http://localhost:8200/v1/sys/health | jq . -# Should return health status JSON -``` - -### Configure RustyVault for Encryption - -**Initialize RustyVault (first time only):** -```bash -# Generate initial token -VAULT_INIT=$(curl -s -X POST http://localhost:8200/v1/sys/init \ - -d '{"secret_shares": 1, "secret_threshold": 1}' | jq -r .keys[0]) - -# Unseal vault -curl -s -X PUT http://localhost:8200/v1/sys/unseal \ - -d "{\"key\": \"$VAULT_INIT\"}" > /dev/null - -# Save root token -ROOT_TOKEN=$(curl -s -X POST http://localhost:8200/v1/sys/unseal \ - -d "{\"key\": \"$VAULT_INIT\"}" | jq -r .auth.client_token) - -export VAULT_TOKEN="$ROOT_TOKEN" -``` - -**Enable Transit secrets engine:** -```bash -curl -s -X POST http://localhost:8200/v1/sys/mounts/transit \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -d '{"type": "transit"}' | jq . -``` - -**Create encryption key:** -```bash -curl -s -X POST http://localhost:8200/v1/transit/keys/typedialog-key \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -d '{}' | jq . - -# Verify key created -curl -s http://localhost:8200/v1/transit/keys/typedialog-key \ - -H "X-Vault-Token: $VAULT_TOKEN" | jq . -``` - -### Test RustyVault Encryption - -**Encrypt data via HTTP:** -```bash -# Plaintext (base64 encoded) -PLAINTEXT=$(echo -n "my-secret-password" | base64) - -curl -s -X POST http://localhost:8200/v1/transit/encrypt/typedialog-key \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -d "{\"plaintext\": \"$PLAINTEXT\"}" | jq .data.ciphertext -``` - -**Decrypt data via HTTP:** -```bash -# From encryption output above -CIPHERTEXT="vault:v1:..." - -curl -s -X POST http://localhost:8200/v1/transit/decrypt/typedialog-key \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -d "{\"ciphertext\": \"$CIPHERTEXT\"}" | jq -r .data.plaintext | base64 -d -``` - -### Configure typedialog to Use RustyVault - -**Environment variables:** -```bash -export VAULT_ADDR="http://localhost:8200" -export VAULT_TOKEN="s.xxxx..." # Token from above -``` - -**CLI flags:** -```bash -typedialog form examples/08-encryption/credentials.toml \ - --encrypt \ - --backend rustyvault \ - --vault-addr http://localhost:8200 \ - --vault-token "s.xxxx..." \ - --vault-key-path "transit/keys/typedialog-key" \ - --format json -``` - -This form includes field-level RustyVault configuration in the `vault_token` field. - -**TOML form configuration:** -```toml -[[fields]] -name = "password" -type = "password" -prompt = "Enter password" -sensitive = true -encryption_backend = "rustyvault" - -[fields.encryption_config] -vault_addr = "http://localhost:8200" -vault_token = "s.xxxx..." -key_path = "transit/keys/typedialog-key" -``` - ---- - -## Part 3: Complete Integration Test Workflow - -### Script: Setup Everything - -Create `scripts/encryption-test-setup.sh`: - -```bash -#!/usr/bin/env bash -set -e - -echo "=== typedialog Encryption Services Setup ===" - -# Age Setup -echo "1. Setting up Age..." -if ! command -v age &> /dev/null; then - echo " ✗ age not installed. Run: brew install age" - exit 1 -fi - -mkdir -p ~/.age -if [ ! -f ~/.age/key.txt ]; then - echo " → Generating Age keys..." - age-keygen -o ~/.age/key.txt -fi -export AGE_KEY_FILE="$HOME/.age/key.txt" -echo " ✓ Age configured at: $AGE_KEY_FILE" - -# RustyVault Setup (Docker) -echo "" -echo "2. Setting up RustyVault (Docker)..." -if ! command -v docker &> /dev/null; then - echo " ⚠ Docker not installed, skipping RustyVault" - echo " → Install Docker or skip RustyVault tests" -else - if ! docker ps | grep -q rustyvault; then - echo " → Starting RustyVault container..." - docker run -d \ - --name rustyvault \ - -p 8200:8200 \ - -e RUSTYVAULT_LOG_LEVEL=info \ - rustyvault:latest - sleep 2 - fi - - # Initialize vault - echo " → Initializing RustyVault..." - VAULT_INIT=$(curl -s -X POST http://localhost:8200/v1/sys/init \ - -d '{"secret_shares": 1, "secret_threshold": 1}' | jq -r .keys[0]) - - curl -s -X PUT http://localhost:8200/v1/sys/unseal \ - -d "{\"key\": \"$VAULT_INIT\"}" > /dev/null - - # Get root token - RESPONSE=$(curl -s -X GET http://localhost:8200/v1/sys/unseal \ - -H "X-Vault-Token: $VAULT_INIT") - export VAULT_TOKEN=$(echo "$RESPONSE" | jq -r .auth.client_token // "root") - export VAULT_ADDR="http://localhost:8200" - - # Enable transit - curl -s -X POST http://localhost:8200/v1/sys/mounts/transit \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -d '{"type": "transit"}' > /dev/null 2>&1 || true - - # Create key - curl -s -X POST http://localhost:8200/v1/transit/keys/typedialog-key \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -d '{}' > /dev/null 2>&1 || true - - echo " ✓ RustyVault running at: http://localhost:8200" - echo " ✓ Token: $VAULT_TOKEN" -fi - -echo "" -echo "=== Setup Complete ===" -echo "" -echo "Test Age encryption:" -echo " typedialog form test.toml --encrypt --backend age --key-file ~/.age/key.txt" -echo "" -echo "Test RustyVault encryption:" -echo " export VAULT_ADDR='http://localhost:8200'" -echo " export VAULT_TOKEN='$VAULT_TOKEN'" -echo " typedialog form test.toml --encrypt --backend rustyvault --vault-key-path 'transit/keys/typedialog-key'" -``` - -**Make executable and run:** -```bash -chmod +x scripts/encryption-test-setup.sh -./scripts/encryption-test-setup.sh -``` - -### Test Case 1: Age Redaction (No Service Required) - -**Option A: Use pre-built example (Recommended)** -```bash -typedialog form examples/08-encryption/simple-login.toml --redact --format json - -# Expected output: -# {"username": "alice", "password": "[REDACTED]"} -``` - -**Option B: Create form manually** -```bash -# Create test form -cat > test_redaction.toml <<'EOF' -name = "test_form" -display_mode = "complete" - -[[fields]] -name = "username" -type = "text" -prompt = "Username" - -[[fields]] -name = "password" -type = "password" -prompt = "Password" -sensitive = true -EOF - -# Test redaction (requires no service) -typedialog form test_redaction.toml --redact --format json -``` - -### Test Case 2: Age Encryption (Service Not Required, Key File Required) - -**Option A: Use pre-built example (Recommended)** -```bash -# Prerequisites: Age key generated (from setup script) -./scripts/encryption-test-setup.sh - -# Test with simple form -typedialog form examples/08-encryption/simple-login.toml \ - --encrypt --backend age --key-file ~/.age/key.txt --format json - -# Expected output: password field contains age ciphertext -# {"username": "alice", "password": "age1muz6ah54ew9am7mzmy0m4w5..."} - -# Or test with full credentials form -typedialog form examples/08-encryption/credentials.toml \ - --encrypt --backend age --key-file ~/.age/key.txt --format json -``` - -**Option B: Create form manually** -```bash -# Generate Age key if not exists -mkdir -p ~/.age -if [ ! -f ~/.age/key.txt ]; then - age-keygen -o ~/.age/key.txt -fi - -# Create test form -cat > test_age_encrypt.toml <<'EOF' -name = "test_form" -display_mode = "complete" - -[[fields]] -name = "username" -type = "text" -prompt = "Username" - -[[fields]] -name = "password" -type = "password" -prompt = "Password" -sensitive = true -encryption_backend = "age" - -[fields.encryption_config] -key = "~/.age/key.txt" -EOF - -# Test encryption (requires Age key file) -typedialog form test_age_encrypt.toml --encrypt --backend age --key-file ~/.age/key.txt --format json -``` - -### Test Case 3: RustyVault Encryption (Service Required) - -**Prerequisites: RustyVault running** -```bash -# Start RustyVault and setup (requires Docker) -./scripts/encryption-test-setup.sh - -# Verify service is healthy -curl http://localhost:8200/v1/sys/health | jq . -``` - -**Option A: Use pre-built example (Recommended)** -```bash -# Export Vault credentials -export VAULT_ADDR="http://localhost:8200" -export VAULT_TOKEN="root" - -# Test with simple form -typedialog form examples/08-encryption/simple-login.toml \ - --encrypt --backend rustyvault \ - --vault-key-path "transit/keys/typedialog-key" \ - --format json - -# Expected output: password field contains vault ciphertext -# {"username": "alice", "password": "vault:v1:K8..."} - -# Or test with full credentials form (demonstrates field-level config) -typedialog form examples/08-encryption/credentials.toml \ - --encrypt --backend rustyvault \ - --vault-key-path "transit/keys/typedialog-key" \ - --format json -``` - -**Option B: Create form manually** -```bash -cat > test_vault_encrypt.toml <<'EOF' -name = "test_form" -display_mode = "complete" - -[[fields]] -name = "username" -type = "text" -prompt = "Username" - -[[fields]] -name = "password" -type = "password" -prompt = "Password" -sensitive = true -encryption_backend = "rustyvault" - -[fields.encryption_config] -vault_addr = "http://localhost:8200" -key_path = "transit/keys/typedialog-key" -EOF - -# Test encryption with RustyVault -export VAULT_TOKEN="s.xxxx" # From setup output -export VAULT_ADDR="http://localhost:8200" - -typedialog form test_vault_encrypt.toml \ - --encrypt \ - --backend rustyvault \ - --vault-addr http://localhost:8200 \ - --vault-token "$VAULT_TOKEN" \ - --vault-key-path "transit/keys/typedialog-key" \ - --format json - -# Expected output: password field contains vault ciphertext -# {"username": "alice", "password": "vault:v1:..."} -``` - ---- - -## Part 4: Run Actual Integration Tests - -### Test Case: Age Roundtrip (Encrypt → Decrypt) - -Once Age is set up, these test scenarios validate the pipeline: - -**Scenario 1: Redaction works (no encryption service)** -```bash -cargo test --test nickel_integration test_encryption_roundtrip_with_redaction -- --nocapture - -# Expected: PASS - redacts sensitive fields -``` - -**Scenario 2: Metadata mapping works** -```bash -cargo test --test nickel_integration test_encryption_metadata_to_field_definition -- --nocapture - -# Expected: PASS - EncryptionMetadata maps to FieldDefinition -``` - -**Scenario 3: Auto-detection of password fields** -```bash -cargo test --test nickel_integration test_encryption_auto_detection_from_field_type -- --nocapture - -# Expected: PASS - Password fields auto-marked as sensitive -``` - -### Run All Encryption Tests - -```bash -cargo test --test nickel_integration test_encryption -- --nocapture -``` - -**Current status:** -- ✅ 5 tests passing (redaction, metadata mapping) -- ⏳ 0 tests for actual Age encryption roundtrip (not yet implemented) -- ⏳ 0 tests for RustyVault integration (backend not implemented) - ---- - -## Part 5: Troubleshooting - -### Age Issues - -**Problem: `age: command not found`** -```bash -# Install age -brew install age # macOS -sudo apt install age # Linux -``` - -**Problem: Permission denied on ~/.age/key.txt** -```bash -chmod 600 ~/.age/key.txt -``` - -**Problem: Invalid key format** -```bash -# Regenerate keys -rm ~/.age/key.txt -age-keygen -o ~/.age/key.txt -``` - -### RustyVault Issues - -**Problem: Docker container won't start** -```bash -# Check logs -docker logs rustyvault - -# Remove and restart -docker rm -f rustyvault -docker run -d --name rustyvault -p 8200:8200 rustyvault:latest -``` - -**Problem: Vault initialization fails** -```bash -# Check if vault is responding -curl -s http://localhost:8200/v1/sys/health - -# If not, restart container -docker restart rustyvault -``` - -**Problem: Transit API not working** -```bash -# Verify token -echo $VAULT_TOKEN - -# Check auth -curl -s http://localhost:8200/v1/sys/mounts \ - -H "X-Vault-Token: $VAULT_TOKEN" -``` - -**Problem: Can't connect from typedialog** -```bash -# Verify network -curl -s http://localhost:8200/v1/sys/health | jq . - -# Check environment variables -echo $VAULT_ADDR -echo $VAULT_TOKEN - -# Test encryption endpoint -curl -s -X POST http://localhost:8200/v1/transit/encrypt/typedialog-key \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -d '{"plaintext": "dGVzdA=="}' | jq . -``` - ---- - -## Part 6: Next Steps - -Once services are running, implement: - -1. **test_age_encrypt_roundtrip** - Encrypt with Age, decrypt, verify plaintext -2. **test_rustyvault_encrypt_roundtrip** - Encrypt with RustyVault, decrypt, verify -3. **test_cli_encrypt_age** - Run `typedialog form --encrypt --backend age`, verify output is ciphertext -4. **test_cli_encrypt_rustyvault** - Run `typedialog form --encrypt --backend rustyvault`, verify output is ciphertext -5. **Integration test script** - Single script that tests all pipelines end-to-end - ---- - -## References - -- **Age**: https://github.com/FiloSottile/age -- **RustyVault**: https://github.com/Tongsuo-Project/RustyVault -- **HashiCorp Vault Transit**: https://www.vaultproject.io/api-docs/secret/transit diff --git a/docs/ENCRYPTION-UNIFIED-ARCHITECTURE.md b/docs/ENCRYPTION-UNIFIED-ARCHITECTURE.md deleted file mode 100644 index f43b8e9..0000000 --- a/docs/ENCRYPTION-UNIFIED-ARCHITECTURE.md +++ /dev/null @@ -1,438 +0,0 @@ -# Unified Encryption Architecture - -This document explains the updated encryption architecture for typedialog and how it integrates with the unified encryption system from prov-ecosystem. - -## Overview - -typedialog now uses a **unified encryption API** from the `encrypt` crate, eliminating backend-specific code and enabling support for multiple encryption backends (Age, SOPS, SecretumVault, AWS/GCP/Azure KMS) through a single API. - -**Key Benefits:** -- Single code path supports all backends -- Configuration-driven backend selection -- Multi-backend support in TOML and Nickel schemas -- Post-quantum cryptography ready (via SecretumVault) -- Cleaner, more maintainable code - -## Architecture Changes - -### Before: Direct Backend Instantiation - -```rust -// OLD: Direct Age backend instantiation -use encrypt::backend::age::AgeBackend; - -let backend = AgeBackend::with_defaults()?; -let ciphertext = backend.encrypt(&plaintext)?; - -// To support other backends, need separate code paths -#[match] -"sops" => { /* SOPS code */ } -"rustyvault" => { /* RustyVault code */ } -``` - -### After: Unified API with BackendSpec - -```rust -// NEW: Configuration-driven, backend-agnostic -use encrypt::{encrypt, BackendSpec}; - -// Same code for all backends -let spec = BackendSpec::age_default(); -let ciphertext = encrypt(&plaintext, &spec)?; - -// Or SOPS -let spec = BackendSpec::sops(); -let ciphertext = encrypt(&plaintext, &spec)?; - -// Or KMS -let spec = BackendSpec::aws_kms(region, key_id); -let ciphertext = encrypt(&plaintext, &spec)?; -``` - -## Integration Points - -### 1. TOML Form Configuration - -No changes required for existing TOML configurations. The system transparently uses the new API: - -```toml -[[fields]] -name = "password" -type = "password" -sensitive = true -encryption_backend = "age" -encryption_config = { key_file = "~/.age/key.txt" } -``` - -The `encryption_bridge` module automatically converts this to `BackendSpec::age(...)` and uses the unified API. - -### 2. Nickel Schema Integration - -Nickel support remains unchanged but now uses the unified backend system: - -```nickel -{ - # Age backend for development - dev_password | Sensitive Backend="age" Key="~/.age/key.txt" = "", - - # SOPS for staging - staging_secret | Sensitive Backend="sops" = "", - - # SecretumVault Transit Engine for production (post-quantum) - prod_token | Sensitive Backend="secretumvault" - Vault="https://vault.internal:8200" - Key="app-key" = "", - - # AWS KMS for cloud-native deployments - aws_secret | Sensitive Backend="awskms" - Region="us-east-1" - KeyId="arn:aws:kms:..." = "", -} -``` - -### 3. Internal Encryption Function - -The `transform_sensitive_value()` function now uses the unified API: - -```rust -// File: crates/typedialog-core/src/helpers.rs - -fn transform_sensitive_value( - value: &Value, - field: &FieldDefinition, - context: &EncryptionContext, - _global_config: Option<&EncryptionDefaults>, -) -> Result { - // Convert field definition to BackendSpec - let spec = crate::encryption_bridge::field_to_backend_spec(field, None)?; - - // Use unified API - let plaintext = serde_json::to_string(value)?; - let ciphertext = encrypt::encrypt(&plaintext, &spec)?; - - Ok(Value::String(ciphertext)) -} -``` - -## New Bridge Module - -New `encryption_bridge.rs` module provides seamless conversion: - -```rust -// File: crates/typedialog-core/src/encryption_bridge.rs - -pub fn field_to_backend_spec( - field: &FieldDefinition, - default_backend: Option<&str>, -) -> Result -``` - -**Conversion Logic:** -1. Reads `field.encryption_backend` (or uses default) -2. Extracts `field.encryption_config` (backend-specific settings) -3. Validates required configuration for the backend -4. Returns `BackendSpec` ready for use with `encrypt::encrypt()` - -**Supported Backends:** -- ✓ Age (with custom key paths) -- ✓ SOPS (minimal config) -- ✓ SecretumVault (vault_addr, vault_token, key_name) -- ✓ AWS KMS (region, key_id) -- ✓ GCP KMS (project_id, key_ring, crypto_key, location) -- ✓ Azure KMS (vault_name, tenant_id) - -## Configuration Changes - -### Cargo.toml - -No changes required - encryption support includes all commonly used backends by default. - -To customize backends: - -```toml -[dependencies] -# Default: age + other major backends -typedialog-core = { path = "...", features = ["encryption"] } - -# Only Age -typedialog-core = { path = "...", features = ["encryption"] } -# (Age is default in encrypt crate) - -# Custom selection -typedialog-core = { path = "...", features = ["encryption"] } -# Depends on encrypt crate configuration -``` - -### Environment Variables - -Backend-specific configuration via environment: - -**Age:** -```bash -# Uses ~/.age/key.txt by default -# Or specify via field config: encryption_config = { key_file = "/custom/path" } -``` - -**SOPS:** -```bash -# Uses .sops.yaml in current/parent directories -``` - -**SecretumVault:** -```bash -export VAULT_ADDR="https://vault.internal:8200" -export VAULT_TOKEN="hvs.CAAA..." -``` - -**AWS KMS:** -```bash -export AWS_REGION="us-east-1" -# AWS credentials from standard chain (env vars, ~/.aws/credentials, IAM roles) -``` - -**GCP KMS:** -```bash -export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" -``` - -**Azure KMS:** -```bash -# Azure CLI authentication or environment variables -``` - -## Error Handling - -Enhanced error messages guide users through troubleshooting: - -``` -Error: Encryption failed: Backend 'age' not available. - Enable feature 'age' in Cargo.toml - -Error: Encryption failed: SecretumVault backend requires - vault_addr in encryption_config - -Error: Encryption failed: AWS KMS backend requires region - in encryption_config -``` - -## Testing - -### Running Encryption Tests - -```bash -# All encryption tests -cargo test --features encryption - -# Only encryption integration tests -cargo test --features encryption --test encryption_integration - -# Specific test -cargo test --features encryption test_age_roundtrip_encrypt_decrypt -``` - -### Test Results - -All 15 encryption integration tests pass: -- ✓ 7 encryption behavior tests -- ✓ 8 Age backend roundtrip tests - -``` -running 15 tests -test encryption_tests::test_explicit_non_sensitive_overrides_password_type ... ok -test encryption_tests::test_auto_detect_password_field_as_sensitive ... ok -test encryption_tests::test_redaction_preserves_non_sensitive ... ok -test encryption_tests::test_multiple_sensitive_fields ... ok -test encryption_tests::test_redaction_in_json_output ... ok -test encryption_tests::test_unknown_backend_error ... ok -test encryption_tests::test_redaction_in_yaml_output ... ok -test age_roundtrip_tests::test_age_backend_availability ... ok -test age_roundtrip_tests::test_age_invalid_ciphertext_fails ... ok -test age_roundtrip_tests::test_age_encryption_produces_ciphertext ... ok -test age_roundtrip_tests::test_age_roundtrip_encrypt_decrypt ... ok -test age_roundtrip_tests::test_age_handles_empty_string ... ok -test age_roundtrip_tests::test_age_handles_unicode ... ok -test age_roundtrip_tests::test_age_encryption_different_ciphertexts ... ok -test age_roundtrip_tests::test_age_handles_large_values ... ok - -test result: ok. 15 passed; 0 failed -``` - -## Migration Path - -### For Existing Users - -**No action required.** The system is backward compatible: - -1. Existing TOML forms work unchanged -2. Existing Nickel schemas work unchanged -3. Internal implementation now uses unified API -4. No visible changes to users - -### For New Deployments - -Can now use additional backends: - -```toml -# Now supports more backends in single codebase -[[fields]] -name = "secret" -type = "text" -sensitive = true -encryption_backend = "secretumvault" # Or awskms, gcpkms, azurekms -encryption_config = { vault_addr = "...", vault_token = "..." } -``` - -### For Code Extending typedialog - -If extending typedialog with custom encryption logic: - -```rust -// OLD: Manual backend instantiation (still works) -use encrypt::backend::age::AgeBackend; -let backend = AgeBackend::new(pub_key, priv_key)?; - -// NEW: Use bridge module + unified API (recommended) -use encrypt::encrypt; -use typedialog_core::encryption_bridge; - -let spec = encryption_bridge::field_to_backend_spec(&field, None)?; -let ciphertext = encrypt(&plaintext, &spec)?; -``` - -## Multi-Backend Support - -### Same Code, Different Configs - -```toml -# Development (Age) -[[fields]] -name = "db_password" -sensitive = true -encryption_backend = "age" - -# Production (SecretumVault - post-quantum) -[[fields]] -name = "db_password" -sensitive = true -encryption_backend = "secretumvault" -encryption_config = { vault_addr = "https://vault.prod:8200", vault_token = "..." } -``` - -Same Rust code handles both without changes. - -### CLI Overrides - -```bash -# Override backend from command line -typedialog form config.toml --encrypt --backend secretumvault - -# Use with environment variables -export VAULT_ADDR="https://vault.internal:8200" -export VAULT_TOKEN="hvs.CAAA..." -typedialog form config.toml --encrypt --backend secretumvault -``` - -## Feature Flags - -Backends are feature-gated in the encrypt crate: - -```rust -// With feature enabled -#[cfg(feature = "age")] -{ - let spec = BackendSpec::age_default(); - encrypt(&plaintext, &spec)?; // Works -} - -// Without feature -#[cfg(not(feature = "age"))] -{ - let spec = BackendSpec::age_default(); - encrypt(&plaintext, &spec)?; // Returns: Backend 'age' not available -} -``` - -## Post-Quantum Cryptography - -### SecretumVault Transit Engine - -For post-quantum cryptography support, use SecretumVault with ML-KEM/ML-DSA: - -```toml -[[fields]] -name = "pqc_secret" -sensitive = true -encryption_backend = "secretumvault" -encryption_config = { - vault_addr = "https://pq-vault.internal:8200", - vault_token = "hvs.CAAA...", - key_name = "pqc-key" # Uses ML-KEM encapsulation, ML-DSA signatures -} -``` - -**Requirements:** -- SecretumVault server configured with Transit Engine -- Post-quantum crypto backend enabled (aws-lc-rs or Tongsuo) - -## Troubleshooting - -### "Backend not available" Error - -**Problem:** Encryption fails with "Backend 'age' not available" - -**Solution:** Feature may not be enabled in encrypt crate. Check: - -```bash -# Check available backends -cargo build --features encryption --verbose - -# Look for feature compilation -# It should show: "Compiling encrypt ... with features: age,..." -``` - -### "Invalid ciphertext" After Update - -**Problem:** Old ciphertexts fail to decrypt - -**Solution:** Age format hasn't changed. Verify: -1. Same Age key is used -2. Ciphertext format is valid (hex-encoded) -3. Key file permissions: `chmod 600 ~/.age/key.txt` - -### Form TOML Backward Compatibility - -**Problem:** Existing TOML forms stop working after update - -**Solution:** No breaking changes. Forms should work as-is. If not: - -1. Verify encryption_backend name is valid -2. Check encryption_config required fields -3. Test with: `cargo test --features encryption` - -## Testing with Mock Backend - -For faster tests without real encryption keys: - -```bash -# In development/testing -cargo test --features test-util -``` - -MockBackend provides deterministic encryption for CI/CD: - -```rust -#[cfg(test)] -use encrypt::test_util::MockBackend; - -let backend = MockBackend::new(); -let ct = backend.encrypt("secret")?; -// Fast, reproducible, no real keys needed -``` - -## See Also - -- [ENCRYPTION-QUICK-START.md](ENCRYPTION-QUICK-START.md) - Getting started with encryption -- [ENCRYPTION-SERVICES-SETUP.md](ENCRYPTION-SERVICES-SETUP.md) - Setting up encryption services -- [../../prov-ecosystem/docs/guides/ENCRYPTION.md](../../prov-ecosystem/docs/guides/ENCRYPTION.md) - Comprehensive encryption guide -- [encryption_bridge.rs](crates/typedialog-core/src/encryption_bridge.rs) - Bridge module source -- [../../prov-ecosystem/crates/encrypt](../../prov-ecosystem/crates/encrypt) - encrypt crate source diff --git a/locales/en-US.toml b/locales/en-US.toml deleted file mode 100644 index 61bf405..0000000 --- a/locales/en-US.toml +++ /dev/null @@ -1,31 +0,0 @@ -# English translations (alternative TOML format) - -[forms.registration] -title = "User Registration" -description = "Create a new user account" -username-label = "Username" -username-prompt = "Please enter a username" -username-placeholder = "user123" -email-label = "Email Address" -email-prompt = "Please enter your email address" -email-placeholder = "user@example.com" - -[forms.registration.roles] -admin = "Administrator" -user = "Regular User" -guest = "Guest" -developer = "Developer" - -[forms.employee-onboarding] -title = "Employee Onboarding" -description = "Complete your onboarding process" -welcome = "Welcome to the team!" -full-name-prompt = "What is your full name?" -department-prompt = "Which department are you joining?" -start-date-prompt = "What is your start date?" - -[forms.feedback] -title = "Feedback Form" -overall-satisfaction-prompt = "How satisfied are you with our service?" -improvement-prompt = "What could we improve?" -contact-prompt = "Can we contact you with follow-up questions?" diff --git a/locales/en-US/forms.ftl b/locales/en-US/forms.ftl deleted file mode 100644 index 2e830a2..0000000 --- a/locales/en-US/forms.ftl +++ /dev/null @@ -1,37 +0,0 @@ -# English translations for common form fields - -## Registration form -registration-title = User Registration -registration-description = Create a new user account -registration-username-label = Username -registration-username-prompt = Please enter a username -registration-username-placeholder = user123 -registration-email-label = Email Address -registration-email-prompt = Please enter your email address -registration-email-placeholder = user@example.com -registration-password-label = Password -registration-password-prompt = Please enter a password -registration-password-placeholder = •••••••• -registration-confirm-label = I agree to the terms and conditions -registration-confirm-prompt = Do you agree to the terms and conditions? - -## Role selection -role-prompt = Please select your role -role-admin = Administrator -role-user = Regular User -role-guest = Guest -role-developer = Developer - -## Common actions -action-submit = Submit -action-cancel = Cancel -action-next = Next -action-previous = Previous -action-confirm = Confirm -action-decline = Decline - -## Common validation messages -error-required = This field is required -error-invalid-email = Please enter a valid email address -error-password-too-short = Password must be at least 8 characters -error-passwords-mismatch = Passwords do not match diff --git a/locales/es-ES.toml b/locales/es-ES.toml deleted file mode 100644 index 79fd0ab..0000000 --- a/locales/es-ES.toml +++ /dev/null @@ -1,31 +0,0 @@ -# Traducciones al español (formato TOML alternativo) - -[forms.registration] -title = "Registro de Usuario" -description = "Crear una nueva cuenta de usuario" -username-label = "Nombre de usuario" -username-prompt = "Por favor, ingrese su nombre de usuario" -username-placeholder = "usuario123" -email-label = "Correo electrónico" -email-prompt = "Por favor, ingrese su correo electrónico" -email-placeholder = "usuario@ejemplo.com" - -[forms.registration.roles] -admin = "Administrador" -user = "Usuario Regular" -guest = "Invitado" -developer = "Desarrollador" - -[forms.employee-onboarding] -title = "Incorporación de Empleado" -description = "Complete su proceso de incorporación" -welcome = "¡Bienvenido al equipo!" -full-name-prompt = "¿Cuál es su nombre completo?" -department-prompt = "¿A cuál departamento se está uniendo?" -start-date-prompt = "¿Cuál es su fecha de inicio?" - -[forms.feedback] -title = "Formulario de Retroalimentación" -overall-satisfaction-prompt = "¿Cuán satisfecho está con nuestro servicio?" -improvement-prompt = "¿Qué podríamos mejorar?" -contact-prompt = "¿Podemos contactarlo con preguntas de seguimiento?" diff --git a/locales/es-ES/forms.ftl b/locales/es-ES/forms.ftl deleted file mode 100644 index 25c7159..0000000 --- a/locales/es-ES/forms.ftl +++ /dev/null @@ -1,37 +0,0 @@ -# Traducciones al español para formularios comunes - -## Formulario de registro -registration-title = Registro de Usuario -registration-description = Crear una nueva cuenta de usuario -registration-username-label = Nombre de usuario -registration-username-prompt = Por favor, ingrese su nombre de usuario -registration-username-placeholder = usuario123 -registration-email-label = Correo electrónico -registration-email-prompt = Por favor, ingrese su correo electrónico -registration-email-placeholder = usuario@ejemplo.com -registration-password-label = Contraseña -registration-password-prompt = Por favor, ingrese su contraseña -registration-password-placeholder = •••••••• -registration-confirm-label = Acepto los términos y condiciones -registration-confirm-prompt = ¿Acepta los términos y condiciones? - -## Selección de rol -role-prompt = Por favor, seleccione su rol -role-admin = Administrador -role-user = Usuario Regular -role-guest = Invitado -role-developer = Desarrollador - -## Acciones comunes -action-submit = Enviar -action-cancel = Cancelar -action-next = Siguiente -action-previous = Anterior -action-confirm = Confirmar -action-decline = Rechazar - -## Mensajes de validación comunes -error-required = Este campo es requerido -error-invalid-email = Por favor, ingrese una dirección de correo válida -error-password-too-short = La contraseña debe tener al menos 8 caracteres -error-passwords-mismatch = Las contraseñas no coinciden