diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -199,7 +199,7 @@
 ```
 niv - dependency manager for Nix projects
 
-version: 0.2.1
+version: 0.2.2
 
 Usage: niv COMMAND
 
@@ -251,10 +251,10 @@
 
 ```
 Examples:
-
-  niv update
-  niv update nixpkgs
-  niv update my-package -v beta-0.2
+  
+  niv update                     # update all packages
+  niv update nixpkgs             # update nixpkgs
+  niv update my-package -v beta-0.2 # update my-package to version "beta-0.2"
 
 Usage: niv update [PACKAGE] ([-a|--attribute KEY=VAL] | [-b|--branch BRANCH] |
                   [-o|--owner OWNER] | [-r|--repo REPO] | [-v|--version VERSION]
diff --git a/niv.cabal b/niv.cabal
--- a/niv.cabal
+++ b/niv.cabal
@@ -4,10 +4,10 @@
 --
 -- see: https://github.com/sol/hpack
 --
--- hash: 7cabb5fdfc03b35748a6e8348284d303786488a08d6819852d4df7d428791e6e
+-- hash: 4ae2ca2b71f11737dafa898e811c9e1a8f33a82bb9c5365bff2ff9e9d89dab93
 
 name:           niv
-version:        0.2.1
+version:        0.2.2
 synopsis:       Easy dependency management for Nix projects
 description:    Easy dependency management for Nix projects.
 category:       Development
@@ -30,10 +30,14 @@
 
 library
   exposed-modules:
+      Data.Aeson.Extended
+      Data.HashMap.Strict.Extended
       Niv.Cli
       Niv.GitHub
       Niv.GitHub.Test
       Niv.Logger
+      Niv.Sources
+      Niv.Sources.Test
       Niv.Test
       Niv.Update
       Niv.Update.Test
@@ -57,6 +61,7 @@
     , optparse-applicative
     , process
     , profunctors
+    , pureMD5
     , string-qq
     , tasty
     , tasty-hunit
@@ -89,6 +94,7 @@
     , optparse-applicative
     , process
     , profunctors
+    , pureMD5
     , string-qq
     , text
     , unliftio
@@ -119,6 +125,7 @@
     , optparse-applicative
     , process
     , profunctors
+    , pureMD5
     , string-qq
     , tasty
     , text
diff --git a/nix/sources.nix b/nix/sources.nix
--- a/nix/sources.nix
+++ b/nix/sources.nix
@@ -6,42 +6,40 @@
   pkgs =
     if hasNixpkgsPath
     then
-        if hasThisAsNixpkgsPath
-        then import (builtins_fetchTarball { inherit (sources_nixpkgs) url sha256; }) {}
-        else import <nixpkgs> {}
+      if hasThisAsNixpkgsPath
+      then import (builtins_fetchTarball { inherit (sources_nixpkgs) url sha256; }) {}
+      else import <nixpkgs> {}
     else
-        import (builtins_fetchTarball { inherit (sources_nixpkgs) url sha256; }) {};
+      import (builtins_fetchTarball { inherit (sources_nixpkgs) url sha256; }) {};
 
   sources_nixpkgs =
     if builtins.hasAttr "nixpkgs" sources
     then sources.nixpkgs
     else abort
-    ''
+      ''
         Please specify either <nixpkgs> (through -I or NIX_PATH=nixpkgs=...) or
         add a package called "nixpkgs" to your sources.json.
-    '';
+      '';
 
   # fetchTarball version that is compatible between all the versions of Nix
-  builtins_fetchTarball =
-      { url, sha256 }@attrs:
-      let
-        inherit (builtins) lessThan nixVersion fetchTarball;
-      in
-        if lessThan nixVersion "1.12" then
-          fetchTarball { inherit url; }
-        else
-          fetchTarball attrs;
+  builtins_fetchTarball = { url, sha256 }@attrs:
+    let
+      inherit (builtins) lessThan nixVersion fetchTarball;
+    in
+      if lessThan nixVersion "1.12" then
+        fetchTarball { inherit url; }
+      else
+        fetchTarball attrs;
 
   # fetchurl version that is compatible between all the versions of Nix
-  builtins_fetchurl =
-      { url, sha256 }@attrs:
-      let
-        inherit (builtins) lessThan nixVersion fetchurl;
-      in
-        if lessThan nixVersion "1.12" then
-          fetchurl { inherit url; }
-        else
-          fetchurl attrs;
+  builtins_fetchurl = { url, sha256 }@attrs:
+    let
+      inherit (builtins) lessThan nixVersion fetchurl;
+    in
+      if lessThan nixVersion "1.12" then
+        fetchurl { inherit url; }
+      else
+        fetchurl attrs;
 
   # A wrapper around pkgs.fetchzip that has inspectable arguments,
   # annoyingly this means we have to specify them
@@ -57,37 +55,42 @@
 
   sources = builtins.fromJSON (builtins.readFile ./sources.json);
 
-  mapAttrs = builtins.mapAttrs or
-    (f: set: with builtins;
-      listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set)));
+  mapAttrs = builtins.mapAttrs or (
+    f: set: with builtins;
+    listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set))
+  );
 
   # borrowed from nixpkgs
   functionArgs = f: f.__functionArgs or (builtins.functionArgs f);
   callFunctionWith = autoArgs: f: args:
