From 2e8ad5df671be45bbf29a2621fa6a37f783fd3f2 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 18:12:03 +0100 Subject: [PATCH 01/33] Add devctl crate: local dev environment orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds the devctl CLI tool with: - Config loading: walks up directory tree to find devctl.toml, parses all services/infra/docker config - State tracking: JSON state file in .devctl/ for service mode/PID tracking - Health checks: TCP port probing, Docker daemon check, Caddy check, compose project status, port owner detection via lsof - `devctl status`: shows all services with mode, running state, and URL, plus infra status with per-service port probes - `devctl infra up/down/status`: manages shared infrastructure via docker compose, auto-creates volumes Phase 1 of devctl — replaces session.sh incrementally. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 1 + crates/devctl/Cargo.toml | 25 ++++++ crates/devctl/src/commands/infra.rs | 113 +++++++++++++++++++++++++ crates/devctl/src/commands/mod.rs | 2 + crates/devctl/src/commands/status.rs | 103 +++++++++++++++++++++++ crates/devctl/src/config.rs | 118 +++++++++++++++++++++++++++ crates/devctl/src/error.rs | 1 + crates/devctl/src/health.rs | 56 +++++++++++++ crates/devctl/src/lib.rs | 5 ++ crates/devctl/src/main.rs | 70 ++++++++++++++++ crates/devctl/src/state.rs | 51 ++++++++++++ 11 files changed, 545 insertions(+) create mode 100644 crates/devctl/Cargo.toml create mode 100644 crates/devctl/src/commands/infra.rs create mode 100644 crates/devctl/src/commands/mod.rs create mode 100644 crates/devctl/src/commands/status.rs create mode 100644 crates/devctl/src/config.rs create mode 100644 crates/devctl/src/error.rs create mode 100644 crates/devctl/src/health.rs create mode 100644 crates/devctl/src/lib.rs create mode 100644 crates/devctl/src/main.rs create mode 100644 crates/devctl/src/state.rs diff --git a/Cargo.toml b/Cargo.toml index 90001e1..42aaa92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ tb-lf = { path = "crates/tb-lf" } tb-sem = { path = "crates/tb-sem" } tb-prod = { path = "crates/tb-prod" } tb-bug = { path = "crates/tb-bug" } +devctl = { path = "crates/devctl" } # Dev/test (also in workspace.dependencies so crates can inherit them) assert_cmd = "2" diff --git a/crates/devctl/Cargo.toml b/crates/devctl/Cargo.toml new file mode 100644 index 0000000..670d841 --- /dev/null +++ b/crates/devctl/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "devctl" +version = "0.1.0" +edition = "2024" +description = "Local dev environment orchestrator for Productive services" +authors.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "devctl" +path = "src/main.rs" + +[lib] +doctest = false + +[dependencies] +toolbox-core.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +colored.workspace = true +thiserror.workspace = true diff --git a/crates/devctl/src/commands/infra.rs b/crates/devctl/src/commands/infra.rs new file mode 100644 index 0000000..996d8c4 --- /dev/null +++ b/crates/devctl/src/commands/infra.rs @@ -0,0 +1,113 @@ +use std::path::Path; +use std::process::Command; + +use colored::Colorize; + +use crate::config::Config; +use crate::error::{Error, Result}; +use crate::health; + +pub fn up(config: &Config, project_root: &Path) -> Result<()> { + if !health::docker_is_running() { + return Err(Error::Other("Docker is not running. Start Docker Desktop first.".into())); + } + + let compose_file = project_root.join(&config.infra.compose_file); + + // Auto-create volumes + for svc in config.infra.services.values() { + if let Some(vol) = &svc.volume { + let exists = Command::new("docker") + .args(["volume", "inspect", vol]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + + if !exists { + println!(" Creating volume: {}", vol.bold()); + let status = Command::new("docker") + .args(["volume", "create", vol]) + .stdout(std::process::Stdio::null()) + .status()?; + if !status.success() { + return Err(Error::Other(format!("Failed to create volume: {}", vol))); + } + } + } + } + + println!("{}", "Starting infrastructure...".blue()); + let status = Command::new("docker") + .args([ + "compose", + "-p", + &config.infra.compose_project, + "-f", + &compose_file.to_string_lossy(), + "up", + "-d", + ]) + .status()?; + + if !status.success() { + return Err(Error::Other("docker compose up failed".into())); + } + + println!("{}", "Infrastructure started.".green()); + for (name, svc) in &config.infra.services { + println!(" {} → port {}", name.bold(), svc.port); + } + Ok(()) +} + +pub fn down(config: &Config, project_root: &Path) -> Result<()> { + let compose_file = project_root.join(&config.infra.compose_file); + + println!("{}", "Stopping infrastructure...".yellow()); + let status = Command::new("docker") + .args([ + "compose", + "-p", + &config.infra.compose_project, + "-f", + &compose_file.to_string_lossy(), + "down", + ]) + .status()?; + + if !status.success() { + return Err(Error::Other("docker compose down failed".into())); + } + + println!("{}", "Infrastructure stopped.".green()); + Ok(()) +} + +pub fn status(config: &Config, project_root: &Path) -> Result<()> { + let compose_file = project_root.join(&config.infra.compose_file); + let running = health::compose_is_running( + &config.infra.compose_project, + &compose_file.to_string_lossy(), + ); + + if running { + println!("{}", "Infrastructure is running.".green()); + } else { + println!("{}", "Infrastructure is not running.".red()); + println!(" Start with: devctl infra up"); + return Ok(()); + } + + // Show per-service port status + for (name, svc) in &config.infra.services { + let port_status = if health::port_is_open(svc.port) { + "●".green() + } else { + "○".red() + }; + println!(" {} {} (port {})", port_status, name, svc.port); + } + + Ok(()) +} diff --git a/crates/devctl/src/commands/mod.rs b/crates/devctl/src/commands/mod.rs new file mode 100644 index 0000000..5ad93cc --- /dev/null +++ b/crates/devctl/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod infra; +pub mod status; diff --git a/crates/devctl/src/commands/status.rs b/crates/devctl/src/commands/status.rs new file mode 100644 index 0000000..154ba62 --- /dev/null +++ b/crates/devctl/src/commands/status.rs @@ -0,0 +1,103 @@ +use std::path::Path; + +use colored::Colorize; + +use crate::config::Config; +use crate::error::Result; +use crate::health; +use crate::state::State; + +pub fn run(config: &Config, project_root: &Path) -> Result<()> { + let state = State::load(project_root)?; + + // Prerequisite checks + let docker_ok = health::docker_is_running(); + let caddy_ok = health::caddy_is_running(); + + if !docker_ok { + println!("{} Docker is not running", "✗".red()); + } + if !caddy_ok { + println!("{} Caddy is not running (localhost:2019)", "!".yellow()); + } + + // Service table header + println!(); + println!( + " {:<20} {:<10} {:<10} {:<30}", + "SERVICE", "MODE", "STATE", "URL" + ); + println!( + " {:<20} {:<10} {:<10} {:<30}", + "───────", "────", "─────", "───" + ); + + for (name, svc) in &config.services { + let mode; + let state_str; + + if let Some(svc_state) = state.services.get(name) { + mode = svc_state.mode.clone(); + } else { + mode = "-".to_string(); + } + + // Determine actual running state by probing the port + if let Some(port) = svc.port { + if health::port_is_open(port) { + state_str = "running".green().to_string(); + } else if mode != "-" { + state_str = "stopped".red().to_string(); + } else { + state_str = "stopped".dimmed().to_string(); + } + } else { + // No port (e.g., sidekiq) — can't probe + if mode != "-" { + state_str = "running".green().to_string(); + } else { + state_str = "-".dimmed().to_string(); + } + } + + let url = svc + .hostname + .as_deref() + .unwrap_or("-") + .to_string(); + + println!( + " {:<20} {:<10} {:<22} {}", + name, mode, state_str, url + ); + } + + // Infra status + let compose_file = project_root.join(&config.infra.compose_file); + let infra_running = health::compose_is_running( + &config.infra.compose_project, + &compose_file.to_string_lossy(), + ); + + println!(); + println!( + " {:<20} {:<10}", + "INFRA", "STATE" + ); + println!( + " {:<20} {:<10}", + "─────", "─────" + ); + + for (name, svc) in &config.infra.services { + let state_str = if infra_running && health::port_is_open(svc.port) { + "running".green().to_string() + } else { + "stopped".red().to_string() + }; + println!(" {:<20} {}", name, state_str); + } + + println!(); + Ok(()) +} diff --git a/crates/devctl/src/config.rs b/crates/devctl/src/config.rs new file mode 100644 index 0000000..82cce58 --- /dev/null +++ b/crates/devctl/src/config.rs @@ -0,0 +1,118 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::{Error, Result}; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub infra: InfraConfig, + pub docker: DockerConfig, + #[serde(default)] + pub services: BTreeMap, +} + +#[derive(Debug, Deserialize)] +pub struct InfraConfig { + pub compose_file: String, + pub compose_project: String, + #[serde(default)] + pub services: BTreeMap, +} + +#[derive(Debug, Deserialize)] +pub struct InfraServiceConfig { + pub port: u16, + #[serde(default)] + pub volume: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DockerConfig { + pub compose_file: String, + pub compose_project: String, + pub container: String, +} + +#[derive(Debug, Deserialize)] +pub struct ServiceConfig { + #[serde(default)] + pub port: Option, + #[serde(default)] + pub hostname: Option, + #[serde(default)] + pub repo: Option, + #[serde(default)] + pub cmd: Option, + #[serde(default)] + pub infra: Vec, + #[serde(default)] + pub secrets: Vec, + #[serde(default)] + pub companion: Option, + #[serde(default)] + pub init: Vec, + #[serde(default)] + pub start: Vec, +} + +/// Walk up from `start` looking for `devctl.toml`. +/// Returns (config, project_root) on success. +pub fn find_and_load(start: &Path) -> Result<(Config, PathBuf)> { + let config_path = find_config_file(start)?; + let project_root = config_path + .parent() + .ok_or_else(|| Error::Config("devctl.toml has no parent directory".into()))? + .to_path_buf(); + + let content = std::fs::read_to_string(&config_path)?; + let config: Config = toml::from_str(&content)?; + Ok((config, project_root)) +} + +/// Walk up the directory tree to find `devctl.toml`. +fn find_config_file(start: &Path) -> Result { + let mut dir = start.to_path_buf(); + loop { + let candidate = dir.join("devctl.toml"); + if candidate.exists() { + return Ok(candidate); + } + if !dir.pop() { + return Err(Error::Config( + "devctl.toml not found (searched up from current directory)".into(), + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_config() { + let toml_str = r#" +[infra] +compose_file = "docker/infra-compose.yml" +compose_project = "productive-infra" + +[docker] +compose_file = "docker/dev-compose.yml" +compose_project = "productive-dev" +container = "productive-dev-workspace" + +[services.api] +port = 3000 +hostname = "api.productive.io.localhost" +repo = "api" +cmd = "bundle exec rails server -b 0.0.0.0 -p 3000" +infra = ["mysql", "redis"] +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.services.len(), 1); + assert_eq!(config.services["api"].port, Some(3000)); + assert_eq!(config.services["api"].infra, vec!["mysql", "redis"]); + } +} diff --git a/crates/devctl/src/error.rs b/crates/devctl/src/error.rs new file mode 100644 index 0000000..b3b881b --- /dev/null +++ b/crates/devctl/src/error.rs @@ -0,0 +1 @@ +toolbox_core::define_error!(Error); diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs new file mode 100644 index 0000000..2a364df --- /dev/null +++ b/crates/devctl/src/health.rs @@ -0,0 +1,56 @@ +use std::net::TcpStream; +use std::process::Command; +use std::time::Duration; + +/// Check if a TCP port is listening on localhost. +pub fn port_is_open(port: u16) -> bool { + TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port).parse().unwrap(), + Duration::from_millis(200), + ) + .is_ok() +} + +/// Check if Docker daemon is running. +pub fn docker_is_running() -> bool { + Command::new("docker") + .args(["info"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Check if Caddy admin API is responding (localhost:2019). +pub fn caddy_is_running() -> bool { + port_is_open(2019) +} + +/// Check if a Docker compose project has running containers. +pub fn compose_is_running(project: &str, compose_file: &str) -> bool { + Command::new("docker") + .args(["compose", "-p", project, "-f", compose_file, "ps", "--quiet"]) + .output() + .is_ok_and(|o| !o.stdout.is_empty()) +} + +/// Get the PID and command of the process listening on a port. +/// Returns None if no process is found. +pub fn port_owner(port: u16) -> Option<(u32, String)> { + let output = Command::new("lsof") + .args(["-i", &format!(":{}", port), "-sTCP:LISTEN", "-n", "-P"]) + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + // Skip header line, parse first result + let line = stdout.lines().nth(1)?; + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let pid: u32 = parts[1].parse().ok()?; + let cmd = parts[0].to_string(); + Some((pid, cmd)) + } else { + None + } +} diff --git a/crates/devctl/src/lib.rs b/crates/devctl/src/lib.rs new file mode 100644 index 0000000..62edb91 --- /dev/null +++ b/crates/devctl/src/lib.rs @@ -0,0 +1,5 @@ +pub mod commands; +pub mod config; +pub mod error; +pub mod health; +pub mod state; diff --git a/crates/devctl/src/main.rs b/crates/devctl/src/main.rs new file mode 100644 index 0000000..7d75e26 --- /dev/null +++ b/crates/devctl/src/main.rs @@ -0,0 +1,70 @@ +use std::env; + +use clap::Parser; +use colored::Colorize; + +use devctl::commands; +use devctl::config; + +#[derive(Parser)] +#[command( + name = "devctl", + about = "Local dev environment orchestrator for Productive services" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(clap::Subcommand)] +enum Commands { + /// Show status of all services and infrastructure + Status, + + /// Manage shared infrastructure (MySQL, Redis, etc.) + Infra { + #[command(subcommand)] + action: InfraAction, + }, +} + +#[derive(clap::Subcommand)] +enum InfraAction { + /// Start shared infrastructure + Up, + /// Stop shared infrastructure + Down, + /// Check infrastructure status + Status, +} + +fn main() { + let cli = Cli::parse(); + + let cwd = env::current_dir().unwrap_or_else(|e| { + eprintln!("{} Cannot determine current directory: {}", "Error:".red().bold(), e); + std::process::exit(1); + }); + + let (cfg, root) = match config::find_and_load(&cwd) { + Ok(result) => result, + Err(e) => { + eprintln!("{} {}", "Error:".red().bold(), e); + std::process::exit(1); + } + }; + + let result = match cli.command { + Commands::Status => commands::status::run(&cfg, &root), + Commands::Infra { action } => match action { + InfraAction::Up => commands::infra::up(&cfg, &root), + InfraAction::Down => commands::infra::down(&cfg, &root), + InfraAction::Status => commands::infra::status(&cfg, &root), + }, + }; + + if let Err(e) = result { + eprintln!("{} {}", "Error:".red().bold(), e); + std::process::exit(1); + } +} diff --git a/crates/devctl/src/state.rs b/crates/devctl/src/state.rs new file mode 100644 index 0000000..4c7582a --- /dev/null +++ b/crates/devctl/src/state.rs @@ -0,0 +1,51 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct State { + #[serde(default)] + pub services: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ServiceState { + pub mode: String, + pub started_at: String, + #[serde(default)] + pub dir: Option, + #[serde(default)] + pub pid: Option, +} + +impl State { + /// Load state from `.devctl/state.json` under the project root. + /// Returns empty state if file doesn't exist. + pub fn load(project_root: &Path) -> Result { + let path = state_path(project_root); + if !path.exists() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(&path)?; + let state: Self = serde_json::from_str(&content)?; + Ok(state) + } + + /// Save state to `.devctl/state.json` under the project root. + pub fn save(&self, project_root: &Path) -> Result<()> { + let path = state_path(project_root); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(self)?; + std::fs::write(&path, content)?; + Ok(()) + } +} + +fn state_path(project_root: &Path) -> PathBuf { + project_root.join(".devctl").join("state.json") +} From ff81d33f7a8add7b989415cfc7a2c35523437f2f Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 18:24:20 +0100 Subject: [PATCH 02/33] Add start, stop, restart, logs, and doctor commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `devctl start --docker`: full Docker mode start with port conflict detection, repo/secrets validation, auto-infra startup, Procfile generation (with runtime version wrappers), container lifecycle, healthcheck waiting, state tracking, env capture - `devctl stop`: stops Docker container, clears state - `devctl restart `: restarts individual service via overmind - `devctl logs `: captures logs from overmind tmux pane (app services) or docker compose logs (infra services) - `devctl doctor`: comprehensive diagnostic — checks Docker, Caddy, infra ports, repo presence, secrets, and reports issues Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/Cargo.toml | 1 + crates/devctl/src/commands/doctor.rs | 110 ++++++++++++ crates/devctl/src/commands/logs.rs | 90 ++++++++++ crates/devctl/src/commands/mod.rs | 4 + crates/devctl/src/commands/start.rs | 244 +++++++++++++++++++++++++++ crates/devctl/src/commands/stop.rs | 47 ++++++ crates/devctl/src/docker.rs | 187 ++++++++++++++++++++ crates/devctl/src/lib.rs | 1 + crates/devctl/src/main.rs | 50 ++++++ 9 files changed, 734 insertions(+) create mode 100644 crates/devctl/src/commands/doctor.rs create mode 100644 crates/devctl/src/commands/logs.rs create mode 100644 crates/devctl/src/commands/start.rs create mode 100644 crates/devctl/src/commands/stop.rs create mode 100644 crates/devctl/src/docker.rs diff --git a/crates/devctl/Cargo.toml b/crates/devctl/Cargo.toml index 670d841..be34770 100644 --- a/crates/devctl/Cargo.toml +++ b/crates/devctl/Cargo.toml @@ -21,5 +21,6 @@ reqwest.workspace = true serde.workspace = true serde_json.workspace = true toml.workspace = true +chrono.workspace = true colored.workspace = true thiserror.workspace = true diff --git a/crates/devctl/src/commands/doctor.rs b/crates/devctl/src/commands/doctor.rs new file mode 100644 index 0000000..ab61fa8 --- /dev/null +++ b/crates/devctl/src/commands/doctor.rs @@ -0,0 +1,110 @@ +use std::path::Path; + +use colored::Colorize; + +use crate::config::Config; +use crate::error::Result; +use crate::health; + +pub fn run(config: &Config, project_root: &Path) -> Result<()> { + let mut issues = 0; + + // --- System checks --- + println!("{}", "System".bold()); + + let docker_ok = health::docker_is_running(); + if docker_ok { + println!(" {} Docker", "✓".green()); + } else { + println!(" {} Docker — not running", "✗".red()); + issues += 1; + } + + let caddy_ok = health::caddy_is_running(); + if caddy_ok { + println!(" {} Caddy (localhost:2019)", "✓".green()); + } else { + println!( + " {} Caddy — not responding on localhost:2019", + "✗".red() + ); + println!(" Run: ./scripts/setup-caddy.sh"); + issues += 1; + } + + // --- Infrastructure --- + println!(); + println!("{}", "Infrastructure".bold()); + + let compose_file = project_root.join(&config.infra.compose_file); + let infra_running = health::compose_is_running( + &config.infra.compose_project, + &compose_file.to_string_lossy(), + ); + + for (name, svc) in &config.infra.services { + if infra_running && health::port_is_open(svc.port) { + println!(" {} {} (port {})", "✓".green(), name, svc.port); + } else { + println!(" {} {} (port {}) — not running", "✗".red(), name, svc.port); + issues += 1; + } + } + + // --- Services --- + println!(); + println!("{}", "Services".bold()); + + let repos_dir = project_root.join("repos"); + + for (name, svc) in &config.services { + let mut svc_issues = Vec::new(); + + // Repo cloned? + if let Some(repo) = &svc.repo { + let repo_path = repos_dir.join(repo); + if !repo_path.exists() { + svc_issues.push("repo not cloned".into()); + } else { + // Secrets present? + for secret in &svc.secrets { + if !repo_path.join(secret).exists() { + svc_issues.push(format!("missing {}", secret)); + } + } + } + } + + // Port conflict with non-devctl process? + if let Some(port) = svc.port + && health::port_is_open(port) { + // Port is in use — could be devctl or something else, just note it + } + + if svc_issues.is_empty() { + println!(" {} {}", "✓".green(), name); + } else { + println!( + " {} {} — {}", + "✗".red(), + name, + svc_issues.join(", ") + ); + issues += 1; + } + } + + // --- Summary --- + println!(); + if issues == 0 { + println!("{}", "Everything looks good!".green().bold()); + } else { + println!( + "{} {} issue(s) found.", + "!".yellow().bold(), + issues + ); + } + + Ok(()) +} diff --git a/crates/devctl/src/commands/logs.rs b/crates/devctl/src/commands/logs.rs new file mode 100644 index 0000000..e365a39 --- /dev/null +++ b/crates/devctl/src/commands/logs.rs @@ -0,0 +1,90 @@ +use std::path::Path; +use std::process::Command; + +use crate::config::Config; +use crate::docker; +use crate::error::{Error, Result}; + +pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { + // Infra services → docker compose logs + if config.infra.services.contains_key(service) { + let compose_file = project_root.join(&config.infra.compose_file); + let status = Command::new("docker") + .args([ + "compose", + "-p", + &config.infra.compose_project, + "-f", + &compose_file.to_string_lossy(), + "logs", + "-f", + "--tail", + "100", + service, + ]) + .status()?; + + if !status.success() { + return Err(Error::Other(format!("Failed to get logs for {}", service))); + } + return Ok(()); + } + + // App services → overmind tmux pane capture (non-interactive) + if !docker::container_is_running(config) { + return Err(Error::Other( + "Dev container is not running.".into(), + )); + } + + // Find the overmind tmux socket + let output = Command::new("docker") + .args([ + "exec", + "-u", + "dev", + &config.docker.container, + "bash", + "-c", + "basename $(ls -d /tmp/overmind-workspace-*/ 2>/dev/null | head -1) 2>/dev/null || echo ''", + ]) + .output()?; + + let socket = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if socket.is_empty() { + return Err(Error::Other( + "Overmind not running in container.".into(), + )); + } + + // Capture last 100 lines from tmux pane + let output = Command::new("docker") + .args([ + "exec", + "-u", + "dev", + &config.docker.container, + "tmux", + "-L", + &socket, + "capture-pane", + "-t", + &format!("workspace:{}", service), + "-p", + "-S", + "-100", + ]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::Other(format!( + "Failed to capture logs for '{}': {}", + service, + stderr.trim() + ))); + } + + print!("{}", String::from_utf8_lossy(&output.stdout)); + Ok(()) +} diff --git a/crates/devctl/src/commands/mod.rs b/crates/devctl/src/commands/mod.rs index 5ad93cc..36fdb65 100644 --- a/crates/devctl/src/commands/mod.rs +++ b/crates/devctl/src/commands/mod.rs @@ -1,2 +1,6 @@ +pub mod doctor; pub mod infra; +pub mod logs; +pub mod start; pub mod status; +pub mod stop; diff --git a/crates/devctl/src/commands/start.rs b/crates/devctl/src/commands/start.rs new file mode 100644 index 0000000..1db823a --- /dev/null +++ b/crates/devctl/src/commands/start.rs @@ -0,0 +1,244 @@ +use std::path::Path; + +use colored::Colorize; + +use crate::config::Config; +use crate::docker; +use crate::error::{Error, Result}; +use crate::health; +use crate::state::{ServiceState, State}; + +pub fn docker( + config: &Config, + project_root: &Path, + services: &[String], + skip_setup: bool, +) -> Result<()> { + // --- Prerequisite: Docker running --- + if !health::docker_is_running() { + return Err(Error::Other( + "Docker is not running. Start Docker Desktop first.".into(), + )); + } + + // --- Validate services --- + for svc in services { + if !config.services.contains_key(svc) { + return Err(Error::Config(format!( + "Unknown service: '{}'. Check devctl.toml.", + svc + ))); + } + } + + // --- Check port conflicts --- + println!("{}", "Checking ports...".blue()); + let mut conflicts = Vec::new(); + for svc_name in services { + let svc = &config.services[svc_name]; + if let Some(port) = svc.port + && health::port_is_open(port) { + let owner = health::port_owner(port) + .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) + .unwrap_or_else(|| "unknown".into()); + conflicts.push(format!(" {} (port {}) — occupied by {}", svc_name, port, owner)); + } + } + // Also check companion ports + for svc_name in services { + if let Some(companion) = &config.services[svc_name].companion + && let Some(comp_svc) = config.services.get(companion) + && let Some(port) = comp_svc.port + && health::port_is_open(port) { + let owner = health::port_owner(port) + .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) + .unwrap_or_else(|| "unknown".into()); + conflicts.push(format!( + " {} (port {}) — occupied by {}", + companion, port, owner + )); + } + } + if !conflicts.is_empty() { + eprintln!("{}", "Port conflicts detected:".red()); + for c in &conflicts { + eprintln!("{}", c); + } + return Err(Error::Other( + "Stop conflicting processes before starting.".into(), + )); + } + + // --- Ensure repos are cloned --- + println!("{}", "Checking repos...".blue()); + let repos_dir = project_root.join("repos"); + for svc_name in services { + let svc = &config.services[svc_name]; + if let Some(repo) = &svc.repo + && !repos_dir.join(repo).exists() { + return Err(Error::Config(format!( + "Repo not cloned: repos/{}. Run: git clone https://github.com/productiveio/{}.git repos/{}", + repo, repo, repo + ))); + } + } + + // --- Check secrets --- + println!("{}", "Checking secrets...".blue()); + let mut missing = Vec::new(); + for svc_name in services { + let svc = &config.services[svc_name]; + if let Some(repo) = &svc.repo { + for secret in &svc.secrets { + let secret_path = repos_dir.join(repo).join(secret); + if !secret_path.exists() { + missing.push(format!(" {}: {} (missing)", svc_name, secret)); + } + } + } + } + if !missing.is_empty() { + eprintln!("{}", "Missing secrets:".red()); + for m in &missing { + eprintln!("{}", m); + } + return Err(Error::Other( + "Pull secrets before starting. See devctl.toml init steps.".into(), + )); + } + + // --- Auto-start infra if needed --- + let infra_compose = project_root.join(&config.infra.compose_file); + let infra_needed = services.iter().any(|svc_name| { + !config.services[svc_name].infra.is_empty() + }); + + if infra_needed { + let infra_running = health::compose_is_running( + &config.infra.compose_project, + &infra_compose.to_string_lossy(), + ); + if !infra_running { + println!("{}", "Starting infrastructure...".blue()); + crate::commands::infra::up(config, project_root)?; + } else { + println!(" Infrastructure already running."); + } + } + + // --- Stop existing container if running --- + if docker::container_is_running(config) { + println!("{}", "Stopping existing container...".yellow()); + docker::stop_container(config, project_root)?; + } + + // --- Capture env vars --- + println!("{}", "Capturing environment...".blue()); + capture_env(project_root)?; + + // --- Generate Procfile --- + println!("{}", "Generating Procfile...".blue()); + docker::generate_procfile(config, services, project_root)?; + + // --- Start container --- + println!("{}", "Starting container...".blue()); + docker::start_container(config, project_root, services, skip_setup)?; + + // --- Wait for healthy --- + print!("{}", "Waiting for container to be ready".blue()); + docker::wait_for_healthy(config)?; + println!(" {}", "ready!".green()); + + // --- Update state --- + let now = chrono::Utc::now().to_rfc3339(); + let mut state = State::load(project_root)?; + // Clear previous docker services + state.services.retain(|_, s| s.mode != "docker"); + for svc_name in services { + state.services.insert( + svc_name.clone(), + ServiceState { + mode: "docker".into(), + started_at: now.clone(), + dir: config.services[svc_name] + .repo + .as_ref() + .map(|r| format!("repos/{}", r)), + pid: None, + }, + ); + // Track companions too + if let Some(companion) = &config.services[svc_name].companion { + state.services.insert( + companion.clone(), + ServiceState { + mode: "docker".into(), + started_at: now.clone(), + dir: config.services.get(companion) + .and_then(|s| s.repo.as_ref()) + .map(|r| format!("repos/{}", r)), + pid: None, + }, + ); + } + } + state.save(project_root)?; + + // --- Report --- + println!(); + println!("{}", "Services started:".green()); + for svc_name in services { + let svc = &config.services[svc_name]; + if let Some(hostname) = &svc.hostname { + println!(" https://{} → port {}", hostname, svc.port.unwrap_or(0)); + } + } + println!(); + println!("Branch switch: cd repos/ && git checkout "); + println!("Then: devctl stop && devctl start --docker"); + + Ok(()) +} + +/// Capture host environment variables to .env.session file. +fn capture_env(project_root: &Path) -> Result<()> { + let env_dir = project_root.join(".docker-sessions/.dev"); + std::fs::create_dir_all(&env_dir)?; + let env_file = env_dir.join(".env.session"); + + let mut lines = vec!["# Auto-captured from host environment".to_string()]; + + let vars = [ + "ANTHROPIC_API_KEY", + "PRODUCTIVE_AUTH_TOKEN", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "BUGSNAG_AUTH_TOKEN", + "SEMAPHORE_API_TOKEN", + "GRAFANA_SERVICE_ACCOUNT_TOKEN", + ]; + + for var in &vars { + let val = std::env::var(var).unwrap_or_default(); + lines.push(format!("{}={}", var, val)); + } + + // GH_TOKEN fallback + let gh_token = std::env::var("GH_TOKEN") + .or_else(|_| std::env::var("GITHUB_PERSONAL_ACCESS_TOKEN")) + .unwrap_or_default(); + lines.push(format!("GH_TOKEN={}", gh_token)); + + // AWS + let aws_region = std::env::var("AWS_DEFAULT_REGION").unwrap_or_else(|_| "eu-central-1".into()); + lines.push(format!("AWS_DEFAULT_REGION={}", aws_region)); + + for var in &["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_PROFILE"] { + if let Ok(val) = std::env::var(var) + && !val.is_empty() { + lines.push(format!("{}={}", var, val)); + } + } + + std::fs::write(&env_file, lines.join("\n") + "\n")?; + Ok(()) +} diff --git a/crates/devctl/src/commands/stop.rs b/crates/devctl/src/commands/stop.rs new file mode 100644 index 0000000..e02b09a --- /dev/null +++ b/crates/devctl/src/commands/stop.rs @@ -0,0 +1,47 @@ +use std::path::Path; + +use colored::Colorize; + +use crate::config::Config; +use crate::docker; +use crate::error::{Error, Result}; +use crate::state::State; + +pub fn run(config: &Config, project_root: &Path) -> Result<()> { + if !docker::container_is_running(config) { + println!("{}", "Dev container is not running.".yellow()); + return Ok(()); + } + + println!("{}", "Stopping dev container...".yellow()); + docker::stop_container(config, project_root)?; + + // Clear docker services from state + let mut state = State::load(project_root)?; + state.services.retain(|_, s| s.mode != "docker"); + state.save(project_root)?; + + println!("{}", "Dev container stopped.".green()); + Ok(()) +} + +/// Restart a specific service inside the running container via overmind. +pub fn restart_service(config: &Config, service: &str) -> Result<()> { + if !docker::container_is_running(config) { + return Err(Error::Other( + "Dev container is not running. Start with: devctl start --docker".into(), + )); + } + + println!("Restarting {}...", service.bold()); + let status = std::process::Command::new("docker") + .args(["exec", &config.docker.container, "overmind", "restart", service]) + .status()?; + + if !status.success() { + return Err(Error::Other(format!("Failed to restart {}", service))); + } + + println!("{} restarted.", service.green()); + Ok(()) +} diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs new file mode 100644 index 0000000..ada081f --- /dev/null +++ b/crates/devctl/src/docker.rs @@ -0,0 +1,187 @@ +use std::path::Path; +use std::process::Command; + +use crate::config::{Config, ServiceConfig}; +use crate::error::{Error, Result}; + +/// Generate a Procfile for overmind from the selected services. +/// Writes to `.docker-sessions/.dev/Procfile.dev`. +pub fn generate_procfile( + config: &Config, + services: &[String], + project_root: &Path, +) -> Result<()> { + let procfile_dir = project_root.join(".docker-sessions/.dev"); + std::fs::create_dir_all(&procfile_dir)?; + let procfile_path = procfile_dir.join("Procfile.dev"); + + let mut lines = Vec::new(); + + for svc_name in services { + let svc = config.services.get(svc_name).ok_or_else(|| { + Error::Config(format!("Unknown service: {}", svc_name)) + })?; + + if let Some(entry) = procfile_entry(svc_name, svc, config, project_root) { + lines.push(entry); + } + + // Add companion (e.g., sidekiq for api) + if let Some(companion) = &svc.companion + && let Some(comp_svc) = config.services.get(companion) + && let Some(entry) = procfile_entry(companion, comp_svc, config, project_root) { + lines.push(entry); + } + } + + std::fs::write(&procfile_path, lines.join("\n") + "\n")?; + Ok(()) +} + +/// Build a single Procfile entry, with runtime version wrappers if needed. +fn procfile_entry( + name: &str, + svc: &ServiceConfig, + _config: &Config, + project_root: &Path, +) -> Option { + let repo = svc.repo.as_deref()?; + let cmd = svc.cmd.as_deref()?; + + let repos_dir = project_root.join("repos"); + let mut wrapper = String::new(); + + // Check if repo needs a different Ruby version + let ruby_version_file = repos_dir.join(repo).join(".ruby-version"); + if ruby_version_file.exists() + && let Ok(version) = std::fs::read_to_string(&ruby_version_file) { + let version = version.trim(); + let default_ruby = "3.4.7"; // matches Dockerfile.base ARG + if version != default_ruby { + wrapper.push_str(&format!("rvm use {} && ", version)); + } + } + + // Check if repo needs a different Node version + let node_version = read_node_version(&repos_dir.join(repo)); + if let Some(version) = node_version { + let default_node = "22.16.0"; // matches Dockerfile.base ARG + if version != default_node { + wrapper.push_str(&format!(". /usr/local/nvm/nvm.sh && nvm use {} && ", version)); + } + } + + let full_cmd = if wrapper.is_empty() { + format!("{}: cd /workspace/{} && {}", name, repo, cmd) + } else { + format!( + "{}: bash -lc '{} cd /workspace/{} && {}'", + name, wrapper, repo, cmd + ) + }; + + Some(full_cmd) +} + +/// Read Node version from .node-version or .nvmrc +fn read_node_version(repo_path: &Path) -> Option { + for filename in &[".node-version", ".nvmrc"] { + let path = repo_path.join(filename); + if path.exists() + && let Ok(version) = std::fs::read_to_string(&path) { + return Some(version.trim().to_string()); + } + } + None +} + +/// Check if the dev container is currently running. +pub fn container_is_running(config: &Config) -> bool { + Command::new("docker") + .args([ + "ps", + "--filter", + &format!("name={}", config.docker.container), + "--format", + "{{.Status}}", + ]) + .output() + .is_ok_and(|o| !o.stdout.is_empty()) +} + +/// Stop the dev container. +pub fn stop_container(config: &Config, project_root: &Path) -> Result<()> { + let compose_file = project_root.join(&config.docker.compose_file); + let status = Command::new("docker") + .args([ + "compose", + "-p", + &config.docker.compose_project, + "-f", + &compose_file.to_string_lossy(), + "down", + ]) + .status()?; + + if !status.success() { + return Err(Error::Other("Failed to stop dev container".into())); + } + Ok(()) +} + +/// Start the dev container. +pub fn start_container( + config: &Config, + project_root: &Path, + services: &[String], + skip_setup: bool, +) -> Result<()> { + let compose_file = project_root.join(&config.docker.compose_file); + + let selected_repos = services.join(","); + + let mut cmd = Command::new("docker"); + cmd.args([ + "compose", + "-p", + &config.docker.compose_project, + "-f", + &compose_file.to_string_lossy(), + "up", + "-d", + ]); + cmd.env("SELECTED_REPOS", &selected_repos); + if skip_setup { + cmd.env("SKIP_SETUP", "true"); + } + + let status = cmd.status()?; + if !status.success() { + return Err(Error::Other("Failed to start dev container".into())); + } + Ok(()) +} + +/// Wait for the container healthcheck to pass. +pub fn wait_for_healthy(config: &Config) -> Result<()> { + let container = &config.docker.container; + for i in 0..60 { + let output = Command::new("docker") + .args(["inspect", "--format", "{{.State.Health.Status}}", container]) + .output()?; + + let status = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if status == "healthy" { + return Ok(()); + } + + if i % 5 == 0 && i > 0 { + eprint!("."); + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } + + Err(Error::Other( + "Container did not become healthy within 2 minutes".into(), + )) +} diff --git a/crates/devctl/src/lib.rs b/crates/devctl/src/lib.rs index 62edb91..f515c53 100644 --- a/crates/devctl/src/lib.rs +++ b/crates/devctl/src/lib.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod config; +pub mod docker; pub mod error; pub mod health; pub mod state; diff --git a/crates/devctl/src/main.rs b/crates/devctl/src/main.rs index 7d75e26..ff17078 100644 --- a/crates/devctl/src/main.rs +++ b/crates/devctl/src/main.rs @@ -21,11 +21,43 @@ enum Commands { /// Show status of all services and infrastructure Status, + /// Start services + Start { + /// Comma-separated list of services + services: String, + + /// Run in Docker container + #[arg(long)] + docker: bool, + + /// Skip dependency install and DB setup + #[arg(long)] + skip_setup: bool, + }, + + /// Stop the Docker dev container + Stop, + + /// Restart a service inside the running container + Restart { + /// Service name + service: String, + }, + + /// View logs for a service + Logs { + /// Service name + service: String, + }, + /// Manage shared infrastructure (MySQL, Redis, etc.) Infra { #[command(subcommand)] action: InfraAction, }, + + /// Diagnose environment health + Doctor, } #[derive(clap::Subcommand)] @@ -56,11 +88,29 @@ fn main() { let result = match cli.command { Commands::Status => commands::status::run(&cfg, &root), + Commands::Start { + services, + docker, + skip_setup, + } => { + let svc_list: Vec = services.split(',').map(|s| s.trim().to_string()).collect(); + if docker { + commands::start::docker(&cfg, &root, &svc_list, skip_setup) + } else { + Err(devctl::error::Error::Other( + "Local mode (--local) not yet implemented. Use --docker.".into(), + )) + } + } + Commands::Stop => commands::stop::run(&cfg, &root), + Commands::Restart { service } => commands::stop::restart_service(&cfg, &service), + Commands::Logs { service } => commands::logs::run(&cfg, &root, &service), Commands::Infra { action } => match action { InfraAction::Up => commands::infra::up(&cfg, &root), InfraAction::Down => commands::infra::down(&cfg, &root), InfraAction::Status => commands::infra::status(&cfg, &root), }, + Commands::Doctor => commands::doctor::run(&cfg, &root), }; if let Err(e) = result { From 48409e57a62375633ccc077ebf30544be2315e4a Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 18:42:37 +0100 Subject: [PATCH 03/33] Fix status: use overmind for Docker services, compose for infra - Status now queries overmind inside the container for Docker service state instead of TCP port probing (which gave false positives due to Docker's static port bindings) - Infra status now uses docker compose ps instead of host port probing (works even when ports aren't published to host) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/status.rs | 118 ++++++++++++++++----------- crates/devctl/src/docker.rs | 36 ++++++++ crates/devctl/src/health.rs | 33 ++++++++ 3 files changed, 141 insertions(+), 46 deletions(-) diff --git a/crates/devctl/src/commands/status.rs b/crates/devctl/src/commands/status.rs index 154ba62..8454e6c 100644 --- a/crates/devctl/src/commands/status.rs +++ b/crates/devctl/src/commands/status.rs @@ -3,6 +3,7 @@ use std::path::Path; use colored::Colorize; use crate::config::Config; +use crate::docker; use crate::error::Result; use crate::health; use crate::state::State; @@ -21,6 +22,14 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { println!("{} Caddy is not running (localhost:2019)", "!".yellow()); } + // If container is running, get overmind status for accurate service state + let container_up = docker::container_is_running(config); + let overmind = if container_up { + docker::overmind_status(config) + } else { + Default::default() + }; + // Service table header println!(); println!( @@ -33,43 +42,17 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { ); for (name, svc) in &config.services { - let mode; - let state_str; - - if let Some(svc_state) = state.services.get(name) { - mode = svc_state.mode.clone(); + let mode = if let Some(svc_state) = state.services.get(name) { + svc_state.mode.clone() } else { - mode = "-".to_string(); - } + "-".to_string() + }; - // Determine actual running state by probing the port - if let Some(port) = svc.port { - if health::port_is_open(port) { - state_str = "running".green().to_string(); - } else if mode != "-" { - state_str = "stopped".red().to_string(); - } else { - state_str = "stopped".dimmed().to_string(); - } - } else { - // No port (e.g., sidekiq) — can't probe - if mode != "-" { - state_str = "running".green().to_string(); - } else { - state_str = "-".dimmed().to_string(); - } - } + let state_str = determine_service_state(name, svc.port, &mode, &overmind, container_up); - let url = svc - .hostname - .as_deref() - .unwrap_or("-") - .to_string(); + let url = svc.hostname.as_deref().unwrap_or("-").to_string(); - println!( - " {:<20} {:<10} {:<22} {}", - name, mode, state_str, url - ); + println!(" {:<20} {:<10} {:<22} {}", name, mode, state_str, url); } // Infra status @@ -80,20 +63,23 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { ); println!(); - println!( - " {:<20} {:<10}", - "INFRA", "STATE" - ); - println!( - " {:<20} {:<10}", - "─────", "─────" - ); + println!(" {:<20} {:<10}", "INFRA", "STATE"); + println!(" {:<20} {:<10}", "─────", "─────"); - for (name, svc) in &config.infra.services { - let state_str = if infra_running && health::port_is_open(svc.port) { - "running".green().to_string() - } else { - "stopped".red().to_string() + let infra_containers = if infra_running { + health::compose_container_states( + &config.infra.compose_project, + &compose_file.to_string_lossy(), + ) + } else { + Default::default() + }; + + for (name, _svc) in &config.infra.services { + let state_str = match infra_containers.get(name.as_str()).map(|s| s.as_str()) { + Some(s) if s.starts_with("Up") => "running".green().to_string(), + Some(s) => s.yellow().to_string(), + None => "stopped".red().to_string(), }; println!(" {:<20} {}", name, state_str); } @@ -101,3 +87,43 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { println!(); Ok(()) } + +fn determine_service_state( + name: &str, + port: Option, + mode: &str, + overmind: &std::collections::BTreeMap, + container_up: bool, +) -> String { + // Docker mode: use overmind as source of truth + if mode == "docker" && container_up { + return match overmind.get(name).map(|s| s.as_str()) { + Some("running") => "running".green().to_string(), + Some("dead") => "crashed".red().to_string(), + Some(other) => other.yellow().to_string(), + None => "not in procfile".dimmed().to_string(), + }; + } + + // No mode set: check if something is actually listening on the port + // but only if the container is NOT running (to avoid false positives + // from Docker's static port bindings) + if mode == "-" { + if let Some(port) = port { + if !container_up && health::port_is_open(port) { + // Something external is using this port + return "running (external)".yellow().to_string(); + } + } + return "-".dimmed().to_string(); + } + + // Local mode (future): probe port + if let Some(port) = port { + if health::port_is_open(port) { + return "running".green().to_string(); + } + } + + "stopped".red().to_string() +} diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index ada081f..fb4c238 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -95,6 +95,42 @@ fn read_node_version(repo_path: &Path) -> Option { None } +/// Query overmind inside the container to get running service names and their status. +/// Returns a map of service_name → "running" | "stopped" | "dead". +pub fn overmind_status(config: &Config) -> std::collections::BTreeMap { + let mut result = std::collections::BTreeMap::new(); + + let output = Command::new("docker") + .args([ + "exec", + &config.docker.container, + "overmind", + "status", + ]) + .output(); + + let Ok(output) = output else { + return result; + }; + + // overmind status output: + // PROCESS PID STATUS + // api 5796 running + // sidekiq 5797 running + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines().skip(1) { + // Skip header + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + let name = parts[0].to_string(); + let status = parts[2].to_string(); + result.insert(name, status); + } + } + + result +} + /// Check if the dev container is currently running. pub fn container_is_running(config: &Config) -> bool { Command::new("docker") diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index 2a364df..9753607 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -34,6 +34,39 @@ pub fn compose_is_running(project: &str, compose_file: &str) -> bool { .is_ok_and(|o| !o.stdout.is_empty()) } +/// Get container states from a docker compose project. +/// Returns a map of service_name → status string (e.g., "Up 7 hours (healthy)"). +pub fn compose_container_states( + project: &str, + compose_file: &str, +) -> std::collections::BTreeMap { + let mut result = std::collections::BTreeMap::new(); + + let output = Command::new("docker") + .args([ + "compose", + "-p", + project, + "-f", + compose_file, + "ps", + "--format", + "{{.Service}}\t{{.Status}}", + ]) + .output(); + + if let Ok(output) = output { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some((service, status)) = line.split_once('\t') { + result.insert(service.to_string(), status.to_string()); + } + } + } + + result +} + /// Get the PID and command of the process listening on a port. /// Returns None if no process is found. pub fn port_owner(port: u16) -> Option<(u32, String)> { From ee86924cdfd2514d7c2e37e38bdbbf7e3e44ed93 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 18:53:31 +0100 Subject: [PATCH 04/33] Fix status column alignment with ANSI color codes Pad text before colorizing to prevent invisible escape codes from throwing off column widths. Also return (text, color) tuples from state detection for cleaner separation. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/status.rs | 86 +++++++++++++++++----------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/crates/devctl/src/commands/status.rs b/crates/devctl/src/commands/status.rs index 8454e6c..7b1299a 100644 --- a/crates/devctl/src/commands/status.rs +++ b/crates/devctl/src/commands/status.rs @@ -30,16 +30,8 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { Default::default() }; - // Service table header - println!(); - println!( - " {:<20} {:<10} {:<10} {:<30}", - "SERVICE", "MODE", "STATE", "URL" - ); - println!( - " {:<20} {:<10} {:<10} {:<30}", - "───────", "────", "─────", "───" - ); + // Collect rows first, then format with correct alignment + let mut rows: Vec<(String, String, String, String, String)> = Vec::new(); for (name, svc) in &config.services { let mode = if let Some(svc_state) = state.services.get(name) { @@ -48,11 +40,33 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { "-".to_string() }; - let state_str = determine_service_state(name, svc.port, &mode, &overmind, container_up); - + let (state_text, state_color) = + determine_service_state(name, svc.port, &mode, &overmind, container_up); let url = svc.hostname.as_deref().unwrap_or("-").to_string(); - println!(" {:<20} {:<10} {:<22} {}", name, mode, state_str, url); + rows.push((name.clone(), mode, state_text, state_color, url)); + } + + // Print service table with padding applied before colorization + println!(); + println!( + " {:<20} {:<10} {:<10} {}", + "SERVICE", "MODE", "STATE", "URL" + ); + println!( + " {:<20} {:<10} {:<10} {}", + "───────", "────", "─────", "───" + ); + + for (name, mode, state_text, state_color, url) in &rows { + let padded_state = format!("{:<10}", state_text); + let colored_state = match state_color.as_str() { + "green" => padded_state.green().to_string(), + "red" => padded_state.red().to_string(), + "yellow" => padded_state.yellow().to_string(), + _ => padded_state.dimmed().to_string(), + }; + println!(" {:<20} {:<10} {} {}", name, mode, colored_state, url); } // Infra status @@ -62,10 +76,6 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { &compose_file.to_string_lossy(), ); - println!(); - println!(" {:<20} {:<10}", "INFRA", "STATE"); - println!(" {:<20} {:<10}", "─────", "─────"); - let infra_containers = if infra_running { health::compose_container_states( &config.infra.compose_project, @@ -75,55 +85,61 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { Default::default() }; + println!(); + println!(" {:<20} {:<10}", "INFRA", "STATE"); + println!(" {:<20} {:<10}", "─────", "─────"); + for (name, _svc) in &config.infra.services { - let state_str = match infra_containers.get(name.as_str()).map(|s| s.as_str()) { - Some(s) if s.starts_with("Up") => "running".green().to_string(), - Some(s) => s.yellow().to_string(), - None => "stopped".red().to_string(), + let is_up = infra_containers + .get(name.as_str()) + .is_some_and(|s| s.starts_with("Up")); + let padded = format!("{:<10}", if is_up { "running" } else { "stopped" }); + let colored = if is_up { + padded.green().to_string() + } else { + padded.red().to_string() }; - println!(" {:<20} {}", name, state_str); + println!(" {:<20} {}", name, colored); } println!(); Ok(()) } +/// Returns (display_text, color_name) for a service state. fn determine_service_state( name: &str, port: Option, mode: &str, overmind: &std::collections::BTreeMap, container_up: bool, -) -> String { +) -> (String, String) { // Docker mode: use overmind as source of truth if mode == "docker" && container_up { return match overmind.get(name).map(|s| s.as_str()) { - Some("running") => "running".green().to_string(), - Some("dead") => "crashed".red().to_string(), - Some(other) => other.yellow().to_string(), - None => "not in procfile".dimmed().to_string(), + Some("running") => ("running".into(), "green".into()), + Some("dead") => ("crashed".into(), "red".into()), + Some(other) => (other.into(), "yellow".into()), + None => ("no proc".into(), "dim".into()), }; } - // No mode set: check if something is actually listening on the port - // but only if the container is NOT running (to avoid false positives - // from Docker's static port bindings) + // No mode set if mode == "-" { if let Some(port) = port { if !container_up && health::port_is_open(port) { - // Something external is using this port - return "running (external)".yellow().to_string(); + return ("external".into(), "yellow".into()); } } - return "-".dimmed().to_string(); + return ("-".into(), "dim".into()); } // Local mode (future): probe port if let Some(port) = port { if health::port_is_open(port) { - return "running".green().to_string(); + return ("running".into(), "green".into()); } } - "stopped".red().to_string() + ("stopped".into(), "red".into()) } From 514deb7b9c9081994a39b959b776765367940cbf Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 18:56:34 +0100 Subject: [PATCH 05/33] Remove --skip-setup flag, always run setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per decisions.md: "No --skip-setup — Always run setup steps. Fast when nothing changed. Removes a decision point that adds no value." Setup always runs on container start. Init (first-time secrets, schema, seeding) is separate via `devctl init`. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/start.rs | 3 +-- crates/devctl/src/docker.rs | 29 ++++++++++++----------------- crates/devctl/src/main.rs | 7 +------ 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/crates/devctl/src/commands/start.rs b/crates/devctl/src/commands/start.rs index 1db823a..e683ed0 100644 --- a/crates/devctl/src/commands/start.rs +++ b/crates/devctl/src/commands/start.rs @@ -12,7 +12,6 @@ pub fn docker( config: &Config, project_root: &Path, services: &[String], - skip_setup: bool, ) -> Result<()> { // --- Prerequisite: Docker running --- if !health::docker_is_running() { @@ -142,7 +141,7 @@ pub fn docker( // --- Start container --- println!("{}", "Starting container...".blue()); - docker::start_container(config, project_root, services, skip_setup)?; + docker::start_container(config, project_root, services)?; // --- Wait for healthy --- print!("{}", "Waiting for container to be ready".blue()); diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index fb4c238..946da01 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -170,28 +170,23 @@ pub fn start_container( config: &Config, project_root: &Path, services: &[String], - skip_setup: bool, ) -> Result<()> { let compose_file = project_root.join(&config.docker.compose_file); let selected_repos = services.join(","); - let mut cmd = Command::new("docker"); - cmd.args([ - "compose", - "-p", - &config.docker.compose_project, - "-f", - &compose_file.to_string_lossy(), - "up", - "-d", - ]); - cmd.env("SELECTED_REPOS", &selected_repos); - if skip_setup { - cmd.env("SKIP_SETUP", "true"); - } - - let status = cmd.status()?; + let status = Command::new("docker") + .args([ + "compose", + "-p", + &config.docker.compose_project, + "-f", + &compose_file.to_string_lossy(), + "up", + "-d", + ]) + .env("SELECTED_REPOS", &selected_repos) + .status()?; if !status.success() { return Err(Error::Other("Failed to start dev container".into())); } diff --git a/crates/devctl/src/main.rs b/crates/devctl/src/main.rs index ff17078..0fecc35 100644 --- a/crates/devctl/src/main.rs +++ b/crates/devctl/src/main.rs @@ -29,10 +29,6 @@ enum Commands { /// Run in Docker container #[arg(long)] docker: bool, - - /// Skip dependency install and DB setup - #[arg(long)] - skip_setup: bool, }, /// Stop the Docker dev container @@ -91,11 +87,10 @@ fn main() { Commands::Start { services, docker, - skip_setup, } => { let svc_list: Vec = services.split(',').map(|s| s.trim().to_string()).collect(); if docker { - commands::start::docker(&cfg, &root, &svc_list, skip_setup) + commands::start::docker(&cfg, &root, &svc_list) } else { Err(devctl::error::Error::Other( "Local mode (--local) not yet implemented. Use --docker.".into(), From 5bf5ef69797e9fa6cd99c2dd244a4f8340f3eedd Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 19:08:46 +0100 Subject: [PATCH 06/33] Add init and local mode commands - `devctl init `: runs init steps from devctl.toml (secrets, schema:load, seeding). Runs inside Docker container if running, otherwise on host in repos/. - `devctl start --local [--dir ] [--bg]`: local mode start with setup steps (git pull with dirty guard, deps, migrate, git restore), port conflict check, secrets validation, auto-infra, PID cleanup. Foreground (default) or background with log file. - `devctl stop `: stops local background service by PID. - Updated CLI: --docker and --local are mutually exclusive flags, --dir and --bg require --local. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/init.rs | 90 ++++++++++ crates/devctl/src/commands/local.rs | 245 +++++++++++++++++++++++++++ crates/devctl/src/commands/mod.rs | 2 + crates/devctl/src/commands/status.rs | 20 +-- crates/devctl/src/main.rs | 54 +++++- 5 files changed, 393 insertions(+), 18 deletions(-) create mode 100644 crates/devctl/src/commands/init.rs create mode 100644 crates/devctl/src/commands/local.rs diff --git a/crates/devctl/src/commands/init.rs b/crates/devctl/src/commands/init.rs new file mode 100644 index 0000000..1db3050 --- /dev/null +++ b/crates/devctl/src/commands/init.rs @@ -0,0 +1,90 @@ +use std::path::Path; +use std::process::Command; + +use colored::Colorize; + +use crate::config::Config; +use crate::docker; +use crate::error::{Error, Result}; + +pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { + let svc = config.services.get(service).ok_or_else(|| { + Error::Config(format!("Unknown service: '{}'", service)) + })?; + + if svc.init.is_empty() { + println!("No init steps defined for '{}'.", service); + return Ok(()); + } + + let repo = svc.repo.as_deref().ok_or_else(|| { + Error::Config(format!("Service '{}' has no repo defined", service)) + })?; + + // Determine execution context + let container_up = docker::container_is_running(config); + + println!("{} {}", "Initializing".blue(), service.bold()); + println!(" Steps: {}", svc.init.len()); + println!(); + + for (i, step) in svc.init.iter().enumerate() { + println!( + " [{}/{}] {}", + i + 1, + svc.init.len(), + step.bold() + ); + + if container_up { + // Run inside Docker container + let status = Command::new("docker") + .args([ + "exec", + "-u", + "dev", + "-w", + &format!("/workspace/{}", repo), + &config.docker.container, + "bash", + "-lc", + step, + ]) + .status()?; + + if !status.success() { + return Err(Error::Other(format!( + "Init step failed: {} (exit {})", + step, + status.code().unwrap_or(-1) + ))); + } + } else { + // Run on host in repos/ + let repo_dir = project_root.join("repos").join(repo); + if !repo_dir.exists() { + return Err(Error::Config(format!( + "Repo not found: repos/{}", + repo + ))); + } + + let status = Command::new("bash") + .args(["-lc", step]) + .current_dir(&repo_dir) + .status()?; + + if !status.success() { + return Err(Error::Other(format!( + "Init step failed: {} (exit {})", + step, + status.code().unwrap_or(-1) + ))); + } + } + } + + println!(); + println!("{} {} initialized.", "✓".green(), service); + Ok(()) +} diff --git a/crates/devctl/src/commands/local.rs b/crates/devctl/src/commands/local.rs new file mode 100644 index 0000000..abc81ed --- /dev/null +++ b/crates/devctl/src/commands/local.rs @@ -0,0 +1,245 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use colored::Colorize; + +use crate::config::Config; +use crate::error::{Error, Result}; +use crate::health; +use crate::state::{ServiceState, State}; + +pub fn start( + config: &Config, + project_root: &Path, + service: &str, + dir_override: Option<&str>, + background: bool, +) -> Result<()> { + let svc = config.services.get(service).ok_or_else(|| { + Error::Config(format!("Unknown service: '{}'", service)) + })?; + + let cmd = svc.cmd.as_deref().ok_or_else(|| { + Error::Config(format!("Service '{}' has no cmd defined", service)) + })?; + + let repo = svc.repo.as_deref().ok_or_else(|| { + Error::Config(format!("Service '{}' has no repo defined", service)) + })?; + + // Determine service directory + let svc_dir: PathBuf = if let Some(dir) = dir_override { + PathBuf::from(dir) + } else { + project_root.join("repos").join(repo) + }; + + if !svc_dir.exists() { + return Err(Error::Config(format!( + "Service directory not found: {}", + svc_dir.display() + ))); + } + + // Check port conflicts + if let Some(port) = svc.port + && health::port_is_open(port) { + let owner = health::port_owner(port) + .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) + .unwrap_or_else(|| "unknown".into()); + return Err(Error::Other(format!( + "Port {} is already in use by {}", + port, owner + ))); + } + + // Check secrets + for secret in &svc.secrets { + if !svc_dir.join(secret).exists() { + return Err(Error::Config(format!( + "Missing secret: {}/{}. Run `devctl init {}` first.", + svc_dir.display(), + secret, + service + ))); + } + } + + // Auto-start infra if needed + if !svc.infra.is_empty() { + let infra_compose = project_root.join(&config.infra.compose_file); + let infra_running = health::compose_is_running( + &config.infra.compose_project, + &infra_compose.to_string_lossy(), + ); + if !infra_running { + println!("{}", "Starting infrastructure...".blue()); + crate::commands::infra::up(config, project_root)?; + } + } + + // Run start steps (git pull, deps, migrate) + if !svc.start.is_empty() { + println!("{}", "Running setup steps...".blue()); + for step in &svc.start { + // git pull: skip if working tree is dirty + if step.starts_with("git pull") { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&svc_dir) + .output()?; + if !output.stdout.is_empty() { + println!(" {} git pull (dirty working tree, skipping)", "!".yellow()); + continue; + } + } + + // git restore: clean up generated files after migrations + if step.starts_with("git restore") { + let status = Command::new("bash") + .args(["-lc", step]) + .current_dir(&svc_dir) + .status()?; + if !status.success() { + println!(" {} {} (non-fatal)", "!".yellow(), step); + } + continue; + } + + println!(" {}", step); + let status = Command::new("bash") + .args(["-lc", step]) + .current_dir(&svc_dir) + .status()?; + + if !status.success() { + return Err(Error::Other(format!("Setup step failed: {}", step))); + } + } + } + + // Clean stale PID files + let pid_file = svc_dir.join("tmp/pids/server.pid"); + if pid_file.exists() { + std::fs::remove_file(&pid_file)?; + println!(" Cleaned stale PID file"); + } + + // Start the service + let now = chrono::Utc::now().to_rfc3339(); + + if background { + // Background mode: redirect output to log file + let log_dir = project_root.join(".devctl/logs"); + std::fs::create_dir_all(&log_dir)?; + let log_file = log_dir.join(format!("{}.log", service)); + let log = std::fs::File::create(&log_file)?; + + println!( + "{} {} (background, logs: {})", + "Starting".blue(), + service.bold(), + log_file.display() + ); + + let child = Command::new("bash") + .args(["-lc", cmd]) + .current_dir(&svc_dir) + .stdout(log.try_clone()?) + .stderr(log) + .spawn()?; + + // Update state with PID + let mut state = State::load(project_root)?; + state.services.insert( + service.to_string(), + ServiceState { + mode: "local".into(), + started_at: now, + dir: Some(svc_dir.to_string_lossy().into()), + pid: Some(child.id()), + }, + ); + state.save(project_root)?; + + println!("{} {} started (PID {})", "✓".green(), service, child.id()); + if let Some(hostname) = &svc.hostname { + println!(" https://{}", hostname); + } + } else { + // Foreground mode: inherit terminal + println!( + "{} {} (foreground, Ctrl+C to stop)", + "Starting".blue(), + service.bold() + ); + + // Update state before starting (no PID for foreground) + let mut state = State::load(project_root)?; + state.services.insert( + service.to_string(), + ServiceState { + mode: "local".into(), + started_at: now, + dir: Some(svc_dir.to_string_lossy().into()), + pid: None, + }, + ); + state.save(project_root)?; + + let status = Command::new("bash") + .args(["-lc", cmd]) + .current_dir(&svc_dir) + .status()?; + + // Clean up state after exit + let mut state = State::load(project_root)?; + state.services.remove(service); + state.save(project_root)?; + + if !status.success() { + return Err(Error::Other(format!( + "{} exited with code {}", + service, + status.code().unwrap_or(-1) + ))); + } + } + + Ok(()) +} + +/// Stop a locally running service by PID. +pub fn stop(project_root: &Path, service: &str) -> Result<()> { + let mut state = State::load(project_root)?; + + let svc_state = state.services.get(service).ok_or_else(|| { + Error::Other(format!("Service '{}' is not tracked in state", service)) + })?; + + if svc_state.mode != "local" { + return Err(Error::Other(format!( + "Service '{}' is in {} mode, not local", + service, svc_state.mode + ))); + } + + if let Some(pid) = svc_state.pid { + println!("Stopping {} (PID {})...", service, pid); + let _ = Command::new("kill") + .arg(pid.to_string()) + .status(); + std::thread::sleep(std::time::Duration::from_secs(2)); + println!("{} stopped.", service.green()); + } else { + println!( + "{} {} was running in foreground (no PID tracked)", + "!".yellow(), + service + ); + } + + state.services.remove(service); + state.save(project_root)?; + Ok(()) +} diff --git a/crates/devctl/src/commands/mod.rs b/crates/devctl/src/commands/mod.rs index 36fdb65..6f6c042 100644 --- a/crates/devctl/src/commands/mod.rs +++ b/crates/devctl/src/commands/mod.rs @@ -1,5 +1,7 @@ pub mod doctor; pub mod infra; +pub mod init; +pub mod local; pub mod logs; pub mod start; pub mod status; diff --git a/crates/devctl/src/commands/status.rs b/crates/devctl/src/commands/status.rs index 7b1299a..bfb7931 100644 --- a/crates/devctl/src/commands/status.rs +++ b/crates/devctl/src/commands/status.rs @@ -50,12 +50,12 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { // Print service table with padding applied before colorization println!(); println!( - " {:<20} {:<10} {:<10} {}", - "SERVICE", "MODE", "STATE", "URL" + " {:<20} {:<10} {:<10} URL", + "SERVICE", "MODE", "STATE" ); println!( - " {:<20} {:<10} {:<10} {}", - "───────", "────", "─────", "───" + " {:<20} {:<10} {:<10} ───", + "───────", "────", "─────" ); for (name, mode, state_text, state_color, url) in &rows { @@ -89,7 +89,7 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { println!(" {:<20} {:<10}", "INFRA", "STATE"); println!(" {:<20} {:<10}", "─────", "─────"); - for (name, _svc) in &config.infra.services { + for name in config.infra.services.keys() { let is_up = infra_containers .get(name.as_str()) .is_some_and(|s| s.starts_with("Up")); @@ -126,20 +126,18 @@ fn determine_service_state( // No mode set if mode == "-" { - if let Some(port) = port { - if !container_up && health::port_is_open(port) { + if let Some(port) = port + && !container_up && health::port_is_open(port) { return ("external".into(), "yellow".into()); } - } return ("-".into(), "dim".into()); } // Local mode (future): probe port - if let Some(port) = port { - if health::port_is_open(port) { + if let Some(port) = port + && health::port_is_open(port) { return ("running".into(), "green".into()); } - } ("stopped".into(), "red".into()) } diff --git a/crates/devctl/src/main.rs b/crates/devctl/src/main.rs index 0fecc35..a51e2ed 100644 --- a/crates/devctl/src/main.rs +++ b/crates/devctl/src/main.rs @@ -23,16 +23,31 @@ enum Commands { /// Start services Start { - /// Comma-separated list of services + /// Comma-separated list of services (Docker) or single service (local) services: String, /// Run in Docker container - #[arg(long)] + #[arg(long, conflicts_with_all = ["local"])] docker: bool, + + /// Run locally from repos/ (or --dir) + #[arg(long, conflicts_with_all = ["docker"])] + local: bool, + + /// Service directory override (local mode only) + #[arg(long, requires = "local")] + dir: Option, + + /// Run in background (local mode only) + #[arg(long, requires = "local")] + bg: bool, }, - /// Stop the Docker dev container - Stop, + /// Stop services + Stop { + /// Service name (local mode). Omit to stop Docker container. + service: Option, + }, /// Restart a service inside the running container Restart { @@ -46,6 +61,12 @@ enum Commands { service: String, }, + /// First-time setup for a service (secrets, schema, seeding) + Init { + /// Service name + service: String, + }, + /// Manage shared infrastructure (MySQL, Redis, etc.) Infra { #[command(subcommand)] @@ -87,19 +108,38 @@ fn main() { Commands::Start { services, docker, + local, + dir, + bg, } => { - let svc_list: Vec = services.split(',').map(|s| s.trim().to_string()).collect(); if docker { + let svc_list: Vec = + services.split(',').map(|s| s.trim().to_string()).collect(); commands::start::docker(&cfg, &root, &svc_list) + } else if local { + commands::local::start( + &cfg, + &root, + &services, + dir.as_deref(), + bg, + ) } else { Err(devctl::error::Error::Other( - "Local mode (--local) not yet implemented. Use --docker.".into(), + "Specify --docker or --local mode.".into(), )) } } - Commands::Stop => commands::stop::run(&cfg, &root), + Commands::Stop { service } => { + if let Some(svc) = service { + commands::local::stop(&root, &svc) + } else { + commands::stop::run(&cfg, &root) + } + } Commands::Restart { service } => commands::stop::restart_service(&cfg, &service), Commands::Logs { service } => commands::logs::run(&cfg, &root, &service), + Commands::Init { service } => commands::init::run(&cfg, &root, &service), Commands::Infra { action } => match action { InfraAction::Up => commands::infra::up(&cfg, &root), InfraAction::Down => commands::infra::down(&cfg, &root), From 63d27dc79e6fb65e818fff3c4659dbe14870bba2 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 19:12:28 +0100 Subject: [PATCH 07/33] Update git workflow: require branches and PRs Direct commits to main are no longer allowed. Use feature branches and draft PRs for review. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 413b404..e0b6b0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Git Workflow -- Commit directly to `main` — no branches or PRs needed for now. +- Create a feature branch and open a draft PR for review. Do not commit directly to `main`. - **Before publishing a tool**, always check for uncommitted changes in its crate directory and commit them first. The bump script only commits the version change — any pending code changes will be left out of the tagged release. ## Bugs and Issues From 447af4ab62ebc925d273275eeda21573647fb41d Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 19:14:15 +0100 Subject: [PATCH 08/33] Update Cargo.lock for devctl crate --- Cargo.lock | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 382bbc4..43e7e2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,21 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "devctl" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "colored", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", + "toolbox-core", +] + [[package]] name = "difflib" version = "0.4.0" From bb05d59e66ebb29ab68ad681ea62613d6f487db6 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 19:57:57 +0100 Subject: [PATCH 09/33] Add AWS SSO validation for init, improve doctor - Init checks AWS SSO session before running steps that need secrets-manager (pattern match on step content) - Doctor reports AWS SSO status as a warning - health::aws_sso_is_valid() calls aws sts get-caller-identity Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/doctor.rs | 9 +++++++++ crates/devctl/src/commands/init.rs | 8 ++++++++ crates/devctl/src/health.rs | 10 ++++++++++ 3 files changed, 27 insertions(+) diff --git a/crates/devctl/src/commands/doctor.rs b/crates/devctl/src/commands/doctor.rs index ab61fa8..912646c 100644 --- a/crates/devctl/src/commands/doctor.rs +++ b/crates/devctl/src/commands/doctor.rs @@ -32,6 +32,15 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { issues += 1; } + let aws_ok = health::aws_sso_is_valid(); + if aws_ok { + println!(" {} AWS SSO session", "✓".green()); + } else { + println!(" {} AWS SSO — expired or invalid", "!".yellow()); + println!(" Run: aws sso login"); + // Warning only, not an issue — only needed for init/secrets + } + // --- Infrastructure --- println!(); println!("{}", "Infrastructure".bold()); diff --git a/crates/devctl/src/commands/init.rs b/crates/devctl/src/commands/init.rs index 1db3050..984481f 100644 --- a/crates/devctl/src/commands/init.rs +++ b/crates/devctl/src/commands/init.rs @@ -24,6 +24,14 @@ pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { // Determine execution context let container_up = docker::container_is_running(config); + // Check AWS SSO if any init step needs it + let needs_aws = svc.init.iter().any(|s| s.contains("secrets-manager")); + if needs_aws && !crate::health::aws_sso_is_valid() { + return Err(Error::Other( + "AWS SSO session expired or invalid. Run: aws sso login".into(), + )); + } + println!("{} {}", "Initializing".blue(), service.bold()); println!(" Steps: {}", svc.init.len()); println!(); diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index 9753607..f789923 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -67,6 +67,16 @@ pub fn compose_container_states( result } +/// Check if AWS SSO session is valid by calling sts get-caller-identity. +pub fn aws_sso_is_valid() -> bool { + Command::new("aws") + .args(["sts", "get-caller-identity", "--no-cli-pager"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + /// Get the PID and command of the process listening on a port. /// Returns None if no process is found. pub fn port_owner(port: u16) -> Option<(u32, String)> { From 960c35073b4060bfa5348e01d2da9f5fce0e0bdf Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 19:59:32 +0100 Subject: [PATCH 10/33] Show AWS SSO session time remaining in doctor and status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor shows full SSO status with time remaining (warns at <30min). Status only shows SSO info when expiring soon or expired — no clutter when session is healthy. Reads expiry from ~/.aws/sso/cache/*.json (same approach as paws). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + crates/devctl/Cargo.toml | 1 + crates/devctl/src/commands/doctor.rs | 26 +++++--- crates/devctl/src/commands/status.rs | 14 +++++ crates/devctl/src/health.rs | 91 ++++++++++++++++++++++++++-- 5 files changed, 121 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43e7e2e..fb19d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,6 +408,7 @@ dependencies = [ "chrono", "clap", "colored", + "dirs", "reqwest", "serde", "serde_json", diff --git a/crates/devctl/Cargo.toml b/crates/devctl/Cargo.toml index be34770..6ad6620 100644 --- a/crates/devctl/Cargo.toml +++ b/crates/devctl/Cargo.toml @@ -23,4 +23,5 @@ serde_json.workspace = true toml.workspace = true chrono.workspace = true colored.workspace = true +dirs = "6" thiserror.workspace = true diff --git a/crates/devctl/src/commands/doctor.rs b/crates/devctl/src/commands/doctor.rs index 912646c..2b0c821 100644 --- a/crates/devctl/src/commands/doctor.rs +++ b/crates/devctl/src/commands/doctor.rs @@ -32,13 +32,25 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { issues += 1; } - let aws_ok = health::aws_sso_is_valid(); - if aws_ok { - println!(" {} AWS SSO session", "✓".green()); - } else { - println!(" {} AWS SSO — expired or invalid", "!".yellow()); - println!(" Run: aws sso login"); - // Warning only, not an issue — only needed for init/secrets + match health::aws_sso_status() { + health::AwsSsoStatus::Valid(Some(remaining)) => { + let time_str = health::format_duration(&remaining); + if remaining.as_secs() < 1800 { + println!(" {} AWS SSO ({} remaining)", "!".yellow(), time_str); + } else { + println!(" {} AWS SSO ({} remaining)", "✓".green(), time_str); + } + } + health::AwsSsoStatus::Valid(None) => { + println!(" {} AWS SSO (valid, expiry unknown)", "✓".green()); + } + health::AwsSsoStatus::Expired => { + println!(" {} AWS SSO — expired or invalid", "!".yellow()); + println!(" Run: aws sso login"); + } + health::AwsSsoStatus::NotInstalled => { + println!(" {} AWS CLI not installed", "!".yellow()); + } } // --- Infrastructure --- diff --git a/crates/devctl/src/commands/status.rs b/crates/devctl/src/commands/status.rs index bfb7931..0a639e4 100644 --- a/crates/devctl/src/commands/status.rs +++ b/crates/devctl/src/commands/status.rs @@ -22,6 +22,20 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { println!("{} Caddy is not running (localhost:2019)", "!".yellow()); } + match health::aws_sso_status() { + health::AwsSsoStatus::Valid(Some(remaining)) if remaining.as_secs() < 1800 => { + println!( + "{} AWS SSO expires in {}", + "!".yellow(), + health::format_duration(&remaining) + ); + } + health::AwsSsoStatus::Expired => { + println!("{} AWS SSO expired (run: aws sso login)", "!".yellow()); + } + _ => {} // Valid with plenty of time, or not installed — don't clutter + } + // If container is running, get overmind status for accurate service state let container_up = docker::container_is_running(config); let overmind = if container_up { diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index f789923..6eaa355 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -67,14 +67,95 @@ pub fn compose_container_states( result } -/// Check if AWS SSO session is valid by calling sts get-caller-identity. -pub fn aws_sso_is_valid() -> bool { - Command::new("aws") +/// AWS SSO session status. +pub enum AwsSsoStatus { + /// Valid session with optional time remaining + Valid(Option), + /// Session expired or not authenticated + Expired, + /// AWS CLI not installed + NotInstalled, +} + +/// Check AWS SSO session validity and remaining time. +pub fn aws_sso_status() -> AwsSsoStatus { + // First check if aws CLI works + let ok = Command::new("aws") .args(["sts", "get-caller-identity", "--no-cli-pager"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + .status(); + + match ok { + Err(_) => return AwsSsoStatus::NotInstalled, + Ok(s) if !s.success() => return AwsSsoStatus::Expired, + _ => {} + } + + // Session is valid — try to find expiry from SSO cache + let remaining = sso_session_remaining(); + AwsSsoStatus::Valid(remaining) +} + +/// Convenience check for simple valid/invalid. +pub fn aws_sso_is_valid() -> bool { + matches!(aws_sso_status(), AwsSsoStatus::Valid(_)) +} + +/// Read SSO session expiry from ~/.aws/sso/cache/*.json. +/// Returns remaining duration if found. +fn sso_session_remaining() -> Option { + let cache_dir = dirs::home_dir()?.join(".aws/sso/cache"); + if !cache_dir.exists() { + return None; + } + + let mut newest_expiry: Option> = None; + let mut newest_mtime = std::time::SystemTime::UNIX_EPOCH; + + for entry in std::fs::read_dir(&cache_dir).ok()? { + let entry = entry.ok()?; + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") { + let content = std::fs::read_to_string(&path).ok()?; + // Only consider files with an accessToken (SSO session files) + if !content.contains("accessToken") { + continue; + } + let mtime = entry.metadata().ok()?.modified().ok()?; + if mtime > newest_mtime { + newest_mtime = mtime; + // Parse expiresAt from JSON + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(expires_at) = json.get("expiresAt").and_then(|v| v.as_str()) { + if let Ok(dt) = expires_at.parse::>() { + newest_expiry = Some(dt); + } + } + } + } + } + } + + let expiry = newest_expiry?; + let now = chrono::Utc::now(); + if expiry > now { + Some((expiry - now).to_std().ok()?) + } else { + None // Already expired + } +} + +/// Format a duration as human-readable time remaining. +pub fn format_duration(d: &std::time::Duration) -> String { + let total_secs = d.as_secs(); + let hours = total_secs / 3600; + let mins = (total_secs % 3600) / 60; + if hours > 0 { + format!("{}h {}m", hours, mins) + } else { + format!("{}m", mins) + } } /// Get the PID and command of the process listening on a port. From a90265267da9e3f1b32e69f8a40c93bcaa786c0d Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 20:08:30 +0100 Subject: [PATCH 11/33] Generate docker-compose dynamically, enable hybrid mode Static dev-compose.yml bound all 14 ports regardless of which services were running, blocking local mode for any service whose port was mapped. devctl now generates docker-compose.yml at runtime with only the ports and mounts needed for the requested Docker services. This enables the core hybrid mode: Docker for some services, local for others. Tested: api in Docker + frontend locally, both accessible via Caddy. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/docker.rs | 184 ++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 6 deletions(-) diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 946da01..55071d6 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -131,6 +131,168 @@ pub fn overmind_status(config: &Config) -> std::collections::BTreeMap Result { + let compose_dir = project_root.join(".docker-sessions/.dev"); + std::fs::create_dir_all(&compose_dir)?; + let compose_path = compose_dir.join("docker-compose.yml"); + + // Collect ports and repos for selected services (+ companions) + let mut ports = Vec::new(); + let mut repos = std::collections::BTreeSet::new(); + + for svc_name in services { + if let Some(svc) = config.services.get(svc_name) { + if let Some(port) = svc.port { + ports.push(port); + } + if let Some(repo) = &svc.repo { + repos.insert(repo.clone()); + } + // Include companion + if let Some(companion) = &svc.companion { + if let Some(comp) = config.services.get(companion) { + if let Some(port) = comp.port { + ports.push(port); + } + if let Some(repo) = &comp.repo { + repos.insert(repo.clone()); + } + } + } + } + } + + // Service discovery env vars — always include all services so inter-service + // communication works regardless of which are running + let mut service_urls = Vec::new(); + for (name, svc) in &config.services { + if let (Some(hostname), Some(port)) = (&svc.hostname, svc.port) { + let env_key = format!( + "{}_SERVICE_URL", + name.to_uppercase().replace('-', "_") + ); + service_urls.push(format!( + " - {}=http://{}:{}", + env_key, hostname, port + )); + } + } + + // Build ports section + let ports_yaml: Vec = ports + .iter() + .map(|p| format!(" - \"{}:{}\"", p, p)) + .collect(); + + // Build volume mounts — only for repos we need, plus always mount all + // (Docker creates empty dirs for unmounted repos, harmless) + let docker_dir = project_root.join("docker"); + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + + let mut content = format!( + r#"# Generated by devctl — do not edit manually +services: + workspace: + image: productive-dev:base + container_name: {container} + hostname: productive-dev + env_file: + - .env.session + environment: + - SESSION_NAME=productive-dev + - SESSION_MODE=dev + - SELECTED_REPOS={selected_repos} + - MYSQL_HOST=productive-dev-mysql + - MYSQL_PORT=3306 + - MYSQL_USER=root + - MYSQL_PASSWORD= + - REDIS_URL=redis://productive-dev-redis:6379/0 + - MEILISEARCH_URL=http://productive-dev-meilisearch:7700 + - MEMCACHE_SERVERS=productive-dev-memcached:11211 + - cache_url=productive-dev-memcached:11211 + - db_host=productive-dev-mysql + - RAISE_ON_MISSING_FLAGS=false + - RAISE_ON_MISSING_FEATURES=false + - RAILS_ENV=development + - NODE_ENV=development + - PRODUCTIVE_API_BASE_URL=http://api.productive.io.localhost:3000 + - BUGSNAG_AUTH_TOKEN= +{service_urls} + ports: +{ports} + volumes: +"#, + container = config.docker.container, + selected_repos = services.join(","), + service_urls = service_urls.join("\n"), + ports = ports_yaml.join("\n"), + ); + + // Mount all repos (static, same as dev-compose.yml) + for (_, svc) in &config.services { + if let Some(repo) = &svc.repo { + content.push_str(&format!( + " - {}/repos/{}:/workspace/{}\n", + project_root.display(), + repo, + repo + )); + } + } + + // Procfile, entrypoint, config, AWS + content.push_str(&format!( + r#" - {compose_dir}/Procfile.dev:/workspace/Procfile.dev + - {docker_dir}/entrypoint.sh:/entrypoint.sh:ro + - {docker_dir}/config:/docker-config:ro + - {home}/.aws:/home/dev/.aws:ro + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + - NET_BIND_SERVICE + deploy: + resources: + limits: + memory: 8G + cpus: "4" + pids: 2048 + healthcheck: + test: ["CMD", "test", "-f", "/tmp/.session-ready"] + interval: 10s + timeout: 5s + retries: 60 + start_period: 120s + stdin_open: true + tty: true + networks: + - productive-dev-net + +networks: + productive-dev-net: + external: true +"#, + compose_dir = compose_dir.display(), + docker_dir = docker_dir.display(), + home = home, + )); + + std::fs::write(&compose_path, content)?; + Ok(compose_path) +} + /// Check if the dev container is currently running. pub fn container_is_running(config: &Config) -> bool { Command::new("docker") @@ -147,7 +309,14 @@ pub fn container_is_running(config: &Config) -> bool { /// Stop the dev container. pub fn stop_container(config: &Config, project_root: &Path) -> Result<()> { - let compose_file = project_root.join(&config.docker.compose_file); + let compose_file = generated_compose_path(project_root); + // Fall back to static compose if generated doesn't exist + let compose_file = if compose_file.exists() { + compose_file + } else { + project_root.join(&config.docker.compose_file) + }; + let status = Command::new("docker") .args([ "compose", @@ -165,15 +334,19 @@ pub fn stop_container(config: &Config, project_root: &Path) -> Result<()> { Ok(()) } -/// Start the dev container. +fn generated_compose_path(project_root: &Path) -> std::path::PathBuf { + project_root + .join(".docker-sessions/.dev/docker-compose.yml") +} + +/// Start the dev container using the generated compose file. pub fn start_container( config: &Config, project_root: &Path, services: &[String], ) -> Result<()> { - let compose_file = project_root.join(&config.docker.compose_file); - - let selected_repos = services.join(","); + // Generate compose with only the needed ports + let compose_file = generate_compose(config, services, project_root)?; let status = Command::new("docker") .args([ @@ -185,7 +358,6 @@ pub fn start_container( "up", "-d", ]) - .env("SELECTED_REPOS", &selected_repos) .status()?; if !status.success() { return Err(Error::Other("Failed to start dev container".into())); From 10cc4b27a22b65a53b34cb6a08960dce1bc81e40 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 20:16:50 +0100 Subject: [PATCH 12/33] Fix init: run Docker exec as root for gem/package installs Bundle install needs write access to RVM gem directories which are owned by root. Init steps run as root (same as setup.sh), matching the container's privilege model. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/init.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/devctl/src/commands/init.rs b/crates/devctl/src/commands/init.rs index 984481f..2302037 100644 --- a/crates/devctl/src/commands/init.rs +++ b/crates/devctl/src/commands/init.rs @@ -45,12 +45,10 @@ pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { ); if container_up { - // Run inside Docker container + // Run inside Docker container as root (needed for gem/package installs) let status = Command::new("docker") .args([ "exec", - "-u", - "dev", "-w", &format!("/workspace/{}", repo), &config.docker.container, From 46d02ac25d9b514e61aa756307ad9680b7e22d27 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 20:21:18 +0100 Subject: [PATCH 13/33] Fix init: set HOME=/home/dev for AWS SDK credential resolution When running init steps as root inside the container, the AWS SDK looks for ~/.aws in /root (root's home) instead of /home/dev (where the host's ~/.aws is mounted). Setting HOME=/home/dev in the docker exec environment fixes credential resolution. Tested: secrets-manager, bundle install, rails db:create, and schema:load safety guard all work correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/init.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/devctl/src/commands/init.rs b/crates/devctl/src/commands/init.rs index 2302037..da37a84 100644 --- a/crates/devctl/src/commands/init.rs +++ b/crates/devctl/src/commands/init.rs @@ -45,10 +45,13 @@ pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { ); if container_up { - // Run inside Docker container as root (needed for gem/package installs) + // Run inside Docker container as root (needed for gem/package installs). + // Set HOME=/home/dev so AWS SDK finds the mounted ~/.aws credentials. let status = Command::new("docker") .args([ "exec", + "-e", + "HOME=/home/dev", "-w", &format!("/workspace/{}", repo), &config.docker.container, From e552048c82310de32f795ba545a8b2b0e3972047 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 20:31:29 +0100 Subject: [PATCH 14/33] Fix declarative start: stop before port check, write state early Three fixes from testing: 1. Declarative Docker start now stops existing container BEFORE checking port conflicts. Previously it checked ports first, failed because our own container was using them. 2. State is written immediately after container starts (before waiting for healthcheck). This ensures `devctl status` works during boot, even if the healthcheck takes a long time (e.g., Ruby 4.0.1 compilation). 3. Healthcheck timeout increased from 2 to 10 minutes to handle first-time setup with multi-Ruby compilation. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/start.rs | 26 +++++++++++++------------- crates/devctl/src/docker.rs | 9 ++++++--- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/devctl/src/commands/start.rs b/crates/devctl/src/commands/start.rs index e683ed0..7cc3ea9 100644 --- a/crates/devctl/src/commands/start.rs +++ b/crates/devctl/src/commands/start.rs @@ -30,7 +30,13 @@ pub fn docker( } } - // --- Check port conflicts --- + // --- Stop existing container if running (declarative: new list replaces old) --- + if docker::container_is_running(config) { + println!("{}", "Replacing existing container...".yellow()); + docker::stop_container(config, project_root)?; + } + + // --- Check port conflicts (after stopping our container, before starting new) --- println!("{}", "Checking ports...".blue()); let mut conflicts = Vec::new(); for svc_name in services { @@ -125,12 +131,6 @@ pub fn docker( } } - // --- Stop existing container if running --- - if docker::container_is_running(config) { - println!("{}", "Stopping existing container...".yellow()); - docker::stop_container(config, project_root)?; - } - // --- Capture env vars --- println!("{}", "Capturing environment...".blue()); capture_env(project_root)?; @@ -143,12 +143,7 @@ pub fn docker( println!("{}", "Starting container...".blue()); docker::start_container(config, project_root, services)?; - // --- Wait for healthy --- - print!("{}", "Waiting for container to be ready".blue()); - docker::wait_for_healthy(config)?; - println!(" {}", "ready!".green()); - - // --- Update state --- + // --- Update state immediately (so status works during boot) --- let now = chrono::Utc::now().to_rfc3339(); let mut state = State::load(project_root)?; // Clear previous docker services @@ -183,6 +178,11 @@ pub fn docker( } state.save(project_root)?; + // --- Wait for healthy --- + print!("{}", "Waiting for container to be ready".blue()); + docker::wait_for_healthy(config)?; + println!(" {}", "ready!".green()); + // --- Report --- println!(); println!("{}", "Services started:".green()); diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 55071d6..059068d 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -366,9 +366,10 @@ pub fn start_container( } /// Wait for the container healthcheck to pass. +/// Timeout: 10 minutes (first-time setup may compile Ruby/Node from source). pub fn wait_for_healthy(config: &Config) -> Result<()> { let container = &config.docker.container; - for i in 0..60 { + for i in 0..300 { let output = Command::new("docker") .args(["inspect", "--format", "{{.State.Health.Status}}", container]) .output()?; @@ -378,13 +379,15 @@ pub fn wait_for_healthy(config: &Config) -> Result<()> { return Ok(()); } - if i % 5 == 0 && i > 0 { + if i % 15 == 0 && i > 0 { + eprint!(" {}s", i * 2); + } else if i % 5 == 0 { eprint!("."); } std::thread::sleep(std::time::Duration::from_secs(2)); } Err(Error::Other( - "Container did not become healthy within 2 minutes".into(), + "Container did not become healthy within 10 minutes".into(), )) } From f4d3bf93b34708844720bba2c5ebcc23c71d99a6 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 20:45:08 +0100 Subject: [PATCH 15/33] QA: remove dead code, fix SELECTED_REPOS, extract helpers From bloat detector review: - Remove dead `repos` BTreeSet, use collected repo names for SELECTED_REPOS env var (was passing service names, not repo names) - Remove dead port-check block in doctor (TCP connect with no effect) - Extract health::infra_is_running() helper (was duplicated 5 times) - Move default Ruby/Node versions to module-level consts - Remove unused _config parameter from procfile_entry Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/doctor.rs | 12 +------- crates/devctl/src/commands/infra.rs | 8 +---- crates/devctl/src/commands/local.rs | 10 ++----- crates/devctl/src/commands/start.rs | 7 +---- crates/devctl/src/commands/status.rs | 9 ++---- crates/devctl/src/docker.rs | 45 ++++++++++++++-------------- crates/devctl/src/health.rs | 14 +++++---- 7 files changed, 40 insertions(+), 65 deletions(-) diff --git a/crates/devctl/src/commands/doctor.rs b/crates/devctl/src/commands/doctor.rs index 2b0c821..690d041 100644 --- a/crates/devctl/src/commands/doctor.rs +++ b/crates/devctl/src/commands/doctor.rs @@ -57,11 +57,7 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { println!(); println!("{}", "Infrastructure".bold()); - let compose_file = project_root.join(&config.infra.compose_file); - let infra_running = health::compose_is_running( - &config.infra.compose_project, - &compose_file.to_string_lossy(), - ); + let infra_running = health::infra_is_running(config, project_root); for (name, svc) in &config.infra.services { if infra_running && health::port_is_open(svc.port) { @@ -96,12 +92,6 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { } } - // Port conflict with non-devctl process? - if let Some(port) = svc.port - && health::port_is_open(port) { - // Port is in use — could be devctl or something else, just note it - } - if svc_issues.is_empty() { println!(" {} {}", "✓".green(), name); } else { diff --git a/crates/devctl/src/commands/infra.rs b/crates/devctl/src/commands/infra.rs index 996d8c4..69b1d7c 100644 --- a/crates/devctl/src/commands/infra.rs +++ b/crates/devctl/src/commands/infra.rs @@ -85,13 +85,7 @@ pub fn down(config: &Config, project_root: &Path) -> Result<()> { } pub fn status(config: &Config, project_root: &Path) -> Result<()> { - let compose_file = project_root.join(&config.infra.compose_file); - let running = health::compose_is_running( - &config.infra.compose_project, - &compose_file.to_string_lossy(), - ); - - if running { + if health::infra_is_running(config, project_root) { println!("{}", "Infrastructure is running.".green()); } else { println!("{}", "Infrastructure is not running.".red()); diff --git a/crates/devctl/src/commands/local.rs b/crates/devctl/src/commands/local.rs index abc81ed..5b34250 100644 --- a/crates/devctl/src/commands/local.rs +++ b/crates/devctl/src/commands/local.rs @@ -66,17 +66,11 @@ pub fn start( } // Auto-start infra if needed - if !svc.infra.is_empty() { - let infra_compose = project_root.join(&config.infra.compose_file); - let infra_running = health::compose_is_running( - &config.infra.compose_project, - &infra_compose.to_string_lossy(), - ); - if !infra_running { + if !svc.infra.is_empty() + && !health::infra_is_running(config, project_root) { println!("{}", "Starting infrastructure...".blue()); crate::commands::infra::up(config, project_root)?; } - } // Run start steps (git pull, deps, migrate) if !svc.start.is_empty() { diff --git a/crates/devctl/src/commands/start.rs b/crates/devctl/src/commands/start.rs index 7cc3ea9..f394653 100644 --- a/crates/devctl/src/commands/start.rs +++ b/crates/devctl/src/commands/start.rs @@ -113,17 +113,12 @@ pub fn docker( } // --- Auto-start infra if needed --- - let infra_compose = project_root.join(&config.infra.compose_file); let infra_needed = services.iter().any(|svc_name| { !config.services[svc_name].infra.is_empty() }); if infra_needed { - let infra_running = health::compose_is_running( - &config.infra.compose_project, - &infra_compose.to_string_lossy(), - ); - if !infra_running { + if !health::infra_is_running(config, project_root) { println!("{}", "Starting infrastructure...".blue()); crate::commands::infra::up(config, project_root)?; } else { diff --git a/crates/devctl/src/commands/status.rs b/crates/devctl/src/commands/status.rs index 0a639e4..33d39db 100644 --- a/crates/devctl/src/commands/status.rs +++ b/crates/devctl/src/commands/status.rs @@ -84,16 +84,13 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { } // Infra status - let compose_file = project_root.join(&config.infra.compose_file); - let infra_running = health::compose_is_running( - &config.infra.compose_project, - &compose_file.to_string_lossy(), - ); + let infra_running = health::infra_is_running(config, project_root); + let infra_compose = project_root.join(&config.infra.compose_file); let infra_containers = if infra_running { health::compose_container_states( &config.infra.compose_project, - &compose_file.to_string_lossy(), + &infra_compose.to_string_lossy(), ) } else { Default::default() diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 059068d..820ab4c 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -4,6 +4,10 @@ use std::process::Command; use crate::config::{Config, ServiceConfig}; use crate::error::{Error, Result}; +/// Default runtime versions — must match Dockerfile.base ARGs. +const DEFAULT_RUBY: &str = "3.4.7"; +const DEFAULT_NODE: &str = "22.16.0"; + /// Generate a Procfile for overmind from the selected services. /// Writes to `.docker-sessions/.dev/Procfile.dev`. pub fn generate_procfile( @@ -22,14 +26,14 @@ pub fn generate_procfile( Error::Config(format!("Unknown service: {}", svc_name)) })?; - if let Some(entry) = procfile_entry(svc_name, svc, config, project_root) { + if let Some(entry) = procfile_entry(svc_name, svc, project_root) { lines.push(entry); } // Add companion (e.g., sidekiq for api) if let Some(companion) = &svc.companion && let Some(comp_svc) = config.services.get(companion) - && let Some(entry) = procfile_entry(companion, comp_svc, config, project_root) { + && let Some(entry) = procfile_entry(companion, comp_svc, project_root) { lines.push(entry); } } @@ -42,7 +46,6 @@ pub fn generate_procfile( fn procfile_entry( name: &str, svc: &ServiceConfig, - _config: &Config, project_root: &Path, ) -> Option { let repo = svc.repo.as_deref()?; @@ -56,20 +59,17 @@ fn procfile_entry( if ruby_version_file.exists() && let Ok(version) = std::fs::read_to_string(&ruby_version_file) { let version = version.trim(); - let default_ruby = "3.4.7"; // matches Dockerfile.base ARG - if version != default_ruby { + if version != DEFAULT_RUBY { wrapper.push_str(&format!("rvm use {} && ", version)); } } // Check if repo needs a different Node version let node_version = read_node_version(&repos_dir.join(repo)); - if let Some(version) = node_version { - let default_node = "22.16.0"; // matches Dockerfile.base ARG - if version != default_node { + if let Some(version) = node_version + && version != DEFAULT_NODE { wrapper.push_str(&format!(". /usr/local/nvm/nvm.sh && nvm use {} && ", version)); } - } let full_cmd = if wrapper.is_empty() { format!("{}: cd /workspace/{} && {}", name, repo, cmd) @@ -142,29 +142,30 @@ pub fn generate_compose( std::fs::create_dir_all(&compose_dir)?; let compose_path = compose_dir.join("docker-compose.yml"); - // Collect ports and repos for selected services (+ companions) + // Collect ports and repo names for selected services (+ companions) let mut ports = Vec::new(); - let mut repos = std::collections::BTreeSet::new(); + let mut selected_repos = Vec::new(); for svc_name in services { if let Some(svc) = config.services.get(svc_name) { if let Some(port) = svc.port { ports.push(port); } - if let Some(repo) = &svc.repo { - repos.insert(repo.clone()); - } + if let Some(repo) = &svc.repo + && !selected_repos.contains(repo) { + selected_repos.push(repo.clone()); + } // Include companion - if let Some(companion) = &svc.companion { - if let Some(comp) = config.services.get(companion) { + if let Some(companion) = &svc.companion + && let Some(comp) = config.services.get(companion) { if let Some(port) = comp.port { ports.push(port); } - if let Some(repo) = &comp.repo { - repos.insert(repo.clone()); - } + if let Some(repo) = &comp.repo + && !selected_repos.contains(repo) { + selected_repos.push(repo.clone()); + } } - } } } @@ -229,13 +230,13 @@ services: volumes: "#, container = config.docker.container, - selected_repos = services.join(","), + selected_repos = selected_repos.join(","), service_urls = service_urls.join("\n"), ports = ports_yaml.join("\n"), ); // Mount all repos (static, same as dev-compose.yml) - for (_, svc) in &config.services { + for svc in config.services.values() { if let Some(repo) = &svc.repo { content.push_str(&format!( " - {}/repos/{}:/workspace/{}\n", diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index 6eaa355..ab06537 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -26,6 +26,12 @@ pub fn caddy_is_running() -> bool { port_is_open(2019) } +/// Check if the shared infrastructure is running. +pub fn infra_is_running(config: &crate::config::Config, project_root: &std::path::Path) -> bool { + let compose_file = project_root.join(&config.infra.compose_file); + compose_is_running(&config.infra.compose_project, &compose_file.to_string_lossy()) +} + /// Check if a Docker compose project has running containers. pub fn compose_is_running(project: &str, compose_file: &str) -> bool { Command::new("docker") @@ -126,13 +132,11 @@ fn sso_session_remaining() -> Option { if mtime > newest_mtime { newest_mtime = mtime; // Parse expiresAt from JSON - if let Ok(json) = serde_json::from_str::(&content) { - if let Some(expires_at) = json.get("expiresAt").and_then(|v| v.as_str()) { - if let Ok(dt) = expires_at.parse::>() { + if let Ok(json) = serde_json::from_str::(&content) + && let Some(expires_at) = json.get("expiresAt").and_then(|v| v.as_str()) + && let Ok(dt) = expires_at.parse::>() { newest_expiry = Some(dt); } - } - } } } } From 2b30ff3fb3aa441387ff02eb378ca1f379824759 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 20:46:56 +0100 Subject: [PATCH 16/33] QA: fix container name filter, remove BUGSNAG_AUTH_TOKEN override - container_is_running now uses ^name$ anchor for exact match (docker ps filter does substring by default, causing false positives) - Removed hardcoded empty BUGSNAG_AUTH_TOKEN from generated compose (was overriding the value from .env.session) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/docker.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 820ab4c..54b98c3 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -223,7 +223,6 @@ services: - RAILS_ENV=development - NODE_ENV=development - PRODUCTIVE_API_BASE_URL=http://api.productive.io.localhost:3000 - - BUGSNAG_AUTH_TOKEN= {service_urls} ports: {ports} @@ -296,13 +295,16 @@ networks: /// Check if the dev container is currently running. pub fn container_is_running(config: &Config) -> bool { + // Use ^name$ anchor for exact match (docker filter does substring by default) Command::new("docker") .args([ "ps", "--filter", - &format!("name={}", config.docker.container), + &format!("name=^{}$", config.docker.container), + "--filter", + "status=running", "--format", - "{{.Status}}", + "{{.Names}}", ]) .output() .is_ok_and(|o| !o.stdout.is_empty()) From 6810a67ac92c42ac814691ffb6c3ce8a434d8fc2 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 20:48:54 +0100 Subject: [PATCH 17/33] Fix rustfmt formatting for CI Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/doctor.rs | 18 +---- crates/devctl/src/commands/infra.rs | 4 +- crates/devctl/src/commands/init.rs | 26 +++---- crates/devctl/src/commands/local.rs | 60 +++++++-------- crates/devctl/src/commands/logs.rs | 8 +- crates/devctl/src/commands/start.rs | 80 +++++++++++--------- crates/devctl/src/commands/status.rs | 25 +++---- crates/devctl/src/commands/stop.rs | 8 +- crates/devctl/src/docker.rs | 105 ++++++++++++--------------- crates/devctl/src/health.rs | 22 ++++-- crates/devctl/src/main.rs | 14 ++-- 11 files changed, 181 insertions(+), 189 deletions(-) diff --git a/crates/devctl/src/commands/doctor.rs b/crates/devctl/src/commands/doctor.rs index 690d041..ec86898 100644 --- a/crates/devctl/src/commands/doctor.rs +++ b/crates/devctl/src/commands/doctor.rs @@ -24,10 +24,7 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { if caddy_ok { println!(" {} Caddy (localhost:2019)", "✓".green()); } else { - println!( - " {} Caddy — not responding on localhost:2019", - "✗".red() - ); + println!(" {} Caddy — not responding on localhost:2019", "✗".red()); println!(" Run: ./scripts/setup-caddy.sh"); issues += 1; } @@ -95,12 +92,7 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { if svc_issues.is_empty() { println!(" {} {}", "✓".green(), name); } else { - println!( - " {} {} — {}", - "✗".red(), - name, - svc_issues.join(", ") - ); + println!(" {} {} — {}", "✗".red(), name, svc_issues.join(", ")); issues += 1; } } @@ -110,11 +102,7 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { if issues == 0 { println!("{}", "Everything looks good!".green().bold()); } else { - println!( - "{} {} issue(s) found.", - "!".yellow().bold(), - issues - ); + println!("{} {} issue(s) found.", "!".yellow().bold(), issues); } Ok(()) diff --git a/crates/devctl/src/commands/infra.rs b/crates/devctl/src/commands/infra.rs index 69b1d7c..246ec0d 100644 --- a/crates/devctl/src/commands/infra.rs +++ b/crates/devctl/src/commands/infra.rs @@ -9,7 +9,9 @@ use crate::health; pub fn up(config: &Config, project_root: &Path) -> Result<()> { if !health::docker_is_running() { - return Err(Error::Other("Docker is not running. Start Docker Desktop first.".into())); + return Err(Error::Other( + "Docker is not running. Start Docker Desktop first.".into(), + )); } let compose_file = project_root.join(&config.infra.compose_file); diff --git a/crates/devctl/src/commands/init.rs b/crates/devctl/src/commands/init.rs index da37a84..c6e4e9d 100644 --- a/crates/devctl/src/commands/init.rs +++ b/crates/devctl/src/commands/init.rs @@ -8,18 +8,20 @@ use crate::docker; use crate::error::{Error, Result}; pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { - let svc = config.services.get(service).ok_or_else(|| { - Error::Config(format!("Unknown service: '{}'", service)) - })?; + let svc = config + .services + .get(service) + .ok_or_else(|| Error::Config(format!("Unknown service: '{}'", service)))?; if svc.init.is_empty() { println!("No init steps defined for '{}'.", service); return Ok(()); } - let repo = svc.repo.as_deref().ok_or_else(|| { - Error::Config(format!("Service '{}' has no repo defined", service)) - })?; + let repo = svc + .repo + .as_deref() + .ok_or_else(|| Error::Config(format!("Service '{}' has no repo defined", service)))?; // Determine execution context let container_up = docker::container_is_running(config); @@ -37,12 +39,7 @@ pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { println!(); for (i, step) in svc.init.iter().enumerate() { - println!( - " [{}/{}] {}", - i + 1, - svc.init.len(), - step.bold() - ); + println!(" [{}/{}] {}", i + 1, svc.init.len(), step.bold()); if container_up { // Run inside Docker container as root (needed for gem/package installs). @@ -72,10 +69,7 @@ pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { // Run on host in repos/ let repo_dir = project_root.join("repos").join(repo); if !repo_dir.exists() { - return Err(Error::Config(format!( - "Repo not found: repos/{}", - repo - ))); + return Err(Error::Config(format!("Repo not found: repos/{}", repo))); } let status = Command::new("bash") diff --git a/crates/devctl/src/commands/local.rs b/crates/devctl/src/commands/local.rs index 5b34250..9cf038e 100644 --- a/crates/devctl/src/commands/local.rs +++ b/crates/devctl/src/commands/local.rs @@ -15,17 +15,20 @@ pub fn start( dir_override: Option<&str>, background: bool, ) -> Result<()> { - let svc = config.services.get(service).ok_or_else(|| { - Error::Config(format!("Unknown service: '{}'", service)) - })?; + let svc = config + .services + .get(service) + .ok_or_else(|| Error::Config(format!("Unknown service: '{}'", service)))?; - let cmd = svc.cmd.as_deref().ok_or_else(|| { - Error::Config(format!("Service '{}' has no cmd defined", service)) - })?; + let cmd = svc + .cmd + .as_deref() + .ok_or_else(|| Error::Config(format!("Service '{}' has no cmd defined", service)))?; - let repo = svc.repo.as_deref().ok_or_else(|| { - Error::Config(format!("Service '{}' has no repo defined", service)) - })?; + let repo = svc + .repo + .as_deref() + .ok_or_else(|| Error::Config(format!("Service '{}' has no repo defined", service)))?; // Determine service directory let svc_dir: PathBuf = if let Some(dir) = dir_override { @@ -43,15 +46,16 @@ pub fn start( // Check port conflicts if let Some(port) = svc.port - && health::port_is_open(port) { - let owner = health::port_owner(port) - .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) - .unwrap_or_else(|| "unknown".into()); - return Err(Error::Other(format!( - "Port {} is already in use by {}", - port, owner - ))); - } + && health::port_is_open(port) + { + let owner = health::port_owner(port) + .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) + .unwrap_or_else(|| "unknown".into()); + return Err(Error::Other(format!( + "Port {} is already in use by {}", + port, owner + ))); + } // Check secrets for secret in &svc.secrets { @@ -66,11 +70,10 @@ pub fn start( } // Auto-start infra if needed - if !svc.infra.is_empty() - && !health::infra_is_running(config, project_root) { - println!("{}", "Starting infrastructure...".blue()); - crate::commands::infra::up(config, project_root)?; - } + if !svc.infra.is_empty() && !health::infra_is_running(config, project_root) { + println!("{}", "Starting infrastructure...".blue()); + crate::commands::infra::up(config, project_root)?; + } // Run start steps (git pull, deps, migrate) if !svc.start.is_empty() { @@ -207,9 +210,10 @@ pub fn start( pub fn stop(project_root: &Path, service: &str) -> Result<()> { let mut state = State::load(project_root)?; - let svc_state = state.services.get(service).ok_or_else(|| { - Error::Other(format!("Service '{}' is not tracked in state", service)) - })?; + let svc_state = state + .services + .get(service) + .ok_or_else(|| Error::Other(format!("Service '{}' is not tracked in state", service)))?; if svc_state.mode != "local" { return Err(Error::Other(format!( @@ -220,9 +224,7 @@ pub fn stop(project_root: &Path, service: &str) -> Result<()> { if let Some(pid) = svc_state.pid { println!("Stopping {} (PID {})...", service, pid); - let _ = Command::new("kill") - .arg(pid.to_string()) - .status(); + let _ = Command::new("kill").arg(pid.to_string()).status(); std::thread::sleep(std::time::Duration::from_secs(2)); println!("{} stopped.", service.green()); } else { diff --git a/crates/devctl/src/commands/logs.rs b/crates/devctl/src/commands/logs.rs index e365a39..a5cbe7c 100644 --- a/crates/devctl/src/commands/logs.rs +++ b/crates/devctl/src/commands/logs.rs @@ -32,9 +32,7 @@ pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { // App services → overmind tmux pane capture (non-interactive) if !docker::container_is_running(config) { - return Err(Error::Other( - "Dev container is not running.".into(), - )); + return Err(Error::Other("Dev container is not running.".into())); } // Find the overmind tmux socket @@ -52,9 +50,7 @@ pub fn run(config: &Config, project_root: &Path, service: &str) -> Result<()> { let socket = String::from_utf8_lossy(&output.stdout).trim().to_string(); if socket.is_empty() { - return Err(Error::Other( - "Overmind not running in container.".into(), - )); + return Err(Error::Other("Overmind not running in container.".into())); } // Capture last 100 lines from tmux pane diff --git a/crates/devctl/src/commands/start.rs b/crates/devctl/src/commands/start.rs index f394653..724ad99 100644 --- a/crates/devctl/src/commands/start.rs +++ b/crates/devctl/src/commands/start.rs @@ -8,11 +8,7 @@ use crate::error::{Error, Result}; use crate::health; use crate::state::{ServiceState, State}; -pub fn docker( - config: &Config, - project_root: &Path, - services: &[String], -) -> Result<()> { +pub fn docker(config: &Config, project_root: &Path, services: &[String]) -> Result<()> { // --- Prerequisite: Docker running --- if !health::docker_is_running() { return Err(Error::Other( @@ -42,27 +38,32 @@ pub fn docker( for svc_name in services { let svc = &config.services[svc_name]; if let Some(port) = svc.port - && health::port_is_open(port) { - let owner = health::port_owner(port) - .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) - .unwrap_or_else(|| "unknown".into()); - conflicts.push(format!(" {} (port {}) — occupied by {}", svc_name, port, owner)); - } + && health::port_is_open(port) + { + let owner = health::port_owner(port) + .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) + .unwrap_or_else(|| "unknown".into()); + conflicts.push(format!( + " {} (port {}) — occupied by {}", + svc_name, port, owner + )); + } } // Also check companion ports for svc_name in services { if let Some(companion) = &config.services[svc_name].companion && let Some(comp_svc) = config.services.get(companion) - && let Some(port) = comp_svc.port - && health::port_is_open(port) { - let owner = health::port_owner(port) - .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) - .unwrap_or_else(|| "unknown".into()); - conflicts.push(format!( - " {} (port {}) — occupied by {}", - companion, port, owner - )); - } + && let Some(port) = comp_svc.port + && health::port_is_open(port) + { + let owner = health::port_owner(port) + .map(|(pid, cmd)| format!("{} (PID {})", cmd, pid)) + .unwrap_or_else(|| "unknown".into()); + conflicts.push(format!( + " {} (port {}) — occupied by {}", + companion, port, owner + )); + } } if !conflicts.is_empty() { eprintln!("{}", "Port conflicts detected:".red()); @@ -80,12 +81,13 @@ pub fn docker( for svc_name in services { let svc = &config.services[svc_name]; if let Some(repo) = &svc.repo - && !repos_dir.join(repo).exists() { - return Err(Error::Config(format!( - "Repo not cloned: repos/{}. Run: git clone https://github.com/productiveio/{}.git repos/{}", - repo, repo, repo - ))); - } + && !repos_dir.join(repo).exists() + { + return Err(Error::Config(format!( + "Repo not cloned: repos/{}. Run: git clone https://github.com/productiveio/{}.git repos/{}", + repo, repo, repo + ))); + } } // --- Check secrets --- @@ -113,9 +115,9 @@ pub fn docker( } // --- Auto-start infra if needed --- - let infra_needed = services.iter().any(|svc_name| { - !config.services[svc_name].infra.is_empty() - }); + let infra_needed = services + .iter() + .any(|svc_name| !config.services[svc_name].infra.is_empty()); if infra_needed { if !health::infra_is_running(config, project_root) { @@ -163,7 +165,9 @@ pub fn docker( ServiceState { mode: "docker".into(), started_at: now.clone(), - dir: config.services.get(companion) + dir: config + .services + .get(companion) .and_then(|s| s.repo.as_ref()) .map(|r| format!("repos/{}", r)), pid: None, @@ -226,11 +230,17 @@ fn capture_env(project_root: &Path) -> Result<()> { let aws_region = std::env::var("AWS_DEFAULT_REGION").unwrap_or_else(|_| "eu-central-1".into()); lines.push(format!("AWS_DEFAULT_REGION={}", aws_region)); - for var in &["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_PROFILE"] { + for var in &[ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + ] { if let Ok(val) = std::env::var(var) - && !val.is_empty() { - lines.push(format!("{}={}", var, val)); - } + && !val.is_empty() + { + lines.push(format!("{}={}", var, val)); + } } std::fs::write(&env_file, lines.join("\n") + "\n")?; diff --git a/crates/devctl/src/commands/status.rs b/crates/devctl/src/commands/status.rs index 33d39db..4c87768 100644 --- a/crates/devctl/src/commands/status.rs +++ b/crates/devctl/src/commands/status.rs @@ -63,14 +63,8 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { // Print service table with padding applied before colorization println!(); - println!( - " {:<20} {:<10} {:<10} URL", - "SERVICE", "MODE", "STATE" - ); - println!( - " {:<20} {:<10} {:<10} ───", - "───────", "────", "─────" - ); + println!(" {:<20} {:<10} {:<10} URL", "SERVICE", "MODE", "STATE"); + println!(" {:<20} {:<10} {:<10} ───", "───────", "────", "─────"); for (name, mode, state_text, state_color, url) in &rows { let padded_state = format!("{:<10}", state_text); @@ -138,17 +132,20 @@ fn determine_service_state( // No mode set if mode == "-" { if let Some(port) = port - && !container_up && health::port_is_open(port) { - return ("external".into(), "yellow".into()); - } + && !container_up + && health::port_is_open(port) + { + return ("external".into(), "yellow".into()); + } return ("-".into(), "dim".into()); } // Local mode (future): probe port if let Some(port) = port - && health::port_is_open(port) { - return ("running".into(), "green".into()); - } + && health::port_is_open(port) + { + return ("running".into(), "green".into()); + } ("stopped".into(), "red".into()) } diff --git a/crates/devctl/src/commands/stop.rs b/crates/devctl/src/commands/stop.rs index e02b09a..2fcc375 100644 --- a/crates/devctl/src/commands/stop.rs +++ b/crates/devctl/src/commands/stop.rs @@ -35,7 +35,13 @@ pub fn restart_service(config: &Config, service: &str) -> Result<()> { println!("Restarting {}...", service.bold()); let status = std::process::Command::new("docker") - .args(["exec", &config.docker.container, "overmind", "restart", service]) + .args([ + "exec", + &config.docker.container, + "overmind", + "restart", + service, + ]) .status()?; if !status.success() { diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 54b98c3..907e34a 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -10,11 +10,7 @@ const DEFAULT_NODE: &str = "22.16.0"; /// Generate a Procfile for overmind from the selected services. /// Writes to `.docker-sessions/.dev/Procfile.dev`. -pub fn generate_procfile( - config: &Config, - services: &[String], - project_root: &Path, -) -> Result<()> { +pub fn generate_procfile(config: &Config, services: &[String], project_root: &Path) -> Result<()> { let procfile_dir = project_root.join(".docker-sessions/.dev"); std::fs::create_dir_all(&procfile_dir)?; let procfile_path = procfile_dir.join("Procfile.dev"); @@ -22,9 +18,10 @@ pub fn generate_procfile( let mut lines = Vec::new(); for svc_name in services { - let svc = config.services.get(svc_name).ok_or_else(|| { - Error::Config(format!("Unknown service: {}", svc_name)) - })?; + let svc = config + .services + .get(svc_name) + .ok_or_else(|| Error::Config(format!("Unknown service: {}", svc_name)))?; if let Some(entry) = procfile_entry(svc_name, svc, project_root) { lines.push(entry); @@ -33,9 +30,10 @@ pub fn generate_procfile( // Add companion (e.g., sidekiq for api) if let Some(companion) = &svc.companion && let Some(comp_svc) = config.services.get(companion) - && let Some(entry) = procfile_entry(companion, comp_svc, project_root) { - lines.push(entry); - } + && let Some(entry) = procfile_entry(companion, comp_svc, project_root) + { + lines.push(entry); + } } std::fs::write(&procfile_path, lines.join("\n") + "\n")?; @@ -43,11 +41,7 @@ pub fn generate_procfile( } /// Build a single Procfile entry, with runtime version wrappers if needed. -fn procfile_entry( - name: &str, - svc: &ServiceConfig, - project_root: &Path, -) -> Option { +fn procfile_entry(name: &str, svc: &ServiceConfig, project_root: &Path) -> Option { let repo = svc.repo.as_deref()?; let cmd = svc.cmd.as_deref()?; @@ -57,19 +51,24 @@ fn procfile_entry( // Check if repo needs a different Ruby version let ruby_version_file = repos_dir.join(repo).join(".ruby-version"); if ruby_version_file.exists() - && let Ok(version) = std::fs::read_to_string(&ruby_version_file) { - let version = version.trim(); - if version != DEFAULT_RUBY { - wrapper.push_str(&format!("rvm use {} && ", version)); - } + && let Ok(version) = std::fs::read_to_string(&ruby_version_file) + { + let version = version.trim(); + if version != DEFAULT_RUBY { + wrapper.push_str(&format!("rvm use {} && ", version)); } + } // Check if repo needs a different Node version let node_version = read_node_version(&repos_dir.join(repo)); if let Some(version) = node_version - && version != DEFAULT_NODE { - wrapper.push_str(&format!(". /usr/local/nvm/nvm.sh && nvm use {} && ", version)); - } + && version != DEFAULT_NODE + { + wrapper.push_str(&format!( + ". /usr/local/nvm/nvm.sh && nvm use {} && ", + version + )); + } let full_cmd = if wrapper.is_empty() { format!("{}: cd /workspace/{} && {}", name, repo, cmd) @@ -88,9 +87,10 @@ fn read_node_version(repo_path: &Path) -> Option { for filename in &[".node-version", ".nvmrc"] { let path = repo_path.join(filename); if path.exists() - && let Ok(version) = std::fs::read_to_string(&path) { - return Some(version.trim().to_string()); - } + && let Ok(version) = std::fs::read_to_string(&path) + { + return Some(version.trim().to_string()); + } } None } @@ -101,12 +101,7 @@ pub fn overmind_status(config: &Config) -> std::collections::BTreeMap Result<()> { } fn generated_compose_path(project_root: &Path) -> std::path::PathBuf { - project_root - .join(".docker-sessions/.dev/docker-compose.yml") + project_root.join(".docker-sessions/.dev/docker-compose.yml") } /// Start the dev container using the generated compose file. -pub fn start_container( - config: &Config, - project_root: &Path, - services: &[String], -) -> Result<()> { +pub fn start_container(config: &Config, project_root: &Path, services: &[String]) -> Result<()> { // Generate compose with only the needed ports let compose_file = generate_compose(config, services, project_root)?; diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index ab06537..c150f63 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -29,13 +29,24 @@ pub fn caddy_is_running() -> bool { /// Check if the shared infrastructure is running. pub fn infra_is_running(config: &crate::config::Config, project_root: &std::path::Path) -> bool { let compose_file = project_root.join(&config.infra.compose_file); - compose_is_running(&config.infra.compose_project, &compose_file.to_string_lossy()) + compose_is_running( + &config.infra.compose_project, + &compose_file.to_string_lossy(), + ) } /// Check if a Docker compose project has running containers. pub fn compose_is_running(project: &str, compose_file: &str) -> bool { Command::new("docker") - .args(["compose", "-p", project, "-f", compose_file, "ps", "--quiet"]) + .args([ + "compose", + "-p", + project, + "-f", + compose_file, + "ps", + "--quiet", + ]) .output() .is_ok_and(|o| !o.stdout.is_empty()) } @@ -134,9 +145,10 @@ fn sso_session_remaining() -> Option { // Parse expiresAt from JSON if let Ok(json) = serde_json::from_str::(&content) && let Some(expires_at) = json.get("expiresAt").and_then(|v| v.as_str()) - && let Ok(dt) = expires_at.parse::>() { - newest_expiry = Some(dt); - } + && let Ok(dt) = expires_at.parse::>() + { + newest_expiry = Some(dt); + } } } } diff --git a/crates/devctl/src/main.rs b/crates/devctl/src/main.rs index a51e2ed..a519356 100644 --- a/crates/devctl/src/main.rs +++ b/crates/devctl/src/main.rs @@ -91,7 +91,11 @@ fn main() { let cli = Cli::parse(); let cwd = env::current_dir().unwrap_or_else(|e| { - eprintln!("{} Cannot determine current directory: {}", "Error:".red().bold(), e); + eprintln!( + "{} Cannot determine current directory: {}", + "Error:".red().bold(), + e + ); std::process::exit(1); }); @@ -117,13 +121,7 @@ fn main() { services.split(',').map(|s| s.trim().to_string()).collect(); commands::start::docker(&cfg, &root, &svc_list) } else if local { - commands::local::start( - &cfg, - &root, - &services, - dir.as_deref(), - bg, - ) + commands::local::start(&cfg, &root, &services, dir.as_deref(), bg) } else { Err(devctl::error::Error::Other( "Specify --docker or --local mode.".into(), From 2420b020c9d30bc98d275079712d8e6b360101e3 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 21:04:40 +0100 Subject: [PATCH 18/33] Fix AWS region: don't override AWS_DEFAULT_REGION in env capture The hardcoded fallback AWS_DEFAULT_REGION=eu-central-1 was overriding ~/.aws/config (which has region=us-east-1). This caused secrets-manager pull to look in the wrong region inside the container. Now only forwards AWS env vars if explicitly set on the host. The AWS SDK reads the correct region from the mounted ~/.aws/config. Tested: secrets-manager pull works inside the container. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/start.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/devctl/src/commands/start.rs b/crates/devctl/src/commands/start.rs index 724ad99..6395d90 100644 --- a/crates/devctl/src/commands/start.rs +++ b/crates/devctl/src/commands/start.rs @@ -226,11 +226,10 @@ fn capture_env(project_root: &Path) -> Result<()> { .unwrap_or_default(); lines.push(format!("GH_TOKEN={}", gh_token)); - // AWS - let aws_region = std::env::var("AWS_DEFAULT_REGION").unwrap_or_else(|_| "eu-central-1".into()); - lines.push(format!("AWS_DEFAULT_REGION={}", aws_region)); - + // AWS — only forward explicit credentials, never override region + // (region comes from ~/.aws/config which is mounted into the container) for var in &[ + "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", From b6f8a972590a95df5540c52879bafbd87a594afe Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 21:41:25 +0100 Subject: [PATCH 19/33] Add CI=true and COREPACK env vars to generated compose - CI=true suppresses pnpm's interactive "reinstall modules?" prompt that blocks in non-interactive Docker environments - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 and COREPACK_ENABLE_AUTO_PIN=0 prevent corepack from prompting when downloading pnpm versions Discovered during live testing with frontend service. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/docker.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 907e34a..72ef9e3 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -214,6 +214,9 @@ services: - RAISE_ON_MISSING_FEATURES=false - RAILS_ENV=development - NODE_ENV=development + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + - COREPACK_ENABLE_AUTO_PIN=0 + - CI=true - PRODUCTIVE_API_BASE_URL=http://api.productive.io.localhost:3000 {service_urls} ports: From 41b30699772c3e90095fe1cfc3a4c3c15480d723 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 21:59:35 +0100 Subject: [PATCH 20/33] Add redis_host env var for exporter Docker compatibility Exporter reads redis_host (lowercase) from process.env, not REDIS_URL. Added redis_host=productive-dev-redis to generated compose environment. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/docker.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 72ef9e3..a060f8c 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -206,6 +206,7 @@ services: - MYSQL_USER=root - MYSQL_PASSWORD= - REDIS_URL=redis://productive-dev-redis:6379/0 + - redis_host=productive-dev-redis - MEILISEARCH_URL=http://productive-dev-meilisearch:7700 - MEMCACHE_SERVERS=productive-dev-memcached:11211 - cache_url=productive-dev-memcached:11211 From 9dfa8ca8daa3eda850ffd69b8e31d14d8857e7cf Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 22:20:29 +0100 Subject: [PATCH 21/33] Add PUPPETEER_EXECUTABLE_PATH to generated compose Points Puppeteer at the arm64 Chromium binary from Playwright's CDN, installed in /opt/chromium in the Docker image. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/docker.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index a060f8c..85c0712 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -218,6 +218,7 @@ services: - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 - COREPACK_ENABLE_AUTO_PIN=0 - CI=true + - PUPPETEER_EXECUTABLE_PATH=/opt/chromium/chrome-linux/chrome - PRODUCTIVE_API_BASE_URL=http://api.productive.io.localhost:3000 {service_urls} ports: From f2390515163bc41a474b28b4d25374b58f40dcc9 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 23:03:44 +0100 Subject: [PATCH 22/33] Fix local mode: rbenv/nvm init for correct runtime versions bash -lc doesn't put rbenv shims first in PATH, causing system Ruby to be used instead of the version in .ruby-version. Fix: prepend `eval "$(rbenv init - bash)"` when .ruby-version exists, and source nvm when .node-version/.nvmrc exists. Also: resolve --dir paths relative to project root, not cwd. Tested: devportal from worktree with Ruby 4.0.1 via rbenv. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/local.rs | 53 ++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/crates/devctl/src/commands/local.rs b/crates/devctl/src/commands/local.rs index 9cf038e..8e023f4 100644 --- a/crates/devctl/src/commands/local.rs +++ b/crates/devctl/src/commands/local.rs @@ -8,6 +8,27 @@ use crate::error::{Error, Result}; use crate::health; use crate::state::{ServiceState, State}; +/// Build a shell command that initializes runtime version managers and +/// cd's into the service directory before running the actual command. +/// This ensures rbenv/nvm detect .ruby-version/.node-version/.nvmrc. +fn shell_cmd(svc_dir: &Path, cmd: &str) -> String { + let mut parts = Vec::new(); + + // rbenv init puts shims first in PATH (needed for .ruby-version detection) + if svc_dir.join(".ruby-version").exists() { + parts.push("eval \"$(rbenv init - bash)\" 2>/dev/null".to_string()); + } + + // nvm init for .node-version/.nvmrc detection + if svc_dir.join(".node-version").exists() || svc_dir.join(".nvmrc").exists() { + parts.push("export NVM_DIR=\"$HOME/.nvm\" && [ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\" 2>/dev/null".to_string()); + } + + parts.push(format!("cd {}", svc_dir.display())); + parts.push(cmd.to_string()); + parts.join(" && ") +} + pub fn start( config: &Config, project_root: &Path, @@ -30,9 +51,14 @@ pub fn start( .as_deref() .ok_or_else(|| Error::Config(format!("Service '{}' has no repo defined", service)))?; - // Determine service directory + // Determine service directory (--dir paths resolved relative to project root) let svc_dir: PathBuf = if let Some(dir) = dir_override { - PathBuf::from(dir) + let p = PathBuf::from(dir); + if p.is_absolute() { + p + } else { + project_root.join(p) + } } else { project_root.join("repos").join(repo) }; @@ -93,10 +119,8 @@ pub fn start( // git restore: clean up generated files after migrations if step.starts_with("git restore") { - let status = Command::new("bash") - .args(["-lc", step]) - .current_dir(&svc_dir) - .status()?; + let cmd = shell_cmd(&svc_dir, step); + let status = Command::new("bash").args(["-lc", &cmd]).status()?; if !status.success() { println!(" {} {} (non-fatal)", "!".yellow(), step); } @@ -104,10 +128,9 @@ pub fn start( } println!(" {}", step); - let status = Command::new("bash") - .args(["-lc", step]) - .current_dir(&svc_dir) - .status()?; + // Explicit cd so rbenv/nvm detect .ruby-version/.node-version + let cmd = shell_cmd(&svc_dir, step); + let status = Command::new("bash").args(["-lc", &cmd]).status()?; if !status.success() { return Err(Error::Other(format!("Setup step failed: {}", step))); @@ -139,9 +162,9 @@ pub fn start( log_file.display() ); + let full_cmd = shell_cmd(&svc_dir, cmd); let child = Command::new("bash") - .args(["-lc", cmd]) - .current_dir(&svc_dir) + .args(["-lc", &full_cmd]) .stdout(log.try_clone()?) .stderr(log) .spawn()?; @@ -184,10 +207,8 @@ pub fn start( ); state.save(project_root)?; - let status = Command::new("bash") - .args(["-lc", cmd]) - .current_dir(&svc_dir) - .status()?; + let full_cmd = shell_cmd(&svc_dir, cmd); + let status = Command::new("bash").args(["-lc", &full_cmd]).status()?; // Clean up state after exit let mut state = State::load(project_root)?; From eac2c50352de24b5fab4a8ed59e52cba8b771dcf Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 23:33:09 +0100 Subject: [PATCH 23/33] Add per-service env vars from devctl.toml Services can now declare environment variables in devctl.toml: [services.ai-agent] env = { NODE_EXTRA_CA_CERTS = "/etc/pki/tls/certs/ca-bundle.crt" } Docker mode: added to generated compose environment block. Local mode: exported before the service command in bash. This bridges the gap between production Dockerfiles (which set their own env vars) and the shared dev container (which uses a common image). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/local.rs | 27 ++++++++++++++++++++++----- crates/devctl/src/config.rs | 2 ++ crates/devctl/src/docker.rs | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/devctl/src/commands/local.rs b/crates/devctl/src/commands/local.rs index 8e023f4..a70583c 100644 --- a/crates/devctl/src/commands/local.rs +++ b/crates/devctl/src/commands/local.rs @@ -11,9 +11,18 @@ use crate::state::{ServiceState, State}; /// Build a shell command that initializes runtime version managers and /// cd's into the service directory before running the actual command. /// This ensures rbenv/nvm detect .ruby-version/.node-version/.nvmrc. -fn shell_cmd(svc_dir: &Path, cmd: &str) -> String { +fn shell_cmd( + svc_dir: &Path, + cmd: &str, + env: &std::collections::BTreeMap, +) -> String { let mut parts = Vec::new(); + // Per-service env vars from devctl.toml + for (key, val) in env { + parts.push(format!("export {}={}", key, shell_escape(val))); + } + // rbenv init puts shims first in PATH (needed for .ruby-version detection) if svc_dir.join(".ruby-version").exists() { parts.push("eval \"$(rbenv init - bash)\" 2>/dev/null".to_string()); @@ -29,6 +38,14 @@ fn shell_cmd(svc_dir: &Path, cmd: &str) -> String { parts.join(" && ") } +fn shell_escape(s: &str) -> String { + if s.contains(' ') || s.contains('"') || s.contains('$') { + format!("'{}'", s.replace('\'', "'\\''")) + } else { + s.to_string() + } +} + pub fn start( config: &Config, project_root: &Path, @@ -119,7 +136,7 @@ pub fn start( // git restore: clean up generated files after migrations if step.starts_with("git restore") { - let cmd = shell_cmd(&svc_dir, step); + let cmd = shell_cmd(&svc_dir, step, &svc.env); let status = Command::new("bash").args(["-lc", &cmd]).status()?; if !status.success() { println!(" {} {} (non-fatal)", "!".yellow(), step); @@ -129,7 +146,7 @@ pub fn start( println!(" {}", step); // Explicit cd so rbenv/nvm detect .ruby-version/.node-version - let cmd = shell_cmd(&svc_dir, step); + let cmd = shell_cmd(&svc_dir, step, &svc.env); let status = Command::new("bash").args(["-lc", &cmd]).status()?; if !status.success() { @@ -162,7 +179,7 @@ pub fn start( log_file.display() ); - let full_cmd = shell_cmd(&svc_dir, cmd); + let full_cmd = shell_cmd(&svc_dir, cmd, &svc.env); let child = Command::new("bash") .args(["-lc", &full_cmd]) .stdout(log.try_clone()?) @@ -207,7 +224,7 @@ pub fn start( ); state.save(project_root)?; - let full_cmd = shell_cmd(&svc_dir, cmd); + let full_cmd = shell_cmd(&svc_dir, cmd, &svc.env); let status = Command::new("bash").args(["-lc", &full_cmd]).status()?; // Clean up state after exit diff --git a/crates/devctl/src/config.rs b/crates/devctl/src/config.rs index 82cce58..cf05aca 100644 --- a/crates/devctl/src/config.rs +++ b/crates/devctl/src/config.rs @@ -55,6 +55,8 @@ pub struct ServiceConfig { pub init: Vec, #[serde(default)] pub start: Vec, + #[serde(default)] + pub env: std::collections::BTreeMap, } /// Walk up from `start` looking for `devctl.toml`. diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 85c0712..8db22b3 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -177,6 +177,23 @@ pub fn generate_compose( } } + // Collect per-service env vars from selected services (+ companions) + let mut service_env_lines = Vec::new(); + for svc_name in services { + if let Some(svc) = config.services.get(svc_name) { + for (key, val) in &svc.env { + service_env_lines.push(format!(" - {}={}", key, val)); + } + if let Some(companion) = &svc.companion { + if let Some(comp) = config.services.get(companion) { + for (key, val) in &comp.env { + service_env_lines.push(format!(" - {}={}", key, val)); + } + } + } + } + } + // Build ports section let ports_yaml: Vec = ports .iter() @@ -221,6 +238,7 @@ services: - PUPPETEER_EXECUTABLE_PATH=/opt/chromium/chrome-linux/chrome - PRODUCTIVE_API_BASE_URL=http://api.productive.io.localhost:3000 {service_urls} +{service_env} ports: {ports} volumes: @@ -228,6 +246,7 @@ services: container = config.docker.container, selected_repos = selected_repos.join(","), service_urls = service_urls.join("\n"), + service_env = service_env_lines.join("\n"), ports = ports_yaml.join("\n"), ); From 7338a216149a6f61d507db1c203b9132441af31b Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 23:38:16 +0100 Subject: [PATCH 24/33] Split per-service env into env, env_docker, env_local Services can now declare mode-specific environment variables: - env: shared across all modes - env_docker: Docker mode only (overrides env) - env_local: local mode only (overrides env) Example: ai-agent needs NODE_EXTRA_CA_CERTS but the path differs between Docker (/etc/pki/tls/certs/ca-bundle.crt) and macOS (/etc/ssl/cert.pem). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/local.rs | 14 +++++++++----- crates/devctl/src/config.rs | 4 ++++ crates/devctl/src/docker.rs | 5 +++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/devctl/src/commands/local.rs b/crates/devctl/src/commands/local.rs index a70583c..aad79d0 100644 --- a/crates/devctl/src/commands/local.rs +++ b/crates/devctl/src/commands/local.rs @@ -18,7 +18,7 @@ fn shell_cmd( ) -> String { let mut parts = Vec::new(); - // Per-service env vars from devctl.toml + // Per-service env vars from devctl.toml (shared + local-specific) for (key, val) in env { parts.push(format!("export {}={}", key, shell_escape(val))); } @@ -118,6 +118,10 @@ pub fn start( crate::commands::infra::up(config, project_root)?; } + // Merge shared + local-specific env vars (local overrides shared) + let mut merged_env = svc.env.clone(); + merged_env.extend(svc.env_local.iter().map(|(k, v)| (k.clone(), v.clone()))); + // Run start steps (git pull, deps, migrate) if !svc.start.is_empty() { println!("{}", "Running setup steps...".blue()); @@ -136,7 +140,7 @@ pub fn start( // git restore: clean up generated files after migrations if step.starts_with("git restore") { - let cmd = shell_cmd(&svc_dir, step, &svc.env); + let cmd = shell_cmd(&svc_dir, step, &merged_env); let status = Command::new("bash").args(["-lc", &cmd]).status()?; if !status.success() { println!(" {} {} (non-fatal)", "!".yellow(), step); @@ -146,7 +150,7 @@ pub fn start( println!(" {}", step); // Explicit cd so rbenv/nvm detect .ruby-version/.node-version - let cmd = shell_cmd(&svc_dir, step, &svc.env); + let cmd = shell_cmd(&svc_dir, step, &merged_env); let status = Command::new("bash").args(["-lc", &cmd]).status()?; if !status.success() { @@ -179,7 +183,7 @@ pub fn start( log_file.display() ); - let full_cmd = shell_cmd(&svc_dir, cmd, &svc.env); + let full_cmd = shell_cmd(&svc_dir, cmd, &merged_env); let child = Command::new("bash") .args(["-lc", &full_cmd]) .stdout(log.try_clone()?) @@ -224,7 +228,7 @@ pub fn start( ); state.save(project_root)?; - let full_cmd = shell_cmd(&svc_dir, cmd, &svc.env); + let full_cmd = shell_cmd(&svc_dir, cmd, &merged_env); let status = Command::new("bash").args(["-lc", &full_cmd]).status()?; // Clean up state after exit diff --git a/crates/devctl/src/config.rs b/crates/devctl/src/config.rs index cf05aca..4903a5e 100644 --- a/crates/devctl/src/config.rs +++ b/crates/devctl/src/config.rs @@ -57,6 +57,10 @@ pub struct ServiceConfig { pub start: Vec, #[serde(default)] pub env: std::collections::BTreeMap, + #[serde(default)] + pub env_docker: std::collections::BTreeMap, + #[serde(default)] + pub env_local: std::collections::BTreeMap, } /// Walk up from `start` looking for `devctl.toml`. diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 8db22b3..91e1e71 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -181,12 +181,13 @@ pub fn generate_compose( let mut service_env_lines = Vec::new(); for svc_name in services { if let Some(svc) = config.services.get(svc_name) { - for (key, val) in &svc.env { + // Shared env, then docker-specific (docker overrides shared) + for (key, val) in svc.env.iter().chain(svc.env_docker.iter()) { service_env_lines.push(format!(" - {}={}", key, val)); } if let Some(companion) = &svc.companion { if let Some(comp) = config.services.get(companion) { - for (key, val) in &comp.env { + for (key, val) in comp.env.iter().chain(comp.env_docker.iter()) { service_env_lines.push(format!(" - {}={}", key, val)); } } From 5e32a79a7efca8ca1a5738aef39cd5c252bcc80b Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 23:51:02 +0100 Subject: [PATCH 25/33] Add presets and --version flag Presets are named configurations in devctl.toml that expand to services + mode + env vars: devctl start --preset frontend-only devctl start --preset ai-dev Preset runs services in the configured mode (local/docker), with env vars set before launching. For local mode, all services except the last run in background; the last runs in foreground. Also adds --version flag (standard CLI convention). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/mod.rs | 1 + crates/devctl/src/commands/preset.rs | 65 ++++++++++++++++++++++++++++ crates/devctl/src/config.rs | 13 ++++++ crates/devctl/src/main.rs | 32 ++++++++++---- 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 crates/devctl/src/commands/preset.rs diff --git a/crates/devctl/src/commands/mod.rs b/crates/devctl/src/commands/mod.rs index 6f6c042..d519536 100644 --- a/crates/devctl/src/commands/mod.rs +++ b/crates/devctl/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod infra; pub mod init; pub mod local; pub mod logs; +pub mod preset; pub mod start; pub mod status; pub mod stop; diff --git a/crates/devctl/src/commands/preset.rs b/crates/devctl/src/commands/preset.rs new file mode 100644 index 0000000..fd1456b --- /dev/null +++ b/crates/devctl/src/commands/preset.rs @@ -0,0 +1,65 @@ +use std::path::Path; + +use colored::Colorize; + +use crate::config::Config; +use crate::error::{Error, Result}; + +pub fn run(config: &Config, project_root: &Path, preset_name: &str) -> Result<()> { + let preset = config.presets.get(preset_name).ok_or_else(|| { + let available: Vec<&str> = config.presets.keys().map(|k| k.as_str()).collect(); + Error::Config(format!( + "Unknown preset: '{}'. Available: {}", + preset_name, + available.join(", ") + )) + })?; + + if let Some(desc) = &preset.description { + println!("{} {}", "Preset:".blue(), desc); + } + + let mode = preset.mode.as_deref().unwrap_or("local"); + + // Set preset env vars in the current process (inherited by child commands) + for (key, val) in &preset.env { + println!(" {} {}={}", "env".dimmed(), key, val); + // SAFETY: single-threaded CLI, no concurrent env access + unsafe { std::env::set_var(key, val) }; + } + + match mode { + "docker" => crate::commands::start::docker(config, project_root, &preset.services), + "local" => { + // Start each service locally in background + for (i, service) in preset.services.iter().enumerate() { + let is_last = i == preset.services.len() - 1; + if is_last { + // Last service runs in foreground (so Ctrl+C stops everything) + println!(); + crate::commands::local::start( + config, + project_root, + service, + None, + false, // foreground + )?; + } else { + // Other services run in background + crate::commands::local::start( + config, + project_root, + service, + None, + true, // background + )?; + } + } + Ok(()) + } + other => Err(Error::Config(format!( + "Unknown preset mode: '{}'. Use 'docker' or 'local'.", + other + ))), + } +} diff --git a/crates/devctl/src/config.rs b/crates/devctl/src/config.rs index 4903a5e..d4e6b07 100644 --- a/crates/devctl/src/config.rs +++ b/crates/devctl/src/config.rs @@ -10,9 +10,22 @@ pub struct Config { pub infra: InfraConfig, pub docker: DockerConfig, #[serde(default)] + pub presets: BTreeMap, + #[serde(default)] pub services: BTreeMap, } +#[derive(Debug, Deserialize)] +pub struct PresetConfig { + #[serde(default)] + pub description: Option, + pub services: Vec, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub env: BTreeMap, +} + #[derive(Debug, Deserialize)] pub struct InfraConfig { pub compose_file: String, diff --git a/crates/devctl/src/main.rs b/crates/devctl/src/main.rs index a519356..6e00eac 100644 --- a/crates/devctl/src/main.rs +++ b/crates/devctl/src/main.rs @@ -9,6 +9,7 @@ use devctl::config; #[derive(Parser)] #[command( name = "devctl", + version, about = "Local dev environment orchestrator for Productive services" )] struct Cli { @@ -23,8 +24,12 @@ enum Commands { /// Start services Start { - /// Comma-separated list of services (Docker) or single service (local) - services: String, + /// Comma-separated list of services, or omit when using --preset + services: Option, + + /// Use a named preset from devctl.toml + #[arg(long)] + preset: Option, /// Run in Docker container #[arg(long, conflicts_with_all = ["local"])] @@ -111,20 +116,29 @@ fn main() { Commands::Status => commands::status::run(&cfg, &root), Commands::Start { services, + preset, docker, local, dir, bg, } => { - if docker { - let svc_list: Vec = - services.split(',').map(|s| s.trim().to_string()).collect(); - commands::start::docker(&cfg, &root, &svc_list) - } else if local { - commands::local::start(&cfg, &root, &services, dir.as_deref(), bg) + if let Some(preset_name) = preset { + commands::preset::run(&cfg, &root, &preset_name) + } else if let Some(services) = services { + if docker { + let svc_list: Vec = + services.split(',').map(|s| s.trim().to_string()).collect(); + commands::start::docker(&cfg, &root, &svc_list) + } else if local { + commands::local::start(&cfg, &root, &services, dir.as_deref(), bg) + } else { + Err(devctl::error::Error::Other( + "Specify --docker or --local mode.".into(), + )) + } } else { Err(devctl::error::Error::Other( - "Specify --docker or --local mode.".into(), + "Specify services or --preset.".into(), )) } } From dbdca4507d5654ba7dacece9af731b928337b245 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Wed, 25 Mar 2026 23:59:23 +0100 Subject: [PATCH 26/33] Fix local mode: CI=true for pnpm, non-fatal nvm init - Set CI=true in shell_cmd to suppress pnpm interactive prompts (same as Docker mode) - nvm init no longer breaks the command chain if nvm isn't installed (uses semicolons + `true` fallback instead of &&) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/local.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/devctl/src/commands/local.rs b/crates/devctl/src/commands/local.rs index aad79d0..a37a132 100644 --- a/crates/devctl/src/commands/local.rs +++ b/crates/devctl/src/commands/local.rs @@ -18,6 +18,9 @@ fn shell_cmd( ) -> String { let mut parts = Vec::new(); + // Suppress interactive prompts (pnpm, corepack, etc.) + parts.push("export CI=true".to_string()); + // Per-service env vars from devctl.toml (shared + local-specific) for (key, val) in env { parts.push(format!("export {}={}", key, shell_escape(val))); @@ -30,7 +33,7 @@ fn shell_cmd( // nvm init for .node-version/.nvmrc detection if svc_dir.join(".node-version").exists() || svc_dir.join(".nvmrc").exists() { - parts.push("export NVM_DIR=\"$HOME/.nvm\" && [ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\" 2>/dev/null".to_string()); + parts.push("export NVM_DIR=\"${NVM_DIR:-$HOME/.nvm}\"; [ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\" 2>/dev/null; true".to_string()); } parts.push(format!("cd {}", svc_dir.display())); From 362e4c77404e56dcefd5509abb118540d6ebe848 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Thu, 26 Mar 2026 10:54:36 +0100 Subject: [PATCH 27/33] Fix clippy collapsible_if for CI --- crates/devctl/src/docker.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/devctl/src/docker.rs b/crates/devctl/src/docker.rs index 91e1e71..6e10a18 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/devctl/src/docker.rs @@ -185,11 +185,11 @@ pub fn generate_compose( for (key, val) in svc.env.iter().chain(svc.env_docker.iter()) { service_env_lines.push(format!(" - {}={}", key, val)); } - if let Some(companion) = &svc.companion { - if let Some(comp) = config.services.get(companion) { - for (key, val) in comp.env.iter().chain(comp.env_docker.iter()) { - service_env_lines.push(format!(" - {}={}", key, val)); - } + if let Some(companion) = &svc.companion + && let Some(comp) = config.services.get(companion) + { + for (key, val) in comp.env.iter().chain(comp.env_docker.iter()) { + service_env_lines.push(format!(" - {}={}", key, val)); } } } From 0f9ce64f2b3ed1401a3ea8ce55512de538534206 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Thu, 26 Mar 2026 15:15:15 +0100 Subject: [PATCH 28/33] Add local requirements checking to devctl doctor Doctor now shows a per-service readiness matrix (LOCAL/DOCKER columns) with a detailed issues section below. Each service in devctl.toml can declare local hard dependencies via `requires = ["ruby", "node", ...]`. Runtime checks (ruby, node, python3) auto-detect the version manager (rbenv/rvm, nvm/fnm/volta, pyenv) and verify the required version from .ruby-version/.node-version/.python-version is installed. `n` is not supported as a Node version manager (single active version limitation). Companion services (e.g. sidekiq) show as "(companion of api)" instead of independent checks. Chromium checks for actual binaries in the Puppeteer cache. SSO cache parsing no longer aborts on a single bad file. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/devctl/src/commands/doctor.rs | 157 +++++++++++-- crates/devctl/src/config.rs | 15 ++ crates/devctl/src/health.rs | 331 ++++++++++++++++++++++++++- 3 files changed, 480 insertions(+), 23 deletions(-) diff --git a/crates/devctl/src/commands/doctor.rs b/crates/devctl/src/commands/doctor.rs index ec86898..90a2e27 100644 --- a/crates/devctl/src/commands/doctor.rs +++ b/crates/devctl/src/commands/doctor.rs @@ -6,6 +6,14 @@ use crate::config::Config; use crate::error::Result; use crate::health; +struct ServiceResult { + name: String, + companion_of: Option, + docker_ok: bool, + local_ok: bool, + issues: Vec, +} + pub fn run(config: &Config, project_root: &Path) -> Result<()> { let mut issues = 0; @@ -65,34 +73,145 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { } } - // --- Services --- - println!(); - println!("{}", "Services".bold()); - + // --- Services: collect results --- let repos_dir = project_root.join("repos"); + let companions = config.companion_map(); + let mut results: Vec = Vec::new(); for (name, svc) in &config.services { - let mut svc_issues = Vec::new(); + // Companion services — don't check independently + if let Some(parent) = companions.get(name.as_str()) { + results.push(ServiceResult { + name: name.clone(), + companion_of: Some(parent.clone()), + docker_ok: true, + local_ok: true, + issues: Vec::new(), + }); + continue; + } - // Repo cloned? - if let Some(repo) = &svc.repo { - let repo_path = repos_dir.join(repo); - if !repo_path.exists() { - svc_issues.push("repo not cloned".into()); - } else { - // Secrets present? - for secret in &svc.secrets { - if !repo_path.join(secret).exists() { - svc_issues.push(format!("missing {}", secret)); - } + let repo_path = svc.repo.as_ref().map(|r| repos_dir.join(r)); + let repo_exists = repo_path.as_ref().is_some_and(|p| p.exists()); + + // Repo not cloned — fail both, single issue, skip rest + if repo_path.is_some() && !repo_exists { + results.push(ServiceResult { + name: name.clone(), + companion_of: None, + docker_ok: false, + local_ok: false, + issues: vec!["repo not cloned".into()], + }); + continue; + } + + let mut svc_issues: Vec = Vec::new(); + let mut docker_issues = false; + let mut local_issues = false; + + // Secrets check (affects both docker and local) + if let Some(ref path) = repo_path { + for secret in &svc.secrets { + if !path.join(secret).exists() { + svc_issues.push(format!("missing {}", secret)); + docker_issues = true; + local_issues = true; } } } - if svc_issues.is_empty() { - println!(" {} {}", "✓".green(), name); + // Local requirements check (affects local only) + for req in &svc.requires { + let check_path = if repo_exists { repo_path.as_deref() } else { None }; + let status = health::check_requirement(req, check_path); + if !status.ok { + let msg = format!( + "{} — {}", + req, + status.detail.unwrap_or_else(|| "not found".into()) + ); + svc_issues.push(msg); + local_issues = true; + } + } + + results.push(ServiceResult { + name: name.clone(), + companion_of: None, + docker_ok: !docker_issues, + local_ok: !local_issues, + issues: svc_issues, + }); + } + + // --- Services: render table --- + println!(); + println!("{}", "Services".bold()); + + let max_name_len = results + .iter() + .map(|r| r.name.len()) + .max() + .unwrap_or(7) + .max(7); // minimum "SERVICE" width + + // Header + println!( + " {: = results + .iter() + .filter(|r| !r.issues.is_empty()) + .collect(); + + if !failing.is_empty() { + println!(); + println!("{}", "Issues".bold()); + + for (i, result) in failing.iter().enumerate() { + if i > 0 { + println!(); + } + println!(" {}", result.name); + for issue in &result.issues { + println!(" {} {}", "✗".red(), issue); + } issues += 1; } } diff --git a/crates/devctl/src/config.rs b/crates/devctl/src/config.rs index d4e6b07..43f00fe 100644 --- a/crates/devctl/src/config.rs +++ b/crates/devctl/src/config.rs @@ -65,6 +65,8 @@ pub struct ServiceConfig { #[serde(default)] pub companion: Option, #[serde(default)] + pub requires: Vec, + #[serde(default)] pub init: Vec, #[serde(default)] pub start: Vec, @@ -76,6 +78,19 @@ pub struct ServiceConfig { pub env_local: std::collections::BTreeMap, } +impl Config { + /// Build a map of companion service name → parent service name. + pub fn companion_map(&self) -> BTreeMap { + let mut map = BTreeMap::new(); + for (name, svc) in &self.services { + if let Some(companion) = &svc.companion { + map.insert(companion.clone(), name.clone()); + } + } + map + } +} + /// Walk up from `start` looking for `devctl.toml`. /// Returns (config, project_root) on success. pub fn find_and_load(start: &Path) -> Result<(Config, PathBuf)> { diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index c150f63..21705a2 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -130,16 +130,21 @@ fn sso_session_remaining() -> Option { let mut newest_expiry: Option> = None; let mut newest_mtime = std::time::SystemTime::UNIX_EPOCH; - for entry in std::fs::read_dir(&cache_dir).ok()? { - let entry = entry.ok()?; + let Ok(entries) = std::fs::read_dir(&cache_dir) else { + return None; + }; + for entry in entries { + let Ok(entry) = entry else { continue }; let path = entry.path(); if path.extension().is_some_and(|e| e == "json") { - let content = std::fs::read_to_string(&path).ok()?; + let Ok(content) = std::fs::read_to_string(&path) else { continue }; // Only consider files with an accessToken (SSO session files) if !content.contains("accessToken") { continue; } - let mtime = entry.metadata().ok()?.modified().ok()?; + let Some(mtime) = entry.metadata().ok().and_then(|m| m.modified().ok()) else { + continue; + }; if mtime > newest_mtime { newest_mtime = mtime; // Parse expiresAt from JSON @@ -174,6 +179,324 @@ pub fn format_duration(d: &std::time::Duration) -> String { } } +/// Result of checking a single requirement. +pub struct RequirementStatus { + pub ok: bool, + /// Human-readable detail for the issue line (shown on failure). + pub detail: Option, +} + +/// Check whether a local requirement is satisfied. +pub fn check_requirement(req: &str, repo_path: Option<&std::path::Path>) -> RequirementStatus { + match req { + "ruby" => check_ruby(repo_path), + "node" => check_node(repo_path), + "python3" => check_python(repo_path), + "chromium" => check_chromium(), + _ => check_command(req), + } +} + +// --------------------------------------------------------------------------- +// Ruby +// --------------------------------------------------------------------------- + +fn check_ruby(repo_path: Option<&std::path::Path>) -> RequirementStatus { + let home = dirs::home_dir().unwrap_or_default(); + + let manager = if home.join(".rvm").exists() { + Some("rvm") + } else if command_exists("rbenv") { + Some("rbenv") + } else if command_exists("asdf") && asdf_has_plugin("ruby") { + Some("asdf") + } else { + None + }; + + if manager.is_none() && !command_exists("ruby") { + return fail("no version manager found (install rvm or rbenv)"); + } + + let version_check = + repo_path.and_then(|p| check_runtime_version(p, ".ruby-version", manager, "ruby")); + runtime_result(version_check, manager, manager.is_some() || command_exists("ruby")) +} + +// --------------------------------------------------------------------------- +// Node +// --------------------------------------------------------------------------- + +fn check_node(repo_path: Option<&std::path::Path>) -> RequirementStatus { + let home = dirs::home_dir().unwrap_or_default(); + + // Detect version manager — n is NOT supported + let manager = if home.join(".nvm").exists() || std::env::var("NVM_DIR").is_ok() { + Some("nvm") + } else if command_exists("fnm") { + Some("fnm") + } else if command_exists("volta") { + Some("volta") + } else if command_exists("asdf") && asdf_has_plugin("nodejs") { + Some("asdf") + } else { + None + }; + + // n detected as the only tool → hard fail + if manager.is_none() && command_exists("n") { + return fail("n is not supported (install nvm or fnm for multi-version)"); + } + + if manager.is_none() && !command_exists("node") { + return fail("no version manager found (install nvm or fnm)"); + } + + let version_check = repo_path.and_then(|p| { + check_runtime_version(p, ".node-version", manager, "node") + .or_else(|| check_runtime_version(p, ".nvmrc", manager, "node")) + }); + runtime_result(version_check, manager, manager.is_some() || command_exists("node")) +} + +// --------------------------------------------------------------------------- +// Python +// --------------------------------------------------------------------------- + +fn check_python(repo_path: Option<&std::path::Path>) -> RequirementStatus { + let manager = if command_exists("pyenv") { + Some("pyenv") + } else if command_exists("asdf") && asdf_has_plugin("python") { + Some("asdf") + } else { + None + }; + + if manager.is_none() && !command_exists("python3") { + return fail("not found"); + } + + let version_check = + repo_path.and_then(|p| check_runtime_version(p, ".python-version", manager, "python3")); + runtime_result(version_check, manager, true) +} + +// --------------------------------------------------------------------------- +// Chromium +// --------------------------------------------------------------------------- + +fn check_chromium() -> RequirementStatus { + let home = dirs::home_dir().unwrap_or_default(); + let chrome_dir = home.join(".cache/puppeteer/chrome"); + + // Check for at least one Chrome binary in the Puppeteer cache + if chrome_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&chrome_dir) { + for entry in entries.flatten() { + let sub = entry.path(); + if sub.is_dir() && has_chrome_binary(&sub) { + return RequirementStatus { ok: true, detail: None }; + } + } + } + } + + // Fallback: system chromium + if command_exists("chromium") { + return RequirementStatus { ok: true, detail: None }; + } + + fail("not found (run: npx puppeteer install chrome)") +} + +// --------------------------------------------------------------------------- +// Shared runtime result builder +// --------------------------------------------------------------------------- + +/// Build a RequirementStatus from a version check result. +/// Used by all three runtime checks (ruby, node, python). +fn runtime_result( + version_check: Option<(String, bool)>, + manager: Option<&str>, + fallback_ok: bool, +) -> RequirementStatus { + match version_check { + Some((_version, true)) => RequirementStatus { ok: true, detail: None }, + Some((version, false)) => RequirementStatus { + ok: false, + detail: Some(format!( + "{} not installed ({})", + version, + manager.unwrap_or("no version manager") + )), + }, + None => RequirementStatus { ok: fallback_ok, detail: None }, + } +} + +fn fail(detail: &str) -> RequirementStatus { + RequirementStatus { ok: false, detail: Some(detail.into()) } +} + +/// Check if a Puppeteer chrome version directory contains an actual Chrome binary. +fn has_chrome_binary(version_dir: &std::path::Path) -> bool { + // Structure: /chrome-mac-arm64/Google Chrome for Testing.app/... + // or: /chrome-linux64/chrome + if let Ok(entries) = std::fs::read_dir(version_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + // macOS: look for .app bundle + if let Ok(inner) = std::fs::read_dir(&p) { + for inner_entry in inner.flatten() { + let name = inner_entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.ends_with(".app") || name_str == "chrome" { + return true; + } + } + } + } + } + } + false +} + +// --------------------------------------------------------------------------- +// Generic command check +// --------------------------------------------------------------------------- + +fn check_command(cmd: &str) -> RequirementStatus { + if command_exists(cmd) { + RequirementStatus { ok: true, detail: None } + } else { + fail("not found") + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn command_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +fn asdf_has_plugin(plugin: &str) -> bool { + Command::new("asdf") + .args(["list", plugin]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Read a version file from the repo and check if the version is installed. +/// Returns (wanted_version, is_installed). +fn check_runtime_version( + repo_path: &std::path::Path, + version_filename: &str, + manager: Option<&str>, + runtime: &str, +) -> Option<(String, bool)> { + let wanted = read_version_file(repo_path, version_filename)?; + + let installed = match (runtime, manager) { + // Ruby + ("ruby", Some("rvm")) => { + let home = dirs::home_dir()?; + home.join(".rvm/rubies") + .join(format!("ruby-{}", wanted)) + .exists() + } + ("ruby", Some("rbenv")) => { + let home = dirs::home_dir()?; + home.join(".rbenv/versions").join(&wanted).exists() + } + ("ruby", Some("asdf")) => { + let home = dirs::home_dir()?; + home.join(".asdf/installs/ruby").join(&wanted).exists() + } + + // Node + ("node", Some("nvm")) => { + let nvm_dir = std::env::var("NVM_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".nvm")); + let v = if wanted.starts_with('v') { + wanted.clone() + } else { + format!("v{}", wanted) + }; + nvm_dir.join("versions/node").join(&v).exists() + } + ("node", Some("fnm")) => { + let home = dirs::home_dir().unwrap_or_default(); + let v = if wanted.starts_with('v') { + wanted.clone() + } else { + format!("v{}", wanted) + }; + home.join(".local/share/fnm/node-versions") + .join(&v) + .exists() + || home.join(".fnm/node-versions").join(&v).exists() + } + ("node", Some("volta")) => { + let home = dirs::home_dir().unwrap_or_default(); + let v = wanted.strip_prefix('v').unwrap_or(&wanted); + home.join(".volta/tools/image/node").join(v).exists() + } + ("node", Some("asdf")) => { + let home = dirs::home_dir().unwrap_or_default(); + home.join(".asdf/installs/nodejs").join(&wanted).exists() + } + + // Python + ("python3", Some("pyenv")) => { + let home = dirs::home_dir()?; + home.join(".pyenv/versions").join(&wanted).exists() + } + ("python3", Some("asdf")) => { + let home = dirs::home_dir()?; + home.join(".asdf/installs/python").join(&wanted).exists() + } + + // Fallback: compare active version + _ => check_current_version(runtime, &wanted), + }; + + Some((wanted, installed)) +} + +/// Read a version file, trim whitespace. +fn read_version_file(repo_path: &std::path::Path, filename: &str) -> Option { + let content = std::fs::read_to_string(repo_path.join(filename)).ok()?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +/// Check if the currently active version of a command matches the wanted version. +fn check_current_version(cmd: &str, wanted: &str) -> bool { + let output = Command::new(cmd).arg("--version").output().ok(); + if let Some(output) = output { + let version_str = String::from_utf8_lossy(&output.stdout); + let clean = wanted.strip_prefix('v').unwrap_or(wanted); + version_str.contains(clean) + } else { + false + } +} + /// Get the PID and command of the process listening on a port. /// Returns None if no process is found. pub fn port_owner(port: u16) -> Option<(u32, String)> { From 7a917af3bc0a3282a8866e3f23584254f99deab8 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Fri, 27 Mar 2026 11:13:11 +0100 Subject: [PATCH 29/33] Fix cargo fmt violations in devctl doctor and health modules Co-Authored-By: Claude Sonnet 4.6 --- crates/devctl/src/commands/doctor.rs | 15 +++++---- crates/devctl/src/health.rs | 46 ++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/crates/devctl/src/commands/doctor.rs b/crates/devctl/src/commands/doctor.rs index 90a2e27..e18daf8 100644 --- a/crates/devctl/src/commands/doctor.rs +++ b/crates/devctl/src/commands/doctor.rs @@ -123,7 +123,11 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { // Local requirements check (affects local only) for req in &svc.requires { - let check_path = if repo_exists { repo_path.as_deref() } else { None }; + let check_path = if repo_exists { + repo_path.as_deref() + } else { + None + }; let status = health::check_requirement(req, check_path); if !status.ok { let msg = format!( @@ -188,17 +192,16 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { // "LOCAL " is 8 chars, symbol is 1 visible char, so pad 7 after println!( " {: = results - .iter() - .filter(|r| !r.issues.is_empty()) - .collect(); + let failing: Vec<&ServiceResult> = results.iter().filter(|r| !r.issues.is_empty()).collect(); if !failing.is_empty() { println!(); diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index 21705a2..48f4443 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -137,7 +137,9 @@ fn sso_session_remaining() -> Option { let Ok(entry) = entry else { continue }; let path = entry.path(); if path.extension().is_some_and(|e| e == "json") { - let Ok(content) = std::fs::read_to_string(&path) else { continue }; + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; // Only consider files with an accessToken (SSO session files) if !content.contains("accessToken") { continue; @@ -220,7 +222,11 @@ fn check_ruby(repo_path: Option<&std::path::Path>) -> RequirementStatus { let version_check = repo_path.and_then(|p| check_runtime_version(p, ".ruby-version", manager, "ruby")); - runtime_result(version_check, manager, manager.is_some() || command_exists("ruby")) + runtime_result( + version_check, + manager, + manager.is_some() || command_exists("ruby"), + ) } // --------------------------------------------------------------------------- @@ -256,7 +262,11 @@ fn check_node(repo_path: Option<&std::path::Path>) -> RequirementStatus { check_runtime_version(p, ".node-version", manager, "node") .or_else(|| check_runtime_version(p, ".nvmrc", manager, "node")) }); - runtime_result(version_check, manager, manager.is_some() || command_exists("node")) + runtime_result( + version_check, + manager, + manager.is_some() || command_exists("node"), + ) } // --------------------------------------------------------------------------- @@ -295,7 +305,10 @@ fn check_chromium() -> RequirementStatus { for entry in entries.flatten() { let sub = entry.path(); if sub.is_dir() && has_chrome_binary(&sub) { - return RequirementStatus { ok: true, detail: None }; + return RequirementStatus { + ok: true, + detail: None, + }; } } } @@ -303,7 +316,10 @@ fn check_chromium() -> RequirementStatus { // Fallback: system chromium if command_exists("chromium") { - return RequirementStatus { ok: true, detail: None }; + return RequirementStatus { + ok: true, + detail: None, + }; } fail("not found (run: npx puppeteer install chrome)") @@ -321,7 +337,10 @@ fn runtime_result( fallback_ok: bool, ) -> RequirementStatus { match version_check { - Some((_version, true)) => RequirementStatus { ok: true, detail: None }, + Some((_version, true)) => RequirementStatus { + ok: true, + detail: None, + }, Some((version, false)) => RequirementStatus { ok: false, detail: Some(format!( @@ -330,12 +349,18 @@ fn runtime_result( manager.unwrap_or("no version manager") )), }, - None => RequirementStatus { ok: fallback_ok, detail: None }, + None => RequirementStatus { + ok: fallback_ok, + detail: None, + }, } } fn fail(detail: &str) -> RequirementStatus { - RequirementStatus { ok: false, detail: Some(detail.into()) } + RequirementStatus { + ok: false, + detail: Some(detail.into()), + } } /// Check if a Puppeteer chrome version directory contains an actual Chrome binary. @@ -368,7 +393,10 @@ fn has_chrome_binary(version_dir: &std::path::Path) -> bool { fn check_command(cmd: &str) -> RequirementStatus { if command_exists(cmd) { - RequirementStatus { ok: true, detail: None } + RequirementStatus { + ok: true, + detail: None, + } } else { fail("not found") } From c9892bb3d254eb8ab14cdfb6e86f0a963f7cf702 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Fri, 27 Mar 2026 11:21:55 +0100 Subject: [PATCH 30/33] Fix clippy collapsible_if in health.rs chrome check Co-Authored-By: Claude Sonnet 4.6 --- crates/devctl/src/health.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/devctl/src/health.rs b/crates/devctl/src/health.rs index 48f4443..1a34110 100644 --- a/crates/devctl/src/health.rs +++ b/crates/devctl/src/health.rs @@ -300,16 +300,16 @@ fn check_chromium() -> RequirementStatus { let chrome_dir = home.join(".cache/puppeteer/chrome"); // Check for at least one Chrome binary in the Puppeteer cache - if chrome_dir.is_dir() { - if let Ok(entries) = std::fs::read_dir(&chrome_dir) { - for entry in entries.flatten() { - let sub = entry.path(); - if sub.is_dir() && has_chrome_binary(&sub) { - return RequirementStatus { - ok: true, - detail: None, - }; - } + if chrome_dir.is_dir() + && let Ok(entries) = std::fs::read_dir(&chrome_dir) + { + for entry in entries.flatten() { + let sub = entry.path(); + if sub.is_dir() && has_chrome_binary(&sub) { + return RequirementStatus { + ok: true, + detail: None, + }; } } } From 9538e0e1c25d09bb7ceb8e25178ddea04351feac Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Fri, 27 Mar 2026 11:28:49 +0100 Subject: [PATCH 31/33] Add devctl to publish, install, bump, and release workflows Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/cli-toolbox_publish/SKILL.md | 2 +- .github/workflows/release.yml | 4 ++-- scripts/bump.sh | 2 +- scripts/install.sh | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/skills/cli-toolbox_publish/SKILL.md b/.claude/skills/cli-toolbox_publish/SKILL.md index 9702d52..76593f3 100644 --- a/.claude/skills/cli-toolbox_publish/SKILL.md +++ b/.claude/skills/cli-toolbox_publish/SKILL.md @@ -23,7 +23,7 @@ Parse `$ARGUMENTS` to determine: - **`--install`**: after releases complete, install binaries locally via `scripts/install.sh` - **`--with-skill`**: when installing, also install Claude Code skills (passed through to install.sh) -Valid tool names: `tb-prod`, `tb-sem`, `tb-bug`, `tb-lf` +Valid tool names: `tb-prod`, `tb-sem`, `tb-bug`, `tb-lf`, `devctl` If `--all` is used and no version is specified, read each crate's current version from `crates//Cargo.toml` and suggest a patch bump for each. Ask the user to confirm. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c10692..3b73b70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,13 @@ name: Release on: push: - tags: ["tb-*-v*"] + tags: ["tb-*-v*", "devctl-v*"] workflow_dispatch: inputs: crate: description: "Crate to build (e.g. tb-prod)" type: choice - options: [tb-prod, tb-sem, tb-bug, tb-lf] + options: [tb-prod, tb-sem, tb-bug, tb-lf, devctl] required: true dry_run: description: "Dry run (build but don't create release)" diff --git a/scripts/bump.sh b/scripts/bump.sh index 89fcbdf..90bde00 100755 --- a/scripts/bump.sh +++ b/scripts/bump.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf" +VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf devctl" usage() { echo "Usage: $0 " diff --git a/scripts/install.sh b/scripts/install.sh index ee0e0c4..661705c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -euo pipefail REPO="productiveio/cli-toolbox" -ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf" +ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf devctl" INSTALL_DIR="$HOME/.local/bin" # --- Flags --- From 258c19b8096327de96062621fbd7ef53a8d68607 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Fri, 27 Mar 2026 13:17:56 +0100 Subject: [PATCH 32/33] =?UTF-8?q?Rename=20devctl=20=E2=86=92=20tb-devctl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Crate directory: crates/devctl/ → crates/tb-devctl/ - Package and binary name: devctl → tb-devctl - Config file on disk: devctl.toml → tb-devctl.toml - State/log dir on disk: .devctl/ → .tb-devctl/ - All user-facing messages, help text, generated comments - Tooling: bump.sh, install.sh, release.yml, publish skill Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/cli-toolbox_publish/SKILL.md | 2 +- .github/workflows/release.yml | 4 +- Cargo.lock | 32 ++++---- Cargo.toml | 2 +- crates/{devctl => tb-devctl}/Cargo.toml | 4 +- .../src/commands/doctor.rs | 0 .../src/commands/infra.rs | 2 +- .../src/commands/init.rs | 0 .../src/commands/local.rs | 6 +- .../src/commands/logs.rs | 0 .../{devctl => tb-devctl}/src/commands/mod.rs | 0 .../src/commands/preset.rs | 0 .../src/commands/start.rs | 6 +- .../src/commands/status.rs | 0 .../src/commands/stop.rs | 2 +- crates/{devctl => tb-devctl}/src/config.rs | 10 +-- crates/{devctl => tb-devctl}/src/docker.rs | 2 +- crates/{devctl => tb-devctl}/src/error.rs | 0 crates/{devctl => tb-devctl}/src/health.rs | 0 crates/{devctl => tb-devctl}/src/lib.rs | 0 crates/{devctl => tb-devctl}/src/main.rs | 12 +-- crates/{devctl => tb-devctl}/src/state.rs | 6 +- .../rename-devctl-to-tb-devctl/idea.md | 16 ++++ .../rename-devctl-to-tb-devctl/plan.md | 67 +++++++++++++++++ .../rename-devctl-to-tb-devctl/research.md | 72 ++++++++++++++++++ .../rename-devctl-to-tb-devctl/spec.md | 74 +++++++++++++++++++ .../rename-devctl-to-tb-devctl/status.md | 11 +++ scripts/bump.sh | 2 +- scripts/install.sh | 2 +- 29 files changed, 287 insertions(+), 47 deletions(-) rename crates/{devctl => tb-devctl}/Cargo.toml (92%) rename crates/{devctl => tb-devctl}/src/commands/doctor.rs (100%) rename crates/{devctl => tb-devctl}/src/commands/infra.rs (98%) rename crates/{devctl => tb-devctl}/src/commands/init.rs (100%) rename crates/{devctl => tb-devctl}/src/commands/local.rs (97%) rename crates/{devctl => tb-devctl}/src/commands/logs.rs (100%) rename crates/{devctl => tb-devctl}/src/commands/mod.rs (100%) rename crates/{devctl => tb-devctl}/src/commands/preset.rs (100%) rename crates/{devctl => tb-devctl}/src/commands/start.rs (97%) rename crates/{devctl => tb-devctl}/src/commands/status.rs (100%) rename crates/{devctl => tb-devctl}/src/commands/stop.rs (93%) rename crates/{devctl => tb-devctl}/src/config.rs (91%) rename crates/{devctl => tb-devctl}/src/docker.rs (99%) rename crates/{devctl => tb-devctl}/src/error.rs (100%) rename crates/{devctl => tb-devctl}/src/health.rs (100%) rename crates/{devctl => tb-devctl}/src/lib.rs (100%) rename crates/{devctl => tb-devctl}/src/main.rs (94%) rename crates/{devctl => tb-devctl}/src/state.rs (86%) create mode 100644 output/features/rename-devctl-to-tb-devctl/idea.md create mode 100644 output/features/rename-devctl-to-tb-devctl/plan.md create mode 100644 output/features/rename-devctl-to-tb-devctl/research.md create mode 100644 output/features/rename-devctl-to-tb-devctl/spec.md create mode 100644 output/features/rename-devctl-to-tb-devctl/status.md diff --git a/.claude/skills/cli-toolbox_publish/SKILL.md b/.claude/skills/cli-toolbox_publish/SKILL.md index 76593f3..03a1bcf 100644 --- a/.claude/skills/cli-toolbox_publish/SKILL.md +++ b/.claude/skills/cli-toolbox_publish/SKILL.md @@ -23,7 +23,7 @@ Parse `$ARGUMENTS` to determine: - **`--install`**: after releases complete, install binaries locally via `scripts/install.sh` - **`--with-skill`**: when installing, also install Claude Code skills (passed through to install.sh) -Valid tool names: `tb-prod`, `tb-sem`, `tb-bug`, `tb-lf`, `devctl` +Valid tool names: `tb-prod`, `tb-sem`, `tb-bug`, `tb-lf`, `tb-devctl` If `--all` is used and no version is specified, read each crate's current version from `crates//Cargo.toml` and suggest a patch bump for each. Ask the user to confirm. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b73b70..8be0ddd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,13 @@ name: Release on: push: - tags: ["tb-*-v*", "devctl-v*"] + tags: ["tb-*-v*"] workflow_dispatch: inputs: crate: description: "Crate to build (e.g. tb-prod)" type: choice - options: [tb-prod, tb-sem, tb-bug, tb-lf, devctl] + options: [tb-prod, tb-sem, tb-bug, tb-lf, tb-devctl] required: true dry_run: description: "Dry run (build but don't create release)" diff --git a/Cargo.lock b/Cargo.lock index fb19d23..d56405b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,22 +401,6 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" -[[package]] -name = "devctl" -version = "0.1.0" -dependencies = [ - "chrono", - "clap", - "colored", - "dirs", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.18", - "toml", - "toolbox-core", -] - [[package]] name = "difflib" version = "0.4.0" @@ -1965,6 +1949,22 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "tb-devctl" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "colored", + "dirs", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", + "toolbox-core", +] + [[package]] name = "tb-lf" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 42aaa92..96673fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ tb-lf = { path = "crates/tb-lf" } tb-sem = { path = "crates/tb-sem" } tb-prod = { path = "crates/tb-prod" } tb-bug = { path = "crates/tb-bug" } -devctl = { path = "crates/devctl" } +tb-devctl = { path = "crates/tb-devctl" } # Dev/test (also in workspace.dependencies so crates can inherit them) assert_cmd = "2" diff --git a/crates/devctl/Cargo.toml b/crates/tb-devctl/Cargo.toml similarity index 92% rename from crates/devctl/Cargo.toml rename to crates/tb-devctl/Cargo.toml index 6ad6620..959b0c2 100644 --- a/crates/devctl/Cargo.toml +++ b/crates/tb-devctl/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "devctl" +name = "tb-devctl" version = "0.1.0" edition = "2024" description = "Local dev environment orchestrator for Productive services" @@ -8,7 +8,7 @@ license.workspace = true repository.workspace = true [[bin]] -name = "devctl" +name = "tb-devctl" path = "src/main.rs" [lib] diff --git a/crates/devctl/src/commands/doctor.rs b/crates/tb-devctl/src/commands/doctor.rs similarity index 100% rename from crates/devctl/src/commands/doctor.rs rename to crates/tb-devctl/src/commands/doctor.rs diff --git a/crates/devctl/src/commands/infra.rs b/crates/tb-devctl/src/commands/infra.rs similarity index 98% rename from crates/devctl/src/commands/infra.rs rename to crates/tb-devctl/src/commands/infra.rs index 246ec0d..0119a16 100644 --- a/crates/devctl/src/commands/infra.rs +++ b/crates/tb-devctl/src/commands/infra.rs @@ -91,7 +91,7 @@ pub fn status(config: &Config, project_root: &Path) -> Result<()> { println!("{}", "Infrastructure is running.".green()); } else { println!("{}", "Infrastructure is not running.".red()); - println!(" Start with: devctl infra up"); + println!(" Start with: tb-devctl infra up"); return Ok(()); } diff --git a/crates/devctl/src/commands/init.rs b/crates/tb-devctl/src/commands/init.rs similarity index 100% rename from crates/devctl/src/commands/init.rs rename to crates/tb-devctl/src/commands/init.rs diff --git a/crates/devctl/src/commands/local.rs b/crates/tb-devctl/src/commands/local.rs similarity index 97% rename from crates/devctl/src/commands/local.rs rename to crates/tb-devctl/src/commands/local.rs index a37a132..d4447bd 100644 --- a/crates/devctl/src/commands/local.rs +++ b/crates/tb-devctl/src/commands/local.rs @@ -21,7 +21,7 @@ fn shell_cmd( // Suppress interactive prompts (pnpm, corepack, etc.) parts.push("export CI=true".to_string()); - // Per-service env vars from devctl.toml (shared + local-specific) + // Per-service env vars from tb-devctl.toml (shared + local-specific) for (key, val) in env { parts.push(format!("export {}={}", key, shell_escape(val))); } @@ -107,7 +107,7 @@ pub fn start( for secret in &svc.secrets { if !svc_dir.join(secret).exists() { return Err(Error::Config(format!( - "Missing secret: {}/{}. Run `devctl init {}` first.", + "Missing secret: {}/{}. Run `tb-devctl init {}` first.", svc_dir.display(), secret, service @@ -174,7 +174,7 @@ pub fn start( if background { // Background mode: redirect output to log file - let log_dir = project_root.join(".devctl/logs"); + let log_dir = project_root.join(".tb-devctl/logs"); std::fs::create_dir_all(&log_dir)?; let log_file = log_dir.join(format!("{}.log", service)); let log = std::fs::File::create(&log_file)?; diff --git a/crates/devctl/src/commands/logs.rs b/crates/tb-devctl/src/commands/logs.rs similarity index 100% rename from crates/devctl/src/commands/logs.rs rename to crates/tb-devctl/src/commands/logs.rs diff --git a/crates/devctl/src/commands/mod.rs b/crates/tb-devctl/src/commands/mod.rs similarity index 100% rename from crates/devctl/src/commands/mod.rs rename to crates/tb-devctl/src/commands/mod.rs diff --git a/crates/devctl/src/commands/preset.rs b/crates/tb-devctl/src/commands/preset.rs similarity index 100% rename from crates/devctl/src/commands/preset.rs rename to crates/tb-devctl/src/commands/preset.rs diff --git a/crates/devctl/src/commands/start.rs b/crates/tb-devctl/src/commands/start.rs similarity index 97% rename from crates/devctl/src/commands/start.rs rename to crates/tb-devctl/src/commands/start.rs index 6395d90..5f071ce 100644 --- a/crates/devctl/src/commands/start.rs +++ b/crates/tb-devctl/src/commands/start.rs @@ -20,7 +20,7 @@ pub fn docker(config: &Config, project_root: &Path, services: &[String]) -> Resu for svc in services { if !config.services.contains_key(svc) { return Err(Error::Config(format!( - "Unknown service: '{}'. Check devctl.toml.", + "Unknown service: '{}'. Check tb-devctl.toml.", svc ))); } @@ -110,7 +110,7 @@ pub fn docker(config: &Config, project_root: &Path, services: &[String]) -> Resu eprintln!("{}", m); } return Err(Error::Other( - "Pull secrets before starting. See devctl.toml init steps.".into(), + "Pull secrets before starting. See tb-devctl.toml init steps.".into(), )); } @@ -193,7 +193,7 @@ pub fn docker(config: &Config, project_root: &Path, services: &[String]) -> Resu } println!(); println!("Branch switch: cd repos/ && git checkout "); - println!("Then: devctl stop && devctl start --docker"); + println!("Then: tb-devctl stop && tb-devctl start --docker"); Ok(()) } diff --git a/crates/devctl/src/commands/status.rs b/crates/tb-devctl/src/commands/status.rs similarity index 100% rename from crates/devctl/src/commands/status.rs rename to crates/tb-devctl/src/commands/status.rs diff --git a/crates/devctl/src/commands/stop.rs b/crates/tb-devctl/src/commands/stop.rs similarity index 93% rename from crates/devctl/src/commands/stop.rs rename to crates/tb-devctl/src/commands/stop.rs index 2fcc375..613d7b5 100644 --- a/crates/devctl/src/commands/stop.rs +++ b/crates/tb-devctl/src/commands/stop.rs @@ -29,7 +29,7 @@ pub fn run(config: &Config, project_root: &Path) -> Result<()> { pub fn restart_service(config: &Config, service: &str) -> Result<()> { if !docker::container_is_running(config) { return Err(Error::Other( - "Dev container is not running. Start with: devctl start --docker".into(), + "Dev container is not running. Start with: tb-devctl start --docker".into(), )); } diff --git a/crates/devctl/src/config.rs b/crates/tb-devctl/src/config.rs similarity index 91% rename from crates/devctl/src/config.rs rename to crates/tb-devctl/src/config.rs index 43f00fe..de08d92 100644 --- a/crates/devctl/src/config.rs +++ b/crates/tb-devctl/src/config.rs @@ -91,13 +91,13 @@ impl Config { } } -/// Walk up from `start` looking for `devctl.toml`. +/// Walk up from `start` looking for `tb-devctl.toml`. /// Returns (config, project_root) on success. pub fn find_and_load(start: &Path) -> Result<(Config, PathBuf)> { let config_path = find_config_file(start)?; let project_root = config_path .parent() - .ok_or_else(|| Error::Config("devctl.toml has no parent directory".into()))? + .ok_or_else(|| Error::Config("tb-devctl.toml has no parent directory".into()))? .to_path_buf(); let content = std::fs::read_to_string(&config_path)?; @@ -105,17 +105,17 @@ pub fn find_and_load(start: &Path) -> Result<(Config, PathBuf)> { Ok((config, project_root)) } -/// Walk up the directory tree to find `devctl.toml`. +/// Walk up the directory tree to find `tb-devctl.toml`. fn find_config_file(start: &Path) -> Result { let mut dir = start.to_path_buf(); loop { - let candidate = dir.join("devctl.toml"); + let candidate = dir.join("tb-devctl.toml"); if candidate.exists() { return Ok(candidate); } if !dir.pop() { return Err(Error::Config( - "devctl.toml not found (searched up from current directory)".into(), + "tb-devctl.toml not found (searched up from current directory)".into(), )); } } diff --git a/crates/devctl/src/docker.rs b/crates/tb-devctl/src/docker.rs similarity index 99% rename from crates/devctl/src/docker.rs rename to crates/tb-devctl/src/docker.rs index 6e10a18..63c854a 100644 --- a/crates/devctl/src/docker.rs +++ b/crates/tb-devctl/src/docker.rs @@ -207,7 +207,7 @@ pub fn generate_compose( let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); let mut content = format!( - r#"# Generated by devctl — do not edit manually + r#"# Generated by tb-devctl — do not edit manually services: workspace: image: productive-dev:base diff --git a/crates/devctl/src/error.rs b/crates/tb-devctl/src/error.rs similarity index 100% rename from crates/devctl/src/error.rs rename to crates/tb-devctl/src/error.rs diff --git a/crates/devctl/src/health.rs b/crates/tb-devctl/src/health.rs similarity index 100% rename from crates/devctl/src/health.rs rename to crates/tb-devctl/src/health.rs diff --git a/crates/devctl/src/lib.rs b/crates/tb-devctl/src/lib.rs similarity index 100% rename from crates/devctl/src/lib.rs rename to crates/tb-devctl/src/lib.rs diff --git a/crates/devctl/src/main.rs b/crates/tb-devctl/src/main.rs similarity index 94% rename from crates/devctl/src/main.rs rename to crates/tb-devctl/src/main.rs index 6e00eac..47fd7ea 100644 --- a/crates/devctl/src/main.rs +++ b/crates/tb-devctl/src/main.rs @@ -3,12 +3,12 @@ use std::env; use clap::Parser; use colored::Colorize; -use devctl::commands; -use devctl::config; +use tb_devctl::commands; +use tb_devctl::config; #[derive(Parser)] #[command( - name = "devctl", + name = "tb-devctl", version, about = "Local dev environment orchestrator for Productive services" )] @@ -27,7 +27,7 @@ enum Commands { /// Comma-separated list of services, or omit when using --preset services: Option, - /// Use a named preset from devctl.toml + /// Use a named preset from tb-devctl.toml #[arg(long)] preset: Option, @@ -132,12 +132,12 @@ fn main() { } else if local { commands::local::start(&cfg, &root, &services, dir.as_deref(), bg) } else { - Err(devctl::error::Error::Other( + Err(tb_devctl::error::Error::Other( "Specify --docker or --local mode.".into(), )) } } else { - Err(devctl::error::Error::Other( + Err(tb_devctl::error::Error::Other( "Specify services or --preset.".into(), )) } diff --git a/crates/devctl/src/state.rs b/crates/tb-devctl/src/state.rs similarity index 86% rename from crates/devctl/src/state.rs rename to crates/tb-devctl/src/state.rs index 4c7582a..80545d2 100644 --- a/crates/devctl/src/state.rs +++ b/crates/tb-devctl/src/state.rs @@ -22,7 +22,7 @@ pub struct ServiceState { } impl State { - /// Load state from `.devctl/state.json` under the project root. + /// Load state from `.tb-devctl/state.json` under the project root. /// Returns empty state if file doesn't exist. pub fn load(project_root: &Path) -> Result { let path = state_path(project_root); @@ -34,7 +34,7 @@ impl State { Ok(state) } - /// Save state to `.devctl/state.json` under the project root. + /// Save state to `.tb-devctl/state.json` under the project root. pub fn save(&self, project_root: &Path) -> Result<()> { let path = state_path(project_root); if let Some(parent) = path.parent() { @@ -47,5 +47,5 @@ impl State { } fn state_path(project_root: &Path) -> PathBuf { - project_root.join(".devctl").join("state.json") + project_root.join(".tb-devctl").join("state.json") } diff --git a/output/features/rename-devctl-to-tb-devctl/idea.md b/output/features/rename-devctl-to-tb-devctl/idea.md new file mode 100644 index 0000000..7cf3d6e --- /dev/null +++ b/output/features/rename-devctl-to-tb-devctl/idea.md @@ -0,0 +1,16 @@ +# Idea: Rename devctl → tb-devctl + +Rename the `devctl` tool to `tb-devctl` everywhere — as if it was always named that way. +The goal is consistency with the other cli-toolbox binaries (`tb-prod`, `tb-sem`, `tb-bug`, `tb-lf`). + +**In scope:** +- Binary name: `tb-devctl` (user types `tb-devctl start`) +- Crate package name: `tb-devctl` +- Crate directory: `crates/tb-devctl/` +- Config file: `tb-devctl.toml` (currently `devctl.toml`) +- State directory: `.tb-devctl/` (currently `.devctl/`) +- All user-facing strings, help text, generated comments, error messages +- Tooling: bump.sh, install.sh, release.yml, publish skill + +**Out of scope:** +- Nothing — treat this as a full clean rename from the start diff --git a/output/features/rename-devctl-to-tb-devctl/plan.md b/output/features/rename-devctl-to-tb-devctl/plan.md new file mode 100644 index 0000000..364110a --- /dev/null +++ b/output/features/rename-devctl-to-tb-devctl/plan.md @@ -0,0 +1,67 @@ +# Plan: Rename devctl → tb-devctl + +Two branches, dva repo-a. Redoslijed: prvo cli-toolbox (binary), pa work (config/skill). + +--- + +## Branch 1: `productiveio/cli-toolbox` — `feature/devctl` + +### Task 1: Rename crate directory +- `git mv crates/devctl crates/tb-devctl` + +### Task 2: Update Cargo.toml files +- `crates/tb-devctl/Cargo.toml`: `name = "devctl"` → `"tb-devctl"` (package i [[bin]]) +- `Cargo.toml` (root): `devctl = { path = "crates/devctl" }` → `tb-devctl = { path = "crates/tb-devctl" }` + +### Task 3: Update Rust source — imports i clap name +- `src/main.rs`: `use devctl::` → `use tb_devctl::` (×4), `name = "devctl"` → `"tb-devctl"` + +### Task 4: Update runtime paths — config file + state dir +- `src/config.rs`: sve `devctl.toml` → `tb-devctl.toml` +- `src/state.rs`: `.devctl/` → `.tb-devctl/` +- `src/commands/local.rs`: `.devctl/logs` → `.tb-devctl/logs` + +### Task 5: Update user-facing messages +- `src/commands/local.rs`: `devctl init` → `tb-devctl init` +- `src/commands/start.rs`: `devctl stop/start` → `tb-devctl stop/start`, `devctl.toml` → `tb-devctl.toml` +- `src/commands/stop.rs`: `devctl start` → `tb-devctl start` +- `src/commands/infra.rs`: `devctl infra up` → `tb-devctl infra up` +- `src/docker.rs`: `Generated by devctl` → `Generated by tb-devctl` + +### Task 6: Update tooling + CI +- `scripts/bump.sh`: `devctl` → `tb-devctl` u VALID_TOOLS +- `scripts/install.sh`: `devctl` → `tb-devctl` u ALL_TOOLS +- `.github/workflows/release.yml`: ukloniti `"devctl-v*"` iz tag patterna (tb-devctl-v* već odgovara tb-*-v*), `devctl` → `tb-devctl` u dropdown opcijama +- `.claude/skills/cli-toolbox_publish/SKILL.md`: `devctl` → `tb-devctl` u valid tool names + +### Task 7: Verify + cargo check +- `cargo check --workspace` — mora proći +- `cargo fmt --check` — mora proći +- `cargo clippy --workspace -- -D warnings` — mora proći + +--- + +## Branch 2: `productive/work` — `feature/devctl-docker-improvements` + +### Task 8: Rename config file +- `git mv devctl.toml tb-devctl.toml` + +### Task 9: Update skill +- `git mv .claude/skills/devctl .claude/skills/tb-devctl` +- Unutar `SKILL.md`: sve `devctl` reference → `tb-devctl` + +### Task 10: Update knowledge +- `git mv knowledge/devctl-migration.md knowledge/tb-devctl-migration.md` +- Unutar datoteke: reference na `devctl` → `tb-devctl` + +### Task 11: Update .gitignore (ako postoji entry za .devctl/) +- `.devctl/` → `.tb-devctl/` + +--- + +## Napomene + +- Tasks 1–7 idu zajedno u jedan commit na cli-toolbox (`feature/devctl`) +- Tasks 8–11 idu zajedno u jedan commit na work (`feature/devctl-docker-improvements`) +- Oba PR-a trebaju biti mergana zajedno (ili work prvi, jer sadrži config koji binary čita) +- `Cargo.lock` se regenerira automatski — ne editati ručno diff --git a/output/features/rename-devctl-to-tb-devctl/research.md b/output/features/rename-devctl-to-tb-devctl/research.md new file mode 100644 index 0000000..2496706 --- /dev/null +++ b/output/features/rename-devctl-to-tb-devctl/research.md @@ -0,0 +1,72 @@ +# Research: Rename devctl → tb-devctl + +## Scope: Two linked PRs + +This rename spans two repositories/branches: + +1. **`productiveio/cli-toolbox`** — branch `feature/devctl` — the binary itself +2. **`productive/work`** — branch `feature/devctl-docker-improvements` — config, skill, knowledge + +Both branches have open draft PRs and must be updated together. + +--- + +## Full occurrence inventory + +### cli-toolbox — `crates/devctl/` (the binary) + +| File | Line | Occurrence | Context | +|------|------|-----------|---------| +| `crates/devctl/Cargo.toml` | 2 | `name = "devctl"` | Package name | +| `crates/devctl/Cargo.toml` | 11 | `name = "devctl"` | [[bin]] name (what user types) | +| `Cargo.toml` (root) | 47 | `devctl = { path = "crates/devctl" }` | Workspace dep key + path | +| `src/main.rs` | 6,7 | `use devctl::` | Rust imports (×2) | +| `src/main.rs` | 11 | `name = "devctl"` | clap #[command] display name | +| `src/main.rs` | 135,140 | `devctl::error::Error::Other` | Fully-qualified type (×2) | +| `src/main.rs` | 30 | `devctl.toml` | Doc comment | +| `src/config.rs` | 94,100,108,112,118 | `devctl.toml` | Config filename on disk + messages | +| `src/state.rs` | 25,37,50 | `.devctl/` | State dir on disk + comments | +| `src/commands/local.rs` | 24,110,177 | `devctl.toml`, `devctl init`, `.devctl/logs` | Comments + user messages | +| `src/commands/start.rs` | 23,113,196 | `devctl.toml`, `devctl stop/start` | User-facing messages | +| `src/commands/stop.rs` | 32 | `devctl start` | User-facing message | +| `src/commands/infra.rs` | 94 | `devctl infra up` | User-facing message | +| `src/docker.rs` | 210 | `Generated by devctl` | Written into generated docker-compose.yml | +| `.github/workflows/release.yml` | 5,11 | `devctl-v*`, `devctl` | Tag trigger + dropdown option | +| `scripts/bump.sh` | 4 | `devctl` | VALID_TOOLS list | +| `scripts/install.sh` | 5 | `devctl` | ALL_TOOLS list | +| `.claude/skills/cli-toolbox_publish/SKILL.md` | 26 | `devctl` | Valid tool names | +| `Cargo.lock` | 405 | `name = "devctl"` | Auto-regenerated, no manual edit | + +**Directory rename:** `crates/devctl/` → `crates/tb-devctl/` + +### productive/work — config + skill + knowledge + +| File | What changes | +|------|-------------| +| `devctl.toml` | Rename file to `tb-devctl.toml` | +| `.claude/skills/devctl/SKILL.md` | Rename dir to `tb-devctl/`, update all `devctl` references inside | +| `knowledge/devctl-migration.md` | Rename to `tb-devctl-migration.md`, update references inside | +| `.gitignore` | Any `.devctl/` entry → `.tb-devctl/` | + +### Runtime artifacts (on-disk paths written by the binary) + +| Current | New | +|---------|-----| +| `devctl.toml` | `tb-devctl.toml` | +| `.devctl/state.json` | `.tb-devctl/state.json` | +| `.devctl/logs/.log` | `.tb-devctl/logs/.log` | + +--- + +## Clean files (no devctl references) + +`lib.rs`, `error.rs`, `health.rs`, `commands/mod.rs`, `commands/doctor.rs`, +`commands/init.rs`, `commands/status.rs`, `commands/preset.rs`, `commands/logs.rs`, `ci.yml` + +--- + +## Notes + +- `release.yml` tag pattern `devctl-v*` can be dropped entirely — `tb-devctl-v*` already matches `tb-*-v*` +- `Cargo.lock` regenerates automatically after Cargo.toml changes, no manual edit +- Both PRs need to land together (or work PR first since it contains config the binary reads) diff --git a/output/features/rename-devctl-to-tb-devctl/spec.md b/output/features/rename-devctl-to-tb-devctl/spec.md new file mode 100644 index 0000000..5d51058 --- /dev/null +++ b/output/features/rename-devctl-to-tb-devctl/spec.md @@ -0,0 +1,74 @@ +# Rename devctl → tb-devctl + +**Status:** Ready +**Last updated:** 2026-03-27 + +## Summary + +Rename the `devctl` tool to `tb-devctl` everywhere — binary name, crate, config file, state directory, user-facing strings, and all tooling. The goal is consistency with the other cli-toolbox binaries (`tb-prod`, `tb-sem`, `tb-bug`, `tb-lf`). The tool has not been released yet, so this is a clean rename with no migration concerns. + +## Requirements + +1. The binary the user runs is `tb-devctl` (e.g. `tb-devctl start`, `tb-devctl doctor`) +2. The Rust crate is named `tb-devctl`, directory is `crates/tb-devctl/` +3. The config file the tool looks for is `tb-devctl.toml` +4. The state/log directory the tool writes to is `.tb-devctl/` +5. All user-facing output (help text, error messages, hints) refers to `tb-devctl`, not `devctl` +6. The generated docker-compose header says `Generated by tb-devctl` +7. Release tooling (`bump.sh`, `install.sh`, `release.yml`) works with `tb-devctl` +8. The publish skill recognises `tb-devctl` as a valid tool name +9. In the `work` repo: config file is `tb-devctl.toml`, skill dir is `.claude/skills/tb-devctl/`, knowledge file is `knowledge/tb-devctl-migration.md` +10. `cargo fmt`, `cargo clippy`, `cargo test` all pass after the rename + +## Non-goals + +- No migration code — the tool is unreleased, no existing `devctl.toml` or `.devctl/` dirs to support +- No deprecation shims or aliases (`devctl` as a fallback command name) +- No changes to the tool's behaviour, commands, or flags — pure rename only + +## Technical approach + +### cli-toolbox branch (`feature/devctl`) + +Changes grouped into one commit: + +| What | From | To | +|------|------|----| +| Crate directory | `crates/devctl/` | `crates/tb-devctl/` | +| Package name | `name = "devctl"` | `name = "tb-devctl"` | +| Binary name (`[[bin]]`) | `name = "devctl"` | `name = "tb-devctl"` | +| Workspace dep | `devctl = { path = "crates/devctl" }` | `tb-devctl = { path = "crates/tb-devctl" }` | +| Rust imports | `use devctl::` | `use tb_devctl::` | +| clap display name | `#[command(name = "devctl")]` | `#[command(name = "tb-devctl")]` | +| Fully-qualified types | `devctl::error::Error` | `tb_devctl::error::Error` | +| Config filename on disk | `devctl.toml` | `tb-devctl.toml` | +| State dir on disk | `.devctl/` | `.tb-devctl/` | +| Log dir on disk | `.devctl/logs/` | `.tb-devctl/logs/` | +| User-facing messages | `devctl ` | `tb-devctl ` | +| Generated compose header | `Generated by devctl` | `Generated by tb-devctl` | +| `scripts/bump.sh` VALID_TOOLS | `devctl` | `tb-devctl` | +| `scripts/install.sh` ALL_TOOLS | `devctl` | `tb-devctl` | +| `release.yml` tag trigger | `"devctl-v*"` (remove — redundant) | drop entry, `tb-devctl-v*` matches existing `tb-*-v*` | +| `release.yml` dropdown | `devctl` | `tb-devctl` | +| publish SKILL.md | `devctl` | `tb-devctl` | + +`Cargo.lock` regenerates automatically — not touched manually. + +### work branch (`feature/devctl-docker-improvements`) + +Changes grouped into one commit: + +| What | From | To | +|------|------|----| +| Config file | `devctl.toml` | `tb-devctl.toml` | +| Skill directory | `.claude/skills/devctl/` | `.claude/skills/tb-devctl/` | +| Skill content | all `devctl` refs | `tb-devctl` | +| Knowledge file | `knowledge/devctl-migration.md` | `knowledge/tb-devctl-migration.md` | +| Knowledge content | all `devctl` refs | `tb-devctl` | +| `.gitignore` | `.devctl/` (if present) | `.tb-devctl/` | + +### Key decisions + +- **Both PRs land together** — work PR can go first (config the binary reads), cli-toolbox after. Or simultaneously since the tool is unreleased. +- **`devctl-v*` tag pattern dropped** — `tb-devctl-v*` already matches `tb-*-v*`, so the extra pattern in `release.yml` is redundant and removed rather than updated. +- **No migration code** — clean rename only, tool is unreleased. diff --git a/output/features/rename-devctl-to-tb-devctl/status.md b/output/features/rename-devctl-to-tb-devctl/status.md new file mode 100644 index 0000000..a31f4f5 --- /dev/null +++ b/output/features/rename-devctl-to-tb-devctl/status.md @@ -0,0 +1,11 @@ +# Feature: rename-devctl-to-tb-devctl +**Current phase:** Research +**Started:** 2026-03-27 + +## Progress +- [x] Idea — full clean rename, devctl.toml → tb-devctl.toml, .devctl/ → .tb-devctl/, binary, crate, dir +- [x] Research — kompletni inventory, dva repo-a (cli-toolbox + work) +- [x] Spec — Ready, no open questions +- [ ] Plan +- [ ] Execute +- [ ] QA diff --git a/scripts/bump.sh b/scripts/bump.sh index 90bde00..57116f0 100755 --- a/scripts/bump.sh +++ b/scripts/bump.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf devctl" +VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-devctl" usage() { echo "Usage: $0 " diff --git a/scripts/install.sh b/scripts/install.sh index 661705c..1132b61 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -euo pipefail REPO="productiveio/cli-toolbox" -ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf devctl" +ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-devctl" INSTALL_DIR="$HOME/.local/bin" # --- Flags --- From d0da71fdb79d51c178b28b67977020a56b2d2393 Mon Sep 17 00:00:00 2001 From: Tibor Rogulja Date: Fri, 27 Mar 2026 13:38:47 +0100 Subject: [PATCH 33/33] QA: remove output/ from tracking, add to .gitignore Feature planning docs don't belong in this repo. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .../rename-devctl-to-tb-devctl/idea.md | 16 ---- .../rename-devctl-to-tb-devctl/plan.md | 67 ----------------- .../rename-devctl-to-tb-devctl/research.md | 72 ------------------ .../rename-devctl-to-tb-devctl/spec.md | 74 ------------------- .../rename-devctl-to-tb-devctl/status.md | 11 --- 6 files changed, 1 insertion(+), 240 deletions(-) delete mode 100644 output/features/rename-devctl-to-tb-devctl/idea.md delete mode 100644 output/features/rename-devctl-to-tb-devctl/plan.md delete mode 100644 output/features/rename-devctl-to-tb-devctl/research.md delete mode 100644 output/features/rename-devctl-to-tb-devctl/spec.md delete mode 100644 output/features/rename-devctl-to-tb-devctl/status.md diff --git a/.gitignore b/.gitignore index 7f7a5ed..7a39ecd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ secrets.toml benchmark-results/ docs/ +output/ diff --git a/output/features/rename-devctl-to-tb-devctl/idea.md b/output/features/rename-devctl-to-tb-devctl/idea.md deleted file mode 100644 index 7cf3d6e..0000000 --- a/output/features/rename-devctl-to-tb-devctl/idea.md +++ /dev/null @@ -1,16 +0,0 @@ -# Idea: Rename devctl → tb-devctl - -Rename the `devctl` tool to `tb-devctl` everywhere — as if it was always named that way. -The goal is consistency with the other cli-toolbox binaries (`tb-prod`, `tb-sem`, `tb-bug`, `tb-lf`). - -**In scope:** -- Binary name: `tb-devctl` (user types `tb-devctl start`) -- Crate package name: `tb-devctl` -- Crate directory: `crates/tb-devctl/` -- Config file: `tb-devctl.toml` (currently `devctl.toml`) -- State directory: `.tb-devctl/` (currently `.devctl/`) -- All user-facing strings, help text, generated comments, error messages -- Tooling: bump.sh, install.sh, release.yml, publish skill - -**Out of scope:** -- Nothing — treat this as a full clean rename from the start diff --git a/output/features/rename-devctl-to-tb-devctl/plan.md b/output/features/rename-devctl-to-tb-devctl/plan.md deleted file mode 100644 index 364110a..0000000 --- a/output/features/rename-devctl-to-tb-devctl/plan.md +++ /dev/null @@ -1,67 +0,0 @@ -# Plan: Rename devctl → tb-devctl - -Two branches, dva repo-a. Redoslijed: prvo cli-toolbox (binary), pa work (config/skill). - ---- - -## Branch 1: `productiveio/cli-toolbox` — `feature/devctl` - -### Task 1: Rename crate directory -- `git mv crates/devctl crates/tb-devctl` - -### Task 2: Update Cargo.toml files -- `crates/tb-devctl/Cargo.toml`: `name = "devctl"` → `"tb-devctl"` (package i [[bin]]) -- `Cargo.toml` (root): `devctl = { path = "crates/devctl" }` → `tb-devctl = { path = "crates/tb-devctl" }` - -### Task 3: Update Rust source — imports i clap name -- `src/main.rs`: `use devctl::` → `use tb_devctl::` (×4), `name = "devctl"` → `"tb-devctl"` - -### Task 4: Update runtime paths — config file + state dir -- `src/config.rs`: sve `devctl.toml` → `tb-devctl.toml` -- `src/state.rs`: `.devctl/` → `.tb-devctl/` -- `src/commands/local.rs`: `.devctl/logs` → `.tb-devctl/logs` - -### Task 5: Update user-facing messages -- `src/commands/local.rs`: `devctl init` → `tb-devctl init` -- `src/commands/start.rs`: `devctl stop/start` → `tb-devctl stop/start`, `devctl.toml` → `tb-devctl.toml` -- `src/commands/stop.rs`: `devctl start` → `tb-devctl start` -- `src/commands/infra.rs`: `devctl infra up` → `tb-devctl infra up` -- `src/docker.rs`: `Generated by devctl` → `Generated by tb-devctl` - -### Task 6: Update tooling + CI -- `scripts/bump.sh`: `devctl` → `tb-devctl` u VALID_TOOLS -- `scripts/install.sh`: `devctl` → `tb-devctl` u ALL_TOOLS -- `.github/workflows/release.yml`: ukloniti `"devctl-v*"` iz tag patterna (tb-devctl-v* već odgovara tb-*-v*), `devctl` → `tb-devctl` u dropdown opcijama -- `.claude/skills/cli-toolbox_publish/SKILL.md`: `devctl` → `tb-devctl` u valid tool names - -### Task 7: Verify + cargo check -- `cargo check --workspace` — mora proći -- `cargo fmt --check` — mora proći -- `cargo clippy --workspace -- -D warnings` — mora proći - ---- - -## Branch 2: `productive/work` — `feature/devctl-docker-improvements` - -### Task 8: Rename config file -- `git mv devctl.toml tb-devctl.toml` - -### Task 9: Update skill -- `git mv .claude/skills/devctl .claude/skills/tb-devctl` -- Unutar `SKILL.md`: sve `devctl` reference → `tb-devctl` - -### Task 10: Update knowledge -- `git mv knowledge/devctl-migration.md knowledge/tb-devctl-migration.md` -- Unutar datoteke: reference na `devctl` → `tb-devctl` - -### Task 11: Update .gitignore (ako postoji entry za .devctl/) -- `.devctl/` → `.tb-devctl/` - ---- - -## Napomene - -- Tasks 1–7 idu zajedno u jedan commit na cli-toolbox (`feature/devctl`) -- Tasks 8–11 idu zajedno u jedan commit na work (`feature/devctl-docker-improvements`) -- Oba PR-a trebaju biti mergana zajedno (ili work prvi, jer sadrži config koji binary čita) -- `Cargo.lock` se regenerira automatski — ne editati ručno diff --git a/output/features/rename-devctl-to-tb-devctl/research.md b/output/features/rename-devctl-to-tb-devctl/research.md deleted file mode 100644 index 2496706..0000000 --- a/output/features/rename-devctl-to-tb-devctl/research.md +++ /dev/null @@ -1,72 +0,0 @@ -# Research: Rename devctl → tb-devctl - -## Scope: Two linked PRs - -This rename spans two repositories/branches: - -1. **`productiveio/cli-toolbox`** — branch `feature/devctl` — the binary itself -2. **`productive/work`** — branch `feature/devctl-docker-improvements` — config, skill, knowledge - -Both branches have open draft PRs and must be updated together. - ---- - -## Full occurrence inventory - -### cli-toolbox — `crates/devctl/` (the binary) - -| File | Line | Occurrence | Context | -|------|------|-----------|---------| -| `crates/devctl/Cargo.toml` | 2 | `name = "devctl"` | Package name | -| `crates/devctl/Cargo.toml` | 11 | `name = "devctl"` | [[bin]] name (what user types) | -| `Cargo.toml` (root) | 47 | `devctl = { path = "crates/devctl" }` | Workspace dep key + path | -| `src/main.rs` | 6,7 | `use devctl::` | Rust imports (×2) | -| `src/main.rs` | 11 | `name = "devctl"` | clap #[command] display name | -| `src/main.rs` | 135,140 | `devctl::error::Error::Other` | Fully-qualified type (×2) | -| `src/main.rs` | 30 | `devctl.toml` | Doc comment | -| `src/config.rs` | 94,100,108,112,118 | `devctl.toml` | Config filename on disk + messages | -| `src/state.rs` | 25,37,50 | `.devctl/` | State dir on disk + comments | -| `src/commands/local.rs` | 24,110,177 | `devctl.toml`, `devctl init`, `.devctl/logs` | Comments + user messages | -| `src/commands/start.rs` | 23,113,196 | `devctl.toml`, `devctl stop/start` | User-facing messages | -| `src/commands/stop.rs` | 32 | `devctl start` | User-facing message | -| `src/commands/infra.rs` | 94 | `devctl infra up` | User-facing message | -| `src/docker.rs` | 210 | `Generated by devctl` | Written into generated docker-compose.yml | -| `.github/workflows/release.yml` | 5,11 | `devctl-v*`, `devctl` | Tag trigger + dropdown option | -| `scripts/bump.sh` | 4 | `devctl` | VALID_TOOLS list | -| `scripts/install.sh` | 5 | `devctl` | ALL_TOOLS list | -| `.claude/skills/cli-toolbox_publish/SKILL.md` | 26 | `devctl` | Valid tool names | -| `Cargo.lock` | 405 | `name = "devctl"` | Auto-regenerated, no manual edit | - -**Directory rename:** `crates/devctl/` → `crates/tb-devctl/` - -### productive/work — config + skill + knowledge - -| File | What changes | -|------|-------------| -| `devctl.toml` | Rename file to `tb-devctl.toml` | -| `.claude/skills/devctl/SKILL.md` | Rename dir to `tb-devctl/`, update all `devctl` references inside | -| `knowledge/devctl-migration.md` | Rename to `tb-devctl-migration.md`, update references inside | -| `.gitignore` | Any `.devctl/` entry → `.tb-devctl/` | - -### Runtime artifacts (on-disk paths written by the binary) - -| Current | New | -|---------|-----| -| `devctl.toml` | `tb-devctl.toml` | -| `.devctl/state.json` | `.tb-devctl/state.json` | -| `.devctl/logs/.log` | `.tb-devctl/logs/.log` | - ---- - -## Clean files (no devctl references) - -`lib.rs`, `error.rs`, `health.rs`, `commands/mod.rs`, `commands/doctor.rs`, -`commands/init.rs`, `commands/status.rs`, `commands/preset.rs`, `commands/logs.rs`, `ci.yml` - ---- - -## Notes - -- `release.yml` tag pattern `devctl-v*` can be dropped entirely — `tb-devctl-v*` already matches `tb-*-v*` -- `Cargo.lock` regenerates automatically after Cargo.toml changes, no manual edit -- Both PRs need to land together (or work PR first since it contains config the binary reads) diff --git a/output/features/rename-devctl-to-tb-devctl/spec.md b/output/features/rename-devctl-to-tb-devctl/spec.md deleted file mode 100644 index 5d51058..0000000 --- a/output/features/rename-devctl-to-tb-devctl/spec.md +++ /dev/null @@ -1,74 +0,0 @@ -# Rename devctl → tb-devctl - -**Status:** Ready -**Last updated:** 2026-03-27 - -## Summary - -Rename the `devctl` tool to `tb-devctl` everywhere — binary name, crate, config file, state directory, user-facing strings, and all tooling. The goal is consistency with the other cli-toolbox binaries (`tb-prod`, `tb-sem`, `tb-bug`, `tb-lf`). The tool has not been released yet, so this is a clean rename with no migration concerns. - -## Requirements - -1. The binary the user runs is `tb-devctl` (e.g. `tb-devctl start`, `tb-devctl doctor`) -2. The Rust crate is named `tb-devctl`, directory is `crates/tb-devctl/` -3. The config file the tool looks for is `tb-devctl.toml` -4. The state/log directory the tool writes to is `.tb-devctl/` -5. All user-facing output (help text, error messages, hints) refers to `tb-devctl`, not `devctl` -6. The generated docker-compose header says `Generated by tb-devctl` -7. Release tooling (`bump.sh`, `install.sh`, `release.yml`) works with `tb-devctl` -8. The publish skill recognises `tb-devctl` as a valid tool name -9. In the `work` repo: config file is `tb-devctl.toml`, skill dir is `.claude/skills/tb-devctl/`, knowledge file is `knowledge/tb-devctl-migration.md` -10. `cargo fmt`, `cargo clippy`, `cargo test` all pass after the rename - -## Non-goals - -- No migration code — the tool is unreleased, no existing `devctl.toml` or `.devctl/` dirs to support -- No deprecation shims or aliases (`devctl` as a fallback command name) -- No changes to the tool's behaviour, commands, or flags — pure rename only - -## Technical approach - -### cli-toolbox branch (`feature/devctl`) - -Changes grouped into one commit: - -| What | From | To | -|------|------|----| -| Crate directory | `crates/devctl/` | `crates/tb-devctl/` | -| Package name | `name = "devctl"` | `name = "tb-devctl"` | -| Binary name (`[[bin]]`) | `name = "devctl"` | `name = "tb-devctl"` | -| Workspace dep | `devctl = { path = "crates/devctl" }` | `tb-devctl = { path = "crates/tb-devctl" }` | -| Rust imports | `use devctl::` | `use tb_devctl::` | -| clap display name | `#[command(name = "devctl")]` | `#[command(name = "tb-devctl")]` | -| Fully-qualified types | `devctl::error::Error` | `tb_devctl::error::Error` | -| Config filename on disk | `devctl.toml` | `tb-devctl.toml` | -| State dir on disk | `.devctl/` | `.tb-devctl/` | -| Log dir on disk | `.devctl/logs/` | `.tb-devctl/logs/` | -| User-facing messages | `devctl ` | `tb-devctl ` | -| Generated compose header | `Generated by devctl` | `Generated by tb-devctl` | -| `scripts/bump.sh` VALID_TOOLS | `devctl` | `tb-devctl` | -| `scripts/install.sh` ALL_TOOLS | `devctl` | `tb-devctl` | -| `release.yml` tag trigger | `"devctl-v*"` (remove — redundant) | drop entry, `tb-devctl-v*` matches existing `tb-*-v*` | -| `release.yml` dropdown | `devctl` | `tb-devctl` | -| publish SKILL.md | `devctl` | `tb-devctl` | - -`Cargo.lock` regenerates automatically — not touched manually. - -### work branch (`feature/devctl-docker-improvements`) - -Changes grouped into one commit: - -| What | From | To | -|------|------|----| -| Config file | `devctl.toml` | `tb-devctl.toml` | -| Skill directory | `.claude/skills/devctl/` | `.claude/skills/tb-devctl/` | -| Skill content | all `devctl` refs | `tb-devctl` | -| Knowledge file | `knowledge/devctl-migration.md` | `knowledge/tb-devctl-migration.md` | -| Knowledge content | all `devctl` refs | `tb-devctl` | -| `.gitignore` | `.devctl/` (if present) | `.tb-devctl/` | - -### Key decisions - -- **Both PRs land together** — work PR can go first (config the binary reads), cli-toolbox after. Or simultaneously since the tool is unreleased. -- **`devctl-v*` tag pattern dropped** — `tb-devctl-v*` already matches `tb-*-v*`, so the extra pattern in `release.yml` is redundant and removed rather than updated. -- **No migration code** — clean rename only, tool is unreleased. diff --git a/output/features/rename-devctl-to-tb-devctl/status.md b/output/features/rename-devctl-to-tb-devctl/status.md deleted file mode 100644 index a31f4f5..0000000 --- a/output/features/rename-devctl-to-tb-devctl/status.md +++ /dev/null @@ -1,11 +0,0 @@ -# Feature: rename-devctl-to-tb-devctl -**Current phase:** Research -**Started:** 2026-03-27 - -## Progress -- [x] Idea — full clean rename, devctl.toml → tb-devctl.toml, .devctl/ → .tb-devctl/, binary, crate, dir -- [x] Research — kompletni inventory, dva repo-a (cli-toolbox + work) -- [x] Spec — Ready, no open questions -- [ ] Plan -- [ ] Execute -- [ ] QA