packages feed

hkgr-0.4: src/Main.hs

{-# 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