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

2652 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `new project <name>` chat command — Phase 6: `--adopt` flow.
//!
//! Provisions a project container and registers it with the gateway.
//! The command is gateway-only:
//! `new project <name> [--stack <stack>] [--git <url>] [--git-token <token>] [--path <dir>] [--adopt <dir>]`
//!
//! Without `--stack`, the orchestrator inspects the (just-cloned or
//! just-init'd) source tree for stack markers found in
//! `docker/stacks/<name>/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-<stack>`.
//! 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/<name>/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 22002300 range and recorded in
//! `projects.toml` as `ssh_port`.
//!
//! Phase 4 (story 1109):
//! - `--git <url>` 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 <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/<name>/Dockerfile.fragment` — overlay instructions
//! 2. `docker/stacks/<name>/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 <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>] [--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<String>,
/// Git repository URL to clone into the project directory instead of running `git init`.
///
/// When `Some`, the bootstrap runs `git clone <url>` instead of `git init`.
/// Failure aborts the whole bootstrap with no partial state left on disk.
pub git_url: Option<String>,
/// 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<String>,
/// Override the default host directory (`~/huskies/<name>/`).
///
/// 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<String>,
/// 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<String>,
/// 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 <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>]` 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<NewProjectCommand> {
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 `--<flag> <value>` from a token slice.
fn parse_flag(tokens: &[&str], flag: &str) -> Option<String> {
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::<toml::Value>(&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 `<agent_specifier>.<field>` (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<String, String> {
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<String, String> {
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<String, String> {
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<String> = 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<str> = 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<String, String> {
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::<i64>().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/<name>/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<String>, Vec<String>) {
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<String> = 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 <name>` to override."
);
(Some(chosen), vec![warning])
}
}
}
/// Return the Docker image name for the given stack.
///
/// Stack images follow the convention `huskies-project-<stack>`.
/// 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<String>, Option<String>) {
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::<BotConfig>(&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<String> {
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:<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<String, String> {
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 <key_path>` with no passphrase.
/// Returns the public key string (trimmed) on success.
async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result<String, String> {
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<RwLock<BTreeMap<String, ProjectEntry>>>,
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<String> = 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 31003200. Stop unused containers and retry.".to_string();
};
let Some(ssh_port) = find_free_port(2200) else {
return "No free SSH port in range 22002300. 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 <name>` 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/<name>/`, 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 `<path>`".
///
/// `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<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."
);
}
// --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 <existing-path>` 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/<name>/`.
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 <url>`: 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/<name>/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<String> = 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 31003200. 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 22002300. 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<String, String> {
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=<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<String> {
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<u16> {
(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<u16> {
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"), "<project/>").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");
}
}