From 6b8d51f143b35f44128bc9c4eac0f1450508f77e Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 13:54:51 -0700 Subject: [PATCH 1/8] docs: add design spec for custom slugs and docker compose command parity Adds --slug flag to `devproxy up` for predictable URLs and introduces stop/start/daemon restart commands to mirror docker compose lifecycle. Co-Authored-By: Claude Opus 4.6 --- ...lugs-and-docker-compose-commands-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-10-custom-slugs-and-docker-compose-commands-design.md diff --git a/docs/superpowers/specs/2026-03-10-custom-slugs-and-docker-compose-commands-design.md b/docs/superpowers/specs/2026-03-10-custom-slugs-and-docker-compose-commands-design.md new file mode 100644 index 0000000..48a407d --- /dev/null +++ b/docs/superpowers/specs/2026-03-10-custom-slugs-and-docker-compose-commands-design.md @@ -0,0 +1,149 @@ +# Custom Slugs & Docker Compose Command Parity + +## Summary + +Add `--slug` flag to `devproxy up` for predictable URLs, and introduce `stop`, `start`, `daemon restart` commands to mirror docker compose's lifecycle API. This enables workflows where apps need a known `BASE_URL` before startup. + +**Breaking change:** `devproxy restart` changes from daemon restart to app stack restart. Daemon restart moves to `devproxy daemon restart`. Bump minor version. + +## Motivation + +When apps require environment variables like `BASE_URL`, the current random slug generation means the URL isn't known until after `devproxy up` runs. A `--slug` flag lets users lock in a predictable URL. Additionally, the current command set lacks `stop`/`start` (non-destructive pause/resume), and `restart` incorrectly targets the daemon instead of the app stack. + +This supersedes the "pinned slugs" open question in `docs/spec.md` (line ~199). + +## Command Structure + +| Command | Behavior | Override/Slug files | +|---------|----------|---------------------| +| `devproxy up [--slug NAME]` | Create override + slug if none exist, reuse if they do. Start stack with `docker compose up -d`. | Creates if missing, preserves if present | +| `devproxy down` | Stop stack with `docker compose down`. Remove override + slug files. | Removes both | +| `devproxy stop` | Stop stack with `docker compose stop`. Leave files intact. | Preserves both | +| `devproxy start` | Start stopped stack with `docker compose start`. | Requires both to exist | +| `devproxy restart` | Restart stack with `docker compose restart`. | Requires both to exist | +| `devproxy daemon restart` | Restart the background daemon process (launchd/systemd). | No effect | + +## `--slug` Flag + +### Usage + +```bash +devproxy up --slug dirty-panda +# URL: https://dirty-panda-myrepo.{configured_domain} +``` + +The custom slug replaces the random `{adjective}-{animal}` prefix. The app name is still appended via `compose_slug()`, producing `{custom_slug}-{app_name}`. + +### Validation + +Custom slugs are **validated and rejected** if invalid (not sanitized/transformed like app names). Applied before any Docker or file operations: + +- Lowercase alphanumeric and hyphens only +- No leading or trailing hyphens +- Non-empty +- Combined result (with app name) must be <= 63 characters (DNS label limit) + +Validation lives in `src/config.rs` alongside the existing `compose_slug()` and `sanitize_subdomain()`. + +### Slug Collisions + +Custom slugs are not checked for uniqueness against running routes. If two projects use the same `--slug` and have the same app name, Docker Compose will treat them as the same project. This is the user's responsibility — the same way `docker compose -p` works. + +### Reuse Behavior + +**This is a behavioral change to `up`.** Currently `up` always generates a new slug and overwrites files. After this change, `up` checks for existing state first. + +When `.devproxy-project` and `.devproxy-override.yml` already exist: + +- `--slug` is ignored with a warning: "Ignoring --slug, reusing existing slug. Run `devproxy down` first to change slug." +- Existing slug and port binding are reused +- `docker compose up -d` is run with existing configuration + +When files don't exist (fresh start or after `down`): + +- `--slug` value is used as prefix (or random if not provided) +- New port is allocated, override and project files are written + +## New Commands + +### `devproxy stop` + +1. Read slug from `.devproxy-project` (error if missing) +2. Find compose file +3. Run `docker compose -f -f -p stop` +4. Print confirmation +5. Leave `.devproxy-project` and `.devproxy-override.yml` in place + +Idempotent — running on already-stopped containers is a no-op. + +### `devproxy start` + +1. Read slug from `.devproxy-project` (error if missing) +2. Verify `.devproxy-override.yml` exists (error if missing) +3. Verify daemon is running via IPC ping +4. Run `docker compose -f -f -p start` +5. Print URL + +The daemon's Docker event watcher already handles container `start` events and inserts routes automatically. No additional IPC needed. + +### `devproxy restart` + +1. Same precondition checks as `start` +2. Run `docker compose -f -f -p restart` +3. Print URL + +### `devproxy daemon restart` + +Existing daemon restart logic (`platform::restart_daemon`) moved to this subcommand. + +## `daemon` Subcommand Structure + +The existing hidden `devproxy daemon --port ` command (which runs the daemon process) becomes a subcommand group: + +``` +devproxy daemon run [--port PORT] # hidden, called by launchd/systemd +devproxy daemon restart # visible, restarts the daemon +``` + +Clap structure: `Daemon` variant contains a `DaemonCommand` enum with `Run { port }` (hidden) and `Restart` variants. The `Run` subcommand replaces the current top-level `Daemon` variant. Launchd plists and systemd unit files must be updated to use `devproxy daemon run --port `. + +Since `devproxy init` writes fresh plist/unit files, existing installations will get the updated invocation on next `devproxy init`. No migration needed — the daemon is always launched by the platform service manager using the plist/unit file that `init` writes. + +## Docker Event Watcher Compatibility + +The daemon's Docker event watcher (`proxy/docker.rs`) listens for `start`, `die`, `stop`, `kill` events. This already handles: + +- **`devproxy stop`**: Container `stop` events trigger route removal +- **`devproxy start`**: Container `start` events trigger route insertion +- **`devproxy restart`**: Container `stop` + `start` events handled in sequence + +No changes needed to the event watcher. + +## Files Changed + +| File | Change | +|------|--------| +| `src/cli.rs` | Add `Stop`, `Start` variants; restructure `Daemon` as subcommand group with `Run` (hidden) and `Restart`; add `--slug` arg on `Up` | +| `src/commands/up.rs` | Restructure: check for existing override/project files before generating new slug/port | +| `src/commands/stop.rs` | New file | +| `src/commands/start.rs` | New file | +| `src/commands/restart.rs` | Rewritten: replaces daemon restart logic with app stack restart logic | +| `src/commands/daemon.rs` | Existing file updated: add `restart` handling alongside existing `run` logic | +| `src/commands/mod.rs` | Register new modules | +| `src/main.rs` | Dispatch new commands, update `Daemon` dispatch for subcommands | +| `src/config.rs` | Add `validate_custom_slug()` alongside existing `compose_slug()` | +| `docs/spec.md` | Mark "pinned slugs" open question as resolved | +| `README.md` | Document new commands and `--slug` flag | +| `skills/devproxy/SKILL.md` | Update command table and triggers | +| `skills/setup/SKILL.md` | Update if relevant | +| `.claude-plugin/plugin.json` | Bump version | + +## Edge Cases + +- **`--slug` on existing project**: Warn and ignore, user must `down` first to change slug +- **`start`/`restart` with no project file**: Error with guidance to run `up` first +- **`start` with missing override but existing project file**: Error with guidance to run `up` to reconfigure +- **Custom slug fails DNS validation**: Error before any side effects +- **Custom slug + app name exceeds 63 chars**: Error with the computed length shown +- **`stop` on already-stopped stack**: No-op (idempotent) +- **Slug collision across projects**: User's responsibility, same as `docker compose -p` From fe8bd286ff5a5f7b9ac0c37bc2f252dceee37ab3 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 14:00:46 -0700 Subject: [PATCH 2/8] docs: add implementation plan for custom slugs and compose commands 11 tasks across 4 chunks: validation, CLI restructure, command implementations, platform updates, and documentation sync. Co-Authored-By: Claude Opus 4.6 --- ...ustom-slugs-and-docker-compose-commands.md | 1099 +++++++++++++++++ 1 file changed, 1099 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md diff --git a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md new file mode 100644 index 0000000..15309e2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md @@ -0,0 +1,1099 @@ +# Custom Slugs & Docker Compose Command Parity — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `--slug` flag to `devproxy up` for predictable URLs, and introduce `stop`, `start`, `restart` (app stack), and `daemon restart` commands to mirror docker compose lifecycle. + +**Architecture:** Existing file-based state (`.devproxy-project`, `.devproxy-override.yml`) becomes the source of truth for whether a project is "configured." `up` checks for these files before generating new slugs/ports. New `stop`/`start` commands mirror docker compose stop/start without touching these files. `daemon` becomes a clap subcommand group with `run` (hidden) and `restart`. + +**Tech Stack:** Rust, clap (derive), anyhow, colored, Docker Compose CLI + +**Spec:** `docs/superpowers/specs/2026-03-10-custom-slugs-and-docker-compose-commands-design.md` + +--- + +## Chunk 1: Validation + CLI Structure + +### Task 1: Add `validate_custom_slug()` to config.rs + +**Files:** +- Modify: `src/config.rs:317-328` (after `compose_slug`) + +- [ ] **Step 1: Write the failing tests** + +Add to the `#[cfg(test)] mod tests` block in `src/config.rs`: + +```rust +#[test] +fn validate_custom_slug_accepts_valid() { + assert!(validate_custom_slug("dirty-panda").is_ok()); + assert!(validate_custom_slug("my-app").is_ok()); + assert!(validate_custom_slug("a").is_ok()); + assert!(validate_custom_slug("abc123").is_ok()); +} + +#[test] +fn validate_custom_slug_rejects_empty() { + assert!(validate_custom_slug("").is_err()); +} + +#[test] +fn validate_custom_slug_rejects_uppercase() { + assert!(validate_custom_slug("Dirty-Panda").is_err()); +} + +#[test] +fn validate_custom_slug_rejects_special_chars() { + assert!(validate_custom_slug("dirty_panda").is_err()); + assert!(validate_custom_slug("dirty.panda").is_err()); + assert!(validate_custom_slug("dirty panda").is_err()); +} + +#[test] +fn validate_custom_slug_rejects_leading_trailing_hyphens() { + assert!(validate_custom_slug("-dirty").is_err()); + assert!(validate_custom_slug("dirty-").is_err()); + assert!(validate_custom_slug("-dirty-").is_err()); +} + +#[test] +fn validate_custom_slug_rejects_too_long_composite() { + // compose_slug joins as "{slug}-{app_name}" and must be <= 63 + // Use a slug that when combined with a reasonable app name exceeds 63 + let long_slug = "a".repeat(60); + assert!(validate_custom_slug_with_app(&long_slug, "my-app").is_err()); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib config::tests::validate_custom_slug 2>&1 | head -30` +Expected: compilation errors — functions don't exist yet + +- [ ] **Step 3: Write minimal implementation** + +Add to `src/config.rs` after the `compose_slug` function (after line 328): + +```rust +/// Validate a user-provided custom slug prefix. +/// Unlike `sanitize_subdomain` which transforms input, this rejects invalid input. +/// Rules: lowercase alphanumeric + hyphens, no leading/trailing hyphens, non-empty. +pub fn validate_custom_slug(slug: &str) -> Result<()> { + if slug.is_empty() { + bail!("slug cannot be empty"); + } + if slug.starts_with('-') || slug.ends_with('-') { + bail!("slug cannot start or end with a hyphen: '{slug}'"); + } + if !slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + bail!("slug must contain only lowercase letters, digits, and hyphens: '{slug}'"); + } + Ok(()) +} + +/// Validate a custom slug and check the composite length with app name. +pub fn validate_custom_slug_with_app(slug: &str, app_name: &str) -> Result<()> { + validate_custom_slug(slug)?; + let composite = compose_slug(slug, app_name); + if composite.len() > 63 { + bail!( + "slug '{slug}' combined with app name '{app_name}' is {} chars (max 63)", + format!("{slug}-{app_name}").len() + ); + } + Ok(()) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --lib config::tests::validate_custom_slug` +Expected: all 7 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/config.rs +git commit -m "feat: add validate_custom_slug() for custom slug validation" +``` + +--- + +### Task 2: Restructure CLI — add `--slug`, `Stop`, `Start`, `Daemon` subcommand group + +**Files:** +- Modify: `src/cli.rs` + +- [ ] **Step 1: Write the failing tests** + +Replace the existing test and add new ones in `src/cli.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_up_no_slug() { + let cli = Cli::try_parse_from(["devproxy", "up"]).expect("should parse up"); + match cli.command { + Commands::Up { slug } => assert!(slug.is_none()), + _ => panic!("expected Up"), + } + } + + #[test] + fn test_parse_up_with_slug() { + let cli = Cli::try_parse_from(["devproxy", "up", "--slug", "dirty-panda"]) + .expect("should parse up --slug"); + match cli.command { + Commands::Up { slug } => assert_eq!(slug.as_deref(), Some("dirty-panda")), + _ => panic!("expected Up"), + } + } + + #[test] + fn test_parse_stop() { + let cli = Cli::try_parse_from(["devproxy", "stop"]).expect("should parse stop"); + assert!(matches!(cli.command, Commands::Stop)); + } + + #[test] + fn test_parse_start() { + let cli = Cli::try_parse_from(["devproxy", "start"]).expect("should parse start"); + assert!(matches!(cli.command, Commands::Start)); + } + + #[test] + fn test_parse_restart() { + let cli = Cli::try_parse_from(["devproxy", "restart"]).expect("should parse restart"); + assert!(matches!(cli.command, Commands::Restart)); + } + + #[test] + fn test_parse_daemon_run() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "run"]) + .expect("should parse daemon run"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { + assert_eq!(port, 443); + } + _ => panic!("expected Daemon Run"), + } + } + + #[test] + fn test_parse_daemon_run_with_port() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "run", "--port", "8443"]) + .expect("should parse daemon run --port"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { + assert_eq!(port, 8443); + } + _ => panic!("expected Daemon Run"), + } + } + + #[test] + fn test_parse_daemon_restart() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "restart"]) + .expect("should parse daemon restart"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Restart } => {} + _ => panic!("expected Daemon Restart"), + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib cli::tests 2>&1 | head -20` +Expected: compilation errors + +- [ ] **Step 3: Write the implementation** + +Replace the entire `src/cli.rs` content: + +```rust +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "devproxy", + about = "Local HTTPS dev subdomains for Docker Compose", + version = env!("CARGO_PKG_VERSION") +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// One-time setup: generate certs, trust CA, start daemon + Init { + /// Domain for dev subdomains (e.g., mysite.dev) + #[arg(long, default_value = "mysite.dev")] + domain: String, + /// Port for the daemon to listen on (default: 443) + #[arg(long, default_value = "443")] + port: u16, + /// Skip starting the daemon (useful for CI or testing) + #[arg(long)] + no_daemon: bool, + }, + /// Start this project and assign a dev subdomain + Up { + /// Custom slug prefix (e.g., --slug dirty-panda for dirty-panda-myapp.mysite.dev) + #[arg(long)] + slug: Option, + }, + /// Stop this project and remove override file + Down, + /// Stop containers without removing override (preserves slug) + Stop, + /// Start previously stopped containers (reuses existing slug) + Start, + /// Restart app containers (stop + start) + Restart, + /// List all running projects with slugs and URLs + Ls, + /// Print this project's proxy URL (empty + exit 1 if not running) + GetUrl, + /// Open this project's URL in the browser + Open, + /// Show daemon health and active route count + Status, + /// Check for updates and self-update the binary + Update, + /// Daemon management (run, restart) + Daemon { + #[command(subcommand)] + subcommand: DaemonCommand, + }, +} + +#[derive(Subcommand)] +pub enum DaemonCommand { + /// Run the proxy daemon (internal, used by launchd/systemd) + #[command(hide = true)] + Run { + /// Port to listen on (default: 443) + #[arg(long, default_value = "443")] + port: u16, + }, + /// Restart the background daemon process + Restart, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_up_no_slug() { + let cli = Cli::try_parse_from(["devproxy", "up"]).expect("should parse up"); + match cli.command { + Commands::Up { slug } => assert!(slug.is_none()), + _ => panic!("expected Up"), + } + } + + #[test] + fn test_parse_up_with_slug() { + let cli = Cli::try_parse_from(["devproxy", "up", "--slug", "dirty-panda"]) + .expect("should parse up --slug"); + match cli.command { + Commands::Up { slug } => assert_eq!(slug.as_deref(), Some("dirty-panda")), + _ => panic!("expected Up"), + } + } + + #[test] + fn test_parse_stop() { + let cli = Cli::try_parse_from(["devproxy", "stop"]).expect("should parse stop"); + assert!(matches!(cli.command, Commands::Stop)); + } + + #[test] + fn test_parse_start() { + let cli = Cli::try_parse_from(["devproxy", "start"]).expect("should parse start"); + assert!(matches!(cli.command, Commands::Start)); + } + + #[test] + fn test_parse_restart() { + let cli = Cli::try_parse_from(["devproxy", "restart"]).expect("should parse restart"); + assert!(matches!(cli.command, Commands::Restart)); + } + + #[test] + fn test_parse_daemon_run() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "run"]) + .expect("should parse daemon run"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { + assert_eq!(port, 443); + } + _ => panic!("expected Daemon Run"), + } + } + + #[test] + fn test_parse_daemon_run_with_port() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "run", "--port", "8443"]) + .expect("should parse daemon run --port"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { + assert_eq!(port, 8443); + } + _ => panic!("expected Daemon Run"), + } + } + + #[test] + fn test_parse_daemon_restart() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "restart"]) + .expect("should parse daemon restart"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Restart } => {} + _ => panic!("expected Daemon Restart"), + } + } +} +``` + +- [ ] **Step 4: Update main.rs dispatch** + +Replace the match block in `src/main.rs`: + +```rust +match cli.command { + Commands::Init { + domain, + port, + no_daemon, + } => commands::init::run(&domain, port, no_daemon), + Commands::Up { slug } => commands::up::run(slug.as_deref()), + Commands::Down => commands::down::run(), + Commands::Stop => commands::stop::run(), + Commands::Start => commands::start::run(), + Commands::Restart => commands::restart::run(), + Commands::GetUrl => commands::get_url::run(), + Commands::Ls => commands::ls::run().await, + Commands::Open => commands::open::run().await, + Commands::Status => commands::status::run().await, + Commands::Update => commands::update::run().await, + Commands::Daemon { subcommand } => match subcommand { + cli::DaemonCommand::Run { port } => commands::daemon::run(port).await, + cli::DaemonCommand::Restart => commands::daemon::restart(), + }, +} +``` + +- [ ] **Step 5: Update commands/mod.rs** + +Add the new modules: + +```rust +pub mod daemon; +pub mod down; +pub mod get_url; +pub mod init; +pub mod ls; +pub mod open; +pub mod restart; +pub mod start; +pub mod status; +pub mod stop; +pub mod up; +pub mod update; +``` + +- [ ] **Step 6: Create stub command files so it compiles** + +Create `src/commands/stop.rs`: +```rust +use anyhow::{Result, bail}; + +pub fn run() -> Result<()> { + bail!("not yet implemented") +} +``` + +Create `src/commands/start.rs`: +```rust +use anyhow::{Result, bail}; + +pub fn run() -> Result<()> { + bail!("not yet implemented") +} +``` + +- [ ] **Step 7: Update up.rs signature** + +Change the signature in `src/commands/up.rs` from `pub fn run() -> Result<()>` to `pub fn run(_slug: Option<&str>) -> Result<()>`. The `_slug` parameter is unused for now. + +- [ ] **Step 8: Add daemon restart function** + +Add to `src/commands/daemon.rs`: + +```rust +pub fn restart() -> anyhow::Result<()> { + use colored::Colorize; + match crate::platform::restart_daemon() { + Ok(true) => { + eprintln!("{} daemon restarted", "ok:".green()); + Ok(()) + } + Ok(false) => { + eprintln!( + "{} no platform-managed daemon found. Run {} to set one up", + "error:".red(), + "devproxy init".bold() + ); + std::process::exit(1); + } + Err(e) => Err(e), + } +} +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `cargo test --lib cli::tests` +Expected: all 8 tests pass + +Run: `cargo test --lib config::tests` +Expected: all existing + new tests pass + +- [ ] **Step 10: Commit** + +```bash +git add src/cli.rs src/main.rs src/commands/mod.rs src/commands/stop.rs src/commands/start.rs src/commands/up.rs src/commands/daemon.rs +git commit -m "feat: restructure CLI for stop/start/restart and daemon subcommands + +Add --slug flag to up, stop/start commands (stubs), daemon run/restart +subcommands. Moves daemon restart from top-level restart to daemon restart." +``` + +--- + +## Chunk 2: Command Implementations + +### Task 3: Implement `devproxy up` with slug reuse and `--slug` flag + +**Files:** +- Modify: `src/commands/up.rs` + +- [ ] **Step 1: Rewrite up.rs with slug resolution logic** + +Replace the entire `src/commands/up.rs`: + +```rust +use crate::config::{self, Config}; +use crate::slugs; +use anyhow::{Context, Result, bail}; +use colored::Colorize; + +pub fn run(custom_slug: Option<&str>) -> 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")?; + + eprintln!( + "found compose file: {}", + compose_path.display().to_string().cyan() + ); + + let compose = config::parse_compose_file(&compose_path)?; + let (service_name, container_port) = config::find_devproxy_service(&compose)?; + eprintln!( + "service: {}, container port: {}", + service_name.cyan(), + container_port.to_string().cyan() + ); + + // Check for existing project state (reuse if present) + let project_path = compose_dir.join(".devproxy-project"); + let override_path = compose_dir.join(".devproxy-override.yml"); + let reusing = project_path.exists() && override_path.exists(); + + let slug = if reusing { + let existing_slug = config::read_project_file(compose_dir)?; + if custom_slug.is_some() { + eprintln!( + "{} ignoring --slug, reusing existing slug. Run `devproxy down` first to change slug.", + "warn:".yellow() + ); + } + eprintln!("slug: {} (reusing)", existing_slug.cyan()); + existing_slug + } else { + let app_name = config::detect_app_name(&cwd)?; + eprintln!("app: {}", app_name.cyan()); + + let slug_prefix = match custom_slug { + Some(s) => { + config::validate_custom_slug_with_app(s, &app_name)?; + s.to_string() + } + None => slugs::generate_slug(), + }; + let slug = config::compose_slug(&slug_prefix, &app_name); + eprintln!("slug: {}", slug.cyan()); + + let host_port = config::find_free_port()?; + eprintln!("host port: {}", host_port.to_string().cyan()); + + config::write_override_file(compose_dir, &service_name, host_port, container_port)?; + eprintln!( + "override: {}", + override_path.display().to_string().cyan() + ); + + config::write_project_file(compose_dir, &slug)?; + slug + }; + + // Verify daemon is running + let socket_path = Config::socket_path()?; + if !socket_path.exists() { + bail!( + "daemon is not running (no socket at {}). Run `devproxy init` first.", + socket_path.display() + ); + } + + if !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) { + bail!( + "daemon is not running (no response from {}). Run `devproxy init` first.", + socket_path.display() + ); + } + + // Run docker compose up + let compose_file_name = compose_path + .file_name() + .context("no filename")? + .to_string_lossy(); + + let status = std::process::Command::new("docker") + .args([ + "compose", + "-f", + &compose_file_name, + "-f", + ".devproxy-override.yml", + "--project-name", + &slug, + "up", + "-d", + ]) + .current_dir(compose_dir) + .status() + .context("failed to run docker compose")?; + + if !status.success() { + // Only clean up files we just created (not reused ones) + if !reusing { + let _ = std::fs::remove_file(&override_path); + let _ = std::fs::remove_file(&project_path); + } + bail!("docker compose up failed"); + } + + let url = format!("https://{slug}.{}", config.domain); + eprintln!(); + eprintln!("{} {}", "->".green().bold(), url.green().bold()); + + Ok(()) +} +``` + +**Note on cleanup logic change:** The `reusing` boolean is captured before any file writes. On `docker compose up` failure, we only clean up files we freshly created (`!reusing`), not files that existed beforehand. For daemon-not-running errors, we bail without cleanup since the files may be intentionally there from a previous `stop`. + +- [ ] **Step 2: Run clippy and tests** + +Run: `cargo clippy --all-targets 2>&1 | tail -20` +Run: `cargo test --lib 2>&1 | tail -20` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/up.rs +git commit -m "feat: up command reuses existing slug/override and supports --slug" +``` + +--- + +### Task 4: Implement `devproxy stop` + +**Files:** +- Modify: `src/commands/stop.rs` + +- [ ] **Step 1: Implement stop.rs** + +Replace `src/commands/stop.rs`: + +```rust +use crate::config; +use anyhow::{Context, Result}; +use colored::Colorize; + +pub fn run() -> Result<()> { + 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")?; + + let slug = config::read_project_file(compose_dir)?; + eprintln!("project: {}", slug.cyan()); + + let compose_file_name = compose_path + .file_name() + .context("no filename")? + .to_string_lossy() + .to_string(); + + let status = std::process::Command::new("docker") + .args([ + "compose", + "-f", + &compose_file_name, + "-f", + ".devproxy-override.yml", + "--project-name", + &slug, + "stop", + ]) + .current_dir(compose_dir) + .status() + .context("failed to run docker compose stop")?; + + if !status.success() { + eprintln!("{} docker compose stop exited with error", "warn:".yellow()); + } + + eprintln!("{} project stopped (slug and override preserved)", "ok:".green()); + Ok(()) +} +``` + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy --all-targets 2>&1 | tail -10` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/stop.rs +git commit -m "feat: add devproxy stop command (preserves slug and override)" +``` + +--- + +### Task 5: Implement `devproxy start` + +**Files:** +- Modify: `src/commands/start.rs` + +- [ ] **Step 1: Implement start.rs** + +Replace `src/commands/start.rs`: + +```rust +use crate::config::{self, Config}; +use anyhow::{Context, Result, bail}; +use colored::Colorize; + +pub fn run() -> Result<()> { + 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")?; + + let slug = config::read_project_file(compose_dir)?; + eprintln!("project: {}", slug.cyan()); + + let override_path = compose_dir.join(".devproxy-override.yml"); + if !override_path.exists() { + bail!("override file missing. Run `devproxy up` to reconfigure."); + } + + // Verify daemon is running + let socket_path = Config::socket_path()?; + if !socket_path.exists() + || !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) + { + bail!("daemon is not running. Run `devproxy init` first."); + } + + let compose_file_name = compose_path + .file_name() + .context("no filename")? + .to_string_lossy() + .to_string(); + + let status = std::process::Command::new("docker") + .args([ + "compose", + "-f", + &compose_file_name, + "-f", + ".devproxy-override.yml", + "--project-name", + &slug, + "start", + ]) + .current_dir(compose_dir) + .status() + .context("failed to run docker compose start")?; + + if !status.success() { + bail!("docker compose start failed"); + } + + let config = Config::load().context("run `devproxy init` first")?; + let url = format!("https://{slug}.{}", config.domain); + eprintln!(); + eprintln!("{} {}", "->".green().bold(), url.green().bold()); + + Ok(()) +} +``` + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy --all-targets 2>&1 | tail -10` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/start.rs +git commit -m "feat: add devproxy start command (resumes stopped containers)" +``` + +--- + +### Task 6: Rewrite `devproxy restart` for app stack + +**Files:** +- Modify: `src/commands/restart.rs` + +- [ ] **Step 1: Rewrite restart.rs** + +Replace `src/commands/restart.rs`: + +```rust +use crate::config::{self, Config}; +use anyhow::{Context, Result, bail}; +use colored::Colorize; + +pub fn run() -> Result<()> { + 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")?; + + let slug = config::read_project_file(compose_dir)?; + eprintln!("project: {}", slug.cyan()); + + let override_path = compose_dir.join(".devproxy-override.yml"); + if !override_path.exists() { + bail!("override file missing. Run `devproxy up` to reconfigure."); + } + + // Verify daemon is running (same checks as start, per spec) + let socket_path = Config::socket_path()?; + if !socket_path.exists() + || !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) + { + bail!("daemon is not running. Run `devproxy init` first."); + } + + let compose_file_name = compose_path + .file_name() + .context("no filename")? + .to_string_lossy() + .to_string(); + + let status = std::process::Command::new("docker") + .args([ + "compose", + "-f", + &compose_file_name, + "-f", + ".devproxy-override.yml", + "--project-name", + &slug, + "restart", + ]) + .current_dir(compose_dir) + .status() + .context("failed to run docker compose restart")?; + + if !status.success() { + bail!("docker compose restart failed"); + } + + let config = Config::load().context("run `devproxy init` first")?; + let url = format!("https://{slug}.{}", config.domain); + eprintln!(); + eprintln!("{} {}", "->".green().bold(), url.green().bold()); + + Ok(()) +} +``` + +- [ ] **Step 2: Run clippy and all lib tests** + +Run: `cargo clippy --all-targets 2>&1 | tail -10` +Run: `cargo test --lib 2>&1 | tail -20` +Expected: all pass + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/restart.rs +git commit -m "feat: restart now restarts app stack instead of daemon + +Daemon restart moved to devproxy daemon restart." +``` + +--- + +## Chunk 3: Platform Updates + Launchd/Systemd Compatibility + +### Task 7: Update platform plist/unit generation for `daemon run` subcommand + +**Files:** +- Modify: `src/platform.rs` + +The plist currently generates `daemon--port`. It needs to become `daemonrun--port`. Same for systemd ExecStart. + +- [ ] **Step 1: Update the existing platform tests first** + +In `src/platform.rs`, find the test assertions that check for `"daemon --port"` and update them: + +In `test_systemd_service_unit_contains_binary_and_port`: +```rust +// Change: assert!(unit.contains("daemon --port 443"), ...); +// To: +assert!(unit.contains("daemon run --port 443"), "should run daemon run subcommand with port"); +``` + +In `test_systemd_service_unit_custom_port`: +```rust +// Change: assert!(unit.contains("daemon --port 8443"), ...); +// To: +assert!(unit.contains("daemon run --port 8443"), "should use custom port in ExecStart"); +``` + +The launchd plist tests check for individual `` elements, so they need a new assertion for the `run` argument. Add to `test_launchagent_plist_contains_required_fields`: +```rust +assert!(plist.contains("run"), "should have run subcommand"); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib platform::tests 2>&1 | tail -20` +Expected: assertion failures on the updated strings + +- [ ] **Step 3: Update plist generation** + +In `src/platform.rs` `generate_launchagent_plist()`, update the ProgramArguments array (around line 108-113): + +Change: +```xml + ProgramArguments + + {binary_path} + daemon + --port + {port} + +``` +To: +```xml + ProgramArguments + + {binary_path} + daemon + run + --port + {port} + +``` + +- [ ] **Step 4: Update systemd service generation** + +In `generate_systemd_service_unit()`, change the ExecStart line (around line 180): + +Change: `ExecStart="{binary_path}" daemon --port {port}` +To: `ExecStart="{binary_path}" daemon run --port {port}` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib platform::tests` +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add src/platform.rs +git commit -m "feat: update plist/unit templates for daemon run subcommand" +``` + +--- + +## Chunk 4: Documentation + Plugin Sync + +### Task 8: Update README.md + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update the example and commands table** + +Update the example at the top to show `--slug`: + +```bash +devproxy up +# → https://swift-penguin-myapp.mysite.dev + +devproxy up --slug my-app +# → https://my-app-myapp.mysite.dev +``` + +Update the commands table: + +```markdown +| Command | Description | +|----------------------|---------------------------------------------------| +| `devproxy init` | One-time setup: certs, CA trust, daemon | +| `devproxy up` | Start project, assign slug, proxy it | +| `devproxy up --slug` | Start project with a custom slug prefix | +| `devproxy down` | Stop project, remove override and slug | +| `devproxy stop` | Stop containers (preserves slug for restart) | +| `devproxy start` | Start previously stopped containers | +| `devproxy restart` | Restart app containers | +| `devproxy ls` | List running projects with URLs | +| `devproxy open` | Open project URL in browser | +| `devproxy status` | Daemon health check | +| `devproxy daemon restart` | Restart the background daemon | +| `devproxy update` | Check for updates and self-update | +| `devproxy --version` | Show installed version | +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: update README with stop/start/restart and --slug flag" +``` + +--- + +### Task 9: Update skills/devproxy/SKILL.md + +**Files:** +- Modify: `skills/devproxy/SKILL.md` + +- [ ] **Step 1: Update the command table and descriptions** + +Update the commands table to add new commands and update `restart`: + +```markdown +| Command | What it does | +|----------------------------------|-------------------------------------------------| +| `devproxy init --domain X` | One-time: certs, CA trust, start daemon | +| `devproxy init --port 8443` | Use non-privileged port (avoids sudo on Linux) | +| `devproxy up` | Assign slug, bind port, `docker compose up -d` | +| `devproxy up --slug NAME` | Use custom slug prefix for predictable URLs | +| `devproxy down` | `docker compose down` + remove override & slug | +| `devproxy stop` | `docker compose stop` (preserves slug/override) | +| `devproxy start` | `docker compose start` (reuses existing slug) | +| `devproxy restart` | Restart app containers (stop + start) | +| `devproxy ls` | List running projects with slugs and URLs | +| `devproxy get-url` | Print this project's proxy URL (for scripting) | +| `devproxy open` | Open this project's URL in browser | +| `devproxy daemon restart` | Restart the background daemon process | +| `devproxy update` | Check for updates and self-update the binary | +| `devproxy --version` | Show installed version | +| `devproxy status` | Daemon health + active route count | +``` + +Update the "Daemon Lifecycle" section to change `devproxy restart` reference to `devproxy daemon restart`. + +Update the "Common Issues" table — change the "Slug changed after restart" row: +```markdown +| Slug changed after restart | Use `devproxy stop`/`start` to preserve slug, or `devproxy up --slug NAME` for a predictable slug | +``` + +Update the trigger description in the frontmatter to include the new commands: +``` +"devproxy stop", "devproxy start", "devproxy daemon restart" +``` + +- [ ] **Step 2: Commit** + +```bash +git add skills/devproxy/SKILL.md +git commit -m "docs: update devproxy skill with new commands and --slug" +``` + +--- + +### Task 10: Bump version in Cargo.toml and plugin.json + +**Files:** +- Modify: `Cargo.toml` +- Modify: `.claude-plugin/plugin.json` + +- [ ] **Step 1: Bump version to 0.5.0** + +In `Cargo.toml`, change `version = "0.4.4"` to `version = "0.5.0"`. + +In `.claude-plugin/plugin.json`, change `"version": "0.4.4"` to `"version": "0.5.0"`. + +- [ ] **Step 2: Commit** + +```bash +git add Cargo.toml .claude-plugin/plugin.json +git commit -m "chore: bump version to 0.5.0 for breaking restart change" +``` + +--- + +### Task 11: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `cargo clippy --all-targets 2>&1` +Run: `cargo test --lib 2>&1` +Expected: all pass, no warnings + +- [ ] **Step 2: Build release binary** + +Run: `cargo build --release 2>&1 | tail -5` +Expected: successful build + +- [ ] **Step 3: Verify help output** + +Run: `cargo run -- --help 2>&1` +Run: `cargo run -- up --help 2>&1` +Run: `cargo run -- daemon --help 2>&1` +Run: `cargo run -- daemon restart --help 2>&1` +Expected: all show correct descriptions and options From a8abcaa9011808d8b17639c1233159377fb7f6de Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 14:07:54 -0700 Subject: [PATCH 3/8] docs: improve implementation plan for custom slugs and compose commands Add decisions section with justifications, fix daemon check cleanup logic in up.rs for !reusing path, add missing e2e test updates (start_test_daemon, help output, restart tests), add tasks for skills/setup/SKILL.md and docs/spec.md updates. Co-Authored-By: Claude Opus 4.6 --- ...ustom-slugs-and-docker-compose-commands.md | 363 ++++++++++++------ 1 file changed, 246 insertions(+), 117 deletions(-) diff --git a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md index 15309e2..4e2c380 100644 --- a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md +++ b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md @@ -10,6 +10,31 @@ **Spec:** `docs/superpowers/specs/2026-03-10-custom-slugs-and-docker-compose-commands-design.md` +**Breaking change:** `devproxy restart` changes from daemon restart to app stack restart. Daemon restart moves to `devproxy daemon restart`. The `Daemon` CLI variant changes from a hidden top-level command to a visible subcommand group. Version bumped to 0.5.0. + +--- + +## Decisions and Justifications + +### D1: Daemon check placement in `up.rs` + +The current `up.rs` checks the daemon AFTER writing override/project files and cleans up on failure. The new `up.rs` introduces a `reusing` flag. On the fresh (`!reusing`) path, daemon-not-running errors should still clean up the just-written files, matching the current behavior. On the `reusing` path, no cleanup is needed since the files pre-existed. This is implemented by checking `!reusing` in cleanup guards. + +### D2: E2E test updates for CLI restructuring + +The e2e tests have several references to the old CLI structure that must be updated: +- `start_test_daemon()` calls `["daemon", "--port", ...]` — must become `["daemon", "run", "--port", ...]` +- `test_restart_no_daemon` and `test_restart_running_daemon` test `devproxy restart` for daemon restart behavior — must be updated to test `devproxy daemon restart` +- `test_help_output` asserts `daemon` is hidden from help — must be updated since `daemon` is now a visible subcommand group (only `daemon run` is hidden) + +### D3: `restart` e2e tests — rewrite vs. remove + +The two daemon restart e2e tests (`test_restart_no_daemon`, `test_restart_running_daemon`) test that `devproxy restart` reports "no platform-managed daemon found" when `DEVPROXY_NO_SOCKET_ACTIVATION=1`. After restructuring, these should test `devproxy daemon restart` instead. The behavior is identical — just the command path changes. Additionally, `devproxy restart` (now app-stack restart) will fail in these tests because there's no compose project, but that's a different error. We rewrite the tests to target `daemon restart`. + +### D4: Version bump rationale + +0.4.4 -> 0.5.0 because `devproxy restart` changes from daemon restart to app stack restart. This is a breaking behavioral change for users who have `devproxy restart` in scripts. + --- ## Chunk 1: Validation + CLI Structure @@ -17,7 +42,7 @@ ### Task 1: Add `validate_custom_slug()` to config.rs **Files:** -- Modify: `src/config.rs:317-328` (after `compose_slug`) +- Modify: `src/config.rs` (after `compose_slug` function, ~line 328) - [ ] **Step 1: Write the failing tests** @@ -108,7 +133,7 @@ pub fn validate_custom_slug_with_app(slug: &str, app_name: &str) -> Result<()> { - [ ] **Step 4: Run tests to verify they pass** Run: `cargo test --lib config::tests::validate_custom_slug` -Expected: all 7 tests pass +Expected: all 7 tests pass (6 for validate_custom_slug + 1 for validate_custom_slug_with_app) - [ ] **Step 5: Commit** @@ -123,97 +148,16 @@ git commit -m "feat: add validate_custom_slug() for custom slug validation" **Files:** - Modify: `src/cli.rs` +- Modify: `src/main.rs` +- Modify: `src/commands/mod.rs` +- Modify: `src/commands/up.rs` (signature only) +- Modify: `src/commands/daemon.rs` (add restart fn) +- Create: `src/commands/stop.rs` (stub) +- Create: `src/commands/start.rs` (stub) -- [ ] **Step 1: Write the failing tests** +- [ ] **Step 1: Replace the entire `src/cli.rs`** -Replace the existing test and add new ones in `src/cli.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_up_no_slug() { - let cli = Cli::try_parse_from(["devproxy", "up"]).expect("should parse up"); - match cli.command { - Commands::Up { slug } => assert!(slug.is_none()), - _ => panic!("expected Up"), - } - } - - #[test] - fn test_parse_up_with_slug() { - let cli = Cli::try_parse_from(["devproxy", "up", "--slug", "dirty-panda"]) - .expect("should parse up --slug"); - match cli.command { - Commands::Up { slug } => assert_eq!(slug.as_deref(), Some("dirty-panda")), - _ => panic!("expected Up"), - } - } - - #[test] - fn test_parse_stop() { - let cli = Cli::try_parse_from(["devproxy", "stop"]).expect("should parse stop"); - assert!(matches!(cli.command, Commands::Stop)); - } - - #[test] - fn test_parse_start() { - let cli = Cli::try_parse_from(["devproxy", "start"]).expect("should parse start"); - assert!(matches!(cli.command, Commands::Start)); - } - - #[test] - fn test_parse_restart() { - let cli = Cli::try_parse_from(["devproxy", "restart"]).expect("should parse restart"); - assert!(matches!(cli.command, Commands::Restart)); - } - - #[test] - fn test_parse_daemon_run() { - let cli = Cli::try_parse_from(["devproxy", "daemon", "run"]) - .expect("should parse daemon run"); - match cli.command { - Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { - assert_eq!(port, 443); - } - _ => panic!("expected Daemon Run"), - } - } - - #[test] - fn test_parse_daemon_run_with_port() { - let cli = Cli::try_parse_from(["devproxy", "daemon", "run", "--port", "8443"]) - .expect("should parse daemon run --port"); - match cli.command { - Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { - assert_eq!(port, 8443); - } - _ => panic!("expected Daemon Run"), - } - } - - #[test] - fn test_parse_daemon_restart() { - let cli = Cli::try_parse_from(["devproxy", "daemon", "restart"]) - .expect("should parse daemon restart"); - match cli.command { - Commands::Daemon { subcommand: DaemonCommand::Restart } => {} - _ => panic!("expected Daemon Restart"), - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test --lib cli::tests 2>&1 | head -20` -Expected: compilation errors - -- [ ] **Step 3: Write the implementation** - -Replace the entire `src/cli.rs` content: +The existing `cli.rs` has a flat `Commands` enum with `Daemon { port }` as a hidden variant and `Restart` as a daemon-restart command. Replace with: ```rust use clap::{Parser, Subcommand}; @@ -364,7 +308,7 @@ mod tests { } ``` -- [ ] **Step 4: Update main.rs dispatch** +- [ ] **Step 2: Update main.rs dispatch** Replace the match block in `src/main.rs`: @@ -392,9 +336,9 @@ match cli.command { } ``` -- [ ] **Step 5: Update commands/mod.rs** +- [ ] **Step 3: Update commands/mod.rs** -Add the new modules: +Add the new modules (keep alphabetical order): ```rust pub mod daemon; @@ -411,7 +355,7 @@ pub mod up; pub mod update; ``` -- [ ] **Step 6: Create stub command files so it compiles** +- [ ] **Step 4: Create stub command files so it compiles** Create `src/commands/stop.rs`: ```rust @@ -431,13 +375,13 @@ pub fn run() -> Result<()> { } ``` -- [ ] **Step 7: Update up.rs signature** +- [ ] **Step 5: Update up.rs signature** Change the signature in `src/commands/up.rs` from `pub fn run() -> Result<()>` to `pub fn run(_slug: Option<&str>) -> Result<()>`. The `_slug` parameter is unused for now. -- [ ] **Step 8: Add daemon restart function** +- [ ] **Step 6: Add daemon restart function** -Add to `src/commands/daemon.rs`: +Add to `src/commands/daemon.rs` (after the existing `run` function): ```rust pub fn restart() -> anyhow::Result<()> { @@ -460,7 +404,9 @@ pub fn restart() -> anyhow::Result<()> { } ``` -- [ ] **Step 9: Run tests to verify they pass** +**Note:** This is the same logic that currently lives in `src/commands/restart.rs`. It moves to `daemon.rs` because it handles the daemon lifecycle, not the app stack. + +- [ ] **Step 7: Run tests to verify compilation and unit tests pass** Run: `cargo test --lib cli::tests` Expected: all 8 tests pass @@ -468,7 +414,7 @@ Expected: all 8 tests pass Run: `cargo test --lib config::tests` Expected: all existing + new tests pass -- [ ] **Step 10: Commit** +- [ ] **Step 8: Commit** ```bash git add src/cli.rs src/main.rs src/commands/mod.rs src/commands/stop.rs src/commands/start.rs src/commands/up.rs src/commands/daemon.rs @@ -561,9 +507,15 @@ pub fn run(custom_slug: Option<&str>) -> Result<()> { slug }; - // Verify daemon is running + // Verify daemon is running. + // On the !reusing path, clean up freshly-written files on failure. + // On the reusing path, files pre-existed so leave them alone. let socket_path = Config::socket_path()?; if !socket_path.exists() { + if !reusing { + let _ = std::fs::remove_file(&override_path); + let _ = std::fs::remove_file(&project_path); + } bail!( "daemon is not running (no socket at {}). Run `devproxy init` first.", socket_path.display() @@ -571,6 +523,10 @@ pub fn run(custom_slug: Option<&str>) -> Result<()> { } if !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) { + if !reusing { + let _ = std::fs::remove_file(&override_path); + let _ = std::fs::remove_file(&project_path); + } bail!( "daemon is not running (no response from {}). Run `devproxy init` first.", socket_path.display() @@ -616,7 +572,11 @@ pub fn run(custom_slug: Option<&str>) -> Result<()> { } ``` -**Note on cleanup logic change:** The `reusing` boolean is captured before any file writes. On `docker compose up` failure, we only clean up files we freshly created (`!reusing`), not files that existed beforehand. For daemon-not-running errors, we bail without cleanup since the files may be intentionally there from a previous `stop`. +**Key behavioral changes from current `up.rs`:** + +1. **Slug reuse:** When `.devproxy-project` and `.devproxy-override.yml` both exist, the existing slug and port binding are reused. `--slug` is ignored with a warning. +2. **Custom slug support:** When files don't exist, `--slug` replaces the random slug prefix. Validation runs before any side effects. +3. **Cleanup guards:** On the `!reusing` path, freshly-written files are cleaned up on daemon-not-running or docker-compose-up failures. On the `reusing` path, files are left intact (they pre-existed, possibly from a `devproxy stop`). - [ ] **Step 2: Run clippy and tests** @@ -958,17 +918,120 @@ git commit -m "feat: update plist/unit templates for daemon run subcommand" --- -## Chunk 4: Documentation + Plugin Sync +## Chunk 4: E2E Test Updates + +### Task 8: Update e2e tests for CLI restructuring + +**Files:** +- Modify: `tests/e2e.rs` + +The CLI restructuring changes three things that affect e2e tests: + +1. `devproxy daemon --port N` becomes `devproxy daemon run --port N` +2. `devproxy restart` (daemon restart) becomes `devproxy daemon restart` +3. `daemon` is now visible in `--help` (as a subcommand group with only `restart` visible) + +- [ ] **Step 1: Update `start_test_daemon()` helper** + +In `tests/e2e.rs`, find the `start_test_daemon` function (~line 153-155). Change: + +```rust +.args(["daemon", "--port", &port.to_string()]) +``` +To: +```rust +.args(["daemon", "run", "--port", &port.to_string()]) +``` + +- [ ] **Step 2: Update `test_help_output`** + +In `test_help_output` (~line 228-247): + +1. The assertion for `restart` (line 229) is still valid — `restart` is a visible command. +2. Add assertions for `stop`, `start`, and `daemon`: +```rust +assert!( + stdout.contains("stop"), + "help should list the stop command" +); +assert!( + stdout.contains("start"), + "help should list the start command" +); +assert!( + stdout.contains("daemon"), + "help should list the daemon subcommand group" +); +``` +3. Remove the assertion that `daemon` is hidden (~lines 240-247): +```rust +// DELETE these lines — daemon is now a visible subcommand group: +// assert!( +// !stdout +// .lines() +// .any(|l| l.trim_start().starts_with("daemon ")), +// "daemon command should be hidden from help" +// ); +``` + +- [ ] **Step 3: Update daemon restart e2e tests** + +In `test_restart_no_daemon` (~line 351-381), change: +```rust +.args(["restart"]) +``` +To: +```rust +.args(["daemon", "restart"]) +``` + +In `test_restart_running_daemon` (~line 383-408), change: +```rust +.args(["restart"]) +``` +To: +```rust +.args(["daemon", "restart"]) +``` + +The assertion strings ("no platform-managed daemon found") remain the same since the daemon restart logic is unchanged — it just moved from `commands::restart::run()` to `commands::daemon::restart()`. + +- [ ] **Step 4: Run e2e tests (non-ignored)** + +Run: `cargo test --test e2e -- --skip test_launchd --skip test_full_flow --skip test_self_heal --skip test_daemon_restart --skip test_up_fails_fast --skip test_daemon_writes_pid --skip test_reinit --skip test_open --skip test_version_works --skip test_init_daemon --skip test_daemon_binary 2>&1 | tail -30` + +The skipped tests require Docker or are `#[ignore]`. The non-skipped tests (help output, version, init generates certs, restart tests) should pass. + +- [ ] **Step 5: Commit** + +```bash +git add tests/e2e.rs +git commit -m "test: update e2e tests for daemon run subcommand and CLI restructuring + +- start_test_daemon now uses 'daemon run --port' +- daemon restart tests use 'daemon restart' instead of 'restart' +- help output test updated for visible daemon subcommand group" +``` + +--- + +## Chunk 5: Documentation + Plugin Sync -### Task 8: Update README.md +### Task 9: Update README.md **Files:** - Modify: `README.md` - [ ] **Step 1: Update the example and commands table** -Update the example at the top to show `--slug`: +Update the code example at the top (~line 14-17) to show the composite slug format and `--slug`: +Change: +```bash +devproxy up +# → https://swift-penguin.mysite.dev +``` +To: ```bash devproxy up # → https://swift-penguin-myapp.mysite.dev @@ -977,7 +1040,7 @@ devproxy up --slug my-app # → https://my-app-myapp.mysite.dev ``` -Update the commands table: +Update the commands table (~lines 52-62): ```markdown | Command | Description | @@ -1006,14 +1069,18 @@ git commit -m "docs: update README with stop/start/restart and --slug flag" --- -### Task 9: Update skills/devproxy/SKILL.md +### Task 10: Update skills/devproxy/SKILL.md **Files:** - Modify: `skills/devproxy/SKILL.md` -- [ ] **Step 1: Update the command table and descriptions** +- [ ] **Step 1: Update the trigger description in the frontmatter** -Update the commands table to add new commands and update `restart`: +Add `"devproxy stop"`, `"devproxy start"`, `"devproxy daemon restart"` to the description field. + +- [ ] **Step 2: Update the commands table** + +Replace the commands table with: ```markdown | Command | What it does | @@ -1035,28 +1102,83 @@ Update the commands table to add new commands and update `restart`: | `devproxy status` | Daemon health + active route count | ``` -Update the "Daemon Lifecycle" section to change `devproxy restart` reference to `devproxy daemon restart`. +- [ ] **Step 3: Update the "Daemon Lifecycle" section** + +Change `devproxy restart` to `devproxy daemon restart` in the bullet point about restarting. -Update the "Common Issues" table — change the "Slug changed after restart" row: +- [ ] **Step 4: Update the "Common Issues" table** + +Change the "Slug changed after restart" row: ```markdown | Slug changed after restart | Use `devproxy stop`/`start` to preserve slug, or `devproxy up --slug NAME` for a predictable slug | ``` -Update the trigger description in the frontmatter to include the new commands: +Also update the "Connection refused" row to reference `devproxy daemon restart` instead of `devproxy restart`. + +- [ ] **Step 5: Commit** + +```bash +git add skills/devproxy/SKILL.md +git commit -m "docs: update devproxy skill with new commands and --slug" +``` + +--- + +### Task 11: Update skills/setup/SKILL.md + +**Files:** +- Modify: `skills/setup/SKILL.md` + +- [ ] **Step 1: Update daemon restart reference** + +In Step 7 (~line 182), change: +```markdown +- Use `devproxy restart` to restart the daemon if needed +``` +To: +```markdown +- Use `devproxy daemon restart` to restart the daemon if needed +``` + +- [ ] **Step 2: Commit** + +```bash +git add skills/setup/SKILL.md +git commit -m "docs: update setup skill for daemon restart command change" ``` -"devproxy stop", "devproxy start", "devproxy daemon restart" + +--- + +### Task 12: Update docs/spec.md — resolve slug persistence open question + +**Files:** +- Modify: `docs/spec.md` + +- [ ] **Step 1: Mark slug persistence as resolved** + +Find the "Slug persistence" bullet in the "Open questions / future work" section (~line 199-201) and replace: +```markdown +- **Slug persistence**: slugs are stable for the lifetime of a running container but reset on + `devproxy up`. Could offer `devproxy pin ` to write the slug into a `.devproxy` file + so the same slug is always used for a given project. +``` +With: +```markdown +- ~~**Slug persistence**: slugs are stable for the lifetime of a running container but reset on + `devproxy up`. Could offer `devproxy pin ` to write the slug into a `.devproxy` file + so the same slug is always used for a given project.~~ **Done** — `devproxy up --slug NAME` allows predictable slugs. `devproxy stop`/`start` preserves the slug across stop/start cycles without regenerating. ``` - [ ] **Step 2: Commit** ```bash -git add skills/devproxy/SKILL.md -git commit -m "docs: update devproxy skill with new commands and --slug" +git add docs/spec.md +git commit -m "docs: mark slug persistence open question as resolved" ``` --- -### Task 10: Bump version in Cargo.toml and plugin.json +### Task 13: Bump version in Cargo.toml and plugin.json **Files:** - Modify: `Cargo.toml` @@ -1077,7 +1199,9 @@ git commit -m "chore: bump version to 0.5.0 for breaking restart change" --- -### Task 11: Final verification +## Chunk 6: Final Verification + +### Task 14: Final verification - [ ] **Step 1: Run full test suite** @@ -1097,3 +1221,8 @@ Run: `cargo run -- up --help 2>&1` Run: `cargo run -- daemon --help 2>&1` Run: `cargo run -- daemon restart --help 2>&1` Expected: all show correct descriptions and options + +- [ ] **Step 4: Run non-Docker e2e tests** + +Run: `cargo test --test e2e test_cli_help_output test_cli_version test_restart_no_daemon 2>&1` +Expected: all pass From 6e128ce67ae27bf481e14a7cc5a95091dc2c1793 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 14:12:14 -0700 Subject: [PATCH 4/8] fix: correct plan header format and test name references - Use trycycle-executing header format instead of superpowers reference - Fix test_help_output -> test_cli_help (matches actual test name in e2e.rs) - Fix test_cli_help_output -> test_cli_help in final verification step Co-Authored-By: Claude Opus 4.6 --- ...6-03-10-custom-slugs-and-docker-compose-commands.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md index 4e2c380..4c429f6 100644 --- a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md +++ b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md @@ -1,6 +1,6 @@ # Custom Slugs & Docker Compose Command Parity — Implementation Plan -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For Claude:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. **Goal:** Add `--slug` flag to `devproxy up` for predictable URLs, and introduce `stop`, `start`, `restart` (app stack), and `daemon restart` commands to mirror docker compose lifecycle. @@ -25,7 +25,7 @@ The current `up.rs` checks the daemon AFTER writing override/project files and c The e2e tests have several references to the old CLI structure that must be updated: - `start_test_daemon()` calls `["daemon", "--port", ...]` — must become `["daemon", "run", "--port", ...]` - `test_restart_no_daemon` and `test_restart_running_daemon` test `devproxy restart` for daemon restart behavior — must be updated to test `devproxy daemon restart` -- `test_help_output` asserts `daemon` is hidden from help — must be updated since `daemon` is now a visible subcommand group (only `daemon run` is hidden) +- `test_cli_help` asserts `daemon` is hidden from help — must be updated since `daemon` is now a visible subcommand group (only `daemon run` is hidden) ### D3: `restart` e2e tests — rewrite vs. remove @@ -943,9 +943,9 @@ To: .args(["daemon", "run", "--port", &port.to_string()]) ``` -- [ ] **Step 2: Update `test_help_output`** +- [ ] **Step 2: Update `test_cli_help`** -In `test_help_output` (~line 228-247): +In `test_cli_help` (~line 228-247): 1. The assertion for `restart` (line 229) is still valid — `restart` is a visible command. 2. Add assertions for `stop`, `start`, and `daemon`: @@ -1224,5 +1224,5 @@ Expected: all show correct descriptions and options - [ ] **Step 4: Run non-Docker e2e tests** -Run: `cargo test --test e2e test_cli_help_output test_cli_version test_restart_no_daemon 2>&1` +Run: `cargo test --test e2e test_cli_help test_cli_version test_restart_no_daemon 2>&1` Expected: all pass From 83fc45c621415db8cb4a917ad5fd30cea9c9c718 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 14:16:24 -0700 Subject: [PATCH 5/8] fix(plan): correct validate_custom_slug_with_app to check raw length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation called compose_slug() which truncates to 63 chars, then checked the truncated result — the length check could never fail. Fixed to check raw "{slug}-{app_name}" length before truncation, per the design spec requirement that custom slugs are "validated and rejected if invalid (not sanitized/transformed)." Co-Authored-By: Claude Opus 4.6 --- ...-03-10-custom-slugs-and-docker-compose-commands.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md index 4c429f6..0d1362e 100644 --- a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md +++ b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md @@ -117,13 +117,16 @@ pub fn validate_custom_slug(slug: &str) -> Result<()> { } /// Validate a custom slug and check the composite length with app name. +/// Checks the raw `{slug}-{app_name}` length BEFORE truncation — custom slugs +/// should be rejected when too long, not silently truncated (unlike random slugs +/// where truncation is acceptable). See design spec: "validated and rejected if +/// invalid (not sanitized/transformed like app names)." pub fn validate_custom_slug_with_app(slug: &str, app_name: &str) -> Result<()> { validate_custom_slug(slug)?; - let composite = compose_slug(slug, app_name); - if composite.len() > 63 { + let raw_len = slug.len() + 1 + app_name.len(); // "{slug}-{app_name}" + if raw_len > 63 { bail!( - "slug '{slug}' combined with app name '{app_name}' is {} chars (max 63)", - format!("{slug}-{app_name}").len() + "slug '{slug}' combined with app name '{app_name}' is {raw_len} chars (max 63)", ); } Ok(()) From 233b82dff6a81f6b9cdbfce36b0b74326d7b2950 Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 14:20:09 -0700 Subject: [PATCH 6/8] fix(plan): rename e2e tests for clarity, simplify test run commands - Rename test_restart_* to test_daemon_restart_* so it's unambiguous which command they test after the CLI restructuring - Simplify e2e test run commands: use `cargo test --test e2e` instead of brittle --skip lists (ignored tests are skipped by default) - Update final verification step to match Co-Authored-By: Claude Opus 4.6 --- ...ustom-slugs-and-docker-compose-commands.md | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md index 0d1362e..34939be 100644 --- a/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md +++ b/docs/superpowers/plans/2026-03-10-custom-slugs-and-docker-compose-commands.md @@ -24,12 +24,12 @@ The current `up.rs` checks the daemon AFTER writing override/project files and c The e2e tests have several references to the old CLI structure that must be updated: - `start_test_daemon()` calls `["daemon", "--port", ...]` — must become `["daemon", "run", "--port", ...]` -- `test_restart_no_daemon` and `test_restart_running_daemon` test `devproxy restart` for daemon restart behavior — must be updated to test `devproxy daemon restart` +- `test_restart_no_daemon` and `test_restart_running_daemon` test `devproxy restart` for daemon restart behavior — must be updated to test `devproxy daemon restart` and renamed to `test_daemon_restart_*` for clarity - `test_cli_help` asserts `daemon` is hidden from help — must be updated since `daemon` is now a visible subcommand group (only `daemon run` is hidden) -### D3: `restart` e2e tests — rewrite vs. remove +### D3: `restart` e2e tests — rewrite, rename, and clarify -The two daemon restart e2e tests (`test_restart_no_daemon`, `test_restart_running_daemon`) test that `devproxy restart` reports "no platform-managed daemon found" when `DEVPROXY_NO_SOCKET_ACTIVATION=1`. After restructuring, these should test `devproxy daemon restart` instead. The behavior is identical — just the command path changes. Additionally, `devproxy restart` (now app-stack restart) will fail in these tests because there's no compose project, but that's a different error. We rewrite the tests to target `daemon restart`. +The two daemon restart e2e tests (`test_restart_no_daemon`, `test_restart_running_daemon`) test that `devproxy restart` reports "no platform-managed daemon found" when `DEVPROXY_NO_SOCKET_ACTIVATION=1`. After restructuring, these should test `devproxy daemon restart` instead. The behavior is identical — just the command path changes. Additionally, `devproxy restart` (now app-stack restart) will fail in these tests because there's no compose project, but that's a different error. We rewrite the tests to target `daemon restart` and rename them to `test_daemon_restart_no_daemon` / `test_daemon_restart_running_daemon` so it's unambiguous which command they test. ### D4: Version bump rationale @@ -977,9 +977,17 @@ assert!( // ); ``` -- [ ] **Step 3: Update daemon restart e2e tests** +- [ ] **Step 3: Rename and update daemon restart e2e tests** + +Rename `test_restart_no_daemon` to `test_daemon_restart_no_daemon` and update the command args. In `tests/e2e.rs` (~line 350-381): -In `test_restart_no_daemon` (~line 351-381), change: +```rust +// Rename: fn test_restart_no_daemon() -> fn test_daemon_restart_no_daemon() +#[test] +fn test_daemon_restart_no_daemon() { +``` + +Change: ```rust .args(["restart"]) ``` @@ -988,7 +996,15 @@ To: .args(["daemon", "restart"]) ``` -In `test_restart_running_daemon` (~line 383-408), change: +Similarly, rename `test_restart_running_daemon` to `test_daemon_restart_running_daemon` (~line 383-408): + +```rust +// Rename: fn test_restart_running_daemon() -> fn test_daemon_restart_running_daemon() +#[test] +fn test_daemon_restart_running_daemon() { +``` + +Change: ```rust .args(["restart"]) ``` @@ -997,13 +1013,13 @@ To: .args(["daemon", "restart"]) ``` -The assertion strings ("no platform-managed daemon found") remain the same since the daemon restart logic is unchanged — it just moved from `commands::restart::run()` to `commands::daemon::restart()`. +The assertion strings ("no platform-managed daemon found") remain the same since the daemon restart logic is unchanged — it just moved from `commands::restart::run()` to `commands::daemon::restart()`. The renames make it clear these tests target `devproxy daemon restart`, not the new app-stack `devproxy restart`. - [ ] **Step 4: Run e2e tests (non-ignored)** -Run: `cargo test --test e2e -- --skip test_launchd --skip test_full_flow --skip test_self_heal --skip test_daemon_restart --skip test_up_fails_fast --skip test_daemon_writes_pid --skip test_reinit --skip test_open --skip test_version_works --skip test_init_daemon --skip test_daemon_binary 2>&1 | tail -30` +Run: `cargo test --test e2e 2>&1 | tail -30` -The skipped tests require Docker or are `#[ignore]`. The non-skipped tests (help output, version, init generates certs, restart tests) should pass. +Tests marked `#[ignore]` (which require Docker or a running daemon) are skipped by default. The non-ignored tests (help output, version, init generates certs, daemon restart tests) should all pass. - [ ] **Step 5: Commit** @@ -1227,5 +1243,5 @@ Expected: all show correct descriptions and options - [ ] **Step 4: Run non-Docker e2e tests** -Run: `cargo test --test e2e test_cli_help test_cli_version test_restart_no_daemon 2>&1` -Expected: all pass +Run: `cargo test --test e2e 2>&1 | tail -30` +Expected: all non-ignored tests pass (help, version, init generates certs, daemon restart tests) From b65cff45987c55551ba112bf008fc07398f8637e Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 14:26:52 -0700 Subject: [PATCH 7/8] test: add test plan for custom slugs and docker compose commands Co-Authored-By: Claude Opus 4.6 --- ...s-and-docker-compose-commands-test-plan.md | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/plans/2026-03-10-custom-slugs-and-docker-compose-commands-test-plan.md diff --git a/docs/plans/2026-03-10-custom-slugs-and-docker-compose-commands-test-plan.md b/docs/plans/2026-03-10-custom-slugs-and-docker-compose-commands-test-plan.md new file mode 100644 index 0000000..cb2b756 --- /dev/null +++ b/docs/plans/2026-03-10-custom-slugs-and-docker-compose-commands-test-plan.md @@ -0,0 +1,339 @@ +# Test Plan: Custom Slugs & Docker Compose Command Parity + +## Strategy reconciliation + +The implementation plan describes changes across six chunks: slug validation (config.rs), CLI restructuring (cli.rs, main.rs), command implementations (up.rs, stop.rs, start.rs, restart.rs, daemon.rs), platform template updates (platform.rs), e2e test updates (e2e.rs), and documentation/version bumps. + +The testing strategy embedded in the plan holds without modification. Key observations: + +- **Harnesses match**: All tests use either in-process unit testing (clap parsing, slug validation) or the existing e2e harness (binary invocation with `DEVPROXY_CONFIG_DIR` isolation). No new harnesses are needed. +- **External dependencies**: Docker/Docker Compose is required only for `#[ignore]`-marked tests, consistent with the existing pattern. No paid APIs or new infrastructure. +- **Interaction surface**: The `stop`/`start`/`restart` commands delegate to `docker compose` subprocesses, which the daemon's event watcher already handles. No new daemon IPC or protocol changes. +- **One area the plan doesn't test explicitly**: the `up` command's reuse behavior (running `up` twice, or `up` after `stop`). These are Docker-dependent scenarios and belong in the `#[ignore]` tier, which is appropriate given the existing pattern. + +No strategy changes requiring user approval. + +## Harness requirements + +No new harnesses needed. Existing infrastructure is sufficient: + +- **Unit test harness**: `#[cfg(test)]` modules in `config.rs`, `cli.rs`, `platform.rs` for in-process testing +- **E2E binary harness**: `tests/e2e.rs` with `devproxy_bin()`, `copy_fixtures()`, `create_test_config_dir()`, `start_test_daemon()`, `DaemonGuard`, `ComposeGuard` + +## Test plan + +### 1. Full e2e workflow with `--slug` flag: up with custom slug produces predictable URL + +- **Name**: Running `up --slug dirty-panda` produces a URL with the custom slug prefix +- **Type**: scenario +- **Harness**: E2E binary invocation with Docker (`#[ignore]`) +- **Preconditions**: Test config dir with certs generated. Test daemon running on ephemeral port. Fixture compose project with `devproxy.port` label copied to temp dir. +- **Actions**: + 1. Run `devproxy up --slug dirty-panda` in the fixture directory + 2. Extract slug from output URL + 3. Verify `.devproxy-project` contains the slug + 4. Verify the URL contains `dirty-panda-e2e-fixture` + 5. Run `devproxy down` to clean up +- **Expected outcome**: The output URL is `https://dirty-panda-e2e-fixture.{domain}`. The `.devproxy-project` file contains `dirty-panda-e2e-fixture`. Source of truth: design spec section "Usage" shows `{custom_slug}-{app_name}` format. `compose_slug()` in `config.rs` joins as `{slug_prefix}-{app_name}`. +- **Interactions**: Docker Compose, daemon event watcher (route insertion on container start). + +### 2. `up` reuses existing slug and ignores `--slug` on second run + +- **Name**: Running `up --slug new-name` on an already-configured project reuses the existing slug with a warning +- **Type**: scenario +- **Harness**: E2E binary invocation with Docker (`#[ignore]`) +- **Preconditions**: Same as test 1. First `up --slug dirty-panda` has already run and containers are up. +- **Actions**: + 1. Run `devproxy up --slug new-name` in the same fixture directory + 2. Capture stderr + 3. Read `.devproxy-project` +- **Expected outcome**: Stderr contains "ignoring --slug" or "reusing existing slug". The `.devproxy-project` file still contains the original slug (`dirty-panda-e2e-fixture`), not `new-name-e2e-fixture`. Source of truth: design spec "Reuse Behavior" section. +- **Interactions**: Docker Compose (re-runs `up -d` with existing config). + +### 3. Stop preserves slug and override, start resumes with same URL + +- **Name**: `stop` pauses containers without removing state files; `start` resumes them +- **Type**: scenario +- **Harness**: E2E binary invocation with Docker (`#[ignore]`) +- **Preconditions**: Project is running via `devproxy up`. +- **Actions**: + 1. Run `devproxy stop` in the fixture directory + 2. Verify `.devproxy-project` and `.devproxy-override.yml` still exist + 3. Run `devproxy start` in the fixture directory + 4. Verify output contains the same URL as the original `up` +- **Expected outcome**: After `stop`, both state files remain. `start` succeeds and prints the same URL. Source of truth: design spec command table shows `stop` "Preserves both" and `start` "Requires both to exist". +- **Interactions**: Docker Compose stop/start, daemon event watcher (route removal on stop, re-insertion on start). + +### 4. `daemon restart` reports no platform daemon when socket activation is disabled + +- **Name**: `daemon restart` without a platform-managed daemon reports error and exits non-zero +- **Type**: scenario +- **Harness**: E2E binary invocation with `DEVPROXY_NO_SOCKET_ACTIVATION=1` +- **Preconditions**: No platform-managed daemon. Socket activation disabled via env var. Valid config dir with `config.json`. +- **Actions**: Run `devproxy daemon restart` with isolated config dir. +- **Expected outcome**: Exit code is non-zero. Stderr contains "no platform-managed daemon found". Source of truth: `commands/daemon.rs::restart()` delegates to `platform::restart_daemon()` which returns `Ok(false)` when env var is set. +- **Interactions**: `platform::restart_daemon()` checks env var and short-circuits. + +### 5. `daemon restart` reports no platform daemon even when a direct-spawn daemon is running + +- **Name**: `daemon restart` with a running non-platform daemon still reports no platform daemon +- **Type**: scenario +- **Harness**: Test daemon harness (`start_test_daemon`) + binary invocation +- **Preconditions**: A daemon is running on an ephemeral port via direct spawn. `DEVPROXY_NO_SOCKET_ACTIVATION=1` is set. +- **Actions**: Start a test daemon. Run `devproxy daemon restart` against the same config dir. +- **Expected outcome**: Exit code is non-zero. Stderr contains "no platform-managed daemon found". Source of truth: same as test 4. +- **Interactions**: Verifies `daemon restart` does not accidentally affect a non-platform daemon. + +### 6. Help output lists new commands (`stop`, `start`, `daemon`) and keeps `restart` visible + +- **Name**: `--help` lists all new commands and the daemon subcommand group +- **Type**: integration +- **Harness**: E2E binary invocation +- **Preconditions**: Binary is built. +- **Actions**: Run `devproxy --help`. +- **Expected outcome**: Stdout contains "stop", "start", "restart", "daemon". The "daemon" line is visible (not hidden). Source of truth: CLI definition in `cli.rs` -- `Stop`, `Start`, `Restart` have no `hide` attribute, and `Daemon` variant has no `hide` attribute (only its `Run` subcommand is hidden). +- **Interactions**: clap help generation. + +### 7. Platform plist includes `daemon run` subcommand in ProgramArguments + +- **Name**: Generated LaunchAgent plist invokes `daemon run --port` not `daemon --port` +- **Type**: integration +- **Harness**: In-process unit test in `platform.rs` +- **Preconditions**: None. +- **Actions**: Call `generate_launchagent_plist("/usr/local/bin/devproxy", 443, None)`. +- **Expected outcome**: The returned plist contains `run` between `daemon` and `--port`. Source of truth: implementation plan Task 7 Step 3. +- **Interactions**: None. + +### 8. Platform systemd service unit includes `daemon run` subcommand in ExecStart + +- **Name**: Generated systemd service unit invokes `daemon run --port` not `daemon --port` +- **Type**: integration +- **Harness**: In-process unit test in `platform.rs` +- **Preconditions**: None. +- **Actions**: Call `generate_systemd_service_unit("/usr/local/bin/devproxy", 443, None)`. +- **Expected outcome**: The ExecStart line contains `daemon run --port 443`. Source of truth: implementation plan Task 7 Step 4. +- **Interactions**: None. + +### 9. Systemd service unit with custom port uses `daemon run --port` + +- **Name**: Generated systemd service unit with port 8443 uses `daemon run --port 8443` +- **Type**: boundary +- **Harness**: In-process unit test in `platform.rs` +- **Preconditions**: None. +- **Actions**: Call `generate_systemd_service_unit("/usr/local/bin/devproxy", 8443, None)`. +- **Expected outcome**: The ExecStart line contains `daemon run --port 8443`. Source of truth: implementation plan Task 7 Step 1. +- **Interactions**: None. + +### 10. `start_test_daemon` helper uses `daemon run` subcommand + +- **Name**: E2E test daemon helper starts daemon with `daemon run --port` command +- **Type**: integration +- **Harness**: E2E test infrastructure (verified by all `#[ignore]` tests passing) +- **Preconditions**: Binary is built. +- **Actions**: Any `#[ignore]` test that calls `start_test_daemon()`. +- **Expected outcome**: The daemon starts successfully (socket becomes connectable within 5s). Source of truth: implementation plan Task 8 Step 1. +- **Interactions**: All Docker-dependent e2e tests depend on this helper. + +### 11. `validate_custom_slug` accepts valid slugs + +- **Name**: Valid slug strings pass validation +- **Type**: unit +- **Harness**: In-process unit test in `config.rs` +- **Preconditions**: None. +- **Actions**: Call `validate_custom_slug()` with "dirty-panda", "my-app", "a", "abc123". +- **Expected outcome**: All return `Ok(())`. Source of truth: design spec validation rules -- "Lowercase alphanumeric and hyphens only, no leading/trailing hyphens, non-empty". +- **Interactions**: None. + +### 12. `validate_custom_slug` rejects empty string + +- **Name**: Empty slug is rejected +- **Type**: boundary +- **Harness**: In-process unit test in `config.rs` +- **Preconditions**: None. +- **Actions**: Call `validate_custom_slug("")`. +- **Expected outcome**: Returns `Err` with message containing "empty". Source of truth: design spec -- "Non-empty". +- **Interactions**: None. + +### 13. `validate_custom_slug` rejects uppercase characters + +- **Name**: Slugs with uppercase letters are rejected +- **Type**: boundary +- **Harness**: In-process unit test in `config.rs` +- **Preconditions**: None. +- **Actions**: Call `validate_custom_slug("Dirty-Panda")`. +- **Expected outcome**: Returns `Err`. Source of truth: design spec -- "Lowercase alphanumeric and hyphens only". +- **Interactions**: None. + +### 14. `validate_custom_slug` rejects special characters + +- **Name**: Slugs with underscores, dots, or spaces are rejected +- **Type**: boundary +- **Harness**: In-process unit test in `config.rs` +- **Preconditions**: None. +- **Actions**: Call `validate_custom_slug()` with "dirty_panda", "dirty.panda", "dirty panda". +- **Expected outcome**: All return `Err`. Source of truth: design spec -- "Lowercase alphanumeric and hyphens only". +- **Interactions**: None. + +### 15. `validate_custom_slug` rejects leading/trailing hyphens + +- **Name**: Slugs starting or ending with hyphens are rejected +- **Type**: boundary +- **Harness**: In-process unit test in `config.rs` +- **Preconditions**: None. +- **Actions**: Call `validate_custom_slug()` with "-dirty", "dirty-", "-dirty-". +- **Expected outcome**: All return `Err`. Source of truth: design spec -- "No leading or trailing hyphens". +- **Interactions**: None. + +### 16. `validate_custom_slug_with_app` rejects slug+app exceeding 63 chars + +- **Name**: Slug combined with app name exceeding DNS label limit is rejected before truncation +- **Type**: boundary +- **Harness**: In-process unit test in `config.rs` +- **Preconditions**: None. +- **Actions**: Call `validate_custom_slug_with_app("a".repeat(60), "my-app")`. The raw composite is 67 chars ("a"*60 + "-" + "my-app"). +- **Expected outcome**: Returns `Err` with a message containing the computed length. Source of truth: design spec -- "Combined result must be <= 63 characters". Implementation plan D1 notes custom slugs should be rejected when too long, not silently truncated. +- **Interactions**: None. + +### 17. CLI parses `up` without `--slug` + +- **Name**: `devproxy up` parses with slug as None +- **Type**: unit +- **Harness**: In-process clap parsing in `cli.rs` +- **Preconditions**: None. +- **Actions**: Call `Cli::try_parse_from(["devproxy", "up"])`. +- **Expected outcome**: Parses as `Commands::Up { slug: None }`. Source of truth: `cli.rs` defines `slug` as `Option` with `#[arg(long)]`. +- **Interactions**: None. + +### 18. CLI parses `up --slug dirty-panda` + +- **Name**: `devproxy up --slug dirty-panda` parses with the slug value +- **Type**: unit +- **Harness**: In-process clap parsing in `cli.rs` +- **Preconditions**: None. +- **Actions**: Call `Cli::try_parse_from(["devproxy", "up", "--slug", "dirty-panda"])`. +- **Expected outcome**: Parses as `Commands::Up { slug: Some("dirty-panda") }`. Source of truth: CLI definition. +- **Interactions**: None. + +### 19. CLI parses `stop`, `start`, `restart` commands + +- **Name**: New lifecycle commands parse correctly +- **Type**: unit +- **Harness**: In-process clap parsing in `cli.rs` +- **Preconditions**: None. +- **Actions**: Call `Cli::try_parse_from` with `["devproxy", "stop"]`, `["devproxy", "start"]`, `["devproxy", "restart"]`. +- **Expected outcome**: Each parses as the corresponding `Commands` variant. Source of truth: CLI definition. +- **Interactions**: None. + +### 20. CLI parses `daemon run` and `daemon run --port 8443` + +- **Name**: Daemon run subcommand parses with default and custom port +- **Type**: unit +- **Harness**: In-process clap parsing in `cli.rs` +- **Preconditions**: None. +- **Actions**: Call `Cli::try_parse_from` with `["devproxy", "daemon", "run"]` and `["devproxy", "daemon", "run", "--port", "8443"]`. +- **Expected outcome**: First parses as `Daemon { Run { port: 443 } }`, second as `Daemon { Run { port: 8443 } }`. Source of truth: CLI definition with `default_value = "443"`. +- **Interactions**: None. + +### 21. CLI parses `daemon restart` + +- **Name**: Daemon restart subcommand parses correctly +- **Type**: unit +- **Harness**: In-process clap parsing in `cli.rs` +- **Preconditions**: None. +- **Actions**: Call `Cli::try_parse_from(["devproxy", "daemon", "restart"])`. +- **Expected outcome**: Parses as `Daemon { Restart }`. Source of truth: CLI definition. +- **Interactions**: None. + +### 22. `up` without compose file fails with helpful error + +- **Name**: Running `up` in a directory without a compose file reports missing file +- **Type**: regression +- **Harness**: E2E binary invocation (no Docker required) +- **Preconditions**: Empty temp directory. Config dir with `config.json`. +- **Actions**: Run `devproxy up` in the empty directory. +- **Expected outcome**: Exit non-zero. Stderr contains "no docker-compose.yml". Source of truth: `config::find_compose_file()` returns this error. This is existing behavior that must be preserved. +- **Interactions**: None. + +### 23. `up` without `devproxy.port` label fails with helpful error + +- **Name**: Running `up` with a compose file lacking `devproxy.port` reports no service found +- **Type**: regression +- **Harness**: E2E binary invocation (no Docker required) +- **Preconditions**: Temp directory with compose file that has no `devproxy.port` label. Config dir with `config.json`. +- **Actions**: Run `devproxy up` in the temp directory. +- **Expected outcome**: Exit non-zero. Stderr contains "no service". Source of truth: `config::find_devproxy_service()` returns this error. Existing behavior preserved. +- **Interactions**: None. + +### 24. `up` fails fast with dead daemon (stale socket) + +- **Name**: Running `up` with a stale daemon socket fails within 5 seconds, not hanging +- **Type**: regression +- **Harness**: E2E binary invocation with stale Unix socket +- **Preconditions**: Config dir with `config.json`. Compose project with `devproxy.port` label. A stale Unix socket file at the socket path (created by binding and immediately dropping a `UnixListener`). +- **Actions**: Run `devproxy up` and measure elapsed time. +- **Expected outcome**: Exit non-zero within 5 seconds. Stderr contains "not running" or "no response". Cleanup: `.devproxy-override.yml` and `.devproxy-project` should NOT exist after failure (cleaned up by the `!reusing` guard). Source of truth: implementation plan Task 3 cleanup guards. +- **Interactions**: IPC ping timeout (2 seconds). + +### 25. `stop` without project file fails with helpful error + +- **Name**: Running `stop` in a directory without `.devproxy-project` reports error +- **Type**: boundary +- **Harness**: E2E binary invocation (no Docker required) +- **Preconditions**: Temp directory with compose file but no `.devproxy-project`. +- **Actions**: Run `devproxy stop`. +- **Expected outcome**: Exit non-zero. Stderr contains ".devproxy-project" or "Is this project running". Source of truth: `config::read_project_file()` error message. +- **Interactions**: None. + +### 26. `start` without project file fails with helpful error + +- **Name**: Running `start` in a directory without `.devproxy-project` reports error +- **Type**: boundary +- **Harness**: E2E binary invocation (no Docker required) +- **Preconditions**: Temp directory with compose file but no `.devproxy-project`. +- **Actions**: Run `devproxy start`. +- **Expected outcome**: Exit non-zero. Stderr contains ".devproxy-project" or "Is this project running". Source of truth: `config::read_project_file()` error message. +- **Interactions**: None. + +### 27. `start` with project file but missing override fails with helpful error + +- **Name**: Running `start` with a project file but no override file reports error +- **Type**: boundary +- **Harness**: E2E binary invocation (no Docker required) +- **Preconditions**: Temp directory with compose file and `.devproxy-project` but no `.devproxy-override.yml`. +- **Actions**: Run `devproxy start`. +- **Expected outcome**: Exit non-zero. Stderr contains "override file missing" or "devproxy up". Source of truth: `commands/start.rs` checks for override existence. +- **Interactions**: None. + +### 28. `restart` (app stack) without project file fails with helpful error + +- **Name**: Running `restart` in a directory without `.devproxy-project` reports error +- **Type**: boundary +- **Harness**: E2E binary invocation (no Docker required) +- **Preconditions**: Temp directory with compose file but no `.devproxy-project`. +- **Actions**: Run `devproxy restart`. +- **Expected outcome**: Exit non-zero. Stderr contains ".devproxy-project" or "Is this project running". Source of truth: `commands/restart.rs` calls `config::read_project_file()`. +- **Interactions**: None. + +## Coverage summary + +### Covered + +- **Custom slug flag**: Validation (accept/reject), CLI parsing, URL composition, DNS label length enforcement +- **Slug reuse**: `up` reuses existing state files, warns when `--slug` is ignored +- **New commands**: `stop` (preserves files), `start` (requires files, checks daemon), `restart` (app stack) +- **CLI restructuring**: `daemon run`/`daemon restart` subcommands, all new variants parseable, help output updated +- **Platform templates**: Both launchd plist and systemd unit updated to `daemon run --port` +- **Regression protection**: Existing `up` error paths (no compose file, no label, dead daemon), `down` without project file, version output, init generates certs +- **E2E infrastructure**: `start_test_daemon()` updated for `daemon run` + +### Explicitly excluded + +- **Actual launchd/systemd restart**: Requires platform service manager. Covered by existing `platform::restart_daemon()` logic and the `test_launchd_socket_activation` e2e test. +- **Linux systemd path**: Covered by existing `tests/linux-docker/` infrastructure. +- **Slug collision between projects**: Design spec explicitly states this is the user's responsibility, same as `docker compose -p`. +- **`down` after `stop`**: Standard compose behavior, no devproxy-specific logic beyond what `down` already does (remove files + `docker compose down`). + +### Residual risk + +- **Low**: The `stop`/`start`/`restart` commands are thin wrappers around `docker compose` subcommands with file-existence checks. The daemon's event watcher already handles container lifecycle events correctly. +- **Medium**: The `up` reuse path (test 2) depends on Docker being available. If Docker-dependent tests are not run, this path is only verified by code review and the dead-daemon regression test (test 24) which exercises the reuse-path cleanup guard. From 4033c543b83c81ee196fc89427254311ae55d8cc Mon Sep 17 00:00:00 2001 From: Chris Fenton Date: Tue, 10 Mar 2026 14:35:37 -0700 Subject: [PATCH 8/8] feat: add custom slugs, stop/start/restart commands, daemon subcommand group - Add --slug flag to `devproxy up` for predictable URL slugs - Add validate_custom_slug() with DNS label length checking - `devproxy up` now reuses existing slug/override when state files exist - Add `devproxy stop` (preserves slug and override for restart) - Add `devproxy start` (resumes stopped containers with existing slug) - `devproxy restart` now restarts app containers (breaking change) - Daemon restart moved to `devproxy daemon restart` subcommand - `devproxy daemon run` replaces hidden top-level `devproxy daemon` - Update platform plist/unit templates for `daemon run` subcommand - Update e2e tests, README, skills, spec, and plugin version - Bump version to 0.5.0 for breaking restart behavior change Co-Authored-By: Claude Opus 4.6 --- .claude-plugin/plugin.json | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 30 +++++++---- docs/spec.md | 4 +- skills/devproxy/SKILL.md | 16 +++--- skills/setup/SKILL.md | 2 +- src/cli.rs | 101 +++++++++++++++++++++++++++++++++---- src/commands/daemon.rs | 19 +++++++ src/commands/init.rs | 2 +- src/commands/mod.rs | 2 + src/commands/restart.rs | 69 +++++++++++++++++++------ src/commands/start.rs | 59 ++++++++++++++++++++++ src/commands/stop.rs | 42 +++++++++++++++ src/commands/up.rs | 87 ++++++++++++++++++++------------ src/config.rs | 71 ++++++++++++++++++++++++++ src/main.rs | 11 ++-- src/platform.rs | 10 ++-- tests/e2e.rs | 38 +++++++------- 19 files changed, 464 insertions(+), 105 deletions(-) create mode 100644 src/commands/start.rs create mode 100644 src/commands/stop.rs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ae3431b..868cbfb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "devproxy", - "version": "0.4.4", + "version": "0.5.0", "description": "Claude Code plugin for setting up and using devproxy — local HTTPS dev subdomains for Docker Compose projects", "author": { "name": "Foundra", diff --git a/Cargo.lock b/Cargo.lock index 68a84fa..cf29d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,7 +275,7 @@ dependencies = [ [[package]] name = "devproxy" -version = "0.4.4" +version = "0.5.0" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 1366803..fe2b5a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devproxy" -version = "0.4.4" +version = "0.5.0" edition = "2024" [[bin]] diff --git a/README.md b/README.md index 238bd69..5838162 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ services: ```bash devproxy up -# → https://swift-penguin.mysite.dev +# → https://swift-penguin-myapp.mysite.dev + +devproxy up --slug my-app +# → https://my-app-myapp.mysite.dev ``` ## Features @@ -49,16 +52,21 @@ devproxy up ## Commands -| Command | Description | -|----------------------|----------------------------------------------| -| `devproxy init` | One-time setup: certs, CA trust, daemon | -| `devproxy up` | Start project, assign slug, proxy it | -| `devproxy down` | Stop project, clean up override | -| `devproxy ls` | List running projects with URLs | -| `devproxy open` | Open project URL in browser | -| `devproxy status` | Daemon health check | -| `devproxy update` | Check for updates and self-update | -| `devproxy --version` | Show installed version | +| Command | Description | +|----------------------|---------------------------------------------------| +| `devproxy init` | One-time setup: certs, CA trust, daemon | +| `devproxy up` | Start project, assign slug, proxy it | +| `devproxy up --slug` | Start project with a custom slug prefix | +| `devproxy down` | Stop project, remove override and slug | +| `devproxy stop` | Stop containers (preserves slug for restart) | +| `devproxy start` | Start previously stopped containers | +| `devproxy restart` | Restart app containers | +| `devproxy ls` | List running projects with URLs | +| `devproxy open` | Open project URL in browser | +| `devproxy status` | Daemon health check | +| `devproxy daemon restart` | Restart the background daemon | +| `devproxy update` | Check for updates and self-update | +| `devproxy --version` | Show installed version | ## Claude Code Plugin diff --git a/docs/spec.md b/docs/spec.md index a34b3ab..22ad95b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -196,6 +196,6 @@ No Caddy. No Traefik. No mkcert. No external proxy process. Could support `devproxy.port=3000,devproxy.name=api` to allow multiple routes per project. - **HTTP → HTTPS redirect**: daemon currently only listens on :443. Add an :80 listener that 301-redirects to HTTPS. -- **Slug persistence**: slugs are stable for the lifetime of a running container but reset on +- ~~**Slug persistence**: slugs are stable for the lifetime of a running container but reset on `devproxy up`. Could offer `devproxy pin ` to write the slug into a `.devproxy` file - so the same slug is always used for a given project. + so the same slug is always used for a given project.~~ **Done** — `devproxy up --slug NAME` allows predictable slugs. `devproxy stop`/`start` preserves the slug across stop/start cycles without regenerating. diff --git a/skills/devproxy/SKILL.md b/skills/devproxy/SKILL.md index 5fdf82e..73adbb8 100644 --- a/skills/devproxy/SKILL.md +++ b/skills/devproxy/SKILL.md @@ -1,6 +1,6 @@ --- name: devproxy -description: This skill should be used when the user mentions "devproxy", "dev subdomain", "local HTTPS proxy", asks about HTTPS for Docker Compose, wants to route Docker services through HTTPS subdomains, needs to troubleshoot devproxy issues like certificate errors or daemon problems, or asks about devproxy commands like "devproxy up", "devproxy down", "devproxy ls", "devproxy status", "devproxy init", "devproxy open", "devproxy restart", or "devproxy update". +description: This skill should be used when the user mentions "devproxy", "dev subdomain", "local HTTPS proxy", asks about HTTPS for Docker Compose, wants to route Docker services through HTTPS subdomains, needs to troubleshoot devproxy issues like certificate errors or daemon problems, or asks about devproxy commands like "devproxy up", "devproxy down", "devproxy ls", "devproxy status", "devproxy init", "devproxy open", "devproxy stop", "devproxy start", "devproxy restart", "devproxy daemon restart", or "devproxy update". --- # devproxy @@ -16,11 +16,15 @@ Local HTTPS dev subdomains for Docker Compose projects. Single Rust binary — n | `devproxy init --domain X` | One-time: certs, CA trust, start daemon | | `devproxy init --port 8443` | Use non-privileged port (avoids sudo on Linux) | | `devproxy up` | Assign slug, bind port, `docker compose up -d` | -| `devproxy down` | `docker compose down` + remove override file | +| `devproxy up --slug NAME` | Use custom slug prefix for predictable URLs | +| `devproxy down` | `docker compose down` + remove override & slug | +| `devproxy stop` | `docker compose stop` (preserves slug/override) | +| `devproxy start` | `docker compose start` (reuses existing slug) | +| `devproxy restart` | Restart app containers (stop + start) | | `devproxy ls` | List running projects with slugs and URLs | | `devproxy get-url` | Print this project's proxy URL (for scripting) | | `devproxy open` | Open this project's URL in browser | -| `devproxy restart` | Restart the daemon | +| `devproxy daemon restart` | Restart the background daemon process | | `devproxy update` | Check for updates and self-update the binary | | `devproxy --version` | Show installed version | | `devproxy status` | Daemon health + active route count | @@ -75,7 +79,7 @@ curl --resolve .:443:127.0.0.1 https://. - **macOS**: `devproxy init` installs a LaunchAgent plist. launchd binds port 443 and passes the socket fd to the daemon running as the current user (no sudo). - **Linux**: Uses systemd user socket activation. Falls back to `setcap cap_net_bind_service` if systemd is unavailable. -- `devproxy restart` restarts the daemon via `launchctl kickstart -k` (macOS) or `systemctl --user restart` (Linux). +- `devproxy daemon restart` restarts the daemon via `launchctl kickstart -k` (macOS) or `systemctl --user restart` (Linux). - `devproxy update` replaces the binary and restarts the daemon. - `sudo` is only needed for one-time DNS setup and CA trust — never for daemon startup. @@ -83,10 +87,10 @@ curl --resolve .:443:127.0.0.1 https://. | Problem | Fix | |---------|-----| -| "Connection refused" on HTTPS | Check daemon: `devproxy status`. Restart with `devproxy restart` or re-init with `devproxy init` | +| "Connection refused" on HTTPS | Check daemon: `devproxy status`. Restart with `devproxy daemon restart` or re-init with `devproxy init` | | Port 443 requires sudo (Linux) | Normally handled by systemd socket activation. Fallback: `sudo setcap cap_net_bind_service=+ep $(which devproxy)` or use `devproxy init --port 8443` | | DNS not resolving `*.mysite.dev` | Add `127.0.0.1 slug.mysite.dev` to `/etc/hosts` or configure dnsmasq | | `curl` fails but browser works (macOS) | `curl` doesn't use `/etc/resolver/`. Use `curl --resolve .:443:127.0.0.1 https://.` | | `.devproxy-override.yml` in git | Add it to `.gitignore` | -| Slug changed after restart | Slugs are random per `devproxy up`. Pin not yet supported | +| Slug changed after restart | Use `devproxy stop`/`start` to preserve slug, or `devproxy up --slug NAME` for a predictable slug | | Binary "killed" (exit code 137) on macOS | Gatekeeper quarantine. Re-run the install script or run: `xattr -cr $(which devproxy) && codesign --force --sign - $(which devproxy)` | diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 3d3acb2..e803e78 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -179,5 +179,5 @@ devproxy --version # version shown If everything passes, the setup is complete. Remind the user: - Add `.devproxy-override.yml` to `.gitignore` in each project - Use `devproxy up` / `devproxy down` to manage projects -- Use `devproxy restart` to restart the daemon if needed +- Use `devproxy daemon restart` to restart the daemon if needed - Use `devproxy update` to stay current diff --git a/src/cli.rs b/src/cli.rs index 0bdcdcb..b00edac 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,9 +26,19 @@ pub enum Commands { no_daemon: bool, }, /// Start this project and assign a dev subdomain - Up, + Up { + /// Custom slug prefix (e.g., --slug dirty-panda for dirty-panda-myapp.mysite.dev) + #[arg(long)] + slug: Option, + }, /// Stop this project and remove override file Down, + /// Stop containers without removing override (preserves slug) + Stop, + /// Start previously stopped containers (reuses existing slug) + Start, + /// Restart app containers (stop + start) + Restart, /// List all running projects with slugs and URLs Ls, /// Print this project's proxy URL (empty + exit 1 if not running) @@ -37,17 +47,26 @@ pub enum Commands { Open, /// Show daemon health and active route count Status, - /// Restart the daemon - Restart, /// Check for updates and self-update the binary Update, - /// Run the proxy daemon (internal, hidden) - #[command(hide = true)] + /// Daemon management (run, restart) Daemon { + #[command(subcommand)] + subcommand: DaemonCommand, + }, +} + +#[derive(Subcommand)] +pub enum DaemonCommand { + /// Run the proxy daemon (internal, used by launchd/systemd) + #[command(hide = true)] + Run { /// Port to listen on (default: 443) #[arg(long, default_value = "443")] port: u16, }, + /// Restart the background daemon process + Restart, } #[cfg(test)] @@ -55,11 +74,73 @@ mod tests { use super::*; #[test] - fn test_parse_restart_command() { + fn test_parse_up_no_slug() { + let cli = Cli::try_parse_from(["devproxy", "up"]).expect("should parse up"); + match cli.command { + Commands::Up { slug } => assert!(slug.is_none()), + _ => panic!("expected Up"), + } + } + + #[test] + fn test_parse_up_with_slug() { + let cli = Cli::try_parse_from(["devproxy", "up", "--slug", "dirty-panda"]) + .expect("should parse up --slug"); + match cli.command { + Commands::Up { slug } => assert_eq!(slug.as_deref(), Some("dirty-panda")), + _ => panic!("expected Up"), + } + } + + #[test] + fn test_parse_stop() { + let cli = Cli::try_parse_from(["devproxy", "stop"]).expect("should parse stop"); + assert!(matches!(cli.command, Commands::Stop)); + } + + #[test] + fn test_parse_start() { + let cli = Cli::try_parse_from(["devproxy", "start"]).expect("should parse start"); + assert!(matches!(cli.command, Commands::Start)); + } + + #[test] + fn test_parse_restart() { let cli = Cli::try_parse_from(["devproxy", "restart"]).expect("should parse restart"); - assert!( - matches!(cli.command, Commands::Restart), - "should parse as Restart variant" - ); + assert!(matches!(cli.command, Commands::Restart)); + } + + #[test] + fn test_parse_daemon_run() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "run"]) + .expect("should parse daemon run"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { + assert_eq!(port, 443); + } + _ => panic!("expected Daemon Run"), + } + } + + #[test] + fn test_parse_daemon_run_with_port() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "run", "--port", "8443"]) + .expect("should parse daemon run --port"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Run { port } } => { + assert_eq!(port, 8443); + } + _ => panic!("expected Daemon Run"), + } + } + + #[test] + fn test_parse_daemon_restart() { + let cli = Cli::try_parse_from(["devproxy", "daemon", "restart"]) + .expect("should parse daemon restart"); + match cli.command { + Commands::Daemon { subcommand: DaemonCommand::Restart } => {} + _ => panic!("expected Daemon Restart"), + } } } diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index f8dab80..73d083f 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -5,3 +5,22 @@ pub async fn run(port: u16) -> Result<()> { eprintln!("devproxy daemon starting..."); proxy::run_daemon(port).await } + +pub fn restart() -> Result<()> { + use colored::Colorize; + match crate::platform::restart_daemon() { + Ok(true) => { + eprintln!("{} daemon restarted", "ok:".green()); + Ok(()) + } + Ok(false) => { + eprintln!( + "{} no platform-managed daemon found. Run {} to set one up", + "error:".red(), + "devproxy init".bold() + ); + std::process::exit(1); + } + Err(e) => Err(e), + } +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 30306bd..2cbc28d 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -248,7 +248,7 @@ fn spawn_daemon_directly(exe: &std::path::Path, port: u16, domain: &str) -> Resu } let mut cmd = std::process::Command::new(exe); - cmd.args(["daemon", "--port", &port.to_string()]); + cmd.args(["daemon", "run", "--port", &port.to_string()]); if let Ok(dir) = std::env::var("DEVPROXY_CONFIG_DIR") { cmd.env("DEVPROXY_CONFIG_DIR", dir); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 99093be..543c443 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,8 @@ pub mod init; pub mod ls; pub mod open; pub mod restart; +pub mod start; pub mod status; +pub mod stop; pub mod up; pub mod update; diff --git a/src/commands/restart.rs b/src/commands/restart.rs index 32e8c3d..3c6a954 100644 --- a/src/commands/restart.rs +++ b/src/commands/restart.rs @@ -1,20 +1,59 @@ -use anyhow::Result; +use crate::config::{self, Config}; +use anyhow::{Context, Result, bail}; use colored::Colorize; pub fn run() -> Result<()> { - match crate::platform::restart_daemon() { - Ok(true) => { - eprintln!("{} daemon restarted", "ok:".green()); - Ok(()) - } - Ok(false) => { - eprintln!( - "{} no platform-managed daemon found. Run {} to set one up", - "error:".red(), - "devproxy init".bold() - ); - std::process::exit(1); - } - Err(e) => Err(e), + 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")?; + + let slug = config::read_project_file(compose_dir)?; + eprintln!("project: {}", slug.cyan()); + + let override_path = compose_dir.join(".devproxy-override.yml"); + if !override_path.exists() { + bail!("override file missing. Run `devproxy up` to reconfigure."); + } + + // Verify daemon is running (same checks as start, per spec) + let socket_path = Config::socket_path()?; + if !socket_path.exists() + || !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) + { + bail!("daemon is not running. Run `devproxy init` first."); + } + + let compose_file_name = compose_path + .file_name() + .context("no filename")? + .to_string_lossy() + .to_string(); + + let status = std::process::Command::new("docker") + .args([ + "compose", + "-f", + &compose_file_name, + "-f", + ".devproxy-override.yml", + "--project-name", + &slug, + "restart", + ]) + .current_dir(compose_dir) + .status() + .context("failed to run docker compose restart")?; + + if !status.success() { + bail!("docker compose restart failed"); } + + let config = Config::load().context("run `devproxy init` first")?; + let url = format!("https://{slug}.{}", config.domain); + eprintln!(); + eprintln!("{} {}", "->".green().bold(), url.green().bold()); + + Ok(()) } diff --git a/src/commands/start.rs b/src/commands/start.rs new file mode 100644 index 0000000..e3eb872 --- /dev/null +++ b/src/commands/start.rs @@ -0,0 +1,59 @@ +use crate::config::{self, Config}; +use anyhow::{Context, Result, bail}; +use colored::Colorize; + +pub fn run() -> Result<()> { + 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")?; + + let slug = config::read_project_file(compose_dir)?; + eprintln!("project: {}", slug.cyan()); + + let override_path = compose_dir.join(".devproxy-override.yml"); + if !override_path.exists() { + bail!("override file missing. Run `devproxy up` to reconfigure."); + } + + // Verify daemon is running + let socket_path = Config::socket_path()?; + if !socket_path.exists() + || !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) + { + bail!("daemon is not running. Run `devproxy init` first."); + } + + let compose_file_name = compose_path + .file_name() + .context("no filename")? + .to_string_lossy() + .to_string(); + + let status = std::process::Command::new("docker") + .args([ + "compose", + "-f", + &compose_file_name, + "-f", + ".devproxy-override.yml", + "--project-name", + &slug, + "start", + ]) + .current_dir(compose_dir) + .status() + .context("failed to run docker compose start")?; + + if !status.success() { + bail!("docker compose start failed"); + } + + let config = Config::load().context("run `devproxy init` first")?; + let url = format!("https://{slug}.{}", config.domain); + eprintln!(); + eprintln!("{} {}", "->".green().bold(), url.green().bold()); + + Ok(()) +} diff --git a/src/commands/stop.rs b/src/commands/stop.rs new file mode 100644 index 0000000..e7028dc --- /dev/null +++ b/src/commands/stop.rs @@ -0,0 +1,42 @@ +use crate::config; +use anyhow::{Context, Result}; +use colored::Colorize; + +pub fn run() -> Result<()> { + 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")?; + + let slug = config::read_project_file(compose_dir)?; + eprintln!("project: {}", slug.cyan()); + + let compose_file_name = compose_path + .file_name() + .context("no filename")? + .to_string_lossy() + .to_string(); + + let status = std::process::Command::new("docker") + .args([ + "compose", + "-f", + &compose_file_name, + "-f", + ".devproxy-override.yml", + "--project-name", + &slug, + "stop", + ]) + .current_dir(compose_dir) + .status() + .context("failed to run docker compose stop")?; + + if !status.success() { + eprintln!("{} docker compose stop exited with error", "warn:".yellow()); + } + + eprintln!("{} project stopped (slug and override preserved)", "ok:".green()); + Ok(()) +} diff --git a/src/commands/up.rs b/src/commands/up.rs index 96ce888..bb3eb35 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -3,11 +3,9 @@ use crate::slugs; use anyhow::{Context, Result, bail}; use colored::Colorize; -pub fn run() -> Result<()> { - // Check config exists (implies init was run) +pub fn run(custom_slug: Option<&str>) -> Result<()> { let config = Config::load().context("run `devproxy init` first")?; - // Find docker-compose.yml (shared utility from config module) let cwd = std::env::current_dir()?; let compose_path = config::find_compose_file(&cwd)?; let compose_dir = compose_path @@ -19,7 +17,6 @@ pub fn run() -> Result<()> { compose_path.display().to_string().cyan() ); - // Parse compose file let compose = config::parse_compose_file(&compose_path)?; let (service_name, container_port) = config::find_devproxy_service(&compose)?; eprintln!( @@ -28,44 +25,68 @@ pub fn run() -> Result<()> { container_port.to_string().cyan() ); - // Detect app name and generate composite slug - let app_name = config::detect_app_name(&cwd)?; - eprintln!("app: {}", app_name.cyan()); + // Check for existing project state (reuse if present) + let project_path = compose_dir.join(".devproxy-project"); + let override_path = compose_dir.join(".devproxy-override.yml"); + let reusing = project_path.exists() && override_path.exists(); + + let slug = if reusing { + let existing_slug = config::read_project_file(compose_dir)?; + if custom_slug.is_some() { + eprintln!( + "{} ignoring --slug, reusing existing slug. Run `devproxy down` first to change slug.", + "warn:".yellow() + ); + } + eprintln!("slug: {} (reusing)", existing_slug.cyan()); + existing_slug + } else { + let app_name = config::detect_app_name(&cwd)?; + eprintln!("app: {}", app_name.cyan()); + + let slug_prefix = match custom_slug { + Some(s) => { + config::validate_custom_slug_with_app(s, &app_name)?; + s.to_string() + } + None => slugs::generate_slug(), + }; + let slug = config::compose_slug(&slug_prefix, &app_name); + eprintln!("slug: {}", slug.cyan()); + + let host_port = config::find_free_port()?; + eprintln!("host port: {}", host_port.to_string().cyan()); - let random_slug = slugs::generate_slug(); - let slug = config::compose_slug(&random_slug, &app_name); - eprintln!("slug: {}", slug.cyan()); - - // Find free port - let host_port = config::find_free_port()?; - eprintln!("host port: {}", host_port.to_string().cyan()); - - // Write override file (port binding) - let override_path = config::write_override_file(compose_dir, &service_name, host_port, container_port)?; - eprintln!("override: {}", override_path.display().to_string().cyan()); + eprintln!( + "override: {}", + override_path.display().to_string().cyan() + ); - // Write project file (slug tracking -- used by `down` and `open`) - config::write_project_file(compose_dir, &slug)?; + config::write_project_file(compose_dir, &slug)?; + slug + }; - // Verify daemon is running before starting containers. - // Use a short timeout (2s) so we fail fast instead of hanging forever. + // Verify daemon is running. + // On the !reusing path, clean up freshly-written files on failure. + // On the reusing path, files pre-existed so leave them alone. let socket_path = Config::socket_path()?; if !socket_path.exists() { - // Clean up files we already wrote - let _ = std::fs::remove_file(&override_path); - let _ = std::fs::remove_file(compose_dir.join(".devproxy-project")); + if !reusing { + let _ = std::fs::remove_file(&override_path); + let _ = std::fs::remove_file(&project_path); + } bail!( "daemon is not running (no socket at {}). Run `devproxy init` first.", socket_path.display() ); } - // Send an actual IPC ping with a 2s timeout to verify the daemon is - // responsive, not just that a stale socket file exists. if !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) { - let _ = std::fs::remove_file(&override_path); - let _ = std::fs::remove_file(compose_dir.join(".devproxy-project")); + if !reusing { + let _ = std::fs::remove_file(&override_path); + let _ = std::fs::remove_file(&project_path); + } bail!( "daemon is not running (no response from {}). Run `devproxy init` first.", socket_path.display() @@ -95,9 +116,11 @@ pub fn run() -> Result<()> { .context("failed to run docker compose")?; if !status.success() { - // Clean up on failure - let _ = std::fs::remove_file(&override_path); - let _ = std::fs::remove_file(compose_dir.join(".devproxy-project")); + // Only clean up files we just created (not reused ones) + if !reusing { + let _ = std::fs::remove_file(&override_path); + let _ = std::fs::remove_file(&project_path); + } bail!("docker compose up failed"); } diff --git a/src/config.rs b/src/config.rs index 2311aa7..dd66ec0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -327,6 +327,38 @@ pub fn compose_slug(random_slug: &str, app_name: &str) -> String { .to_string() } +/// Validate a user-provided custom slug prefix. +/// Unlike `sanitize_subdomain` which transforms input, this rejects invalid input. +/// Rules: lowercase alphanumeric + hyphens, no leading/trailing hyphens, non-empty. +pub fn validate_custom_slug(slug: &str) -> Result<()> { + if slug.is_empty() { + bail!("slug cannot be empty"); + } + if slug.starts_with('-') || slug.ends_with('-') { + bail!("slug cannot start or end with a hyphen: '{slug}'"); + } + if !slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + bail!("slug must contain only lowercase letters, digits, and hyphens: '{slug}'"); + } + Ok(()) +} + +/// Validate a custom slug and check the composite length with app name. +/// Checks the raw `{slug}-{app_name}` length BEFORE truncation — custom slugs +/// should be rejected when too long, not silently truncated (unlike random slugs +/// where truncation is acceptable). See design spec: "validated and rejected if +/// invalid (not sanitized/transformed like app names)." +pub fn validate_custom_slug_with_app(slug: &str, app_name: &str) -> Result<()> { + validate_custom_slug(slug)?; + let raw_len = slug.len() + 1 + app_name.len(); // "{slug}-{app_name}" + if raw_len > 63 { + bail!( + "slug '{slug}' combined with app name '{app_name}' is {raw_len} chars (max 63)", + ); + } + Ok(()) +} + /// Find a free ephemeral port pub fn find_free_port() -> Result { let listener = std::net::TcpListener::bind("127.0.0.1:0")?; @@ -618,4 +650,43 @@ services: let result = compose_slug("bold-fox", "my-cool-app"); assert_eq!(result, "bold-fox-my-cool-app"); } + + #[test] + fn validate_custom_slug_accepts_valid() { + assert!(validate_custom_slug("dirty-panda").is_ok()); + assert!(validate_custom_slug("my-app").is_ok()); + assert!(validate_custom_slug("a").is_ok()); + assert!(validate_custom_slug("abc123").is_ok()); + } + + #[test] + fn validate_custom_slug_rejects_empty() { + assert!(validate_custom_slug("").is_err()); + } + + #[test] + fn validate_custom_slug_rejects_uppercase() { + assert!(validate_custom_slug("Dirty-Panda").is_err()); + } + + #[test] + fn validate_custom_slug_rejects_special_chars() { + assert!(validate_custom_slug("dirty_panda").is_err()); + assert!(validate_custom_slug("dirty.panda").is_err()); + assert!(validate_custom_slug("dirty panda").is_err()); + } + + #[test] + fn validate_custom_slug_rejects_leading_trailing_hyphens() { + assert!(validate_custom_slug("-dirty").is_err()); + assert!(validate_custom_slug("dirty-").is_err()); + assert!(validate_custom_slug("-dirty-").is_err()); + } + + #[test] + fn validate_custom_slug_rejects_too_long_composite() { + // compose_slug joins as "{slug}-{app_name}" and must be <= 63 + let long_slug = "a".repeat(60); + assert!(validate_custom_slug_with_app(&long_slug, "my-app").is_err()); + } } diff --git a/src/main.rs b/src/main.rs index 302be11..3bc7272 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,14 +19,19 @@ async fn main() -> anyhow::Result<()> { port, no_daemon, } => commands::init::run(&domain, port, no_daemon), - Commands::Up => commands::up::run(), + Commands::Up { slug } => commands::up::run(slug.as_deref()), Commands::Down => commands::down::run(), + Commands::Stop => commands::stop::run(), + Commands::Start => commands::start::run(), + Commands::Restart => commands::restart::run(), Commands::GetUrl => commands::get_url::run(), Commands::Ls => commands::ls::run().await, Commands::Open => commands::open::run().await, Commands::Status => commands::status::run().await, - Commands::Restart => commands::restart::run(), Commands::Update => commands::update::run().await, - Commands::Daemon { port } => commands::daemon::run(port).await, + Commands::Daemon { subcommand } => match subcommand { + cli::DaemonCommand::Run { port } => commands::daemon::run(port).await, + cli::DaemonCommand::Restart => commands::daemon::restart(), + }, } } diff --git a/src/platform.rs b/src/platform.rs index 87b4192..64e595b 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -108,6 +108,7 @@ pub fn generate_launchagent_plist( {binary_path} daemon + run --port {port} @@ -177,7 +178,7 @@ After={SYSTEMD_UNIT_NAME}.socket [Service] Type=simple -{env_line}ExecStart="{binary_path}" daemon --port {port} +{env_line}ExecStart="{binary_path}" daemon run --port {port} Restart=on-failure RestartSec=5 @@ -607,6 +608,7 @@ mod tests { "should have socket name matching code" ); assert!(plist.contains("127.0.0.1"), "should bind to localhost only"); + assert!(plist.contains("run"), "should have run subcommand"); assert!( plist.contains("EnvironmentVariables"), "should have env vars (at least PATH)" @@ -686,8 +688,8 @@ mod tests { "should have binary path" ); assert!( - unit.contains("daemon --port 443"), - "should run daemon subcommand with port" + unit.contains("daemon run --port 443"), + "should run daemon run subcommand with port" ); assert!(unit.contains("Type=simple"), "should have Type=simple"); assert!( @@ -700,7 +702,7 @@ mod tests { fn test_systemd_service_unit_custom_port() { let unit = generate_systemd_service_unit("/usr/local/bin/devproxy", 8443, None); assert!( - unit.contains("daemon --port 8443"), + unit.contains("daemon run --port 8443"), "should use custom port in ExecStart" ); } diff --git a/tests/e2e.rs b/tests/e2e.rs index 9f12a13..617fda3 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -152,7 +152,7 @@ impl Drop for DaemonGuard { /// Waits until the IPC socket is connectable. fn start_test_daemon(config_dir: &Path, port: u16) -> DaemonGuard { let child = Command::new(devproxy_bin()) - .args(["daemon", "--port", &port.to_string()]) + .args(["daemon", "run", "--port", &port.to_string()]) .env("DEVPROXY_CONFIG_DIR", config_dir) .env("DEVPROXY_NO_SOCKET_ACTIVATION", "1") .stdout(Stdio::null()) @@ -237,13 +237,17 @@ fn test_cli_help() { stdout.contains("Check for updates") || stdout.contains("self-update"), "help should include update command description" ); - // Daemon should be hidden as a top-level command (it may appear in descriptions) - // Check that "daemon" does not appear as a command entry (lines starting with " daemon") assert!( - !stdout - .lines() - .any(|l| l.trim_start().starts_with("daemon ")), - "daemon command should be hidden from help" + stdout.contains("stop"), + "help should list the stop command" + ); + assert!( + stdout.contains("start"), + "help should list the start command" + ); + assert!( + stdout.contains("daemon"), + "help should list the daemon subcommand group" ); } @@ -348,9 +352,9 @@ fn test_status_without_daemon() { } #[test] -fn test_restart_no_daemon() { +fn test_daemon_restart_no_daemon() { // Without a platform-managed daemon (DEVPROXY_NO_SOCKET_ACTIVATION=1), - // restart should report that no daemon was found and exit non-zero. + // daemon restart should report that no daemon was found and exit non-zero. let config_dir = std::env::temp_dir().join(format!("devproxy-restart-nodaemon-{}", std::process::id())); std::fs::create_dir_all(&config_dir).unwrap(); @@ -361,15 +365,15 @@ fn test_restart_no_daemon() { .unwrap(); let output = Command::new(devproxy_bin()) - .args(["restart"]) + .args(["daemon", "restart"]) .env("DEVPROXY_CONFIG_DIR", &config_dir) .env("DEVPROXY_NO_SOCKET_ACTIVATION", "1") .output() - .expect("failed to run restart"); + .expect("failed to run daemon restart"); assert!( !output.status.success(), - "restart without a platform-managed daemon should exit non-zero" + "daemon restart without a platform-managed daemon should exit non-zero" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -381,24 +385,24 @@ fn test_restart_no_daemon() { } #[test] -fn test_restart_running_daemon() { +fn test_daemon_restart_running_daemon() { // Start a daemon with DEVPROXY_NO_SOCKET_ACTIVATION=1 (no launchd/systemd). - // `restart` should still report "no platform-managed daemon" because the + // `daemon restart` should still report "no platform-managed daemon" because the // daemon is running directly, not via launchd/systemd. let config_dir = create_test_config_dir("restart-running"); let port = find_free_port(); let _guard = start_test_daemon(&config_dir, port); let output = Command::new(devproxy_bin()) - .args(["restart"]) + .args(["daemon", "restart"]) .env("DEVPROXY_CONFIG_DIR", &config_dir) .env("DEVPROXY_NO_SOCKET_ACTIVATION", "1") .output() - .expect("failed to run restart"); + .expect("failed to run daemon restart"); assert!( !output.status.success(), - "restart should exit non-zero when daemon is not platform-managed" + "daemon restart should exit non-zero when daemon is not platform-managed" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!(