Init
commit
73c8669d80
@ -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…
Reference in New Issue