From 73c8669d80234bb6f44465e59c66ebe2e100034f Mon Sep 17 00:00:00 2001 From: Ivan Klymenchenko Date: Sun, 20 Dec 2020 15:48:13 +0200 Subject: [PATCH] Init --- Makefile | 2 + README.md | 66 ++++++++++++++++++++++++ commander.go | 28 +++++++++++ config.go | 38 ++++++++++++++ errors.go | 12 +++++ go.mod | 8 +++ go.sum | 6 +++ main.go | 53 ++++++++++++++++++++ options.go | 61 +++++++++++++++++++++++ options_test.go | 41 +++++++++++++++ smug.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++ smug_test.go | 110 ++++++++++++++++++++++++++++++++++++++++ tmux.go | 105 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 660 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 commander.go create mode 100644 config.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 options.go create mode 100644 options_test.go create mode 100644 smug.go create mode 100644 smug_test.go create mode 100644 tmux.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..488be91 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +build: + go build -o smug *.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..75f31b5 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Smug - tmux session manager. + +Inspired by [tmuxinator](https://github.com/tmuxinator/tmuxinator) and [tmuxp](https://github.com/tmux-python/tmuxp) + +## Usage + +`tmux [:window name] [-w window name]` + +## Examples + +To start/stop a project and all windows, run: + +`$ smug start project` + +`$ smug stop project` + +When you already have a started session, and you want to start only some windows from the configuration file, you can do something like this: + +`$ smug start project:window1` + +`$ smug start project:window1,window2` + +`$ smug start project -w window1` + +`$ smug start project -w window1 -w window2` + +## Configuration + +Configuration files stored in the `~/.config/smug` directory in the `YAML` format, e.g `~/config/smug/your_project.yml`. + +Example: + +```yaml +session: blog + +root: ~/Developer/blog + +before_start: + - docker-compose -f my-microservices/docker-compose.yml up -d # my-microservices/docker-compose.yml is a relative to `root` + +stop: + - docker stop $(docker ps -q) + +windows: + - name: code + root: blog # a relative path to root + manual: true # you can start this window only manually, using the -w arg + commands: + - docker-compose start + panes: + - type: horizontal + root: . + commands: + - docker-compose exec php /bin/sh + - clear + + - name: infrastructure + root: ~/Developer/blog/my-microservices + panes: + - type: horizontal + root: . + commands: + - docker-compose up -d + - docker-compose exec php /bin/sh + - clear +``` diff --git a/commander.go b/commander.go new file mode 100644 index 0000000..aaafba9 --- /dev/null +++ b/commander.go @@ -0,0 +1,28 @@ +package main + +import ( + "os/exec" + "strings" +) + +type Commander interface { + Exec(cmd *exec.Cmd) (string, error) + ExecSilently(cmd *exec.Cmd) error +} + +type DefaultCommander struct { +} + +func (c DefaultCommander) Exec(cmd *exec.Cmd) (string, error) { + output, err := cmd.CombinedOutput() + + if err != nil { + return "", err + } + + return strings.TrimSuffix(string(output), "\n"), nil +} + +func (c DefaultCommander) ExecSilently(cmd *exec.Cmd) error { + return cmd.Run() +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..2ba7b89 --- /dev/null +++ b/config.go @@ -0,0 +1,38 @@ +package main + +import "gopkg.in/yaml.v2" + +type Pane struct { + Root string `yaml:"root"` + Type string `yaml:"type"` + Commands []string `yaml:"commands"` +} + +type Window struct { + Name string `yaml:"name"` + Root string `yaml:"root"` + BeforeStart []string `yaml:"before_start"` + Panes []Pane `yaml:"panes"` + Commands []string `yaml:"commands"` + Manual bool +} + +type Config struct { + Session string `yaml:"session"` + Root string `yaml:"root"` + BeforeStart []string `yaml:"before_start"` + Stop []string `yaml:"stop"` + Windows []Window `yaml:"windows"` +} + +func ParseConfig(data string) (*Config, error) { + c := Config{} + + err := yaml.Unmarshal([]byte(data), &c) + + if err != nil { + return nil, err + } + + return &c, nil +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..1ca774e --- /dev/null +++ b/errors.go @@ -0,0 +1,12 @@ +package main + +import "fmt" + +type ShellError struct { + Command string + Err error +} + +func (e *ShellError) Error() string { + return fmt.Sprintf("Cannot run %q. Error %v", e.Command, e.Err) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3d300d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/ivaaaan/smug + +go 1.13 + +require ( + github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 + gopkg.in/yaml.v2 v2.4.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d04df37 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9a9e957 --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "github.com/docopt/docopt-go" +) + +func main() { + parser := docopt.Parser{} + + options, err := ParseOptions(parser, os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot parse command line otions: %q", err.Error()) + os.Exit(1) + } + + userConfigDir := filepath.Join(ExpandPath("~/"), ".config/smug") + configPath := filepath.Join(userConfigDir, options.Project+".yml") + + f, err := ioutil.ReadFile(configPath) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + config, err := ParseConfig(string(f)) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + commander := DefaultCommander{} + tmux := Tmux{commander} + smug := Smug{tmux, commander} + + switch options.Command { + case "start": + err = smug.StartSession(*config, options.Windows) + case "stop": + err = smug.StopSession(*config) + default: + err = fmt.Errorf("Unknown command %q", options.Command) + } + + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..f23a0b4 --- /dev/null +++ b/options.go @@ -0,0 +1,61 @@ +package main + +import ( + "strings" + + "github.com/docopt/docopt-go" +) + +const usage = `Smug - tmux session manager. + +Usage: + smug [-w ]... + +Options: + -w List of windows to start. If session exists, those windows will be attached to current session. + +Examples: + $ smug start blog + $ smug start blog:win1 + $ smug start blog -w win1 + $ smug start blog:win1,win2 + $ smug stop blog +` + +type Options struct { + Command string + Project string + Windows []string +} + +func ParseOptions(p docopt.Parser, argv []string) (Options, error) { + arguments, err := p.ParseArgs(usage, argv, "") + + if err != nil { + return Options{}, err + } + + cmd, err := arguments.String("") + + if err != nil { + return Options{}, err + } + + project, err := arguments.String("") + + if err != nil { + return Options{}, err + } + + var windows []string + + if strings.Contains(project, ":") { + parts := strings.Split(project, ":") + project = parts[0] + windows = strings.Split(parts[1], ",") + } else { + windows = arguments["-w"].([]string) + } + + return Options{cmd, project, windows}, nil +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..b0cfc8a --- /dev/null +++ b/options_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "reflect" + "testing" + + "github.com/docopt/docopt-go" +) + +var usageTestTable = []struct { + argv []string + opts Options +}{ + { + []string{"start", "smug"}, + Options{"start", "smug", []string{}}, + }, + { + []string{"start", "smug", "-wfoo"}, + Options{"start", "smug", []string{"foo"}}, + }, + { + []string{"start", "smug:foo,bar"}, + Options{"start", "smug", []string{"foo", "bar"}}, + }, +} + +func TestParseOptions(t *testing.T) { + parser := docopt.Parser{} + for _, v := range usageTestTable { + opts, err := ParseOptions(parser, v.argv) + + if err != nil { + t.Fail() + } + + if !reflect.DeepEqual(v.opts, opts) { + t.Errorf("expected struct %v, got %v", v.opts, opts) + } + } +} diff --git a/smug.go b/smug.go new file mode 100644 index 0000000..a7a5711 --- /dev/null +++ b/smug.go @@ -0,0 +1,130 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +func ExpandPath(path string) string { + if strings.HasPrefix(path, "~/") { + userHome, err := os.UserHomeDir() + if err != nil { + return path + } + + return strings.Replace(path, "~", userHome, 1) + } + + return path +} + +func Contains(slice []string, s string) bool { + for _, e := range slice { + if e == s { + return true + } + } + + return false +} + +type Smug struct { + tmux Tmux + commander Commander +} + +func (smug Smug) execShellCommands(commands []string, path string) error { + for _, c := range commands { + args := strings.Split(c, " ") + + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = path + + _, err := smug.commander.Exec(cmd) + if err != nil { + return &ShellError{c, err} + } + } + return nil +} + +func (smug Smug) StopSession(config Config) error { + sessionRoot := ExpandPath(config.Root) + + err := smug.execShellCommands(config.Stop, sessionRoot) + if err != nil { + return err + } + + _, err = smug.tmux.StopSession(config.Session) + + return err +} + +func (smug Smug) StartSession(config Config, windows []string) error { + var ses string + var err error + + sessionRoot := ExpandPath(config.Root) + + sessionExists := smug.tmux.SessionExists(config.Session) + if !sessionExists { + err = smug.execShellCommands(config.BeforeStart, sessionRoot) + if err != nil { + return err + } + + ses, err = smug.tmux.NewSession(config.Session) + if err != nil { + return err + } + } else { + ses = config.Session + ":" + } + + for _, w := range config.Windows { + if (len(windows) == 0 && w.Manual == true) || (len(windows) > 0 && !Contains(windows, w.Name)) { + continue + } + + windowRoot := ExpandPath(w.Root) + if windowRoot == "" || !filepath.IsAbs(windowRoot) { + windowRoot = filepath.Join(sessionRoot, w.Root) + } + + window, err := smug.tmux.NewWindow(ses, w.Name, windowRoot, w.Commands) + if err != nil { + return err + } + + for _, p := range w.Panes { + paneRoot := ExpandPath(p.Root) + if paneRoot == "" || !filepath.IsAbs(p.Root) { + paneRoot = filepath.Join(windowRoot, p.Root) + } + + smug.tmux.SplitWindow(window, p.Type, paneRoot, p.Commands) + } + } + + if len(windows) == 0 { + err = smug.tmux.KillWindow(ses + "0") + if err != nil { + return err + } + + err = smug.tmux.RenumberWindows() + if err != nil { + return err + } + + err = smug.tmux.Attach(ses+"0", os.Stdin, os.Stdout, os.Stderr) + if err != nil { + return err + } + } + + return nil +} diff --git a/smug_test.go b/smug_test.go new file mode 100644 index 0000000..35cf28e --- /dev/null +++ b/smug_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "os/exec" + "reflect" + "strings" + "testing" +) + +var startSessionTestTable = []struct { + config Config + commands []string + windows []string +}{ + { + Config{ + Session: "ses", + Root: "root", + BeforeStart: []string{"command1", "command2"}, + }, + []string{ + "tmux has-session -t ses", + "command1", + "command2", + "tmux new -Pd -s ses", + "tmux kill-window -t ses:0", + "tmux move-window -r", + "tmux attach -t ses:0", + }, + []string{}, + }, + { + Config{ + Session: "ses", + Root: "root", + Windows: []Window{ + { + Name: "win1", + Manual: false, + }, + { + Name: "win2", + Manual: true, + }, + }, + }, + []string{ + "tmux has-session -t ses", + "tmux new -Pd -s ses", + "tmux neww -Pd -t ses: -n win1 -c root", + "tmux kill-window -t ses:0", + "tmux move-window -r", + "tmux attach -t ses:0", + }, + []string{}, + }, + { + Config{ + Session: "ses", + Root: "root", + Windows: []Window{ + { + Name: "win1", + Manual: false, + }, + { + Name: "win2", + Manual: true, + }, + }, + }, + []string{ + "tmux has-session -t ses", + "tmux new -Pd -s ses", + "tmux neww -Pd -t ses: -n win2 -c root", + }, + []string{ + "win2", + }, + }, +} + +type MockCommander struct { + Commands []string +} + +func (c *MockCommander) Exec(cmd *exec.Cmd) (string, error) { + c.Commands = append(c.Commands, strings.Join(cmd.Args, " ")) + + return "ses:", nil +} + +func (c *MockCommander) ExecSilently(cmd *exec.Cmd) error { + c.Commands = append(c.Commands, strings.Join(cmd.Args, " ")) + return nil +} + +func TestStartSession(t *testing.T) { + for _, params := range startSessionTestTable { + commander := &MockCommander{} + tmux := Tmux{commander} + smug := Smug{tmux, commander} + + smug.StartSession(params.config, params.windows) + + if !reflect.DeepEqual(params.commands, commander.Commands) { + t.Errorf("expected\n%s\ngot\n%s", strings.Join(params.commands, "\n"), strings.Join(commander.Commands, "\n")) + } + } +} diff --git a/tmux.go b/tmux.go new file mode 100644 index 0000000..417e164 --- /dev/null +++ b/tmux.go @@ -0,0 +1,105 @@ +package main + +import ( + "os" + "os/exec" + "strings" +) + +const ( + VSplit = "vertical" + HSplit = "horizontal" +) + +type Tmux struct { + commander Commander +} + +func (tmux Tmux) NewSession(name string) (string, error) { + cmd := exec.Command("tmux", "new", "-Pd", "-s", name) + + session, err := tmux.commander.Exec(cmd) + if err != nil { + return "", &ShellError{strings.Join(cmd.Args, " "), err} + } + + return session, nil +} + +func (tmux Tmux) SessionExists(name string) bool { + cmd := exec.Command("tmux", "has-session", "-t", name) + res, err := tmux.commander.Exec(cmd) + return res == "" && err == nil +} + +func (tmux Tmux) KillWindow(target string) error { + cmd := exec.Command("tmux", "kill-window", "-t", target) + _, err := tmux.commander.Exec(cmd) + return err +} + +func (tmux Tmux) NewWindow(target string, name string, root string, commands []string) (string, error) { + cmd := exec.Command("tmux", "neww", "-Pd", "-t", target, "-n", name, "-c", root) + + window, err := tmux.commander.Exec(cmd) + if err != nil { + return "", err + } + + for _, c := range commands { + tmux.SendKeys(window, c) + } + + return window, nil +} + +func (tmux Tmux) SendKeys(target string, command string) error { + cmd := exec.Command("tmux", "send-keys", "-t", target, command, "Enter") + _, err := tmux.commander.Exec(cmd) + return err +} + +func (tmux Tmux) Attach(target string, stdin *os.File, stdout *os.File, stderr *os.File) error { + cmd := exec.Command("tmux", "attach", "-t", target) + + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + + return tmux.commander.ExecSilently(cmd) +} + +func (tmux Tmux) RenumberWindows() error { + cmd := exec.Command("tmux", "move-window", "-r") + _, err := tmux.commander.Exec(cmd) + return err +} + +func (tmux Tmux) SplitWindow(target string, splitType string, root string, commands []string) (string, error) { + args := []string{"split-window", "-Pd", "-t", target, "-c", root} + + switch splitType { + case VSplit: + args = append(args, "-v") + case HSplit: + args = append(args, "-h") + } + + cmd := exec.Command("tmux", args...) + + pane, err := tmux.commander.Exec(cmd) + if err != nil { + return "", err + } + + for _, c := range commands { + tmux.SendKeys(pane, c) + } + + return pane, nil +} + +func (tmux Tmux) StopSession(target string) (string, error) { + cmd := exec.Command("tmux", "stop-session", "-t", target) + return tmux.commander.Exec(cmd) +}