Opinionated Android 15+ Linux Terminal Setup
android
linux
command-line-tools
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}