Skip to content

Commit 524b1e0

Browse files
authored
Provide Go API by separating CLI from library (#6)
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
1 parent 141ee97 commit 524b1e0

File tree

6 files changed

+163
-57
lines changed

6 files changed

+163
-57
lines changed

.goreleaser.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ before:
77
- go mod tidy
88
- go generate ./...
99
builds:
10-
- env:
10+
- main: ./cmd/tandem
11+
env:
1112
- CGO_ENABLED=0
1213
goos:
1314
- linux

ansi/ansi.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Package ansi provides ANSI escape codes for color and formatting.
2+
package ansi
3+
4+
import (
5+
"fmt"
6+
"os"
7+
)
8+
9+
// NoColor disables ANSI color output. By default it is set to true if the
10+
// NO_COLOR environment variable is set.
11+
var NoColor = os.Getenv("NO_COLOR") != ""
12+
13+
// Red returns a string wrapped in ANSI escape codes to make it red.
14+
func Red(s string) string {
15+
if NoColor {
16+
return s
17+
}
18+
return "\033[0;31m" + s + "\033[0m"
19+
}
20+
21+
// Gray returns a string wrapped in ANSI escape codes to make it gray.
22+
func Gray(s string) string {
23+
if NoColor {
24+
return s
25+
}
26+
return "\033[0;38;5;8m" + s + "\033[0m"
27+
}
28+
29+
// Dim returns a string wrapped in ANSI escape codes to make it dim.
30+
func Dim(s string) string {
31+
if NoColor {
32+
return s
33+
}
34+
return "\033[0;2m" + s + "\033[0m"
35+
}
36+
37+
// Bold returns a string wrapped in ANSI escape codes to make it bold.
38+
func Bold(s string) string {
39+
if NoColor {
40+
return s
41+
}
42+
return "\033[1m" + s + "\033[0m"
43+
}
44+
45+
func ColorStart(i int) string {
46+
if NoColor {
47+
return ""
48+
}
49+
return fmt.Sprintf("\033[0;38;5;%vm", i)
50+
}
51+
52+
func ColorEnd() string {
53+
if NoColor {
54+
return ""
55+
}
56+
return "\033[0m"
57+
}

main.go renamed to cmd/tandem/tandem.go

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"errors"
55
"fmt"
66
"os"
7-
"path/filepath"
87
"sort"
98

9+
"github.com/rosszurowski/tandem/ansi"
10+
"github.com/rosszurowski/tandem/tandem"
1011
"github.com/urfave/cli/v2"
1112
)
1213

