From 6b9964bd215a9628abc2885503b4d8986ef02237 Mon Sep 17 00:00:00 2001 From: Dan Croak Date: Wed, 31 Dec 2025 09:23:55 -0500 Subject: [PATCH 1/2] go: consolidate procman.go into main.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-file structure is idiomatic for small CLI tools that aren't intended as libraries. At ~320 lines, the code is easily scannable in one file without jumping between files. This also improves consistency with mdembed, another pinned repo with similar single-purpose CLI tool characteristics. Ordering: imports → const/types → main() → functions by receiver. Co-authored-by: Warp --- main.go | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++ procman.go | 211 ----------------------------------------------------- 2 files changed, 203 insertions(+), 211 deletions(-) delete mode 100644 procman.go diff --git a/main.go b/main.go index 244ef27..d7c7152 100644 --- a/main.go +++ b/main.go @@ -9,14 +9,24 @@ package main import ( "bufio" + "bytes" "errors" "fmt" "io" "os" + "os/exec" + "os/signal" "regexp" "strings" + "sync" + "syscall" + "time" + + "github.com/creack/pty" ) +const timeout = 5 * time.Second + var ( colors = []int{2, 3, 4, 5, 6, 42, 130, 103, 129, 108} procfileRe = regexp.MustCompile(`^([\w-]+):\s+(.+)$`) @@ -28,6 +38,30 @@ type procDef struct { cmd string } +// manager handles overall process management +type manager struct { + output *output + procs []*process + procWg sync.WaitGroup + done chan bool + interrupted chan os.Signal +} + +// process represents an individual process to be managed +type process struct { + *exec.Cmd + name string + color int + output *output +} + +// output manages the output display of processes +type output struct { + maxNameLength int + mutex sync.Mutex + pipes map[*process]*os.File +} + func main() { procNames, err := parseArgs(os.Args) if err != nil { @@ -115,3 +149,172 @@ func parseProcfile(r io.Reader) ([]procDef, error) { } return defs, nil } + +// setupProcesses creates and initializes processes based on the given procDefs. +func (mgr *manager) setupProcesses(defs []procDef, procNames []string) error { + defMap := make(map[string]string) + for _, def := range defs { + defMap[def.name] = def.cmd + } + + for i, name := range procNames { + cmd, ok := defMap[name] + if !ok { + return fmt.Errorf("No process named %s in Procfile.dev\n", name) + } + + proc := &process{ + Cmd: exec.Command("/bin/sh", "-c", cmd), + name: name, + color: colors[i%len(colors)], + output: mgr.output, + } + mgr.procs = append(mgr.procs, proc) + } + return nil +} + +// setupSignalHandling configures handling of interrupt signals. +func (mgr *manager) setupSignalHandling() { + mgr.done = make(chan bool, len(mgr.procs)) + mgr.interrupted = make(chan os.Signal, 1) + signal.Notify(mgr.interrupted, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) +} + +// runProcess adds the process to the wait group and starts it. +func (mgr *manager) runProcess(proc *process) { + mgr.procWg.Add(1) + go func() { + defer mgr.procWg.Done() + defer func() { mgr.done <- true }() + proc.run() + }() +} + +// waitForExit waits for all processes to exit or for an interruption signal. +func (mgr *manager) waitForExit() { + select { + case <-mgr.done: + case <-mgr.interrupted: + } + + for _, proc := range mgr.procs { + proc.interrupt() + } + + select { + case <-time.After(timeout): + case <-mgr.interrupted: + } + + for _, proc := range mgr.procs { + proc.kill() + } +} + +// running inspects the process to see if it is currently running. +func (proc *process) running() bool { + return proc.Process != nil && proc.ProcessState == nil +} + +// run starts the execution of the process and handles its output. +func (proc *process) run() { + if proc.Process != nil { + fmt.Fprintf(os.Stderr, "Process %s already started\n", proc.name) + return + } + + proc.output.pipeOutput(proc) + defer proc.output.closePipe(proc) + + if err := proc.Cmd.Wait(); err != nil { + proc.output.writeErr(proc, err) + } +} + +// interrupt sends an interrupt signal to a running process. +func (proc *process) interrupt() { + if proc.running() { + proc.signal(syscall.SIGINT) + } +} + +// kill forcefully stops a running process. +func (proc *process) kill() { + if proc.running() { + proc.signal(syscall.SIGKILL) + } +} + +// signal sends a specified signal to the process group. +func (proc *process) signal(sig syscall.Signal) { + if err := syscall.Kill(-proc.Process.Pid, sig); err != nil { + proc.output.writeErr(proc, err) + } +} + +// init initializes the output handler for all processes. +func (out *output) init(procs []*process) { + out.pipes = make(map[*process]*os.File) + for _, proc := range procs { + if len(proc.name) > out.maxNameLength { + out.maxNameLength = len(proc.name) + } + } +} + +// pipeOutput handles the output piping for a process. +func (out *output) pipeOutput(proc *process) { + ptyFile, err := pty.Start(proc.Cmd) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening PTY: %v\n", err) + os.Exit(1) + } + + out.mutex.Lock() + out.pipes[proc] = ptyFile + out.mutex.Unlock() + + go func() { + scanner := bufio.NewScanner(ptyFile) + for scanner.Scan() { + out.writeLine(proc, scanner.Bytes()) + } + if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) { + out.writeErr(proc, err) + } + }() +} + +// closePipe closes the pseudo-terminal associated with the process. +func (out *output) closePipe(proc *process) { + out.mutex.Lock() + ptyFile := out.pipes[proc] + out.mutex.Unlock() + + if ptyFile != nil { + ptyFile.Close() + } +} + +// writeLine writes a line of output for the specified process, with color formatting. +func (out *output) writeLine(proc *process, p []byte) { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("\033[1;38;5;%vm", proc.color)) + buf.WriteString(proc.name) + for i := len(proc.name); i <= out.maxNameLength; i++ { + buf.WriteByte(' ') + } + buf.WriteString("\033[0m| ") + buf.Write(p) + buf.WriteByte('\n') + + out.mutex.Lock() + defer out.mutex.Unlock() + buf.WriteTo(os.Stdout) +} + +// writeErr writes an error message for the specified process. +func (out *output) writeErr(proc *process, err error) { + out.writeLine(proc, []byte(fmt.Sprintf("\033[0;31m%v\033[0m", err))) +} diff --git a/procman.go b/procman.go deleted file mode 100644 index 45b5c13..0000000 --- a/procman.go +++ /dev/null @@ -1,211 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "os" - "os/exec" - "os/signal" - "sync" - "syscall" - "time" - - "github.com/creack/pty" -) - -const timeout = 5 * time.Second - -// manager handles overall process management -type manager struct { - output *output - procs []*process - procWg sync.WaitGroup - done chan bool - interrupted chan os.Signal -} - -// setupProcesses creates and initializes processes based on the given procDefs. -func (mgr *manager) setupProcesses(defs []procDef, procNames []string) error { - defMap := make(map[string]string) - for _, def := range defs { - defMap[def.name] = def.cmd - } - - for i, name := range procNames { - cmd, ok := defMap[name] - if !ok { - return fmt.Errorf("No process named %s in Procfile.dev\n", name) - } - - proc := &process{ - Cmd: exec.Command("/bin/sh", "-c", cmd), - name: name, - color: colors[i%len(colors)], - output: mgr.output, - } - mgr.procs = append(mgr.procs, proc) - } - return nil -} - -// setupSignalHandling configures handling of interrupt signals. -func (mgr *manager) setupSignalHandling() { - mgr.done = make(chan bool, len(mgr.procs)) - mgr.interrupted = make(chan os.Signal, 1) - signal.Notify(mgr.interrupted, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) -} - -// runProcess adds the process to the wait group and starts it. -func (mgr *manager) runProcess(proc *process) { - mgr.procWg.Add(1) - go func() { - defer mgr.procWg.Done() - defer func() { mgr.done <- true }() - proc.run() - }() -} - -// waitForExit waits for all processes to exit or for an interruption signal. -func (mgr *manager) waitForExit() { - select { - case <-mgr.done: - case <-mgr.interrupted: - } - - for _, proc := range mgr.procs { - proc.interrupt() - } - - select { - case <-time.After(timeout): - case <-mgr.interrupted: - } - - for _, proc := range mgr.procs { - proc.kill() - } -} - -// process represents an individual process to be managed -type process struct { - *exec.Cmd - name string - color int - output *output -} - -// running inspects the process to see if it is currently running. -func (proc *process) running() bool { - return proc.Process != nil && proc.ProcessState == nil -} - -// run starts the execution of the process and handles its output. -func (proc *process) run() { - if proc.Process != nil { - fmt.Fprintf(os.Stderr, "Process %s already started\n", proc.name) - return - } - - proc.output.pipeOutput(proc) - defer proc.output.closePipe(proc) - - if err := proc.Cmd.Wait(); err != nil { - proc.output.writeErr(proc, err) - } -} - -// interrupt sends an interrupt signal to a running process. -func (proc *process) interrupt() { - if proc.running() { - proc.signal(syscall.SIGINT) - } -} - -// kill forcefully stops a running process. -func (proc *process) kill() { - if proc.running() { - proc.signal(syscall.SIGKILL) - } -} - -// signal sends a specified signal to the process group. -func (proc *process) signal(sig syscall.Signal) { - if err := syscall.Kill(-proc.Process.Pid, sig); err != nil { - proc.output.writeErr(proc, err) - } -} - -// output manages the output display of processes -type output struct { - maxNameLength int - mutex sync.Mutex - pipes map[*process]*os.File -} - -// init initializes the output handler for all processes. -func (out *output) init(procs []*process) { - out.pipes = make(map[*process]*os.File) - for _, proc := range procs { - if len(proc.name) > out.maxNameLength { - out.maxNameLength = len(proc.name) - } - } -} - -// pipeOutput handles the output piping for a process. -func (out *output) pipeOutput(proc *process) { - ptyFile, err := pty.Start(proc.Cmd) - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening PTY: %v\n", err) - os.Exit(1) - } - - out.mutex.Lock() - out.pipes[proc] = ptyFile - out.mutex.Unlock() - - go func() { - scanner := bufio.NewScanner(ptyFile) - for scanner.Scan() { - out.writeLine(proc, scanner.Bytes()) - } - if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) { - out.writeErr(proc, err) - } - }() -} - -// closePipe closes the pseudo-terminal associated with the process. -func (out *output) closePipe(proc *process) { - out.mutex.Lock() - ptyFile := out.pipes[proc] - out.mutex.Unlock() - - if ptyFile != nil { - ptyFile.Close() - } -} - -// writeLine writes a line of output for the specified process, with color formatting. -func (out *output) writeLine(proc *process, p []byte) { - var buf bytes.Buffer - buf.WriteString(fmt.Sprintf("\033[1;38;5;%vm", proc.color)) - buf.WriteString(proc.name) - for i := len(proc.name); i <= out.maxNameLength; i++ { - buf.WriteByte(' ') - } - buf.WriteString("\033[0m| ") - buf.Write(p) - buf.WriteByte('\n') - - out.mutex.Lock() - defer out.mutex.Unlock() - buf.WriteTo(os.Stdout) -} - -// writeErr writes an error message for the specified process. -func (out *output) writeErr(proc *process, err error) { - out.writeLine(proc, []byte(fmt.Sprintf("\033[0;31m%v\033[0m", err))) -} From 0654ebba4cf2bc89a903747d8eb164ef8c295649 Mon Sep 17 00:00:00 2001 From: Dan Croak Date: Wed, 31 Dec 2025 09:26:49 -0500 Subject: [PATCH 2/2] test: consolidate procman_test.go into main_test.go Mirrors the source file consolidation for consistency. At ~270 lines, the tests are easily scannable in one file. TestMain stays at the top as test infrastructure, followed by unit tests, then the integration test. Co-authored-by: Warp --- main_test.go | 165 ++++++++++++++++++++++++++++++++++++++++++++++ procman_test.go | 170 ------------------------------------------------ 2 files changed, 165 insertions(+), 170 deletions(-) delete mode 100644 procman_test.go diff --git a/main_test.go b/main_test.go index 82804b9..29e1ec7 100644 --- a/main_test.go +++ b/main_test.go @@ -1,11 +1,28 @@ package main import ( + "bytes" + "fmt" + "os" + "os/exec" "reflect" "strings" "testing" + "time" ) +func TestMain(m *testing.M) { + cmd := exec.Command("go", "build", "-o", "procman", ".") + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "failed to build: %v\n", err) + os.Exit(1) + } + code := m.Run() + os.Remove("procman") + os.Remove("Procfile.dev") + os.Exit(code) +} + func TestParseArgs(t *testing.T) { tests := []struct { name string @@ -104,3 +121,151 @@ func TestParseProcfile(t *testing.T) { }) } } + +func TestSetupProcesses(t *testing.T) { + tests := []struct { + name string + defs []procDef + procNames []string + wantError bool + }{ + { + name: "Valid setup", + defs: []procDef{ + {name: "web", cmd: "command1"}, + {name: "db", cmd: "command2"}, + }, + procNames: []string{"web", "db"}, + wantError: false, + }, + { + name: "Invalid process name", + defs: []procDef{ + {name: "web", cmd: "command1"}, + }, + procNames: []string{"web", "api"}, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr := manager{output: &output{}} + err := mgr.setupProcesses(tt.defs, tt.procNames) + + if (err != nil) != tt.wantError { + t.Errorf("setupProcesses() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +func TestSetupSignalHandling(t *testing.T) { + mgr := manager{output: &output{}, procs: make([]*process, 2)} + mgr.setupSignalHandling() + + if mgr.done == nil || mgr.interrupted == nil { + t.Errorf("setupSignalHandling did not initialize channels correctly") + } +} + +func TestProcessRunning(t *testing.T) { + cmd := exec.Command("sleep", "1") + proc := &process{Cmd: cmd, output: &output{}} + + if proc.running() { + t.Errorf("expected process to not be running before start") + } + + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start process: %v", err) + } + + if !proc.running() { + t.Errorf("expected process to be running after start") + } +} + +func TestInit(t *testing.T) { + out := &output{} + procs := []*process{{name: "testProcess"}} + + out.init(procs) + + if out.maxNameLength != len(procs[0].name) { + t.Errorf("Expected maxNameLength to be %d, got %d", len(procs[0].name), out.maxNameLength) + } + if out.pipes == nil { + t.Errorf("Expected pipes to be initialized") + } +} + +func TestWriteLine(t *testing.T) { + out := &output{maxNameLength: 11} // Length of "testProcess" + proc := &process{name: "testProcess", color: 31} + + // Capture standard output + originalStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + out.writeLine(proc, []byte("Hello world")) + + w.Close() + os.Stdout = originalStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + expected := "\033[1;38;5;31mtestProcess \033[0m| Hello world\n" + if got := buf.String(); got != expected { + t.Errorf("Expected output %q, got %q", expected, got) + } +} + +func TestWriteErr(t *testing.T) { + out := &output{maxNameLength: 11} + proc := &process{name: "testProcess", color: 31} + testErr := fmt.Errorf("test error") + + originalStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + out.writeErr(proc, testErr) + + w.Close() + os.Stdout = originalStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + expected := "\033[1;38;5;31mtestProcess \033[0m| \033[0;31mtest error\033[0m\n" + if got := buf.String(); got != expected { + t.Errorf("Expected output %q, got %q", expected, got) + } +} + +// TestProcmanIntegration tests the full procman workflow. +func TestProcmanIntegration(t *testing.T) { + content := "echo: echo 'hello'\nsleep: sleep 10" + if err := os.WriteFile("Procfile.dev", []byte(content), 0644); err != nil { + t.Fatalf("Failed to create mock Procfile.dev: %v", err) + } + defer os.Remove("Procfile.dev") + + cmd := exec.Command("./procman", "echo,sleep") + if err := cmd.Start(); err != nil { + t.Fatalf("Failed to start procman: %v", err) + } + + time.Sleep(1 * time.Second) + + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatalf("Failed to send SIGINT to procman: %v", err) + } + + if err := cmd.Wait(); err != nil { + t.Fatalf("procman did not exit cleanly: %v", err) + } +} diff --git a/procman_test.go b/procman_test.go deleted file mode 100644 index 0de20dc..0000000 --- a/procman_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "testing" - "time" -) - -func TestMain(m *testing.M) { - cmd := exec.Command("go", "build", "-o", "procman", ".") - if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "failed to build: %v\n", err) - os.Exit(1) - } - code := m.Run() - os.Remove("procman") - os.Remove("Procfile.dev") - os.Exit(code) -} - -func TestSetupProcesses(t *testing.T) { - tests := []struct { - name string - defs []procDef - procNames []string - wantError bool - }{ - { - name: "Valid setup", - defs: []procDef{ - {name: "web", cmd: "command1"}, - {name: "db", cmd: "command2"}, - }, - procNames: []string{"web", "db"}, - wantError: false, - }, - { - name: "Invalid process name", - defs: []procDef{ - {name: "web", cmd: "command1"}, - }, - procNames: []string{"web", "api"}, - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := manager{output: &output{}} - err := mgr.setupProcesses(tt.defs, tt.procNames) - - if (err != nil) != tt.wantError { - t.Errorf("setupProcesses() error = %v, wantError %v", err, tt.wantError) - } - }) - } -} - -func TestSetupSignalHandling(t *testing.T) { - mgr := manager{output: &output{}, procs: make([]*process, 2)} - mgr.setupSignalHandling() - - if mgr.done == nil || mgr.interrupted == nil { - t.Errorf("setupSignalHandling did not initialize channels correctly") - } -} - -func TestProcessRunning(t *testing.T) { - cmd := exec.Command("sleep", "1") - proc := &process{Cmd: cmd, output: &output{}} - - if proc.running() { - t.Errorf("expected process to not be running before start") - } - - if err := cmd.Start(); err != nil { - t.Fatalf("failed to start process: %v", err) - } - - if !proc.running() { - t.Errorf("expected process to be running after start") - } -} - -func TestInit(t *testing.T) { - out := &output{} - procs := []*process{{name: "testProcess"}} - - out.init(procs) - - if out.maxNameLength != len(procs[0].name) { - t.Errorf("Expected maxNameLength to be %d, got %d", len(procs[0].name), out.maxNameLength) - } - if out.pipes == nil { - t.Errorf("Expected pipes to be initialized") - } -} - -func TestWriteLine(t *testing.T) { - out := &output{maxNameLength: 11} // Length of "testProcess" - proc := &process{name: "testProcess", color: 31} - - // Capture standard output - originalStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - out.writeLine(proc, []byte("Hello world")) - - w.Close() - os.Stdout = originalStdout - - var buf bytes.Buffer - buf.ReadFrom(r) - - expected := "\033[1;38;5;31mtestProcess \033[0m| Hello world\n" - if got := buf.String(); got != expected { - t.Errorf("Expected output %q, got %q", expected, got) - } -} - -func TestWriteErr(t *testing.T) { - out := &output{maxNameLength: 11} - proc := &process{name: "testProcess", color: 31} - testErr := fmt.Errorf("test error") - - originalStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - out.writeErr(proc, testErr) - - w.Close() - os.Stdout = originalStdout - - var buf bytes.Buffer - buf.ReadFrom(r) - - expected := "\033[1;38;5;31mtestProcess \033[0m| \033[0;31mtest error\033[0m\n" - if got := buf.String(); got != expected { - t.Errorf("Expected output %q, got %q", expected, got) - } -} - -// TestProcmanIntegration tests the full procman workflow. -func TestProcmanIntegration(t *testing.T) { - content := "echo: echo 'hello'\nsleep: sleep 10" - if err := os.WriteFile("Procfile.dev", []byte(content), 0644); err != nil { - t.Fatalf("Failed to create mock Procfile.dev: %v", err) - } - defer os.Remove("Procfile.dev") - - cmd := exec.Command("./procman", "echo,sleep") - if err := cmd.Start(); err != nil { - t.Fatalf("Failed to start procman: %v", err) - } - - time.Sleep(1 * time.Second) - - if err := cmd.Process.Signal(os.Interrupt); err != nil { - t.Fatalf("Failed to send SIGINT to procman: %v", err) - } - - if err := cmd.Wait(); err != nil { - t.Fatalf("procman did not exit cleanly: %v", err) - } -}