{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE NoImplicitPrelude #-} {-# LANGUAGE ViewPatterns #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE DeriveDataTypeable #-} module Main where import qualified ClassyPrelude.Conduit as ClassyPrelude import ClassyPrelude.Conduit hiding ((<>)) import Control.Applicative import Crypto.Hash import Crypto.Hash.Conduit (sinkHash) import Data.Aeson (FromJSON(..), withObject, (.:), (.:?), (.!=)) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Lazy as LByteString import qualified Data.Char as Char import qualified Data.HashMap.Strict as HashMap import qualified Data.List as List import Data.Monoid import qualified Data.Text.Lazy as LText import qualified Data.Text.Lazy.Encoding as LText import qualified Data.Yaml as Yaml import Options.Applicative (Parser, strArgument, metavar, value) import Stackage.CLI import Network.HTTP.Client.Conduit import Network.HTTP.Client.TLS (tlsManagerSettings) import Network.HTTP.Types (hUserAgent) import Filesystem import qualified Filesystem.Path.CurrentOS as Path import System.FilePath (searchPathSeparator, getSearchPath) import System.Environment (lookupEnv, getEnv, setEnv) import System.Exit (exitFailure) import System.Process (callProcess, readProcess) import qualified Paths_stackage_setup as CabalInfo import qualified Prelude import Prelude (Bool(..)) version :: String version = $(simpleVersion CabalInfo.version) header :: String header = "Retrieve ghc, cabal, etc, for use with stackage" progDesc :: String progDesc = header userAgent :: ByteString userAgent = "stackage-setup" stackageHostDefault :: String stackageHostDefault = "https://www.stackage.org" main :: IO () main = do (target,()) <- simpleOptions version header progDesc setupTargetParser empty withManagerSettings tlsManagerSettings $ withConfig $ curryReaderT R $ setup target data R = R { rConfig :: Config , rManager :: Manager } instance HasHttpManager R where getHttpManager = rManager data Config = Config { configStackageRoot :: FilePath , configStackageHost :: String } data StackageConfig = StackageConfig { _stackageHost :: String } instance FromJSON StackageConfig where parseJSON = withObject "StackageConfig" $ \obj -> do _stackageHost <- obj .:? "stackage-host" .!= stackageHostDefault return StackageConfig{..} -- Check if given processes appear to be present. -- Halts the program with exit failure if any are missing. -- TODO: make cross platform checkDependencies :: [String] -> IO () checkDependencies deps = do missing <- mapM checkIsMissing deps when (or missing) $ do hPutStrLn stderr $ asText $ "Please install missing dependencies and try again." exitFailure -- return True if it appears to be missing checkIsMissing :: String -> IO Bool checkIsMissing process = do isMissing <- procIsMissing process `catch` \(e :: IOException) -> return True if isMissing then do hPutStrLn stderr $ "Couldn't find required dependency: " <> process return True else return False -- return True if it appears to be missing procIsMissing :: String -> IO Bool procIsMissing process = do output <- readProcess process ["--version"] "" return $ null output getStackageRoot :: GetConfig m => m FilePath getStackageRoot = liftM configStackageRoot getConfig getStackageHost :: GetConfig m => m String getStackageHost = liftM configStackageHost getConfig class HasConfig env where accessConfig :: env -> Config instance HasConfig R where accessConfig = rConfig -- TODO: check environment properly getStackageRootIO :: IO FilePath getStackageRootIO = do stackageRoot <- lookupEnv "STACKAGE_ROOT" >>= \case Just dir -> return $ Path.decodeString dir Nothing -> do -- TODO: windows-ify home <- lookupEnv "HOME" >>= \case Just homeStr -> return (fromString homeStr) Nothing -> throwIO StackageRootNotFound return (home ".stackage") createTree stackageRoot return stackageRoot readFileMay :: FilePath -> IO (Maybe ByteString) readFileMay path = do exists <- isFile path if exists then Just <$> ClassyPrelude.readFile path else return Nothing getFirstJust :: [IO (Maybe a)] -> IO (Maybe a) getFirstJust [] = return Nothing getFirstJust (xIO:xsIO) = do xMay <- xIO case xMay of Just{} -> return xMay Nothing -> getFirstJust xsIO getStackageHostIO :: FilePath -> IO String getStackageHostIO stackageRoot = do bsMay <- getFirstJust [ readFileMay (stackageRoot "config") , readFileMay (stackageRoot "config.yaml") , readFileMay (stackageRoot "config.yml") ] case bsMay of Nothing -> return stackageHostDefault Just bs -> case Yaml.decodeEither' bs of Left{} -> return stackageHostDefault Right stackageConfig -> return (_stackageHost stackageConfig) getConfigIO :: IO Config getConfigIO = do configStackageRoot <- getStackageRootIO configStackageHost <- getStackageHostIO configStackageRoot return Config{..} class Monad m => GetConfig m where getConfig :: m Config instance GetConfig IO where getConfig = getConfigIO instance (HasConfig env, Monad m) => GetConfig (ReaderT env m) where getConfig = liftM accessConfig ask withConfig :: (MonadIO m) => ReaderT Config m a -> m a withConfig m = do config <- liftIO getConfigIO runReaderT m config curryReaderT :: (r1 -> r2 -> env) -> ReaderT env m a -> ReaderT r1 (ReaderT r2 m) a curryReaderT tup m = ReaderT $ \r1 -> ReaderT $ \r2 -> runReaderT m $ tup r1 r2 arch :: String arch = "linux64" ltsSnapshotsReq :: (MonadThrow m, GetConfig m) => m Request ltsSnapshotsReq = do stackageHost <- getStackageHost parseUrl $ stackageHost <> "/download/lts-snapshots.json" ghcMajorVersionReq :: (MonadThrow m, GetConfig m) => Snapshot -> m Request ghcMajorVersionReq snapshot = do stackageHost <- getStackageHost parseUrl $ stackageHost <> "/snapshot/" <> snapshot <> "/ghc-major-version" getLinksReq :: (MonadThrow m, GetConfig m) => GhcMajorVersion -> m Request getLinksReq ghcMajorVersion = do stackageHost <- getStackageHost parseUrl $ stackageHost <> "/download/" <> arch <> "/ghc-" <> ghcMajorVersion <> "-links.yaml" setupTargetParser :: Parser SetupTarget setupTargetParser = strArgument mods where mods = metavar "TARGET" <> value "lts" refreshLtsSnapshots :: ( HasHttpManager env , MonadReader env m , GetConfig m , MonadThrow m , MonadIO m ) => m (HashMap String String) refreshLtsSnapshots = do stackageRoot <- getStackageRoot let path = Path.encodeString $ stackageRoot ltsSnapshotsPath response <- httpLbs =<< ltsSnapshotsReq let lbs = responseBody response liftIO $ LByteString.writeFile path lbs either (throwM . ParseLtsSnapshotsError) return $ Aeson.eitherDecode lbs getLinks :: ( MonadIO m , MonadThrow m , MonadReader env m , GetConfig m , HasHttpManager env ) => GhcMajorVersion -> m [Download] getLinks ghcMajorVersion = do stackageRoot <- getStackageRoot response <- httpLbs =<< getLinksReq ghcMajorVersion let lbs = responseBody response bs = LByteString.toStrict lbs path = Path.encodeString $ stackageRoot linksPath ghcMajorVersion liftIO $ LByteString.writeFile path lbs either (throwM . ParseLinksError) return $ Yaml.decodeEither' bs data Download = Download { downloadName :: Text , downloadVersion :: Text , downloadUrl :: String , downloadSha1 :: String , downloadInstructions :: [Text] } instance FromJSON Download where parseJSON = withObject "Download" $ \obj -> do downloadName <- obj .: "name" downloadVersion <- obj .: "version" downloadUrl <- obj .: "url" downloadSha1 <- obj .: "sha1" downloadInstructions <- obj .: "instructions" return Download{..} type SetupTarget = String type GhcMajorVersion = String type Series = String type Snapshot = String data SetupExceptions = SeriesNotFound Series | ParseLtsSnapshotsError String | ParseLinksError Yaml.ParseException | StackageRootNotFound deriving (Show, Typeable) instance Exception SetupExceptions readSeries :: String -> Maybe Series readSeries s@"lts" = Just s readSeries s@(stripPrefix "lts-" -> Just sver) | all Char.isNumber sver = Just s readSeries (stripPrefix "lts/" -> Just sver) | all Char.isNumber sver = Just $ "lts-" <> sver readSeries _ = Nothing readGhcVersion :: String -> Maybe GhcMajorVersion readGhcVersion (stripPrefix "ghc-" -> Just s) = case break (== '.') s of (m1, '.':m2) | all Char.isNumber m1 && all Char.isNumber m2 -> Just s _ -> Nothing readGhcVersion _ = Nothing getGhcMajorVersion :: ( HasHttpManager env , MonadReader env m , GetConfig m , MonadThrow m , MonadIO m ) => String -> m GhcMajorVersion getGhcMajorVersion target = case readGhcVersion target of Just version -> return version Nothing -> do snapshot <- case readSeries target of Just series -> lookupSnapshot series Nothing -> return target -- just try using it as a snapshot putStrLn $ "Setup for snapshot: " <> pack snapshot lookupGhcMajorVersion snapshot lookupSnapshot :: ( HasHttpManager env , MonadReader env m , GetConfig m , MonadThrow m , MonadIO m ) => Series -> m Snapshot lookupSnapshot series = do ltsSnapshots <- refreshLtsSnapshots case HashMap.lookup series ltsSnapshots of Just snapshot -> return snapshot Nothing -> throwM $ SeriesNotFound series lookupGhcMajorVersion :: ( HasHttpManager env , MonadReader env m , GetConfig m , MonadThrow m , MonadIO m ) => Snapshot -> m GhcMajorVersion lookupGhcMajorVersion snapshot = do response <- httpLbs =<< ghcMajorVersionReq snapshot let lbs = responseBody response return $ unpack $ LText.toStrict $ LText.decodeUtf8 lbs setup :: ( HasHttpManager env , MonadReader env m , GetConfig m , MonadThrow m , MonadIO m , MonadBaseControl IO m ) => SetupTarget -> m () setup target = do ghcMajorVersion <- getGhcMajorVersion target putStrLn $ "Selecting ghc-" <> pack ghcMajorVersion links <- getLinks ghcMajorVersion stackageRoot <- getStackageRoot forM_ links $ \d@Download{..} -> do let dir = stackageRoot downloadDir downloadName liftIO $ createTree dir let versionedDir = dir downloadPath downloadName downloadVersion exists <- liftIO $ isDirectory versionedDir if (not exists) then do liftIO $ checkDependencies (depsFor downloadName) download downloadUrl downloadSha1 dir postDownload d dir versionedDir else putStrLn $ "Already have: " <> downloadName <> "-" <> downloadVersion augmentPath (versionedDir "bin") -- The dependencies required for stackage-setup's implementation of -- the instructions for a given download item. depsFor :: Text -> [String] depsFor "ghc" = ["make", "tar", "xz"] depsFor "cabal" = ["tar", "gzip"] depsFor "stackage" = ["tar", "xz"] depsFor "alex" = ["tar", "xz"] depsFor "happy" = ["tar", "xz"] depsFor _ = [] -- TODO: ensure this works cross-platform augmentPath :: MonadIO m => FilePath -> m () augmentPath pathHead = liftIO $ do pathRest <- getSearchPath let paths = fpToString pathHead : pathRest path = intercalate [searchPathSeparator] paths setEnv "PATH" path postDownload :: (MonadIO m) => Download -> Path.FilePath -> Path.FilePath -> m () postDownload d@Download{..} dir versionedDir = do expectInstructions downloadName d dir versionedDir expectInstructions :: (MonadIO m) => Text -> Download -> Path.FilePath -> Path.FilePath -> m () expectInstructions "ghc" = expectGhcInstructions expectInstructions "cabal" = expectCabalInstructions expectInstructions "stackage" = justUnpackInstructions expectInstructions "alex" = justUnpackInstructions expectInstructions "happy" = justUnpackInstructions expectInstructions t = unexpectedInstructions t unexpectedInstructions :: (MonadIO m) => Text -> Download -> FilePath -> FilePath -> m () unexpectedInstructions t Download{..} dir _ = do putStrLn $ "Unexpected download: " <> t when (not $ null downloadInstructions) $ do putStrLn $ "Manual instructions:" putStrLn $ "$ cd " <> fpToText dir mapM_ (putStrLn . ("$ " <>)) downloadInstructions justUnpackInstructions :: (MonadIO m) => Download -> FilePath -> FilePath -> m () justUnpackInstructions Download{..} dir _ = do liftIO $ go downloadInstructions where go :: [Text] -> IO () go [] = return () go ( (stripPrefix "tar xJf " -> Just file) : next ) = unzipXZ dir (Path.fromText file) >> go next go ( (stripPrefix "rm " -> Just _file) : next ) = go next -- already done in unzipXZ go (t:_) = fail $ "command not recognized: " <> unpack t expectGhcInstructions :: (MonadIO m) => Download -> Path.FilePath -> Path.FilePath -> m () expectGhcInstructions Download{..} dir versionedDir = liftIO $ go downloadInstructions where go :: [Text] -> IO () go [] = return () go ( (stripPrefix "tar xJf " -> Just file) : next ) = unzipXZ dir (Path.fromText file) >> go next go ( (stripPrefix "rm ghc-" -> Just _file) : next ) = go next -- already done in unzipXZ go ( (stripPrefix "cd ghc-" -> Just version) : "./configure --prefix=`pwd`" : "make install" : "cd .." : next ) | version == downloadVersion = ghcConfigureInstall versionedDir >> go next go (t:_) = fail $ "command not recognized: " <> unpack t inDir :: Path.FilePath -> IO a -> IO a inDir dir action = do workingDir <- getWorkingDirectory let enter = setWorkingDirectory dir exit = setWorkingDirectory workingDir bracket_ enter exit action ghcConfigureInstall :: (MonadIO m) => Path.FilePath -> m () ghcConfigureInstall versionedDir = liftIO $ do let srcDir = versionedDir <.> "src" whenM (isDirectory srcDir) $ removeTree srcDir -- TODO: reuse instead rename versionedDir srcDir createTree versionedDir let go = inDir srcDir $ do callProcess "./configure" ["--prefix=" <> Path.encodeString versionedDir] callProcess "make" ["install"] go `onException` removeTree versionedDir removeTree srcDir expectCabalInstructions :: (MonadIO m) => Download -> Path.FilePath -> Path.FilePath -> m () expectCabalInstructions Download{..} dir versionedDir = liftIO $ rememberingOldCabal $ go downloadInstructions where go [] = return () go ( (stripPrefix "tar xzf " -> Just file) : next ) = unzipGZ dir (Path.fromText file) >> go next go ( (stripPrefix "rm cabal-install-" -> Just (stripSuffix ".tar.gz" -> Just _file)) : next ) = go next -- already done in unzipGZ go ( (stripPrefix "cd cabal-install-" -> Just version) : "./bootstrap.sh" : "cd .." : (stripPrefix "rm -r cabal-install-" -> Just version') : next ) | version == downloadVersion && version == version' = cabalBootstrap dir version >> go next go ( (stripPrefix "mkdir cabal-" -> Just version) : (stripPrefix "mv $HOME/.cabal/bin/cabal cabal-" -> Just _newLoc) : next ) | version == downloadVersion = cabalMoveExe versionedDir >> go next go (t:_) = fail $ "command not recognized: " <> unpack t cabalBootstrap :: FilePath -> Text -> IO () cabalBootstrap dir version = do let cabalInstallVersionedDir = dir Path.fromText ("cabal-install-" <> version) inDir cabalInstallVersionedDir $ do callProcess "./bootstrap.sh" [] removeTree cabalInstallVersionedDir cabalMoveExe :: FilePath -> IO () cabalMoveExe versionedDir = do let versionedDirBin = versionedDir "bin" createTree versionedDirBin homeCabal <- getHomeCabal rename homeCabal (versionedDirBin "cabal") getHomeCabal :: IO FilePath getHomeCabal = do -- TODO: errors Just home <- lookupEnv "HOME" return $ Path.decodeString home ".cabal" "bin" "cabal" rememberingOldCabal :: IO a -> IO a rememberingOldCabal action = do homeCabal <- getHomeCabal exists <- isFile homeCabal if exists then do let tempCabal = homeCabal <.> "old" toTemp = rename homeCabal tempCabal fromTemp = rename tempCabal homeCabal bracket_ toTemp fromTemp action else action -- TODO: use Text more than String download :: ( MonadIO m , MonadBaseControl IO m , MonadThrow m , MonadReader env m , HasHttpManager env ) => String -> String -> Path.FilePath -> m () download url sha1 dir = do -- TODO: deal with partial function last let fname = List.drop (List.last slashes + 1) url slashes = List.findIndices (== '/') url file = dir Path.decodeString fname putStrLn $ "Downloading: " <> pack fname req0 <- parseUrl url let req = req0 { requestHeaders = [(hUserAgent, userAgent)] } runResourceT $ withResponse req $ \res -> do let source = responseBody res let sink = getZipSink $ ZipSink sinkHash <* ZipSink (sinkFile file) hash <- source $$ sink let _ = hash :: Digest SHA1 when (show hash /= sha1) $ do fail "Corrupted download" putStrLn $ "Verified sha1: " <> pack sha1 -- TODO: make cross platform unzipDownload :: (MonadIO m) => Path.FilePath -> Path.FilePath -> m () unzipDownload dir file = case stripSuffix ".tar.xz" (fpToText file) of Just{} -> unzipXZ dir file _ -> case stripSuffix ".tar.gz" (fpToText file) of Just {} -> unzipGZ dir file _ -> fail $ "unzipDownload: unknown extension" -- TODO: other extensions -- TODO: make cross platform unzipXZ :: (MonadIO m) => Path.FilePath -> Path.FilePath -> m () unzipXZ dir file = liftIO $ inDir dir $ do putStrLn "Decompressing XZ archive" callProcess "tar" ["xJf" , Path.encodeString file ] removeFile file -- TODO: make cross platform unzipGZ :: (MonadIO m) => Path.FilePath -> Path.FilePath -> m () unzipGZ dir file = liftIO $ inDir dir $ do putStrLn "Decompressing GZ archive" callProcess "tar" ["xzf" , Path.encodeString file ] removeFile file downloadDir :: Text -> Path.FilePath downloadDir name = "environment" Path.fromText name downloadPath :: Text -> Text -> Path.FilePath downloadPath name version = Path.fromText (name <> "-" <> version) ltsSnapshotsPath :: Path.FilePath ltsSnapshotsPath = Path.fromText "lts-snapshots.json" linksPath :: GhcMajorVersion -> Path.FilePath linksPath ghcMajorVersion = Path.fromText $ "ghc-" <> pack ghcMajorVersion <> "-links.yaml"