├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build-all.sh ├── build.sh ├── command.go ├── go.mod ├── go.sum ├── main.go └── pty.go /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | release: 9 | types: [created] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Build 22 | env: 23 | VERSION: ${{ github.event.release.tag_name }} 24 | run: | 25 | ./build-all.sh 26 | 27 | - name: Upload the artifacts 28 | uses: skx/github-action-publish-binaries@release-1.3 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | releaseId: ${{ needs.create_release.outputs.id }} 33 | args: 'build/*' 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | host-spawn 2 | build 3 | vendor/ 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.6.1 (10 Jan 2025) 2 | 3 | * Increase robustness in handling DBus messages (https://github.com/1player/host-spawn/issues/38) 4 | * Show an error message if the command we're trying to run does not exist (https://github.com/1player/host-spawn/issues/39) 5 | * Do not allocate a pty if stdout is redirected (https://github.com/1player/host-spawn/issues/40) 6 | 7 | ## 1.6.0 (28 Apr 2024) 8 | 9 | ### Added 10 | 11 | * Build and distribute binary for loongarch64. Thanks @shenmo7192. 12 | * Add `-cwd` flag to change the working directory of the spawned process. Thanks @someone13574. 13 | 14 | ## 1.5.1 (21 Dec 2023) 15 | 16 | ### Minor 17 | 18 | * Update golang.org/x/sys dependency to v0.15.0. Thanks @klugier. 19 | * Refactor build scripts to target single architecture 20 | 21 | ## 1.5.0 (2 Sep 2023) 22 | 23 | ### Added 24 | 25 | * Forward signals to the host process (https://github.com/1player/host-spawn/issues/18) 26 | 27 | ## 1.4.2 (3 Jun 2023) 28 | 29 | ### Fixed 30 | 31 | * Do not allocate a pty for `podman` (https://github.com/1player/host-spawn/issues/21). Thanks @lbssousa. 32 | 33 | ## 1.4.1 (18 Feb 2023) 34 | 35 | ### Fixed 36 | 37 | * Do not propagate environment variables that are not set (https://github.com/1player/host-spawn/issues/17) 38 | 39 | ## 1.4.0 (14 Jan 2023) 40 | 41 | ### Added 42 | 43 | * Do not allocate a pty if the command is known to misbehave when attached to one. Thanks @89luca89 44 | 45 | ## 1.3.0 (12 Oct 2022) 46 | 47 | ### Added 48 | 49 | * `-env` command line argument to specify which environment variables to pass to the host process. If unspecified, defaults to "TERM". Thanks @travier 50 | 51 | ## 1.2.1 (12 Aug 2022) 52 | 53 | ### Fixed 54 | 55 | * Don't fail if stdin is redirected (https://github.com/1player/host-spawn/issues/11) 56 | 57 | ## 1.2.0 (27 Jul 2022) 58 | 59 | ### Added 60 | 61 | * Spawn a shell on the host if no command is passed. 62 | 63 | ## 1.1.0 (24 Jul 2022) 64 | 65 | ### Added 66 | 67 | * Shim host binaries when symlinked. See example at https://github.com/1player/host-spawn#creating-shims-for-host-binaries 68 | 69 | ## 1.0.2 (15 Jul 2022) 70 | 71 | ### Changed 72 | 73 | * Added `--no-pty` flag to work around misbehaving processes that terminate too early. See https://github.com/1player/host-spawn/issues/7. Thanks @89luca89 74 | 75 | ## 1.0.1 (11 Jul 2022) 76 | 77 | ### Changed 78 | 79 | * Terminal no longer gets scrambled on error. Thanks @89luca89 80 | 81 | ## 1.0 (9 Jul 2022) 82 | 83 | Compiled, statically linked version 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Stéphane Travostino 2 | 3 | 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. 4 | 5 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # host-spawn 2 | 3 | Run commands on your host machine from inside your flatpak sandbox, [toolbox](https://github.com/containers/toolbox) or [distrobox](https://github.com/89luca89/distrobox) containers. 4 | 5 | Originally started as a reimplementation of `flatpak-spawn --host`. 6 | 7 | ## Recommended setup 8 | 9 | **Note:** Distrobox already ships with host-spawn. You might be better served by using their wrapper `distrobox-host-exec` which runs host-spawn under the hood. 10 | 11 | * Install host-spawn in a location visible only to the container. I recommend `/usr/local/bin`. 12 | * Make sure it is executable with `chmod +x host-spawn` 13 | 14 | ## How to use 15 | 16 | * `host-spawn` with no argument will open a shell on your host. 17 | * `host-spawn command...` will run the command on your host. 18 | 19 | Run `host-spawn -h` for more options. 20 | 21 | ## Creating shims for host binaries 22 | 23 | If there's a process that you always want to execute on the host system, you can 24 | create a symlink to it somewhere in your $PATH and it'll always be executed through `host-spawn`. 25 | 26 | Example of creating a shim for the `flatpak` command: 27 | 28 | ``` 29 | # Inside your container: 30 | 31 | $ flatpak --version 32 | zsh: command not found: flatpak 33 | 34 | # Have host-spawn handle any flatpak command 35 | $ ln -s /usr/local/bin/host-spawn /usr/local/bin/flatpak 36 | 37 | # Now flatpak will always be executed on the host 38 | $ flatpak --version 39 | Flatpak 1.12.7 40 | ``` 41 | 42 | **Note:** you will want to store the symlink in a location visible only to the container, to avoid an infinite loop. If you are using toolbox/distrobox, this means anywhere outside your home directory. I recommend `/usr/local/bin`. 43 | 44 | ## Improvements over flatpak-spawn --host 45 | 46 | * Allocates a pty for the spawned process, fixing the following upstream issues: https://github.com/flatpak/flatpak/issues/3697, https://github.com/flatpak/flatpak/issues/3285 and https://github.com/flatpak/flatpak-xdg-utils/issues/57 47 | * Handles SIGWINCH (terminal size changes) 48 | * Passes through `$TERM` environment variable 49 | * Shims host binaries when symlinked, see section above 50 | -------------------------------------------------------------------------------- /build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | ARCHS="i386 i686 x86_64 armv7 aarch64 loongarch64" # in `uname -m` format 6 | 7 | ROOT_DIR=$(dirname "$0") 8 | 9 | cd "$ROOT_DIR" 10 | for arch in ${ARCHS}; do 11 | ./build.sh "$arch" 12 | done 13 | 14 | ./build.sh source 15 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | ARCH="$1" # in `uname -m` format 6 | ROOT_DIR=$(dirname "$0") 7 | VERSION=${VERSION:-HEAD} 8 | 9 | cd "$ROOT_DIR" 10 | mkdir -p build 11 | 12 | case $ARCH in 13 | source) 14 | git clean -fdx -e build 15 | go mod vendor 16 | tar --create --zst --exclude build --file build/host-spawn-vendor.tar.zst "$ROOT_DIR" 17 | exit 18 | ;; 19 | 20 | i386 | i686) 21 | GOARCH=386 22 | ;; 23 | 24 | x86_64) 25 | GOARCH=amd64 26 | ;; 27 | 28 | armv7) 29 | GOARCH=arm 30 | ;; 31 | 32 | aarch64) 33 | GOARCH=arm64 34 | ;; 35 | 36 | loongarch64) 37 | GOARCH=loong64 38 | ;; 39 | 40 | *) 41 | GOARCH=$ARCH 42 | ;; 43 | esac 44 | 45 | export GOARCH 46 | CGO_ENABLED=0 go build \ 47 | -ldflags "-X main.Version=$VERSION" \ 48 | -o "build/host-spawn-$ARCH" 49 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/godbus/dbus/v5" 11 | "golang.org/x/sys/unix" 12 | ) 13 | 14 | type Command struct { 15 | Args []string 16 | WorkingDirectory string 17 | AllocatePty bool 18 | EnvVars map[string]string 19 | 20 | proxy dbus.BusObject 21 | pty *pty 22 | pid uint32 23 | } 24 | 25 | func nullTerminatedByteString(s string) []byte { 26 | return append([]byte(s), 0) 27 | } 28 | 29 | // Extract exit code from waitpid(2) status 30 | func interpretWaitStatus(status uint32) (int, bool) { 31 | // From /usr/include/bits/waitstatus.h 32 | WTERMSIG := status & 0x7f 33 | WIFEXITED := WTERMSIG == 0 34 | 35 | if WIFEXITED { 36 | WEXITSTATUS := (status & 0xff00) >> 8 37 | return int(WEXITSTATUS), true 38 | } 39 | 40 | return 0, false 41 | } 42 | 43 | func (c *Command) signal(signal syscall.Signal) error { 44 | return c.proxy.Call("org.freedesktop.Flatpak.Development.HostCommandSignal", 0, 45 | c.pid, uint32(signal), false, 46 | ).Store() 47 | } 48 | 49 | func (c *Command) SpawnAndWait() (int, error) { 50 | // Connect to the dbus session to talk with flatpak-session-helper process. 51 | conn, err := dbus.ConnectSessionBus() 52 | if err != nil { 53 | return 0, err 54 | } 55 | defer conn.Close() 56 | 57 | // Subscribe to HostCommandExited messages 58 | if err = conn.AddMatchSignal( 59 | dbus.WithMatchInterface("org.freedesktop.Flatpak.Development"), 60 | dbus.WithMatchMember("HostCommandExited"), 61 | ); err != nil { 62 | return 0, err 63 | } 64 | dbusSignals := make(chan *dbus.Signal, 1) 65 | conn.Signal(dbusSignals) 66 | 67 | // Spawn host command 68 | c.proxy = conn.Object("org.freedesktop.Flatpak", "/org/freedesktop/Flatpak/Development") 69 | 70 | cwdPath := nullTerminatedByteString(c.WorkingDirectory) 71 | 72 | argv := make([][]byte, len(c.Args)) 73 | for i, arg := range c.Args { 74 | argv[i] = nullTerminatedByteString(arg) 75 | } 76 | 77 | fds := map[uint32]dbus.UnixFD{ 78 | 0: dbus.UnixFD(os.Stdin.Fd()), 79 | 1: dbus.UnixFD(os.Stdout.Fd()), 80 | 2: dbus.UnixFD(os.Stderr.Fd()), 81 | } 82 | if c.AllocatePty { 83 | c.pty, err = createPty() 84 | if err != nil { 85 | return 0, err 86 | } 87 | err = c.pty.Start() 88 | if err != nil { 89 | return 0, err 90 | } 91 | defer c.pty.Terminate() 92 | 93 | fds[0] = dbus.UnixFD(c.pty.Stdin().Fd()) 94 | fds[1] = dbus.UnixFD(c.pty.Stdout().Fd()) 95 | fds[2] = dbus.UnixFD(c.pty.Stderr().Fd()) 96 | } 97 | 98 | flags := uint32(0) 99 | 100 | // Call command on the host 101 | err = c.proxy.Call("org.freedesktop.Flatpak.Development.HostCommand", 0, 102 | cwdPath, argv, fds, c.EnvVars, flags, 103 | ).Store(&c.pid) 104 | 105 | // an error occurred this early, most likely command not found. 106 | if err != nil { 107 | return 0, err 108 | } 109 | 110 | return c.waitForSignals(dbusSignals) 111 | } 112 | 113 | // Wait for either host or DBus signals 114 | func (c *Command) waitForSignals(dbusSignals chan *dbus.Signal) (int, error) { 115 | hostSignals := make(chan os.Signal, 1) 116 | signal.Notify(hostSignals) 117 | 118 | for { 119 | select { 120 | case signal := <-hostSignals: 121 | unixSignal := signal.(syscall.Signal) 122 | 123 | if unixSignal == unix.SIGWINCH && c.pty != nil { 124 | c.pty.inheritWindowSize() 125 | break 126 | } else if unixSignal == unix.SIGURG { 127 | // Ignore runtime-generated SIGURG messages 128 | // See https://github.com/golang/go/issues/37942 129 | break 130 | } 131 | 132 | // Send the signal but ignore any error, as there is 133 | // nothing much we can do about them 134 | _ = c.signal(unixSignal) 135 | 136 | case message := <-dbusSignals: 137 | // Wait for HostCommandExited message 138 | if message.Name != "org.freedesktop.Flatpak.Development.HostCommandExited" { 139 | continue 140 | } 141 | 142 | waitStatus := message.Body[1].(uint32) 143 | status, exited := interpretWaitStatus(waitStatus) 144 | if exited { 145 | return status, nil 146 | } else { 147 | return status, errors.New("child process did not terminate cleanly") 148 | } 149 | } 150 | } 151 | 152 | // unreachable 153 | } 154 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/1player/host-spawn 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/godbus/dbus/v5 v5.1.0 7 | github.com/pkg/term v1.1.0 8 | golang.org/x/sys v0.15.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 2 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 3 | github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk= 4 | github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= 5 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 6 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "path" 8 | "strings" 9 | ) 10 | 11 | // Version is the current value injected at build time. 12 | var Version string = "HEAD" 13 | 14 | // blocklist contains the list of programs not working well with an allocated pty. 15 | var blocklist = map[string]bool{ 16 | "gio": true, 17 | "podman": true, 18 | "kde-open": true, 19 | "kde-open5": true, 20 | "xdg-open": true, 21 | } 22 | 23 | // Command line options 24 | var flagPty = flag.Bool("pty", false, "Force allocate a pseudo-terminal for the host process") 25 | var flagNoPty = flag.Bool("no-pty", false, "Do not allocate a pseudo-terminal for the host process") 26 | var flagVersion = flag.Bool("version", false, "Show this program's version") 27 | var flagEnvironmentVariables = flag.String("env", "TERM", "Comma separated list of environment variables to pass to the host process.") 28 | var flagWorkingDirectory = flag.String("cwd", "", "Change working directory of the spawned process") 29 | 30 | const OUR_BASENAME = "host-spawn" 31 | 32 | // The exit code we return to identify an error in host-spawn itself, 33 | // rather than in the host process 34 | const OUR_EXIT_CODE = 127 35 | 36 | func parseArguments() { 37 | const USAGE_PREAMBLE = `Usage: %s [options] [ COMMAND [ arguments... ] ] 38 | 39 | If COMMAND is not set, spawn a shell on the host. 40 | 41 | Accepted options: 42 | ` 43 | const USAGE_FOOTER = `-- 44 | 45 | If neither pty option is passed, default to allocating a pseudo-terminal unless 46 | the command is known for misbehaving when attached to a pty. 47 | 48 | For more details visit https://github.com/1player/host-spawn/issues/12 49 | ` 50 | 51 | flag.Usage = func() { 52 | fmt.Fprintf(os.Stderr, USAGE_PREAMBLE, os.Args[0]) 53 | flag.PrintDefaults() 54 | fmt.Fprintf(os.Stderr, USAGE_FOOTER) 55 | os.Exit(0) 56 | } 57 | 58 | flag.Parse() 59 | 60 | if *flagVersion { 61 | fmt.Println(Version) 62 | os.Exit(0) 63 | } 64 | } 65 | 66 | func main() { 67 | var args []string 68 | 69 | basename := path.Base(os.Args[0]) 70 | 71 | // Check if we're shimming a host command 72 | if strings.HasPrefix(basename, OUR_BASENAME) { 73 | parseArguments() 74 | args = flag.Args() 75 | 76 | // If no command is given, spawn a shell 77 | if len(args) == 0 { 78 | args = []string{"sh", "-c", "$SHELL"} 79 | } 80 | } else { 81 | args = append([]string{basename}, os.Args[1:]...) 82 | } 83 | 84 | // Allocate a pty if: 85 | // - stdout isn't redirected 86 | // - this isn't a blocklisted program 87 | // Any of the --pty or --no-pty options will take precedence 88 | 89 | allocatePty := !isStdoutRedirected() && !blocklist[args[0]] 90 | 91 | if *flagPty { 92 | allocatePty = true 93 | } else if *flagNoPty { 94 | allocatePty = false 95 | } 96 | 97 | // Get working directory 98 | var wd string 99 | if *flagWorkingDirectory != "" { 100 | wd = *flagWorkingDirectory 101 | } else { 102 | var err error 103 | wd, err = os.Getwd() 104 | if err != nil { 105 | fmt.Fprintln(os.Stderr, err) 106 | os.Exit(OUR_EXIT_CODE) 107 | } 108 | } 109 | 110 | // Lookup and passthrough environment variables 111 | envVars := make(map[string]string) 112 | for _, k := range strings.Split(*flagEnvironmentVariables, ",") { 113 | if v, ok := os.LookupEnv(k); ok { 114 | envVars[k] = v 115 | } 116 | } 117 | 118 | // OK, let's go 119 | command := Command{ 120 | Args: args, 121 | WorkingDirectory: wd, 122 | AllocatePty: allocatePty, 123 | EnvVars: envVars, 124 | } 125 | 126 | exitCode, err := command.SpawnAndWait() 127 | if err != nil { 128 | fmt.Fprintln(os.Stderr, err) 129 | exitCode = OUR_EXIT_CODE 130 | } 131 | 132 | os.Exit(exitCode) 133 | } 134 | -------------------------------------------------------------------------------- /pty.go: -------------------------------------------------------------------------------- 1 | // Create a pty for us 2 | package main 3 | 4 | import ( 5 | "io" 6 | "os" 7 | "sync" 8 | 9 | "github.com/pkg/term/termios" 10 | "golang.org/x/sys/unix" 11 | ) 12 | 13 | type winsize struct { 14 | Rows uint16 // ws_row: Number of rows (in cells) 15 | Cols uint16 // ws_col: Number of columns (in cells) 16 | X uint16 // ws_xpixel: Width in pixels 17 | Y uint16 // ws_ypixel: Height in pixels 18 | } 19 | 20 | type pty struct { 21 | wg sync.WaitGroup 22 | signals chan os.Signal 23 | 24 | previousStdinTermios unix.Termios 25 | stdinIsatty bool 26 | 27 | master *os.File 28 | slave *os.File 29 | } 30 | 31 | func createPty() (*pty, error) { 32 | master, slave, err := termios.Pty() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return &pty{ 38 | master: master, 39 | slave: slave, 40 | signals: make(chan os.Signal, 1), 41 | }, nil 42 | } 43 | 44 | func (p *pty) Stdin() *os.File { 45 | return p.slave 46 | } 47 | 48 | func (p *pty) Stdout() *os.File { 49 | return p.slave 50 | } 51 | 52 | func (p *pty) Stderr() *os.File { 53 | return p.slave 54 | } 55 | 56 | func (p *pty) Start() error { 57 | err := p.makeStdinRaw() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | p.wg.Add(2) 63 | 64 | go func() { 65 | io.Copy(p.master, os.Stdin) 66 | p.wg.Done() 67 | }() 68 | 69 | go func() { 70 | io.Copy(os.Stdout, p.master) 71 | p.wg.Done() 72 | }() 73 | 74 | p.inheritWindowSize() 75 | 76 | return nil 77 | } 78 | 79 | func (p *pty) Terminate() { 80 | p.restoreStdin() 81 | 82 | p.master.Close() 83 | p.slave.Close() 84 | close(p.signals) 85 | 86 | // TODO: somehow I can't figure out how to have the 87 | // spawned process send an EOF when its fds are closed, 88 | // so for this reason the io.Copy calls above never return. 89 | //p.wg.Wait() 90 | } 91 | 92 | func (p *pty) inheritWindowSize() error { 93 | winsz, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) 94 | if err != nil { 95 | return err 96 | } 97 | if err := unix.IoctlSetWinsize(int(p.master.Fd()), unix.TIOCSWINSZ, winsz); err != nil { 98 | return err 99 | } 100 | return nil 101 | } 102 | 103 | func (p *pty) makeStdinRaw() error { 104 | var stdinTermios unix.Termios 105 | 106 | err := termios.Tcgetattr(os.Stdin.Fd(), &stdinTermios) 107 | 108 | // We might get ENOTTY if stdin is redirected 109 | if err != nil { 110 | if errno, ok := err.(unix.Errno); ok { 111 | if errno == unix.ENOTTY { 112 | return nil 113 | } else { 114 | return err 115 | } 116 | } 117 | } 118 | 119 | p.previousStdinTermios = stdinTermios 120 | p.stdinIsatty = true 121 | 122 | termios.Cfmakeraw(&stdinTermios) 123 | if err := termios.Tcsetattr(os.Stdin.Fd(), termios.TCSANOW, &stdinTermios); err != nil { 124 | return err 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (p *pty) restoreStdin() { 131 | if p.stdinIsatty { 132 | _ = termios.Tcsetattr(os.Stdin.Fd(), termios.TCSANOW, &p.previousStdinTermios) 133 | } 134 | } 135 | 136 | func isStdoutRedirected() bool { 137 | // From https://github.com/mattn/go-isatty/blob/master/isatty_tcgets.go 138 | _, err := unix.IoctlGetTermios(int(os.Stdout.Fd()), unix.TCGETS) 139 | 140 | // We expect ENOTTY if stdout is redirected 141 | return err != nil 142 | } 143 | --------------------------------------------------------------------------------