diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e280f20..a57e7ef 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,9 +112,9 @@ 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 + return fmt.Errorf("failed to run command in pseudo terminal: %w", err) } buf.Write(bytes) @@ -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..a717214 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,40 @@ 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 { + // Obtaining terminal size is prone to error in CI systems, e.g. in + // GitHub Action setup or similar, so only fail if CI is not set + if !isCI() { + return nil, fmt.Errorf("failed to get size: %w", err) + } + + // For CI systems, assume a reasonable default even if the terminal + // size cannot be obtained through ioctl + size = &pty.Winsize{Rows: 25, Cols: 80} + } + + // Overwrite rows if fixed value is configured + if c.rows != 0 { + size.Rows = c.rows + } + + // Overwrite columns if fixed value is configured + if c.cols != 0 { + size.Cols = c.cols + } + + // With fixed rows/cols, terminal resizing support is not useful + 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 { @@ -135,3 +224,8 @@ func isTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } + +func isCI() bool { + ci, ok := os.LookupEnv("CI") + return ok && ci == "true" +} diff --git a/internal/ptexec/exec_test.go b/internal/ptexec/exec_test.go new file mode 100644 index 0000000..1c385c6 --- /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(trimmed(out)).To(Equal("hello")) + }) + + It("should run a script just fine", func() { + out, err := New().Stdout(GinkgoWriter). + Command("echo hello"). + Run() + + Expect(err).ToNot(HaveOccurred()) + Expect(trimmed(out)).To(Equal("hello")) + }) + + It("should run with fixed terminal size", func() { + out, err := New().Stdout(GinkgoWriter).Cols(40).Rows(12).Command("stty", "size").Run() + Expect(err).ToNot(HaveOccurred()) + Expect(trimmed(out)).To(Equal("12 40")) + }) + }) +}) diff --git a/internal/ptexec/ptexec_suite_test.go b/internal/ptexec/ptexec_suite_test.go new file mode 100644 index 0000000..aaaee30 --- /dev/null +++ b/internal/ptexec/ptexec_suite_test.go @@ -0,0 +1,38 @@ +// 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 ( + "strings" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPtexec(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pseudo Terminal Exec Suite") +} + +func trimmed(in []byte) string { + return strings.TrimSpace(string(in)) +}