Changes
4 changed files (+330/-50)
-
-
@@ -48,10 +48,16 @@ `specialisation` activates the given Home Manager specialisation.```sh # Switch to dark mode specialisation dark specialisation set dark # Switch to light mode specialisation light specialisation set light # Switch to non-specialised profile specialisation unset # Clean obsolete home-manager generations specialisation clean ``` ## License
-
-
-
@@ -32,7 +32,7 @@modules = [ ./features ({ config, ... }: { ({ config, ... }: rec { # Turn off Home Manager news bs news.display = "silent";
-
@@ -51,8 +51,10 @@ };home.packages = [ pkgs.home-manager (import ./programs/specialise { inherit pkgs; (pkgs.callPackage ./programs/specialise { specialisations = pkgs.lib.attrsets.mapAttrsToList (name: _: name) specialisation; }) ];
-
-
-
@@ -1,12 +1,42 @@{ pkgs }: pkgs.buildGoModule { { pkgs, installShellFiles, lib, buildGoModule, specialisations ? null }: buildGoModule rec { name = "specialise"; src = ./.; nativeBuildInputs = [ installShellFiles ]; vendorHash = null; ldflags = [ "-X main.homeManagerPath=${pkgs.home-manager}/bin/home-manager" ]; postInstall = '' installShellCompletion --zsh --cmd ${name} <(cat << "EOF" #compdef _${name} ${name} function _${name} { local line _arguments -C \ "--help[Output usage text to stdout]" \ "--verbose[Enable verbose logging]" \ "1: :(set unset clean)" \ "*::arg:->args" case $line[1] in set) _${name}_set ;; esac } function _${name}_set { _arguments "1: :(${if specialisations == null then "" else lib.strings.concatStringsSep " " specialisations })" } EOF) ''; }
-
-
-
@@ -1,103 +1,345 @@package main import ( "errors" "flag" "fmt" "io/fs" "log" "os" "os/exec" "strings" ) // Path to the home-manager bin. // Normally this will be overridden at compile time via "ldflags -X main.homeManagerPath=<path>". var homeManagerPath = "home-manager" func main() { help := flag.Bool("help", false, "Print usage text to stdout.") verbose := flag.Bool("verbose", false, "Enable verbose logging.") flag.Parse() if *help { fmt.Print(helpText()) os.Exit(0) } args := flag.Args() if len(args) < 1 { fmt.Fprint(os.Stderr, helpText()) log.Fatal("No command provided.") } switch len(args) { case 0: log.Fatal("Argument not set: which specialisation to switch to?") case 1: break switch args[0] { case "set": if len(args) != 2 { fmt.Fprint(os.Stderr, helpText()) log.Fatal("`set` requires exact 1 argument") } setSpecialisation(args[1], *verbose) case "unset": activateNonSpecialisedProfile() case "clean": clean() default: log.Fatalf("specialisation takes exactly 1 argument: You set %d arguments", len(args)) fmt.Fprint(os.Stderr, helpText()) log.Fatalf("Unknown command: %s", args[0]) } } target := args[0] func helpText() string { binName := os.Args[0] if strings.Contains(target, "/") { log.Fatalf("specialisation cannot have slash (\"/\"): %s", target) return fmt.Sprintf(`%s - Manage Home-Manager specialisation easily. [Usage] %s set <specialisation> %s unset [COMMANDS] set <specialisation> Activate the particular specialisation. unset Activate the most recent home-manager profile which have specialisations. clean Delete generations other than the latest generation and the latest generation having specialisation directory. [OPTIONS] --help Print this message to stdout. --verbose Enable verbose logging. `, binName, binName, binName) } func setSpecialisation(specialisation string, verbose bool) { if strings.Contains(specialisation, "/") { log.Fatalf("specialisation cannot have slash (\"/\"): %s", specialisation) } cmd := exec.Command(homeManagerPath, "generations") generations, err := Generations() if err != nil { log.Fatalf("Failed to get generations: %s", err) } var out strings.Builder cmd.Stdout = &out for _, generation := range generations { specialisations, err := generation.Specialisations() if err != nil { log.Fatalf("Failed to get specialisations for %s: %s", generation.ID, err) } if err := cmd.Run(); err != nil { log.Fatal(err) if verbose { log.Printf("Found generation with ID=%s", generation.ID) } for _, s := range specialisations { if verbose { log.Printf("Found specialisation with Name=%s in generation ID=%s", s.Name, generation.ID) } if s.Name != specialisation { continue } log.Printf("Activate %s from ID=%s", specialisation, generation.ID) if err := s.Profile.Activate(); err != nil { log.Fatalf("Failed to activate %s: %s", specialisation, err) } log.Printf("Activated %s", specialisation) os.Exit(0) } } paths := parseGenerationsOutput(out.String()) log.Fatalf("No specialisation named \"%s\" found", specialisation) } for _, path := range paths { dir := os.DirFS(path) func activateNonSpecialisedProfile() { generations, err := Generations() if err != nil { log.Fatalf("Failed to get generations: %s", err) } exe, err := dir.Open(fmt.Sprintf("specialisation/%s/activate", target)) for _, generation := range generations { specialisations, err := generation.Specialisations() if err != nil { continue log.Fatalf("Failed to check specialisations (ID=%s): %s", generation.ID, err) } defer exe.Close() // Whether `fs.FS.Open` performs actual FS open operation is undocumented. // Make sure the file exists by getting file stats. if _, err := exe.Stat(); err != nil { // Home Manager's specialisation feature has fundamental design failure: // there is no way to go back to "unspecialised" generation or switch to another // specialisations once activated a specialisation. Because of this, I have to // brute-force each Home Manager generation directories. // https://github.com/nix-community/home-manager/issues/4073 if len(specialisations) == 0 { continue } bin := fmt.Sprintf("%s/specialisation/%s/activate", path, target) log.Printf("Activate generation (ID=%s)", generation.ID) if err := generation.Profile.Activate(); err != nil { log.Fatalf("Failed to activate: %s", err) } os.Exit(0) } log.Fatal("No generation having specialisations found: Manually switch to a profile using home-manager.") } func clean() { generations, err := Generations() if err != nil { log.Fatalf("Failed to get generations: %s", err) } log.Printf("> %s", bin) cmd := exec.Command(bin) foundGenerationHavingSpecialisation := false if err := cmd.Run(); err != nil { log.Fatal(err) args := []string{"remove-generations"} for i, generation := range generations { if !foundGenerationHavingSpecialisation { specialisations, err := generation.Specialisations() if err != nil { log.Fatalf("Failed to get specialisations for ID=%s: %s", generation.ID, err) } if len(specialisations) > 0 { foundGenerationHavingSpecialisation = true continue } } log.Printf("Activated %s", target) if i == 0 { continue } log.Printf("Removing generation ID=%s", generation.ID) args = append(args, generation.ID) } if len(args) == 1 { log.Print("Nothing to clean.") os.Exit(0) } log.Fatalf("No specialisation named \"%s\" found", target) cmd := exec.Command(homeManagerPath, args...) var out strings.Builder cmd.Stdout = &out if err := cmd.Run(); err != nil { log.Fatalf("Failed to remove generations: %s", err) } } // parseGenerationsOutput parses stdout of `home-manager generations` and // returns the list of paths to generation directories. func parseGenerationsOutput(stdout string) []string { type Profile struct { Path string } func (p Profile) Activate() error { dir := os.DirFS(p.Path) bin, err := dir.Open("activate") if err != nil { return err } defer bin.Close() stat, err := bin.Stat() if err != nil { return err } if stat.IsDir() { return fmt.Errorf("Cannot activate %s: target is directory", p.Path) } path := fmt.Sprintf("%s/activate", p.Path) cmd := exec.Command(path) return cmd.Run() } type Specialisation struct { Name string Profile Profile } type Generation struct { ID string Profile Profile } func ParseGeneration(line string) (*Generation, error) { tokens := strings.Split(line, " ") colonPosition := -1 for i, token := range tokens { if token == ":" { colonPosition = i break } } if colonPosition < 0 { return nil, errors.New("Unexpected generation output line: No colon found") } if len(tokens) < colonPosition+4 { return nil, errors.New("Unexpected generation output line: Missing tokens") } if tokens[colonPosition+1] != "id" { return nil, fmt.Errorf("Unexpected generation output line: Expected `id`, found `%s`", tokens[colonPosition+1]) } id := tokens[colonPosition+2] if tokens[colonPosition+3] != "->" { return nil, fmt.Errorf("Unexpected generation output line: Expected `->`, found `%s`", tokens[colonPosition+3]) } path := strings.Join(tokens[(colonPosition+4):], " ") return &Generation{ ID: id, Profile: Profile{ Path: path, }, }, nil } func Generations() ([]Generation, error) { cmd := exec.Command(homeManagerPath, "generations") var out strings.Builder cmd.Stdout = &out if err := cmd.Run(); err != nil { return nil, err } stdout := out.String() lines := strings.Split(stdout, "\n") paths := make([]string, 0, len(lines)) generations := make([]Generation, 0, len(lines)) for _, line := range strings.Split(stdout, "\n") { if len(strings.TrimSpace(line)) == 0 { continue } tokens := strings.Split(line, " ") if len(tokens) == 0 { continue } last := tokens[len(tokens)-1] if !strings.HasPrefix(last, "/") { generation, err := ParseGeneration(line) if err != nil { log.Printf("Failed to parse generation output: %s", err.Error()) continue } paths = append(paths, last) generations = append(generations, *generation) } return generations, nil } func (g Generation) Specialisations() ([]Specialisation, error) { dir := os.DirFS(g.Profile.Path) entries, err := fs.ReadDir(dir, "specialisation") if err != nil { if errors.Is(err, fs.ErrNotExist) { return []Specialisation{}, nil } return nil, err } return paths specialisations := make([]Specialisation, 0, len(entries)) for _, entry := range entries { name := entry.Name() specialisations = append(specialisations, Specialisation{ Name: name, Profile: Profile{ Path: fmt.Sprintf("%s/specialisation/%s", g.Profile.Path, name), }, }) } return specialisations, nil }
-