diff --git a/envpath/envpath.go b/envpath/envpath.go new file mode 100644 index 0000000..d61d79b --- /dev/null +++ b/envpath/envpath.go @@ -0,0 +1,378 @@ +package main + +import ( + "fmt" + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// ppsep is used as the replacement for slashes in path +// ex: ~/bin => ~bin +// ex: ~/bin => home~bin +// ex: ~/.local/opt/foo/bin => ~.local»opt»foo»bin +// other patterns considered: +// ~/.local/opt/foo/bin => ~.local·opt·foo·bin +// ~/.local/opt/foo/bin => home».local»opt»foo»bin +// ~/.local/opt/foo/bin => HOME•.local•opt•foo•bin +// ~/.local/opt/foo/bin => HOME_.local_opt_foo_bin +// ~/.local/opt/foo/bin => HOME·.local·opt·foo·bin +const ppsep = "»" + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: envpath show|add|append|remove \n") +} + +func main() { + // TODO --system to add to the system PATH rather than the user PATH + + // Usage: + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + action := os.Args[1] + + paths, err := Paths() + if nil != err { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(2) + } + + if "show" == action { + if len(os.Args) > 2 { + usage() + } + fmt.Println() + for i := range paths { + fmt.Println("\t" + paths[i]) + } + fmt.Println() + return + } + + if len(os.Args) < 3 { + usage() + os.Exit(1) + } + + pathentry := os.Args[2] + if "remove" != action { + stat, err := os.Stat(pathentry) + if nil != err { + // TODO --force + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(2) + } + if !stat.IsDir() { + fmt.Fprintf(os.Stderr, "%q is not a directory (folder)\n", pathentry) + os.Exit(2) + } + } + + switch action { + default: + usage() + os.Exit(1) + case "append": + newpath, _, err := addPath(pathentry, appendOrder) + if nil != err { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + fmt.Println("New sessions will have " + pathentry + " in their PATH.") + fmt.Println("To update this session run\n") + //fmt.Println("\tsource", pathfile) + fmt.Printf(`%sexport PATH="$PATH:%s"%s`, "\t", newpath, "\n") + case "add": + _, pathfile, err := addPath(pathentry, prependOrder) + if nil != err { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + fmt.Println("\nRun this command (or open a new shell) to finish:\n") + fmt.Printf("\tsource %s\n\n", pathfile) + //fmt.Printf(`%sexport PATH="%s:$PATH"%s`, "\t", newpath, "\n\n") + case "remove": + _, err := removePath(pathentry) + if nil != err { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + } + + // TODO support both of these usages: + // 1. export PATH="$(envpath append /opt/whatever/bin)" + // 2. envpath append /opt/whatever/bin + // export PATH="$PATH:/opt/whatever/bin" +} + +// Paths returns the slice of PATHs from the Environment +func Paths() ([]string, error) { + // ":" on *nix + return strings.Split(os.Getenv("PATH"), string(os.PathListSeparator)), nil +} + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +type setOrder bool + +const prependOrder setOrder = true +const appendOrder setOrder = false + +// returns newpath, error +func addPath(oldpathentry string, order setOrder) (string, string, error) { + home, err := os.UserHomeDir() + if nil != err { + return "", "", err + } + home = filepath.ToSlash(home) + + pathentry, fname, err := normalizeEntryAndFile(home, oldpathentry) + if nil != err { + return "", "", err + } + + envpathd := filepath.Join(home, ".config/envpath/path.d") + err = os.MkdirAll(envpathd, 0755) + if nil != err { + return "", "", err + } + + err = initializeShells(home) + if nil != err { + return "", "", err + } + + nodes, err := ioutil.ReadDir(envpathd) + if nil != err { + return "", "", err + } + + var priority int + if prependOrder == order { + // Counter-intuitively later PATHs, prepended are placed earlier + priority, err = getOrder(nodes, pathentry, fname, envpathd, sortForward) + } else { + // Counter-intuitively earlier PATHs are placed later + priority, err = getOrder(nodes, pathentry, fname, envpathd, sortBackward) + } + if nil != err { + return "", "", err + } + + if err := ensureNotInPath(home, pathentry); nil != err { + return "", "", err + } + + // ex: 100-opt»foo»bin.sh + // ex: 105-home»bar»bin.sh + pathfile := fmt.Sprintf("%03d-%s", priority, fname) + + fullname := filepath.Join(envpathd, pathfile) + export := []byte(fmt.Sprintf("# Generated for envpath. Do not edit.\nexport PATH=\"%s:$PATH\"\n", pathentry)) + err = ioutil.WriteFile(fullname, export, 0755) + if nil != err { + return "", "", err + } + // If we change from having the user source the path directory + // then we should uncomment this so the user knows where the path files are + //fmt.Printf("Wrote %s\n", fullname) + + return pathentry, fullname, nil +} + +// TODO don't check in parser before add/append functions actually run +func ensureNotInPath(home, pathentry string) error { + paths, err := Paths() + if nil != err { + return err + } + + index := -1 + for i := range paths { + entry, _ := normalizePathEntry(home, paths[i]) + if pathentry == entry { + index = i + break + } + } + if index >= 0 { + fmt.Fprintf( + os.Stderr, + "%q is in your PATH at position %d and must be removed manually to re-order\n", + pathentry, + index, + ) + os.Exit(3) + } + return nil +} + +func normalizeEntryAndFile(home, pathentry string) (string, string, error) { + var err error + + pathentry, err = normalizePathEntry(home, pathentry) + if nil != err { + return "", "", err + } + + // Now we split and rejoin the paths as a unique name + // ex: /opt/foo/bin/ => opt/foo/bin => [opt foo bin] + // ex: ~/bar/bin/ => bar/bin => [bar bin] + names := strings.Split(strings.Trim(filepath.ToSlash(pathentry), "/"), "/") + if strings.HasPrefix(pathentry, "$HOME/") { + // ~/bar/bin/ => [home bar bin] + names[0] = "home" + } + + // ex: /opt/foo/bin/ => opt»foo»bin.sh + fname := strings.Join(names, ppsep) + ".sh" + + return pathentry, fname, nil +} + +func normalizePathEntry(home, pathentry string) (string, error) { + var err error + + // We add the slashes so that we don't get false matches + // ex: foo should match foo/bar, but should NOT match foobar + home, err = filepath.Abs(home) + if nil != err { + // I'm not sure how it's possible to get an error with Abs... + return "", err + } + home += "/" + pathentry, err = filepath.Abs(pathentry) + if nil != err { + return "", err + } + pathentry += "/" + + // Next we make the path relative to / or ~/ + // ex: /Users/me/.local/bin/ => .local/bin/ + if strings.HasPrefix(pathentry, home) { + pathentry = "$HOME/" + strings.TrimPrefix(pathentry, home) + } + + return pathentry, nil +} + +func sortForward(priority, n int) int { + // Pick a number such that 99 > newpriority > priority + if n >= priority { + m := n % 5 + if 0 == m { + priority += 5 + } else { + priority += (5 - m) + } + } + + return priority +} + +func sortBackward(priority, n int) int { + // Pick a number such that 0 < newpriority < priority + if n <= priority { + m := n % 5 + if 0 == m { + m = 5 + } + priority -= m + } + + return priority +} + +type sorter = func(int, int) int + +func getOrder(nodes []os.FileInfo, pathentry, fname, envpathd string, fn sorter) (int, error) { + // assuming people will append more often than prepend + // default the priority to less than halfway + priority := 100 + for i := range nodes { + f := nodes[i] + name := f.Name() + if !strings.HasSuffix(name, ".sh") { + continue + } + if strings.HasSuffix(name, "-"+fname) { + return 0, fmt.Errorf( + "Error: %s already exports %s", + filepath.Join("~/.config/envpath/path.d", f.Name()), + pathentry, + ) + } + n, err := strconv.Atoi(strings.Split(name, "-")[0]) + if nil != err { + continue + } + + priority = fn(priority, n) + } + + return priority, nil +} + +func removePath(oldpathentry string) (string, error) { + // TODO Show current PATH sans this item + /* + oldpaths := paths + paths = []string{} + for i := range oldpaths { + if i != index { + paths = append(paths, oldpaths[i]) + } + } + */ + + home, err := os.UserHomeDir() + if nil != err { + return "", err + } + home = filepath.ToSlash(home) + + pathentry, fname, err := normalizeEntryAndFile(home, oldpathentry) + if nil != err { + return "", err + } + + envpathd := filepath.Join(home, ".config/envpath/path.d") + err = os.MkdirAll(envpathd, 0755) + if nil != err { + return "", err + } + + /* + err = ensureNotInPath(home, pathentry) + if nil != err { + return "", err + } + */ + + nodes, err := ioutil.ReadDir(envpathd) + if nil != err { + return "", err + } + + // TODO rename getOrder to getNext + _, err = getOrder(nodes, pathentry, fname, envpathd, sortForward) + if nil == err { + fmt.Fprintf(os.Stderr, "%q is not managed by envpath.\n", pathentry) + } + + /* + if index < 0 { + fmt.Fprintf(os.Stderr, "%q is NOT in PATH.\n", pathentry) + os.Exit(3) + } + */ + return "", nil +} diff --git a/envpath/manager.go b/envpath/manager.go new file mode 100644 index 0000000..c9e4bf3 --- /dev/null +++ b/envpath/manager.go @@ -0,0 +1,248 @@ +package main + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "strings" +) + +type envConfig struct { + shell string + shellDesc string + home string + rcFile string + rcScript string + loadFile string + loadScript string +} + +var confs []*envConfig + +func init() { + home, err := os.UserHomeDir() + if nil != err { + panic(err) // Must get home directory + } + home = filepath.ToSlash(home) + + confs = []*envConfig{ + &envConfig{ + home: home, + shell: "bash", + shellDesc: "bourne-compatible shell (bash)", + rcFile: ".bashrc", + rcScript: "[ -s \"$HOME/.config/envpath/load.sh\" ] && source \"$HOME/.config/envpath/load.sh\"\n", + loadFile: ".config/envpath/load.sh", + loadScript: "for x in ~/.config/envpath/path.d/*.sh; do\n\tsource \"$x\"\ndone\n", + }, + &envConfig{ + home: home, + shell: "zsh", + shellDesc: "bourne-compatible shell (zsh)", + rcFile: ".zshrc", + rcScript: "[ -s \"$HOME/.config/envpath/load.sh\" ] && source \"$HOME/.config/envpath/load.sh\"\n", + loadFile: ".config/envpath/load.sh", + loadScript: "for x in ~/.config/envpath/path.d/*.sh; do\n\tsource \"$x\"\ndone\n", + }, + &envConfig{ + home: home, + shell: "fish", + shellDesc: "fish shell", + rcFile: ".config/fish/config.fish", + rcScript: "test -s \"$HOME/.config/envpath/load.fish\"; and source \"$HOME/.config/envpath/load.fish\"\n", + loadFile: ".config/envpath/load.fish", + loadScript: "for x in ~/.config/envpath/path.d/*.sh\n\tsource \"$x\"\nend\n", + }, + } +} + +func initializeShells(home string) error { + var hasRC bool + var nativeMatch *envConfig + for i := range confs { + c := confs[i] + + if os.Getenv("SHELL") == c.shell { + nativeMatch = c + } + + _, err := os.Stat(filepath.Join(home, c.rcFile)) + if nil != err { + continue + } + hasRC = true + } + + // ensure rc + if !hasRC { + if nil == nativeMatch { + return fmt.Errorf( + "%q is not a recognized shell and found none of .bashrc, .zshrc, .config/fish/config.fish", + os.Getenv("SHELL"), + ) + } + + // touch the rc file + f, err := os.OpenFile(filepath.Join(home, nativeMatch.rcFile), os.O_CREATE|os.O_WRONLY, 0644) + if nil != err { + return err + } + if err := f.Close(); nil != err { + return err + } + } + + // MacOS is special. It *requires* .bash_profile in order to read .bashrc + if "darwin" == runtime.GOOS && "bash" == os.Getenv("SHELL") { + if err := ensureBashProfile(home); nil != err { + return err + } + } + + // + // Bash (sh, dash, zsh, ksh) + // + // http://www.joshstaiger.org/archives/2005/07/bash_profile_vs.html + for i := range confs { + c := confs[i] + err := c.maybeInitializeShell() + if nil != err { + return err + } + } + + return nil +} + +func (c *envConfig) maybeInitializeShell() error { + if _, err := os.Stat(filepath.Join(c.home, c.rcFile)); nil != err { + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "%s\n", err) + } + return nil + } + + changed, err := c.initializeShell() + if nil != err { + return err + } + + if changed { + fmt.Printf( + "Detected %s shell and updated ~/%s\n", + c.shellDesc, + strings.TrimPrefix(c.rcFile, c.home), + ) + } + + return nil +} + +func (c *envConfig) initializeShell() (bool, error) { + if err := c.ensurePathsLoader(); err != nil { + return false, err + } + + // Get current config + // ex: ~/.bashrc + // ex: ~/.config/fish/config.fish + b, err := ioutil.ReadFile(filepath.Join(c.home, c.rcFile)) + if nil != err { + return false, err + } + + // For Windows, just in case + s := strings.Replace(string(b), "\r\n", "\n", -1) + + // Looking to see if loader script has been added to rc file + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := range lines { + line := lines[i] + if line == strings.TrimSpace(c.rcScript) { + // indicate that it was not neccesary to change the rc file + return false, nil + } + } + + // Open rc file to append and write + f, err := os.OpenFile(filepath.Join(c.home, c.rcFile), os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return false, err + } + + // Generate our script + script := fmt.Sprintf("# Generated for envpath. Do not edit.\n%s\n", c.rcScript) + + // If there's not a newline before our template, + // include it in the template. We want nice things. + n := len(lines) + if "" != strings.TrimSpace(lines[n-1]) { + script = "\n" + script + } + + // Write and close the rc file + if _, err := f.Write([]byte(script)); err != nil { + return false, err + } + if err := f.Close(); err != nil { + return true, err + } + + // indicate that we have changed the rc file + return true, nil +} + +func (c *envConfig) ensurePathsLoader() error { + loadFile := filepath.Join(c.home, c.loadFile) + + if _, err := os.Stat(loadFile); nil != err { + // Write the loop file. For example: + // $HOME/.config/envpath/load.sh + // $HOME/.config/envpath/load.fish + // TODO maybe don't write every time + if err := ioutil.WriteFile( + loadFile, + []byte(fmt.Sprintf("# Generated for envpath. Do not edit.\n%s\n", c.loadScript)), + os.FileMode(0755), + ); nil != err { + return err + } + fmt.Printf("Created %s\n", "~/"+c.loadFile) + } + return nil +} + +// I think this issue only affects darwin users with bash as the default shell +func ensureBashProfile(home string) error { + profileFile := filepath.Join(home, ".bash_profile") + + // touch the profile file + f, err := os.OpenFile(profileFile, os.O_CREATE|os.O_WRONLY, 0644) + if nil != err { + return err + } + if err := f.Close(); nil != err { + return err + } + + b, err := ioutil.ReadFile(profileFile) + if !bytes.Contains(b, []byte(".bashrc")) { + f, err := os.OpenFile(profileFile, os.O_APPEND|os.O_WRONLY, 0644) + if nil != err { + return err + } + sourceBashRC := "[ -s \"$HOME/.bashrc\" ] && source \"$HOME/.bashrc\"\n" + b := []byte(fmt.Sprintf("# Generated for MacOS bash. Do not edit.\n%s\n", sourceBashRC)) + _, err = f.Write(b) + if nil != err { + return err + } + fmt.Printf("Updated ~/.bash_profile to source ~/.bashrc\n") + } + + return nil +}