├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── console.go ├── console_nix.go ├── console_test.go ├── console_windows.go ├── console_windows_test.go ├── errors.go ├── examples └── simple │ └── simple.go ├── go.mod ├── go.sum ├── interfaces └── console.go ├── snapshots ├── darwin │ ├── TestRun.snap │ ├── TestSize.snap │ └── TestSize2.snap ├── linux │ ├── TestRun.snap │ ├── TestSize.snap │ └── TestSize2.snap └── windows │ ├── TestRun.snap │ ├── TestSize.snap │ └── TestSize2.snap └── winpty ├── winpty-agent.exe └── winpty.dll /.gitattributes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runletapp/go-console/27323a28410a42120ce1e906e2c9332addc7aeb8/.gitattributes -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "07:00" 8 | open-pull-requests-limit: 99 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | env: 12 | CACHE_VERSION: 1 13 | 14 | jobs: 15 | amd64: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, macos-latest, windows-latest] 20 | go: [ '1.16' ] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - uses: actions/setup-go@v2 26 | with: 27 | go-version: ${{ matrix.go }} # The Go version to download (if necessary) and use. 28 | - run: go version 29 | 30 | - name: Cache 31 | uses: actions/cache@v1 32 | with: 33 | path: ~/go/pkg/mod 34 | key: ${{ runner.os }}-go_modules-${{ matrix.go }}-${{env.CACHE_VERSION}}-${{ hashFiles('**/go.*') }} 35 | restore-keys: | 36 | ${{ runner.os }}-go_modules-${{ matrix.go }}-${{env.CACHE_VERSION}}- 37 | 38 | - name: Run tests 39 | run: go test ./... 40 | 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | .idea -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.formatTool": "goimports" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Runlet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-console 2 | 3 | [![Main](https://github.com/runletapp/go-console/actions/workflows/main.yml/badge.svg)](https://github.com/runletapp/go-console/actions/workflows/main.yml) 4 | [![GoDoc](https://godoc.org/github.com/runletapp/go-console?status.svg)](https://godoc.org/github.com/runletapp/go-console) 5 | 6 | `go-console` is a cross-platform `PTY` interface. On *nix platforms we rely on [pty](github.com/creack/pty) and on windows [go-winpty](https://github.com/iamacarpet/go-winpty) (go-console will ship [winpty-0.4.3-msvc2015](https://github.com/rprichard/winpty/releases/tag/0.4.3) using `go:embed`, so there's no need to include winpty binaries) 7 | 8 | ## Example 9 | 10 | ```go 11 | package main 12 | 13 | import ( 14 | "io" 15 | "log" 16 | "os" 17 | "runtime" 18 | "sync" 19 | 20 | "github.com/runletapp/go-console" 21 | ) 22 | 23 | func main() { 24 | 25 | proc, err := console.New(120, 60) 26 | if err != nil { 27 | panic(err) 28 | } 29 | defer proc.Close() 30 | 31 | var args []string 32 | 33 | if runtime.GOOS == "windows" { 34 | args = []string{"cmd.exe", "/c", "dir"} 35 | } else { 36 | args = []string{"ls", "-lah", "--color"} 37 | } 38 | 39 | if err := proc.Start(args); err != nil { 40 | panic(err) 41 | } 42 | 43 | var wg sync.WaitGroup 44 | wg.Add(1) 45 | go func() { 46 | defer wg.Done() 47 | 48 | _, err = io.Copy(os.Stdout, proc) 49 | if err != nil { 50 | log.Printf("Error: %v\n", err) 51 | } 52 | }() 53 | 54 | if _, err := proc.Wait(); err != nil { 55 | log.Printf("Wait err: %v\n", err) 56 | } 57 | 58 | wg.Wait() 59 | } 60 | 61 | ``` 62 | -------------------------------------------------------------------------------- /console.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "github.com/runletapp/go-console/interfaces" 5 | ) 6 | 7 | // Console communication interface 8 | type Console interfaces.Console 9 | 10 | // New creates a new console with initial size 11 | func New(w int, h int) (Console, error) { 12 | return newNative(w, h) 13 | } 14 | -------------------------------------------------------------------------------- /console_nix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package console 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/creack/pty" 10 | 11 | "github.com/runletapp/go-console/interfaces" 12 | ) 13 | 14 | var _ interfaces.Console = (*consoleNix)(nil) 15 | 16 | type consoleNix struct { 17 | file *os.File 18 | cmd *exec.Cmd 19 | 20 | initialCols int 21 | initialRows int 22 | 23 | cwd string 24 | env []string 25 | } 26 | 27 | func newNative(cols int, rows int) (Console, error) { 28 | return &consoleNix{ 29 | initialCols: cols, 30 | initialRows: rows, 31 | 32 | file: nil, 33 | 34 | cwd: ".", 35 | env: os.Environ(), 36 | }, nil 37 | } 38 | 39 | // Start starts a process and wraps in a console 40 | func (c *consoleNix) Start(args []string) error { 41 | cmd, err := c.buildCmd(args) 42 | if err != nil { 43 | return err 44 | } 45 | c.cmd = cmd 46 | 47 | cmd.Dir = c.cwd 48 | cmd.Env = c.env 49 | 50 | f, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(c.initialCols), Rows: uint16(c.initialRows)}) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | c.file = f 56 | return nil 57 | } 58 | 59 | func (c *consoleNix) buildCmd(args []string) (*exec.Cmd, error) { 60 | if len(args) < 1 { 61 | return nil, ErrInvalidCmd 62 | } 63 | cmd := exec.Command(args[0], args[1:]...) 64 | return cmd, nil 65 | } 66 | 67 | func (c *consoleNix) Read(b []byte) (int, error) { 68 | if c.file == nil { 69 | return 0, ErrProcessNotStarted 70 | } 71 | 72 | return c.file.Read(b) 73 | } 74 | 75 | func (c *consoleNix) Write(b []byte) (int, error) { 76 | if c.file == nil { 77 | return 0, ErrProcessNotStarted 78 | } 79 | 80 | return c.file.Write(b) 81 | } 82 | 83 | func (c *consoleNix) Close() error { 84 | if c.file == nil { 85 | return ErrProcessNotStarted 86 | } 87 | 88 | return c.file.Close() 89 | } 90 | 91 | func (c *consoleNix) SetSize(cols int, rows int) error { 92 | if c.file == nil { 93 | c.initialRows = rows 94 | c.initialCols = cols 95 | return nil 96 | } 97 | 98 | return pty.Setsize(c.file, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) 99 | } 100 | 101 | func (c *consoleNix) GetSize() (int, int, error) { 102 | if c.file == nil { 103 | return c.initialCols, c.initialRows, nil 104 | } 105 | 106 | rows, cols, err := pty.Getsize(c.file) 107 | return cols, rows, err 108 | } 109 | 110 | func (c *consoleNix) Wait() (*os.ProcessState, error) { 111 | if c.cmd == nil { 112 | return nil, ErrProcessNotStarted 113 | } 114 | 115 | return c.cmd.Process.Wait() 116 | } 117 | 118 | func (c *consoleNix) SetCWD(cwd string) error { 119 | c.cwd = cwd 120 | return nil 121 | } 122 | 123 | func (c *consoleNix) SetENV(environ []string) error { 124 | c.env = append(os.Environ(), environ...) 125 | return nil 126 | } 127 | 128 | func (c *consoleNix) Pid() (int, error) { 129 | if c.cmd == nil { 130 | return 0, ErrProcessNotStarted 131 | } 132 | 133 | return c.cmd.Process.Pid, nil 134 | } 135 | 136 | func (c *consoleNix) Kill() error { 137 | if c.cmd == nil { 138 | return ErrProcessNotStarted 139 | } 140 | 141 | return c.cmd.Process.Kill() 142 | } 143 | 144 | func (c *consoleNix) Signal(sig os.Signal) error { 145 | if c.cmd == nil { 146 | return ErrProcessNotStarted 147 | } 148 | 149 | return c.cmd.Process.Signal(sig) 150 | } 151 | -------------------------------------------------------------------------------- /console_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path" 10 | "runtime" 11 | "sync" 12 | "syscall" 13 | "testing" 14 | "time" 15 | 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | func createSnapshot(t *testing.T, filename string, data []byte) { 20 | assert := assert.New(t) 21 | 22 | file, err := os.Create(filename) 23 | assert.Nil(err) 24 | defer file.Close() 25 | 26 | _, err = file.Write(data) 27 | assert.Nil(err) 28 | 29 | t.Fatalf("Snapshot created") 30 | } 31 | 32 | func checkSnapshot(t *testing.T, name string, data []byte) { 33 | assert := assert.New(t) 34 | 35 | snapshot := fmt.Sprintf(path.Join("snapshots", runtime.GOOS, "%s.snap"), name) 36 | assert.Nil(os.MkdirAll(path.Dir(snapshot), 0755)) 37 | 38 | file, err := os.Open(snapshot) 39 | if err != nil { 40 | createSnapshot(t, snapshot, data) 41 | } 42 | defer file.Close() 43 | 44 | snapshotData, err := ioutil.ReadAll(file) 45 | assert.Nil(err) 46 | 47 | assert.EqualValues(snapshotData, data) 48 | } 49 | 50 | func TestRun(t *testing.T) { 51 | assert := assert.New(t) 52 | 53 | var args []string 54 | if runtime.GOOS == "windows" { 55 | args = []string{"powershell.exe", "-command", "\"echo windows\""} 56 | } else { 57 | args = []string{"printf", "with \033[0;31mCOLOR\033[0m"} 58 | } 59 | 60 | proc, err := New(120, 60) 61 | assert.Nil(err) 62 | 63 | err = proc.Start(args) 64 | assert.Nil(err) 65 | defer proc.Close() 66 | 67 | data, _ := ioutil.ReadAll(proc) 68 | 69 | if runtime.GOOS == "windows" { 70 | assert.Truef(bytes.Contains(data, []byte("windows")), "Does not contain output") 71 | } else { 72 | checkSnapshot(t, "TestRun", data) 73 | } 74 | } 75 | 76 | func TestSize(t *testing.T) { 77 | 78 | assert := assert.New(t) 79 | 80 | args := []string{"stty", "size"} 81 | if runtime.GOOS == "windows" { 82 | args = []string{"cmd", "/c", "mode"} 83 | } 84 | 85 | proc, err := New(120, 60) 86 | assert.Nil(err) 87 | 88 | assert.Nil(proc.Start(args)) 89 | 90 | data, _ := ioutil.ReadAll(proc) 91 | 92 | os.Stdout.Write(data) 93 | 94 | if runtime.GOOS == "windows" { 95 | assert.Truef(bytes.Contains(data, []byte("120")), "Does not contain size") 96 | } else { 97 | assert.Truef(bytes.Contains(data, []byte("60 120")), "Does not contain size") 98 | } 99 | } 100 | 101 | func TestSize2(t *testing.T) { 102 | assert := assert.New(t) 103 | 104 | args := []string{"stty", "size"} 105 | if runtime.GOOS == "windows" { 106 | args = []string{"cmd", "/c", "mode"} 107 | } 108 | 109 | proc, err := New(60, 120) 110 | assert.Nil(err) 111 | 112 | assert.Nil(proc.Start(args)) 113 | 114 | data, _ := ioutil.ReadAll(proc) 115 | os.Stdout.Write(data) 116 | 117 | if runtime.GOOS == "windows" { 118 | assert.Truef(bytes.Contains(data, []byte("60")), "Does not contain size") 119 | } else { 120 | assert.Truef(bytes.Contains(data, []byte("120 60")), "Does not contain size") 121 | } 122 | } 123 | 124 | func TestWait(t *testing.T) { 125 | assert := assert.New(t) 126 | 127 | var args []string 128 | if runtime.GOOS == "windows" { 129 | args = []string{"powershell.exe", "-command", "\"sleep 5\""} 130 | } else { 131 | args = []string{"sleep", "5s"} 132 | } 133 | 134 | proc, err := New(120, 60) 135 | assert.Nil(err) 136 | 137 | assert.Nil(proc.Start(args)) 138 | defer proc.Close() 139 | 140 | var wg sync.WaitGroup 141 | wg.Add(1) 142 | now := time.Now().UTC() 143 | go func() { 144 | _, err := proc.Wait() 145 | assert.Nil(err) 146 | wg.Done() 147 | }() 148 | 149 | io.Copy(os.Stdout, proc) 150 | 151 | wg.Wait() 152 | 153 | diff := time.Now().UTC().Sub(now).Seconds() 154 | assert.GreaterOrEqual(diff, float64(5)) 155 | } 156 | 157 | func TestCWD(t *testing.T) { 158 | assert := assert.New(t) 159 | 160 | args := []string{"pwd"} 161 | if runtime.GOOS == "windows" { 162 | args = []string{"cmd", "/c", "echo", "%cd%"} 163 | } 164 | 165 | proc, err := New(120, 60) 166 | assert.Nil(err) 167 | defer proc.Close() 168 | 169 | tmpdir, err := ioutil.TempDir("", "go-console_") 170 | assert.Nil(err) 171 | defer os.RemoveAll(tmpdir) 172 | 173 | assert.Nil(proc.SetCWD(tmpdir)) 174 | 175 | assert.Nil(proc.Start(args)) 176 | 177 | data, _ := ioutil.ReadAll(proc) 178 | 179 | assert.Contains(string(data), tmpdir) 180 | } 181 | 182 | func TestENV(t *testing.T) { 183 | assert := assert.New(t) 184 | 185 | args := []string{"env"} 186 | if runtime.GOOS == "windows" { 187 | args = []string{"cmd", "/c", "echo", "MYENV=%MYENV%"} 188 | } 189 | 190 | proc, err := New(120, 60) 191 | assert.Nil(err) 192 | defer proc.Close() 193 | 194 | assert.Nil(proc.SetENV([]string{"MYENV=test"})) 195 | 196 | assert.Nil(proc.Start(args)) 197 | 198 | data, _ := ioutil.ReadAll(proc) 199 | 200 | assert.Contains(string(data), "MYENV=test") 201 | } 202 | 203 | func TestPID(t *testing.T) { 204 | assert := assert.New(t) 205 | 206 | args := []string{"sleep", "5s"} 207 | if runtime.GOOS == "windows" { 208 | args = []string{"powershell.exe", "-command", "\"sleep 5\""} 209 | } 210 | 211 | proc, err := New(120, 60) 212 | assert.Nil(err) 213 | 214 | assert.Nil(proc.Start(args)) 215 | defer proc.Close() 216 | 217 | var wg sync.WaitGroup 218 | wg.Add(1) 219 | go func() { 220 | _, err := proc.Wait() 221 | assert.Nil(err) 222 | wg.Done() 223 | }() 224 | 225 | pid, err := proc.Pid() 226 | assert.Nil(err) 227 | assert.NotEqual(0, pid) 228 | 229 | wg.Wait() 230 | } 231 | 232 | func TestKill(t *testing.T) { 233 | assert := assert.New(t) 234 | 235 | args := []string{"sleep", "3600"} 236 | if runtime.GOOS == "windows" { 237 | args = []string{"powershell.exe", "-command", "\"sleep 3600\""} 238 | } 239 | 240 | proc, err := New(120, 60) 241 | assert.Nil(err) 242 | 243 | assert.Nil(proc.Start(args)) 244 | defer proc.Close() 245 | 246 | var wg sync.WaitGroup 247 | wg.Add(1) 248 | go func() { 249 | state, err := proc.Wait() 250 | assert.Nil(err) 251 | 252 | xSignal := "killed" 253 | if runtime.GOOS == "windows" { 254 | xSignal = "signal -1" 255 | } 256 | 257 | signal := state.Sys().(syscall.WaitStatus).Signal() 258 | sig := signal.String() 259 | assert.Equal(xSignal, sig) 260 | wg.Done() 261 | }() 262 | 263 | time.Sleep(1 * time.Second) 264 | assert.Nil(proc.Kill()) 265 | 266 | io.Copy(os.Stdout, proc) 267 | 268 | wg.Wait() 269 | } 270 | 271 | func TestSignal(t *testing.T) { 272 | assert := assert.New(t) 273 | 274 | args := []string{"sleep", "3600"} 275 | if runtime.GOOS == "windows" { 276 | args = []string{"powershell.exe", "-command", "\"sleep 3600\""} 277 | } 278 | 279 | proc, err := New(120, 60) 280 | assert.Nil(err) 281 | 282 | assert.Nil(proc.Start(args)) 283 | defer proc.Close() 284 | 285 | var wg sync.WaitGroup 286 | wg.Add(1) 287 | go func() { 288 | state, err := proc.Wait() 289 | assert.Nil(err) 290 | 291 | xSignal := "killed" 292 | if runtime.GOOS == "windows" { 293 | xSignal = "signal -1" 294 | } 295 | 296 | signal := state.Sys().(syscall.WaitStatus).Signal() 297 | assert.Equal(xSignal, signal.String()) 298 | wg.Done() 299 | }() 300 | 301 | time.Sleep(1 * time.Second) 302 | assert.Nil(proc.Signal(os.Kill)) 303 | 304 | io.Copy(os.Stdout, proc) 305 | 306 | wg.Wait() 307 | } 308 | -------------------------------------------------------------------------------- /console_windows.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | "syscall" 12 | 13 | "github.com/iamacarpet/go-winpty" 14 | "github.com/runletapp/go-console/interfaces" 15 | ) 16 | 17 | // Do the interface allocations only once for common 18 | // Errno values. 19 | const ( 20 | errnoERROR_IO_PENDING = 997 21 | ) 22 | 23 | var ( 24 | errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) 25 | ) 26 | 27 | //go:embed winpty/* 28 | var winpty_deps embed.FS 29 | 30 | // errnoErr returns common boxed Errno values, to prevent 31 | // allocations at runtime. 32 | func errnoErr(e syscall.Errno) error { 33 | switch e { 34 | case 0: 35 | return nil 36 | case errnoERROR_IO_PENDING: 37 | return errERROR_IO_PENDING 38 | } 39 | // TODO: add more here, after collecting data on the common 40 | // error values see on Windows. (perhaps when running 41 | // all.bat?) 42 | return e 43 | } 44 | 45 | var _ interfaces.Console = (*consoleWindows)(nil) 46 | 47 | type consoleWindows struct { 48 | initialCols int 49 | initialRows int 50 | 51 | file *winpty.WinPTY 52 | 53 | cwd string 54 | env []string 55 | } 56 | 57 | func newNative(cols int, rows int) (Console, error) { 58 | return &consoleWindows{ 59 | initialCols: cols, 60 | initialRows: rows, 61 | 62 | file: nil, 63 | 64 | cwd: ".", 65 | env: os.Environ(), 66 | }, nil 67 | } 68 | 69 | func (c *consoleWindows) Start(args []string) error { 70 | dllDir, err := c.UnloadEmbeddedDeps() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | opts := winpty.Options{ 76 | DLLPrefix: dllDir, 77 | InitialCols: uint32(c.initialCols), 78 | InitialRows: uint32(c.initialRows), 79 | Command: strings.Join(args, " "), 80 | Dir: c.cwd, 81 | Env: c.env, 82 | } 83 | 84 | cmd, err := winpty.OpenWithOptions(opts) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | c.file = cmd 90 | return nil 91 | } 92 | 93 | func (c *consoleWindows) UnloadEmbeddedDeps() (string, error) { 94 | 95 | executableName, err := os.Executable() 96 | if err != nil { 97 | return "", err 98 | } 99 | executableName = filepath.Base(executableName) 100 | 101 | dllDir := filepath.Join(os.TempDir(), fmt.Sprintf("%s_winpty", executableName)) 102 | 103 | if err := os.MkdirAll(dllDir, 0755); err != nil { 104 | return "", err 105 | } 106 | 107 | files := []string{"winpty.dll", "winpty-agent.exe"} 108 | for _, file := range files { 109 | filenameEmbedded := fmt.Sprintf("winpty/%s", file) 110 | filenameDisk := path.Join(dllDir, file) 111 | 112 | _, statErr := os.Stat(filenameDisk) 113 | if statErr == nil { 114 | // file is already there, skip it 115 | continue 116 | } 117 | 118 | data, err := winpty_deps.ReadFile(filenameEmbedded) 119 | if err != nil { 120 | return "", err 121 | } 122 | 123 | if err := ioutil.WriteFile(path.Join(dllDir, file), data, 0644); err != nil { 124 | return "", err 125 | } 126 | } 127 | 128 | return dllDir, nil 129 | } 130 | 131 | func (c *consoleWindows) Read(b []byte) (int, error) { 132 | if c.file == nil { 133 | return 0, ErrProcessNotStarted 134 | } 135 | 136 | n, err := c.file.StdOut.Read(b) 137 | 138 | return n, err 139 | } 140 | 141 | func (c *consoleWindows) Write(b []byte) (int, error) { 142 | if c.file == nil { 143 | return 0, ErrProcessNotStarted 144 | } 145 | 146 | return c.file.StdIn.Write(b) 147 | } 148 | 149 | func (c *consoleWindows) Close() error { 150 | if c.file == nil { 151 | return ErrProcessNotStarted 152 | } 153 | 154 | c.file.Close() 155 | return nil 156 | } 157 | 158 | func (c *consoleWindows) SetSize(cols int, rows int) error { 159 | c.initialRows = rows 160 | c.initialCols = cols 161 | 162 | if c.file == nil { 163 | return nil 164 | } 165 | 166 | c.file.SetSize(uint32(c.initialCols), uint32(c.initialRows)) 167 | return nil 168 | } 169 | 170 | func (c *consoleWindows) GetSize() (int, int, error) { 171 | return c.initialCols, c.initialRows, nil 172 | } 173 | 174 | // At this point, sys/windows does not yet contain the method GetProcessID 175 | // this was copied and pasted from: https://github.com/golang/sys/blob/master/windows/zsyscall_windows.go#L2226 176 | func (c *consoleWindows) getProcessIDFromHandle(process uintptr) (id uint32, err error) { 177 | modkernel32 := syscall.NewLazyDLL("kernel32.dll") 178 | procGetProcessId := modkernel32.NewProc("GetProcessId") 179 | 180 | r0, _, e1 := syscall.Syscall(procGetProcessId.Addr(), 1, process, 0, 0) 181 | id = uint32(r0) 182 | if id == 0 { 183 | if e1 != 0 { 184 | err = errnoErr(e1) 185 | } else { 186 | err = syscall.EINVAL 187 | } 188 | } 189 | return 190 | } 191 | 192 | func (c *consoleWindows) Wait() (*os.ProcessState, error) { 193 | if c.file == nil { 194 | return nil, ErrProcessNotStarted 195 | } 196 | 197 | handle := c.file.GetProcHandle() 198 | pid, err := c.getProcessIDFromHandle(handle) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | proc, err := os.FindProcess(int(pid)) 204 | if err != nil { 205 | return nil, err 206 | } 207 | 208 | return proc.Wait() 209 | } 210 | 211 | func (c *consoleWindows) SetCWD(cwd string) error { 212 | c.cwd = cwd 213 | return nil 214 | } 215 | 216 | func (c *consoleWindows) SetENV(environ []string) error { 217 | c.env = append(os.Environ(), environ...) 218 | return nil 219 | } 220 | 221 | func (c *consoleWindows) Pid() (int, error) { 222 | if c.file == nil { 223 | return 0, ErrProcessNotStarted 224 | } 225 | 226 | handle := c.file.GetProcHandle() 227 | pid, err := c.getProcessIDFromHandle(handle) 228 | 229 | return int(pid), err 230 | } 231 | 232 | func (c *consoleWindows) Kill() error { 233 | if c.file == nil { 234 | return ErrProcessNotStarted 235 | } 236 | 237 | handle := c.file.GetProcHandle() 238 | pid, err := c.getProcessIDFromHandle(handle) 239 | if err != nil { 240 | return err 241 | } 242 | 243 | proc, err := os.FindProcess(int(pid)) 244 | if err != nil { 245 | return err 246 | } 247 | 248 | return proc.Kill() 249 | } 250 | 251 | func (c *consoleWindows) Signal(sig os.Signal) error { 252 | if c.file == nil { 253 | return ErrProcessNotStarted 254 | } 255 | 256 | handle := c.file.GetProcHandle() 257 | pid, err := c.getProcessIDFromHandle(handle) 258 | if err != nil { 259 | return err 260 | } 261 | 262 | proc, err := os.FindProcess(int(pid)) 263 | if err != nil { 264 | return err 265 | } 266 | 267 | return proc.Signal(sig) 268 | } 269 | -------------------------------------------------------------------------------- /console_windows_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestUnloadEmbeddedDeps(t *testing.T) { 11 | procI, err := New(120, 60) 12 | assert.Nil(t, err) 13 | 14 | proc := procI.(*consoleWindows) 15 | 16 | dllPath, err := proc.UnloadEmbeddedDeps() 17 | assert.Nil(t, err) 18 | 19 | files, err := os.ReadDir(dllPath) 20 | assert.Nil(t, err) 21 | 22 | assert.Equal(t, 2, len(files)) 23 | 24 | filenames := []string{} 25 | 26 | for _, file := range files { 27 | filenames = append(filenames, file.Name()) 28 | } 29 | 30 | assert.Contains(t, filenames, "winpty-agent.exe") 31 | assert.Contains(t, filenames, "winpty.dll") 32 | } 33 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrProcessNotStarted = errors.New("Process has not been started") 7 | ErrInvalidCmd = errors.New("Invalid command") 8 | ) 9 | -------------------------------------------------------------------------------- /examples/simple/simple.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | "runtime" 8 | "sync" 9 | 10 | "github.com/runletapp/go-console" 11 | ) 12 | 13 | func main() { 14 | 15 | proc, err := console.New(120, 60) 16 | if err != nil { 17 | panic(err) 18 | } 19 | defer proc.Close() 20 | 21 | var args []string 22 | 23 | if runtime.GOOS == "windows" { 24 | args = []string{"cmd.exe", "/c", "dir"} 25 | } else { 26 | args = []string{"ls", "-lah", "--color"} 27 | } 28 | 29 | if err := proc.Start(args); err != nil { 30 | panic(err) 31 | } 32 | 33 | var wg sync.WaitGroup 34 | wg.Add(1) 35 | go func() { 36 | defer wg.Done() 37 | 38 | _, err = io.Copy(os.Stdout, proc) 39 | if err != nil { 40 | log.Printf("Error: %v\n", err) 41 | } 42 | }() 43 | 44 | if _, err := proc.Wait(); err != nil { 45 | log.Printf("Wait err: %v\n", err) 46 | } 47 | 48 | wg.Wait() 49 | } 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/runletapp/go-console 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/creack/pty v1.1.17 7 | github.com/iamacarpet/go-winpty v1.0.2 8 | github.com/stretchr/testify v1.7.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 2 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/iamacarpet/go-winpty v1.0.2 h1:jwPVTYrjAHZx6Mcm6K5i9G4opMp5TblEHH5EQCl/Gzw= 6 | github.com/iamacarpet/go-winpty v1.0.2/go.mod h1:/GHKJicG/EVRQIK1IQikMYBakBkhj/3hTjLgdzYsmpI= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 11 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 14 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | -------------------------------------------------------------------------------- /interfaces/console.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | // Console communication interface 9 | type Console interface { 10 | io.Reader 11 | io.Writer 12 | io.Closer 13 | 14 | // SetSize sets the console size 15 | SetSize(cols int, rows int) error 16 | 17 | // GetSize gets the console size 18 | // cols, rows, error 19 | GetSize() (int, int, error) 20 | 21 | // Start starts the process with the supplied args 22 | Start(args []string) error 23 | 24 | // Wait waits the process to finish 25 | Wait() (*os.ProcessState, error) 26 | 27 | // SetCWD sets the current working dir of the process 28 | SetCWD(cwd string) error 29 | 30 | // SetENV sets environment variables to pass to the child process 31 | SetENV(environ []string) error 32 | 33 | // Pid returns the pid of the running process 34 | Pid() (int, error) 35 | 36 | // Kill kills the process. See exec/Process.Kill 37 | Kill() error 38 | 39 | // Signal sends a signal to the process. See exec/Process.Signal 40 | Signal(sig os.Signal) error 41 | } 42 | -------------------------------------------------------------------------------- /snapshots/darwin/TestRun.snap: -------------------------------------------------------------------------------- 1 | with COLOR -------------------------------------------------------------------------------- /snapshots/darwin/TestSize.snap: -------------------------------------------------------------------------------- 1 | 60 120 2 | -------------------------------------------------------------------------------- /snapshots/darwin/TestSize2.snap: -------------------------------------------------------------------------------- 1 | 120 60 2 | -------------------------------------------------------------------------------- /snapshots/linux/TestRun.snap: -------------------------------------------------------------------------------- 1 | with COLOR -------------------------------------------------------------------------------- /snapshots/linux/TestSize.snap: -------------------------------------------------------------------------------- 1 | 60 120 2 | -------------------------------------------------------------------------------- /snapshots/linux/TestSize2.snap: -------------------------------------------------------------------------------- 1 | 120 60 2 | -------------------------------------------------------------------------------- /snapshots/windows/TestRun.snap: -------------------------------------------------------------------------------- 1 | windows[?25l 2 | [?25h -------------------------------------------------------------------------------- /snapshots/windows/TestSize.snap: -------------------------------------------------------------------------------- 1 | 60 120[?25l 2 | [?25h -------------------------------------------------------------------------------- /snapshots/windows/TestSize2.snap: -------------------------------------------------------------------------------- 1 | 120 60[?25l 2 | [?25h -------------------------------------------------------------------------------- /winpty/winpty-agent.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runletapp/go-console/27323a28410a42120ce1e906e2c9332addc7aeb8/winpty/winpty-agent.exe -------------------------------------------------------------------------------- /winpty/winpty.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runletapp/go-console/27323a28410a42120ce1e906e2c9332addc7aeb8/winpty/winpty.dll --------------------------------------------------------------------------------