yarn2nix-0.10.1: NodePackageTool.hs
{-# LANGUAGE RecordWildCards, NoImplicitPrelude, LambdaCase, FlexibleContexts, NamedFieldPuns, OverloadedStrings #-}
import Protolude
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import qualified Control.Monad.Except as ExcT
import qualified Control.Exception as Exc
import qualified System.IO.Error as IOErr
import qualified Data.ByteString.Lazy as BL
import qualified System.FilePath as FP
import qualified System.Directory as Dir
import qualified System.Posix.Files as PosixFiles
import Options.Applicative
import qualified Distribution.Nodejs.Package as NP
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.Aeson.Key as Key
data Args
= Args
{ argsPackageDir :: FilePath
, argsMode :: Mode }
data Mode
= LinkBin
{ linkBinTargetDir :: FilePath }
| SetBinExecFlag
args :: Parser Args
args = subparser
( command "link-bin"
(info (modeCommands linkBinSubcommands)
(progDesc "link package dependecies’ bin files"))
<> command "set-bin-exec-flag"
(info (modeCommands setBinExecFlagSubcommands)
(progDesc "make all bin scripts executable")) )
where
modeCommands modeSubcommands = Args
<$> strOption
( long "package"
<> metavar "FOLDER"
<> help "folder with the node package" )
<*> modeSubcommands
linkBinSubcommands = LinkBin
<$> strOption
( long "to"
<> metavar "LINK_TARGET"
<> help "folder to link to (absolute or relative from package folder)" )
setBinExecFlagSubcommands = pure SetBinExecFlag
type ErrorLogger = ExceptT [Char] IO
-- | Print a warning to stdout.
warn :: [Text] -> ErrorLogger ()
warn ws = for_ ws $ \w -> liftIO $ TIO.hPutStrLn stderr ("Warning: " <> w)
-- | On Exception rethrow with annotation.
tryIOMsg :: ([Char] -> [Char]) -> IO a -> ErrorLogger a
tryIOMsg errAnn = ExcT.withExceptT (errAnn . Exc.displayException) . tryIO
main :: IO ()
main = execParser (info (args <**> helper)
(progDesc "Tool for various node package maintenance tasks"))
>>= realMain
realMain :: Args -> IO ()
realMain Args{..} = do
-- basic sanity checks
let packageJsonPath = argsPackageDir FP.</> "package.json"
unlessM (Dir.doesFileExist packageJsonPath)
$ die $ toS $ packageJsonPath <> " does not exist."
case argsMode of
LinkBin{..} -> do
unlessM (Dir.doesDirectoryExist linkBinTargetDir)
$ die $ toS $ linkBinTargetDir <> " is not a directory."
_ -> pass
-- parse & decode & run logic
runExceptT
(tryRead packageJsonPath >>= tryDecode packageJsonPath >>= go)
>>= \case
(Left err) -> die $ toS $ "ERROR: " <> err
(Right _) -> pass
where
tryRead :: FilePath -> ErrorLogger BL.ByteString
tryRead fp = tryIOMsg exc $ BL.readFile fp
where exc e = fp <> " cannot be read:\n" <> e
tryDecode :: FilePath -> BL.ByteString -> ExceptT [Char] IO NP.Package
tryDecode fp fileBs = do
(pkg, warnings) <- NP.unLoggingPackage
<$> ExcT.ExceptT (pure $ first exc $ NP.decode fileBs)
warn $ fmap ((\w -> toS fp <> ": " <> w) . NP.formatWarning) warnings
pure pkg
where exc e = fp <> " cannot be decoded\n" <> toS e
tryAccess :: IO a -> IO (Maybe a)
tryAccess io =
hush <$> tryJust
(\e -> guard (IOErr.isDoesNotExistError e ||
IOErr.isPermissionError e))
io
qte s = "\"" <> s <> "\""
go :: NP.Package -> ErrorLogger ()
go NP.Package{bin} = do
binFiles <- readBinFiles bin
for_ binFiles $ case argsMode of
-- Link all dependency binaries to their target folder
LinkBin{..} -> linkBin linkBinTargetDir
-- Set the executable flag of all package binaries
SetBinExecFlag -> setBinExecFlag . snd
-- | Read the binary files and return their names & paths.
readBinFiles :: NP.Bin -> ErrorLogger [(Text, FilePath)]
readBinFiles bin = case bin of
-- files with names how they should be linked
(NP.BinFiles bs) -> pure $ (bs & KeyMap.toList <&> first Key.toText)
-- a whole folder where everything should be linked
(NP.BinFolder bf) -> do
dirM <- liftIO $ tryAccess (Dir.listDirectory bf)
case dirM of
Nothing -> do
warn ["Binary folder " <> toS (qte bf) <> " could not be accessed."]
pure []
(Just dir) ->
pure $ fmap (\f -> (toS f, bf FP.</> f)) dir
-- | Canonicalize the path.
canon :: FilePath -> ErrorLogger FilePath
canon fp = tryIOMsg
(\e -> "Couldn’t canonicalize path " <> qte fp <> ": " <> e)
(Dir.canonicalizePath fp)
-- | Canonicalize relative to our package
-- and ensure that the relative path is not outside.
canonPkg :: FilePath -> ErrorLogger FilePath
canonPkg relPath = do
pkgDir <- canon argsPackageDir
resPath <- canon $ argsPackageDir FP.</> relPath
when (not $ pkgDir `isPrefixOf` resPath)
$ throwError $ mconcat
[ "The link to executable file "
, qte relPath
, " lies outside of the package folder!\n"
, "That’s a security risk, aborting." ]
pure resPath
-- | Link a binary file to @targetDir/name@.
-- @relBinPath@ is relative from the package dir.
linkBin :: FilePath -> (Text, FilePath) -> ErrorLogger ()
linkBin targetDir_ (name_, relBinPath) = do
binPath <- canonPkg relBinPath
(name, targetDir) <- traverse canon $
symlinkTarget name_ targetDir_
tryIOMsg (\e -> "Directory could not be created: " <> e) $
Dir.createDirectoryIfMissing False targetDir
tryIOMsg (\e -> "Symlink could not be created: " <> e) $
PosixFiles.createSymbolicLink binPath $ targetDir FP.</> toS name
-- | Given a name and a target directory, return
-- the basename and the target (sub) directory
-- of the target file
symlinkTarget :: Text -> FilePath -> (FilePath, FilePath)
symlinkTarget name targetDir =
if "/" `T.isInfixOf` name
then (FP.takeFileName name', targetDir FP.</> FP.takeDirectory name')
else (name', targetDir)
where name' = T.unpack name
-- | Set executable flag of the file.
setBinExecFlag :: FilePath -> ErrorLogger ()
setBinExecFlag file_ = do
file <- canonPkg file_
res <- liftIO $ tryAccess $ do
perm <- Dir.getPermissions file
Dir.setPermissions file
$ Dir.setOwnerExecutable True perm
case res of
Nothing ->
warn ["Cannot set executable bit on " <> toS file]
Just () -> pass