add and source new PATHs

This commit is contained in:
AJ ONeal 2019-07-19 01:52:53 -06:00
parent 7ca8158a1c
commit a17b60d46a
2 changed files with 626 additions and 0 deletions

378
envpath/envpath.go Normal file
View File

@ -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 <path>\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
}

248
envpath/manager.go Normal file
View File

@ -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
}