294 lines
9.8 KiB
Rust
294 lines
9.8 KiB
Rust
//! `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())
|
|
);
|
|
}
|
|
}
|