Skip to content

Commit

Permalink
Provide Go API by separating CLI from library (#6)
Browse files Browse the repository at this point in the history
This commit splits tandem into a Go package and a CLI, letting users
import the package and use it in their own Go programs.

Fixes #5
  • Loading branch information
rosszurowski authored Feb 3, 2023
1 parent 141ee97 commit 524b1e0
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 57 deletions.
3 changes: 2 additions & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ before:
- go mod tidy
- go generate ./...
builds:
- env:
- main: ./cmd/tandem
env:
- CGO_ENABLED=0
goos:
- linux
Expand Down
57 changes: 57 additions & 0 deletions ansi/ansi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Package ansi provides ANSI escape codes for color and formatting.
package ansi

import (
"fmt"
"os"
)

// NoColor disables ANSI color output. By default it is set to true if the
// NO_COLOR environment variable is set.
var NoColor = os.Getenv("NO_COLOR") != ""

// Red returns a string wrapped in ANSI escape codes to make it red.
func Red(s string) string {
if NoColor {
return s
}
return "\033[0;31m" + s + "\033[0m"
}

// Gray returns a string wrapped in ANSI escape codes to make it gray.
func Gray(s string) string {
if NoColor {
return s
}
return "\033[0;38;5;8m" + s + "\033[0m"
}

// Dim returns a string wrapped in ANSI escape codes to make it dim.
func Dim(s string) string {
if NoColor {
return s
}
return "\033[0;2m" + s + "\033[0m"
}

// Bold returns a string wrapped in ANSI escape codes to make it bold.
func Bold(s string) string {
if NoColor {
return s
}
return "\033[1m" + s + "\033[0m"
}

func ColorStart(i int) string {
if NoColor {
return ""
}
return fmt.Sprintf("\033[0;38;5;%vm", i)
}

func ColorEnd() string {
if NoColor {
return ""
}
return "\033[0m"
}
26 changes: 11 additions & 15 deletions main.go → cmd/tandem/tandem.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"

"github.com/rosszurowski/tandem/ansi"
"github.com/rosszurowski/tandem/tandem"
"github.com/urfave/cli/v2"
)

