A safe, simple, extensible, and fast agent harness
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rename ein-tui to ein. Move from crates/ directory to top level project directory. #2

open opened by mstallmo.com targeting main from codebase-reorg
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:vzio3dyompxwp7sguhzroqc4/sh.tangled.repo.pull/3mkzxeodapy22
+388 -20
Interdiff #4 โ†’ #5
CLAUDE.md

This file has not been changed.

Cargo.lock

This file has not been changed.

Cargo.toml

This file has not been changed.

README.md

This file has not been changed.

+368
crates/ein-tui/src/bootstrap.rs
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // Copyright 2026 Mason Stallmo 3 + 4 + //! Bootstrap logic: downloads `eind` on first run and registers it as a 5 + //! system service (macOS LaunchAgent or Linux systemd user service). 6 + 7 + // These items are only called from the #[cfg(not(debug_assertions))] block in 8 + // lib.rs, so they appear unused in debug builds. That's intentional. 9 + #![cfg_attr(debug_assertions, allow(dead_code))] 10 + 11 + use anyhow::{Context, Result}; 12 + use std::{ 13 + io, 14 + os::unix::fs::PermissionsExt, 15 + path::{Path, PathBuf}, 16 + }; 17 + use tar::Archive; 18 + use tokio::{fs, process::Command, task}; 19 + use xz2::read::XzDecoder; 20 + 21 + const GITHUB_REPO: &str = "mstallmo/ein"; 22 + 23 + /// Path where `ein` installs the server binary: `~/.ein/bin/eind`. 24 + pub fn server_bin_path() -> PathBuf { 25 + dirs::home_dir() 26 + .expect("home directory not found") 27 + .join(".ein") 28 + .join("bin") 29 + .join("eind") 30 + } 31 + 32 + /// Compile-time target triple used to select the right GitHub release asset. 33 + pub fn target_triple() -> &'static str { 34 + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 35 + return "aarch64-apple-darwin"; 36 + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] 37 + return "x86_64-apple-darwin"; 38 + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] 39 + return "aarch64-unknown-linux-gnu"; 40 + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] 41 + return "x86_64-unknown-linux-gnu"; 42 + #[allow(unreachable_code)] 43 + "" 44 + } 45 + 46 + /// Downloads the `eind` binary for the current platform from GitHub 47 + /// releases and writes it to `~/.ein/bin/eind` with executable permissions. 48 + pub async fn download_server(version: &str) -> Result<()> { 49 + let ver = version.trim_start_matches('v'); 50 + let tag = format!("v{ver}"); 51 + let triple = target_triple(); 52 + // cargo-dist names archives as "{package}-{triple}.tar.xz" (no version in filename). 53 + let archive_name = format!("eind-{triple}.tar.xz"); 54 + let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); 55 + 56 + let dest = server_bin_path(); 57 + fs::create_dir_all(dest.parent().unwrap()) 58 + .await 59 + .context("failed to create ~/.ein/bin")?; 60 + 61 + println!("Downloading {url}..."); 62 + 63 + let response = reqwest::get(&url) 64 + .await 65 + .with_context(|| format!("failed to fetch {url}"))?; 66 + 67 + if !response.status().is_success() { 68 + anyhow::bail!("download failed: HTTP {}", response.status()); 69 + } 70 + 71 + let bytes = response 72 + .bytes() 73 + .await 74 + .context("failed to read response body")?; 75 + 76 + let dest_clone = dest.clone(); 77 + task::spawn_blocking(move || extract_server(&bytes, &dest_clone)) 78 + .await 79 + .context("extraction task panicked")??; 80 + 81 + // Make the binary executable. 82 + let mut perms = fs::metadata(&dest).await?.permissions(); 83 + perms.set_mode(0o755); 84 + fs::set_permissions(&dest, perms).await?; 85 + 86 + println!("eind installed to {}", dest.display()); 87 + Ok(()) 88 + } 89 + 90 + /// Extracts the `eind` binary from a tar.xz archive into `dest`. 91 + fn extract_server(bytes: &[u8], dest: &Path) -> Result<()> { 92 + let xz = XzDecoder::new(io::Cursor::new(bytes)); 93 + let mut archive = Archive::new(xz); 94 + 95 + for entry in archive 96 + .entries() 97 + .context("failed to read archive entries")? 98 + { 99 + let mut entry = entry.context("corrupt archive entry")?; 100 + let entry_path = entry.path().context("entry has no path")?; 101 + 102 + // The archive contains exactly one file: the `eind` binary. 103 + // Accept it regardless of any leading directory component. 104 + let file_name = entry_path 105 + .file_name() 106 + .and_then(|n| n.to_str()) 107 + .unwrap_or(""); 108 + 109 + if file_name == "eind" { 110 + let mut file = std::fs::File::create(dest) 111 + .with_context(|| format!("failed to create {}", dest.display()))?; 112 + io::copy(&mut entry, &mut file).context("failed to write eind")?; 113 + return Ok(()); 114 + } 115 + } 116 + 117 + anyhow::bail!("eind binary not found in archive") 118 + } 119 + 120 + // --------------------------------------------------------------------------- 121 + // Service registration 122 + // --------------------------------------------------------------------------- 123 + 124 + /// Ensures `eind` is registered as a system service. 125 + /// 126 + /// On macOS, installs a LaunchAgent plist and loads it. 127 + /// On Linux, writes a systemd user unit and enables it. 128 + /// On other platforms, does nothing (the TUI's retry loop handles reconnects). 129 + pub async fn ensure_service_installed() -> Result<()> { 130 + #[cfg(target_os = "macos")] 131 + return ensure_launchagent_installed().await; 132 + 133 + #[cfg(target_os = "linux")] 134 + return ensure_systemd_installed().await; 135 + 136 + #[cfg(not(any(target_os = "macos", target_os = "linux")))] 137 + Ok(()) 138 + } 139 + 140 + // --------------------------------------------------------------------------- 141 + // Uninstall 142 + // --------------------------------------------------------------------------- 143 + 144 + /// Stops and removes the `eind` service and binary installed by 145 + /// [`ensure_service_installed`] and [`download_server`]. 146 + /// 147 + /// Returns a list of completed step descriptions for display in the TUI. 148 + /// User config and session data in `~/.ein/` are left intact. 149 + pub async fn uninstall() -> Result<Vec<String>> { 150 + let mut steps: Vec<String> = Vec::new(); 151 + #[cfg(target_os = "macos")] 152 + uninstall_launchagent(&mut steps).await?; 153 + #[cfg(target_os = "linux")] 154 + uninstall_systemd(&mut steps).await?; 155 + remove_server_binary(&mut steps).await?; 156 + Ok(steps) 157 + } 158 + 159 + async fn remove_server_binary(steps: &mut Vec<String>) -> Result<()> { 160 + let path = server_bin_path(); 161 + if path.exists() { 162 + fs::remove_file(&path) 163 + .await 164 + .with_context(|| format!("failed to remove {}", path.display()))?; 165 + steps.push(format!("Removed {}", path.display())); 166 + } 167 + Ok(()) 168 + } 169 + 170 + #[cfg(target_os = "macos")] 171 + async fn uninstall_launchagent(steps: &mut Vec<String>) -> Result<()> { 172 + let plist = launchagent_plist_path(); 173 + // Ignore errors โ€” the service may already be stopped/unloaded. 174 + let _ = Command::new("launchctl") 175 + .args(["unload", plist.to_str().unwrap_or("")]) 176 + .output() 177 + .await; 178 + if plist.exists() { 179 + fs::remove_file(&plist) 180 + .await 181 + .with_context(|| format!("failed to remove {}", plist.display()))?; 182 + steps.push(format!("Removed LaunchAgent ({})", LAUNCH_AGENT_LABEL)); 183 + } 184 + Ok(()) 185 + } 186 + 187 + #[cfg(target_os = "linux")] 188 + async fn uninstall_systemd(steps: &mut Vec<String>) -> Result<()> { 189 + let unit = systemd_unit_path(); 190 + let _ = Command::new("systemctl") 191 + .args(["--user", "stop", SYSTEMD_SERVICE_NAME]) 192 + .output() 193 + .await; 194 + let _ = Command::new("systemctl") 195 + .args(["--user", "disable", SYSTEMD_SERVICE_NAME]) 196 + .output() 197 + .await; 198 + if unit.exists() { 199 + fs::remove_file(&unit) 200 + .await 201 + .with_context(|| format!("failed to remove {}", unit.display()))?; 202 + steps.push(format!( 203 + "Removed systemd user service ({})", 204 + SYSTEMD_SERVICE_NAME 205 + )); 206 + } 207 + let _ = Command::new("systemctl") 208 + .args(["--user", "daemon-reload"]) 209 + .output() 210 + .await; 211 + Ok(()) 212 + } 213 + 214 + // --------------------------------------------------------------------------- 215 + // macOS LaunchAgent 216 + // --------------------------------------------------------------------------- 217 + 218 + #[cfg(target_os = "macos")] 219 + const LAUNCH_AGENT_LABEL: &str = "com.ein.server"; 220 + 221 + #[cfg(target_os = "macos")] 222 + fn launchagent_plist_path() -> PathBuf { 223 + dirs::home_dir() 224 + .expect("home directory not found") 225 + .join("Library") 226 + .join("LaunchAgents") 227 + .join(format!("{LAUNCH_AGENT_LABEL}.plist")) 228 + } 229 + 230 + #[cfg(target_os = "macos")] 231 + async fn ensure_launchagent_installed() -> Result<()> { 232 + // Check if already loaded. 233 + let status = Command::new("launchctl") 234 + .args(["list", LAUNCH_AGENT_LABEL]) 235 + .output() 236 + .await 237 + .context("launchctl not found")?; 238 + 239 + if status.status.success() { 240 + return Ok(()); // Already running. 241 + } 242 + 243 + let plist_path = launchagent_plist_path(); 244 + let bin = server_bin_path(); 245 + let log = dirs::home_dir() 246 + .expect("home directory not found") 247 + .join(".ein") 248 + .join("server.log"); 249 + 250 + let plist = format!( 251 + r#"<?xml version="1.0" encoding="UTF-8"?> 252 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 253 + <plist version="1.0"> 254 + <dict> 255 + <key>Label</key> 256 + <string>{LAUNCH_AGENT_LABEL}</string> 257 + <key>ProgramArguments</key> 258 + <array> 259 + <string>{bin}</string> 260 + </array> 261 + <key>RunAtLoad</key> 262 + <true/> 263 + <key>KeepAlive</key> 264 + <true/> 265 + <key>StandardOutPath</key> 266 + <string>{log}</string> 267 + <key>StandardErrorPath</key> 268 + <string>{log}</string> 269 + </dict> 270 + </plist> 271 + "#, 272 + LAUNCH_AGENT_LABEL = LAUNCH_AGENT_LABEL, 273 + bin = bin.display(), 274 + log = log.display(), 275 + ); 276 + 277 + fs::create_dir_all(plist_path.parent().unwrap()) 278 + .await 279 + .context("failed to create LaunchAgents directory")?; 280 + fs::write(&plist_path, plist) 281 + .await 282 + .context("failed to write plist")?; 283 + 284 + let output = Command::new("launchctl") 285 + .args(["load", plist_path.to_str().unwrap()]) 286 + .output() 287 + .await 288 + .context("failed to run launchctl load")?; 289 + 290 + if !output.status.success() { 291 + let stderr = String::from_utf8_lossy(&output.stderr); 292 + anyhow::bail!("launchctl load failed: {stderr}"); 293 + } 294 + 295 + println!("eind registered as LaunchAgent ({LAUNCH_AGENT_LABEL})"); 296 + Ok(()) 297 + } 298 + 299 + // --------------------------------------------------------------------------- 300 + // Linux systemd user service 301 + // --------------------------------------------------------------------------- 302 + 303 + #[cfg(target_os = "linux")] 304 + const SYSTEMD_SERVICE_NAME: &str = "eind"; 305 + 306 + #[cfg(target_os = "linux")] 307 + fn systemd_unit_path() -> PathBuf { 308 + dirs::home_dir() 309 + .expect("home directory not found") 310 + .join(".config") 311 + .join("systemd") 312 + .join("user") 313 + .join(format!("{SYSTEMD_SERVICE_NAME}.service")) 314 + } 315 + 316 + #[cfg(target_os = "linux")] 317 + async fn ensure_systemd_installed() -> Result<()> { 318 + // Check if already enabled. 319 + let status = Command::new("systemctl") 320 + .args(["--user", "is-enabled", SYSTEMD_SERVICE_NAME]) 321 + .output() 322 + .await 323 + .context("systemctl not found")?; 324 + 325 + if status.status.success() { 326 + return Ok(()); // Already enabled. 327 + } 328 + 329 + let unit_path = systemd_unit_path(); 330 + let bin = server_bin_path(); 331 + 332 + let unit = format!( 333 + "[Unit]\nDescription=Ein server\n\n[Service]\nExecStart={bin}\nRestart=always\n\n[Install]\nWantedBy=default.target\n", 334 + bin = bin.display(), 335 + ); 336 + 337 + fs::create_dir_all(unit_path.parent().unwrap()) 338 + .await 339 + .context("failed to create systemd user directory")?; 340 + fs::write(&unit_path, unit) 341 + .await 342 + .context("failed to write systemd unit")?; 343 + 344 + let reload = Command::new("systemctl") 345 + .args(["--user", "daemon-reload"]) 346 + .output() 347 + .await 348 + .context("failed to run systemctl daemon-reload")?; 349 + 350 + if !reload.status.success() { 351 + let stderr = String::from_utf8_lossy(&reload.stderr); 352 + anyhow::bail!("systemctl daemon-reload failed: {stderr}"); 353 + } 354 + 355 + let enable = Command::new("systemctl") 356 + .args(["--user", "enable", "--now", SYSTEMD_SERVICE_NAME]) 357 + .output() 358 + .await 359 + .context("failed to run systemctl enable")?; 360 + 361 + if !enable.status.success() { 362 + let stderr = String::from_utf8_lossy(&enable.stderr); 363 + anyhow::bail!("systemctl enable failed: {stderr}"); 364 + } 365 + 366 + println!("eind registered as systemd user service ({SYSTEMD_SERVICE_NAME})"); 367 + Ok(()) 368 + }
ein/Cargo.toml

