From c77e93cf9e9d7f363006dc092dc3f6807e38ff9a Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Mon, 9 Mar 2026 14:52:25 -0700 Subject: [PATCH 1/6] plan: app-named slugs and ls current-directory indicator Add implementation plan for changing URL format from {slug}.{domain} to {slug}.{app-name}.{domain} where app-name is derived from the git remote origin repo name, and for adding a * indicator in devproxy ls for the current directory's project. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-09-app-named-slugs.md | 665 +++++++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 docs/plans/2026-03-09-app-named-slugs.md diff --git a/docs/plans/2026-03-09-app-named-slugs.md b/docs/plans/2026-03-09-app-named-slugs.md new file mode 100644 index 0000000..3872213 --- /dev/null +++ b/docs/plans/2026-03-09-app-named-slugs.md @@ -0,0 +1,665 @@ +# App-Named Slugs — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. + +**Goal:** Make devproxy URLs include the app/repo name so users can identify which project a URL belongs to. Change the URL format from `https://{slug}.{domain}` to `https://{slug}.{app-name}.{domain}` where `{app-name}` is derived from the git remote origin (GitHub repo name), falling back to the directory name. Also enhance `devproxy ls` to mark the current directory's project with `*`. + +**Architecture:** + +The URL format changes from `https://swift-penguin.mysite.dev` to `https://swift-penguin.devproxy.mysite.dev` where `devproxy` is the app name (derived from the repo name of the cwd where `devproxy up` was run). Multiple instances of the same repo (e.g., worktrees) get different random slugs but share the same app-name subdomain level: `https://bold-fox.devproxy.mysite.dev`, `https://calm-otter.devproxy.mysite.dev`. + +**Key mechanism:** The `devproxy up` command detects the app name and passes it to Docker Compose as a label (`devproxy.app`) in the override file. The daemon's docker.rs reads this label when inspecting containers, and constructs the hostname as `{slug}.{app-name}.{domain}` instead of `{slug}.{domain}`. The `com.docker.compose.project` label remains the Docker Compose project name (used as the random slug for `--project-name`). + +**App name detection:** A new function `detect_app_name(dir: &Path) -> Result` in `config.rs`: +1. Run `git -C {dir} remote get-url origin` and parse the repo name from the URL (handles both HTTPS and SSH formats). Strip `.git` suffix if present. +2. If git fails or there's no remote, fall back to the directory name. +3. Sanitize the result: lowercase, replace non-alphanumeric chars with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. + +**DNS note:** The wildcard DNS setup (dnsmasq) already resolves `*.mysite.dev` so `slug.app-name.mysite.dev` works without any DNS changes. The TLS wildcard cert is `*.mysite.dev` which does NOT match `a.b.mysite.dev` (wildcards only match one level). The cert generation in `cert.rs` must be updated to also include `*.*.mysite.dev` as a SAN to cover the two-level subdomain. + +**`.devproxy-project` format change:** Currently stores just the slug (`swift-penguin`). Change to store `slug\napp-name` (two lines). The `read_project_file` function returns both values. For backward compatibility during transition, if only one line exists, treat the slug as the full compose project name with no app name (legacy format). + +**`devproxy ls` current-directory indicator:** The `ls` command reads `.devproxy-project` from the cwd (if it exists, failing silently if not) to get the current project's slug. When printing routes, any route matching the cwd's slug gets a `*` prefix. + +**Tech Stack:** No new dependencies. Uses `std::process::Command` to run `git`. + +--- + +### Task 1: Add `detect_app_name()` to `config.rs` + +**Files:** +- Modify: `src/config.rs` + +**Step 1: Write the failing tests** + +Add unit tests for app name detection: + +```rust +#[cfg(test)] +mod tests { + // ... existing tests ... + + #[test] + fn detect_app_name_from_git_remote_https() { + let dir = tempfile::tempdir().unwrap(); + // Initialize a git repo with an HTTPS remote + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "https://github.com/user/my-cool-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "my-cool-app"); + } + + #[test] + fn detect_app_name_from_git_remote_ssh() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "git@github.com:user/another-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "another-app"); + } + + #[test] + fn detect_app_name_falls_back_to_dir_name() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("my-project"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-project"); + } + + #[test] + fn detect_app_name_sanitizes() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("My Cool App!!!"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-cool-app"); + } +} +``` + +**Step 2: Implement** + +```rust +/// Detect the app name for the given directory. +/// Tries git remote origin first (extracts repo name from URL), +/// falls back to directory name. Result is sanitized for use as a subdomain. +pub fn detect_app_name(dir: &Path) -> Result { + // Try git remote + if let Ok(output) = std::process::Command::new("git") + .args(["-C", &dir.to_string_lossy(), "remote", "get-url", "origin"]) + .output() + { + if output.status.success() { + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if let Some(name) = extract_repo_name(&url) { + return Ok(sanitize_subdomain(&name)); + } + } + } + + // Fall back to directory name + let dir_name = dir + .file_name() + .context("directory has no name")? + .to_string_lossy() + .to_string(); + Ok(sanitize_subdomain(&dir_name)) +} + +/// Extract the repository name from a git remote URL. +/// Handles HTTPS (https://github.com/user/repo.git) and SSH (git@github.com:user/repo.git). +fn extract_repo_name(url: &str) -> Option { + let path = if url.contains("://") { + // HTTPS: https://github.com/user/repo.git + url.split('/').last()? + } else if url.contains(':') { + // SSH: git@github.com:user/repo.git + url.split('/').last()? + } else { + return None; + }; + let name = path.strip_suffix(".git").unwrap_or(path); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +/// Sanitize a string for use as a DNS subdomain label: +/// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, +/// trim leading/trailing hyphens, truncate to 63 chars. +fn sanitize_subdomain(s: &str) -> String { + let lower = s.to_lowercase(); + let replaced: String = lower + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let collapsed = replaced + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + let truncated = if collapsed.len() > 63 { + &collapsed[..63] + } else { + &collapsed + }; + truncated.trim_end_matches('-').to_string() +} +``` + +**Step 3: Verify** + +Run `cargo test -p devproxy config::tests::detect_app_name` — all 4 tests should pass. + +--- + +### Task 2: Update `.devproxy-project` file format to include app name + +**Files:** +- Modify: `src/config.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn project_file_roundtrip_with_app_name() { + let dir = tempfile::tempdir().unwrap(); + write_project_file(dir.path(), "swift-penguin", Some("devproxy")).unwrap(); + let (slug, app_name) = read_project_file(dir.path()).unwrap(); + assert_eq!(slug, "swift-penguin"); + assert_eq!(app_name, Some("devproxy".to_string())); +} + +#[test] +fn project_file_backward_compat_no_app_name() { + let dir = tempfile::tempdir().unwrap(); + // Simulate legacy format: just the slug, no app name + std::fs::write(dir.path().join(".devproxy-project"), "swift-penguin\n").unwrap(); + let (slug, app_name) = read_project_file(dir.path()).unwrap(); + assert_eq!(slug, "swift-penguin"); + assert_eq!(app_name, None); +} +``` + +**Step 2: Implement** + +Update `write_project_file` signature to accept optional app name, write two lines when present. Update `read_project_file` to return `(String, Option)`. Update all callers (`up.rs`, `down.rs`, `open.rs`). + +```rust +pub fn write_project_file(dir: &Path, slug: &str, app_name: Option<&str>) -> Result { + let path = dir.join(".devproxy-project"); + let content = match app_name { + Some(name) => format!("{slug}\n{name}\n"), + None => format!("{slug}\n"), + }; + std::fs::write(&path, content)?; + Ok(path) +} + +pub fn read_project_file(dir: &Path) -> Result<(String, Option)> { + let path = dir.join(".devproxy-project"); + let content = std::fs::read_to_string(&path).with_context(|| { + format!( + "no .devproxy-project file found in {}. Is this project running via `devproxy up`?", + dir.display() + ) + })?; + let mut lines = content.lines(); + let slug = lines.next().context("empty .devproxy-project file")?.trim().to_string(); + let app_name = lines.next().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + Ok((slug, app_name)) +} +``` + +**Step 3: Verify** + +Run `cargo test -p devproxy config::tests::project_file` — both roundtrip tests pass. + +--- + +### Task 3: Update override file to include `devproxy.app` label + +**Files:** +- Modify: `src/config.rs` (`write_override_file`) + +**Step 1: Write the failing test** + +```rust +#[test] +fn override_file_includes_app_label() { + let dir = tempfile::tempdir().unwrap(); + let path = write_override_file(dir.path(), "web", 51234, 3000, Some("devproxy")).unwrap(); + let content = std::fs::read_to_string(path).unwrap(); + assert!(content.contains("devproxy.app: devproxy"), "override should include app label: {content}"); +} + +#[test] +fn override_file_without_app_label() { + let dir = tempfile::tempdir().unwrap(); + let path = write_override_file(dir.path(), "web", 51234, 3000, None).unwrap(); + let content = std::fs::read_to_string(path).unwrap(); + assert!(!content.contains("devproxy.app"), "override should not include app label when None: {content}"); +} +``` + +**Step 2: Implement** + +Add `app_name: Option<&str>` parameter to `write_override_file`. When present, add a `labels` section to the service in the override YAML: + +```rust +pub fn write_override_file( + dir: &Path, + service_name: &str, + host_port: u16, + container_port: u16, + app_name: Option<&str>, +) -> Result { + // ... existing validation ... + let labels_section = match app_name { + Some(name) => format!(" labels:\n devproxy.app: {name}\n"), + None => String::new(), + }; + let path = dir.join(".devproxy-override.yml"); + let content = format!( + "services:\n {service_name}:\n ports:\n - \"127.0.0.1:{host_port}:{container_port}\"\n{labels_section}" + ); + std::fs::write(&path, &content)?; + Ok(path) +} +``` + +Validate `app_name` the same way as `service_name` (only alphanumeric, hyphens, underscores) to prevent YAML injection. + +**Step 3: Verify** + +Run `cargo test -p devproxy config::tests::override_file` — both tests pass. + +--- + +### Task 4: Update `up.rs` to detect app name and use new signatures + +**Files:** +- Modify: `src/commands/up.rs` + +**Step 1: No new tests needed** — this is wiring. The unit tests for detect_app_name and the e2e test cover this. + +**Step 2: Implement** + +```rust +pub fn run() -> Result<()> { + let config = Config::load().context("run `devproxy init` first")?; + let cwd = std::env::current_dir()?; + let compose_path = config::find_compose_file(&cwd)?; + let compose_dir = compose_path.parent().context("compose file has no parent directory")?; + // ... existing output ... + + let compose = config::parse_compose_file(&compose_path)?; + let (service_name, container_port) = config::find_devproxy_service(&compose)?; + + // Detect app name from git remote or directory name + let app_name = config::detect_app_name(&cwd)?; + eprintln!("app: {}", app_name.cyan()); + + let slug = slugs::generate_slug(); + eprintln!("slug: {}", slug.cyan()); + + let host_port = config::find_free_port()?; + eprintln!("host port: {}", host_port.to_string().cyan()); + + // Pass app_name to override file (adds devproxy.app label) + let override_path = + config::write_override_file(compose_dir, &service_name, host_port, container_port, Some(&app_name))?; + eprintln!("override: {}", override_path.display().to_string().cyan()); + + // Write project file with app name + config::write_project_file(compose_dir, &slug, Some(&app_name))?; + + // ... rest of daemon check, docker compose up, etc. ... + + let url = format!("https://{slug}.{app_name}.{}", config.domain); + eprintln!(); + eprintln!("{} {}", "->".green().bold(), url.green().bold()); + + Ok(()) +} +``` + +**Step 3: Verify** + +`cargo build` succeeds. Manual test or e2e test confirms the URL format. + +--- + +### Task 5: Update `docker.rs` to read `devproxy.app` label and construct app-named hostnames + +**Files:** +- Modify: `src/proxy/docker.rs` + +**Step 1: Write failing test (none practical here)** — docker.rs functions are async and require Docker. Tested via e2e. + +**Step 2: Implement** + +In `inspect_container`, read `devproxy.app` label in addition to `com.docker.compose.project`. Construct the slug for `router.insert` as `{project_name}.{app_name}` when the app label is present, or just `{project_name}` for backward compatibility: + +```rust +async fn inspect_container(container_id: &str) -> Result> { + // ... existing code ... + + let slug = match inspect.config.labels.get("com.docker.compose.project") { + Some(s) => s.clone(), + None => return Ok(None), + }; + + // Read app name label if present + let app_name = inspect.config.labels.get("devproxy.app").cloned(); + + // Construct the router key: {slug}.{app_name} if app_name present, else just {slug} + let router_key = match app_name { + Some(ref name) => format!("{slug}.{name}"), + None => slug, + }; + + // ... find host_port ... + + match host_port { + Some(port) => Ok(Some((router_key, port))), + None => Ok(None), + } +} +``` + +Similarly, in `watch_events_inner`, when handling `die`/`stop`/`kill` events, read the `devproxy.app` attribute from the event to construct the correct key for `router.remove`: + +```rust +"die" | "stop" | "kill" => { + let attrs = event + .get("Actor").or_else(|| event.get("actor")) + .and_then(|a| a.get("Attributes").or_else(|| a.get("attributes"))); + + let slug = attrs.and_then(|a| a.get("com.docker.compose.project").and_then(|v| v.as_str())); + let app_name = attrs.and_then(|a| a.get("devproxy.app").and_then(|v| v.as_str())); + + if let Some(slug) = slug { + let router_key = match app_name { + Some(name) => format!("{slug}.{name}"), + None => slug.to_string(), + }; + eprintln!(" route removed: {router_key}"); + router.remove(&router_key); + } +} +``` + +**Step 3: Verify** + +`cargo build` succeeds. E2e test will verify full flow. + +--- + +### Task 6: Update `down.rs` and `open.rs` to use new project file format + +**Files:** +- Modify: `src/commands/down.rs` +- Modify: `src/commands/open.rs` + +**Step 1: No new tests** — covered by existing callers and e2e. + +**Step 2: Implement** + +`down.rs`: Update to destructure `(slug, _app_name)` from `read_project_file`. The slug is still the compose project name, so no behavior change here. + +`open.rs`: Update to construct `{slug}.{app_name}.{domain}` when app_name is present: + +```rust +pub async fn run() -> Result<()> { + let cwd = std::env::current_dir()?; + let (slug, app_name) = config::read_project_file(&cwd)?; + let config = Config::load()?; + let full_host = match app_name { + Some(ref name) => format!("{slug}.{name}.{}", config.domain), + None => format!("{slug}.{}", config.domain), + }; + // ... rest unchanged, use full_host for lookup and URL ... +} +``` + +**Step 3: Verify** + +`cargo build` succeeds. + +--- + +### Task 7: Update `ls.rs` to show `*` indicator for current directory's project + +**Files:** +- Modify: `src/commands/ls.rs` + +**Step 1: Write the failing test** + +Add a unit test for the formatting logic: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::ipc::RouteInfo; + + #[test] + fn format_route_with_current_marker() { + let route = RouteInfo { + slug: "swift-penguin.devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, Some("swift-penguin.devproxy.mysite.dev")); + assert!(line.contains("*"), "current project should have * marker"); + } + + #[test] + fn format_route_without_current_marker() { + let route = RouteInfo { + slug: "bold-fox.devproxy.mysite.dev".to_string(), + port: 51235, + }; + let line = format_route_line(&route, Some("swift-penguin.devproxy.mysite.dev")); + assert!(!line.contains("*"), "non-current project should not have * marker"); + } + + #[test] + fn format_route_no_current_project() { + let route = RouteInfo { + slug: "swift-penguin.devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, None); + assert!(!line.contains("*"), "no current project means no marker"); + } +} +``` + +**Step 2: Implement** + +```rust +use crate::config::{self, Config}; +use crate::ipc::{self, Request, Response, RouteInfo}; +use anyhow::{Result, bail}; +use colored::Colorize; + +/// Format a single route line, with optional `*` marker for current project. +fn format_route_line(route: &RouteInfo, current_slug: Option<&str>) -> String { + let marker = match current_slug { + Some(s) if s == route.slug => "* ", + _ => " ", + }; + format!( + "{}{:<30} {:<10}", + marker, + format!("https://{}", route.slug), + route.port + ) +} + +pub async fn run() -> Result<()> { + let socket_path = Config::socket_path()?; + let response = ipc::send_request(&socket_path, &Request::List).await?; + + // Try to read current project slug (silently ignore failures) + let current_slug = std::env::current_dir() + .ok() + .and_then(|cwd| config::read_project_file(&cwd).ok()) + .and_then(|(slug, app_name)| { + let config = Config::load().ok()?; + let full = match app_name { + Some(name) => format!("{slug}.{name}.{}", config.domain), + None => format!("{slug}.{}", config.domain), + }; + Some(full) + }); + + match response { + Response::Routes { routes } => { + if routes.is_empty() { + println!("no active projects"); + } else { + println!(" {:<30} {:<10}", "SLUG".bold(), "PORT".bold()); + for route in &routes { + println!("{}", format_route_line(route, current_slug.as_deref())); + } + println!(); + println!("{} active project(s)", routes.len()); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + _ => bail!("unexpected response from daemon"), + } + + Ok(()) +} +``` + +**Step 3: Verify** + +Run `cargo test -p devproxy commands::ls::tests` — all 3 tests pass. + +--- + +### Task 8: Update TLS cert to include `*.*.` SAN + +**Files:** +- Modify: `src/proxy/cert.rs` + +**Step 1: Write the failing test** + +```rust +#[test] +fn wildcard_cert_covers_two_level_subdomain() { + // The generated cert should have *.*.domain as a SAN + let dir = tempfile::tempdir().unwrap(); + let domain = "test.dev"; + generate_ca_and_cert(dir.path(), domain).unwrap(); + let cert_pem = std::fs::read_to_string(dir.path().join("tls-cert.pem")).unwrap(); + let cert = rustls_pemfile::certs(&mut cert_pem.as_bytes()) + .next() + .unwrap() + .unwrap(); + let parsed = x509_parser::parse_x509_certificate(&cert).unwrap().1; + let sans: Vec = parsed + .subject_alternative_name() + .unwrap() + .unwrap() + .value + .general_names + .iter() + .filter_map(|n| match n { + x509_parser::extensions::GeneralName::DNSName(s) => Some(s.to_string()), + _ => None, + }) + .collect(); + assert!(sans.contains(&format!("*.*.{domain}")), "cert should have *.*.domain SAN: {sans:?}"); +} +``` + +Note: This test may require adding `x509-parser` as a dev dependency, or alternatively inspect the cert PEM text. A simpler approach: just verify the cert generation code adds the SAN and test the full flow via e2e curl. + +**Step 2: Implement** + +In `cert.rs`, where the wildcard cert SANs are set, add `*.*.{domain}` alongside the existing `*.{domain}`: + +```rust +// In the cert generation function, where SANs are added: +let subject_alt_names = vec![ + format!("*.{domain}"), + format!("*.*.{domain}"), + domain.to_string(), +]; +``` + +**Step 3: Verify** + +`cargo build` succeeds. The e2e test will verify TLS works with the two-level subdomain. + +--- + +### Task 9: Update e2e test and add new unit tests + +**Files:** +- Modify: `tests/e2e.rs` + +**Step 1: Update existing e2e** + +The `test_full_e2e_workflow` test extracts the slug from `up` output. Update the slug extraction to handle the new URL format `https://{slug}.{app-name}.{domain}`: + +```rust +// In test_full_e2e_workflow, update slug extraction: +// Old: splits on first dot to get slug +// New: URL is https://slug.app-name.test.devproxy.dev +// The slug is the first subdomain component +let slug = up_stderr + .lines() + .find(|l| l.contains(&format!(".{TEST_DOMAIN}"))) + .and_then(|l| l.split("https://").nth(1).and_then(|s| s.split('.').next())) + .expect("should find slug in up output"); +``` + +The slug extraction already takes the first dot-separated component, so it should work. But the `--project-name` is still the slug (e.g., `swift-penguin`), and the route in the daemon now uses `swift-penguin.{app-name}.test.devproxy.dev`. Update the ls assertion and curl resolve accordingly. + +Also update the `ls` output check since routes now have the app name component, and verify the `*` indicator appears when running `ls` from the fixture directory. + +**Step 2: Verify** + +Run `cargo test --test e2e` (non-ignored tests) to verify compilation. Full e2e: `cargo test --test e2e -- --ignored test_full_e2e_workflow`. + +--- + +### Task 10: Update `init.rs` DNS instructions + +**Files:** +- Modify: `src/commands/init.rs` + +**Step 1: No test needed** — output text change only. + +**Step 2: Implement** + +The DNS instructions should note that wildcard DNS covers all subdomain levels, so no additional setup is needed for the new URL format. No change may be needed if the instructions already say `*.{domain}`. Verify and update if the instructions are specific to single-level subdomains. + +**Step 3: Verify** + +Run `cargo test --test e2e test_init_output` — existing init output tests should still pass. From 41d6c5716b46ccd5794c3e8dbfd13923ad5d895b Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Mon, 9 Mar 2026 14:58:41 -0700 Subject: [PATCH 2/6] plan: revise to single-subdomain URL format for TLS compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change URL format from {slug}.{appname}.{domain} (two subdomain levels) to {slug}-{appname}.{domain} (single level) because RFC 6125 wildcard certs only match one DNS label. This eliminates the need for cert changes entirely and simplifies the daemon side — the composite slug is used as the Docker Compose project name, so the existing com.docker.compose.project label already carries the full routing key. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-09-app-named-slugs.md | 513 ++++++----------------- 1 file changed, 136 insertions(+), 377 deletions(-) diff --git a/docs/plans/2026-03-09-app-named-slugs.md b/docs/plans/2026-03-09-app-named-slugs.md index 3872213..9d7811a 100644 --- a/docs/plans/2026-03-09-app-named-slugs.md +++ b/docs/plans/2026-03-09-app-named-slugs.md @@ -2,37 +2,41 @@ > **For Claude:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. -**Goal:** Make devproxy URLs include the app/repo name so users can identify which project a URL belongs to. Change the URL format from `https://{slug}.{domain}` to `https://{slug}.{app-name}.{domain}` where `{app-name}` is derived from the git remote origin (GitHub repo name), falling back to the directory name. Also enhance `devproxy ls` to mark the current directory's project with `*`. +**Goal:** Make devproxy URLs include the app/repo name so users can identify which project a URL belongs to. Change the URL format from `https://{slug}.{domain}` to `https://{slug}-{app-name}.{domain}` where `{app-name}` is derived from the git remote origin (GitHub repo name), falling back to the directory name. Also enhance `devproxy ls` to mark the current directory's project with `*`. **Architecture:** -The URL format changes from `https://swift-penguin.mysite.dev` to `https://swift-penguin.devproxy.mysite.dev` where `devproxy` is the app name (derived from the repo name of the cwd where `devproxy up` was run). Multiple instances of the same repo (e.g., worktrees) get different random slugs but share the same app-name subdomain level: `https://bold-fox.devproxy.mysite.dev`, `https://calm-otter.devproxy.mysite.dev`. +The URL format changes from `https://swift-penguin.mysite.dev` to `https://swift-penguin-devproxy.mysite.dev` where `devproxy` is the app name (derived from the repo name of the cwd where `devproxy up` was run). Multiple instances of the same repo (e.g., worktrees) get different random slugs but share the same app-name suffix: `https://bold-fox-devproxy.mysite.dev`, `https://calm-otter-devproxy.mysite.dev`. -**Key mechanism:** The `devproxy up` command detects the app name and passes it to Docker Compose as a label (`devproxy.app`) in the override file. The daemon's docker.rs reads this label when inspecting containers, and constructs the hostname as `{slug}.{app-name}.{domain}` instead of `{slug}.{domain}`. The `com.docker.compose.project` label remains the Docker Compose project name (used as the random slug for `--project-name`). +**Why a single subdomain level:** RFC 6125 Section 6.4.3 specifies that wildcard certificates (`*.domain`) only match a single DNS label. The existing TLS cert uses `*.mysite.dev` which matches `anything.mysite.dev` but would NOT match `a.b.mysite.dev`. By keeping the format as `{slug}-{appname}.{domain}` (a single subdomain label), the existing wildcard cert works without modification. No cert changes are needed. + +**Key mechanism:** The `devproxy up` command detects the app name and combines it with the random slug to form the Docker Compose project name: `{slug}-{app-name}` (e.g., `swift-penguin-devproxy`). This composite name is used as the `--project-name` for `docker compose`, which means the `com.docker.compose.project` label on the container already contains the full routing key. The daemon's docker.rs reads this label and inserts it into the router as before — no daemon-side changes needed. + +This is the simplest possible approach: the app name is baked into the compose project name, which Docker propagates as a label, which the daemon already reads for routing. No new labels, no docker.rs changes, no cert changes. **App name detection:** A new function `detect_app_name(dir: &Path) -> Result` in `config.rs`: 1. Run `git -C {dir} remote get-url origin` and parse the repo name from the URL (handles both HTTPS and SSH formats). Strip `.git` suffix if present. 2. If git fails or there's no remote, fall back to the directory name. -3. Sanitize the result: lowercase, replace non-alphanumeric chars with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. +3. Sanitize the result: lowercase, replace non-alphanumeric chars with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. Truncate so the total `{slug}-{appname}` stays within the 63-char DNS label limit. -**DNS note:** The wildcard DNS setup (dnsmasq) already resolves `*.mysite.dev` so `slug.app-name.mysite.dev` works without any DNS changes. The TLS wildcard cert is `*.mysite.dev` which does NOT match `a.b.mysite.dev` (wildcards only match one level). The cert generation in `cert.rs` must be updated to also include `*.*.mysite.dev` as a SAN to cover the two-level subdomain. +**Compose project name:** Currently `up.rs` generates a random slug (e.g., `swift-penguin`) and uses it as `--project-name`. The new behavior generates the composite `{slug}-{app-name}` (e.g., `swift-penguin-devproxy`) and uses that as `--project-name`. The `.devproxy-project` file stores this composite name. The daemon reads `com.docker.compose.project` which already equals this composite name — no daemon changes needed. -**`.devproxy-project` format change:** Currently stores just the slug (`swift-penguin`). Change to store `slug\napp-name` (two lines). The `read_project_file` function returns both values. For backward compatibility during transition, if only one line exists, treat the slug as the full compose project name with no app name (legacy format). +**`.devproxy-project` file:** No format change needed. The file continues to store the compose project name (now the composite `{slug}-{app-name}`). The `read_project_file` and `write_project_file` signatures remain the same. -**`devproxy ls` current-directory indicator:** The `ls` command reads `.devproxy-project` from the cwd (if it exists, failing silently if not) to get the current project's slug. When printing routes, any route matching the cwd's slug gets a `*` prefix. +**`devproxy ls` current-directory indicator:** The `ls` command reads `.devproxy-project` from the cwd (if it exists, failing silently if not) to get the current project's composite slug. When printing routes, any route matching the cwd's slug gets a `*` prefix. **Tech Stack:** No new dependencies. Uses `std::process::Command` to run `git`. --- -### Task 1: Add `detect_app_name()` to `config.rs` +### Task 1: Add `detect_app_name()` and helpers to `config.rs` **Files:** - Modify: `src/config.rs` **Step 1: Write the failing tests** -Add unit tests for app name detection: +Add unit tests for app name detection, repo name extraction, and subdomain sanitization: ```rust #[cfg(test)] @@ -42,7 +46,6 @@ mod tests { #[test] fn detect_app_name_from_git_remote_https() { let dir = tempfile::tempdir().unwrap(); - // Initialize a git repo with an HTTPS remote std::process::Command::new("git") .args(["init"]) .current_dir(dir.path()) @@ -91,6 +94,47 @@ mod tests { let name = detect_app_name(&sub).unwrap(); assert_eq!(name, "my-cool-app"); } + + #[test] + fn extract_repo_name_https() { + assert_eq!( + extract_repo_name("https://github.com/user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_ssh() { + assert_eq!( + extract_repo_name("git@github.com:user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_no_git_suffix() { + assert_eq!( + extract_repo_name("https://github.com/user/repo"), + Some("repo".to_string()) + ); + } + + #[test] + fn sanitize_subdomain_basic() { + assert_eq!(sanitize_subdomain("My Cool App!!!"), "my-cool-app"); + } + + #[test] + fn sanitize_subdomain_already_clean() { + assert_eq!(sanitize_subdomain("my-app"), "my-app"); + } + + #[test] + fn sanitize_subdomain_truncates_long_names() { + let long_name = "a".repeat(100); + let result = sanitize_subdomain(&long_name); + assert!(result.len() <= 63); + } } ``` @@ -99,7 +143,7 @@ mod tests { ```rust /// Detect the app name for the given directory. /// Tries git remote origin first (extracts repo name from URL), -/// falls back to directory name. Result is sanitized for use as a subdomain. +/// falls back to directory name. Result is sanitized for use in a subdomain label. pub fn detect_app_name(dir: &Path) -> Result { // Try git remote if let Ok(output) = std::process::Command::new("git") @@ -126,16 +170,15 @@ pub fn detect_app_name(dir: &Path) -> Result { /// Extract the repository name from a git remote URL. /// Handles HTTPS (https://github.com/user/repo.git) and SSH (git@github.com:user/repo.git). fn extract_repo_name(url: &str) -> Option { - let path = if url.contains("://") { - // HTTPS: https://github.com/user/repo.git - url.split('/').last()? - } else if url.contains(':') { - // SSH: git@github.com:user/repo.git + // For SSH URLs like git@github.com:user/repo.git, split on ':' first then '/' + let path_part = if url.contains("://") { url.split('/').last()? + } else if let Some(after_colon) = url.split(':').nth(1) { + after_colon.split('/').last()? } else { return None; }; - let name = path.strip_suffix(".git").unwrap_or(path); + let name = path_part.strip_suffix(".git").unwrap_or(path_part); if name.is_empty() { None } else { @@ -143,7 +186,7 @@ fn extract_repo_name(url: &str) -> Option { } } -/// Sanitize a string for use as a DNS subdomain label: +/// Sanitize a string for use as part of a DNS subdomain label: /// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, /// trim leading/trailing hyphens, truncate to 63 chars. fn sanitize_subdomain(s: &str) -> String { @@ -168,294 +211,55 @@ fn sanitize_subdomain(s: &str) -> String { **Step 3: Verify** -Run `cargo test -p devproxy config::tests::detect_app_name` — all 4 tests should pass. +Run `cargo test config::tests` — all new and existing tests should pass. --- -### Task 2: Update `.devproxy-project` file format to include app name - -**Files:** -- Modify: `src/config.rs` - -**Step 1: Write the failing tests** - -```rust -#[test] -fn project_file_roundtrip_with_app_name() { - let dir = tempfile::tempdir().unwrap(); - write_project_file(dir.path(), "swift-penguin", Some("devproxy")).unwrap(); - let (slug, app_name) = read_project_file(dir.path()).unwrap(); - assert_eq!(slug, "swift-penguin"); - assert_eq!(app_name, Some("devproxy".to_string())); -} - -#[test] -fn project_file_backward_compat_no_app_name() { - let dir = tempfile::tempdir().unwrap(); - // Simulate legacy format: just the slug, no app name - std::fs::write(dir.path().join(".devproxy-project"), "swift-penguin\n").unwrap(); - let (slug, app_name) = read_project_file(dir.path()).unwrap(); - assert_eq!(slug, "swift-penguin"); - assert_eq!(app_name, None); -} -``` - -**Step 2: Implement** - -Update `write_project_file` signature to accept optional app name, write two lines when present. Update `read_project_file` to return `(String, Option)`. Update all callers (`up.rs`, `down.rs`, `open.rs`). - -```rust -pub fn write_project_file(dir: &Path, slug: &str, app_name: Option<&str>) -> Result { - let path = dir.join(".devproxy-project"); - let content = match app_name { - Some(name) => format!("{slug}\n{name}\n"), - None => format!("{slug}\n"), - }; - std::fs::write(&path, content)?; - Ok(path) -} - -pub fn read_project_file(dir: &Path) -> Result<(String, Option)> { - let path = dir.join(".devproxy-project"); - let content = std::fs::read_to_string(&path).with_context(|| { - format!( - "no .devproxy-project file found in {}. Is this project running via `devproxy up`?", - dir.display() - ) - })?; - let mut lines = content.lines(); - let slug = lines.next().context("empty .devproxy-project file")?.trim().to_string(); - let app_name = lines.next().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); - Ok((slug, app_name)) -} -``` - -**Step 3: Verify** - -Run `cargo test -p devproxy config::tests::project_file` — both roundtrip tests pass. - ---- - -### Task 3: Update override file to include `devproxy.app` label - -**Files:** -- Modify: `src/config.rs` (`write_override_file`) - -**Step 1: Write the failing test** - -```rust -#[test] -fn override_file_includes_app_label() { - let dir = tempfile::tempdir().unwrap(); - let path = write_override_file(dir.path(), "web", 51234, 3000, Some("devproxy")).unwrap(); - let content = std::fs::read_to_string(path).unwrap(); - assert!(content.contains("devproxy.app: devproxy"), "override should include app label: {content}"); -} - -#[test] -fn override_file_without_app_label() { - let dir = tempfile::tempdir().unwrap(); - let path = write_override_file(dir.path(), "web", 51234, 3000, None).unwrap(); - let content = std::fs::read_to_string(path).unwrap(); - assert!(!content.contains("devproxy.app"), "override should not include app label when None: {content}"); -} -``` - -**Step 2: Implement** - -Add `app_name: Option<&str>` parameter to `write_override_file`. When present, add a `labels` section to the service in the override YAML: - -```rust -pub fn write_override_file( - dir: &Path, - service_name: &str, - host_port: u16, - container_port: u16, - app_name: Option<&str>, -) -> Result { - // ... existing validation ... - let labels_section = match app_name { - Some(name) => format!(" labels:\n devproxy.app: {name}\n"), - None => String::new(), - }; - let path = dir.join(".devproxy-override.yml"); - let content = format!( - "services:\n {service_name}:\n ports:\n - \"127.0.0.1:{host_port}:{container_port}\"\n{labels_section}" - ); - std::fs::write(&path, &content)?; - Ok(path) -} -``` - -Validate `app_name` the same way as `service_name` (only alphanumeric, hyphens, underscores) to prevent YAML injection. - -**Step 3: Verify** - -Run `cargo test -p devproxy config::tests::override_file` — both tests pass. - ---- - -### Task 4: Update `up.rs` to detect app name and use new signatures +### Task 2: Update `up.rs` to detect app name and form composite slug **Files:** - Modify: `src/commands/up.rs` -**Step 1: No new tests needed** — this is wiring. The unit tests for detect_app_name and the e2e test cover this. +**Step 1: No new unit tests needed** — this is wiring. The unit tests for `detect_app_name` and the e2e test cover the behavior. **Step 2: Implement** +Change slug generation from: ```rust -pub fn run() -> Result<()> { - let config = Config::load().context("run `devproxy init` first")?; - let cwd = std::env::current_dir()?; - let compose_path = config::find_compose_file(&cwd)?; - let compose_dir = compose_path.parent().context("compose file has no parent directory")?; - // ... existing output ... - - let compose = config::parse_compose_file(&compose_path)?; - let (service_name, container_port) = config::find_devproxy_service(&compose)?; - - // Detect app name from git remote or directory name - let app_name = config::detect_app_name(&cwd)?; - eprintln!("app: {}", app_name.cyan()); - - let slug = slugs::generate_slug(); - eprintln!("slug: {}", slug.cyan()); - - let host_port = config::find_free_port()?; - eprintln!("host port: {}", host_port.to_string().cyan()); - - // Pass app_name to override file (adds devproxy.app label) - let override_path = - config::write_override_file(compose_dir, &service_name, host_port, container_port, Some(&app_name))?; - eprintln!("override: {}", override_path.display().to_string().cyan()); - - // Write project file with app name - config::write_project_file(compose_dir, &slug, Some(&app_name))?; - - // ... rest of daemon check, docker compose up, etc. ... - - let url = format!("https://{slug}.{app_name}.{}", config.domain); - eprintln!(); - eprintln!("{} {}", "->".green().bold(), url.green().bold()); - - Ok(()) -} +let slug = slugs::generate_slug(); ``` - -**Step 3: Verify** - -`cargo build` succeeds. Manual test or e2e test confirms the URL format. - ---- - -### Task 5: Update `docker.rs` to read `devproxy.app` label and construct app-named hostnames - -**Files:** -- Modify: `src/proxy/docker.rs` - -**Step 1: Write failing test (none practical here)** — docker.rs functions are async and require Docker. Tested via e2e. - -**Step 2: Implement** - -In `inspect_container`, read `devproxy.app` label in addition to `com.docker.compose.project`. Construct the slug for `router.insert` as `{project_name}.{app_name}` when the app label is present, or just `{project_name}` for backward compatibility: - +to: ```rust -async fn inspect_container(container_id: &str) -> Result> { - // ... existing code ... - - let slug = match inspect.config.labels.get("com.docker.compose.project") { - Some(s) => s.clone(), - None => return Ok(None), - }; - - // Read app name label if present - let app_name = inspect.config.labels.get("devproxy.app").cloned(); +let app_name = config::detect_app_name(&cwd)?; +eprintln!("app: {}", app_name.cyan()); - // Construct the router key: {slug}.{app_name} if app_name present, else just {slug} - let router_key = match app_name { - Some(ref name) => format!("{slug}.{name}"), - None => slug, - }; - - // ... find host_port ... - - match host_port { - Some(port) => Ok(Some((router_key, port))), - None => Ok(None), - } -} +let random_slug = slugs::generate_slug(); +let slug = format!("{random_slug}-{app_name}"); +eprintln!("slug: {}", slug.cyan()); ``` -Similarly, in `watch_events_inner`, when handling `die`/`stop`/`kill` events, read the `devproxy.app` attribute from the event to construct the correct key for `router.remove`: - +Everything downstream already uses `slug` as the compose project name and the value written to `.devproxy-project`. The URL output line changes from: ```rust -"die" | "stop" | "kill" => { - let attrs = event - .get("Actor").or_else(|| event.get("actor")) - .and_then(|a| a.get("Attributes").or_else(|| a.get("attributes"))); - - let slug = attrs.and_then(|a| a.get("com.docker.compose.project").and_then(|v| v.as_str())); - let app_name = attrs.and_then(|a| a.get("devproxy.app").and_then(|v| v.as_str())); - - if let Some(slug) = slug { - let router_key = match app_name { - Some(name) => format!("{slug}.{name}"), - None => slug.to_string(), - }; - eprintln!(" route removed: {router_key}"); - router.remove(&router_key); - } -} +let url = format!("https://{slug}.{}", config.domain); ``` +This remains correct since `slug` is now the composite `swift-penguin-devproxy`. -**Step 3: Verify** - -`cargo build` succeeds. E2e test will verify full flow. - ---- - -### Task 6: Update `down.rs` and `open.rs` to use new project file format - -**Files:** -- Modify: `src/commands/down.rs` -- Modify: `src/commands/open.rs` - -**Step 1: No new tests** — covered by existing callers and e2e. - -**Step 2: Implement** - -`down.rs`: Update to destructure `(slug, _app_name)` from `read_project_file`. The slug is still the compose project name, so no behavior change here. - -`open.rs`: Update to construct `{slug}.{app_name}.{domain}` when app_name is present: - -```rust -pub async fn run() -> Result<()> { - let cwd = std::env::current_dir()?; - let (slug, app_name) = config::read_project_file(&cwd)?; - let config = Config::load()?; - let full_host = match app_name { - Some(ref name) => format!("{slug}.{name}.{}", config.domain), - None => format!("{slug}.{}", config.domain), - }; - // ... rest unchanged, use full_host for lookup and URL ... -} -``` +No signature changes to `write_project_file`, `write_override_file`, or any other function. **Step 3: Verify** -`cargo build` succeeds. +`cargo build` succeeds. The `test_up_without_label` and `test_up_without_compose_file` e2e tests still pass (they fail before reaching slug generation). --- -### Task 7: Update `ls.rs` to show `*` indicator for current directory's project +### Task 3: Update `ls.rs` to show `*` indicator for current directory's project **Files:** - Modify: `src/commands/ls.rs` -**Step 1: Write the failing test** +**Step 1: Write the failing tests** -Add a unit test for the formatting logic: +Add a unit test module for the formatting logic. The `format_route_line` function is extracted to be testable independently of IPC: ```rust #[cfg(test)] @@ -466,37 +270,42 @@ mod tests { #[test] fn format_route_with_current_marker() { let route = RouteInfo { - slug: "swift-penguin.devproxy.mysite.dev".to_string(), + slug: "swift-penguin-devproxy.mysite.dev".to_string(), port: 51234, }; - let line = format_route_line(&route, Some("swift-penguin.devproxy.mysite.dev")); - assert!(line.contains("*"), "current project should have * marker"); + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(line.contains("*"), "current project should have * marker: {line}"); } #[test] fn format_route_without_current_marker() { let route = RouteInfo { - slug: "bold-fox.devproxy.mysite.dev".to_string(), + slug: "bold-fox-devproxy.mysite.dev".to_string(), port: 51235, }; - let line = format_route_line(&route, Some("swift-penguin.devproxy.mysite.dev")); - assert!(!line.contains("*"), "non-current project should not have * marker"); + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(!line.contains("*"), "non-current project should not have * marker: {line}"); } #[test] fn format_route_no_current_project() { let route = RouteInfo { - slug: "swift-penguin.devproxy.mysite.dev".to_string(), + slug: "swift-penguin-devproxy.mysite.dev".to_string(), port: 51234, }; let line = format_route_line(&route, None); - assert!(!line.contains("*"), "no current project means no marker"); + assert!(!line.contains("*"), "no current project means no marker: {line}"); } } ``` **Step 2: Implement** +Refactor `ls.rs` to: +1. Extract a `format_route_line` function that takes a route and optional current slug. +2. In `run()`, attempt to read `.devproxy-project` from the cwd (silently ignore failures) and construct the full hostname to match against. +3. Print each route using `format_route_line`, prepending `*` for the current project or a space for others. + ```rust use crate::config::{self, Config}; use crate::ipc::{self, Request, Response, RouteInfo}; @@ -510,7 +319,7 @@ fn format_route_line(route: &RouteInfo, current_slug: Option<&str>) -> String { _ => " ", }; format!( - "{}{:<30} {:<10}", + "{}{:<40} {:<10}", marker, format!("https://{}", route.slug), route.port @@ -521,17 +330,13 @@ pub async fn run() -> Result<()> { let socket_path = Config::socket_path()?; let response = ipc::send_request(&socket_path, &Request::List).await?; - // Try to read current project slug (silently ignore failures) + // Try to read current project slug from cwd (silently ignore failures) let current_slug = std::env::current_dir() .ok() .and_then(|cwd| config::read_project_file(&cwd).ok()) - .and_then(|(slug, app_name)| { + .and_then(|slug| { let config = Config::load().ok()?; - let full = match app_name { - Some(name) => format!("{slug}.{name}.{}", config.domain), - None => format!("{slug}.{}", config.domain), - }; - Some(full) + Some(format!("{slug}.{}", config.domain)) }); match response { @@ -539,7 +344,7 @@ pub async fn run() -> Result<()> { if routes.is_empty() { println!("no active projects"); } else { - println!(" {:<30} {:<10}", "SLUG".bold(), "PORT".bold()); + println!(" {:<40} {:<10}", "URL".bold(), "PORT".bold()); for route in &routes { println!("{}", format_route_line(route, current_slug.as_deref())); } @@ -555,111 +360,65 @@ pub async fn run() -> Result<()> { } ``` +Note: The column header changes from `SLUG` to `URL` since the displayed value is now the full `https://` URL which includes the app name. The column width increases from 30 to 40 to accommodate longer composite slugs. + **Step 3: Verify** -Run `cargo test -p devproxy commands::ls::tests` — all 3 tests pass. +Run `cargo test ls::tests` — all 3 tests pass. --- -### Task 8: Update TLS cert to include `*.*.` SAN +### Task 4: Update e2e tests **Files:** -- Modify: `src/proxy/cert.rs` +- Modify: `tests/e2e.rs` -**Step 1: Write the failing test** +**Step 1: Update existing e2e tests** -```rust -#[test] -fn wildcard_cert_covers_two_level_subdomain() { - // The generated cert should have *.*.domain as a SAN - let dir = tempfile::tempdir().unwrap(); - let domain = "test.dev"; - generate_ca_and_cert(dir.path(), domain).unwrap(); - let cert_pem = std::fs::read_to_string(dir.path().join("tls-cert.pem")).unwrap(); - let cert = rustls_pemfile::certs(&mut cert_pem.as_bytes()) - .next() - .unwrap() - .unwrap(); - let parsed = x509_parser::parse_x509_certificate(&cert).unwrap().1; - let sans: Vec = parsed - .subject_alternative_name() - .unwrap() - .unwrap() - .value - .general_names - .iter() - .filter_map(|n| match n { - x509_parser::extensions::GeneralName::DNSName(s) => Some(s.to_string()), - _ => None, - }) - .collect(); - assert!(sans.contains(&format!("*.*.{domain}")), "cert should have *.*.domain SAN: {sans:?}"); -} -``` +The `test_full_e2e_workflow` test extracts the slug from `up` output by splitting on the first dot. This still works because the composite slug (`swift-penguin-devproxy`) is the first dot-separated component of `swift-penguin-devproxy.test.devproxy.dev`. -Note: This test may require adding `x509-parser` as a dev dependency, or alternatively inspect the cert PEM text. A simpler approach: just verify the cert generation code adds the SAN and test the full flow via e2e curl. +However, several things need updating: +1. The slug is now composite (e.g., `swift-penguin-devproxy`) — it includes the app name suffix derived from the fixtures directory name. Since `copy_fixtures` copies into a temp dir with a name like `devproxy-fixtures-e2e-{pid}`, the app name will be derived from that directory name (there's no git remote in the fixture copy). Account for this in assertions. +2. The `ls` output now shows `*` for the current directory's project. Add an assertion for this. +3. The `curl` resolve and host must use the composite slug. -**Step 2: Implement** +The e2e test runs `devproxy up` from the fixtures directory. Since the fixtures directory has no git remote, `detect_app_name` falls back to the directory name. The copy is at a path like `/tmp/devproxy-fixtures-e2e-{pid}`, so the sanitized directory name becomes something like `devproxy-fixtures-e2e-{pid}`. The slug in the `up` output will be `{adj}-{animal}-devproxy-fixtures-e2e-{pid}`. -In `cert.rs`, where the wildcard cert SANs are set, add `*.*.{domain}` alongside the existing `*.{domain}`: +To make e2e testing cleaner, we can initialize a git repo with a known remote in the fixture copy. Add this after `copy_fixtures`: ```rust -// In the cert generation function, where SANs are added: -let subject_alt_names = vec![ - format!("*.{domain}"), - format!("*.*.{domain}"), - domain.to_string(), -]; +// Initialize a git repo with a known remote so detect_app_name is predictable +std::process::Command::new("git") + .args(["init"]) + .current_dir(&fixtures) + .output() + .expect("git init failed"); +std::process::Command::new("git") + .args(["remote", "add", "origin", "https://github.com/test/e2e-fixture.git"]) + .current_dir(&fixtures) + .output() + .expect("git remote add failed"); ``` -**Step 3: Verify** +Then the app name will always be `e2e-fixture` and the slug will be like `swift-penguin-e2e-fixture`. -`cargo build` succeeds. The e2e test will verify TLS works with the two-level subdomain. - ---- - -### Task 9: Update e2e test and add new unit tests - -**Files:** -- Modify: `tests/e2e.rs` - -**Step 1: Update existing e2e** - -The `test_full_e2e_workflow` test extracts the slug from `up` output. Update the slug extraction to handle the new URL format `https://{slug}.{app-name}.{domain}`: +Update slug extraction to handle the composite format. The existing extraction (`split('.').next()`) already returns the full first label, which is now `swift-penguin-e2e-fixture`. Update assertions accordingly. +For the `ls` check from the fixture directory, verify that the `*` indicator appears: ```rust -// In test_full_e2e_workflow, update slug extraction: -// Old: splits on first dot to get slug -// New: URL is https://slug.app-name.test.devproxy.dev -// The slug is the first subdomain component -let slug = up_stderr - .lines() - .find(|l| l.contains(&format!(".{TEST_DOMAIN}"))) - .and_then(|l| l.split("https://").nth(1).and_then(|s| s.split('.').next())) - .expect("should find slug in up output"); +let ls_output = Command::new(devproxy_bin()) + .args(["ls"]) + .current_dir(&fixtures) // Run from fixtures dir to get * indicator + .env("DEVPROXY_CONFIG_DIR", &config_dir) + .output() + .expect("failed to run devproxy ls"); +let ls_stdout = String::from_utf8_lossy(&ls_output.stdout); +assert!(ls_stdout.contains(slug), "ls should show our slug '{slug}': {ls_stdout}"); +assert!(ls_stdout.contains("*"), "ls should show * for current project: {ls_stdout}"); ``` -The slug extraction already takes the first dot-separated component, so it should work. But the `--project-name` is still the slug (e.g., `swift-penguin`), and the route in the daemon now uses `swift-penguin.{app-name}.test.devproxy.dev`. Update the ls assertion and curl resolve accordingly. - -Also update the `ls` output check since routes now have the app name component, and verify the `*` indicator appears when running `ls` from the fixture directory. +Also update the `test_self_healing_route_removed_on_container_die` and `test_daemon_restart_rebuilds_routes` tests similarly (add git init + remote to fixture copies, update slug extraction). **Step 2: Verify** -Run `cargo test --test e2e` (non-ignored tests) to verify compilation. Full e2e: `cargo test --test e2e -- --ignored test_full_e2e_workflow`. - ---- - -### Task 10: Update `init.rs` DNS instructions - -**Files:** -- Modify: `src/commands/init.rs` - -**Step 1: No test needed** — output text change only. - -**Step 2: Implement** - -The DNS instructions should note that wildcard DNS covers all subdomain levels, so no additional setup is needed for the new URL format. No change may be needed if the instructions already say `*.{domain}`. Verify and update if the instructions are specific to single-level subdomains. - -**Step 3: Verify** - -Run `cargo test --test e2e test_init_output` — existing init output tests should still pass. +Run `cargo test --test e2e` (non-ignored) to verify compilation. Full e2e: `cargo test --test e2e -- --ignored test_full_e2e_workflow`. From 7f6489012f663cb90fb8e14ef64e16e0c8eae150 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Mon, 9 Mar 2026 15:01:37 -0700 Subject: [PATCH 3/6] plan: fix DNS label overflow with compose_slug truncation Move truncation from sanitize_subdomain into a new compose_slug function that joins {random_slug}-{app_name} and truncates the composite result to 63 chars (RFC 1035 DNS label limit). Prevents overflow when the app name is long, since the composite could otherwise exceed 63 chars. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-09-app-named-slugs.md | 56 ++++++++++++++++++------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/plans/2026-03-09-app-named-slugs.md b/docs/plans/2026-03-09-app-named-slugs.md index 9d7811a..ef3d055 100644 --- a/docs/plans/2026-03-09-app-named-slugs.md +++ b/docs/plans/2026-03-09-app-named-slugs.md @@ -17,9 +17,11 @@ This is the simplest possible approach: the app name is baked into the compose p **App name detection:** A new function `detect_app_name(dir: &Path) -> Result` in `config.rs`: 1. Run `git -C {dir} remote get-url origin` and parse the repo name from the URL (handles both HTTPS and SSH formats). Strip `.git` suffix if present. 2. If git fails or there's no remote, fall back to the directory name. -3. Sanitize the result: lowercase, replace non-alphanumeric chars with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. Truncate so the total `{slug}-{appname}` stays within the 63-char DNS label limit. +3. Sanitize the result: lowercase, replace non-alphanumeric chars with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. No truncation at this stage — truncation happens when composing the full slug. -**Compose project name:** Currently `up.rs` generates a random slug (e.g., `swift-penguin`) and uses it as `--project-name`. The new behavior generates the composite `{slug}-{app-name}` (e.g., `swift-penguin-devproxy`) and uses that as `--project-name`. The `.devproxy-project` file stores this composite name. The daemon reads `com.docker.compose.project` which already equals this composite name — no daemon changes needed. +**Composite slug and DNS label limit:** A new function `compose_slug(random_slug: &str, app_name: &str) -> String` in `config.rs` joins them as `{random_slug}-{app_name}` and then truncates the result to 63 characters (the RFC 1035 DNS label limit), trimming any trailing hyphen that the truncation might produce. The longest random slug is `bright-penguin` (14 chars); typical app names are short (e.g., `devproxy` = 8 chars, composite = 23 chars). Truncation only fires for unusually long app names. + +**Compose project name:** Currently `up.rs` generates a random slug (e.g., `swift-penguin`) and uses it as `--project-name`. The new behavior calls `compose_slug(&random_slug, &app_name)` to form the composite (e.g., `swift-penguin-devproxy`) and uses that as `--project-name`. The `.devproxy-project` file stores this composite name. The daemon reads `com.docker.compose.project` which already equals this composite name — no daemon changes needed. **`.devproxy-project` file:** No format change needed. The file continues to store the compose project name (now the composite `{slug}-{app-name}`). The `read_project_file` and `write_project_file` signatures remain the same. @@ -130,10 +132,30 @@ mod tests { } #[test] - fn sanitize_subdomain_truncates_long_names() { + fn sanitize_subdomain_does_not_truncate() { let long_name = "a".repeat(100); let result = sanitize_subdomain(&long_name); - assert!(result.len() <= 63); + assert_eq!(result.len(), 100, "sanitize_subdomain should not truncate"); + } + + #[test] + fn compose_slug_basic() { + assert_eq!(compose_slug("swift-penguin", "devproxy"), "swift-penguin-devproxy"); + } + + #[test] + fn compose_slug_truncates_to_63_chars() { + let long_app = "a".repeat(100); + let result = compose_slug("swift-penguin", &long_app); + assert!(result.len() <= 63, "composite slug must fit in a DNS label: len={}", result.len()); + assert!(!result.ends_with('-'), "should not end with hyphen after truncation"); + assert!(result.starts_with("swift-penguin-"), "should preserve the random slug prefix"); + } + + #[test] + fn compose_slug_normal_lengths_not_truncated() { + let result = compose_slug("bold-fox", "my-cool-app"); + assert_eq!(result, "bold-fox-my-cool-app"); } } ``` @@ -188,24 +210,30 @@ fn extract_repo_name(url: &str) -> Option { /// Sanitize a string for use as part of a DNS subdomain label: /// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, -/// trim leading/trailing hyphens, truncate to 63 chars. +/// trim leading/trailing hyphens. Does NOT truncate — callers that need length +/// enforcement should use `compose_slug` which truncates the composite result. fn sanitize_subdomain(s: &str) -> String { let lower = s.to_lowercase(); let replaced: String = lower .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); - let collapsed = replaced + replaced .split('-') .filter(|s| !s.is_empty()) .collect::>() - .join("-"); - let truncated = if collapsed.len() > 63 { - &collapsed[..63] - } else { - &collapsed - }; - truncated.trim_end_matches('-').to_string() + .join("-") +} + +/// Compose a DNS-safe slug from a random slug and app name. +/// Joins as `{random_slug}-{app_name}` and truncates the result to 63 characters +/// (the RFC 1035 DNS label limit), trimming any trailing hyphen. +pub fn compose_slug(random_slug: &str, app_name: &str) -> String { + let composite = format!("{random_slug}-{app_name}"); + if composite.len() <= 63 { + return composite; + } + composite[..63].trim_end_matches('-').to_string() } ``` @@ -234,7 +262,7 @@ let app_name = config::detect_app_name(&cwd)?; eprintln!("app: {}", app_name.cyan()); let random_slug = slugs::generate_slug(); -let slug = format!("{random_slug}-{app_name}"); +let slug = config::compose_slug(&random_slug, &app_name); eprintln!("slug: {}", slug.cyan()); ``` From 3a86b3c714792ef6aab76e640894b9e00682556b Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Mon, 9 Mar 2026 15:05:08 -0700 Subject: [PATCH 4/6] docs: add test plan for app-named slugs feature Co-Authored-By: Claude Opus 4.6 --- .../2026-03-09-app-named-slugs-test-plan.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/plans/2026-03-09-app-named-slugs-test-plan.md diff --git a/docs/plans/2026-03-09-app-named-slugs-test-plan.md b/docs/plans/2026-03-09-app-named-slugs-test-plan.md new file mode 100644 index 0000000..99e3c29 --- /dev/null +++ b/docs/plans/2026-03-09-app-named-slugs-test-plan.md @@ -0,0 +1,250 @@ +# App-Named Slugs — Test Plan + +## Strategy reconciliation + +The agreed strategy was **Medium fidelity**: unit tests for repo name detection, slug composition, and ls formatting, plus update existing e2e tests. After reviewing the implementation plan against the codebase: + +- **Strategy holds.** The plan's architecture is straightforward: new pure functions in `config.rs`, wiring in `up.rs`, formatting in `ls.rs`. No new dependencies, no daemon-side changes, no cert changes. +- **One adjustment (no cost change):** The implementation plan's `ls` tests compare `route.slug` against a `current_slug` parameter. In the actual codebase, `RouteInfo.slug` contains the **full hostname** (e.g., `swift-penguin-devproxy.mysite.dev`), not just the compose project name. The plan already accounts for this (constructing `format!("{slug}.{}", config.domain)` in the `run()` function). The test plan below uses full hostnames in `RouteInfo.slug` to match reality. + +## Sources of truth + +- **S1**: Implementation plan (`docs/plans/2026-03-09-app-named-slugs.md`) — defines URL format, detection logic, composition rules, ls behavior +- **S2**: RFC 1035 Section 2.3.4 — DNS label limit of 63 characters +- **S3**: Git remote URL formats — HTTPS (`https://github.com/user/repo.git`) and SSH (`git@github.com:user/repo.git`) +- **S4**: Current codebase behavior — `RouteInfo.slug` contains full hostname, `.devproxy-project` stores compose project name + +--- + +## Test plan + +### 1. Scenario: Full workflow produces app-named URL and ls shows current marker + +- **Type**: scenario +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir with certs, running test daemon, fixture directory with git remote set to `https://github.com/test/e2e-fixture.git` +- **Actions**: + 1. Run `devproxy up` from the fixture directory + 2. Extract slug from output — should be `{adj}-{animal}-e2e-fixture` + 3. Verify `.devproxy-project` contains the composite slug + 4. Run `devproxy ls` from the fixture directory + 5. Verify ls output contains the composite slug + 6. Verify ls output contains `*` marker for current project + 7. Run curl through the proxy using the composite hostname + 8. Run `devproxy down` +- **Expected outcome**: + - Up output contains URL of form `https://{adj}-{animal}-e2e-fixture.test.devproxy.dev` [S1] + - `.devproxy-project` stores the composite slug [S1, S4] + - ls shows the composite slug with `*` marker when run from the fixture dir [S1] + - curl succeeds through the proxy using the composite hostname [S1] + - Down cleans up `.devproxy-project` and `.devproxy-override.yml` [S4] +- **Interactions**: Docker compose project naming, daemon route table, TLS cert wildcard matching + +### 2. Scenario: Self-healing works with composite slugs + +- **Type**: scenario +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir, running daemon, fixture with git remote +- **Actions**: + 1. Run `devproxy up`, extract composite slug + 2. Verify route appears in `devproxy ls` + 3. Kill container externally via `docker compose kill` + 4. Wait for event watcher to process + 5. Verify route removed from `devproxy ls` +- **Expected outcome**: Route with composite slug is added on start and removed on die [S1, S4] +- **Interactions**: Docker event watcher reads `com.docker.compose.project` label which now contains the composite slug + +### 3. Scenario: Daemon restart rebuilds routes with composite slugs + +- **Type**: scenario +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir, running daemon, fixture with git remote, container running +- **Actions**: + 1. Run `devproxy up`, extract composite slug + 2. Kill daemon process + 3. Start new daemon + 4. Verify composite slug appears in `devproxy ls` +- **Expected outcome**: Route with composite slug is rebuilt from running container [S1, S4] +- **Interactions**: Docker inspect reads `com.docker.compose.project` which contains composite slug + +### 4. Integration: ls without current project shows no marker + +- **Type**: integration +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir, running daemon, container running via `devproxy up` from fixture dir +- **Actions**: + 1. Run `devproxy ls` from a **different** directory (not the fixture dir) +- **Expected outcome**: ls output shows the route but without `*` marker [S1] +- **Interactions**: ls reads `.devproxy-project` from cwd — should fail silently when not present + +### 5. Unit: extract_repo_name from HTTPS URL + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `extract_repo_name("https://github.com/user/repo.git")` +- **Expected outcome**: Returns `Some("repo")` [S3] +- **Interactions**: None + +### 6. Unit: extract_repo_name from SSH URL + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `extract_repo_name("git@github.com:user/repo.git")` +- **Expected outcome**: Returns `Some("repo")` [S3] +- **Interactions**: None + +### 7. Unit: extract_repo_name from URL without .git suffix + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `extract_repo_name("https://github.com/user/repo")` +- **Expected outcome**: Returns `Some("repo")` [S3] +- **Interactions**: None + +### 8. Unit: detect_app_name from git remote (HTTPS) + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs`, creates temp dir with `git init` + remote +- **Preconditions**: Temp dir initialized as git repo with HTTPS remote `https://github.com/user/my-cool-app.git` +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"my-cool-app"` [S1, S3] +- **Interactions**: Spawns `git` subprocess + +### 9. Unit: detect_app_name from git remote (SSH) + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs`, creates temp dir with `git init` + remote +- **Preconditions**: Temp dir initialized as git repo with SSH remote `git@github.com:user/another-app.git` +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"another-app"` [S1, S3] +- **Interactions**: Spawns `git` subprocess + +### 10. Unit: detect_app_name falls back to directory name + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs`, creates temp dir without git +- **Preconditions**: Temp dir named `my-project`, no git repo +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"my-project"` [S1] +- **Interactions**: Spawns `git` subprocess (which fails), then reads dir name + +### 11. Unit: detect_app_name sanitizes directory name + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: Temp dir named `My Cool App!!!` +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"my-cool-app"` [S1] +- **Interactions**: Spawns `git` subprocess (fails), sanitizes dir name + +### 12. Unit: sanitize_subdomain basic case + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `sanitize_subdomain("My Cool App!!!")` +- **Expected outcome**: Returns `"my-cool-app"` [S1] +- **Interactions**: None + +### 13. Unit: sanitize_subdomain already clean + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `sanitize_subdomain("my-app")` +- **Expected outcome**: Returns `"my-app"` [S1] +- **Interactions**: None + +### 14. Unit: sanitize_subdomain does not truncate + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `sanitize_subdomain(&"a".repeat(100))` +- **Expected outcome**: Returns string of length 100 [S1 — truncation is compose_slug's job] +- **Interactions**: None + +### 15. Unit: compose_slug basic case + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `compose_slug("swift-penguin", "devproxy")` +- **Expected outcome**: Returns `"swift-penguin-devproxy"` [S1] +- **Interactions**: None + +### 16. Boundary: compose_slug truncates to 63 chars + +- **Type**: boundary +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `compose_slug("swift-penguin", &"a".repeat(100))` +- **Expected outcome**: Result length <= 63, does not end with `-`, starts with `"swift-penguin-"` [S1, S2] +- **Interactions**: None + +### 17. Unit: compose_slug normal lengths not truncated + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `compose_slug("bold-fox", "my-cool-app")` +- **Expected outcome**: Returns `"bold-fox-my-cool-app"` [S1] +- **Interactions**: None + +### 18. Unit: format_route_line with current marker + +- **Type**: unit +- **Harness**: `#[test]` in `ls.rs` +- **Preconditions**: None +- **Actions**: Call `format_route_line` with a route whose slug matches the current slug +- **Expected outcome**: Output contains `*` [S1] +- **Interactions**: None + +### 19. Unit: format_route_line without current marker + +- **Type**: unit +- **Harness**: `#[test]` in `ls.rs` +- **Preconditions**: None +- **Actions**: Call `format_route_line` with a route whose slug does NOT match the current slug +- **Expected outcome**: Output does not contain `*` [S1] +- **Interactions**: None + +### 20. Unit: format_route_line with no current project + +- **Type**: unit +- **Harness**: `#[test]` in `ls.rs` +- **Preconditions**: None +- **Actions**: Call `format_route_line` with `current_slug = None` +- **Expected outcome**: Output does not contain `*` [S1] +- **Interactions**: None + +--- + +## Coverage summary + +### Covered + +- App name detection from git remote (HTTPS and SSH formats) +- App name fallback to directory name +- Subdomain sanitization (special chars, case, consecutive hyphens) +- Composite slug composition (join and truncation) +- DNS label length limit (63 chars) enforcement +- ls current-directory `*` marker (match, no match, no project) +- Full e2e workflow with composite slugs (up, ls, curl, down) +- Self-healing with composite slugs +- Daemon restart route rebuilding with composite slugs +- ls behavior from non-project directory + +### Explicitly excluded per strategy + +- **Daemon-side routing logic**: The plan explicitly states no daemon changes are needed. The composite slug flows through `com.docker.compose.project` label which the daemon already reads. Covered indirectly by e2e tests. +- **TLS cert changes**: No cert changes needed (single subdomain level maintained). Verified indirectly by curl in e2e test. +- **Performance**: No performance-critical changes. The only new work is one `git` subprocess call during `devproxy up`, which is already dominated by `docker compose up` time. + +### Risks of exclusions + +- If the composite slug somehow exceeds the wildcard cert's matching, the e2e curl test would catch this. +- If Docker Compose handles the longer project name differently (e.g., container naming), the e2e tests would catch this since they run real Docker containers. From c0ac730f9de31735e6a8860543d808102fa91581 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Mon, 9 Mar 2026 15:08:20 -0700 Subject: [PATCH 5/6] feat: add app-named slugs and ls current-directory indicator URLs now include the app/repo name: https://{slug}-{app-name}.{domain} where app-name is derived from git remote origin (falling back to dir name). The ls command marks the current directory's project with *. Co-Authored-By: Claude Opus 4.6 --- src/commands/ls.rs | 71 +++++++++++++++-- src/commands/up.rs | 8 +- src/config.rs | 184 +++++++++++++++++++++++++++++++++++++++++++++ tests/e2e.rs | 34 ++++++++- 4 files changed, 286 insertions(+), 11 deletions(-) diff --git a/src/commands/ls.rs b/src/commands/ls.rs index c9f3079..9e38241 100644 --- a/src/commands/ls.rs +++ b/src/commands/ls.rs @@ -1,24 +1,43 @@ -use crate::config::Config; -use crate::ipc::{self, Request, Response}; +use crate::config::{self, Config}; +use crate::ipc::{self, Request, Response, RouteInfo}; use anyhow::{Result, bail}; use colored::Colorize; +/// Format a single route line, with optional `*` marker for current project. +fn format_route_line(route: &RouteInfo, current_slug: Option<&str>) -> String { + let marker = match current_slug { + Some(s) if s == route.slug => "* ", + _ => " ", + }; + format!( + "{}{:<40} {:<10}", + marker, + format!("https://{}", route.slug), + route.port + ) +} + pub async fn run() -> Result<()> { let socket_path = Config::socket_path()?; let response = ipc::send_request(&socket_path, &Request::List).await?; + // Try to read current project slug from cwd (silently ignore failures) + let current_slug = std::env::current_dir() + .ok() + .and_then(|cwd| config::read_project_file(&cwd).ok()) + .and_then(|slug| { + let config = Config::load().ok()?; + Some(format!("{slug}.{}", config.domain)) + }); + match response { Response::Routes { routes } => { if routes.is_empty() { println!("no active projects"); } else { - println!("{:<30} {:<10}", "SLUG".bold(), "PORT".bold()); + println!(" {:<40} {:<10}", "URL".bold(), "PORT".bold()); for route in &routes { - println!( - "{:<30} {:<10}", - format!("https://{}", route.slug).cyan(), - route.port - ); + println!("{}", format_route_line(route, current_slug.as_deref())); } println!(); println!("{} active project(s)", routes.len()); @@ -30,3 +49,39 @@ pub async fn run() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ipc::RouteInfo; + + #[test] + fn format_route_with_current_marker() { + let route = RouteInfo { + slug: "swift-penguin-devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(line.contains("*"), "current project should have * marker: {line}"); + } + + #[test] + fn format_route_without_current_marker() { + let route = RouteInfo { + slug: "bold-fox-devproxy.mysite.dev".to_string(), + port: 51235, + }; + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(!line.contains("*"), "non-current project should not have * marker: {line}"); + } + + #[test] + fn format_route_no_current_project() { + let route = RouteInfo { + slug: "swift-penguin-devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, None); + assert!(!line.contains("*"), "no current project means no marker: {line}"); + } +} diff --git a/src/commands/up.rs b/src/commands/up.rs index 86ec994..96ce888 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -28,8 +28,12 @@ pub fn run() -> Result<()> { container_port.to_string().cyan() ); - // Generate slug - let slug = slugs::generate_slug(); + // Detect app name and generate composite slug + let app_name = config::detect_app_name(&cwd)?; + eprintln!("app: {}", app_name.cyan()); + + let random_slug = slugs::generate_slug(); + let slug = config::compose_slug(&random_slug, &app_name); eprintln!("slug: {}", slug.cyan()); // Find free port diff --git a/src/config.rs b/src/config.rs index 61cdebe..2964d6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -225,6 +225,77 @@ pub fn read_project_file(dir: &Path) -> Result { Ok(content.trim().to_string()) } +/// Detect the app name for the given directory. +/// Tries git remote origin first (extracts repo name from URL), +/// falls back to directory name. Result is sanitized for use in a subdomain label. +pub fn detect_app_name(dir: &Path) -> Result { + // Try git remote + if let Ok(output) = std::process::Command::new("git") + .args(["-C", &dir.to_string_lossy(), "remote", "get-url", "origin"]) + .output() + && output.status.success() + { + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if let Some(name) = extract_repo_name(&url) { + return Ok(sanitize_subdomain(&name)); + } + } + + // Fall back to directory name + let dir_name = dir + .file_name() + .context("directory has no name")? + .to_string_lossy() + .to_string(); + Ok(sanitize_subdomain(&dir_name)) +} + +/// Extract the repository name from a git remote URL. +/// Handles HTTPS (https://github.com/user/repo.git) and SSH (git@github.com:user/repo.git). +fn extract_repo_name(url: &str) -> Option { + let path_part = if url.contains("://") { + url.split('/').next_back()? + } else if let Some(after_colon) = url.split(':').nth(1) { + after_colon.split('/').next_back()? + } else { + return None; + }; + let name = path_part.strip_suffix(".git").unwrap_or(path_part); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +/// Sanitize a string for use as part of a DNS subdomain label: +/// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, +/// trim leading/trailing hyphens. Does NOT truncate — callers that need length +/// enforcement should use `compose_slug` which truncates the composite result. +fn sanitize_subdomain(s: &str) -> String { + let lower = s.to_lowercase(); + let replaced: String = lower + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + replaced + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Compose a DNS-safe slug from a random slug and app name. +/// Joins as `{random_slug}-{app_name}` and truncates the result to 63 characters +/// (the RFC 1035 DNS label limit), trimming any trailing hyphen. +pub fn compose_slug(random_slug: &str, app_name: &str) -> String { + let composite = format!("{random_slug}-{app_name}"); + if composite.len() <= 63 { + return composite; + } + composite[..63].trim_end_matches('-').to_string() +} + /// Find a free ephemeral port pub fn find_free_port() -> Result { let listener = std::net::TcpListener::bind("127.0.0.1:0")?; @@ -380,4 +451,117 @@ services: .contains("no docker-compose.yml") ); } + + #[test] + fn detect_app_name_from_git_remote_https() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "https://github.com/user/my-cool-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "my-cool-app"); + } + + #[test] + fn detect_app_name_from_git_remote_ssh() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "git@github.com:user/another-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "another-app"); + } + + #[test] + fn detect_app_name_falls_back_to_dir_name() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("my-project"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-project"); + } + + #[test] + fn detect_app_name_sanitizes() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("My Cool App!!!"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-cool-app"); + } + + #[test] + fn extract_repo_name_https() { + assert_eq!( + extract_repo_name("https://github.com/user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_ssh() { + assert_eq!( + extract_repo_name("git@github.com:user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_no_git_suffix() { + assert_eq!( + extract_repo_name("https://github.com/user/repo"), + Some("repo".to_string()) + ); + } + + #[test] + fn sanitize_subdomain_basic() { + assert_eq!(sanitize_subdomain("My Cool App!!!"), "my-cool-app"); + } + + #[test] + fn sanitize_subdomain_already_clean() { + assert_eq!(sanitize_subdomain("my-app"), "my-app"); + } + + #[test] + fn sanitize_subdomain_does_not_truncate() { + let long_name = "a".repeat(100); + let result = sanitize_subdomain(&long_name); + assert_eq!(result.len(), 100, "sanitize_subdomain should not truncate"); + } + + #[test] + fn compose_slug_basic() { + assert_eq!(compose_slug("swift-penguin", "devproxy"), "swift-penguin-devproxy"); + } + + #[test] + fn compose_slug_truncates_to_63_chars() { + let long_app = "a".repeat(100); + let result = compose_slug("swift-penguin", &long_app); + assert!(result.len() <= 63, "composite slug must fit in a DNS label: len={}", result.len()); + assert!(!result.ends_with('-'), "should not end with hyphen after truncation"); + assert!(result.starts_with("swift-penguin-"), "should preserve the random slug prefix"); + } + + #[test] + fn compose_slug_normal_lengths_not_truncated() { + let result = compose_slug("bold-fox", "my-cool-app"); + assert_eq!(result, "bold-fox-my-cool-app"); + } } diff --git a/tests/e2e.rs b/tests/e2e.rs index 1ff26f1..973224e 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -45,6 +45,7 @@ fn find_free_port() -> u16 { /// Copy the fixtures directory into an isolated temp dir for one test. /// Returns the path to the copy (which contains docker-compose.yml, Dockerfile, etc). +/// Initializes a git repo with a known remote so detect_app_name is predictable. fn copy_fixtures(test_name: &str) -> PathBuf { let dest = std::env::temp_dir().join(format!( "devproxy-fixtures-{test_name}-{}", @@ -61,9 +62,29 @@ fn copy_fixtures(test_name: &str) -> PathBuf { let dest_path = dest.join(entry.file_name()); std::fs::copy(entry.path(), &dest_path).unwrap(); } + + // Initialize a git repo with a known remote so detect_app_name is predictable + Command::new("git") + .args(["init"]) + .current_dir(&dest) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .expect("git init failed"); + Command::new("git") + .args(["remote", "add", "origin", "https://github.com/test/e2e-fixture.git"]) + .current_dir(&dest) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .expect("git remote add failed"); + dest } +/// The expected app name suffix for fixture directories (derived from git remote) +const FIXTURE_APP_NAME: &str = "e2e-fixture"; + /// Create an isolated test config directory and generate certs using `init --no-daemon`. /// Returns the path to the config directory (to be set as DEVPROXY_CONFIG_DIR). fn create_test_config_dir(test_name: &str) -> PathBuf { @@ -473,6 +494,12 @@ fn test_full_e2e_workflow() { .and_then(|l| l.split("https://").nth(1).and_then(|s| s.split('.').next())) .expect("should find slug in up output"); + // Verify the slug contains the app name suffix + assert!( + slug.ends_with(&format!("-{FIXTURE_APP_NAME}")), + "slug should end with app name '-{FIXTURE_APP_NAME}': {slug}" + ); + // Verify .devproxy-project was written with the correct slug let project_file = fixtures.join(".devproxy-project"); assert!( @@ -506,9 +533,10 @@ fn test_full_e2e_workflow() { "daemon should be running: {status_stderr}" ); - // Ls check + // Ls check (run from fixtures dir to get * indicator) let ls_output = Command::new(devproxy_bin()) .args(["ls"]) + .current_dir(&fixtures) .env("DEVPROXY_CONFIG_DIR", &config_dir) .output() .expect("failed to run devproxy ls"); @@ -517,6 +545,10 @@ fn test_full_e2e_workflow() { ls_stdout.contains(slug), "ls should show our slug '{slug}': {ls_stdout}" ); + assert!( + ls_stdout.contains("*"), + "ls should show * for current project: {ls_stdout}" + ); // Curl through the proxy (--resolve bypasses DNS, --cacert trusts our test CA) let ca_cert_path = config_dir.join("ca-cert.pem"); From c8c30bcabd6a4370c71b6e830ecc6b3689c63641 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Mon, 9 Mar 2026 15:11:26 -0700 Subject: [PATCH 6/6] fix: address review findings for app-named slugs - Use char-based truncation in compose_slug to avoid potential panic on multi-byte UTF-8 (composite[..63] -> chars().take(63)) - Restore colored (.cyan()) output in ls route lines - Use dynamic column width in ls based on longest URL - Use starts_with("* ") in tests instead of fragile contains("*") - Move format_route_line into test module since run() inlines the logic for colored output support Co-Authored-By: Claude Opus 4.6 --- src/commands/ls.rs | 55 ++++++++++++++++++++++++++++------------------ src/config.rs | 2 +- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/commands/ls.rs b/src/commands/ls.rs index 9e38241..ff4f2f0 100644 --- a/src/commands/ls.rs +++ b/src/commands/ls.rs @@ -1,22 +1,8 @@ use crate::config::{self, Config}; -use crate::ipc::{self, Request, Response, RouteInfo}; +use crate::ipc::{self, Request, Response}; use anyhow::{Result, bail}; use colored::Colorize; -/// Format a single route line, with optional `*` marker for current project. -fn format_route_line(route: &RouteInfo, current_slug: Option<&str>) -> String { - let marker = match current_slug { - Some(s) if s == route.slug => "* ", - _ => " ", - }; - format!( - "{}{:<40} {:<10}", - marker, - format!("https://{}", route.slug), - route.port - ) -} - pub async fn run() -> Result<()> { let socket_path = Config::socket_path()?; let response = ipc::send_request(&socket_path, &Request::List).await?; @@ -35,9 +21,26 @@ pub async fn run() -> Result<()> { if routes.is_empty() { println!("no active projects"); } else { - println!(" {:<40} {:<10}", "URL".bold(), "PORT".bold()); + // Compute column width dynamically based on longest URL + let url_width = routes + .iter() + .map(|r| format!("https://{}", r.slug).len()) + .max() + .unwrap_or(3) + .max(3); // at least "URL".len() + + println!(" {: Result<()> { #[cfg(test)] mod tests { - use super::*; use crate::ipc::RouteInfo; + /// Format a single route line, with optional `*` marker for current project. + /// Used only in tests to verify marker logic independently of colored output. + fn format_route_line(route: &RouteInfo, current_slug: Option<&str>) -> String { + let marker = match current_slug { + Some(s) if s == route.slug => "* ", + _ => " ", + }; + let url = format!("https://{}", route.slug); + format!("{}{} {}", marker, url, route.port) + } + #[test] fn format_route_with_current_marker() { let route = RouteInfo { @@ -62,7 +75,7 @@ mod tests { port: 51234, }; let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); - assert!(line.contains("*"), "current project should have * marker: {line}"); + assert!(line.starts_with("* "), "current project should have * marker: {line}"); } #[test] @@ -72,7 +85,7 @@ mod tests { port: 51235, }; let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); - assert!(!line.contains("*"), "non-current project should not have * marker: {line}"); + assert!(line.starts_with(" "), "non-current project should not have * marker: {line}"); } #[test] @@ -82,6 +95,6 @@ mod tests { port: 51234, }; let line = format_route_line(&route, None); - assert!(!line.contains("*"), "no current project means no marker: {line}"); + assert!(line.starts_with(" "), "no current project means no marker: {line}"); } } diff --git a/src/config.rs b/src/config.rs index 2964d6d..4946423 100644 --- a/src/config.rs +++ b/src/config.rs @@ -293,7 +293,7 @@ pub fn compose_slug(random_slug: &str, app_name: &str) -> String { if composite.len() <= 63 { return composite; } - composite[..63].trim_end_matches('-').to_string() + composite.chars().take(63).collect::().trim_end_matches('-').to_string() } /// Find a free ephemeral port