packages feed

cachix (empty) → 0.1.0.0

raw patch · 20 files changed

+1391/−0 lines, 20 filesdep +asyncdep +basedep +base16-bytestringsetup-changed

Dependencies added: async, base, base16-bytestring, base64-bytestring, bifunctors, bytestring, cachix, cachix-api, conduit, conduit-combinators, conduit-extra, cookie, cryptonite, dhall, directory, ed25519, fsnotify, here, hspec, http-client, http-client-tls, http-conduit, http-types, lzma-conduit, megaparsec, memory, mmorph, optparse-applicative, process, protolude, resourcet, servant, servant-auth, servant-auth-client, servant-client, servant-generic, servant-streaming-client, streaming, text, unix, uri-bytestring, versions

Files

+ ChangeLog.md view
@@ -0,0 +1,13 @@+# Changelog++All notable changes to this project will be documented in this file.++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).++## [Unreleased]++## [0.1.0.0] - 2018-07-01++### Added+- Initial release @domenkozar
+ LICENSE view
@@ -0,0 +1,13 @@+Copyright 2018 Domen Kožar++Licensed under the Apache License, Version 2.0 (the "License");+you may not use this file except in compliance with the License.+You may obtain a copy of the License at++    http://www.apache.org/licenses/LICENSE-2.0++Unless required by applicable law or agreed to in writing, software+distributed under the License is distributed on an "AS IS" BASIS,+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+See the License for the specific language governing permissions and+limitations under the License.
+ README.md view
@@ -0,0 +1,1 @@+https://github.com/cachix/cachix/blob/master/README.md
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ cachix.cabal view
@@ -0,0 +1,196 @@+-- This file has been generated from package.yaml by hpack version 0.21.2.+--+-- see: https://github.com/sol/hpack+--+-- hash: 4ecd761e07cb906fa23f7c69e728be19cfd4d7d067f5860ff640827b44f5dfaf++name:           cachix+version:        0.1.0.0+description:    Please see the README on Github at <https://github.com/cachix/cachix#readme>+homepage:       https://github.com/cachix/cachix#readme+bug-reports:    https://github.com/cachix/cachix/issues+author:         Domen Kožar+maintainer:     domen@enlambda.com+copyright:      2018 Domen Kožar+license:        Apache-2.0+license-file:   LICENSE+build-type:     Simple+cabal-version:  >= 1.10++extra-source-files:+    ChangeLog.md+    README.md++source-repository head+  type: git+  location: https://github.com/cachix/cachix++library+  exposed-modules:+      Cachix.Client+      Cachix.Client.Commands+      Cachix.Client.Config+      Cachix.Client.InstallationMode+      Cachix.Client.NixConf+      Cachix.Client.NixVersion+      Cachix.Client.OptionsParser+      Cachix.Client.Servant+      Cachix.Client.URI+  other-modules:+      Paths_cachix+  hs-source-dirs:+      src+  default-extensions: OverloadedStrings NoImplicitPrelude RecordWildCards+  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+  build-depends:+      async+    , base >=4.7 && <5+    , base16-bytestring+    , base64-bytestring+    , bifunctors+    , bytestring+    , cachix-api+    , conduit+    , conduit-combinators+    , conduit-extra+    , cookie+    , cryptonite+    , dhall+    , directory+    , ed25519+    , fsnotify+    , here+    , http-client+    , http-client-tls+    , http-conduit+    , http-types+    , lzma-conduit+    , megaparsec+    , memory+    , mmorph+    , optparse-applicative+    , process+    , protolude+    , resourcet+    , servant >=0.14+    , servant-auth+    , servant-auth-client >=0.3.3.0+    , servant-client >=0.14+    , servant-generic+    , servant-streaming-client >=0.3.0.0+    , streaming+    , text+    , unix+    , uri-bytestring+    , versions+  default-language: Haskell2010++executable cachix+  main-is: Main.hs+  other-modules:+      Paths_cachix+  hs-source-dirs:+      cachix+  default-extensions: OverloadedStrings NoImplicitPrelude RecordWildCards+  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 -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      async+    , base >=4.7 && <5+    , base16-bytestring+    , base64-bytestring+    , bifunctors+    , bytestring+    , cachix+    , cachix-api+    , conduit+    , conduit-combinators+    , conduit-extra+    , cookie+    , cryptonite+    , dhall+    , directory+    , ed25519+    , fsnotify+    , here+    , http-client+    , http-client-tls+    , http-conduit+    , http-types+    , lzma-conduit+    , megaparsec+    , memory+    , mmorph+    , optparse-applicative+    , process+    , protolude+    , resourcet+    , servant >=0.14+    , servant-auth+    , servant-auth-client >=0.3.3.0+    , servant-client >=0.14+    , servant-generic+    , servant-streaming-client >=0.3.0.0+    , streaming+    , text+    , unix+    , uri-bytestring+    , versions+  default-language: Haskell2010++test-suite cachix-test+  type: exitcode-stdio-1.0+  main-is: Main.hs+  other-modules:+      InstallationModeSpec+      NixConfSpec+      NixVersionSpec+      Spec+      Paths_cachix+  hs-source-dirs:+      test+  default-extensions: OverloadedStrings NoImplicitPrelude RecordWildCards+  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 -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      async+    , base >=4.7 && <5+    , base16-bytestring+    , base64-bytestring+    , bifunctors+    , bytestring+    , cachix+    , cachix-api+    , conduit+    , conduit-combinators+    , conduit-extra+    , cookie+    , cryptonite+    , dhall+    , directory+    , ed25519+    , fsnotify+    , here+    , hspec+    , http-client+    , http-client-tls+    , http-conduit+    , http-types+    , lzma-conduit+    , megaparsec+    , memory+    , mmorph+    , optparse-applicative+    , process+    , protolude+    , resourcet+    , servant >=0.14+    , servant-auth+    , servant-auth-client >=0.3.3.0+    , servant-client >=0.14+    , servant-generic+    , servant-streaming-client >=0.3.0.0+    , streaming+    , text+    , unix+    , uri-bytestring+    , versions+  default-language: Haskell2010
+ cachix/Main.hs view
@@ -0,0 +1,25 @@+module Main (main) where++import Control.Exception (handle, SomeException)+import System.IO+import System.Exit (exitFailure)+import GHC.IO.Encoding++import qualified Cachix.Client as CC+++main :: IO ()+main = do+  setLocaleEncoding utf8+  hSetBuffering stdout LineBuffering+  hSetBuffering stderr LineBuffering+  handleExceptions CC.main++handleExceptions :: IO a -> IO a+handleExceptions = handle handler+  where+    handler :: SomeException -> IO a+    handler e = do+      hPutStrLn stderr ""+      hPrint stderr e+      exitFailure
+ src/Cachix/Client.hs view
@@ -0,0 +1,41 @@+module Cachix.Client+  ( main+  ) where++import Data.Version                (showVersion)+import Paths_cachix                (version)+import Protolude+import Network.HTTP.Simple         ( setRequestHeader )+import Network.HTTP.Client.TLS     ( newTlsManagerWith, tlsManagerSettings )+import Network.HTTP.Client         ( managerResponseTimeout, managerModifyRequest+                                   , responseTimeoutNone, ManagerSettings)+import Servant.Client              ( mkClientEnv)++import Cachix.Client.OptionsParser ( CachixCommand(..), CachixOptions(..), getOpts )+import Cachix.Client.Config        ( readConfig )+import Cachix.Client.Commands      as Commands+import Cachix.Client.URI           ( getBaseUrl)+++main :: IO ()+main = do+  (CachixOptions{..}, command) <- getOpts+  config <- readConfig+  manager <- newTlsManagerWith customManagerSettings+  let env = mkClientEnv manager $ getBaseUrl host+  case command of -- TODO: might want readerT here with client, config and env and opts+    AuthToken token -> Commands.authtoken env config token+    Create name -> Commands.create env config name+    Push name paths watchStore -> Commands.push env config name paths watchStore+    Use name shouldEchoNixOS -> Commands.use env config name shouldEchoNixOS+    Version -> putText cachixVersion++customManagerSettings :: ManagerSettings+customManagerSettings = tlsManagerSettings+  { managerResponseTimeout = responseTimeoutNone+  -- managerModifyRequest :: Request -> IO Request+  , managerModifyRequest = \request -> return $ setRequestHeader "User-Agent" [toS cachixVersion] request+  }++cachixVersion :: Text+cachixVersion = "cachix " <> toS (showVersion version)
+ src/Cachix/Client/Commands.hs view
@@ -0,0 +1,282 @@+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeOperators #-}+module Cachix.Client.Commands+  ( authtoken+  , create+  , push+  , use+  ) where++import           Crypto.Sign.Ed25519+import           Control.Concurrent           (threadDelay)+import qualified Control.Concurrent.QSem       as QSem+import           Control.Concurrent.Async     (mapConcurrently)+import           Control.Monad                (forever)+import           Control.Monad.Trans.Resource (ResourceT)+import qualified Data.ByteString.Base64        as B64+import           Data.Conduit+import           Data.Conduit.Process+import qualified Data.Conduit.List             as CL+import           Data.Conduit.Lzma              ( compress )+import           Data.IORef+import           Data.List                      ( isSuffixOf )+import           Data.Maybe                     ( fromJust )+import           Data.String.Here+import qualified Data.Text                      as T+import           Network.HTTP.Types (status404)+import           Protolude+import           Servant.API+import           Servant.Client+import           Servant.Auth                   ()+import           Servant.Auth.Client+import           Servant.Streaming.Client       ()+import           Servant.Generic+import           System.Directory               ( doesFileExist )+import           System.FSNotify+import           System.IO                      ( stdin, hIsTerminalDevice )+import           System.Process                 ( readProcessWithExitCode, readProcess )+import           System.Environment             ( lookupEnv )+import qualified Streaming.Prelude             as S++import qualified Cachix.Api                    as Api+import           Cachix.Api.Signing             (fingerprint, passthroughSizeSink, passthroughHashSink)+import           Cachix.Client.Config           ( Config(..)+                                                , BinaryCacheConfig(..)+                                                , writeConfig+                                                , mkConfig+                                                )+import qualified Cachix.Client.Config          as Config+import           Cachix.Client.InstallationMode+import           Cachix.Client.NixVersion       ( getNixMode+                                                , NixMode+                                                )+import qualified Cachix.Client.NixConf         as NixConf+import           Cachix.Client.Servant+++cachixClient :: Api.CachixAPI AsClient+cachixClient = fromServant $ client Api.servantApi++cachixBCClient :: Text -> Api.BinaryCacheAPI AsClient+cachixBCClient name = fromServant $ Api.cache cachixClient name++authtoken :: ClientEnv -> Maybe Config -> Text -> IO ()+authtoken _ maybeConfig token = do+  -- TODO: check that token actually authenticates!+  writeConfig $ case maybeConfig of+    Just config -> config { authToken = token }+    Nothing -> mkConfig token+  putStrLn ([hereLit|+Continue by creating a binary cache with:++    $ cachix create <name>++and share it with others over https://<name>.cachix.org+  |] :: Text)++create :: ClientEnv -> Maybe Config -> Text -> IO ()+create _ Nothing _ = throwIO $ NoConfig "start with: $ cachix authtoken <token>"+create env (Just config@Config{..}) name = do+  (PublicKey pk, SecretKey sk) <- createKeypair++  let bc = Api.BinaryCacheCreate $ toS $ B64.encode pk+  res <- (`runClientM` env) $ Api.create (cachixBCClient name) (Token (toS authToken)) bc+  case res of+    -- TODO: handle all kinds of errors+    Left err -> panic $ show err+    Right _ -> do+      -- write signing key to config+      let signingKey = toS $ B64.encode sk+          bcc = BinaryCacheConfig name signingKey+      writeConfig $ config { binaryCaches = binaryCaches <> [bcc] }++      putStrLn ([iTrim|+Signing key has been saved on your local machine. 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}++      |] :: Text)++use :: ClientEnv -> Maybe Config -> Text -> Bool -> IO ()+use env _ name shouldEchoNixOS = do+  -- 1. get cache public key+  res <- (`runClientM` env) $ Api.get (cachixBCClient name)+  case res of+    -- TODO: handle 404+    Left err -> panic $ show err+    Right binaryCache -> do+      eitherNixMode <- getNixMode+      case eitherNixMode of+        Left err -> panic err+        Right nixMode -> do+          user <- getUser+          nc <- NixConf.read NixConf.Global+          isTrusted <- isTrustedUser $ NixConf.readLines (catMaybes [nc]) NixConf.isTrustedUsers+          isNixOS <- doesFileExist "/etc/NIXOS"+          let nixEnv = NixEnv+                { nixMode = nixMode+                , isRoot = user == "root"+                , isTrusted = isTrusted+                , isNixOS = isNixOS+                }+          addBinaryCache binaryCache $+            if shouldEchoNixOS+            then EchoNixOS+            else getInstallationMode nixEnv+++-- TODO: lots of room for perfomance improvements+push :: ClientEnv -> Maybe Config -> Text -> [Text] -> Bool -> IO ()+push env config name rawPaths False = do+  hasNoStdin <- hIsTerminalDevice stdin+  when (not hasNoStdin && not (null rawPaths)) $ throwIO $ AmbiguousInput "You provided both stdin and store path arguments, pick only one to proceed."+  inputStorePaths <-+    if hasNoStdin+    then return rawPaths+    else T.words <$> getContents++  -- TODO: if empty, take whole nix store and warn: nix store-path --all+  when (null inputStorePaths) $ throwIO $ NoInput "You need to specify store paths either as stdin or as a cli argument"++  -- Query list of paths+  -- TODO: split args if too big+  (exitcode, out, err) <- readProcessWithExitCode "nix-store" (fmap toS (["-qR"] <> inputStorePaths)) mempty++  -- TODO: make pool size configurable, on beefier machines this could be doubled+  _ <- mapConcurrentlyBounded 4 (pushStorePath env config name) (T.lines (toS out))+  putText "All done."+push env config name _ True = withManager $ \mgr -> do+  _ <- watchDir mgr "/nix/store" filterF action+  putText "Watching /nix/store for new builds ..."+  forever $ threadDelay 1000000+  where+    action :: Action+    action (Removed fp _) = pushStorePath env config name $ toS $ dropEnd 5 fp+    action _ = return ()++    filterF :: ActionPredicate+    filterF (Removed fp _) | ".lock" `isSuffixOf` fp = True+    filterF _ = False++    dropEnd :: Int -> [a] -> [a]+    dropEnd index xs = take (length xs - index) xs++pushStorePath :: ClientEnv -> Maybe Config -> Text -> Text -> IO ()+pushStorePath env config name storePath = do+  -- use secret key from config or env+  maybeEnvSK <- lookupEnv "CACHIX_SIGNING_KEY"+  let matches Config{..} = filter (\bc -> Config.name bc == name) binaryCaches+      maybeBCSK = case config of+        Nothing -> Nothing+        Just c -> Config.secretKey <$> head (matches c)+      -- TODO: better error msg+      sk = SecretKey $ toS $ B64.decodeLenient $ toS $ fromJust $ maybeBCSK <|> toS <$> maybeEnvSK <|> panic "You need to: export CACHIX_SIGNING_KEY=XXX"+      (storeHash, _) = splitStorePath $ toS storePath+  -- Check if narinfo already exists+  -- TODO: query also cache.nixos.org? server-side?+  res <- (`runClientM` env) $ Api.narinfoHead+    (cachixBCClient name)+    (Api.NarInfoC storeHash)+  case res of+    Right NoContent -> return ()+    Left err | isErr err status404 -> go sk+             | otherwise -> panic $ show err -- TODO: retry+    where+      go sk = do+        let (storeHash, storeSuffix) = splitStorePath $ toS storePath+        putStrLn $ "pushing " <> storePath++        narSizeRef <- liftIO $ newIORef 0+        fileSizeRef <- liftIO $ newIORef 0+        narHashRef <- liftIO $ newIORef ("" :: Text)+        fileHashRef <- liftIO $ newIORef ("" :: Text)++        -- stream store path as xz compressed nar file+        (ClosedStream, (source, close), ClosedStream, cph) <- streamingProcess $ shell ("nix-store --dump " <> toS storePath)+        let stream'+              = source+             .| passthroughSizeSink narSizeRef+             .| passthroughHashSink narHashRef+             .| compress Nothing+             .| passthroughSizeSink fileSizeRef+             .| passthroughHashSink fileHashRef++            conduitToStreaming :: S.Stream (S.Of ByteString) (ResourceT IO) ()+            conduitToStreaming = runConduit (transPipe lift stream' .| CL.mapM_ S.yield)+        -- for now we need to use letsencrypt domain instead of cloudflare due to its upload limits+        let newEnv = env {+              baseUrl = (baseUrl env) { baseUrlHost = toS name <> "." <> baseUrlHost (baseUrl env)}+            }+        -- TODO: http retry: retry package?+        res <- (`runClientM` newEnv) $ Api.createNar+          (cachixBCClient name)+          (contentType (Proxy :: Proxy Api.XNixNar), conduitToStreaming)+        close+        exitcode <- waitForStreamingProcess cph+        case res of+          Left err -> panic $ show err+          Right NoContent -> do+            narSize <- readIORef narSizeRef+            narHashB16 <- readIORef narHashRef+            fileHash <- readIORef fileHashRef+            fileSize <- readIORef fileSizeRef++            -- TODO: #3: implement using pure haskell+            narHash <- ("sha256:" <>) . T.strip . toS <$> readProcess "nix-hash" ["--type", "sha256", "--to-base32", toS narHashB16] mempty++            (exitcode, out, err) <- readProcessWithExitCode "nix-store" ["-q", "--deriver", toS storePath] mempty+            let deriverRaw = T.strip $ toS out+                deriver = if deriverRaw == "unknown-deriver"+                          then deriverRaw+                          else T.drop 11 deriverRaw++            (exitcode, out, err) <- readProcessWithExitCode "nix-store" ["-q", "--references", toS storePath] mempty++            let+                references = sort $ T.lines $ T.strip $ toS out+                fp = fingerprint storePath narHash narSize references+                sig = dsign sk fp+                nic = Api.NarInfoCreate+                  { cStoreHash = storeHash+                  , cStoreSuffix = storeSuffix+                  , cNarHash = narHash+                  , cNarSize = narSize+                  , cFileSize = fileSize+                  , cFileHash = fileHash+                  , cReferences = fmap (T.drop 11) references+                  , cDeriver = deriver+                  , cSig = toS $ B64.encode $ unSignature sig+                  }+            -- Upload narinfo with signature+            res <- (`runClientM` env) $ Api.createNarinfo+              (cachixBCClient name)+              (Api.NarInfoC storeHash)+              nic+            case res of+              Left err -> panic $ show err -- TODO: handle json errors+              Right NoContent -> return ()+++-- Utils+++mapConcurrentlyBounded :: Traversable t => Int -> (a -> IO b) -> t a -> IO (t b)+mapConcurrentlyBounded bound action items = do+  qs <- QSem.newQSem bound+  let wrapped x = bracket_ (QSem.waitQSem qs) (QSem.signalQSem qs) (action x)+  mapConcurrently wrapped items++splitStorePath :: Text -> (Text, Text)+splitStorePath storePath =+  (T.take 32 (T.drop 11 storePath), T.drop 44 storePath)
+ src/Cachix/Client/Config.hs view
@@ -0,0 +1,64 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveAnyClass #-}+module Cachix.Client.Config+  ( Config(..)+  , BinaryCacheConfig(..)+  , readConfig+  , writeConfig+  , mkConfig+  ) where++import Dhall     hiding ( Text )+import Dhall.Pretty     ( prettyExpr )+import GHC.Generics     ( Generic )+import System.Directory ( doesFileExist, createDirectoryIfMissing+                        , getXdgDirectory, XdgDirectory(..)+                        )+import System.Posix.Files ( setFileMode, unionFileModes, ownerReadMode+                          , ownerWriteMode )+import Protolude+++data BinaryCacheConfig = BinaryCacheConfig+ { name :: Text+ , secretKey :: Text+ } deriving (Show, Generic, Interpret, Inject)++data Config = Config+ { authToken :: Text+ , binaryCaches :: [BinaryCacheConfig]+ } deriving (Show, Generic, Interpret, Inject)++mkConfig :: Text -> Config+mkConfig authtoken = Config+  { authToken = authtoken+  , binaryCaches = []+  }++readConfig :: IO (Maybe Config)+readConfig = do+  filename <- getFilename+  doesExist <- doesFileExist filename+  if doesExist+  then Just <$> input auto (toS filename)+  else return Nothing+++getFilename :: IO FilePath+getFilename = do+  dir <- getXdgDirectory XdgConfig "cachix"+  createDirectoryIfMissing True dir+  return $ dir <> "/cachix.dhall"++writeConfig :: Config -> IO ()+writeConfig config = do+  filename <- getFilename+  let doc = prettyExpr $ embed inject config+  writeFile filename $ show doc+  assureFilePermissions filename+  putStrLn $ "Written to " <> filename++-- Does: chmod rw file+assureFilePermissions :: FilePath -> IO ()+assureFilePermissions fp =+  setFileMode fp $ unionFileModes ownerReadMode ownerWriteMode
+ src/Cachix/Client/InstallationMode.hs view
@@ -0,0 +1,123 @@+{-# LANGUAGE QuasiQuotes #-}+module Cachix.Client.InstallationMode+  ( InstallationMode(..)+  , NixEnv(..)+  , CachixException(..)+  , getInstallationMode+  , addBinaryCache+  , isTrustedUser+  , getUser+  ) where++import           Protolude+import           Data.String.Here+import qualified Data.Text as T+import qualified Cachix.Client.NixConf as NixConf+import           Cachix.Client.NixVersion ( NixMode(..) )+import           Cachix.Api as Api+import           System.Directory               ( getPermissions, writable )+import           System.Environment             ( lookupEnv )++data CachixException+  = UnsupportedNixVersion Text+  | UserEnvNotSet Text+  | MustBeRoot Text+  | NixOSInstructions Text+  | AmbiguousInput Text+  | NoInput Text+  | NoConfig Text+  deriving (Show, Typeable)++instance Exception CachixException++data NixEnv = NixEnv+  { nixMode :: NixMode+  , isTrusted :: Bool+  , isRoot :: Bool+  , isNixOS :: Bool+  }++data InstallationMode+  = Install NixConf.NixConfLoc+  | UnsupportedNix1X+  | EchoNixOS+  | EchoNixOSWithTrustedUser+  | UntrustedRequiresSudo+  | Nix20RequiresSudo+  deriving (Show, Eq)++getInstallationMode :: NixEnv -> InstallationMode+getInstallationMode NixEnv{..}+  | nixMode == Nix1XX = UnsupportedNix1X+  | isNixOS && isRoot = EchoNixOS+  | isNixOS && (not isTrusted) = EchoNixOSWithTrustedUser+  | (not isNixOS) && isRoot = Install NixConf.Global+  | nixMode == Nix20 = Nix20RequiresSudo+  | isTrusted = Install NixConf.Local+  | not isTrusted = UntrustedRequiresSudo+++-- | Add a Binary cache to nix.conf, print nixos config or fail+addBinaryCache :: Api.BinaryCache -> InstallationMode -> IO ()+addBinaryCache _ UnsupportedNix1X = throwIO $+  UnsupportedNixVersion "Nix 1.x is not supported, please upgrade to Nix 2.0.1 or greater"+addBinaryCache Api.BinaryCache{..} EchoNixOS = do+  putText [iTrim|+nix = {+  binaryCaches = [+    "${uri}"+  ];+  binaryCachePublicKeys = [+    ${T.intercalate " " (map (\s -> "\"" <> s <> "\"") publicSigningKeys)}+  ];+};+  |]+  throwIO $ NixOSInstructions "Add above lines to your NixOS configuration file"+addBinaryCache Api.BinaryCache{..} EchoNixOSWithTrustedUser = do+  -- TODO: DRY+  user <- getUser+  putText [iTrim|+nix = {+  binaryCaches = [+    "${uri}"+  ];+  binaryCachePublicKeys = [+    ${T.intercalate " " (map (\s -> "\"" <> s <> "\"") publicSigningKeys)}+  ];+  trustedUsers = [ "root" "${user}" ];+};+  |]+  throwIO $ NixOSInstructions "Add above lines to your NixOS configuration file"+addBinaryCache _ UntrustedRequiresSudo = throwIO $+  MustBeRoot "Run command as root OR execute: $ echo \"trusted-users = root $USER\" | sudo tee -a /etc/nix/nix.conf && sudo pkill nix-daemon"+addBinaryCache _ Nix20RequiresSudo = throwIO $+  MustBeRoot "Run command as root OR upgrade to latest Nix - to be able to use it without root (recommended)"+addBinaryCache bc@Api.BinaryCache{..} (Install ncl) = do+  -- TODO: might need locking one day+  gnc <- NixConf.read NixConf.Global+  lnc <- NixConf.read NixConf.Local+  let final = if ncl == NixConf.Global then gnc else lnc+      input = if ncl == NixConf.Global then [gnc] else [gnc, lnc]+  NixConf.write ncl $ NixConf.add bc (catMaybes input) (fromMaybe (NixConf.NixConf []) final)+  filename <- NixConf.getFilename ncl+  putStrLn $ "Configured " <> uri <> " binary cache in " <> toS filename++isTrustedUser :: [Text] -> IO Bool+isTrustedUser users = do+  user <- getUser+  -- to detect single user installations+  permissions <- getPermissions "/nix/store"+  unless (groups == []) $ do+    -- TODO: support Nix group syntax+    putText "Warn: cachix doesn't yet support checking if user is trusted via groups, so it will recommend sudo"+    putStrLn $ "Warn: groups found " <> T.intercalate "," groups+  return $ (writable permissions) || (user `elem` users)+  where+    groups = filter (\u -> T.head u == '@') users++getUser :: IO Text+getUser = do+  maybeUser <- lookupEnv "USER"+  case maybeUser of+    Nothing -> throwIO $ UserEnvNotSet "$USER must be set"+    Just user -> return $ toS user
+ src/Cachix/Client/NixConf.hs view
@@ -0,0 +1,173 @@+{- (Very limited) parser, rendered and modifier of nix.conf+-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE DeriveFunctor #-}+module Cachix.Client.NixConf+  ( NixConf+  , NixConfG(..)+  , NixConfLine(..)+  , NixConfLoc(..)+  , render+  , add+  , read+  , update+  , write+  , getFilename+  , parser+  , parse+  , readLines+  , writeLines+  , isTrustedUsers+  , defaultPublicURI+  , defaultSigningKey+  ) where++import Control.Exception (catch)+import Data.Char (isSpace)+import Data.Text (unwords, unlines)+import Data.List (nub)+import Protolude+import qualified Text.Megaparsec as Mega+import Text.Megaparsec.Char+import System.Directory ( doesFileExist, createDirectoryIfMissing+                        , getXdgDirectory, XdgDirectory(..)+                        )+import Cachix.Api (BinaryCache(..))+++data NixConfLine+  = Substituters [Text]+  | TrustedUsers [Text]+  | TrustedPublicKeys [Text]+  | BinaryCachePublicKeys [Text]+  | BinaryCaches [Text]+  | Other Text+  deriving (Show, Eq)++newtype NixConfG a = NixConf a+  deriving (Show, Eq, Functor)++type NixConf = NixConfG [NixConfLine]++readLines :: [NixConf] -> (NixConfLine -> Maybe [Text]) -> [Text]+readLines nixconfs predicate = concat $ fmap f nixconfs+  where+    f (NixConf xs) = foldl foldIt [] xs++    foldIt :: [Text] -> NixConfLine -> [Text]+    foldIt prev new = prev <> (fromMaybe [] $ predicate new)++writeLines :: (NixConfLine -> Maybe [Text]) -> NixConfLine -> NixConf -> NixConf+writeLines predicate addition nixconf = fmap f nixconf+  where+    f x = filter (isNothing . predicate) x <> [addition]++isSubstituter :: NixConfLine -> Maybe [Text]+isSubstituter (Substituters xs) = Just xs+isSubstituter (BinaryCaches xs) = Just xs+isSubstituter _ = Nothing++isPublicKey :: NixConfLine -> Maybe [Text]+isPublicKey (TrustedPublicKeys xs) = Just xs+isPublicKey (BinaryCachePublicKeys xs) = Just xs+isPublicKey _ = Nothing++isTrustedUsers :: NixConfLine -> Maybe [Text]+isTrustedUsers (TrustedUsers xs) = Just xs+isTrustedUsers _ = Nothing++-- | Pure version of addIO+add :: BinaryCache -> [NixConf] -> NixConf -> NixConf+add BinaryCache{..} toRead toWrite =+  writeLines isPublicKey (TrustedPublicKeys $ nub publicKeys) $+    writeLines isSubstituter (Substituters $ nub substituters) toWrite+  where+    -- Note: some defaults are always appended since overriding some setttings in nix.conf overrides defaults otherwise+    substituters = (defaultPublicURI : readLines toRead isSubstituter) <> [uri]+    publicKeys = (defaultSigningKey : readLines toRead isPublicKey) <> publicSigningKeys++defaultPublicURI :: Text+defaultPublicURI = "https://cache.nixos.org"+defaultSigningKey :: Text+defaultSigningKey = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="++render :: NixConf -> Text+render (NixConf nixconflines) = unlines $ fmap go nixconflines+  where+    go :: NixConfLine -> Text+    go (Substituters xs) = "substituters = " <> unwords xs+    go (BinaryCaches xs) = "substituters = " <> unwords xs+    go (TrustedUsers xs) = "trusted-users = " <> unwords xs+    go (TrustedPublicKeys xs) = "trusted-public-keys = " <> unwords xs+    go (BinaryCachePublicKeys xs) = "trusted-public-keys = " <> unwords xs+    go (Other line) = line++write :: NixConfLoc -> NixConf -> IO ()+write ncl nc = do+  filename <- getFilename ncl+  writeFile filename $ render nc++read :: NixConfLoc -> IO (Maybe NixConf)+read ncl = do+  filename <- getFilename ncl+  doesExist <- doesFileExist filename+  if not doesExist+  then return Nothing+  else do+    result <- parse <$> readFile filename+    case result of+      Left err -> do+        putStrLn (Mega.parseErrorPretty err)+        panic $ toS filename <> " failed to parse, please copy the above error and contents of nix.conf and open an issue at https://github.com/cachix/cachix"+      Right conf -> return $ Just conf++update :: NixConfLoc -> (Maybe NixConf -> NixConf) -> IO ()+update ncl f = do+  nc <- f <$> read ncl+  write ncl nc++data NixConfLoc = Global | Local+  deriving (Show, Eq)++getFilename :: NixConfLoc -> IO FilePath+getFilename ncl = do+  dir <- case ncl of+    Global -> return "/etc/nix"+    Local -> getXdgDirectory XdgConfig "nix"+  _ <- catch (createDirectoryIfMissing True dir) $ \e ->+    hPutStr stderr ("Warning: Couldn't create " <> dir <> " :" <> show (e :: IOException))+  return $ dir <> "/nix.conf"++-- nix.conf Parser++type Parser = Mega.Parsec Void Text++-- TODO: handle comments+parseLine :: ([Text] -> NixConfLine) -> Text -> Parser NixConfLine+parseLine constr name = Mega.try $ do+    _ <- optional (some (char ' '))+    _ <- string name+    _ <- many (char ' ')+    _ <- char '='+    _ <- many (char ' ')+    values <- Mega.sepBy1 (many (satisfy (not . isSpace))) (some (char ' '))+    _ <- many spaceChar+    return $ constr (fmap toS values)++parseOther :: Parser NixConfLine+parseOther = Other . toS <$> Mega.manyTill anyChar eol++parseAltLine :: Parser NixConfLine+parseAltLine =+      parseLine Substituters "substituters"+  <|> parseLine TrustedPublicKeys "trusted-public-keys"+  <|> parseLine TrustedUsers "trusted-users"+  <|> parseLine BinaryCachePublicKeys "binary-cache-public-keys"+  <|> parseLine BinaryCaches "binary-caches"+  <|> parseOther++parser :: Parser NixConf+parser = NixConf <$> many parseAltLine++parse :: Text -> Either (Mega.ParseError (Mega.Token Text) Void) NixConf+parse = Mega.parse parser "nix.conf"
+ src/Cachix/Client/NixVersion.hs view
@@ -0,0 +1,37 @@+module Cachix.Client.NixVersion+  ( getNixMode+  , parseNixMode+  , NixMode(..)+  ) where++import Protolude+import System.Process (readProcessWithExitCode)+import Data.Versions+import Data.Text as T+++data NixMode+  = Nix20+  | Nix201+  | Nix1XX+  deriving (Show, Eq)++getNixMode :: IO (Either Text NixMode)+getNixMode = do+  (exitcode, out, err) <- readProcessWithExitCode "nix-env" ["--version"] mempty+  unless (err == "") $ putStrLn $ "nix-env stderr: " <> err+  return $ case exitcode of+    ExitFailure i -> Left $ "'nix-env --version' exited with " <> show i+    ExitSuccess -> parseNixMode $ toS out++parseNixMode :: Text -> Either Text NixMode+parseNixMode output =+  let+    verStr = T.drop 14 $ T.strip output+    err = "Couldn't parse 'nix-env --version' output: " <> output+  in case versioning verStr of+    Left _ -> Left err+    Right ver | verStr == "" -> Left err+              | ver < Ideal (SemVer 1 99 0 [] []) -> Right Nix1XX+              | ver < Ideal (SemVer 2 0 1 [] []) -> Right Nix20+              | otherwise -> Right Nix201
+ src/Cachix/Client/OptionsParser.hs view
@@ -0,0 +1,85 @@+{-# LANGUAGE QuasiQuotes #-}+module Cachix.Client.OptionsParser+  ( CachixCommand(..)+  , CachixOptions(..)+  , BinaryCacheName+  , getOpts+  ) where++import Data.Bifunctor            (first)+import Protolude hiding          (option)+import URI.ByteString            (URIRef, Absolute, parseURI, strictURIParserOptions+                                 , serializeURIRef')+import URI.ByteString.QQ+import Options.Applicative+++data CachixOptions = CachixOptions+  { host :: URIRef Absolute+  , verbose :: Bool+  } deriving Show++parserCachixOptions :: Parser CachixOptions+parserCachixOptions = CachixOptions+  <$> option uriOption ( long "host"+                       <> short 'h'+                       <> value [uri|https://cachix.org|]+                       <> metavar "URI"+                       <> showDefaultWith (toS . serializeURIRef')+                       <> help "Host to connect to"+                       )+  <*> switch ( long "verbose"+             <> short 'v'+             <> help "Verbose mode"+             )++uriOption :: ReadM (URIRef Absolute)+uriOption = eitherReader $ \s ->+  first show $ parseURI strictURIParserOptions $ toS s++type BinaryCacheName = Text++data CachixCommand+  = AuthToken Text+  | Create BinaryCacheName+  | Push BinaryCacheName [Text] Bool -- TODO: refactor to a record+  | Use BinaryCacheName Bool --- TODO: refactor to a record+  | Version+  deriving Show+++parserCachixCommand :: Parser CachixCommand+parserCachixCommand = subparser $+  command "authtoken" (infoH authtoken (progDesc "Configure token for authentication to cachix.org")) <>+  command "create" (infoH create (progDesc "Create a new binary cache")) <>+  command "push" (infoH push (progDesc "Upload Nix store paths to the binary cache")) <>+  command "use" (infoH use (progDesc "Configure nix.conf to enable binary cache during builds"))+  where+    authtoken = AuthToken <$> strArgument (metavar "TOKEN")+    create = Create <$> strArgument (metavar "NAME")+    push = Push <$> strArgument (metavar "NAME")+                <*> many (strArgument (metavar "PATHS..."))+                <*> switch (long "watch-store" <> short 'w' <> help "Run in daemon mode and push store paths as they are added to /nix/store")+    use = Use <$> strArgument (metavar "NAME")+              <*> switch (long "nixos" <> short 'n' <> help "Output NixOS configuration lines")++getOpts :: IO (CachixOptions, CachixCommand)+getOpts = customExecParser (prefs showHelpOnEmpty) opts++opts :: ParserInfo (CachixOptions, CachixCommand)+opts = infoH parser desc+  where parser = (,) <$> parserCachixOptions <*> (parserCachixCommand <|> versionParser)+        versionParser :: Parser CachixCommand+        versionParser = flag' Version ( long "version"+                     <> short 'V'+                     <> help "Show cachix version"+                     )++desc :: InfoMod a+desc = fullDesc+    <> progDesc "Sign into https://cachix.org to get started."+    <> header "cachix.org command interface"+    -- TODO: usage footer++infoH :: Parser a -> InfoMod a -> ParserInfo a+infoH a = info (helper <*> a)
+ src/Cachix/Client/Servant.hs view
@@ -0,0 +1,22 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}++module Cachix.Client.Servant+  ( AsClient+  , isErr+  ) where++import Protolude+import Network.HTTP.Types (Status)+import Servant.Client+import Servant.Generic++-- TODO: servant-generic-client https://github.com/chpatrick/servant-generic/issues/10+data AsClient+type instance AsClient :- api = Client ClientM api++isErr :: ServantError -> Status -> Bool+isErr (FailureResponse resp) status | responseStatusCode resp == status = True+isErr _ _ = False
+ src/Cachix/Client/URI.hs view
@@ -0,0 +1,37 @@+-- | Ugly glue between URI and BaseUrl+-- | TODO: mark as Internal module+{-# LANGUAGE GADTs #-}+module Cachix.Client.URI+  ( getBaseUrl+  ) where++import Protolude+import qualified URI.ByteString as UBS+import URI.ByteString hiding (Scheme)+import Servant.Client+++getBaseUrl :: URIRef Absolute -> BaseUrl+getBaseUrl URI{..} =+  case uriAuthority of+    Nothing -> panic "missing host in url"+    Just authority ->+      BaseUrl+        getScheme+        (toS (hostBS (authorityHost authority)))+        getPort+        (toS uriPath)+      where+        getScheme :: Scheme+        getScheme = case uriScheme of+          UBS.Scheme "http" -> Http+          UBS.Scheme "https" -> Https+          _ -> panic "uri can only be http/https"++        getPort :: Int+        getPort = maybe defaultPort portNumber $ authorityPort authority++        defaultPort :: Int+        defaultPort = case getScheme of+          Http -> 80+          Https -> 443
+ test/InstallationModeSpec.hs view
@@ -0,0 +1,94 @@+module InstallationModeSpec where++import Protolude+import Test.Hspec++import qualified Cachix.Client.NixConf as NixConf+import           Cachix.Client.NixVersion ( NixMode(..) )+import           Cachix.Client.InstallationMode+++spec :: Spec+spec = do+  describe "getInstallationMode" $ do+    it "Nix 1.X is unsupported" $+      let+        nixenv = NixEnv+          { nixMode = Nix1XX+          , isTrusted = False -- any+          , isRoot = False -- any+          , isNixOS = False -- any+          }+      in getInstallationMode nixenv `shouldBe` UnsupportedNix1X+    it "NixOS with root prints configuration" $+      let+        nixenv = NixEnv+          { nixMode = Nix20 -- any except 1.0+          , isTrusted = True -- any+          , isRoot = True+          , isNixOS = True+          }+      in getInstallationMode nixenv `shouldBe` EchoNixOS+    it "NixOS without trust prints configuration plus trust" $+      let+        nixenv = NixEnv+          { nixMode = Nix20+          , isTrusted = False+          , isRoot = False+          , isNixOS = True+          }+      in getInstallationMode nixenv `shouldBe` EchoNixOSWithTrustedUser+    it "NixOS non-root trusted results into local install" $+      let+        nixenv = NixEnv+          { nixMode = Nix201+          , isTrusted = True+          , isRoot = False+          , isNixOS = True+          }+      in getInstallationMode nixenv `shouldBe` Install NixConf.Local+    it "non-NixOS root results into global install" $+      let+        nixenv = NixEnv+          { nixMode = Nix201+          , isTrusted = True+          , isRoot = True+          , isNixOS = False+          }+      in getInstallationMode nixenv `shouldBe` Install NixConf.Global+    it "non-NixOS non-root trusted results into local install" $+      let+        nixenv = NixEnv+          { nixMode = Nix201+          , isTrusted = True+          , isRoot = False+          , isNixOS = False+          }+      in getInstallationMode nixenv `shouldBe` Install NixConf.Local+    it "non-NixOS non-root non-trusted results into required sudo" $+      let+        nixenv = NixEnv+          { nixMode = Nix201+          , isTrusted = False+          , isRoot = False+          , isNixOS = False+          }+      in getInstallationMode nixenv `shouldBe` UntrustedRequiresSudo+    it "non-NixOS non-root non-trusted on Nix 2.0.0 results into required sudo with upgrade warning" $+      let+        nixenv = NixEnv+          { nixMode = Nix20+          , isTrusted = False+          , isRoot = False+          , isNixOS = False+          }+      in getInstallationMode nixenv `shouldBe` Nix20RequiresSudo+    it "non-NixOS non-root trusted on Nix 2.0.0 results into required sudo with upgrade warning" $+      let+        nixenv = NixEnv+          { nixMode = Nix20+          , isTrusted = True+          , isRoot = False+          , isNixOS = False+          }+      in getInstallationMode nixenv `shouldBe` Nix20RequiresSudo
+ test/Main.hs view
@@ -0,0 +1,13 @@+module Main where++import Protolude+import Test.Hspec.Runner+import Test.Hspec.Formatters+import qualified Spec++main :: IO ()+main = hspecWith config Spec.spec+  where+    config = defaultConfig+      { configColorMode = ColorAlways+      }
+ test/NixConfSpec.hs view
@@ -0,0 +1,147 @@+{-# LANGUAGE QuasiQuotes #-}+module NixConfSpec where++import Protolude+import Data.String.Here+import Test.Hspec++import Cachix.Client.NixConf as NixConf+import Cachix.Api (BinaryCache(..))+++property :: Text -> Expectation+property x = NixConf.render <$> parse x `shouldBe` Right x++bc = BinaryCache+ { name = "name"+ , uri = "https://name.cachix.org"+ , publicSigningKeys = ["pub"]+ }++spec :: Spec+spec = do+  describe "render . parse" $ do+    it "handles single value substituters" $+      property "substituters = a\n"+    it "handles multi value substituters" $+      property "substituters = a b c\n"+    it "handles all known keys" $+      property "substituters = a b c\ntrusted-users = him me\ntrusted-public-keys = a\n"+    it "random content" $+      property "blabla = foobar\nfoo = bar\n"+  describe "add" $ do+    it "merges binary caches from both files" $+      let+        globalConf = NixConf+          [ Substituters ["bc1"]+          , TrustedPublicKeys ["pub1"]+          ]+        localConf = NixConf+          [ Substituters ["bc2"]+          , TrustedPublicKeys ["pub2"]+          ]+        result = NixConf+          [ Substituters [defaultPublicURI, "bc1", "bc2", "https://name.cachix.org"]+          , TrustedPublicKeys [defaultSigningKey, "pub1", "pub2", "pub"]+          ]+      in add bc [globalConf, localConf] localConf `shouldBe` result+    it "is noop if binary cache exists in one file" $+      let+        globalConf = NixConf+          [ Substituters [defaultPublicURI, "https://name.cachix.org"]+          , TrustedPublicKeys [defaultSigningKey, "pub"]+          ]+        localConf = NixConf [ ]+        result = NixConf+          [ Substituters [defaultPublicURI, "https://name.cachix.org"]+          , TrustedPublicKeys [defaultSigningKey, "pub"]+          ]+      in add bc [globalConf, localConf] localConf `shouldBe` result+    it "preserves other nixconf entries" $+      let+        globalConf = NixConf+          [ Substituters ["http"]+          , TrustedPublicKeys ["pub1"]+          , TrustedUsers ["user"]+          , Other "foo"+          ]+        localConf = NixConf+          [ TrustedUsers ["user2"]+          , Other "bar"+          ]+        result = NixConf+          [ TrustedUsers ["user2"]+          , Other "bar"+          , Substituters [defaultPublicURI, "http", "https://name.cachix.org"]+          , TrustedPublicKeys [defaultSigningKey, "pub1", "pub"]+          ]+      in add bc [globalConf, localConf] localConf `shouldBe` result+    it "removed duplicates" $+      let+        globalConf = NixConf+          [ Substituters ["bc1"]+          , BinaryCaches ["bc1"]+          , TrustedPublicKeys ["pub1"]+          , BinaryCachePublicKeys ["pub1"]+          ]+        localConf = NixConf+          [ Substituters ["bc2", "bc2"]+          , TrustedPublicKeys ["pub2", "pub2"]+          ]+        result = NixConf+          [ Substituters [defaultPublicURI, "bc1", "bc2", "https://name.cachix.org"]+          , TrustedPublicKeys [defaultSigningKey, "pub1", "pub2", "pub"]+          ]+      in add bc [globalConf, localConf] localConf `shouldBe` result+    it "can mix and match old and new names" $+      let+        globalConf = NixConf+          [ BinaryCaches ["bc1"]+          , BinaryCachePublicKeys ["pub1"]+          ]+        localConf = NixConf+          [ Substituters ["bc2"]+          , TrustedPublicKeys ["pub2"]+          ]+        result = NixConf+          [ Substituters [defaultPublicURI, "bc1", "bc2", "https://name.cachix.org"]+          , TrustedPublicKeys [defaultSigningKey, "pub1", "pub2", "pub"]+          ]+      in add bc [globalConf, localConf] localConf `shouldBe` result+    it "adds binary cache and defaults if no existing entries exist" $+      let+        globalConf = NixConf []+        localConf = NixConf []+        result = NixConf+          [ Substituters [defaultPublicURI, "https://name.cachix.org"]+          , TrustedPublicKeys [defaultSigningKey, "pub"]+          ]+      in add bc [globalConf, localConf] localConf `shouldBe` result+  describe "parse" $ do+    it "parses substituters" $+      parse "substituters = a\n" `shouldBe` (Right $ NixConf [Substituters ["a"]])+    it "parses long key" $+      parse "binary-caches-parallel-connections = 40\n" `shouldBe` (Right $ NixConf [Other "binary-caches-parallel-connections = 40"])+    it "parses substituters with multiple values" $+      parse "substituters = a b c\n" `shouldBe` (Right $ NixConf [Substituters ["a", "b", "c"]])+    it "parses equal sign after the first key as literal" $+      parse "substituters = a b c= d\n" `shouldBe` (Right $ NixConf [Substituters ["a", "b", "c=", "d"]])+    it "parses a complex example" $+      parse realExample `shouldBe` (Right $ NixConf [ Other ""+                                                    , Substituters ["a","b","c"]+                                                    , TrustedUsers ["him","me"]+                                                    , TrustedPublicKeys ["a"]+                                                    , Other "blabla =  asd"+                                                    , Other "# comment"+                                                    , Other ""+                                                    , Other ""])++realExample = [hereLit|+substituters = a  b c+trusted-users = him me+trusted-public-keys  =  a+blabla =  asd+# comment+++|]
+ test/NixVersionSpec.hs view
@@ -0,0 +1,22 @@+module NixVersionSpec where++import Protolude+import Test.Hspec++import Cachix.Client.NixVersion++spec :: Spec+spec = do+  describe "parseNixMode" $ do+    it "parses 'nix-env (Nix) 2.0' as Nix20" $+      parseNixMode "nix-env (Nix) 2.0" `shouldBe` Right Nix20+    it "parses 'nix-env (Nix) 2.0.1' as Nix201" $+      parseNixMode "nix-env (Nix) 2.0.1" `shouldBe` Right Nix201+    it "parses 'nix-env (Nix) 1.11.13' as Nix1XX" $+      parseNixMode "nix-env (Nix) 1.11.13" `shouldBe` Right Nix1XX+    it "parses 'nix-env (Nix) 2.0.5' as Nix201" $+      parseNixMode "nix-env (Nix) 2.0.5" `shouldBe` Right Nix201+    it "parses 'nix-env (Nix) 2.0.1pre6053_444b921' as Nix201" $+      parseNixMode "nix-env (Nix) 2.0.1pre6053_444b921" `shouldBe` Right Nix201+    it "fails with unknown string 'foobar'" $+      parseNixMode "foobar" `shouldBe` Left "Couldn't parse 'nix-env --version' output: foobar"
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Spec #-}