This file has not been changed.

ein/src/app.rs

This file has not been changed.

ein/src/config.rs

This file has not been changed.

ein/src/connection.rs

This file has not been changed.

ein/src/input.rs

This file has not been changed.

ein/src/lib.rs

This file has not been changed.

ein/src/main.rs

This file has not been changed.

ein/src/render.rs

This file has not been changed.

+20 -20
ein/src/bootstrap.rs
··· 249 249 250 250 let plist = format!( 251 251 r#"<?xml version="1.0" encoding="UTF-8"?> 252 - <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 253 - <plist version="1.0"> 254 - <dict> 255 - <key>Label</key> 256 - <string>{LAUNCH_AGENT_LABEL}</string> 257 - <key>ProgramArguments</key> 258 - <array> 259 - <string>{bin}</string> 260 - </array> 261 - <key>RunAtLoad</key> 262 - <true/> 263 - <key>KeepAlive</key> 264 - <true/> 265 - <key>StandardOutPath</key> 266 - <string>{log}</string> 267 - <key>StandardErrorPath</key> 268 - <string>{log}</string> 269 - </dict> 270 - </plist> 271 - "#, 252 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 253 + <plist version="1.0"> 254 + <dict> 255 + <key>Label</key> 256 + <string>{LAUNCH_AGENT_LABEL}</string> 257 + <key>ProgramArguments</key> 258 + <array> 259 + <string>{bin}</string> 260 + </array> 261 + <key>RunAtLoad</key> 262 + <true/> 263 + <key>KeepAlive</key> 264 + <true/> 265 + <key>StandardOutPath</key> 266 + <string>{log}</string> 267 + <key>StandardErrorPath</key> 268 + <string>{log}</string> 269 + </dict> 270 + </plist> 271 + "#, 272 272 LAUNCH_AGENT_LABEL = LAUNCH_AGENT_LABEL, 273 273 bin = bin.display(), 274 274 log = log.display(),

History

7 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
Rename ein-tui to ein. Move from crates/ directory to top level project directory.
merge conflicts detected
expand
  • CLAUDE.md:4
  • Cargo.lock:970
  • Cargo.toml:1
  • README.md:4
expand 0 comments
1 commit
expand
Rename ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
1 commit
expand
Rename ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
1 commit
expand
Rename ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
1 commit
expand
Rename ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
1 commit
expand
Rename ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments
1 commit
expand
Rename ein-tui to ein. Move from crates/ directory to top level project directory.
expand 0 comments