diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml index 8a38479..8543fe9 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-script.yml @@ -7,12 +7,14 @@ on: - "run_once_install-packages.sh.tmpl" - "dot_config/fish/config.fish.tmpl" - "installer/**" + - "Dockerfile.fedora-test-ci" pull_request: paths: - ".github/workflows/install-script.yml" - "run_once_install-packages.sh.tmpl" - "dot_config/fish/config.fish.tmpl" - "installer/**" + - "Dockerfile.fedora-test-ci" workflow_dispatch: jobs: @@ -31,6 +33,21 @@ jobs: - name: Run Arch installer dry-run run: docker run --rm -v "$PWD:/work:ro" dotfiles-arch-test-ci + fedora: + name: Fedora install + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build Fedora test image + run: docker build -f Dockerfile.fedora-test-ci -t dotfiles-fedora-test-ci . + + - name: Run Fedora installer dry-run + run: docker run --rm -v "$PWD:/work:ro" dotfiles-fedora-test-ci + apple-silicon: name: Apple Silicon install runs-on: macos-15 diff --git a/Dockerfile.fedora-test b/Dockerfile.fedora-test new file mode 100644 index 0000000..523349c --- /dev/null +++ b/Dockerfile.fedora-test @@ -0,0 +1,13 @@ +FROM fedora:latest + +RUN dnf install --assumeyes \ + rust \ + cargo \ + chezmoi \ + && dnf clean all + +WORKDIR /work + +ENV CARGO_TARGET_DIR=/tmp/cargo-target + +CMD ["bash", "-lc", "bash -n run_once_install-packages.sh.tmpl && cd installer && cargo build --release && /tmp/cargo-target/release/dotsetup install"] diff --git a/Dockerfile.fedora-test-ci b/Dockerfile.fedora-test-ci new file mode 100644 index 0000000..791314c --- /dev/null +++ b/Dockerfile.fedora-test-ci @@ -0,0 +1,13 @@ +FROM fedora:latest + +RUN dnf install --assumeyes \ + rust \ + cargo \ + chezmoi \ + && dnf clean all + +WORKDIR /work + +ENV CARGO_TARGET_DIR=/tmp/cargo-target + +CMD ["bash", "-lc", "bash -n run_once_install-packages.sh.tmpl && cd installer && cargo build --release && CI=1 DRY_RUN=1 /tmp/cargo-target/release/dotsetup install"] diff --git a/README.md b/README.md index 47de726..1387c29 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dotfiles -Personal dotfiles managed with [chezmoi](https://www.chezmoi.io/). Works for macOS (Apple Silicon) and Linux (Arch/CachyOS). Also installs a large set of tools and packages I want in every system. +Personal dotfiles managed with [chezmoi](https://www.chezmoi.io/). Works for macOS (Apple Silicon) and Linux (Arch/CachyOS/Fedora). Also installs a large set of tools and packages I want in every system. ## Quick start @@ -35,6 +35,8 @@ Examples: ```bash just test-arch just test-arch-ci +just test-fedora +just test-fedora-ci ``` ## Wiki diff --git a/installer/bin/dotsetup-linux-x86_64 b/installer/bin/dotsetup-linux-x86_64 index e6b7c38..d4eda05 100755 Binary files a/installer/bin/dotsetup-linux-x86_64 and b/installer/bin/dotsetup-linux-x86_64 differ diff --git a/installer/packages.toml b/installer/packages.toml index bf057c6..7aadb7d 100644 --- a/installer/packages.toml +++ b/installer/packages.toml @@ -53,6 +53,59 @@ pacman = [ ] aur = [] +[fedora] +copr = [ + "dejan/lazygit", + "varlad/zellij", + "lihaohong/yazi", +] +dnf = [ + "git", + "git-lfs", + "fish", + "neovim", + "tree-sitter-cli", + "@development-tools", + "wl-clipboard", + "xclip", + "moby-engine", + "tailscale", + "ripgrep", + "fd-find", + "fzf", + "gdu", + "btop", + "python3", + "go", + "zoxide", + "lsd", + "file", + "ffmpeg-free", + "unzip", + "7zip", + "jq", + "poppler-utils", + "ImageMagick", + "thefuck", + "fastfetch", + "figlet", + "cowsay", + "fortune-mod", + "tealdeer", + "curl", + "jetbrains-mono-fonts", + "just", + "navi", + # Virtualization (QEMU) + "qemu-kvm", + "virt-manager", + "virt-viewer", + "dnsmasq", + "nmap-ncat", + "libvirt", + "edk2-ovmf", +] + [macos.brew] formula = [ "git", @@ -104,6 +157,7 @@ casks = [ packages = [ { check = "topgrade", package = "topgrade" }, { check = "tldr", package = "tealdeer" }, + { check = "btm", package = "bottom" }, ] [go] diff --git a/installer/src/helpers.rs b/installer/src/helpers.rs index c4deba1..e7b10e0 100644 --- a/installer/src/helpers.rs +++ b/installer/src/helpers.rs @@ -1,5 +1,6 @@ use indicatif::ProgressBar; use inquire::{Confirm, Select}; +use std::process::Command; /// Prompts the user with a spinner and a confirmation message. /// Returns `true` if the user confirms, `false` otherwise. @@ -58,3 +59,61 @@ pub fn is_non_interactive() -> bool { || std::env::var("DRY_RUN").is_ok() || !std::io::IsTerminal::is_terminal(&std::io::stdin()) } + +pub fn root_command(command: &str, args: Vec) -> Vec { + root_command_for_uid(command, args, current_uid()) +} + +fn current_uid() -> Option { + let output = Command::new("id").arg("-u").output().ok()?; + if !output.status.success() { + return None; + } + + std::str::from_utf8(&output.stdout) + .ok()? + .trim() + .parse() + .ok() +} + +fn root_command_for_uid(command: &str, args: Vec, uid: Option) -> Vec { + let mut command_parts = Vec::new(); + + if uid != Some(0) { + command_parts.push("sudo".into()); + } + + command_parts.push(command.into()); + command_parts.extend(args); + command_parts +} + +#[cfg(test)] +mod tests { + use super::root_command_for_uid; + + #[test] + fn root_command_skips_sudo_for_root() { + assert_eq!( + root_command_for_uid("dnf", vec!["install".into()], Some(0)), + vec!["dnf".to_string(), "install".to_string()] + ); + } + + #[test] + fn root_command_uses_sudo_for_non_root() { + assert_eq!( + root_command_for_uid("dnf", vec!["install".into()], Some(1000)), + vec!["sudo".to_string(), "dnf".to_string(), "install".to_string()] + ); + } + + #[test] + fn root_command_uses_sudo_when_uid_is_unknown() { + assert_eq!( + root_command_for_uid("dnf", vec!["install".into()], None), + vec!["sudo".to_string(), "dnf".to_string(), "install".to_string()] + ); + } +} diff --git a/installer/src/installers/arch.rs b/installer/src/installers/arch.rs index 8786b72..a098424 100644 --- a/installer/src/installers/arch.rs +++ b/installer/src/installers/arch.rs @@ -7,7 +7,7 @@ use strum_macros::{Display, EnumIter}; use which::which; use crate::{ - helpers::{is_non_interactive, select_with_spinner}, + helpers::{is_non_interactive, root_command, select_with_spinner}, packages::Packages, platform, }; @@ -70,9 +70,17 @@ impl ArchInstaller { AurHelper::None => return Ok(()), }; - let status = Command::new("sudo") - .args(["pacman", "-S", "--needed", "base-devel", "git"]) - .status()?; + let command = root_command( + "pacman", + vec![ + "-S".into(), + "--needed".into(), + "base-devel".into(), + "git".into(), + ], + ); + let status = + spinner.suspend(|| Command::new(&command[0]).args(&command[1..]).status())?; if !status.success() { anyhow::bail!("Failed to install AUR build dependencies"); @@ -108,14 +116,14 @@ impl ArchInstaller { let pacman = &self.packages.arch.pacman; if !pacman.is_empty() { - let mut args = vec!["pacman".into(), "-S".into(), "--needed".into()]; + let mut args = vec!["-S".into(), "--needed".into()]; if is_non_interactive() { args.push("--noconfirm".into()); } args.append(&mut pacman.clone()); - self.commands.push(args); + self.commands.push(root_command("pacman", args)); } else { println!("{}", style("No Pacman packages to install").bold().yellow()); } diff --git a/installer/src/installers/fedora.rs b/installer/src/installers/fedora.rs new file mode 100644 index 0000000..c855fa2 --- /dev/null +++ b/installer/src/installers/fedora.rs @@ -0,0 +1,70 @@ +use console::style; + +use crate::{ + helpers::{is_non_interactive, root_command}, + packages::Packages, + platform, +}; + +pub struct FedoraInstaller { + packages: Packages, + commands: Vec>, +} + +impl FedoraInstaller { + pub fn new(packages: Packages) -> Self { + Self { + packages, + commands: Vec::new(), + } + } + + pub fn get_commands(&self) -> &[Vec] { + &self.commands + } + + pub fn install(&mut self) -> anyhow::Result<()> { + if !platform::is_fedora() { + return Ok(()); + } + + let copr = &self.packages.fedora.copr; + if !copr.is_empty() { + self.commands.push(root_command( + "dnf", + vec![ + "install".into(), + "--assumeyes".into(), + "dnf-plugins-core".into(), + ], + )); + + for repository in copr { + self.commands.push(root_command( + "dnf", + vec![ + "copr".into(), + "enable".into(), + "--assumeyes".into(), + repository.clone(), + ], + )); + } + } + + let dnf = &self.packages.fedora.dnf; + if dnf.is_empty() { + println!("{}", style("No DNF packages to install").bold().yellow()); + return Ok(()); + } + + let mut args = vec!["install".into()]; + if is_non_interactive() { + args.push("--assumeyes".into()); + } + args.append(&mut dnf.clone()); + self.commands.push(root_command("dnf", args)); + + Ok(()) + } +} diff --git a/installer/src/installers/installer.rs b/installer/src/installers/installer.rs index 28a196d..c5f1290 100644 --- a/installer/src/installers/installer.rs +++ b/installer/src/installers/installer.rs @@ -6,13 +6,14 @@ use which::which; use crate::{ helpers::confirm_with_spinner, - installers::{arch::ArchInstaller, mac::AppleSiliconInstaller}, + installers::{arch::ArchInstaller, fedora::FedoraInstaller, mac::AppleSiliconInstaller}, packages::Packages, platform::{self, LinuxDistro, Platform}, }; pub struct Installer { arch: ArchInstaller, + fedora: FedoraInstaller, silicon: AppleSiliconInstaller, pub packages: Packages, pub commands: Vec>, @@ -28,12 +29,14 @@ impl Installer { let packages = Packages::load()?; let arch_packages = packages.clone(); + let fedora_packages = packages.clone(); let silicon_packages = packages.clone(); Ok(Self { packages, commands: Vec::new(), arch: ArchInstaller::new(arch_packages), + fedora: FedoraInstaller::new(fedora_packages), silicon: AppleSiliconInstaller::new(silicon_packages), spinner, }) @@ -70,6 +73,9 @@ impl Installer { Platform::Linux(LinuxDistro::Arch | LinuxDistro::CachyOS) => { self.arch.install()?; } + Platform::Linux(LinuxDistro::Fedora) => { + self.fedora.install()?; + } other => anyhow::bail!("Unsupported OS: {other:?}"), } @@ -90,10 +96,12 @@ impl Installer { self.spinner.set_message("Installing homebrew..."); if which("brew").is_err() { - let status = Command::new("/bin/bash") - .arg("-c") - .arg(r#"curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | /bin/bash"#) - .status()?; + let status = self.spinner.suspend(|| { + Command::new("/bin/bash") + .arg("-c") + .arg(r#"curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | /bin/bash"#) + .status() + })?; if !status.success() { return Err(anyhow::anyhow!("Homebrew install failed")); @@ -103,10 +111,12 @@ impl Installer { self.spinner.set_message("Installing rust..."); if which("rustup").is_err() { - let status = Command::new("sh") - .arg("-c") - .arg(r#"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"#) - .status()?; + let status = self.spinner.suspend(|| { + Command::new("sh") + .arg("-c") + .arg(r#"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"#) + .status() + })?; if !status.success() { return Err(anyhow::anyhow!("Rust install failed")); @@ -318,13 +328,21 @@ impl Installer { fn run_commands(&mut self) -> anyhow::Result<()> { let mut commands = self.arch.get_commands().to_vec(); + commands.extend(self.fedora.get_commands().to_vec()); commands.extend(self.silicon.get_commands().to_vec()); commands.extend(self.commands.clone()); for command in commands { let mut cmd = Command::new(&command[0]); cmd.args(&command[1..]); - if !cmd.status()?.success() { + + let status = if command[0] == "sudo" { + self.spinner.suspend(|| cmd.status())? + } else { + cmd.status()? + }; + + if !status.success() { return Err(anyhow::anyhow!("Failed to run command: {:?}", command)); } } diff --git a/installer/src/installers/mod.rs b/installer/src/installers/mod.rs index 36a9ba1..0ada422 100644 --- a/installer/src/installers/mod.rs +++ b/installer/src/installers/mod.rs @@ -1,3 +1,4 @@ pub mod arch; +pub mod fedora; pub mod installer; pub mod mac; diff --git a/installer/src/packages.rs b/installer/src/packages.rs index af23afe..d29094d 100644 --- a/installer/src/packages.rs +++ b/installer/src/packages.rs @@ -6,6 +6,12 @@ pub struct Arch { pub aur: Vec, } +#[derive(Deserialize, Debug, Clone)] +pub struct Fedora { + pub dnf: Vec, + pub copr: Vec, +} + #[derive(Deserialize, Debug, Clone)] pub struct Brew { pub formula: Vec, @@ -60,6 +66,7 @@ pub struct Manual { #[derive(Deserialize, Debug, Clone)] pub struct Packages { pub arch: Arch, + pub fedora: Fedora, pub macos: MacOS, pub cargo: CargoPackages, pub go: ToolPackages, diff --git a/installer/src/platform.rs b/installer/src/platform.rs index 748a059..81f3273 100644 --- a/installer/src/platform.rs +++ b/installer/src/platform.rs @@ -9,6 +9,7 @@ pub enum Platform { pub enum LinuxDistro { Arch, CachyOS, + Fedora, Other { id: Option }, } @@ -27,20 +28,32 @@ pub fn is_arch_like() -> bool { ) } +pub fn is_fedora() -> bool { + matches!(current(), Platform::Linux(LinuxDistro::Fedora)) +} + fn linux_platform() -> Platform { let Ok(os_release) = std::fs::read_to_string("/etc/os-release") else { return Platform::Unsupported; }; - let id = os_release_value(&os_release, "ID"); - let id_like = os_release_value(&os_release, "ID_LIKE"); + linux_platform_from_os_release(&os_release) +} + +fn linux_platform_from_os_release(os_release: &str) -> Platform { + let id = os_release_value(os_release, "ID"); + let id_like = os_release_value(os_release, "ID_LIKE"); let distro = match (id.as_deref(), id_like.as_deref()) { (Some("cachyos"), _) => LinuxDistro::CachyOS, (Some("arch"), _) => LinuxDistro::Arch, + (Some("fedora"), _) => LinuxDistro::Fedora, (_, Some(value)) if value.split_whitespace().any(|part| part == "arch") => { LinuxDistro::Arch } + (_, Some(value)) if value.split_whitespace().any(|part| part == "fedora") => { + LinuxDistro::Fedora + } _ => LinuxDistro::Other { id }, }; @@ -56,3 +69,16 @@ fn os_release_value(contents: &str, key: &str) -> Option { Some(value.trim_matches('"').to_string()) } + +#[cfg(test)] +mod tests { + use super::{LinuxDistro, Platform, linux_platform_from_os_release}; + + #[test] + fn detects_fedora() { + assert_eq!( + linux_platform_from_os_release("ID=fedora\nID_LIKE=\"fedora\"\n"), + Platform::Linux(LinuxDistro::Fedora) + ); + } +} diff --git a/justfile b/justfile index a0174ab..e9d0b9d 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,7 @@ set shell := ["bash", "-cu"] image := "dotfiles-arch-test" +fedora_image := "dotfiles-fedora-test" # Show available commands default: @@ -22,6 +23,22 @@ test-arch: build-arch test-arch-ci: build-arch-ci docker run --rm -v "$PWD:/work:ro" {{image}} +# Build the Fedora test Docker image +build-fedora: + docker build -f Dockerfile.fedora-test -t {{fedora_image}} . + +# Build the Fedora test ci Docker image +build-fedora-ci: + docker build -f Dockerfile.fedora-test-ci -t {{fedora_image}} . + +# Test Fedora installer in Docker +test-fedora: build-fedora + docker run --rm -it -v "$PWD:/work:ro" {{fedora_image}} + +# Test Fedora installer in Docker without interaction +test-fedora-ci: build-fedora-ci + docker run --rm -v "$PWD:/work:ro" {{fedora_image}} + # Build the dotsetup binary locally build-dotsetup: cd installer && cargo build --release diff --git a/wiki/Install-Script-Testing.md b/wiki/Install-Script-Testing.md index da8a83c..c2e7591 100644 --- a/wiki/Install-Script-Testing.md +++ b/wiki/Install-Script-Testing.md @@ -82,7 +82,7 @@ cd installer DRY_RUN=1 ./target/release/dotsetup install ``` -## Arch and CachyOS +## Arch, CachyOS, and Fedora The installer supports Arch and CachyOS through the same pacman/AUR path. It detects Arch-like systems from `/etc/os-release`. @@ -105,6 +105,19 @@ docker build -f Dockerfile.arch-test-ci -t dotfiles-arch-test-ci . docker run --rm -v "$PWD:/work:ro" dotfiles-arch-test-ci ``` +The installer supports Fedora through DNF. Use the equivalent Docker targets +to run the full package installation against the current Fedora base image: + +```bash +just test-fedora +just test-fedora-ci +``` + +`test-fedora` runs interactively. `test-fedora-ci` sets `CI=1 DRY_RUN=1` so +prompts take defaults, but package commands still run inside the container. +The container validates Fedora package installation; it does not launch KDE +Plasma. + ## macOS The macOS installer supports Apple Silicon only. It rejects Intel macOS. @@ -138,6 +151,8 @@ before compiling the installer. This handles hosted runner images where - Arch: builds `Dockerfile.arch-test-ci` and runs the installer in the container. +- Fedora: builds `Dockerfile.fedora-test-ci` and runs the installer in the + container. - Apple Silicon: runs on `macos-15`, repairs the Xcode developer path, builds the Rust installer, runs it with `CI=1 DRY_RUN=1 INSTALL_CASKS=0`, then verifies expected commands are available. diff --git a/wiki/Installed-Tools.md b/wiki/Installed-Tools.md index 882bee2..1482681 100644 --- a/wiki/Installed-Tools.md +++ b/wiki/Installed-Tools.md @@ -49,6 +49,8 @@ runtimes, diagnostics, and media/document utilities. - `just --list`: show recipes. - `just check`: local checks. - `just test-arch-ci`: run installer test container. + - `just test-fedora-ci`: run Fedora installer test container. +- `bottom` (`btm`): terminal system monitor, installed through Cargo on Fedora. - `actionlint`: validate GitHub Actions workflows. - `actionlint .github/workflows/install-script.yml`. diff --git a/wiki/Setup.md b/wiki/Setup.md index f3ef5b4..a544675 100644 --- a/wiki/Setup.md +++ b/wiki/Setup.md @@ -74,6 +74,10 @@ only needed when rebuilding the committed binaries. The installer package list lives in `installer/packages.toml`; the Rust code handles platform detection and command execution. +On Fedora, Lazygit, Zellij, and Yazi are installed from their documented COPR +repositories. Bottom is installed from crates.io. The remaining Fedora tools +come from the standard DNF repositories. + On non-CI run-once script runs, the wrapper calls `dotsetup bootstrap` before `dotsetup install`. The bootstrap command has its own confirmation prompt. CI runs skip bootstrap and go straight to `dotsetup install`. @@ -90,9 +94,9 @@ not completed for that local CLI profile. The script checks for commands before installing packages, so tools already installed through another manager are left alone. -Supported installer targets are macOS on Apple Silicon and Arch/CachyOS Linux. -Other Linux distributions are detected separately, but package installation is -not implemented for them yet. +Supported installer targets are macOS on Apple Silicon and Arch/CachyOS/Fedora +Linux. Other Linux distributions are detected separately, but package +installation is not implemented for them yet. To force the bootstrap again, run the rendered script manually or clear the relevant chezmoi script state. diff --git a/wiki/TODO.md b/wiki/TODO.md index dc76d75..83056c0 100644 --- a/wiki/TODO.md +++ b/wiki/TODO.md @@ -1,8 +1,7 @@ 1. Obsidian integration -2. Add fedora support -3. Uninstall packages and reset/remove dotfiles? -4. Automatic periodic checks to pipeline -5. gdu-go to mac only -6. Add/update committed macOS arm64 dotsetup binary -7. Check if repo contains unnecessary things (random python scripts etc) -8. Add bin to PATH? +2. Uninstall packages and reset/remove dotfiles? +3. Automatic periodic checks to pipeline +4. gdu-go to mac only +5. Add/update committed macOS arm64 dotsetup binary +6. Check if repo contains unnecessary things (random python scripts etc) +7. Add bin to PATH?