Files
huskies/server/src/chat/transport/matrix/new_project.rs
T

294 lines
9.8 KiB
Rust
Raw Normal View History

//! `new project <name>` chat command — Phase 1 bootstrap.
//!
//! Provisions a bare project container and registers it with the gateway.
//! The command is gateway-only: `new project <name> [--stack <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 <name>` 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<String> {
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 <name>` command.
///
/// Creates `~/huskies/<name>/`, 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 `<path>`".
pub async fn handle_new_project(
name: &str,
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
config_dir: &Path,
) -> String {
let name = name.trim();
if name.is_empty() {
return "Usage: `new project <name>` — 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/<name>/
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 <name>` 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())
);
}
}