diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index 9be7e95a..91eab920 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -270,10 +270,11 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( ) { slog!( - "[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?}", + "[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?} adopt_path={:?}", cmd.name, cmd.stack, cmd.git_url, + cmd.adopt_path, ); let response = if let Some(ref store) = ctx.gateway_projects_store { super::super::super::new_project::handle_new_project( @@ -282,6 +283,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( cmd.git_url.as_deref(), cmd.git_token.as_deref(), cmd.host_path.as_deref(), + cmd.adopt_path.as_deref(), store, &ctx.services.project_root, ) diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index 947c6bcc..b85a84e1 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -1,8 +1,8 @@ -//! `new project ` chat command — Phase 5: `--path` override. +//! `new project ` chat command — Phase 6: `--adopt` flow. //! //! Provisions a project container and registers it with the gateway. //! The command is gateway-only: -//! `new project [--stack ] [--git ] [--git-token ] [--path ]` +//! `new project [--stack ] [--git ] [--git-token ] [--path ] [--adopt ]` //! //! Without `--stack`, the orchestrator inspects the (just-cloned or //! just-init'd) source tree for stack markers found in @@ -47,7 +47,7 @@ use tokio::sync::RwLock; use crate::service::gateway::config::ProjectEntry; -/// Parsed result of a `new project [--stack ] [--git ] [--git-token ] [--path ]` command. +/// Parsed result of a `new project [--stack ] [--git ] [--git-token ] [--path ] [--adopt ]` command. pub struct NewProjectCommand { /// Project name (alphanumeric, hyphens, underscores). pub name: String, @@ -68,9 +68,16 @@ pub struct NewProjectCommand { /// When `Some`, the project is created at this path instead of the default. /// The same existence check applies: the path must not already exist. pub host_path: Option, + /// Wrap a container around an existing checkout at this path. + /// + /// When `Some`, the directory must already exist. No `git clone` or + /// `git init` is performed — the container is simply launched with the + /// existing directory bind-mounted at `/workspace`. + /// Mutually exclusive with `--path` and `--git`. + pub adopt_path: Option, } -/// Parse a `new project [--stack ] [--git ] [--git-token ] [--path ]` command. +/// Parse a `new project [--stack ] [--git ] [--git-token ] [--path ] [--adopt ]` command. /// /// Returns `Some(NewProjectCommand)` when the stripped message starts with /// "new project" (case-insensitive). An empty name (bare "new project" with @@ -103,6 +110,7 @@ pub fn extract_new_project_command( let git_url = parse_flag(&remaining, "--git"); let git_token = parse_flag(&remaining, "--git-token"); let host_path = parse_flag(&remaining, "--path"); + let adopt_path = parse_flag(&remaining, "--adopt"); Some(NewProjectCommand { name, @@ -110,6 +118,7 @@ pub fn extract_new_project_command( git_url, git_token, host_path, + adopt_path, }) } @@ -360,6 +369,164 @@ async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result, + host_path: &std::path::Path, + home: &str, + projects_store: &Arc>>, + config_dir: &Path, +) -> String { + // Scaffold .huskies/ into the existing repo (write-if-missing — safe). + if let Err(e) = crate::service::gateway::io::scaffold_project(host_path) { + return format!("Scaffold failed: {e}"); + } + crate::service::gateway::io::init_wizard_state(host_path); + + // ── Detect or validate stack ───────────────────────────────────────────── + let stacks_dir = config_dir.join("docker").join("stacks"); + let (resolved_stack, detect_warnings) = match stack { + Some(s) => (Some(s.to_string()), vec![]), + None => detect_stack(host_path, &stacks_dir), + }; + let image = image_for_stack(resolved_stack.as_deref()); + + // ── Generate SSH keypair ───────────────────────────────────────────────── + let ssh_key_dir = std::path::PathBuf::from(home).join(".huskies").join(name); + if let Err(e) = tokio::fs::create_dir_all(&ssh_key_dir).await { + return format!( + "Failed to create SSH key directory `{}`: {e}", + ssh_key_dir.display() + ); + } + let private_key_path = ssh_key_dir.join("id_ed25519"); + let pubkey = match generate_ssh_keypair(&private_key_path).await { + Ok(k) => k, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; + return format!("SSH keypair generation failed: {e}"); + } + }; + + // ── Resolve git identity ───────────────────────────────────────────────── + let (git_user_name, git_user_email) = resolve_git_identity(config_dir).await; + + // ── Discover host SSH keys for bind-mounting ───────────────────────────── + let host_ssh_dir = std::path::PathBuf::from(home).join(".ssh"); + let mut ssh_key_mounts: Vec = Vec::new(); + for key_name in &["id_ed25519", "id_rsa"] { + let key_path = host_ssh_dir.join(key_name); + if key_path.exists() { + ssh_key_mounts.push(format!( + "{}:/home/huskies/.ssh/{key_name}:ro", + key_path.display() + )); + } + } + + // ── Allocate ports and launch container ────────────────────────────────── + let port = find_free_port(3100); + let ssh_port = find_free_port(2200); + let container_url = format!("http://127.0.0.1:{port}"); + let container_name = format!("huskies-{name}"); + + let mut docker_args: Vec = vec![ + "run".into(), + "-d".into(), + "--name".into(), + container_name.clone(), + "-p".into(), + format!("127.0.0.1:{port}:3001"), + "-p".into(), + format!("127.0.0.1:{ssh_port}:22"), + "-e".into(), + format!("HUSKIES_SSH_PUBKEY={pubkey}"), + "-e".into(), + format!("GIT_USER_NAME={git_user_name}"), + "-e".into(), + format!("GIT_USER_EMAIL={git_user_email}"), + "-v".into(), + format!("{}:/workspace", host_path.display()), + ]; + + for mount in &ssh_key_mounts { + docker_args.push("-v".into()); + docker_args.push(mount.clone()); + } + + docker_args.push("--restart".into()); + docker_args.push("unless-stopped".into()); + docker_args.push(image.clone()); + docker_args.push("huskies".into()); + docker_args.push("/workspace".into()); + + let docker_result = tokio::process::Command::new("docker") + .args(&docker_args) + .output() + .await; + + match docker_result { + Ok(out) if out.status.success() => { + crate::crdt_state::write_gateway_project(name, &container_url); + { + let mut projects = projects_store.write().await; + projects.insert( + name.to_string(), + ProjectEntry { + url: Some(container_url.clone()), + auth_token: None, + ssh_port: Some(ssh_port), + host_path: Some(host_path.to_string_lossy().into_owned()), + }, + ); + crate::service::gateway::io::save_config(&projects, config_dir).await; + } + + crate::slog!( + "[new-project] Adopted project '{name}' at {container_url} \ + ssh=127.0.0.1:{ssh_port} (image={image})" + ); + + let stack_note = match resolved_stack.as_deref() { + Some(s) => format!("- Stack detected: **{s}** (`{image}`)\n"), + None => "- Stack: not detected (pass `--stack ` to set one)\n".to_string(), + }; + let warning_block = if detect_warnings.is_empty() { + String::new() + } else { + format!("\n> {}\n", detect_warnings.join("\n> ")) + }; + + format!( + "{warning_block}Project **{name}** adopted.\n\ + - Host path: `{host}` (existing checkout, bind-mounted)\n\ + - Container: `{container_name}` → `{container_url}`\n\ + {stack_note}\ + - SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \ + -i ~/.huskies/{name}/id_ed25519`\n\ + \n\ + Use `switch {name}` then `status` to view the pipeline.", + host = host_path.display() + ) + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; + format!("Docker container launch failed: {}", stderr.trim()) + } + Err(e) => { + let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; + format!("Docker container launch failed: {e}") + } + } +} + /// Bootstrap a new project from the `new project` chat command. /// /// Creates the project directory (default `~/huskies//`, or `host_path` @@ -368,16 +535,23 @@ async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result`". /// /// `git_token` is never echoed in any returned string or log line. +#[allow(clippy::too_many_arguments)] pub async fn handle_new_project( name: &str, stack: Option<&str>, git_url: Option<&str>, git_token: Option<&str>, host_path_override: Option<&str>, + adopt_path_override: Option<&str>, projects_store: &Arc>>, config_dir: &Path, ) -> String { @@ -396,6 +570,14 @@ pub async fn handle_new_project( ); } + // --adopt is mutually exclusive with --path and --git. + if adopt_path_override.is_some() && (host_path_override.is_some() || git_url.is_some()) { + return "`--adopt` is mutually exclusive with `--path` and `--git`. \ + Use `--adopt ` alone to wrap an existing checkout, \ + or use `--path`/`--git` to create a new project." + .to_string(); + } + // Name conflict — check both the in-memory store and the CRDT. { let projects = projects_store.read().await; @@ -408,6 +590,23 @@ pub async fn handle_new_project( } let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); + + // ── Adopt path: wrap container around an existing checkout ─────────────── + if let Some(adopt) = adopt_path_override { + let host_path = std::path::PathBuf::from(adopt); + if !host_path.exists() { + return format!( + "Adopt path `{}` does not exist — specify the path to an existing checkout.", + host_path.display() + ); + } + if !host_path.is_dir() { + return format!("Adopt path `{}` is not a directory.", host_path.display()); + } + return handle_adopt_project(name, stack, &host_path, &home, projects_store, config_dir) + .await; + } + // `--path` overrides the default `~/huskies//`. let host_path = match host_path_override { Some(p) => std::path::PathBuf::from(p), @@ -1290,4 +1489,131 @@ mod tests { assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("Multiple stacks")); } + + // ── Phase 6: --adopt flag parsing and validation ───────────────────────── + + #[test] + fn extract_parses_adopt_flag() { + let cmd = extract_new_project_command( + "@timmy new project myapp --adopt /projects/myapp", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.name, "myapp"); + assert_eq!(cmd.adopt_path, Some("/projects/myapp".to_string())); + assert_eq!(cmd.host_path, None); + assert_eq!(cmd.git_url, None); + } + + #[test] + fn extract_adopt_with_stack() { + let cmd = extract_new_project_command( + "@timmy new project myapp --adopt /srv/myapp --stack rust", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.adopt_path, Some("/srv/myapp".to_string())); + assert_eq!(cmd.stack, Some("rust".to_string())); + } + + #[test] + fn extract_no_adopt_flag_returns_none_field() { + let cmd = extract_new_project_command( + "@timmy new project myapp --stack node", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.adopt_path, None); + } + + #[tokio::test] + async fn handle_new_project_adopt_and_path_are_mutually_exclusive() { + let store = Arc::new(RwLock::new(BTreeMap::new())); + let config_dir = tempfile::tempdir().unwrap(); + let result = handle_new_project( + "myapp", + None, + None, + None, + Some("/tmp/something"), + Some("/existing/checkout"), + &store, + config_dir.path(), + ) + .await; + assert!( + result.contains("mutually exclusive"), + "expected mutual-exclusion error, got: {result}" + ); + } + + #[tokio::test] + async fn handle_new_project_adopt_and_git_are_mutually_exclusive() { + let store = Arc::new(RwLock::new(BTreeMap::new())); + let config_dir = tempfile::tempdir().unwrap(); + let result = handle_new_project( + "myapp", + None, + Some("https://github.com/user/repo"), + None, + None, + Some("/existing/checkout"), + &store, + config_dir.path(), + ) + .await; + assert!( + result.contains("mutually exclusive"), + "expected mutual-exclusion error, got: {result}" + ); + } + + #[tokio::test] + async fn handle_new_project_adopt_missing_path_returns_error() { + let store = Arc::new(RwLock::new(BTreeMap::new())); + let config_dir = tempfile::tempdir().unwrap(); + let result = handle_new_project( + "myapp", + None, + None, + None, + None, + Some("/nonexistent/path/that/does/not/exist"), + &store, + config_dir.path(), + ) + .await; + assert!( + result.contains("does not exist"), + "expected missing-path error, got: {result}" + ); + } + + #[tokio::test] + async fn handle_new_project_adopt_file_not_dir_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("a_file.txt"); + std::fs::write(&file_path, "not a directory").unwrap(); + + let store = Arc::new(RwLock::new(BTreeMap::new())); + let config_dir = tempfile::tempdir().unwrap(); + let result = handle_new_project( + "myapp", + None, + None, + None, + None, + Some(file_path.to_str().unwrap()), + &store, + config_dir.path(), + ) + .await; + assert!( + result.contains("not a directory"), + "expected not-a-directory error, got: {result}" + ); + } }