huskies: merge 1115 story new project: --adopt flow to wrap a container around an existing checkout
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user