diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8f736a28..5ffcade990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ - TUI operation activities (`Configuring shell`, `Configuring cachix`, `Loading tasks`, etc.) now nest under their parent activity instead of being forced to the top level. "Configuring cachix" appears as a child of "Configuring shell" (next to "Evaluating shell"), reflecting that cachix setup runs as part of shell configuration. - "Validating lock" is now visible in the TUI by default instead of hidden behind the Debug filter. - Sped up `devenv shell` startup on projects with many cached input paths by batching file watcher registration into a single pathset update and readiness wait, instead of reconciling the pathset once per path. This removes long hangs before `enterShell` on large inputs. +- Added human-facing process URLs via `processes..urls`, `devenv processes endpoints`, and clickable endpoint display in the `devenv up` TUI for browser-facing services and apps. - Fixed port allocation values (`config.processes..ports..value`) resolving to the base `allocate` port in `devenv shell`, `devenv tasks run`, and other commands. When the native process manager is running, port values now match the ports allocated by `devenv up` ([#2710](https://github.com/cachix/devenv/issues/2710)). - Standardized keyboard shortcut notation across `devenv up` TUI and `devenv shell` to use consistent `Ctrl-E` format instead of mixed `^e`/`Ctrl-Alt-E` styles. macOS now shows `Opt` instead of `Alt` ([#2736](https://github.com/cachix/devenv/issues/2736)). - Auto-detect AI coding agents (via `CLAUDECODE`, `OPENCODE_CLIENT`, and `AI_AGENT` environment variables) and enable quiet mode to avoid wasting LLM tokens on TUI progress output. Override with `--verbose`, `--tui`, or set `DEVENV_NO_AI_AGENT=1` to disable detection entirely ([#2723](https://github.com/cachix/devenv/issues/2723)). diff --git a/devenv-activity/src/builders.rs b/devenv-activity/src/builders.rs index c36773a560..2817efe751 100644 --- a/devenv-activity/src/builders.rs +++ b/devenv-activity/src/builders.rs @@ -676,6 +676,7 @@ pub struct ProcessBuilder { command: Option, ports: Vec, ready_probe: Option, + urls: Vec, id: Option, parent: Option>, level: Option, @@ -688,6 +689,7 @@ impl ProcessBuilder { command: None, ports: Vec::new(), ready_probe: None, + urls: Vec::new(), id: None, parent: None, level: None, @@ -709,6 +711,11 @@ impl ProcessBuilder { self } + pub fn urls(mut self, urls: Vec) -> Self { + self.urls = urls; + self + } + pub fn id(mut self, id: u64) -> Self { self.id = Some(id); self @@ -762,6 +769,7 @@ impl ActivityStart for ProcessBuilder { ports: self.ports, ready_probe: self.ready_probe, level, + urls: self.urls, timestamp: Timestamp::now(), })); diff --git a/devenv-activity/src/events.rs b/devenv-activity/src/events.rs index 322a64a9d8..51a775fdcf 100644 --- a/devenv-activity/src/events.rs +++ b/devenv-activity/src/events.rs @@ -320,6 +320,9 @@ pub enum Process { /// Human-readable description of the readiness probe (e.g., "exec: pg_isready", "http: localhost:8080/health") #[serde(default, skip_serializing_if = "Option::is_none")] ready_probe: Option, + /// Human-facing URLs for this process (e.g., ["admin: http://127.0.0.1:15672/"]) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + urls: Vec, #[serde(default)] level: ActivityLevel, timestamp: Timestamp, diff --git a/devenv-processes/src/config.rs b/devenv-processes/src/config.rs index 36f0ff7377..51ef73afa1 100644 --- a/devenv-processes/src/config.rs +++ b/devenv-processes/src/config.rs @@ -59,6 +59,30 @@ pub struct SocketActivationConfig { pub listens: Vec, } +/// Human-facing URL for a process endpoint +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProcessUrl { + #[serde(default = "default_url_scheme")] + pub scheme: String, + #[serde(default = "default_url_host")] + pub host: String, + pub port: u16, + #[serde(default = "default_url_path")] + pub path: String, +} + +fn default_url_scheme() -> String { + "http".to_string() +} + +fn default_url_host() -> String { + "127.0.0.1".to_string() +} + +fn default_url_path() -> String { + "/".to_string() +} + /// Watch configuration for file-based process restarts #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct WatchConfig { @@ -240,6 +264,9 @@ pub struct ProcessConfig { /// Allocated ports for display (e.g., {"http": 8080, "admin": 9000}) #[serde(default)] pub ports: HashMap, + /// Human-facing process URLs/endpoints (e.g., {"admin": {"scheme":"http", ...}}) + #[serde(default)] + pub urls: HashMap, /// Readiness probe configuration #[serde(default)] pub ready: Option, @@ -278,6 +305,7 @@ impl Default for ProcessConfig { env: HashMap::new(), listen: Vec::new(), ports: HashMap::new(), + urls: HashMap::new(), ready: None, restart: RestartConfig::default(), watch: WatchConfig::default(), @@ -286,3 +314,34 @@ impl Default for ProcessConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn process_config_defaults_urls_to_empty() { + let config = ProcessConfig::default(); + assert!(config.urls.is_empty()); + } + + #[test] + fn process_url_deserializes_with_defaults() { + let json = r#"{ + "name": "app", + "exec": "run-app", + "urls": { + "main": { + "port": 8080 + } + } + }"#; + + let config: ProcessConfig = serde_json::from_str(json).expect("deserialize process config"); + let url = config.urls.get("main").expect("main url"); + assert_eq!(url.scheme, "http"); + assert_eq!(url.host, "127.0.0.1"); + assert_eq!(url.port, 8080); + assert_eq!(url.path, "/"); + } +} diff --git a/devenv-processes/src/manager.rs b/devenv-processes/src/manager.rs index dae2175d15..e40f0a2a7f 100644 --- a/devenv-processes/src/manager.rs +++ b/devenv-processes/src/manager.rs @@ -48,6 +48,8 @@ pub enum ApiRequest { Stop { name: String }, /// Query all port allocations from running processes. Ports, + /// Query all human-facing process endpoints from managed processes. + Endpoints, } /// Port allocation info from a running process. @@ -58,6 +60,14 @@ pub struct PortInfo { pub port: u16, } +/// Human-facing endpoint info from a managed process. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EndpointInfo { + pub process_name: String, + pub label: String, + pub url: String, +} + /// Summary information about a managed process. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProcessInfo { @@ -84,6 +94,8 @@ pub enum ApiResponse { Ok, /// All port allocations from managed processes. PortAllocations { ports: Vec }, + /// All human-facing endpoints from managed processes. + Endpoints { endpoints: Vec }, } use watchexec_supervisor::{ @@ -91,7 +103,7 @@ use watchexec_supervisor::{ job::{Job, start_job}, }; -use crate::config::ProcessConfig; +use crate::config::{ProcessConfig, ProcessUrl}; use crate::pid::{self, PidStatus}; use crate::socket_activation::{ProcessSetupWrapper, activation_from_listen}; use crate::{ProcessManager, StartOptions}; @@ -132,6 +144,54 @@ pub struct JobHandle { pub output_readers: Option<(JoinHandle<()>, JoinHandle<()>)>, } +fn render_process_url(url: &ProcessUrl) -> String { + let path = if url.path.is_empty() { + "/".to_string() + } else if url.path.starts_with('/') { + url.path.clone() + } else { + format!("/{}", url.path) + }; + + let host = render_process_url_host(&url.host); + + format!("{}://{}:{}{}", url.scheme, host, url.port, path) +} + +fn render_process_url_host(host: &str) -> String { + if host.starts_with('[') && host.ends_with(']') { + host.to_string() + } else if host.contains(':') { + format!("[{host}]") + } else { + host.to_string() + } +} + +fn render_process_activity_urls(config: &ProcessConfig) -> Vec { + let mut urls: Vec<(String, String)> = config + .urls + .iter() + .map(|(label, url)| (label.clone(), render_process_url(url))) + .collect(); + urls.sort_by(|a, b| a.0.cmp(&b.0)); + urls.into_iter() + .map(|(label, url)| format!("{label}: {url}")) + .collect() +} + +fn collect_endpoints_from_config(process_name: &str, config: &ProcessConfig) -> Vec { + config + .urls + .iter() + .map(|(label, url)| EndpointInfo { + process_name: process_name.to_string(), + label: label.clone(), + url: render_process_url(url), + }) + .collect() +} + /// Lifecycle phase of a managed process. /// /// Shared between the process manager and the task system to avoid @@ -511,6 +571,10 @@ impl NativeProcessManager { let mut builder = Activity::process(&config.name) .command(&config.exec) .ports(ports); + let urls = render_process_activity_urls(config); + if !urls.is_empty() { + builder = builder.urls(urls); + } if let Some(probe_desc) = probe_description(config) { builder = builder.ready_probe(probe_desc); } @@ -1518,6 +1582,20 @@ impl NativeProcessManager { } ApiResponse::PortAllocations { ports } } + Ok(ApiRequest::Endpoints) => { + let procs = manager.processes.read().await; + let mut endpoints = Vec::new(); + for (name, entry) in procs.iter() { + let config = match entry { + ProcessEntry::NotStarted { config, .. } + | ProcessEntry::Stopped { config, .. } + | ProcessEntry::Waiting { config, .. } => config, + ProcessEntry::Active(handle) => &handle.resources.config, + }; + endpoints.extend(collect_endpoints_from_config(name, config)); + } + ApiResponse::Endpoints { endpoints } + } Err(e) => ApiResponse::Error { message: format!("invalid request: {}", e), }, @@ -2005,6 +2083,54 @@ mod tests { } } + #[test] + fn test_render_process_url_normalizes_path() { + let url = ProcessUrl { + scheme: "http".to_string(), + host: "127.0.0.1".to_string(), + port: 8080, + path: "admin".to_string(), + }; + + assert_eq!(render_process_url(&url), "http://127.0.0.1:8080/admin"); + } + + #[test] + fn test_render_process_url_brackets_ipv6_hosts() { + let url = ProcessUrl { + scheme: "http".to_string(), + host: "::1".to_string(), + port: 8080, + path: "/".to_string(), + }; + + assert_eq!(render_process_url(&url), "http://[::1]:8080/"); + } + + #[test] + fn test_collect_endpoints_from_config() { + let mut config = test_config("rabbitmq"); + config.urls.insert( + "admin".to_string(), + ProcessUrl { + scheme: "http".to_string(), + host: "127.0.0.1".to_string(), + port: 15672, + path: "/".to_string(), + }, + ); + + let endpoints = collect_endpoints_from_config("rabbitmq", &config); + assert_eq!( + endpoints, + vec![EndpointInfo { + process_name: "rabbitmq".to_string(), + label: "admin".to_string(), + url: "http://127.0.0.1:15672/".to_string(), + }] + ); + } + #[tokio::test] async fn test_register_waiting_sets_phase() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/devenv-tui/src/model.rs b/devenv-tui/src/model.rs index 8160613237..2e4ae91521 100644 --- a/devenv-tui/src/model.rs +++ b/devenv-tui/src/model.rs @@ -106,6 +106,8 @@ pub struct ProcessActivity { pub ports: Vec, /// Human-readable description of the readiness probe (e.g., "exec: pg_isready") pub ready_probe: Option, + /// Human-facing URLs for this process (e.g., ["admin: http://127.0.0.1:15672/"]) + pub urls: Vec, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -639,6 +641,7 @@ impl ActivityModel { command, ports, ready_probe, + urls, level, .. } => { @@ -646,6 +649,7 @@ impl ActivityModel { status: ProcessStatus::Starting, ports, ready_probe, + urls, }); self.create_activity(id, name, parent, command, variant, level); } @@ -1661,6 +1665,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1713,6 +1718,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1754,6 +1760,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); diff --git a/devenv-tui/src/view.rs b/devenv-tui/src/view.rs index 8ee0578765..cf28220c9d 100644 --- a/devenv-tui/src/view.rs +++ b/devenv-tui/src/view.rs @@ -22,6 +22,54 @@ pub const SUMMARY_BAR_HEIGHT: u16 = 2; /// Map from activity_id to rendered height in lines. pub type ActivityHeights = Ref>; +fn render_process_urls(urls: &[String], depth: usize, terminal_width: u16) -> AnyElement<'static> { + let indent = 2 + (depth * 2); + let mut line_elements = vec![]; + let filler = " ".repeat(terminal_width as usize); + + for entry in urls { + let (label, url) = entry.split_once(": ").unwrap_or(("", entry.as_str())); + let mut contents = vec![]; + if !label.is_empty() { + contents + .push(MixedTextContent::new(format!(" {label}: ")).color(Color::AnsiValue(245))); + } + contents.push( + MixedTextContent::new(url.to_string()) + .color(COLOR_INFO) + .decoration(TextDecoration::Underline), + ); + contents.push(MixedTextContent::new(filler.clone())); + line_elements.push( + element! { + View(height: 1, overflow: Overflow::Hidden) { + MixedText( + contents: contents, + wrap: TextWrap::NoWrap + ) + } + } + .into_any(), + ); + } + + element! { + View( + height: urls.len() as u32, + flex_direction: FlexDirection::Column, + overflow: Overflow::Hidden, + margin_left: indent as u32, + margin_right: 1, + border_style: BorderStyle::Single, + border_edges: Edges::Left, + border_color: Color::AnsiValue(245), + ) { + #(line_elements) + } + } + .into_any() +} + /// Scroll state and the display list the app already computed for this frame. /// /// Passing `display_activities` through avoids re-walking the activity tree @@ -725,24 +773,39 @@ fn ActivityItem(mut hooks: Hooks) -> impl Into> { .render(terminal_width, *depth, prefix); // Show logs: always show LOG_VIEWPORT_SHOW_OUTPUT lines, - // expand when selected, show more when failed + // expand when selected, show more when failed. + // Human-facing URLs are always shown inline for instant clickability. let process_failed = *completed == Some(false); - if logs.is_some() { - let mut component = ExpandedContentComponent::new(logs.as_deref()) - .with_depth(*depth) - .with_empty_message("→ no output yet (press 'e' to expand)"); - if process_failed && *render_context == RenderContext::Final { - component = component.with_max_lines(LOG_VIEWPORT_FAILED); - } else if !is_selected { - component = component.with_max_lines(LOG_VIEWPORT_SHOW_OUTPUT); + let show_urls = !process_data.urls.is_empty(); + if logs.is_some() || show_urls { + let mut elements = vec![main_line]; + let mut total_height = 1usize; + + if show_urls { + elements.push(render_process_urls( + &process_data.urls, + *depth, + terminal_width, + )); + total_height += process_data.urls.len(); } - let mut elements = vec![main_line]; - let log_elements = component.render(); - elements.extend(log_elements); + if logs.is_some() { + let mut component = ExpandedContentComponent::new(logs.as_deref()) + .with_depth(*depth) + .with_empty_message("→ no output yet (press 'e' to expand)"); + if process_failed && *render_context == RenderContext::Final { + component = component.with_max_lines(LOG_VIEWPORT_FAILED); + } else if !is_selected { + component = component.with_max_lines(LOG_VIEWPORT_SHOW_OUTPUT); + } + + let log_elements = component.render(); + total_height += component.calculate_height(); + elements.extend(log_elements); + } - let log_viewport_height = component.calculate_height(); - let total_height = (1 + log_viewport_height).min(50) as u32; + let total_height = total_height.min(50) as u32; return element! { View(height: total_height, flex_direction: FlexDirection::Column) { #(elements) @@ -1381,6 +1444,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1440,6 +1504,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1508,6 +1573,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1563,6 +1629,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); diff --git a/devenv-tui/tests/tui_tests.rs b/devenv-tui/tests/tui_tests.rs index 5727e246e2..942e6f7de5 100644 --- a/devenv-tui/tests/tui_tests.rs +++ b/devenv-tui/tests/tui_tests.rs @@ -1654,6 +1654,7 @@ fn test_overflow_clips_top_keeps_bottom() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1712,6 +1713,7 @@ fn test_hide_stopped_processes_filters_manually_stopped_processes_but_keeps_fail command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1781,6 +1783,7 @@ fn test_previous_hide_stopped_processes_coverage_used_completed_success() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1823,6 +1826,7 @@ fn test_toggle_hide_stopped_processes_clears_hidden_selection() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1864,6 +1868,7 @@ fn test_hide_stopped_processes_removes_hidden_processes_from_selection() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1880,6 +1885,7 @@ fn test_hide_stopped_processes_removes_hidden_processes_from_selection() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -2047,6 +2053,7 @@ fn test_processes_alphabetical_order() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -2057,6 +2064,7 @@ fn test_processes_alphabetical_order() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -2077,6 +2085,7 @@ fn test_processes_alphabetical_order() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -2103,6 +2112,30 @@ fn test_processes_alphabetical_order() { insta::assert_snapshot!(output); } +#[test] +fn test_process_urls_are_always_visible_inline() { + let (mut model, ui_state) = new_test_model(); + + model.apply_activity_event(ActivityEvent::Process(Process::Start { + id: 1, + name: "rabbitmq".to_string(), + parent: None, + command: None, + ports: vec!["management:15672".to_string()], + ready_probe: None, + urls: vec!["admin: http://127.0.0.1:15672/".to_string()], + level: ActivityLevel::Info, + timestamp: Timestamp::now(), + })); + + let output = render_to_string(&model, &ui_state); + assert!( + output.contains("admin: http://127.0.0.1:15672/"), + "Process should show URLs inline without selection.\nFull output:\n{}", + output + ); +} + /// Test cachix push alongside other activities (build + push concurrent). #[test] fn test_cachix_push_alongside_build() { diff --git a/devenv.nix b/devenv.nix index b254d2db39..bb0027464e 100644 --- a/devenv.nix +++ b/devenv.nix @@ -139,7 +139,6 @@ in execIfModified = [ "Cargo.lock" ]; }; - git-hooks.package = pkgs.prek; git-hooks.excludes = [ "Cargo.nix" ]; git-hooks.hooks = { nixpkgs-fmt.enable = true; diff --git a/devenv/src/cli.rs b/devenv/src/cli.rs index 8fc5b51c29..cfbcf4e772 100644 --- a/devenv/src/cli.rs +++ b/devenv/src/cli.rs @@ -968,6 +968,9 @@ pub enum ProcessesCommand { stderr: bool, }, + #[command(about = "Show human-facing endpoints for managed processes.")] + Endpoints {}, + #[command(about = "Restart a process.")] Restart { #[arg(help = "Name of the process.")] @@ -1327,4 +1330,16 @@ mod tests { _ => panic!("expected `devenv processes up` command"), } } + + #[test] + fn processes_endpoints_parses() { + let cli = Cli::parse_from(["devenv", "processes", "endpoints"]); + + match cli.command { + Commands::Processes { + command: ProcessesCommand::Endpoints {}, + } => {} + _ => panic!("expected `devenv processes endpoints` command"), + } + } } diff --git a/devenv/src/devenv/mod.rs b/devenv/src/devenv/mod.rs index d49db80e51..0dc3c0c710 100644 --- a/devenv/src/devenv/mod.rs +++ b/devenv/src/devenv/mod.rs @@ -19,6 +19,7 @@ use devenv_core::{ ports::PortAllocator, settings::{CacheSettings, InputOverrides, NixSettings, SecretSettings, ShellSettings}, }; +use devenv_processes::manager::EndpointInfo; use devenv_shell::dialect::{BashDialect, RcfileContext, ShellDialect, create_dialect}; use miette::{IntoDiagnostic, Result, WrapErr, bail, miette}; use nix::sys::signal; @@ -1958,6 +1959,19 @@ impl Devenv { } } + pub async fn processes_endpoints(&self) -> Result { + match self + .native_api_request(&processes::ApiRequest::Endpoints) + .await? + { + processes::ApiResponse::Endpoints { endpoints } => { + Ok(format_process_endpoints(endpoints)) + } + processes::ApiResponse::Error { message } => bail!("{}", message), + other => bail!("Unexpected response: {:?}", other), + } + } + async fn expect_ok_response(&self, request: &processes::ApiRequest) -> Result<()> { match self.native_api_request(request).await? { processes::ApiResponse::Ok => Ok(()), @@ -2534,6 +2548,26 @@ fn resolve_secretspec_into( .map_err(|_| miette!("Secretspec resolved already set")) } +fn format_process_endpoints(mut endpoints: Vec) -> String { + if endpoints.is_empty() { + return "No endpoints found.\n".to_string(); + } + + endpoints.sort_by(|a, b| { + a.process_name + .cmp(&b.process_name) + .then_with(|| a.label.cmp(&b.label)) + }); + + let mut output = String::new(); + for endpoint in endpoints { + output.push_str(&format!( + "{}/{} {}\n", + endpoint.process_name, endpoint.label, endpoint.url + )); + } + output +} #[cfg(test)] mod tests { use super::*; @@ -2636,6 +2670,36 @@ mod tests { assert_eq!(build_children, vec!["myapp:setup"]); } + #[test] + fn test_format_process_endpoints_flat_output() { + let output = format_process_endpoints(vec![ + EndpointInfo { + process_name: "rabbitmq".to_string(), + label: "admin".to_string(), + url: "http://127.0.0.1:15672/".to_string(), + }, + EndpointInfo { + process_name: "mailpit".to_string(), + label: "ui".to_string(), + url: "http://127.0.0.1:8025/".to_string(), + }, + EndpointInfo { + process_name: "rabbitmq".to_string(), + label: "metrics".to_string(), + url: "http://127.0.0.1:15692/metrics".to_string(), + }, + ]); + + assert_eq!( + output, + concat!( + "mailpit/ui http://127.0.0.1:8025/\n", + "rabbitmq/admin http://127.0.0.1:15672/\n", + "rabbitmq/metrics http://127.0.0.1:15692/metrics\n", + ) + ); + } + #[test] fn test_parse_cli_task_inputs_empty() { let result = parse_cli_task_inputs(&[], None).unwrap(); diff --git a/devenv/src/main.rs b/devenv/src/main.rs index 705f21b8c5..e38b2f0db4 100644 --- a/devenv/src/main.rs +++ b/devenv/src/main.rs @@ -974,6 +974,12 @@ async fn dispatch_command( let output = devenv.processes_logs(&name, lines, stdout, stderr).await?; Ok(CommandResult::Print(output)) } + Commands::Processes { + command: ProcessesCommand::Endpoints {}, + } => { + let output = devenv.processes_endpoints().await?; + Ok(CommandResult::Print(output)) + } Commands::Processes { command: ProcessesCommand::Restart { name }, } => { diff --git a/docs/src/processes.md b/docs/src/processes.md index 753572bd69..cb6050d7c1 100644 --- a/docs/src/processes.md +++ b/docs/src/processes.md @@ -39,6 +39,14 @@ $ devenv processes wait --timeout 120 The default timeout is 120 seconds. +To list human-facing process endpoints (useful for clicking into admin UIs, mock servers, or your app): + +```shell-session +$ devenv processes endpoints +rabbitmq/admin http://127.0.0.1:15672/ +mailpit/ui http://127.0.0.1:8025/ +``` + ## Dependencies Processes can depend on other processes and tasks using `after` and `before`: @@ -333,6 +341,29 @@ The CLI flags take precedence over the config value. This is useful when you need deterministic port assignments and want to be notified of conflicts rather than having them silently resolved. When a port conflict is detected in strict mode, devenv will show an error message including which process is currently using the port. +### Human-facing URLs + +Use `processes..urls` to declare clickable endpoints for a process. This is meant for browser-facing endpoints like admin UIs, dashboards, mock servers, or your application under development. It's particularly helpful when using dynamically allocated process ports. + +```nix title="devenv.nix" +{ config, ... }: + +{ + processes.app = { + ports.http.allocate = 8080; + exec = "my-dev-server --port ${toString config.processes.app.ports.http.value}"; + urls.main = { + scheme = "http"; + host = "127.0.0.1"; + port = config.processes.app.ports.http.value; + path = "/"; + }; + }; +} +``` + +These URLs are shown by `devenv processes endpoints`, and browser-facing built-in services can surface them directly in the `devenv up` TUI. + ## Alternative Process Managers By default, devenv uses its native process manager. You can switch to alternative implementations: diff --git a/docs/src/reference/options.md b/docs/src/reference/options.md index af6530cfe1..fc9c3cc75c 100644 --- a/docs/src/reference/options.md +++ b/docs/src/reference/options.md @@ -25550,6 +25550,176 @@ true +## processes.\.urls + + + +Human-facing URLs for this process. + +These are intended for clickable local endpoints such as admin UIs, +dashboards, or application dev servers, and can reference dynamically +allocated process ports via ` config.processes..ports..value `. + + + +*Type:* +attribute set of (submodule) + + + +*Default:* + +```nix +{ } +``` + + + +*Example:* + +```nix +{ + app = { + scheme = "http"; + host = "127.0.0.1"; + port = config.processes.myapp.ports.http.value; + path = "/"; + }; + admin = { + scheme = "http"; + host = "127.0.0.1"; + port = config.processes.myapp.ports.admin.value; + path = "/admin"; + }; +} + +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/processes.nix](https://github.com/cachix/devenv/blob/main/src/modules/processes.nix) + + + +## processes.\.urls.\.host + + + +URL host. + + + +*Type:* +string + + + +*Default:* + +```nix +"127.0.0.1" +``` + + + +*Example:* + +```nix +"localhost" +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/processes.nix](https://github.com/cachix/devenv/blob/main/src/modules/processes.nix) + + + +## processes.\.urls.\.path + + + +URL path. + + + +*Type:* +string + + + +*Default:* + +```nix +"/" +``` + + + +*Example:* + +```nix +"/admin" +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/processes.nix](https://github.com/cachix/devenv/blob/main/src/modules/processes.nix) + + + +## processes.\.urls.\.port + + + +URL port. + + + +*Type:* +16 bit unsigned integer; between 0 and 65535 (both inclusive) + + + +*Example:* + +```nix +8080 +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/processes.nix](https://github.com/cachix/devenv/blob/main/src/modules/processes.nix) + + + +## processes.\.urls.\.scheme + + + +URL scheme. + + + +*Type:* +string + + + +*Default:* + +```nix +"http" +``` + + + +*Example:* + +```nix +"https" +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/processes.nix](https://github.com/cachix/devenv/blob/main/src/modules/processes.nix) + + + ## processes.\.watch @@ -37980,6 +38150,126 @@ true +## tasks.\.process.urls.\.host + + + +URL host. + + + +*Type:* +string + + + +*Default:* + +```nix +"127.0.0.1" +``` + + + +*Example:* + +```nix +"localhost" +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.process.urls.\.path + + + +URL path. + + + +*Type:* +string + + + +*Default:* + +```nix +"/" +``` + + + +*Example:* + +```nix +"/admin" +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.process.urls.\.port + + + +URL port. + + + +*Type:* +16 bit unsigned integer; between 0 and 65535 (both inclusive) + + + +*Example:* + +```nix +8080 +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.process.urls.\.scheme + + + +URL scheme. + + + +*Type:* +string + + + +*Default:* + +```nix +"http" +``` + + + +*Example:* + +```nix +"https" +``` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + ## tasks.\.process.watch diff --git a/src/modules/lib/url.nix b/src/modules/lib/url.nix new file mode 100644 index 0000000000..b41281ea0d --- /dev/null +++ b/src/modules/lib/url.nix @@ -0,0 +1,31 @@ +{ lib }: +lib.types.submodule { + options = { + scheme = lib.mkOption { + type = lib.types.str; + default = "http"; + description = "URL scheme."; + example = "https"; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "URL host."; + example = "localhost"; + }; + + port = lib.mkOption { + type = lib.types.port; + description = "URL port."; + example = 8080; + }; + + path = lib.mkOption { + type = lib.types.str; + default = "/"; + description = "URL path."; + example = "/admin"; + }; + }; +} diff --git a/src/modules/processes.nix b/src/modules/processes.nix index 6f64eac74c..679079e268 100644 --- a/src/modules/processes.nix +++ b/src/modules/processes.nix @@ -3,6 +3,7 @@ let types = lib.types; listenType = import ./lib/listen.nix { inherit lib; }; readyType = import ./lib/ready.nix { inherit lib; }; + urlType = import ./lib/url.nix { inherit lib; }; # Get primops from _module.args (set via specialArgs in bootstrapLib.nix) # Use default empty attrset if not available (e.g., when evaluated without devenv CLI) @@ -81,6 +82,34 @@ let ''; }; + urls = lib.mkOption { + type = types.attrsOf urlType; + default = { }; + description = '' + Human-facing URLs for this process. + + These are intended for clickable local endpoints such as admin UIs, + dashboards, or application dev servers, and can reference dynamically + allocated process ports via `config.processes..ports..value`. + ''; + example = lib.literalExpression '' + { + app = { + scheme = "http"; + host = "127.0.0.1"; + port = config.processes.myapp.ports.http.value; + path = "/"; + }; + admin = { + scheme = "http"; + host = "127.0.0.1"; + port = config.processes.myapp.ports.admin.value; + path = "/admin"; + }; + } + ''; + }; + env = lib.mkOption { type = types.attrsOf types.str; default = { }; @@ -485,6 +514,7 @@ in restart = process.restart; listen = process.listen; ports = lib.mapAttrs (_: portCfg: portCfg.value) process.ports; + urls = process.urls; watch = process.watch // { paths = map toString process.watch.paths; }; diff --git a/src/modules/services/adminer.nix b/src/modules/services/adminer.nix index 8cac59684d..98f83c93d8 100644 --- a/src/modules/services/adminer.nix +++ b/src/modules/services/adminer.nix @@ -11,6 +11,10 @@ let basePort = parsePort cfg.listen; allocatedPort = config.processes.adminer.ports.main.value; host = parseHost cfg.listen; + urlHost = + if host == "" || host == "0.0.0.0" || host == "::" + then "127.0.0.1" + else host; listenAddr = "${host}:${toString allocatedPort}"; in { @@ -37,6 +41,12 @@ in config = lib.mkIf cfg.enable { processes.adminer.ports.main.allocate = basePort; + processes.adminer.urls.ui = { + scheme = "http"; + host = urlHost; + port = allocatedPort; + path = "/"; + }; processes.adminer.exec = "exec ${config.languages.php.package}/bin/php ${lib.optionalString config.services.mysql.enable "-dmysqli.default_socket=${config.env.MYSQL_UNIX_PORT}"} -S ${listenAddr} -t ${cfg.package} ${cfg.package}/adminer.php"; }; } diff --git a/src/modules/services/mailhog.nix b/src/modules/services/mailhog.nix index e1d7c51cd7..23c582ba5e 100644 --- a/src/modules/services/mailhog.nix +++ b/src/modules/services/mailhog.nix @@ -15,6 +15,10 @@ let apiHost = parseHost cfg.apiListenAddress; uiHost = parseHost cfg.uiListenAddress; smtpHost = parseHost cfg.smtpListenAddress; + uiUrlHost = + if uiHost == "" || uiHost == "0.0.0.0" || uiHost == "::" + then "127.0.0.1" + else uiHost; apiAddr = "${apiHost}:${toString allocatedApiPort}"; uiAddr = "${uiHost}:${toString allocatedApiPort}"; # UI shares port with API smtpAddr = "${smtpHost}:${toString allocatedSmtpPort}"; @@ -61,6 +65,12 @@ in config = lib.mkIf cfg.enable { processes.mailhog.ports.api.allocate = baseApiPort; processes.mailhog.ports.smtp.allocate = baseSmtpPort; + processes.mailhog.urls.ui = { + scheme = "http"; + host = uiUrlHost; + port = allocatedApiPort; + path = "/"; + }; processes.mailhog.exec = "exec ${cfg.package}/bin/MailHog -api-bind-addr ${apiAddr} -ui-bind-addr ${uiAddr} -smtp-bind-addr ${smtpAddr} ${lib.concatStringsSep " " cfg.additionalArgs}"; }; } diff --git a/src/modules/services/mailpit.nix b/src/modules/services/mailpit.nix index 416d528b18..2feb6f93f6 100644 --- a/src/modules/services/mailpit.nix +++ b/src/modules/services/mailpit.nix @@ -14,6 +14,10 @@ let allocatedSmtpPort = config.processes.mailpit.ports.smtp.value; uiHost = parseHost cfg.uiListenAddress; smtpHost = parseHost cfg.smtpListenAddress; + uiUrlHost = + if uiHost == "" || uiHost == "0.0.0.0" || uiHost == "::" + then "127.0.0.1" + else uiHost; uiAddr = "${uiHost}:${toString allocatedUiPort}"; smtpAddr = "${smtpHost}:${toString allocatedSmtpPort}"; in @@ -61,6 +65,12 @@ in processes.mailpit.ports.ui.allocate = baseUiPort; processes.mailpit.ports.smtp.allocate = baseSmtpPort; + processes.mailpit.urls.ui = { + scheme = "http"; + host = uiUrlHost; + port = allocatedUiPort; + path = "/"; + }; processes.mailpit.exec = "exec ${cfg.package}/bin/mailpit --db-file $DEVENV_STATE/mailpit/db.sqlite3 --listen ${lib.escapeShellArg uiAddr} --smtp ${lib.escapeShellArg smtpAddr} ${lib.escapeShellArgs cfg.additionalArgs}"; }; } diff --git a/src/modules/services/minio.nix b/src/modules/services/minio.nix index cd017a58c5..7ff1e9cd4d 100644 --- a/src/modules/services/minio.nix +++ b/src/modules/services/minio.nix @@ -15,6 +15,10 @@ let allocatedConsolePort = config.processes.minio.ports.console.value; apiHost = parseHost cfg.listenAddress; consoleHost = parseHost cfg.consoleAddress; + consoleUrlHost = + if consoleHost == "" || consoleHost == "0.0.0.0" || consoleHost == "::" + then "127.0.0.1" + else consoleHost; apiAddr = "${apiHost}:${toString allocatedApiPort}"; consoleAddr = "${consoleHost}:${toString allocatedConsolePort}"; @@ -157,6 +161,14 @@ in processes.minio.ports.api.allocate = baseApiPort; processes.minio.ports.console.allocate = baseConsolePort; + processes.minio.urls = lib.optionalAttrs cfg.browser { + console = { + scheme = "http"; + host = consoleUrlHost; + port = allocatedConsolePort; + path = "/"; + }; + }; processes.minio.exec = "${startScript}"; env.MINIO_PORT = allocatedApiPort; diff --git a/src/modules/services/rabbitmq.nix b/src/modules/services/rabbitmq.nix index 602ecbc418..460fead8cc 100644 --- a/src/modules/services/rabbitmq.nix +++ b/src/modules/services/rabbitmq.nix @@ -14,6 +14,10 @@ let allocatedManagementPort = config.processes.rabbitmq.ports.management.value; allocatedDistributionPort = config.processes.rabbitmq.ports.distribution.value; allocatedEpmdPort = config.processes.rabbitmq.ports.epmd.value; + urlHost = + if cfg.listenAddress == "" || cfg.listenAddress == "0.0.0.0" || cfg.listenAddress == "::" + then "127.0.0.1" + else cfg.listenAddress; config_file_content = lib.generators.toKeyValue { } cfg.configItems; config_file = pkgs.writeText "rabbitmq.conf" config_file_content; @@ -195,6 +199,14 @@ in ports.management.allocate = baseManagementPort; ports.distribution.allocate = basePort + 20000; ports.epmd.allocate = 4369; + urls = optionalAttrs cfg.managementPlugin.enable { + admin = { + scheme = "http"; + host = urlHost; + port = allocatedManagementPort; + path = "/"; + }; + }; exec = "exec ${cfg.package}/bin/rabbitmq-server"; ready = { diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index ab4a6a5717..fcf0f0243d 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -3,6 +3,7 @@ let types = lib.types; listenType = import ./lib/listen.nix { inherit lib; }; readyType = import ./lib/ready.nix { inherit lib; }; + urlType = import ./lib/url.nix { inherit lib; }; # Attempt to evaluate devenv-tasks using the exact nixpkgs used by the root devenv flake. # If the locked input is not what we expect, fall back to evaluating with the user's nixpkgs. @@ -288,6 +289,16 @@ let ''; }; + urls = lib.mkOption { + internal = true; + type = types.attrsOf urlType; + default = { }; + description = '' + Human-facing URLs for this process (label -> URL parts), + which can reference dynamically allocated process ports. + ''; + }; + listen = lib.mkOption { type = types.listOf listenType; default = [ ]; diff --git a/tests/processes-endpoints/.test-config.yml b/tests/processes-endpoints/.test-config.yml new file mode 100644 index 0000000000..13c16cea3a --- /dev/null +++ b/tests/processes-endpoints/.test-config.yml @@ -0,0 +1 @@ +use_shell: false diff --git a/tests/processes-endpoints/.test.sh b/tests/processes-endpoints/.test.sh new file mode 100755 index 0000000000..6ce8f88dac --- /dev/null +++ b/tests/processes-endpoints/.test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +cleanup() { + devenv processes down >/dev/null 2>&1 || true +} +trap cleanup EXIT + +devenv up -d +devenv processes wait + +output=$(devenv processes endpoints) +printf '%s\n' "$output" + +if ! grep -Fq 'app-1' <<<"$output"; then + echo "✗ Missing app-1 in endpoints output" + exit 1 +fi + +if ! grep -Fq 'app-2' <<<"$output"; then + echo "✗ Missing app-2 in endpoints output" + exit 1 +fi + +mapfile -t urls < <(grep -Eo 'http://127\.0\.0\.1:[0-9]+/' <<<"$output" | awk '!seen[$0]++') +if [ "${#urls[@]}" -ne 2 ]; then + echo "✗ Expected exactly 2 distinct URLs, got ${#urls[@]}" + exit 1 +fi + +if [ "${urls[0]}" = "${urls[1]}" ]; then + echo "✗ Expected distinct dynamically allocated URLs" + exit 1 +fi + +for url in "${urls[@]}"; do + status=$(curl -s -o /dev/null -w '%{http_code}' "$url") + if [ "$status" != "200" ]; then + echo "✗ Expected HTTP 200 from $url, got $status" + exit 1 + fi +done + +echo "✓ devenv processes endpoints shows resolved dynamic URLs" diff --git a/tests/processes-endpoints/devenv.nix b/tests/processes-endpoints/devenv.nix new file mode 100644 index 0000000000..2a6d4521f7 --- /dev/null +++ b/tests/processes-endpoints/devenv.nix @@ -0,0 +1,39 @@ +{ config, lib, pkgs, ... }: + +{ + process.manager.implementation = "native"; + + processes.app-1 = { + ports.http.allocate = 18080; + urls.web = { + scheme = "http"; + host = "127.0.0.1"; + port = config.processes.app-1.ports.http.value; + path = "/"; + }; + exec = '' + exec ${lib.getExe pkgs.python3} -m http.server ${toString config.processes.app-1.ports.http.value} + ''; + ready.http.get = { + port = config.processes.app-1.ports.http.value; + path = "/"; + }; + }; + + processes.app-2 = { + ports.http.allocate = 18080; + urls.web = { + scheme = "http"; + host = "127.0.0.1"; + port = config.processes.app-2.ports.http.value; + path = "/"; + }; + exec = '' + exec ${lib.getExe pkgs.python3} -m http.server ${toString config.processes.app-2.ports.http.value} + ''; + ready.http.get = { + port = config.processes.app-2.ports.http.value; + path = "/"; + }; + }; +}