{-# LANGUAGE CPP #-} module Main (main) where #if !MIN_VERSION_base(4,8,0) import Control.Applicative (pure, (<$>)) #endif import Control.Exception (bracket_, finally, onException) import Control.Monad.Extra import qualified Data.ByteString.Lazy.Char8 as B import Data.Char import Data.List.Extra import Data.Maybe #if !MIN_VERSION_base(4,11,0) import Data.Monoid ((<>)) #endif import SimpleCabal import SimpleCmdArgs import System.Directory import System.Environment.XDG.BaseDir import System.Exit (ExitCode (..)) import System.FilePath import System.IO (BufferMode(NoBuffering), hSetBuffering, hSetEcho, stdin, stdout) import qualified System.Process.Typed as P import Paths_hkgr (getDataFileName, version) main :: IO () main = do hSetBuffering stdout NoBuffering simpleCmdArgs (Just version) "Hackage Release tool" "'Hackager' is a package release tool for easy Hackage workflow" $ subcommands [ Subcommand "new" "setup a new project" $ newCmd <$> optional (strArg "PROJECT") , Subcommand "tagdist" "'git tag' version and 'cabal sdist' tarball" $ tagDistCmd <$> existingTag <*> forceOpt "Move existing tag" <*> hlintOpt , Subcommand "upload" "'cabal upload' candidate tarball to Hackage" $ uploadCmd False <$> existingTag <*> forceOpt "Move existing tag" <*> hlintOpt , Subcommand "publish" "Publish to Hackage ('cabal upload --publish')" $ pure $ uploadCmd True False False True , Subcommand "upload-haddock" "Upload candidate documentation to Hackage" $ pure $ upHaddockCmd False , Subcommand "publish-haddock" "Publish documentation to Hackage" $ pure $ upHaddockCmd True , Subcommand "version" "Show the package version from .cabal file" $ pure showVersionCmd , Subcommand "rename" "Rename the Cabal package" $ renameCmd <$> strArg "NEWNAME" ] where forceOpt = switchWith 'f' "force" existingTag = switchWith 'T' "existing-tag" "Use existing tag to create tarball" hlintOpt = switchWith 'H' "no-hlint" "Skip running hlint" git :: String -> [String] -> P.ProcessConfig () () () git c args = P.proc "git" (c:args) git_ :: String -> [String] -> IO () git_ c args = cmd_ "git" (c:args) gitBool :: String -> [String] -> IO Bool gitBool c args = do (== ExitSuccess) <$> P.runProcess (git c args) removeTrailingNewline :: B.ByteString -> B.ByteString removeTrailingNewline "" = "" removeTrailingNewline bs = if B.last bs == '\n' then B.init bs else bs -- cmd :: String -> [String] -> IO String -- cmd c args = -- B.unpack . removeTrailingNewline <$> P.readProcessStdout_ (P.proc c args) cmd_ :: String -> [String] -> IO () cmd_ c args = P.runProcess_ $ P.proc c args cmdOut :: P.ProcessConfig () () () -> IO String cmdOut p = B.unpack . removeTrailingNewline <$> P.readProcessStdout_ p cmdMaybe :: P.ProcessConfig () () () -> IO (Maybe String) cmdMaybe p = do (ret, out ,_err) <- P.readProcess p return $ if ret == ExitSuccess then Just (B.unpack (removeTrailingNewline out)) else Nothing cmdLines :: P.ProcessConfig () () () -> IO [String] cmdLines p = map B.unpack . B.lines <$> P.readProcessStdout_ p -- from simple-cmd error' :: String -> a #if (defined(MIN_VERSION_base) && MIN_VERSION_base(4,9,0)) error' = errorWithoutStackTrace #else error' = error #endif tarGzExt :: String tarGzExt = "tar.gz" assertTagOnBranch :: String -> IO () assertTagOnBranch tag = whenM (null <$> cmdOut (git "branch" ["--contains", "tags/" ++ tag])) $ error' $ tag ++ " is no longer on branch: use --force to move it" tagDistCmd :: Bool -> Bool -> Bool -> IO () tagDistCmd existingtag force noHlint = do pkgid <- checkPackage True let tag = pkgidTag pkgid mtagHash <- cmdMaybe (git "rev-parse" [tag]) when (not force && isJust mtagHash) $ assertTagOnBranch tag if existingtag then if isNothing mtagHash then error' $ "tag " ++ tag ++ " does not exist" else sdist force noHlint pkgid else do if isJust mtagHash && not force then do headHash <- cmdOut (git "rev-parse" ["HEAD"]) let onHead = Just headHash == mtagHash putStrLn $ "tag is " ++ (if onHead then "" else "not ") ++ "on HEAD" let tarball = sdistDir showPkgId pkgid <.> tarGzExt exists <- doesFileExist tarball if exists then error' $ "tag " ++ tag ++ " exists: use --force to " ++ (if exists then "override tarball and " else "") ++ "move" else sdist force noHlint pkgid else do git_ "tag" $ ["--force" | force] ++ [tag] unless force $ putStrLn tag sdist force noHlint pkgid `onException` do putStrLn "Resetting tag" if force then git_ "tag" ["--force", tag, fromJust mtagHash] else git_ "tag" ["--delete", tag] pkgidTag :: PackageIdentifier -> String pkgidTag pkgid = "v" ++ packageVersion pkgid checkPackage :: Bool -> IO PackageIdentifier checkPackage checkDiff = do when checkDiff $ do diff <- cmdOut $ git "diff" ["HEAD"] unless (null diff) $ do putStrLn "=== start of uncommitted changes ===" putStrLn diff putStrLn "=== end of uncommitted changes ===" pkgid <- getPackageId checkNameVersionCommitted pkgid checkNotPublished pkgid return pkgid where checkNameVersionCommitted :: PackageIdentifier -> IO () checkNameVersionCommitted pkgid = do let pkg = packageName pkgid cabalfile = unPackageName pkg <.> "cabal" unlessM (doesFileExist cabalfile) $ error' $ ".cabal filename differs from package name: " ++ unPackageName pkg diff <- cmdOut (git "diff" ["-U0", "HEAD", cabalfile]) when (any ("+version:" `isPrefixOf`) (lines (map toLower diff))) $ error' "Please commit or revert the changed package Version first" checkNotPublished :: PackageIdentifier -> IO () checkNotPublished pkgid = do let published = sdistDir showPkgId pkgid <.> tarGzExt <.> "published" exists <- doesFileExist published when exists $ error' $ showPkgId pkgid <> " was already published!!" let oldpublished = "dist" showPkgId pkgid <.> tarGzExt <.> "published" oldExists <- doesFileExist oldpublished when oldExists $ error' $ showPkgId pkgid <> " was already published!!" sdistDir :: FilePath sdistDir = ".hkgr" sdist :: Bool -> Bool -> PackageIdentifier -> IO () sdist force noHlint pkgid = do let tag = pkgidTag pkgid let target = sdistDir showPkgId pkgid <.> tarGzExt haveTarget <- doesFileExist target when haveTarget $ if force then removeFile target else error' $ target <> " exists already!" cwd <- getCurrentDirectory withTempDirectory "tmp-sdist" $ do git_ "clone" ["-q", "--no-checkout", "..", "."] git_ "checkout" ["-q", tag] whenM (doesFileExist ".gitmodules") $ git_ "submodule" ["update", "--init"] cabal_ "check" [] unless noHlint $ do mhlint <- findExecutable "hlint" when (isJust mhlint) $ do putStrLn "Running hlint" void $ P.runProcess $ P.proc "hlint" ["--no-summary", "."] let dest = takeDirectory $ cwd target unlessM (doesDirectoryExist dest) $ createDirectoryIfMissing True dest cabal_ "v2-sdist" ["--output-dir=" ++ dest] showVersionCmd :: IO () showVersionCmd = do pkgid <- getPackageId putStrLn $ packageVersion pkgid -- FIXME cabal install creates tarballs now uploadCmd :: Bool -> Bool -> Bool -> Bool -> IO () uploadCmd publish existingtag force noHlint = do needProgram "cabal" pkgid <- checkPackage False let tarball = sdistDir showPkgId pkgid <.> tarGzExt tag = pkgidTag pkgid exists <- doesFileExist tarball when (force || not exists) $ tagDistCmd existingtag force noHlint assertTagOnBranch tag untagged <- cmdLines $ git "log" ["--pretty=reference", tag ++ "..HEAD"] unless (null untagged) $ do putStrLn "untagged newer commits:" mapM_ putStrLn untagged when publish $ do tagHash <- cmdOut $ git "rev-parse" [tag] branch <- cmdOut $ git "branch" ["--show-current"] mergeable <- gitBool "merge-base" ["--is-ancestor", "HEAD", tagHash] when mergeable $ do putStr "git pushing... " git_ "push" ["--quiet", "origin", tagHash ++ ":" ++ branch] putStrLn "done" git_ "push" ["origin", tag] userpassBS <- getUserPassword void $ P.readProcessInterleaved_ (P.setStdin (P.byteStringInput userpassBS) $ P.proc "cabal" ("upload" : ["--publish" | publish] ++ [tarball])) putStrLn $ (if publish then "Published at " else "Uploaded to ") ++ "https://hackage.haskell.org/package/" ++ showPkgId pkgid ++ if publish then "" else "/candidate" when publish $ createFileLink (takeFileName tarball) (tarball <.> "published") getUserPassword :: IO B.ByteString getUserPassword = do cabalConfig <- do home <- getHomeDirectory let cabalConfig = home ".cabal/config" exists <- doesFileExist cabalConfig if exists then readFile cabalConfig else do putStrLn $ "Warning: " ++ cabalConfig ++ " not found!" return "" muser <- maybeGetHackage "username" False cabalConfig mpasswd <- maybeGetHackage "password" True cabalConfig return $ B.pack $ unlines $ catMaybes [muser, mpasswd] where maybeGetHackage :: String -> Bool -> String -> IO (Maybe String) maybeGetHackage field hide cabalConfig = if haveCabalConfigField then return Nothing else Just <$> prompt hide ("hackage.haskell.org " ++ field) where haveCabalConfigField :: Bool haveCabalConfigField = any ((field ++ ":") `isPrefixOf`) (lines cabalConfig) prompt :: Bool -> String -> IO String prompt hide s = do putStr $ s ++ ": " inp <- if hide then withoutEcho getLine else getLine when hide $ putChar '\n' if null inp then prompt hide s else return inp where withoutEcho :: IO a -> IO a withoutEcho action = finally (hSetEcho stdin False >> action) (hSetEcho stdin True) upHaddockCmd :: Bool -> IO () upHaddockCmd publish = do needProgram "cabal" userpassBS <- getUserPassword _out <- P.readProcessInterleaved_ (P.setStdin (P.byteStringInput userpassBS) $ P.proc "cabal" ("upload" : "--documentation" : ["--publish" | publish])) pkgid <- getPackageId putStrLn $ (if publish then "Published at " else "Uploaded to ") ++ "https://hackage.haskell.org/package/" ++ showPkgId pkgid ++ if publish then "" else "/candidate" cabal_ :: String -> [String] -> IO () cabal_ c args = cmd_ "cabal" (c:args) withTempDirectory :: FilePath -> IO a -> IO a withTempDirectory dir run = bracket_ (createDirectory dir) (removeDirectoryRecursive dir) $ withCurrentDirectory dir run -- #if (defined(MIN_VERSION_simple_cmd) && MIN_VERSION_simple_cmd(0,2,1)) -- #else needProgram :: String -> IO () needProgram prog = do mx <- findExecutable prog unless (isJust mx) $ error' $ "program needs " ++ prog -- #endif -- FIXME warning if upstream template changed (keep a versioned template copy) -- FIXME add default templates dir -- FIXME --license newCmd :: Maybe String -> IO () newCmd mproject = do needProgram "cabal" name <- case mproject of Just ns -> return ns Nothing -> do files <- listDirectory "." if null files then takeFileName <$> getCurrentDirectory else do -- filter out dirs dirs <- filterM doesDirectoryExist files if dirs == files then error' "Could not guess name: subdirectories found" else case filter ("cabal" `isExtensionOf`) $ files \\ dirs of [] -> takeFileName <$> getCurrentDirectory [cbl] -> return $ takeBaseName cbl _ -> error' "More than one .cabal file found!" when (isJust mproject) $ createDirectory name >> setCurrentDirectory name mcabal <- checkForCabalFile case mcabal of Nothing -> do let setupFile = "Setup.hs" origsetup <- doesFileExist setupFile cabal_ "init" ["--quiet", "--no-comments", "--non-interactive", "--is-libandexe", "--cabal-version=1.18", "--license=BSD3", "--package-name=" ++ name, "--version=0.1.0", "--dependency=base<5", "--source-dir=src"] whenJustM (cmdMaybe $ P.proc "find" ["-name", "Main.hs"]) $ \ file -> do sed ["1s/^module Main where/-- SPDX-License-Identifier: BSD-3-Clause\\n\\nmodule Main (main) where/"] file unless (file == "src/Main.hs") $ renameFile file "src/Main.hs" whenM (doesFileExist "CHANGELOG.md") $ renameFile "CHANGELOG.md" "ChangeLog.md" unlessM (doesFileExist "README.md") $ writeFile "README.md" $ "# " ++ name ++ "\n" unless origsetup $ do setup <- doesFileExist setupFile when setup $ removeFile setupFile setupCabalTemplate name Just cblName -> when (cblName /= name) $ putStrLn $ "Warning: " ++ cblName ++ " different to " ++ name haveStackCfg <- doesFileExist "stack.yaml" mstack <- findExecutable "stack" -- FIXME add stack.yaml template too when (not haveStackCfg && isJust mstack) $ do cmd_ "stack" ["init", "--verbosity", "warn", "--resolver", "lts-19"] sed ["/^#/d", "/^$/d"] "stack.yaml" haveGit <- doesDirectoryExist ".git" unless haveGit $ do git_ "init" ["-q"] git_ "add" [name <.> "cabal"] where checkForCabalFile :: IO (Maybe String) checkForCabalFile = do files <- listDirectory "." case filter ("cabal" `isExtensionOf`) files of [] -> return Nothing [cbl] -> return $ Just (takeBaseName cbl) _ -> error' "More than one .cabal file found!" setupCabalTemplate :: String -> IO () setupCabalTemplate name = do userTemplate <- getUserConfigFile "hkgr" "template.cabal" unlessM (doesFileExist userTemplate) $ do origTemplate <- getDataFileName "template.cabal.tmpl" createDirectoryIfMissing True $ takeDirectory userTemplate -- FIXME put a copy of the current template there too for reference copyFile origTemplate userTemplate username <- cmdOut $ git "config" ["--global", "user.name"] replaceHolder "NAME" username userTemplate usermail <- fromMaybeM (cmdOut $ git "config" ["--global", "user.email"]) $ cmdMaybe $ git "config" ["--global", "github.email"] replaceHolder "EMAIL" usermail userTemplate githubuser <- cmdOut $ git "config" ["--global", "github.user"] replaceHolder "USER" githubuser userTemplate putStrLn $ userTemplate ++ " set up" copyFile userTemplate $ name <.> "cabal" replaceHolder "PROJECT" name $ name <.> "cabal" replaceHolder "PROJECT_" (map underscore name) $ name <.> "cabal" replaceHolder "SUMMARY" (name ++ " project") $ name <.> "cabal" year <- cmdOut (P.proc "date" ["+%Y"]) replaceHolder "YEAR" year $ name <.> "cabal" let modulePath = "src/MyLib.hs" unlessM (doesFileExist modulePath) $ do createDirectoryIfMissing True $ takeDirectory modulePath writeFile modulePath "-- SPDX-License-Identifier: BSD-3-Clause\n\nmodule MyLib where\n" where replaceHolder lbl val = sed ["s/@" ++ lbl ++ "@/" ++ val ++ "/"] underscore '-' = '_' underscore c = c sed :: [String] -> FilePath -> IO () sed [] _ = error' "sed given no script" sed args file = cmd_ "sed" $ ["-i", "-e"] ++ intersperse "-e" args ++ [file] #if !MIN_VERSION_filepath(1,4,2) isExtensionOf :: String -> FilePath -> Bool isExtensionOf ext@('.':_) = isSuffixOf ext . takeExtensions isExtensionOf ext = isSuffixOf ('.':ext) . takeExtensions #endif #if !MIN_VERSION_extra(1,6,15) fromMaybeM :: Monad m => m a -> m (Maybe a) -> m a fromMaybeM n = maybeM n return #endif renameCmd :: String -> IO () renameCmd newname = do pkgid <- getPackageId let oldname = unPackageName (pkgName pkgid) sed ["s/" ++ oldname ++ "/" ++ newname ++ "/g"] $ oldname <.> "cabal" renameFile (oldname <.> "cabal") (newname <.> "cabal") -- FIXME adjust README