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

1667 lines
62 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>]` 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>,
}
/// 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");
Some(NewProjectCommand {
name,
stack,
git_url,
git_token,
host_path,
adopt_path,
})
}
/// 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
}
/// 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.
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> {
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,
) -> String {
// Scaffold .huskies/ into the existing repo (write-if-missing — safe).
if let Err(e) = crate::service::gateway::io::scaffold_project(host_path) {
return format!("Scaffold failed: {e}");
}
crate::service::gateway::io::init_wizard_state(host_path);
// ── Detect or validate stack ─────────────────────────────────────────────
let stacks_dir = config_dir.join("docker").join("stacks");
let (resolved_stack, detect_warnings) = match stack {
Some(s) => (Some(s.to_string()), vec![]),
None => detect_stack(host_path, &stacks_dir),
};
let image = image_for_stack(resolved_stack.as_deref());
// ── Generate SSH keypair ─────────────────────────────────────────────────
let ssh_key_dir = std::path::PathBuf::from(home).join(".huskies").join(name);
if let Err(e) = tokio::fs::create_dir_all(&ssh_key_dir).await {
return format!(
"Failed to create SSH key directory `{}`: {e}",
ssh_key_dir.display()
);
}
let private_key_path = ssh_key_dir.join("id_ed25519");
let pubkey = match generate_ssh_keypair(&private_key_path).await {
Ok(k) => k,
Err(e) => {
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
return format!("SSH keypair generation failed: {e}");
}
};
// ── Resolve git identity ─────────────────────────────────────────────────
let (git_user_name, git_user_email) = resolve_git_identity(config_dir).await;
// ── Discover host SSH keys for bind-mounting ─────────────────────────────
let host_ssh_dir = std::path::PathBuf::from(home).join(".ssh");
let mut ssh_key_mounts: Vec<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 port = find_free_port(3100);
let ssh_port = find_free_port(2200);
let container_url = format!("http://127.0.0.1:{port}");
let container_name = format!("huskies-{name}");
let mut docker_args: Vec<String> = vec![
"run".into(),
"-d".into(),
"--name".into(),
container_name.clone(),
"-p".into(),
format!("127.0.0.1:{port}:3001"),
"-p".into(),
format!("127.0.0.1:{ssh_port}:22"),
"-e".into(),
format!("HUSKIES_SSH_PUBKEY={pubkey}"),
"-e".into(),
format!("GIT_USER_NAME={git_user_name}"),
"-e".into(),
format!("GIT_USER_EMAIL={git_user_email}"),
"-v".into(),
format!("{}:/workspace", host_path.display()),
];
for mount in &ssh_key_mounts {
docker_args.push("-v".into());
docker_args.push(mount.clone());
}
docker_args.push("--restart".into());
docker_args.push("unless-stopped".into());
docker_args.push(image.clone());
docker_args.push("huskies".into());
docker_args.push("/workspace".into());
let docker_result = tokio::process::Command::new("docker")
.args(&docker_args)
.output()
.await;
match docker_result {
Ok(out) if out.status.success() => {
crate::crdt_state::write_gateway_project(name, &container_url);
{
let mut projects = projects_store.write().await;
projects.insert(
name.to_string(),
ProjectEntry {
url: Some(container_url.clone()),
auth_token: None,
ssh_port: Some(ssh_port),
host_path: Some(host_path.to_string_lossy().into_owned()),
},
);
crate::service::gateway::io::save_config(&projects, config_dir).await;
}
crate::slog!(
"[new-project] Adopted project '{name}' at {container_url} \
ssh=127.0.0.1:{ssh_port} (image={image})"
);
let stack_note = match resolved_stack.as_deref() {
Some(s) => format!("- Stack detected: **{s}** (`{image}`)\n"),
None => "- Stack: not detected (pass `--stack <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> "))
};
format!(
"{warning_block}Project **{name}** adopted.\n\
- Host path: `{host}` (existing checkout, bind-mounted)\n\
- Container: `{container_name}` → `{container_url}`\n\
{stack_note}\
- SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
-i ~/.huskies/{name}/id_ed25519`\n\
\n\
Use `switch {name}` then `status` to view the pipeline.",
host = host_path.display()
)
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
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>,
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)
.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 stacks_dir = config_dir.join("docker").join("stacks");
let (resolved_stack, detect_warnings) = match stack {
Some(s) => (Some(s.to_string()), vec![]),
None => detect_stack(&host_path, &stacks_dir),
};
let image = image_for_stack(resolved_stack.as_deref());
// ── Generate SSH keypair ─────────────────────────────────────────────────
// 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()
);
}
};
// ── 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 port = find_free_port(3100);
let ssh_port = find_free_port(2200);
let container_url = format!("http://127.0.0.1:{port}");
let container_name = format!("huskies-{name}");
// 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: Vec<String> = vec![
"run".into(),
"-d".into(),
"--name".into(),
container_name.clone(),
"-p".into(),
format!("127.0.0.1:{port}:3001"),
"-p".into(),
format!("127.0.0.1:{ssh_port}:22"),
"-e".into(),
format!("HUSKIES_SSH_PUBKEY={pubkey}"),
"-e".into(),
format!("GIT_USER_NAME={git_user_name}"),
"-e".into(),
format!("GIT_USER_EMAIL={git_user_email}"),
];
// 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> "))
};
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.",
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()
)
}
}
}
/// 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())
}
}
/// Find a free TCP port by attempting to bind starting from `start`.
///
/// Scans up to 100 ports above `start` and returns the first available one.
/// Falls back to `start` if none are found (unlikely in practice).
fn find_free_port(start: u16) -> u16 {
for port in start..start + 100 {
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
start
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_parses_name() {
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);
}
#[test]
fn find_free_port_returns_bindable_port() {
let port = find_free_port(2200);
// The returned port must be in range and actually bindable.
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 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 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}"
);
}
#[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"),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("mutually exclusive"),
"expected mutual-exclusion error, got: {result}"
);
}
#[tokio::test]
async fn handle_new_project_adopt_and_git_are_mutually_exclusive() {
let store = Arc::new(RwLock::new(BTreeMap::new()));
let config_dir = tempfile::tempdir().unwrap();
let result = handle_new_project(
"myapp",
None,
Some("https://github.com/user/repo"),
None,
None,
Some("/existing/checkout"),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("mutually exclusive"),
"expected mutual-exclusion error, got: {result}"
);
}
#[tokio::test]
async fn handle_new_project_adopt_missing_path_returns_error() {
let store = Arc::new(RwLock::new(BTreeMap::new()));
let config_dir = tempfile::tempdir().unwrap();
let result = handle_new_project(
"myapp",
None,
None,
None,
None,
Some("/nonexistent/path/that/does/not/exist"),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("does not exist"),
"expected missing-path error, got: {result}"
);
}
#[tokio::test]
async fn handle_new_project_adopt_file_not_dir_returns_error() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("a_file.txt");
std::fs::write(&file_path, "not a directory").unwrap();
let store = Arc::new(RwLock::new(BTreeMap::new()));
let config_dir = tempfile::tempdir().unwrap();
let result = handle_new_project(
"myapp",
None,
None,
None,
None,
Some(file_path.to_str().unwrap()),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("not a directory"),
"expected not-a-directory error, got: {result}"
);
}
}