packages feed

hkgr-0.5: 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_, onException)
import Control.Monad.Extra
import qualified Data.ByteString.Lazy.Char8 as B
import Data.Char
import Data.List.Extra
import Data.Maybe
import Data.Version.Extra
import SimpleCabal
import SimpleCmd ((+-+))
import SimpleCmdArgs
import SimplePrompt (promptNonEmpty, promptPassword)
import System.Directory
import System.Environment.XDG.BaseDir (getUserConfigFile)
import System.Exit (ExitCode (..))
import System.FilePath
import System.IO (BufferMode(NoBuffering), hSetBuffering, 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 (strOptionWith 'l' "license" "LICENSE" "Specify license")
      <*> optional (strArg "PROJECT")
    , Subcommand "tagdist" "'git tag' version and 'cabal sdist' tarball" $
      tagDistCmd
      <$> existingTag
      <*> forceOpt "Move existing tag"
      <*> hlintOpt
      <*> nobuildOpt
    , Subcommand "upload" "'cabal upload' candidate tarball to Hackage" $
      uploadCmd False
      <$> existingTag
      <*> forceOpt "Move existing tag"
      <*> hlintOpt
      <*> nobuildOpt
      <*> pure False
    , Subcommand "publish" "git push and cabal upload --publish" $
      uploadCmd True False False True
      <$> nobuildOpt
      <*> switchWith 'U' "no-upload" "Do not upload to Hackage"
    , Subcommand "upload-haddock" "Upload candidate documentation to Hackage" $
      pure (upHaddockCmd False)
    , Subcommand "publish-haddock" "Publish documentation to Hackage" $
      pure (upHaddockCmd True)
    , Subcommand "build" "Do a local pristine build from the tarball" $
      pure pristineBuildCmd
    , Subcommand "version" "Show the package version from .cabal file" $
      pure showVersionCmd
    , Subcommand "rename" "Rename the Cabal package" $
      renameCmd
      <$> strArg "NEWNAME"
    , Subcommand "github" "Add github repo" $
      pure githubCmd
    ]
  where
    forceOpt = switchWith 'f' "force"

    existingTag =
      switchWith 'T' "existing-tag" "Use existing tag to create tarball"

    hlintOpt =
      switchWith 'H' "no-hlint" "Skip running hlint"

    nobuildOpt =
      switchWith 'N' "no-build" "Do not perform local sanity build"

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 bs =
  case B.unsnoc bs of
    Nothing -> bs
    Just (ini,l) ->
      if l == '\n'
      then ini
      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 -> Bool -> IO ()
tagDistCmd existingtag force noHlint nobuild = do
  pkgid <- checkPackage True
  let tag = pkgidTag pkgid
  mtagHash <- cmdMaybe (git "rev-parse" [tag])
  let tagexists = isJust mtagHash
  when (not force && tagexists) $ assertTagOnBranch tag
  if existingtag
    then if not tagexists
         then error' $ "tag" +-+ tag +-+ "does not exist"
         else sdist force noHlint nobuild pkgid
    else do
    if tagexists && 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 nobuild pkgid
      else do
      git_ "tag" $ ["--force" | force] ++ [tag]
      unless force $ putStrLn tag
      -- actually always forced because: `== not (tagexists && not force)`
      sdist (force || not tagexists) noHlint nobuild 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 -> Bool -> PackageIdentifier -> IO ()
sdist force noHlint nobuild 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
    addCabalProject
    cabal_ "v2-sdist" ["--output-dir=" ++ dest]
  unless nobuild pristineBuildCmd

showVersionCmd :: IO ()
showVersionCmd = do
  pkgid <- getPackageId
  putStrLn $ packageVersion pkgid

gitListTag :: String -> IO Bool
gitListTag tag = do
  res <- cmdOut $ git "tag" ["--list", tag]
  return $ res == tag

-- FIXME cabal install creates tarballs now
uploadCmd :: Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> IO ()
uploadCmd publish existingtag force noHlint nobuild noupload = do
  needProgram "cabal"
  pkgid <- checkPackage False
  let tarball = sdistDir </> showPkgId pkgid <.> tarGzExt
      tag = pkgidTag pkgid
  tagexists <- gitListTag tag
  when (not tagexists && existingtag) $
    error' $ tag +-+ "does not exist"
  when (force || not tagexists) $
    tagDistCmd existingtag force noHlint nobuild
  assertTagOnBranch tag
  untagged <- cmdLines $ git "log" ["--pretty=reference", tag ++ "..HEAD"]
  unless (null untagged) $ do
    putStrLn $ "untagged newer commit" ++
      (if length untagged > 1 then "s" else "") ++ ":"
    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]
  unless noupload $ do
    userpassBS <- getUserPassword
    -- FIXME if password fails, repeat login
    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
    xdgConfig <- getUserConfigFile "cabal" "config"
    have_xdg <- doesFileExist xdgConfig
    if have_xdg
      then readFile xdgConfig
      else do
      let config = home </> ".cabal/config"
      exists <- doesFileExist config
      if exists
        then readFile config
        else do
        putStrLn $ "Warning: no cabal" +-+ show "config" +-+ "file found!"
        return ""
  muser <- maybeGetUsername cabalConfig
  mpasswd <- maybeGetPassword cabalConfig
  return $ B.pack $ unlines $ catMaybes [muser, mpasswd]
  where
    maybeGetUsername :: String -> IO (Maybe String)
    maybeGetUsername config =
      if haveField config "username"
        then return Nothing
        else Just <$> prompt False "hackage.haskell.org username"

    maybeGetPassword :: String -> IO (Maybe String)
    maybeGetPassword config =
      if any (haveField config) ["password", "token", "password-command"]
        then return Nothing
        else Just <$> prompt True "hackage.haskell.org password"

    haveField :: String -> String -> Bool
    haveField config field =
      any ((field ++ ":") `isPrefixOf`) (lines config)

