Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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.<name>.ports.<port>.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)).
Expand Down
8 changes: 8 additions & 0 deletions devenv-activity/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ pub struct ProcessBuilder {
command: Option<String>,
ports: Vec<String>,
ready_probe: Option<String>,
urls: Vec<String>,
id: Option<u64>,
parent: Option<Option<u64>>,
level: Option<ActivityLevel>,
Expand All @@ -688,6 +689,7 @@ impl ProcessBuilder {
command: None,
ports: Vec::new(),
ready_probe: None,
urls: Vec::new(),
id: None,
parent: None,
level: None,
Expand All @@ -709,6 +711,11 @@ impl ProcessBuilder {
self
}

pub fn urls(mut self, urls: Vec<String>) -> Self {
self.urls = urls;
self
}

pub fn id(mut self, id: u64) -> Self {
self.id = Some(id);
self
Expand Down Expand Up @@ -762,6 +769,7 @@ impl ActivityStart for ProcessBuilder {
ports: self.ports,
ready_probe: self.ready_probe,
level,
urls: self.urls,
timestamp: Timestamp::now(),
}));

Expand Down
3 changes: 3 additions & 0 deletions devenv-activity/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
#[serde(default)]
level: ActivityLevel,
timestamp: Timestamp,
Expand Down
59 changes: 59 additions & 0 deletions devenv-processes/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ pub struct SocketActivationConfig {
pub listens: Vec<ListenSpec>,
}

/// 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 {
Expand Down Expand Up @@ -240,6 +264,9 @@ pub struct ProcessConfig {
/// Allocated ports for display (e.g., {"http": 8080, "admin": 9000})
#[serde(default)]
pub ports: HashMap<String, u16>,
/// Human-facing process URLs/endpoints (e.g., {"admin": {"scheme":"http", ...}})
#[serde(default)]
pub urls: HashMap<String, ProcessUrl>,
/// Readiness probe configuration
#[serde(default)]
pub ready: Option<ReadyConfig>,
Expand Down Expand Up @@ -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(),
Expand All @@ -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, "/");
}
}
128 changes: 127 additions & 1 deletion devenv-processes/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -84,14 +94,16 @@ pub enum ApiResponse {
Ok,
/// All port allocations from managed processes.
PortAllocations { ports: Vec<PortInfo> },
/// All human-facing endpoints from managed processes.
Endpoints { endpoints: Vec<EndpointInfo> },
}

use watchexec_supervisor::{
Signal,
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};
Expand Down Expand Up @@ -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<String> {
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<EndpointInfo> {
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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions devenv-tui/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ pub struct ProcessActivity {
pub ports: Vec<String>,
/// Human-readable description of the readiness probe (e.g., "exec: pg_isready")
pub ready_probe: Option<String>,
/// Human-facing URLs for this process (e.g., ["admin: http://127.0.0.1:15672/"])
pub urls: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
Expand Down Expand Up @@ -639,13 +641,15 @@ impl ActivityModel {
command,
ports,
ready_probe,
urls,
level,
..
} => {
let variant = ActivityVariant::Process(ProcessActivity {
status: ProcessStatus::Starting,
ports,
ready_probe,
urls,
});
self.create_activity(id, name, parent, command, variant, level);
}
Expand Down Expand Up @@ -1661,6 +1665,7 @@ mod tests {
command: None,
ports: vec![],
ready_probe: None,
urls: vec![],
level: ActivityLevel::Info,
timestamp: Timestamp::now(),
}));
Expand Down Expand Up @@ -1713,6 +1718,7 @@ mod tests {
command: None,
ports: vec![],
ready_probe: None,
urls: vec![],
level: ActivityLevel::Info,
timestamp: Timestamp::now(),
}));
Expand Down Expand Up @@ -1754,6 +1760,7 @@ mod tests {
command: None,
ports: vec![],
ready_probe: None,
urls: vec![],
level: ActivityLevel::Info,
timestamp: Timestamp::now(),
}));
Expand Down
Loading