From f2f71159162bac61966980738b9181d7b8e4d084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Sun, 12 Apr 2026 23:15:34 +0200 Subject: [PATCH 01/20] Add support in model for processes..urls --- devenv-processes/src/config.rs | 59 ++++++++++++++++++++++++++++++++++ src/modules/lib/url.nix | 31 ++++++++++++++++++ src/modules/processes.nix | 30 +++++++++++++++++ src/modules/tasks.nix | 11 +++++++ 4 files changed, 131 insertions(+) create mode 100644 src/modules/lib/url.nix 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/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/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 = [ ]; From 46f5bcb7b0230d1f13209bd57928fbcd72768865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Sun, 12 Apr 2026 23:18:58 +0200 Subject: [PATCH 02/20] Add native manager endpoints api for process urls. --- devenv-processes/src/manager.rs | 88 ++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/devenv-processes/src/manager.rs b/devenv-processes/src/manager.rs index dae2175d15..6f9715955f 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,30 @@ 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) + }; + + format!("{}://{}:{}{}", url.scheme, url.host, url.port, path) +} + +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 @@ -1518,6 +1554,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 +2055,42 @@ 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_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(); From ab1c484944088d14108e3ad62aceb000afbc3d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Sun, 12 Apr 2026 23:21:59 +0200 Subject: [PATCH 03/20] add `devenv processes endpoints` command. --- devenv/src/cli.rs | 15 +++++++++++++++ devenv/src/devenv/mod.rs | 39 +++++++++++++++++++++++++++++++++++++++ devenv/src/main.rs | 6 ++++++ 3 files changed, 60 insertions(+) diff --git a/devenv/src/cli.rs b/devenv/src/cli.rs index 8fc5b51c29..51d41c29a9 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 { + Some(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..d2a80023f8 100644 --- a/devenv/src/devenv/mod.rs +++ b/devenv/src/devenv/mod.rs @@ -1958,6 +1958,45 @@ impl Devenv { } } + pub async fn processes_endpoints(&self) -> Result { + match self + .native_api_request(&processes::ApiRequest::Endpoints) + .await? + { + processes::ApiResponse::Endpoints { endpoints } => { + if endpoints.is_empty() { + return Ok("No endpoints found.\n".to_string()); + } + + let mut grouped: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for endpoint in endpoints { + grouped + .entry(endpoint.process_name) + .or_default() + .push((endpoint.label, endpoint.url)); + } + + let mut output = String::new(); + let mut first_process = true; + for (process_name, mut urls) in grouped { + urls.sort_by(|a, b| a.0.cmp(&b.0)); + if !first_process { + output.push('\n'); + } + first_process = false; + output.push_str(&format!("{process_name}\n")); + for (label, url) in urls { + output.push_str(&format!(" {:<12} {}\n", label, url)); + } + } + Ok(output) + } + 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(()), 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 }, } => { From 9393f72e54a48c284661648d666f28d3959405ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 10:14:36 +0200 Subject: [PATCH 04/20] Added urls to adminer. --- src/modules/services/adminer.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/modules/services/adminer.nix b/src/modules/services/adminer.nix index 8cac59684d..e38c77c430 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.main = { + 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"; }; } From ec0f9300cb6078839ae73301b5a536016a624b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 10:14:44 +0200 Subject: [PATCH 05/20] Added urls to mailhog. --- src/modules/services/mailhog.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) 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}"; }; } From 4e441f521c0e875e75e9072ce83c0d1ea173cbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 10:14:50 +0200 Subject: [PATCH 06/20] Added urls to mailpit. --- src/modules/services/mailpit.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) 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}"; }; } From a52e964e1bdd0de6ac9bc50784acfc6f4ec87878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 10:16:16 +0200 Subject: [PATCH 07/20] Added urls to rabbitmq (admin gui). --- src/modules/services/rabbitmq.nix | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 = { From 2502411e8fbb8fcfd9d935d321eacd0d886f7f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 10:18:48 +0200 Subject: [PATCH 08/20] Added urls to minio. --- src/modules/services/minio.nix | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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; From f33d34cfe59092f2da218fda24f5b031e6aefbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 10:20:15 +0200 Subject: [PATCH 09/20] processes.admine.urls.main -> ui --- src/modules/services/adminer.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/services/adminer.nix b/src/modules/services/adminer.nix index e38c77c430..98f83c93d8 100644 --- a/src/modules/services/adminer.nix +++ b/src/modules/services/adminer.nix @@ -41,7 +41,7 @@ in config = lib.mkIf cfg.enable { processes.adminer.ports.main.allocate = basePort; - processes.adminer.urls.main = { + processes.adminer.urls.ui = { scheme = "http"; host = urlHost; port = allocatedPort; From 797645f25a691bf4d1a923e18800c9a6b7460663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 11:10:23 +0200 Subject: [PATCH 10/20] Show defined endpoints in TUI. --- devenv-activity/src/builders.rs | 8 +++ devenv-activity/src/events.rs | 3 ++ devenv-processes/src/manager.rs | 16 ++++++ devenv-tui/src/model.rs | 4 ++ devenv-tui/src/view.rs | 86 +++++++++++++++++++++++++++------ devenv-tui/tests/tui_tests.rs | 28 +++++++++++ devenv/src/devenv/mod.rs | 79 +++++++++++++++++++----------- 7 files changed, 183 insertions(+), 41 deletions(-) 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/manager.rs b/devenv-processes/src/manager.rs index 6f9715955f..25e8c735fb 100644 --- a/devenv-processes/src/manager.rs +++ b/devenv-processes/src/manager.rs @@ -156,6 +156,18 @@ fn render_process_url(url: &ProcessUrl) -> String { format!("{}://{}:{}{}", url.scheme, url.host, url.port, path) } +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 @@ -547,6 +559,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); } diff --git a/devenv-tui/src/model.rs b/devenv-tui/src/model.rs index 8160613237..e3e73b08f5 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); } diff --git a/devenv-tui/src/view.rs b/devenv-tui/src/view.rs index 8ee0578765..30162da896 100644 --- a/devenv-tui/src/view.rs +++ b/devenv-tui/src/view.rs @@ -22,6 +22,53 @@ 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) -> AnyElement<'static> { + let indent = 2 + (depth * 2); + let mut line_elements = vec![]; + + for entry in urls { + let (label, url) = entry.split_once(": ").unwrap_or(("", entry.as_str())); + line_elements.push( + element! { + View(height: 1, flex_direction: FlexDirection::Row) { + #(if label.is_empty() { + vec![] + } else { + vec![element!(Text( + content: format!(" {label}: "), + color: Color::AnsiValue(245) + )) + .into_any()] + }) + Text( + content: url.to_string(), + color: COLOR_INFO, + decoration: TextDecoration::Underline + ) + Text(content: " ") + } + } + .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 +772,35 @@ 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)); + 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) diff --git a/devenv-tui/tests/tui_tests.rs b/devenv-tui/tests/tui_tests.rs index 5727e246e2..0dd155514e 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(), })); @@ -2047,6 +2048,7 @@ fn test_processes_alphabetical_order() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -2057,6 +2059,7 @@ fn test_processes_alphabetical_order() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -2077,6 +2080,7 @@ fn test_processes_alphabetical_order() { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -2103,6 +2107,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/src/devenv/mod.rs b/devenv/src/devenv/mod.rs index d2a80023f8..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; @@ -1964,33 +1965,7 @@ impl Devenv { .await? { processes::ApiResponse::Endpoints { endpoints } => { - if endpoints.is_empty() { - return Ok("No endpoints found.\n".to_string()); - } - - let mut grouped: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for endpoint in endpoints { - grouped - .entry(endpoint.process_name) - .or_default() - .push((endpoint.label, endpoint.url)); - } - - let mut output = String::new(); - let mut first_process = true; - for (process_name, mut urls) in grouped { - urls.sort_by(|a, b| a.0.cmp(&b.0)); - if !first_process { - output.push('\n'); - } - first_process = false; - output.push_str(&format!("{process_name}\n")); - for (label, url) in urls { - output.push_str(&format!(" {:<12} {}\n", label, url)); - } - } - Ok(output) + Ok(format_process_endpoints(endpoints)) } processes::ApiResponse::Error { message } => bail!("{}", message), other => bail!("Unexpected response: {:?}", other), @@ -2573,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::*; @@ -2675,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(); From 631570560c7c1cc6b369dc74fc6eb333edc6cb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 11:27:12 +0200 Subject: [PATCH 11/20] Always show urls and underline them. --- devenv-tui/src/view.rs | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/devenv-tui/src/view.rs b/devenv-tui/src/view.rs index 30162da896..283428e95f 100644 --- a/devenv-tui/src/view.rs +++ b/devenv-tui/src/view.rs @@ -22,30 +22,31 @@ 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) -> AnyElement<'static> { +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, flex_direction: FlexDirection::Row) { - #(if label.is_empty() { - vec![] - } else { - vec![element!(Text( - content: format!(" {label}: "), - color: Color::AnsiValue(245) - )) - .into_any()] - }) - Text( - content: url.to_string(), - color: COLOR_INFO, - decoration: TextDecoration::Underline + View(height: 1, overflow: Overflow::Hidden) { + MixedText( + contents: contents, + wrap: TextWrap::NoWrap ) - Text(content: " ") } } .into_any(), @@ -726,7 +727,7 @@ fn ActivityItem(mut hooks: Hooks) -> impl Into> { }; // Format ports: extract just the port numbers for brevity - let ports_suffix = if !process_data.ports.is_empty() { + let ports_suffix = if !process_data.ports.is_empty() && process_data.urls.is_empty() { let port_list: Vec = process_data .ports .iter() @@ -781,7 +782,11 @@ fn ActivityItem(mut hooks: Hooks) -> impl Into> { let mut total_height = 1usize; if show_urls { - elements.push(render_process_urls(&process_data.urls, *depth)); + elements.push(render_process_urls( + &process_data.urls, + *depth, + terminal_width, + )); total_height += process_data.urls.len(); } From e18ad3b0e1f6ef3b9bcc51b7e3acc9d9889c7e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 11:32:59 +0200 Subject: [PATCH 12/20] Test for process endpoints. --- tests/processes-endpoints/.test-config.yml | 1 + tests/processes-endpoints/.test.sh | 44 ++++++++++++++++++++++ tests/processes-endpoints/devenv.nix | 39 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 tests/processes-endpoints/.test-config.yml create mode 100755 tests/processes-endpoints/.test.sh create mode 100644 tests/processes-endpoints/devenv.nix 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 = "/"; + }; + }; +} From 1c2a57323a0f201768faee6bba00f695f06ccbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 13:22:51 +0200 Subject: [PATCH 13/20] Added Endpoints + Human-facing URLs to processes doc. --- docs/src/processes.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) 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: From 9bd44b38a63c966eb19d7254cb46d8739e536f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Mon, 13 Apr 2026 13:23:08 +0200 Subject: [PATCH 14/20] Changelog-entry. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)). From a21ebb9d09c4860969d6bfdead3f90b2eadc974e Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:43:21 +0000 Subject: [PATCH 15/20] Auto generate docs/src/reference/options.md --- docs/src/reference/options.md | 290 ++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) 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 From 262adf5103015e03827c92e16f03f51b1b151e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Thu, 16 Apr 2026 09:05:25 +0200 Subject: [PATCH 16/20] I haven touched devenv.nix, this change was necesesary from upstream. --- devenv.nix | 1 - 1 file changed, 1 deletion(-) 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; From 0c423e69a6b51b2194f8949cd24c7a192cc3b403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Fri, 17 Apr 2026 14:31:32 +0200 Subject: [PATCH 17/20] Fixed regression where process ports didn't show when process had a defined url. --- devenv-tui/src/view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv-tui/src/view.rs b/devenv-tui/src/view.rs index 283428e95f..9f2579f128 100644 --- a/devenv-tui/src/view.rs +++ b/devenv-tui/src/view.rs @@ -727,7 +727,7 @@ fn ActivityItem(mut hooks: Hooks) -> impl Into> { }; // Format ports: extract just the port numbers for brevity - let ports_suffix = if !process_data.ports.is_empty() && process_data.urls.is_empty() { + let ports_suffix = if !process_data.ports.is_empty() { let port_list: Vec = process_data .ports .iter() From 82ec82124150d534613866f7c7d0c5e213b2b245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Sun, 3 May 2026 21:53:14 +0200 Subject: [PATCH 18/20] add missing process urls in tests --- devenv-tui/src/model.rs | 3 +++ devenv-tui/src/view.rs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/devenv-tui/src/model.rs b/devenv-tui/src/model.rs index e3e73b08f5..2e4ae91521 100644 --- a/devenv-tui/src/model.rs +++ b/devenv-tui/src/model.rs @@ -1665,6 +1665,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1717,6 +1718,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1758,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 9f2579f128..cf28220c9d 100644 --- a/devenv-tui/src/view.rs +++ b/devenv-tui/src/view.rs @@ -1444,6 +1444,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1503,6 +1504,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1571,6 +1573,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); @@ -1626,6 +1629,7 @@ mod tests { command: None, ports: vec![], ready_probe: None, + urls: vec![], level: ActivityLevel::Info, timestamp: Timestamp::now(), })); From c1eaca996605992eb05fa4f2a8f82ca486bc77c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Sun, 3 May 2026 21:54:03 +0200 Subject: [PATCH 19/20] bracket IPv6 hosts in rendered URLs --- devenv-processes/src/manager.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/devenv-processes/src/manager.rs b/devenv-processes/src/manager.rs index 25e8c735fb..e40f0a2a7f 100644 --- a/devenv-processes/src/manager.rs +++ b/devenv-processes/src/manager.rs @@ -153,7 +153,19 @@ fn render_process_url(url: &ProcessUrl) -> String { format!("/{}", url.path) }; - format!("{}://{}:{}{}", url.scheme, url.host, url.port, 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 { @@ -2083,6 +2095,18 @@ mod tests { 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"); From e28cb5fdec07f994a146e7f69b4a66ca16f7b21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Sj=C3=B6strand?= Date: Tue, 5 May 2026 15:07:26 +0200 Subject: [PATCH 20/20] Fixed tests I had broken --- devenv-tui/tests/tui_tests.rs | 5 +++++ devenv/src/cli.rs | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/devenv-tui/tests/tui_tests.rs b/devenv-tui/tests/tui_tests.rs index 0dd155514e..942e6f7de5 100644 --- a/devenv-tui/tests/tui_tests.rs +++ b/devenv-tui/tests/tui_tests.rs @@ -1713,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(), })); @@ -1782,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(), })); @@ -1824,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(), })); @@ -1865,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(), })); @@ -1881,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(), })); diff --git a/devenv/src/cli.rs b/devenv/src/cli.rs index 51d41c29a9..cfbcf4e772 100644 --- a/devenv/src/cli.rs +++ b/devenv/src/cli.rs @@ -1336,9 +1336,9 @@ mod tests { let cli = Cli::parse_from(["devenv", "processes", "endpoints"]); match cli.command { - Some(Commands::Processes { + Commands::Processes { command: ProcessesCommand::Endpoints {}, - }) => {} + } => {} _ => panic!("expected `devenv processes endpoints` command"), } }