commit 73c8669d80234bb6f44465e59c66ebe2e100034f Author: Ivan Klymenchenko Date: Sun Dec 20 15:48:13 2020 +0200 Init 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) +}