master
Ivan Klymenchenko 3 years ago
commit 73c8669d80

@ -0,0 +1,2 @@
build:
go build -o smug *.go

@ -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 <command> <project>[: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
```

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

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

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

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

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

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

@ -0,0 +1,61 @@
package main
import (
"strings"
"github.com/docopt/docopt-go"
)
const usage = `Smug - tmux session manager.
Usage:
smug <command> <project> [-w <window>]...
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("<command>")
if err != nil {
return Options{}, err
}
project, err := arguments.String("<project>")
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
}

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

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

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

@ -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)
}
Loading…
Cancel
Save