prompt :: Bool -> String -> IO String
prompt hide s = do
  inp <- (if hide then promptPassword else promptNonEmpty) s
  when hide $ putChar '\n'
  return inp

upHaddockCmd :: Bool -> IO ()
upHaddockCmd publish = do
  needProgram "cabal"
  userpassBS <- getUserPassword
  pkgid <- getPackageId
  let tarball = "dist-newstyle" </> showPkgId pkgid ++ '-' : "docs" <.> tarGzExt
  cabal_ "haddock" ["--haddock-for-hackage"]
  _out <- P.readProcessInterleaved_ (P.setStdin (P.byteStringInput userpassBS) $ P.proc "cabal" ("upload" : "--documentation" : ["--publish" | publish] ++ [tarball]))
  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
newCmd :: Maybe String -> Maybe String -> IO ()
newCmd mlicense 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
      cabalversion <- readVersion <$> cmd "cabal" ["--numeric-version"]
      let license =
            case mlicense of
              Just l -> l
              Nothing ->
                if cabalversion > makeVersion [3,4]
                then "BSD-3-Clause"
                else "BSD3"
      cabal_ "init" ["--quiet", "--no-comments", "--non-interactive", "--is-libandexe", "--cabal-version=1.18", "--license=" ++ license, "--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:" +-+ license ++ "\\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 license
    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
    -- FIXME determine last lts automatically
    cmd_ "stack" ["init", "--verbosity", "warn", "--resolver", "lts-23"]
    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 -> String -> IO ()
    setupCabalTemplate name license = 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"
      replaceHolder "LICENSE" license $ 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

-- FIXME rename github remote too?
renameCmd :: String -> IO ()
renameCmd newname = do
  pkgid <- getPackageId
  let oldname = unPackageName (pkgName pkgid)
  sed ["s/" ++ oldname ++ "/" ++ newname ++ "/g", "s/Paths_" ++ replace "-" "_" oldname ++ "/Paths_" ++ replace "-" "_" newname ++ "/g"] $ oldname <.> "cabal"
  git_ "mv" [oldname <.> "cabal", newname <.> "cabal"]
  -- FIXME for all *.hs
  whenM (doesFileExist "src/Main.hs") $
    sed ["s/" ++ oldname ++ "/" ++ newname ++ "/g", "s/Paths_" ++ replace "-" "_" oldname ++ "/Paths_" ++ replace "-" "_" newname ++ "/g"] "src/Main.hs"
  sed ["s/" ++ oldname ++ "/" ++ newname ++ "/g"] "README.md"
  sed ["s/" ++ oldname ++ "/" ++ newname ++ "/g"] "ChangeLog.md"
  renameDirectory (".." </> oldname) (".." </> newname)

githubCmd :: IO ()
githubCmd = do
  ghuser <- cmdOut $ git "config" ["--global", "github.user"]
  pkgid <- getPackageId
  let name = unPackageName (pkgName pkgid)
  git_ "remote" ["add", "origin", "git@github.com:" ++ ghuser </> name <.> "git"]
  git_ "branch" ["-M", "main"]
--  git_ "push" ["-u", "origin", "main"]

-- pristine cabal sdist/build fail if there's a top cabal.project
addCabalProject :: IO ()
addCabalProject = do
  let cabalproject = "cabal.project"
  unlessM (doesFileExist cabalproject) $
    writeFile cabalproject "packages: .\n"

pristineBuildCmd :: IO ()
pristineBuildCmd = do
  needProgram "cabal"
  pkgid <- getPackageId
  cwd <- getCurrentDirectory
  let tarball = cwd </> sdistDir </> showPkgId pkgid <.> tarGzExt
  exists <- doesFileExist tarball
  unless exists $
    error' $ "Please 'tagdist' first:" +-+ tarball +-+ "not found"
  withTempDirectory "tmp-build" $ do
    cmd_ "tar" ["xf", tarball]
    setCurrentDirectory $ showPkgId pkgid
    addCabalProject
    putStrLn $ "cabal building" +-+ showPkgId pkgid <.> tarGzExt
    cabal_ "build" []