start_project.rs
923 lignes · 29740 octets
use std::fs; use std::path::{Path, PathBuf}; use console::style; use dialoguer::{Confirm, Input, Select}; use rand::Rng; use crate::templates::{ self, FediverseMode, OAuthProviderConfig, OAuthWizardConfig, ProjectConfig, SmtpConfig, }; use crate::version; pub fn run() -> Result<(), Box<dyn std::error::Error>> { println!( "\n{}", style(" Rustwarden - Nouveau projet ") .bold() .on_cyan() .black(), ); println!(); let rustwarden_root = find_rustwarden_root()?; let name = ask_project_name()?; let app_display_name = ask_app_display_name(&name)?; let description = ask_description(&name)?; let project_dir = ask_target_dir(&name)?; handle_existing_dir(&project_dir)?; let with_ui = ask_ui_mode()?; let (fediverse_mode, fediverse_domain) = ask_fediverse_mode()?; let auto_migrate = ask_auto_migrate()?; let port = ask_port()?; println!("\n{}", style("── Base de donnees ──").cyan().bold()); let (db_name, db_user, db_password) = ask_database_config(&name)?; println!("\n{}", style("── Administrateur ──").cyan().bold()); let (admin_username, admin_email, admin_password) = ask_admin_config()?; println!("\n{}", style("── Email SMTP ──").cyan().bold()); let smtp = ask_smtp_config(&app_display_name)?; println!("\n{}", style("── OAuth / SSO ──").cyan().bold()); let oauth = ask_oauth_config(port)?; let jwt_secret = generate_jwt_secret(); let cfg = ProjectConfig { name: name.clone(), app_display_name: app_display_name.clone(), description: description.clone(), with_ui, fediverse_mode, fediverse_domain, auto_migrate, port, db_name, db_user, db_password, admin_username, admin_email, admin_password, jwt_secret, smtp, oauth, }; println!("\n{}", style("Generation du projet...").cyan().bold()); fs::create_dir_all(project_dir.join("src"))?; fs::create_dir_all(project_dir.join("crates"))?; copy_crate(&rustwarden_root, &project_dir, "rustwarden-core")?; if with_ui { copy_crate(&rustwarden_root, &project_dir, "rustwarden-ui")?; } match cfg.fediverse_mode { FediverseMode::None => {} FediverseMode::Headless => { copy_crate(&rustwarden_root, &project_dir, "rustwarden-fediverse")?; } FediverseMode::WithReferenceUi => { copy_crate(&rustwarden_root, &project_dir, "rustwarden-fediverse")?; copy_crate(&rustwarden_root, &project_dir, "rustwarden-fediverse-ui")?; } } copy_resources(&rustwarden_root, &project_dir, with_ui)?; let fediverse_active = !matches!(cfg.fediverse_mode, FediverseMode::None); copy_docs(&rustwarden_root, &project_dir, fediverse_active)?; generate_files(&project_dir, &cfg)?; rebrand_templates(&project_dir, &app_display_name)?; // Ecrire le fichier de version du framework if let Ok(v) = version::compute_version(&rustwarden_root) { let _ = version::write_version(&project_dir, &v); } print_summary(&cfg, &project_dir); Ok(()) } // --------------------------------------------------------------------------- // Questions // --------------------------------------------------------------------------- fn ask_project_name() -> Result<String, Box<dyn std::error::Error>> { let name: String = Input::new() .with_prompt(format!("{}", style("Nom du projet").cyan())) .validate_with(|input: &String| -> Result<(), String> { if input.is_empty() { return Err("Le nom ne peut pas etre vide".into()); } if input.contains(' ') { return Err("Le nom ne peut pas contenir d'espaces".into()); } if !input .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') { return Err( "Seuls les caracteres alphanumeriques minuscules, tirets et underscores sont autorises (Docker Compose exige lowercase)" .into(), ); } let first = input.chars().next().unwrap_or(' '); if !first.is_ascii_alphanumeric() { return Err( "Le nom doit commencer par une lettre ou un chiffre (Docker Compose)".into(), ); } Ok(()) }) .interact_text()?; Ok(name) } fn ask_app_display_name(name: &str) -> Result<String, Box<dyn std::error::Error>> { let display_name: String = Input::new() .with_prompt(format!( "{}", style("Nom affiche dans l'interface (APP_NAME)").cyan() )) .default(name.to_string()) .interact_text()?; Ok(display_name) } fn ask_description(name: &str) -> Result<String, Box<dyn std::error::Error>> { let desc: String = Input::new() .with_prompt(format!("{}", style("Description").cyan())) .default(format!("Application {name} propulsee par Rustwarden")) .interact_text()?; Ok(desc) } fn ask_target_dir(name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> { let default = format!("./{name}"); let dir: String = Input::new() .with_prompt(format!( "{}", style("Dossier cible (le projet sera cree a cet emplacement)").cyan() )) .default(default) .interact_text()?; let target = PathBuf::from(&dir); let target = if target.is_absolute() { target } else { std::env::current_dir()?.join(target) }; Ok(target) } fn handle_existing_dir(project_dir: &Path) -> Result<(), Box<dyn std::error::Error>> { if project_dir.exists() { println!( "\n{}", style(format!( "Le dossier {} existe deja :", project_dir.display() )) .yellow(), ); if let Ok(entries) = fs::read_dir(project_dir) { let items: Vec<String> = entries .filter_map(|e| e.ok()) .map(|e| { let name = e.file_name().to_string_lossy().to_string(); if e.path().is_dir() { format!(" {}/", name) } else { format!(" {}", name) } }) .collect(); if items.is_empty() { println!(" {}", style("(vide)").dim()); } else { for item in &items { println!("{item}"); } } } println!(); let confirm = Confirm::new() .with_prompt("Utiliser ce dossier ? Les fichiers existants du meme nom seront ecrases") .default(false) .interact()?; if !confirm { return Err("Operation annulee par l'utilisateur".into()); } } Ok(()) } /// Demande comment le projet consommateur doit consommer la federation /// ActivityPub : pas du tout, protocole headless seul, ou protocole + /// UI HTML de reference. /// /// Si un mode federe est selectionne, demande aussi le domaine federe a /// placer dans `FEDIVERSE_DOMAIN`. /// /// Retourne `(FediverseMode, Some(domain))` quand la federation est /// activee, ou `(FediverseMode::None, None)` sinon. fn ask_fediverse_mode() -> Result<(FediverseMode, Option<String>), Box<dyn std::error::Error>> { let options = vec![ "Pas de federation", "Federation headless (protocole AP seul, vous implementez votre propre UI)", "Federation avec UI de reference (protocole AP + interface HTML par defaut) [recommande]", ]; let selection = Select::new() .with_prompt(format!( "{}", style("Federation ActivityPub (RFC 0001+, WebFinger, AP-native auth, invitations cross-instance)").cyan() )) .items(&options) .default(2) .interact()?; let mode = match selection { 0 => FediverseMode::None, 1 => FediverseMode::Headless, _ => FediverseMode::WithReferenceUi, }; if matches!(mode, FediverseMode::None) { return Ok((mode, None)); } let domain: String = Input::new() .with_prompt(format!( "{}", style("Domaine federe (FEDIVERSE_DOMAIN, ex. example.org)").cyan() )) .default("example.org".into()) .validate_with(|input: &String| -> Result<(), String> { let trimmed = input.trim(); if trimmed.is_empty() { return Err("Le domaine ne peut pas etre vide".into()); } // Refuse les placeholders attendus par FediverseConfig::from_env. let lower = trimmed.to_ascii_lowercase(); let stripped = lower .trim_start_matches("http://") .trim_start_matches("https://") .trim_end_matches('/'); let placeholders = [ "example.com", "example.net", "localhost.localdomain", "your.tld", "your-domain.tld", "domain.tld", "change-me.tld", ]; if placeholders.contains(&stripped) { return Err(format!( "`{stripped}` est un placeholder refuse par FediverseConfig — saisir un vrai domaine" )); } Ok(()) }) .interact_text()?; Ok((mode, Some(domain.trim().to_string()))) } fn ask_ui_mode() -> Result<bool, Box<dyn std::error::Error>> { let options = vec!["Avec interface web (rustwarden-ui)", "API seule (headless)"]; let selection = Select::new() .with_prompt(format!("{}", style("Mode").cyan())) .items(&options) .default(0) .interact()?; Ok(selection == 0) } fn ask_auto_migrate() -> Result<bool, Box<dyn std::error::Error>> { let confirm = Confirm::new() .with_prompt(format!( "{}", style("Lancer les migrations automatiquement au demarrage ?").cyan() )) .default(true) .interact()?; Ok(confirm) } fn ask_port() -> Result<u16, Box<dyn std::error::Error>> { let port: String = Input::new() .with_prompt(format!("{}", style("Port du serveur").cyan())) .default("3000".into()) .validate_with(|input: &String| -> Result<(), String> { input .parse::<u16>() .map(|_| ()) .map_err(|_| "Veuillez entrer un numero de port valide (1-65535)".into()) }) .interact_text()?; Ok(port.parse()?) } fn ask_database_config( project_name: &str, ) -> Result<(String, String, String), Box<dyn std::error::Error>> { let db_name: String = Input::new() .with_prompt(format!("{}", style("Nom de la base de donnees").cyan())) .default(project_name.replace('-', "_")) .interact_text()?; let db_user: String = Input::new() .with_prompt(format!("{}", style("Utilisateur PostgreSQL").cyan())) .default(format!("{}_user", project_name.replace('-', "_"))) .interact_text()?; let default_pw = generate_random_password(16); let db_password: String = Input::new() .with_prompt(format!("{}", style("Mot de passe PostgreSQL").cyan())) .default(default_pw) .interact_text()?; Ok((db_name, db_user, db_password)) } /// Validate an admin username against the RFC 0036 charset rules. /// /// The framework's `Username::parse_allow_reserved` is `pub(crate)` inside /// `rustwarden-core`, so it cannot be reused here; the rule is reimplemented /// locally and kept in sync manually. /// Source of truth: `docs/rfcs/0036-username-charset-validation.md` and /// `crates/rustwarden-core/src/types/username.rs`. /// /// Reserved names (`admin`, …) are deliberately accepted: the bootstrap path /// (`ensure_default_admin`) goes through `parse_allow_reserved`. fn validate_admin_username(raw: &str) -> Result<(), String> { const RULE: &str = "minuscules, chiffres et tirets uniquement, 3 à 39 caractères, \ sans '--', sans tiret en début ou en fin"; let s = raw.trim(); if s.len() < 3 || s.len() > 39 { return Err(format!( "Username admin invalide ({RULE}) : longueur hors 3-39" )); } if s.starts_with('-') || s.ends_with('-') { return Err(format!( "Username admin invalide ({RULE}) : tiret en début ou en fin" )); } if s.contains("--") { return Err(format!("Username admin invalide ({RULE}) : '--' interdit")); } if !s .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(format!( "Username admin invalide ({RULE}) : caractère hors [a-z0-9-] \ (les majuscules sont refusées, pas de '_' ni de '.')" )); } Ok(()) } fn ask_admin_config() -> Result<(String, String, String), Box<dyn std::error::Error>> { let username: String = Input::new() .with_prompt(format!("{}", style("Username admin").cyan())) .default("admin".into()) .validate_with(|input: &String| -> Result<(), String> { validate_admin_username(input) }) .interact_text()?; let username = username.trim().to_string(); let email: String = Input::new() .with_prompt(format!("{}", style("Email admin").cyan())) .default("admin@localhost".into()) .interact_text()?; let default_pw = generate_random_password(12); let password: String = Input::new() .with_prompt(format!("{}", style("Mot de passe admin").cyan())) .default(default_pw) .interact_text()?; Ok((username, email, password)) } fn ask_smtp_config( app_display_name: &str, ) -> Result<Option<SmtpConfig>, Box<dyn std::error::Error>> { let configure = Confirm::new() .with_prompt(format!( "{}", style("Configurer un serveur SMTP pour l'envoi d'emails ?").cyan() )) .default(false) .interact()?; if !configure { println!( " {}", style("SMTP desactive. Les emails ne seront pas envoyes.").dim() ); return Ok(None); } let host: String = Input::new() .with_prompt(format!("{}", style("Hote SMTP").cyan())) .interact_text()?; let port: String = Input::new() .with_prompt(format!("{}", style("Port SMTP").cyan())) .default("587".into()) .validate_with(|input: &String| -> Result<(), String> { input .parse::<u16>() .map(|_| ()) .map_err(|_| "Port invalide".into()) }) .interact_text()?; let user: String = Input::new() .with_prompt(format!("{}", style("Utilisateur SMTP").cyan())) .interact_text()?; let password: String = Input::new() .with_prompt(format!("{}", style("Mot de passe SMTP").cyan())) .interact_text()?; let from: String = Input::new() .with_prompt(format!("{}", style("Adresse expediteur (From)").cyan())) .default(user.clone()) .interact_text()?; let from_name: String = Input::new() .with_prompt(format!("{}", style("Nom expediteur").cyan())) .default(app_display_name.to_string()) .interact_text()?; let use_tls = Confirm::new() .with_prompt(format!("{}", style("Utiliser TLS ?").cyan())) .default(true) .interact()?; let use_starttls = Confirm::new() .with_prompt(format!("{}", style("Utiliser STARTTLS ?").cyan())) .default(true) .interact()?; Ok(Some(SmtpConfig { host, port: port.parse()?, user, password, from, from_name, use_tls, use_starttls, })) } // --------------------------------------------------------------------------- // Generation aleatoire // --------------------------------------------------------------------------- fn generate_random_password(len: usize) -> String { // Strictly safe charset for passwords that flow through three layers: // 1. DATABASE_URL — RFC 3986 reserved chars (@, #, %, &, /, ?, :) // would break the postgres://user:pass@host/db parser. // 2. .env files consumed by Docker Compose — `$` triggers variable // substitution (`$VAR`); `~` may expand to `$HOME` in some shells. // 3. Interactive shells — `!` triggers history expansion in bash, // `*` is a glob, `&`/`|`/`;` are job-control or pipe operators. // We keep alphanumeric + `-_` (RFC 3986 unreserved minus `.~`) which // are inert in all three contexts. const CHARSET: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_"; let mut rng = rand::rng(); (0..len) .map(|_| { let idx = rng.random_range(0..CHARSET.len()); CHARSET[idx] as char }) .collect() } fn ask_oauth_config(port: u16) -> Result<Option<OAuthWizardConfig>, Box<dyn std::error::Error>> { let enable = Confirm::new() .with_prompt(format!( "{}", style("Activer OAuth / SSO (Google, GitHub, Discord, Microsoft) ?").cyan() )) .default(false) .interact()?; if !enable { println!(" {} OAuth desactive.", style("→").dim()); return Ok(None); } let redirect_base_url: String = Input::new() .with_prompt(format!( "{}", style("URL de base pour les callbacks OAuth").cyan() )) .default(format!("http://localhost:{}", port)) .interact_text()?; let provider_names = ["google", "github", "discord", "microsoft"]; let display_names = ["Google", "GitHub", "Discord", "Microsoft"]; let mut providers = Vec::new(); for (i, name) in provider_names.iter().enumerate() { let configure = Confirm::new() .with_prompt(format!( " {} {}", style("Configurer").cyan(), style(display_names[i]).bold() )) .default(false) .interact()?; if !configure { continue; } let client_id: String = Input::new() .with_prompt(format!(" {} Client ID", style(display_names[i]).cyan())) .interact_text()?; let client_secret: String = Input::new() .with_prompt(format!( " {} Client Secret", style(display_names[i]).cyan() )) .interact_text()?; providers.push(OAuthProviderConfig { name: name.to_string(), client_id, client_secret, }); } if providers.is_empty() { println!( " {} OAuth active mais aucun provider configure. Ajoutez-les dans .env plus tard.", style("⚠").yellow() ); } Ok(Some(OAuthWizardConfig { enabled: true, redirect_base_url, providers, })) } fn generate_jwt_secret() -> String { let mut rng = rand::rng(); (0..64) .map(|_| format!("{:02x}", rng.random::<u8>())) .collect::<String>() } // --------------------------------------------------------------------------- // Decouverte du workspace Rustwarden // --------------------------------------------------------------------------- fn find_rustwarden_root() -> Result<PathBuf, Box<dyn std::error::Error>> { let start_dirs = [ std::env::current_exe() .ok() .and_then(|p| p.parent().map(|p| p.to_path_buf())), std::env::current_dir().ok(), ]; for start in start_dirs.into_iter().flatten() { let mut dir = start; loop { let cargo_toml = dir.join("Cargo.toml"); if cargo_toml.exists() { let content = fs::read_to_string(&cargo_toml)?; if content.contains("[workspace]") && dir.join("crates/rustwarden-core").exists() { return Ok(dir); } } if !dir.pop() { break; } } } Err("Impossible de trouver la racine du projet Rustwarden.\n\ Lancez cette commande depuis le repertoire Rustwarden \ ou son sous-dossier." .into()) } // --------------------------------------------------------------------------- // Copie des crates et ressources // --------------------------------------------------------------------------- fn copy_crate( rustwarden_root: &Path, project_dir: &Path, crate_name: &str, ) -> Result<(), Box<dyn std::error::Error>> { let src = rustwarden_root.join("crates").join(crate_name); let dst = project_dir.join("crates").join(crate_name); if !src.exists() { return Err(format!("Crate source introuvable : {}", src.display()).into()); } println!( " {} {}", style("Copie").dim(), style(format!("crates/{crate_name}/")).white() ); copy_dir_recursive(&src, &dst)?; Ok(()) } fn copy_resources( rustwarden_root: &Path, project_dir: &Path, with_ui: bool, ) -> Result<(), Box<dyn std::error::Error>> { let dirs_to_copy: Vec<&str> = if with_ui { vec!["static", "database"] } else { vec!["database"] }; for dir_name in dirs_to_copy { let src = rustwarden_root.join(dir_name); let dst = project_dir.join(dir_name); if src.exists() { println!( " {} {}", style("Copie").dim(), style(format!("{dir_name}/")).white() ); copy_dir_recursive(&src, &dst)?; } } Ok(()) } /// Copy framework documentation relevant to consumer projects. fn copy_docs( rustwarden_root: &Path, project_dir: &Path, fediverse_active: bool, ) -> Result<(), Box<dyn std::error::Error>> { let mut docs_to_copy: Vec<&str> = vec!["docs/SERVICES_REFERENCE.md", "docs/MIGRATION_GUIDE.md"]; if fediverse_active { docs_to_copy.push("docs/fediverse-consumer-guide.md"); } let dst_dir = project_dir.join("docs"); fs::create_dir_all(&dst_dir)?; for doc in &docs_to_copy { let src = rustwarden_root.join(doc); if src.exists() { let filename = Path::new(doc).file_name().unwrap(); let dst = dst_dir.join(filename); println!( " {} {}", style("Copie").dim(), style(format!("docs/{}", filename.to_string_lossy())).white() ); fs::copy(&src, &dst)?; } } Ok(()) } fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Box<dyn std::error::Error>> { fs::create_dir_all(dst)?; for entry in fs::read_dir(src)? { let entry = entry?; let file_type = entry.file_type()?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); let name = entry.file_name().to_string_lossy().to_string(); if name == "target" || name == ".git" || name == "data" { continue; } if file_type.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { fs::copy(&src_path, &dst_path)?; } } Ok(()) } // --------------------------------------------------------------------------- // Rebrand : remplacer "RustWarden" dans les templates copies // --------------------------------------------------------------------------- fn rebrand_templates( project_dir: &Path, app_display_name: &str, ) -> Result<(), Box<dyn std::error::Error>> { let template_dirs = [ project_dir.join("crates/rustwarden-ui/templates"), project_dir.join("crates/rustwarden-core/templates"), ]; let mut count = 0u32; for dir in &template_dirs { if dir.exists() { count += rebrand_dir(dir, app_display_name)?; } } if count > 0 { println!( " {} {} fichier(s) rebrandes avec \"{}\"", style("Rebrand").dim(), count, app_display_name, ); } Ok(()) } fn rebrand_dir(dir: &Path, app_display_name: &str) -> Result<u32, Box<dyn std::error::Error>> { let mut count = 0u32; for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { count += rebrand_dir(&path, app_display_name)?; } else if path.extension().and_then(|e| e.to_str()) == Some("html") { let content = fs::read_to_string(&path)?; if content.contains("RustWarden") { let replaced = content.replace("RustWarden", app_display_name); fs::write(&path, replaced)?; count += 1; } } } Ok(count) } // --------------------------------------------------------------------------- // Generation des fichiers // --------------------------------------------------------------------------- fn generate_files( project_dir: &Path, cfg: &ProjectConfig, ) -> Result<(), Box<dyn std::error::Error>> { let mut files: Vec<(&str, String)> = vec![ ("Cargo.toml", templates::cargo_toml(cfg)), ("src/main.rs", templates::main_rs(cfg)), (".env", templates::dot_env(cfg)), (".env.example", templates::dot_env_example()), ("database/.env", templates::database_dot_env(cfg)), ("README.md", templates::readme(cfg)), ("DEVELOPER.md", templates::developer_md(cfg)), ]; if cfg.with_ui { files.push(( "crates/rustwarden-ui/templates/home.html", templates::custom_home_html(cfg), )); } for (path, content) in files { let full_path = project_dir.join(path); if let Some(parent) = full_path.parent() { fs::create_dir_all(parent)?; } println!(" {} {}", style("Genere").dim(), style(path).white()); fs::write(full_path, content)?; } Ok(()) } // --------------------------------------------------------------------------- // Resume final // --------------------------------------------------------------------------- fn print_summary(cfg: &ProjectConfig, project_dir: &Path) { let divider = style("─".repeat(55)).dim(); println!("\n{divider}"); println!(" {}", style("Projet cree avec succes !").green().bold()); println!("{divider}\n"); println!(" {} {}", style("Nom").bold(), cfg.name); println!(" {} {}", style("App").bold(), cfg.app_display_name); println!(" {} {}", style("Dossier").bold(), project_dir.display()); println!( " {} {}", style("Mode").bold(), if cfg.with_ui { "Interface web" } else { "API seule" }, ); println!(" {} {}", style("Port").bold(), cfg.port); println!(" {} {}", style("DB").bold(), cfg.db_name); println!(" {} {}", style("Admin").bold(), cfg.admin_username); println!( " {} {}", style("SMTP").bold(), if let Some(smtp) = &cfg.smtp { format!("{} (port {})", smtp.host, smtp.port) } else { "desactive".to_string() }, ); println!( " {} {}", style("OAuth").bold(), if let Some(ref oauth) = cfg.oauth { if oauth.providers.is_empty() { "active (aucun provider configure)".to_string() } else { let names: Vec<&str> = oauth.providers.iter().map(|p| p.name.as_str()).collect(); format!("active ({})", names.join(", ")) } } else { "desactive".to_string() }, ); println!("\n{divider}"); println!(" {}", style("Prochaines etapes :").bold()); println!("{divider}\n"); println!( " {} Se deplacer dans le dossier :", style("1.").cyan().bold() ); println!( " {}", style(format!("cd {}", project_dir.display())).green() ); println!( " {} Lancer la base de donnees :", style("2.").cyan().bold() ); println!( " {}", style("cd database && docker compose up -d && cd ..").green() ); println!(" {} Lancer l'application :", style("3.").cyan().bold()); println!(" {}", style("cargo run").green()); println!( " {} Générer la liste des écrans d'UI à coder :", style("4.").cyan().bold() ); println!( " {}", style("cargo run -p rustwarden-core --bin screen-select").green() ); println!( " puis {} pour recocher le réalisé", style("cargo run -p rustwarden-core --bin screen-select -- --check").green() ); println!( " {} Ouvrir dans le navigateur :", style("5.").cyan().bold() ); println!( " {}", style(format!("http://localhost:{}", cfg.port)).underlined() ); println!( "\n {}", style("Les fichiers .env sont deja configures avec vos identifiants.").dim() ); println!( " {}", style("Voir .env.example pour la reference des variables disponibles.").dim() ); println!(); }
GitRust