From 9254e307e3bde48c67f2baa7c3b5a81e87466df5 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 28 Jul 2019 02:32:57 -0600 Subject: [PATCH] got some tests done --- envpath/envpath.go | 230 ++++++++++++++++++++++++++++++++++++ envpath/envpath_test.go | 96 +++++++++++++++ envpath/manager.go | 254 ++++++++++++++++++++++++++++++++++++++++ envpath/parse.go | 2 +- envpath/parse_test.go | 2 +- 5 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 envpath/envpath.go create mode 100644 envpath/envpath_test.go create mode 100644 envpath/manager.go diff --git a/envpath/envpath.go b/envpath/envpath.go new file mode 100644 index 0000000..b6c4688 --- /dev/null +++ b/envpath/envpath.go @@ -0,0 +1,230 @@ +package envpath + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +// Paths parses the PATH.env file and returns a slice of valid paths +func Paths() ([]string, error) { + home, err := os.UserHomeDir() + if nil != err { + return nil, err + } + home = filepath.ToSlash(home) + + _, paths, err := getEnv(home, "PATH") + if nil != err { + return nil, err + } + + // ":" on *nix + return paths, nil +} + +// Add adds a path entry to the PATH env file +func Add(entry string) (bool, error) { + home, err := os.UserHomeDir() + if nil != err { + return false, err + } + home = filepath.ToSlash(home) + + pathentry, err := normalizePathEntry(home, entry) + if nil != err { + return false, err + } + + err = initializeShells(home) + if nil != err { + return false, err + } + + fullpath, paths, err := getEnv(home, "PATH") + if nil != err { + return false, err + } + + _, ok := isInPath(home, paths, pathentry) + if ok { + return false, nil + } + + paths = append([]string{pathentry}, paths...) + err = writeEnv(fullpath, paths) + if nil != err { + return false, err + } + + fmt.Println("Wrote " + fullpath) + return true, nil +} + +// Remove adds a path entry to the PATH env file +func Remove(entry string) (bool, error) { + home, err := os.UserHomeDir() + if nil != err { + return false, err + } + home = filepath.ToSlash(home) + + pathentry, err := normalizePathEntry(home, entry) + if nil != err { + return false, err + } + + err = initializeShells(home) + if nil != err { + return false, err + } + + fullpath, oldpaths, err := getEnv(home, "PATH") + if nil != err { + return false, err + } + + index, exists := isInPath(home, oldpaths, pathentry) + if !exists { + return false, nil + } + + paths := []string{} + for i := range oldpaths { + if index != i { + paths = append(paths, oldpaths[i]) + } + } + + err = writeEnv(fullpath, paths) + if nil != err { + return false, err + } + + fmt.Println("Wrote " + fullpath) + return true, nil +} + +func getEnv(home string, env string) (string, []string, error) { + envmand := filepath.Join(home, ".config/envman") + err := os.MkdirAll(envmand, 0755) + if nil != err { + return "", nil, err + } + + nodes, err := ioutil.ReadDir(envmand) + if nil != err { + return "", nil, err + } + + filename := fmt.Sprintf("00-%s.env", env) + for i := range nodes { + name := nodes[i].Name() + if fmt.Sprintf("%s.env", env) == name || strings.HasSuffix(name, fmt.Sprintf("-%s.env", env)) { + filename = name + break + } + } + + fullpath := filepath.Join(envmand, filename) + f, err := os.OpenFile(fullpath, os.O_CREATE|os.O_RDONLY, 0644) + if nil != err { + return "", nil, err + } + + b, err := ioutil.ReadAll(f) + f.Close() + if nil != err { + return "", nil, err + } + + paths, warnings := Parse(b, env) + for i := range warnings { + w := warnings[i] + fmt.Printf("warning: dropped %q from %s:%d: %s\n", w.Line, filename, w.LineNumber, w.Message) + } + + pathlines := []string{} + for i := range paths { + pathname := strings.TrimSuffix(paths[i], ":$PATH") + if strings.HasPrefix(pathname, "$PATH:") { + fixed := strings.TrimPrefix(pathname, "$PATH:") + fmt.Fprintf(os.Stderr, "warning: re-arranging $PATH:%s to %s:$PATH\n", fixed, fixed) + pathname = fixed + } + pathlines = append(pathlines, pathname) + } + + if len(warnings) > 0 { + err := writeEnv(fullpath, pathlines) + if nil != err { + return "", nil, err + } + } + + return fullpath, pathlines, nil +} + +func writeEnv(fullpath string, paths []string) error { + f, err := os.OpenFile(fullpath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if nil != err { + return err + } + + _, err = f.Write([]byte("# Generated for envman. Do not edit.\n")) + if nil != err { + return err + } + + for i := range paths { + _, err := f.Write([]byte(fmt.Sprintf("export PATH=\"%s:$PATH\"\n", paths[i]))) + if nil != err { + return err + } + } + + return f.Close() +} + +func isInPath(home string, paths []string, pathentry string) (int, bool) { + 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 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 strings.TrimSuffix(pathentry, "/"), nil +} diff --git a/envpath/envpath_test.go b/envpath/envpath_test.go new file mode 100644 index 0000000..fc2d11b --- /dev/null +++ b/envpath/envpath_test.go @@ -0,0 +1,96 @@ +package envpath + +import ( + "fmt" + "testing" +) + +func TestAddRemove(t *testing.T) { + paths, err := Paths() + if nil != err { + t.Error(err) + return + } + for i := range paths { + fmt.Println(paths[i]) + } + + modified, err := Remove("/tmp/doesnt/exist") + if nil != err { + t.Error(err) + return + } + if modified { + t.Error(fmt.Errorf("Remove /tmp/doesnt/exist: should not have modified")) + return + } + + modified, err = Add("/tmp/delete/me") + if nil != err { + t.Error(err) + return + } + if !modified { + t.Error(fmt.Errorf("Add /tmp/delete/me: should have modified")) + return + } + + paths, err = Paths() + if 1 != len(paths) || "/tmp/delete/me" != paths[0] { + fmt.Println("len(paths):", len(paths)) + t.Error(fmt.Errorf("Paths: should have had exactly one entry: /tmp/delete/me")) + return + } + + modified, err = Add("/tmp/delete/me") + if nil != err { + t.Error(err) + return + } + if modified { + t.Error(fmt.Errorf("Add /tmp/delete/me: should not have modified")) + return + } + + paths, err = Paths() + if 1 != len(paths) || "/tmp/delete/me" != paths[0] { + t.Error(fmt.Errorf("Paths: should have had exactly one entry: /tmp/delete/me")) + return + } + + modified, err = Remove("/tmp/doesnt/exist") + if nil != err { + t.Error(err) + return + } + if modified { + t.Error(fmt.Errorf("Remove /tmp/doesnt/exist: should not have modified")) + return + } + + modified, err = Remove("/tmp/delete/me") + if nil != err { + t.Error(err) + return + } + if !modified { + t.Error(fmt.Errorf("Remove /tmp/delete/me: should have modified")) + return + } + + paths, err = Paths() + if 0 != len(paths) { + t.Error(fmt.Errorf("Paths: should have had no entries")) + return + } + + modified, err = Remove("/tmp/delete/me") + if nil != err { + t.Error(err) + return + } + if modified { + t.Error(fmt.Errorf("Remove /tmp/delete/me: should not have modified")) + return + } +} diff --git a/envpath/manager.go b/envpath/manager.go new file mode 100644 index 0000000..e7c6741 --- /dev/null +++ b/envpath/manager.go @@ -0,0 +1,254 @@ +package envpath + +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/envman/load.sh\" ] && source \"$HOME/.config/envman/load.sh\"\n", + loadFile: ".config/envman/load.sh", + loadScript: "for x in ~/.config/envman/*.env; do\n\tsource \"$x\"\ndone\n", + }, + &envConfig{ + home: home, + shell: "zsh", + shellDesc: "bourne-compatible shell (zsh)", + rcFile: ".zshrc", + rcScript: "[ -s \"$HOME/.config/envman/load.sh\" ] && source \"$HOME/.config/envman/load.sh\"\n", + loadFile: ".config/envman/load.sh", + loadScript: "for x in ~/.config/envman/*.env; do\n\tsource \"$x\"\ndone\n", + }, + &envConfig{ + home: home, + shell: "fish", + shellDesc: "fish shell", + rcFile: ".config/fish/config.fish", + rcScript: "test -s \"$HOME/.config/envman/load.fish\"; and source \"$HOME/.config/envman/load.fish\"\n", + loadFile: ".config/envman/load.fish", + loadScript: "for x in ~/.config/envman/*.env\n\tsource \"$x\"\nend\n", + }, + } +} + +func initializeShells(home string) error { + envmand := filepath.Join(home, ".config/envman") + err := os.MkdirAll(envmand, 0755) + if nil != err { + return err + } + + 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/envman/load.sh + // $HOME/.config/envman/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 +} diff --git a/envpath/parse.go b/envpath/parse.go index 88f625d..9024ba4 100644 --- a/envpath/parse.go +++ b/envpath/parse.go @@ -12,7 +12,7 @@ type Warning struct { } // Parse will return a list of paths from an export file -func Parse(envname string, b []byte) ([]string, []Warning) { +func Parse(b []byte, envname string) ([]string, []Warning) { s := string(b) s = strings.Replace(s, "\r\n", "\n", -1) diff --git a/envpath/parse_test.go b/envpath/parse_test.go index a8818cb..a1d0726 100644 --- a/envpath/parse_test.go +++ b/envpath/parse_test.go @@ -43,7 +43,7 @@ var paths = []string{ } func TestParse(t *testing.T) { - newlines, warnings := Parse("PATH", []byte(file)) + newlines, warnings := Parse([]byte(file), "PATH") newfile := `PATH="` + strings.Join(newlines, "\"\n\tPATH=\"") + `"` expfile := strings.Join(paths, "\n\t") if newfile != expfile {