diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,48 @@
+## 0.3.0.0
+
+* Add proper set of dependency version constraints.
+* Use `optparse-applicative` to parse arguments.
+* Add support for comments and empty lines to scripts.
+* Parse ssh port from `PORT` environment variable.
+* Drop support for GHCs older than 7.10 (because Chris Done's `path` does
+  not compile with them, see: https://github.com/chrisdone/path/issues/46).
+* Now Hapistrano uses `hap.yaml` file for all its configuration.
+* Added the ability to copy arbitrary files and directories verbatim from
+  local machine to target host.
+
+## 0.2.1.2
+
+* Add change log (#23).
+* Add `README.md` to extra source files.
+* Handle missing environment variables more graciously.
+* Allow GHC 8 and base 4.9.
+
+## 0.2.1.1
+
+* Fix tests (#31).
+
+## 0.2.1
+
+* Use Stack (#17).
+* Clean up package (#20).
+* Fix tests (#25).
+
+## 0.2.0.2
+
+* GHC 7.10 support.
+
+## 0.2.0.1
+
+* Refactoring and documentation improvements.
+
+## 0.2.0.0
+
+* Various refactoring and relaxed dependency constraints.
+
+## 0.1.0.2
+
+* Print error messages to `stderr`, return non-zero exit code on failure.
+
+## 0.1.0.1
+
+* Initial release.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -22,59 +22,88 @@
 new directory quickly by placing a git repository for caching purposes
 on the remote server.
 
-When the build process completes, it switches a symlink to the
-'current' release directory, and optionally restarts the web server.
+When the build process completes, it switches a symlink to the `current`
+release directory, and optionally restarts the web server.
 
 By default, Hapistrano keeps the last five releases on the target host
 filesystem and deletes previous releases to avoid filling up the disk.
 
 ## Usage
 
-The deploy requires the following environment variables:
+Hapistrano 0.3.0.0 looks for a configuration file called `hap.yaml` that
+typically looks like this:
 
-* `DEPLOY_PATH` - The root of the deploy target on the remote host
-* `HOST` - The target host
-* `REPOSITORY` - The origin repository
-* `REVISION` - The SHA1 or branch to deploy. If a branch, you will need
-  to specify it as origin/branch_name due to the way that the cache
-  repo is configured.
+```yaml
+deploy_path: '/var/projects/my-project'
+host: myserver.com
+port: 2222
+repo: 'https://github.com/stackbuilders/hapistrano.git'
+revision: origin/master
+build_script:
+  - stack setup
+  - stack build
+restart_command: systemd restart my-app-service
+```
 
-The following environment variables are *optional* and affect the
-deploy process:
+The following parameters are required:
 
-* `BUILD_SCRIPT` - The local path to a file that should be executed on
-  the remote server to build the application. The script isn't
-  executed verbatim - instead, every line is joined with `&&` so that
-  the script aborts if any component fails. See a sample script for a
-  clean build of a Haskell/Cabal application in this project under
-  [script/clean-build.sh].
-* `RESTART_COMMAND` - If you need to restart a remote web server after a
-  successful deploy, specify the command that you use in this
-  variable. It will be run after both deploy and rollback.
+* `deploy_path` — the root of the deploy target on the remote host.
+* `repo` — the origin repository.
+* `revision` — the SHA1 or branch to deploy. If a branch, you will need to
+  specify it as `origin/branch_name` due to the way that the cache repo is
+  configured.
 
-You may want to save the environment variables that you need for your
-deploy in a shell script that you `source` before deploy. Make sure
-you `export` these variables so that they're available in the shell
-after you run the script. For example, you could use the following to
-configure your deploy:
+The following parameters are *optional*:
 
-    export DEPLOY_PATH="/var/project"
-    export HOST="my-app-staging"
-    export REPOSITORY="git@github.com:yourorg/yourrepo.com.git"
-    export REVISION="origin/staging"
-    export BUILD_SCRIPT="/home/you/Code/hapistrano/script/clean-build.sh"
-    export RESTART_COMMAND="echo Replace me with your restart command"
+* `host` — the target host, if missing, `localhost` will be assumed (which
+  is useful for testing and playing with `hap` locally).
+* `port` — SSH port number to use. If missing, 22 will be used.
+* `build_script` — instructions how to build the application in the form of
+  shell commands.
+* `restart_command` — if you need to restart a remote web server after a
+  successful rollback, specify the command that you use in this variable. It
+  will be run after both deploy and rollback.
 
-After creating a configuration script as above, deploying is as simple as:
+After creating a configuration file as above, deploying is as simple as:
 
-    source your-config-script.sh && hap deploy
+```bash
+$ hap deploy
+```
 
-# License
+Rollback is also trivial:
 
-MIT, see [the LICENSE file](LICENSE).
+```bash
+$ hap rollback # to rollback to previous successful deploy
+$ hap rollback -n 2 # go two deploys back in time, etc.
+```
 
+## What to do when compiling on server is not viable
 
-# Contributing
+Sometimes the target machine (server) is not capable of compiling your
+application because e.g. it has not enough memory and GHC exhausts it all.
+You can copy pre-compiled files from local machine or CI server using
+`copy_files` and `copy_dirs` parameters:
+
+```haskell
+copy_files:
+  - src: '/home/stackbuilders/my-file.txt'
+    dest: 'my-file.txt'
+copy_dirs:
+  - src: .stack-work
+    dest: .stack-work
+```
+
+`src` maybe absolute or relative, it's path to file or directory on local
+machine, `dest` may only be relative (it's expanded relatively to cloned
+repo) and specifies where to put the files/directories on target machine.
+Directories and files with clashing names will be overwritten. Directories
+are copied recursively.
+
+## License
+
+MIT, see [the LICENSE file](LICENSE).
+
+## Contributing
 
 Pull requests for modifications to this program are welcome. Fork and
 open a PR. Feel free to [email me](mailto:justin@stackbuilders.com) if
diff --git a/app/Config.hs b/app/Config.hs
new file mode 100644
--- /dev/null
+++ b/app/Config.hs
@@ -0,0 +1,67 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards   #-}
+
+module Config
+  ( Config (..)
+  , CopyThing (..) )
+where
+
+import Data.Aeson
+import Data.Yaml
+import Path
+import System.Hapistrano.Commands
+
+-- | Hapistrano configuration typically loaded from @hap.yaml@ file.
+
+data Config = Config
+  { configDeployPath :: !(Path Abs Dir)
+    -- ^ Top-level deploy directory on target machine
+  , configHost :: !(Maybe String)
+    -- ^ Host to deploy to. If missing, localhost will be assumed.
+  , configPort :: !Word
+    -- ^ SSH port number to use, may be omitted
+  , configRepo :: !String
+    -- ^ Location of repository that contains the source code to deploy
+  , configRevision :: !String
+    -- ^ Revision to use
+  , configRestartCommand :: !(Maybe GenericCommand)
+    -- ^ The command to execute when switching to a different release
+    -- (usually after a deploy or rollback).
+  , configBuildScript :: !(Maybe [GenericCommand])
+    -- ^ Build script to execute to build the project
+  , configCopyFiles :: ![CopyThing]
+    -- ^ Collection of files to copy over to target machine before building
+  , configCopyDirs :: ![CopyThing]
+    -- ^ Collection of directories to copy over to target machine before building
+  } deriving (Eq, Ord, Show)
+
+-- | Information about source and destination locations of a file\/directory
+-- to copy.
+
+data CopyThing = CopyThing FilePath FilePath
+  deriving (Eq, Ord, Show)
+
+instance FromJSON Config where
+  parseJSON = withObject "Hapistrano configuration" $ \o -> do
+    configDeployPath <- o .: "deploy_path"
+    configHost       <- o .:? "host"
+    configPort       <- o .:? "port" .!= 22
+    configRepo       <- o .: "repo"
+    configRevision   <- o .: "revision"
+    configRestartCommand <- (o .:? "restart_command") >>=
+      maybe (return Nothing) (fmap Just . mkCmd)
+    configBuildScript <- o .:? "build_script" >>=
+      maybe (return Nothing) (fmap Just . mapM mkCmd)
+    configCopyFiles  <- o .:? "copy_files" .!= []
+    configCopyDirs   <- o .:? "copy_dirs"  .!= []
+    return Config {..}
+
+instance FromJSON CopyThing where
+  parseJSON = withObject "src and dest of a thing to copy" $ \o ->
+    CopyThing <$> (o .: "src") <*> (o .: "dest")
+
+mkCmd :: String -> Parser GenericCommand
+mkCmd raw =
+  case mkGenericCommand raw of
+    Nothing -> fail "invalid restart command"
+    Just cmd -> return cmd
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -1,147 +1,137 @@
+{-# LANGUAGE CPP             #-}
 {-# LANGUAGE RecordWildCards #-}
 
 module Main (main) where
 
+import Control.Monad
+import Data.Monoid ((<>))
+import Data.Version (showVersion)
+import Numeric.Natural
+import Options.Applicative
+import Path
+import Path.IO
+import Paths_hapistrano (version)
+import System.Exit
+import System.Hapistrano.Types
+import qualified Config as C
+import qualified Data.Yaml as Yaml
 import qualified System.Hapistrano as Hap
-import Control.Monad (void)
-import System.Environment.Compat (lookupEnv)
-
-import System.Hapistrano (ReleaseFormat(..))
-
-import qualified Control.Monad as Monad
-import qualified Data.Maybe as Maybe
-import qualified System.Console.GetOpt as GetOpt
-import qualified System.Environment as Environment
-import qualified System.Exit as Exit
-import qualified System.Exit.Compat as Exit
-import qualified System.IO as IO
-
--- | Rolls back to previous release.
-rollback :: Hap.Config -> IO ()
-rollback cfg =
-  Hap.runRC errorHandler successHandler cfg $ do
-
-    _ <- Hap.rollback
-    void Hap.restartServerCommand
-
-  where
-    errorHandler   = Hap.defaultErrorHandler
-    successHandler = Hap.defaultSuccessHandler
-
--- | Deploys the current release with Config options.
-deploy :: Hap.Config -> IO ()
-deploy cfg =
-  Hap.runRC errorHandler successHandler cfg $ do
-    _ <- Hap.pushRelease >>= Hap.runBuild >>= Hap.activateRelease
-
-    void Hap.restartServerCommand
-
-  where
-    errorHandler   = Hap.defaultErrorHandler
-    successHandler = Hap.defaultSuccessHandler
-
--- | Retrieves the configuration from environment variables.
-configFromEnv :: IO Hap.Config
-configFromEnv = do
-  maybeDeployPath <- lookupEnv "DEPLOY_PATH"
-  maybeRepository <- lookupEnv "REPOSITORY"
-  maybeRevision <- lookupEnv "REVISION"
-
-  deployPath <- maybe (Exit.die (noEnv "DEPLOY_PATH")) return maybeDeployPath
-  repository <- maybe (Exit.die (noEnv "REPOSITORY")) return maybeRepository
-  revision <- maybe (Exit.die (noEnv "REVISION")) return maybeRevision
-
-  host           <- lookupEnv "HOST"
-  buildScript    <- lookupEnv "BUILD_SCRIPT"
-  restartCommand <- lookupEnv "RESTART_COMMAND"
-
-  return Hap.Config { Hap.deployPath     = deployPath
-                    , Hap.host           = host
-                    , Hap.releaseFormat  = Short
-                    , Hap.repository     = repository
-                    , Hap.revision       = revision
-                    , Hap.buildScript    = buildScript
-                    , Hap.restartCommand = restartCommand
-                    }
-  where
-    noEnv env = env ++ " environment variable does not exist"
-
-data HapCommand
-  = HapDeploy
-  | HapRollback
-  deriving Show
+import qualified System.Hapistrano.Core as Hap
 
-parseHapCommand :: String -> Maybe HapCommand
-parseHapCommand "deploy" = Just HapDeploy
-parseHapCommand "rollback" = Just HapRollback
-parseHapCommand _ = Nothing
+#if !MIN_VERSION_base(4,8,0)
+import Control.Applicative
+#endif
 
-data HapOptions =
-  HapOptions
-    { hapCommand :: Maybe HapCommand
-    , hapHelp :: Bool
-    }
-  deriving Show
+----------------------------------------------------------------------------
+-- Command line options
 
-defaultHapOptions :: HapOptions
-defaultHapOptions =
-  HapOptions
-    { hapCommand = Nothing
-    , hapHelp = False
-    }
+-- | Command line options.
 
-hapOptionDescriptions :: [GetOpt.OptDescr (HapOptions -> HapOptions)]
-hapOptionDescriptions =
-  [ GetOpt.Option
-      ['h']
-      ["help"]
-      (GetOpt.NoArg (\hapOptions -> hapOptions { hapHelp = True }))
-      "Show this help text"
+data Opts = Opts
+  { optsCommand :: Command
+  , optsVersion :: Bool
+  , optsConfigFile :: FilePath
+  }
 
-  ]
+-- | Command to execute and command-specific options.
 
-parseHapOptions :: [String] -> Either String HapOptions
-parseHapOptions args =
-  case GetOpt.getOpt GetOpt.Permute hapOptionDescriptions args of
-    (options, [], []) ->
-      Right (foldl (flip id) defaultHapOptions options)
+data Command
+  = Deploy ReleaseFormat Natural -- ^ Deploy a new release (with timestamp
+    -- format and how many releases to keep)
+  | Rollback Natural -- ^ Rollback to Nth previous release
 
-    (options, [command], []) ->
-      case parseHapCommand command of
-        Nothing ->
-          Left ("Invalid argument: " ++ command)
+parserInfo :: ParserInfo Opts
+parserInfo = info (helper <*> optionParser)
+  ( fullDesc <>
+    progDesc "Deploy tool for Haskell applications" <>
+    header "Hapistrano - A deployment library for Haskell applications" )
 
-        maybeHC ->
-          Right (foldl (flip id) defaultHapOptions {hapCommand = maybeHC} options)
+optionParser :: Parser Opts
+optionParser = Opts
+  <$> subparser
+  ( command "deploy"
+    (info deployParser (progDesc "Deploy a new release")) <>
+    command "rollback"
+    (info rollbackParser (progDesc "Roll back to Nth previous release")) )
+  <*> switch
+  ( long "version"
+  <> short 'v'
+  <> help "Show version of the program" )
+  <*> strOption
+  ( long "config"
+  <> short 'c'
+  <> value "hap.yaml"
+  <> metavar "PATH"
+  <> showDefault
+  <> help "Configuration file to use" )
 
-    _ ->
-      Left "First argument must be either 'deploy' or 'rollback'."
+deployParser :: Parser Command
+deployParser = Deploy
+  <$> option pReleaseFormat
+  ( long "release-format"
+  <> short 'r'
+  <> value ReleaseShort
+  <> help "Which format release timestamp format to use: ‘long’ or ‘short’, default is ‘short’." )
+  <*> option auto
+  ( long "keep-releases"
+  <> short 'k'
+  <> value 5
+  <> showDefault
+  <> help "How many releases to keep" )
 
-hapHelpAction :: Maybe HapCommand -> IO ()
-hapHelpAction _ =
-  putStrLn hapUsage >> Exit.exitSuccess
+rollbackParser :: Parser Command
+rollbackParser = Rollback
+  <$> option auto
+  ( long "use-nth"
+  <> short 'n'
+  <> value 1
+  <> showDefault
+  <> help "How many deployments back to go?" )
 
-hapUsage :: String
-hapUsage =
-  GetOpt.usageInfo hapUsageHeader hapOptionDescriptions
+pReleaseFormat :: ReadM ReleaseFormat
+pReleaseFormat = eitherReader $ \s ->
+  case s of
+    "long"  -> Right ReleaseLong
+    "short" -> Right ReleaseShort
+    _       -> Left ("Unknown format: " ++ s ++ ", try ‘long’ or ‘short’.")
 
-hapUsageHeader :: String
-hapUsageHeader =
-  "usage: hap [-h | --help] <command>\n"
+----------------------------------------------------------------------------
+-- Main
 
 main :: IO ()
 main = do
-  eitherHapOptions <- fmap parseHapOptions Environment.getArgs
-
-  HapOptions{..} <- either Exit.die return eitherHapOptions
-
-  Monad.when hapHelp (hapHelpAction hapCommand)
-
-  hapConfiguration <- configFromEnv
-
-  case hapCommand of
-    Just HapDeploy -> deploy hapConfiguration
-
-    Just HapRollback -> rollback hapConfiguration
+  Opts {..} <- execParser parserInfo
+  when optsVersion $ do
+    putStrLn $ "Hapistrano " ++ showVersion version
+    exitSuccess
 
-    Nothing -> hapHelpAction Nothing
+  econfig <- Yaml.decodeFileEither optsConfigFile
+  case econfig of
+    Left err -> do
+      putStrLn (Yaml.prettyPrintParseException err)
+      exitFailure
+    Right C.Config {..} ->
+      Hap.runHapistrano (SshOptions <$> configHost <*> pure configPort) $ case optsCommand of
+        Deploy releaseFormat n -> do
+          release <- Hap.pushRelease Task
+            { taskDeployPath    = configDeployPath
+            , taskRepository    = configRepo
+            , taskRevision      = configRevision
+            , taskReleaseFormat = releaseFormat }
+          rpath <- Hap.releasePath configDeployPath release
+          forM_ configCopyFiles $ \(C.CopyThing src dest) -> do
+            srcPath  <- resolveFile' src
+            destPath <- parseRelFile dest
+            Hap.scpFile srcPath (rpath </> destPath)
+          forM_ configCopyDirs $ \(C.CopyThing src dest) -> do
+            srcPath  <- resolveDir' src
+            destPath <- parseRelDir dest
+            Hap.scpDir srcPath (rpath </> destPath)
+          forM_ configBuildScript (Hap.playScript configDeployPath release)
+          forM_ configRestartCommand Hap.exec
+          Hap.registerReleaseAsComplete configDeployPath release
+          Hap.activateRelease configDeployPath release
+          Hap.dropOldReleases configDeployPath n
+        Rollback n -> do
+          Hap.rollback configDeployPath n
+          forM_ configRestartCommand Hap.exec
diff --git a/changes.md b/changes.md
deleted file mode 100644
--- a/changes.md
+++ /dev/null
@@ -1,13 +0,0 @@
-## Unreleased changes
-
-* Add change log (#23).
-
-## 0.2.1.1
-
-* Fix tests (#31).
-
-## 0.2.1
-
-* Use Stack (#17).
-* Clean up package (#20).
-* Fix tests (#25).
diff --git a/hapistrano.cabal b/hapistrano.cabal
--- a/hapistrano.cabal
+++ b/hapistrano.cabal
@@ -1,5 +1,5 @@
 name:                hapistrano
-version:             0.2.1.2
+version:             0.3.0.0
 synopsis:            A deployment library for Haskell applications
 description:
   .
@@ -22,55 +22,78 @@
 license-file:        LICENSE
 author:              Justin Leitgeb
 maintainer:          justin@stackbuilders.com
-copyright:           2015 Stack Builders Inc.
+copyright:           2015-2017 Stack Builders Inc.
 category:            System
 homepage:            https://github.com/stackbuilders/hapistrano
 bug-reports:         https://github.com/stackbuilders/hapistrano/issues
 build-type:          Simple
 cabal-version:       >=1.10
-extra-source-files:  changes.md README.md
+extra-source-files:  CHANGELOG.md
+                   , README.md
+data-files:          script/clean-build.sh
 
+flag dev
+  description:        Turn on development settings.
+  manual:             True
+  default:            False
+
 library
   hs-source-dirs:      src
   exposed-modules:     System.Hapistrano
-  other-modules:       System.Hapistrano.Types
-  build-depends:       base >= 4.5 && < 4.10
-                     , either
-                     , filepath
-                     , mtl
-                     , process
-                     , time-locale-compat
-                     , time
-                     , transformers
-  ghc-options:         -Wall
+                     , System.Hapistrano.Commands
+                     , System.Hapistrano.Core
+                     , System.Hapistrano.Types
+  build-depends:       base               >= 4.6 && < 5.0
+                     , filepath           >= 1.2 && < 1.5
+                     , mtl                >= 2.0 && < 3.0
+                     , path               >= 0.5 && < 6.0
+                     , process            >= 1.4 && < 1.5
+                     , time               >= 1.5 && < 1.8
+                     , transformers       >= 0.4 && < 0.6
+  if flag(dev)
+    ghc-options:       -Wall -Werror
+  else
+    ghc-options:       -O2 -Wall
   default-language:    Haskell2010
 
 executable hap
   hs-source-dirs:      app
   main-is:             Main.hs
-  build-depends:       base
+  other-modules:       Config
+  build-depends:       aeson              >= 0.11 && < 1.2
+                     , base               >= 4.6 && < 5.0
                      , hapistrano
-                     , base-compat
-  ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N
+                     , optparse-applicative >= 0.11 && < 0.14
+                     , path               >= 0.5.8 && < 6.0
+                     , path-io            >= 1.2 && < 1.3
+                     , yaml               >= 0.8 && < 0.9
+  if flag(dev)
+    ghc-options:       -threaded -rtsopts -with-rtsopts=-N -Wall -Werror
+  else
+    ghc-options:       -threaded -rtsopts -with-rtsopts=-N -O2 -Wall
   default-language:    Haskell2010
 
-test-suite hapistrano-test
+test-suite test
   type:                exitcode-stdio-1.0
   hs-source-dirs:      spec
   main-is:             Spec.hs
   other-modules:       System.HapistranoSpec
-  build-depends:       base
+  build-depends:       base               >= 4.5 && < 5.0
+                     , directory          >= 1.2.2 && < 1.4
+                     , filepath           >= 1.2 && < 1.5
                      , hapistrano
-                     , directory
-                     , either
-                     , filepath
-                     , hspec
-                     , mtl
-                     , process
-                     , temporary
-  ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N
+                     , hspec              >= 2.0 && < 3.0
+                     , mtl                >= 2.0 && < 3.0
+                     , path               >= 0.5 && < 6.0
+                     , path-io            >= 1.2 && < 1.3
+                     , process            >= 1.4 && < 1.5
+                     , temporary          >= 1.1 && < 1.3
+  if flag(dev)
+    ghc-options:       -threaded -rtsopts -with-rtsopts=-N -Wall -Werror
+  else
+    ghc-options:       -threaded -rtsopts -with-rtsopts=-N -O2 -Wall
   default-language:    Haskell2010
 
 source-repository head
   type:     git
-  location: https://github.com/stackbuilders/hapistrano
+  location: https://github.com/stackbuilders/hapistrano.git
diff --git a/script/clean-build.sh b/script/clean-build.sh
new file mode 100644
--- /dev/null
+++ b/script/clean-build.sh
@@ -0,0 +1,9 @@
+# This is a comment
+export PATH=~/.cabal/bin:/usr/local/bin:$PATH
+ 
+cabal sandbox delete # kill it with fire!
+cabal sandbox init
+cabal clean
+cabal update
+cabal install --only-dependencies -j
+cabal build -j
diff --git a/spec/System/HapistranoSpec.hs b/spec/System/HapistranoSpec.hs
--- a/spec/System/HapistranoSpec.hs
+++ b/spec/System/HapistranoSpec.hs
@@ -1,213 +1,158 @@
-module System.HapistranoSpec (spec) where
-
-import Test.Hspec (it, describe, shouldBe, Spec)
-
-import System.IO.Temp (withSystemTempDirectory)
-
-import System.Directory (getDirectoryContents)
-import Control.Monad (void, replicateM_)
-import Control.Monad.Trans.Either (runEitherT)
-
-import Control.Monad.Reader (ReaderT(..))
+{-# LANGUAGE TemplateHaskell #-}
 
-import System.FilePath.Posix (joinPath)
+module System.HapistranoSpec
+  ( spec )
+where
 
+import Control.Monad
+import Control.Monad.Reader
+import Path
+import Path.IO
+import System.Hapistrano.Types
+import Test.Hspec hiding (shouldBe, shouldReturn)
 import qualified System.Hapistrano as Hap
-import Data.List (intercalate, sort)
-
-import qualified System.IO as IO
-import qualified System.Process as Process
-
-runCommand :: String -> IO ()
-runCommand command = do
-  putStrLn ("GIT running: " ++ command)
-  let process = Process.shell command
-  (_, Just outHandle, Just errHandle, processHandle) <-
-    Process.createProcess process { Process.std_err = Process.CreatePipe
-                                  , Process.std_in = Process.CreatePipe
-                                  , Process.std_out = Process.CreatePipe
-                                  }
-  exitCode <- fmap show (Process.waitForProcess processHandle)
-  out <- IO.hGetContents outHandle
-  err <- IO.hGetContents errHandle
-  putStrLn ("GIT res: " ++ show (exitCode, out, err))
-
--- | Generate a source git repo as test fixture. Push an initial commit
--- to the bare repo by making a clone and committing a trivial change and
--- pushing to the bare repo.
-genSourceRepo :: FilePath -> IO FilePath
-genSourceRepo path = do
-  let fullRepoPath = joinPath [path, "testRepo"]
-      clonePath    = joinPath [path, "testRepoClone"]
-
-      gitConfigReplace =
-        intercalate
-          " && "
-          [ "git config --local --replace-all push.default simple"
-          , "git config --local --replace-all user.email hap@hap"
-          , "git config --local --replace-all user.name Hap"
-          ]
-
-      gitConfigUnset =
-        intercalate
-          " && "
-          [ "git config --local --unset push.default"
-          , "git config --local --unset user.email"
-          , "git config --local --unset user.name"
-          ]
-
-      commands =
-        [ "mkdir -p " ++ fullRepoPath
-        , "git init --bare " ++ fullRepoPath
-        , "git clone " ++ fullRepoPath ++ " " ++ clonePath
-        , "echo testing > " ++ joinPath [clonePath, "README"]
-        , "cd " ++ clonePath ++ " && " ++ gitConfigReplace
-        , "cd " ++ clonePath ++ " && git add -A"
-        , "cd " ++ clonePath ++ " && git commit -m\"First commit\""
-        , "cd " ++ clonePath ++ " && git push"
-        , "cd " ++ clonePath ++ " && " ++ gitConfigUnset
-        ]
-
-  mapM_ runCommand commands
-
-  return fullRepoPath
-
-rollback :: Hap.Config -> IO ()
-rollback cfg =
-  Hap.runRC errorHandler successHandler cfg $ do
-
-    _ <- Hap.rollback
-    void Hap.restartServerCommand
-
-  where
-    errorHandler   = Hap.defaultErrorHandler
-    successHandler = Hap.defaultSuccessHandler
-
-
--- | Deploys the current release with Config options.
-deployOnly :: Hap.Config -> IO ()
-deployOnly cfg =
-  Hap.runRC errorHandler successHandler cfg $ void Hap.pushRelease
-
-  where
-    errorHandler   = Hap.defaultErrorHandler
-    successHandler = Hap.defaultSuccessHandler
-
--- | Deploys the current release with Config options.
-deployAndActivate :: Hap.Config -> IO ()
-deployAndActivate cfg =
-  Hap.runRC errorHandler successHandler cfg $ do
-    rel <- Hap.pushRelease
-    _ <- Hap.runBuild rel
-
-    void $ Hap.activateRelease rel
-
-  where
-    errorHandler   = Hap.defaultErrorHandler
-    successHandler = Hap.defaultSuccessHandler
-
-defaultState :: FilePath -> FilePath -> Hap.Config
-defaultState tmpDir testRepo =
-  Hap.Config { Hap.deployPath     = tmpDir
-             , Hap.host           = Nothing
-             , Hap.repository     = testRepo
-             , Hap.releaseFormat  = Hap.Long
-             , Hap.revision       = "master"
-             , Hap.buildScript    = Nothing
-             , Hap.restartCommand = Nothing
-             }
-
--- | The 'fromRight' function extracts the element out of a 'Right' and
--- throws an error if its argument take the form  @Left _@.
-fromRight           :: Either a b -> b
-fromRight (Left _)  = error "fromRight: Argument takes form 'Left _'" -- yuck
-fromRight (Right x) = x
+import qualified System.Hapistrano.Commands as Hap
+import qualified System.Hapistrano.Core as Hap
+import qualified Test.Hspec as Hspec
 
 spec :: Spec
-spec = describe "hapistrano" $ do
-  describe "readCurrentLink" $
-    it "trims trailing whitespace" $
-      withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
-
-        testRepoPath <- genSourceRepo tmpDir
-
-        deployAndActivate $ defaultState tmpDir testRepoPath
-
-        ltarget <-
-          runReaderT (runEitherT Hap.readCurrentLink) $
-          defaultState tmpDir testRepoPath
-
-        last (fromRight ltarget) /= '\n' `shouldBe` True
-
-  describe "deploying" $ do
-    it "a simple deploy" $
-      withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
-
-        testRepoPath <- genSourceRepo tmpDir
-
-        deployOnly $ defaultState tmpDir testRepoPath
-
-        contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
-        length (filter (Hap.isReleaseString Hap.Long) contents) `shouldBe` 1
-
-    it "activates the release" $
-      withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
-
-        testRepoPath <- genSourceRepo tmpDir
-
-        deployAndActivate $ defaultState tmpDir testRepoPath
+spec = do
+  describe "readScript" $
+    it "preforms all the necessary normalizations correctly" $ do
+      spath <- makeAbsolute $(mkRelFile "script/clean-build.sh")
+      (fmap Hap.unGenericCommand <$> Hap.readScript spath)
+        `Hspec.shouldReturn`
+        [ "export PATH=~/.cabal/bin:/usr/local/bin:$PATH"
+        , "cabal sandbox delete"
+        , "cabal sandbox init"
+        , "cabal clean"
+        , "cabal update"
+        , "cabal install --only-dependencies -j"
+        , "cabal build -j" ]
 
-        contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
-        length (filter (Hap.isReleaseString Hap.Long) contents) `shouldBe` 1
+  around withSandbox $ do
+    describe "pushRelease" $
+      it "sets up repo all right" $ \(deployPath, repoPath) -> runHap $ do
+        let task = mkTask deployPath repoPath
+        release <- Hap.pushRelease task
+        rpath   <- Hap.releasePath deployPath release
+        -- let's check that the dir exists and contains the right files
+        (liftIO . readFile . fromAbsFile) (rpath </> $(mkRelFile "foo.txt"))
+          `shouldReturn` "Foo!\n"
 
-    it "cleans up old releases" $
-      withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
-        testRepoPath <- genSourceRepo tmpDir
+    describe "registerReleaseAsComplete" $
+      it "creates the token all right" $ \(deployPath, repoPath) -> runHap $ do
+        let task = mkTask deployPath repoPath
+        release <- Hap.pushRelease task
+        Hap.registerReleaseAsComplete deployPath release
+        (Hap.ctokenPath deployPath release >>= doesFileExist) `shouldReturn` True
 
-        replicateM_ 7 $ deployAndActivate $ defaultState tmpDir testRepoPath
+    describe "activateRelease" $
+      it "creates the ‘current’ symlink correctly" $ \(deployPath, repoPath) -> runHap $ do
+        let task = mkTask deployPath repoPath
+        release <- Hap.pushRelease task
+        Hap.activateRelease deployPath release
+        rpath <- Hap.releasePath deployPath release
+        let rc :: Hap.Readlink Dir
+            rc = Hap.Readlink (Hap.currentSymlinkPath deployPath)
+        Hap.exec rc `shouldReturn` rpath
+        doesFileExist (Hap.tempSymlinkPath deployPath) `shouldReturn` False
 
-        contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
-        length (filter (Hap.isReleaseString Hap.Long) contents) `shouldBe` 5
+    describe "rollback" $ do
+      context "without completion tokens" $
+        it "resets the ‘current’ symlink correctly" $ \(deployPath, repoPath) -> runHap $ do
+          let task = mkTask deployPath repoPath
+          rs <- replicateM 5 (Hap.pushRelease task)
+          Hap.rollback deployPath 2
+          rpath <- Hap.releasePath deployPath (rs !! 2)
+          let rc :: Hap.Readlink Dir
+              rc = Hap.Readlink (Hap.currentSymlinkPath deployPath)
+          Hap.exec rc `shouldReturn` rpath
+          doesFileExist (Hap.tempSymlinkPath deployPath) `shouldReturn` False
+      context "with completion tokens" $
+        it "resets the ‘current’ symlink correctly" $ \(deployPath, repoPath) -> runHap $ do
+          let task = mkTask deployPath repoPath
+          rs <- replicateM 5 (Hap.pushRelease task)
+          forM_ (take 3 rs) (Hap.registerReleaseAsComplete deployPath)
+          Hap.rollback deployPath 2
+          rpath <- Hap.releasePath deployPath (rs !! 0)
+          let rc :: Hap.Readlink Dir
+              rc = Hap.Readlink (Hap.currentSymlinkPath deployPath)
+          Hap.exec rc `shouldReturn` rpath
+          doesFileExist (Hap.tempSymlinkPath deployPath) `shouldReturn` False
 
-  describe "rollback" $
-    it "rolls back to the previous release" $
-      withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
+    describe "dropOldReleases" $
+      it "works" $ \(deployPath, repoPath) -> runHap $ do
+        let task  = mkTask deployPath repoPath
+        rs <- replicateM 7 (Hap.pushRelease task)
+        Hap.dropOldReleases deployPath 5
+        -- two oldest releases should not survive:
+        forM_ (take 2 rs) $ \r ->
+          (Hap.releasePath deployPath r >>= doesDirExist)
+            `shouldReturn` False
+        -- 5 most recent releases should stay alive:
+        forM_ (drop 2 rs) $ \r ->
+          (Hap.releasePath deployPath r >>= doesDirExist)
+            `shouldReturn` True
 
-        testRepoPath <- genSourceRepo tmpDir
-        let deployState = defaultState tmpDir testRepoPath
+----------------------------------------------------------------------------
+-- Helpers
 
-        deployAndActivate deployState
+infix 1 `shouldBe`, `shouldReturn`
 
-        -- current symlink should point to the last release directory
-        contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
+-- | Lifted 'Hspec.shouldBe'.
 
-        let firstRelease = head $ filter (Hap.isReleaseString Hap.Long) contents
+shouldBe :: (MonadIO m, Show a, Eq a) => a -> a -> m ()
+shouldBe x y = liftIO (x `Hspec.shouldBe` y)
 
-        firstReleaseLinkTarget <-
-          runReaderT (runEitherT Hap.readCurrentLink) deployState
+-- | Lifted 'Hspec.shouldReturn'.
 
-        firstRelease `shouldBe` Hap.pathToRelease (fromRight firstReleaseLinkTarget)
+shouldReturn :: (MonadIO m, Show a, Eq a) => m a -> a -> m ()
+shouldReturn m y = m >>= (`shouldBe` y)
 
-        -- deploy a second version
-        deployAndActivate deployState
+-- | The sandbox prepares the environment for an independent round of
+-- testing. It provides two paths: deploy path and path where git repo is
+-- located.
 
-        -- current symlink should point to second release
+withSandbox :: ActionWith (Path Abs Dir, Path Abs Dir) -> IO ()
+withSandbox action = withSystemTempDir "hap-test" $ \dir -> do
+  let dpath = dir </> $(mkRelDir "deploy")
+      rpath = dir </> $(mkRelDir "repo")
+  ensureDir dpath
+  ensureDir rpath
+  populateTestRepo rpath
+  action (dpath, rpath)
 
-        conts <- getDirectoryContents (joinPath [tmpDir, "releases"])
+-- | Given path where to put the repo, generate it for testing.
 
-        let secondRelease =
-              sort (filter (Hap.isReleaseString Hap.Long) conts) !! 1
+populateTestRepo :: Path Abs Dir -> IO ()
+populateTestRepo path = runHap $ do
+  justExec path "git init"
+  justExec path "git config --local --replace-all push.default simple"
+  justExec path "git config --local --replace-all user.email   hap@hap"
+  justExec path "git config --local --replace-all user.name    Hap"
+  justExec path "echo 'Foo!' > foo.txt"
+  justExec path "git add -A"
+  justExec path "git commit -m 'Initial commit'"
 
-        secondReleaseLinkTarget <-
-          runReaderT (runEitherT Hap.readCurrentLink) deployState
+-- | Execute arbitrary commands in the specified directory.
 
-        secondRelease `shouldBe` Hap.pathToRelease (fromRight secondReleaseLinkTarget)
+justExec :: Path Abs Dir -> String -> Hapistrano ()
+justExec path cmd' =
+  case Hap.mkGenericCommand cmd' of
+    Nothing -> Hap.failWith 1 (Just $ "Failed to parse the command: " ++ cmd')
+    Just cmd -> Hap.exec (Hap.Cd path cmd)
 
-        -- roll back, and current symlink should point to first release again
+-- | Run 'Hapistrano' monad locally.
 
-        rollback deployState
+runHap :: Hapistrano a -> IO a
+runHap = Hap.runHapistrano Nothing
 
-        afterRollbackLinkTarget <-
-          runReaderT (runEitherT Hap.readCurrentLink) deployState
+-- | Make a 'Task' given deploy path and path to the repo.
 
-        Hap.pathToRelease (fromRight afterRollbackLinkTarget) `shouldBe` firstRelease
+mkTask :: Path Abs Dir -> Path Abs Dir -> Task
+mkTask deployPath repoPath = Task
+  { taskDeployPath    = deployPath
+  , taskRepository    = fromAbsDir repoPath
+  , taskRevision      = "master"
+  , taskReleaseFormat = ReleaseLong }
diff --git a/src/System/Hapistrano.hs b/src/System/Hapistrano.hs
--- a/src/System/Hapistrano.hs
+++ b/src/System/Hapistrano.hs
@@ -1,424 +1,268 @@
-{-# LANGUAGE OverloadedStrings #-}
-
--- | A module for easily creating reliable deploy processes for Haskell
--- applications.
-module System.Hapistrano
-       ( Config(..)
-       , ReleaseFormat(..)
-
-       , activateRelease
-       , currentPath
-       , defaultSuccessHandler
-       , defaultErrorHandler
-       , directoryExists
-       , isReleaseString
-       , pathToRelease
-       , pushRelease
-       , readCurrentLink
-       , restartServerCommand
-       , rollback
-       , runRC
-       , runBuild
-
-       ) where
+-- |
+-- Module      :  System.Hapistrano
+-- Copyright   :  © 2015-2017 Stack Builders
+-- License     :  MIT
+--
+-- Maintainer  :  Justin Leitgeb <justin@stackbuilders.com>
+-- Stability   :  experimental
+-- Portability :  portable
+--
+-- A module for creating reliable deploy processes for Haskell applications.
 
-import Control.Monad.Reader (ReaderT(..), ask)
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE RecordWildCards     #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell     #-}
 
+module System.Hapistrano
+  ( pushRelease
+  , registerReleaseAsComplete
+  , activateRelease
+  , rollback
+  , dropOldReleases
+  , playScript
+    -- * Path helpers
+  , releasePath
+  , currentSymlinkPath
+  , tempSymlinkPath
+  , ctokenPath )
+where
 
+import Control.Monad
+import Control.Monad.Except
+import Data.List (genericDrop, dropWhileEnd, sortBy)
+import Data.Maybe (mapMaybe)
+import Data.Ord (comparing, Down (..))
+import Data.Time
+import Numeric.Natural
+import Path
+import System.Hapistrano.Commands
+import System.Hapistrano.Core
 import System.Hapistrano.Types
-  (Config(..), FailureResult, Hapistrano, Release, ReleaseFormat(..))
 
-import Control.Monad (unless, void)
-import System.Exit (ExitCode(..), exitWith)
-
-import Control.Monad.IO.Class (MonadIO(liftIO))
-import Control.Monad.Trans.Either ( left
-                                  , right
-                                  , eitherT )
-
-import Data.Char (isNumber)
-import Data.List (intercalate, sortBy, isInfixOf)
-import Data.Time (getCurrentTime)
-import Data.Time.Format (formatTime)
-import Data.Time.Locale.Compat (defaultTimeLocale)
-import System.FilePath.Posix (joinPath, splitPath)
-import System.IO (hPutStrLn, stderr)
-import System.Process (readProcessWithExitCode)
-
-import qualified System.IO as IO
-import qualified System.Process as Process
-
--- | Does basic project setup for a project, including making sure
--- some directories exist, and pushing a new release directory with the
--- SHA1 or branch specified in the configuration.
-pushRelease :: Hapistrano Release
-pushRelease = setupDirs >> ensureRepositoryPushed >> updateCacheRepo >>
-              cleanReleases >> cloneToRelease >>= setReleaseRevision
+----------------------------------------------------------------------------
+-- High-level functionality
 
--- | Switches the current symlink to point to the release specified in
--- the configuration. Maybe used in either deploy or rollback cases.
-activateRelease :: Release -> Hapistrano String
-activateRelease rel = removeCurrentSymlink >> symlinkCurrent rel
+-- | Perform basic setup for a project, making sure necessary directories
+-- exist and pushing a new release directory with the SHA1 or branch
+-- specified in the configuration. Return identifier of the pushed release.
 
--- | Runs the deploy, along with an optional success or failure function.
-runRC :: ((Int, String) -> ReaderT Config IO a) -- ^ Error handler
-      -> (a -> ReaderT Config IO a)             -- ^ Success handler
-      -> Config                  -- ^ Hapistrano deployment configuration
-      -> Hapistrano a            -- ^ The remote command to run
-      -> IO a
-runRC errorHandler successHandler config command =
-    runReaderT (eitherT errorHandler successHandler command) config
+pushRelease :: Task -> Hapistrano Release
+pushRelease Task {..} = do
+  setupDirs taskDeployPath
+  ensureCacheInPlace taskRepository taskDeployPath
+  release <- newRelease taskReleaseFormat
+  cloneToRelease taskDeployPath release
+  setReleaseRevision taskDeployPath release taskRevision
+  return release
 
--- | Default method to run on deploy failure. Emits a failure message
--- and exits with a status code of 1.
-defaultErrorHandler :: FailureResult -> ReaderT Config IO ()
-defaultErrorHandler res =
-  liftIO $ hPutStrLn stderr
-  ("Deploy failed with (status, message): " ++ show res)
-  >> exitWith (ExitFailure 1)
+-- | Create a file-token that will tell rollback function that this release
+-- should be considered successfully compiled\/completed.
 
--- | Default method to run on deploy success.
-defaultSuccessHandler :: a -> ReaderT Config IO ()
-defaultSuccessHandler _ =
-  liftIO $ putStrLn "Deploy completed successfully."
+registerReleaseAsComplete
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Release           -- ^ Release identifier to activate
+  -> Hapistrano ()
+registerReleaseAsComplete deployPath release = do
+  cpath <- ctokenPath deployPath release
+  exec (Touch cpath)
 
+-- | Switch the current symlink to point to the specified release. May be
+-- used in deploy or rollback cases.
 
--- | Creates necessary directories for the hapistrano project. Should
--- only need to run the first time the project is deployed on a given
--- system.
-setupDirs :: Hapistrano ()
-setupDirs = do
-  conf <- ask
+activateRelease
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Release           -- ^ Release identifier to activate
+  -> Hapistrano ()
+activateRelease deployPath release = do
+  rpath <- releasePath deployPath release
+  let tpath = tempSymlinkPath deployPath
+      cpath = currentSymlinkPath deployPath
+  exec (Ln rpath tpath) -- create a symlink for the new candidate
+  exec (Mv tpath cpath) -- atomically replace the symlink
 
-  mapM_ (runCommand (host conf))
-    ["mkdir -p " ++ releasesPath conf, "mkdir -p " ++ cacheRepoPath conf]
+-- | Activates one of already deployed releases.
 
-directoryExists :: Maybe String -> FilePath -> IO Bool
-directoryExists hst path = do
-  let (command, args) = case hst of
-        Just h  -> ("ssh", [h, "ls", path])
-        Nothing -> ("ls", [path])
+rollback
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Natural           -- ^ How many releases back to go, 0 re-activates current
+  -> Hapistrano ()
+rollback deployPath n = do
+  crs <- completedReleases deployPath
+  drs <- deployedReleases  deployPath
+  -- NOTE If we don't have any completed releases, then perhaps the
+  -- application was used with older versions of Hapistrano that did not
+  -- have this functionality. We then fall back and use collection of “just”
+  -- deployed releases.
+  case genericDrop n (if null crs then drs else crs) of
+    [] -> failWith 1 (Just "Could not find the requested release to rollback to.")
+    (x:_) -> activateRelease deployPath x
 
-  (code, _, _) <- readProcessWithExitCode command args ""
+-- | Remove older releases to avoid filling up the target host filesystem.
 
-  return $ case code of
-    ExitSuccess   -> True
-    ExitFailure _ -> False
+dropOldReleases
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Natural           -- ^ How many releases to keep
+  -> Hapistrano ()     -- ^ Deleted Releases
+dropOldReleases deployPath n = do
+  releases <- deployedReleases deployPath
+  forM_ (genericDrop n releases) $ \release -> do
+    rpath <- releasePath deployPath release
+    exec (Rm rpath)
 
--- | Runs the given command either locally or on the local machine.
-runCommand :: Maybe String -- ^ The host on which to run the command
-           -> String -- ^ The command to run, either on the local or remote host
-           -> Hapistrano String
+-- | Play the given script switching to diroctory of given release.
 
-runCommand Nothing command = execShellCommand command
-runCommand (Just server) command =
-  execCommand $ unwords ["ssh", server, command]
+playScript
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Release           -- ^ Release identifier
+  -> [GenericCommand] -- ^ Commands to execute
+  -> Hapistrano ()
+playScript deployDir release cmds = do
+  rpath <- releasePath deployDir release
+  forM_ cmds (exec . Cd rpath)
 
-execShellCommand :: String -> Hapistrano String
-execShellCommand command = do
-  liftIO $ putStrLn ("Executing: " ++ command)
-  let process = Process.shell command
-  (_, Just outHandle, Just errHandle, processHandle) <-
-    liftIO $
-      Process.createProcess process { Process.std_err = Process.CreatePipe
-                                    , Process.std_in = Process.CreatePipe
-                                    , Process.std_out = Process.CreatePipe
-                                    }
-  exitCode <- liftIO $ Process.waitForProcess processHandle
-  case exitCode of
-    ExitFailure code -> do
-      err <- liftIO $ IO.hGetContents errHandle
-      left (code, trim err)
-    ExitSuccess -> do
-      out <- liftIO $ IO.hGetContents outHandle
-      unless (null out) (liftIO $ putStrLn ("Output: " ++ out))
-      right (trim out)
+----------------------------------------------------------------------------
+-- Helpers
 
-execCommand :: String -> Hapistrano String
-execCommand cmd = do
-  let wds         = words cmd
-      (cmd', args) = (head wds, tail wds)
+-- | Ensure that necessary directories exist. Idempotent.
 
-  liftIO $ putStrLn $ "Executing: " ++ cmd
+setupDirs
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Hapistrano ()
+setupDirs deployPath = do
+  (exec . MkDir . releasesPath)  deployPath
+  (exec . MkDir . cacheRepoPath) deployPath
+  (exec . MkDir . ctokensPath)   deployPath
 
-  (code, stdout, err) <- liftIO $ readProcessWithExitCode cmd' args ""
+-- | Ensure that the specified repo is cloned and checked out on the given
+-- revision. Idempotent.
 
-  case code of
-    ExitSuccess -> do
-      unless (null stdout) (liftIO $ putStrLn $ "Output: " ++ stdout)
+ensureCacheInPlace
+  :: String            -- ^ Repo URL
+  -> Path Abs Dir      -- ^ Deploy path
+  -> Hapistrano ()
+ensureCacheInPlace repo deployPath = do
+  let cpath = cacheRepoPath deployPath
+      refs  = cpath </> $(mkRelDir "refs")
+  exists <- (exec (Ls refs) >> return True)
+    `catchError` const (return False)
+  unless exists $
+    exec (GitClone True (Left repo) cpath)
+  exec (Cd cpath (GitFetch "origin")) -- TODO store this in task description?
 
-      right $ trim stdout
+-- | Create a new realese identifier based on current timestamp.
 
-    ExitFailure int -> left (int, trim err)
+newRelease :: ReleaseFormat -> Hapistrano Release
+newRelease releaseFormat =
+  mkRelease releaseFormat <$> liftIO getCurrentTime
 
--- | Returns a timestamp in the default format for build directories.
-currentTimestamp :: ReleaseFormat -> IO String
-currentTimestamp format = do
-  curTime <- getCurrentTime
-  return $ formatTime defaultTimeLocale fstring curTime
+-- | Clone the repository to create the specified 'Release'.
 
-  where fstring = case format of
-          Short -> "%Y%m%d%H%M%S"
-          Long  -> "%Y%m%d%H%M%S%q"
+cloneToRelease
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Release           -- ^ 'Release' to create
+  -> Hapistrano ()
+cloneToRelease deployPath release = do
+  rpath <- releasePath deployPath release
+  let cpath = cacheRepoPath deployPath
+  exec (GitClone False (Right cpath) rpath)
 
--- | Returns the FilePath pointed to by the current symlink.
-readCurrentLink :: Hapistrano FilePath -- ^ The target of the symlink in the Hapistrano monad
-readCurrentLink = do
-  conf <- ask
-  runCommand (host conf) $ "readlink " ++ currentPath (deployPath conf)
+-- | Set the release to the correct revision by resetting the head of the
+-- git repo.
 
--- ^ Trims any newlines from the given String
-trim :: String -- ^ String to have trailing newlines stripped
-     -> String -- ^ String with trailing newlines removed
-trim = reverse . dropWhile (== '\n') . reverse
+setReleaseRevision
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Release           -- ^ 'Release' to reset
+  -> String            -- ^ Revision to reset to
+  -> Hapistrano ()
+setReleaseRevision deployPath release revision = do
+  rpath <- releasePath deployPath release
+  exec (Cd rpath (GitReset revision))
 
--- | Ensure that the initial bare repo exists in the repo directory. Idempotent.
-ensureRepositoryPushed :: Hapistrano String
-ensureRepositoryPushed = do
-  conf <- ask
-  res  <-
-    liftIO $ directoryExists (host conf) $ joinPath [cacheRepoPath conf, "refs"]
+-- | Return a list of all currently deployed releases sorted newest first.
 
-  if res
-    then right "Repo already existed"
-    else createCacheRepo
+deployedReleases
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Hapistrano [Release]
+deployedReleases deployPath = do
+  let rpath = releasesPath deployPath
+  xs <- exec (Find 1 rpath :: Find Dir)
+  ps <- mapM (stripDir rpath) (filter (/= rpath) xs)
+  (return . sortBy (comparing Down) . mapMaybe parseRelease)
+    (dropWhileEnd (== '/') . fromRelDir <$> ps)
 
--- | Returns the full path of the folder containing all of the release builds.
-releasesPath :: Config -> FilePath
-releasesPath conf = joinPath [deployPath conf, "releases"]
+-- | Return a list of successfully completed releases sorted newest first.
 
--- | Figures out the most recent release if possible.
-detectPrevious :: [String] -- ^ The releases in `releases` path
-               -> Hapistrano String -- ^ The previous release in the Hapistrano monad
-detectPrevious rs =
-  case biggest rs of
-    Nothing -> left (1, "No previous releases detected!")
-    Just rls -> right rls
+completedReleases
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Hapistrano [Release]
+completedReleases deployPath = do
+  let cpath = ctokensPath deployPath
+  xs <- exec (Find 1 cpath :: Find File)
+  ps <- mapM (stripDir cpath) xs
+  (return . sortBy (comparing Down) . mapMaybe parseRelease)
+    (dropWhileEnd (== '/') . fromRelFile <$> ps)
 
--- | Activates the previous detected release.
-rollback :: Hapistrano String -- ^ The current Release in the Hapistrano monad
-rollback = previousReleases >>= detectPrevious >>= activateRelease
+----------------------------------------------------------------------------
+-- Path helpers
 
--- | Clones the repository to the next releasePath timestamp. Makes a new
--- timestamp if one doesn't yet exist in the HapistranoState. Returns the
--- timestamp of the release that we cloned to.
-cloneToRelease :: Hapistrano Release -- ^ The newly-cloned Release, in the Hapistrano monad
-cloneToRelease = do
-  conf <- ask
-  rls  <- liftIO $ currentTimestamp (releaseFormat conf)
+-- | Return the full path to the directory containing all of the release
+-- builds.
 
-  void $ runCommand (host conf) $ "git clone " ++ cacheRepoPath conf ++
-    " " ++ joinPath [ releasesPath conf, rls ]
+releasesPath
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Path Abs Dir
+releasesPath deployPath = deployPath </> $(mkRelDir "releases")
 
-  return rls
+-- | Construct path to a particular 'Release'.
 
+releasePath
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Release           -- ^ 'Release' identifier
+  -> Hapistrano (Path Abs Dir)
+releasePath deployPath release = do
+  let rendered = renderRelease release
+  case parseRelDir rendered of
+    Nothing -> failWith 1 (Just $ "Could not append path: " ++ rendered)
+    Just rpath -> return (releasesPath deployPath </> rpath)
 
--- | Returns the full path to the git repo used for cache purposes on the
+-- | Return the full path to the git repo used for cache purposes on the
 -- target host filesystem.
-cacheRepoPath :: Config -- ^ The Hapistrano configuration
-              -> FilePath -- ^ The full path to the git cache repo used for speeding up deploys
-cacheRepoPath conf = joinPath [deployPath conf, "repo"]
 
--- | Returns the full path to the current symlink.
-currentPath :: FilePath -- ^ The full path of the deploy folder root
-            -> FilePath -- ^ The full path to the `current` symlink
-currentPath depPath = joinPath [depPath, "current"]
-
--- | Take the release timestamp from the end of a filepath.
-pathToRelease :: FilePath -- ^ The entire FilePath to a Release directory
-              -> Release -- ^ The Release number.
-pathToRelease = last . splitPath
-
--- | Returns a list of Strings representing the currently deployed releases.
-releases :: Hapistrano [Release] -- ^ A list of all found Releases on the target host
-releases = do
-  conf <- ask
-  res  <- runCommand (host conf) $ "find " ++ releasesPath conf ++
-          " -type d -maxdepth 1"
-
-  right $
-    filter (isReleaseString (releaseFormat conf)) . map pathToRelease $
-    lines res
-
-previousReleases :: Hapistrano [Release] -- ^ All non-current releases on the target host
-previousReleases = do
-  rls            <- releases
-  currentRelease <- readCurrentLink
-
-  let currentRel = (head . lines . pathToRelease) currentRelease
-  return $ filter (< currentRel) rls
-
-releasePath :: Config -> Release -> FilePath
-releasePath conf rls = joinPath [releasesPath conf, rls]
-
--- | Given a list of release strings, takes the last four in the sequence.
--- Assumes a list of folders that has been determined to be a proper release
--- path.
-oldReleases :: Config -> [Release] -> [FilePath]
-oldReleases conf rs = map mergePath toDelete
-  where sorted             = sortBy (flip compare) rs
-        toDelete           = drop 4 sorted
-        mergePath = releasePath conf
-
--- | Removes releases older than the last five to avoid filling up the target
--- host filesystem.
-cleanReleases :: Hapistrano [String] -- ^ Deleted Release directories
-cleanReleases = do
-  conf        <- ask
-  allReleases <- releases
-
-  let deletable = oldReleases conf allReleases
-
-  if null deletable
-    then do
-      liftIO $ putStrLn "There are no old releases to prune."
-      return []
-
-    else do
-      _ <- runCommand (host conf) $ "rm -rf -- " ++ unwords deletable
-      return deletable
-
--- | Returns a Bool indicating if the given String is in the proper release
--- format.
-isReleaseString :: ReleaseFormat -- ^ Format of Release directories
-                -> String -- ^ String to check against Release format
-                -> Bool -- ^ Whether the given String adheres to the specified Release format
-isReleaseString format s = all isNumber s && length s == releaseLength
-  where releaseLength = case format of
-          Short -> 14
-          Long  -> 26
-
--- | Creates the git repository that is used on the target host for
--- cache purposes.
-createCacheRepo :: Hapistrano String -- ^ Output of the git command used to create the bare cache repo
-createCacheRepo = do
-  conf <- ask
-
-  runCommand (host conf) $ "git clone --bare " ++ repository conf ++ " " ++
-    cacheRepoPath conf
-
--- | Returns the full path of the symlink pointing to the current
--- release.
-currentSymlinkPath :: Config -> FilePath
-currentSymlinkPath conf = joinPath [deployPath conf, "current"]
-
-currentTempSymlinkPath :: Config -> FilePath
-currentTempSymlinkPath conf = joinPath [deployPath conf, "current_tmp"]
-
--- | Removes the current symlink in preparation for a new release being
--- activated.
-removeCurrentSymlink :: Hapistrano ()
-removeCurrentSymlink = do
-  conf <- ask
-
-  void $ runCommand (host conf) $ "rm -rf " ++ currentSymlinkPath conf
-
--- | Determines whether the target host OS is Linux
-targetIsLinux :: Hapistrano Bool
-targetIsLinux = do
-  conf <- ask
-  res <- runCommand (host conf) "uname"
-
-  right $ "Linux" `isInfixOf` res
-
--- | Runs a command to restart a server if a command is provided.
-restartServerCommand :: Hapistrano String
-restartServerCommand = do
-  conf <- ask
-
-  case restartCommand conf of
-    Nothing -> return "No command given for restart action."
-    Just cmd -> runCommand (host conf) cmd
-
--- | Runs a build script if one is provided.
-runBuild :: Release -> Hapistrano Release
-runBuild rel = do
-  conf <- ask
-
-  case buildScript conf of
-    Nothing ->
-      liftIO $ putStrLn "No build script specified, skipping build step."
-
-    Just scr -> do
-      fl <- liftIO $ readFile scr
-      buildRelease rel $ lines fl
-
-  right rel
-
--- | Returns the best 'mv' command for a symlink given the target platform.
-mvCommand :: Bool   -- ^ Whether the target host is Linux
-          -> String -- ^ The best mv command for a symlink on the platform
-mvCommand True  = "mv -Tf"
-mvCommand False = "mv -f"
-
--- | Creates a symlink to the current release.
-lnCommand ::
-  String    -- ^ The path of the new release
-  -> String -- ^ The temporary symlink target for the release
-  -> String -- ^ A command to create the temporary symlink
-lnCommand rlsPath symlinkPath = unwords ["ln -s", rlsPath, symlinkPath]
-
--- | Creates a symlink to the directory indicated by the release timestamp.
--- hapistrano does this by creating a temporary symlink and doing an atomic
--- mv (1) operation to activate the new release.
-symlinkCurrent :: Release -> Hapistrano String
-symlinkCurrent rel = do
-  conf <- ask
-
-  isLnx <- targetIsLinux
-
-  let tmpLnCmd =
-        lnCommand (releasePath conf rel) (currentTempSymlinkPath conf)
-
-  _ <- runCommand (host conf) tmpLnCmd
-
-  runCommand (host conf) $ unwords [ mvCommand isLnx
-                                   , currentTempSymlinkPath conf
-                                   , currentSymlinkPath conf ]
-
-
--- | Updates the git repo used as a cache in the target host filesystem.
-updateCacheRepo :: Hapistrano ()
-updateCacheRepo = do
-  conf <- ask
+cacheRepoPath
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Path Abs Dir
+cacheRepoPath deployPath = deployPath </> $(mkRelDir "repo")
 
-  void $ runCommand (host conf) $ intercalate " && "
-    [ "cd " ++ cacheRepoPath conf
-    , "git fetch origin +refs/heads/*:refs/heads/*" ]
+-- | Get full path to current symlink.
 
--- | Sets the release to the correct revision by resetting the
--- head of the git repo.
-setReleaseRevision :: Release -> Hapistrano Release
-setReleaseRevision rel = do
-  conf <- ask
+currentSymlinkPath
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Path Abs File
+currentSymlinkPath deployPath = deployPath </> $(mkRelFile "current")
 
-  liftIO $ putStrLn "Setting revision in release path."
+-- | Get full path to temp symlink.
 
-  void $ runCommand (host conf) $ intercalate " && "
-    [ "cd " ++ releasePath conf rel
-    , "git fetch --all"
-    , "git reset --hard " ++ revision conf
-    ]
+tempSymlinkPath
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Path Abs File
+tempSymlinkPath deployPath = deployPath </> $(mkRelFile "current_tmp")
 
-  return rel
+-- | Get path to the directory that contains tokens of build completion.
 
--- | Returns a command that builds this application. Sets the context
--- of the build by switching to the release directory before running
--- the script.
-buildRelease :: Release  -- ^ The Release to build
-             -> [String] -- ^ Commands to be run. List intercalated
-                         -- with "&&" so that failure aborts the
-                         -- sequence.
-             -> Hapistrano ()
-buildRelease rel commands = do
-  conf <- ask
-  let cdCmd = "cd " ++ releasePath conf rel
-  void $ runCommand (host conf) $ intercalate " && " $ cdCmd : commands
+ctokensPath
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Path Abs Dir
+ctokensPath deployPath = deployPath </> $(mkRelDir "ctokens")
 
+-- | Get path to completion token file for particular release.
 
--- | A safe version of the `maximum` function in Data.List.
-biggest :: Ord a => [a] -> Maybe a
-biggest rls =
-  case sortBy (flip compare) rls of
-    []  -> Nothing
-    r:_ -> Just r
+ctokenPath
+  :: Path Abs Dir      -- ^ Deploy path
+  -> Release           -- ^ 'Release' identifier
+  -> Hapistrano (Path Abs File)
+ctokenPath deployPath release = do
+  let rendered = renderRelease release
+  case parseRelFile rendered of
+    Nothing -> failWith 1 (Just $ "Could not append path: " ++ rendered)
+    Just rpath -> return (ctokensPath deployPath </> rpath)
diff --git a/src/System/Hapistrano/Commands.hs b/src/System/Hapistrano/Commands.hs
new file mode 100644
--- /dev/null
+++ b/src/System/Hapistrano/Commands.hs
@@ -0,0 +1,294 @@
+-- |
+-- Module      :  System.Hapistrano.Commands
+-- Copyright   :  © 2015-2017 Stack Builders
+-- License     :  MIT
+--
+-- Maintainer  :  Justin Leitgeb <justin@stackbuilders.com>
+-- Stability   :  experimental
+-- Portability :  portable
+--
+-- Collection of type safe shell commands that can be fed into
+-- 'System.Hapistrano.Core.runCommand'.
+
+{-# LANGUAGE FlexibleInstances   #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies        #-}
+
+module System.Hapistrano.Commands
+  ( Command (..)
+  , Whoami (..)
+  , Cd (..)
+  , MkDir (..)
+  , Rm (..)
+  , Mv (..)
+  , Ln (..)
+  , Ls (..)
+  , Readlink (..)
+  , Find (..)
+  , Touch (..)
+  , GitClone (..)
+  , GitFetch (..)
+  , GitReset (..)
+  , GenericCommand
+  , mkGenericCommand
+  , unGenericCommand
+  , readScript )
+where
+
+import Control.Monad.IO.Class
+import Data.Char (isSpace)
+import Data.List (dropWhileEnd)
+import Data.Maybe (catMaybes, mapMaybe, fromJust)
+import Data.Proxy
+import Numeric.Natural
+import Path
+
+----------------------------------------------------------------------------
+-- Commands
+
+-- | Class for data types that represent shell commands in typed way.
+
+class Command a where
+
+  -- | Type of result.
+
+  type Result a :: *
+
+  -- | How to render the command before feeding it into shell (possibly via
+  -- SSH).
+
+  renderCommand :: a -> String
+
+  -- | How to parse the result from stdout.
+
+  parseResult :: Proxy a -> String -> Result a
+
+-- | Unix @whoami@.
+
+data Whoami = Whoami
+  deriving (Show, Eq, Ord)
+
+instance Command Whoami where
+  type Result Whoami = String
+  renderCommand Whoami = "whoami"
+  parseResult Proxy = trim
+
+-- | Specify directory in which to perform another command.
+
+data Cd cmd = Cd (Path Abs Dir) cmd
+
+instance Command cmd => Command (Cd cmd) where
+  type Result (Cd cmd) = Result cmd
+  renderCommand (Cd path cmd) = "(cd " ++ quoteCmd (fromAbsDir path) ++
+    " && " ++ renderCommand cmd ++ ")"
+  parseResult Proxy = parseResult (Proxy :: Proxy cmd)
+
+-- | Create a directory. Does not fail if the directory already exists.
+
+data MkDir = MkDir (Path Abs Dir)
+
+instance Command MkDir where
+  type Result MkDir = ()
+  renderCommand (MkDir path) = formatCmd "mkdir"
+    [ Just "-pv"
+    , Just (fromAbsDir path) ]
+  parseResult Proxy _ = ()
+
+-- | Delete file or directory.
+
+data Rm where
+  Rm :: Path Abs t -> Rm
+
+instance Command Rm where
+  type Result Rm = ()
+  renderCommand (Rm path) = formatCmd "rm"
+    [ Just "-rfv"
+    , Just (toFilePath path) ]
+  parseResult Proxy _ = ()
+
+-- | Move or rename files or directories.
+
+data Mv t = Mv (Path Abs t) (Path Abs t)
+
+instance Command (Mv File) where
+  type Result (Mv File) = ()
+  renderCommand (Mv old new) = formatCmd "mv"
+    [ Just "-fvT"
+    , Just (fromAbsFile old)
+    , Just (fromAbsFile new) ]
+  parseResult Proxy _ = ()
+
+instance Command (Mv Dir) where
+  type Result (Mv Dir) = ()
+  renderCommand (Mv old new) = formatCmd "mv"
+    [ Just "-fv"
+    , Just (fromAbsDir old)
+    , Just (fromAbsDir new) ]
+  parseResult Proxy _ = ()
+
+-- | Create symlinks.
+
+data Ln where
+  Ln :: Path Abs t -> Path Abs File -> Ln
+
+instance Command Ln where
+  type Result Ln = ()
+  renderCommand (Ln target linkName) = formatCmd "ln"
+    [ Just "-svT"
+    , Just (toFilePath target)
+    , Just (fromAbsFile linkName) ]
+  parseResult Proxy _ = ()
+
+-- | Read link.
+
+data Readlink t = Readlink (Path Abs File)
+
+instance Command (Readlink File) where
+  type Result (Readlink File) = Path Abs File
+  renderCommand (Readlink path) = formatCmd "readlink"
+    [ Just "-f"
+    , Just (toFilePath path) ]
+  parseResult Proxy = fromJust . parseAbsFile . trim
+
+instance Command (Readlink Dir) where
+  type Result (Readlink Dir) = Path Abs Dir
+  renderCommand (Readlink path) = formatCmd "readlink"
+    [ Just "-f"
+    , Just (toFilePath path) ]
+  parseResult Proxy = fromJust . parseAbsDir . trim
+
+-- | @ls@, so far used only to check existence of directories, so it's not
+-- very functional right now.
+
+data Ls = Ls (Path Abs Dir)
+
+instance Command Ls where
+  type Result Ls = ()
+  renderCommand (Ls path) = formatCmd "ls"
+    [ Just (fromAbsDir path) ]
+  parseResult Proxy _ = ()
+
+-- | Find (a very limited version).
+
+data Find t = Find Natural (Path Abs Dir)
+
+instance Command (Find Dir) where
+  type Result (Find Dir) = [Path Abs Dir]
+  renderCommand (Find maxDepth dir) = formatCmd "find"
+    [ Just (fromAbsDir dir)
+    , Just "-maxdepth"
+    , Just (show maxDepth)
+    , Just "-type"
+    , Just "d" ]
+  parseResult Proxy = mapMaybe parseAbsDir . fmap trim . lines
+
+instance Command (Find File) where
+  type Result (Find File) = [Path Abs File]
+  renderCommand (Find maxDepth dir) = formatCmd "find"
+    [ Just (fromAbsDir dir)
+    , Just "-maxdepth"
+    , Just (show maxDepth)
+    , Just "-type"
+    , Just "f" ]
+  parseResult Proxy = mapMaybe parseAbsFile . fmap trim . lines
+
+-- | @touch@.
+
+data Touch = Touch (Path Abs File)
+
+instance Command Touch where
+  type Result Touch = ()
+  renderCommand (Touch path) = formatCmd "touch"
+    [ Just (fromAbsFile path) ]
+  parseResult Proxy _ = ()
+
+-- | Git clone.
+
+data GitClone = GitClone Bool (Either String (Path Abs Dir)) (Path Abs Dir)
+
+instance Command GitClone where
+  type Result GitClone = ()
+  renderCommand (GitClone bare src dest) = formatCmd "git"
+    [ Just "clone"
+    , if bare then Just "--bare" else Nothing
+    , Just (case src of
+       Left repoUrl -> repoUrl
+       Right srcPath -> fromAbsDir srcPath)
+    , Just (fromAbsDir dest) ]
+  parseResult Proxy _ = ()
+
+-- | Git fetch (simplified).
+
+data GitFetch = GitFetch String
+
+instance Command GitFetch where
+  type Result GitFetch = ()
+  renderCommand (GitFetch remote) = formatCmd "git"
+    [ Just "fetch"
+    , Just remote ]
+  parseResult Proxy _ = ()
+
+-- | Git reset.
+
+data GitReset = GitReset String
+
+instance Command GitReset where
+  type Result GitReset = ()
+  renderCommand (GitReset revision) = formatCmd "git"
+    [ Just "reset"
+    , Just revision ]
+  parseResult Proxy _ = ()
+
+-- | Weakly-typed generic command, avoid using it directly.
+
+data GenericCommand = GenericCommand String
+  deriving (Show, Eq, Ord)
+
+instance Command GenericCommand where
+  type Result GenericCommand = ()
+  renderCommand (GenericCommand cmd) = cmd
+  parseResult Proxy _ = ()
+
+-- | Smart constructor that allows to create 'GenericCommand's. Just a
+-- little bit more safety.
+
+mkGenericCommand :: String -> Maybe GenericCommand
+mkGenericCommand str =
+  if '\n' `elem` str' || null str'
+    then Nothing
+    else Just (GenericCommand str')
+  where
+    str' = trim (takeWhile (/= '#') str)
+
+-- | Get the raw command back from 'GenericCommand'.
+
+unGenericCommand :: GenericCommand -> String
+unGenericCommand (GenericCommand x) = x
+
+-- | Read commands from a file.
+
+readScript :: MonadIO m => Path Abs File -> m [GenericCommand]
+readScript path = liftIO $ catMaybes . fmap mkGenericCommand . lines
+  <$> readFile (fromAbsFile path)
+
+----------------------------------------------------------------------------
+-- Helpers
+
+-- | Format a command.
+
+formatCmd :: String -> [Maybe String] -> String
+formatCmd cmd args = unwords (quoteCmd <$> (cmd : catMaybes args))
+
+-- | Simple-minded quoter.
+
+quoteCmd :: String -> String
+quoteCmd str =
+  if any isSpace str
+    then "\"" ++ str ++ "\""
+    else str
+
+-- | Trim whitespace from beginning and end.
+
+trim :: String -> String
+trim = dropWhileEnd isSpace . dropWhile isSpace
diff --git a/src/System/Hapistrano/Core.hs b/src/System/Hapistrano/Core.hs
new file mode 100644
--- /dev/null
+++ b/src/System/Hapistrano/Core.hs
@@ -0,0 +1,149 @@
+-- |
+-- Module      :  System.Hapistrano.Core
+-- Copyright   :  © 2015-2017 Stack Builders
+-- License     :  MIT
+--
+-- Maintainer  :  Justin Leitgeb <justin@stackbuilders.com>
+-- Stability   :  experimental
+-- Portability :  portable
+--
+-- Core Hapistrano functions that provide basis on which all the
+-- functionality is built.
+
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE RecordWildCards     #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module System.Hapistrano.Core
+  ( runHapistrano
+  , failWith
+  , exec
+  , scpFile
+  , scpDir )
+where
+
+import Control.Monad
+import Control.Monad.Except
+import Control.Monad.Reader
+import Data.Proxy
+import Path
+import System.Exit
+import System.Hapistrano.Commands
+import System.Hapistrano.Types
+import System.IO
+import System.Process
+
+-- | Run the 'Hapistrano' monad. The monad hosts 'exec' actions.
+
+runHapistrano :: MonadIO m
+  => Maybe SshOptions  -- ^ SSH options to use or 'Nothing' if we run locally
+  -> Hapistrano a      -- ^ The computation to run
+  -> m a               -- ^ IO-enabled monad that hosts the computation
+runHapistrano sshOptions m = liftIO $ do
+  let config = Config
+        { configSshOptions = sshOptions }
+  r <- runReaderT (runExceptT m) config
+  case r of
+    Left (Failure n msg) -> do
+      forM_ msg (hPutStrLn stderr)
+      exitWith (ExitFailure n)
+    Right x ->
+      x <$ putStrLn "Success."
+
+-- | Fail returning the following status code and printing given message to
+-- 'stderr'.
+
+failWith :: Int -> Maybe String -> Hapistrano a
+failWith n msg = throwError (Failure n msg)
+
+-- | Run the given sequence of command. Whether to use SSH or not is
+-- determined from settings contained in the 'Hapistrano' monad
+-- configuration. Commands that return non-zero exit codes will result in
+-- short-cutting of execution.
+
+exec :: forall a. Command a => a -> Hapistrano (Result a)
+exec typedCmd = do
+  Config {..} <- ask
+  let (prog, args) =
+        case configSshOptions of
+          Nothing ->
+            ("bash", ["-c", cmd])
+          Just SshOptions {..} ->
+            ("ssh", [sshHost, "-p", show sshPort, cmd])
+      cmd = renderCommand typedCmd
+  parseResult (Proxy :: Proxy a) <$> exec' prog args cmd
+
+-- | Copy a file from local path to target server.
+
+scpFile
+  :: Path Abs File     -- ^ Location of the file to copy
+  -> Path Abs File     -- ^ Where to put the file on target machine
+  -> Hapistrano ()
+scpFile src dest =
+  scp' (fromAbsFile src) (fromAbsFile dest) ["-qv"]
+
+-- | Copy a local directory recursively to target server.
+
+scpDir
+  :: Path Abs Dir      -- ^ Location of the directory to copy
+  -> Path Abs Dir      -- ^ Where to put the dir on target machine
+  -> Hapistrano ()
+scpDir src dest =
+  scp' (fromAbsDir src) (fromAbsDir dest) ["-qvr"]
+
+scp'
+  :: FilePath
+  -> FilePath
+  -> [String]
+  -> Hapistrano ()
+scp' src dest extraArgs = do
+  Config {..} <- ask
+  let prog = "scp"
+      portArg =
+        case sshPort <$> configSshOptions of
+          Nothing -> []
+          Just x  -> ["-p", show x]
+      hostPrefix =
+        case sshHost <$> configSshOptions of
+          Nothing -> ""
+          Just x -> x ++ ":"
+      args = extraArgs ++ portArg ++ [src, hostPrefix ++ dest]
+  void (exec' prog args (prog ++ " " ++ unwords args))
+
+----------------------------------------------------------------------------
+-- Helpers
+
+-- | A helper for 'exec' and similar functions.
+
+exec'
+  :: String            -- ^ Name of program to run
+  -> [String]          -- ^ Arguments to that program
+  -> String            -- ^ How to show the command in print-outs
+  -> Hapistrano String -- ^ Raw stdout output of that program
+exec' prog args cmd = do
+  Config {..} <- ask
+  let hostLabel =
+        case configSshOptions of
+          Nothing              -> "localhost"
+          Just SshOptions {..} -> sshHost ++ ":" ++ show sshPort
+  liftIO $ do
+    printLine hostLabel
+    putStrLn ("$ " ++ cmd)
+  (exitCode, stdout', stderr') <- liftIO
+    (readProcessWithExitCode prog args "")
+  unless (null stdout') . liftIO $
+    putStrLn stdout'
+  unless (null stderr') . liftIO $
+    hPutStrLn stderr stderr'
+  case exitCode of
+    ExitSuccess ->
+      return stdout'
+    ExitFailure n ->
+      failWith n Nothing
+
+-- | Print something “inside” a line, sort-of beautifully.
+
+printLine :: String -> IO ()
+printLine str = putStrLn ("*** " ++ str ++ padding)
+  where
+    padding = ' ' : replicate (75 - length str) '*'
diff --git a/src/System/Hapistrano/Types.hs b/src/System/Hapistrano/Types.hs
--- a/src/System/Hapistrano/Types.hs
+++ b/src/System/Hapistrano/Types.hs
@@ -1,49 +1,108 @@
+-- |
+-- Module      :  System.Hapistrano.Types
+-- Copyright   :  © 2015-2017 Stack Builders
+-- License     :  MIT
+--
+-- Maintainer  :  Justin Leitgeb <justin@stackbuilders.com>
+-- Stability   :  experimental
+-- Portability :  portable
+--
+-- Type definitions for the Hapistrano tool.
+
 module System.Hapistrano.Types
-       ( Config(..)
-       , FailureResult
-       , Hapistrano
-       , Release
-       , ReleaseFormat(..)
-       ) where
+  ( Hapistrano
+  , Failure (..)
+  , Config (..)
+  , Task (..)
+  , ReleaseFormat(..)
+  , SshOptions (..)
+  , Release
+  , mkRelease
+  , releaseTime
+  , renderRelease
+  , parseRelease )
+where
 
-import Control.Monad.Reader (ReaderT(..))
-import Control.Monad.Trans.Either (EitherT(..))
+import Control.Applicative
+import Control.Monad.Except
+import Control.Monad.Reader
+import Data.Time
+import Path
 
--- | Config stuff that will be replaced by config file reading
-data Config =
-  Config { deployPath     :: String
-           -- ^ The root of the deploy target on the remote host
+-- | Hapistrano monad.
 
-         , repository     :: String -- ^ The remote git repo
-         , revision       :: String -- ^ A SHA1 or branch to release
+type Hapistrano a = ExceptT Failure (ReaderT Config IO) a
 
-         , releaseFormat  :: ReleaseFormat
-         , host           :: Maybe String
-           -- ^ The target host for the deploy, or Nothing to indicate that
-           -- operations should be done directly in the local deployPath without
-           -- going over SSH
+-- | Failure with status code and a message.
 
-         , buildScript    :: Maybe FilePath
-           -- ^ The local path to a file that should be executed on the remote
-           -- server to build the application.
+data Failure = Failure Int (Maybe String)
 
-         , restartCommand :: Maybe String
-           -- ^ Optional command to restart the server after a successful deploy
+-- | Hapistrano configuration options.
 
-         } deriving (Show)
+data Config = Config
+  { configSshOptions :: Maybe SshOptions
+    -- ^ 'Nothing' if we are running locally, or SSH options to use.
+  }
 
-data ReleaseFormat = Short
-                     -- ^ Standard release path following Capistrano's format
+-- | The records describes deployment task.
 
-                   | Long
-                     -- ^ Long release path including picoseconds for testing
-                     -- or people seriously into continuous deployment
+data Task = Task
+  { taskDeployPath :: Path Abs Dir
+    -- ^ The root of the deploy target on the remote host
+  , taskRepository :: String
+    -- ^ The URL of remote Git repo to deploy
+  , taskRevision :: String
+    -- ^ A SHA1 or branch to release
+  , taskReleaseFormat :: ReleaseFormat
+    -- ^ The 'ReleaseFormat' to use
+  } deriving (Show, Eq)
 
-                   deriving (Show)
+-- | Release format mode.
 
+data ReleaseFormat
+  = ReleaseShort -- ^ Standard release path following Capistrano's format
+  | ReleaseLong  -- ^ Long release path including picoseconds
+  deriving (Show, Read, Eq, Ord, Enum, Bounded)
 
-type Release = String
+-- | SSH options.
 
-type FailureResult = (Int, String)
+data SshOptions = SshOptions
+  { sshHost :: String  -- ^ Host to use
+  , sshPort :: Word    -- ^ Port to use
+  } deriving (Show, Eq, Ord)
 
-type Hapistrano a = EitherT FailureResult (ReaderT Config IO) a
+-- | Release indentifier.
+
+data Release = Release ReleaseFormat UTCTime
+  deriving (Eq, Show, Ord)
+
+-- | Create a 'Release' indentifier.
+
+mkRelease :: ReleaseFormat -> UTCTime -> Release
+mkRelease = Release
+
+-- | Extract deployment time from 'Release'.
+
+releaseTime :: Release -> UTCTime
+releaseTime (Release _ time) = time
+
+-- | Render 'Release' indentifier as a 'String'.
+
+renderRelease :: Release -> String
+renderRelease (Release rfmt time) = formatTime defaultTimeLocale fmt time
+  where
+    fmt = case rfmt of
+      ReleaseShort -> releaseFormatShort
+      ReleaseLong  -> releaseFormatLong
+
+-- | Parse 'Release' identifier from a 'String'.
+
+parseRelease :: String -> Maybe Release
+parseRelease s = (Release ReleaseLong <$> p releaseFormatLong s)
+  <|> (Release ReleaseShort <$> p releaseFormatShort s)
+  where
+    p = parseTimeM False defaultTimeLocale
+
+releaseFormatShort, releaseFormatLong :: String
+releaseFormatShort = "%Y%m%d%H%M%S"
+releaseFormatLong  = "%Y%m%d%H%M%S%q"
