diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,11 +5,21 @@
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## [1.7.5] - 2024-10-11
+
+### Fixed
+
+- don't assume terminal mode if `CI=true`
+- batch narinfo looksup if we have more than 1MB of hashes
+- `compression-level` can't be have a short of `-c` since it clashes with `--config`
+- improve hash mismatch error
+- don't retry all http exceptions
+
 ## [1.7.4] - 2024-05-24
 
 ### Fixed
 
-- :ignore sigPIPE on macOS after initialising nix
+- ignore sigPIPE on macOS after initialising nix
 
 ## [1.7.3] - 2024-05-17
 
@@ -103,14 +113,14 @@
 ### Fixed
 
 - Reverted "Rewrite C++ bits to Haskell"
-  
+
   This reverts 15 commits related towards getting Cachix to
   built statically without C++ code in Nix.
-  
+
   Since it's not possible to interact with Nix sqlite directly
   in a reliable manner, we'll go the route of autogenerating C++
   binding without Template Haskell, at some point.
-  
+
 ## [1.4.2] - 2023-04-05
 
 ### Fixed
@@ -189,7 +199,7 @@
 
 ## [1.1] - 2022-12-16
 
-### Added 
+### Added
 
 - Use ZSTD compresion method by default and allow overriding it via `--compression-method` back to XZ. You can also change the default permanently on your binary cache settings page.
 
@@ -203,7 +213,7 @@
 
 ## [1.0.1] - 2022-09-24
 
-### Added 
+### Added
 
 - `cachix config`: allow setting hostname
 
@@ -221,7 +231,7 @@
 
 ## [0.8.1] - 2022-07-26
 
-- Cachix Deploy: retry exceptions every 1s instead of exponentially 
+- Cachix Deploy: retry exceptions every 1s instead of exponentially
 
 ### Fixed
 
@@ -241,7 +251,7 @@
 
 ## [0.7.0] - 2022-01-12
 
-### Added 
+### Added
 
 - Cachix Deploy support
 
@@ -294,14 +304,14 @@
 
 ### Fixed
 
-- Regression: use auth token when using signing key with private caches 
+- Regression: use auth token when using signing key with private caches
 - Configure netrc even if cachix config doesn't exist
 
 ## [0.5.0] - 2020-11-06
 
 ### Added
 
-- Allow specifying output directory to write nix.conf and netrc files. 
+- Allow specifying output directory to write nix.conf and netrc files.
 - Allow pushing without a Signing key using only auth token
 - Allow setting auth token via `$CACHIX_AUTH_TOKEN` shell variable
 
@@ -345,7 +355,7 @@
 
 ### Changed
 
-- Print stderr during streaming of nars    
+- Print stderr during streaming of nars
 
 ### Fixed
 
@@ -356,7 +366,7 @@
   then shelling out (slight performance improvement)
 
 - #251: Assert nar hash before creating narinfo
-    
+
   Fixes rare but annoying bug of "bad nar archive" from Nix.
   Never managed to reproduce. One theory is that the path disappears as
   its deleted by GC or nix-store --delete.
@@ -385,11 +395,11 @@
 
 ### Fixed
 
-- #240: push: prefer store paths as command arguments over stdin 
+- #240: push: prefer store paths as command arguments over stdin
 
   Some programming languages like go and nodejs always open
   stdin pipe, which confuses cachix to think there's stdin content.
-  
+
   Sadly checking stdin for contents is tricky since we get
   into the whole buffering mess.
 
diff --git a/cachix-deployment/Main.hs b/cachix-deployment/Main.hs
--- a/cachix-deployment/Main.hs
+++ b/cachix-deployment/Main.hs
@@ -6,27 +6,27 @@
 where
 
 import Cachix.API.Error (escalateAs)
-import qualified Cachix.API.WebSocketSubprotocol as AgentInformation (AgentInformation (..))
-import qualified Cachix.API.WebSocketSubprotocol as DeploymentDetails (DeploymentDetails (..))
-import qualified Cachix.API.WebSocketSubprotocol as WSS
-import qualified Cachix.Client.URI as URI
-import qualified Cachix.Deploy.Activate as Activate
-import qualified Cachix.Deploy.Agent as Agent
+import Cachix.API.WebSocketSubprotocol qualified as AgentInformation (AgentInformation (..))
+import Cachix.API.WebSocketSubprotocol qualified as DeploymentDetails (DeploymentDetails (..))
+import Cachix.API.WebSocketSubprotocol qualified as WSS
+import Cachix.Client.URI qualified as URI
+import Cachix.Deploy.Activate qualified as Activate
+import Cachix.Deploy.Agent qualified as Agent
 import Cachix.Deploy.Deployment (Deployment (..))
-import qualified Cachix.Deploy.Lock as Lock
-import qualified Cachix.Deploy.Log as Log
-import qualified Cachix.Deploy.Websocket as WebSocket
-import qualified Control.Concurrent.Async as Async
-import qualified Control.Concurrent.STM.TMQueue as TMQueue
-import qualified Control.Exception.Safe as Safe
-import qualified Data.Aeson as Aeson
-import qualified Data.Conduit.TQueue as Conduit
+import Cachix.Deploy.Lock qualified as Lock
+import Cachix.Deploy.Log qualified as Log
+import Cachix.Deploy.Websocket qualified as WebSocket
+import Control.Concurrent.Async qualified as Async
+import Control.Concurrent.STM.TMQueue qualified as TMQueue
+import Control.Exception.Safe qualified as Safe
+import Data.Aeson qualified as Aeson
+import Data.Conduit.TQueue qualified as Conduit
 import Data.Time.Clock (getCurrentTime)
-import qualified Data.UUID as UUID
-import qualified Data.UUID.V4 as UUID
+import Data.UUID qualified as UUID
+import Data.UUID.V4 qualified as UUID
 import GHC.IO.Encoding
-import qualified Katip as K
-import qualified Network.WebSockets as WS
+import Katip qualified as K
+import Network.WebSockets qualified as WS
 import Protolude hiding (toS)
 import Protolude.Conv
 import System.IO (BufferMode (..), hSetBuffering)
diff --git a/cachix.cabal b/cachix.cabal
--- a/cachix.cabal
+++ b/cachix.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.2
 name:               cachix
-version:            1.7.4
+version:            1.7.5
 synopsis:
   Command-line client for Nix binary cache hosting https://cachix.org
 
@@ -30,65 +30,42 @@
   build-depends:      base >=4.7 && <5
   default-extensions:
     DeriveAnyClass
-    DeriveGeneric
     DerivingVia
     LambdaCase
-    NamedFieldPuns
     NoImplicitPrelude
-    NoImportQualifiedPost
     OverloadedStrings
     RecordWildCards
-    ScopedTypeVariables
 
   ghc-options:
     -Wall -Wcompat -Wincomplete-record-updates
     -Wincomplete-uni-patterns -Wredundant-constraints -fwarn-tabs
     -fwarn-unused-imports -fwarn-missing-signatures
-    -fwarn-name-shadowing -fwarn-incomplete-patterns
+    -fwarn-name-shadowing -fwarn-incomplete-patterns -Wunused-packages
+    -Wredundant-bang-patterns
 
   -- TODO: address partial record fields in Cachix Deploy
   -- if impl(ghc >= 8.4)
   --   ghc-options:       -Wpartial-fields
 
-  if impl(ghc >=8.10)
-    ghc-options: -Wunused-packages
-
-  if impl(ghc >=9.2)
-    ghc-options: -Wredundant-bang-patterns
+  if impl(ghc >=9.4)
+    ghc-options: -Wredundant-strictness-flags -Wforall-identifier
 
-  default-language:   Haskell2010
+  default-language:   GHC2021
 
 library
   import:            defaults
   exposed-modules:
     Cachix.Client
     Cachix.Client.CNix
-    Cachix.Client.Commands
-    Cachix.Client.Commands.Push
+    Cachix.Client.Command
+    Cachix.Client.Command.Cache
+    Cachix.Client.Command.Config
+    Cachix.Client.Command.Import
+    Cachix.Client.Command.Pin
+    Cachix.Client.Command.Push
+    Cachix.Client.Command.Watch
     Cachix.Client.Config
     Cachix.Client.Config.Orphans
-    Cachix.Client.Daemon
-    Cachix.Client.Daemon.Client
-    Cachix.Client.Daemon.EventLoop
-    Cachix.Client.Daemon.Listen
-    Cachix.Client.Daemon.Log
-    Cachix.Client.Daemon.PostBuildHook
-    Cachix.Client.Daemon.Progress
-    Cachix.Client.Daemon.Protocol
-    Cachix.Client.Daemon.Push
-    Cachix.Client.Daemon.PushManager
-    Cachix.Client.Daemon.PushManager.PushJob
-    Cachix.Client.Daemon.ShutdownLatch
-    Cachix.Client.Daemon.SocketStore
-    Cachix.Client.Daemon.Subscription
-    Cachix.Client.Daemon.Types
-    Cachix.Client.Daemon.Types.Daemon
-    Cachix.Client.Daemon.Types.EventLoop
-    Cachix.Client.Daemon.Types.Log
-    Cachix.Client.Daemon.Types.PushEvent
-    Cachix.Client.Daemon.Types.PushManager
-    Cachix.Client.Daemon.Types.SocketStore
-    Cachix.Client.Daemon.Worker
     Cachix.Client.Env
     Cachix.Client.Exception
     Cachix.Client.HumanSize
@@ -106,6 +83,29 @@
     Cachix.Client.URI
     Cachix.Client.Version
     Cachix.Client.WatchStore
+    Cachix.Daemon
+    Cachix.Daemon.Client
+    Cachix.Daemon.EventLoop
+    Cachix.Daemon.Listen
+    Cachix.Daemon.Log
+    Cachix.Daemon.PostBuildHook
+    Cachix.Daemon.Progress
+    Cachix.Daemon.Protocol
+    Cachix.Daemon.Push
+    Cachix.Daemon.PushManager
+    Cachix.Daemon.PushManager.PushJob
+    Cachix.Daemon.ShutdownLatch
+    Cachix.Daemon.SocketStore
+    Cachix.Daemon.Subscription
+    Cachix.Daemon.Types
+    Cachix.Daemon.Types.Daemon
+    Cachix.Daemon.Types.Error
+    Cachix.Daemon.Types.EventLoop
+    Cachix.Daemon.Types.Log
+    Cachix.Daemon.Types.PushEvent
+    Cachix.Daemon.Types.PushManager
+    Cachix.Daemon.Types.SocketStore
+    Cachix.Daemon.Worker
     Cachix.Deploy.Activate
     Cachix.Deploy.ActivateCommand
     Cachix.Deploy.Agent
@@ -151,7 +151,8 @@
     , generic-lens
     , hercules-ci-cnix-store
     , here
-    , hnix-store-core         >=0.6.1.0
+    , hnix-store-core         >=0.8
+    , hnix-store-nar
     , http-client
     , http-client-tls
     , http-conduit
@@ -237,11 +238,12 @@
 test-suite cachix-test
   import:             defaults
   type:               exitcode-stdio-1.0
-  ghc-options:        -threaded -rtsopts -with-rtsopts=-N
+  ghc-options:        -threaded -rtsopts -with-rtsopts=-N -Wno-orphans
   main-is:            Main.hs
   hs-source-dirs:     test
   other-modules:
     Daemon.PostBuildHookSpec
+    Daemon.ProtocolSpec
     Daemon.PushManagerSpec
     DeploySpec
     InstallationModeSpec
@@ -261,6 +263,7 @@
     , dhall
     , directory
     , extra
+    , hercules-ci-cnix-store
     , here
     , hspec
     , protolude
diff --git a/cachix/Main.hs b/cachix/Main.hs
--- a/cachix/Main.hs
+++ b/cachix/Main.hs
@@ -1,6 +1,6 @@
 module Main (main) where
 
-import qualified Cachix.Client as CC
+import Cachix.Client qualified as CC
 import Cachix.Client.CNix (handleCppExceptions)
 import Cachix.Client.Exception (CachixException)
 import Control.Exception.Safe (Handler (..), catches, displayException)
diff --git a/src/Cachix/Client.hs b/src/Cachix/Client.hs
--- a/src/Cachix/Client.hs
+++ b/src/Cachix/Client.hs
@@ -3,21 +3,27 @@
   )
 where
 
-import Cachix.Client.Commands as Commands
-import qualified Cachix.Client.Config as Config
-import qualified Cachix.Client.Daemon as Daemon
-import qualified Cachix.Client.Daemon.Client as Daemon.Client
+import Cachix.Client.Command qualified as Command
+import Cachix.Client.Config qualified as Config
 import Cachix.Client.Env (cachixoptions, mkEnv)
-import Cachix.Client.OptionsParser (CachixCommand (..), DaemonCommand (..), getOpts)
+import Cachix.Client.Exception (CachixException (DeprecatedCommand))
+import Cachix.Client.OptionsParser
+  ( CachixCommand (..),
+    DaemonCommand (..),
+    PushArguments (..),
+    getOpts,
+  )
 import Cachix.Client.Version (cachixVersion)
+import Cachix.Daemon qualified as Daemon
+import Cachix.Daemon.Client qualified as Daemon.Client
 import Cachix.Deploy.ActivateCommand as ActivateCommand
-import qualified Cachix.Deploy.Agent as AgentCommand
-import qualified Cachix.Deploy.OptionsParser as DeployOptions
-import qualified Hercules.CNix as CNix
-import qualified Hercules.CNix.Util as CNix.Util
+import Cachix.Deploy.Agent qualified as AgentCommand
+import Cachix.Deploy.OptionsParser qualified as DeployOptions
+import Hercules.CNix qualified as CNix
+import Hercules.CNix.Util qualified as CNix.Util
 import Protolude
 import System.Console.AsciiProgress (displayConsoleRegions)
-import qualified System.Posix.Signals as Signal
+import System.Posix.Signals qualified as Signal
 
 main :: IO ()
 main = displayConsoleRegions $ do
@@ -25,28 +31,30 @@
   env <- mkEnv flags
 
   initNixStore
-
   installSignalHandlers
 
   let cachixOptions = cachixoptions env
   case command of
-    AuthToken token -> Commands.authtoken env token
+    AuthToken token -> Command.authtoken env token
     Config configCommand -> Config.run cachixOptions configCommand
     Daemon (DaemonRun daemonOptions pushOptions mcacheName) -> Daemon.start env daemonOptions pushOptions mcacheName
     Daemon (DaemonStop daemonOptions) -> Daemon.Client.stop env daemonOptions
     Daemon (DaemonPushPaths daemonOptions storePaths) -> Daemon.Client.push env daemonOptions storePaths
-    Daemon (DaemonWatchExec pushOptions cacheName cmd args) -> Commands.watchExecDaemon env pushOptions cacheName cmd args
-    GenerateKeypair name -> Commands.generateKeypair env name
-    Push pushArgs -> Commands.push env pushArgs
-    Pin pingArgs -> Commands.pin env pingArgs
-    Import pushOptions name uri -> Commands.import' env pushOptions name uri
-    WatchStore watchArgs name -> Commands.watchStore env watchArgs name
-    WatchExec pushArgs name cmd args -> Commands.watchExec env pushArgs name cmd args
-    Use name useOptions -> Commands.use env name useOptions
-    Remove name -> Commands.remove env name
-    Version -> putText cachixVersion
+    Daemon (DaemonWatchExec pushOptions cacheName cmd args) -> Command.watchExecDaemon env pushOptions cacheName cmd args
     DeployCommand (DeployOptions.Agent opts) -> AgentCommand.run cachixOptions opts
     DeployCommand (DeployOptions.Activate opts) -> ActivateCommand.run env opts
+    GenerateKeypair name -> Command.generateKeypair env name
+    Import pushOptions name uri -> Command.import' env pushOptions name uri
+    Pin pingArgs -> Command.pin env pingArgs
+    Push (PushPaths opts name cliPaths) -> Command.push env opts name cliPaths
+    Push (PushWatchStore _ _) ->
+      throwIO $ DeprecatedCommand "DEPRECATED: cachix watch-store has replaced cachix push --watch-store."
+    Remove name -> Command.remove env name
+    Use name useOptions -> Command.use env name useOptions
+    Version -> putText cachixVersion
+    WatchExec watchExecMode pushArgs name cmd args ->
+      Command.watchExec env watchExecMode pushArgs name cmd args
+    WatchStore watchArgs name -> Command.watchStore env watchArgs name
 
 -- | Install client-wide signal handlers.
 installSignalHandlers :: IO ()
diff --git a/src/Cachix/Client/CNix.hs b/src/Cachix/Client/CNix.hs
--- a/src/Cachix/Client/CNix.hs
+++ b/src/Cachix/Client/CNix.hs
@@ -1,7 +1,7 @@
 module Cachix.Client.CNix where
 
 import Hercules.CNix.Store (Store, StorePath)
-import qualified Hercules.CNix.Store as Store
+import Hercules.CNix.Store qualified as Store
 import Language.C.Inline.Cpp.Exception
 import Protolude
 import System.Console.Pretty (Color (..), Style (..), color, style)
diff --git a/src/Cachix/Client/Command.hs b/src/Cachix/Client/Command.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Command.hs
@@ -0,0 +1,21 @@
+module Cachix.Client.Command
+  ( Cache.use,
+    Cache.remove,
+    Config.authtoken,
+    Config.generateKeypair,
+    Import.import',
+    Pin.pin,
+    Push.push,
+    Watch.watchExec,
+    Watch.watchExecDaemon,
+    Watch.watchExecStore,
+    Watch.watchStore,
+  )
+where
+
+import Cachix.Client.Command.Cache qualified as Cache
+import Cachix.Client.Command.Config qualified as Config
+import Cachix.Client.Command.Import qualified as Import
+import Cachix.Client.Command.Pin qualified as Pin
+import Cachix.Client.Command.Push qualified as Push
+import Cachix.Client.Command.Watch qualified as Watch
diff --git a/src/Cachix/Client/Command/Cache.hs b/src/Cachix/Client/Command/Cache.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Command/Cache.hs
@@ -0,0 +1,35 @@
+module Cachix.Client.Command.Cache (use, remove) where
+
+import Cachix.API qualified as API
+import Cachix.API.Error
+import Cachix.Client.Command.Push qualified as Push
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.Env (Env (..))
+import Cachix.Client.Exception (CachixException (..))
+import Cachix.Client.InstallationMode qualified as InstallationMode
+import Cachix.Client.NixVersion (assertNixVersion)
+import Cachix.Client.Retry (retryHttp)
+import Cachix.Client.Servant
+import Protolude hiding (toS)
+import Servant.Auth.Client
+import Servant.Client.Streaming
+
+use :: Env -> Text -> InstallationMode.UseOptions -> IO ()
+use env name useOptions = do
+  optionalAuthToken <- Config.getAuthTokenMaybe (config env)
+  let token = fromMaybe (Token "") optionalAuthToken
+  -- 1. get cache public key
+  res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name
+  case res of
+    Left err -> Push.handleCacheResponse name optionalAuthToken err
+    Right binaryCache -> do
+      () <- escalateAs UnsupportedNixVersion =<< assertNixVersion
+      nixEnv <- InstallationMode.getNixEnv
+      InstallationMode.addBinaryCache (config env) binaryCache useOptions $
+        InstallationMode.getInstallationMode nixEnv useOptions
+
+remove :: Env -> Text -> IO ()
+remove env name = do
+  nixEnv <- InstallationMode.getNixEnv
+  InstallationMode.removeBinaryCache (Config.hostname $ config env) name $
+    InstallationMode.getInstallationMode nixEnv InstallationMode.defaultUseOptions
diff --git a/src/Cachix/Client/Command/Config.hs b/src/Cachix/Client/Command/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Command/Config.hs
@@ -0,0 +1,71 @@
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE TypeOperators #-}
+
+module Cachix.Client.Command.Config where
+
+import Cachix.API qualified as API
+import Cachix.API.Error
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.Env (Env (..))
+import Cachix.Client.Retry (retryHttp)
+import Cachix.Client.Secrets
+  ( SigningKey (SigningKey),
+    exportSigningKey,
+  )
+import Cachix.Client.Servant
+import Cachix.Types.SigningKeyCreate qualified as SigningKeyCreate
+import Crypto.Sign.Ed25519 (PublicKey (PublicKey), createKeypair)
+import Data.ByteString.Base64 qualified as B64
+import Data.String.Here
+import Data.Text qualified as T
+import Data.Text.IO qualified as T.IO
+import Protolude hiding (toS)
+import Protolude.Conv
+import Servant.API (NoContent (..))
+import Servant.Auth.Client
+import Servant.Client.Streaming
+
+-- TODO: check that token actually authenticates!
+authtoken :: Env -> Maybe Text -> IO ()
+authtoken Env {cachixoptions} (Just token) = do
+  let configPath = Config.configPath cachixoptions
+  config <- Config.getConfig configPath
+  Config.writeConfig configPath $ config {Config.authToken = Token (toS token)}
+authtoken env Nothing = authtoken env . Just . T.strip =<< T.IO.getContents
+
+generateKeypair :: Env -> Text -> IO ()
+generateKeypair env name = do
+  authToken <- Config.getAuthTokenRequired (config env)
+  (PublicKey pk, sk) <- createKeypair
+  let signingKey = exportSigningKey $ SigningKey sk
+      signingKeyCreate = SigningKeyCreate.SigningKeyCreate (toS $ B64.encode pk)
+      bcc = Config.BinaryCacheConfig name signingKey
+  -- we first validate if key can be added to the binary cache
+  (_ :: NoContent) <-
+    escalate <=< retryHttp $
+      (`runClientM` clientenv env) $
+        API.createKey cachixClient authToken name signingKeyCreate
+  -- if key was successfully added, write it to the config
+  -- TODO: warn if binary cache with the same key already exists
+  let cfg = config env & Config.setBinaryCaches [bcc]
+  Config.writeConfig (Config.configPath (cachixoptions env)) cfg
+  putStrLn
+    ( [iTrim|
+Secret signing key has been saved in the file above. To populate
+your binary cache:
+
+    $ nix-build | cachix push ${name}
+
+Or if you'd like to use the signing key on another machine or CI:
+
+    $ export CACHIX_SIGNING_KEY=${signingKey}
+    $ nix-build | cachix push ${name}
+
+To instruct Nix to use the binary cache:
+
+    $ cachix use ${name}
+
+IMPORTANT: Make sure to make a backup for the signing key above, as you have the only copy.
+  |] ::
+        Text
+    )
diff --git a/src/Cachix/Client/Command/Import.hs b/src/Cachix/Client/Command/Import.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Command/Import.hs
@@ -0,0 +1,156 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE OverloadedLabels #-}
+
+module Cachix.Client.Command.Import where
+
+import Amazonka qualified
+import Amazonka.Data.Body (ResponseBody (..))
+import Amazonka.Data.Text qualified
+import Amazonka.S3 qualified
+import Amazonka.S3.GetObject (getObjectResponse_body)
+import Amazonka.S3.ListObjectsV2 (listObjectsV2Response_contents)
+import Amazonka.S3.Types.Object (object_key)
+import Cachix.API.Error
+import Cachix.Client.Command.Push
+import Cachix.Client.Env (Env (..))
+import Cachix.Client.Exception (CachixException (..))
+import Cachix.Client.OptionsParser
+  ( PushOptions (..),
+  )
+import Cachix.Client.Push
+import Cachix.Client.Servant
+import Cachix.Client.URI (URI)
+import Cachix.Client.URI qualified as URI
+import Conduit
+import Control.Retry (defaultRetryStatus)
+import Data.Attoparsec.Text qualified
+import Data.Conduit.Combinators qualified as C
+import Data.Conduit.ConcurrentMap (concurrentMapM_)
+import Data.Conduit.List qualified as CL
+import Data.Generics.Labels ()
+import Data.Text qualified as T
+import Hercules.CNix.Store (parseStorePath)
+import Lens.Micro
+import Network.HTTP.Client qualified as HTTP
+import Network.HTTP.Types (status404)
+import Nix.NarInfo qualified as NarInfo
+import Protolude hiding (toS)
+import Protolude.Conv
+import Servant.API (NoContent (..))
+import URI.ByteString qualified as UBS
+
+discoverAwsEnv :: Maybe ByteString -> Maybe ByteString -> IO Amazonka.Env
+discoverAwsEnv maybeEndpoint maybeRegion = do
+  s3Endpoint <-
+    case maybeEndpoint of
+      Nothing -> pure Amazonka.S3.defaultService
+      Just url -> do
+        req <- HTTP.parseRequest (toS url)
+        pure $
+          Amazonka.S3.defaultService
+            & Amazonka.setEndpoint (HTTP.secure req) (HTTP.host req) (HTTP.port req)
+            -- Don't overwrite requests into the virtual-hosted style i.e. <bucket>.<host>.
+            -- This would break IP-based endpoints, like our test endpoint.
+            & #s3AddressingStyle
+              .~ Amazonka.S3AddressingStylePath
+
+  region <-
+    traverse (escalateAs (FatalError . toS) . Amazonka.Data.Text.fromText . toS) maybeRegion
+
+  -- Create a service client with the custom endpoint
+  Amazonka.newEnv Amazonka.discover
+    <&> Amazonka.configureService s3Endpoint
+      . maybe identity (#region .~) region
+
+import' :: Env -> PushOptions -> Text -> URI -> IO ()
+import' env pushOptions name s3uri = do
+  awsEnv <- discoverAwsEnv (URI.getQueryParam s3uri "endpoint") (URI.getQueryParam s3uri "region")
+  putErrText $ "Importing narinfos/nars using " <> show (numJobs pushOptions) <> " workers from " <> URI.serialize s3uri <> " to " <> name
+  putErrText ""
+  Amazonka.runResourceT $
+    runConduit $
+      Amazonka.paginate awsEnv (Amazonka.S3.newListObjectsV2 bucketName)
+        .| CL.mapMaybe (^. listObjectsV2Response_contents)
+        .| CL.concat
+        .| CL.map (^. object_key)
+        .| CL.filter (T.isSuffixOf ".narinfo" . Amazonka.Data.Text.toText)
+        .| concurrentMapM_ (numJobs pushOptions) (numJobs pushOptions * 2) (uploadNarinfo awsEnv)
+        .| CL.sinkNull
+  putErrText "All done."
+  where
+    bucketName :: Amazonka.S3.BucketName
+    bucketName = Amazonka.S3.BucketName bucketNameText
+    bucketNameText = toS $ UBS.hostBS $ URI.getHostname s3uri
+
+    getObject ::
+      (MonadResource m) =>
+      Amazonka.Env ->
+      Amazonka.S3.ObjectKey ->
+      m (ConduitT () ByteString (ResourceT IO) ())
+    getObject awsEnv key = do
+      rs <- Amazonka.send awsEnv (Amazonka.S3.newGetObject bucketName key)
+      return $ body $ rs ^. getObjectResponse_body
+
+    fileHashParse :: Text -> IO Text
+    fileHashParse s
+      | "sha256:" `T.isPrefixOf` s = return $ T.drop 7 s
+      | otherwise = throwM $ ImportUnsupportedHash $ "file hash " <> s <> " is unsupported. Leave us feedback at https://github.com/cachix/cachix/issues/601"
+
+    uploadNarinfo :: Amazonka.Env -> Amazonka.S3.ObjectKey -> ResourceT IO ()
+    uploadNarinfo awsEnv entry = liftIO $ do
+      let storeHash = T.dropEnd 8 $ Amazonka.Data.Text.toText entry
+
+      -- get narinfo
+      narinfoText <- runConduitRes $ do
+        narinfoStream <- getObject awsEnv entry
+        narinfoStream .| C.decodeUtf8 .| C.fold
+
+      -- parse narinfo
+      case Data.Attoparsec.Text.parseOnly NarInfo.parseNarInfo (toS narinfoText) of
+        Left e -> hPutStr stderr $ "error while parsing " <> storeHash <> ": " <> show e
+        Right parsedNarInfo -> do
+          -- we support only sha256: for now
+          narInfo <- do
+            fileHash <- fileHashParse $ NarInfo.fileHash parsedNarInfo
+            return $ parsedNarInfo {NarInfo.fileHash = fileHash}
+
+          -- stream nar and narinfo
+          liftIO $ withPushParams env pushOptions name $ \pushParams -> do
+            narinfoResponse <- liftIO $ narinfoExists pushParams (toS storeHash)
+            let storePathText = NarInfo.storePath narInfo
+                store = pushParamsStore pushParams
+            storePath <- liftIO $ parseStorePath store (toS storePathText)
+            let strategy = pushParamsStrategy pushParams storePath
+
+            case narinfoResponse of
+              Right NoContent -> onAlreadyPresent strategy
+              Left err
+                | isErr err status404 -> runResourceT $ do
+                    pathInfo <- newPathInfoFromNarInfo narInfo
+                    let fileSize = fromInteger $ NarInfo.fileSize narInfo
+
+                    case readMaybe (NarInfo.compression narInfo) of
+                      Nothing -> putErrText $ "Unsupported compression method: " <> NarInfo.compression narInfo
+                      Just compressionMethod -> do
+                        liftIO $ onAttempt strategy defaultRetryStatus fileSize
+
+                        narStream <- getObject awsEnv $ Amazonka.S3.ObjectKey $ NarInfo.url narInfo
+
+                        res <-
+                          runConduit $
+                            narStream
+                              .| streamCopy pushParams storePath fileSize defaultRetryStatus compressionMethod
+
+                        case res of
+                          Left uploadErr ->
+                            liftIO $ onError strategy uploadErr
+                          Right (uploadResult, uploadNarDetails) -> do
+                            -- TODO: Check that the file size matches?
+                            -- Copy over details about the NAR from the narinfo.
+                            let newNarDetails = uploadNarDetails {undNarSize = NarInfo.narSize narInfo, undNarHash = NarInfo.narHash narInfo}
+
+                            nic <- newNarInfoCreate pushParams storePath pathInfo newNarDetails
+                            completeNarUpload pushParams uploadResult nic
+
+                            liftIO $ onDone strategy
+                | otherwise -> putErrText $ show err
diff --git a/src/Cachix/Client/Command/Pin.hs b/src/Cachix/Client/Command/Pin.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Command/Pin.hs
@@ -0,0 +1,44 @@
+module Cachix.Client.Command.Pin (pin) where
+
+import Cachix.API qualified as API
+import Cachix.API.Error
+import Cachix.Client.CNix (followLinksToStorePath)
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.Env (Env (..))
+import Cachix.Client.Exception (CachixException (..))
+import Cachix.Client.OptionsParser (PinOptions (..))
+import Cachix.Client.Retry (retryHttp)
+import Cachix.Client.Servant
+import Cachix.Types.PinCreate qualified as PinCreate
+import Data.Text qualified as T
+import Hercules.CNix.Store (storePathToPath, withStore)
+import Protolude hiding (toS)
+import Protolude.Conv
+import Servant.Client.Streaming
+import System.Directory (doesFileExist)
+
+pin :: Env -> PinOptions -> IO ()
+pin env pinOpts = do
+  authToken <- Config.getAuthTokenRequired (config env)
+  storePath <- withStore $ \store -> do
+    mpath <- followLinksToStorePath store (encodeUtf8 $ pinStorePath pinOpts)
+    maybe exitFailure (storePathToPath store) mpath
+  traverse_ (validateArtifact (toS storePath)) (pinArtifacts pinOpts)
+  let pinCreate =
+        PinCreate.PinCreate
+          { name = pinName pinOpts,
+            storePath = toS storePath,
+            artifacts = pinArtifacts pinOpts,
+            keep = pinKeep pinOpts
+          }
+  void $
+    escalate <=< retryHttp $
+      (`runClientM` clientenv env) $
+        API.createPin cachixClient authToken (pinCacheName pinOpts) pinCreate
+  where
+    validateArtifact :: Text -> Text -> IO ()
+    validateArtifact storePath artifact = do
+      -- strip prefix / from artifact path if it exists
+      let artifactPath = storePath <> "/" <> fromMaybe artifact (T.stripPrefix "/" artifact)
+      exists <- doesFileExist (toS artifactPath)
+      unless exists $ throwIO $ ArtifactNotFound $ "Artifact " <> artifactPath <> " doesn't exist."
diff --git a/src/Cachix/Client/Command/Push.hs b/src/Cachix/Client/Command/Push.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Command/Push.hs
@@ -0,0 +1,244 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Cachix.Client.Command.Push
+  ( push,
+    pushStrategy,
+    withPushParams,
+    withPushParams',
+    handleCacheResponse,
+    getPushSecret,
+    getPushSecretRequired,
+  )
+where
+
+import Cachix.API qualified as API
+import Cachix.Client.CNix (filterInvalidStorePath, followLinksToStorePath)
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.Env (Env (..))
+import Cachix.Client.Exception (CachixException (..))
+import Cachix.Client.HumanSize (humanSize)
+import Cachix.Client.OptionsParser (PushOptions (..))
+import Cachix.Client.OptionsParser as Options (PushOptions (..))
+import Cachix.Client.Push as Push
+import Cachix.Client.Retry (retryHttp)
+import Cachix.Client.Secrets
+import Cachix.Client.Servant
+import Cachix.Types.BinaryCache (BinaryCacheName)
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Control.Exception.Safe (throwM)
+import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
+import Control.Retry (RetryStatus (rsIterNumber))
+import Data.ByteString qualified as BS
+import Data.Conduit qualified as Conduit
+import Data.String.Here
+import Data.Text qualified as T
+import Hercules.CNix (StorePath)
+import Hercules.CNix.Store (Store, storePathToPath, withStore)
+import Network.HTTP.Types (status401, status404)
+import Protolude hiding (toS)
+import Protolude.Conv
+import Servant.Auth ()
+import Servant.Auth.Client
+import Servant.Client.Streaming
+import Servant.Conduit ()
+import System.Console.AsciiProgress
+import System.Console.Pretty
+import System.Environment (lookupEnv)
+import System.IO (hIsTerminalDevice)
+
+push :: Env -> PushOptions -> BinaryCacheName -> [Text] -> IO ()
+push env opts name cliPaths = do
+  hasStdin <- not <$> hIsTerminalDevice stdin
+  inputStorePaths <-
+    case (hasStdin, cliPaths) of
+      (False, []) -> throwIO $ NoInput "You need to specify store paths either as stdin or as an command argument"
+      (True, []) -> T.words <$> getContents
+      -- If we get both stdin and cli args, prefer cli args.
+      -- This avoids hangs in cases where stdin is non-interactive but unused by caller
+      -- some programming environments always create a (non-interactive) stdin
+      -- that may or may not be written to by the caller.
+      -- This is somewhat like the behavior of `cat` for example.
+      (_, paths) -> return paths
+  withPushParams env opts name $ \pushParams -> do
+    normalized <- liftIO $
+      for inputStorePaths $ \path ->
+        runMaybeT $ do
+          storePath <- MaybeT $ followLinksToStorePath (pushParamsStore pushParams) (encodeUtf8 path)
+          MaybeT $ filterInvalidStorePath (pushParamsStore pushParams) storePath
+    pushedPaths <-
+      pushClosure
+        (mapConcurrentlyBounded (numJobs opts))
+        pushParams
+        (catMaybes normalized)
+    case (length normalized, length pushedPaths) of
+      (0, _) -> putErrText "Nothing to push."
+      (_, 0) -> putErrText "Nothing to push - all store paths are already on Cachix."
+      _ -> putErrText "\nAll done."
+
+pushStrategy :: Store -> Maybe Token -> PushOptions -> Text -> BinaryCache.CompressionMethod -> StorePath -> PushStrategy IO ()
+pushStrategy store authToken opts name compressionMethod storePath =
+  PushStrategy
+    { onAlreadyPresent = pass,
+      on401 = handleCacheResponse name authToken,
+      onError = throwM,
+      onAttempt = \_ _ -> pass,
+      onUncompressedNARStream = showUploadProgress,
+      onDone = pass,
+      Push.compressionMethod = compressionMethod,
+      Push.compressionLevel = Options.compressionLevel opts,
+      Push.chunkSize = Options.chunkSize opts,
+      Push.numConcurrentChunks = Options.numConcurrentChunks opts,
+      Push.omitDeriver = Options.omitDeriver opts
+    }
+  where
+    retryText :: RetryStatus -> Text
+    retryText retryStatus =
+      if rsIterNumber retryStatus == 0
+        then ""
+        else color Yellow $ "retry #" <> show (rsIterNumber retryStatus) <> " "
+
+    showUploadProgress retryStatus size = do
+      let hSize = toS $ humanSize $ fromIntegral size
+      path <- liftIO $ decodeUtf8With lenientDecode <$> storePathToPath store storePath
+
+      isTerminal <- liftIO $ hIsTerminalDevice stderr
+      isCI <- liftIO $ (== Just "true") <$> lookupEnv "CI"
+      onTick <-
+        if isTerminal && not isCI
+          then do
+            let bar = color Blue "[:bar] " <> toS (retryText retryStatus) <> toS path <> " (:percent of " <> hSize <> ")"
+                barLength = T.length $ T.replace ":percent" "  0%" (T.replace "[:bar]" "" (toS bar))
+
+            progressBar <-
+              liftIO $
+                newProgressBar
+                  def
+                    { pgTotal = fromIntegral size,
+                      -- https://github.com/yamadapc/haskell-ascii-progress/issues/24
+                      pgWidth = 20 + barLength,
+                      pgOnCompletion = Just $ color Green "✓ " <> toS path <> " (" <> hSize <> ")",
+                      pgFormat = bar
+                    }
+
+            return $ liftIO . tickN progressBar . BS.length
+          else do
+            -- we append newline instead of putStrLn due to https://github.com/haskell/text/issues/242
+            appendErrText $ retryText retryStatus <> "Pushing " <> path <> " (" <> toS hSize <> ")\n"
+            return $ const pass
+
+      Conduit.awaitForever $ \chunk -> do
+        Conduit.yield chunk
+        onTick chunk
+
+withPushParams :: Env -> PushOptions -> BinaryCacheName -> (PushParams IO () -> IO ()) -> IO ()
+withPushParams env pushOpts name m = do
+  pushSecret <- getPushSecretRequired (config env) name
+  withPushParams' env pushOpts name pushSecret m
+
+withPushParams' :: Env -> PushOptions -> BinaryCacheName -> PushSecret -> (PushParams IO () -> IO ()) -> IO ()
+withPushParams' env pushOpts name pushSecret m = do
+  let authToken = getAuthTokenFromPushSecret pushSecret
+
+  compressionMethodBackend <- case pushSecret of
+    PushSigningKey {} -> pure Nothing
+    PushToken token -> do
+      res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name
+      case res of
+        Left err -> handleCacheResponse name authToken err
+        Right binaryCache -> pure (Just $ BinaryCache.preferredCompressionMethod binaryCache)
+  let compressionMethod =
+        fromMaybe BinaryCache.ZSTD (head $ catMaybes [Options.compressionMethod pushOpts, compressionMethodBackend])
+
+  withStore $ \store ->
+    m
+      PushParams
+        { pushParamsName = name,
+          pushParamsSecret = pushSecret,
+          pushParamsClientEnv = clientenv env,
+          pushOnClosureAttempt = \full missing -> do
+            unless (null missing) $ do
+              let numMissing = length missing
+                  numCached = length full - numMissing
+              putErrText $ "Pushing " <> show numMissing <> " paths (" <> show numCached <> " are already present) using " <> T.toLower (show compressionMethod) <> " to cache " <> name <> " ⏳\n"
+            return missing,
+          pushParamsStrategy = pushStrategy store authToken pushOpts name compressionMethod,
+          pushParamsStore = store
+        }
+
+handleCacheResponse :: Text -> Maybe Token -> ClientError -> IO a
+handleCacheResponse name optionalAuthToken err
+  | isErr err status401 && isJust optionalAuthToken = throwM $ accessDeniedBinaryCache name (failureResponseBody err)
+  | isErr err status401 = throwM $ notAuthenticatedBinaryCache name
+  | isErr err status404 = throwM $ BinaryCacheNotFound $ "Binary cache " <> name <> " does not exist."
+  | otherwise = throwM err
+
+failureResponseBody :: ClientError -> Maybe ByteString
+failureResponseBody (FailureResponse _ response) = Just $ toS $ responseBody response
+failureResponseBody _ = Nothing
+
+notAuthenticatedBinaryCache :: Text -> CachixException
+notAuthenticatedBinaryCache name =
+  AccessDeniedBinaryCache $
+    "Binary cache " <> name <> " doesn't exist or it's private and you need a token: " <> Config.noAuthTokenError
+
+accessDeniedBinaryCache :: Text -> Maybe ByteString -> CachixException
+accessDeniedBinaryCache name maybeBody =
+  AccessDeniedBinaryCache $ "Binary cache " <> name <> " doesn't exist or you don't have access." <> context maybeBody
+  where
+    context Nothing = ""
+    context (Just body) = " Error: " <> toS body
+
+-- | Fetch the push credentials from the environment or config.
+getPushSecret ::
+  Config.Config ->
+  -- | Cache name
+  Text ->
+  IO (Either Text PushSecret)
+getPushSecret config name = do
+  maybeAuthToken <- Config.getAuthTokenMaybe config
+
+  maybeSigningKeyEnv <- toS <<$>> lookupEnv "CACHIX_SIGNING_KEY"
+  let maybeSigningKeyConfig = Config.secretKey <$> head (getBinaryCache config)
+
+  case maybeSigningKeyEnv <|> maybeSigningKeyConfig of
+    Just signingKey ->
+      return $ PushSigningKey (fromMaybe (Token "") maybeAuthToken) <$> parseSigningKeyLenient signingKey
+    Nothing -> case maybeAuthToken of
+      Just authToken -> return $ Right $ PushToken authToken
+      Nothing -> return $ Left msg
+  where
+    -- we reverse list of caches to prioritize keys added as last
+    getBinaryCache c =
+      reverse $
+        filter (\bc -> Config.name bc == name) (Config.binaryCaches c)
+
+    msg :: Text
+    msg =
+      [iTrim|
+Neither auth token nor signing key are present.
+
+They are looked up via $CACHIX_AUTH_TOKEN and $CACHIX_SIGNING_KEY,
+and if missing also looked up from ~/.config/cachix/cachix.dhall
+
+Read https://mycache.cachix.org for instructions how to push to your binary cache.
+    |]
+
+-- | Like 'getPushSecret', but throws a fatal error if the secret is not found.
+getPushSecretRequired ::
+  Config.Config ->
+  -- | Cache name
+  Text ->
+  -- | Secret key or exception
+  IO PushSecret
+getPushSecretRequired config name = do
+  epushSecret <- getPushSecret config name
+  case epushSecret of
+    -- TODO: technically, we're missing any credentials, not just the signing key
+    Left err -> throwIO $ NoSigningKey err
+    Right pushSecret -> return pushSecret
+
+-- | Put text to stderr without a new line.
+--
+-- This is safe to use when printing from multiple threads, unlike hPutStrLn which may fail to insert the newline at the right place.
+appendErrText :: (MonadIO m) => Text -> m ()
+appendErrText = hPutStr stderr
diff --git a/src/Cachix/Client/Command/Watch.hs b/src/Cachix/Client/Command/Watch.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Client/Command/Watch.hs
@@ -0,0 +1,193 @@
+module Cachix.Client.Command.Watch
+  ( watchExec,
+    watchStore,
+    watchExecDaemon,
+    watchExecStore,
+  )
+where
+
+import Cachix.Client.Command.Push
+import Cachix.Client.Env (Env (..))
+import Cachix.Client.InstallationMode qualified as InstallationMode
+import Cachix.Client.OptionsParser
+  ( DaemonOptions (..),
+    PushOptions (..),
+    WatchExecMode (..),
+  )
+import Cachix.Client.Push
+import Cachix.Client.WatchStore qualified as WatchStore
+import Cachix.Daemon qualified as Daemon
+import Cachix.Daemon.PostBuildHook qualified as Daemon.PostBuildHook
+import Cachix.Daemon.Progress qualified as Daemon.Progress
+import Cachix.Daemon.Types
+import Cachix.Types.BinaryCache (BinaryCacheName)
+import Conduit
+import Control.Concurrent.Async qualified as Async
+import Data.Conduit.Combinators qualified as C
+import Data.Conduit.TMChan qualified as C
+import Data.Generics.Labels ()
+import Data.HashMap.Strict as HashMap
+import Data.IORef
+import Data.Text.IO (hGetLine)
+import GHC.IO.Handle (hDuplicate, hDuplicateTo)
+import Hercules.CNix.Store (withStore)
+import Protolude hiding (toS)
+import Protolude.Conv
+import Servant.Conduit ()
+import System.Console.Pretty
+import System.Environment (getEnvironment)
+import System.IO.Error (isEOFError)
+import System.IO.Temp (withTempFile)
+import System.Posix.Signals qualified as Signals
+import System.Process qualified
+
+watchStore :: Env -> PushOptions -> Text -> IO ()
+watchStore env opts name = do
+  withPushParams env opts name $ \pushParams ->
+    WatchStore.startWorkers (pushParamsStore pushParams) (numJobs opts) pushParams
+
+-- | Run a command and upload any new paths to the binary cache.
+--
+-- In auto mode, registers a post-build hook if the user is trusted.
+-- Otherwise, falls back to watching the entire Nix store.
+watchExec :: Env -> WatchExecMode -> PushOptions -> BinaryCacheName -> Text -> [Text] -> IO ()
+watchExec env watchExecMode pushOptions cacheName cmd args = do
+  case watchExecMode of
+    PostBuildHook ->
+      watchExecDaemon env pushOptions cacheName cmd args
+    Store ->
+      watchExecStore env pushOptions cacheName cmd args
+    Auto -> do
+      nixEnv <- InstallationMode.getNixEnv
+
+      if InstallationMode.isTrusted nixEnv
+        then watchExecDaemon env pushOptions cacheName cmd args
+        else do
+          putErrText fallbackWarning
+          watchExecStore env pushOptions cacheName cmd args
+  where
+    fallbackWarning =
+      color Yellow "WARNING: " <> "failed to register a post-build hook for this command because you're not a trusted user. Falling back to watching the entire Nix store for new paths."
+
+-- | Run a command and push any new paths to the binary cache.
+--
+-- Requires the user to be a trusted user in a multi-user installation.
+watchExecDaemon :: Env -> PushOptions -> BinaryCacheName -> Text -> [Text] -> IO ()
+watchExecDaemon env pushOpts cacheName cmd args =
+  Daemon.PostBuildHook.withSetup Nothing $ \hookEnv ->
+    withTempFile (Daemon.PostBuildHook.tempDir hookEnv) "daemon-log-capture" $ \_ logHandle ->
+      withStore $ \store -> do
+        let daemonOptions = DaemonOptions {daemonSocketPath = Just (Daemon.PostBuildHook.daemonSock hookEnv)}
+        daemon <- Daemon.new env store daemonOptions (Just logHandle) pushOpts cacheName
+
+        exitCode <-
+          bracket (startDaemonThread daemon) (shutdownDaemonThread daemon logHandle) $ \_ -> do
+            processEnv <- getEnvironment
+            let newProcessEnv = Daemon.PostBuildHook.modifyEnv (Daemon.PostBuildHook.envVar hookEnv) processEnv
+            let process =
+                  (System.Process.proc (toS cmd) (toS <$> args))
+                    { System.Process.std_out = System.Process.Inherit,
+                      System.Process.env = Just newProcessEnv,
+                      System.Process.delegate_ctlc = True
+                    }
+            System.Process.withCreateProcess process $ \_ _ _ processHandle ->
+              System.Process.waitForProcess processHandle
+
+        exitWith exitCode
+  where
+    -- Launch the daemon in the background and subscribe to all push events
+    startDaemonThread daemon = do
+      daemonThread <- Async.async $ Daemon.run daemon
+      daemonChan <- Daemon.subscribe daemon
+      return (daemonThread, daemonChan)
+
+    shutdownDaemonThread daemon logHandle (daemonThread, daemonChan) = do
+      -- TODO: process and fold events into a state during command execution
+      daemonRes <- Async.withAsync (postWatchExec daemonChan) $ \_ -> do
+        Daemon.stopIO daemon
+        Async.wait daemonThread
+
+      -- Print the daemon log in case there was an internal error
+      case toExitCode daemonRes of
+        ExitFailure _ -> printLog logHandle
+        ExitSuccess -> return ()
+
+    postWatchExec chan = do
+      statsRef <- newIORef HashMap.empty
+      runConduit $
+        C.sourceTMChan chan
+          .| C.mapM_ (displayPushEvent statsRef)
+
+    -- Deduplicate events by path and retry status
+    displayPushEvent statsRef PushEvent {eventMessage} = liftIO $ do
+      stats <- readIORef statsRef
+      case eventMessage of
+        PushStorePathAttempt path pathSize retryStatus -> do
+          case HashMap.lookup path stats of
+            Just (progress, prevRetryStatus)
+              | prevRetryStatus == retryStatus -> return ()
+              | otherwise -> do
+                  newProgress <- Daemon.Progress.update progress retryStatus
+                  writeIORef statsRef $ HashMap.insert path (newProgress, retryStatus) stats
+            Nothing -> do
+              progress <- Daemon.Progress.new stderr (toS path) pathSize retryStatus
+              writeIORef statsRef $ HashMap.insert path (progress, retryStatus) stats
+        PushStorePathProgress path _ newBytes -> do
+          case HashMap.lookup path stats of
+            Nothing -> return ()
+            Just (progress, _) -> Daemon.Progress.tick progress newBytes
+        PushStorePathDone path -> do
+          mapM_ (Daemon.Progress.complete . fst) (HashMap.lookup path stats)
+        PushStorePathFailed path _ -> do
+          mapM_ (Daemon.Progress.fail . fst) (HashMap.lookup path stats)
+        _ -> return ()
+
+    printLog h = getLineLoop
+      where
+        getLineLoop = do
+          eline <- try $ hGetLine h
+          case eline of
+            Left e
+              | isEOFError e -> return ()
+              | otherwise -> putErrText $ "Error reading daemon log: " <> show e
+            Right line -> do
+              hPutStr stderr line
+              getLineLoop
+
+-- | Runs a command while watching the entire Nix store and pushing any new paths.
+--
+-- Prefer to use the more granular 'watchExecDaemon' whenever possible.
+watchExecStore :: Env -> PushOptions -> BinaryCacheName -> Text -> [Text] -> IO ()
+watchExecStore env pushOpts name cmd args =
+  withPushParams env pushOpts name $ \pushParams -> do
+    stdoutOriginal <- hDuplicate stdout
+    let process =
+          (System.Process.proc (toS cmd) (toS <$> args))
+            { System.Process.std_out = System.Process.UseHandle stdoutOriginal
+            }
+        watch = do
+          hDuplicateTo stderr stdout -- redirect all stdout to stderr
+          WatchStore.startWorkers (pushParamsStore pushParams) (numJobs pushOpts) pushParams
+
+    (_, exitCode) <-
+      Async.concurrently watch $ do
+        exitCode <-
+          bracketOnError
+            (getProcessHandle <$> System.Process.createProcess process)
+            ( \processHandle -> do
+                -- Terminate the process
+                uninterruptibleMask_ (System.Process.terminateProcess processHandle)
+                -- Wait for the process to clean up and exit
+                _ <- System.Process.waitForProcess processHandle
+                -- Stop watching the store and wait for all paths to be pushed
+                Signals.raiseSignal Signals.sigINT
+            )
+            System.Process.waitForProcess
+
+        -- Stop watching the store and wait for all paths to be pushed
+        Signals.raiseSignal Signals.sigINT
+        return exitCode
+
+    exitWith exitCode
+  where
+    getProcessHandle (_, _, _, processHandle) = processHandle
diff --git a/src/Cachix/Client/Commands.hs b/src/Cachix/Client/Commands.hs
deleted file mode 100644
--- a/src/Cachix/Client/Commands.hs
+++ /dev/null
@@ -1,494 +0,0 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE OverloadedLabels #-}
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE TypeOperators #-}
-
-module Cachix.Client.Commands
-  ( authtoken,
-    generateKeypair,
-    push,
-    watchStore,
-    watchExec,
-    watchExecDaemon,
-    watchExecStore,
-    use,
-    import',
-    remove,
-    pin,
-  )
-where
-
-import qualified Amazonka
-import Amazonka.Data.Body (ResponseBody (..))
-import qualified Amazonka.Data.Text
-import qualified Amazonka.S3
-import Amazonka.S3.GetObject (getObjectResponse_body)
-import Amazonka.S3.ListObjectsV2 (listObjectsV2Response_contents)
-import Amazonka.S3.Types.Object (object_key)
-import qualified Cachix.API as API
-import Cachix.API.Error
-import Cachix.Client.CNix (filterInvalidStorePath, followLinksToStorePath)
-import Cachix.Client.Commands.Push
-import qualified Cachix.Client.Config as Config
-import qualified Cachix.Client.Daemon as Daemon
-import qualified Cachix.Client.Daemon.PostBuildHook as Daemon.PostBuildHook
-import qualified Cachix.Client.Daemon.Progress as Daemon.Progress
-import Cachix.Client.Daemon.Types
-import Cachix.Client.Env (Env (..))
-import Cachix.Client.Exception (CachixException (..))
-import qualified Cachix.Client.InstallationMode as InstallationMode
-import qualified Cachix.Client.NixConf as NixConf
-import Cachix.Client.NixVersion (assertNixVersion)
-import Cachix.Client.OptionsParser
-  ( DaemonOptions (..),
-    PinOptions (..),
-    PushArguments (..),
-    PushOptions (..),
-  )
-import Cachix.Client.Push
-import Cachix.Client.Retry (retryHttp)
-import Cachix.Client.Secrets
-  ( SigningKey (SigningKey),
-    exportSigningKey,
-  )
-import Cachix.Client.Servant
-import Cachix.Client.URI (URI)
-import qualified Cachix.Client.URI as URI
-import qualified Cachix.Client.WatchStore as WatchStore
-import Cachix.Types.BinaryCache (BinaryCacheName)
-import qualified Cachix.Types.PinCreate as PinCreate
-import qualified Cachix.Types.SigningKeyCreate as SigningKeyCreate
-import Conduit
-import qualified Control.Concurrent.Async as Async
-import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
-import Control.Retry (defaultRetryStatus)
-import Crypto.Sign.Ed25519 (PublicKey (PublicKey), createKeypair)
-import qualified Data.Attoparsec.Text
-import qualified Data.ByteString.Base64 as B64
-import qualified Data.Conduit.Combinators as C
-import Data.Conduit.ConcurrentMap (concurrentMapM_)
-import qualified Data.Conduit.List as CL
-import qualified Data.Conduit.TMChan as C
-import Data.Generics.Labels ()
-import Data.HashMap.Strict as HashMap
-import Data.IORef
-import Data.String.Here
-import qualified Data.Text as T
-import Data.Text.IO (hGetLine)
-import qualified Data.Text.IO as T.IO
-import GHC.IO.Handle (hDuplicate, hDuplicateTo)
-import Hercules.CNix.Store (parseStorePath, storePathToPath, withStore)
-import Lens.Micro
-import qualified Network.HTTP.Client as HTTP
-import Network.HTTP.Types (status404)
-import qualified Nix.NarInfo as NarInfo
-import Protolude hiding (toS)
-import Protolude.Conv
-import Servant.API (NoContent (..))
-import Servant.Auth.Client
-import Servant.Client.Streaming
-import Servant.Conduit ()
-import System.Console.Pretty
-import System.Directory (doesFileExist)
-import System.Environment (getEnvironment)
-import System.IO (hIsTerminalDevice)
-import System.IO.Error (isEOFError)
-import System.IO.Temp (withTempFile)
-import qualified System.Posix.Signals as Signals
-import qualified System.Process
-import qualified URI.ByteString as UBS
-
--- TODO: check that token actually authenticates!
-authtoken :: Env -> Maybe Text -> IO ()
-authtoken Env {cachixoptions} (Just token) = do
-  let configPath = Config.configPath cachixoptions
-  config <- Config.getConfig configPath
-  Config.writeConfig configPath $ config {Config.authToken = Token (toS token)}
-authtoken env Nothing = authtoken env . Just . T.strip =<< T.IO.getContents
-
-generateKeypair :: Env -> Text -> IO ()
-generateKeypair env name = do
-  authToken <- Config.getAuthTokenRequired (config env)
-  (PublicKey pk, sk) <- createKeypair
-  let signingKey = exportSigningKey $ SigningKey sk
-      signingKeyCreate = SigningKeyCreate.SigningKeyCreate (toS $ B64.encode pk)
-      bcc = Config.BinaryCacheConfig name signingKey
-  -- we first validate if key can be added to the binary cache
-  (_ :: NoContent) <-
-    escalate <=< retryHttp $
-      (`runClientM` clientenv env) $
-        API.createKey cachixClient authToken name signingKeyCreate
-  -- if key was successfully added, write it to the config
-  -- TODO: warn if binary cache with the same key already exists
-  let cfg = config env & Config.setBinaryCaches [bcc]
-  Config.writeConfig (Config.configPath (cachixoptions env)) cfg
-  putStrLn
-    ( [iTrim|
-Secret signing key has been saved in the file above. To populate
-your binary cache:
-
-    $ nix-build | cachix push ${name}
-
-Or if you'd like to use the signing key on another machine or CI:
-
-    $ export CACHIX_SIGNING_KEY=${signingKey}
-    $ nix-build | cachix push ${name}
-
-To instruct Nix to use the binary cache:
-
-    $ cachix use ${name}
-
-IMPORTANT: Make sure to make a backup for the signing key above, as you have the only copy.
-  |] ::
-        Text
-    )
-
-getNixEnv :: IO InstallationMode.NixEnv
-getNixEnv = do
-  user <- InstallationMode.getUser
-  nc <- NixConf.read NixConf.Global
-  isTrusted <- InstallationMode.isTrustedUser $ NixConf.readLines (catMaybes [nc]) NixConf.isTrustedUsers
-  isNixOS <- doesFileExist "/run/current-system/nixos-version"
-  return $
-    InstallationMode.NixEnv
-      { InstallationMode.isRoot = user == "root",
-        InstallationMode.isTrusted = isTrusted,
-        InstallationMode.isNixOS = isNixOS
-      }
-
-use :: Env -> Text -> InstallationMode.UseOptions -> IO ()
-use env name useOptions = do
-  optionalAuthToken <- Config.getAuthTokenMaybe (config env)
-  let token = fromMaybe (Token "") optionalAuthToken
-  -- 1. get cache public key
-  res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name
-  case res of
-    Left err -> handleCacheResponse name optionalAuthToken err
-    Right binaryCache -> do
-      () <- escalateAs UnsupportedNixVersion =<< assertNixVersion
-      nixEnv <- getNixEnv
-      InstallationMode.addBinaryCache (config env) binaryCache useOptions $
-        InstallationMode.getInstallationMode nixEnv useOptions
-
-remove :: Env -> Text -> IO ()
-remove env name = do
-  nixEnv <- getNixEnv
-  InstallationMode.removeBinaryCache (Config.hostname $ config env) name $
-    InstallationMode.getInstallationMode nixEnv InstallationMode.defaultUseOptions
-
-push :: Env -> PushArguments -> IO ()
-push env (PushPaths opts name cliPaths) = do
-  hasStdin <- not <$> hIsTerminalDevice stdin
-  inputStorePaths <-
-    case (hasStdin, cliPaths) of
-      (False, []) -> throwIO $ NoInput "You need to specify store paths either as stdin or as an command argument"
-      (True, []) -> T.words <$> getContents
-      -- If we get both stdin and cli args, prefer cli args.
-      -- This avoids hangs in cases where stdin is non-interactive but unused by caller
-      -- some programming environments always create a (non-interactive) stdin
-      -- that may or may not be written to by the caller.
-      -- This is somewhat like the behavior of `cat` for example.
-      (_, paths) -> return paths
-  withPushParams env opts name $ \pushParams -> do
-    normalized <- liftIO $
-      for inputStorePaths $ \path ->
-        runMaybeT $ do
-          storePath <- MaybeT $ followLinksToStorePath (pushParamsStore pushParams) (encodeUtf8 path)
-          MaybeT $ filterInvalidStorePath (pushParamsStore pushParams) storePath
-    pushedPaths <-
-      pushClosure
-        (mapConcurrentlyBounded (numJobs opts))
-        pushParams
-        (catMaybes normalized)
-    case (length normalized, length pushedPaths) of
-      (0, _) -> putErrText "Nothing to push."
-      (_, 0) -> putErrText "Nothing to push - all store paths are already on Cachix."
-      _ -> putErrText "\nAll done."
-push _ _ =
-  throwIO $
-    DeprecatedCommand "DEPRECATED: cachix watch-store has replaced cachix push --watch-store."
-
-discoverAwsEnv :: Maybe ByteString -> Maybe ByteString -> IO Amazonka.Env
-discoverAwsEnv maybeEndpoint maybeRegion = do
-  s3Endpoint <-
-    case maybeEndpoint of
-      Nothing -> pure Amazonka.S3.defaultService
-      Just url -> do
-        req <- HTTP.parseRequest (toS url)
-        pure $
-          Amazonka.S3.defaultService
-            & Amazonka.setEndpoint (HTTP.secure req) (HTTP.host req) (HTTP.port req)
-            -- Don't overwrite requests into the virtual-hosted style i.e. <bucket>.<host>.
-            -- This would break IP-based endpoints, like our test endpoint.
-            & #s3AddressingStyle .~ Amazonka.S3AddressingStylePath
-
-  region <-
-    traverse (escalateAs (FatalError . toS) . Amazonka.Data.Text.fromText . toS) maybeRegion
-
-  -- Create a service client with the custom endpoint
-  Amazonka.newEnv Amazonka.discover
-    <&> Amazonka.configureService s3Endpoint
-      . maybe identity (#region .~) region
-
-import' :: Env -> PushOptions -> Text -> URI -> IO ()
-import' env pushOptions name s3uri = do
-  awsEnv <- discoverAwsEnv (URI.getQueryParam s3uri "endpoint") (URI.getQueryParam s3uri "region")
-  putErrText $ "Importing narinfos/nars using " <> show (numJobs pushOptions) <> " workers from " <> URI.serialize s3uri <> " to " <> name
-  putErrText ""
-  Amazonka.runResourceT $
-    runConduit $
-      Amazonka.paginate awsEnv (Amazonka.S3.newListObjectsV2 bucketName)
-        .| CL.mapMaybe (^. listObjectsV2Response_contents)
-        .| CL.concat
-        .| CL.map (^. object_key)
-        .| CL.filter (T.isSuffixOf ".narinfo" . Amazonka.Data.Text.toText)
-        .| concurrentMapM_ (numJobs pushOptions) (numJobs pushOptions * 2) (uploadNarinfo awsEnv)
-        .| CL.sinkNull
-  putErrText "All done."
-  where
-    bucketName :: Amazonka.S3.BucketName
-    bucketName = Amazonka.S3.BucketName bucketNameText
-    bucketNameText = toS $ UBS.hostBS $ URI.getHostname s3uri
-
-    getObject ::
-      (MonadResource m) =>
-      Amazonka.Env ->
-      Amazonka.S3.ObjectKey ->
-      m (ConduitT () ByteString (ResourceT IO) ())
-    getObject awsEnv key = do
-      rs <- Amazonka.send awsEnv (Amazonka.S3.newGetObject bucketName key)
-      return $ body $ rs ^. getObjectResponse_body
-
-    fileHashParse :: Text -> IO Text
-    fileHashParse s
-      | "sha256:" `T.isPrefixOf` s = return $ T.drop 7 s
-      | otherwise = throwM $ ImportUnsupportedHash $ "file hash " <> s <> " is unsupported. Leave us feedback at https://github.com/cachix/cachix/issues/601"
-
-    uploadNarinfo :: Amazonka.Env -> Amazonka.S3.ObjectKey -> ResourceT IO ()
-    uploadNarinfo awsEnv entry = liftIO $ do
-      let storeHash = T.dropEnd 8 $ Amazonka.Data.Text.toText entry
-
-      -- get narinfo
-      narinfoText <- runConduitRes $ do
-        narinfoStream <- getObject awsEnv entry
-        narinfoStream .| C.decodeUtf8 .| C.fold
-
-      -- parse narinfo
-      case Data.Attoparsec.Text.parseOnly NarInfo.parseNarInfo (toS narinfoText) of
-        Left e -> hPutStr stderr $ "error while parsing " <> storeHash <> ": " <> show e
-        Right parsedNarInfo -> do
-          -- we support only sha256: for now
-          narInfo <- do
-            fileHash <- fileHashParse $ NarInfo.fileHash parsedNarInfo
-            return $ parsedNarInfo {NarInfo.fileHash = fileHash}
-
-          -- stream nar and narinfo
-          liftIO $ withPushParams env pushOptions name $ \pushParams -> do
-            narinfoResponse <- liftIO $ narinfoExists pushParams (toS storeHash)
-            let storePathText = NarInfo.storePath narInfo
-                store = pushParamsStore pushParams
-            storePath <- liftIO $ parseStorePath store (toS storePathText)
-            let strategy = pushParamsStrategy pushParams storePath
-
-            case narinfoResponse of
-              Right NoContent -> onAlreadyPresent strategy
-              Left err
-                | isErr err status404 -> runResourceT $ do
-                    pathInfo <- newPathInfoFromNarInfo narInfo
-                    let fileSize = fromInteger $ NarInfo.fileSize narInfo
-
-                    case readMaybe (NarInfo.compression narInfo) of
-                      Nothing -> putErrText $ "Unsupported compression method: " <> NarInfo.compression narInfo
-                      Just compressionMethod -> do
-                        liftIO $ onAttempt strategy defaultRetryStatus fileSize
-
-                        narStream <- getObject awsEnv $ Amazonka.S3.ObjectKey $ NarInfo.url narInfo
-
-                        res <-
-                          runConduit $
-                            narStream
-                              .| streamCopy pushParams storePath fileSize defaultRetryStatus compressionMethod
-
-                        case res of
-                          Left uploadErr ->
-                            liftIO $ onError strategy uploadErr
-                          Right (uploadResult, uploadNarDetails) -> do
-                            -- TODO: Check that the file size matches?
-                            -- Copy over details about the NAR from the narinfo.
-                            let newNarDetails = uploadNarDetails {undNarSize = NarInfo.narSize narInfo, undNarHash = NarInfo.narHash narInfo}
-
-                            nic <- newNarInfoCreate pushParams storePath pathInfo newNarDetails
-                            completeNarUpload pushParams uploadResult nic
-
-                            liftIO $ onDone strategy
-                | otherwise -> putErrText $ show err
-
-pin :: Env -> PinOptions -> IO ()
-pin env pinOpts = do
-  authToken <- Config.getAuthTokenRequired (config env)
-  storePath <- withStore $ \store -> do
-    mpath <- followLinksToStorePath store (encodeUtf8 $ pinStorePath pinOpts)
-    maybe exitFailure (storePathToPath store) mpath
-  traverse_ (validateArtifact (toS storePath)) (pinArtifacts pinOpts)
-  let pinCreate =
-        PinCreate.PinCreate
-          { name = pinName pinOpts,
-            storePath = toS storePath,
-            artifacts = pinArtifacts pinOpts,
-            keep = pinKeep pinOpts
-          }
-  void $
-    escalate <=< retryHttp $
-      (`runClientM` clientenv env) $
-        API.createPin cachixClient authToken (pinCacheName pinOpts) pinCreate
-  where
-    validateArtifact :: Text -> Text -> IO ()
-    validateArtifact storePath artifact = do
-      -- strip prefix / from artifact path if it exists
-      let artifactPath = storePath <> "/" <> fromMaybe artifact (T.stripPrefix "/" artifact)
-      exists <- doesFileExist (toS artifactPath)
-      unless exists $ throwIO $ ArtifactNotFound $ "Artifact " <> artifactPath <> " doesn't exist."
-
-watchStore :: Env -> PushOptions -> Text -> IO ()
-watchStore env opts name = do
-  withPushParams env opts name $ \pushParams ->
-    WatchStore.startWorkers (pushParamsStore pushParams) (numJobs opts) pushParams
-
--- | Run a command and upload any new paths to the binary cache.
---
--- Registers a post-build hook if the user is trusted.
--- Otherwise, falls back to watching the entire Nix store.
-watchExec :: Env -> PushOptions -> BinaryCacheName -> Text -> [Text] -> IO ()
-watchExec env pushOptions cacheName cmd args = do
-  nixEnv <- getNixEnv
-
-  if InstallationMode.isTrusted nixEnv
-    then watchExecDaemon env pushOptions cacheName cmd args
-    else do
-      putErrText fallbackWarning
-      watchExecStore env pushOptions cacheName cmd args
-  where
-    fallbackWarning =
-      color Yellow "WARNING: " <> "failed to register a post-build hook for this command because you're not a trusted user. Falling back to watching the entire Nix store for new paths."
-
--- | Run a command and push any new paths to the binary cache.
---
--- Requires the user to be a trusted user in a multi-user installation.
-watchExecDaemon :: Env -> PushOptions -> BinaryCacheName -> Text -> [Text] -> IO ()
-watchExecDaemon env pushOpts cacheName cmd args =
-  Daemon.PostBuildHook.withSetup Nothing $ \hookEnv ->
-    withTempFile (Daemon.PostBuildHook.tempDir hookEnv) "daemon-log-capture" $ \_ logHandle -> do
-      let daemonOptions = DaemonOptions {daemonSocketPath = Just (Daemon.PostBuildHook.daemonSock hookEnv)}
-      daemon <- Daemon.new env daemonOptions (Just logHandle) pushOpts cacheName
-
-      exitCode <-
-        bracket (startDaemonThread daemon) (shutdownDaemonThread daemon logHandle) $ \_ -> do
-          processEnv <- getEnvironment
-          let newProcessEnv = Daemon.PostBuildHook.modifyEnv (Daemon.PostBuildHook.envVar hookEnv) processEnv
-          let process =
-                (System.Process.proc (toS cmd) (toS <$> args))
-                  { System.Process.std_out = System.Process.Inherit,
-                    System.Process.env = Just newProcessEnv,
-                    System.Process.delegate_ctlc = True
-                  }
-          System.Process.withCreateProcess process $ \_ _ _ processHandle ->
-            System.Process.waitForProcess processHandle
-
-      exitWith exitCode
-  where
-    -- Launch the daemon in the background and subscribe to all push events
-    startDaemonThread daemon = do
-      daemonThread <- Async.async $ Daemon.run daemon
-      daemonChan <- Daemon.subscribe daemon
-      return (daemonThread, daemonChan)
-
-    shutdownDaemonThread daemon logHandle (daemonThread, daemonChan) = do
-      -- TODO: process and fold events into a state during command execution
-      daemonExitCode <- Async.withAsync (postWatchExec daemonChan) $ \_ -> do
-        Daemon.stopIO daemon
-        Async.wait daemonThread
-
-      -- Print the daemon log in case there was an internal error
-      case daemonExitCode of
-        ExitFailure _ -> printLog logHandle
-        ExitSuccess -> return ()
-
-    postWatchExec chan = do
-      statsRef <- newIORef HashMap.empty
-      runConduit $
-        C.sourceTMChan chan
-          .| C.mapM_ (displayPushEvent statsRef)
-
-    -- Deduplicate events by path and retry status
-    displayPushEvent statsRef PushEvent {eventMessage} = liftIO $ do
-      stats <- readIORef statsRef
-      case eventMessage of
-        PushStorePathAttempt path pathSize retryStatus -> do
-          case HashMap.lookup path stats of
-            Just (progress, prevRetryStatus)
-              | prevRetryStatus == retryStatus -> return ()
-              | otherwise -> do
-                  newProgress <- Daemon.Progress.update progress retryStatus
-                  writeIORef statsRef $ HashMap.insert path (newProgress, retryStatus) stats
-            Nothing -> do
-              progress <- Daemon.Progress.new stderr (toS path) pathSize retryStatus
-              writeIORef statsRef $ HashMap.insert path (progress, retryStatus) stats
-        PushStorePathProgress path _ newBytes -> do
-          case HashMap.lookup path stats of
-            Nothing -> return ()
-            Just (progress, _) -> Daemon.Progress.tick progress newBytes
-        PushStorePathDone path -> do
-          mapM_ (Daemon.Progress.complete . fst) (HashMap.lookup path stats)
-        PushStorePathFailed path _ -> do
-          mapM_ (Daemon.Progress.fail . fst) (HashMap.lookup path stats)
-        _ -> return ()
-
-    printLog h = getLineLoop
-      where
-        getLineLoop = do
-          eline <- try $ hGetLine h
-          case eline of
-            Left e
-              | isEOFError e -> return ()
-              | otherwise -> putErrText $ "Error reading daemon log: " <> show e
-            Right line -> do
-              hPutStr stderr line
-              getLineLoop
-
--- | Runs a command while watching the entire Nix store and pushing any new paths.
---
--- Prefer to use the more granular 'watchExecDaemon' whenever possible.
-watchExecStore :: Env -> PushOptions -> BinaryCacheName -> Text -> [Text] -> IO ()
-watchExecStore env pushOpts name cmd args =
-  withPushParams env pushOpts name $ \pushParams -> do
-    stdoutOriginal <- hDuplicate stdout
-    let process =
-          (System.Process.proc (toS cmd) (toS <$> args))
-            { System.Process.std_out = System.Process.UseHandle stdoutOriginal
-            }
-        watch = do
-          hDuplicateTo stderr stdout -- redirect all stdout to stderr
-          WatchStore.startWorkers (pushParamsStore pushParams) (numJobs pushOpts) pushParams
-
-    (_, exitCode) <-
-      Async.concurrently watch $ do
-        exitCode <-
-          bracketOnError
-            (getProcessHandle <$> System.Process.createProcess process)
-            ( \processHandle -> do
-                -- Terminate the process
-                uninterruptibleMask_ (System.Process.terminateProcess processHandle)
-                -- Wait for the process to clean up and exit
-                _ <- System.Process.waitForProcess processHandle
-                -- Stop watching the store and wait for all paths to be pushed
-                Signals.raiseSignal Signals.sigINT
-            )
-            System.Process.waitForProcess
-
-        -- Stop watching the store and wait for all paths to be pushed
-        Signals.raiseSignal Signals.sigINT
-        return exitCode
-
-    exitWith exitCode
-  where
-    getProcessHandle (_, _, _, processHandle) = processHandle
diff --git a/src/Cachix/Client/Commands/Push.hs b/src/Cachix/Client/Commands/Push.hs
deleted file mode 100644
--- a/src/Cachix/Client/Commands/Push.hs
+++ /dev/null
@@ -1,212 +0,0 @@
-{-# LANGUAGE QuasiQuotes #-}
-
-module Cachix.Client.Commands.Push
-  ( pushStrategy,
-    withPushParams,
-    withPushParams',
-    handleCacheResponse,
-    getPushSecret,
-    getPushSecretRequired,
-  )
-where
-
-import qualified Cachix.API as API
-import qualified Cachix.Client.Config as Config
-import Cachix.Client.Env (Env (..))
-import Cachix.Client.Exception (CachixException (..))
-import Cachix.Client.HumanSize (humanSize)
-import Cachix.Client.OptionsParser as Options
-  ( PushOptions (..),
-  )
-import Cachix.Client.Push as Push
-import Cachix.Client.Retry (retryHttp)
-import Cachix.Client.Secrets
-import Cachix.Client.Servant
-import Cachix.Types.BinaryCache (BinaryCacheName)
-import qualified Cachix.Types.BinaryCache as BinaryCache
-import Control.Exception.Safe (throwM)
-import Control.Retry (RetryStatus (rsIterNumber))
-import qualified Data.ByteString as BS
-import qualified Data.Conduit as Conduit
-import Data.String.Here
-import qualified Data.Text as T
-import Hercules.CNix (StorePath)
-import Hercules.CNix.Store (Store, storePathToPath, withStore)
-import Network.HTTP.Types (status401, status404)
-import Protolude hiding (toS)
-import Protolude.Conv
-import Servant.Auth ()
-import Servant.Auth.Client
-import Servant.Client.Streaming
-import Servant.Conduit ()
-import System.Console.AsciiProgress
-import System.Console.Pretty
-import System.Environment (lookupEnv)
-import System.IO (hIsTerminalDevice)
-
-pushStrategy :: Store -> Maybe Token -> PushOptions -> Text -> BinaryCache.CompressionMethod -> StorePath -> PushStrategy IO ()
-pushStrategy store authToken opts name compressionMethod storePath =
-  PushStrategy
-    { onAlreadyPresent = pass,
-      on401 = handleCacheResponse name authToken,
-      onError = throwM,
-      onAttempt = \_ _ -> pass,
-      onUncompressedNARStream = showUploadProgress,
-      onDone = pass,
-      Push.compressionMethod = compressionMethod,
-      Push.compressionLevel = Options.compressionLevel opts,
-      Push.chunkSize = Options.chunkSize opts,
-      Push.numConcurrentChunks = Options.numConcurrentChunks opts,
-      Push.omitDeriver = Options.omitDeriver opts
-    }
-  where
-    retryText :: RetryStatus -> Text
-    retryText retryStatus =
-      if rsIterNumber retryStatus == 0
-        then ""
-        else color Yellow $ "retry #" <> show (rsIterNumber retryStatus) <> " "
-
-    showUploadProgress retryStatus size = do
-      let hSize = toS $ humanSize $ fromIntegral size
-      path <- liftIO $ decodeUtf8With lenientDecode <$> storePathToPath store storePath
-
-      isTerminal <- liftIO $ hIsTerminalDevice stdout
-      onTick <-
-        if isTerminal
-          then do
-            let bar = color Blue "[:bar] " <> toS (retryText retryStatus) <> toS path <> " (:percent of " <> hSize <> ")"
-                barLength = T.length $ T.replace ":percent" "  0%" (T.replace "[:bar]" "" (toS bar))
-
-            progressBar <-
-              liftIO $
-                newProgressBar
-                  def
-                    { pgTotal = fromIntegral size,
-                      -- https://github.com/yamadapc/haskell-ascii-progress/issues/24
-                      pgWidth = 20 + barLength,
-                      pgOnCompletion = Just $ color Green "✓ " <> toS path <> " (" <> hSize <> ")",
-                      pgFormat = bar
-                    }
-
-            return $ liftIO . tickN progressBar . BS.length
-          else do
-            -- we append newline instead of putStrLn due to https://github.com/haskell/text/issues/242
-            appendErrText $ retryText retryStatus <> "Pushing " <> path <> " (" <> toS hSize <> ")\n"
-            return $ const pass
-
-      Conduit.awaitForever $ \chunk -> do
-        Conduit.yield chunk
-        onTick chunk
-
-withPushParams :: Env -> PushOptions -> BinaryCacheName -> (PushParams IO () -> IO ()) -> IO ()
-withPushParams env pushOpts name m = do
-  pushSecret <- getPushSecretRequired (config env) name
-  withPushParams' env pushOpts name pushSecret m
-
-withPushParams' :: Env -> PushOptions -> BinaryCacheName -> PushSecret -> (PushParams IO () -> IO ()) -> IO ()
-withPushParams' env pushOpts name pushSecret m = do
-  let authToken = getAuthTokenFromPushSecret pushSecret
-
-  compressionMethodBackend <- case pushSecret of
-    PushSigningKey {} -> pure Nothing
-    PushToken token -> do
-      res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name
-      case res of
-        Left err -> handleCacheResponse name authToken err
-        Right binaryCache -> pure (Just $ BinaryCache.preferredCompressionMethod binaryCache)
-  let compressionMethod =
-        fromMaybe BinaryCache.ZSTD (head $ catMaybes [Options.compressionMethod pushOpts, compressionMethodBackend])
-
-  withStore $ \store ->
-    m
-      PushParams
-        { pushParamsName = name,
-          pushParamsSecret = pushSecret,
-          pushParamsClientEnv = clientenv env,
-          pushOnClosureAttempt = \full missing -> do
-            unless (null missing) $ do
-              let numMissing = length missing
-                  numCached = length full - numMissing
-              putErrText $ "Pushing " <> show numMissing <> " paths (" <> show numCached <> " are already present) using " <> T.toLower (show compressionMethod) <> " to cache " <> name <> " ⏳\n"
-            return missing,
-          pushParamsStrategy = pushStrategy store authToken pushOpts name compressionMethod,
-          pushParamsStore = store
-        }
-
-handleCacheResponse :: Text -> Maybe Token -> ClientError -> IO a
-handleCacheResponse name optionalAuthToken err
-  | isErr err status401 && isJust optionalAuthToken = throwM $ accessDeniedBinaryCache name (failureResponseBody err)
-  | isErr err status401 = throwM $ notAuthenticatedBinaryCache name
-  | isErr err status404 = throwM $ BinaryCacheNotFound $ "Binary cache " <> name <> " does not exist."
-  | otherwise = throwM err
-
-failureResponseBody :: ClientError -> Maybe ByteString
-failureResponseBody (FailureResponse _ response) = Just $ toS $ responseBody response
-failureResponseBody _ = Nothing
-
-notAuthenticatedBinaryCache :: Text -> CachixException
-notAuthenticatedBinaryCache name =
-  AccessDeniedBinaryCache $
-    "Binary cache " <> name <> " doesn't exist or it's private and you need a token: " <> Config.noAuthTokenError
-
-accessDeniedBinaryCache :: Text -> Maybe ByteString -> CachixException
-accessDeniedBinaryCache name maybeBody =
-  AccessDeniedBinaryCache $ "Binary cache " <> name <> " doesn't exist or you don't have access." <> context maybeBody
-  where
-    context Nothing = ""
-    context (Just body) = " Error: " <> toS body
-
--- | Fetch the push credentials from the environment or config.
-getPushSecret ::
-  Config.Config ->
-  -- | Cache name
-  Text ->
-  IO (Either Text PushSecret)
-getPushSecret config name = do
-  maybeAuthToken <- Config.getAuthTokenMaybe config
-
-  maybeSigningKeyEnv <- toS <<$>> lookupEnv "CACHIX_SIGNING_KEY"
-  let maybeSigningKeyConfig = Config.secretKey <$> head (getBinaryCache config)
-
-  case maybeSigningKeyEnv <|> maybeSigningKeyConfig of
-    Just signingKey ->
-      return $ PushSigningKey (fromMaybe (Token "") maybeAuthToken) <$> parseSigningKeyLenient signingKey
-    Nothing -> case maybeAuthToken of
-      Just authToken -> return $ Right $ PushToken authToken
-      Nothing -> return $ Left msg
-  where
-    -- we reverse list of caches to prioritize keys added as last
-    getBinaryCache c =
-      reverse $
-        filter (\bc -> Config.name bc == name) (Config.binaryCaches c)
-
-    msg :: Text
-    msg =
-      [iTrim|
-Neither auth token nor signing key are present.
-
-They are looked up via $CACHIX_AUTH_TOKEN and $CACHIX_SIGNING_KEY,
-and if missing also looked up from ~/.config/cachix/cachix.dhall
-
-Read https://mycache.cachix.org for instructions how to push to your binary cache.
-    |]
-
--- | Like 'getPushSecret', but throws a fatal error if the secret is not found.
-getPushSecretRequired ::
-  Config.Config ->
-  -- | Cache name
-  Text ->
-  -- | Secret key or exception
-  IO PushSecret
-getPushSecretRequired config name = do
-  epushSecret <- getPushSecret config name
-  case epushSecret of
-    -- TODO: technically, we're missing any credentials, not just the signing key
-    Left err -> throwIO $ NoSigningKey err
-    Right pushSecret -> return pushSecret
-
--- | Put text to stderr without a new line.
---
--- This is safe to use when printing from multiple threads, unlike hPutStrLn which may fail to insert the newline at the right place.
-appendErrText :: (MonadIO m) => Text -> m ()
-appendErrText = hPutStr stderr
diff --git a/src/Cachix/Client/Config.hs b/src/Cachix/Client/Config.hs
--- a/src/Cachix/Client/Config.hs
+++ b/src/Cachix/Client/Config.hs
@@ -28,14 +28,14 @@
 import Cachix.Client.Config.Orphans ()
 import Cachix.Client.Exception (CachixException (..))
 import Cachix.Client.URI as URI
-import qualified Control.Exception.Safe as Safe
+import Control.Exception.Safe qualified as Safe
 import Data.Either.Extra (eitherToMaybe)
 import Data.String.Here
-import qualified Dhall
-import qualified Dhall.Pretty
-import qualified Options.Applicative as Opt
-import qualified Prettyprinter as Pretty
-import qualified Prettyprinter.Render.Text as Pretty
+import Dhall qualified
+import Dhall.Pretty qualified
+import Options.Applicative qualified as Opt
+import Prettyprinter qualified as Pretty
+import Prettyprinter.Render.Text qualified as Pretty
 import Protolude hiding (toS)
 import Protolude.Conv
 import Servant.Auth.Client
diff --git a/src/Cachix/Client/Config/Orphans.hs b/src/Cachix/Client/Config/Orphans.hs
--- a/src/Cachix/Client/Config/Orphans.hs
+++ b/src/Cachix/Client/Config/Orphans.hs
@@ -2,9 +2,9 @@
 
 module Cachix.Client.Config.Orphans where
 
-import qualified Data.Aeson as Aeson
-import qualified Dhall
-import qualified Dhall.Core
+import Data.Aeson qualified as Aeson
+import Dhall qualified
+import Dhall.Core qualified
 import Protolude hiding (toS)
 import Protolude.Conv
 import Servant.Auth.Client
diff --git a/src/Cachix/Client/Daemon.hs b/src/Cachix/Client/Daemon.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon.hs
+++ /dev/null
@@ -1,239 +0,0 @@
-module Cachix.Client.Daemon
-  ( Types.Daemon,
-    Types.runDaemon,
-    new,
-    start,
-    run,
-    stop,
-    stopIO,
-    subscribe,
-  )
-where
-
-import qualified Cachix.Client.Commands.Push as Commands.Push
-import qualified Cachix.Client.Config as Config
-import Cachix.Client.Config.Orphans ()
-import qualified Cachix.Client.Daemon.EventLoop as EventLoop
-import Cachix.Client.Daemon.Listen as Daemon
-import qualified Cachix.Client.Daemon.Log as Log
-import Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Daemon.Push as Push
-import qualified Cachix.Client.Daemon.PushManager as PushManager
-import Cachix.Client.Daemon.ShutdownLatch
-import qualified Cachix.Client.Daemon.SocketStore as SocketStore
-import Cachix.Client.Daemon.Subscription as Subscription
-import Cachix.Client.Daemon.Types as Types
-import Cachix.Client.Daemon.Types.EventLoop (DaemonEvent (ShutdownGracefully))
-import qualified Cachix.Client.Daemon.Types.PushManager as PushManager
-import qualified Cachix.Client.Daemon.Worker as Worker
-import Cachix.Client.Env as Env
-import Cachix.Client.OptionsParser (DaemonOptions, PushOptions)
-import qualified Cachix.Client.OptionsParser as Options
-import Cachix.Client.Push
-import Cachix.Types.BinaryCache (BinaryCacheName)
-import qualified Cachix.Types.BinaryCache as BinaryCache
-import Control.Concurrent.STM.TMChan
-import Control.Exception.Safe (catchAny)
-import qualified Control.Monad.Catch as E
-import qualified Data.Text as T
-import qualified Hercules.CNix.Util as CNix.Util
-import qualified Katip
-import qualified Network.Socket as Socket
-import qualified Network.Socket.ByteString as Socket.BS
-import Protolude
-import System.IO.Error (isResourceVanishedError)
-import System.Posix.Process (getProcessID)
-import qualified System.Posix.Signals as Signal
-import qualified UnliftIO.Async as Async
-
--- | Configure a new daemon. Use 'run' to start it.
-new ::
-  -- | The Cachix environment.
-  Env ->
-  -- | Daemon-specific options.
-  DaemonOptions ->
-  -- | An optional handle to output logs to.
-  Maybe Handle ->
-  -- | Push options, like compression settings and number of jobs.
-  PushOptions ->
-  -- | The name of the binary cache to push to.
-  BinaryCacheName ->
-  -- | The configured daemon environment.
-  IO DaemonEnv
-new daemonEnv daemonOptions daemonLogHandle daemonPushOptions daemonCacheName = do
-  let daemonLogLevel =
-        if Config.verbose (Env.cachixoptions daemonEnv)
-          then Debug
-          else Info
-  daemonLogger <- Log.new "cachix.daemon" daemonLogHandle daemonLogLevel
-
-  daemonSocketPath <- maybe getSocketPath pure (Options.daemonSocketPath daemonOptions)
-  daemonSocketThread <- newEmptyMVar
-  daemonEventLoop <- EventLoop.new
-  daemonClients <- SocketStore.newSocketStore
-  daemonShutdownLatch <- newShutdownLatch
-  daemonPid <- getProcessID
-
-  daemonPushSecret <- Commands.Push.getPushSecretRequired (config daemonEnv) daemonCacheName
-  let authToken = getAuthTokenFromPushSecret daemonPushSecret
-  daemonBinaryCache <- Push.getBinaryCache daemonEnv authToken daemonCacheName
-
-  daemonSubscriptionManager <- Subscription.newSubscriptionManager
-  let onPushEvent = Subscription.pushEvent daemonSubscriptionManager
-  daemonPushManager <- PushManager.newPushManagerEnv daemonPushOptions daemonLogger onPushEvent
-
-  return $ DaemonEnv {..}
-
--- | Configure and run the daemon. Equivalent to running 'new' and 'run' together with some signal handling.
-start :: Env -> DaemonOptions -> PushOptions -> BinaryCacheName -> IO ()
-start daemonEnv daemonOptions daemonPushOptions daemonCacheName = do
-  daemon <- new daemonEnv daemonOptions Nothing daemonPushOptions daemonCacheName
-  installSignalHandlers daemon
-  void $ run daemon
-
--- | Run a daemon from a given configuration.
-run :: DaemonEnv -> IO ExitCode
-run daemon = runDaemon daemon $ flip E.onError (return $ ExitFailure 1) $ do
-  Katip.logFM Katip.InfoS "Starting Cachix Daemon"
-  DaemonEnv {..} <- ask
-
-  printConfiguration
-
-  Push.withPushParams $ \pushParams -> do
-    subscriptionManagerThread <-
-      Async.async $ runSubscriptionManager daemonSubscriptionManager
-
-    let runWorkerTask =
-          liftIO . PushManager.runPushManager daemonPushManager . PushManager.handleTask pushParams
-    workersThreads <-
-      Worker.startWorkers
-        (Options.numJobs daemonPushOptions)
-        (PushManager.pmTaskQueue daemonPushManager)
-        runWorkerTask
-
-    listenThread <- Async.async (Daemon.listen daemonEventLoop daemonSocketPath)
-    liftIO $ putMVar daemonSocketThread listenThread
-
-    EventLoop.run daemonEventLoop $ \case
-      EventLoop.AddSocketClient conn ->
-        SocketStore.addSocket conn (Daemon.handleClient daemonEventLoop) daemonClients
-      EventLoop.RemoveSocketClient socketId ->
-        SocketStore.removeSocket socketId daemonClients
-      EventLoop.ReconnectSocket ->
-        -- TODO: implement reconnection logic
-        EventLoop.exitLoopWith (ExitFailure 1) daemonEventLoop
-      EventLoop.ReceivedMessage clientMsg ->
-        case clientMsg of
-          ClientPushRequest pushRequest -> queueJob pushRequest
-          ClientStop -> EventLoop.send daemonEventLoop EventLoop.ShutdownGracefully
-          _ -> return ()
-      EventLoop.ShutdownGracefully -> do
-        Katip.logFM Katip.InfoS "Shutting down daemon..."
-        shutdownGracefully subscriptionManagerThread workersThreads
-        Katip.logFM Katip.InfoS "Daemon shut down. Exiting."
-        EventLoop.exitLoopWith ExitSuccess daemonEventLoop
-
-stop :: Daemon ()
-stop = do
-  eventloop <- asks daemonEventLoop
-  EventLoop.send eventloop ShutdownGracefully
-
-stopIO :: DaemonEnv -> IO ()
-stopIO DaemonEnv {daemonEventLoop} =
-  EventLoop.sendIO daemonEventLoop ShutdownGracefully
-
-installSignalHandlers :: DaemonEnv -> IO ()
-installSignalHandlers daemon = do
-  for_ [Signal.sigTERM, Signal.sigINT] $ \signal ->
-    Signal.installHandler signal (Signal.CatchOnce handler) Nothing
-  where
-    handler = do
-      CNix.Util.triggerInterrupt
-      stopIO daemon
-
-queueJob :: Protocol.PushRequest -> Daemon ()
-queueJob pushRequest = do
-  daemonPushManager <- asks daemonPushManager
-  -- TODO: subscribe the socket to updates if available
-
-  -- Queue the job
-  void $
-    PushManager.runPushManager daemonPushManager (PushManager.addPushJob pushRequest)
-
-subscribe :: DaemonEnv -> IO (TMChan PushEvent)
-subscribe DaemonEnv {..} = do
-  chan <- liftIO newBroadcastTMChanIO
-  liftIO $ atomically $ do
-    subscribeToAllSTM daemonSubscriptionManager (SubChannel chan)
-    dupTMChan chan
-
--- | Print the daemon configuration to the log.
-printConfiguration :: Daemon ()
-printConfiguration = do
-  config <- showConfiguration
-  Katip.logFM Katip.InfoS $ Katip.ls $ "Configuration:\n" <> config
-
--- | Fetch debug information about the daemon configuration.
-showConfiguration :: Daemon Text
-showConfiguration = do
-  DaemonEnv {..} <- ask
-  pure $
-    T.intercalate
-      "\n"
-      [ "PID: " <> show daemonPid,
-        "Socket: " <> toS daemonSocketPath,
-        "Workers: " <> show (Options.numJobs daemonPushOptions),
-        "Cache name: " <> toS daemonCacheName,
-        "Cache URI: " <> BinaryCache.uri daemonBinaryCache,
-        "Cache public keys: " <> show (BinaryCache.publicSigningKeys daemonBinaryCache),
-        "Cache is public: " <> show (BinaryCache.isPublic daemonBinaryCache),
-        "Compression: " <> show (Push.getCompressionMethod daemonPushOptions daemonBinaryCache)
-      ]
-
-shutdownGracefully :: Async () -> [Worker.Thread] -> Daemon ()
-shutdownGracefully subscriptionManagerThread workersThreads = do
-  DaemonEnv {..} <- ask
-
-  initiateShutdown daemonShutdownLatch
-  PushManager.runPushManager daemonPushManager $ do
-    queuedStorePathCount <- PushManager.queuedStorePathCount
-    when (queuedStorePathCount > 0) $
-      Katip.logFM Katip.InfoS $
-        Katip.logStr $
-          "Remaining store paths: " <> (show queuedStorePathCount :: Text)
-
-  Katip.logFM Katip.DebugS "Waiting for push manager to clear remaining jobs..."
-  -- Finish processing remaining push jobs
-  let timeoutOptions =
-        PushManager.TimeoutOptions
-          { PushManager.toTimeout = 60.0,
-            PushManager.toPollingInterval = 1.0
-          }
-  liftIO $ PushManager.stopPushManager timeoutOptions daemonPushManager
-  Katip.logFM Katip.DebugS "Push manager shut down."
-
-  -- Gracefully shut down the worker before closing the socket
-  Worker.stopWorkers workersThreads
-
-  -- Close all event subscriptions
-  Katip.logFM Katip.DebugS "Shutting down event manager..."
-  liftIO $ stopSubscriptionManager daemonSubscriptionManager
-  Async.wait subscriptionManagerThread
-  Katip.logFM Katip.DebugS "Event manager shut down."
-
-  let sayGoodbye socket = do
-        let clientSock = SocketStore.socket socket
-        let clientThread = SocketStore.handlerThread socket
-        Async.cancel clientThread
-
-        -- Wave goodbye to the client that requested the shutdown
-        liftIO $ Daemon.serverBye clientSock
-        liftIO $ Socket.shutdown clientSock Socket.ShutdownBoth `catchAny` (\_ -> return ())
-        -- Wait for the other end to disconnect
-        ebs <- liftIO $ try $ Socket.BS.recv clientSock 4096
-        case ebs of
-          Left err | isResourceVanishedError err -> Katip.logFM Katip.DebugS "Client did not disconnect cleanly."
-          Left err -> Katip.logFM Katip.DebugS $ Katip.ls $ "Client socket threw an error: " <> displayException err
-          Right _ -> Katip.logFM Katip.DebugS "Client disconnected."
-
-  Async.mapConcurrently_ sayGoodbye =<< SocketStore.toList daemonClients
diff --git a/src/Cachix/Client/Daemon/Client.hs b/src/Cachix/Client/Daemon/Client.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Client.hs
+++ /dev/null
@@ -1,132 +0,0 @@
-module Cachix.Client.Daemon.Client (push, stop) where
-
-import Cachix.Client.Daemon.Listen (getSocketPath)
-import Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Env as Env
-import Cachix.Client.OptionsParser (DaemonOptions (..))
-import qualified Cachix.Client.Retry as Retry
-import qualified Control.Concurrent.Async as Async
-import Control.Concurrent.STM.TBMQueue
-import Control.Exception.Safe (catchAny)
-import qualified Data.Aeson as Aeson
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Lazy.Char8 as Lazy.Char8
-import Data.IORef
-import Data.Time.Clock
-import qualified Network.Socket as Socket
-import qualified Network.Socket.ByteString as Socket.BS
-import qualified Network.Socket.ByteString.Lazy as Socket.LBS
-import Protolude
-
-data SocketError
-  = -- | The socket has been closed
-    SocketClosed
-  | -- | The socket has stopped responding to pings
-    SocketStalled
-  | -- | Failed to decode a message from the socket
-    SocketDecodingError !Text
-  deriving stock (Show)
-
-instance Exception SocketError where
-  displayException = \case
-    SocketClosed -> "The socket has been closed"
-    SocketStalled -> "The socket has stopped responding to pings"
-    SocketDecodingError err -> "Failed to decode message from socket: " <> toS err
-
--- | Queue up push requests with the daemon
---
--- TODO: wait for the daemon to respond that it has received the request
-push :: Env -> DaemonOptions -> [FilePath] -> IO ()
-push _env daemonOptions storePaths =
-  withDaemonConn (daemonSocketPath daemonOptions) $ \sock -> do
-    Socket.LBS.sendAll sock $ Aeson.encode pushRequest `Lazy.Char8.snoc` '\n'
-  where
-    pushRequest =
-      Protocol.ClientPushRequest $
-        PushRequest {storePaths = storePaths}
-
--- | Tell the daemon to stop and wait for it to gracefully exit
-stop :: Env -> DaemonOptions -> IO ()
-stop _env daemonOptions =
-  withDaemonConn (daemonSocketPath daemonOptions) $ \sock -> do
-    let size = 100
-    (rx, tx) <- atomically $ (,) <$> newTBMQueue size <*> newTBMQueue size
-
-    rxThread <- Async.async (handleIncoming rx sock)
-    txThread <- Async.async (handleOutgoing tx sock)
-
-    lastPongRef <- newIORef =<< getCurrentTime
-    pingThread <- Async.async (runPingThread lastPongRef rx tx)
-
-    mapM_ Async.link [rxThread, txThread, pingThread]
-
-    -- Request the daemon to stop
-    atomically $ writeTBMQueue tx Protocol.ClientStop
-
-    fix $ \loop -> do
-      mmsg <- atomically (readTBMQueue rx)
-      case mmsg of
-        Nothing -> return ()
-        Just (Left err) -> putErrText $ toS $ displayException err
-        Just (Right msg) ->
-          case msg of
-            Protocol.DaemonPong -> do
-              writeIORef lastPongRef =<< getCurrentTime
-              loop
-            Protocol.DaemonBye -> exitSuccess
-  where
-    runPingThread lastPongRef rx tx = go
-      where
-        go = do
-          timestamp <- getCurrentTime
-          lastPong <- readIORef lastPongRef
-
-          if timestamp >= addUTCTime 20 lastPong
-            then atomically $ writeTBMQueue rx (Left SocketStalled)
-            else do
-              atomically $ writeTBMQueue tx Protocol.ClientPing
-              threadDelay (2 * 1000 * 1000)
-              go
-
-    handleOutgoing tx sock = go
-      where
-        go = do
-          mmsg <- atomically $ readTBMQueue tx
-          case mmsg of
-            Nothing -> return ()
-            Just msg -> do
-              Retry.retryAll $ const $ Socket.LBS.sendAll sock $ Aeson.encode msg `Lazy.Char8.snoc` '\n'
-              go
-
-    handleIncoming rx sock = go
-      where
-        go = do
-          -- Wait for the socket to close
-          bs <- Socket.BS.recv sock 4096 `catchAny` (\_ -> return BS.empty)
-
-          -- A zero-length response means that the daemon has closed the socket
-          if BS.null bs
-            then atomically $ writeTBMQueue rx (Left SocketClosed)
-            else case Aeson.eitherDecodeStrict bs of
-              Left err -> do
-                let terr = toS err
-                putErrText terr
-                atomically $ writeTBMQueue rx (Left (SocketDecodingError terr))
-              Right msg -> do
-                atomically $ writeTBMQueue rx (Right msg)
-                go
-
-withDaemonConn :: Maybe FilePath -> (Socket.Socket -> IO a) -> IO a
-withDaemonConn optionalSocketPath f = do
-  socketPath <- maybe getSocketPath pure optionalSocketPath
-  bracket (open socketPath `onException` failedToConnectTo socketPath) Socket.close f
-  where
-    open socketPath = do
-      sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
-      Retry.retryAll $ const $ Socket.connect sock (Socket.SockAddrUnix socketPath)
-      return sock
-
-    failedToConnectTo :: FilePath -> IO ()
-    failedToConnectTo socketPath = do
-      putErrText "\nFailed to connect to Cachix Daemon"
-      putErrText $ "Tried to connect to: " <> toS socketPath <> "\n"
diff --git a/src/Cachix/Client/Daemon/EventLoop.hs b/src/Cachix/Client/Daemon/EventLoop.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/EventLoop.hs
+++ /dev/null
@@ -1,73 +0,0 @@
-module Cachix.Client.Daemon.EventLoop
-  ( new,
-    send,
-    sendIO,
-    run,
-    exitLoopWith,
-    EventLoop,
-    DaemonEvent (..),
-  )
-where
-
-import Cachix.Client.Daemon.Types.EventLoop (DaemonEvent (..), EventLoop (..))
-import Control.Concurrent.STM.TBMQueue
-  ( isFullTBMQueue,
-    newTBMQueueIO,
-    readTBMQueue,
-    tryWriteTBMQueue,
-  )
-import Data.Text.Lazy.Builder (toLazyText)
-import qualified Katip
-import Protolude
-
-new :: (MonadIO m) => m EventLoop
-new = do
-  exitLatch <- liftIO newEmptyMVar
-  queue <- liftIO $ newTBMQueueIO 100
-  return $ EventLoop {queue, exitLatch}
-
--- | Send an event to the event loop with logging.
-send :: (Katip.KatipContext m) => EventLoop -> DaemonEvent -> m ()
-send = send' Katip.logFM
-
--- | Same as 'send', but does not require a 'Katip.KatipContext'.
-sendIO :: forall m. (MonadIO m) => EventLoop -> DaemonEvent -> m ()
-sendIO = send' logger
-  where
-    logger :: Katip.Severity -> Katip.LogStr -> m ()
-    logger Katip.ErrorS msg = liftIO $ hPutStrLn stderr (toLazyText $ Katip.unLogStr msg)
-    logger _ _ = return ()
-
-send' :: (MonadIO m) => (Katip.Severity -> Katip.LogStr -> m ()) -> EventLoop -> DaemonEvent -> m ()
-send' logger eventloop@(EventLoop {queue}) event = do
-  res <- liftIO $ atomically $ tryWriteTBMQueue queue event
-  case res of
-    -- The queue is closed.
-    Nothing ->
-      logger Katip.DebugS "Ignored an event because the event loop is closed"
-    -- Successfully wrote to the queue
-    Just True -> return ()
-    -- Failed to write to the queue
-    Just False -> do
-      isFull <- liftIO $ atomically $ isFullTBMQueue queue
-      let message =
-            if isFull
-              then "Event loop is full"
-              else "Unknown error"
-      logger Katip.ErrorS $ "Failed to write to event loop: " <> message
-      exitLoopWith (ExitFailure 1) eventloop
-
-run :: (MonadIO m) => EventLoop -> (DaemonEvent -> m ()) -> m ExitCode
-run eventloop f = do
-  fix $ \loop -> do
-    mevent <- liftIO $ atomically $ readTBMQueue (queue eventloop)
-    case mevent of
-      Just event -> f event
-      Nothing -> exitLoopWith (ExitFailure 1) eventloop
-
-    liftIO (tryReadMVar (exitLatch eventloop)) >>= \case
-      Just exitCode -> return exitCode
-      Nothing -> loop
-
-exitLoopWith :: (MonadIO m) => ExitCode -> EventLoop -> m ()
-exitLoopWith exitCode EventLoop {exitLatch} = void $ liftIO $ tryPutMVar exitLatch exitCode
diff --git a/src/Cachix/Client/Daemon/Listen.hs b/src/Cachix/Client/Daemon/Listen.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Listen.hs
+++ /dev/null
@@ -1,146 +0,0 @@
-module Cachix.Client.Daemon.Listen
-  ( listen,
-    handleClient,
-    serverBye,
-    getSocketPath,
-    openSocket,
-    closeSocket,
-  )
-where
-
-import Cachix.Client.Config.Orphans ()
-import qualified Cachix.Client.Daemon.EventLoop as EventLoop
-import Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Daemon.Types.EventLoop (EventLoop)
-import Cachix.Client.Daemon.Types.SocketStore (SocketId)
-import Control.Exception.Safe (catchAny)
-import qualified Control.Monad.Catch as E
-import qualified Data.Aeson as Aeson
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Char8 as Char8
-import qualified Katip
-import Network.Socket (Socket)
-import qualified Network.Socket as Socket
-import qualified Network.Socket.ByteString as Socket.BS
-import qualified Network.Socket.ByteString.Lazy as Socket.LBS
-import Protolude
-import System.Directory
-  ( XdgDirectory (..),
-    createDirectoryIfMissing,
-    getXdgDirectory,
-    removeFile,
-  )
-import qualified System.Environment as System
-import System.FilePath ((</>))
-import System.IO.Error (isDoesNotExistError, isResourceVanishedError)
-
--- TODO: reconcile with Client
-data ListenError
-  = SocketError SomeException
-  | DecodingError Text
-  deriving stock (Show)
-
-instance Exception ListenError where
-  displayException = \case
-    SocketError err -> "Failed to read from the daemon socket: " <> show err
-    DecodingError err -> "Failed to decode request:\n" <> toS err
-
--- | Listen for incoming connections on the given socket path.
-listen ::
-  (E.MonadMask m, Katip.KatipContext m) =>
-  EventLoop ->
-  FilePath ->
-  m ()
-listen eventloop daemonSocketPath = forever $ do
-  E.bracketOnError (openSocket daemonSocketPath) closeSocket $ \sock -> do
-    liftIO $ Socket.listen sock Socket.maxListenQueue
-    (conn, _peerAddr) <- liftIO $ Socket.accept sock
-    EventLoop.send eventloop (EventLoop.AddSocketClient conn)
-
--- | Handle incoming messages from a client.
---
--- Automatically responds to pings.
--- Requests the daemon to remove the client socket once the loop exits.
-handleClient ::
-  forall m.
-  (E.MonadMask m, Katip.KatipContext m) =>
-  EventLoop ->
-  SocketId ->
-  Socket ->
-  m ()
-handleClient eventloop socketId conn = do
-  go `E.finally` removeClient
-  where
-    go = do
-      ebs <- liftIO $ try $ Socket.BS.recv conn 4096
-
-      case ebs of
-        Left err | isResourceVanishedError err -> return ()
-        Left _ -> return ()
-        -- If the socket returns 0 bytes, then it is closed
-        Right bs | BS.null bs -> return ()
-        Right bs -> do
-          msgs <- catMaybes <$> mapM decodeMessage (Char8.split '\n' bs)
-
-          forM_ msgs $ \msg -> do
-            EventLoop.send eventloop (EventLoop.ReceivedMessage msg)
-            case msg of
-              Protocol.ClientPing ->
-                liftIO $ Socket.LBS.sendAll conn (Aeson.encode DaemonPong)
-              _ -> return ()
-
-          go
-
-    decodeMessage :: ByteString -> m (Maybe Protocol.ClientMessage)
-    decodeMessage "" = return Nothing
-    decodeMessage bs =
-      case Aeson.eitherDecodeStrict bs of
-        Left err -> do
-          Katip.logFM Katip.ErrorS $ Katip.ls $ displayException (DecodingError (toS err))
-          return Nothing
-        Right msg -> return (Just msg)
-
-    removeClient = EventLoop.send eventloop (EventLoop.RemoveSocketClient socketId)
-
-serverBye :: Socket.Socket -> IO ()
-serverBye sock =
-  Socket.LBS.sendAll sock (Aeson.encode DaemonBye) `catchAny` (\_ -> return ())
-
-getSocketPath :: IO FilePath
-getSocketPath = do
-  socketDir <- getSocketDir
-  return $ socketDir </> "cachix-daemon.sock"
-
-getSocketDir :: IO FilePath
-getSocketDir = do
-  xdgRuntimeDir <- getXdgRuntimeDir
-  let socketDir = xdgRuntimeDir </> "cachix"
-  createDirectoryIfMissing True socketDir
-  return socketDir
-
--- On systems with systemd: /run/user/$UID
--- Otherwise, fall back to XDG_CACHE_HOME
-getXdgRuntimeDir :: IO FilePath
-getXdgRuntimeDir = do
-  xdgRuntimeDir <- System.lookupEnv "XDG_RUNTIME_DIR"
-  cacheFallback <- getXdgDirectory XdgCache ""
-  return $ fromMaybe cacheFallback xdgRuntimeDir
-
--- TODO: lock the socket
-openSocket :: (MonadIO m) => FilePath -> m Socket.Socket
-openSocket socketFilePath = liftIO $ do
-  deleteSocketFileIfExists socketFilePath
-  sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
-  Socket.bind sock $ Socket.SockAddrUnix socketFilePath
-  -- setFileMode socketFilePath socketFileMode
-  return sock
-  where
-    deleteSocketFileIfExists path =
-      removeFile path `catch` handleDoesNotExist
-
-    handleDoesNotExist e
-      | isDoesNotExistError e = return ()
-      | otherwise = throwIO e
-
-closeSocket :: (MonadIO m) => Socket.Socket -> m ()
-closeSocket = liftIO . Socket.close
diff --git a/src/Cachix/Client/Daemon/Log.hs b/src/Cachix/Client/Daemon/Log.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Log.hs
+++ /dev/null
@@ -1,79 +0,0 @@
-module Cachix.Client.Daemon.Log
-  ( new,
-    withLogger,
-    getKatipNamespace,
-    getKatipContext,
-    getKatipLogEnv,
-    localLogEnv,
-    localKatipContext,
-    localKatipNamespace,
-    toKatipLogLevel,
-    Log.Logger (..),
-    Log.LogLevel (..),
-  )
-where
-
-import Cachix.Client.Daemon.Types.Log as Log
-import qualified Control.Monad.Catch as E
-import Data.Text.Lazy.Builder
-import Katip (renderSeverity)
-import qualified Katip
-import qualified Katip.Format.Time as Katip.Format
-import Katip.Scribes.Handle (brackets, colorBySeverity, getKeys)
-import Protolude
-
-new :: (MonadIO m) => Katip.Namespace -> Maybe Handle -> LogLevel -> m Logger
-new logLabel logHandle logLevel = do
-  logKLogEnv <- liftIO $ Katip.initLogEnv logLabel ""
-  let logKNamespace = mempty
-  let logKContext = mempty
-  return $ Logger {..}
-
-withLogger :: (MonadIO m, E.MonadMask m) => Logger -> (Logger -> m a) -> m a
-withLogger logger@(Logger {..}) f = do
-  let kLogLevel = toKatipLogLevel logLevel
-  let kLogHandle = fromMaybe stdout logHandle
-  let registerScribe = liftIO $ do
-        scribeHandle <- Katip.mkHandleScribeWithFormatter conciseBracketFormat Katip.ColorIfTerminal kLogHandle (Katip.permitItem kLogLevel) Katip.V2
-        Katip.registerScribe "stdout" scribeHandle Katip.defaultScribeSettings logKLogEnv
-
-  E.bracket registerScribe (liftIO . Katip.closeScribes) $ \logEnv ->
-    f logger {logKLogEnv = logEnv}
-
-getKatipNamespace :: Logger -> Katip.Namespace
-getKatipNamespace = logKNamespace
-
-getKatipContext :: Logger -> Katip.LogContexts
-getKatipContext = logKContext
-
-getKatipLogEnv :: Logger -> Katip.LogEnv
-getKatipLogEnv = logKLogEnv
-
-localLogEnv :: (Katip.LogEnv -> Katip.LogEnv) -> Logger -> Logger
-localLogEnv f logger = logger {logKLogEnv = f (logKLogEnv logger)}
-
-localKatipContext :: (Katip.LogContexts -> Katip.LogContexts) -> Logger -> Logger
-localKatipContext f logger = logger {logKContext = f (logKContext logger)}
-
-localKatipNamespace :: (Katip.Namespace -> Katip.Namespace) -> Logger -> Logger
-localKatipNamespace f logger = logger {logKNamespace = f (logKNamespace logger)}
-
-toKatipLogLevel :: LogLevel -> Katip.Severity
-toKatipLogLevel = \case
-  Debug -> Katip.DebugS
-  Info -> Katip.InfoS
-  Warning -> Katip.WarningS
-  Error -> Katip.ErrorS
-
-conciseBracketFormat :: (Katip.LogItem a) => Katip.ItemFormatter a
-conciseBracketFormat withColor verbosity Katip.Item {..} =
-  brackets nowStr
-    <> brackets (fromText (renderSeverity' _itemSeverity))
-    <> mconcat ks
-    <> fromText " "
-    <> Katip.unLogStr _itemMessage
-  where
-    nowStr = fromText (Katip.Format.formatAsLogTime _itemTime)
-    ks = map brackets $ getKeys verbosity _itemPayload
-    renderSeverity' severity =
-      colorBySeverity withColor severity (renderSeverity severity)
diff --git a/src/Cachix/Client/Daemon/PostBuildHook.hs b/src/Cachix/Client/Daemon/PostBuildHook.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/PostBuildHook.hs
+++ /dev/null
@@ -1,154 +0,0 @@
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE TypeApplications #-}
-
-module Cachix.Client.Daemon.PostBuildHook
-  ( -- * Post-build hook setup
-    PostBuildHookEnv (..),
-    withSetup,
-
-    -- * Set up env vars
-    EnvVar,
-    modifyEnv,
-
-    -- * Internal
-    buildNixConfEnv,
-    buildNixUserConfFilesEnv,
-  )
-where
-
-import Control.Monad.Catch (MonadMask)
-import Data.Containers.ListUtils (nubOrd)
-import Data.String (String)
-import Data.String.Here
-import Protolude
-import System.Directory
-  ( XdgDirectory (XdgConfig),
-    XdgDirectoryList (XdgConfigDirs),
-    getXdgDirectory,
-    getXdgDirectoryList,
-  )
-import System.Environment (getExecutablePath, lookupEnv)
-import System.FilePath ((</>))
-import System.IO.Temp (getCanonicalTemporaryDirectory, withTempDirectory)
-import System.Posix.Files
-
-type EnvVar = (String, String)
-
-modifyEnv :: EnvVar -> [EnvVar] -> [EnvVar]
-modifyEnv (envName, envValue) processEnv =
-  nubOrd $ (envName, envValue) : processEnv
-
-data PostBuildHookEnv = PostBuildHookEnv
-  { tempDir :: !FilePath,
-    postBuildHookConfigPath :: !FilePath,
-    postBuildHookScriptPath :: !FilePath,
-    daemonSock :: !FilePath,
-    envVar :: !EnvVar
-  }
-
-withSetup :: Maybe FilePath -> (PostBuildHookEnv -> IO a) -> IO a
-withSetup mdaemonSock f =
-  withRunnerFriendlyTempDirectory "cachix-daemon" $ \tempDir -> do
-    let postBuildHookScriptPath = tempDir </> "post-build-hook.sh"
-        postBuildHookConfigPath = tempDir </> "nix.conf"
-        daemonSock = fromMaybe (tempDir </> "daemon.sock") mdaemonSock
-
-    cachixBin <- getExecutablePath
-    writeFile postBuildHookScriptPath (postBuildHookScript cachixBin daemonSock)
-    setFileMode postBuildHookScriptPath 0o755
-
-    mnixConfEnv <- buildNixConfEnv postBuildHookScriptPath
-    nixUserConfFilesEnv <- buildNixUserConfFilesEnv postBuildHookConfigPath
-    envVar <- case mnixConfEnv of
-      Just nixConfEnv -> return nixConfEnv
-      Nothing -> do
-        writeFile postBuildHookConfigPath (postBuildHookConfig postBuildHookScriptPath)
-        return nixUserConfFilesEnv
-
-    f PostBuildHookEnv {..}
-
--- | Build the NIX_CONF environment variable.
---
--- NIX_CONF completely overrides the nix.conf.
--- This is generally undesirable because the user and system nix.confs contain important settings, like substituters.
--- Therefore, this returns Nothing if NIX_CONF is not already set to allow fallback to NIX_USER_CONF_FILES.
-buildNixConfEnv :: FilePath -> IO (Maybe EnvVar)
-buildNixConfEnv postBuildHookScriptPath =
-  fmap appendNixConf <$> lookupEnv "NIX_CONF"
-  where
-    appendNixConf :: String -> EnvVar
-    appendNixConf conf =
-      ( "NIX_CONF",
-        conf <> "\n" <> toS (postBuildHookConfig postBuildHookScriptPath)
-      )
-
--- | Build the NIX_USER_CONF_FILES environment variable.
---
--- From man nix.conf:
---
--- If NIX_USER_CONF_FILES is set, then each path separated by : will be loaded in reverse order.
---
--- Otherwise it will look for nix/nix.conf files in XDG_CONFIG_DIRS and XDG_CONFIG_HOME. If
--- unset, XDG_CONFIG_DIRS defaults to /etc/xdg, and XDG_CONFIG_HOME defaults to $HOME/.config
--- as per XDG Base Directory Specification.
---
--- We don't need to load the system config from $NIX_CONF_DIR/nix.conf.
--- Nix loads it by default and uses it as the base config.
-buildNixUserConfFilesEnv :: FilePath -> IO EnvVar
-buildNixUserConfFilesEnv nixConfPath = do
-  -- A user can set NIX_USER_CONF_FILES to override the default nix.conf files.
-  -- In that case, we reuse it and prepend our own config file.
-  mexistingEnv <- lookupEnv "NIX_USER_CONF_FILES"
-
-  newNixUserConfFiles <- case mexistingEnv of
-    Just existingEnv -> return $ nixConfPath <> ":" <> existingEnv
-    Nothing -> do
-      userConfigFiles <- getUserConfigFiles
-
-      -- Combine all the nix.conf paths into one string, separated by colons.
-      -- Filter out empty paths.
-      return $ intercalate ":" $ filter (not . null) $ nixConfPath : userConfigFiles
-
-  return ("NIX_USER_CONF_FILES", newNixUserConfFiles)
-
-getUserConfigFiles :: IO [FilePath]
-getUserConfigFiles =
-  fmap (</> "nix/nix.conf") <$> getUserConfigDirs
-
-getUserConfigDirs :: IO [FilePath]
-getUserConfigDirs = do
-  configHome <- getXdgDirectory XdgConfig empty
-  configDirs <- getXdgDirectoryList XdgConfigDirs
-
-  return $ configHome : configDirs
-
-postBuildHookConfig :: FilePath -> Text
-postBuildHookConfig scriptPath =
-  [iTrim|
-post-build-hook = ${toS scriptPath :: Text}
-  |]
-
-postBuildHookScript :: FilePath -> FilePath -> Text
-postBuildHookScript cachixBin socketPath =
-  [iTrim|
-\#!/bin/sh
-
-\# set -eu
-set -f # disable globbing
-
-exec ${toS cachixBin :: Text} daemon push \\
-  --socket ${toS socketPath :: Text} \\
-  $OUT_PATHS
-  |]
-
--- | Run an action with a temporary directory.
---
--- Respects the RUNNER_TEMP environment variable if set.
--- This is important on self-hosted GitHub runners with locked down system temp directories.
--- The directory is deleted after use.
-withRunnerFriendlyTempDirectory :: (MonadIO m, MonadMask m) => String -> (FilePath -> m a) -> m a
-withRunnerFriendlyTempDirectory name action = do
-  runnerTempDir <- liftIO $ lookupEnv "RUNNER_TEMP"
-  systemTempDir <- liftIO getCanonicalTemporaryDirectory
-  let tempDir = maybe systemTempDir toS runnerTempDir
-  withTempDirectory tempDir name action
diff --git a/src/Cachix/Client/Daemon/Progress.hs b/src/Cachix/Client/Daemon/Progress.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Progress.hs
+++ /dev/null
@@ -1,166 +0,0 @@
-module Cachix.Client.Daemon.Progress
-  ( UploadProgress,
-    new,
-    complete,
-    fail,
-    tick,
-    update,
-  )
-where
-
-import Cachix.Client.Daemon.Types (PushRetryStatus (..))
-import Cachix.Client.HumanSize (humanSize)
-import qualified Control.Concurrent.Async as Async
-import Control.Concurrent.MVar
-import Data.String (String)
-import qualified Data.Text as T
-import Protolude
-import qualified System.Console.AsciiProgress as Ascii
-import qualified System.Console.AsciiProgress.Internal as Ascii.Internal
-import System.Console.Pretty
-import System.IO (hIsTerminalDevice)
-
-data UploadProgress
-  = ProgressBar
-      { path :: String,
-        size :: Int64,
-        progressBar :: Ascii.ProgressBar
-      }
-  | FallbackText
-      { path :: String,
-        size :: Int64
-      }
-
-new :: Handle -> String -> Int64 -> PushRetryStatus -> IO UploadProgress
-new hdl path size retryStatus = do
-  isTerminal <- liftIO $ hIsTerminalDevice hdl
-  if isTerminal
-    then do
-      progressBar <- newProgressBar path size retryStatus
-      return $ ProgressBar {..}
-    else do
-      hPutStr stderr $ uploadStartFallback path size retryStatus
-      return $ FallbackText {..}
-
-complete :: UploadProgress -> IO ()
-complete ProgressBar {..} = Ascii.complete progressBar
-complete _ = pure ()
-
-tick :: UploadProgress -> Int64 -> IO ()
-tick ProgressBar {..} deltaBytes = Ascii.tickN progressBar (fromIntegral deltaBytes)
-tick _ _ = pure ()
-
-fail :: UploadProgress -> IO ()
-fail ProgressBar {..} =
-  clearAsciiWith progressBar (uploadFailed path)
-fail FallbackText {path} =
-  hPutStr stderr $ uploadFailed path
-
-update :: UploadProgress -> PushRetryStatus -> IO UploadProgress
-update pg@ProgressBar {..} retryStatus = do
-  let opts = newProgressBarOptions path size retryStatus
-  pg' <- newAsciiProgressBarInPlace progressBar opts
-
-  return $ pg {progressBar = pg'}
-update fbt _ = return fbt
-
-newProgressBar :: String -> Int64 -> PushRetryStatus -> IO Ascii.ProgressBar
-newProgressBar path size retryStatus =
-  newAsciiProgressBar $ newProgressBarOptions path size retryStatus
-
-newProgressBarOptions :: String -> Int64 -> PushRetryStatus -> Ascii.Options
-newProgressBarOptions path size retryStatus = do
-  let hSize = toS $ humanSize $ fromIntegral size
-  let barLength =
-        uploadTickBar path hSize retryStatus
-          & toS
-          & T.replace "[:bar]" ""
-          & T.replace ":percent" "  0%"
-          & T.length
-  Ascii.def
-    { Ascii.pgTotal = fromIntegral size,
-      -- https://github.com/yamadapc/haskell-ascii-progress/issues/24
-      Ascii.pgWidth = 20 + barLength,
-      Ascii.pgOnCompletion = Just $ uploadComplete path size,
-      Ascii.pgFormat = uploadTickBar path hSize retryStatus
-    }
-
-retryText :: PushRetryStatus -> String
-retryText PushRetryStatus {retryCount} =
-  if retryCount == 0
-    then ""
-    else color Yellow $ "retry #" <> show retryCount <> " "
-
-uploadComplete :: String -> Int64 -> String
-uploadComplete path size =
-  let hSize = toS $ humanSize $ fromIntegral size
-   in color Green "✓ " <> path <> " (" <> hSize <> ")"
-
-uploadFailed :: String -> String
-uploadFailed path =
-  color Red "✗ " <> path
-
-uploadTickBar :: String -> String -> PushRetryStatus -> String
-uploadTickBar path hSize retryStatus =
-  color Blue "[:bar] " <> retryText retryStatus <> toS path <> " (:percent of " <> hSize <> ")"
-
-uploadStartFallback :: String -> Int64 -> PushRetryStatus -> String
-uploadStartFallback path size retryStatus =
-  let hSize = toS $ humanSize $ fromIntegral size
-   in retryText retryStatus <> "Pushing " <> path <> " (" <> hSize <> ")\n"
-
--- Internal
-
-newAsciiProgressBar :: Ascii.Options -> IO Ascii.ProgressBar
-newAsciiProgressBar opts = do
-  region <- Ascii.openConsoleRegion Ascii.Linear
-  newAsciiProgressBarWithRegion opts region
-
--- | Create a new progress bar in place of an existing one, reusing the console region.
-newAsciiProgressBarInPlace :: Ascii.ProgressBar -> Ascii.Options -> IO Ascii.ProgressBar
-newAsciiProgressBarInPlace pg opts = do
-  cancelUpdates pg
-  newAsciiProgressBarWithRegion opts (Ascii.pgRegion pg)
-
--- | Create a new progress bar using the provided console region.
-newAsciiProgressBarWithRegion :: Ascii.Options -> Ascii.ConsoleRegion -> IO Ascii.ProgressBar
-newAsciiProgressBarWithRegion opts region = do
-  info <- Ascii.Internal.newProgressBarInfo opts
-
-  -- Display initial progress-bar
-  pgStr <- Ascii.pgGetProgressStr opts opts <$> Ascii.Internal.getInfoStats info
-  Ascii.setConsoleRegion region pgStr
-
-  future <- Async.async $ start info
-  return $ Ascii.ProgressBar info future region
-  where
-    start info@Ascii.Internal.ProgressBarInfo {..} = do
-      c <- readMVar pgCompleted
-      unlessDone c $ do
-        n <- readChan pgChannel
-        _ <- handleMessage info n
-        unlessDone (c + n) $ start info
-      where
-        unlessDone c action | c < Ascii.pgTotal opts = action
-        unlessDone _ _ = do
-          let fmt = fromMaybe (Ascii.pgFormat opts) (Ascii.pgOnCompletion opts)
-          onCompletion <- Ascii.pgGetProgressStr opts opts {Ascii.pgFormat = fmt} <$> Ascii.Internal.getInfoStats info
-          Ascii.setConsoleRegion region onCompletion
-
-    handleMessage info n = do
-      -- Update the completed tick count
-      modifyMVar_ (Ascii.Internal.pgCompleted info) (\c -> return (c + n))
-      -- Find and update the current and first tick times:
-      stats <- Ascii.Internal.getInfoStats info
-      let progressStr = Ascii.Internal.pgGetProgressStr opts opts stats
-      Ascii.setConsoleRegion region progressStr
-
--- | Cancel async updates.
-cancelUpdates :: Ascii.ProgressBar -> IO ()
-cancelUpdates (Ascii.ProgressBar _ future _) = Async.cancel future
-
--- | Cancel async updates and clear the console region with the given string.
-clearAsciiWith :: Ascii.ProgressBar -> String -> IO ()
-clearAsciiWith pg@(Ascii.ProgressBar _ _ region) str = do
-  cancelUpdates pg
-  Ascii.setConsoleRegion region str
diff --git a/src/Cachix/Client/Daemon/Protocol.hs b/src/Cachix/Client/Daemon/Protocol.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Protocol.hs
+++ /dev/null
@@ -1,44 +0,0 @@
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-
-module Cachix.Client.Daemon.Protocol
-  ( ClientMessage (..),
-    DaemonMessage (..),
-    PushRequestId,
-    newPushRequestId,
-    PushRequest (..),
-  )
-where
-
-import qualified Data.Aeson as Aeson
-import Data.UUID (UUID)
-import qualified Data.UUID.V4 as UUID
-import Protolude
-
--- | JSON messages that the client can send to the daemon
-data ClientMessage
-  = ClientPushRequest !PushRequest
-  | ClientStop
-  | ClientPing
-  deriving stock (Generic, Show)
-  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
-
--- | JSON messages that the daemon can send to the client
-data DaemonMessage
-  = DaemonPong
-  | DaemonBye
-  deriving stock (Generic, Show)
-  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
-
-newtype PushRequestId = PushRequestId UUID
-  deriving stock (Generic)
-  deriving newtype (Eq, Ord, Show, Aeson.FromJSON, Aeson.ToJSON, Hashable)
-
-newPushRequestId :: (MonadIO m) => m PushRequestId
-newPushRequestId = liftIO $ PushRequestId <$> UUID.nextRandom
-
--- | A request for the daemon to push store paths to a binary cache
-data PushRequest = PushRequest
-  { storePaths :: [FilePath]
-  }
-  deriving stock (Eq, Generic, Show)
-  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
diff --git a/src/Cachix/Client/Daemon/Push.hs b/src/Cachix/Client/Daemon/Push.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Push.hs
+++ /dev/null
@@ -1,61 +0,0 @@
-module Cachix.Client.Daemon.Push where
-
-import qualified Cachix.API as API
-import Cachix.Client.Commands.Push hiding (pushStrategy)
-import qualified Cachix.Client.Daemon.PushManager as PushManager
-import Cachix.Client.Daemon.Types (Daemon, DaemonEnv (..), PushManager)
-import Cachix.Client.Env (Env (..))
-import Cachix.Client.OptionsParser as Client.OptionsParser
-  ( PushOptions (..),
-  )
-import Cachix.Client.Push as Client.Push
-import Cachix.Client.Retry (retryHttp)
-import Cachix.Client.Servant
-import Cachix.Types.BinaryCache (BinaryCacheName)
-import qualified Cachix.Types.BinaryCache as BinaryCache
-import qualified Data.Set as Set
-import Hercules.CNix.Store (withStore)
-import Protolude hiding (toS)
-import Servant.Auth ()
-import Servant.Auth.Client
-import Servant.Client.Streaming
-import Servant.Conduit ()
-
-withPushParams :: (PushParams PushManager () -> Daemon b) -> Daemon b
-withPushParams m = do
-  DaemonEnv {..} <- ask
-  let authToken = getAuthTokenFromPushSecret daemonPushSecret
-      cacheName = BinaryCache.name daemonBinaryCache
-      compressionMethod = getCompressionMethod daemonPushOptions daemonBinaryCache
-
-  withStore $ \store ->
-    m $ do
-      let pushStrategy = PushManager.newPushStrategy store authToken daemonPushOptions cacheName compressionMethod
-
-      PushParams
-        { pushParamsName = cacheName,
-          pushParamsSecret = daemonPushSecret,
-          pushParamsClientEnv = clientenv daemonEnv,
-          pushOnClosureAttempt = \full missing -> do
-            let already = Set.toList $ Set.difference (Set.fromList full) (Set.fromList missing)
-            mapM_ (onAlreadyPresent . pushStrategy) already
-            return missing,
-          pushParamsStrategy = pushStrategy,
-          pushParamsStore = store
-        }
-
-getBinaryCache :: Env -> Maybe Token -> BinaryCacheName -> IO BinaryCache.BinaryCache
-getBinaryCache env authToken name = do
-  -- Self-signed caches might not have a token, which is why this code is so weird.
-  -- In practice, public self-signed caches don't need one and private ones always need a token.
-  let token = fromMaybe (Token "") authToken
-  res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name
-  case res of
-    Left err -> handleCacheResponse name authToken err
-    Right binaryCache -> pure binaryCache
-
-getCompressionMethod :: PushOptions -> BinaryCache.BinaryCache -> BinaryCache.CompressionMethod
-getCompressionMethod opts binaryCache =
-  fromMaybe BinaryCache.ZSTD $
-    Client.OptionsParser.compressionMethod opts
-      <|> Just (BinaryCache.preferredCompressionMethod binaryCache)
diff --git a/src/Cachix/Client/Daemon/PushManager.hs b/src/Cachix/Client/Daemon/PushManager.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/PushManager.hs
+++ /dev/null
@@ -1,491 +0,0 @@
-module Cachix.Client.Daemon.PushManager
-  ( newPushManagerEnv,
-    runPushManager,
-    stopPushManager,
-
-    -- * Push strategy
-    newPushStrategy,
-
-    -- * Push job
-    PushJob (..),
-    addPushJob,
-    lookupPushJob,
-    withPushJob,
-    resolvePushJob,
-    pendingJobCount,
-
-    -- * Store paths
-    queueStorePaths,
-    removeStorePath,
-    queuedStorePathCount,
-
-    -- * Tasks
-    handleTask,
-    -- Push events
-    pushStarted,
-    pushFinished,
-    pushStorePathAttempt,
-    pushStorePathProgress,
-    pushStorePathDone,
-    pushStorePathFailed,
-
-    -- * Helpers
-    atomicallyWithTimeout,
-  )
-where
-
-import Cachix.Client.CNix (filterInvalidStorePath, followLinksToStorePath)
-import Cachix.Client.Commands.Push hiding (pushStrategy)
-import qualified Cachix.Client.Daemon.Protocol as Protocol
-import qualified Cachix.Client.Daemon.PushManager.PushJob as PushJob
-import Cachix.Client.Daemon.Types.Log (Logger)
-import Cachix.Client.Daemon.Types.PushEvent
-import Cachix.Client.Daemon.Types.PushManager
-import Cachix.Client.OptionsParser as Client.OptionsParser
-  ( PushOptions (..),
-  )
-import Cachix.Client.Push as Client.Push
-import Cachix.Client.Retry (retryAll)
-import qualified Cachix.Types.BinaryCache as BinaryCache
-import qualified Conduit as C
-import qualified Control.Concurrent.Async as Async
-import Control.Concurrent.STM.TBMQueue
-import Control.Concurrent.STM.TVar
-import qualified Control.Monad.Catch as E
-import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
-import Control.Retry (RetryStatus)
-import qualified Data.ByteString as BS
-import qualified Data.HashMap.Strict as HashMap
-import Data.IORef
-import qualified Data.Set as Set
-import qualified Data.Text as T
-import Data.Time (UTCTime, diffUTCTime, getCurrentTime, secondsToNominalDiffTime)
-import Hercules.CNix (StorePath)
-import Hercules.CNix.Store (Store, parseStorePath, storePathToPath)
-import qualified Katip
-import Protolude hiding (toS)
-import Protolude.Conv (toS)
-import Servant.Auth ()
-import Servant.Auth.Client
-import Servant.Conduit ()
-import qualified UnliftIO.QSem as QSem
-
-newPushManagerEnv :: (MonadIO m) => PushOptions -> Logger -> OnPushEvent -> m PushManagerEnv
-newPushManagerEnv pushOptions pmLogger onPushEvent = liftIO $ do
-  pmPushJobs <- newTVarIO mempty
-  pmPendingJobCount <- newTVarIO 0
-  pmStorePathReferences <- newTVarIO mempty
-  pmTaskQueue <- newTBMQueueIO 1000
-  pmTaskSemaphore <- QSem.newQSem (numJobs pushOptions)
-  pmLastEventTimestamp <- newTVarIO =<< getCurrentTime
-  let pmOnPushEvent id pushEvent = updateTimestampTVar pmLastEventTimestamp >> onPushEvent id pushEvent
-
-  return $ PushManagerEnv {..}
-
-runPushManager :: (MonadIO m) => PushManagerEnv -> PushManager a -> m a
-runPushManager env f = liftIO $ unPushManager f `runReaderT` env
-
-stopPushManager :: TimeoutOptions -> PushManagerEnv -> IO ()
-stopPushManager timeoutOptions PushManagerEnv {..} = do
-  atomicallyWithTimeout timeoutOptions pmLastEventTimestamp $ do
-    pendingJobs <- readTVar pmPendingJobCount
-    check (pendingJobs <= 0)
-  atomically $ closeTBMQueue pmTaskQueue
-
--- Manage push jobs
-
-addPushJob :: Protocol.PushRequest -> PushManager Protocol.PushRequestId
-addPushJob pushRequest = do
-  PushManagerEnv {..} <- ask
-  pushJob <- PushJob.new pushRequest
-
-  Katip.logLocM Katip.DebugS $ Katip.ls $ "Queued push job " <> (show (pushId pushJob) :: Text)
-
-  liftIO $
-    atomically $ do
-      modifyTVar' pmPushJobs $ HashMap.insert (pushId pushJob) pushJob
-      incrementTVar pmPendingJobCount
-      writeTBMQueue pmTaskQueue $ ResolveClosure (pushId pushJob)
-
-  return (pushId pushJob)
-
-removePushJob :: Protocol.PushRequestId -> PushManager ()
-removePushJob pushId = do
-  PushManagerEnv {..} <- ask
-  liftIO $ atomically $ do
-    mpushJob <- HashMap.lookup pushId <$> readTVar pmPushJobs
-    for_ mpushJob $ \pushJob -> do
-      -- Decrement the job count if this job had not been processed yet
-      unless (PushJob.isProcessed pushJob) (decrementTVar pmPendingJobCount)
-      modifyTVar' pmPushJobs (HashMap.delete pushId)
-
-lookupPushJob :: Protocol.PushRequestId -> PushManager (Maybe PushJob)
-lookupPushJob pushId = do
-  pushJobs <- asks pmPushJobs
-  liftIO $ HashMap.lookup pushId <$> readTVarIO pushJobs
-
-withPushJob :: Protocol.PushRequestId -> (PushJob -> PushManager ()) -> PushManager ()
-withPushJob pushId f =
-  maybe handleMissingPushJob f =<< lookupPushJob pushId
-  where
-    handleMissingPushJob =
-      Katip.logLocM Katip.ErrorS $ Katip.ls $ "Push job " <> (show pushId :: Text) <> " not found"
-
-modifyPushJob :: Protocol.PushRequestId -> (PushJob -> PushJob) -> PushManager (Maybe PushJob)
-modifyPushJob pushId f = do
-  pushJobs <- asks pmPushJobs
-  liftIO $ atomically $ modifyPushJobSTM pushJobs pushId f
-
-modifyPushJobSTM :: PushJobStore -> Protocol.PushRequestId -> (PushJob -> PushJob) -> STM (Maybe PushJob)
-modifyPushJobSTM pushJobs pushId f =
-  stateTVar pushJobs $ \jobs -> do
-    let pj = HashMap.adjust f pushId jobs
-    (HashMap.lookup pushId pj, pj)
-
-modifyPushJobs :: [Protocol.PushRequestId] -> (PushJob -> PushJob) -> PushManager ()
-modifyPushJobs pushIds f = do
-  pushJobs <- asks pmPushJobs
-  liftIO $ atomically $ modifyTVar' pushJobs $ \pushJobs' ->
-    foldl' (flip (HashMap.adjust f)) pushJobs' pushIds
-
-failPushJob :: Protocol.PushRequestId -> PushManager ()
-failPushJob pushId = do
-  PushManagerEnv {..} <- ask
-  timestamp <- liftIO getCurrentTime
-  liftIO $ atomically $ do
-    _ <- modifyPushJobSTM pmPushJobs pushId $ PushJob.fail timestamp
-    decrementTVar pmPendingJobCount
-
-pendingJobCount :: PushManager Int
-pendingJobCount = do
-  pmPendingJobCount <- asks pmPendingJobCount
-  liftIO $ readTVarIO pmPendingJobCount
-
--- Manage store paths
-
-queueStorePaths :: Protocol.PushRequestId -> [FilePath] -> PushManager ()
-queueStorePaths pushId storePaths = do
-  PushManagerEnv {..} <- ask
-
-  let addToQueue storePath = do
-        isDuplicate <- HashMap.member storePath <$> readTVar pmStorePathReferences
-        unless isDuplicate $
-          writeTBMQueue pmTaskQueue (PushStorePath storePath)
-
-        modifyTVar' pmStorePathReferences $ HashMap.insertWith (<>) storePath [pushId]
-
-  transactionally $ map addToQueue storePaths
-
-removeStorePath :: FilePath -> PushManager ()
-removeStorePath storePath = do
-  pmStorePathReferences <- asks pmStorePathReferences
-  liftIO $ atomically $ do
-    modifyTVar' pmStorePathReferences $ HashMap.delete storePath
-
-lookupStorePathReferences :: FilePath -> PushManager [Protocol.PushRequestId]
-lookupStorePathReferences storePath = do
-  pmStorePathReferences <- asks pmStorePathReferences
-  references <- liftIO $ readTVarIO pmStorePathReferences
-  return $ fromMaybe [] (HashMap.lookup storePath references)
-
-checkPushJobCompleted :: Protocol.PushRequestId -> PushManager ()
-checkPushJobCompleted pushId = do
-  PushManagerEnv {..} <- ask
-  mpushJob <- lookupPushJob pushId
-  for_ mpushJob $ \pushJob ->
-    when (Set.null $ pushQueue pushJob) $ do
-      timestamp <- liftIO getCurrentTime
-      liftIO $ atomically $ do
-        _ <- modifyPushJobSTM pmPushJobs pushId $ PushJob.complete timestamp
-        decrementTVar pmPendingJobCount
-      pushFinished pushJob
-
-queuedStorePathCount :: PushManager Integer
-queuedStorePathCount = do
-  pmPushJobs <- asks pmPushJobs
-  jobs <- liftIO $ readTVarIO pmPushJobs
-  pure $ foldl' countQueuedPaths 0 (HashMap.elems jobs)
-  where
-    countQueuedPaths acc job = acc + fromIntegral (Set.size $ pushQueue job)
-
-resolvePushJob :: Protocol.PushRequestId -> PushJob.ResolvedClosure FilePath -> PushManager ()
-resolvePushJob pushId closure = do
-  Katip.logLocM Katip.DebugS $ Katip.ls $ showClosureStats closure
-
-  timestamp <- liftIO getCurrentTime
-  _ <- modifyPushJob pushId $ PushJob.populateQueue closure timestamp
-
-  withPushJob pushId $ \pushJob -> do
-    pushStarted pushJob
-    -- Create STM action for each path and then run everything atomically
-    queueStorePaths pushId $ Set.toList (PushJob.rcMissingPaths closure)
-    -- Check if the job is already completed, i.e. all paths have been skipped.
-    checkPushJobCompleted pushId
-  where
-    showClosureStats :: PushJob.ResolvedClosure FilePath -> Text
-    showClosureStats PushJob.ResolvedClosure {..} =
-      let skippedPaths = Set.difference rcAllPaths rcMissingPaths
-          queuedCount = length rcMissingPaths
-          skippedCount = length skippedPaths
-          totalCount = queuedCount + skippedCount
-       in T.intercalate
-            "\n"
-            [ "Resolved push job " <> show pushId,
-              "Total paths: " <> show totalCount,
-              "Queued paths: " <> show queuedCount,
-              "Skipped paths: " <> show skippedCount
-            ]
-
-handleTask :: PushParams PushManager () -> Task -> PushManager ()
-handleTask pushParams task = do
-  case task of
-    ResolveClosure pushId ->
-      runResolveClosureTask pushParams pushId
-    PushStorePath filePath ->
-      runPushStorePathTask pushParams filePath
-
-runResolveClosureTask :: PushParams PushManager () -> Protocol.PushRequestId -> PushManager ()
-runResolveClosureTask pushParams pushId =
-  resolveClosure `withException` failJob
-  where
-    failJob :: SomeException -> PushManager ()
-    failJob err = do
-      failPushJob pushId
-
-      Katip.katipAddContext (Katip.sl "error" (displayException err)) $
-        Katip.logLocM Katip.ErrorS $
-          Katip.ls $
-            "Failed to resolve closure for push job " <> (show pushId :: Text)
-
-    resolveClosure = do
-      Katip.logLocM Katip.DebugS $ Katip.ls $ "Resolving closure for push job " <> (show pushId :: Text)
-
-      withPushJob pushId $ \pushJob -> do
-        let sps = Protocol.storePaths (pushRequest pushJob)
-            store = pushParamsStore pushParams
-        normalized <- mapM (normalizeStorePath store) sps
-        (allStorePaths, missingStorePaths) <- getMissingPathsForClosure pushParams (catMaybes normalized)
-        storePathsToPush <- pushOnClosureAttempt pushParams allStorePaths missingStorePaths
-
-        resolvedClosure <- do
-          allPaths <- liftIO $ mapM (storeToFilePath store) allStorePaths
-          pathsToPush <- liftIO $ mapM (storeToFilePath store) storePathsToPush
-          return $
-            PushJob.ResolvedClosure
-              { rcAllPaths = Set.fromList allPaths,
-                rcMissingPaths = Set.fromList pathsToPush
-              }
-
-        resolvePushJob pushId resolvedClosure
-
-runPushStorePathTask :: PushParams PushManager () -> FilePath -> PushManager ()
-runPushStorePathTask pushParams filePath = do
-  pushStorePath `withException` failStorePath
-  where
-    failStorePath =
-      pushStorePathFailed filePath . toS . displayException
-
-    pushStorePath = do
-      qs <- asks pmTaskSemaphore
-      E.bracket_ (QSem.waitQSem qs) (QSem.signalQSem qs) $ do
-        Katip.logLocM Katip.DebugS $ Katip.ls $ "Pushing store path " <> filePath
-
-        let store = pushParamsStore pushParams
-        storePath <- liftIO $ parseStorePath store (toS filePath)
-
-        retryAll $ uploadStorePath pushParams storePath
-
-newPushStrategy ::
-  Store ->
-  Maybe Token ->
-  PushOptions ->
-  Text ->
-  BinaryCache.CompressionMethod ->
-  (StorePath -> PushStrategy PushManager ())
-newPushStrategy store authToken opts cacheName compressionMethod storePath =
-  let onAlreadyPresent = do
-        sp <- liftIO $ storePathToPath store storePath
-        Katip.logFM Katip.InfoS $ Katip.ls $ "Skipping " <> (toS sp :: Text)
-        -- TODO: needs another event type here
-        pushStorePathDone (toS sp)
-
-      onError err = do
-        let errText = toS (displayException err)
-        sp <- liftIO $ storePathToPath store storePath
-        Katip.katipAddContext (Katip.sl "error" errText) $
-          Katip.logFM Katip.InfoS (Katip.ls $ "Failed " <> (toS sp :: Text))
-        pushStorePathFailed (toS sp) errText
-
-      onAttempt retryStatus size = do
-        sp <- liftIO $ storePathToPath store storePath
-        Katip.logFM Katip.InfoS $ Katip.ls $ "Pushing " <> (toS sp :: Text)
-        pushStorePathAttempt (toS sp) size retryStatus
-
-      onUncompressedNARStream _ size = do
-        sp <- liftIO $ storePathToPath store storePath
-        lastEmitRef <- liftIO $ newIORef (0 :: Int64)
-        currentBytesRef <- liftIO $ newIORef (0 :: Int64)
-        C.awaitForever $ \chunk -> do
-          let newBytes = fromIntegral (BS.length chunk)
-          currentBytes <- liftIO $ atomicModifyIORef' currentBytesRef (\b -> (b + newBytes, b + newBytes))
-          lastEmit <- liftIO $ readIORef lastEmitRef
-
-          when (currentBytes - lastEmit >= 1024 || currentBytes == size) $ do
-            liftIO $ writeIORef lastEmitRef currentBytes
-            lift $ lift $ pushStorePathProgress (toS sp) currentBytes newBytes
-
-          C.yield chunk
-
-      onDone = do
-        sp <- liftIO $ storePathToPath store storePath
-        Katip.logFM Katip.InfoS $ Katip.ls $ "Pushed " <> (toS sp :: Text)
-        pushStorePathDone (toS sp)
-   in PushStrategy
-        { onAlreadyPresent = onAlreadyPresent,
-          on401 = liftIO . handleCacheResponse cacheName authToken,
-          onError = onError,
-          onAttempt = onAttempt,
-          onUncompressedNARStream = onUncompressedNARStream,
-          onDone = onDone,
-          Client.Push.compressionMethod = compressionMethod,
-          Client.Push.compressionLevel = Client.OptionsParser.compressionLevel opts,
-          Client.Push.chunkSize = Client.OptionsParser.chunkSize opts,
-          Client.Push.numConcurrentChunks = Client.OptionsParser.numConcurrentChunks opts,
-          Client.Push.omitDeriver = Client.OptionsParser.omitDeriver opts
-        }
-
--- Push events
-
-pushStarted :: PushJob -> PushManager ()
-pushStarted pushJob@PushJob {pushId} = do
-  case PushJob.startedAt pushJob of
-    Nothing -> return ()
-    Just timestamp -> do
-      sendPushEvent <- asks pmOnPushEvent
-      liftIO $ do
-        sendPushEvent pushId $
-          PushEvent timestamp pushId PushStarted
-
-pushFinished :: PushJob -> PushManager ()
-pushFinished pushJob@PushJob {pushId} = void $ runMaybeT $ do
-  pushDuration <- MaybeT $ pure $ PushJob.duration pushJob
-  completedAt <- MaybeT $ pure $ PushJob.completedAt pushJob
-
-  Katip.logLocM Katip.InfoS $
-    Katip.ls $
-      T.intercalate
-        " "
-        [ "Push job",
-          show pushId :: Text,
-          "finished in",
-          show pushDuration
-        ]
-
-  sendPushEvent <- asks pmOnPushEvent
-  liftIO $ do
-    sendPushEvent pushId $
-      PushEvent completedAt pushId PushFinished
-
-  lift $ removePushJob pushId
-
-sendStorePathEvent :: [Protocol.PushRequestId] -> PushEventMessage -> PushManager ()
-sendStorePathEvent pushIds msg = do
-  timestamp <- liftIO getCurrentTime
-  sendPushEvent <- asks pmOnPushEvent
-  liftIO $ forM_ pushIds $ \pushId ->
-    sendPushEvent pushId (PushEvent timestamp pushId msg)
-
-pushStorePathAttempt :: FilePath -> Int64 -> RetryStatus -> PushManager ()
-pushStorePathAttempt storePath size retryStatus = do
-  let pushRetryStatus = newPushRetryStatus retryStatus
-  pushIds <- lookupStorePathReferences storePath
-  sendStorePathEvent pushIds (PushStorePathAttempt storePath size pushRetryStatus)
-
-pushStorePathProgress :: FilePath -> Int64 -> Int64 -> PushManager ()
-pushStorePathProgress storePath currentBytes newBytes = do
-  pushIds <- lookupStorePathReferences storePath
-  sendStorePathEvent pushIds (PushStorePathProgress storePath currentBytes newBytes)
-
-pushStorePathDone :: FilePath -> PushManager ()
-pushStorePathDone storePath = do
-  pushIds <- lookupStorePathReferences storePath
-  modifyPushJobs pushIds (PushJob.markStorePathPushed storePath)
-
-  sendStorePathEvent pushIds (PushStorePathDone storePath)
-
-  forM_ pushIds checkPushJobCompleted
-
-  removeStorePath storePath
-
-pushStorePathFailed :: FilePath -> Text -> PushManager ()
-pushStorePathFailed storePath errMsg = do
-  pushIds <- lookupStorePathReferences storePath
-  modifyPushJobs pushIds (PushJob.markStorePathFailed storePath)
-
-  sendStorePathEvent pushIds (PushStorePathFailed storePath errMsg)
-
-  forM_ pushIds checkPushJobCompleted
-
-  removeStorePath storePath
-
--- Helpers
-
-storeToFilePath :: (MonadIO m) => Store -> StorePath -> m FilePath
-storeToFilePath store storePath = do
-  fp <- liftIO $ storePathToPath store storePath
-  pure $ toS fp
-
--- | Canonicalize and validate a store path
-normalizeStorePath :: (MonadIO m) => Store -> FilePath -> m (Maybe StorePath)
-normalizeStorePath store fp =
-  liftIO $ runMaybeT $ do
-    storePath <- MaybeT $ followLinksToStorePath store (encodeUtf8 $ T.pack fp)
-    MaybeT $ filterInvalidStorePath store storePath
-
-withException :: (E.MonadCatch m) => m a -> (SomeException -> m a) -> m a
-withException action handler = action `E.catchAll` (\e -> handler e >> E.throwM e)
-
--- STM helpers
-
-transactionally :: (Foldable t, MonadIO m) => t (STM ()) -> m ()
-transactionally = liftIO . atomically . sequence_
-
-updateTimestampTVar :: (MonadIO m) => TVar UTCTime -> m ()
-updateTimestampTVar tvar = liftIO $ do
-  now <- getCurrentTime
-  atomically $ writeTVar tvar now
-
-incrementTVar :: TVar Int -> STM ()
-incrementTVar tvar = modifyTVar' tvar (+ 1)
-
-decrementTVar :: TVar Int -> STM ()
-decrementTVar tvar = modifyTVar' tvar (subtract 1)
-
--- | Run a transaction with a timeout.
-atomicallyWithTimeout ::
-  TimeoutOptions ->
-  -- | A TVar timestamp to compare against
-  TVar UTCTime ->
-  -- | The transaction to run
-  STM () ->
-  IO ()
-atomicallyWithTimeout TimeoutOptions {..} timeVar transaction = do
-  timeoutVar <- newTVarIO False
-  Async.race_
-    (updateShutdownTimeout timeoutVar)
-    (waitForGracefulShutdown timeoutVar)
-  where
-    waitForGracefulShutdown timeout =
-      atomically $ transaction `orElse` checkShutdownTimeout timeout
-
-    updateShutdownTimeout timeoutVar =
-      forever $ do
-        now <- getCurrentTime
-        atomically $ do
-          timestamp <- readTVar timeVar
-          let isTimeout =
-                secondsToNominalDiffTime (realToFrac toTimeout) <= now `diffUTCTime` timestamp
-          writeTVar timeoutVar isTimeout
-        threadDelay $ ceiling (toPollingInterval * 1000.0 * 1000.0)
-
-    checkShutdownTimeout timeout = check =<< readTVar timeout
diff --git a/src/Cachix/Client/Daemon/PushManager/PushJob.hs b/src/Cachix/Client/Daemon/PushManager/PushJob.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/PushManager/PushJob.hs
+++ /dev/null
@@ -1,117 +0,0 @@
-module Cachix.Client.Daemon.PushManager.PushJob
-  ( module Cachix.Client.Daemon.PushManager.PushJob,
-    module Types,
-  )
-where
-
-import qualified Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Daemon.Types.PushManager as Types
-import qualified Data.Set as Set
-import Data.Time (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
-import Protolude
-
-new :: (MonadIO m) => Protocol.PushRequest -> m PushJob
-new pushRequest = do
-  pushId <- Protocol.newPushRequestId
-  timestamp <- liftIO getCurrentTime
-  return $
-    PushJob
-      { pushId,
-        pushRequest,
-        pushStatus = Queued,
-        pushQueue = mempty,
-        pushResult = mempty,
-        pushStats = newStats timestamp
-      }
-
-newStats :: UTCTime -> JobStats
-newStats createdAt =
-  JobStats
-    { jsCreatedAt = createdAt,
-      jsStartedAt = Nothing,
-      jsCompletedAt = Nothing
-    }
-
-data ResolvedClosure p = ResolvedClosure
-  { rcAllPaths :: Set p,
-    rcMissingPaths :: Set p
-  }
-
-populateQueue :: ResolvedClosure FilePath -> UTCTime -> PushJob -> PushJob
-populateQueue ResolvedClosure {..} timestamp pushJob@PushJob {..} = do
-  let skippedPaths = Set.difference rcAllPaths rcMissingPaths
-  pushJob
-    { pushStatus = Running,
-      pushStats = pushStats {jsStartedAt = Just timestamp},
-      pushQueue = rcMissingPaths,
-      pushResult = pushResult {prSkippedPaths = skippedPaths}
-    }
-
-addPushedPath :: FilePath -> PushResult -> PushResult
-addPushedPath storePath pushResult =
-  pushResult {prPushedPaths = Set.insert storePath (prPushedPaths pushResult)}
-
-addFailedPath :: FilePath -> PushResult -> PushResult
-addFailedPath storePath pushResult =
-  pushResult {prFailedPaths = Set.insert storePath (prFailedPaths pushResult)}
-
-markStorePathPushed :: FilePath -> PushJob -> PushJob
-markStorePathPushed storePath pushJob@(PushJob {pushQueue, pushResult}) =
-  pushJob
-    { pushQueue = Set.delete storePath pushQueue,
-      pushResult = addPushedPath storePath pushResult
-    }
-
-markStorePathFailed :: FilePath -> PushJob -> PushJob
-markStorePathFailed storePath pushJob@(PushJob {pushQueue, pushResult}) =
-  pushJob
-    { pushQueue = Set.delete storePath pushQueue,
-      pushResult = addFailedPath storePath pushResult
-    }
-
-status :: PushJob -> JobStatus
-status PushJob {pushStatus} = pushStatus
-
-queue :: PushJob -> Set FilePath
-queue PushJob {pushQueue} = pushQueue
-
-result :: PushJob -> PushResult
-result PushJob {pushResult} = pushResult
-
-hasQueuedPaths :: PushJob -> Bool
-hasQueuedPaths = not . Set.null . queue
-
-complete :: UTCTime -> PushJob -> PushJob
-complete timestamp pushJob@PushJob {..} = do
-  pushJob
-    { pushStatus = Completed,
-      pushStats = pushStats {jsCompletedAt = Just timestamp}
-    }
-
-fail :: UTCTime -> PushJob -> PushJob
-fail timestamp pushJob@PushJob {..} = do
-  pushJob
-    { pushStatus = Failed,
-      pushStats = pushStats {jsCompletedAt = Just timestamp}
-    }
-
-isCompleted :: PushJob -> Bool
-isCompleted PushJob {pushStatus} = pushStatus == Completed
-
-isFailed :: PushJob -> Bool
-isFailed PushJob {pushStatus} = pushStatus == Failed
-
-isProcessed :: PushJob -> Bool
-isProcessed pushJob = isCompleted pushJob || isFailed pushJob
-
-startedAt :: PushJob -> Maybe UTCTime
-startedAt PushJob {pushStats = JobStats {jsStartedAt}} = jsStartedAt
-
-completedAt :: PushJob -> Maybe UTCTime
-completedAt PushJob {pushStats = JobStats {jsCompletedAt}} = jsCompletedAt
-
-duration :: PushJob -> Maybe NominalDiffTime
-duration PushJob {pushStats} = do
-  t1 <- jsStartedAt pushStats
-  t2 <- jsCompletedAt pushStats
-  pure $ diffUTCTime t2 t1
diff --git a/src/Cachix/Client/Daemon/ShutdownLatch.hs b/src/Cachix/Client/Daemon/ShutdownLatch.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/ShutdownLatch.hs
+++ /dev/null
@@ -1,26 +0,0 @@
-module Cachix.Client.Daemon.ShutdownLatch
-  ( ShutdownLatch,
-    newShutdownLatch,
-    waitForShutdown,
-    initiateShutdown,
-    isShuttingDown,
-  )
-where
-
-import Control.Concurrent.MVar
-import Protolude
-
--- | A latch to keep track of the shutdown process.
-newtype ShutdownLatch = ShutdownLatch {unShutdownLatch :: MVar ()}
-
-newShutdownLatch :: (MonadIO m) => m ShutdownLatch
-newShutdownLatch = ShutdownLatch <$> liftIO newEmptyMVar
-
-waitForShutdown :: (MonadIO m) => ShutdownLatch -> m ()
-waitForShutdown = liftIO . readMVar . unShutdownLatch
-
-initiateShutdown :: (MonadIO m) => ShutdownLatch -> m ()
-initiateShutdown = void . liftIO . flip tryPutMVar () . unShutdownLatch
-
-isShuttingDown :: (MonadIO m) => ShutdownLatch -> m Bool
-isShuttingDown = liftIO . fmap not . isEmptyMVar . unShutdownLatch
diff --git a/src/Cachix/Client/Daemon/SocketStore.hs b/src/Cachix/Client/Daemon/SocketStore.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/SocketStore.hs
+++ /dev/null
@@ -1,43 +0,0 @@
-module Cachix.Client.Daemon.SocketStore
-  ( newSocketStore,
-    addSocket,
-    removeSocket,
-    toList,
-    Socket (..),
-  )
-where
-
-import Cachix.Client.Daemon.Types.SocketStore (Socket (..), SocketId, SocketStore (..))
-import Control.Concurrent.STM.TVar
-import Control.Monad.IO.Unlift (MonadUnliftIO)
-import qualified Data.HashMap.Strict as HashMap
-import qualified Data.UUID.V4 as UUID
-import qualified Network.Socket
-import Protolude hiding (toList)
-import qualified UnliftIO.Async as Async
-
-newSocketStore :: (MonadIO m) => m SocketStore
-newSocketStore = SocketStore <$> liftIO (newTVarIO mempty)
-
-newSocketId :: (MonadIO m) => m SocketId
-newSocketId = liftIO UUID.nextRandom
-
-addSocket :: (MonadUnliftIO m) => Network.Socket.Socket -> (SocketId -> Network.Socket.Socket -> m ()) -> SocketStore -> m ()
-addSocket socket handler (SocketStore st) = do
-  socketId <- newSocketId
-  handlerThread <- Async.async (handler socketId socket)
-  liftIO $ atomically $ modifyTVar' st $ HashMap.insert socketId (Socket {..})
-
-removeSocket :: (MonadIO m) => SocketId -> SocketStore -> m ()
-removeSocket socketId (SocketStore stvar) = do
-  msocket <- liftIO $ atomically $ stateTVar stvar $ \st ->
-    let msocket = HashMap.lookup socketId st
-     in (msocket, HashMap.delete socketId st)
-
-  -- shut down the handler thread
-  mapM_ (Async.uninterruptibleCancel . handlerThread) msocket
-
-toList :: (MonadIO m) => SocketStore -> m [Socket]
-toList (SocketStore st) = do
-  hm <- liftIO $ readTVarIO st
-  return $ HashMap.elems hm
diff --git a/src/Cachix/Client/Daemon/Subscription.hs b/src/Cachix/Client/Daemon/Subscription.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Subscription.hs
+++ /dev/null
@@ -1,102 +0,0 @@
-module Cachix.Client.Daemon.Subscription where
-
-import Control.Concurrent.STM.TBMQueue
-import Control.Concurrent.STM.TMChan
-import Control.Concurrent.STM.TVar
-import Data.Aeson as Aeson (ToJSON)
-import Data.HashMap.Strict (HashMap)
-import qualified Data.HashMap.Strict as HashMap
-import qualified Network.Socket as Socket
-import Protolude
-
-data SubscriptionManager k v = SubscriptionManager
-  { managerSubscriptions :: TVar (HashMap k [Subscription v]),
-    managerGlobalSubscriptions :: TVar [Subscription v],
-    managerEvents :: TBMQueue (k, v)
-  }
-
-data Subscription v
-  = -- | A subscriber that listens on a socket.
-    SubSocket (TBMQueue v) Socket.Socket
-  | -- | A subscriber that listens on a channel.
-    SubChannel (TMChan v)
-
-newSubscriptionManager :: IO (SubscriptionManager k v)
-newSubscriptionManager = do
-  subscriptions <- newTVarIO HashMap.empty
-  globalSubscriptions <- newTVarIO []
-  events <- newTBMQueueIO 10000
-  pure $ SubscriptionManager subscriptions globalSubscriptions events
-
--- Subscriptions
-
-subscribeTo :: (Hashable k, MonadIO m) => SubscriptionManager k v -> k -> Subscription v -> m ()
-subscribeTo manager key subscription =
-  liftIO $ atomically $ subscribeToSTM manager key subscription
-
-subscribeToAll :: (MonadIO m) => SubscriptionManager k v -> Subscription v -> m ()
-subscribeToAll manager subscription =
-  liftIO $ atomically $ subscribeToAllSTM manager subscription
-
-getSubscriptionsFor :: (Hashable k, MonadIO m) => SubscriptionManager k v -> k -> m [Subscription v]
-getSubscriptionsFor manager key =
-  liftIO $ atomically $ getSubscriptionsForSTM manager key
-
-subscribeToSTM :: (Hashable k) => SubscriptionManager k v -> k -> Subscription v -> STM ()
-subscribeToSTM manager key subscription = do
-  subscriptions <- readTVar $ managerSubscriptions manager
-  let subscriptions' = HashMap.insertWith (<>) key [subscription] subscriptions
-  writeTVar (managerSubscriptions manager) subscriptions'
-
-subscribeToAllSTM :: SubscriptionManager k v -> Subscription v -> STM ()
-subscribeToAllSTM manager subscription = do
-  subscriptions <- readTVar $ managerGlobalSubscriptions manager
-  let subscriptions' = subscription : subscriptions
-  writeTVar (managerGlobalSubscriptions manager) subscriptions'
-
-getSubscriptionsForSTM :: (Hashable k) => SubscriptionManager k v -> k -> STM [Subscription v]
-getSubscriptionsForSTM manager key = do
-  subscriptions <- readTVar $ managerSubscriptions manager
-  pure $ HashMap.lookupDefault [] key subscriptions
-
--- Events
-
-pushEvent :: (MonadIO m) => SubscriptionManager k v -> k -> v -> m ()
-pushEvent manager key event =
-  liftIO $ atomically $ pushEventSTM manager key event
-
-pushEventSTM :: SubscriptionManager k v -> k -> v -> STM ()
-pushEventSTM manager key event =
-  writeTBMQueue (managerEvents manager) (key, event)
-
-sendEventToSub :: Subscription v -> v -> STM ()
--- TODO: implement socket subscriptions.
-sendEventToSub (SubSocket _queue _) _ = pure () -- writeTBMQueue queue
-sendEventToSub (SubChannel chan) event = writeTMChan chan event
-
-runSubscriptionManager :: (Show k, Show v, Hashable k, ToJSON v, MonadIO m) => SubscriptionManager k v -> m ()
-runSubscriptionManager manager = do
-  isDone <- liftIO $ atomically $ do
-    mevent <- readTBMQueue (managerEvents manager)
-    case mevent of
-      Nothing -> return True
-      Just (key, event) -> do
-        subscriptions <- getSubscriptionsForSTM manager key
-        globalSubscriptions <- readTVar $ managerGlobalSubscriptions manager
-        mapM_ (`sendEventToSub` event) (subscriptions <> globalSubscriptions)
-        return False
-
-  unless isDone $
-    runSubscriptionManager manager
-
-stopSubscriptionManager :: SubscriptionManager k v -> IO ()
-stopSubscriptionManager manager = do
-  liftIO $ atomically $ closeTBMQueue (managerEvents manager)
-  globalSubscriptions <- liftIO $ readTVarIO $ managerGlobalSubscriptions manager
-  subscriptions <- liftIO $ readTVarIO $ managerSubscriptions manager
-
-  forM_ (concat subscriptions <> globalSubscriptions) $ \case
-    SubSocket queue sock -> do
-      atomically $ closeTBMQueue queue
-      Socket.close sock
-    SubChannel channel -> atomically $ closeTMChan channel
diff --git a/src/Cachix/Client/Daemon/Types.hs b/src/Cachix/Client/Daemon/Types.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Types.hs
+++ /dev/null
@@ -1,26 +0,0 @@
-module Cachix.Client.Daemon.Types
-  ( -- * Daemon
-    DaemonEnv (..),
-    Daemon,
-    runDaemon,
-
-    -- * Log
-    LogLevel (..),
-
-    -- * Push
-    PushManager.PushManagerEnv (..),
-    PushManager.PushManager,
-    PushManager.PushJob (..),
-    Task (..),
-
-    -- * Push Event
-    PushEvent.PushEvent (..),
-    PushEvent.PushEventMessage (..),
-    PushEvent.PushRetryStatus (..),
-  )
-where
-
-import Cachix.Client.Daemon.Types.Daemon
-import Cachix.Client.Daemon.Types.Log
-import Cachix.Client.Daemon.Types.PushEvent as PushEvent
-import Cachix.Client.Daemon.Types.PushManager as PushManager
diff --git a/src/Cachix/Client/Daemon/Types/Daemon.hs b/src/Cachix/Client/Daemon/Types/Daemon.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Types/Daemon.hs
+++ /dev/null
@@ -1,93 +0,0 @@
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-
-module Cachix.Client.Daemon.Types.Daemon
-  ( -- * Daemon
-    DaemonEnv (..),
-    Daemon,
-    runDaemon,
-  )
-where
-
-import Cachix.Client.Config.Orphans ()
-import qualified Cachix.Client.Daemon.Log as Log
-import qualified Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Daemon.ShutdownLatch (ShutdownLatch)
-import Cachix.Client.Daemon.Subscription (SubscriptionManager)
-import Cachix.Client.Daemon.Types.EventLoop (EventLoop)
-import Cachix.Client.Daemon.Types.Log (Logger)
-import Cachix.Client.Daemon.Types.PushEvent (PushEvent)
-import Cachix.Client.Daemon.Types.PushManager (PushManagerEnv (..))
-import Cachix.Client.Daemon.Types.SocketStore (SocketStore)
-import Cachix.Client.Env as Env
-import Cachix.Client.OptionsParser (PushOptions)
-import Cachix.Client.Push
-import Cachix.Types.BinaryCache (BinaryCache, BinaryCacheName)
-import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow)
-import Control.Monad.IO.Unlift (MonadUnliftIO)
-import qualified Katip
-import Protolude hiding (bracketOnError)
-import System.Posix.Types (ProcessID)
-
-data DaemonEnv = DaemonEnv
-  { -- | Cachix client env
-    daemonEnv :: Env,
-    -- | The main event loop
-    daemonEventLoop :: EventLoop,
-    -- | Push options, like compression settings and number of jobs
-    daemonPushOptions :: PushOptions,
-    -- | Path to the socket that the daemon listens on
-    daemonSocketPath :: FilePath,
-    -- | Main inbound socket thread
-    daemonSocketThread :: MVar (Async ()),
-    -- | The push secret for the binary cache
-    daemonPushSecret :: PushSecret,
-    -- | The name of the binary cache to push to
-    daemonCacheName :: BinaryCacheName,
-    -- | The binary cache to push to
-    daemonBinaryCache :: BinaryCache,
-    -- | The state of active push requests
-    daemonPushManager :: PushManagerEnv,
-    -- | Connected clients over the socket
-    daemonClients :: SocketStore,
-    -- | A multiplexer for push events.
-    daemonSubscriptionManager :: SubscriptionManager Protocol.PushRequestId PushEvent,
-    -- | Logging env
-    daemonLogger :: Logger,
-    -- | Shutdown latch
-    daemonShutdownLatch :: ShutdownLatch,
-    -- | The PID of the daemon process
-    daemonPid :: ProcessID
-  }
-
-newtype Daemon a = Daemon
-  { unDaemon :: ReaderT DaemonEnv IO a
-  }
-  deriving newtype
-    ( Functor,
-      Applicative,
-      Monad,
-      MonadIO,
-      MonadReader DaemonEnv,
-      MonadUnliftIO,
-      MonadCatch,
-      MonadMask,
-      MonadThrow
-    )
-
-instance Katip.Katip Daemon where
-  getLogEnv = Log.getKatipLogEnv <$> asks daemonLogger
-  localLogEnv f (Daemon m) = Daemon (local (\s -> s {daemonLogger = Log.localLogEnv f (daemonLogger s)}) m)
-
-instance Katip.KatipContext Daemon where
-  getKatipContext = Log.getKatipContext <$> asks daemonLogger
-  localKatipContext f (Daemon m) = Daemon (local (\s -> s {daemonLogger = Log.localKatipContext f (daemonLogger s)}) m)
-
-  getKatipNamespace = Log.getKatipNamespace <$> asks daemonLogger
-  localKatipNamespace f (Daemon m) = Daemon (local (\s -> s {daemonLogger = Log.localKatipNamespace f (daemonLogger s)}) m)
-
--- | Run a pre-configured daemon.
-runDaemon :: DaemonEnv -> Daemon a -> IO a
-runDaemon env f = do
-  Log.withLogger (daemonLogger env) $ \logger -> do
-    let pushManagerEnv = (daemonPushManager env) {pmLogger = logger}
-    unDaemon f `runReaderT` env {daemonLogger = logger, daemonPushManager = pushManagerEnv}
diff --git a/src/Cachix/Client/Daemon/Types/EventLoop.hs b/src/Cachix/Client/Daemon/Types/EventLoop.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Types/EventLoop.hs
+++ /dev/null
@@ -1,35 +0,0 @@
-module Cachix.Client.Daemon.Types.EventLoop
-  ( EventLoop (..),
-    ExitLatch,
-    DaemonEvent (..),
-  )
-where
-
-import qualified Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Daemon.Types.SocketStore (SocketId)
-import Control.Concurrent.STM.TBMQueue (TBMQueue)
-import Network.Socket (Socket)
-import Protolude
-
--- | An event loop that processes 'DaemonEvent's.
-data EventLoop = EventLoop
-  { queue :: TBMQueue DaemonEvent,
-    exitLatch :: ExitLatch
-  }
-
--- | An exit latch is a semaphore that signals the event loop to exit.
--- The exit code should be returned by the 'EventLoop'.
-type ExitLatch = MVar ExitCode
-
--- | Daemon events that are handled by the 'EventLoop'.
-data DaemonEvent
-  = -- | Shut down the daemon gracefully.
-    ShutdownGracefully
-  | -- | Re-establish the daemon socket
-    ReconnectSocket
-  | -- | Add a new client socket connection.
-    AddSocketClient Socket
-  | -- | Remove an existing client socket connection. For example, after it is closed.
-    RemoveSocketClient SocketId
-  | -- | Handle a new message from a client.
-    ReceivedMessage Protocol.ClientMessage
diff --git a/src/Cachix/Client/Daemon/Types/Log.hs b/src/Cachix/Client/Daemon/Types/Log.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Types/Log.hs
+++ /dev/null
@@ -1,32 +0,0 @@
-module Cachix.Client.Daemon.Types.Log
-  ( LogLevel (..),
-    Logger (..),
-  )
-where
-
-import qualified Katip
-import Protolude
-
--- | The log level to use for logging
---
--- TODO: reuse in deploy agent
-data LogLevel
-  = Debug
-  | Info
-  | Warning
-  | Error
-  deriving stock (Eq, Ord, Show)
-
-data Logger = Logger
-  { -- | An optional handle to output logs to.
-    -- Defaults to stdout.
-    logHandle :: Maybe Handle,
-    -- | The log level to use for logging
-    logLevel :: LogLevel,
-    -- | Logger namespace
-    logKNamespace :: Katip.Namespace,
-    -- | Logger context
-    logKContext :: Katip.LogContexts,
-    -- | Logger env
-    logKLogEnv :: Katip.LogEnv
-  }
diff --git a/src/Cachix/Client/Daemon/Types/PushEvent.hs b/src/Cachix/Client/Daemon/Types/PushEvent.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Types/PushEvent.hs
+++ /dev/null
@@ -1,42 +0,0 @@
-module Cachix.Client.Daemon.Types.PushEvent
-  ( PushEvent (..),
-    PushEventMessage (..),
-    PushRetryStatus (..),
-    newPushRetryStatus,
-  )
-where
-
-import qualified Cachix.Client.Daemon.Protocol as Protocol
-import Control.Retry (RetryStatus (..))
-import Data.Aeson (FromJSON, ToJSON)
-import Data.Time (UTCTime)
-import Protolude
-
-data PushEvent = PushEvent
-  { -- TODO: newtype a monotonic clock
-    eventTimestamp :: UTCTime,
-    eventPushId :: Protocol.PushRequestId,
-    eventMessage :: PushEventMessage
-  }
-  deriving stock (Eq, Generic, Show)
-  deriving anyclass (FromJSON, ToJSON)
-
-instance Ord PushEvent where
-  compare = compare `on` eventTimestamp
-
-data PushEventMessage
-  = PushStarted
-  | PushStorePathAttempt FilePath Int64 PushRetryStatus
-  | PushStorePathProgress FilePath Int64 Int64
-  | PushStorePathDone FilePath
-  | PushStorePathFailed FilePath Text
-  | PushFinished
-  deriving stock (Eq, Generic, Show)
-  deriving anyclass (FromJSON, ToJSON)
-
-data PushRetryStatus = PushRetryStatus {retryCount :: Int}
-  deriving stock (Eq, Generic, Show)
-  deriving anyclass (FromJSON, ToJSON)
-
-newPushRetryStatus :: RetryStatus -> PushRetryStatus
-newPushRetryStatus RetryStatus {..} = PushRetryStatus {retryCount = rsIterNumber}
diff --git a/src/Cachix/Client/Daemon/Types/PushManager.hs b/src/Cachix/Client/Daemon/Types/PushManager.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Types/PushManager.hs
+++ /dev/null
@@ -1,133 +0,0 @@
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-{-# LANGUAGE RankNTypes #-}
-{-# LANGUAGE TypeFamilies #-}
-
-module Cachix.Client.Daemon.Types.PushManager
-  ( PushManagerEnv (..),
-    PushManager (..),
-    PushJobStore,
-    PushJob (..),
-    JobStatus (..),
-    JobStats (..),
-    PushResult (..),
-    OnPushEvent,
-    Task (..),
-    TimeoutOptions (..),
-  )
-where
-
-import qualified Cachix.Client.Daemon.Log as Log
-import qualified Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Daemon.Types.Log (Logger)
-import Cachix.Client.Daemon.Types.PushEvent (PushEvent (..))
-import Control.Concurrent.STM.TBMQueue
-import Control.Concurrent.STM.TVar
-import Control.Monad.Catch
-import Control.Monad.IO.Unlift (MonadUnliftIO)
-import Data.HashMap.Strict (HashMap)
-import Data.Time (UTCTime)
-import qualified Katip
-import Protolude
-
-data Task
-  = ResolveClosure Protocol.PushRequestId
-  | PushStorePath FilePath
-
-type PushJobStore = TVar (HashMap Protocol.PushRequestId PushJob)
-
--- TODO: a lot of the logic surrounding deduping, search, and job tracking could be replaced by sqlite.
--- sqlite can run in-memory if we don't need persistence.
--- If we do, then we can we get stop/resume for free.
-data PushManagerEnv = PushManagerEnv
-  { -- | A store of push jobs indexed by a PushRequestId.
-    pmPushJobs :: PushJobStore,
-    -- | A mapping of store paths to push requests.
-    -- Used to prevent duplicate pushes and track which store paths are referenced by which push requests.
-    pmStorePathReferences :: TVar (HashMap FilePath [Protocol.PushRequestId]),
-    -- | FIFO queue of push tasks.
-    pmTaskQueue :: TBMQueue Task,
-    -- | A semaphore to control task concurrency.
-    pmTaskSemaphore :: QSem,
-    -- | A callback for push events.
-    pmOnPushEvent :: OnPushEvent,
-    -- | The timestamp of the most recent event. This is used to track activity internally.
-    pmLastEventTimestamp :: TVar UTCTime,
-    -- | The number of pending (uncompleted) jobs.
-    pmPendingJobCount :: TVar Int,
-    pmLogger :: Logger
-  }
-
-type OnPushEvent = Protocol.PushRequestId -> PushEvent -> IO ()
-
-newtype PushManager a = PushManager {unPushManager :: ReaderT PushManagerEnv IO a}
-  deriving newtype
-    ( Functor,
-      Applicative,
-      Monad,
-      MonadIO,
-      MonadUnliftIO,
-      MonadReader PushManagerEnv,
-      MonadCatch,
-      MonadMask,
-      MonadThrow
-    )
-
-instance Katip.Katip PushManager where
-  getLogEnv = Log.getKatipLogEnv <$> asks pmLogger
-  localLogEnv f (PushManager m) = PushManager (local (\s -> s {pmLogger = Log.localLogEnv f (pmLogger s)}) m)
-
-instance Katip.KatipContext PushManager where
-  getKatipContext = Log.getKatipContext <$> asks pmLogger
-  localKatipContext f (PushManager m) = PushManager (local (\s -> s {pmLogger = Log.localKatipContext f (pmLogger s)}) m)
-
-  getKatipNamespace = Log.getKatipNamespace <$> asks pmLogger
-  localKatipNamespace f (PushManager m) = PushManager (local (\s -> s {pmLogger = Log.localKatipNamespace f (pmLogger s)}) m)
-
-data JobStatus = Queued | Running | Completed | Failed
-  deriving stock (Eq, Show)
-
--- | A push request that has been queued for processing.
-data PushJob = PushJob
-  { -- | A unique identifier for this push request.
-    pushId :: Protocol.PushRequestId,
-    -- | The original push request.
-    pushRequest :: Protocol.PushRequest,
-    -- | The current status of the push job.
-    pushStatus :: JobStatus,
-    -- | Paths that need to be pushed.
-    pushQueue :: Set FilePath,
-    -- | Track whether paths were pushed, skipped, or failed to push.
-    pushResult :: PushResult,
-    -- | Timing stats for the push job.
-    pushStats :: JobStats
-  }
-  deriving stock (Eq, Show)
-
-data PushResult = PushResult
-  { prPushedPaths :: Set FilePath,
-    prFailedPaths :: Set FilePath,
-    prSkippedPaths :: Set FilePath
-  }
-  deriving stock (Eq, Show)
-
-instance Semigroup PushResult where
-  PushResult a1 b1 c1 <> PushResult a2 b2 c2 =
-    PushResult (a1 <> a2) (b1 <> b2) (c1 <> c2)
-
-instance Monoid PushResult where
-  mempty = PushResult mempty mempty mempty
-
-data JobStats = JobStats
-  { jsCreatedAt :: UTCTime,
-    jsStartedAt :: Maybe UTCTime,
-    jsCompletedAt :: Maybe UTCTime
-  }
-  deriving stock (Eq, Show)
-
-data TimeoutOptions = TimeoutOptions
-  { -- | The maximum time to wait in seconds.
-    toTimeout :: Float,
-    -- | The interval at which to check the timeout condition.
-    toPollingInterval :: Float
-  }
-  deriving stock (Eq, Show)
diff --git a/src/Cachix/Client/Daemon/Types/SocketStore.hs b/src/Cachix/Client/Daemon/Types/SocketStore.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Types/SocketStore.hs
+++ /dev/null
@@ -1,23 +0,0 @@
-module Cachix.Client.Daemon.Types.SocketStore (Socket (..), SocketId, SocketStore (..)) where
-
-import Control.Concurrent.STM.TVar (TVar)
-import Data.HashMap.Strict (HashMap)
-import Data.UUID (UUID)
-import qualified Network.Socket as Network (Socket)
-import Protolude
-
-data Socket = Socket
-  { socketId :: SocketId,
-    socket :: Network.Socket,
-    handlerThread :: Async ()
-  }
-
-instance Eq Socket where
-  (==) = (==) `on` socketId
-
-instance Ord Socket where
-  compare = comparing socketId
-
-type SocketId = UUID
-
-newtype SocketStore = SocketStore (TVar (HashMap SocketId Socket))
diff --git a/src/Cachix/Client/Daemon/Worker.hs b/src/Cachix/Client/Daemon/Worker.hs
deleted file mode 100644
--- a/src/Cachix/Client/Daemon/Worker.hs
+++ /dev/null
@@ -1,62 +0,0 @@
-module Cachix.Client.Daemon.Worker
-  ( startWorkers,
-    stopWorkers,
-    startWorker,
-    stopWorker,
-    Immortal.Thread,
-  )
-where
-
-import qualified Control.Concurrent.Async as Async
-import Control.Concurrent.STM.TBMQueue
-import qualified Control.Immortal as Immortal
-import Control.Monad.IO.Unlift (MonadUnliftIO)
-import qualified Katip
-import Protolude
-
-startWorkers ::
-  (MonadUnliftIO m, Katip.KatipContext m) =>
-  Int ->
-  TBMQueue a ->
-  (a -> m ()) ->
-  m [Immortal.Thread]
-startWorkers numWorkers queue f = do
-  replicateM numWorkers (startWorker queue f)
-
-startWorker ::
-  (MonadUnliftIO m, Katip.KatipContext m) =>
-  TBMQueue a ->
-  (a -> m ()) ->
-  m Immortal.Thread
-startWorker queue f = do
-  Immortal.createWithLabel "worker" $ \thread -> do
-    Katip.katipAddNamespace "worker" $
-      Immortal.onUnexpectedFinish thread logWorkerException (runWorker queue f)
-
-stopWorkers :: (Katip.KatipContext m) => [Immortal.Thread] -> m ()
-stopWorkers workers = do
-  Katip.logFM Katip.DebugS "Waiting for workers to finish..."
-  liftIO $ Async.mapConcurrently_ stopWorker workers
-  Katip.logFM Katip.DebugS "Workers finished."
-
-stopWorker :: (MonadIO m) => Immortal.Thread -> m ()
-stopWorker worker = liftIO $ do
-  Immortal.mortalize worker
-  Immortal.wait worker
-
-logWorkerException :: (Exception e, Katip.KatipContext m) => Either e () -> m ()
-logWorkerException (Left err) =
-  Katip.logFM Katip.ErrorS $ Katip.ls (toS $ displayException err :: Text)
-logWorkerException _ = return ()
-
-runWorker :: (MonadIO m) => TBMQueue a -> (a -> m ()) -> m ()
-runWorker queue f = loop
-  where
-    loop = do
-      mres <- liftIO $ atomically $ readTBMQueue queue
-
-      case mres of
-        Nothing -> return ()
-        Just job -> do
-          f job
-          loop
diff --git a/src/Cachix/Client/Env.hs b/src/Cachix/Client/Env.hs
--- a/src/Cachix/Client/Env.hs
+++ b/src/Cachix/Client/Env.hs
@@ -3,12 +3,13 @@
     mkEnv,
     createClientEnv,
     customManagerSettings,
+    Config.defaultCachixOptions,
   )
 where
 
 import Cachix.Client.Config (Config)
-import qualified Cachix.Client.Config as Config
-import qualified Cachix.Client.OptionsParser as Options
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.OptionsParser qualified as Options
 import Cachix.Client.URI (getBaseUrl)
 import Cachix.Client.Version (cachixVersion)
 import Network.HTTP.Client
diff --git a/src/Cachix/Client/HumanSize.hs b/src/Cachix/Client/HumanSize.hs
--- a/src/Cachix/Client/HumanSize.hs
+++ b/src/Cachix/Client/HumanSize.hs
@@ -2,7 +2,7 @@
 
 import Protolude
 import Text.Printf (printf)
-import qualified Prelude
+import Prelude qualified
 
 humanSize :: Double -> Text
 humanSize size
diff --git a/src/Cachix/Client/InstallationMode.hs b/src/Cachix/Client/InstallationMode.hs
--- a/src/Cachix/Client/InstallationMode.hs
+++ b/src/Cachix/Client/InstallationMode.hs
@@ -3,6 +3,7 @@
 module Cachix.Client.InstallationMode
   ( InstallationMode (..),
     NixEnv (..),
+    getNixEnv,
     getInstallationMode,
     addBinaryCache,
     removeBinaryCache,
@@ -16,18 +17,18 @@
 where
 
 import Cachix.Client.Config (Config)
-import qualified Cachix.Client.Config as Config
+import Cachix.Client.Config qualified as Config
 import Cachix.Client.Exception (CachixException (..))
-import qualified Cachix.Client.NetRc as NetRc
-import qualified Cachix.Client.NixConf as NixConf
-import qualified Cachix.Client.URI as URI
-import qualified Cachix.Types.BinaryCache as BinaryCache
-import qualified Data.Maybe
+import Cachix.Client.NetRc qualified as NetRc
+import Cachix.Client.NixConf qualified as NixConf
+import Cachix.Client.URI qualified as URI
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Data.Maybe qualified
 import Data.String.Here
-import qualified Data.Text as T
+import Data.Text qualified as T
 import Protolude hiding (toS)
 import Protolude.Conv (toS)
-import System.Directory (Permissions, createDirectoryIfMissing, getPermissions, writable)
+import System.Directory (Permissions, createDirectoryIfMissing, doesFileExist, getPermissions, writable)
 import System.Environment (lookupEnv)
 import System.FilePath (replaceFileName, (</>))
 import System.Process (readProcessWithExitCode)
@@ -76,6 +77,19 @@
 toString WriteNixOS = "nixos"
 toString UntrustedRequiresSudo = "untrusted-requires-sudo"
 toString UntrustedNixOS = "untrusted-nixos"
+
+getNixEnv :: IO NixEnv
+getNixEnv = do
+  user <- getUser
+  nc <- NixConf.read NixConf.Global
+  isTrusted <- isTrustedUser $ NixConf.readLines (catMaybes [nc]) NixConf.isTrustedUsers
+  isNixOS <- doesFileExist "/run/current-system/nixos-version"
+  return $
+    NixEnv
+      { isRoot = user == "root",
+        isTrusted = isTrusted,
+        isNixOS = isNixOS
+      }
 
 getInstallationMode :: NixEnv -> UseOptions -> InstallationMode
 getInstallationMode nixenv useOptions
diff --git a/src/Cachix/Client/NetRc.hs b/src/Cachix/Client/NetRc.hs
--- a/src/Cachix/Client/NetRc.hs
+++ b/src/Cachix/Client/NetRc.hs
@@ -6,10 +6,10 @@
 
 import Cachix.API.Error (escalateAs)
 import Cachix.Client.Exception (CachixException (NetRcParseError))
-import qualified Cachix.Types.BinaryCache as BinaryCache
-import qualified Data.ByteString as BS
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Data.ByteString qualified as BS
 import Data.List (nubBy)
-import qualified Data.Text as T
+import Data.Text qualified as T
 import Network.NetRc
 import Protolude hiding (toS)
 import Protolude.Conv
diff --git a/src/Cachix/Client/NixConf.hs b/src/Cachix/Client/NixConf.hs
--- a/src/Cachix/Client/NixConf.hs
+++ b/src/Cachix/Client/NixConf.hs
@@ -33,10 +33,10 @@
   )
 where
 
-import qualified Cachix.Client.URI as URI
-import qualified Cachix.Types.BinaryCache as BinaryCache
+import Cachix.Client.URI qualified as URI
+import Cachix.Types.BinaryCache qualified as BinaryCache
 import Data.List (nub)
-import qualified Data.Text as T
+import Data.Text qualified as T
 import Protolude hiding (toS)
 import Protolude.Conv (toS)
 import System.Directory
@@ -46,7 +46,7 @@
     getXdgDirectory,
   )
 import System.FilePath.Posix (takeDirectory)
-import qualified Text.Megaparsec as Mega
+import Text.Megaparsec qualified as Mega
 import Text.Megaparsec.Char
 
 data NixConfLine
diff --git a/src/Cachix/Client/NixVersion.hs b/src/Cachix/Client/NixVersion.hs
--- a/src/Cachix/Client/NixVersion.hs
+++ b/src/Cachix/Client/NixVersion.hs
@@ -12,7 +12,7 @@
 import Protolude
 import System.Process (readProcessWithExitCode)
 import Text.Megaparsec (Parsec, anySingle, choice, eof, lookAhead, manyTill, parse)
-import qualified Text.Megaparsec as P
+import Text.Megaparsec qualified as P
 import Text.Megaparsec.Char (digitChar)
 
 minimalVersion :: SemVer
diff --git a/src/Cachix/Client/OptionsParser.hs b/src/Cachix/Client/OptionsParser.hs
--- a/src/Cachix/Client/OptionsParser.hs
+++ b/src/Cachix/Client/OptionsParser.hs
@@ -1,5 +1,3 @@
-{-# LANGUAGE ApplicativeDo #-}
-
 module Cachix.Client.OptionsParser
   ( CachixCommand (..),
     DaemonCommand (..),
@@ -16,6 +14,9 @@
     defaultNumJobs,
     defaultOmitDeriver,
 
+    -- * Watch exec
+    WatchExecMode (..),
+
     -- * Pin options
     PinOptions (..),
 
@@ -28,20 +29,20 @@
   )
 where
 
-import qualified Cachix.Client.Config as Config
-import qualified Cachix.Client.InstallationMode as InstallationMode
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.InstallationMode qualified as InstallationMode
 import Cachix.Client.URI (URI)
-import qualified Cachix.Client.URI as URI
-import qualified Cachix.Deploy.OptionsParser as DeployOptions
+import Cachix.Client.URI qualified as URI
+import Cachix.Deploy.OptionsParser qualified as DeployOptions
 import Cachix.Types.BinaryCache (BinaryCacheName)
-import qualified Cachix.Types.BinaryCache as BinaryCache
+import Cachix.Types.BinaryCache qualified as BinaryCache
 import Cachix.Types.PinCreate (Keep (..))
 import Data.Conduit.ByteString (ChunkSize)
-import qualified Data.Text as T
+import Data.Text qualified as T
 import Options.Applicative
 import Protolude hiding (toS)
 import Protolude.Conv
-import qualified Prelude
+import Prelude qualified
 
 data Flags = Flags
   { configPath :: Config.ConfigPath,
@@ -49,48 +50,6 @@
     verbose :: Bool
   }
 
-flagParser :: Config.ConfigPath -> Parser Flags
-flagParser defaultConfigPath = do
-  hostname <-
-    optional $
-      option
-        uriOption
-        ( mconcat
-            [ long "hostname",
-              metavar "URI",
-              help ("Host to connect to (default: " <> defaultHostname <> ")")
-            ]
-        )
-        -- Accept `host` for backwards compatibility
-        <|> option uriOption (long "host" <> hidden)
-
-  configPath <-
-    strOption $
-      mconcat
-        [ long "config",
-          short 'c',
-          value defaultConfigPath,
-          metavar "CONFIGPATH",
-          showDefault,
-          help "Cachix configuration file"
-        ]
-
-  verbose <-
-    switch $
-      mconcat
-        [ long "verbose",
-          short 'v',
-          help "Verbose mode"
-        ]
-
-  pure Flags {hostname, configPath, verbose}
-  where
-    defaultHostname = URI.serialize URI.defaultCachixURI
-
-uriOption :: ReadM URI
-uriOption = eitherReader $ \s ->
-  first show $ URI.parseURI (toS s)
-
 data CachixCommand
   = AuthToken (Maybe Text)
   | Config Config.Command
@@ -100,7 +59,7 @@
   | Import PushOptions Text URI
   | Pin PinOptions
   | WatchStore PushOptions Text
-  | WatchExec PushOptions Text Text [Text]
+  | WatchExec WatchExecMode PushOptions Text Text [Text]
   | Use BinaryCacheName InstallationMode.UseOptions
   | Remove BinaryCacheName
   | DeployCommand DeployOptions.DeployCommand
@@ -125,7 +84,7 @@
   { -- | The compression level to use.
     compressionLevel :: Int,
     -- | The compression method to use.
-    -- Default value taken rom the cache settings.
+    -- The default value is read from the remote binary cache settings.
     compressionMethod :: Maybe BinaryCache.CompressionMethod,
     -- | The size of each uploaded part.
     --
@@ -191,183 +150,443 @@
   }
   deriving (Show)
 
+-- | CLI parser entry point
+getOpts :: IO (Flags, CachixCommand)
+getOpts = do
+  configpath <- Config.getDefaultFilename
+  let preferences = showHelpOnError <> showHelpOnEmpty <> helpShowGlobals <> subparserInline
+  customExecParser (prefs preferences) (optsInfo configpath)
+
+optsInfo :: Config.ConfigPath -> ParserInfo (Flags, CachixCommand)
+optsInfo configpath =
+  infoH parser $
+    fullDesc
+      <> progDesc "To get started, log in to https://app.cachix.org"
+      <> header "https://cachix.org command line interface"
+  where
+    parser = (,) <$> flagParser configpath <*> (commandParser <|> versionParser)
+
 commandParser :: Parser CachixCommand
 commandParser =
-  subparser $
-    command "authtoken" (infoH authtoken (progDesc "Configure an authentication token for Cachix"))
-      <> command "config" (Config <$> Config.parser)
-      <> (hidden <> command "daemon" (infoH (Daemon <$> daemon) (progDesc "Run a daemon that listens to push requests over a unix socket")))
-      <> command "generate-keypair" (infoH generateKeypair (progDesc "Generate a signing key pair for a binary cache"))
-      <> command "push" (infoH push (progDesc "Upload Nix store paths to a binary cache"))
-      <> command "import" (infoH import' (progDesc "Import the contents of a binary cache from an S3-compatible object storage service into Cachix"))
-      <> command "pin" (infoH pin (progDesc "Pin a store path to prevent it from being garbage collected"))
-      <> command "watch-exec" (infoH watchExec (progDesc "Run a command while watching /nix/store for newly added store paths and upload them to a binary cache"))
-      <> command "watch-store" (infoH watchStore (progDesc "Watch /nix/store for newly added store paths and upload them to a binary cache"))
-      <> command "use" (infoH use (progDesc "Configure a binary cache in nix.conf"))
-      <> command "remove" (infoH remove (progDesc "Remove a binary cache from nix.conf"))
-      <> command "deploy" (infoH (DeployCommand <$> DeployOptions.parser) (progDesc "Manage remote Nix-based systems with Cachix Deploy"))
+  configCommands
+    <|> cacheCommands
+    <|> pushCommands
+    <|> storePathCommands
+    <|> daemonCommands
+    <|> deployCommands
   where
-    nameArg = strArgument (metavar "CACHE-NAME")
-    authtoken = AuthToken <$> (stdinFlag <|> (Just <$> authTokenArg))
-      where
-        stdinFlag = flag' Nothing (long "stdin" <> help "Read the auth token from stdin")
-        authTokenArg = strArgument (metavar "AUTH-TOKEN")
-    generateKeypair = GenerateKeypair <$> nameArg
-    validatedLevel l =
-      l <$ unless (l `elem` [0 .. 16]) (readerError $ "value " <> show l <> " not in expected range: [0..16]")
-    validatedMethod :: Prelude.String -> Either Prelude.String (Maybe BinaryCache.CompressionMethod)
-    validatedMethod method =
+    configCommands =
+      subparser $
+        fold
+          [ commandGroup "Config commands:",
+            hidden,
+            command "authtoken" $ infoH authTokenCommand $ progDesc "Configure an authentication token for Cachix",
+            command "config" configCommand
+          ]
+
+    cacheCommands =
+      subparser $
+        fold
+          [ commandGroup "Cache commands:",
+            hidden,
+            command "generate-keypair" $ infoH generateKeypairCommand $ progDesc "Generate a signing key pair for a binary cache",
+            command "use" $ infoH useCommand $ progDesc "Configure a binary cache in nix.conf",
+            command "remove" $ infoH removeCommand $ progDesc "Remove a binary cache from nix.conf"
+          ]
+
+    pushCommands =
+      subparser $
+        fold
+          [ commandGroup "Push commands:",
+            command "push" $ infoH pushCommand $ progDesc "Upload Nix store paths to a binary cache",
+            command "watch-exec" $ infoH watchExecCommand $ progDesc "Run a command while watching /nix/store for newly added store paths and upload them to a binary cache",
+            command "watch-store" $ infoH watchStoreCommand $ progDesc "Watch /nix/store for newly added store paths and upload them to a binary cache",
+            command "import" $ infoH importCommand $ progDesc "Import the contents of a binary cache from an S3-compatible object storage service into Cachix"
+          ]
+
+    storePathCommands =
+      subparser $
+        fold
+          [ commandGroup "Store path commands:",
+            hidden,
+            command "pin" $ infoH pinCommand $ progDesc "Pin a store path to prevent it from being garbage collected"
+          ]
+
+    daemonCommands =
+      subparser $
+        fold
+          [ commandGroup "Daemon commands:",
+            hidden,
+            command "daemon" $ infoH daemonCommand $ progDesc "Run a daemon that listens to push requests over a unix socket"
+          ]
+
+    deployCommands =
+      subparser $
+        fold
+          [ commandGroup "Cachix Deploy commands:",
+            hidden,
+            command "deploy" $ infoH deployCommand $ progDesc "Manage remote Nix-based systems with Cachix Deploy"
+          ]
+
+flagParser :: Config.ConfigPath -> Parser Flags
+flagParser defaultConfigPath =
+  Flags <$> configPath <*> (host <|> hostname) <*> verbose
+  where
+    defaultHostname = URI.serialize URI.defaultCachixURI
+
+    hostOpts =
+      [ metavar "URI",
+        help $ "Host to connect to (default: " <> defaultHostname <> ")"
+      ]
+
+    -- Accept both hostname and host.
+    -- TODO: switch to host.
+    hostname =
+      optional . option uriOption $
+        long "hostname" <> mconcat hostOpts
+
+    host =
+      optional . option uriOption $
+        long "host" <> metavar "URI"
+
+    configPath =
+      strOption $
+        mconcat
+          [ long "config",
+            short 'c',
+            value defaultConfigPath,
+            metavar "CONFIGPATH",
+            showDefault,
+            help "Cachix configuration file"
+          ]
+
+    verbose =
+      switch $
+        long "verbose"
+          <> short 'v'
+          <> help "Verbose mode"
+
+uriOption :: ReadM URI
+uriOption = eitherReader (first show . URI.parseURI . toS)
+
+cacheNameParser :: Parser BinaryCacheName
+cacheNameParser = strArgument (metavar "CACHE-NAME")
+
+authTokenCommand :: Parser CachixCommand
+authTokenCommand = AuthToken <$> (stdinFlag <|> (Just <$> authTokenArg))
+  where
+    stdinFlag = flag' Nothing (long "stdin" <> help "Read the auth token from stdin")
+    authTokenArg = strArgument (metavar "AUTH-TOKEN")
+
+configCommand :: ParserInfo CachixCommand
+configCommand = Config <$> Config.parser
+
+generateKeypairCommand :: Parser CachixCommand
+generateKeypairCommand = GenerateKeypair <$> cacheNameParser
+
+pushOptionsParser :: Parser PushOptions
+pushOptionsParser =
+  PushOptions
+    <$> compressionLevel
+    <*> compressionMethod
+    <*> chunkSize
+    <*> numConcurrentChunks
+    <*> numJobs
+    <*> omitDeriver
+  where
+    compressionLevel =
+      option (auto >>= validateCompressionLevel) $
+        long "compression-level"
+          <> short 'l'
+          <> metavar "[0..16]"
+          <> help
+            "The compression level to use. Supported range: [0-9] for xz and [0-16] for zstd."
+          <> showDefault
+          <> value defaultCompressionLevel
+
+    validateCompressionLevel l =
+      l
+        <$ unless
+          (l `elem` [0 .. 16])
+          (readerError $ "value " <> show l <> " not in expected range: [0..16]")
+
+    compressionMethod =
+      option (eitherReader validateCompressionMethod) $
+        long "compression-method"
+          <> short 'm'
+          <> metavar "xz | zstd"
+          <> help
+            "The compression method to use. Overrides the preferred compression method advertised by the cache. Supported methods: xz | zstd. Defaults to zstd."
+          <> value Nothing
+
+    validateCompressionMethod :: Prelude.String -> Either Prelude.String (Maybe BinaryCache.CompressionMethod)
+    validateCompressionMethod method =
       if method `elem` ["xz", "zstd"]
         then case readEither (T.toUpper (toS method)) of
           Right a -> Right $ Just a
           Left b -> Left $ toS b
         else Left $ "Compression method " <> show method <> " not expected. Use xz or zstd."
-    validatedChunkSize c =
-      c <$ unless (c >= minChunkSize && c <= maxChunkSize) (readerError $ "value " <> show c <> " not in expected range: " <> prettyChunkRange)
+
+    chunkSize =
+      option (auto >>= validateChunkSize) $
+        long "chunk-size"
+          <> short 's'
+          <> metavar prettyChunkRange
+          <> help "The size of each uploaded part in bytes. The supported range is from 5MiB to 5GiB."
+          <> showDefault
+          <> value defaultChunkSize
+
+    validateChunkSize c =
+      c
+        <$ unless
+          (c >= minChunkSize && c <= maxChunkSize)
+          (readerError $ "value " <> show c <> " not in expected range: " <> prettyChunkRange)
+
     prettyChunkRange = "[" <> show minChunkSize <> ".." <> show maxChunkSize <> "]"
-    pushOptions :: Parser PushOptions
-    pushOptions =
-      PushOptions
-        <$> option
-          (auto >>= validatedLevel)
-          ( long "compression-level"
-              <> short 'c'
-              <> metavar "[0..16]"
-              <> help
-                "The compression level to use. Supported range: [0-9] for xz and [0-16] for zstd."
-              <> showDefault
-              <> value defaultCompressionLevel
-          )
-        <*> option
-          (eitherReader validatedMethod)
-          ( long "compression-method"
-              <> short 'm'
-              <> metavar "xz | zstd"
-              <> help
-                "The compression method to use. Overrides the preferred compression method advertised by the cache. Supported methods: xz | zstd. Defaults to zstd."
-              <> value Nothing
-          )
-        <*> option
-          (auto >>= validatedChunkSize)
-          ( long "chunk-size"
-              <> short 's'
-              <> metavar prettyChunkRange
-              <> help "The size of each uploaded part in bytes. The supported range is from 5MiB to 5GiB."
-              <> showDefault
-              <> value defaultChunkSize
-          )
-        <*> option
-          auto
-          ( long "num-concurrent-chunks"
-              <> short 'n'
-              <> metavar "INT"
-              <> help "The number of chunks to upload concurrently. The total memory usage is jobs * num-concurrent-chunks * chunk-size."
-              <> showDefault
-              <> value defaultNumConcurrentChunks
-          )
-        <*> option
-          auto
-          ( long "jobs"
-              <> short 'j'
-              <> metavar "INT"
-              <> help "The number of threads to use when pushing store paths."
-              <> showDefault
-              <> value defaultNumJobs
-          )
-        <*> switch (long "omit-deriver" <> help "Do not publish which derivations built the store paths.")
-    push = (\opts cache f -> Push $ f opts cache) <$> pushOptions <*> nameArg <*> (pushPaths <|> pushWatchStore)
-    pushPaths =
-      (\paths opts cache -> PushPaths opts cache paths)
-        <$> many (strArgument (metavar "PATHS..."))
-    import' = Import <$> pushOptions <*> nameArg <*> strArgument (metavar "S3-URI" <> help "e.g. s3://mybucket?endpoint=https://myexample.com&region=eu-central-1")
-    keepParser = daysParser <|> revisionsParser <|> foreverParser <|> pure Nothing
+
+    numConcurrentChunks =
+      option auto $
+        long "num-concurrent-chunks"
+          <> short 'n'
+          <> metavar "INT"
+          <> help "The number of chunks to upload concurrently. The total memory usage is jobs * num-concurrent-chunks * chunk-size."
+          <> showDefault
+          <> value defaultNumConcurrentChunks
+
+    numJobs =
+      option auto $
+        long "jobs"
+          <> short 'j'
+          <> metavar "INT"
+          <> help "The number of threads to use when pushing store paths."
+          <> showDefault
+          <> value defaultNumJobs
+
+    omitDeriver =
+      switch $
+        long "omit-deriver"
+          <> help "Do not publish which derivations built the store paths."
+
+pushCommand :: Parser CachixCommand
+pushCommand = toPushCommand <$> pushOptionsParser <*> cacheNameParser <*> pushArgumentsParser
+  where
+    -- Figure out which subcommand we're in at the end and pass it the previous options.
+    -- We can't do that beforehand because the paths argument parser needs to come after the cache name argument parser.
+    toPushCommand pushOptions cacheName pushArguments =
+      Push $ pushArguments pushOptions cacheName
+
+pushArgumentsParser :: Parser (PushOptions -> BinaryCacheName -> PushArguments)
+pushArgumentsParser = pushPathsParser <|> deprecatedPushWatchStoreParser
+
+pushPathsParser :: Parser (PushOptions -> BinaryCacheName -> PushArguments)
+pushPathsParser = toPushPaths <$> pathArgs
+  where
+    -- Move the path arguments to the end
+    toPushPaths paths pushOptions cacheName =
+      PushPaths pushOptions cacheName paths
+
+    pathArgs =
+      many $ strArgument (metavar "PATHS...")
+
+deprecatedPushWatchStoreParser :: Parser (PushOptions -> BinaryCacheName -> PushArguments)
+deprecatedPushWatchStoreParser =
+  flag' PushWatchStore $
+    long "watch-store"
+      <> short 'w'
+      <> help "DEPRECATED: use watch-store command instead."
+
+importCommand :: Parser CachixCommand
+importCommand =
+  Import
+    <$> pushOptionsParser
+    <*> cacheNameParser
+    <*> s3UriOption
+  where
+    s3UriOption =
+      strArgument $
+        metavar "S3-URI"
+          <> help "e.g. s3://mybucket?endpoint=https://myexample.com&region=eu-central-1"
+
+pinCommand :: Parser CachixCommand
+pinCommand = Pin <$> pinOptionsParser
+
+pinOptionsParser :: Parser PinOptions
+pinOptionsParser =
+  PinOptions
+    <$> cacheNameParser
+    <*> pinName
+    <*> storePath
+    <*> artifacts
+    <*> keepParser
+  where
+    pinName = strArgument $ metavar "PIN-NAME"
+
+    storePath = strArgument $ metavar "STORE-PATH"
+
+    artifacts =
+      many . strOption $
+        metavar "ARTIFACTS..."
+          <> long "artifact"
+          <> short 'a'
+
+keepParser :: Parser (Maybe Keep)
+keepParser =
+  daysParser
+    <|> revisionsParser
+    <|> foreverParser
+    <|> pure Nothing
+  where
     -- these three flag are mutually exclusive
-    daysParser = Just . Days <$> option auto (long "keep-days" <> metavar "INT")
-    revisionsParser = Just . Revisions <$> option auto (long "keep-revisions" <> metavar "INT")
-    foreverParser = flag' (Just Forever) (long "keep-forever")
-    pinOptions =
-      PinOptions
-        <$> nameArg
-        <*> strArgument (metavar "PIN-NAME")
-        <*> strArgument (metavar "STORE-PATH")
-        <*> many (strOption (metavar "ARTIFACTS..." <> long "artifact" <> short 'a'))
-        <*> keepParser
-    pin = Pin <$> pinOptions
-    daemon =
-      subparser $
-        command "push" (infoH daemonPush (progDesc "Push store paths to the daemon"))
-          <> command "run" (infoH daemonRun (progDesc "Launch the daemon"))
-          <> command "stop" (infoH daemonStop (progDesc "Stop the daemon and wait for any queued paths to be pushed"))
-          <> command "watch-exec" (infoH daemonWatchExec (progDesc "Run a command and upload any store paths built during its execution"))
-    daemonPush = DaemonPushPaths <$> daemonOptions <*> many (strArgument (metavar "PATHS..."))
-    daemonRun = DaemonRun <$> daemonOptions <*> pushOptions <*> nameArg
-    daemonStop = DaemonStop <$> daemonOptions
-    daemonWatchExec = DaemonWatchExec <$> pushOptions <*> nameArg <*> strArgument (metavar "CMD") <*> many (strArgument (metavar "-- ARGS"))
-    daemonOptions = DaemonOptions <$> optional (strOption (long "socket" <> short 's' <> metavar "SOCKET"))
-    watchExec = WatchExec <$> pushOptions <*> nameArg <*> strArgument (metavar "CMD") <*> many (strArgument (metavar "-- ARGS"))
-    watchStore = WatchStore <$> pushOptions <*> nameArg
-    pushWatchStore =
-      (\() opts cache -> PushWatchStore opts cache)
-        <$> flag'
-          ()
-          ( long "watch-store"
-              <> short 'w'
-              <> help "DEPRECATED: use watch-store command instead."
-          )
-    remove = Remove <$> nameArg
-    use =
-      Use
-        <$> nameArg
-        <*> ( InstallationMode.UseOptions
-                <$> optional
-                  ( option
-                      (maybeReader InstallationMode.fromString)
-                      ( long "mode"
-                          <> short 'm'
-                          <> metavar "nixos | root-nixconf | user-nixconf"
-                          <> help "Mode in which to configure binary caches for Nix. Supported values: nixos | root-nixconf | user-nixconf"
-                      )
-                  )
-                <*> strOption
-                  ( long "nixos-folder"
-                      <> short 'd'
-                      <> help "Base directory for NixOS configuration generation"
-                      <> value "/etc/nixos/"
-                      <> showDefault
-                  )
-                <*> optional
-                  ( strOption
-                      ( long "output-directory"
-                          <> short 'O'
-                          <> help "Output directory where nix.conf and netrc will be updated."
-                      )
-                  )
-            )
+    daysParser =
+      Just . Days <$> keepDays
 
-getOpts :: IO (Flags, CachixCommand)
-getOpts = do
-  configpath <- Config.getDefaultFilename
-  let preferences = showHelpOnError <> showHelpOnEmpty <> helpShowGlobals <> subparserInline
-  customExecParser (prefs preferences) (optsInfo configpath)
+    keepDays =
+      option auto $
+        long "keep-days"
+          <> metavar "INT"
 
-optsInfo :: Config.ConfigPath -> ParserInfo (Flags, CachixCommand)
-optsInfo configpath = infoH parser desc
+    revisionsParser =
+      Just . Revisions <$> keepRevisions
+
+    keepRevisions =
+      option auto $
+        long "keep-revisions"
+          <> metavar "INT"
+
+    foreverParser =
+      flag' (Just Forever) $
+        long "keep-forever"
+
+daemonCommand :: Parser CachixCommand
+daemonCommand = Daemon <$> daemonSubCommand
+
+daemonSubCommand :: Parser DaemonCommand
+daemonSubCommand =
+  subparser $
+    fold
+      [ command "push" $ infoH daemonPush $ progDesc "Push store paths to the daemon",
+        command "run" $ infoH daemonRun $ progDesc "Launch the daemon",
+        command "stop" $ infoH daemonStop $ progDesc "Stop the daemon and wait for any queued paths to be pushed",
+        command "watch-exec" $ infoH daemonWatchExec $ progDesc "Run a command and upload any store paths built during its execution"
+      ]
+
+daemonPush :: Parser DaemonCommand
+daemonPush =
+  DaemonPushPaths
+    <$> daemonOptionsParser
+    <*> many (strArgument (metavar "PATHS..."))
+
+daemonRun :: Parser DaemonCommand
+daemonRun =
+  DaemonRun
+    <$> daemonOptionsParser
+    <*> pushOptionsParser
+    <*> cacheNameParser
+
+daemonStop :: Parser DaemonCommand
+daemonStop = DaemonStop <$> daemonOptionsParser
+
+daemonWatchExec :: Parser DaemonCommand
+daemonWatchExec =
+  DaemonWatchExec
+    <$> pushOptionsParser
+    <*> cacheNameParser
+    <*> strArgument (metavar "CMD")
+    <*> many (strArgument (metavar "-- ARGS"))
+
+daemonOptionsParser :: Parser DaemonOptions
+daemonOptionsParser =
+  DaemonOptions <$> socketOption
   where
-    parser = (,) <$> flagParser configpath <*> (commandParser <|> versionParser)
-    versionParser :: Parser CachixCommand
-    versionParser =
-      flag'
-        Version
-        ( long "version"
-            <> short 'V'
-            <> help "Show cachix version"
-        )
+    socketOption =
+      optional . strOption $
+        long "socket"
+          <> short 's'
+          <> metavar "SOCKET"
 
-desc :: InfoMod a
-desc =
-  fullDesc
-    <> progDesc "To get started log in to https://app.cachix.org"
-    <> header "https://cachix.org command line interface"
+deployCommand :: Parser CachixCommand
+deployCommand = DeployCommand <$> DeployOptions.parser
+
+watchExecCommand :: Parser CachixCommand
+watchExecCommand =
+  WatchExec
+    <$> watchExecModeParser
+    <*> pushOptionsParser
+    <*> cacheNameParser
+    <*> strArgument (metavar "CMD")
+    <*> many (strArgument (metavar "-- ARGS"))
+
+data WatchExecMode = Auto | Store | PostBuildHook deriving (Enum, Bounded)
+
+instance Show WatchExecMode where
+  show Auto = "auto"
+  show Store = "store"
+  show PostBuildHook = "post-build-hook"
+
+watchExecModes :: [WatchExecMode]
+watchExecModes = [minBound .. maxBound]
+
+prettyWatchExecModes :: Prelude.String
+prettyWatchExecModes = intercalate " | " (fmap show watchExecModes)
+
+watchExecModeParser :: Parser WatchExecMode
+watchExecModeParser =
+  option (eitherReader parseWatchExecMode) $
+    long "watch-mode"
+      <> value Auto
+      <> showDefault
+      <> metavar prettyWatchExecModes
+      <> help
+        "Mode in which to watch for store paths.\
+        \ \"auto\" attempts to register a post-build hook.\
+        \ This pushes just the store paths built during the execution of CMD.\
+        \ If that fails, it falls back to watching the entire store."
+
+parseWatchExecMode :: Prelude.String -> Either Prelude.String WatchExecMode
+parseWatchExecMode "store" = Right Store
+parseWatchExecMode "post-build-hook" = Right PostBuildHook
+parseWatchExecMode mode = Left $ "Unsupported mode: " <> mode <> ". Supported values: " <> prettyWatchExecModes
+
+watchStoreCommand :: Parser CachixCommand
+watchStoreCommand = WatchStore <$> pushOptionsParser <*> cacheNameParser
+
+removeCommand :: Parser CachixCommand
+removeCommand = Remove <$> cacheNameParser
+
+useCommand :: Parser CachixCommand
+useCommand = Use <$> cacheNameParser <*> installationMode
+
+installationMode :: Parser InstallationMode.UseOptions
+installationMode =
+  InstallationMode.UseOptions <$> useMode <*> nixosFolderPath <*> outputDir
+  where
+    useMode =
+      optional . option (maybeReader InstallationMode.fromString) $
+        long "mode"
+          <> short 'm'
+          <> metavar "nixos | root-nixconf | user-nixconf"
+          <> help "Mode in which to configure binary caches for Nix. Supported values: nixos | root-nixconf | user-nixconf"
+
+    nixosFolderPath =
+      strOption $
+        long "nixos-folder"
+          <> short 'd'
+          <> help "Base directory for NixOS configuration generation"
+          <> value "/etc/nixos/"
+          <> showDefault
+
+    outputDir =
+      optional . strOption $
+        long "output-directory"
+          <> short 'O'
+          <> help "Output directory where nix.conf and netrc will be updated."
+
+versionParser :: Parser CachixCommand
+versionParser =
+  flag' Version $
+    long "version"
+      <> short 'V'
+      <> help "Show cachix version"
 
 -- TODO: usage footer
 infoH :: Parser a -> InfoMod a -> ParserInfo a
diff --git a/src/Cachix/Client/Push.hs b/src/Cachix/Client/Push.hs
--- a/src/Cachix/Client/Push.hs
+++ b/src/Cachix/Client/Push.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE DataKinds #-}
+{-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE Rank2Types #-}
 
 {- This is a standalone module so it shouldn't depend on any CLI state like Env -}
@@ -32,47 +33,51 @@
     -- * Narinfo
     narinfoExists,
     newNarInfoCreate,
+    queryNarInfoBulk,
 
     -- * Pushing a closure of store paths
     pushClosure,
+    computeClosure,
     getMissingPathsForClosure,
     mapConcurrentlyBounded,
   )
 where
 
-import qualified Cachix.API as API
+import Cachix.API qualified as API
 import Cachix.API.Error
 import Cachix.API.Signing (fingerprint, passthroughHashSink, passthroughHashSinkB16, passthroughSizeSink)
 import Cachix.Client.Exception (CachixException (..))
-import qualified Cachix.Client.Push.S3 as Push.S3
+import Cachix.Client.Push.S3 qualified as Push.S3
 import Cachix.Client.Retry (retryAll, retryHttp)
 import Cachix.Client.Secrets
 import Cachix.Client.Servant
-import qualified Cachix.Types.BinaryCache as BinaryCache
-import qualified Cachix.Types.MultipartUpload as Multipart
-import qualified Cachix.Types.NarInfoCreate as Api
-import qualified Cachix.Types.NarInfoHash as NarInfoHash
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Cachix.Types.MultipartUpload qualified as Multipart
+import Cachix.Types.NarInfoCreate qualified as Api
+import Cachix.Types.NarInfoHash qualified as NarInfoHash
 import Conduit (MonadUnliftIO)
 import Control.Concurrent.Async (mapConcurrently)
-import qualified Control.Concurrent.QSem as QSem
+import Control.Concurrent.QSem qualified as QSem
 import Control.Exception.Safe (MonadCatch, MonadMask, throwM)
 import Control.Monad.Trans.Resource (ResourceT)
 import Control.Retry (RetryStatus)
 import Crypto.Sign.Ed25519
-import qualified Data.ByteString.Base64 as B64
+import Data.ByteString.Base64 qualified as B64
 import Data.Conduit
 import Data.Conduit.ByteString (ChunkSize)
-import qualified Data.Conduit.Lzma as Lzma (compress)
-import qualified Data.Conduit.Zstd as Zstd (compress)
+import Data.Conduit.Lzma qualified as Lzma (compress)
+import Data.Conduit.Zstd qualified as Zstd (compress)
 import Data.IORef
-import qualified Data.Set as Set
-import qualified Data.Text as T
+import Data.List.Extra (chunksOf)
+import Data.Set qualified as Set
+import Data.String.Here (iTrim)
+import Data.Text qualified as T
 import Hercules.CNix (StorePath)
-import qualified Hercules.CNix.Std.Set as Std.Set
+import Hercules.CNix.Std.Set qualified as Std.Set
 import Hercules.CNix.Store (Store)
-import qualified Hercules.CNix.Store as Store
+import Hercules.CNix.Store qualified as Store
 import Network.HTTP.Types (status401, status404)
-import qualified Nix.NarInfo as NarInfo
+import Nix.NarInfo qualified as NarInfo
 import Protolude hiding (toS)
 import Protolude.Conv
 import Servant.API
@@ -80,7 +85,7 @@
 import Servant.Auth.Client
 import Servant.Client.Streaming
 import Servant.Conduit ()
-import qualified System.Nix.Base32
+import System.Nix.Base32 qualified
 import System.Nix.Nar
 
 -- | A secret for authenticating with a cache.
@@ -96,7 +101,7 @@
   PushSigningKey token _ -> nullTokenToMaybe token
   where
     nullTokenToMaybe (Token "") = Nothing
-    nullTokenToMaybe (Token token) = Just $ Token token
+    nullTokenToMaybe token = Just token
 
 -- | Parameters for pushing a closure of store paths, to be passed to 'pushClosure'.
 -- This also contains the parameters for pushing a single path, in 'pushParamStrategy'.
@@ -442,12 +447,22 @@
   storeDir <- Store.storeDir store
   storePathText <- liftIO $ toS <$> Store.storePathToPath store storePath
 
-  -- TODO: show expected vs actual NAR hash
   when (undNarHash /= piNarHash pathInfo) $
     throwM $
-      NarHashMismatch $
-        toS storePathText <> ": Nar hash mismatch between nix-store --dump and nix db. You can repair db metadata by running as root: $ nix-store --verify --repair --check-contents"
+      NarHashMismatch
+        [iTrim|
+${storePathText}: the computed NAR hash doesn't match the hash returned by the Nix store.
 
+Expected: ${undNarHash}
+Got:      ${piNarHash pathInfo}
+
+1. Try repairing the Nix store:
+
+   sudo nix-store --verify --repair --check-contents
+
+2. If the issue persists, report it to https://github.com/cachix/cachix/issues
+        |]
+
   let deriver =
         fromMaybe "unknown-deriver" $
           if omitDeriver strategy
@@ -483,31 +498,40 @@
 getMissingPathsForClosure :: (MonadIO m, MonadMask m) => PushParams n r -> [StorePath] -> m ([StorePath], [StorePath])
 getMissingPathsForClosure pushParams inputPaths = do
   let store = pushParamsStore pushParams
-      clientEnv = pushParamsClientEnv pushParams
-  -- Get the transitive closure of dependencies
-  paths <- liftIO $ do
-    inputs <- Std.Set.new
-    for_ inputPaths $ Std.Set.insertFP inputs
-    closure <- Store.computeFSClosure store Store.defaultClosureParams inputs
+  paths <- computeClosure store inputPaths
+  queryNarInfoBulk pushParams paths
+
+-- | Get the transitive closure of dependencies
+computeClosure :: (MonadIO m) => Store -> [StorePath] -> m [StorePath]
+computeClosure store inputPaths =
+  liftIO $ do
+    inputSet <- Std.Set.fromListFP inputPaths
+    closure <- Store.computeFSClosure store Store.defaultClosureParams inputSet
     Std.Set.toListFP closure
-  hashes <- liftIO $ for paths (fmap (decodeUtf8With lenientDecode) . Store.getStorePathHash)
-  -- Check what store paths are missing
-  missingHashesList <-
-    retryHttp $
-      liftIO $
-        escalate
-          =<< API.narinfoBulk
+
+queryNarInfoBulk :: (MonadIO m, MonadMask m) => PushParams n r -> [StorePath] -> m ([StorePath], [StorePath])
+queryNarInfoBulk pushParams paths = do
+  let clientEnv = pushParamsClientEnv pushParams
+
+  pathsAndHashes <- liftIO $
+    for paths $ \path -> do
+      hash' <- decodeUtf8With lenientDecode <$> Store.getStorePathHash path
+      pure (hash', path)
+  let hashes = fmap fst pathsAndHashes
+  let processBatch hashesChunk = retryHttp $ liftIO $ do
+        result <-
+          API.narinfoBulk
             cachixClient
             (getCacheAuthToken (pushParamsSecret pushParams))
             (pushParamsName pushParams)
-            hashes
-          `runClientM` clientEnv
-  let missingHashes = Set.fromList (encodeUtf8 <$> missingHashesList)
-  pathsAndHashes <- liftIO $
-    for paths $ \path -> do
-      hash_ <- Store.getStorePathHash path
-      pure (hash_, path)
-  let missing = map snd $ filter (\(hash_, _path) -> Set.member hash_ missingHashes) pathsAndHashes
+            hashesChunk
+            `runClientM` clientEnv
+        escalate result
+  -- 32 bytes of each hash for 1MB limit
+  missingHashesList <- concat <$> mapM processBatch (chunksOf 32768 hashes)
+
+  let missingHashes = Set.fromList missingHashesList
+  let missing = map snd $ filter (\(hash', _path) -> Set.member hash' missingHashes) pathsAndHashes
   return (paths, missing)
 
 mapConcurrentlyBounded :: (Traversable t) => Int -> (a -> IO b) -> t a -> IO (t b)
diff --git a/src/Cachix/Client/Push/S3.hs b/src/Cachix/Client/Push/S3.hs
--- a/src/Cachix/Client/Push/S3.hs
+++ b/src/Cachix/Client/Push/S3.hs
@@ -9,30 +9,30 @@
   )
 where
 
-import qualified Cachix.API as API
+import Cachix.API qualified as API
 import Cachix.API.Error
 import Cachix.Client.Retry (retryHttp)
 import Cachix.Client.Servant (cachixClient)
 import Cachix.Types.BinaryCache
-import qualified Cachix.Types.MultipartUpload as Multipart
+import Cachix.Types.MultipartUpload qualified as Multipart
 import Conduit (MonadResource, MonadUnliftIO)
 import Control.DeepSeq (rwhnf)
 import Crypto.Hash (Digest, MD5)
-import qualified Crypto.Hash as Crypto
+import Crypto.Hash qualified as Crypto
 import Data.ByteArray.Encoding (Base (..), convertToBase)
 import Data.Conduit (ConduitT, handleC, (.|))
 import Data.Conduit.ByteString (ChunkSize, chunkStream)
-import qualified Data.Conduit.Combinators as CC
+import Data.Conduit.Combinators qualified as CC
 import Data.Conduit.ConcurrentMap (concurrentMapM_)
 import Data.List (lookup)
-import qualified Data.List.NonEmpty as NonEmpty
+import Data.List.NonEmpty qualified as NonEmpty
 import Data.UUID (UUID)
 import Network.HTTP.Client as HTTP
-import qualified Network.HTTP.Types.Header as HTTP
+import Network.HTTP.Types.Header qualified as HTTP
 import Protolude
 import Servant.Auth ()
 import Servant.Auth.Client
-import qualified Servant.Client as Client
+import Servant.Client qualified as Client
 import Servant.Client.Streaming
 import Servant.Conduit ()
 
diff --git a/src/Cachix/Client/PushQueue.hs b/src/Cachix/Client/PushQueue.hs
--- a/src/Cachix/Client/PushQueue.hs
+++ b/src/Cachix/Client/PushQueue.hs
@@ -14,18 +14,18 @@
 
 import Cachix.Client.CNix (filterInvalidStorePath)
 import Cachix.Client.Push (getMissingPathsForClosure)
-import qualified Cachix.Client.Push as Push
+import Cachix.Client.Push qualified as Push
 import Cachix.Client.Retry (retryAll)
 import Control.Concurrent.Async
 import Control.Concurrent.Extra (once)
 import Control.Concurrent.STM (TVar, modifyTVar', newTVarIO, readTVar)
-import qualified Control.Concurrent.STM.Lock as Lock
-import qualified Control.Concurrent.STM.TBQueue as TBQueue
-import qualified Data.Set as S
+import Control.Concurrent.STM.Lock qualified as Lock
+import Control.Concurrent.STM.TBQueue qualified as TBQueue
+import Data.Set qualified as S
 import Hercules.CNix.Store (StorePath)
 import Protolude
-import qualified System.Posix.Signals as Signals
-import qualified System.Systemd.Daemon as Systemd
+import System.Posix.Signals qualified as Signals
+import System.Systemd.Daemon qualified as Systemd
 
 type Queue = TBQueue.TBQueue StorePath
 
diff --git a/src/Cachix/Client/Retry.hs b/src/Cachix/Client/Retry.hs
--- a/src/Cachix/Client/Retry.hs
+++ b/src/Cachix/Client/Retry.hs
@@ -1,7 +1,6 @@
 module Cachix.Client.Retry
   ( retryAll,
     retryAllWithPolicy,
-    retryAllWithLogging,
     retryHttp,
     retryHttpWith,
     endlessRetryPolicy,
@@ -10,11 +9,8 @@
 where
 
 import Cachix.Client.Exception (CachixException (..))
-import qualified Control.Concurrent.Async as Async
-import Control.Exception.Safe
-  ( Handler (..),
-    isSyncException,
-  )
+import Control.Concurrent.Async qualified as Async
+import Control.Exception.Safe (Handler (..))
 import Control.Monad.Catch (MonadCatch, MonadMask, handleJust, throwM)
 import Control.Retry
 import Data.List (lookup)
@@ -28,11 +24,13 @@
     rfc822DateFormat,
   )
 import GHC.Read (Read (readPrec))
-import qualified Network.HTTP.Client as HTTP
+import Network.HTTP.Client qualified as HTTP
 import Network.HTTP.Types.Header (hRetryAfter)
-import qualified Network.HTTP.Types.Status as HTTP
+import Network.HTTP.Types.Status qualified as HTTP
 import Protolude hiding (Handler (..), handleJust)
-import qualified Text.ParserCombinators.ReadPrec as ReadPrec (lift)
+import Servant.Client (ClientError (..))
+import Servant.Client qualified as Servant
+import Text.ParserCombinators.ReadPrec qualified as ReadPrec (lift)
 
 defaultRetryPolicy :: RetryPolicy
 defaultRetryPolicy =
@@ -65,25 +63,6 @@
     -- Retry everything else
     allHandler _ = Handler $ \(_ :: SomeException) -> return True
 
--- Catches all exceptions except async exceptions with logging support
-retryAllWithLogging ::
-  (MonadIO m, MonadMask m) =>
-  RetryPolicyM m ->
-  (Bool -> SomeException -> RetryStatus -> m ()) ->
-  m a ->
-  m a
-retryAllWithLogging policy logger f =
-  recovering policy handlers $
-    const (rethrowLinkedThreadExceptions f)
-  where
-    handlers = skipAsyncExceptions ++ [exitCodeHandler, loggingHandler]
-
-    -- Skip over exitSuccess/exitFailure
-    exitCodeHandler _ = Handler $ \(_ :: ExitCode) -> return False
-
-    -- Log and retry everything else
-    loggingHandler = logRetries (return . isSyncException) logger
-
 -- | Unwrap 'Async.ExceptionInLinkedThread' exceptions and rethrow the inner exception.
 rethrowLinkedThreadExceptions :: (MonadCatch m) => m a -> m a
 rethrowLinkedThreadExceptions =
@@ -105,20 +84,27 @@
   where
     handlers :: [RetryStatus -> Handler m RetryAction]
     handlers =
-      skipAsyncExceptions' ++ [retryHttpExceptions, retrySyncExceptions]
+      skipAsyncExceptions' ++ [retryHttpExceptions, retryClientExceptions]
 
     skipAsyncExceptions' = map (fmap toRetryAction .) skipAsyncExceptions
 
-    retryHttpExceptions _ = Handler httpExceptionToRetryAction
-    retrySyncExceptions _ = Handler $ \(_ :: SomeException) -> return ConsultPolicy
+    retryHttpExceptions _ = Handler httpExceptionToRetryHandler
+    retryClientExceptions _ = Handler clientExceptionToRetryHandler
 
-    httpExceptionToRetryAction :: HTTP.HttpException -> m RetryAction
-    httpExceptionToRetryAction (HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException response _))
+    httpExceptionToRetryHandler :: HTTP.HttpException -> m RetryAction
+    httpExceptionToRetryHandler (HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException response _))
       | statusMayHaveRetryHeader (HTTP.responseStatus response) = overrideDelayWithRetryAfter response
-    httpExceptionToRetryAction ex = return . toRetryAction . shouldRetryHttpException $ ex
+    httpExceptionToRetryHandler ex = return . toRetryAction . shouldRetryHttpException $ ex
 
-    statusMayHaveRetryHeader :: HTTP.Status -> Bool
-    statusMayHaveRetryHeader = flip elem [HTTP.tooManyRequests429, HTTP.serviceUnavailable503]
+    clientExceptionToRetryHandler :: ClientError -> m RetryAction
+    clientExceptionToRetryHandler (FailureResponse _req res)
+      | shouldRetryHttpStatusCode (Servant.responseStatusCode res) =
+          return ConsultPolicy
+    clientExceptionToRetryHandler (ConnectionError ex) =
+      case fromException ex of
+        Just httpException -> httpExceptionToRetryHandler httpException
+        Nothing -> return DontRetry
+    clientExceptionToRetryHandler _ = return DontRetry
 
 data RetryAfter
   = RetryAfterDate UTCTime
@@ -171,9 +157,16 @@
       | HTTP.statusIsServerError status -> True
     HTTP.ResponseBodyTooShort _ _ -> True
     HTTP.ResponseTimeout -> True
-    HTTP.StatusCodeException response _
-      | HTTP.responseStatus response == HTTP.tooManyRequests429 -> True
-    HTTP.StatusCodeException response _
-      | HTTP.statusIsServerError (HTTP.responseStatus response) -> True
+    HTTP.StatusCodeException response _ ->
+      shouldRetryHttpStatusCode (HTTP.responseStatus response)
     HTTP.HttpZlibException _ -> True
     _ -> False
+
+-- | Determine whether the HTTP status code is worth retrying.
+shouldRetryHttpStatusCode :: HTTP.Status -> Bool
+shouldRetryHttpStatusCode code | code == HTTP.tooManyRequests429 = True
+shouldRetryHttpStatusCode code | HTTP.statusIsServerError code = True
+shouldRetryHttpStatusCode _ = False
+
+statusMayHaveRetryHeader :: HTTP.Status -> Bool
+statusMayHaveRetryHeader = flip elem [HTTP.tooManyRequests429, HTTP.serviceUnavailable503]
diff --git a/src/Cachix/Client/Secrets.hs b/src/Cachix/Client/Secrets.hs
--- a/src/Cachix/Client/Secrets.hs
+++ b/src/Cachix/Client/Secrets.hs
@@ -10,8 +10,8 @@
 
 -- TODO: * Auth token
 import Crypto.Sign.Ed25519
-import qualified Data.ByteString.Base64 as B64
-import qualified Data.ByteString.Char8 as BC
+import Data.ByteString.Base64 qualified as B64
+import Data.ByteString.Char8 qualified as BC
 import Protolude hiding (toS)
 import Protolude.Conv
 
diff --git a/src/Cachix/Client/Servant.hs b/src/Cachix/Client/Servant.hs
--- a/src/Cachix/Client/Servant.hs
+++ b/src/Cachix/Client/Servant.hs
@@ -10,15 +10,15 @@
   )
 where
 
-import qualified Cachix.API as API
-import qualified Cachix.API.Deploy.V1 as API.Deploy.V1
-import qualified Cachix.API.Deploy.V2 as API.Deploy.V2
+import Cachix.API qualified as API
+import Cachix.API.Deploy.V1 qualified as API.Deploy.V1
+import Cachix.API.Deploy.V2 qualified as API.Deploy.V2
 import Cachix.Types.ContentTypes ()
 import Network.HTTP.Types (Status)
 import Protolude
 import Servant.API.Generic
 import Servant.Auth.Client ()
-import qualified Servant.Client
+import Servant.Client qualified
 import Servant.Client.Streaming
 import Servant.Conduit ()
 
diff --git a/src/Cachix/Client/URI.hs b/src/Cachix/Client/URI.hs
--- a/src/Cachix/Client/URI.hs
+++ b/src/Cachix/Client/URI.hs
@@ -25,20 +25,20 @@
 where
 
 import Control.Monad (fail)
-import qualified Data.Aeson as Aeson
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Char8 as Char8
+import Data.Aeson qualified as Aeson
+import Data.ByteString qualified as BS
+import Data.ByteString.Char8 qualified as Char8
 import Data.Either.Validation (Validation (Failure, Success))
-import qualified Data.Map.Strict as Map
+import Data.Map.Strict qualified as Map
 import Data.Maybe (fromJust)
 import Data.String
-import qualified Dhall
-import qualified Dhall.Core
+import Dhall qualified
+import Dhall.Core qualified
 import Protolude hiding (toS)
 import Protolude.Conv
 import Servant.Client
-import qualified URI.ByteString as UBS
-import qualified URI.ByteString.QQ as UBS
+import URI.ByteString qualified as UBS
+import URI.ByteString.QQ qualified as UBS
 
 -- Default URIs
 
diff --git a/src/Cachix/Client/WatchStore.hs b/src/Cachix/Client/WatchStore.hs
--- a/src/Cachix/Client/WatchStore.hs
+++ b/src/Cachix/Client/WatchStore.hs
@@ -5,13 +5,13 @@
 
 import Cachix.Client.CNix (filterInvalidStorePath)
 import Cachix.Client.Push
-import qualified Cachix.Client.PushQueue as PushQueue
-import qualified Control.Concurrent.STM.TBQueue as TBQueue
+import Cachix.Client.PushQueue qualified as PushQueue
+import Control.Concurrent.STM.TBQueue qualified as TBQueue
 import Hercules.CNix.Store (Store)
-import qualified Hercules.CNix.Store as Store
+import Hercules.CNix.Store qualified as Store
 import Protolude
 import System.FSNotify
-import qualified System.Systemd.Daemon as Systemd
+import System.Systemd.Daemon qualified as Systemd
 
 startWorkers :: Store -> Int -> PushParams IO () -> IO ()
 startWorkers store numWorkers pushParams = do
diff --git a/src/Cachix/Daemon.hs b/src/Cachix/Daemon.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon.hs
@@ -0,0 +1,283 @@
+module Cachix.Daemon
+  ( Types.Daemon,
+    Types.runDaemon,
+    new,
+    start,
+    run,
+    stop,
+    stopIO,
+    subscribe,
+  )
+where
+
+import Cachix.Client.Command.Push qualified as Command.Push
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.Config.Orphans ()
+import Cachix.Client.Env as Env
+import Cachix.Client.OptionsParser (DaemonOptions, PushOptions)
+import Cachix.Client.OptionsParser qualified as Options
+import Cachix.Client.Push
+import Cachix.Daemon.EventLoop qualified as EventLoop
+import Cachix.Daemon.Listen as Listen
+import Cachix.Daemon.Log qualified as Log
+import Cachix.Daemon.Protocol as Protocol
+import Cachix.Daemon.Push as Push
+import Cachix.Daemon.PushManager qualified as PushManager
+import Cachix.Daemon.ShutdownLatch
+import Cachix.Daemon.SocketStore qualified as SocketStore
+import Cachix.Daemon.Subscription as Subscription
+import Cachix.Daemon.Types as Types
+import Cachix.Daemon.Types.PushManager qualified as PushManager
+import Cachix.Daemon.Worker qualified as Worker
+import Cachix.Types.BinaryCache (BinaryCacheName)
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Control.Concurrent.STM.TMChan
+import Control.Exception.Safe (catchAny)
+import Data.Text qualified as T
+import Hercules.CNix.Store (Store, withStore)
+import Hercules.CNix.Util qualified as CNix.Util
+import Katip qualified
+import Network.Socket qualified as Socket
+import Network.Socket.ByteString qualified as Socket.BS
+import Protolude hiding (bracket)
+import System.IO.Error (isResourceVanishedError)
+import System.Posix.Process (getProcessID)
+import System.Posix.Signals qualified as Signal
+import UnliftIO (MonadUnliftIO)
+import UnliftIO.Async qualified as Async
+import UnliftIO.Exception (bracket)
+
+-- | Configure a new daemon. Use 'run' to start it.
+new ::
+  -- | The Cachix environment.
+  Env ->
+  -- | A handle to the Nix store.
+  Store ->
+  -- | Daemon-specific options.
+  DaemonOptions ->
+  -- | An optional handle to output logs to.
+  Maybe Handle ->
+  -- | Push options, like compression settings and number of jobs.
+  PushOptions ->
+  -- | The name of the binary cache to push to.
+  BinaryCacheName ->
+  -- | The configured daemon environment.
+  IO DaemonEnv
+new daemonEnv nixStore daemonOptions daemonLogHandle daemonPushOptions daemonCacheName = do
+  let daemonLogLevel =
+        if Config.verbose (Env.cachixoptions daemonEnv)
+          then Debug
+          else Info
+  daemonLogger <- Log.new "cachix.daemon" daemonLogHandle daemonLogLevel
+
+  daemonEventLoop <- EventLoop.new
+  daemonShutdownLatch <- newShutdownLatch
+  daemonPid <- getProcessID
+
+  daemonSocketPath <- maybe getSocketPath pure (Options.daemonSocketPath daemonOptions)
+  daemonSocketThread <- newEmptyMVar
+  daemonClients <- SocketStore.newSocketStore
+
+  daemonPushSecret <- Command.Push.getPushSecretRequired (config daemonEnv) daemonCacheName
+  let authToken = getAuthTokenFromPushSecret daemonPushSecret
+  daemonBinaryCache <- Push.getBinaryCache daemonEnv authToken daemonCacheName
+
+  daemonSubscriptionManagerThread <- newEmptyMVar
+  daemonSubscriptionManager <- Subscription.newSubscriptionManager
+  let onPushEvent = Subscription.pushEvent daemonSubscriptionManager
+
+  let pushParams = Push.newPushParams nixStore (clientenv daemonEnv) daemonBinaryCache daemonPushSecret daemonPushOptions
+  daemonPushManager <- PushManager.newPushManagerEnv daemonPushOptions pushParams onPushEvent daemonLogger
+
+  daemonWorkerThreads <- newEmptyMVar
+
+  return $ DaemonEnv {..}
+
+-- | Configure and run the daemon as a CLI command.
+-- Equivalent to running 'withStore', new', and 'run', together with some signal handling.
+start :: Env -> DaemonOptions -> PushOptions -> BinaryCacheName -> IO ()
+start daemonEnv daemonOptions daemonPushOptions daemonCacheName =
+  withStore $ \store -> do
+    daemon <- new daemonEnv store daemonOptions Nothing daemonPushOptions daemonCacheName
+    installSignalHandlers daemon
+    result <- run daemon
+    exitWith (toExitCode result)
+
+-- | Run a daemon from a given configuration.
+run :: DaemonEnv -> IO (Either DaemonError ())
+run daemon = fmap join <$> runDaemon daemon $ do
+  Katip.logFM Katip.InfoS "Starting Cachix Daemon"
+  DaemonEnv {..} <- ask
+
+  printConfiguration
+
+  Async.async (runSubscriptionManager daemonSubscriptionManager)
+    >>= (liftIO . putMVar daemonSubscriptionManagerThread)
+
+  Worker.startWorkers
+    (Options.numJobs daemonPushOptions)
+    (PushManager.pmTaskQueue daemonPushManager)
+    (liftIO . PushManager.runPushManager daemonPushManager . PushManager.handleTask)
+    >>= (liftIO . putMVar daemonWorkerThreads)
+
+  Async.async (Listen.listen daemonEventLoop daemonSocketPath)
+    >>= (liftIO . putMVar daemonSocketThread)
+
+  eventLoopRes <- EventLoop.run daemonEventLoop $ \case
+    AddSocketClient conn ->
+      SocketStore.addSocket conn (Listen.handleClient daemonEventLoop) daemonClients
+    RemoveSocketClient socketId ->
+      SocketStore.removeSocket socketId daemonClients
+    ReconnectSocket ->
+      -- TODO: implement reconnection logic
+      EventLoop.exitLoopWith (Left DaemonSocketError) daemonEventLoop
+    ReceivedMessage clientMsg ->
+      case clientMsg of
+        ClientPushRequest pushRequest -> queueJob pushRequest
+        ClientStop -> EventLoop.send daemonEventLoop ShutdownGracefully
+        _ -> return ()
+    ShutdownGracefully -> do
+      Katip.logFM Katip.InfoS "Shutting down daemon..."
+      pushResult <- shutdownGracefully
+      Katip.logFM Katip.InfoS "Daemon shut down. Exiting."
+      EventLoop.exitLoopWith pushResult daemonEventLoop
+
+  return $ case eventLoopRes of
+    Left err -> Left (DaemonEventLoopError err)
+    Right (Left err) -> Left err
+    Right (Right ()) -> Right ()
+
+stop :: Daemon ()
+stop = do
+  eventloop <- asks daemonEventLoop
+  EventLoop.send eventloop ShutdownGracefully
+
+stopIO :: DaemonEnv -> IO ()
+stopIO DaemonEnv {daemonEventLoop} =
+  EventLoop.sendIO daemonEventLoop ShutdownGracefully
+
+installSignalHandlers :: DaemonEnv -> IO ()
+installSignalHandlers daemon = do
+  for_ [Signal.sigTERM, Signal.sigINT] $ \signal ->
+    Signal.installHandler signal (Signal.CatchOnce handler) Nothing
+  where
+    handler = do
+      CNix.Util.triggerInterrupt
+      stopIO daemon
+
+queueJob :: Protocol.PushRequest -> Daemon ()
+queueJob pushRequest = do
+  daemonPushManager <- asks daemonPushManager
+  -- TODO: subscribe the socket to updates if available
+
+  -- Queue the job
+  void $
+    PushManager.runPushManager daemonPushManager (PushManager.addPushJob pushRequest)
+
+subscribe :: DaemonEnv -> IO (TMChan PushEvent)
+subscribe DaemonEnv {..} = do
+  chan <- liftIO newBroadcastTMChanIO
+  liftIO $ atomically $ do
+    subscribeToAllSTM daemonSubscriptionManager (SubChannel chan)
+    dupTMChan chan
+
+-- | Print the daemon configuration to the log.
+printConfiguration :: Daemon ()
+printConfiguration = do
+  config <- showConfiguration
+  Katip.logFM Katip.InfoS $ Katip.ls $ "Configuration:\n" <> config
+
+-- | Fetch debug information about the daemon configuration.
+showConfiguration :: Daemon Text
+showConfiguration = do
+  DaemonEnv {..} <- ask
+  pure $
+    T.intercalate
+      "\n"
+      [ "PID: " <> show daemonPid,
+        "Socket: " <> toS daemonSocketPath,
+        "Workers: " <> show (Options.numJobs daemonPushOptions),
+        "Cache name: " <> toS daemonCacheName,
+        "Cache URI: " <> BinaryCache.uri daemonBinaryCache,
+        "Cache public keys: " <> show (BinaryCache.publicSigningKeys daemonBinaryCache),
+        "Cache is public: " <> show (BinaryCache.isPublic daemonBinaryCache),
+        "Compression: " <> show (Push.getCompressionMethod daemonPushOptions daemonBinaryCache)
+      ]
+
+shutdownGracefully :: Daemon (Either DaemonError ())
+shutdownGracefully = do
+  DaemonEnv {..} <- ask
+
+  -- Indicate that the daemon is shutting down
+  initiateShutdown daemonShutdownLatch
+
+  -- Stop the push manager and wait for any remaining paths to be uploaded
+  shutdownPushManager daemonPushManager
+
+  -- Stop worker threads
+  withTakeMVar daemonWorkerThreads Worker.stopWorkers
+
+  -- Close all event subscriptions
+  withTakeMVar daemonSubscriptionManagerThread (shutdownSubscriptions daemonSubscriptionManager)
+
+  failedJobs <-
+    PushManager.runPushManager daemonPushManager PushManager.getFailedPushJobs
+  let pushResult =
+        if null failedJobs
+          then Right ()
+          else Left DaemonPushFailure
+
+  -- Gracefully close open connections to clients
+  Async.mapConcurrently_ (sayGoodbye pushResult) =<< SocketStore.toList daemonClients
+
+  return pushResult
+  where
+    shutdownPushManager daemonPushManager = do
+      queuedStorePathCount <- PushManager.runPushManager daemonPushManager PushManager.queuedStorePathCount
+      when (queuedStorePathCount > 0) $
+        Katip.logFM Katip.InfoS $
+          Katip.logStr $
+            "Remaining store paths: " <> (show queuedStorePathCount :: Text)
+
+      Katip.logFM Katip.DebugS "Waiting for push manager to clear remaining jobs..."
+      -- Finish processing remaining push jobs
+      let timeoutOptions =
+            PushManager.TimeoutOptions
+              { PushManager.toTimeout = 60.0,
+                PushManager.toPollingInterval = 1.0
+              }
+      liftIO $ PushManager.stopPushManager timeoutOptions daemonPushManager
+      Katip.logFM Katip.DebugS "Push manager shut down."
+
+    shutdownSubscriptions daemonSubscriptionManager subscriptionManagerThread = do
+      Katip.logFM Katip.DebugS "Shutting down event manager..."
+      liftIO $ stopSubscriptionManager daemonSubscriptionManager
+      _ <- Async.wait subscriptionManagerThread
+      Katip.logFM Katip.DebugS "Event manager shut down."
+
+    sayGoodbye exitResult socket = do
+      let clientSock = SocketStore.socket socket
+      let clientThread = SocketStore.handlerThread socket
+      Async.cancel clientThread
+
+      -- Wave goodbye to the client that requested the shutdown
+      liftIO $ Listen.serverBye clientSock exitResult
+      liftIO $ Socket.shutdown clientSock Socket.ShutdownBoth `catchAny` (\_ -> return ())
+      -- Wait for the other end to disconnect
+      ebs <- liftIO $ try $ Socket.BS.recv clientSock 4096
+      case ebs of
+        Left err | isResourceVanishedError err -> Katip.logFM Katip.DebugS "Client did not disconnect cleanly."
+        Left err -> Katip.logFM Katip.DebugS $ Katip.ls $ "Client socket threw an error: " <> displayException err
+        Right _ -> Katip.logFM Katip.DebugS "Client disconnected."
+
+withTakeMVar :: (MonadUnliftIO m) => MVar a -> (a -> m ()) -> m ()
+withTakeMVar mvar f = do
+  bracket acquire release wrapper
+  where
+    acquire = liftIO $ tryTakeMVar mvar
+
+    release Nothing = pure ()
+    release (Just x) = liftIO $ putMVar mvar x
+
+    wrapper Nothing = pure ()
+    wrapper (Just x) = f x
diff --git a/src/Cachix/Daemon/Client.hs b/src/Cachix/Daemon/Client.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Client.hs
@@ -0,0 +1,144 @@
+module Cachix.Daemon.Client (push, stop) where
+
+import Cachix.Client.Env as Env
+import Cachix.Client.OptionsParser (DaemonOptions (..))
+import Cachix.Client.Retry qualified as Retry
+import Cachix.Daemon.Listen (getSocketPath)
+import Cachix.Daemon.Protocol as Protocol
+import Control.Concurrent.Async qualified as Async
+import Control.Concurrent.STM.TBMQueue
+import Data.Aeson qualified as Aeson
+import Data.ByteString qualified as BS
+import Data.IORef
+import Data.Time.Clock
+import Network.Socket qualified as Socket
+import Network.Socket.ByteString qualified as Socket.BS
+import Network.Socket.ByteString.Lazy qualified as Socket.LBS
+import Protolude
+import System.IO.Error (isResourceVanishedError)
+
+data SocketError
+  = -- | The socket has been closed
+    SocketClosed
+  | -- | The socket has stopped responding to pings
+    SocketStalled
+  | -- | Failed to decode a message from the socket
+    SocketDecodingError !Text
+  deriving stock (Show)
+
+instance Exception SocketError where
+  displayException = \case
+    SocketClosed -> "The socket has been closed"
+    SocketStalled -> "The socket has stopped responding to pings"
+    SocketDecodingError err -> "Failed to decode the message from socket: " <> toS err
+
+-- | Queue up push requests with the daemon
+--
+-- TODO: wait for the daemon to respond that it has received the request
+push :: Env -> DaemonOptions -> [FilePath] -> IO ()
+push _env daemonOptions storePaths =
+  withDaemonConn (daemonSocketPath daemonOptions) $ \sock -> do
+    Socket.LBS.sendAll sock $ Protocol.newMessage pushRequest
+  where
+    pushRequest =
+      Protocol.ClientPushRequest $
+        PushRequest {storePaths = storePaths}
+
+-- | Tell the daemon to stop and wait for it to gracefully exit
+stop :: Env -> DaemonOptions -> IO ()
+stop _env daemonOptions =
+  withDaemonConn (daemonSocketPath daemonOptions) $ \sock -> do
+    let size = 100
+    (rx, tx) <- atomically $ (,) <$> newTBMQueue size <*> newTBMQueue size
+
+    rxThread <- Async.async (handleIncoming rx sock)
+    txThread <- Async.async (handleOutgoing tx sock)
+
+    lastPongRef <- newIORef =<< getCurrentTime
+    pingThread <- Async.async (runPingThread lastPongRef rx tx)
+
+    mapM_ Async.link [rxThread, txThread, pingThread]
+
+    -- Request the daemon to stop
+    atomically $ writeTBMQueue tx Protocol.ClientStop
+
+    fix $ \loop -> do
+      mmsg <- atomically (readTBMQueue rx)
+      case mmsg of
+        Nothing -> return ()
+        Just (Left err) -> do
+          putErrText $ toS $ displayException err
+          exitFailure
+        Just (Right msg) ->
+          case msg of
+            Protocol.DaemonPong -> do
+              writeIORef lastPongRef =<< getCurrentTime
+              loop
+            Protocol.DaemonExit exitStatus ->
+              case exitCode exitStatus of
+                0 -> exitSuccess
+                code -> exitWith (ExitFailure code)
+  where
+    runPingThread lastPongRef rx tx = go
+      where
+        go = do
+          timestamp <- getCurrentTime
+          lastPong <- readIORef lastPongRef
+
+          if timestamp >= addUTCTime 20 lastPong
+            then atomically $ writeTBMQueue rx (Left SocketStalled)
+            else do
+              atomically $ writeTBMQueue tx Protocol.ClientPing
+              threadDelay (2 * 1000 * 1000)
+              go
+
+    handleOutgoing tx sock = go
+      where
+        go = do
+          mmsg <- atomically $ readTBMQueue tx
+          case mmsg of
+            Nothing -> return ()
+            Just msg -> do
+              Retry.retryAll $ const $ Socket.LBS.sendAll sock $ Protocol.newMessage msg
+              go
+
+    handleIncoming rx sock = go BS.empty
+      where
+        socketClosed = atomically $ writeTBMQueue rx (Left SocketClosed)
+
+        go leftovers = do
+          ebs <- liftIO $ try $ Socket.BS.recv sock 4096
+
+          case ebs of
+            Left err | isResourceVanishedError err -> socketClosed
+            Left _err -> socketClosed
+            -- If the socket returns 0 bytes, then it is closed
+            Right bs | BS.null bs -> socketClosed
+            Right bs -> do
+              let (rawMsgs, newLeftovers) = Protocol.splitMessages (BS.append leftovers bs)
+
+              forM_ (map Aeson.eitherDecodeStrict rawMsgs) $ \emsg -> do
+                case emsg of
+                  Left err -> do
+                    let terr = toS err
+                    putErrText terr
+                    atomically $ writeTBMQueue rx (Left (SocketDecodingError terr))
+                  Right msg -> do
+                    atomically $ writeTBMQueue rx (Right msg)
+
+              go newLeftovers
+
+withDaemonConn :: Maybe FilePath -> (Socket.Socket -> IO a) -> IO a
+withDaemonConn optionalSocketPath f = do
+  socketPath <- maybe getSocketPath pure optionalSocketPath
+  bracket (open socketPath `onException` failedToConnectTo socketPath) Socket.close f
+  where
+    open socketPath = do
+      sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
+      Retry.retryAll $ const $ Socket.connect sock (Socket.SockAddrUnix socketPath)
+      return sock
+
+    failedToConnectTo :: FilePath -> IO ()
+    failedToConnectTo socketPath = do
+      putErrText "\nFailed to connect to Cachix Daemon"
+      putErrText $ "Tried to connect to: " <> toS socketPath <> "\n"
diff --git a/src/Cachix/Daemon/EventLoop.hs b/src/Cachix/Daemon/EventLoop.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/EventLoop.hs
@@ -0,0 +1,78 @@
+module Cachix.Daemon.EventLoop
+  ( new,
+    send,
+    sendIO,
+    run,
+    exitLoopWith,
+    EventLoop,
+  )
+where
+
+import Cachix.Daemon.Types.EventLoop (EventLoop (..), EventLoopError (..))
+import Control.Concurrent.STM.TBMQueue
+  ( isFullTBMQueue,
+    newTBMQueueIO,
+    readTBMQueue,
+    tryWriteTBMQueue,
+  )
+import Data.Text.Lazy.Builder (toLazyText)
+import Katip qualified
+import Protolude
+
+new :: (MonadIO m) => m (EventLoop event a)
+new = do
+  exitLatch <- liftIO newEmptyMVar
+  queue <- liftIO $ newTBMQueueIO 100
+  return $ EventLoop {queue, exitLatch}
+
+-- | Send an event to the event loop with logging.
+send :: (Katip.KatipContext m) => EventLoop event a -> event -> m ()
+send = send' Katip.logFM
+
+-- | Same as 'send', but does not require a 'Katip.KatipContext'.
+sendIO :: forall m event a. (MonadIO m) => EventLoop event a -> event -> m ()
+sendIO = send' logger
+  where
+    logger :: Katip.Severity -> Katip.LogStr -> m ()
+    logger Katip.ErrorS msg = liftIO $ hPutStrLn stderr (toLazyText $ Katip.unLogStr msg)
+    logger _ _ = return ()
+
+send' :: (MonadIO m) => (Katip.Severity -> Katip.LogStr -> m ()) -> EventLoop event a -> event -> m ()
+send' logger eventloop@(EventLoop {queue}) event = do
+  res <- liftIO $ atomically $ tryWriteTBMQueue queue event
+  case res of
+    -- The queue is closed.
+    Nothing ->
+      logger Katip.DebugS "Ignored an event because the event loop is closed"
+    -- Successfully wrote to the queue
+    Just True -> return ()
+    -- Failed to write to the queue
+    Just False -> do
+      isFull <- liftIO $ atomically $ isFullTBMQueue queue
+      let message =
+            if isFull
+              then "Event loop is full"
+              else "Unknown error"
+      logger Katip.ErrorS $ "Failed to write to event loop: " <> message
+      exitLoopWithFailure EventLoopFull eventloop
+
+-- | Run the event loop until it exits with 'exitLoopWith'.
+run :: (MonadIO m) => EventLoop event a -> (event -> m ()) -> m (Either EventLoopError a)
+run eventloop f = do
+  fix $ \loop -> do
+    mevent <- liftIO $ atomically $ readTBMQueue (queue eventloop)
+    case mevent of
+      Just event -> f event
+      Nothing -> exitLoopWithFailure EventLoopClosed eventloop
+
+    liftIO (tryReadMVar (exitLatch eventloop)) >>= \case
+      Just exitValue -> return exitValue
+      Nothing -> loop
+
+-- | Short-circuit the event loop and exit with a given return value.
+exitLoopWith :: (MonadIO m) => a -> EventLoop event a -> m ()
+exitLoopWith exitValue (EventLoop {exitLatch}) = void $ liftIO $ tryPutMVar exitLatch (Right exitValue)
+
+-- | Short-circuit the event loop in case of an internal error.
+exitLoopWithFailure :: (MonadIO m) => EventLoopError -> EventLoop event a -> m ()
+exitLoopWithFailure err (EventLoop {exitLatch}) = void $ liftIO $ tryPutMVar exitLatch (Left err)
diff --git a/src/Cachix/Daemon/Listen.hs b/src/Cachix/Daemon/Listen.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Listen.hs
@@ -0,0 +1,162 @@
+module Cachix.Daemon.Listen
+  ( listen,
+    handleClient,
+    serverBye,
+    getSocketPath,
+    openSocket,
+    closeSocket,
+  )
+where
+
+import Cachix.Client.Config.Orphans ()
+import Cachix.Daemon.EventLoop qualified as EventLoop
+import Cachix.Daemon.Protocol as Protocol
+import Cachix.Daemon.Types
+  ( DaemonError,
+    DaemonEvent
+      ( AddSocketClient,
+        ReceivedMessage,
+        RemoveSocketClient
+      ),
+    toExitCodeInt,
+  )
+import Cachix.Daemon.Types.EventLoop (EventLoop)
+import Cachix.Daemon.Types.SocketStore (SocketId)
+import Control.Exception.Safe (catchAny)
+import Control.Monad.Catch qualified as E
+import Data.Aeson qualified as Aeson
+import Data.ByteString qualified as BS
+import Katip qualified
+import Network.Socket (Socket)
+import Network.Socket qualified as Socket
+import Network.Socket.ByteString qualified as Socket.BS
+import Network.Socket.ByteString.Lazy qualified as Socket.LBS
+import Protolude
+import System.Directory
+  ( XdgDirectory (..),
+    createDirectoryIfMissing,
+    getXdgDirectory,
+    removeFile,
+  )
+import System.Environment qualified as System
+import System.FilePath ((</>))
+import System.IO.Error (isDoesNotExistError, isResourceVanishedError)
+
+-- TODO: reconcile with Client
+data ListenError
+  = SocketError SomeException
+  | DecodingError Text
+  deriving stock (Show)
+
+instance Exception ListenError where
+  displayException = \case
+    SocketError err -> "Failed to read from the daemon socket: " <> show err
+    DecodingError err -> "Failed to decode request:\n" <> toS err
+
+-- | Listen for incoming connections on the given socket path.
+listen ::
+  (E.MonadMask m, Katip.KatipContext m) =>
+  EventLoop DaemonEvent a ->
+  FilePath ->
+  m ()
+listen eventloop daemonSocketPath = forever $ do
+  E.bracketOnError (openSocket daemonSocketPath) closeSocket $ \sock -> do
+    liftIO $ Socket.listen sock Socket.maxListenQueue
+    (conn, _peerAddr) <- liftIO $ Socket.accept sock
+    EventLoop.send eventloop (AddSocketClient conn)
+
+-- | Handle incoming messages from a client.
+--
+-- Automatically responds to pings.
+-- Requests the daemon to remove the client socket once the loop exits.
+handleClient ::
+  forall m a.
+  (E.MonadMask m, Katip.KatipContext m) =>
+  EventLoop DaemonEvent a ->
+  SocketId ->
+  Socket ->
+  m ()
+handleClient eventloop socketId conn = do
+  go BS.empty `E.finally` removeClient
+  where
+    go leftovers = do
+      ebs <- liftIO $ try $ Socket.BS.recv conn 8192
+
+      case ebs of
+        Left err | isResourceVanishedError err -> return ()
+        Left _ -> return ()
+        -- If the socket returns 0 bytes, then it is closed
+        Right bs | BS.null bs -> return ()
+        Right bs -> do
+          let (rawMsgs, newLeftovers) = Protocol.splitMessages (BS.append leftovers bs)
+          msgs <- catMaybes <$> mapM decodeMessage rawMsgs
+
+          forM_ msgs $ \msg -> do
+            EventLoop.send eventloop (ReceivedMessage msg)
+            case msg of
+              Protocol.ClientPing ->
+                liftIO $ Socket.LBS.sendAll conn $ Protocol.newMessage DaemonPong
+              _ -> return ()
+
+          go newLeftovers
+
+    removeClient = EventLoop.send eventloop (RemoveSocketClient socketId)
+
+decodeMessage :: (Katip.KatipContext m) => ByteString -> m (Maybe Protocol.ClientMessage)
+decodeMessage "" = return Nothing
+decodeMessage bs =
+  case Aeson.eitherDecodeStrict bs of
+    Left err -> do
+      Katip.logFM Katip.ErrorS $ Katip.ls $ displayException (DecodingError (toS err))
+      return Nothing
+    Right msg -> return (Just msg)
+
+serverBye :: Socket.Socket -> Either DaemonError () -> IO ()
+serverBye sock exitResult =
+  Socket.LBS.sendAll sock (Protocol.newMessage (DaemonExit exitStatus)) `catchAny` (\_ -> return ())
+  where
+    exitStatus = DaemonExitStatus {exitCode, exitMessage}
+    exitCode = toExitCodeInt exitResult
+    exitMessage =
+      case exitResult of
+        Left err -> Just (toS $ displayException err)
+        Right _ -> Nothing
+
+getSocketPath :: IO FilePath
+getSocketPath = do
+  socketDir <- getSocketDir
+  return $ socketDir </> "cachix-daemon.sock"
+
+getSocketDir :: IO FilePath
+getSocketDir = do
+  xdgRuntimeDir <- getXdgRuntimeDir
+  let socketDir = xdgRuntimeDir </> "cachix"
+  createDirectoryIfMissing True socketDir
+  return socketDir
+
+-- On systems with systemd: /run/user/$UID
+-- Otherwise, fall back to XDG_CACHE_HOME
+getXdgRuntimeDir :: IO FilePath
+getXdgRuntimeDir = do
+  xdgRuntimeDir <- System.lookupEnv "XDG_RUNTIME_DIR"
+  cacheFallback <- getXdgDirectory XdgCache ""
+  return $ fromMaybe cacheFallback xdgRuntimeDir
+
+-- TODO: lock the socket
+openSocket :: (MonadIO m) => FilePath -> m Socket.Socket
+openSocket socketFilePath = liftIO $ do
+  deleteSocketFileIfExists socketFilePath
+  sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
+  Socket.bind sock $ Socket.SockAddrUnix socketFilePath
+  -- setFileMode socketFilePath socketFileMode
+  return sock
+  where
+    deleteSocketFileIfExists path =
+      removeFile path `catch` handleDoesNotExist
+
+    handleDoesNotExist e
+      | isDoesNotExistError e = return ()
+      | otherwise = throwIO e
+
+closeSocket :: (MonadIO m) => Socket.Socket -> m ()
+closeSocket = liftIO . Socket.close
diff --git a/src/Cachix/Daemon/Log.hs b/src/Cachix/Daemon/Log.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Log.hs
@@ -0,0 +1,79 @@
+module Cachix.Daemon.Log
+  ( new,
+    withLogger,
+    getKatipNamespace,
+    getKatipContext,
+    getKatipLogEnv,
+    localLogEnv,
+    localKatipContext,
+    localKatipNamespace,
+    toKatipLogLevel,
+    Log.Logger (..),
+    Log.LogLevel (..),
+  )
+where
+
+import Cachix.Daemon.Types.Log as Log
+import Control.Monad.Catch qualified as E
+import Data.Text.Lazy.Builder
+import Katip (renderSeverity)
+import Katip qualified
+import Katip.Format.Time qualified as Katip.Format
+import Katip.Scribes.Handle (brackets, colorBySeverity, getKeys)
+import Protolude
+
+new :: (MonadIO m) => Katip.Namespace -> Maybe Handle -> LogLevel -> m Logger
+new logLabel logHandle logLevel = do
+  logKLogEnv <- liftIO $ Katip.initLogEnv logLabel ""
+  let logKNamespace = mempty
+  let logKContext = mempty
+  return $ Logger {..}
+
+withLogger :: (MonadIO m, E.MonadMask m) => Logger -> (Logger -> m a) -> m a
+withLogger logger@(Logger {..}) f = do
+  let kLogLevel = toKatipLogLevel logLevel
+  let kLogHandle = fromMaybe stdout logHandle
+  let registerScribe = liftIO $ do
+        scribeHandle <- Katip.mkHandleScribeWithFormatter conciseBracketFormat Katip.ColorIfTerminal kLogHandle (Katip.permitItem kLogLevel) Katip.V2
+        Katip.registerScribe "stdout" scribeHandle Katip.defaultScribeSettings logKLogEnv
+
+  E.bracket registerScribe (liftIO . Katip.closeScribes) $ \logEnv ->
+    f logger {logKLogEnv = logEnv}
+
+getKatipNamespace :: Logger -> Katip.Namespace
+getKatipNamespace = logKNamespace
+
+getKatipContext :: Logger -> Katip.LogContexts
+getKatipContext = logKContext
+
+getKatipLogEnv :: Logger -> Katip.LogEnv
+getKatipLogEnv = logKLogEnv
+
+localLogEnv :: (Katip.LogEnv -> Katip.LogEnv) -> Logger -> Logger
+localLogEnv f logger = logger {logKLogEnv = f (logKLogEnv logger)}
+
+localKatipContext :: (Katip.LogContexts -> Katip.LogContexts) -> Logger -> Logger
+localKatipContext f logger = logger {logKContext = f (logKContext logger)}
+
+localKatipNamespace :: (Katip.Namespace -> Katip.Namespace) -> Logger -> Logger
+localKatipNamespace f logger = logger {logKNamespace = f (logKNamespace logger)}
+
+toKatipLogLevel :: LogLevel -> Katip.Severity
+toKatipLogLevel = \case
+  Debug -> Katip.DebugS
+  Info -> Katip.InfoS
+  Warning -> Katip.WarningS
+  Error -> Katip.ErrorS
+
+conciseBracketFormat :: (Katip.LogItem a) => Katip.ItemFormatter a
+conciseBracketFormat withColor verbosity Katip.Item {..} =
+  brackets nowStr
+    <> brackets (fromText (renderSeverity' _itemSeverity))
+    <> mconcat ks
+    <> fromText " "
+    <> Katip.unLogStr _itemMessage
+  where
+    nowStr = fromText (Katip.Format.formatAsLogTime _itemTime)
+    ks = map brackets $ getKeys verbosity _itemPayload
+    renderSeverity' severity =
+      colorBySeverity withColor severity (renderSeverity severity)
diff --git a/src/Cachix/Daemon/PostBuildHook.hs b/src/Cachix/Daemon/PostBuildHook.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/PostBuildHook.hs
@@ -0,0 +1,154 @@
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Cachix.Daemon.PostBuildHook
+  ( -- * Post-build hook setup
+    PostBuildHookEnv (..),
+    withSetup,
+
+    -- * Set up env vars
+    EnvVar,
+    modifyEnv,
+
+    -- * Internal
+    buildNixConfEnv,
+    buildNixUserConfFilesEnv,
+  )
+where
+
+import Control.Monad.Catch (MonadMask)
+import Data.Containers.ListUtils (nubOrd)
+import Data.String (String)
+import Data.String.Here
+import Protolude
+import System.Directory
+  ( XdgDirectory (XdgConfig),
+    XdgDirectoryList (XdgConfigDirs),
+    getXdgDirectory,
+    getXdgDirectoryList,
+  )
+import System.Environment (getExecutablePath, lookupEnv)
+import System.FilePath ((</>))
+import System.IO.Temp (getCanonicalTemporaryDirectory, withTempDirectory)
+import System.Posix.Files
+
+type EnvVar = (String, String)
+
+modifyEnv :: EnvVar -> [EnvVar] -> [EnvVar]
+modifyEnv (envName, envValue) processEnv =
+  nubOrd $ (envName, envValue) : processEnv
+
+data PostBuildHookEnv = PostBuildHookEnv
+  { tempDir :: !FilePath,
+    postBuildHookConfigPath :: !FilePath,
+    postBuildHookScriptPath :: !FilePath,
+    daemonSock :: !FilePath,
+    envVar :: !EnvVar
+  }
+
+withSetup :: Maybe FilePath -> (PostBuildHookEnv -> IO a) -> IO a
+withSetup mdaemonSock f =
+  withRunnerFriendlyTempDirectory "cachix-daemon" $ \tempDir -> do
+    let postBuildHookScriptPath = tempDir </> "post-build-hook.sh"
+        postBuildHookConfigPath = tempDir </> "nix.conf"
+        daemonSock = fromMaybe (tempDir </> "daemon.sock") mdaemonSock
+
+    cachixBin <- getExecutablePath
+    writeFile postBuildHookScriptPath (postBuildHookScript cachixBin daemonSock)
+    setFileMode postBuildHookScriptPath 0o755
+
+    mnixConfEnv <- buildNixConfEnv postBuildHookScriptPath
+    nixUserConfFilesEnv <- buildNixUserConfFilesEnv postBuildHookConfigPath
+    envVar <- case mnixConfEnv of
+      Just nixConfEnv -> return nixConfEnv
+      Nothing -> do
+        writeFile postBuildHookConfigPath (postBuildHookConfig postBuildHookScriptPath)
+        return nixUserConfFilesEnv
+
+    f PostBuildHookEnv {..}
+
+-- | Build the NIX_CONF environment variable.
+--
+-- NIX_CONF completely overrides the nix.conf.
+-- This is generally undesirable because the user and system nix.confs contain important settings, like substituters.
+-- Therefore, this returns Nothing if NIX_CONF is not already set to allow fallback to NIX_USER_CONF_FILES.
+buildNixConfEnv :: FilePath -> IO (Maybe EnvVar)
+buildNixConfEnv postBuildHookScriptPath =
+  fmap appendNixConf <$> lookupEnv "NIX_CONF"
+  where
+    appendNixConf :: String -> EnvVar
+    appendNixConf conf =
+      ( "NIX_CONF",
+        conf <> "\n" <> toS (postBuildHookConfig postBuildHookScriptPath)
+      )
+
+-- | Build the NIX_USER_CONF_FILES environment variable.
+--
+-- From man nix.conf:
+--
+-- If NIX_USER_CONF_FILES is set, then each path separated by : will be loaded in reverse order.
+--
+-- Otherwise it will look for nix/nix.conf files in XDG_CONFIG_DIRS and XDG_CONFIG_HOME. If
+-- unset, XDG_CONFIG_DIRS defaults to /etc/xdg, and XDG_CONFIG_HOME defaults to $HOME/.config
+-- as per XDG Base Directory Specification.
+--
+-- We don't need to load the system config from $NIX_CONF_DIR/nix.conf.
+-- Nix loads it by default and uses it as the base config.
+buildNixUserConfFilesEnv :: FilePath -> IO EnvVar
+buildNixUserConfFilesEnv nixConfPath = do
+  -- A user can set NIX_USER_CONF_FILES to override the default nix.conf files.
+  -- In that case, we reuse it and prepend our own config file.
+  mexistingEnv <- lookupEnv "NIX_USER_CONF_FILES"
+
+  newNixUserConfFiles <- case mexistingEnv of
+    Just existingEnv -> return $ nixConfPath <> ":" <> existingEnv
+    Nothing -> do
+      userConfigFiles <- getUserConfigFiles
+
+      -- Combine all the nix.conf paths into one string, separated by colons.
+      -- Filter out empty paths.
+      return $ intercalate ":" $ filter (not . null) $ nixConfPath : userConfigFiles
+
+  return ("NIX_USER_CONF_FILES", newNixUserConfFiles)
+
+getUserConfigFiles :: IO [FilePath]
+getUserConfigFiles =
+  fmap (</> "nix/nix.conf") <$> getUserConfigDirs
+
+getUserConfigDirs :: IO [FilePath]
+getUserConfigDirs = do
+  configHome <- getXdgDirectory XdgConfig empty
+  configDirs <- getXdgDirectoryList XdgConfigDirs
+
+  return $ configHome : configDirs
+
+postBuildHookConfig :: FilePath -> Text
+postBuildHookConfig scriptPath =
+  [iTrim|
+post-build-hook = ${toS scriptPath :: Text}
+  |]
+
+postBuildHookScript :: FilePath -> FilePath -> Text
+postBuildHookScript cachixBin socketPath =
+  [iTrim|
+\#!/bin/sh
+
+\# set -eu
+set -f # disable globbing
+
+exec ${toS cachixBin :: Text} daemon push \\
+  --socket ${toS socketPath :: Text} \\
+  $OUT_PATHS
+  |]
+
+-- | Run an action with a temporary directory.
+--
+-- Respects the RUNNER_TEMP environment variable if set.
+-- This is important on self-hosted GitHub runners with locked down system temp directories.
+-- The directory is deleted after use.
+withRunnerFriendlyTempDirectory :: (MonadIO m, MonadMask m) => String -> (FilePath -> m a) -> m a
+withRunnerFriendlyTempDirectory name action = do
+  runnerTempDir <- liftIO $ lookupEnv "RUNNER_TEMP"
+  systemTempDir <- liftIO getCanonicalTemporaryDirectory
+  let tempDir = maybe systemTempDir toS runnerTempDir
+  withTempDirectory tempDir name action
diff --git a/src/Cachix/Daemon/Progress.hs b/src/Cachix/Daemon/Progress.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Progress.hs
@@ -0,0 +1,168 @@
+module Cachix.Daemon.Progress
+  ( UploadProgress,
+    new,
+    complete,
+    fail,
+    tick,
+    update,
+  )
+where
+
+import Cachix.Client.HumanSize (humanSize)
+import Cachix.Daemon.Types (PushRetryStatus (..))
+import Control.Concurrent.Async qualified as Async
+import Control.Concurrent.MVar
+import Data.String (String)
+import Data.Text qualified as T
+import Protolude
+import System.Console.AsciiProgress qualified as Ascii
+import System.Console.AsciiProgress.Internal qualified as Ascii.Internal
+import System.Console.Pretty
+import System.Environment (lookupEnv)
+import System.IO (hIsTerminalDevice)
+
+data UploadProgress
+  = ProgressBar
+      { path :: String,
+        size :: Int64,
+        progressBar :: Ascii.ProgressBar
+      }
+  | FallbackText
+      { path :: String,
+        size :: Int64
+      }
+
+new :: Handle -> String -> Int64 -> PushRetryStatus -> IO UploadProgress
+new hdl path size retryStatus = do
+  isCI <- liftIO $ (== Just "true") <$> lookupEnv "CI"
+  isTerminal <- liftIO $ hIsTerminalDevice hdl
+  if isTerminal && not isCI
+    then do
+      progressBar <- newProgressBar path size retryStatus
+      return $ ProgressBar {..}
+    else do
+      hPutStr stderr $ uploadStartFallback path size retryStatus
+      return $ FallbackText {..}
+
+complete :: UploadProgress -> IO ()
+complete ProgressBar {..} = Ascii.complete progressBar
+complete _ = pure ()
+
+tick :: UploadProgress -> Int64 -> IO ()
+tick ProgressBar {..} deltaBytes = Ascii.tickN progressBar (fromIntegral deltaBytes)
+tick _ _ = pure ()
+
+fail :: UploadProgress -> IO ()
+fail ProgressBar {..} =
+  clearAsciiWith progressBar (uploadFailed path)
+fail FallbackText {path} =
+  hPutStr stderr $ uploadFailed path
+
+update :: UploadProgress -> PushRetryStatus -> IO UploadProgress
+update pg@ProgressBar {..} retryStatus = do
+  let opts = newProgressBarOptions path size retryStatus
+  pg' <- newAsciiProgressBarInPlace progressBar opts
+
+  return $ pg {progressBar = pg'}
+update fbt _ = return fbt
+
+newProgressBar :: String -> Int64 -> PushRetryStatus -> IO Ascii.ProgressBar
+newProgressBar path size retryStatus =
+  newAsciiProgressBar $ newProgressBarOptions path size retryStatus
+
+newProgressBarOptions :: String -> Int64 -> PushRetryStatus -> Ascii.Options
+newProgressBarOptions path size retryStatus = do
+  let hSize = toS $ humanSize $ fromIntegral size
+  let barLength =
+        uploadTickBar path hSize retryStatus
+          & toS
+          & T.replace "[:bar]" ""
+          & T.replace ":percent" "  0%"
+          & T.length
+  Ascii.def
+    { Ascii.pgTotal = fromIntegral size,
+      -- https://github.com/yamadapc/haskell-ascii-progress/issues/24
+      Ascii.pgWidth = 20 + barLength,
+      Ascii.pgOnCompletion = Just $ uploadComplete path size,
+      Ascii.pgFormat = uploadTickBar path hSize retryStatus
+    }
+
+retryText :: PushRetryStatus -> String
+retryText PushRetryStatus {retryCount} =
+  if retryCount == 0
+    then ""
+    else color Yellow $ "retry #" <> show retryCount <> " "
+
+uploadComplete :: String -> Int64 -> String
+uploadComplete path size =
+  let hSize = toS $ humanSize $ fromIntegral size
+   in color Green "✓ " <> path <> " (" <> hSize <> ")"
+
+uploadFailed :: String -> String
+uploadFailed path =
+  color Red "✗ " <> path
+
+uploadTickBar :: String -> String -> PushRetryStatus -> String
+uploadTickBar path hSize retryStatus =
+  color Blue "[:bar] " <> retryText retryStatus <> toS path <> " (:percent of " <> hSize <> ")"
+
+uploadStartFallback :: String -> Int64 -> PushRetryStatus -> String
+uploadStartFallback path size retryStatus =
+  let hSize = toS $ humanSize $ fromIntegral size
+   in retryText retryStatus <> "Pushing " <> path <> " (" <> hSize <> ")\n"
+
+-- Internal
+
+newAsciiProgressBar :: Ascii.Options -> IO Ascii.ProgressBar
+newAsciiProgressBar opts = do
+  region <- Ascii.openConsoleRegion Ascii.Linear
+  newAsciiProgressBarWithRegion opts region
+
+-- | Create a new progress bar in place of an existing one, reusing the console region.
+newAsciiProgressBarInPlace :: Ascii.ProgressBar -> Ascii.Options -> IO Ascii.ProgressBar
+newAsciiProgressBarInPlace pg opts = do
+  cancelUpdates pg
+  newAsciiProgressBarWithRegion opts (Ascii.pgRegion pg)
+
+-- | Create a new progress bar using the provided console region.
+newAsciiProgressBarWithRegion :: Ascii.Options -> Ascii.ConsoleRegion -> IO Ascii.ProgressBar
+newAsciiProgressBarWithRegion opts region = do
+  info <- Ascii.Internal.newProgressBarInfo opts
+
+  -- Display initial progress-bar
+  pgStr <- Ascii.pgGetProgressStr opts opts <$> Ascii.Internal.getInfoStats info
+  Ascii.setConsoleRegion region pgStr
+
+  future <- Async.async $ start info
+  return $ Ascii.ProgressBar info future region
+  where
+    start info@Ascii.Internal.ProgressBarInfo {..} = do
+      c <- readMVar pgCompleted
+      unlessDone c $ do
+        n <- readChan pgChannel
+        _ <- handleMessage info n
+        unlessDone (c + n) $ start info
+      where
+        unlessDone c action | c < Ascii.pgTotal opts = action
+        unlessDone _ _ = do
+          let fmt = fromMaybe (Ascii.pgFormat opts) (Ascii.pgOnCompletion opts)
+          onCompletion <- Ascii.pgGetProgressStr opts opts {Ascii.pgFormat = fmt} <$> Ascii.Internal.getInfoStats info
+          Ascii.setConsoleRegion region onCompletion
+
+    handleMessage info n = do
+      -- Update the completed tick count
+      modifyMVar_ (Ascii.Internal.pgCompleted info) (\c -> return (c + n))
+      -- Find and update the current and first tick times:
+      stats <- Ascii.Internal.getInfoStats info
+      let progressStr = Ascii.Internal.pgGetProgressStr opts opts stats
+      Ascii.setConsoleRegion region progressStr
+
+-- | Cancel async updates.
+cancelUpdates :: Ascii.ProgressBar -> IO ()
+cancelUpdates (Ascii.ProgressBar _ future _) = Async.cancel future
+
+-- | Cancel async updates and clear the console region with the given string.
+clearAsciiWith :: Ascii.ProgressBar -> String -> IO ()
+clearAsciiWith pg@(Ascii.ProgressBar _ _ region) str = do
+  cancelUpdates pg
+  Ascii.setConsoleRegion region str
diff --git a/src/Cachix/Daemon/Protocol.hs b/src/Cachix/Daemon/Protocol.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Protocol.hs
@@ -0,0 +1,75 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+module Cachix.Daemon.Protocol
+  ( ClientMessage (..),
+    DaemonMessage (..),
+    DaemonExitStatus (..),
+    PushRequestId,
+    newPushRequestId,
+    PushRequest (..),
+    newMessage,
+    splitMessages,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.UUID (UUID)
+import Data.UUID.V4 qualified as UUID
+import Protolude
+
+-- | JSON messages that the client can send to the daemon
+data ClientMessage
+  = ClientPushRequest !PushRequest
+  | ClientStop
+  | ClientPing
+  deriving stock (Eq, Generic, Show)
+  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+-- | JSON messages that the daemon can send to the client
+data DaemonMessage
+  = DaemonPong
+  | DaemonExit !DaemonExitStatus
+  deriving stock (Eq, Generic, Show)
+  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+data DaemonExitStatus = DaemonExitStatus
+  { exitCode :: !Int,
+    exitMessage :: !(Maybe Text)
+  }
+  deriving stock (Eq, Generic, Show)
+  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+newtype PushRequestId = PushRequestId UUID
+  deriving stock (Generic)
+  deriving newtype (Eq, Ord, Show, Aeson.FromJSON, Aeson.ToJSON, Hashable)
+
+newPushRequestId :: (MonadIO m) => m PushRequestId
+newPushRequestId = liftIO $ PushRequestId <$> UUID.nextRandom
+
+-- | A request for the daemon to push store paths to a binary cache
+data PushRequest = PushRequest
+  { storePaths :: [FilePath]
+  }
+  deriving stock (Eq, Generic, Show)
+  deriving anyclass (Aeson.FromJSON, Aeson.ToJSON)
+
+newMessage :: (Aeson.ToJSON a) => a -> LBS.ByteString
+newMessage msg =
+  Aeson.encode msg `LBS.snoc` fromIntegral (fromEnum '\n')
+
+splitMessages :: ByteString -> ([ByteString], ByteString)
+splitMessages = go []
+  where
+    go acc bs =
+      case BS.break (== _n) bs of
+        (line, "") -> (reverse acc, line)
+        (line, "\n") -> go (line `cons` acc) mempty
+        (line, rest) ->
+          go (line `cons` acc) (BS.drop 1 rest)
+
+    cons "" xs = xs
+    cons x xs = x : xs
+
+    _n = fromIntegral (fromEnum '\n')
diff --git a/src/Cachix/Daemon/Push.hs b/src/Cachix/Daemon/Push.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Push.hs
@@ -0,0 +1,68 @@
+module Cachix.Daemon.Push
+  ( newPushParams,
+    getBinaryCache,
+    getCompressionMethod,
+  )
+where
+
+import Cachix.API qualified as API
+import Cachix.Client.Command.Push hiding (pushStrategy)
+import Cachix.Client.Env (Env (..))
+import Cachix.Client.OptionsParser as Client.OptionsParser
+  ( PushOptions (..),
+  )
+import Cachix.Client.Push as Client.Push
+import Cachix.Client.Retry (retryHttp)
+import Cachix.Client.Servant
+import Cachix.Daemon.PushManager qualified as PushManager
+import Cachix.Daemon.Types (PushManager)
+import Cachix.Types.BinaryCache (BinaryCache, BinaryCacheName)
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Data.Set qualified as Set
+import Hercules.CNix.Store (Store)
+import Protolude hiding (toS)
+import Servant.Auth ()
+import Servant.Auth.Client
+import Servant.Client.Streaming
+import Servant.Conduit ()
+
+newPushParams ::
+  Store ->
+  ClientEnv ->
+  BinaryCache ->
+  PushSecret ->
+  PushOptions ->
+  PushParams PushManager ()
+newPushParams store clientEnv binaryCache pushSecret pushOptions = do
+  let authToken = getAuthTokenFromPushSecret pushSecret
+      cacheName = BinaryCache.name binaryCache
+      compressionMethod = getCompressionMethod pushOptions binaryCache
+      pushStrategy = PushManager.newPushStrategy store authToken pushOptions cacheName compressionMethod
+
+  PushParams
+    { pushParamsName = cacheName,
+      pushParamsSecret = pushSecret,
+      pushParamsClientEnv = clientEnv,
+      pushOnClosureAttempt = \full missing -> do
+        let already = Set.toList $ Set.difference (Set.fromList full) (Set.fromList missing)
+        mapM_ (onAlreadyPresent . pushStrategy) already
+        return missing,
+      pushParamsStrategy = pushStrategy,
+      pushParamsStore = store
+    }
+
+getBinaryCache :: Env -> Maybe Token -> BinaryCacheName -> IO BinaryCache
+getBinaryCache env authToken name = do
+  -- Self-signed caches might not have a token, which is why this code is so weird.
+  -- In practice, public self-signed caches don't need one and private ones always need a token.
+  let token = fromMaybe (Token "") authToken
+  res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name
+  case res of
+    Left err -> handleCacheResponse name authToken err
+    Right binaryCache -> pure binaryCache
+
+getCompressionMethod :: PushOptions -> BinaryCache -> BinaryCache.CompressionMethod
+getCompressionMethod opts binaryCache =
+  fromMaybe BinaryCache.ZSTD $
+    Client.OptionsParser.compressionMethod opts
+      <|> Just (BinaryCache.preferredCompressionMethod binaryCache)
diff --git a/src/Cachix/Daemon/PushManager.hs b/src/Cachix/Daemon/PushManager.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/PushManager.hs
@@ -0,0 +1,516 @@
+module Cachix.Daemon.PushManager
+  ( newPushManagerEnv,
+    runPushManager,
+    stopPushManager,
+
+    -- * Push strategy
+    newPushStrategy,
+
+    -- * Push job
+    PushJob (..),
+    addPushJob,
+    lookupPushJob,
+    withPushJob,
+    resolvePushJob,
+    pendingJobCount,
+
+    -- * Query
+    filterPushJobs,
+    getFailedPushJobs,
+
+    -- * Store paths
+    queueStorePaths,
+    removeStorePath,
+    queuedStorePathCount,
+
+    -- * Tasks
+    handleTask,
+
+    -- * Push events
+    pushStarted,
+    pushFinished,
+    pushStorePathAttempt,
+    pushStorePathProgress,
+    pushStorePathDone,
+    pushStorePathFailed,
+
+    -- * Helpers
+    atomicallyWithTimeout,
+  )
+where
+
+import Cachix.Client.CNix (filterInvalidStorePath, followLinksToStorePath)
+import Cachix.Client.Command.Push hiding (pushStrategy)
+import Cachix.Client.OptionsParser as Client.OptionsParser
+  ( PushOptions (..),
+  )
+import Cachix.Client.Push as Client.Push
+import Cachix.Client.Retry (retryAll)
+import Cachix.Daemon.Protocol qualified as Protocol
+import Cachix.Daemon.PushManager.PushJob qualified as PushJob
+import Cachix.Daemon.Types.Log (Logger)
+import Cachix.Daemon.Types.PushEvent
+import Cachix.Daemon.Types.PushManager
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Conduit qualified as C
+import Control.Concurrent.Async qualified as Async
+import Control.Concurrent.STM.TBMQueue
+import Control.Concurrent.STM.TVar
+import Control.Monad.Catch qualified as E
+import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
+import Control.Retry (RetryStatus)
+import Data.ByteString qualified as BS
+import Data.HashMap.Strict qualified as HashMap
+import Data.IORef
+import Data.Set qualified as Set
+import Data.Text qualified as T
+import Data.Time (UTCTime, diffUTCTime, getCurrentTime, secondsToNominalDiffTime)
+import Hercules.CNix (StorePath)
+import Hercules.CNix.Store (Store, parseStorePath, storePathToPath)
+import Katip qualified
+import Protolude hiding (toS)
+import Protolude.Conv (toS)
+import Servant.Auth ()
+import Servant.Auth.Client
+import Servant.Conduit ()
+import UnliftIO.QSem qualified as QSem
+
+newPushManagerEnv :: (MonadIO m) => PushOptions -> PushParams PushManager () -> OnPushEvent -> Logger -> m PushManagerEnv
+newPushManagerEnv pushOptions pmPushParams onPushEvent pmLogger = liftIO $ do
+  pmPushJobs <- newTVarIO mempty
+  pmPendingJobCount <- newTVarIO 0
+  pmStorePathIndex <- newTVarIO mempty
+  pmTaskQueue <- newTBMQueueIO 1000
+  pmTaskSemaphore <- QSem.newQSem (numJobs pushOptions)
+  pmLastEventTimestamp <- newTVarIO =<< getCurrentTime
+  let pmOnPushEvent id pushEvent = updateTimestampTVar pmLastEventTimestamp >> onPushEvent id pushEvent
+
+  return $ PushManagerEnv {..}
+
+runPushManager :: (MonadIO m) => PushManagerEnv -> PushManager a -> m a
+runPushManager env f = liftIO $ unPushManager f `runReaderT` env
+
+stopPushManager :: TimeoutOptions -> PushManagerEnv -> IO ()
+stopPushManager timeoutOptions PushManagerEnv {..} = do
+  atomicallyWithTimeout timeoutOptions pmLastEventTimestamp $ do
+    pendingJobs <- readTVar pmPendingJobCount
+    check (pendingJobs <= 0)
+  atomically $ closeTBMQueue pmTaskQueue
+
+-- Manage push jobs
+
+addPushJob :: Protocol.PushRequest -> PushManager (Maybe Protocol.PushRequestId)
+addPushJob pushRequest = do
+  PushManagerEnv {..} <- ask
+  pushJob <- PushJob.new pushRequest
+
+  Katip.logLocM Katip.DebugS $ Katip.ls $ "Queued push job " <> (show (pushId pushJob) :: Text)
+
+  let queueJob = do
+        modifyTVar' pmPushJobs $ HashMap.insert (pushId pushJob) pushJob
+        incrementTVar pmPendingJobCount
+        res <- tryWriteTBMQueue pmTaskQueue $ ResolveClosure (pushId pushJob)
+        case res of
+          Just True -> return True
+          _ -> retry
+
+  didQueue <- liftIO $ atomically $ queueJob `orElse` return False
+
+  if didQueue
+    then return $ Just $ pushId pushJob
+    else do
+      Katip.logLocM Katip.WarningS "Failed to queue push job. Queue likely full."
+      return Nothing
+
+removePushJob :: Protocol.PushRequestId -> PushManager ()
+removePushJob pushId = do
+  PushManagerEnv {..} <- ask
+  liftIO $ atomically $ do
+    mpushJob <- HashMap.lookup pushId <$> readTVar pmPushJobs
+    for_ mpushJob $ \pushJob -> do
+      -- Decrement the job count if this job had not been processed yet
+      unless (PushJob.isProcessed pushJob) (decrementTVar pmPendingJobCount)
+      modifyTVar' pmPushJobs (HashMap.delete pushId)
+
+lookupPushJob :: Protocol.PushRequestId -> PushManager (Maybe PushJob)
+lookupPushJob pushId = do
+  pushJobs <- asks pmPushJobs
+  liftIO $ HashMap.lookup pushId <$> readTVarIO pushJobs
+
+filterPushJobs :: (PushJob -> Bool) -> PushManager [PushJob]
+filterPushJobs f = do
+  pushJobs <- asks pmPushJobs
+  liftIO $ filter f . HashMap.elems <$> readTVarIO pushJobs
+
+getFailedPushJobs :: PushManager [PushJob]
+getFailedPushJobs = filterPushJobs PushJob.isFailed
+
+withPushJob :: Protocol.PushRequestId -> (PushJob -> PushManager ()) -> PushManager ()
+withPushJob pushId f =
+  maybe handleMissingPushJob f =<< lookupPushJob pushId
+  where
+    handleMissingPushJob =
+      Katip.logLocM Katip.ErrorS $ Katip.ls $ "Push job " <> (show pushId :: Text) <> " not found"
+
+modifyPushJob :: Protocol.PushRequestId -> (PushJob -> PushJob) -> PushManager (Maybe PushJob)
+modifyPushJob pushId f = do
+  pushJobs <- asks pmPushJobs
+  liftIO $ atomically $ modifyPushJobSTM pushJobs pushId f
+
+modifyPushJobSTM :: PushJobStore -> Protocol.PushRequestId -> (PushJob -> PushJob) -> STM (Maybe PushJob)
+modifyPushJobSTM pushJobs pushId f =
+  stateTVar pushJobs $ \jobs -> do
+    let pj = HashMap.adjust f pushId jobs
+    (HashMap.lookup pushId pj, pj)
+
+modifyPushJobs :: [Protocol.PushRequestId] -> (PushJob -> PushJob) -> PushManager ()
+modifyPushJobs pushIds f = do
+  pushJobs <- asks pmPushJobs
+  liftIO $ atomically $ modifyTVar' pushJobs $ \pushJobs' ->
+    foldl' (flip (HashMap.adjust f)) pushJobs' pushIds
+
+failPushJob :: Protocol.PushRequestId -> PushManager ()
+failPushJob pushId = do
+  PushManagerEnv {..} <- ask
+  timestamp <- liftIO getCurrentTime
+  liftIO $ atomically $ do
+    _ <- modifyPushJobSTM pmPushJobs pushId $ PushJob.fail timestamp
+    decrementTVar pmPendingJobCount
+
+pendingJobCount :: PushManager Int
+pendingJobCount = do
+  pmPendingJobCount <- asks pmPendingJobCount
+  liftIO $ readTVarIO pmPendingJobCount
+
+-- Manage store paths
+
+queueStorePaths :: Protocol.PushRequestId -> [FilePath] -> PushManager ()
+queueStorePaths pushId storePaths = do
+  PushManagerEnv {..} <- ask
+
+  let addToQueue storePath = do
+        isDuplicate <- HashMap.member storePath <$> readTVar pmStorePathIndex
+        unless isDuplicate $
+          writeTBMQueue pmTaskQueue (PushStorePath storePath)
+
+        modifyTVar' pmStorePathIndex $ HashMap.insertWith (<>) storePath [pushId]
+
+  transactionally $ map addToQueue storePaths
+
+removeStorePath :: FilePath -> PushManager ()
+removeStorePath storePath = do
+  storePathIndex <- asks pmStorePathIndex
+  liftIO $ atomically $ do
+    modifyTVar' storePathIndex $ HashMap.delete storePath
+
+lookupStorePathIndex :: FilePath -> PushManager [Protocol.PushRequestId]
+lookupStorePathIndex storePath = do
+  storePathIndex <- asks pmStorePathIndex
+  references <- liftIO $ readTVarIO storePathIndex
+  return $ fromMaybe [] (HashMap.lookup storePath references)
+
+checkPushJobCompleted :: Protocol.PushRequestId -> PushManager ()
+checkPushJobCompleted pushId = do
+  PushManagerEnv {..} <- ask
+  mpushJob <- lookupPushJob pushId
+  for_ mpushJob $ \pushJob ->
+    when (Set.null $ pushQueue pushJob) $ do
+      timestamp <- liftIO getCurrentTime
+      liftIO $ atomically $ do
+        _ <- modifyPushJobSTM pmPushJobs pushId $ \pushJob' ->
+          if PushJob.hasFailedPaths pushJob'
+            then PushJob.fail timestamp pushJob'
+            else PushJob.complete timestamp pushJob'
+        decrementTVar pmPendingJobCount
+      pushFinished pushJob
+
+queuedStorePathCount :: PushManager Integer
+queuedStorePathCount = do
+  pmPushJobs <- asks pmPushJobs
+  jobs <- liftIO $ readTVarIO pmPushJobs
+  pure $ foldl' countQueuedPaths 0 (HashMap.elems jobs)
+  where
+    countQueuedPaths acc job = acc + fromIntegral (Set.size $ pushQueue job)
+
+resolvePushJob :: Protocol.PushRequestId -> PushJob.ResolvedClosure FilePath -> PushManager ()
+resolvePushJob pushId closure = do
+  Katip.logLocM Katip.DebugS $ Katip.ls $ showClosureStats closure
+
+  timestamp <- liftIO getCurrentTime
+  _ <- modifyPushJob pushId $ PushJob.populateQueue closure timestamp
+
+  withPushJob pushId $ \pushJob -> do
+    pushStarted pushJob
+    -- Create STM action for each path and then run everything atomically
+    queueStorePaths pushId $ Set.toList (PushJob.rcMissingPaths closure)
+    -- Check if the job is already completed, i.e. all paths have been skipped.
+    checkPushJobCompleted pushId
+  where
+    showClosureStats :: PushJob.ResolvedClosure FilePath -> Text
+    showClosureStats PushJob.ResolvedClosure {..} =
+      let skippedPaths = Set.difference rcAllPaths rcMissingPaths
+          queuedCount = length rcMissingPaths
+          skippedCount = length skippedPaths
+          totalCount = queuedCount + skippedCount
+       in T.intercalate
+            "\n"
+            [ "Resolved push job " <> show pushId,
+              "Total paths: " <> show totalCount,
+              "Queued paths: " <> show queuedCount,
+              "Skipped paths: " <> show skippedCount
+            ]
+
+handleTask :: Task -> PushManager ()
+handleTask task = do
+  pushParams <- asks pmPushParams
+  case task of
+    ResolveClosure pushId ->
+      runResolveClosureTask pushParams pushId
+    PushStorePath filePath ->
+      runPushStorePathTask pushParams filePath
+
+runResolveClosureTask :: PushParams PushManager () -> Protocol.PushRequestId -> PushManager ()
+runResolveClosureTask pushParams pushId =
+  resolveClosure `withException` failJob
+  where
+    failJob :: SomeException -> PushManager ()
+    failJob err = do
+      failPushJob pushId
+
+      Katip.katipAddContext (Katip.sl "error" (displayException err)) $
+        Katip.logLocM Katip.ErrorS $
+          Katip.ls $
+            "Failed to resolve closure for push job " <> (show pushId :: Text)
+
+    resolveClosure = do
+      Katip.logLocM Katip.DebugS $ Katip.ls $ "Resolving closure for push job " <> (show pushId :: Text)
+
+      withPushJob pushId $ \pushJob -> do
+        let sps = Protocol.storePaths (pushRequest pushJob)
+            store = pushParamsStore pushParams
+        normalized <- mapM (normalizeStorePath store) sps
+        (allStorePaths, missingStorePaths) <- getMissingPathsForClosure pushParams (catMaybes normalized)
+        storePathsToPush <- pushOnClosureAttempt pushParams allStorePaths missingStorePaths
+
+        resolvedClosure <- do
+          allPaths <- liftIO $ mapM (storeToFilePath store) allStorePaths
+          pathsToPush <- liftIO $ mapM (storeToFilePath store) storePathsToPush
+          return $
+            PushJob.ResolvedClosure
+              { rcAllPaths = Set.fromList allPaths,
+                rcMissingPaths = Set.fromList pathsToPush
+              }
+
+        resolvePushJob pushId resolvedClosure
+
+runPushStorePathTask :: PushParams PushManager () -> FilePath -> PushManager ()
+runPushStorePathTask pushParams filePath = do
+  pushStorePath `withException` failStorePath
+  where
+    failStorePath =
+      pushStorePathFailed filePath . toS . displayException
+
+    pushStorePath = do
+      qs <- asks pmTaskSemaphore
+      E.bracket_ (QSem.waitQSem qs) (QSem.signalQSem qs) $ do
+        Katip.logLocM Katip.DebugS $ Katip.ls $ "Pushing store path " <> filePath
+
+        let store = pushParamsStore pushParams
+        storePath <- liftIO $ parseStorePath store (toS filePath)
+
+        retryAll $ uploadStorePath pushParams storePath
+
+newPushStrategy ::
+  Store ->
+  Maybe Token ->
+  PushOptions ->
+  Text ->
+  BinaryCache.CompressionMethod ->
+  (StorePath -> PushStrategy PushManager ())
+newPushStrategy store authToken opts cacheName compressionMethod storePath =
+  let onAlreadyPresent = do
+        sp <- liftIO $ storePathToPath store storePath
+        Katip.logFM Katip.InfoS $ Katip.ls $ "Skipping " <> (toS sp :: Text)
+        -- TODO: needs another event type here
+        pushStorePathDone (toS sp)
+
+      onError err = do
+        let errText = toS (displayException err)
+        sp <- liftIO $ storePathToPath store storePath
+        Katip.katipAddContext (Katip.sl "error" errText) $
+          Katip.logFM Katip.InfoS (Katip.ls $ "Failed " <> (toS sp :: Text))
+        pushStorePathFailed (toS sp) errText
+
+      onAttempt retryStatus size = do
+        sp <- liftIO $ storePathToPath store storePath
+        Katip.logFM Katip.InfoS $ Katip.ls $ "Pushing " <> (toS sp :: Text)
+        pushStorePathAttempt (toS sp) size retryStatus
+
+      onUncompressedNARStream _ size = do
+        sp <- liftIO $ storePathToPath store storePath
+        lastEmitRef <- liftIO $ newIORef (0 :: Int64)
+        currentBytesRef <- liftIO $ newIORef (0 :: Int64)
+        C.awaitForever $ \chunk -> do
+          let newBytes = fromIntegral (BS.length chunk)
+          currentBytes <- liftIO $ atomicModifyIORef' currentBytesRef (\b -> (b + newBytes, b + newBytes))
+          lastEmit <- liftIO $ readIORef lastEmitRef
+
+          when (currentBytes - lastEmit >= 1024 || currentBytes == size) $ do
+            liftIO $ writeIORef lastEmitRef currentBytes
+            lift $ lift $ pushStorePathProgress (toS sp) currentBytes newBytes
+
+          C.yield chunk
+
+      onDone = do
+        sp <- liftIO $ storePathToPath store storePath
+        Katip.logFM Katip.InfoS $ Katip.ls $ "Pushed " <> (toS sp :: Text)
+        pushStorePathDone (toS sp)
+   in PushStrategy
+        { onAlreadyPresent = onAlreadyPresent,
+          on401 = liftIO . handleCacheResponse cacheName authToken,
+          onError = onError,
+          onAttempt = onAttempt,
+          onUncompressedNARStream = onUncompressedNARStream,
+          onDone = onDone,
+          Client.Push.compressionMethod = compressionMethod,
+          Client.Push.compressionLevel = Client.OptionsParser.compressionLevel opts,
+          Client.Push.chunkSize = Client.OptionsParser.chunkSize opts,
+          Client.Push.numConcurrentChunks = Client.OptionsParser.numConcurrentChunks opts,
+          Client.Push.omitDeriver = Client.OptionsParser.omitDeriver opts
+        }
+
+-- Push events
+
+pushStarted :: PushJob -> PushManager ()
+pushStarted pushJob@PushJob {pushId} = do
+  case PushJob.startedAt pushJob of
+    Nothing -> return ()
+    Just timestamp -> do
+      sendPushEvent <- asks pmOnPushEvent
+      liftIO $ do
+        sendPushEvent pushId $
+          PushEvent timestamp pushId PushStarted
+
+pushFinished :: PushJob -> PushManager ()
+pushFinished pushJob@PushJob {pushId} = void $ runMaybeT $ do
+  pushDuration <- MaybeT $ pure $ PushJob.duration pushJob
+  completedAt <- MaybeT $ pure $ PushJob.completedAt pushJob
+
+  Katip.logLocM Katip.InfoS $
+    Katip.ls $
+      T.intercalate
+        " "
+        [ "Push job",
+          show pushId :: Text,
+          "finished in",
+          show pushDuration
+        ]
+
+  sendPushEvent <- asks pmOnPushEvent
+  liftIO $ do
+    sendPushEvent pushId $
+      PushEvent completedAt pushId PushFinished
+
+  lift $ removePushJob pushId
+
+sendStorePathEvent :: [Protocol.PushRequestId] -> PushEventMessage -> PushManager ()
+sendStorePathEvent pushIds msg = do
+  timestamp <- liftIO getCurrentTime
+  sendPushEvent <- asks pmOnPushEvent
+  liftIO $ forM_ pushIds $ \pushId ->
+    sendPushEvent pushId (PushEvent timestamp pushId msg)
+
+pushStorePathAttempt :: FilePath -> Int64 -> RetryStatus -> PushManager ()
+pushStorePathAttempt storePath size retryStatus = do
+  let pushRetryStatus = newPushRetryStatus retryStatus
+  pushIds <- lookupStorePathIndex storePath
+  sendStorePathEvent pushIds (PushStorePathAttempt storePath size pushRetryStatus)
+
+pushStorePathProgress :: FilePath -> Int64 -> Int64 -> PushManager ()
+pushStorePathProgress storePath currentBytes newBytes = do
+  pushIds <- lookupStorePathIndex storePath
+  sendStorePathEvent pushIds (PushStorePathProgress storePath currentBytes newBytes)
+
+pushStorePathDone :: FilePath -> PushManager ()
+pushStorePathDone storePath = do
+  pushIds <- lookupStorePathIndex storePath
+  modifyPushJobs pushIds (PushJob.markStorePathPushed storePath)
+
+  sendStorePathEvent pushIds (PushStorePathDone storePath)
+
+  mapM_ checkPushJobCompleted pushIds
+
+  removeStorePath storePath
+
+pushStorePathFailed :: FilePath -> Text -> PushManager ()
+pushStorePathFailed storePath errMsg = do
+  pushIds <- lookupStorePathIndex storePath
+  modifyPushJobs pushIds (PushJob.markStorePathFailed storePath)
+
+  sendStorePathEvent pushIds (PushStorePathFailed storePath errMsg)
+
+  mapM_ checkPushJobCompleted pushIds
+
+  removeStorePath storePath
+
+-- Helpers
+
+storeToFilePath :: (MonadIO m) => Store -> StorePath -> m FilePath
+storeToFilePath store storePath = do
+  fp <- liftIO $ storePathToPath store storePath
+  pure $ toS fp
+
+-- | Canonicalize and validate a store path
+normalizeStorePath :: (MonadIO m) => Store -> FilePath -> m (Maybe StorePath)
+normalizeStorePath store fp =
+  liftIO $ runMaybeT $ do
+    storePath <- MaybeT $ followLinksToStorePath store (encodeUtf8 $ T.pack fp)
+    MaybeT $ filterInvalidStorePath store storePath
+
+withException :: (E.MonadCatch m) => m a -> (SomeException -> m a) -> m a
+withException action handler = action `E.catchAll` (\e -> handler e >> E.throwM e)
+
+-- STM helpers
+
+transactionally :: (Foldable t, MonadIO m) => t (STM ()) -> m ()
+transactionally = liftIO . atomically . sequence_
+
+updateTimestampTVar :: (MonadIO m) => TVar UTCTime -> m ()
+updateTimestampTVar tvar = liftIO $ do
+  now <- getCurrentTime
+  atomically $ writeTVar tvar now
+
+incrementTVar :: TVar Int -> STM ()
+incrementTVar tvar = modifyTVar' tvar (+ 1)
+
+decrementTVar :: TVar Int -> STM ()
+decrementTVar tvar = modifyTVar' tvar (subtract 1)
+
+-- | Run a transaction with a timeout.
+atomicallyWithTimeout ::
+  TimeoutOptions ->
+  -- | A TVar timestamp to compare against
+  TVar UTCTime ->
+  -- | The transaction to run
+  STM () ->
+  IO ()
+atomicallyWithTimeout TimeoutOptions {..} timeVar transaction = do
+  timeoutVar <- newTVarIO False
+  Async.race_
+    (updateShutdownTimeout timeoutVar)
+    (waitForGracefulShutdown timeoutVar)
+  where
+    waitForGracefulShutdown timeout =
+      atomically $ transaction `orElse` checkShutdownTimeout timeout
+
+    updateShutdownTimeout timeoutVar =
+      forever $ do
+        now <- getCurrentTime
+        atomically $ do
+          timestamp <- readTVar timeVar
+          let isTimeout =
+                secondsToNominalDiffTime (realToFrac toTimeout) <= now `diffUTCTime` timestamp
+          writeTVar timeoutVar isTimeout
+        threadDelay $ ceiling (toPollingInterval * 1000.0 * 1000.0)
+
+    checkShutdownTimeout timeout = check =<< readTVar timeout
diff --git a/src/Cachix/Daemon/PushManager/PushJob.hs b/src/Cachix/Daemon/PushManager/PushJob.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/PushManager/PushJob.hs
@@ -0,0 +1,123 @@
+module Cachix.Daemon.PushManager.PushJob
+  ( module Cachix.Daemon.PushManager.PushJob,
+    module Types,
+  )
+where
+
+import Cachix.Daemon.Protocol qualified as Protocol
+import Cachix.Daemon.Types.PushManager as Types
+import Data.Set qualified as Set
+import Data.Time (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
+import Protolude
+
+new :: (MonadIO m) => Protocol.PushRequest -> m PushJob
+new pushRequest = do
+  pushId <- Protocol.newPushRequestId
+  timestamp <- liftIO getCurrentTime
+  return $
+    PushJob
+      { pushId,
+        pushRequest,
+        pushStatus = Queued,
+        pushQueue = mempty,
+        pushResult = mempty,
+        pushStats = newStats timestamp
+      }
+
+newStats :: UTCTime -> JobStats
+newStats createdAt =
+  JobStats
+    { jsCreatedAt = createdAt,
+      jsStartedAt = Nothing,
+      jsCompletedAt = Nothing
+    }
+
+data ResolvedClosure p = ResolvedClosure
+  { rcAllPaths :: Set p,
+    rcMissingPaths :: Set p
+  }
+
+populateQueue :: ResolvedClosure FilePath -> UTCTime -> PushJob -> PushJob
+populateQueue ResolvedClosure {..} timestamp pushJob@PushJob {..} = do
+  let skippedPaths = Set.difference rcAllPaths rcMissingPaths
+  pushJob
+    { pushStatus = Running,
+      pushStats = pushStats {jsStartedAt = Just timestamp},
+      pushQueue = rcMissingPaths,
+      pushResult = pushResult {prSkippedPaths = skippedPaths}
+    }
+
+addPushedPath :: FilePath -> PushResult -> PushResult
+addPushedPath storePath pushResult =
+  pushResult {prPushedPaths = Set.insert storePath (prPushedPaths pushResult)}
+
+addFailedPath :: FilePath -> PushResult -> PushResult
+addFailedPath storePath pushResult =
+  pushResult {prFailedPaths = Set.insert storePath (prFailedPaths pushResult)}
+
+markStorePathPushed :: FilePath -> PushJob -> PushJob
+markStorePathPushed storePath pushJob@(PushJob {pushQueue, pushResult}) =
+  pushJob
+    { pushQueue = Set.delete storePath pushQueue,
+      pushResult = addPushedPath storePath pushResult
+    }
+
+markStorePathFailed :: FilePath -> PushJob -> PushJob
+markStorePathFailed storePath pushJob@(PushJob {pushQueue, pushResult}) =
+  pushJob
+    { pushQueue = Set.delete storePath pushQueue,
+      pushResult = addFailedPath storePath pushResult
+    }
+
+status :: PushJob -> JobStatus
+status PushJob {pushStatus} = pushStatus
+
+queue :: PushJob -> Set FilePath
+queue PushJob {pushQueue} = pushQueue
+
+result :: PushJob -> PushResult
+result PushJob {pushResult} = pushResult
+
+hasQueuedPaths :: PushJob -> Bool
+hasQueuedPaths = not . Set.null . queue
+
+hasFailedPaths :: PushJob -> Bool
+hasFailedPaths = not . Set.null . prFailedPaths . result
+
+complete :: UTCTime -> PushJob -> PushJob
+complete timestamp pushJob@PushJob {..} = do
+  pushJob
+    { pushStatus =
+        case pushStatus of
+          Running -> Completed
+          _ -> pushStatus,
+      pushStats = pushStats {jsCompletedAt = Just timestamp}
+    }
+
+fail :: UTCTime -> PushJob -> PushJob
+fail timestamp pushJob@PushJob {..} = do
+  pushJob
+    { pushStatus = Failed,
+      pushStats = pushStats {jsCompletedAt = Just timestamp}
+    }
+
+isCompleted :: PushJob -> Bool
+isCompleted PushJob {pushStatus} = pushStatus == Completed
+
+isFailed :: PushJob -> Bool
+isFailed PushJob {pushStatus} = pushStatus == Failed
+
+isProcessed :: PushJob -> Bool
+isProcessed pushJob = isCompleted pushJob || isFailed pushJob
+
+startedAt :: PushJob -> Maybe UTCTime
+startedAt PushJob {pushStats = JobStats {jsStartedAt}} = jsStartedAt
+
+completedAt :: PushJob -> Maybe UTCTime
+completedAt PushJob {pushStats = JobStats {jsCompletedAt}} = jsCompletedAt
+
+duration :: PushJob -> Maybe NominalDiffTime
+duration PushJob {pushStats} = do
+  t1 <- jsStartedAt pushStats
+  t2 <- jsCompletedAt pushStats
+  pure $ diffUTCTime t2 t1
diff --git a/src/Cachix/Daemon/ShutdownLatch.hs b/src/Cachix/Daemon/ShutdownLatch.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/ShutdownLatch.hs
@@ -0,0 +1,26 @@
+module Cachix.Daemon.ShutdownLatch
+  ( ShutdownLatch,
+    newShutdownLatch,
+    waitForShutdown,
+    initiateShutdown,
+    isShuttingDown,
+  )
+where
+
+import Control.Concurrent.MVar
+import Protolude
+
+-- | A latch to keep track of the shutdown process.
+newtype ShutdownLatch = ShutdownLatch {unShutdownLatch :: MVar ()}
+
+newShutdownLatch :: (MonadIO m) => m ShutdownLatch
+newShutdownLatch = ShutdownLatch <$> liftIO newEmptyMVar
+
+waitForShutdown :: (MonadIO m) => ShutdownLatch -> m ()
+waitForShutdown = liftIO . readMVar . unShutdownLatch
+
+initiateShutdown :: (MonadIO m) => ShutdownLatch -> m ()
+initiateShutdown = void . liftIO . flip tryPutMVar () . unShutdownLatch
+
+isShuttingDown :: (MonadIO m) => ShutdownLatch -> m Bool
+isShuttingDown = liftIO . fmap not . isEmptyMVar . unShutdownLatch
diff --git a/src/Cachix/Daemon/SocketStore.hs b/src/Cachix/Daemon/SocketStore.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/SocketStore.hs
@@ -0,0 +1,43 @@
+module Cachix.Daemon.SocketStore
+  ( newSocketStore,
+    addSocket,
+    removeSocket,
+    toList,
+    Socket (..),
+  )
+where
+
+import Cachix.Daemon.Types.SocketStore (Socket (..), SocketId, SocketStore (..))
+import Control.Concurrent.STM.TVar
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Data.HashMap.Strict qualified as HashMap
+import Data.UUID.V4 qualified as UUID
+import Network.Socket qualified
+import Protolude hiding (toList)
+import UnliftIO.Async qualified as Async
+
+newSocketStore :: (MonadIO m) => m SocketStore
+newSocketStore = SocketStore <$> liftIO (newTVarIO mempty)
+
+newSocketId :: (MonadIO m) => m SocketId
+newSocketId = liftIO UUID.nextRandom
+
+addSocket :: (MonadUnliftIO m) => Network.Socket.Socket -> (SocketId -> Network.Socket.Socket -> m ()) -> SocketStore -> m ()
+addSocket socket handler (SocketStore st) = do
+  socketId <- newSocketId
+  handlerThread <- Async.async (handler socketId socket)
+  liftIO $ atomically $ modifyTVar' st $ HashMap.insert socketId (Socket {..})
+
+removeSocket :: (MonadIO m) => SocketId -> SocketStore -> m ()
+removeSocket socketId (SocketStore stvar) = do
+  msocket <- liftIO $ atomically $ stateTVar stvar $ \st ->
+    let msocket = HashMap.lookup socketId st
+     in (msocket, HashMap.delete socketId st)
+
+  -- shut down the handler thread
+  mapM_ (Async.uninterruptibleCancel . handlerThread) msocket
+
+toList :: (MonadIO m) => SocketStore -> m [Socket]
+toList (SocketStore st) = do
+  hm <- liftIO $ readTVarIO st
+  return $ HashMap.elems hm
diff --git a/src/Cachix/Daemon/Subscription.hs b/src/Cachix/Daemon/Subscription.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Subscription.hs
@@ -0,0 +1,102 @@
+module Cachix.Daemon.Subscription where
+
+import Control.Concurrent.STM.TBMQueue
+import Control.Concurrent.STM.TMChan
+import Control.Concurrent.STM.TVar
+import Data.Aeson as Aeson (ToJSON)
+import Data.HashMap.Strict (HashMap)
+import Data.HashMap.Strict qualified as HashMap
+import Network.Socket qualified as Socket
+import Protolude
+
+data SubscriptionManager k v = SubscriptionManager
+  { managerSubscriptions :: TVar (HashMap k [Subscription v]),
+    managerGlobalSubscriptions :: TVar [Subscription v],
+    managerEvents :: TBMQueue (k, v)
+  }
+
+data Subscription v
+  = -- | A subscriber that listens on a socket.
+    SubSocket (TBMQueue v) Socket.Socket
+  | -- | A subscriber that listens on a channel.
+    SubChannel (TMChan v)
+
+newSubscriptionManager :: IO (SubscriptionManager k v)
+newSubscriptionManager = do
+  subscriptions <- newTVarIO HashMap.empty
+  globalSubscriptions <- newTVarIO []
+  events <- newTBMQueueIO 10000
+  pure $ SubscriptionManager subscriptions globalSubscriptions events
+
+-- Subscriptions
+
+subscribeTo :: (Hashable k, MonadIO m) => SubscriptionManager k v -> k -> Subscription v -> m ()
+subscribeTo manager key subscription =
+  liftIO $ atomically $ subscribeToSTM manager key subscription
+
+subscribeToAll :: (MonadIO m) => SubscriptionManager k v -> Subscription v -> m ()
+subscribeToAll manager subscription =
+  liftIO $ atomically $ subscribeToAllSTM manager subscription
+
+getSubscriptionsFor :: (Hashable k, MonadIO m) => SubscriptionManager k v -> k -> m [Subscription v]
+getSubscriptionsFor manager key =
+  liftIO $ atomically $ getSubscriptionsForSTM manager key
+
+subscribeToSTM :: (Hashable k) => SubscriptionManager k v -> k -> Subscription v -> STM ()
+subscribeToSTM manager key subscription = do
+  subscriptions <- readTVar $ managerSubscriptions manager
+  let subscriptions' = HashMap.insertWith (<>) key [subscription] subscriptions
+  writeTVar (managerSubscriptions manager) subscriptions'
+
+subscribeToAllSTM :: SubscriptionManager k v -> Subscription v -> STM ()
+subscribeToAllSTM manager subscription = do
+  subscriptions <- readTVar $ managerGlobalSubscriptions manager
+  let subscriptions' = subscription : subscriptions
+  writeTVar (managerGlobalSubscriptions manager) subscriptions'
+
+getSubscriptionsForSTM :: (Hashable k) => SubscriptionManager k v -> k -> STM [Subscription v]
+getSubscriptionsForSTM manager key = do
+  subscriptions <- readTVar $ managerSubscriptions manager
+  pure $ HashMap.lookupDefault [] key subscriptions
+
+-- Events
+
+pushEvent :: (MonadIO m) => SubscriptionManager k v -> k -> v -> m ()
+pushEvent manager key event =
+  liftIO $ atomically $ pushEventSTM manager key event
+
+pushEventSTM :: SubscriptionManager k v -> k -> v -> STM ()
+pushEventSTM manager key event =
+  writeTBMQueue (managerEvents manager) (key, event)
+
+sendEventToSub :: Subscription v -> v -> STM ()
+-- TODO: implement socket subscriptions.
+sendEventToSub (SubSocket _queue _) _ = pure () -- writeTBMQueue queue
+sendEventToSub (SubChannel chan) event = writeTMChan chan event
+
+runSubscriptionManager :: (Show k, Show v, Hashable k, ToJSON v, MonadIO m) => SubscriptionManager k v -> m ()
+runSubscriptionManager manager = do
+  isDone <- liftIO $ atomically $ do
+    mevent <- readTBMQueue (managerEvents manager)
+    case mevent of
+      Nothing -> return True
+      Just (key, event) -> do
+        subscriptions <- getSubscriptionsForSTM manager key
+        globalSubscriptions <- readTVar $ managerGlobalSubscriptions manager
+        mapM_ (`sendEventToSub` event) (subscriptions <> globalSubscriptions)
+        return False
+
+  unless isDone $
+    runSubscriptionManager manager
+
+stopSubscriptionManager :: SubscriptionManager k v -> IO ()
+stopSubscriptionManager manager = do
+  liftIO $ atomically $ closeTBMQueue (managerEvents manager)
+  globalSubscriptions <- liftIO $ readTVarIO $ managerGlobalSubscriptions manager
+  subscriptions <- liftIO $ readTVarIO $ managerSubscriptions manager
+
+  forM_ (concat subscriptions <> globalSubscriptions) $ \case
+    SubSocket queue sock -> do
+      atomically $ closeTBMQueue queue
+      Socket.close sock
+    SubChannel channel -> atomically $ closeTMChan channel
diff --git a/src/Cachix/Daemon/Types.hs b/src/Cachix/Daemon/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types.hs
@@ -0,0 +1,32 @@
+module Cachix.Daemon.Types
+  ( -- * Daemon
+    DaemonEvent (..),
+    DaemonEnv (..),
+    Daemon,
+    runDaemon,
+
+    -- * Daemon errors
+    DaemonError (..),
+    HasExitCode (..),
+
+    -- * Log
+    LogLevel (..),
+
+    -- * Push
+    PushManager.PushManagerEnv (..),
+    PushManager.PushManager,
+    PushManager.PushJob (..),
+    Task (..),
+
+    -- * Push Event
+    PushEvent.PushEvent (..),
+    PushEvent.PushEventMessage (..),
+    PushEvent.PushRetryStatus (..),
+  )
+where
+
+import Cachix.Daemon.Types.Daemon
+import Cachix.Daemon.Types.Error
+import Cachix.Daemon.Types.Log
+import Cachix.Daemon.Types.PushEvent as PushEvent
+import Cachix.Daemon.Types.PushManager as PushManager
diff --git a/src/Cachix/Daemon/Types/Daemon.hs b/src/Cachix/Daemon/Types/Daemon.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types/Daemon.hs
@@ -0,0 +1,124 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Cachix.Daemon.Types.Daemon
+  ( -- * Daemon
+    DaemonEvent (..),
+    DaemonEnv (..),
+    Daemon,
+    runDaemon,
+  )
+where
+
+import Cachix.Client.Config.Orphans ()
+import Cachix.Client.Env as Env
+import Cachix.Client.OptionsParser (PushOptions)
+import Cachix.Daemon.Log qualified as Log
+import Cachix.Daemon.Protocol qualified as Protocol
+import Cachix.Daemon.ShutdownLatch (ShutdownLatch)
+import Cachix.Daemon.Subscription (SubscriptionManager)
+import Cachix.Daemon.Types.Error (DaemonError (DaemonUnhandledException), UnhandledException (..))
+import Cachix.Daemon.Types.EventLoop (EventLoop)
+import Cachix.Daemon.Types.Log (Logger)
+import Cachix.Daemon.Types.PushEvent (PushEvent)
+import Cachix.Daemon.Types.PushManager (PushManagerEnv (..))
+import Cachix.Daemon.Types.SocketStore (SocketId, SocketStore)
+import Cachix.Daemon.Worker qualified as Worker
+import Cachix.Types.BinaryCache (BinaryCache, BinaryCacheName)
+import Control.Exception.Safe qualified as Safe
+import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow)
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Katip qualified
+import Network.Socket (Socket)
+import Protolude hiding (bracketOnError)
+import System.Posix.Types (ProcessID)
+
+-- | Daemon events that are handled by the 'EventLoop'.
+data DaemonEvent
+  = -- | Shut down the daemon gracefully.
+    ShutdownGracefully
+  | -- | Re-establish the daemon socket
+    ReconnectSocket
+  | -- | Add a new client socket connection.
+    AddSocketClient Socket
+  | -- | Remove an existing client socket connection. For example, after it is closed.
+    RemoveSocketClient SocketId
+  | -- | Handle a new message from a client.
+    ReceivedMessage Protocol.ClientMessage
+
+data DaemonEnv = DaemonEnv
+  { -- | Cachix client env
+    daemonEnv :: Env,
+    -- | The main event loop
+    daemonEventLoop :: EventLoop DaemonEvent (Either DaemonError ()),
+    -- | Push options, like compression settings and number of jobs
+    daemonPushOptions :: PushOptions,
+    -- | Path to the socket that the daemon listens on
+    daemonSocketPath :: FilePath,
+    -- | Main inbound socket thread
+    daemonSocketThread :: MVar (Async ()),
+    -- | The name of the binary cache to push to
+    daemonCacheName :: BinaryCacheName,
+    -- | The binary cache to push to
+    daemonBinaryCache :: BinaryCache,
+    -- | The state of active push requests
+    daemonPushManager :: PushManagerEnv,
+    -- | Connected clients over the socket
+    daemonClients :: SocketStore,
+    -- | A pool of worker threads
+    daemonWorkerThreads :: MVar [Worker.Thread],
+    -- | A multiplexer for push events
+    daemonSubscriptionManager :: SubscriptionManager Protocol.PushRequestId PushEvent,
+    -- | A thread handle for the subscription manager
+    daemonSubscriptionManagerThread :: MVar (Async ()),
+    -- | Logging env
+    daemonLogger :: Logger,
+    -- | Shutdown latch
+    daemonShutdownLatch :: ShutdownLatch,
+    -- | The PID of the daemon process
+    daemonPid :: ProcessID
+  }
+
+newtype Daemon a = Daemon
+  { unDaemon :: ReaderT DaemonEnv IO a
+  }
+  deriving newtype
+    ( Functor,
+      Applicative,
+      Monad,
+      MonadIO,
+      MonadReader DaemonEnv,
+      MonadUnliftIO,
+      MonadCatch,
+      MonadMask,
+      MonadThrow
+    )
+
+instance Katip.Katip Daemon where
+  getLogEnv = Log.getKatipLogEnv <$> asks daemonLogger
+  localLogEnv f (Daemon m) = Daemon (local (\s -> s {daemonLogger = Log.localLogEnv f (daemonLogger s)}) m)
+
+instance Katip.KatipContext Daemon where
+  getKatipContext = Log.getKatipContext <$> asks daemonLogger
+  localKatipContext f (Daemon m) = Daemon (local (\s -> s {daemonLogger = Log.localKatipContext f (daemonLogger s)}) m)
+
+  getKatipNamespace = Log.getKatipNamespace <$> asks daemonLogger
+  localKatipNamespace f (Daemon m) = Daemon (local (\s -> s {daemonLogger = Log.localKatipNamespace f (daemonLogger s)}) m)
+
+-- | Run a pre-configured daemon.
+runDaemon :: DaemonEnv -> Daemon a -> IO (Either DaemonError a)
+runDaemon env f = tryDaemon $ do
+  Log.withLogger (daemonLogger env) $ \logger -> do
+    let pushManagerEnv = (daemonPushManager env) {pmLogger = logger}
+    unDaemon f `runReaderT` env {daemonLogger = logger, daemonPushManager = pushManagerEnv}
+  where
+    tryDaemon :: IO a -> IO (Either DaemonError a)
+    tryDaemon a =
+      Safe.catch
+        (a >>= \v -> return (Right v))
+        (return . Left . wrapUnhandledErrors)
+
+    -- Wrap errors in a DaemonError
+    wrapUnhandledErrors :: SomeException -> DaemonError
+    wrapUnhandledErrors e | Just daemonError <- fromException @DaemonError e = daemonError
+    wrapUnhandledErrors e = DaemonUnhandledException (UnhandledException e)
diff --git a/src/Cachix/Daemon/Types/Error.hs b/src/Cachix/Daemon/Types/Error.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types/Error.hs
@@ -0,0 +1,65 @@
+module Cachix.Daemon.Types.Error
+  ( DaemonError (..),
+    UnhandledException (..),
+    HasExitCode (..),
+  )
+where
+
+import Cachix.Daemon.Types.EventLoop (EventLoopError (..))
+import Protolude
+
+-- | An error that can occur in the daemon.
+--
+-- These should not escape the main 'run' function.
+data DaemonError
+  = -- | The daemon shut down due to an unhandled exception
+    DaemonUnhandledException UnhandledException
+  | -- | There was an error in the daemon's event loop
+    DaemonEventLoopError EventLoopError
+  | -- | There was an error with the daemon's socket
+    DaemonSocketError
+  | -- | Failed to push some store paths
+    DaemonPushFailure
+  deriving stock (Show, Eq)
+
+instance Exception DaemonError where
+  displayException =
+    toS . \case
+      DaemonUnhandledException e ->
+        unlines ["Unhandled exception in the Cachix Daemon:", "", show e]
+      DaemonEventLoopError eventLoopError ->
+        unlines ["The daemon encountered an internal event loop error:", "", toS (displayException eventLoopError)]
+      DaemonSocketError -> "There was an error with the socket"
+      DaemonPushFailure -> "Failed to push some store paths"
+
+-- | A wrapper for SomeException that implements equality.
+newtype UnhandledException
+  = UnhandledException SomeException
+  deriving stock (Show)
+
+instance Eq UnhandledException where
+  UnhandledException e1 == UnhandledException e2 =
+    displayException e1 == displayException e2
+
+-- Convert to an error to an integer exit code
+
+class HasExitCode a where
+  toExitCode :: a -> ExitCode
+
+  toExitCodeInt :: a -> Int
+  toExitCodeInt x = case toExitCode x of
+    ExitSuccess -> 0
+    ExitFailure n -> n
+
+instance HasExitCode DaemonError where
+  toExitCode err = ExitFailure $ case err of
+    DaemonPushFailure -> 3
+    _remainingErrors -> 1
+
+instance (HasExitCode a) => HasExitCode (Maybe a) where
+  toExitCode Nothing = ExitSuccess
+  toExitCode (Just e) = toExitCode e
+
+instance (HasExitCode e) => HasExitCode (Either e a) where
+  toExitCode (Right _) = ExitSuccess
+  toExitCode (Left e) = toExitCode e
diff --git a/src/Cachix/Daemon/Types/EventLoop.hs b/src/Cachix/Daemon/Types/EventLoop.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types/EventLoop.hs
@@ -0,0 +1,29 @@
+module Cachix.Daemon.Types.EventLoop
+  ( EventLoop (..),
+    EventLoopError (..),
+    ExitLatch,
+  )
+where
+
+import Control.Concurrent.STM.TBMQueue (TBMQueue)
+import Protolude
+
+-- | An event loop that processes a queue of events.
+data EventLoop event output = EventLoop
+  { queue :: TBMQueue event,
+    exitLatch :: ExitLatch output
+  }
+
+-- | An exit latch is a semaphore that signals the event loop to exit.
+-- The exit code should be returned by the 'EventLoop'.
+type ExitLatch a = MVar (Either EventLoopError a)
+
+data EventLoopError
+  = EventLoopClosed
+  | EventLoopFull
+  deriving stock (Show, Eq)
+
+instance Exception EventLoopError where
+  displayException = \case
+    EventLoopClosed -> "The event loop is closed"
+    EventLoopFull -> "The event loop is full"
diff --git a/src/Cachix/Daemon/Types/Log.hs b/src/Cachix/Daemon/Types/Log.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types/Log.hs
@@ -0,0 +1,32 @@
+module Cachix.Daemon.Types.Log
+  ( LogLevel (..),
+    Logger (..),
+  )
+where
+
+import Katip qualified
+import Protolude
+
+-- | The log level to use for logging
+--
+-- TODO: reuse in deploy agent
+data LogLevel
+  = Debug
+  | Info
+  | Warning
+  | Error
+  deriving stock (Eq, Ord, Show)
+
+data Logger = Logger
+  { -- | An optional handle to output logs to.
+    -- Defaults to stdout.
+    logHandle :: Maybe Handle,
+    -- | The log level to use for logging
+    logLevel :: LogLevel,
+    -- | Logger namespace
+    logKNamespace :: Katip.Namespace,
+    -- | Logger context
+    logKContext :: Katip.LogContexts,
+    -- | Logger env
+    logKLogEnv :: Katip.LogEnv
+  }
diff --git a/src/Cachix/Daemon/Types/PushEvent.hs b/src/Cachix/Daemon/Types/PushEvent.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types/PushEvent.hs
@@ -0,0 +1,42 @@
+module Cachix.Daemon.Types.PushEvent
+  ( PushEvent (..),
+    PushEventMessage (..),
+    PushRetryStatus (..),
+    newPushRetryStatus,
+  )
+where
+
+import Cachix.Daemon.Protocol qualified as Protocol
+import Control.Retry (RetryStatus (..))
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Time (UTCTime)
+import Protolude
+
+data PushEvent = PushEvent
+  { -- TODO: newtype a monotonic clock
+    eventTimestamp :: UTCTime,
+    eventPushId :: Protocol.PushRequestId,
+    eventMessage :: PushEventMessage
+  }
+  deriving stock (Eq, Generic, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+instance Ord PushEvent where
+  compare = compare `on` eventTimestamp
+
+data PushEventMessage
+  = PushStarted
+  | PushStorePathAttempt FilePath Int64 PushRetryStatus
+  | PushStorePathProgress FilePath Int64 Int64
+  | PushStorePathDone FilePath
+  | PushStorePathFailed FilePath Text
+  | PushFinished
+  deriving stock (Eq, Generic, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+data PushRetryStatus = PushRetryStatus {retryCount :: Int}
+  deriving stock (Eq, Generic, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+newPushRetryStatus :: RetryStatus -> PushRetryStatus
+newPushRetryStatus RetryStatus {..} = PushRetryStatus {retryCount = rsIterNumber}
diff --git a/src/Cachix/Daemon/Types/PushManager.hs b/src/Cachix/Daemon/Types/PushManager.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types/PushManager.hs
@@ -0,0 +1,139 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+
+module Cachix.Daemon.Types.PushManager
+  ( PushManagerEnv (..),
+    PushManager (..),
+    PushJobStore,
+    PushJob (..),
+    JobStatus (..),
+    JobStats (..),
+    PushResult (..),
+    OnPushEvent,
+    Task (..),
+    TimeoutOptions (..),
+  )
+where
+
+import Cachix.Client.Push (PushParams)
+import Cachix.Daemon.Log qualified as Log
+import Cachix.Daemon.Protocol qualified as Protocol
+import Cachix.Daemon.Types.Log (Logger)
+import Cachix.Daemon.Types.PushEvent (PushEvent (..))
+import Control.Concurrent.STM.TBMQueue
+import Control.Concurrent.STM.TVar
+import Control.Monad.Catch
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Data.HashMap.Strict (HashMap)
+import Data.Time (UTCTime)
+import Katip qualified
+import Protolude
+
+data Task
+  = ResolveClosure Protocol.PushRequestId
+  | PushStorePath FilePath
+
+type PushJobStore = TVar (HashMap Protocol.PushRequestId PushJob)
+
+type StorePathIndex = TVar (HashMap FilePath [Protocol.PushRequestId])
+
+-- TODO: a lot of the logic surrounding deduping, search, and job tracking could be replaced by sqlite.
+-- sqlite can run in-memory if we don't need persistence.
+-- If we do, then we can we get stop/resume for free.
+data PushManagerEnv = PushManagerEnv
+  { pmPushParams :: PushParams PushManager (),
+    -- | A store of push jobs indexed by a PushRequestId.
+    pmPushJobs :: PushJobStore,
+    -- | A mapping of store paths to push requests.
+    -- Used when deduplicating pushes.
+    pmStorePathIndex :: StorePathIndex,
+    -- | FIFO queue of push tasks.
+    pmTaskQueue :: TBMQueue Task,
+    -- | A semaphore to control task concurrency.
+    pmTaskSemaphore :: QSem,
+    -- | A callback for push events.
+    pmOnPushEvent :: OnPushEvent,
+    -- | The timestamp of the most recent event. This is used to track activity internally.
+    pmLastEventTimestamp :: TVar UTCTime,
+    -- | The number of pending (uncompleted) jobs.
+    pmPendingJobCount :: TVar Int,
+    pmLogger :: Logger
+  }
+
+type OnPushEvent = Protocol.PushRequestId -> PushEvent -> IO ()
+
+newtype PushManager a = PushManager {unPushManager :: ReaderT PushManagerEnv IO a}
+  deriving newtype
+    ( Functor,
+      Applicative,
+      Monad,
+      MonadIO,
+      MonadUnliftIO,
+      MonadReader PushManagerEnv,
+      MonadCatch,
+      MonadMask,
+      MonadThrow,
+      Alternative,
+      MonadPlus
+    )
+
+instance Katip.Katip PushManager where
+  getLogEnv = Log.getKatipLogEnv <$> asks pmLogger
+  localLogEnv f (PushManager m) = PushManager (local (\s -> s {pmLogger = Log.localLogEnv f (pmLogger s)}) m)
+
+instance Katip.KatipContext PushManager where
+  getKatipContext = Log.getKatipContext <$> asks pmLogger
+  localKatipContext f (PushManager m) = PushManager (local (\s -> s {pmLogger = Log.localKatipContext f (pmLogger s)}) m)
+
+  getKatipNamespace = Log.getKatipNamespace <$> asks pmLogger
+  localKatipNamespace f (PushManager m) = PushManager (local (\s -> s {pmLogger = Log.localKatipNamespace f (pmLogger s)}) m)
+
+data JobStatus = Queued | Running | Completed | Failed
+  deriving stock (Eq, Show)
+
+-- | A push request that has been queued for processing.
+data PushJob = PushJob
+  { -- | A unique identifier for this push request.
+    pushId :: Protocol.PushRequestId,
+    -- | The original push request.
+    pushRequest :: Protocol.PushRequest,
+    -- | The current status of the push job.
+    pushStatus :: JobStatus,
+    -- | Paths that need to be pushed.
+    pushQueue :: Set FilePath,
+    -- | Track whether paths were pushed, skipped, or failed to push.
+    pushResult :: PushResult,
+    -- | Timing stats for the push job.
+    pushStats :: JobStats
+  }
+  deriving stock (Eq, Show)
+
+data PushResult = PushResult
+  { prPushedPaths :: Set FilePath,
+    prFailedPaths :: Set FilePath,
+    prSkippedPaths :: Set FilePath
+  }
+  deriving stock (Eq, Show)
+
+instance Semigroup PushResult where
+  PushResult a1 b1 c1 <> PushResult a2 b2 c2 =
+    PushResult (a1 <> a2) (b1 <> b2) (c1 <> c2)
+
+instance Monoid PushResult where
+  mempty = PushResult mempty mempty mempty
+
+data JobStats = JobStats
+  { jsCreatedAt :: UTCTime,
+    jsStartedAt :: Maybe UTCTime,
+    jsCompletedAt :: Maybe UTCTime
+  }
+  deriving stock (Eq, Show)
+
+data TimeoutOptions = TimeoutOptions
+  { -- | The maximum time to wait in seconds.
+    toTimeout :: Float,
+    -- | The interval at which to check the timeout condition.
+    toPollingInterval :: Float
+  }
+  deriving stock (Eq, Show)
diff --git a/src/Cachix/Daemon/Types/SocketStore.hs b/src/Cachix/Daemon/Types/SocketStore.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Types/SocketStore.hs
@@ -0,0 +1,23 @@
+module Cachix.Daemon.Types.SocketStore (Socket (..), SocketId, SocketStore (..)) where
+
+import Control.Concurrent.STM.TVar (TVar)
+import Data.HashMap.Strict (HashMap)
+import Data.UUID (UUID)
+import Network.Socket qualified as Network (Socket)
+import Protolude
+
+data Socket = Socket
+  { socketId :: SocketId,
+    socket :: Network.Socket,
+    handlerThread :: Async ()
+  }
+
+instance Eq Socket where
+  (==) = (==) `on` socketId
+
+instance Ord Socket where
+  compare = comparing socketId
+
+type SocketId = UUID
+
+newtype SocketStore = SocketStore (TVar (HashMap SocketId Socket))
diff --git a/src/Cachix/Daemon/Worker.hs b/src/Cachix/Daemon/Worker.hs
new file mode 100644
--- /dev/null
+++ b/src/Cachix/Daemon/Worker.hs
@@ -0,0 +1,62 @@
+module Cachix.Daemon.Worker
+  ( startWorkers,
+    stopWorkers,
+    startWorker,
+    stopWorker,
+    Immortal.Thread,
+  )
+where
+
+import Control.Concurrent.Async qualified as Async
+import Control.Concurrent.STM.TBMQueue
+import Control.Immortal qualified as Immortal
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Katip qualified
+import Protolude
+
+startWorkers ::
+  (MonadUnliftIO m, Katip.KatipContext m) =>
+  Int ->
+  TBMQueue a ->
+  (a -> m ()) ->
+  m [Immortal.Thread]
+startWorkers numWorkers queue f = do
+  replicateM numWorkers (startWorker queue f)
+
+startWorker ::
+  (MonadUnliftIO m, Katip.KatipContext m) =>
+  TBMQueue a ->
+  (a -> m ()) ->
+  m Immortal.Thread
+startWorker queue f = do
+  Immortal.createWithLabel "worker" $ \thread -> do
+    Katip.katipAddNamespace "worker" $
+      Immortal.onUnexpectedFinish thread logWorkerException (runWorker queue f)
+
+stopWorkers :: (Katip.KatipContext m) => [Immortal.Thread] -> m ()
+stopWorkers workers = do
+  Katip.logFM Katip.DebugS "Waiting for workers to finish..."
+  liftIO $ Async.mapConcurrently_ stopWorker workers
+  Katip.logFM Katip.DebugS "Workers finished."
+
+stopWorker :: (MonadIO m) => Immortal.Thread -> m ()
+stopWorker worker = liftIO $ do
+  Immortal.mortalize worker
+  Immortal.wait worker
+
+logWorkerException :: (Exception e, Katip.KatipContext m) => Either e () -> m ()
+logWorkerException (Left err) =
+  Katip.logFM Katip.ErrorS $ Katip.ls (toS $ displayException err :: Text)
+logWorkerException _ = return ()
+
+runWorker :: (MonadIO m) => TBMQueue a -> (a -> m ()) -> m ()
+runWorker queue f = loop
+  where
+    loop = do
+      mres <- liftIO $ atomically $ readTBMQueue queue
+
+      case mres of
+        Nothing -> return ()
+        Just job -> do
+          f job
+          loop
diff --git a/src/Cachix/Deploy/Activate.hs b/src/Cachix/Deploy/Activate.hs
--- a/src/Cachix/Deploy/Activate.hs
+++ b/src/Cachix/Deploy/Activate.hs
@@ -3,27 +3,27 @@
 
 module Cachix.Deploy.Activate where
 
-import qualified Cachix.API.WebSocketSubprotocol as WSS
-import qualified Cachix.Client.InstallationMode as InstallationMode
-import qualified Cachix.Client.NetRc as NetRc
+import Cachix.API.WebSocketSubprotocol qualified as WSS
+import Cachix.Client.InstallationMode qualified as InstallationMode
+import Cachix.Client.NetRc qualified as NetRc
 import Cachix.Client.URI (URI)
-import qualified Cachix.Client.URI as URI
-import qualified Cachix.Deploy.Log as Log
-import qualified Cachix.Types.BinaryCache as BinaryCache
+import Cachix.Client.URI qualified as URI
+import Cachix.Deploy.Log qualified as Log
+import Cachix.Types.BinaryCache qualified as BinaryCache
 import Cachix.Types.Permission (Permission (..))
-import qualified Data.Aeson as Aeson
+import Data.Aeson qualified as Aeson
 #if MIN_VERSION_aeson(2,0,0)
 import qualified Data.Aeson.KeyMap as HM
 #else
 import qualified Data.HashMap.Strict as HM
 #endif
-import qualified Data.Conduit.Combinators as Conduit
-import qualified Data.Conduit.Process as Conduit
-import qualified Data.Vector as Vector
+import Data.Conduit.Combinators qualified as Conduit
+import Data.Conduit.Process qualified as Conduit
+import Data.Vector qualified as Vector
 import Protolude hiding (log, toS)
 import Protolude.Conv (toS)
 import Servant.Auth.Client (Token (..))
-import qualified System.Directory as Directory
+import System.Directory qualified as Directory
 import System.FilePath ((</>))
 import System.IO.Temp (withSystemTempDirectory)
 import System.Process
diff --git a/src/Cachix/Deploy/ActivateCommand.hs b/src/Cachix/Deploy/ActivateCommand.hs
--- a/src/Cachix/Deploy/ActivateCommand.hs
+++ b/src/Cachix/Deploy/ActivateCommand.hs
@@ -1,38 +1,38 @@
 module Cachix.Deploy.ActivateCommand where
 
-import qualified Cachix.API.Deploy.V1 as API.V1
-import qualified Cachix.API.Deploy.V2 as API.V2
+import Cachix.API.Deploy.V1 qualified as API.V1
+import Cachix.API.Deploy.V2 qualified as API.V2
 import Cachix.API.Error (escalate)
-import qualified Cachix.API.WebSocketSubprotocol as WSS
-import qualified Cachix.Client.Config as Config
-import qualified Cachix.Client.Env as Env
-import qualified Cachix.Client.Retry as Retry
+import Cachix.API.WebSocketSubprotocol qualified as WSS
+import Cachix.Client.Config qualified as Config
+import Cachix.Client.Env qualified as Env
+import Cachix.Client.Retry qualified as Retry
 import Cachix.Client.Servant (deployClientV1, deployClientV2)
-import qualified Cachix.Client.URI as URI
+import Cachix.Client.URI qualified as URI
 import Cachix.Client.Version (versionNumber)
-import qualified Cachix.Deploy.OptionsParser as DeployOptions
-import qualified Cachix.Deploy.Websocket as WebSocket
+import Cachix.Deploy.OptionsParser qualified as DeployOptions
+import Cachix.Deploy.Websocket qualified as WebSocket
 import Cachix.Types.Deploy (Deploy)
-import qualified Cachix.Types.Deploy as Types
-import qualified Cachix.Types.DeployResponse as DeployResponse
-import qualified Cachix.Types.Deployment as Deployment
-import qualified Control.Concurrent.Async as Async
-import qualified Data.Aeson as Aeson
+import Cachix.Types.Deploy qualified as Types
+import Cachix.Types.DeployResponse qualified as DeployResponse
+import Cachix.Types.Deployment qualified as Deployment
+import Control.Concurrent.Async qualified as Async
+import Data.Aeson qualified as Aeson
 import Data.HashMap.Strict (filterWithKey)
-import qualified Data.HashMap.Strict as HM
-import qualified Data.Text as Text
-import qualified Data.Text.IO as Text
+import Data.HashMap.Strict qualified as HM
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text
 import Data.UUID (UUID)
-import qualified Data.UUID as UUID
-import qualified Network.WebSockets as WS
+import Data.UUID qualified as UUID
+import Network.WebSockets qualified as WS
 import Protolude hiding (toS)
 import Protolude.Conv
 import Servant.Auth.Client (Token (..))
 import Servant.Client.Streaming (ClientEnv, runClientM)
 import Servant.Conduit ()
 import System.Environment (getEnv)
-import qualified Text.Megaparsec as Parse
-import qualified Text.Megaparsec.Char as Parse
+import Text.Megaparsec qualified as Parse
+import Text.Megaparsec.Char qualified as Parse
 
 run :: Env.Env -> DeployOptions.ActivateOptions -> IO ()
 run env DeployOptions.ActivateOptions {DeployOptions.payloadPath, DeployOptions.agents, DeployOptions.deployAsync} = do
diff --git a/src/Cachix/Deploy/Agent.hs b/src/Cachix/Deploy/Agent.hs
--- a/src/Cachix/Deploy/Agent.hs
+++ b/src/Cachix/Deploy/Agent.hs
@@ -3,39 +3,39 @@
 
 module Cachix.Deploy.Agent where
 
-import qualified Cachix.API.WebSocketSubprotocol as WSS
-import qualified Cachix.Client.Config as Config
+import Cachix.API.WebSocketSubprotocol qualified as WSS
+import Cachix.Client.Config qualified as Config
 import Cachix.Client.URI (URI)
-import qualified Cachix.Client.URI as URI
+import Cachix.Client.URI qualified as URI
 import Cachix.Client.Version (versionNumber)
-import qualified Cachix.Deploy.Deployment as Deployment
-import qualified Cachix.Deploy.Lock as Lock
-import qualified Cachix.Deploy.Log as Log
-import qualified Cachix.Deploy.OptionsParser as CLI
-import qualified Cachix.Deploy.StdinProcess as StdinProcess
-import qualified Cachix.Deploy.Websocket as WebSocket
-import qualified Control.Concurrent.Async as Async
+import Cachix.Deploy.Deployment qualified as Deployment
+import Cachix.Deploy.Lock qualified as Lock
+import Cachix.Deploy.Log qualified as Log
+import Cachix.Deploy.OptionsParser qualified as CLI
+import Cachix.Deploy.StdinProcess qualified as StdinProcess
+import Cachix.Deploy.Websocket qualified as WebSocket
+import Control.Concurrent.Async qualified as Async
 import Control.Concurrent.Extra (once)
-import qualified Control.Concurrent.MVar as MVar
+import Control.Concurrent.MVar qualified as MVar
 import Control.Exception.Safe (onException)
-import qualified Control.Exception.Safe as Safe
-import qualified Control.Retry as Retry
-import qualified Data.Aeson as Aeson
+import Control.Exception.Safe qualified as Safe
+import Control.Retry qualified as Retry
+import Data.Aeson qualified as Aeson
 import Data.IORef
 import Data.String (String)
-import qualified Katip as K
+import Katip qualified as K
 import Paths_cachix (getBinDir)
 import Protolude hiding (onException, toS, (<.>))
 import Protolude.Conv
-import qualified System.Directory as Directory
+import System.Directory qualified as Directory
 import System.Environment (getEnv, lookupEnv)
 import System.FilePath ((<.>), (</>))
-import qualified System.Posix.Files as Posix.Files
-import qualified System.Posix.Process as Posix
-import qualified System.Posix.Signals as Signals
-import qualified System.Posix.Types as Posix
-import qualified System.Posix.User as Posix.User
-import qualified System.Timeout as Timeout
+import System.Posix.Files qualified as Posix.Files
+import System.Posix.Process qualified as Posix
+import System.Posix.Signals qualified as Signals
+import System.Posix.Types qualified as Posix
+import System.Posix.User qualified as Posix.User
+import System.Timeout qualified as Timeout
 
 type ServiceWebSocket = WebSocket.WebSocket (WSS.Message WSS.AgentCommand) (WSS.Message WSS.BackendCommand)
 
diff --git a/src/Cachix/Deploy/Deployment.hs b/src/Cachix/Deploy/Deployment.hs
--- a/src/Cachix/Deploy/Deployment.hs
+++ b/src/Cachix/Deploy/Deployment.hs
@@ -1,9 +1,9 @@
 module Cachix.Deploy.Deployment where
 
-import qualified Cachix.API.WebSocketSubprotocol as WSS
+import Cachix.API.WebSocketSubprotocol qualified as WSS
 import Cachix.Client.URI as Cachix
-import qualified Cachix.Deploy.Log as Log
-import qualified Data.Aeson as Aeson
+import Cachix.Deploy.Log qualified as Log
+import Data.Aeson qualified as Aeson
 import Protolude
 
 -- | Everything required for the standalone deployment binary to complete a
diff --git a/src/Cachix/Deploy/Lock.hs b/src/Cachix/Deploy/Lock.hs
--- a/src/Cachix/Deploy/Lock.hs
+++ b/src/Cachix/Deploy/Lock.hs
@@ -10,9 +10,9 @@
   )
 where
 
-import qualified Lukko as Lock
+import Lukko qualified as Lock
 import Protolude hiding ((<.>))
-import qualified System.Directory as Directory
+import System.Directory qualified as Directory
 import System.FilePath ((<.>), (</>))
 import System.Posix (getProcessID)
 import System.Posix.Types (CPid (..))
diff --git a/src/Cachix/Deploy/Log.hs b/src/Cachix/Deploy/Log.hs
--- a/src/Cachix/Deploy/Log.hs
+++ b/src/Cachix/Deploy/Log.hs
@@ -2,17 +2,17 @@
 
 module Cachix.Deploy.Log where
 
-import qualified Cachix.API.WebSocketSubprotocol as WSS
-import qualified Control.Concurrent.STM.TMQueue as TMQueue
+import Cachix.API.WebSocketSubprotocol qualified as WSS
+import Control.Concurrent.STM.TMQueue qualified as TMQueue
 import Control.Exception.Safe (MonadMask, bracket)
-import qualified Data.Aeson as Aeson
+import Data.Aeson qualified as Aeson
 import Data.Conduit ((.|))
-import qualified Data.Conduit as Conduit
-import qualified Data.Conduit.Combinators as Conduit
-import qualified Data.Conduit.TQueue as Conduit
+import Data.Conduit qualified as Conduit
+import Data.Conduit.Combinators qualified as Conduit
+import Data.Conduit.TQueue qualified as Conduit
 import Data.Time.Clock (getCurrentTime)
-import qualified Katip
-import qualified Network.WebSockets as WS
+import Katip qualified
+import Network.WebSockets qualified as WS
 import Protolude hiding (bracket, toS)
 import Protolude.Conv (toS)
 
diff --git a/src/Cachix/Deploy/Websocket.hs b/src/Cachix/Deploy/Websocket.hs
--- a/src/Cachix/Deploy/Websocket.hs
+++ b/src/Cachix/Deploy/Websocket.hs
@@ -3,38 +3,38 @@
 -- | A high-level, multiple reader, single writer interface for Websocket clients.
 module Cachix.Deploy.Websocket where
 
-import qualified Cachix.Client.Retry as Retry
-import qualified Cachix.Client.URI as URI
+import Cachix.Client.Retry qualified as Retry
+import Cachix.Client.URI qualified as URI
 import Cachix.Client.Version (versionNumber)
-import qualified Cachix.Deploy.Log as Log
-import qualified Cachix.Deploy.WebsocketPong as WebsocketPong
-import qualified Control.Concurrent.Async as Async
-import qualified Control.Concurrent.MVar as MVar
-import qualified Control.Concurrent.STM.TBMQueue as TBMQueue
-import qualified Control.Concurrent.STM.TMChan as TMChan
+import Cachix.Deploy.Log qualified as Log
+import Cachix.Deploy.WebsocketPong qualified as WebsocketPong
+import Control.Concurrent.Async qualified as Async
+import Control.Concurrent.MVar qualified as MVar
+import Control.Concurrent.STM.TBMQueue qualified as TBMQueue
+import Control.Concurrent.STM.TMChan qualified as TMChan
 import Control.Exception.Safe (Handler (..), MonadMask, isSyncException)
-import qualified Control.Exception.Safe as Safe
-import qualified Control.Retry as Retry
-import qualified Data.Aeson as Aeson
-import qualified Data.ByteString as BS
+import Control.Exception.Safe qualified as Safe
+import Control.Retry qualified as Retry
+import Data.Aeson qualified as Aeson
+import Data.ByteString qualified as BS
 import Data.Conduit ((.|))
-import qualified Data.Conduit as Conduit
-import qualified Data.Conduit.Combinators as Conduit
-import qualified Data.Conduit.TQueue as Conduit
-import qualified Data.IORef as IORef
+import Data.Conduit qualified as Conduit
+import Data.Conduit.Combinators qualified as Conduit
+import Data.Conduit.TQueue qualified as Conduit
+import Data.IORef qualified as IORef
 import Data.String (String)
 import Data.String.Here (iTrim)
-import qualified Data.Text as Text
-import qualified Data.Time.Clock as Time
-import qualified Katip as K
-import qualified Network.HTTP.Simple as HTTP
-import qualified Network.WebSockets as WS
-import qualified Network.WebSockets.Connection as WS.Connection
+import Data.Text qualified as Text
+import Data.Time.Clock qualified as Time
+import Katip qualified as K
+import Network.HTTP.Simple qualified as HTTP
+import Network.WebSockets qualified as WS
+import Network.WebSockets.Connection qualified as WS.Connection
 import Protolude hiding (Handler, toS)
 import Protolude.Conv
-import qualified System.Info
-import qualified System.Timeout as Timeout
-import qualified Wuss
+import System.Info qualified
+import System.Timeout qualified as Timeout
+import Wuss qualified
 
 -- | A reliable WebSocket connection that can be run ergonomically in a
 -- separate thread.
diff --git a/src/Cachix/Deploy/WebsocketPong.hs b/src/Cachix/Deploy/WebsocketPong.hs
--- a/src/Cachix/Deploy/WebsocketPong.hs
+++ b/src/Cachix/Deploy/WebsocketPong.hs
@@ -3,9 +3,9 @@
 module Cachix.Deploy.WebsocketPong where
 
 import Data.IORef (IORef)
-import qualified Data.IORef as IORef
+import Data.IORef qualified as IORef
 import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
-import qualified Network.WebSockets as WS
+import Network.WebSockets qualified as WS
 import Protolude
 
 type LastPongState = IORef UTCTime
diff --git a/src/Data/Conduit/ByteString.hs b/src/Data/Conduit/ByteString.hs
--- a/src/Data/Conduit/ByteString.hs
+++ b/src/Data/Conduit/ByteString.hs
@@ -1,7 +1,7 @@
 module Data.Conduit.ByteString where
 
 import Conduit (MonadUnliftIO)
-import qualified Data.ByteString as BS
+import Data.ByteString qualified as BS
 import Data.ByteString.Internal (ByteString (PS), mallocByteString)
 import Data.ByteString.Unsafe (unsafeIndex)
 import Data.Conduit (ConduitT, await, yield, (.|))
diff --git a/test/Daemon/PostBuildHookSpec.hs b/test/Daemon/PostBuildHookSpec.hs
--- a/test/Daemon/PostBuildHookSpec.hs
+++ b/test/Daemon/PostBuildHookSpec.hs
@@ -1,9 +1,9 @@
 module Daemon.PostBuildHookSpec where
 
-import Cachix.Client.Daemon.PostBuildHook
+import Cachix.Daemon.PostBuildHook
 import Data.String
 import Protolude
-import qualified System.Environment as System
+import System.Environment qualified as System
 import Test.Hspec
 
 spec :: Spec
diff --git a/test/Daemon/ProtocolSpec.hs b/test/Daemon/ProtocolSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Daemon/ProtocolSpec.hs
@@ -0,0 +1,23 @@
+module Daemon.ProtocolSpec where
+
+import Cachix.Daemon.Protocol (splitMessages)
+import Protolude
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "split messages" $ do
+    forM_ testCases $ \(input, output) ->
+      it (show input) $
+        splitMessages input `shouldBe` output
+
+testCases :: [(ByteString, ([ByteString], ByteString))]
+testCases =
+  [ ("hello\n", (["hello"], "")),
+    ("hello\nthere\n", (["hello", "there"], "")),
+    ("hello\nthere", (["hello"], "there")),
+    ("", ([], "")),
+    ("\n", ([], "")),
+    ("\n\n", ([], "")),
+    ("\nhello\n", (["hello"], ""))
+  ]
diff --git a/test/Daemon/PushManagerSpec.hs b/test/Daemon/PushManagerSpec.hs
--- a/test/Daemon/PushManagerSpec.hs
+++ b/test/Daemon/PushManagerSpec.hs
@@ -1,19 +1,31 @@
 module Daemon.PushManagerSpec where
 
-import qualified Cachix.Client.Daemon.Log as Log
-import qualified Cachix.Client.Daemon.Protocol as Protocol
-import Cachix.Client.Daemon.PushManager
-import qualified Cachix.Client.Daemon.PushManager.PushJob as PushJob
-import Cachix.Client.Daemon.Types.PushManager
+import Cachix.Client.Env qualified as Env
 import Cachix.Client.OptionsParser (defaultPushOptions)
+import Cachix.Client.Push (PushSecret (PushToken))
+import Cachix.Daemon.Log qualified as Log
+import Cachix.Daemon.Protocol qualified as Protocol
+import Cachix.Daemon.Push qualified as Daemon.Push
+import Cachix.Daemon.PushManager
+import Cachix.Daemon.PushManager.PushJob qualified as PushJob
+import Cachix.Daemon.Types.PushManager
+import Cachix.Types.BinaryCache qualified as BinaryCache
+import Cachix.Types.Permission (Permission (Write))
 import Control.Concurrent.Async (concurrently_)
 import Control.Concurrent.STM.TVar
+import Control.Monad (fail)
 import Control.Retry (defaultRetryStatus)
-import qualified Data.Set as Set
+import Data.Set qualified as Set
 import Data.Time (getCurrentTime)
+import Hercules.CNix qualified as CNix
 import Protolude
+import Servant.Auth.Client (Token (Token))
+import System.IO.Temp (withSystemTempDirectory)
 import Test.Hspec
 
+instance MonadFail PushManager where
+  fail msg = liftIO (expectationFailure msg) >> mzero
+
 spec :: Spec
 spec = do
   describe "push job" $ do
@@ -79,19 +91,17 @@
   describe "push manager" $ do
     it "queues push jobs " $ inPushManager $ do
       let request = Protocol.PushRequest {Protocol.storePaths = ["foo", "bar"]}
-      pushId <- addPushJob request
-      mpushJob <- lookupPushJob pushId
+      Just pushId <- addPushJob request
+      Just pushJob <- lookupPushJob pushId
       liftIO $ do
-        mpushJob `shouldSatisfy` isJust
-        for_ mpushJob $ \pushJob -> do
-          PushJob.pushId pushJob `shouldBe` pushId
-          PushJob.pushRequest pushJob `shouldBe` request
+        PushJob.pushId pushJob `shouldBe` pushId
+        PushJob.pushRequest pushJob `shouldBe` request
 
     it "manages the lifecycle of a push job" $ inPushManager $ do
       let paths = ["bar", "foo"]
 
       let pushRequest = Protocol.PushRequest {Protocol.storePaths = paths}
-      pushId <- addPushJob pushRequest
+      Just pushId <- addPushJob pushRequest
 
       let pathSet = Set.fromList paths
           closure = PushJob.ResolvedClosure pathSet pathSet
@@ -116,16 +126,13 @@
             }
 
     describe "graceful shutdown" $ do
-      it "shuts down with no jobs" $ do
-        logger <- Log.new "daemon" Nothing Log.Debug
-        pm <- newPushManagerEnv defaultPushOptions logger mempty
-        stopPushManager timeoutOptions pm
+      it "shuts down with no jobs" $
+        withPushManager $
+          stopPushManager timeoutOptions
 
-      it "shuts down after jobs complete" $ do
+      it "shuts down after jobs complete" $ withPushManager $ \pm -> do
         let paths = ["foo"]
         let longTimeoutOptions = TimeoutOptions {toTimeout = 10.0, toPollingInterval = 0.1}
-        logger <- Log.new "daemon" Nothing Log.Debug
-        pm <- newPushManagerEnv defaultPushOptions logger mempty
 
         _ <- runPushManager pm $ do
           let request = Protocol.PushRequest {Protocol.storePaths = paths}
@@ -135,14 +142,13 @@
           runPushManager pm $
             for_ paths pushStorePathDone
 
-      it "shuts down on job stall" $ do
-        logger <- Log.new "daemon" Nothing Log.Debug
-        pm <- newPushManagerEnv defaultPushOptions logger mempty
-        _ <- runPushManager pm $ do
-          let request = Protocol.PushRequest {Protocol.storePaths = ["foo"]}
-          addPushJob request
+      it "shuts down on job stall" $
+        withPushManager $ \pm -> do
+          _ <- runPushManager pm $ do
+            let request = Protocol.PushRequest {Protocol.storePaths = ["foo"]}
+            addPushJob request
 
-        stopPushManager timeoutOptions pm
+          stopPushManager timeoutOptions pm
 
   describe "STM" $
     describe "timeout" $ do
@@ -152,11 +158,36 @@
 
 withPushManager :: (PushManagerEnv -> IO a) -> IO a
 withPushManager f = do
-  logger <- liftIO $ Log.new "daemon" Nothing Log.Debug
-  newPushManagerEnv defaultPushOptions logger mempty >>= f
+  CNix.init
+  withTempStore $ \store -> do
+    logger <- liftIO $ Log.new "daemon" Nothing Log.Debug
+    cachixOptions <- Env.defaultCachixOptions
+    clientEnv <- Env.createClientEnv cachixOptions
+    let binaryCache = newBinaryCache "test"
+        pushSecret = PushToken (Token "test")
+        pushOptions = defaultPushOptions
+        pushParams = Daemon.Push.newPushParams store clientEnv binaryCache pushSecret pushOptions
+    newPushManagerEnv pushOptions pushParams mempty logger >>= f
 
 inPushManager :: PushManager a -> IO a
 inPushManager f = withPushManager (`runPushManager` f)
+
+withTempStore :: (CNix.Store -> IO a) -> IO a
+withTempStore f =
+  withSystemTempDirectory "cnix-test-store" $ \d ->
+    CNix.withStoreFromURI (toS d) f
+
+newBinaryCache :: BinaryCache.BinaryCacheName -> BinaryCache.BinaryCache
+newBinaryCache name =
+  BinaryCache.BinaryCache
+    { BinaryCache.name = name,
+      BinaryCache.uri = "https://" <> name <> ".cachix.org",
+      BinaryCache.isPublic = True,
+      BinaryCache.publicSigningKeys = [],
+      BinaryCache.githubUsername = "",
+      BinaryCache.permission = Write,
+      BinaryCache.preferredCompressionMethod = BinaryCache.ZSTD
+    }
 
 timeoutOptions :: TimeoutOptions
 timeoutOptions = TimeoutOptions {toTimeout = 0.2, toPollingInterval = 0.1}
diff --git a/test/DeploySpec.hs b/test/DeploySpec.hs
--- a/test/DeploySpec.hs
+++ b/test/DeploySpec.hs
@@ -1,11 +1,11 @@
 module DeploySpec where
 
-import qualified Cachix.Client.Config as Config
+import Cachix.Client.Config qualified as Config
 import Cachix.Deploy.Agent (Agent (..), mkAgent, waitForAgent)
 import Cachix.Deploy.Lock (withTryLock, withTryLockAndPid)
-import qualified Cachix.Deploy.Log as Log
-import qualified Cachix.Deploy.OptionsParser as CLI
-import qualified Control.Retry as Retry
+import Cachix.Deploy.Log qualified as Log
+import Cachix.Deploy.OptionsParser qualified as CLI
+import Control.Retry qualified as Retry
 import Protolude
 import System.IO.Temp (withSystemTempDirectory)
 import Test.Hspec
diff --git a/test/InstallationModeSpec.hs b/test/InstallationModeSpec.hs
--- a/test/InstallationModeSpec.hs
+++ b/test/InstallationModeSpec.hs
@@ -1,7 +1,7 @@
 module InstallationModeSpec where
 
 import Cachix.Client.InstallationMode
-import qualified Cachix.Client.NixConf as NixConf
+import Cachix.Client.NixConf qualified as NixConf
 import Protolude
 import Test.Hspec
 
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -1,7 +1,7 @@
 module Main where
 
 import Protolude
-import qualified Spec
+import Spec qualified
 import Test.Hspec.Runner
 
 main :: IO ()
diff --git a/test/NetRcSpec.hs b/test/NetRcSpec.hs
--- a/test/NetRcSpec.hs
+++ b/test/NetRcSpec.hs
@@ -1,6 +1,6 @@
 module NetRcSpec where
 
-import qualified Cachix.Client.NetRc as NetRc
+import Cachix.Client.NetRc qualified as NetRc
 import Cachix.Types.BinaryCache (BinaryCache (..), CompressionMethod (..))
 import Cachix.Types.Permission (Permission (..))
 import Protolude
diff --git a/test/URISpec.hs b/test/URISpec.hs
--- a/test/URISpec.hs
+++ b/test/URISpec.hs
@@ -1,13 +1,13 @@
 module URISpec where
 
-import qualified Cachix.Client.URI as URI
-import qualified Data.Aeson as Aeson
-import qualified Data.ByteString.Lazy as BL
+import Cachix.Client.URI qualified as URI
+import Data.Aeson qualified as Aeson
+import Data.ByteString.Lazy qualified as BL
 import Data.Either.Extra
-import qualified Dhall
-import qualified Dhall.Core
+import Dhall qualified
+import Dhall.Core qualified
 import Protolude
-import qualified Servant.Client.Core as Servant
+import Servant.Client.Core qualified as Servant
 import Test.Hspec
 
 secureScheme :: ByteString