-    let auto = builtins.intersectAttrs (functionArgs f) autoArgs;
-    in f (auto // args);
+    let
+      auto = builtins.intersectAttrs (functionArgs f) autoArgs;
+    in
+      f (auto // args);
 
   getFetcher = spec:
-    let fetcherName =
-      if builtins.hasAttr "type" spec
-      then builtins.getAttr "type" spec
-      else "builtin-tarball";
-    in builtins.getAttr fetcherName {
-      "tarball" = fetchzip;
-      "builtin-tarball" = builtins_fetchTarball;
-      "file" = fetchurl;
-      "builtin-url" = builtins_fetchurl;
-    };
+    let
+      fetcherName =
+        if builtins.hasAttr "type" spec
+        then builtins.getAttr "type" spec
+        else "builtin-tarball";
+    in
+      builtins.getAttr fetcherName {
+        "tarball" = fetchzip;
+        "builtin-tarball" = builtins_fetchTarball;
+        "file" = fetchurl;
+        "builtin-url" = builtins_fetchurl;
+      };
 };
 # NOTE: spec must _not_ have an "outPath" attribute
-mapAttrs (_: spec:
-  if builtins.hasAttr "outPath" spec
-  then abort
-    "The values in sources.json should not have an 'outPath' attribute"
-  else
-    if builtins.hasAttr "url" spec && builtins.hasAttr "sha256" spec
-    then
-      spec //
-      { outPath = callFunctionWith spec (getFetcher spec) { }; }
-    else spec
-  ) sources
+mapAttrs (
+  _: spec:
+    if builtins.hasAttr "outPath" spec
+    then abort
+      "The values in sources.json should not have an 'outPath' attribute"
+    else
+      if builtins.hasAttr "url" spec && builtins.hasAttr "sha256" spec
+      then
+        spec // { outPath = callFunctionWith spec (getFetcher spec) {}; }
+      else spec
+) sources
diff --git a/src/Data/Aeson/Extended.hs b/src/Data/Aeson/Extended.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Aeson/Extended.hs
@@ -0,0 +1,17 @@
+module Data.Aeson.Extended where
+
+import Data.Aeson (ToJSON)
+import qualified Data.Aeson.Encode.Pretty as AesonPretty
+import qualified Data.ByteString.Lazy as BL
+
+--- Aeson
+
+-- | Efficiently prettify and serialize a JSON value as a lazy 'BL.ByteString'
+-- and write it to a file.
+encodeFilePretty :: (ToJSON a) => FilePath -> a -> IO ()
+encodeFilePretty fp = BL.writeFile fp . AesonPretty.encodePretty' config
+  where
+    config = AesonPretty.defConfig {
+      AesonPretty.confTrailingNewline = True,
+      AesonPretty.confCompare = compare
+    }
diff --git a/src/Data/HashMap/Strict/Extended.hs b/src/Data/HashMap/Strict/Extended.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/HashMap/Strict/Extended.hs
@@ -0,0 +1,40 @@
+
+module Data.HashMap.Strict.Extended where
+
+import Control.Monad
+import Data.Hashable (Hashable)
+import qualified Data.HashMap.Strict as HMS
+
+--- HashMap
+
+forWithKeyM
+  :: (Eq k, Hashable k, Monad m)
+  => HMS.HashMap k v1
+  -> (k -> v1 -> m v2)
+  -> m (HMS.HashMap k v2)
+forWithKeyM = flip mapWithKeyM
+
+forWithKeyM_
+  :: (Eq k, Hashable k, Monad m)
+  => HMS.HashMap k v1
+  -> (k -> v1 -> m ())
+  -> m ()
+forWithKeyM_ = flip mapWithKeyM_
+
+mapWithKeyM
+  :: (Eq k, Hashable k, Monad m)
+  => (k -> v1 -> m v2)
+  -> HMS.HashMap k v1
+  -> m (HMS.HashMap k v2)
+mapWithKeyM f m = do
+    fmap mconcat $ forM (HMS.toList m) $ \(k, v) ->
+      HMS.singleton k <$> f k v
+
+mapWithKeyM_
+  :: (Eq k, Hashable k, Monad m)
+  => (k -> v1 -> m ())
+  -> HMS.HashMap k v1
+  -> m ()
+mapWithKeyM_ f m = do
+    forM_ (HMS.toList m) $ \(k, v) ->
+      HMS.singleton k <$> f k v
diff --git a/src/Niv/Cli.hs b/src/Niv/Cli.hs
--- a/src/Niv/Cli.hs
+++ b/src/Niv/Cli.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE DerivingStrategies #-}
-{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE GeneralizedNewtypeDeriving #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
@@ -11,28 +10,27 @@
 
 import Control.Applicative
 import Control.Monad
-import Data.Aeson (FromJSON, FromJSONKey, ToJSON, ToJSONKey, (.=))
+import Data.Aeson ((.=))
 import Data.Char (isSpace)
-import Data.FileEmbed (embedFile)
+import Data.Functor
+import Data.HashMap.Strict.Extended
 import Data.Hashable (Hashable)
-import Data.Maybe (fromMaybe)
 import Data.String.QQ (s)
-import Niv.Logger
+import Data.Version (showVersion)
 import Niv.GitHub
+import Niv.Logger
+import Niv.Sources
 import Niv.Update
+import System.Environment (getArgs)
 import System.Exit (ExitCode(ExitSuccess))
-import System.FilePath ((</>), takeDirectory)
+import System.FilePath (takeDirectory)
 import System.Process (readProcessWithExitCode)
 import UnliftIO
-import Data.Version (showVersion)
 import qualified Data.Aeson as Aeson
-import qualified Data.Aeson.Encode.Pretty as AesonPretty
 import qualified Data.ByteString as B
 import qualified Data.ByteString.Char8 as B8
-import qualified Data.ByteString.Lazy as L
 import qualified Data.HashMap.Strict as HMS
 import qualified Data.Text as T
-import qualified Data.Text.IO as T
 import qualified Options.Applicative as Opts
 import qualified Options.Applicative.Help.Pretty as Opts
 import qualified System.Directory as Dir
@@ -41,9 +39,14 @@
 import Paths_niv (version)
 
 cli :: IO ()
-cli = join $ Opts.execParser opts
+cli = join $
+    execParserPure' Opts.defaultPrefs opts <$> getArgs
+      >>= Opts.handleParseResult
   where
-    opts = Opts.info (parseCommand <**> Opts.helper) $ mconcat desc
+    execParserPure' pprefs pinfo [] = Opts.Failure $
+      Opts.parserFailure pprefs pinfo Opts.ShowHelpText mempty
+    execParserPure' pprefs pinfo args = Opts.execParserPure pprefs pinfo args
+    opts = Opts.info (parseCommand <**> Opts.helper ) $ mconcat desc
     desc =
       [ Opts.fullDesc
       , Opts.headerDoc $ Just $
@@ -61,46 +64,10 @@
     Opts.command "modify"  parseCmdModify <>
     Opts.command "drop"  parseCmdDrop )
 
-newtype Sources = Sources
-  { unSources :: HMS.HashMap PackageName PackageSpec }
-  deriving newtype (FromJSON, ToJSON)
-
-getSources :: IO Sources
-getSources = do
-    exists <- Dir.doesFileExist pathNixSourcesJson
-    unless exists abortSourcesDoesntExist
-
-    warnIfOutdated
-    -- TODO: if doesn't exist: run niv init
-    say $ "Reading sources file"
-    decodeFileStrict pathNixSourcesJson >>= \case
-      Just (Aeson.Object obj) ->
-        fmap (Sources . mconcat) $
-          forM (HMS.toList obj) $ \(k, v) ->
-            case v of
-              Aeson.Object v' ->
-                pure $ HMS.singleton (PackageName k) (PackageSpec v')
-              _ -> abortAttributeIsntAMap
-      Just _ -> abortSourcesIsntAMap
-      Nothing -> abortSourcesIsntJSON
-
-setSources :: Sources -> IO ()
-setSources sources = encodeFile pathNixSourcesJson sources
-
-newtype PackageName = PackageName { unPackageName :: T.Text }
-  deriving newtype (Eq, Hashable, FromJSONKey, ToJSONKey, Show)
-
 parsePackageName :: Opts.Parser PackageName
 parsePackageName = PackageName <$>
     Opts.argument Opts.str (Opts.metavar "PACKAGE")
 
-newtype PackageSpec = PackageSpec { unPackageSpec :: Aeson.Object }
-  deriving newtype (FromJSON, ToJSON, Show, Semigroup, Monoid)
-
--- | Simply discards the 'Freedom'
-attrsToSpec :: Attrs -> PackageSpec
-attrsToSpec = PackageSpec . fmap snd
-
 parsePackageSpec :: Opts.Parser PackageSpec
 parsePackageSpec =
     (PackageSpec . HMS.fromList . fmap fixupAttributes) <$>
@@ -193,12 +160,20 @@
               createFile path initNixSourcesJsonContent
               -- Imports @niv@ and @nixpkgs@ (19.03)
               say "Importing 'niv' ..."
-              cmdAdd Nothing (PackageName "nmattia/niv", PackageSpec HMS.empty)
+              cmdAdd githubUpdate' (PackageName "niv")
+                (specToFreeAttrs $ PackageSpec $ HMS.fromList
+                  [ "owner" .= ("nmattia" :: T.Text)
+                  , "repo" .= ("niv" :: T.Text)
+                  ]
+                )
               say "Importing 'nixpkgs' ..."
-              cmdAdd
-                (Just (PackageName "nixpkgs"))
-                ( PackageName "NixOS/nixpkgs-channels"
-                , PackageSpec (HMS.singleton "branch" "nixos-19.03"))
+              cmdAdd githubUpdate' (PackageName "nixpkgs")
+                (specToFreeAttrs $ PackageSpec $ HMS.fromList
+                  [ "owner" .= ("NixOS" :: T.Text)
+                  , "repo" .= ("nixpkgs-channels" :: T.Text)
+                  , "branch" .= ("nixos-19.03" :: T.Text)
+                  ]
+                )
           , \path _content -> dontCreateFile path)
         ] $ \(path, onCreate, onUpdate) -> do
             exists <- Dir.doesFileExist path
@@ -219,16 +194,37 @@
 
 parseCmdAdd :: Opts.ParserInfo (IO ())
 parseCmdAdd =
-    Opts.info ((cmdAdd <$> optName <*> parsePackage) <**> Opts.helper) $
+    Opts.info
+      ((uncurry (cmdAdd githubUpdate') <$> parseArgs) <**> Opts.helper) $
       mconcat desc
   where
-    optName :: Opts.Parser (Maybe PackageName)
-    optName = Opts.optional $ PackageName <$>  Opts.strOption
+    parseArgs :: Opts.Parser (PackageName, Attrs)
+    parseArgs = collapse <$> parseNameAndGHShortcut <*> parsePackageSpec
+    parseNameAndGHShortcut = (,) <$> optName <*> parseGitHubShortcut
+    -- collaspe a "name or shortcut" with package spec
+    collapse nameAndSpec pspec = (pname, specToLockedAttrs $ pspec <> repoAndOwner)
+      where
+        (pname, repoAndOwner) = case nameAndSpec of
+          (Just pname', (_, spec)) -> (pname', PackageSpec spec)
+          (Nothing, (pname', spec)) -> (pname', PackageSpec spec)
+    optName = Opts.optional $ PackageName <$> Opts.strOption
       ( Opts.long "name" <>
         Opts.short 'n' <>
         Opts.metavar "NAME" <>
         Opts.help "Set the package name to <NAME>"
       )
+
+    -- parse a github shortcut of the form "owner/repo"
+    parseGitHubShortcut = Opts.strArgument (Opts.metavar "PACKAGE") <&>
+          -- parses a string "owner/repo" into package name (repo) and spec (owner +
+          -- repo)
+          \(T.pack -> str) ->
+            case T.span (/= '/') str of
+              (owner@(T.null -> False)
+                , T.uncons -> Just ('/', repo@(T.null -> False))) ->
+                  ( PackageName repo
+                  , HMS.fromList [ "owner" .= owner, "repo" .= repo ])
+              _ -> (PackageName str, HMS.empty)
     desc =
       [ Opts.fullDesc
       , Opts.progDesc "Add dependency"
@@ -240,38 +236,22 @@
           "  niv add my-package -v alpha-0.1 -t http://example.com/archive/<version>.zip"
       ]
 
-cmdAdd :: Maybe PackageName -> (PackageName, PackageSpec) -> IO ()
-cmdAdd mPackageName (PackageName str, cliSpec) =
-    job ("Adding package " <> T.unpack str)  $ do
-
-      -- Figures out the owner and repo
-      let (packageName, defaultSpec) = case T.span (/= '/') str of
-            ( owner@(T.null -> False)
-              , T.uncons -> Just ('/', repo@(T.null -> False))) -> do
-              (PackageName repo, HMS.fromList [ "owner" .= owner, "repo" .= repo ])
-            _ -> (PackageName str, HMS.empty)
-
+cmdAdd :: Update () a -> PackageName -> Attrs -> IO ()
+cmdAdd updateFunc packageName attrs = do
+    job ("Adding package " <> T.unpack (unPackageName packageName)) $ do
       sources <- unSources <$> getSources
 
-      let packageName' = fromMaybe packageName mPackageName
-
-      when (HMS.member packageName' sources) $
-        abortCannotAddPackageExists packageName'
-
-      let defaultSpec' = PackageSpec $ defaultSpec
-
-      let initialSpec = specToLockedAttrs cliSpec <> specToFreeAttrs defaultSpec'
+      when (HMS.member packageName sources) $
+        abortCannotAddPackageExists packageName
 
-      eFinalSpec <- fmap attrsToSpec <$> tryEvalUpdate
-        initialSpec
-        (githubUpdate nixPrefetchURL githubLatestRev githubRepo)
+      eFinalSpec <- fmap attrsToSpec <$> tryEvalUpdate attrs updateFunc
 
       case eFinalSpec of
-        Left e -> abortUpdateFailed [(packageName', e)]
+        Left e -> abortUpdateFailed [(packageName, e)]
         Right finalSpec -> do
           say $ "Writing new sources file"
           setSources $ Sources $
-            HMS.insert packageName' finalSpec sources
+            HMS.insert packageName finalSpec sources
 
 -------------------------------------------------------------------------------
 -- SHOW
@@ -287,32 +267,26 @@
 cmdShow :: Maybe PackageName -> IO ()
 cmdShow = \case
     Just packageName -> do
-      tsay $ "Showing package " <> unPackageName packageName
-
       sources <- unSources <$> getSources
 
       case HMS.lookup packageName sources of
-        Just (PackageSpec spec) -> do
-          forM_ (HMS.toList spec) $ \(attrName, attrValValue) -> do
-            let attrValue = case attrValValue of
-                  Aeson.String str -> str
-                  _ -> "<barabajagal>"
-            tsay $ "  " <> attrName <> ": " <> attrValue
+        Just pspec -> showPackage packageName pspec
         Nothing -> abortCannotShowNoSuchPackage packageName
 
     Nothing -> do
-      say $ "Showing sources file"
-
       sources <- unSources <$> getSources
+      forWithKeyM_ sources $ showPackage
 
-      forWithKeyM_ sources $ \key (PackageSpec spec) -> do
-        tsay $ "Showing " <> tbold (unPackageName key)
-        forM_ (HMS.toList spec) $ \(attrName, attrValValue) -> do
-          let attrValue = case attrValValue of
-                Aeson.String str -> str
-                _ -> tfaint "<barabajagal>"
-          tsay $ "  " <> attrName <> ": " <> attrValue
+showPackage :: PackageName -> PackageSpec -> IO ()
+showPackage (PackageName pname) (PackageSpec spec) = do
+    tsay $ tbold pname
+    forM_ (HMS.toList spec) $ \(attrName, attrValValue) -> do
+      let attrValue = case attrValValue of
+            Aeson.String str -> str
+            _ -> tfaint "<barabajagal>"
+      tsay $ "  " <> attrName <> ": " <> attrValue
 
+
 -------------------------------------------------------------------------------
 -- UPDATE
 -------------------------------------------------------------------------------
@@ -326,12 +300,14 @@
     desc =
       [ Opts.fullDesc
       , Opts.progDesc "Update dependencies"
-      , Opts.headerDoc $ Just $
+      , Opts.headerDoc $ Just $ Opts.nest 2 $
           "Examples:" Opts.<$$>
           "" Opts.<$$>
-          "  niv update" Opts.<$$>
-          "  niv update nixpkgs" Opts.<$$>
-          "  niv update my-package -v beta-0.2"
+          Opts.vcat
+            [ Opts.fill 30 "niv update" Opts.<+> "# update all packages",
+              Opts.fill 30 "niv update nixpkgs" Opts.<+> "# update nixpkgs",
+              Opts.fill 30 "niv update my-package -v beta-0.2" Opts.<+> "# update my-package to version \"beta-0.2\""
+            ]
       ]
 
 specToFreeAttrs :: PackageSpec -> Attrs
@@ -340,7 +316,6 @@
 specToLockedAttrs :: PackageSpec -> Attrs
 specToLockedAttrs = fmap (Locked,) . unPackageSpec
 
--- TODO: sexy logging + concurrent updates
 cmdUpdate :: Maybe (PackageName, PackageSpec) -> IO ()
 cmdUpdate = \case
     Just (packageName, cliSpec) ->
@@ -473,60 +448,6 @@
 -- Aux
 -------------------------------------------------------------------------------
 
---- Aeson
-
--- | Efficiently deserialize a JSON value from a file.
--- If this fails due to incomplete or invalid input, 'Nothing' is
--- returned.
---
--- The input file's content must consist solely of a JSON document,
--- with no trailing data except for whitespace.
---
--- This function parses immediately, but defers conversion.  See
--- 'json' for details.
-decodeFileStrict :: (FromJSON a) => FilePath -> IO (Maybe a)
-decodeFileStrict = fmap Aeson.decodeStrict . B.readFile
-
--- | Efficiently serialize a JSON value as a lazy 'L.ByteString' and write it to a file.
-encodeFile :: (ToJSON a) => FilePath -> a -> IO ()
-encodeFile fp = L.writeFile fp . AesonPretty.encodePretty' config
-  where
-    config =  AesonPretty.defConfig { AesonPretty.confTrailingNewline = True, AesonPretty.confCompare = compare }
-
---- HashMap
-
-forWithKeyM
-  :: (Eq k, Hashable k, Monad m)
-  => HMS.HashMap k v1
-  -> (k -> v1 -> m v2)
-  -> m (HMS.HashMap k v2)
-forWithKeyM = flip mapWithKeyM
-
-forWithKeyM_
-  :: (Eq k, Hashable k, Monad m)
-  => HMS.HashMap k v1
-  -> (k -> v1 -> m ())
-  -> m ()
-forWithKeyM_ = flip mapWithKeyM_
-
-mapWithKeyM
-  :: (Eq k, Hashable k, Monad m)
-  => (k -> v1 -> m v2)
-  -> HMS.HashMap k v1
-  -> m (HMS.HashMap k v2)
-mapWithKeyM f m = do
-    fmap mconcat $ forM (HMS.toList m) $ \(k, v) ->
-      HMS.singleton k <$> f k v
-
-mapWithKeyM_
-  :: (Eq k, Hashable k, Monad m)
-  => (k -> v1 -> m ())
-  -> HMS.HashMap k v1
-  -> m ()
-mapWithKeyM_ f m = do
-    forM_ (HMS.toList m) $ \(k, v) ->
-      HMS.singleton k <$> f k v
-
 nixPrefetchURL :: Bool -> T.Text -> IO T.Text
 nixPrefetchURL unpack (T.unpack -> url) = do
     (exitCode, sout, serr) <- runNixPrefetch
@@ -558,53 +479,14 @@
           _ -> False
         _ -> False
 
-warnIfOutdated :: IO ()
-warnIfOutdated = do
-    tryAny (B.readFile pathNixSourcesNix) >>= \case
-      Left e -> T.putStrLn $ T.unlines
-        [ "Could not read " <> T.pack pathNixSourcesNix
-        , "Error: " <> tshow e
-        ]
-      Right content ->
-        if shouldUpdateNixSourcesNix content
-        then
-          T.putStrLn $ T.unlines
-            [ "WARNING: " <> T.pack pathNixSourcesNix <> " is out of date."
-            , "Please run"
-            , "  niv init"
-            , "or add the following line in the " <> T.pack pathNixSourcesNix <> "  file:"
-            , "  # niv: no_update"
-            ]
-        else pure ()
-
--- | @nix/sources.nix@
-pathNixSourcesNix :: FilePath
-pathNixSourcesNix = "nix" </> "sources.nix"
-
--- | Glue code between nix and sources.json
-initNixSourcesNixContent :: B.ByteString
-initNixSourcesNixContent = $(embedFile "nix/sources.nix")
-
--- | @nix/sources.json"
-pathNixSourcesJson :: FilePath
-pathNixSourcesJson = "nix" </> "sources.json"
-
--- | Empty JSON map
-initNixSourcesJsonContent :: B.ByteString
-initNixSourcesJsonContent = "{}"
+-- | The IO (real) github update
+githubUpdate' :: Update () ()
+githubUpdate' = githubUpdate nixPrefetchURL githubLatestRev githubRepo
 
 -------------------------------------------------------------------------------
 -- Abort
 -------------------------------------------------------------------------------
 
-abortSourcesDoesntExist :: IO a
-abortSourcesDoesntExist = abort $ T.unlines [ line1, line2 ]
-  where
-    line1 = "Cannot use " <> T.pack pathNixSourcesJson
-    line2 = [s|
-The sources file does not exist! You may need to run 'niv init'.
-|]
-
 abortSourcesIsntAMap :: IO a
 abortSourcesIsntAMap = abort $ T.unlines [ line1, line2 ]
   where
@@ -614,22 +496,6 @@
 specification, e.g.:
   { ... }
 |]
-
-abortAttributeIsntAMap :: IO a
-abortAttributeIsntAMap = abort $ T.unlines [ line1, line2 ]
-  where
-    line1 = "Cannot use " <> T.pack pathNixSourcesJson
-    line2 = [s|
-The package specifications in the sources file should be JSON maps from
-attribute name to attribute value, e.g.:
-  { "nixpkgs": { "foo": "bar" } }
-|]
-
-abortSourcesIsntJSON :: IO a
-abortSourcesIsntJSON = abort $ T.unlines [ line1, line2 ]
-  where
-    line1 = "Cannot use " <> T.pack pathNixSourcesJson
-    line2 = "The sources file should be JSON."
 
 abortCannotAddPackageExists :: PackageName -> IO a
 abortCannotAddPackageExists (PackageName n) = abort $ T.unlines
diff --git a/src/Niv/Logger.hs b/src/Niv/Logger.hs
--- a/src/Niv/Logger.hs
+++ b/src/Niv/Logger.hs
@@ -53,6 +53,24 @@
     ANSI.setSGRCode [ANSI.SetColor ANSI.Foreground ANSI.Vivid ANSI.Green] <>
     str <> ANSI.setSGRCode [ANSI.Reset]
 
+yellow :: String -> String
+yellow str =
+    ANSI.setSGRCode [ANSI.SetConsoleIntensity ANSI.BoldIntensity] <>
+    ANSI.setSGRCode [ANSI.SetColor ANSI.Foreground ANSI.Vivid ANSI.Yellow] <>
+    str <> ANSI.setSGRCode [ANSI.Reset]
+
+tyellow :: T.Text -> T.Text
+tyellow = dimap T.unpack T.pack yellow
+
+blue :: String -> String
+blue str =
+    ANSI.setSGRCode [ANSI.SetConsoleIntensity ANSI.BoldIntensity] <>
+    ANSI.setSGRCode [ANSI.SetColor ANSI.Foreground ANSI.Vivid ANSI.Blue] <>
+    str <> ANSI.setSGRCode [ANSI.Reset]
+
+tblue :: T.Text -> T.Text
+tblue = dimap T.unpack T.pack blue
+
 red :: String -> String
 red str =
     ANSI.setSGRCode [ANSI.SetColor ANSI.Foreground ANSI.Vivid ANSI.Red] <>
diff --git a/src/Niv/Sources.hs b/src/Niv/Sources.hs
new file mode 100644
--- /dev/null
+++ b/src/Niv/Sources.hs
@@ -0,0 +1,216 @@
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase #-}
+
+module Niv.Sources where
+
+import Data.Aeson (FromJSON, FromJSONKey, ToJSON, ToJSONKey)
+import Data.FileEmbed (embedFile)
+import Data.Bifunctor (first)
+import Data.Hashable (Hashable)
+import Data.List
+import Data.String.QQ (s)
+import Niv.GitHub
+import Niv.Logger
+import Niv.Update
+import System.FilePath ((</>))
+import UnliftIO
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Extended as Aeson
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy.Char8 as BL8
+import qualified Data.Digest.Pure.MD5 as MD5
+import qualified Data.HashMap.Strict as HMS
+import qualified Data.Text as T
+import qualified System.Directory as Dir
+
+-------------------------------------------------------------------------------
+-- sources.json related
+-------------------------------------------------------------------------------
+
+data SourcesError
+  = SourcesDoesntExist
+  | SourceIsntJSON
+  | SpecIsntAMap
+
+newtype Sources = Sources
+  { unSources :: HMS.HashMap PackageName PackageSpec }
+  deriving newtype (FromJSON, ToJSON)
+
+getSourcesEither :: IO (Either SourcesError Sources)
+getSourcesEither = do
+    Dir.doesFileExist pathNixSourcesJson >>= \case
+      False -> pure $ Left SourcesDoesntExist
+      True ->
+        Aeson.decodeFileStrict pathNixSourcesJson >>= \case
+          Just value -> case valueToSources value of
+            Nothing -> pure $ Left SpecIsntAMap
+            Just srcs -> pure $ Right srcs
+          Nothing -> pure $ Left SourceIsntJSON
+  where
+    valueToSources :: Aeson.Value -> Maybe Sources
+    valueToSources = \case
+        Aeson.Object obj -> fmap (Sources . mapKeys PackageName) $ traverse
+          (\case
+            Aeson.Object obj' -> Just (PackageSpec obj')
+            _ -> Nothing
+          ) obj
+        _ -> Nothing
+    mapKeys :: (Eq k2, Hashable k2) => (k1 -> k2) -> HMS.HashMap k1 v -> HMS.HashMap k2 v
+    mapKeys f = HMS.fromList . map (first f) . HMS.toList
+
+getSources :: IO Sources
+getSources = do
+    warnIfOutdated
+    getSourcesEither >>= either
+      (\case
+        SourcesDoesntExist -> abortSourcesDoesntExist
+        SourceIsntJSON -> abortSourcesIsntJSON
+        SpecIsntAMap -> abortSpecIsntAMap
+      ) pure
+
+setSources :: Sources -> IO ()
+setSources sources = Aeson.encodeFilePretty pathNixSourcesJson sources
+
+newtype PackageName = PackageName { unPackageName :: T.Text }
+  deriving newtype (Eq, Hashable, FromJSONKey, ToJSONKey, Show)
+
+newtype PackageSpec = PackageSpec { unPackageSpec :: Aeson.Object }
+  deriving newtype (FromJSON, ToJSON, Show, Semigroup, Monoid)
+
+-- | Simply discards the 'Freedom'
+attrsToSpec :: Attrs -> PackageSpec
+attrsToSpec = PackageSpec . fmap snd
+
+-- | @nix/sources.json@
+pathNixSourcesJson :: FilePath
+pathNixSourcesJson = "nix" </> "sources.json"
+
+--
+-- ABORT messages
+--
+
+abortSourcesDoesntExist :: IO a
+abortSourcesDoesntExist = abort $ T.unlines [ line1, line2 ]
+  where
+    line1 = "Cannot use " <> T.pack pathNixSourcesJson
+    line2 = [s|
+The sources file does not exist! You may need to run 'niv init'.
+|]
+
+abortSourcesIsntJSON :: IO a
+abortSourcesIsntJSON = abort $ T.unlines [ line1, line2 ]
+  where
+    line1 = "Cannot use " <> T.pack pathNixSourcesJson
+    line2 = "The sources file should be JSON."
+
+abortSpecIsntAMap :: IO a
+abortSpecIsntAMap = abort $ T.unlines [ line1, line2 ]
+  where
+    line1 = "Cannot use " <> T.pack pathNixSourcesJson
+    line2 = [s|
+The package specifications in the sources file should be JSON maps from
+attribute name to attribute value, e.g.:
+  { "nixpkgs": { "foo": "bar" } }
+|]
+
+-------------------------------------------------------------------------------
+-- sources.nix related
+-------------------------------------------------------------------------------
+
+
+-- | All the released versions of nix/sources.nix
+data SourcesNixVersion
+  = V1
+  | V2
+  | V3
+  | V4
+  | V5
+  | V6
+  | V7
+  | V8
+  | V9
+  | V10
+  deriving stock (Bounded, Enum, Eq)
+
+-- | A user friendly version
+sourcesVersionToText :: SourcesNixVersion -> T.Text
+sourcesVersionToText = \case
+    V1 -> "1"
+    V2 -> "2"
+    V3 -> "3"
+    V4 -> "4"
+    V5 -> "5"
+    V6 -> "6"
+    V7 -> "7"
+    V8 -> "8"
+    V9 -> "9"
+    V10 -> "10"
+
+latestVersionMD5 :: T.Text
+latestVersionMD5 = sourcesVersionToMD5 maxBound
+
+-- | Find a version based on the md5 of the nix/sources.nix
+md5ToSourcesVersion :: T.Text -> Maybe SourcesNixVersion
+md5ToSourcesVersion md5 =
+    find (\snv -> sourcesVersionToMD5 snv == md5) [minBound .. maxBound]
+
+-- | The MD5 sum of a particular version
+sourcesVersionToMD5 :: SourcesNixVersion -> T.Text
+sourcesVersionToMD5 = \case
+    V1 -> "a7d3532c70fea66ffa25d6bc7ee49ad5"
+    V2 -> "24cc0719fa744420a04361e23a3598d0"
+    V3 -> "e01ed051e2c416e0fc7355fc72aeee3d"
+    V4 -> "f754fe0e661b61abdcd32cb4062f5014"
+    V5 -> "c34523590ff7dec7bf0689f145df29d1"
+    V6 -> "8143f1db1e209562faf80a998be4929a"
+    V7 -> "00a02cae76d30bbef96f001cabeed96f"
+    V8 -> "e8b860753dd7fa1fd7b805dd836eb607"
+    V9 -> "87149616c1b3b1e5aa73178f91c20b53"
+    V10 -> "d8625c0a03dd935e1c79f46407faa8d3"
+
+-- | The MD5 sum of ./nix/sources.nix
+sourcesNixMD5 :: IO T.Text
+sourcesNixMD5 = T.pack . show . MD5.md5 <$> BL8.readFile pathNixSourcesNix
+
+-- | @nix/sources.nix@
+pathNixSourcesNix :: FilePath
+pathNixSourcesNix = "nix" </> "sources.nix"
+
+warnIfOutdated :: IO ()
+warnIfOutdated = do
+    tryAny (BL8.readFile pathNixSourcesNix) >>= \case
+      Left e -> tsay $ T.unlines -- warn with tsay
+        [ T.unwords [ tyellow "WARNING:",  "Could not read" , T.pack pathNixSourcesNix ]
+        , T.unwords [ "  ", "(", tshow e, ")" ]
+        ]
+      Right content -> do
+        case md5ToSourcesVersion (T.pack $ show $ MD5.md5 content) of
+          -- This is a custom or newer version, we don't do anything
+          Nothing -> pure ()
+          Just v
+            -- The file is the latest
+            | v == maxBound -> pure ()
+            -- The file is older than than latest
+            | otherwise -> do
+                tsay $ T.unlines
+                  [ T.unwords
+                        [ tbold $ tblue "INFO:"
+                        , "new sources.nix available:"
+                        , sourcesVersionToText v, "->", sourcesVersionToText maxBound
+                        ]
+                  , "  Please run 'niv init' or add the following line in the " <>
+                    T.pack pathNixSourcesNix <> " file:"
+                  , "  # niv: no_update"
+                  ]
+
+-- | Glue code between nix and sources.json
+initNixSourcesNixContent :: B.ByteString
+initNixSourcesNixContent = $(embedFile "nix/sources.nix")
+
+-- | Empty JSON map
+initNixSourcesJsonContent :: B.ByteString
+initNixSourcesJsonContent = "{}"
diff --git a/src/Niv/Sources/Test.hs b/src/Niv/Sources/Test.hs
new file mode 100644
--- /dev/null
+++ b/src/Niv/Sources/Test.hs
@@ -0,0 +1,13 @@
+module Niv.Sources.Test where
+
+import Niv.Sources
+import Test.Tasty.HUnit ((@=?))
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.Digest.Pure.MD5 as MD5
+import qualified Data.Text as T
+
+-- | Ensure that the sources.nix we ship is tracked as the latest version
+test_shippedSourcesNixIsLatest :: IO ()
+test_shippedSourcesNixIsLatest =
+    latestVersionMD5 @=?
+      (T.pack . show . MD5.md5 . BL.fromStrict $ initNixSourcesNixContent)
diff --git a/src/Niv/Test.hs b/src/Niv/Test.hs
--- a/src/Niv/Test.hs
+++ b/src/Niv/Test.hs
@@ -1,5 +1,6 @@
 module Niv.Test (tests, test) where
 
+import Niv.Sources.Test
 import Niv.GitHub.Test
 import Niv.Update.Test
 import qualified Test.Tasty as Tasty
@@ -26,5 +27,8 @@
         , Tasty.testCase "updates once" test_githubUpdatesOnce
         , Tasty.testCase "doesn't override rev" test_githubDoesntOverrideRev
         , Tasty.testCase "falls back to URL" test_githubURLFallback
+        ]
+    , Tasty.testGroup "sources.nix"
+        [ Tasty.testCase "has latest version" test_shippedSourcesNixIsLatest
         ]
     ]