@@ -60,17 +61,12 @@ func main() {
6061
if args.Len() < 1 {
6162
return ErrNoCommands
6263
}
63-
64-
root, err := filepath.Abs(c.String("directory"))
65-
if err != nil {
66-
return fmt.Errorf("could not get absolute path for directory: %v", err)
67-
}
68-
pm, err := newProcessManager(
69-
root,
70-
c.Int("timeout"),
71-
args.Slice(),
72-
c.Bool("silent"),
73-
)
64+
pm, err := tandem.New(tandem.Config{
65+
Cmds: args.Slice(),
66+
Root: c.String("directory"),
67+
Timeout: c.Int("timeout"),
68+
Silent: c.Bool("silent"),
69+
})
7470
if err != nil {
7571
return err
7672
}
@@ -85,9 +81,9 @@ func main() {
8581

8682
if err := app.Run(os.Args); err != nil {
8783
if errors.Is(err, ErrNoCommands) {
88-
fmt.Fprintf(os.Stderr, "%s %v\n", red("Error:"), err)
84+
fmt.Fprintf(os.Stderr, "%s %v\n", ansi.Red("Error:"), err)
8985
} else {
90-
fmt.Fprintf(os.Stderr, "%s %v\n", red("Error:"), err)
86+
fmt.Fprintf(os.Stderr, "%s %v\n", ansi.Red("Error:"), err)
9187
}
9288
os.Exit(1)
9389
}
@@ -112,5 +108,5 @@ var (
112108
113109
$ {{.Name}} -t 0 'sleep 5 && echo "hello"' 'sleep 2 && echo "world"'
114110
115-
`, bold(name), dim("Commands:"), dim("Options:"), dim("Examples:"))
111+
`, ansi.Bold(name), ansi.Dim("Commands:"), ansi.Dim("Options:"), ansi.Dim("Examples:"))
116112
)

output.go renamed to tandem/output.go

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package main
1+
package tandem
22

33
import (
44
"bufio"
@@ -10,6 +10,7 @@ import (
1010
"syscall"
1111

1212
"github.com/pkg/term/termios"
13+
"github.com/rosszurowski/tandem/ansi"
1314
)
1415

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

7576
if m.printProcName {
76-
color := fmt.Sprintf("\033[0;38;5;%vm", proc.Color)
77-
buf.WriteString(color)
77+
buf.WriteString(ansi.ColorStart(proc.Color))
7878
if m.printProcName {
7979
buf.WriteString(proc.Name)
8080
for i := len(proc.Name); i <= m.maxNameLength; i++ {
8181
buf.WriteByte(' ')
8282
}
8383
}
84-
buf.WriteString("\033[0m ")
84+
buf.WriteString(ansi.ColorEnd() + " ")
8585
}
8686

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

9898
func (m *multiOutput) WriteErr(proc *process, err error) {
99-
m.WriteLine(proc, []byte(red(err.Error())))
99+
m.WriteLine(proc, []byte(ansi.Red(err.Error())))
100100
}
101101

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

139139
func fatal(i ...interface{}) {
140-
fmt.Fprint(os.Stderr, name+": ")
140+
fmt.Fprint(os.Stderr, "tandem: ")
141141
fmt.Fprintln(os.Stderr, i...)
142142
os.Exit(1)
143143
}
144-
145-
func red(s string) string {
146-
return "\033[0;31m" + s + "\033[0m"
147-
}
148-
149-
func gray(s string) string {
150-
return "\033[0;38;5;8m" + s + "\033[0m"
151-
}
152-
153-
func dim(s string) string {
154-
return "\033[0;2m" + s + "\033[0m"
155-
}
156-
157-
func bold(s string) string {
158-
return "\033[1m" + s + "\033[0m"
159-
}

process.go renamed to tandem/process.go

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
package main
1+
// Package tandem provides a process manager utility for running multiple
2+
// processes and combining their output.
3+
package tandem
24

35
import (
46
"encoding/json"
@@ -12,11 +14,15 @@ import (
1214
"sync"
1315
"syscall"
1416
"time"
17+
18+
"github.com/rosszurowski/tandem/ansi"
1519
)
1620

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

19-
type processManager struct {
23+
// ProcessManager manages a set of processes, combining their output and exiting
24+
// all of them gracefully when one of them exits.
25+
type ProcessManager struct {
2026
output *multiOutput
2127
procs []*process
2228
procWg sync.WaitGroup
@@ -26,21 +32,35 @@ type processManager struct {
2632
silent bool
2733
}
2834

29-
func newProcessManager(root string, timeout int, cmds []string, silent bool) (*processManager, error) {
30-
pm := &processManager{
35+
// Config is the configuration for a process manager.
36+
type Config struct {
37+
Cmds []string // Shell commands to run
38+
Root string // Root directory for commands to run from
39+
Timeout int // Timeout in seconds for commands to exit gracefully before being killed. Defaults to 0.
40+
Silent bool // Whether to silence process management messages like "Starting..."
41+
}
42+
43+
// New creates a new process manager with the given configuration.
44+
func New(cfg Config) (*ProcessManager, error) {
45+
root, err := filepath.Abs(cfg.Root)
46+
if err != nil {
47+
return nil, fmt.Errorf("could not get absolute path for directory: %v", err)
48+
}
49+
50+
pm := &ProcessManager{
3151
output: &multiOutput{printProcName: true},
3252
procs: make([]*process, 0),
33-
timeout: time.Duration(timeout) * time.Second,
34-
silent: silent,
53+
timeout: time.Duration(cfg.Timeout) * time.Second,
54+
silent: cfg.Silent,
3555
}
3656

3757
env := os.Environ()
38-
nodeBin := filepath.Join(root, "node_modules/.bin")
58+
nodeBin := filepath.Join(cfg.Root, "node_modules/.bin")
3959
if fi, err := os.Stat(nodeBin); err == nil && fi.IsDir() {
4060
injectPathVal(env, nodeBin)
4161
}
4262

43-
namedCmds, err := parseCommands(root, cmds)
63+
namedCmds, err := parseCommands(root, cfg.Cmds)
4464
if err != nil {
4565
return nil, err
4666
}
@@ -59,7 +79,8 @@ func newProcessManager(root string, timeout int, cmds []string, silent bool) (*p
5979
return pm, nil
6080
}
6181

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

73-
func (pm *processManager) runProcess(proc *process) {
94+
func (pm *ProcessManager) runProcess(proc *process) {
7495
pm.procWg.Add(1)
7596
go func() {
7697
defer pm.procWg.Done()
@@ -79,21 +100,21 @@ func (pm *processManager) runProcess(proc *process) {
79100
}()
80101
}
81102

82-
func (pm *processManager) waitForDoneOrInterrupt() {
103+
func (pm *ProcessManager) waitForDoneOrInterrupt() {
83104
select {
84105
case <-pm.done:
85106
case <-pm.interrupted:
86107
}
87108
}
88109

89-
func (pm *processManager) waitForTimeoutOrInterrupt() {
110+
func (pm *ProcessManager) waitForTimeoutOrInterrupt() {
90111
select {
91112
case <-time.After(pm.timeout):
92113
case <-pm.interrupted:
93114
}
94115
}
95116

96-
func (pm *processManager) waitForExit() {
117+
func (pm *ProcessManager) waitForExit() {
97118
pm.waitForDoneOrInterrupt()
98119
for _, proc := range pm.procs {
99120
go proc.Interrupt()
@@ -151,6 +172,10 @@ func (p *process) signal(sig os.Signal) {
151172
}
152173
}
153174

175+
func (p *process) writeDebug(s string) {
176+
p.writeLine([]byte(ansi.Dim(s)))
177+
}
178+
154179
func (p *process) writeLine(b []byte) {
155180
p.output.WriteLine(p, b)
156181
}
@@ -163,30 +188,30 @@ func (p *process) Run() {
163188
p.output.PipeOutput(p)
164189
defer p.output.ClosePipe(p)
165190
if !p.silent {
166-
p.writeLine([]byte(dim("Starting...")))
191+
p.writeDebug("Starting...")
167192
}
168193
if err := p.Cmd.Run(); err != nil {
169194
var exitErr *exec.ExitError
170195
if errors.As(err, &exitErr) {
171196
if exitErr.ExitCode() == 1 {
172197
p.writeErr(err)
173198
} else {
174-
p.writeLine([]byte(dim(fmt.Sprintf("exit status %d", exitErr.ExitCode()))))
199+
p.writeLine([]byte(ansi.Dim(fmt.Sprintf("exit status %d", exitErr.ExitCode()))))
175200
}
176201
return
177202
}
178203
p.writeErr(err)
179204
return
180205
}
181206
if !p.silent {
182-
p.writeLine([]byte(dim("Process exited")))
207+
p.writeDebug("Process exited")
183208
}
184209
}
185210

186211
func (p *process) Interrupt() {
187212
if p.Running() {
188213
if !p.silent {
189-
p.writeLine([]byte(dim("Interrupting...")))
214+
p.writeDebug("Interrupting...")
190215
}
191216
p.signal(syscall.SIGINT)
192217
}
@@ -195,7 +220,7 @@ func (p *process) Interrupt() {
195220
func (p *process) Kill() {
196221
if p.Running() {
197222
if !p.silent {
198-
p.writeLine([]byte(dim("Killing...")))
223+
p.writeDebug("Killing...")
199224
}
200225
p.signal(syscall.SIGKILL)
201226
}

0 commit comments

Comments
 (0)