package main import ( "fmt" "io/ioutil" "os" "path/filepath" "strconv" "strings" ) // 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 // ~/.local/opt/foo/bin => HOME·.local·opt·foo·bin const ppsep = "-" func usage() { //fmt.Fprintf(os.Stderr, "Usage: envpath show|add|append|remove \n") fmt.Fprintf(os.Stderr, "Usage: envpath show|add|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 := Paths() 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": msg, err := removePath(pathentry) if nil != err { fmt.Fprintf(os.Stderr, "%s\n", err) return } fmt.Println(msg) } } // Paths returns the slice of PATHs from the Environment func Paths() []string { // ":" on *nix return strings.Split(os.Getenv("PATH"), string(os.PathListSeparator)) } 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 index, ok := isInPath(home, pathentry); ok { return "", "", fmt.Errorf( "%q is in your PATH at position %d and must be removed manually to re-order\n", pathentry, index, ) } // 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 } func removePath(oldpathentry 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 } nodes, err := ioutil.ReadDir(envpathd) if nil != err { return "", err } var fullname string for i := range nodes { node := nodes[i] // 000-foo-bin.sh vs foo-bin.sh if strings.HasSuffix(node.Name(), "-"+fname) { if len(strings.Split(node.Name(), "-"))-1 == len(strings.Split(fname, "-")) { fullname = node.Name() } } } paths := Paths() index, exists := isInPath(home, pathentry) if "" == fullname { if exists { return "", fmt.Errorf("%q is in your PATH, but is NOT managed by envpath", pathentry) } return "", fmt.Errorf("%q is NOT in your PATH, and NOT managed by envpath", pathentry) } err = os.Remove(filepath.Join(envpathd, fullname)) if nil != err { return "", err } if !exists { return fmt.Sprintf("Removed %s", filepath.Join(envpathd, fullname)), nil } newpaths := []string{} for i := range paths { if i == index { continue } newpaths = append(newpaths, paths[i]) } return fmt.Sprintf( "Removed %s. To update the current shell re-export the new PATH:\n\n"+ "\texport PATH=%q\n", fullname, strings.Join(newpaths, ":"), ), nil } func isInPath(home, pathentry string) (int, bool) { paths := Paths() index := -1 for i := range paths { entry, _ := normalizePathEntry(home, paths[i]) if pathentry == entry { index = i break } } if index >= 0 { return index, true } return -1, false } 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 }