Expand Down Expand Up @@ -60,17 +61,12 @@ func main() {
if args.Len() < 1 {
return ErrNoCommands
}

root, err := filepath.Abs(c.String("directory"))
if err != nil {
return fmt.Errorf("could not get absolute path for directory: %v", err)
}
pm, err := newProcessManager(
root,
c.Int("timeout"),
args.Slice(),
c.Bool("silent"),
)
pm, err := tandem.New(tandem.Config{
Cmds: args.Slice(),
Root: c.String("directory"),
Timeout: c.Int("timeout"),
Silent: c.Bool("silent"),
})
if err != nil {
return err
}
Expand All @@ -85,9 +81,9 @@ func main() {

if err := app.Run(os.Args); err != nil {
if errors.Is(err, ErrNoCommands) {
fmt.Fprintf(os.Stderr, "%s %v\n", red("Error:"), err)
fmt.Fprintf(os.Stderr, "%s %v\n", ansi.Red("Error:"), err)
} else {
fmt.Fprintf(os.Stderr, "%s %v\n", red("Error:"), err)
fmt.Fprintf(os.Stderr, "%s %v\n", ansi.Red("Error:"), err)
}
os.Exit(1)
}
Expand All @@ -112,5 +108,5 @@ var (
$ {{.Name}} -t 0 'sleep 5 && echo "hello"' 'sleep 2 && echo "world"'
`, bold(name), dim("Commands:"), dim("Options:"), dim("Examples:"))
`, ansi.Bold(name), ansi.Dim("Commands:"), ansi.Dim("Options:"), ansi.Dim("Examples:"))
)
28 changes: 6 additions & 22 deletions output.go → tandem/output.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package tandem

import (
"bufio"
Expand All @@ -10,6 +10,7 @@ import (
"syscall"

"github.com/pkg/term/termios"
"github.com/rosszurowski/tandem/ansi"
)

type ptyPipe struct {
Expand Down Expand Up @@ -73,15 +74,14 @@ func (m *multiOutput) WriteLine(proc *process, p []byte) {
var buf bytes.Buffer

if m.printProcName {
color := fmt.Sprintf("\033[0;38;5;%vm", proc.Color)
buf.WriteString(color)
buf.WriteString(ansi.ColorStart(proc.Color))
if m.printProcName {
buf.WriteString(proc.Name)
for i := len(proc.Name); i <= m.maxNameLength; i++ {
buf.WriteByte(' ')
}
}
buf.WriteString("\033[0m ")
buf.WriteString(ansi.ColorEnd() + " ")
}

// We trim the "/bin/sh: " prefix from the output of the command
Expand All @@ -96,7 +96,7 @@ func (m *multiOutput) WriteLine(proc *process, p []byte) {
}

func (m *multiOutput) WriteErr(proc *process, err error) {
m.WriteLine(proc, []byte(red(err.Error())))
m.WriteLine(proc, []byte(ansi.Red(err.Error())))
}

func scanLines(r io.Reader, callback func([]byte) bool) error {
Expand Down Expand Up @@ -137,23 +137,7 @@ func fatalOnErr(err error) {
}

func fatal(i ...interface{}) {
fmt.Fprint(os.Stderr, name+": ")
fmt.Fprint(os.Stderr, "tandem: ")
fmt.Fprintln(os.Stderr, i...)
os.Exit(1)
}

func red(s string) string {
return "\033[0;31m" + s + "\033[0m"
}

func gray(s string) string {
return "\033[0;38;5;8m" + s + "\033[0m"
}

func dim(s string) string {
return "\033[0;2m" + s + "\033[0m"
}

func bold(s string) string {
return "\033[1m" + s + "\033[0m"
}
61 changes: 43 additions & 18 deletions process.go → tandem/process.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package main
// Package tandem provides a process manager utility for running multiple
// processes and combining their output.
package tandem

import (
"encoding/json"
Expand All @@ -12,11 +14,15 @@ import (
"sync"
"syscall"
"time"

"github.com/rosszurowski/tandem/ansi"
)

var colors = []int{2, 3, 4, 5, 6, 42, 130, 103, 129, 108}

type processManager struct {
// ProcessManager manages a set of processes, combining their output and exiting
// all of them gracefully when one of them exits.
type ProcessManager struct {
output *multiOutput
procs []*process
procWg sync.WaitGroup
Expand All @@ -26,21 +32,35 @@ type processManager struct {
silent bool
}

func newProcessManager(root string, timeout int, cmds []string, silent bool) (*processManager, error) {
pm := &processManager{
// Config is the configuration for a process manager.
type Config struct {
Cmds []string // Shell commands to run
Root string // Root directory for commands to run from
Timeout int // Timeout in seconds for commands to exit gracefully before being killed. Defaults to 0.
Silent bool // Whether to silence process management messages like "Starting..."
}

// New creates a new process manager with the given configuration.
func New(cfg Config) (*ProcessManager, error) {
root, err := filepath.Abs(cfg.Root)
if err != nil {
return nil, fmt.Errorf("could not get absolute path for directory: %v", err)
}

pm := &ProcessManager{
output: &multiOutput{printProcName: true},
procs: make([]*process, 0),
timeout: time.Duration(timeout) * time.Second,
silent: silent,
timeout: time.Duration(cfg.Timeout) * time.Second,
silent: cfg.Silent,
}

env := os.Environ()
nodeBin := filepath.Join(root, "node_modules/.bin")
nodeBin := filepath.Join(cfg.Root, "node_modules/.bin")
if fi, err := os.Stat(nodeBin); err == nil && fi.IsDir() {
injectPathVal(env, nodeBin)
}

namedCmds, err := parseCommands(root, cmds)
namedCmds, err := parseCommands(root, cfg.Cmds)
if err != nil {
return nil, err
}
Expand All @@ -59,7 +79,8 @@ func newProcessManager(root string, timeout int, cmds []string, silent bool) (*p
return pm, nil
}

func (pm *processManager) Run() {
// Run starts all processes and waits for them to exit or be interrupted.
func (pm *ProcessManager) Run() {
pm.done = make(chan bool, len(pm.procs))
pm.interrupted = make(chan os.Signal)
signal.Notify(pm.interrupted, syscall.SIGINT, syscall.SIGTERM)
Expand All @@ -70,7 +91,7 @@ func (pm *processManager) Run() {
pm.procWg.Wait()
}

func (pm *processManager) runProcess(proc *process) {
func (pm *ProcessManager) runProcess(proc *process) {
pm.procWg.Add(1)
go func() {
defer pm.procWg.Done()
Expand All @@ -79,21 +100,21 @@ func (pm *processManager) runProcess(proc *process) {
}()
}

func (pm *processManager) waitForDoneOrInterrupt() {
func (pm *ProcessManager) waitForDoneOrInterrupt() {
select {
case <-pm.done:
case <-pm.interrupted:
}
}

func (pm *processManager) waitForTimeoutOrInterrupt() {
func (pm *ProcessManager) waitForTimeoutOrInterrupt() {
select {
case <-time.After(pm.timeout):
case <-pm.interrupted:
}
}

func (pm *processManager) waitForExit() {
func (pm *ProcessManager) waitForExit() {
pm.waitForDoneOrInterrupt()
for _, proc := range pm.procs {
go proc.Interrupt()
Expand Down Expand Up @@ -151,6 +172,10 @@ func (p *process) signal(sig os.Signal) {
}
}

func (p *process) writeDebug(s string) {
p.writeLine([]byte(ansi.Dim(s)))
}

func (p *process) writeLine(b []byte) {
p.output.WriteLine(p, b)
}
Expand All @@ -163,30 +188,30 @@ func (p *process) Run() {
p.output.PipeOutput(p)
defer p.output.ClosePipe(p)
if !p.silent {
p.writeLine([]byte(dim("Starting...")))
p.writeDebug("Starting...")
}
if err := p.Cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr.ExitCode() == 1 {
p.writeErr(err)
} else {
p.writeLine([]byte(dim(fmt.Sprintf("exit status %d", exitErr.ExitCode()))))
p.writeLine([]byte(ansi.Dim(fmt.Sprintf("exit status %d", exitErr.ExitCode()))))
}
return
}
p.writeErr(err)
return
}
if !p.silent {
p.writeLine([]byte(dim("Process exited")))
p.writeDebug("Process exited")
}
}

func (p *process) Interrupt() {
if p.Running() {
if !p.silent {
p.writeLine([]byte(dim("Interrupting...")))
p.writeDebug("Interrupting...")
}
p.signal(syscall.SIGINT)
}
Expand All @@ -195,7 +220,7 @@ func (p *process) Interrupt() {
func (p *process) Kill() {
if p.Running() {
if !p.silent {
p.writeLine([]byte(dim("Killing...")))
p.writeDebug("Killing...")
}
p.signal(syscall.SIGKILL)
}
Expand Down
Loading

0 comments on commit 524b1e0

Please sign in to comment.