Opinionated Android 15+ Linux Terminal Setup
android linux command-line-tools
at main 605 lines 21 kB view raw
1use std::{collections::HashMap, path::Path}; 2 3use anyhow::{Context, Error}; 4use owo_colors::OwoColorize; 5 6use crate::{ 7 command::{run_command, run_command_without_local_path}, 8 config::SshConfig, 9 git::extract_version, 10}; 11 12#[derive(Debug)] 13pub enum SetupStep<'a> { 14 AptGet(&'a [String]), 15 Pkgx(&'a HashMap<String, String>), 16 Curl(&'a HashMap<String, String>), 17 Mise(&'a HashMap<String, String>), 18 BleSh(bool), 19 Nix(&'a HashMap<String, String>), 20 Stow(&'a HashMap<String, String>), 21 OhMyPosh(&'a str), 22 Zoxide(bool), 23 Alias(&'a HashMap<String, String>), 24 Ssh(&'a SshConfig), 25 Paths, 26 Tailscale(bool), 27 Neofetch(bool), 28 Doppler(bool), 29 Npm(&'a HashMap<String, String>), 30} 31 32impl<'a> SetupStep<'a> { 33 pub fn run(&self) -> Result<(), Error> { 34 match self { 35 SetupStep::AptGet(pkgs) => install_apt(pkgs), 36 SetupStep::Pkgx(map) => install_pkgx(map), 37 SetupStep::Curl(map) => run_curl_installers(map), 38 SetupStep::Mise(map) => setup_mise(map), 39 SetupStep::BleSh(enabled) => enable_blesh(*enabled), 40 SetupStep::Nix(map) => setup_nix(map), 41 SetupStep::Stow(map) => setup_stow(map), 42 SetupStep::OhMyPosh(theme) => setup_oh_my_posh(theme), 43 SetupStep::Zoxide(enabled) => enable_zoxide(*enabled), 44 SetupStep::Alias(map) => setup_alias(map), 45 SetupStep::Ssh(config) => setup_ssh(config), 46 SetupStep::Paths => setup_paths(), 47 SetupStep::Tailscale(enabled) => enable_tailscale(*enabled), 48 SetupStep::Neofetch(enabled) => enable_neofetch(*enabled), 49 SetupStep::Doppler(enabled) => enable_doppler(*enabled), 50 SetupStep::Npm(map) => setup_npm(map), 51 } 52 } 53 54 pub fn format_dry_run(&self) -> String { 55 match self { 56 SetupStep::AptGet(pkgs) => { 57 let pkg_list = pkgs 58 .iter() 59 .map(|p| format!(" - {}", p.green())) 60 .collect::<Vec<_>>() 61 .join("\n"); 62 format!( 63 "{} {}\n{}", 64 "AptGet".blue().bold(), 65 "(Install system packages via apt-get)".italic(), 66 pkg_list 67 ) 68 } 69 SetupStep::Pkgx(map) => { 70 let pkg_list = map 71 .iter() 72 .map(|(k, v)| format!(" - {}: {}", k.green(), v.cyan())) 73 .collect::<Vec<_>>() 74 .join("\n"); 75 format!( 76 "{} {}\n{}", 77 "Pkgx".blue().bold(), 78 "(Install tools via pkgx)".italic(), 79 pkg_list 80 ) 81 } 82 SetupStep::Curl(map) => { 83 let curl_list = map 84 .iter() 85 .map(|(k, v)| format!(" - {}: {}", k.green(), v.cyan())) 86 .collect::<Vec<_>>() 87 .join("\n"); 88 format!( 89 "{} {}\n{}", 90 "Curl".blue().bold(), 91 "(Run curl-based installers)".italic(), 92 curl_list 93 ) 94 } 95 SetupStep::Mise(map) => { 96 let mise_list = map 97 .iter() 98 .map(|(k, v)| format!(" - {}: {}", k.green(), v.cyan())) 99 .collect::<Vec<_>>() 100 .join("\n"); 101 format!( 102 "{} {}\n{}", 103 "Mise".blue().bold(), 104 "(Configure tools via mise)".italic(), 105 mise_list 106 ) 107 } 108 SetupStep::BleSh(enabled) => { 109 format!( 110 "{} {}\n - Enabled: {}", 111 "BleSh".blue().bold(), 112 "(Enable ble.sh shell enhancements)".italic(), 113 enabled.to_string().green() 114 ) 115 } 116 SetupStep::Zoxide(enabled) => { 117 format!( 118 "{} {}\n - Enabled: {}", 119 "Zoxide".blue().bold(), 120 "(Enable zoxide for directory navigation)".italic(), 121 enabled.to_string().green() 122 ) 123 } 124 SetupStep::Nix(map) => { 125 let nix_list = map 126 .iter() 127 .map(|(k, v)| format!(" - {}: {}", k.green(), v.cyan())) 128 .collect::<Vec<_>>() 129 .join("\n"); 130 format!( 131 "{} {}\n{}", 132 "Nix".blue().bold(), 133 "(Install tools via nix)".italic(), 134 nix_list 135 ) 136 } 137 SetupStep::Stow(map) => { 138 let stow_list = map 139 .iter() 140 .map(|(k, v)| format!(" - {}: {}", k.green(), v.cyan())) 141 .collect::<Vec<_>>() 142 .join("\n"); 143 format!( 144 "{} {}\n{}", 145 "Stow".blue().bold(), 146 "(Manage dotfiles via stow)".italic(), 147 stow_list 148 ) 149 } 150 SetupStep::OhMyPosh(theme) => { 151 format!( 152 "{} {}\n - Theme: {}", 153 "OhMyPosh".blue().bold(), 154 "(Setup Oh My Posh for shell prompt)".italic(), 155 theme.green() 156 ) 157 } 158 SetupStep::Alias(map) => { 159 let alias_list = map 160 .iter() 161 .map(|(k, v)| format!(" - {}: {}", k.green(), v.cyan())) 162 .collect::<Vec<_>>() 163 .join("\n"); 164 format!( 165 "{} {}\n{}", 166 "Alias".blue().bold(), 167 "(Setup shell aliases)".italic(), 168 alias_list 169 ) 170 } 171 SetupStep::Paths => { 172 format!( 173 "{} {}\n{}", 174 "Paths".blue().bold(), 175 "(Setup paths for binaries)".italic(), 176 " - ~/.local/bin".green() 177 ) 178 } 179 SetupStep::Ssh(config) => { 180 format!( 181 "{} {}\n - Port: {}\n - Authorized Keys: {}", 182 "SSH".blue().bold(), 183 "(Setup SSH keys and configuration)".italic(), 184 config.port.unwrap_or(0).to_string().green(), 185 config 186 .authorized_keys 187 .as_ref() 188 .map(|keys| { 189 keys.iter() 190 .map(|key| format!(" - {}", key.green())) 191 .collect::<Vec<_>>() 192 .join("\n") 193 }) 194 .unwrap_or_else(|| " - None".into()) 195 ) 196 } 197 SetupStep::Tailscale(enabled) => { 198 format!( 199 "{} {}\n - Enabled: {}", 200 "Tailscale".blue().bold(), 201 "(Install and configure Tailscale VPN)".italic(), 202 enabled.to_string().green() 203 ) 204 } 205 SetupStep::Neofetch(enabled) => { 206 format!( 207 "{} {}\n - Enabled: {}", 208 "Neofetch".blue().bold(), 209 "(Enable Neofetch on terminal startup)".italic(), 210 enabled.to_string().green() 211 ) 212 } 213 SetupStep::Doppler(enabled) => { 214 format!( 215 "{} {}\n - Enabled: {}", 216 "Doppler".blue().bold(), 217 "(Install and configure Doppler for secrets management)".italic(), 218 enabled.to_string().green() 219 ) 220 } 221 SetupStep::Npm(map) => { 222 let npm_list = map 223 .iter() 224 .map(|(k, v)| format!(" - {}: {}", k.green(), v.cyan())) 225 .collect::<Vec<_>>() 226 .join("\n"); 227 format!( 228 "{} {}\n{}", 229 "Npm".blue().bold(), 230 "(Install global npm packages)".italic(), 231 npm_list 232 ) 233 } 234 } 235 } 236} 237 238fn install_apt(pkgs: &[String]) -> Result<(), Error> { 239 if pkgs.is_empty() { 240 return Ok(()); 241 } 242 243 run_command("sudo", &["apt-get", "update"]).context("Failed to run apt-get update")?; 244 if !Path::new("/etc/apt/sources.list.d/vscode.list").exists() 245 && !Path::new("/etc/apt/sources.list.d/vscode.sources").exists() 246 { 247 run_command("sudo", &["apt-get", "install", "-y", "wget", "curl", "gpg"])?; 248 run_command( 249 "bash", 250 &[ 251 "-c", 252 "wget -qO- https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor > packages.microsoft.gpg", 253 ], 254 )?; 255 run_command( 256 "sudo", 257 &[ 258 "install", 259 "-D", 260 "-o", 261 "root", 262 "-g", 263 "root", 264 "-m", 265 "644", 266 "packages.microsoft.gpg", 267 "/etc/apt/keyrings/packages.microsoft.gpg", 268 ], 269 )?; 270 run_command( 271 "bash", 272 &[ 273 "-c", 274 "echo 'deb [arch=amd64,arm64,armhf signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main' | sudo tee /etc/apt/sources.list.d/vscode.list", 275 ], 276 )?; 277 run_command("rm", &["-f", "packages.microsoft.gpg"])?; 278 run_command("sudo", &["apt-get", "update"]).context("Failed to run apt-get update")?; 279 } 280 281 if !Path::new("/etc/apt/sources.list.d/mise.list").exists() { 282 run_command("bash", &[ 283 "-c", 284 "wget -qO - https://mise.jdx.dev/gpg-key.pub | gpg --dearmor | sudo tee /etc/apt/keyrings/mise-archive-keyring.gpg 1> /dev/null 285"])?; 286 run_command( 287 "bash", 288 &[ 289 "-c", 290 "echo 'deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg arch=amd64,arm64] https://mise.jdx.dev/deb stable main' | sudo tee /etc/apt/sources.list.d/mise.list", 291 ], 292 )?; 293 run_command("sudo", &["apt-get", "update"]).context("Failed to run apt-get update")?; 294 } 295 296 let mut args: Vec<&str> = vec!["apt-get", "install", "-y"]; 297 args.extend(pkgs.iter().map(|s| s.as_str())); 298 run_command("sudo", &args).context("Failed to run apt-get install")?; 299 300 run_command( 301 "sudo", 302 &["rm", "-rf", "/etc/apt/sources.list.d/vscode.list"], 303 )?; 304 305 Ok(()) 306} 307 308fn install_pkgx(map: &HashMap<String, String>) -> Result<(), Error> { 309 for (name, ver) in map { 310 run_command("pkgm", &["install", &format!("{name}@{ver}")]) 311 .context(format!("Failed to install {name} via pkgx"))?; 312 } 313 run_command("pkgm", &["uninstall", "curl"]).context("Failed to uninstall curl via pkgx")?; 314 Ok(()) 315} 316 317fn run_curl_installers(map: &HashMap<String, String>) -> Result<(), Error> { 318 for (name, url) in map { 319 run_command("bash", &["-c", &format!("curl -fsSL {} | bash -s", url)]) 320 .context(format!("Failed to run curl installer for {name}"))?; 321 } 322 Ok(()) 323} 324 325fn setup_mise(map: &HashMap<String, String>) -> Result<(), Error> { 326 if !Path::new("/usr/bin/mise").exists() { 327 run_command("sudo", &["apt-get", "install", "-y", "mise"]) 328 .context("Failed to install mise")?; 329 } 330 331 run_command( 332 "bash", 333 &[ 334 "-c", 335 "sed -i '/mise /d' ~/.bashrc || echo 'No existing mise line found in .bashrc'", 336 ], 337 )?; 338 run_command( 339 "bash", 340 &[ 341 "-c", 342 "echo '\neval \"$(mise activate bash)\"' | tee -a ~/.bashrc", 343 ], 344 )?; 345 346 for (tool, ver) in map { 347 run_command("mise", &["use", "-g", &format!("{tool}@{ver}")]) 348 .context(format!("Failed to configure {tool} via mise"))?; 349 } 350 Ok(()) 351} 352 353fn enable_blesh(enabled: bool) -> Result<(), Error> { 354 let home = dirs::home_dir().ok_or_else(|| Error::msg("Failed to get home directory"))?; 355 let blesh_path = home.join("ble.sh"); 356 if enabled && !blesh_path.exists() { 357 run_command_without_local_path( 358 "bash", 359 &[ 360 "-c", "rm -rf ~/.local/bin/gettext* && git clone --recursive --depth 1 --shallow-submodules https://github.com/akinomyoga/ble.sh.git", 361 ], 362 ) 363 .context("Failed to clone ble.sh repository")?; 364 run_command_without_local_path("make", &["-C", "ble.sh"]) 365 .context("Failed to build ble.sh")?; 366 run_command_without_local_path( 367 "bash", 368 &[ 369 "-c", 370 "grep 'source ble' ~/.bashrc || echo '\nsource ble.sh/out/ble.sh' | tee -a ~/.bashrc", 371 ], 372 ) 373 .context("Failed to add ble.sh to .bashrc")?; 374 } 375 Ok(()) 376} 377 378fn enable_zoxide(enabled: bool) -> Result<(), Error> { 379 if enabled { 380 run_command("bash", &["-c", "curl -sSL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash"]) 381 .context("Failed to install zoxide")?; 382 run_command( 383 "bash", 384 &[ 385 "-c", 386 "grep zoxide ~/.bashrc || echo '\neval \"$(zoxide init bash)\"' | tee -a ~/.bashrc", 387 ], 388 ) 389 .context("Failed to add zoxide initialization to .bashrc")?; 390 } 391 Ok(()) 392} 393 394fn setup_nix(_map: &HashMap<String, String>) -> Result<(), Error> { 395 run_command( 396 "bash", 397 &[ 398 "-c", 399 "type nix || curl -fsSL https://install.determinate.systems/nix | sh -s -- install --determinate", 400 ], 401 ) 402 .context("Failed to install nix")?; 403 Ok(()) 404} 405 406fn setup_stow(map: &HashMap<String, String>) -> Result<(), Error> { 407 if map.is_empty() { 408 return Ok(()); 409 } 410 411 let repo = map 412 .get("git") 413 .ok_or_else(|| Error::msg("No repo specified for stow"))?; 414 415 let repo = if repo.starts_with("github:") { 416 repo.replace("github:", "https://github.com/") 417 } else if repo.starts_with("tangled:") { 418 repo.replace("tangled:", "https://tangled.sh/") 419 } else { 420 repo.to_string() 421 }; 422 423 let (repo, version) = extract_version(&repo); 424 425 let home = dirs::home_dir().ok_or_else(|| Error::msg("Failed to get home directory"))?; 426 427 if !Path::new(&home.join(".dotfiles")).exists() { 428 run_command("bash", &["-c", &format!("git clone {} ~/.dotfiles", repo)]) 429 .context("Failed to clone dotfiles repository")?; 430 } else { 431 run_command("bash", &["-c", "git -C ~/.dotfiles pull"]) 432 .context("Failed to update dotfiles repository")?; 433 } 434 435 if let Some(version) = version { 436 run_command("bash", &["-c", "git -C ~/.dotfiles fetch --all"])?; 437 run_command( 438 "bash", 439 &["-c", &format!("git -C ~/.dotfiles checkout {}", version)], 440 ) 441 .context("Failed to checkout dotfiles version")?; 442 run_command("bash", &["-c", "git -C ~/.dotfiles pull"]) 443 .context("Failed to update dotfiles repository")?; 444 } 445 446 run_command("bash", &["-c", "stow -d ~/.dotfiles -t ~ -- ."]) 447 .context("Failed to stow dotfiles")?; 448 449 Ok(()) 450} 451 452fn setup_oh_my_posh(theme: &str) -> Result<(), Error> { 453 run_command( 454 "bash", 455 &[ 456 "-c", 457 "sed -i '/oh-my-posh/d' ~/.bashrc || echo 'No existing oh-my-posh line found in .bashrc'", 458 ], 459 )?; 460 run_command("bash", &["-c", &format!("echo 'eval \"$(oh-my-posh init bash --config $HOME/.cache/oh-my-posh/themes/{}.omp.json)\"' >> ~/.bashrc", theme)]) 461 .context("Failed to set up Oh My Posh")?; 462 Ok(()) 463} 464 465fn setup_alias(map: &HashMap<String, String>) -> Result<(), Error> { 466 for (alias, command) in map { 467 run_command( 468 "bash", 469 &["-c", &format!("sed -i '/alias {}/d' ~/.bashrc", alias)], 470 )?; 471 run_command( 472 "bash", 473 &[ 474 "-c", 475 &format!("echo 'alias {}=\"{}\"' >> ~/.bashrc", alias, command), 476 ], 477 ) 478 .context(format!( 479 "Failed to set up alias {} for command {}", 480 alias, command 481 ))?; 482 } 483 Ok(()) 484} 485 486fn setup_paths() -> Result<(), Error> { 487 let home = dirs::home_dir().ok_or_else(|| Error::msg("Failed to get home directory"))?; 488 let local_bin = home.join(".local/bin"); 489 if !local_bin.exists() { 490 std::fs::create_dir_all(&local_bin).context("Failed to create ~/.local/bin directory")?; 491 } 492 493 run_command( 494 "bash", 495 &["-c", "grep -q 'export PATH=\"$HOME/.local/bin:$PATH\"' ~/.bashrc || echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc"], 496 ) 497 .context("Failed to add ~/.local/bin to PATH in .bashrc")?; 498 499 run_command( 500 "bash", 501 &["-c", "grep -q 'export PATH=\"/nix/var/nix/profiles/default/bin:$PATH\"' ~/.bashrc || echo 'export PATH=\"/nix/var/nix/profiles/default/bin:$PATH\"' >> ~/.bashrc"], 502 ) 503 .context("Failed to add /nix/var/nix/profiles/default/bin to PATH in .bashrc")?; 504 505 Ok(()) 506} 507 508fn setup_ssh(config: &SshConfig) -> Result<(), Error> { 509 let home = dirs::home_dir().ok_or_else(|| Error::msg("Failed to get home directory"))?; 510 let ssh_dir = home.join(".ssh"); 511 if !ssh_dir.exists() { 512 std::fs::create_dir_all(&ssh_dir).context("Failed to create ~/.ssh directory")?; 513 run_command("chmod", &["700", ssh_dir.to_str().unwrap()]) 514 .context("Failed to set permissions for ~/.ssh directory")?; 515 } 516 517 if let Some(port) = config.port { 518 run_command( 519 "bash", 520 &[ 521 "-c", 522 &format!( 523 "sudo sed -i -E '/^[#[:space:]]*Port[[:space:]]+[0-9]+/d' /etc/ssh/sshd_config && echo \"Port {port}\" | sudo tee -a /etc/ssh/sshd_config >/dev/null && sudo sshd -t" 524 ), 525 ], 526 ) 527 .context("Failed to update SSH config with port")?; 528 run_command("sudo", &["systemctl", "reload", "ssh"]) 529 .context("Failed to restart SSH service")?; 530 } 531 532 if let Some(authorized_keys) = &config.authorized_keys { 533 for key in authorized_keys { 534 run_command( 535 "bash", 536 &["-c", &format!("echo '{}' > ~/.ssh/authorized_keys", key)], 537 )?; 538 } 539 } 540 541 if ssh_dir.join("id_ed25519").exists() { 542 println!("SSH key already exists. Skipping key generation."); 543 return Ok(()); 544 } 545 546 run_command("ssh-keygen", &["-t", "ed25519"]).context("Failed to generate SSH key")?; 547 548 Ok(()) 549} 550 551fn enable_tailscale(enabled: bool) -> Result<(), Error> { 552 if enabled { 553 run_command( 554 "bash", 555 &["-c", "curl -fsSL https://tailscale.com/install.sh | sh"], 556 ) 557 .context("Failed to install Tailscale")?; 558 run_command("bash", &["-c", "sudo tailscale up"]).context("Failed to enable Tailscale")?; 559 run_command("bash", &["-c", "sudo tailscale ip"]) 560 .context("Failed to check Tailscale status")?; 561 } 562 Ok(()) 563} 564 565fn enable_neofetch(enabled: bool) -> Result<(), Error> { 566 if enabled { 567 run_command( 568 "bash", 569 &[ 570 "-c", 571 "grep -q 'neofetch' ~/.bashrc || echo 'neofetch' >> ~/.bashrc", 572 ], 573 ) 574 .context("Failed to add neofetch to .bashrc")?; 575 } 576 Ok(()) 577} 578 579fn enable_doppler(enabled: bool) -> Result<(), Error> { 580 if enabled { 581 run_command( 582 "bash", 583 &[ 584 "-c", 585 "(curl -Ls --tlsv1.2 --proto \"=https\" --retry 3 https://cli.doppler.com/install.sh || wget -t 3 -qO- https://cli.doppler.com/install.sh) | sudo sh", 586 ], 587 ) 588 .context("Failed to install Doppler")?; 589 run_command("bash", &["-c", "doppler login"]).context("Failed to log in to Doppler")?; 590 } 591 Ok(()) 592} 593 594fn setup_npm(map: &HashMap<String, String>) -> Result<(), Error> { 595 for (package, version) in map { 596 run_command( 597 "bash", 598 &[ 599 "-c", 600 &format!("source ~/.bashrc && npm install -g {}@{}", package, version), 601 ], 602 )?; 603 } 604 Ok(()) 605}