AJ ONeal
5 years ago
2 changed files with 626 additions and 0 deletions
@ -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 |
|||
} |
@ -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 |
|||
} |
Loading…
Reference in new issue