packages feed

cfv-0.1.0.0: src/Configuration/Cfv/Shell.hs

{-|
Module      : Shell
Description : Command evocation on the shell
Copyright   : (c) Harley Eades, 2026
Maintainer  : harley.eades@gmail.com

This is the API for accessing the users shell. All commands are ran in the login
shell with access to the @PATH@ environment variable, but the shell's rc-file is
not executed by default, and so aliases are not useable. If you need access to
your rc-file, then define a custom t`ShellConfig` for your project.
-}
module Configuration.Cfv.Shell 
  (CommandError(..)
  ,CommandProblem(..)
  ,ShellConfig(..)
  ,evalValue
  ,defaultShellConfig
  ,describeProblem) where

import qualified GHC.IO.Exception as E
import Data.Text.Encoding.Error (UnicodeException, lenientDecode)
import GHC.IO.Exception (ExitCode(..))
import qualified Data.ByteString as BS
import System.Process (CreateProcess(..), waitForProcess, proc, createProcess, StdStream (..))
import GHC.IO.Handle (Handle, hClose)
import Control.Concurrent (MVar, newEmptyMVar, forkIO, putMVar, takeMVar)
import qualified Control.Exception as E
import System.Environment (lookupEnv)
import qualified Data.Text.Encoding as TE
import Control.Exception (try)
import Data.Text (Text)
import qualified Data.Text as DT

-- | A backtick command that could not produce a value.
--
-- For a @Configuration.Cfv.AST.Secret@ variable anything that could contain or reveal the secret is
-- withheld: the command text (which may embed it) and the details of the
-- command's output (see 'CommandProblem').
data CommandError = CommandError
  { cmdCommand  :: Text    
  , cmdProblem  :: CommandProblem
  } deriving (Eq, Show)

-- | Errors produced by running a command on the shell.
data CommandProblem
  = NoShell
    -- ^ No shell configured and @SHELL@ is unset or empty.
  | ShellLaunchFailed FilePath E.IOException
    -- ^ The shell could not be started (not found, not executable, ...).
  | NonZeroExit Int Text
    -- ^ The command exited with this status (negative: killed by that signal)
    -- and this standard error output, trimmed. The output is 'Nothing' for a
    -- '@Configuration.Cfv.AST.Secret' variable.
  | OutputNotUtf8 UnicodeException
    -- ^ The command's standard output is not valid UTF-8. The exception names
    -- the offending byte, so it is 'Nothing' for a '@Configuration.Cfv.AST.Secret' variable.
  deriving (Eq, Show)

-- | Shell configuration.
data ShellConfig = ShellConfig
  { shellProgram :: Maybe FilePath
    -- ^ The shell to use. 'Nothing' means the @SHELL@ environment variable.
  , shellFlags   :: [String]
    -- ^ Arguments placed before the command text, which is passed last.
  } deriving (Eq, Show)

-- | Use @$SHELL@ as a login shell: @$SHELL -l -c cmd@. A login shell picks up
-- the user's profile (@PATH@ additions and so on). Add @"-i"@ to the flags if
-- you also need what the shell's interactive rc file sets up (aliases,
-- functions).
defaultShellConfig :: ShellConfig
defaultShellConfig = ShellConfig {shellProgram = Nothing, shellFlags = ["-l", "-c"]}

-- | Evaluate a shell command reading in its @System.IO.stdout@. 
evalValue :: ShellConfig -- ^ Shell configuration
          -> Text        -- ^ Command to execute
          -> IO (Either CommandError [Char])
evalValue shellCfg cmd 
  = do let failWith = Left . CommandError cmd
       mShell <- resolveShell shellCfg
       case mShell of
         Nothing -> pure (failWith NoShell)
         Just shell -> do
          result <- try $ execShell shell (shellFlags shellCfg) $ DT.unpack cmd
          pure $ case result of
                    Left e -> failWith (ShellLaunchFailed shell e)
                    Right (ExitFailure n, _, err) ->
                      failWith . NonZeroExit n . DT.strip $ TE.decodeUtf8With lenientDecode err
                    Right (ExitSuccess, out, _) 
                      -> case TE.decodeUtf8' out of
                            Left e  -> failWith (OutputNotUtf8 e)
                            Right t -> Right (DT.unpack (DT.dropWhileEnd (== '\n') t))

-- | Figure out the shell's path.
resolveShell :: ShellConfig -> IO (Maybe FilePath)
resolveShell cfg = case shellProgram cfg of
  Just p  -> pure (Just p)
  Nothing -> do
    v <- lookupEnv "SHELL"
    pure $ case v of
      Just s | not (null s) -> Just s
      _                     -> Nothing          

-- | Run @shell flags... cmd@ with stdin closed, collecting stdout and stderr as
-- raw bytes; to prevent deadlock we read both in concurrently.
execShell :: FilePath -> [String] -> String -> IO (ExitCode, BS.ByteString, BS.ByteString)
execShell shell flags cmd = do
  (mIn, mOut, mErr, ph) <-
    createProcess
      (proc shell (flags ++ [cmd]))
        { std_in = CreatePipe, std_out = CreatePipe, std_err = CreatePipe }
  case (mIn, mOut, mErr) of
    (Just hin, Just hout, Just herr) -> do
      hClose hin
      outV <- readAll hout
      errV <- readAll herr
      code <- waitForProcess ph
      (,,) code <$> takeMVar outV <*> takeMVar errV
    _ -> E.throwIO (userError "could not create pipes for the shell process")

-- | Read a handle until there is no more output to consume.
readAll :: Handle -> IO (MVar BS.ByteString)
readAll h = do
  v <- newEmptyMVar
  _ <- forkIO $ (BS.hGetContents h `E.catch` ignoreIO) >>= putMVar v
  pure v
  where
    ignoreIO :: E.IOException -> IO BS.ByteString
    ignoreIO _ = pure BS.empty

-- | Converts a command problem into a 'Text'.
describeProblem :: CommandProblem -> Text
describeProblem NoShell =
  "cannot run command: no shell configured and $SHELL is not set"
describeProblem (ShellLaunchFailed sh e) =
  "cannot start shell " <> DT.show sh <> ": " <> DT.show e
describeProblem (NonZeroExit n err) =
  "command exited with status " <> DT.show n <> (if DT.null err then "" else ": " <> err)
describeProblem (OutputNotUtf8 e) =
  "command output is not valid UTF-8: " <> DT.show e