//! `new project ` chat command — Phase 1 bootstrap. //! //! Provisions a bare project container and registers it with the gateway. //! The command is gateway-only: `new project [--stack ]`. //! Phase 1 ignores `--stack`; stack-aware images arrive in Phase 2. use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; use tokio::sync::RwLock; use crate::service::gateway::config::ProjectEntry; /// Parse a `new project ` command from a chat message. /// /// Returns `Some(name)` when the stripped message starts with "new project" /// (case-insensitive). An empty name (bare "new project" with no arg) is /// returned as `Some("")` so the handler can emit a usage error. Returns `None` /// for any other message. pub fn extract_new_project_command( message: &str, bot_name: &str, bot_user_id: &str, ) -> Option { let mention_stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id); // Strip leading punctuation (e.g. colon after "@timmy: new project …") let trimmed = mention_stripped .trim() .trim_start_matches(|c: char| !c.is_alphanumeric()); let mut words = trimmed.split_whitespace(); let first = words.next()?; if !first.eq_ignore_ascii_case("new") { return None; } let second = words.next()?; if !second.eq_ignore_ascii_case("project") { return None; } // Third word is the project name; later words (flags) are ignored in Phase 1. let name = words.next().unwrap_or("").to_string(); Some(name) } /// Bootstrap a new project from the `new project ` command. /// /// Creates `~/huskies//`, scaffolds `.huskies/`, runs `git init`, /// launches a Docker container (`huskies-project-base`), and registers the /// project in both the gateway's in-memory store and the CRDT. /// /// On any failure after the host directory is created, the directory is removed /// and the error message includes "Partial state removed at ``". pub async fn handle_new_project( name: &str, projects_store: &Arc>>, config_dir: &Path, ) -> String { let name = name.trim(); if name.is_empty() { return "Usage: `new project ` — e.g. `new project myapp`".to_string(); } if !name .chars() .all(|c| c.is_alphanumeric() || c == '-' || c == '_') { return format!( "Invalid project name `{name}`. \ Use letters, digits, hyphens, or underscores only." ); } // Name conflict — check both the in-memory store and the CRDT. { let projects = projects_store.read().await; if projects.contains_key(name) { return format!( "Project `{name}` is already registered. \ Use `switch {name}` to activate it." ); } } // Default host path: ~/huskies// let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); let host_path = std::path::PathBuf::from(home).join("huskies").join(name); if host_path.exists() { return format!( "Path `{}` already exists. \ Choose a different project name or remove the directory first.", host_path.display() ); } // ── Create host directory ──────────────────────────────────────────────── if let Err(e) = crate::service::gateway::io::ensure_directory(&host_path) { return format!("Failed to create `{}`: {e}", host_path.display()); } // ── Scaffold .huskies/ ─────────────────────────────────────────────────── if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) { let _ = tokio::fs::remove_dir_all(&host_path).await; return format!( "Scaffold failed: {e}\n\nPartial state removed at `{}`.", host_path.display() ); } crate::service::gateway::io::init_wizard_state(&host_path); // ── git init ───────────────────────────────────────────────────────────── match tokio::process::Command::new("git") .arg("init") .arg(&host_path) .output() .await { Err(e) => { let _ = tokio::fs::remove_dir_all(&host_path).await; return format!( "git init failed: {e}\n\nPartial state removed at `{}`.", host_path.display() ); } Ok(out) if !out.status.success() => { let stderr = String::from_utf8_lossy(&out.stderr); let _ = tokio::fs::remove_dir_all(&host_path).await; return format!( "git init failed: {}\n\nPartial state removed at `{}`.", stderr.trim(), host_path.display() ); } Ok(_) => {} } // ── Allocate port and launch container ─────────────────────────────────── let port = find_free_port(3100); let container_url = format!("http://127.0.0.1:{port}"); let container_name = format!("huskies-{name}"); let docker_result = tokio::process::Command::new("docker") .args([ "run", "-d", "--name", &container_name, "-p", &format!("127.0.0.1:{port}:3001"), "-v", &format!("{}:/workspace", host_path.display()), "--restart", "unless-stopped", "huskies-project-base", "huskies", "/workspace", ]) .output() .await; match docker_result { Ok(out) if out.status.success() => { // Register in the CRDT (survives restarts). crate::crdt_state::write_gateway_project(name, &container_url); // Update the in-memory projects store and persist to projects.toml. { let mut projects = projects_store.write().await; projects.insert(name.to_string(), ProjectEntry::with_url(&container_url)); crate::service::gateway::io::save_config(&projects, config_dir).await; } crate::slog!("[new-project] Created project '{name}' at {container_url}"); format!( "Project **{name}** is ready.\n\ - Host path: `{host}`\n\ - Container: `{container_name}` → `{container_url}`\n\ \n\ Use `switch {name}` then `status` to view the pipeline.\n\ *Stack tooling (Rust, Node, etc.) comes in Phase 2 — \ pass `--stack ` now for forward-compat.*", host = host_path.display() ) } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); let _ = tokio::fs::remove_dir_all(&host_path).await; format!( "Docker container launch failed: {}\n\nPartial state removed at `{}`.", stderr.trim(), host_path.display() ) } Err(e) => { let _ = tokio::fs::remove_dir_all(&host_path).await; format!( "Docker container launch failed: {e}\n\nPartial state removed at `{}`.", host_path.display() ) } } } /// Find a free TCP port by attempting to bind starting from `start`. /// /// Scans up to 100 ports above `start` and returns the first available one. /// Falls back to `start` if none are found (unlikely in practice). fn find_free_port(start: u16) -> u16 { for port in start..start + 100 { if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { return port; } } start } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn extract_parses_name() { assert_eq!( extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local"), Some("myapp".to_string()) ); } #[test] fn extract_case_insensitive() { assert_eq!( extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local"), Some("myapp".to_string()) ); } #[test] fn extract_bare_project_keyword_returns_empty_name() { assert_eq!( extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local"), Some("".to_string()) ); } #[test] fn extract_ignores_flags_after_name() { assert_eq!( extract_new_project_command( "@timmy new project myapp --stack rust", "Timmy", "@timmy:srv.local" ), Some("myapp".to_string()) ); } #[test] fn extract_no_match_for_other_commands() { assert_eq!( extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local"), None ); } #[test] fn extract_no_match_when_second_word_is_not_project() { assert_eq!( extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local"), None ); } #[test] fn extract_handles_extra_whitespace() { assert_eq!( extract_new_project_command( "@timmy new project myapp", "Timmy", "@timmy:srv.local" ), Some("myapp".to_string()) ); } }