{-# LANGUAGE NoImplicitPrelude #-} {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE ExistentialQuantification #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} -- | Create new a new project directory populated with a basic working -- project. module Stack.New ( new , NewOpts (..) , TemplateName , templatesHelp ) where import Control.Monad.Trans.Writer.Strict import Data.Aeson as A import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Builder ( lazyByteString ) import qualified Data.ByteString.Lazy as LB import Data.Conduit import qualified Data.List as L import qualified Data.Map.Strict as M import qualified Data.Set as S import qualified Data.Text as T import qualified Data.Text.Encoding as T import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.Encoding as TLE import Data.Time.Calendar import Data.Time.Clock import Network.HTTP.StackClient ( HttpException (..), HttpExceptionContent (..) , Response (..), VerifiedDownloadException (..) , getResponseBody, httpLbs, mkDownloadRequest, notFound404 , parseRequest, parseUrlThrow, setForceDownload , setGitHubHeaders, setRequestCheckStatus , verifiedDownloadWithProgress ) import Path import Path.IO import RIO.Process import Stack.Constants import Stack.Constants.Config import Stack.Prelude import Stack.Types.Config import Stack.Types.TemplateName import qualified Text.Mustache as Mustache import qualified Text.Mustache.Render as Mustache import Text.ProjectTemplate -------------------------------------------------------------------------------- -- Exceptions -- | Type representing \'pretty\' exceptions thrown by functions exported by the -- "Stack.New" module. data NewPrettyException = ProjectDirAlreadyExists !String !(Path Abs Dir) | DownloadTemplateFailed !Text !String !VerifiedDownloadException | forall b. LoadTemplateFailed !TemplateName !(Path b File) | forall b. ExtractTemplateFailed !TemplateName !(Path b File) !String | TemplateInvalid !TemplateName !StyleDoc | MagicPackageNameInvalid !String | AttemptedOverwrites !Text ![Path Abs File] | DownloadTemplatesHelpFailed !HttpException | TemplatesHelpEncodingInvalid !String !UnicodeException deriving Typeable deriving instance Show NewPrettyException instance Pretty NewPrettyException where pretty (ProjectDirAlreadyExists name path) = "[S-2135]" <> line <> fillSep [ flow "Stack failed to create a new directory for project" , style Current (fromString name) <> "," , flow "as the directory" , style Dir (pretty path) , flow "already exists." ] pretty (DownloadTemplateFailed name url err) = "[S-1688]" <> line <> fillSep [ flow "Stack failed to download the template" , style Current (fromString . T.unpack $ name) , "from" , style Url (fromString url) <> "." ] <> blankLine <> ( if isNotFound then flow "Please check that the template exists at that \ \location." <> blankLine else mempty ) <> fillSep [ flow "While downloading, Stack encountered" , msg ] where (msg, isNotFound) = case err of DownloadHttpError (HttpExceptionRequest req content) -> let msg' = flow "an HTTP error. Stack made the request:" <> blankLine <> fromString (show req) <> blankLine <> flow "and the content of the error was:" <> blankLine <> fromString (show content) isNotFound404 = case content of StatusCodeException res _ -> responseStatus res == notFound404 _ -> False in (msg', isNotFound404) DownloadHttpError (InvalidUrlException url' reason) -> let msg' = fillSep [ flow "an HTTP error. The URL" , style Url (fromString url') , flow "was considered invalid because" , fromString reason <> "." ] in (msg', False) _ -> let msg' = flow "the following error:" <> blankLine <> fromString (displayException err) in (msg', False) pretty (LoadTemplateFailed name path) = "[S-3650]" <> line <> fillSep [ flow "Stack failed to load the downloaded template" , style Current (fromString $ T.unpack $ templateName name) , "from" , style File (pretty path) <> "." ] pretty (ExtractTemplateFailed name path err) = "[S-9582]" <> line <> fillSep [ flow "Stack failed to extract the loaded template" , style Current (fromString $ T.unpack $ templateName name) , "at" , style File (pretty path) <> "." ] <> blankLine <> flow "While extracting, Stack encountered the following error:" <> blankLine <> string err pretty (TemplateInvalid name why) = "[S-9490]" <> line <> fillSep [ flow "Stack failed to use the template" , style Current (fromString $ T.unpack $ templateName name) <> "," , "as" , why ] pretty (MagicPackageNameInvalid name) = "[S-5682]" <> line <> fillSep [ flow "Stack declined to create a new directory for project" , style Current (fromString name) <> "," , flow "as package" , fromString name , flow "is 'wired-in' to a version of GHC. That can cause build \ \errors." ] <> blankLine <> fillSep ( flow "The names blocked by Stack are:" : mkNarrativeList Nothing False ( map toStyleDoc (L.sort $ S.toList wiredInPackages) ) ) where toStyleDoc :: PackageName -> StyleDoc toStyleDoc = fromString . packageNameString pretty (AttemptedOverwrites name fps) = "[S-3113]" <> line <> fillSep [ flow "Stack declined to apply the template" , style Current (fromString . T.unpack $ name) <> "," , flow "as it would create files that already exist." ] <> blankLine <> flow "The template would create the following existing files:" <> line <> bulletedList (map (style File . pretty) fps) <> blankLine <> fillSep [ "Use the" , style Shell "--force" , "flag to ignore this and overwrite those files." ] pretty (DownloadTemplatesHelpFailed err) = "[S-8143]" <> line <> fillSep [ flow "Stack failed to download the help for" , style Shell "stack templates" <> "." ] <> blankLine <> flow "While downloading, Stack encountered the following error:" <> blankLine <> string (displayException err) pretty (TemplatesHelpEncodingInvalid url err) = "[S-6670]" <> line <> fillSep [ flow "Stack failed to decode the help for" , style Shell "stack templates" , flow "downloaded from" , style Url (fromString url) <> "." ] <> blankLine <> flow "While decoding, Stack encountered the following error:" <> blankLine <> string (displayException err) instance Exception NewPrettyException -------------------------------------------------------------------------------- -- Main project creation -- | Options for creating a new project. data NewOpts = NewOpts { newOptsProjectName :: PackageName -- ^ Name of the project to create. , newOptsCreateBare :: Bool -- ^ Whether to create the project without a directory. , newOptsTemplate :: Maybe TemplateName -- ^ Name of the template to use. , newOptsNonceParams :: Map Text Text -- ^ Nonce parameters specified just for this invocation. } -- | Create a new project with the given options. new :: HasConfig env => NewOpts -> Bool -> RIO env (Path Abs Dir) new opts forceOverwrite = do when (project `elem` wiredInPackages) $ throwM $ PrettyException $ MagicPackageNameInvalid projectName pwd <- getCurrentDir absDir <- if bare then pure pwd else do relDir <- parseRelDir (packageNameString project) liftM (pwd ) (pure relDir) exists <- doesDirExist absDir configTemplate <- view $ configL.to configDefaultTemplate let template = fromMaybe defaultTemplateName $ asum [ cliOptionTemplate , configTemplate ] if exists && not bare then throwM $ PrettyException $ ProjectDirAlreadyExists projectName absDir else do templateText <- loadTemplate template (logUsing absDir template) files <- applyTemplate project template (newOptsNonceParams opts) absDir templateText when (not forceOverwrite && bare) $ checkForOverwrite (templateName template) (M.keys files) writeTemplateFiles files runTemplateInits absDir pure absDir where cliOptionTemplate = newOptsTemplate opts project = newOptsProjectName opts projectName = packageNameString project bare = newOptsCreateBare opts logUsing absDir template templateFrom = let loading = case templateFrom of LocalTemp -> flow "Loading local" RemoteTemp -> "Downloading" in prettyInfo ( fillSep [ loading , "template" , style Current (fromString $ T.unpack $ templateName template) , flow "to create project" , style Current (fromString projectName) , "in" , ( if bare then flow "the current directory" else fillSep [ "directory" , style Dir (pretty $ dirname absDir) ] ) <> "..." ] ) data TemplateFrom = LocalTemp | RemoteTemp -- | Download and read in a template's text content. loadTemplate :: forall env. HasConfig env => TemplateName -> (TemplateFrom -> RIO env ()) -> RIO env Text loadTemplate name logIt = do templateDir <- view $ configL.to templatesDir case templatePath name of AbsPath absFile -> logIt LocalTemp >> loadLocalFile absFile eitherByteStringToText UrlPath s -> do let settings = asIsFromUrl s downloadFromUrl settings templateDir RelPath rawParam relFile -> catch (do f <- loadLocalFile relFile eitherByteStringToText logIt LocalTemp pure f) (\(e :: PrettyException) -> do case relSettings rawParam of Just settings -> do let url = tplDownloadUrl settings extract = tplExtract settings downloadTemplate url extract (templateDir relFile) Nothing -> throwM e ) RepoPath rtp -> do let settings = settingsFromRepoTemplatePath rtp downloadFromUrl settings templateDir where loadLocalFile :: Path b File -> (ByteString -> Either String Text) -> RIO env Text loadLocalFile path extract = do logDebug ("Opening local template: \"" <> fromString (toFilePath path) <> "\"") exists <- doesFileExist path if exists then do bs <- readFileBinary (toFilePath path) --readFileUtf8 (toFilePath path) case extract bs of Left err -> throwM $ PrettyException $ ExtractTemplateFailed name path err Right template -> pure template else throwM $ PrettyException $ LoadTemplateFailed name path relSettings :: String -> Maybe TemplateDownloadSettings relSettings req = do rtp <- parseRepoPathWithService defaultRepoService (T.pack req) pure (settingsFromRepoTemplatePath rtp) downloadFromUrl :: TemplateDownloadSettings -> Path Abs Dir -> RIO env Text downloadFromUrl settings templateDir = do let url = tplDownloadUrl settings rel = fromMaybe backupUrlRelPath (parseRelFile url) downloadTemplate url (tplExtract settings) (templateDir rel) downloadTemplate :: String -> (ByteString -> Either String Text) -> Path Abs File -> RIO env Text downloadTemplate url extract path = do req <- parseRequest url let dReq = setForceDownload True $ mkDownloadRequest (setRequestCheckStatus req) logIt RemoteTemp catch ( do let label = T.pack $ toFilePath path res <- verifiedDownloadWithProgress dReq path label Nothing if res then logStickyDone ("Downloaded " <> display label <> ".") else logStickyDone "Already downloaded." ) (useCachedVersionOrThrow url path) loadLocalFile path extract useCachedVersionOrThrow :: String -> Path Abs File -> VerifiedDownloadException -> RIO env () useCachedVersionOrThrow url path exception = do exists <- doesFileExist path if exists then prettyWarn ( flow "Tried to download the template but an error was \ \found. Using cached local version. It may not be the \ \most recent version though." ) else throwM $ PrettyException $ DownloadTemplateFailed (templateName name) url exception data TemplateDownloadSettings = TemplateDownloadSettings { tplDownloadUrl :: String , tplExtract :: ByteString -> Either String Text } eitherByteStringToText :: ByteString -> Either String Text eitherByteStringToText = mapLeft show . decodeUtf8' asIsFromUrl :: String -> TemplateDownloadSettings asIsFromUrl url = TemplateDownloadSettings { tplDownloadUrl = url , tplExtract = eitherByteStringToText } -- | Construct a URL for downloading from a repo. settingsFromRepoTemplatePath :: RepoTemplatePath -> TemplateDownloadSettings settingsFromRepoTemplatePath (RepoTemplatePath GitHub user name) = -- T.concat ["https://raw.githubusercontent.com", "/", user, "/stack-templates/master/", name] TemplateDownloadSettings { tplDownloadUrl = concat [ "https://api.github.com/repos/" , T.unpack user , "/stack-templates/contents/" , T.unpack name ] , tplExtract = \bs -> do decodedJson <- eitherDecode (LB.fromStrict bs) case decodedJson of Object o | Just (String content) <- KeyMap.lookup "content" o -> do let noNewlines = T.filter (/= '\n') bsContent <- B64.decode $ T.encodeUtf8 (noNewlines content) mapLeft show $ decodeUtf8' bsContent _ -> Left "Couldn't parse GitHub response as a JSON object with a \"content\" field" } settingsFromRepoTemplatePath (RepoTemplatePath GitLab user name) = asIsFromUrl $ concat [ "https://gitlab.com" , "/" , T.unpack user , "/stack-templates/raw/master/" , T.unpack name ] settingsFromRepoTemplatePath (RepoTemplatePath Bitbucket user name) = asIsFromUrl $ concat [ "https://bitbucket.org" , "/" , T.unpack user , "/stack-templates/raw/master/" , T.unpack name ] -- | Apply and unpack a template into a directory. applyTemplate :: HasConfig env => PackageName -> TemplateName -> Map Text Text -> Path Abs Dir -> Text -> RIO env (Map (Path Abs File) LB.ByteString) applyTemplate project template nonceParams dir templateText = do config <- view configL currentYear <- do now <- liftIO getCurrentTime let (year, _, _) = toGregorian (utctDay now) pure $ T.pack . show $ year let context = M.unions [nonceParams, nameParams, configParams, yearParam] where nameAsVarId = T.replace "-" "_" $ T.pack $ packageNameString project nameAsModule = T.filter (/= ' ') $ T.toTitle $ T.replace "-" " " $ T.pack $ packageNameString project nameParams = M.fromList [ ("name", T.pack $ packageNameString project) , ("name-as-varid", nameAsVarId) , ("name-as-module", nameAsModule) ] configParams = configTemplateParams config yearParam = M.singleton "year" currentYear files :: Map FilePath LB.ByteString <- catch ( execWriterT $ runConduit $ yield (T.encodeUtf8 templateText) .| unpackTemplate receiveMem id ) ( \(e :: ProjectTemplateException) -> throwM $ PrettyException $ TemplateInvalid template (string $ displayException e) ) when (M.null files) $ throwM $ PrettyException $ TemplateInvalid template (flow "the template does not contain any files.") let isPkgSpec f = ".cabal" `L.isSuffixOf` f || f == "package.yaml" unless (any isPkgSpec . M.keys $ files) $ throwM $ PrettyException $ TemplateInvalid template ( flow "the template does not contain a Cabal or package.yaml \ \file." ) -- Apply Mustache templating to a single file within the project template. let applyMustache bytes -- Workaround for performance problems with mustache and -- large files, applies to Yesod templates with large -- bootstrap CSS files. See -- https://github.com/commercialhaskell/stack/issues/4133. | LB.length bytes < 50000 , Right text <- TLE.decodeUtf8' bytes = do let etemplateCompiled = Mustache.compileTemplate (T.unpack (templateName template)) $ TL.toStrict text templateCompiled <- case etemplateCompiled of Left e -> throwM $ PrettyException $ TemplateInvalid template ( flow "Stack encountered the following error:" <> blankLine -- Text.Parsec.Error.ParseError is not an instance -- of Control.Exception. <> string (show e) ) Right t -> pure t let (substitutionErrors, applied) = Mustache.checkedSubstitute templateCompiled context missingKeys = S.fromList $ concatMap onlyMissingKeys substitutionErrors pure (LB.fromStrict $ encodeUtf8 applied, missingKeys) -- Too large or too binary | otherwise = pure (bytes, S.empty) -- Accumulate any missing keys as the file is processed processFile mks (fpOrig, bytes) = do -- Apply the mustache template to the filenames as well, so that we -- can have file names depend on the project name. (fp, mks1) <- applyMustache $ TLE.encodeUtf8 $ TL.pack fpOrig path <- parseRelFile $ TL.unpack $ TLE.decodeUtf8 fp (bytes', mks2) <- applyMustache bytes pure (mks <> mks1 <> mks2, (dir path, bytes')) (missingKeys, results) <- mapAccumLM processFile S.empty (M.toList files) unless (S.null missingKeys) $ do prettyNote $ missingParameters missingKeys (configUserConfigPath config) pure $ M.fromList results where onlyMissingKeys (Mustache.VariableNotFound ks) = map T.unpack ks onlyMissingKeys _ = [] mapAccumLM :: Monad m => (a -> b -> m(a, c)) -> a -> [b] -> m(a, [c]) mapAccumLM _ a [] = pure (a, []) mapAccumLM f a (x:xs) = do (a', c) <- f a x (a'', cs) <- mapAccumLM f a' xs pure (a'', c:cs) missingParameters :: Set String -> Path Abs File -> StyleDoc missingParameters missingKeys userConfigPath = fillSep ( flow "The following parameters were needed by the template but \ \not provided:" : mkNarrativeList Nothing False (map toStyleDoc (S.toList missingKeys)) ) <> blankLine <> fillSep [ flow "You can provide them in Stack's global YAML configuration \ \file" , "(" <> style File (pretty userConfigPath) <> ")" , "like this:" ] <> blankLine <> "templates:" <> line <> " params:" <> line <> vsep ( map (\key -> " " <> fromString key <> ": value") (S.toList missingKeys) ) <> blankLine <> flow "Or you can pass each one on the command line as parameters \ \like this:" <> blankLine <> style Shell ( fillSep [ flow "stack new" , fromString (packageNameString project) , fromString $ T.unpack (templateName template) , hsep $ map ( \key -> fillSep [ "-p" , "\"" <> fromString key <> ":value\"" ] ) (S.toList missingKeys) ] ) <> line where toStyleDoc :: String -> StyleDoc toStyleDoc = fromString -- | Check if we're going to overwrite any existing files. checkForOverwrite :: (MonadIO m, MonadThrow m) => Text -> [Path Abs File] -> m () checkForOverwrite name files = do overwrites <- filterM doesFileExist files unless (null overwrites) $ throwM $ PrettyException $ AttemptedOverwrites name overwrites -- | Write files to the new project directory. writeTemplateFiles :: MonadIO m => Map (Path Abs File) LB.ByteString -> m () writeTemplateFiles files = liftIO $ forM_ (M.toList files) (\(fp,bytes) -> do ensureDir (parent fp) writeBinaryFileAtomic fp $ lazyByteString bytes) -- | Run any initialization functions, such as Git. runTemplateInits :: HasConfig env => Path Abs Dir -> RIO env () runTemplateInits dir = do config <- view configL case configScmInit config of Nothing -> pure () Just Git -> withWorkingDir (toFilePath dir) $ catchAny (proc "git" ["init"] runProcess_) ( \_ -> prettyWarn $ fillSep [ flow "Stack failed to run a" , style Shell (flow "git init") , flow "command. Ignoring..." ] ) -- | Display help for the templates command. templatesHelp :: HasLogFunc env => RIO env () templatesHelp = do let url = defaultTemplatesHelpUrl req <- liftM setGitHubHeaders (parseUrlThrow url) resp <- catch (httpLbs req) (throwM . PrettyException. DownloadTemplatesHelpFailed) case decodeUtf8' $ LB.toStrict $ getResponseBody resp of Left err -> throwM $ PrettyException $ TemplatesHelpEncodingInvalid url err Right txt -> logInfo $ display txt -------------------------------------------------------------------------------- -- Defaults -- | The default service to use to download templates. defaultRepoService :: RepoService defaultRepoService = GitHub -- | Default web URL to get the `stack templates` help output. defaultTemplatesHelpUrl :: String defaultTemplatesHelpUrl = "https://raw.githubusercontent.com/commercialhaskell/stack-templates/master/STACK_HELP.md"