From 4f7d4dc58f127a5a6a7731027810757a9d02c61c Mon Sep 17 00:00:00 2001 From: Matthias Diester Date: Thu, 2 Jan 2025 14:39:56 +0100 Subject: [PATCH] Start pseudo terminal with explicit window size Ref: https://github.com/homeport/termshot/issues/254 Make sure to start pseudo terminal with fixed size in case the columns flag was used. Refactor `ptexec` package to allow for more flexibility. Introduce test cases for `ptexec` package. --- internal/cmd/root.go | 6 +- internal/ptexec/exec.go | 104 +++++++++++++++++++++++---- internal/ptexec/exec_test.go | 56 +++++++++++++++ internal/ptexec/ptexec_suite_test.go | 33 +++++++++ 4 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 internal/ptexec/exec_test.go create mode 100644 internal/ptexec/ptexec_suite_test.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e280f20..6f013fb 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -73,12 +73,14 @@ window including all terminal colors and text decorations. var scaffold = img.NewImageCreator() var buf bytes.Buffer + var pt = ptexec.New() // Initialise scaffold with a column sizing so that the // content can be wrapped accordingly // if columns, err := cmd.Flags().GetInt("columns"); err == nil && columns > 0 { scaffold.SetColumns(columns) + pt.Cols(uint16(columns)) } // Disable window shadow if requested @@ -110,7 +112,7 @@ window including all terminal colors and text decorations. // Run the provided command in a pseudo terminal and capture // the output to be later rendered into the screenshot // - bytes, err := ptexec.RunCommandInPseudoTerminal(args[0], args[1:]...) + bytes, err := pt.Command(args[0], args[1:]...).Run() if err != nil { return err } @@ -135,7 +137,7 @@ window including all terminal colors and text decorations. editor = "vi" } - if _, err := ptexec.RunCommandInPseudoTerminal(editor, tmpFile.Name()); err != nil { + if _, err := ptexec.New().Command(editor, tmpFile.Name()).Run(); err != nil { return err } diff --git a/internal/ptexec/exec.go b/internal/ptexec/exec.go index 61713a1..5eee807 100644 --- a/internal/ptexec/exec.go +++ b/internal/ptexec/exec.go @@ -35,23 +35,74 @@ import ( "golang.org/x/term" ) -// RunCommandInPseudoTerminal runs the provided program with the given -// arguments in a pseudo terminal (PTY) so that the behavior is the same -// if it would be executed in a terminal -func RunCommandInPseudoTerminal(name string, args ...string) ([]byte, error) { - var errors = []error{} +// PseudoTerminal defines the setup for a command to be run in a pseudo +// terminal, e.g. terminal size, or output settings +type PseudoTerminal struct { + name string + args []string + + shell string + + cols uint16 + rows uint16 + resize bool + + stdout io.Writer +} + +// New creates a new pseudo terminal builder +func New() *PseudoTerminal { + return &PseudoTerminal{ + shell: "/bin/sh", + resize: true, + stdout: os.Stdout, + } +} + +// Cols sets the width/columns for the pseudo terminal +func (c *PseudoTerminal) Cols(cols uint16) *PseudoTerminal { + c.cols = cols + return c +} + +// Rows sets the lines/rows for the pseudo terminal +func (c *PseudoTerminal) Rows(rows uint16) *PseudoTerminal { + c.rows = rows + return c +} + +// Stdout sets the writer to be used for the standard output +func (c *PseudoTerminal) Stdout(stdout io.Writer) *PseudoTerminal { + c.stdout = stdout + return c +} + +// Command sets the command and arguments to be used +func (c *PseudoTerminal) Command(name string, args ...string) *PseudoTerminal { + c.name = name + c.args = args + return c +} + +// Run runs the provided command/script with the given arguments in a pseudo +// terminal (PTY) so that the behavior is the same if it would be executed +// in a terminal +func (c *PseudoTerminal) Run() ([]byte, error) { + if c.name == "" { + return nil, fmt.Errorf("no command specified") + } // Convenience hack in case command contains a space, for example in case // typical construct like "foo | grep" are used. - if strings.Contains(name, " ") { - args = []string{ + if strings.Contains(c.name, " ") { + c.args = []string{ "-c", strings.Join(append( - []string{name}, - args..., + []string{c.name}, + c.args..., ), " "), } - name = "/bin/sh" + c.name = c.shell } // Set RAW mode for Stdin @@ -65,13 +116,17 @@ func RunCommandInPseudoTerminal(name string, args ...string) ([]byte, error) { defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() } - pt, err := pty.Start(exec.Command(name, args...)) + // collect all errors along the way + var errors = []error{} + + // #nosec G204 -- since this is exactly what we want, arbitrary commands + pt, err := c.pseudoTerminal(exec.Command(c.name, c.args...)) if err != nil { return nil, err } // Support terminal resizing - if isTerminal(os.Stdin) { + if c.resize && isTerminal(os.Stdin) { ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGWINCH) go func() { @@ -98,7 +153,7 @@ func RunCommandInPseudoTerminal(name string, args ...string) ([]byte, error) { }() var buf bytes.Buffer - if err = copy(io.MultiWriter(os.Stdout, &buf), pt); err != nil { + if err = copy(io.MultiWriter(c.stdout, &buf), pt); err != nil { return nil, err } @@ -112,6 +167,29 @@ func RunCommandInPseudoTerminal(name string, args ...string) ([]byte, error) { return buf.Bytes(), nil } +func (c *PseudoTerminal) pseudoTerminal(cmd *exec.Cmd) (*os.File, error) { + if c.cols == 0 && c.rows == 0 { + return pty.Start(cmd) + } + + size, err := pty.GetsizeFull(os.Stdout) + if err != nil { + return nil, err + } + + if c.rows != 0 { + size.Rows = c.rows + } + + if c.cols != 0 { + size.Cols = c.cols + } + + c.resize = false + + return pty.StartWithSize(cmd, size) +} + func copy(dst io.Writer, src io.Reader) error { _, err := io.Copy(dst, src) if err != nil { diff --git a/internal/ptexec/exec_test.go b/internal/ptexec/exec_test.go new file mode 100644 index 0000000..68c2719 --- /dev/null +++ b/internal/ptexec/exec_test.go @@ -0,0 +1,56 @@ +// Copyright © 2025 The Homeport Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package ptexec_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/homeport/termshot/internal/ptexec" +) + +var _ = Describe("Pseudo Terminal Execute Suite", func() { + Context("running commands in pseudo terminal", func() { + It("should run a command just fine", func() { + out, err := New().Stdout(GinkgoWriter). + Command("echo", "hello"). + Run() + + Expect(err).ToNot(HaveOccurred()) + Expect(string(out)).To(Equal("hello\r\n")) + }) + + It("should run a script just fine", func() { + out, err := New().Stdout(GinkgoWriter). + Command("echo hello"). + Run() + + Expect(err).ToNot(HaveOccurred()) + Expect(string(out)).To(Equal("hello\r\n")) + }) + + It("should run with fixed terminal size", func() { + out, err := New().Stdout(GinkgoWriter).Cols(80).Rows(25).Command("stty", "size").Run() + Expect(err).ToNot(HaveOccurred()) + Expect(string(out)).To(Equal("25 80\r\n")) + }) + }) +}) diff --git a/internal/ptexec/ptexec_suite_test.go b/internal/ptexec/ptexec_suite_test.go new file mode 100644 index 0000000..4ef83be --- /dev/null +++ b/internal/ptexec/ptexec_suite_test.go @@ -0,0 +1,33 @@ +// Copyright © 2025 The Homeport Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package ptexec_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPtexec(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pseudo Terminal Exec Suite") +}