//! `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 ] [--adopt ]` //! //! Without `--stack`, the orchestrator inspects the (just-cloned or //! just-init'd) source tree for stack markers found in //! `docker/stacks//markers` files and auto-selects one, warning in //! chat if multiple stacks matched. With `--stack`, the named stack is used //! unconditionally. //! //! Stack images follow the naming convention `huskies-project-`. //! The base image (no language tooling) is `huskies-project-base`. //! //! Phase 3 (story 1108): an ed25519 SSH keypair is generated per project. //! The private key is stored at `~/.huskies//id_ed25519` on the host. //! The public key is passed to the container as `HUSKIES_SSH_PUBKEY` and //! installed in `~/.ssh/authorized_keys` by the entrypoint. The SSH server //! is bound to a host-local port in the 2200–2300 range and recorded in //! `projects.toml` as `ssh_port`. //! //! Phase 4 (story 1109): //! - `--git ` clones the URL into the host project directory instead of //! running `git init`. Clone failure aborts the bootstrap with no partial //! state left on disk. //! - The container receives `GIT_USER_NAME` and `GIT_USER_EMAIL` env vars drawn //! from `git_user_name`/`git_user_email` in the gateway's `bot.toml`, with //! fallback to the host's `git config user.name`/`user.email`. //! - The host user's SSH keys (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`) are //! bind-mounted read-only into the container so `git push` over SSH works. //! - `--git-token ` stores the HTTPS push token in the container's //! git credential store via the entrypoint; the token is never echoed in //! chat or logs. //! - After the container starts, `git ls-remote` verifies push credentials and //! surfaces success or an actionable failure message in the chat reply. //! //! Adding a new stack requires only: //! 1. `docker/stacks//Dockerfile.fragment` — overlay instructions //! 2. `docker/stacks//markers` — detection marker filenames //! No changes to this orchestration module are needed. use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; use tokio::sync::RwLock; use crate::service::gateway::config::ProjectEntry; /// Parsed result of a `new project [--stack ] [--git ] [--git-token ] [--path ] [--adopt ] [--skip-config]` command. pub struct NewProjectCommand { /// Project name (alphanumeric, hyphens, underscores). pub name: String, /// Explicitly requested stack, or `None` for auto-detection. pub stack: Option, /// Git repository URL to clone into the project directory instead of running `git init`. /// /// When `Some`, the bootstrap runs `git clone ` instead of `git init`. /// Failure aborts the whole bootstrap with no partial state left on disk. pub git_url: Option, /// HTTPS push token for the repository. /// /// Stored in the container's git credential helper by the entrypoint. /// **Never echoed in any chat reply or log line.** pub git_token: Option, /// Override the default host directory (`~/huskies//`). /// /// 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, /// Suppress the first-run configuration summary in the bootstrap reply. /// /// When `true`, the success reply omits the "Default configuration" block /// that lists agents, models, and override commands. pub skip_config: bool, } /// 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 /// no arg) is returned as `Some(name="")` 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; } let name = words.next().unwrap_or("").to_string(); let remaining: Vec<&str> = words.collect(); let stack = parse_flag(&remaining, "--stack"); 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"); let skip_config = remaining.contains(&"--skip-config"); Some(NewProjectCommand { name, stack, git_url, git_token, host_path, adopt_path, skip_config, }) } /// Extract the value of `-- ` from a token slice. fn parse_flag(tokens: &[&str], flag: &str) -> Option { let mut iter = tokens.iter().peekable(); while let Some(tok) = iter.next() { if *tok == flag && let Some(val) = iter.next() { return Some(val.to_string()); } } None } /// Build the first-run configuration summary appended to the bootstrap success reply. /// /// Reads `.huskies/agents.toml` from the project root and formats a Markdown /// block listing each agent's name, stage, model, budget, and turn limit. /// Returns an empty string when the file is missing or unreadable so callers /// can always concatenate it unconditionally. fn format_config_summary(host_path: &Path, name: &str) -> String { let agents_path = host_path.join(".huskies").join("agents.toml"); let Ok(content) = std::fs::read_to_string(&agents_path) else { return String::new(); }; let Ok(val) = toml::from_str::(&content) else { return String::new(); }; let Some(agents) = val.get("agent").and_then(|v| v.as_array()) else { return String::new(); }; let mut lines = vec![format!( "\n**Default configuration** ({} agent{}):", agents.len(), if agents.len() == 1 { "" } else { "s" } )]; for agent in agents { let agent_name = agent.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let stage = agent.get("stage").and_then(|v| v.as_str()).unwrap_or("?"); let model = agent .get("model") .and_then(|v| v.as_str()) .unwrap_or("sonnet"); let budget = agent .get("max_budget_usd") .and_then(|v| v.as_float()) .unwrap_or(5.0); let turns = agent .get("max_turns") .and_then(|v| v.as_integer()) .unwrap_or(50); lines.push(format!( "- **{agent_name}** ({stage}): model=`{model}`, budget=${budget:.2}, max_turns={turns}" )); } lines.push(String::new()); lines.push(format!( "Override via chat: `huskies config {name} coder.model=opus`" )); lines.push(format!( "Project settings: `huskies config {name} default_qa=human`" )); lines.push( "Accept all defaults silently: add `--skip-config` to the bootstrap command.".to_string(), ); lines.join("\n") } /// Apply a single configuration override to a project's `.huskies/` files. /// /// `key` is either `.` (writes to `agents.toml`) or a /// bare project-level key (writes to `project.toml`). /// /// Supported agent fields: `model`, `max_turns`, `max_budget` / `max_budget_usd`. /// Supported project keys: `default_qa`, `max_retries`, `max_coders`, /// `base_branch`, `timezone`, `default_coder_model`. pub fn apply_project_config(host_path: &Path, key: &str, value: &str) -> Result { if let Some((specifier, field)) = key.split_once('.') { // Agent-scoped key: write to agents.toml let path = host_path.join(".huskies").join("agents.toml"); apply_agent_config_file(&path, specifier, field, value) } else { // Project-level key: write to project.toml const SUPPORTED: &[&str] = &[ "default_qa", "max_retries", "max_coders", "base_branch", "timezone", "default_coder_model", ]; if !SUPPORTED.contains(&key) { return Err(format!( "Unknown project key `{key}`. Supported: {}", SUPPORTED.join(", ") )); } let path = host_path.join(".huskies").join("project.toml"); apply_project_toml_key(&path, key, value) } } /// Modify a single `[[agent]]` entry in an `agents.toml` file. fn apply_agent_config_file( path: &std::path::Path, specifier: &str, field: &str, value: &str, ) -> Result { let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read agents.toml: {e}"))?; let new_content = apply_agent_field_in_text(&content, specifier, field, value)?; std::fs::write(path, &new_content).map_err(|e| format!("Cannot write agents.toml: {e}"))?; let canonical_field = if field == "max_budget" { "max_budget_usd" } else { field }; Ok(format!( "Set `{specifier}.{canonical_field} = {value}` in `agents.toml`." )) } /// Rewrite the agent field in raw TOML text without re-serializing the whole file. /// /// Splits the file into `[[agent]]` blocks, finds the one matching `specifier` /// by name or stage, replaces the target field line, and rejoins. fn apply_agent_field_in_text( content: &str, specifier: &str, field: &str, value: &str, ) -> Result { const SUPPORTED_FIELDS: &[&str] = &["model", "max_turns", "max_budget", "max_budget_usd"]; if !SUPPORTED_FIELDS.contains(&field) { return Err(format!( "Unknown agent field `{field}`. Supported: model, max_turns, max_budget" )); } let canonical_field = if field == "max_budget" { "max_budget_usd" } else { field }; // Format the replacement TOML value. let toml_value = match canonical_field { "max_turns" => { let _n: i64 = value .parse() .map_err(|_| format!("`max_turns` must be an integer, got `{value}`"))?; value.to_string() } "max_budget_usd" => { let _f: f64 = value .parse() .map_err(|_| format!("`max_budget` must be a number, got `{value}`"))?; value.to_string() } _ => format!("\"{value}\""), }; // Split on [[agent]] boundaries (keeping the marker with each block). let raw_blocks: Vec<&str> = content.split("\n[[agent]]").collect(); let mut out_blocks: Vec = Vec::with_capacity(raw_blocks.len()); let mut matched = false; for (i, block) in raw_blocks.iter().enumerate() { // Re-attach the [[agent]] prefix that split() removed. let prefixed: std::borrow::Cow = if i == 0 { std::borrow::Cow::Borrowed(block) } else { std::borrow::Cow::Owned(format!("\n[[agent]]{block}")) }; // Check if this block contains a matching name or stage line. let matches_name = prefixed .lines() .any(|l| l.trim() == format!("name = \"{specifier}\"")); let matches_stage = prefixed .lines() .any(|l| l.trim() == format!("stage = \"{specifier}\"")); if (matches_name || matches_stage) && !matched { matched = true; // Replace the field line in this block. let mut new_block = String::new(); let mut field_written = false; for line in prefixed.lines() { let trimmed = line.trim(); if trimmed.starts_with(&format!("{canonical_field} =")) { new_block.push_str(&format!("{canonical_field} = {toml_value}")); new_block.push('\n'); field_written = true; } else { new_block.push_str(line); new_block.push('\n'); } } // Remove the trailing newline we added (the original may not have one at end). if new_block.ends_with('\n') && !prefixed.ends_with('\n') { new_block.pop(); } if !field_written { return Err(format!( "Field `{canonical_field}` not found in the `{specifier}` agent block" )); } out_blocks.push(new_block); } else { out_blocks.push(prefixed.into_owned()); } } if !matched { return Err(format!( "No agent with name or stage `{specifier}` found in agents.toml" )); } Ok(out_blocks.join("")) } /// Set or uncomment a key in a `project.toml` file. /// /// Tries the following in order: /// 1. Replace an existing uncommented `key = ...` line. /// 2. Uncomment and replace a `# key = ...` line. /// 3. Append the key at the end of the file. fn apply_project_toml_key( path: &std::path::Path, key: &str, value: &str, ) -> Result { let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read project.toml: {e}"))?; // Format as TOML: integer if parseable, else quoted string. let toml_value = if value.parse::().is_ok() { value.to_string() } else { format!("\"{value}\"") }; let replacement_line = format!("{key} = {toml_value}"); let mut found = false; let new_content: String = content .lines() .map(|line| { if found { return format!("{line}\n"); } let trimmed = line.trim(); // Match uncommented `key = ...` if trimmed.starts_with(&format!("{key} =")) { found = true; return format!("{replacement_line}\n"); } // Match commented `# key = ...` variants let without_hash = trimmed.strip_prefix('#').map(|s| s.trim()).unwrap_or(""); if without_hash.starts_with(&format!("{key} =")) { found = true; return format!("{replacement_line}\n"); } format!("{line}\n") }) .collect(); let new_content = if found { // Trim the extra newline added by our map if the original didn't end with one. if content.ends_with('\n') { new_content } else { new_content.trim_end_matches('\n').to_string() } } else { // Append. format!("{new_content}{replacement_line}\n") }; std::fs::write(path, &new_content).map_err(|e| format!("Cannot write project.toml: {e}"))?; Ok(format!("Set `{key} = {toml_value}` in `project.toml`.")) } /// Return the path to `docker/stacks/` in the huskies source tree. /// /// Uses `CARGO_MANIFEST_DIR` (baked in at compile time) to find the workspace /// root, which is correct regardless of which project directory the gateway is /// running from. pub fn stacks_dir() -> std::path::PathBuf { let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); manifest_dir .parent() .expect("CARGO_MANIFEST_DIR has no parent") .join("docker") .join("stacks") } /// Scan `stacks_dir` for per-stack `markers` files and detect which stacks /// match the given project directory. /// /// Returns `(selected_stack, warnings)` where `selected_stack` is the /// auto-detected stack name (or `None` if no markers matched) and `warnings` /// carries a human-readable message when multiple stacks matched. /// /// Each `stacks_dir//markers` file lists one filename per line /// (relative to the project root). Lines starting with `#` and blank lines /// are ignored. If any listed file exists in `project_path`, that stack is /// considered a match. pub fn detect_stack(project_path: &Path, stacks_dir: &Path) -> (Option, Vec) { let entries = match std::fs::read_dir(stacks_dir) { Ok(e) => e, Err(_) => return (None, vec![]), }; // (stack_name, number_of_matched_marker_files) let mut matched: Vec<(String, usize)> = Vec::new(); let mut stack_dirs: Vec<_> = entries .filter_map(|e| e.ok()) .filter(|e| e.path().is_dir()) .collect(); // Deterministic order so multi-match selection is stable. stack_dirs.sort_by_key(|e| e.file_name()); for entry in stack_dirs { let stack_name = entry.file_name().to_string_lossy().into_owned(); let markers_path = entry.path().join("markers"); let Ok(content) = std::fs::read_to_string(&markers_path) else { continue; }; let count = content .lines() .filter(|line| { let trimmed = line.trim(); !trimmed.is_empty() && !trimmed.starts_with('#') && project_path.join(trimmed).exists() }) .count(); if count > 0 { matched.push((stack_name, count)); } } match matched.len() { 0 => (None, vec![]), 1 => (Some(matched.remove(0).0), vec![]), _ => { // Dominant stack: most marker files matched; alphabetical tiebreak for stability. matched.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); let names: Vec = matched.iter().map(|(n, _)| n.clone()).collect(); let names_str = names.join(", "); let chosen = matched.swap_remove(0).0; let warning = format!( "Multiple stacks detected ({names_str}); using **{chosen}** \ (most marker files matched). \ Pass `--stack ` to override." ); (Some(chosen), vec![warning]) } } } /// Return the Docker image name for the given stack. /// /// Stack images follow the convention `huskies-project-`. /// When no stack is specified, the base image `huskies-project-base` is used. pub fn image_for_stack(stack: Option<&str>) -> String { match stack { Some(s) => format!("huskies-project-{s}"), None => "huskies-project-base".to_string(), } } /// Read `git_user_name` and `git_user_email` from the gateway's `bot.toml`. /// /// Deserialises directly into `BotConfig` without the enabled/transport /// validation that `BotConfig::load` enforces, so the identity fields are /// always available even when the bot transport is not yet configured. async fn read_git_identity_from_bot_toml( config_dir: &std::path::Path, ) -> (Option, Option) { use crate::chat::transport::matrix::BotConfig; let path = config_dir.join(".huskies").join("bot.toml"); let Ok(content) = tokio::fs::read_to_string(&path).await else { return (None, None); }; let Ok(cfg) = toml::from_str::(&content) else { return (None, None); }; let name = cfg.git_user_name.filter(|s: &String| !s.is_empty()); let email = cfg.git_user_email.filter(|s: &String| !s.is_empty()); (name, email) } /// Read a single key from the host's global git config. async fn read_host_git_config(key: &str) -> Option { let out = tokio::process::Command::new("git") .args(["config", "--global", key]) .output() .await .ok()?; if out.status.success() { let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); if val.is_empty() { None } else { Some(val) } } else { None } } /// Resolve the git identity to use for new project containers. /// /// Priority: `bot.toml` fields → host `git config` → hardcoded fallback. pub(crate) async fn resolve_git_identity(config_dir: &std::path::Path) -> (String, String) { let (bot_name, bot_email) = read_git_identity_from_bot_toml(config_dir).await; let name = if let Some(n) = bot_name { n } else if let Some(n) = read_host_git_config("user.name").await { n } else { "Huskies Agent".to_string() }; let email = if let Some(e) = bot_email { e } else if let Some(e) = read_host_git_config("user.email").await { e } else { "agent@huskies.local".to_string() }; (name, email) } /// Inject a token into an HTTPS git URL for credential-passing. /// /// `https://github.com/user/repo` → `https://x-access-token:@github.com/user/repo` /// The returned URL must NEVER be included in user-visible replies or logs. fn inject_token_into_url(url: &str, token: &str) -> String { if let Some(rest) = url.strip_prefix("https://") { format!("https://x-access-token:{token}@{rest}") } else if let Some(rest) = url.strip_prefix("http://") { format!("http://x-access-token:{token}@{rest}") } else { url.to_string() } } /// Verify push credentials by running `git ls-remote` against the repository. /// /// Returns `Ok(message)` on success or `Err(actionable_message)` on failure. /// The token (if any) is never included in the returned strings. async fn verify_push_credentials(git_url: &str, git_token: Option<&str>) -> Result { let url_for_cmd = match git_token { Some(token) => inject_token_into_url(git_url, token), None => git_url.to_string(), }; let mut cmd = tokio::process::Command::new("git"); cmd.arg("ls-remote").arg(&url_for_cmd); cmd.env("GIT_TERMINAL_PROMPT", "0"); if git_token.is_none() { // SSH: accept new host keys non-interactively; fail fast if no agent. cmd.env( "GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", ); } let output = cmd .output() .await .map_err(|e| format!("git ls-remote unavailable: {e}"))?; if output.status.success() { return Ok("Push credentials verified.".to_string()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); // Map common failure patterns to actionable messages (no token in output). if stderr.contains("Permission denied") || stderr.contains("publickey") { Err( "SSH key not authorised by remote — check that your public key is \ added to the repository's deploy keys or your account." .to_string(), ) } else if stderr.contains("401") || stderr.contains("403") || stderr.contains("Authentication failed") || stderr.contains("could not read Username") { Err( "Token rejected — verify that the token has read/write access \ to the repository." .to_string(), ) } else { Err(format!( "Push verification failed for `{git_url}`: {stderr}" )) } } /// Generate an ed25519 SSH keypair at `key_path` (private) and `key_path.pub` (public). /// /// Calls `ssh-keygen -t ed25519 -N "" -f ` with no passphrase. /// Returns the public key string (trimmed) on success. async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result { if let Some(parent) = key_path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("Cannot create key directory {}: {e}", parent.display()))?; } let out = tokio::process::Command::new("ssh-keygen") .args(["-t", "ed25519", "-N", ""]) .arg("-f") .arg(key_path) .output() .await .map_err(|e| format!("ssh-keygen not available: {e}"))?; if !out.status.success() { return Err(format!( "ssh-keygen failed: {}", String::from_utf8_lossy(&out.stderr).trim() )); } let pub_path = key_path.with_extension("pub"); tokio::fs::read_to_string(&pub_path) .await .map(|s| s.trim().to_string()) .map_err(|e| format!("Cannot read public key {}: {e}", pub_path.display())) } /// Bootstrap a project container around an existing host checkout (`--adopt`). /// /// The directory at `host_path` is bind-mounted at `/workspace`. No `git /// clone` or `git init` is performed. `.huskies/` is scaffolded (write-if- /// missing) so the pipeline files are present. Stack auto-detection runs /// against the existing directory contents. async fn handle_adopt_project( name: &str, stack: Option<&str>, host_path: &std::path::Path, home: &str, projects_store: &Arc>>, config_dir: &Path, skip_config: bool, ) -> String { // ── Credentials pre-flight ─────────────────────────────────────────────── // Agents inside the container need Claude credentials to spawn. Fail fast // with an actionable message rather than launching a sled that immediately // errors with "Not logged in" when `start_agent` is called. let credentials_file = std::path::PathBuf::from(home) .join(".claude") .join(".credentials.json"); if !credentials_file.exists() { return format!( "No Claude credentials found at `{}/.claude/.credentials.json`. \ Run `claude login` on the host first, then retry.", home ); } // 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 (resolved_stack, detect_warnings) = match stack { Some(s) => (Some(s.to_string()), vec![]), None => detect_stack(host_path, &stacks_dir()), }; let stack_image = image_for_stack(resolved_stack.as_deref()); let image = match build_project_image(host_path, &stack_image, name).await { Ok(img) => img, Err(e) => return format!("Failed to build project-specific image: {e}"), }; // ── 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 Some(port) = find_free_port(3100) else { return "No free port in range 3100–3200. Stop unused containers and retry.".to_string(); }; let Some(ssh_port) = find_free_port(2200) else { return "No free SSH port in range 2200–2300. Stop unused containers and retry." .to_string(); }; let container_url = format!("http://127.0.0.1:{port}"); let container_name = format!("huskies-{name}"); let mut docker_args = project_docker_run_args( &container_name, port, ssh_port, &pubkey, &git_user_name, &git_user_email, Some(&credentials_file), &resolve_gateway_url(), ); docker_args.push("-v".into()); docker_args.push(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> ")) }; let config_block = if skip_config { String::new() } else { format_config_summary(host_path, name) }; 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.\ {config_block}", host = host_path.display() ) } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; interpret_docker_run_error(&stderr, &image) } 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` /// when `--path` is supplied), scaffolds `.huskies/`, runs `git clone` (when /// `git_url` is provided) or `git init`, auto-detects or honours the requested /// stack, generates an SSH keypair, launches the appropriate Docker container, /// and registers the project in the gateway's in-memory store and the CRDT. /// /// When `adopt_path` is provided the directory must already exist; no clone or /// init is performed and the container is launched with the existing checkout /// bind-mounted at `/workspace`. `adopt_path` is mutually exclusive with /// `host_path_override` and `git_url`. /// /// On any failure after a directory is created, the directory is removed and /// the error message includes "Partial state removed at ``". /// /// `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>, skip_config: bool, 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." ); } // --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; if projects.contains_key(name) { return format!( "Project `{name}` is already registered. \ Use `switch {name}` to activate it." ); } } 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, skip_config, ) .await; } // `--path` overrides the default `~/huskies//`. let host_path = match host_path_override { Some(p) => std::path::PathBuf::from(p), None => 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() ); } // ── git clone or init + scaffold ───────────────────────────────────────── // // For `--git `: git clone creates host_path itself, so we must NOT call // ensure_directory on it beforehand. Scaffold runs after a successful clone. // // For no `--git`: create host_path first, scaffold, then git init (unchanged // from Phase 3 behaviour). if let Some(url) = git_url { // Ensure the parent directory (~/.huskies/) exists but not host_path itself. if let Some(parent) = host_path.parent() && let Err(e) = crate::service::gateway::io::ensure_directory(parent) { return format!( "Failed to create parent directory `{}`: {e}", parent.display() ); } let clone_out = tokio::process::Command::new("git") .arg("clone") .arg(url) .arg(&host_path) .env("GIT_TERMINAL_PROMPT", "0") .output() .await; match clone_out { Err(e) => { return format!("git clone failed: {e}"); } 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 clone failed: {}\n\nPartial state removed at `{}`.", stderr.trim(), host_path.display() ); } Ok(_) => {} } // Scaffold .huskies/ into the cloned repo (write_file_if_missing — safe on existing repos). 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); } else { // No --git: create directory, scaffold, then git init. if let Err(e) = crate::service::gateway::io::ensure_directory(&host_path) { return format!("Failed to create `{}`: {e}", host_path.display()); } 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); 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(_) => {} } } // ── Detect or validate stack ───────────────────────────────────────────── let (resolved_stack, detect_warnings) = match stack { Some(s) => (Some(s.to_string()), vec![]), None => detect_stack(&host_path, &stacks_dir()), }; let stack_image = image_for_stack(resolved_stack.as_deref()); let image = match build_project_image(&host_path, &stack_image, name).await { Ok(img) => img, Err(e) => { let _ = tokio::fs::remove_dir_all(&host_path).await; return format!( "Failed to build project-specific image: {e}\n\nPartial state removed at `{}`.", host_path.display() ); } }; // ── Generate SSH keypair ───────────────────────────────────────────────── // Private key: ~/.huskies//id_ed25519 (host-side, mode 600 by ssh-keygen) // Public key: installed in the container via HUSKIES_SSH_PUBKEY env var 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 { let _ = tokio::fs::remove_dir_all(&host_path).await; return format!( "Failed to create SSH key directory `{}`: {e}\n\nPartial state removed at `{}`.", ssh_key_dir.display(), host_path.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(&host_path).await; let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; return format!( "SSH keypair generation failed: {e}\n\nPartial state removed at `{}`.", host_path.display() ); } }; // ── Credentials pre-flight ─────────────────────────────────────────────── let credentials_file = std::path::PathBuf::from(&home) .join(".claude") .join(".credentials.json"); if !credentials_file.exists() { let _ = tokio::fs::remove_dir_all(&host_path).await; let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; return format!( "No Claude credentials found at `{home}/.claude/.credentials.json`. \ Run `claude login` on the host first, then retry.\n\n\ Partial state removed at `{}`.", host_path.display() ); } // ── Resolve git identity ───────────────────────────────────────────────── // Read from bot.toml → fallback to host git config → hardcoded default. let (git_user_name, git_user_email) = resolve_git_identity(config_dir).await; // ── Discover host SSH keys for bind-mounting ───────────────────────────── // The user's personal SSH keys are mounted read-only so `git push` over SSH // works inside the container without copying secrets into the image. 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 Some(port) = find_free_port(3100) else { let _ = tokio::fs::remove_dir_all(&host_path).await; let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; return "No free port in range 3100–3200. Stop unused containers and retry.".to_string(); }; let Some(ssh_port) = find_free_port(2200) else { let _ = tokio::fs::remove_dir_all(&host_path).await; let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; return "No free SSH port in range 2200–2300. Stop unused containers and retry." .to_string(); }; let container_url = format!("http://127.0.0.1:{port}"); let container_name = format!("huskies-{name}"); // Build the `docker run` argument list. The token must never appear in any // string that is returned to the caller or written to a log. let mut docker_args = project_docker_run_args( &container_name, port, ssh_port, &pubkey, &git_user_name, &git_user_email, Some(&credentials_file), &resolve_gateway_url(), ); // HTTPS push token: passed as env vars consumed by the entrypoint credential helper. if let Some(token) = git_token { docker_args.push("-e".into()); docker_args.push(format!("GIT_PUSH_TOKEN={token}")); if let Some(url) = git_url { docker_args.push("-e".into()); docker_args.push(format!("GIT_CLONE_URL={url}")); } } // Workspace mount. docker_args.push("-v".into()); docker_args.push(format!("{}:/workspace", host_path.display())); // SSH key bind-mounts (read-only). 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() => { // 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 { 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] Created project '{name}' at {container_url} \ ssh=127.0.0.1:{ssh_port} (image={image})" ); // ── Push credential verification ───────────────────────────────── // Only run when a git URL was provided (clone path); skip for plain // git init projects where there is no remote to verify. let push_note = if let Some(url) = git_url { match verify_push_credentials(url, git_token).await { Ok(msg) => format!("- Push credentials: {msg}\n"), Err(err) => format!("> ⚠ Push verification: {err}\n"), } } else { String::new() }; let stack_note = match resolved_stack.as_deref() { Some(s) => format!("- Stack: **{s}** (`{image}`)\n"), None => String::new(), }; let warning_block = if detect_warnings.is_empty() { String::new() } else { format!("\n> {}\n", detect_warnings.join("\n> ")) }; let config_block = if skip_config { String::new() } else { format_config_summary(&host_path, name) }; format!( "{warning_block}Project **{name}** is ready.\n\ - Host path: `{host}`\n\ - Container: `{container_name}` → `{container_url}`\n\ {stack_note}\ {push_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.\ {config_block}", host = host_path.display() ) } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); let _ = tokio::fs::remove_dir_all(&host_path).await; let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; let base_msg = interpret_docker_run_error(&stderr, &image); format!( "{base_msg}\n\nPartial state removed at `{}`.", host_path.display() ) } Err(e) => { let _ = tokio::fs::remove_dir_all(&host_path).await; let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; format!( "Docker container launch failed: {e}\n\nPartial state removed at `{}`.", host_path.display() ) } } } /// Compose the Dockerfile content used to build a project-specific image. /// /// Concatenates `FROM {base_image}` with the project's own fragment so the /// result can be piped directly to `docker build -`. pub fn dockerfile_for_project(base_image: &str, fragment: &str) -> String { format!("FROM {base_image}\n{fragment}") } /// Extend `base_image` with the project's own `Dockerfile.fragment` and build /// a project-specific Docker image. /// /// Looks for `{project_path}/.huskies/Dockerfile.fragment`. When the file is /// absent the `base_image` is returned unchanged so the caller can skip the /// build step entirely. When the file exists its content is appended to /// `FROM {base_image}` and piped to `docker build -t huskies-project-local-{project_name} -`. /// /// Returns `Ok(image_name)` on success or `Err(message)` if the build fails. pub async fn build_project_image( project_path: &Path, base_image: &str, project_name: &str, ) -> Result { let fragment_path = project_path.join(".huskies").join("Dockerfile.fragment"); let Ok(fragment) = tokio::fs::read_to_string(&fragment_path).await else { return Ok(base_image.to_string()); }; let project_image = format!("huskies-project-local-{project_name}"); let dockerfile_content = dockerfile_for_project(base_image, &fragment); let mut child = tokio::process::Command::new("docker") .args(["build", "-t", &project_image, "-"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| format!("docker build failed to start: {e}"))?; if let Some(mut stdin) = child.stdin.take() { use tokio::io::AsyncWriteExt; stdin .write_all(dockerfile_content.as_bytes()) .await .map_err(|e| format!("Failed to write Dockerfile to docker build stdin: {e}"))?; } let output = child .wait_with_output() .await .map_err(|e| format!("docker build failed: {e}"))?; if output.status.success() { Ok(project_image) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(format!( "docker build for project image `{project_image}` failed:\n{stderr}" )) } } /// Build the base `docker run` argument list for a project container. /// /// Includes `-e HUSKIES_HOST=0.0.0.0` so the server inside the container binds /// to all interfaces, making Docker port forwarding reachable from the host. /// Without this the server defaults to `127.0.0.1` inside the container — /// reachable only from within the container itself, not via `docker -p`. /// /// When `gateway_url` is non-empty, `-e HUSKIES_GATEWAY_URL=` is added so /// the sled's relay task connects back to the gateway and forwards CRDT events. /// /// When `credentials_path` is `Some`, the file is bind-mounted read-only at /// `/run/claude-credentials-src` so the container entrypoint can copy it into /// `/home/huskies/.claude/.credentials.json` with mode 0600. Mounting to an /// intermediate path (rather than directly to the destination) ensures the /// huskies user owns the copy regardless of the host user's UID. /// /// Log rotation flags (`--log-driver json-file`, `max-size=50m`, `max-file=3`) /// match `docker/docker-compose.yml` so containers launched directly via /// `docker run` don't grow logs unbounded the way compose-launched ones can't. #[allow(clippy::too_many_arguments)] pub(crate) fn project_docker_run_args( container_name: &str, port: u16, ssh_port: u16, pubkey: &str, git_user_name: &str, git_user_email: &str, credentials_path: Option<&std::path::Path>, gateway_url: &str, ) -> Vec { let mut args = vec![ "run".into(), "-d".into(), "--log-driver".into(), "json-file".into(), "--log-opt".into(), "max-size=50m".into(), "--log-opt".into(), "max-file=3".into(), "--name".into(), container_name.to_string(), "-p".into(), format!("127.0.0.1:{port}:3001"), "-p".into(), format!("127.0.0.1:{ssh_port}:22"), "-e".into(), "HUSKIES_HOST=0.0.0.0".into(), "-e".into(), "HUSKIES_PORT=3001".into(), "-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}"), ]; if !gateway_url.is_empty() { args.push("-e".into()); args.push(format!("HUSKIES_GATEWAY_URL={gateway_url}")); } if let Some(creds) = credentials_path { args.push("-v".into()); args.push(format!( "{}:/run/claude-credentials-src:ro", creds.display() )); } args } /// Resolve the gateway URL to inject into project sled containers. /// /// Reads `HUSKIES_GATEWAY_URL` from the environment first; falls back to /// `http://host.docker.internal:3000` so containers launched by the gateway /// can always relay events back without explicit configuration. pub(crate) fn resolve_gateway_url() -> String { std::env::var("HUSKIES_GATEWAY_URL") .unwrap_or_else(|_| "http://host.docker.internal:3000".to_string()) } /// Convert a failed `docker run` stderr into an actionable chat message. /// /// When Docker cannot find the image locally it prints `Unable to find image`. /// That bare error is unhelpful — this function maps it to a message that tells /// the user which script to run. All other failures are passed through as-is. fn interpret_docker_run_error(stderr: &str, image: &str) -> String { if stderr.contains("Unable to find image") { format!( "Image `{image}` not found locally. \ Build project images first by running `script/build-project-images`." ) } else { format!("Docker container launch failed: {}", stderr.trim()) } } /// Scan `start..start+range` for a bindable TCP port on 127.0.0.1. /// /// Returns `Some(port)` for the first port that can be bound, or `None` if all /// ports in the range are occupied. fn find_free_port_in_range(start: u16, range: u16) -> Option { (start..start.saturating_add(range)) .find(|&port| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()) } /// Find a free TCP port by attempting to bind starting from `start`. /// /// Scans up to 100 ports above `start` and returns `Some(port)` for the first /// bindable one, or `None` when the entire range `start..start+100` is exhausted. fn find_free_port(start: u16) -> Option { find_free_port_in_range(start, 100) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn extract_parses_name() { let cmd = extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local") .unwrap(); assert_eq!(cmd.name, "myapp"); assert_eq!(cmd.stack, None); } #[test] fn extract_case_insensitive() { let cmd = extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local") .unwrap(); assert_eq!(cmd.name, "myapp"); } #[test] fn extract_bare_project_keyword_returns_empty_name() { let cmd = extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local").unwrap(); assert_eq!(cmd.name, ""); } #[test] fn extract_parses_stack_flag() { let cmd = extract_new_project_command( "@timmy new project myapp --stack rust", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.name, "myapp"); assert_eq!(cmd.stack, Some("rust".to_string())); } #[test] fn extract_stack_node() { let cmd = extract_new_project_command( "@timmy new project myapp --stack node", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.stack, Some("node".to_string())); } #[test] fn extract_no_match_for_other_commands() { assert!( extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local").is_none() ); } #[test] fn extract_no_match_when_second_word_is_not_project() { assert!( extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local").is_none() ); } #[test] fn extract_handles_extra_whitespace() { let cmd = extract_new_project_command( "@timmy new project myapp", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.name, "myapp"); } #[test] fn image_for_stack_rust() { assert_eq!(image_for_stack(Some("rust")), "huskies-project-rust"); } #[test] fn image_for_stack_node() { assert_eq!(image_for_stack(Some("node")), "huskies-project-node"); } #[test] fn image_for_stack_base() { assert_eq!(image_for_stack(None), "huskies-project-base"); } #[test] fn detect_stack_rust_marker() { let dir = tempfile::tempdir().unwrap(); // Create a fake project with Cargo.toml std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); // Create a fake stacks dir let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("rust")).unwrap(); std::fs::write( stacks.path().join("rust/markers"), "# comment\nCargo.toml\n", ) .unwrap(); std::fs::create_dir_all(stacks.path().join("node")).unwrap(); std::fs::write( stacks.path().join("node/markers"), "package.json\ntsconfig.json\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("rust".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_node_tsconfig() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("node")).unwrap(); std::fs::write( stacks.path().join("node/markers"), "package.json\ntsconfig.json\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("node".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_no_markers_returns_none() { let dir = tempfile::tempdir().unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("rust")).unwrap(); std::fs::write(stacks.path().join("rust/markers"), "Cargo.toml\n").unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, None); assert!(warnings.is_empty()); } #[test] fn detect_stack_multiple_warn() { let dir = tempfile::tempdir().unwrap(); // Both Cargo.toml and package.json exist. std::fs::write(dir.path().join("Cargo.toml"), "").unwrap(); std::fs::write(dir.path().join("package.json"), "{}").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("node")).unwrap(); std::fs::write(stacks.path().join("node/markers"), "package.json\n").unwrap(); std::fs::create_dir_all(stacks.path().join("rust")).unwrap(); std::fs::write(stacks.path().join("rust/markers"), "Cargo.toml\n").unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); // Alphabetically first: "node" < "rust" assert_eq!(stack, Some("node".to_string())); assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("Multiple stacks")); } #[test] fn detect_stack_missing_stacks_dir_returns_none() { let dir = tempfile::tempdir().unwrap(); let (stack, warnings) = detect_stack(dir.path(), Path::new("/nonexistent/stacks")); assert_eq!(stack, None); assert!(warnings.is_empty()); } #[tokio::test] async fn generate_ssh_keypair_creates_key_files() { // Skip if ssh-keygen is not available in this environment. if tokio::process::Command::new("ssh-keygen") .arg("-V") .output() .await .is_err() { return; } let dir = tempfile::tempdir().unwrap(); let key_path = dir.path().join("id_ed25519"); let pubkey = generate_ssh_keypair(&key_path).await.unwrap(); // Private key file must exist and be non-empty. assert!(key_path.exists(), "private key file not created"); // Public key is returned as a trimmed string starting with the key type. assert!( pubkey.starts_with("ssh-ed25519"), "public key should start with ssh-ed25519, got: {pubkey}" ); // Public key file must also exist. let pub_path = dir.path().join("id_ed25519.pub"); assert!(pub_path.exists(), "public key file not created"); let pub_contents = std::fs::read_to_string(&pub_path).unwrap(); assert_eq!(pub_contents.trim(), pubkey); } #[tokio::test] async fn generate_ssh_keypair_creates_missing_parent_dirs() { // Skip if ssh-keygen is not available in this environment. if tokio::process::Command::new("ssh-keygen") .arg("-V") .output() .await .is_err() { return; } let base = tempfile::tempdir().unwrap(); // Use a nested path whose intermediate directories do not yet exist. let key_path = base.path().join("a").join("b").join("c").join("id_ed25519"); assert!( !key_path.parent().unwrap().exists(), "precondition: parent should not exist" ); let pubkey = generate_ssh_keypair(&key_path).await.unwrap(); assert!(key_path.exists(), "private key file not created"); assert!( pubkey.starts_with("ssh-ed25519"), "public key should start with ssh-ed25519, got: {pubkey}" ); } #[test] fn find_free_port_returns_bindable_port() { let port = find_free_port(2200).expect("expected Some(port) in range 2200..2300"); assert!((2200..2300).contains(&port)); let listener = std::net::TcpListener::bind(("127.0.0.1", port)); assert!(listener.is_ok(), "returned port {port} is not bindable"); } #[test] fn find_free_port_exhausted_range_returns_none() { // Bind the single port in a 1-wide scan window, then verify None is returned. let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); // The range [port, port+1) contains only `port`, which is already bound. assert_eq!(find_free_port_in_range(port, 1), None); } #[test] fn detect_stack_go_mod() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("go.mod"), "module example").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("go")).unwrap(); std::fs::write(stacks.path().join("go/markers"), "go.mod\n").unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("go".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_python_pyproject() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("python")).unwrap(); std::fs::write( stacks.path().join("python/markers"), "pyproject.toml\nrequirements.txt\nsetup.py\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("python".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_python_requirements_txt() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("python")).unwrap(); std::fs::write( stacks.path().join("python/markers"), "pyproject.toml\nrequirements.txt\nsetup.py\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("python".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_python_setup_py() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("setup.py"), "from setuptools import setup").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("python")).unwrap(); std::fs::write( stacks.path().join("python/markers"), "pyproject.toml\nrequirements.txt\nsetup.py\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("python".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_ruby_gemfile() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("Gemfile"), "source 'https://rubygems.org'").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("ruby")).unwrap(); std::fs::write(stacks.path().join("ruby/markers"), "Gemfile\n").unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("ruby".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_jvm_pom_xml() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("pom.xml"), "").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("jvm")).unwrap(); std::fs::write( stacks.path().join("jvm/markers"), "pom.xml\nbuild.gradle\nbuild.gradle.kts\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("jvm".to_string())); assert!(warnings.is_empty()); } #[test] fn detect_stack_jvm_build_gradle() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("build.gradle"), "plugins { }").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("jvm")).unwrap(); std::fs::write( stacks.path().join("jvm/markers"), "pom.xml\nbuild.gradle\nbuild.gradle.kts\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("jvm".to_string())); assert!(warnings.is_empty()); } // ── Missing-image error path ───────────────────────────────────────────── #[test] fn project_docker_args_include_huskies_host_env() { let args = project_docker_run_args( "huskies-myapp", 3100, 2200, "ssh-ed25519 AAAA...", "Test User", "test@example.com", None, "http://host.docker.internal:3000", ); let pairs: Vec<_> = args.windows(2).collect(); assert!( pairs .iter() .any(|w| w[0] == "-e" && w[1] == "HUSKIES_HOST=0.0.0.0"), "expected -e HUSKIES_HOST=0.0.0.0 in docker args, got: {args:?}" ); assert!( pairs .iter() .any(|w| w[0] == "-e" && w[1] == "HUSKIES_PORT=3001"), "expected -e HUSKIES_PORT=3001 in docker args, got: {args:?}" ); assert!( pairs .iter() .any(|w| w[0] == "-e" && w[1] == "HUSKIES_GATEWAY_URL=http://host.docker.internal:3000"), "expected -e HUSKIES_GATEWAY_URL=http://host.docker.internal:3000 in docker args, got: {args:?}" ); assert!( pairs .iter() .any(|w| w[0] == "--log-driver" && w[1] == "json-file"), "expected --log-driver json-file in docker args, got: {args:?}" ); assert!( pairs .iter() .any(|w| w[0] == "--log-opt" && w[1] == "max-size=50m"), "expected --log-opt max-size=50m in docker args, got: {args:?}" ); assert!( pairs .iter() .any(|w| w[0] == "--log-opt" && w[1] == "max-file=3"), "expected --log-opt max-file=3 in docker args, got: {args:?}" ); } #[test] fn project_docker_args_no_gateway_url_when_empty() { let args = project_docker_run_args( "huskies-myapp", 3100, 2200, "ssh-ed25519 AAAA...", "Test User", "test@example.com", None, "", ); assert!( !args.iter().any(|a| a.contains("HUSKIES_GATEWAY_URL")), "expected no HUSKIES_GATEWAY_URL when gateway_url is empty, got: {args:?}" ); } #[test] fn project_docker_args_include_credentials_mount() { let creds = std::path::Path::new("/home/user/.claude/.credentials.json"); let args = project_docker_run_args( "huskies-myapp", 3100, 2200, "ssh-ed25519 AAAA...", "Test User", "test@example.com", Some(creds), "", ); let pairs: Vec<_> = args.windows(2).collect(); assert!( pairs.iter().any(|w| w[0] == "-v" && w[1] == "/home/user/.claude/.credentials.json:/run/claude-credentials-src:ro"), "expected credentials bind-mount in docker args, got: {args:?}" ); } #[test] fn project_docker_args_no_credentials_mount_when_none() { let args = project_docker_run_args( "huskies-myapp", 3100, 2200, "ssh-ed25519 AAAA...", "Test User", "test@example.com", None, "", ); assert!( !args.iter().any(|a| a.contains("claude-credentials-src")), "expected no credentials mount when credentials_path is None, got: {args:?}" ); } #[tokio::test] async fn handle_adopt_project_missing_credentials_returns_error() { let adopt_dir = tempfile::tempdir().unwrap(); let home_dir = tempfile::tempdir().unwrap(); // home_dir has no .claude/.credentials.json let store = Arc::new(RwLock::new(BTreeMap::new())); let config_dir = tempfile::tempdir().unwrap(); let result = handle_adopt_project( "myapp", None, adopt_dir.path(), home_dir.path().to_str().unwrap(), &store, config_dir.path(), false, ) .await; assert!( result.contains("claude login"), "expected claude login suggestion in error, got: {result}" ); assert!( result.contains(".credentials.json"), "expected credentials path in error, got: {result}" ); } #[test] fn interpret_docker_run_error_missing_image_points_at_script() { let stderr = "Unable to find image 'huskies-project-rust:latest' locally\n\ docker: Error response from daemon: pull access denied"; let msg = interpret_docker_run_error(stderr, "huskies-project-rust"); assert!( msg.contains("script/build-project-images"), "expected script name in error message, got: {msg}" ); assert!( msg.contains("huskies-project-rust"), "expected image name in error message, got: {msg}" ); } #[test] fn interpret_docker_run_error_other_failure_passes_through() { let stderr = "Error response from daemon: container name already in use"; let msg = interpret_docker_run_error(stderr, "huskies-project-rust"); assert!( msg.contains("Docker container launch failed"), "expected generic error message, got: {msg}" ); assert!( msg.contains("container name already in use"), "expected original stderr in message, got: {msg}" ); } // ── Dockerfile fragment ────────────────────────────────────────────────── #[test] fn dockerfile_for_project_prepends_from() { let content = dockerfile_for_project("huskies-project-rust", "RUN echo hello\n"); assert_eq!( content, "FROM huskies-project-rust\nRUN echo hello\n", "Dockerfile should start with FROM line followed by fragment" ); } #[test] fn dockerfile_for_project_base_image() { let content = dockerfile_for_project("huskies-project-base", ""); assert_eq!(content, "FROM huskies-project-base\n"); } #[tokio::test] async fn build_project_image_no_fragment_returns_base_image() { let dir = tempfile::tempdir().unwrap(); // .huskies/ exists but contains no Dockerfile.fragment std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let result = build_project_image(dir.path(), "huskies-project-rust", "myapp").await; assert_eq!( result.unwrap(), "huskies-project-rust", "should return base image unchanged when no fragment is present" ); } #[tokio::test] async fn build_project_image_missing_huskies_dir_returns_base_image() { // No .huskies/ directory at all. let dir = tempfile::tempdir().unwrap(); let result = build_project_image(dir.path(), "huskies-project-base", "myapp").await; assert_eq!( result.unwrap(), "huskies-project-base", "should return base image unchanged when .huskies/ dir is absent" ); } /// End-to-end test: a project fragment that installs `jq` produces an /// image where `docker run ... which jq` exits successfully. /// /// Skipped when Docker is not available in the test environment. #[tokio::test] async fn build_project_image_fragment_installs_jq() { // Skip if docker is not available. let docker_check = tokio::process::Command::new("docker") .args(["info"]) .output() .await; if docker_check.map(|o| !o.status.success()).unwrap_or(true) { return; } let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::write( dir.path().join(".huskies").join("Dockerfile.fragment"), "RUN apt-get update && apt-get install -y jq\n", ) .unwrap(); let result = build_project_image(dir.path(), "debian:bookworm-slim", "test-jq-fragment").await; let image = match result { Ok(img) => img, Err(e) => { // Docker is available but the build failed — likely the base image // is not cached and pull failed in an offline environment. Skip. eprintln!("build_project_image_fragment_installs_jq: skipped ({e})"); return; } }; assert_eq!(image, "huskies-project-local-test-jq-fragment"); let which = tokio::process::Command::new("docker") .args(["run", "--rm", &image, "which", "jq"]) .output() .await .expect("docker run should not fail to start"); assert!( which.status.success(), "`which jq` should succeed inside the built image" ); // Clean up the test image. let _ = tokio::process::Command::new("docker") .args(["rmi", &image]) .output() .await; } #[test] fn detect_stack_jvm_build_gradle_kts() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("build.gradle.kts"), "plugins { }").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("jvm")).unwrap(); std::fs::write( stacks.path().join("jvm/markers"), "pom.xml\nbuild.gradle\nbuild.gradle.kts\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); assert_eq!(stack, Some("jvm".to_string())); assert!(warnings.is_empty()); } // ── Phase 4: --git and --git-token flag parsing ────────────────────────── #[test] fn extract_parses_git_flag() { let cmd = extract_new_project_command( "@timmy new project myapp --git https://github.com/user/repo", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.name, "myapp"); assert_eq!( cmd.git_url, Some("https://github.com/user/repo".to_string()) ); assert_eq!(cmd.git_token, None); } #[test] fn extract_parses_git_token_flag() { let cmd = extract_new_project_command( "@timmy new project myapp --git https://github.com/user/repo --git-token ghp_secret", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!( cmd.git_url, Some("https://github.com/user/repo".to_string()) ); assert_eq!(cmd.git_token, Some("ghp_secret".to_string())); } #[test] fn extract_git_token_without_git_url() { let cmd = extract_new_project_command( "@timmy new project myapp --git-token ghp_secret", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.git_url, None); assert_eq!(cmd.git_token, Some("ghp_secret".to_string())); } #[test] fn extract_git_flag_with_stack() { let cmd = extract_new_project_command( "@timmy new project myapp --stack rust --git git@github.com:user/repo.git", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.stack, Some("rust".to_string())); assert_eq!( cmd.git_url, Some("git@github.com:user/repo.git".to_string()) ); } #[test] fn extract_no_git_flag_returns_none_fields() { let cmd = extract_new_project_command( "@timmy new project myapp --stack node", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.git_url, None); assert_eq!(cmd.git_token, None); } // ── Phase 5: --path flag parsing ───────────────────────────────────────── #[test] fn extract_parses_path_flag() { let cmd = extract_new_project_command( "@timmy new project myapp --path /projects/myapp", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.name, "myapp"); assert_eq!(cmd.host_path, Some("/projects/myapp".to_string())); } #[test] fn extract_path_flag_with_stack_and_git() { let cmd = extract_new_project_command( "@timmy new project myapp --stack rust --git https://github.com/u/r --path /srv/myapp", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.name, "myapp"); assert_eq!(cmd.stack, Some("rust".to_string())); assert_eq!(cmd.git_url, Some("https://github.com/u/r".to_string())); assert_eq!(cmd.host_path, Some("/srv/myapp".to_string())); } #[test] fn extract_no_path_flag_returns_none() { let cmd = extract_new_project_command( "@timmy new project myapp --stack node", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.host_path, None); } #[test] fn inject_token_into_https_url() { let result = inject_token_into_url("https://github.com/user/repo", "mytoken"); assert_eq!( result, "https://x-access-token:mytoken@github.com/user/repo" ); } #[test] fn inject_token_into_http_url() { let result = inject_token_into_url("http://gitea.local/user/repo", "tok123"); assert_eq!(result, "http://x-access-token:tok123@gitea.local/user/repo"); } #[test] fn inject_token_into_ssh_url_passthrough() { // SSH URLs are returned unchanged — token injection only applies to HTTPS. let url = "git@github.com:user/repo.git"; let result = inject_token_into_url(url, "token"); assert_eq!(result, url); } #[tokio::test] async fn read_git_identity_from_bot_toml_reads_fields() { let dir = tempfile::tempdir().unwrap(); let huskies_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write( huskies_dir.join("bot.toml"), "enabled = true\ntransport = \"matrix\"\ngit_user_name = \"Test User\"\ngit_user_email = \"test@example.com\"\n", ) .unwrap(); let (name, email) = read_git_identity_from_bot_toml(dir.path()).await; assert_eq!(name, Some("Test User".to_string())); assert_eq!(email, Some("test@example.com".to_string())); } #[tokio::test] async fn read_git_identity_from_bot_toml_missing_file_returns_nones() { let dir = tempfile::tempdir().unwrap(); let (name, email) = read_git_identity_from_bot_toml(dir.path()).await; assert_eq!(name, None); assert_eq!(email, None); } #[tokio::test] async fn resolve_git_identity_uses_bot_toml_values() { let dir = tempfile::tempdir().unwrap(); let huskies_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write( huskies_dir.join("bot.toml"), "enabled = true\ntransport = \"matrix\"\ngit_user_name = \"Bot Name\"\ngit_user_email = \"bot@example.com\"\n", ) .unwrap(); let (name, email) = resolve_git_identity(dir.path()).await; assert_eq!(name, "Bot Name"); assert_eq!(email, "bot@example.com"); } #[tokio::test] async fn resolve_git_identity_falls_back_to_defaults_when_no_config() { let dir = tempfile::tempdir().unwrap(); // No bot.toml and git config may or may not have values on CI — we only // check that the result is non-empty strings (not panics or errors). let (name, email) = resolve_git_identity(dir.path()).await; assert!(!name.is_empty(), "name must be non-empty"); assert!(!email.is_empty(), "email must be non-empty"); } /// A polyglot repo with more Python markers than Node markers should prefer python. #[test] fn detect_stack_multiple_dominant_wins() { let dir = tempfile::tempdir().unwrap(); // Two Python markers: pyproject.toml + requirements.txt std::fs::write(dir.path().join("pyproject.toml"), "").unwrap(); std::fs::write(dir.path().join("requirements.txt"), "").unwrap(); // One Node marker: package.json (e.g. for a build tool) std::fs::write(dir.path().join("package.json"), "{}").unwrap(); let stacks = tempfile::tempdir().unwrap(); std::fs::create_dir_all(stacks.path().join("node")).unwrap(); std::fs::write( stacks.path().join("node/markers"), "package.json\ntsconfig.json\n", ) .unwrap(); std::fs::create_dir_all(stacks.path().join("python")).unwrap(); std::fs::write( stacks.path().join("python/markers"), "pyproject.toml\nrequirements.txt\nsetup.py\n", ) .unwrap(); let (stack, warnings) = detect_stack(dir.path(), stacks.path()); // python matches 2 markers, node matches 1 — python should win. assert_eq!(stack, Some("python".to_string())); 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"), false, &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"), false, &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"), false, &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()), false, &store, config_dir.path(), ) .await; assert!( result.contains("not a directory"), "expected not-a-directory error, got: {result}" ); } // ── Story 1137: first-run config summary and config override ───────────── // Minimal agents.toml matching the scaffold default (no comments to preserve). const TEST_AGENTS_TOML: &str = r#"[[agent]] name = "coder-1" stage = "coder" model = "sonnet" max_turns = 50 max_budget_usd = 5.00 [[agent]] name = "qa" stage = "qa" model = "sonnet" max_turns = 40 max_budget_usd = 4.00 [[agent]] name = "mergemaster" stage = "mergemaster" model = "sonnet" max_turns = 30 max_budget_usd = 5.00 "#; // Minimal project.toml with one active key and one commented key. const TEST_PROJECT_TOML: &str = r#"default_qa = "server" max_retries = 2 # max_coders = 3 # default_coder_model = "sonnet" "#; #[test] fn apply_agent_field_in_text_changes_coder_model() { let result = apply_agent_field_in_text(TEST_AGENTS_TOML, "coder", "model", "opus") .expect("should succeed"); // The coder agent block should now contain `model = "opus"`. assert!( result.contains("model = \"opus\""), "expected model = \"opus\" in result, got:\n{result}" ); // Verify the qa agent block still contains the original model. // Split on [[agent]] boundaries and check the qa block independently. let qa_block = result .split("\n[[agent]]") .find(|block| block.contains("stage = \"qa\"")); assert!( qa_block.is_some_and(|b| b.contains("model = \"sonnet\"")), "qa agent model should remain sonnet in result:\n{result}" ); } #[test] fn apply_agent_field_in_text_changes_qa_max_turns() { let result = apply_agent_field_in_text(TEST_AGENTS_TOML, "qa", "max_turns", "25") .expect("should succeed"); assert!( result.contains("max_turns = 25"), "expected max_turns = 25 in result, got:\n{result}" ); } #[test] fn apply_agent_field_in_text_unknown_agent_returns_error() { let err = apply_agent_field_in_text(TEST_AGENTS_TOML, "bogus", "model", "opus") .expect_err("should fail for unknown agent"); assert!( err.contains("bogus"), "error should mention the unknown specifier, got: {err}" ); } #[test] fn apply_agent_field_in_text_unknown_field_returns_error() { let err = apply_agent_field_in_text(TEST_AGENTS_TOML, "coder", "unknown_field", "value") .expect_err("should fail for unknown field"); assert!( err.contains("unknown_field"), "error should mention the unknown field, got: {err}" ); } #[test] fn apply_project_config_coder_model_writes_to_agents_toml() { let dir = tempfile::tempdir().unwrap(); let huskies_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write(huskies_dir.join("agents.toml"), TEST_AGENTS_TOML).unwrap(); let msg = apply_project_config(dir.path(), "coder.model", "opus").expect("should succeed"); assert!( msg.contains("agents.toml"), "success message should mention agents.toml, got: {msg}" ); // Verify the file was mutated correctly. let content = std::fs::read_to_string(huskies_dir.join("agents.toml")).unwrap(); assert!( content.contains("model = \"opus\""), "agents.toml should contain model = \"opus\" after override, got:\n{content}" ); } #[test] fn apply_project_config_default_qa_writes_to_project_toml() { let dir = tempfile::tempdir().unwrap(); let huskies_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write(huskies_dir.join("project.toml"), TEST_PROJECT_TOML).unwrap(); let msg = apply_project_config(dir.path(), "default_qa", "human").expect("should succeed"); assert!( msg.contains("project.toml"), "success message should mention project.toml, got: {msg}" ); let content = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap(); assert!( content.contains("default_qa = \"human\""), "project.toml should contain default_qa = \"human\" after override, got:\n{content}" ); } #[test] fn apply_project_config_uncomments_commented_key() { let dir = tempfile::tempdir().unwrap(); let huskies_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write(huskies_dir.join("project.toml"), TEST_PROJECT_TOML).unwrap(); // max_coders is commented out in the template; setting it should uncomment it. apply_project_config(dir.path(), "max_coders", "4").expect("should succeed"); let content = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap(); assert!( content.contains("max_coders = 4"), "project.toml should contain uncommented max_coders = 4, got:\n{content}" ); // Should not still appear as a comment. assert!( !content.contains("# max_coders = 4"), "commented form should have been replaced, got:\n{content}" ); } #[test] fn format_config_summary_lists_default_agents() { let dir = tempfile::tempdir().unwrap(); let huskies_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&huskies_dir).unwrap(); std::fs::write(huskies_dir.join("agents.toml"), TEST_AGENTS_TOML).unwrap(); let summary = format_config_summary(dir.path(), "myapp"); assert!( summary.contains("3 agents"), "summary should show agent count, got: {summary}" ); assert!( summary.contains("sonnet"), "summary should list default model, got: {summary}" ); assert!( summary.contains("huskies config myapp"), "summary should include override command, got: {summary}" ); assert!( summary.contains("--skip-config"), "summary should mention --skip-config, got: {summary}" ); } #[test] fn extract_parses_skip_config_flag() { let cmd = extract_new_project_command( "@timmy new project myapp --adopt /srv/myapp --skip-config", "Timmy", "@timmy:srv.local", ) .unwrap(); assert_eq!(cmd.adopt_path, Some("/srv/myapp".to_string())); assert!(cmd.skip_config, "skip_config should be true"); } #[test] fn extract_skip_config_false_by_default() { let cmd = extract_new_project_command( "@timmy new project myapp --adopt /srv/myapp", "Timmy", "@timmy:srv.local", ) .unwrap(); assert!(!cmd.skip_config, "skip_config should default to false"); } }