diff --git a/README.md b/README.md index 5fedcf6..2f625ac 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,15 @@ # BADM - Born Again Dotfile Manager -BADM is a new (yes, again a new one) Dotfile manager. It uses Git as its backbone. +BADM is a new (yes, again a new one) dotfile and package manager. It uses Git as its backbone. -This Dotfile manager is **ready to use**, but some features are not implemented yet. Have a -look at [Planned features / Known issues](#planned-features--known-issues) to see what is planned to be added. +Packages are managed via a mixture of declarative and imperative configuration file. -## Setup +**Features:** -If it's your first time you are using this tool, you might want to follow these setup steps: +- [Manage your **dotfiles**](docs/dotfiles_management.md) +- [Manage your **packages**](docs/package_management.md) -### Create a fresh Dotfiles repository - -In case you do not have a repository to manage your Dotfiles (or you want to start over) it is pretty straight forward -to get going: - -1. Create new Dotfiles repository - 1. Create a remote repository (e.g. on GitHub, GitLab, ...). Make sure you are able to push and pull to that - repository. - 2. `badm new ` -2. Start managing your Dotfiles - 1. `badm add ` → Your Dotfiles get automatically pushed to your remote repository - 2. `badm rm ` → Your files get removed from the Dotfiles repository and restored - to its original location. **No files get lost!** - 3. `badm save` → Write any changes to the local Dotfiles repository to the remote one. Needed after each change - to local Dotfiles. - -### Get an existing BADM repository - -`badm get ` - It's that easy. - -## Commands +## Command overview | Command | Action | |--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -40,12 +20,8 @@ to get going: | `new` | Initialize a fresh local BADM repository, where new Dotfiles can be added afterwards. | | `get` | Pull an already existing BADM remote repository and persist everything on the system. | | `reset` | Reset all changes made by BADM. It replaces populated symlinks with the original Dotfiles. Choose this command if you do not want to use BADM anymore. Choose the `--dryRun` flag to see what would happen when executing this command. | +| `packages` | Install packages. For detailed help visit [Manage your **packages**](docs/package_management.md). | | `update` | Update BADM to the newest version in-place. | | `help` | Print helpful information. | | `version` | Check your current version. | | `completion` | Generate the autocompletion script for the specified shell. | - -## Planned features / Known issues - -- Execute custom scripts on wish (e.g. pacman, yay, ...) -- Actual configuration features diff --git a/badm.schema.json b/badm.schema.json new file mode 100644 index 0000000..635494c --- /dev/null +++ b/badm.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/Koenigseder/badm/blob/master/badm.schema.json", + "title": "BADM Packages Configuration Schema", + "description": "Schema for the BADM packages configuration file.", + "type": "object", + "required": [ + "packages" + ], + "properties": { + "packages": { + "type": "object", + "description": "Defines package managers, installation scopes, and packages to install.", + "required": [ + "packageManagers", + "installScopes" + ], + "properties": { + "packageManagers": { + "type": "object", + "description": "A map of package managers and their installation commands.", + "additionalProperties": { + "type": "string", + "description": "The command used to install packages with this package manager." + } + }, + "installScopes": { + "type": "object", + "description": "Defines installation variations (e.g., 'basics', 'slim', 'full').", + "additionalProperties": { + "type": "object", + "description": "Definition of an installation scope.", + "properties": { + "dependsOn": { + "type": "string", + "description": "Name of another installation scope that this scope depends on." + }, + "packages": { + "type": "object", + "description": "List of packages to install using the defined package managers.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "description": "Name of the package to install." + } + } + }, + "scripts": { + "type": "array", + "items": { + "type": "string", + "description": "Name of a script to execute after package installation." + } + } + } + } + } + } + }, + "scripts": { + "type": "object", + "description": "Defines scripts that can be referenced in installation scopes.", + "additionalProperties": { + "type": "object", + "description": "Definition of a script.", + "required": [ + "exec", + "shell" + ], + "properties": { + "exec": { + "type": "string", + "description": "The command or script to execute (can be multi-line)." + }, + "shell": { + "type": "string", + "description": "The shell in which the script will be executed (e.g., 'bash')." + } + } + } + } + } +} diff --git a/cmd/new.go b/cmd/new.go index 71260a5..c5fb721 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -46,23 +46,6 @@ func createNewBadmRepo(args []string) { fmt.Printf("Directory %s already exists\n", repoPath) } - // Check if .badm.yaml file exists in .dotfiles folder - _, err = os.Stat(cfgFile) - if errors.Is(err, fs.ErrNotExist) { - // Create .badm.yaml if it does not exist - file, err := os.Create(cfgFile) - if err != nil { - fmt.Println("Unable creating .badm.yaml config file:", err) - os.Exit(1) - } - - defer file.Close() - - fmt.Println("Created .badm.yaml") - } else { - fmt.Println("Config file .badm.yaml already exists") - } - // Check if .gitignore file exists in .dotfiles folder gitignore := fmt.Sprintf("%s/%s", repoPath, ".gitignore") diff --git a/cmd/packages.go b/cmd/packages.go new file mode 100644 index 0000000..2949f0c --- /dev/null +++ b/cmd/packages.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "os" + + pkgs "github.com/Koenigseder/badm/internal/packages" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(packages) +} + +var packages = &cobra.Command{ + Use: "packages", + Short: "Install various packages", + Long: `Install various packages defined in .badm.yaml`, + Run: func(_ *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("Please provide a install scope name") + os.Exit(1) + } + + installScopeName := args[0] + + cfgFile, err := pkgs.ReadConfigFile(cfgFilePath) + if err != nil { + fmt.Printf("Failed reading config file %s: %v\n", cfgFilePath, err) + os.Exit(1) + } + + cfgFile.Directory = repoPath + + err = cfgFile.InstallPackages(installScopeName) + if err != nil { + fmt.Printf("Failed installing packages for '%s': %v\n", installScopeName, err) + os.Exit(1) + } + }, +} diff --git a/cmd/root.go b/cmd/root.go index c071eb3..9a7d5d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,7 +13,7 @@ var ( homeDir string repoPath string repoRootAlias string - cfgFile string + cfgFilePath string // Flags overrideExistingFiles bool @@ -49,7 +49,7 @@ func init() { } repoPath = fmt.Sprintf("%s/%s", homeDir, repoName) - cfgFile = fmt.Sprintf("%s/.badm.yaml", repoPath) + cfgFilePath = fmt.Sprintf("%s/.badm.yaml", repoPath) } // Execute the app diff --git a/docs/dotfiles_management.md b/docs/dotfiles_management.md new file mode 100644 index 0000000..f684b30 --- /dev/null +++ b/docs/dotfiles_management.md @@ -0,0 +1,39 @@ +# Dotfiles management setup + +With **BADM** you are able to manage all your dotfiles in a central place and with minimal effort. + +## Relevant commands + +| Command | Action | +|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `add` | Add a Dotfile (or multiple) to your remote Dotfile repository. It gets replaced with a symlink. Choose the `--override` flag to override already existing files on your system. | +| `rm` | Remove a Dotfile (or multiple) from your remote Dotfile repository. The symlink gets replaced with the original file. | +| `save` | Write any changes to the local Dotfiles repository to the remote one. Needed after each change to local Dotfiles. | +| `fetch` | Manually fetch the current remote repository stage and persist remote changes onto the local system. Choose the `--override` flag to override already existing files on your system. | +| `new` | Initialize a fresh local BADM repository, where new Dotfiles can be added afterwards. | +| `get` | Pull an already existing BADM remote repository and persist everything on the system. | +| `reset` | Reset all changes made by BADM. It replaces populated symlinks with the original Dotfiles. Choose this command if you do not want to use BADM anymore. Choose the `--dryRun` flag to see what would happen when executing this command. | + +## Setup + +If it's your first time you are using this tool, you might want to follow these setup steps: + +### Create a fresh Dotfiles repository + +In case you do not have a repository to manage your Dotfiles (or you want to start over) it is pretty straight forward +to get going: + +1. Create new Dotfiles repository + 1. Create a remote repository (e.g. on GitHub, GitLab, ...). Make sure you are able to push and pull to that + repository. + 2. `badm new ` +2. Start managing your Dotfiles + 1. `badm add ` → Your Dotfiles get automatically pushed to your remote repository + 2. `badm rm ` → Your files get removed from the Dotfiles repository and restored + to its original location. **No files get lost!** + 3. `badm save` → Write any changes to the local Dotfiles repository to the remote one. Needed after each change + to local Dotfiles. + +### Get an existing BADM repository + +`badm get ` - It's that easy. diff --git a/docs/example.badm.yaml b/docs/example.badm.yaml new file mode 100644 index 0000000..2776041 --- /dev/null +++ b/docs/example.badm.yaml @@ -0,0 +1,111 @@ +# e.g. `badm packages ` +packages: + # Define package managers with respective commands + packageManagers: + pacman: sudo pacman -S --needed + yay: yay -S --needed + flatpak-flathub: flatpak install flathub + + # Define installation variations - dependencies can be included + installScopes: + # The very basics + basics: # Name of variation + packages: # Which packages should be installed + pacman: # Which package manager to use (name of `packageManagers`) - order is respected + - git # Name of package + - base-devel + - bluez + - bluez-utils + # Additionally we execute scripts after package installation + scripts: + - setup-bluetooth + + # Slim installation + slim: + dependsOn: basics + packages: + pacman: + - alacritty + - zed + - starship + - okular + - spectacle + - discover + - rofi + yay: + - brave-bin + + # Almost full installation + full: + dependsOn: slim # This variation depends on `slim` - `slim` gets executed first + packages: + pacman: + - go + - keepassxc + - signal-desktop + - podman + - flatpak + - discover + yay: + - intellij-idea-ultimate-edition + - megasync-bin + flatpak-flathub: + - com.discordapp.Discord # Discord + - com.spotify.Client # Spotify + - io.podman_desktop.PodmanDesktop # Podman Desktop + - de.haeckerfelix.Fragments # Fragments + + # Things needed for Displaylink driver + displaylink: + dependsOn: basics + packages: + pacman: + - linux-lts-headers + yay: + - evdi-dkms + - displaylink + scripts: + - setup-displaylink + - prompt-reboot + + # Some things needed for Kubernetes development + k8s: + dependsOn: slim + packages: + pacman: + - kubectl + - minikube + +# All scripts - can be referenced with e.g. `script: install-yay` +scripts: + install-yay: + exec: | + git clone https://aur.archlinux.org/yay.git + cd yay + makepkg -si + cd .. + rm -rf yay + shell: bash + + setup-bluetooth: + exec: | + sudo systemctl start bluetooth.service + sudo systemctl enable bluetooth.service + shell: bash + + setup-displaylink: + exec: | + sudo systemctl start displaylink.service + sudo systemctl enable displaylink.service + shell: bash + + prompt-reboot: + exec: | + read -p "A reboot is required. Reboot now? [y/N] " answer + if [[ "$answer" =~ ^[Yy]$ ]]; then + echo "Rebooting..." + reboot + else + echo "Not rebooting. Have a great day :)" + fi + shell: bash diff --git a/docs/package_management.md b/docs/package_management.md new file mode 100644 index 0000000..e497ba7 --- /dev/null +++ b/docs/package_management.md @@ -0,0 +1,49 @@ +# Package management setup + +With **BADM** you are able to manage all your packages. This comes in handy if you decide to set up a new installation +with all necessary packages but do not want to manually install each and every package manually. + +Also, you can define different **installation scopes* where you are able to manage as granular as you want which +packages +to install. + +## Relevant commands + +| Command | Action | +|------------|-------------------| +| `packages` | Install packages. | + +## Setup + +1. Create a config file `.badm.yaml` in your dotfiles repository (it's located under `/home//.dotfiles`) +2. Edit it as you will and define various `packageManagers`, `installScopes` and `scripts` +3. Use `badm packages ` to install all packages and execute all scripts defined by this installation scope + +### Config file `.badm.yaml` + +For a better DevEx writing the config file you can find the according **JSON schema +mapping** [badm.schema.json](../badm.schema.json) in this repository which can be used in e.g. IntelliJ for e.g. type +hints. + +## Structure + +### `packages` + +- **`packageManagers`**: A map of package managers and their installation commands. The key is used as reference. +- **`installScopes`**: A map which defines installation variations (e.g., `basics`, `slim`, `full`). The key is used as + reference. + - **`packages`**: A map with packages to install using the defined package managers (package managers are referenced + by the key defined at `packageManagers`). The order is respected. + - **`dependsOn`**: (Optional) Name of another installation scope that this scope depends on and which gets executed + prior. + - **`scripts`**: (Optional) List of scripts to execute after package installation. The order is respected. + +### `scripts` + +- Defines scripts that can be referenced in installation scopes. + - **`exec`**: The command or script to execute. + - **`shell`**: The shell in which the script will be executed (e.g., `bash`). + +## Example + +An example can be found [here](example.badm.yaml). diff --git a/go.mod b/go.mod index 860005c..6216144 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,15 @@ module github.com/Koenigseder/badm go 1.25.1 require ( + github.com/creack/pty v1.1.24 github.com/spf13/cobra v1.10.2 - golang.org/x/mod v0.30.0 + golang.org/x/mod v0.31.0 + golang.org/x/term v0.38.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index f71883e..050b8d2 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -8,6 +10,13 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/packages/packages.go b/internal/packages/packages.go new file mode 100644 index 0000000..04f167b --- /dev/null +++ b/internal/packages/packages.go @@ -0,0 +1,163 @@ +package packages + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + + "github.com/creack/pty" + "golang.org/x/term" + "gopkg.in/yaml.v3" +) + +// ReadConfigFile reads and returns the config file .badm.yaml as ConfigFile +func ReadConfigFile(cfgFilePath string) (*ConfigFile, error) { + data, err := os.ReadFile(cfgFilePath) + if err != nil { + return nil, err + } + + cfgFile := new(ConfigFile) + + err = yaml.Unmarshal(data, cfgFile) + if err != nil { + return nil, err + } + + return cfgFile, nil +} + +func (c *ConfigFile) InstallPackages(installScopeName string) error { + // Check if install scope name exists + installScope, exists := c.Packages.InstallScopes[installScopeName] + if !exists { + return fmt.Errorf("install scope '%s' does not exist", installScopeName) + } + + // First, install dependent packages + dependsOn := installScope.DependsOn + if dependsOn == installScopeName { + return errors.New("cyclic dependencies are not allowed") + } + + if dependsOn != "" { + if err := c.InstallPackages(dependsOn); err != nil { + return fmt.Errorf("failed installing depending scope '%s': %v", dependsOn, err) + } + } + + for packageManagerName, packageNames := range installScope.Packages { + // Check if package manager name exists + packageManagerCmd, exists := c.Packages.PackageManagers[packageManagerName] + if !exists { + return fmt.Errorf("package manager alias '%s' does not exist", packageManagerName) + } + + cmdName, cmdArgs := parseCommandString(packageManagerCmd) + + // Construct command + cmd := exec.Command(cmdName, append(cmdArgs, packageNames...)...) + + err := executeCommandInPTY(cmd) + if err != nil { + return fmt.Errorf("failed installing packages for '%s': %v", packageManagerName, err) + } + } + + // Execute scripts if present + if installScope.Scripts == nil { + return nil + } + + // Create temp script directory + tempDir := fmt.Sprintf("%s/.temp", c.Directory) + + err := os.MkdirAll(tempDir, 0777) + if err != nil { + return fmt.Errorf("error creating temporary directory at '%s': %v", tempDir, err) + } + + // ... and defer delete it + defer func() { + err := os.RemoveAll(tempDir) + if err != nil { + fmt.Printf("error removing temporary directory at '%s': %v", tempDir, err) + } + }() + + // Execute each script + for _, scriptName := range installScope.Scripts { + script, exists := c.Scripts[scriptName] + if !exists { + return fmt.Errorf("script '%s' does not exist", scriptName) + } + + scriptPath := fmt.Sprintf("%s/%s", tempDir, scriptName) + + err := os.WriteFile(scriptPath, []byte(script.Exec), 0777) + if err != nil { + return fmt.Errorf("error writing temporary script at '%s': %v", scriptPath, err) + } + + // Execute in PTY + cmd := exec.Command(script.Shell, scriptPath) + + err = executeCommandInPTY(cmd) + if err != nil { + return fmt.Errorf("error executing temporary script at '%s': %v", scriptName, err) + } + } + + return nil +} + +func executeCommandInPTY(cmd *exec.Cmd) error { + // Hand over to PTY + ptmx, err := pty.Start(cmd) + if err != nil { + return fmt.Errorf("failed starting pseudo terminal: %v", err) + } + + defer ptmx.Close() + + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + go func() { + for range ch { + if err := pty.InheritSize(os.Stdin, ptmx); err != nil { + fmt.Printf("error resizing pty: %v\n", err) + } + } + }() + ch <- syscall.SIGWINCH + defer func() { signal.Stop(ch); close(ch) }() + + // Set stdin in raw mode. + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + panic(err) + } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() // Best effort. + + // Copy stdin to the pty and the pty to stdout. + // NOTE: The goroutine will keep reading until the next keystroke before returning. + go func() { _, _ = io.Copy(ptmx, os.Stdin) }() + _, _ = io.Copy(os.Stdout, ptmx) + + return nil +} + +func parseCommandString(cmdString string) (string, []string) { + cmdParts := strings.Split(cmdString, " ") + + if len(cmdParts) == 1 { + return cmdParts[0], nil + } + + return cmdParts[0], cmdParts[1:] +} diff --git a/internal/packages/types.go b/internal/packages/types.go new file mode 100644 index 0000000..851ba40 --- /dev/null +++ b/internal/packages/types.go @@ -0,0 +1,24 @@ +package packages + +type ConfigFile struct { + Packages Package `yaml:"packages"` + Scripts map[string]Script `yaml:"scripts"` + + Directory string +} + +type Package struct { + PackageManagers map[string]string `yaml:"packageManagers"` + InstallScopes map[string]InstallScope `yaml:"installScopes"` +} + +type InstallScope struct { + DependsOn string `yaml:"dependsOn"` + Packages map[string][]string `yaml:"packages"` + Scripts []string `yaml:"scripts"` +} + +type Script struct { + Exec string `yaml:"exec"` + Shell string `yaml:"shell"` +}