huskies: merge 1115 story new project: --adopt flow to wrap a container around an existing checkout

This commit is contained in:
dave
2026-05-17 15:12:16 +00:00
parent eb6b07531a
commit 0695ad7ae6
2 changed files with 333 additions and 5 deletions
@@ -270,10 +270,11 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
)
{
slog!(
"[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?}",
"[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?} adopt_path={:?}",
cmd.name,
cmd.stack,
cmd.git_url,
cmd.adopt_path,
);
let response = if let Some(ref store) = ctx.gateway_projects_store {
super::super::super::new_project::handle_new_project(
@@ -282,6 +283,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
cmd.git_url.as_deref(),
cmd.git_token.as_deref(),
cmd.host_path.as_deref(),
cmd.adopt_path.as_deref(),
store,
&ctx.services.project_root,
)
+330 -4
View File
@@ -1,8 +1,8 @@
//! `new project <name>` chat command — Phase 5: `--path` override.
//! `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>]`
//! `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
@@ -47,7 +47,7 @@ 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>]` command.
/// 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,
@@ -68,9 +68,16 @@ pub struct NewProjectCommand {
/// When `Some`, the project is created at this path instead of the default.
/// The same existence check applies: the path must not already exist.
pub host_path: Option<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>]` command.
/// 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
@@ -103,6 +110,7 @@ pub fn extract_new_project_command(
let git_url = parse_flag(&remaining, "--git");
let git_token = parse_flag(&remaining, "--git-token");
let host_path = parse_flag(&remaining, "--path");
let adopt_path = parse_flag(&remaining, "--adopt");
Some(NewProjectCommand {
name,
@@ -110,6 +118,7 @@ pub fn extract_new_project_command(
git_url,
git_token,
host_path,
adopt_path,
})
}
@@ -360,6 +369,164 @@ async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result<String, Stri
.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;
format!("Docker container launch failed: {}", stderr.trim())
}
Err(e) => {
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
format!("Docker container launch failed: {e}")
}
}
}
/// Bootstrap a new project from the `new project` chat command.
///
/// Creates the project directory (default `~/huskies/<name>/`, or `host_path`
@@ -368,16 +535,23 @@ async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result<String, Stri
/// 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 {
@@ -396,6 +570,14 @@ pub async fn handle_new_project(
);
}
// --adopt is mutually exclusive with --path and --git.
if adopt_path_override.is_some() && (host_path_override.is_some() || git_url.is_some()) {
return "`--adopt` is mutually exclusive with `--path` and `--git`. \
Use `--adopt <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;
@@ -408,6 +590,23 @@ pub async fn handle_new_project(
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string());
// ── Adopt path: wrap container around an existing checkout ───────────────
if let Some(adopt) = adopt_path_override {
let host_path = std::path::PathBuf::from(adopt);
if !host_path.exists() {
return format!(
"Adopt path `{}` does not exist — specify the path to an existing checkout.",
host_path.display()
);
}
if !host_path.is_dir() {
return format!("Adopt path `{}` is not a directory.", host_path.display());
}
return handle_adopt_project(name, stack, &host_path, &home, projects_store, config_dir)
.await;
}
// `--path` overrides the default `~/huskies/<name>/`.
let host_path = match host_path_override {
Some(p) => std::path::PathBuf::from(p),
@@ -1290,4 +1489,131 @@ mod tests {
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Multiple stacks"));
}
// ── Phase 6: --adopt flag parsing and validation ─────────────────────────
#[test]
fn extract_parses_adopt_flag() {
let cmd = extract_new_project_command(
"@timmy new project myapp --adopt /projects/myapp",
"Timmy",
"@timmy:srv.local",
)
.unwrap();
assert_eq!(cmd.name, "myapp");
assert_eq!(cmd.adopt_path, Some("/projects/myapp".to_string()));
assert_eq!(cmd.host_path, None);
assert_eq!(cmd.git_url, None);
}
#[test]
fn extract_adopt_with_stack() {
let cmd = extract_new_project_command(
"@timmy new project myapp --adopt /srv/myapp --stack rust",
"Timmy",
"@timmy:srv.local",
)
.unwrap();
assert_eq!(cmd.adopt_path, Some("/srv/myapp".to_string()));
assert_eq!(cmd.stack, Some("rust".to_string()));
}
#[test]
fn extract_no_adopt_flag_returns_none_field() {
let cmd = extract_new_project_command(
"@timmy new project myapp --stack node",
"Timmy",
"@timmy:srv.local",
)
.unwrap();
assert_eq!(cmd.adopt_path, None);
}
#[tokio::test]
async fn handle_new_project_adopt_and_path_are_mutually_exclusive() {
let store = Arc::new(RwLock::new(BTreeMap::new()));
let config_dir = tempfile::tempdir().unwrap();
let result = handle_new_project(
"myapp",
None,
None,
None,
Some("/tmp/something"),
Some("/existing/checkout"),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("mutually exclusive"),
"expected mutual-exclusion error, got: {result}"
);
}
#[tokio::test]
async fn handle_new_project_adopt_and_git_are_mutually_exclusive() {
let store = Arc::new(RwLock::new(BTreeMap::new()));
let config_dir = tempfile::tempdir().unwrap();
let result = handle_new_project(
"myapp",
None,
Some("https://github.com/user/repo"),
None,
None,
Some("/existing/checkout"),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("mutually exclusive"),
"expected mutual-exclusion error, got: {result}"
);
}
#[tokio::test]
async fn handle_new_project_adopt_missing_path_returns_error() {
let store = Arc::new(RwLock::new(BTreeMap::new()));
let config_dir = tempfile::tempdir().unwrap();
let result = handle_new_project(
"myapp",
None,
None,
None,
None,
Some("/nonexistent/path/that/does/not/exist"),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("does not exist"),
"expected missing-path error, got: {result}"
);
}
#[tokio::test]
async fn handle_new_project_adopt_file_not_dir_returns_error() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("a_file.txt");
std::fs::write(&file_path, "not a directory").unwrap();
let store = Arc::new(RwLock::new(BTreeMap::new()));
let config_dir = tempfile::tempdir().unwrap();
let result = handle_new_project(
"myapp",
None,
None,
None,
None,
Some(file_path.to_str().unwrap()),
&store,
config_dir.path(),
)
.await;
assert!(
result.contains("not a directory"),
"expected not-a-directory error, got: {result}"
);
}
}