2026-05-17 15:12:16 +00:00
|
|
|
|
//! `new project <name>` chat command — Phase 6: `--adopt` flow.
|
2026-05-16 22:34:24 +00:00
|
|
|
|
//!
|
2026-05-16 22:56:49 +00:00
|
|
|
|
//! Provisions a project container and registers it with the gateway.
|
2026-05-17 00:12:40 +00:00
|
|
|
|
//! The command is gateway-only:
|
2026-05-17 15:12:16 +00:00
|
|
|
|
//! `new project <name> [--stack <stack>] [--git <url>] [--git-token <token>] [--path <dir>] [--adopt <dir>]`
|
2026-05-16 22:56:49 +00:00
|
|
|
|
//!
|
|
|
|
|
|
//! 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`.
|
|
|
|
|
|
//!
|
2026-05-16 23:32:33 +00:00
|
|
|
|
//! 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 2200–2300 range and recorded in
|
|
|
|
|
|
//! `projects.toml` as `ssh_port`.
|
|
|
|
|
|
//!
|
2026-05-17 00:12:40 +00:00
|
|
|
|
//! 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.
|
|
|
|
|
|
//!
|
2026-05-16 22:56:49 +00:00
|
|
|
|
//! 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.
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::service::gateway::config::ProjectEntry;
|
|
|
|
|
|
|
2026-05-17 15:12:16 +00:00
|
|
|
|
/// Parsed result of a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>]` command.
|
2026-05-16 22:56:49 +00:00
|
|
|
|
pub struct NewProjectCommand {
|
|
|
|
|
|
/// Project name (alphanumeric, hyphens, underscores).
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
/// Explicitly requested stack, or `None` for auto-detection.
|
|
|
|
|
|
pub stack: Option<String>,
|
2026-05-17 00:12:40 +00:00
|
|
|
|
/// 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>,
|
2026-05-17 14:43:53 +00:00
|
|
|
|
/// 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>,
|
2026-05-17 15:12:16 +00:00
|
|
|
|
/// 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>,
|
2026-05-16 22:56:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 15:12:16 +00:00
|
|
|
|
/// Parse a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>]` command.
|
2026-05-16 22:34:24 +00:00
|
|
|
|
///
|
2026-05-16 22:56:49 +00:00
|
|
|
|
/// 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.
|
2026-05-16 22:34:24 +00:00
|
|
|
|
pub fn extract_new_project_command(
|
|
|
|
|
|
message: &str,
|
|
|
|
|
|
bot_name: &str,
|
|
|
|
|
|
bot_user_id: &str,
|
2026-05-16 22:56:49 +00:00
|
|
|
|
) -> Option<NewProjectCommand> {
|
2026-05-16 22:34:24 +00:00
|
|
|
|
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();
|
2026-05-16 22:56:49 +00:00
|
|
|
|
|
|
|
|
|
|
let remaining: Vec<&str> = words.collect();
|
2026-05-17 00:12:40 +00:00
|
|
|
|
let stack = parse_flag(&remaining, "--stack");
|
|
|
|
|
|
let git_url = parse_flag(&remaining, "--git");
|
|
|
|
|
|
let git_token = parse_flag(&remaining, "--git-token");
|
2026-05-17 14:43:53 +00:00
|
|
|
|
let host_path = parse_flag(&remaining, "--path");
|
2026-05-17 15:12:16 +00:00
|
|
|
|
let adopt_path = parse_flag(&remaining, "--adopt");
|
2026-05-17 00:12:40 +00:00
|
|
|
|
|
|
|
|
|
|
Some(NewProjectCommand {
|
|
|
|
|
|
name,
|
|
|
|
|
|
stack,
|
|
|
|
|
|
git_url,
|
|
|
|
|
|
git_token,
|
2026-05-17 14:43:53 +00:00
|
|
|
|
host_path,
|
2026-05-17 15:12:16 +00:00
|
|
|
|
adopt_path,
|
2026-05-17 00:12:40 +00:00
|
|
|
|
})
|
2026-05-16 22:56:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
/// Extract the value of `--<flag> <value>` from a token slice.
|
|
|
|
|
|
fn parse_flag(tokens: &[&str], flag: &str) -> Option<String> {
|
2026-05-16 22:56:49 +00:00
|
|
|
|
let mut iter = tokens.iter().peekable();
|
|
|
|
|
|
while let Some(tok) = iter.next() {
|
2026-05-17 00:12:40 +00:00
|
|
|
|
if *tok == flag
|
2026-05-16 22:56:49 +00:00
|
|
|
|
&& let Some(val) = iter.next()
|
|
|
|
|
|
{
|
|
|
|
|
|
return Some(val.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
None
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
|
/// 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![]),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 23:15:02 +00:00
|
|
|
|
// (stack_name, number_of_matched_marker_files)
|
|
|
|
|
|
let mut matched: Vec<(String, usize)> = Vec::new();
|
2026-05-16 22:56:49 +00:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-05-16 23:15:02 +00:00
|
|
|
|
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));
|
2026-05-16 22:56:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
match matched.len() {
|
|
|
|
|
|
0 => (None, vec![]),
|
2026-05-16 23:15:02 +00:00
|
|
|
|
1 => (Some(matched.remove(0).0), vec![]),
|
2026-05-16 22:56:49 +00:00
|
|
|
|
_ => {
|
2026-05-16 23:15:02 +00:00
|
|
|
|
// 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;
|
2026-05-16 22:56:49 +00:00
|
|
|
|
let warning = format!(
|
2026-05-16 23:15:02 +00:00
|
|
|
|
"Multiple stacks detected ({names_str}); using **{chosen}** \
|
|
|
|
|
|
(most marker files matched). \
|
2026-05-16 22:56:49 +00:00
|
|
|
|
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(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
/// 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}"
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:32:33 +00:00
|
|
|
|
/// 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()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 15:12:16 +00:00
|
|
|
|
/// 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 ──────────────────────────────────
|
2026-05-17 19:19:15 +00:00
|
|
|
|
let Some(port) = find_free_port(3100) else {
|
|
|
|
|
|
return "No free port in range 3100–3200. Stop unused containers and retry.".to_string();
|
|
|
|
|
|
};
|
|
|
|
|
|
let Some(ssh_port) = find_free_port(2200) else {
|
|
|
|
|
|
return "No free SSH port in range 2200–2300. Stop unused containers and retry."
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
};
|
2026-05-17 15:12:16 +00:00
|
|
|
|
let container_url = format!("http://127.0.0.1:{port}");
|
|
|
|
|
|
let container_name = format!("huskies-{name}");
|
|
|
|
|
|
|
2026-05-17 19:56:12 +00:00
|
|
|
|
let mut docker_args = project_docker_run_args(
|
|
|
|
|
|
&container_name,
|
|
|
|
|
|
port,
|
|
|
|
|
|
ssh_port,
|
|
|
|
|
|
&pubkey,
|
|
|
|
|
|
&git_user_name,
|
|
|
|
|
|
&git_user_email,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
docker_args.push("-v".into());
|
|
|
|
|
|
docker_args.push(format!("{}:/workspace", host_path.display()));
|
2026-05-17 15:12:16 +00:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-17 16:21:11 +00:00
|
|
|
|
interpret_docker_run_error(&stderr, &image)
|
2026-05-17 15:12:16 +00:00
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
|
|
|
|
|
|
format!("Docker container launch failed: {e}")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
/// Bootstrap a new project from the `new project` chat command.
|
|
|
|
|
|
///
|
2026-05-17 14:43:53 +00:00
|
|
|
|
/// Creates the project directory (default `~/huskies/<name>/`, or `host_path`
|
|
|
|
|
|
/// when `--path` is supplied), scaffolds `.huskies/`, runs `git clone` (when
|
2026-05-17 00:12:40 +00:00
|
|
|
|
/// `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.
|
2026-05-16 22:34:24 +00:00
|
|
|
|
///
|
2026-05-17 15:12:16 +00:00
|
|
|
|
/// 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`.
|
|
|
|
|
|
///
|
2026-05-17 00:12:40 +00:00
|
|
|
|
/// On any failure after a directory is created, the directory is removed and
|
|
|
|
|
|
/// the error message includes "Partial state removed at `<path>`".
|
2026-05-16 22:34:24 +00:00
|
|
|
|
///
|
2026-05-17 00:12:40 +00:00
|
|
|
|
/// `git_token` is never echoed in any returned string or log line.
|
2026-05-17 15:12:16 +00:00
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-05-16 22:34:24 +00:00
|
|
|
|
pub async fn handle_new_project(
|
|
|
|
|
|
name: &str,
|
2026-05-16 22:56:49 +00:00
|
|
|
|
stack: Option<&str>,
|
2026-05-17 00:12:40 +00:00
|
|
|
|
git_url: Option<&str>,
|
|
|
|
|
|
git_token: Option<&str>,
|
2026-05-17 14:43:53 +00:00
|
|
|
|
host_path_override: Option<&str>,
|
2026-05-17 15:12:16 +00:00
|
|
|
|
adopt_path_override: Option<&str>,
|
2026-05-16 22:34:24 +00:00
|
|
|
|
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."
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 15:12:16 +00:00
|
|
|
|
// --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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:34:24 +00:00
|
|
|
|
// 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());
|
2026-05-17 15:12:16 +00:00
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 14:43:53 +00:00
|
|
|
|
// `--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),
|
|
|
|
|
|
};
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
|
|
|
|
|
if host_path.exists() {
|
|
|
|
|
|
return format!(
|
|
|
|
|
|
"Path `{}` already exists. \
|
|
|
|
|
|
Choose a different project name or remove the directory first.",
|
|
|
|
|
|
host_path.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
// ── 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()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
let clone_out = tokio::process::Command::new("git")
|
|
|
|
|
|
.arg("clone")
|
|
|
|
|
|
.arg(url)
|
|
|
|
|
|
.arg(&host_path)
|
|
|
|
|
|
.env("GIT_TERMINAL_PROMPT", "0")
|
|
|
|
|
|
.output()
|
|
|
|
|
|
.await;
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
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(_) => {}
|
|
|
|
|
|
}
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
// 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) {
|
2026-05-16 22:34:24 +00:00
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
|
return format!(
|
2026-05-17 00:12:40 +00:00
|
|
|
|
"Scaffold failed: {e}\n\nPartial state removed at `{}`.",
|
2026-05-16 22:34:24 +00:00
|
|
|
|
host_path.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-17 00:12:40 +00:00
|
|
|
|
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) {
|
2026-05-16 22:34:24 +00:00
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
|
return format!(
|
2026-05-17 00:12:40 +00:00
|
|
|
|
"Scaffold failed: {e}\n\nPartial state removed at `{}`.",
|
2026-05-16 22:34:24 +00:00
|
|
|
|
host_path.display()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-17 00:12:40 +00:00
|
|
|
|
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(_) => {}
|
|
|
|
|
|
}
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
|
// ── 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());
|
|
|
|
|
|
|
2026-05-16 23:32:33 +00:00
|
|
|
|
// ── 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()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
// ── 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()
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:32:33 +00:00
|
|
|
|
// ── Allocate ports and launch container ──────────────────────────────────
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
2026-05-17 19:19:15 +00:00
|
|
|
|
let Some(port) = find_free_port(3100) else {
|
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
|
|
|
|
|
|
return "No free port in range 3100–3200. Stop unused containers and retry.".to_string();
|
|
|
|
|
|
};
|
|
|
|
|
|
let Some(ssh_port) = find_free_port(2200) else {
|
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
|
|
|
|
|
|
return "No free SSH port in range 2200–2300. Stop unused containers and retry."
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
};
|
2026-05-16 22:34:24 +00:00
|
|
|
|
let container_url = format!("http://127.0.0.1:{port}");
|
|
|
|
|
|
let container_name = format!("huskies-{name}");
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
// 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.
|
2026-05-17 19:56:12 +00:00
|
|
|
|
let mut docker_args = project_docker_run_args(
|
|
|
|
|
|
&container_name,
|
|
|
|
|
|
port,
|
|
|
|
|
|
ssh_port,
|
|
|
|
|
|
&pubkey,
|
|
|
|
|
|
&git_user_name,
|
|
|
|
|
|
&git_user_email,
|
|
|
|
|
|
);
|
2026-05-17 00:12:40 +00:00
|
|
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
|
|
2026-05-16 22:34:24 +00:00
|
|
|
|
let docker_result = tokio::process::Command::new("docker")
|
2026-05-17 00:12:40 +00:00
|
|
|
|
.args(&docker_args)
|
2026-05-16 22:34:24 +00:00
|
|
|
|
.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;
|
2026-05-16 23:32:33 +00:00
|
|
|
|
projects.insert(
|
|
|
|
|
|
name.to_string(),
|
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
|
url: Some(container_url.clone()),
|
|
|
|
|
|
auth_token: None,
|
|
|
|
|
|
ssh_port: Some(ssh_port),
|
2026-05-17 14:43:53 +00:00
|
|
|
|
host_path: Some(host_path.to_string_lossy().into_owned()),
|
2026-05-16 23:32:33 +00:00
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-05-16 22:34:24 +00:00
|
|
|
|
crate::service::gateway::io::save_config(&projects, config_dir).await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
|
crate::slog!(
|
2026-05-16 23:32:33 +00:00
|
|
|
|
"[new-project] Created project '{name}' at {container_url} \
|
|
|
|
|
|
ssh=127.0.0.1:{ssh_port} (image={image})"
|
2026-05-16 22:56:49 +00:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
// ── 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()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
|
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> "))
|
|
|
|
|
|
};
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
|
|
|
|
|
format!(
|
2026-05-16 22:56:49 +00:00
|
|
|
|
"{warning_block}Project **{name}** is ready.\n\
|
2026-05-16 22:34:24 +00:00
|
|
|
|
- Host path: `{host}`\n\
|
|
|
|
|
|
- Container: `{container_name}` → `{container_url}`\n\
|
2026-05-16 22:56:49 +00:00
|
|
|
|
{stack_note}\
|
2026-05-17 00:12:40 +00:00
|
|
|
|
{push_note}\
|
2026-05-16 23:32:33 +00:00
|
|
|
|
- SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
|
|
|
|
|
|
-i ~/.huskies/{name}/id_ed25519`\n\
|
2026-05-16 22:34:24 +00:00
|
|
|
|
\n\
|
2026-05-16 22:56:49 +00:00
|
|
|
|
Use `switch {name}` then `status` to view the pipeline.",
|
2026-05-16 22:34:24 +00:00
|
|
|
|
host = host_path.display()
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(out) => {
|
|
|
|
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
2026-05-16 23:32:33 +00:00
|
|
|
|
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
|
2026-05-17 16:21:11 +00:00
|
|
|
|
let base_msg = interpret_docker_run_error(&stderr, &image);
|
2026-05-16 22:34:24 +00:00
|
|
|
|
format!(
|
2026-05-17 16:21:11 +00:00
|
|
|
|
"{base_msg}\n\nPartial state removed at `{}`.",
|
2026-05-16 22:34:24 +00:00
|
|
|
|
host_path.display()
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
2026-05-16 23:32:33 +00:00
|
|
|
|
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
|
2026-05-16 22:34:24 +00:00
|
|
|
|
format!(
|
|
|
|
|
|
"Docker container launch failed: {e}\n\nPartial state removed at `{}`.",
|
|
|
|
|
|
host_path.display()
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 19:56:12 +00:00
|
|
|
|
/// 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`.
|
|
|
|
|
|
fn project_docker_run_args(
|
|
|
|
|
|
container_name: &str,
|
|
|
|
|
|
port: u16,
|
|
|
|
|
|
ssh_port: u16,
|
|
|
|
|
|
pubkey: &str,
|
|
|
|
|
|
git_user_name: &str,
|
|
|
|
|
|
git_user_email: &str,
|
|
|
|
|
|
) -> Vec<String> {
|
|
|
|
|
|
vec![
|
|
|
|
|
|
"run".into(),
|
|
|
|
|
|
"-d".into(),
|
|
|
|
|
|
"--name".into(),
|
|
|
|
|
|
container_name.to_string(),
|
|
|
|
|
|
"-p".into(),
|
2026-05-18 00:28:34 +01:00
|
|
|
|
format!("127.0.0.1:{port}:3001"),
|
2026-05-17 19:56:12 +00:00
|
|
|
|
"-p".into(),
|
2026-05-18 00:28:34 +01:00
|
|
|
|
format!("127.0.0.1:{ssh_port}:22"),
|
2026-05-17 19:56:12 +00:00
|
|
|
|
"-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}"),
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 16:21:11 +00:00
|
|
|
|
/// 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())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 19:19:15 +00:00
|
|
|
|
/// 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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 22:34:24 +00:00
|
|
|
|
/// Find a free TCP port by attempting to bind starting from `start`.
|
|
|
|
|
|
///
|
2026-05-17 19:19:15 +00:00
|
|
|
|
/// 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)
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Tests
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_parses_name() {
|
2026-05-16 22:56:49 +00:00
|
|
|
|
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);
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_case_insensitive() {
|
2026-05-16 22:56:49 +00:00
|
|
|
|
let cmd =
|
|
|
|
|
|
extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(cmd.name, "myapp");
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_bare_project_keyword_returns_empty_name() {
|
2026-05-16 22:56:49 +00:00
|
|
|
|
let cmd =
|
|
|
|
|
|
extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local").unwrap();
|
|
|
|
|
|
assert_eq!(cmd.name, "");
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-16 22:56:49 +00:00
|
|
|
|
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()));
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_no_match_for_other_commands() {
|
2026-05-16 22:56:49 +00:00
|
|
|
|
assert!(
|
|
|
|
|
|
extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local").is_none()
|
2026-05-16 22:34:24 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_no_match_when_second_word_is_not_project() {
|
2026-05-16 22:56:49 +00:00
|
|
|
|
assert!(
|
|
|
|
|
|
extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local").is_none()
|
2026-05-16 22:34:24 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn extract_handles_extra_whitespace() {
|
2026-05-16 22:56:49 +00:00
|
|
|
|
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());
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|
2026-05-16 23:15:02 +00:00
|
|
|
|
|
2026-05-16 23:32:33 +00:00
|
|
|
|
#[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() {
|
2026-05-17 19:19:15 +00:00
|
|
|
|
let port = find_free_port(2200).expect("expected Some(port) in range 2200..2300");
|
2026-05-16 23:32:33 +00:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 19:19:15 +00:00
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:15:02 +00:00
|
|
|
|
#[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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 16:21:11 +00:00
|
|
|
|
// ── Missing-image error path ─────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-17 19:56:12 +00:00
|
|
|
|
#[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",
|
|
|
|
|
|
);
|
|
|
|
|
|
// Find "-e" followed by "HUSKIES_HOST=0.0.0.0"
|
|
|
|
|
|
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:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 16:21:11 +00:00
|
|
|
|
#[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}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:15:02 +00:00
|
|
|
|
#[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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 14:43:53 +00:00
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 00:12:40 +00:00
|
|
|
|
#[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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 23:15:02 +00:00
|
|
|
|
/// 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"));
|
|
|
|
|
|
}
|
2026-05-17 15:12:16 +00:00
|
|
|
|
|
|
|
|
|
|
// ── 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}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-16 22:34:24 +00:00
|
|
|
|
}
|