From 3a433377356b325d4b158b0579f13bbd14a0c150 Mon Sep 17 00:00:00 2001 From: dave Date: Sat, 16 May 2026 22:56:49 +0000 Subject: [PATCH] huskies: merge 1107 story Chat bootstrap Phase 2a: stack-overlay framework + Rust and Node stack overlays --- docker/Dockerfile.base | 49 +++ docker/stacks/node/Dockerfile.fragment | 26 ++ docker/stacks/node/markers | 7 + docker/stacks/rust/Dockerfile.fragment | 37 ++ docker/stacks/rust/markers | 4 + .../matrix/bot/messages/on_room_message.rs | 15 +- .../src/chat/transport/matrix/new_project.rs | 347 +++++++++++++++--- 7 files changed, 425 insertions(+), 60 deletions(-) create mode 100644 docker/Dockerfile.base create mode 100644 docker/stacks/node/Dockerfile.fragment create mode 100644 docker/stacks/node/markers create mode 100644 docker/stacks/rust/Dockerfile.fragment create mode 100644 docker/stacks/rust/markers diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base new file mode 100644 index 00000000..03c70f8e --- /dev/null +++ b/docker/Dockerfile.base @@ -0,0 +1,49 @@ +# huskies-project-base — minimal base for all project containers. +# +# This image provides git, the huskies server binary, and a non-root user. +# It carries no language tooling. Per-stack overlays (docker/stacks// +# Dockerfile.fragment) layer their toolchains on top of this base. +# +# Prerequisites: build the main `huskies` image first so its binary is +# available as a build source. +# +# docker build -t huskies -f docker/Dockerfile . +# docker build -t huskies-project-base -f docker/Dockerfile.base . +# +# To build a stack image (e.g. rust): +# (echo "FROM huskies-project-base"; \ +# cat docker/stacks/rust/Dockerfile.fragment) | \ +# docker build -t huskies-project-rust - + +FROM huskies AS huskies-src + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + ca-certificates \ + libssl3 \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Copy the huskies binary and entrypoint from the main image. +COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies +COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh + +# Non-root user — Claude Code refuses --dangerously-skip-permissions as root. +RUN groupadd -r huskies \ + && useradd -r -g huskies -m -d /home/huskies huskies \ + && mkdir -p /home/huskies/.claude \ + && chown -R huskies:huskies /home/huskies \ + && mkdir -p /workspace \ + && chown huskies:huskies /workspace \ + && git config --global init.defaultBranch master + +USER huskies +WORKDIR /workspace + +EXPOSE 3001 + +ENTRYPOINT ["entrypoint.sh"] +CMD ["huskies", "/workspace"] diff --git a/docker/stacks/node/Dockerfile.fragment b/docker/stacks/node/Dockerfile.fragment new file mode 100644 index 00000000..7d5ef73d --- /dev/null +++ b/docker/stacks/node/Dockerfile.fragment @@ -0,0 +1,26 @@ +# Node stack overlay fragment. +# +# Layer this on top of huskies-project-base to produce a project container +# with Node.js 22, TypeScript (tsc), and typescript-language-server. +# +# Build the combined image: +# (echo "FROM huskies-project-base"; \ +# cat docker/stacks/node/Dockerfile.fragment) | \ +# docker build -t huskies-project-node - +# +# Adding a new stack: create docker/stacks//Dockerfile.fragment and +# docker/stacks//markers — no changes to orchestration code required. + +USER root + +# Node.js 22.x (LTS). +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# TypeScript compiler and language server for LSP-aware agents. +# tsc: TypeScript compiler (tsc --version) +# typescript-language-server: LSP server used by editors/agents +RUN npm install -g typescript typescript-language-server + +USER huskies diff --git a/docker/stacks/node/markers b/docker/stacks/node/markers new file mode 100644 index 00000000..2325e81d --- /dev/null +++ b/docker/stacks/node/markers @@ -0,0 +1,7 @@ +# Stack detection markers for the node stack. +# Each non-blank, non-comment line names a file relative to the project root. +# If any listed file exists in the project directory, this stack is matched. +# tsconfig.json is listed explicitly so TypeScript-only projects are detected +# even without a package.json at the repo root. +package.json +tsconfig.json diff --git a/docker/stacks/rust/Dockerfile.fragment b/docker/stacks/rust/Dockerfile.fragment new file mode 100644 index 00000000..224a0d04 --- /dev/null +++ b/docker/stacks/rust/Dockerfile.fragment @@ -0,0 +1,37 @@ +# Rust stack overlay fragment. +# +# Layer this on top of huskies-project-base to produce a project container +# with a full Rust toolchain, rust-analyzer, and cargo-nextest. +# +# Build the combined image: +# (echo "FROM huskies-project-base"; \ +# cat docker/stacks/rust/Dockerfile.fragment) | \ +# docker build -t huskies-project-rust - +# +# Adding a new stack: create docker/stacks//Dockerfile.fragment and +# docker/stacks//markers — no changes to orchestration code required. + +USER root + +# Build tools required by rustup and many Rust crates. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +ENV RUSTUP_HOME="/home/huskies/.rustup" \ + CARGO_HOME="/home/huskies/.cargo" + +# Install stable Rust + rust-analyzer component as the huskies user. +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | su huskies -c "sh -s -- -y --no-modify-path --default-toolchain stable" \ + && /home/huskies/.cargo/bin/rustup component add rust-analyzer \ + && chown -R huskies:huskies /home/huskies/.rustup /home/huskies/.cargo + +# cargo-nextest: fast Rust test runner used by huskies quality gates. +RUN curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/bin + +ENV PATH="/home/huskies/.cargo/bin:${PATH}" + +USER huskies diff --git a/docker/stacks/rust/markers b/docker/stacks/rust/markers new file mode 100644 index 00000000..9e6c40be --- /dev/null +++ b/docker/stacks/rust/markers @@ -0,0 +1,4 @@ +# Stack detection markers for the rust stack. +# Each non-blank, non-comment line names a file relative to the project root. +# If any listed file exists in the project directory, this stack is matched. +Cargo.toml diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index b3451de9..9892869c 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -260,19 +260,24 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( // Gateway-local commands and freeform text fall through to normal handling below. } - // In gateway mode, handle the "new project " command to bootstrap a - // bare project container and register it with the gateway. + // In gateway mode, handle the "new project [--stack ]" command + // to bootstrap a project container and register it with the gateway. if ctx.is_gateway() - && let Some(project_name) = super::super::super::new_project::extract_new_project_command( + && let Some(cmd) = super::super::super::new_project::extract_new_project_command( &user_message, &ctx.services.bot_name, ctx.matrix_user_id.as_str(), ) { - slog!("[matrix-bot] Handling new project command from {sender}: name={project_name:?}"); + slog!( + "[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?}", + cmd.name, + cmd.stack + ); let response = if let Some(ref store) = ctx.gateway_projects_store { super::super::super::new_project::handle_new_project( - &project_name, + &cmd.name, + cmd.stack.as_deref(), store, &ctx.services.project_root, ) diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index d496495a..ed58628d 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -1,8 +1,21 @@ -//! `new project ` chat command — Phase 1 bootstrap. +//! `new project ` chat command — Phase 2a stack-overlay framework. //! -//! Provisions a bare project container and registers it with the gateway. +//! Provisions a project container and registers it with the gateway. //! The command is gateway-only: `new project [--stack ]`. -//! Phase 1 ignores `--stack`; stack-aware images arrive in Phase 2. +//! +//! Without `--stack`, the orchestrator inspects the (just-cloned or +//! just-init'd) source tree for stack markers found in +//! `docker/stacks//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-`. +//! The base image (no language tooling) is `huskies-project-base`. +//! +//! Adding a new stack requires only: +//! 1. `docker/stacks//Dockerfile.fragment` — overlay instructions +//! 2. `docker/stacks//markers` — detection marker filenames +//! No changes to this orchestration module are needed. use std::collections::BTreeMap; use std::path::Path; @@ -11,17 +24,25 @@ use tokio::sync::RwLock; use crate::service::gateway::config::ProjectEntry; -/// Parse a `new project ` command from a chat message. +/// Parsed result of a `new project [--stack ]` chat command. +pub struct NewProjectCommand { + /// Project name (alphanumeric, hyphens, underscores). + pub name: String, + /// Explicitly requested stack, or `None` for auto-detection. + pub stack: Option, +} + +/// Parse a `new project [--stack ]` command from a chat message. /// -/// Returns `Some(name)` when the stripped message starts with "new project" -/// (case-insensitive). An empty name (bare "new project" with no arg) is -/// returned as `Some("")` so the handler can emit a usage error. Returns `None` -/// for any other message. +/// Returns `Some(NewProjectCommand)` when the stripped message starts with +/// "new project" (case-insensitive). An empty name (bare "new project" with +/// no arg) is returned as `Some(name="")` so the handler can emit a usage +/// error. Returns `None` for any other message. pub fn extract_new_project_command( message: &str, bot_name: &str, bot_user_id: &str, -) -> Option { +) -> Option { 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 @@ -37,21 +58,110 @@ pub fn extract_new_project_command( if !second.eq_ignore_ascii_case("project") { return None; } - // Third word is the project name; later words (flags) are ignored in Phase 1. let name = words.next().unwrap_or("").to_string(); - Some(name) + + // Scan remaining tokens for `--stack `. + let remaining: Vec<&str> = words.collect(); + let stack = parse_stack_flag(&remaining); + + Some(NewProjectCommand { name, stack }) } -/// Bootstrap a new project from the `new project ` command. +/// Extract the value of `--stack ` from a token slice. +fn parse_stack_flag(tokens: &[&str]) -> Option { + let mut iter = tokens.iter().peekable(); + while let Some(tok) = iter.next() { + if *tok == "--stack" + && let Some(val) = iter.next() + { + return Some(val.to_string()); + } + } + None +} + +/// Scan `stacks_dir` for per-stack `markers` files and detect which stacks +/// match the given project directory. +/// +/// Returns `(selected_stack, warnings)` where `selected_stack` is the +/// auto-detected stack name (or `None` if no markers matched) and `warnings` +/// carries a human-readable message when multiple stacks matched. +/// +/// Each `stacks_dir//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, Vec) { + let entries = match std::fs::read_dir(stacks_dir) { + Ok(e) => e, + Err(_) => return (None, vec![]), + }; + + let mut matched: Vec = Vec::new(); + + let mut stack_dirs: Vec<_> = entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .collect(); + // Deterministic order so multi-match selection is stable. + stack_dirs.sort_by_key(|e| e.file_name()); + + for entry in stack_dirs { + let stack_name = entry.file_name().to_string_lossy().into_owned(); + let markers_path = entry.path().join("markers"); + let Ok(content) = std::fs::read_to_string(&markers_path) else { + continue; + }; + let hit = content.lines().any(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return false; + } + project_path.join(trimmed).exists() + }); + if hit { + matched.push(stack_name); + } + } + + match matched.len() { + 0 => (None, vec![]), + 1 => (Some(matched.remove(0)), vec![]), + _ => { + let names = matched.join(", "); + let chosen = matched.remove(0); + let warning = format!( + "Multiple stacks detected ({names}); using **{chosen}**. \ + Pass `--stack ` to override." + ); + (Some(chosen), vec![warning]) + } + } +} + +/// Return the Docker image name for the given stack. +/// +/// Stack images follow the convention `huskies-project-`. +/// 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(), + } +} + +/// Bootstrap a new project from the `new project [--stack ]` command. /// /// Creates `~/huskies//`, scaffolds `.huskies/`, runs `git init`, -/// launches a Docker container (`huskies-project-base`), and registers the -/// project in both the gateway's in-memory store and the CRDT. +/// auto-detects or honours the requested stack, launches the appropriate +/// Docker container, and registers the project in both the gateway's +/// in-memory store and the CRDT. /// /// On any failure after the host directory is created, the directory is removed /// and the error message includes "Partial state removed at ``". pub async fn handle_new_project( name: &str, + stack: Option<&str>, projects_store: &Arc>>, config_dir: &Path, ) -> String { @@ -137,6 +247,15 @@ pub async fn handle_new_project( Ok(_) => {} } + // ── Detect or validate stack ───────────────────────────────────────────── + + let stacks_dir = config_dir.join("docker").join("stacks"); + let (resolved_stack, detect_warnings) = match stack { + Some(s) => (Some(s.to_string()), vec![]), + None => detect_stack(&host_path, &stacks_dir), + }; + let image = image_for_stack(resolved_stack.as_deref()); + // ── Allocate port and launch container ─────────────────────────────────── let port = find_free_port(3100); @@ -155,7 +274,7 @@ pub async fn handle_new_project( &format!("{}:/workspace", host_path.display()), "--restart", "unless-stopped", - "huskies-project-base", + &image, "huskies", "/workspace", ]) @@ -174,16 +293,27 @@ pub async fn handle_new_project( crate::service::gateway::io::save_config(&projects, config_dir).await; } - crate::slog!("[new-project] Created project '{name}' at {container_url}"); + crate::slog!( + "[new-project] Created project '{name}' at {container_url} (image={image})" + ); + + let stack_note = match resolved_stack.as_deref() { + Some(s) => format!("- Stack: **{s}** (`{image}`)\n"), + None => String::new(), + }; + let warning_block = if detect_warnings.is_empty() { + String::new() + } else { + format!("\n> {}\n", detect_warnings.join("\n> ")) + }; format!( - "Project **{name}** is ready.\n\ + "{warning_block}Project **{name}** is ready.\n\ - Host path: `{host}`\n\ - Container: `{container_name}` → `{container_url}`\n\ + {stack_note}\ \n\ - Use `switch {name}` then `status` to view the pipeline.\n\ - *Stack tooling (Rust, Node, etc.) comes in Phase 2 — \ - pass `--stack ` now for forward-compat.*", + Use `switch {name}` then `status` to view the pipeline.", host = host_path.display() ) } @@ -229,65 +359,172 @@ mod tests { #[test] fn extract_parses_name() { - assert_eq!( - extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local"), - Some("myapp".to_string()) - ); + let cmd = + extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local") + .unwrap(); + assert_eq!(cmd.name, "myapp"); + assert_eq!(cmd.stack, None); } #[test] fn extract_case_insensitive() { - assert_eq!( - extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local"), - Some("myapp".to_string()) - ); + let cmd = + extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local") + .unwrap(); + assert_eq!(cmd.name, "myapp"); } #[test] fn extract_bare_project_keyword_returns_empty_name() { - assert_eq!( - extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local"), - Some("".to_string()) - ); + let cmd = + extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local").unwrap(); + assert_eq!(cmd.name, ""); } #[test] - fn extract_ignores_flags_after_name() { - assert_eq!( - extract_new_project_command( - "@timmy new project myapp --stack rust", - "Timmy", - "@timmy:srv.local" - ), - Some("myapp".to_string()) - ); + fn extract_parses_stack_flag() { + let cmd = extract_new_project_command( + "@timmy new project myapp --stack rust", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.name, "myapp"); + assert_eq!(cmd.stack, Some("rust".to_string())); + } + + #[test] + fn extract_stack_node() { + let cmd = extract_new_project_command( + "@timmy new project myapp --stack node", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.stack, Some("node".to_string())); } #[test] fn extract_no_match_for_other_commands() { - assert_eq!( - extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local"), - None + assert!( + extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local").is_none() ); } #[test] fn extract_no_match_when_second_word_is_not_project() { - assert_eq!( - extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local"), - None + assert!( + extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local").is_none() ); } #[test] fn extract_handles_extra_whitespace() { - assert_eq!( - extract_new_project_command( - "@timmy new project myapp", - "Timmy", - "@timmy:srv.local" - ), - Some("myapp".to_string()) - ); + 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()); } }