packages feed

confetti (empty) → 0.3.1

raw patch · 8 files changed

+635/−0 lines, 8 filesdep +MissingHdep +basedep +confettisetup-changed

Dependencies added: MissingH, base, confetti, directory, filepath, tasty, tasty-hunit, tasty-smallcheck, text, time, unix, yaml

Files

+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2017 Avi Press++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,122 @@+Confetti+==========++[![Build Status](https://travis-ci.org/aviaviavi/confetti.svg?branch=master)](https://travis-ci.org/aviaviavi/confetti)++Confetti is a tiny tool for managing different versions of a particular+configuration file, and quickly swapping them.<br />++Confetti is set up via a spec file, `~/.confetti.yml`. +You can specify one or more `groups`, each with one or more `targets`, +which are the config files you wish to manage. ++Suppose we have multiple AWS credential files we want to easily switch between (ie use various `~/.aws/credentials`+variants). To use confetti for this task, we'll put our different files in the target directory, `~/.aws`, +named `~/.aws/${variant-name}.credentials`. We might have:++```+~/.aws/personal.credentials+~/.aws/work.credentials+~/.aws/org_name.credentials+```++A simple example group specification in `~/confetti.yml` might look like:+```+groups:+  - name: aws+    targets:+      - /home/you/.aws/credentials+```++To switch to `work.credentials` simply run: +```+$ confetti [required group_name] [optional variant_name]+```+eg.+```+$ confetti aws work+> Setting aws to "work"+> ~/.aws/credentials -> ~/.aws/work.credentials+> Success (ノ◕ヮ◕)ノ*:・゚✧+```++As you can see from the example output,+this will symlink `~/.aws/credentials` -> `~/.aws/work.credentials`. If the target file is +_not_ a symlink when you invoke confetti, a backup will be made before your variant file is +linked. If you have multiple target files in your group, they will all be symlinked to their+respective variants.++You can specify alternative `search_paths` for each `group`, and whether or not to search recursively.+Adding a new group with this functionality could make our .confetti.yml look like:++```+groups:+  - name: aws+    targets:+      - ~/.aws/credentials+  - name: dotfiles+    targets:+      - ~/.spacemacs+      - ~/.vimrc+      - ~/.zshrc+      - ~/.zpreztorc+      - ~/.tmux.conf+      - ~/.tmux.style.conf+    search_paths:+      - path: ~/dotfiles+        recursive: true+```++This makes confetti a great tool for managing things like dotfiles, where the target config files+are located in a github repo anywhere on the machine. Simply running:+`confetti dotfiles` could link `~/.spacemacs` -> the first `.spacemacs` found in a recursive search of+`~/dotfiles`. If your workflow involves regularly swapping any such files, the small amount of +initial configuration can be well worth the cost!++One last feature is that you can specify a top level `common` section that will +be applied to all other groups. For instance:++```+common:+  targets:+      - ~/dir/some.file+  search_paths:+    - path: ~/some/directory+      recursive: false+```++Now, every group we try to use confetti for will also link `~/dir/some.file`, and+will also use `/some/directory` as a search path.++## Installing++### MacOS++You can install with brew via:+```+$ brew tap aviaviavi/homebrew-tap+$ brew install aviaviavi/homebrew-tap/confetti+```++### Linux ++A linux binary can be found on the releases page.++### Source++You can install from source with [stack](https://docs.haskellstack.org/en/stable/README/).++### Windows++Confetti does not currently work on Windows, as there is some reliance on POSIX+for dealing with the file system. In theory it should be fairly straight-forward to+get it working on Windows if anyone is up for implementing it. Feel encouraged to submit a PR+or reach out for any related discussion.++## Contributing++Contributions in any form are welcome!++## Future Features++* Config groups should be able to source bash files or sets of environment variables.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,54 @@+{-# LANGUAGE OverloadedStrings #-}++import           Data.Version       (showVersion)+import           Lib+import           Paths_confetti     (version)+import           System.Environment+import           System.Exit+import           Text.Printf++import qualified Data.Text          as T++-- Structure for our command line arguments+data MainArgs = MainArgs+  { groupName     :: String+  , variantPrefix :: ConfigVariantPrefix+  } deriving (Show)++-- Parse our command line arguments for a confetti command,+-- or display some information and exit+parseMainArgs :: [String] -> IO MainArgs+parseMainArgs [g, v] = return (MainArgs g $ Just v)+parseMainArgs a =+  let usage =+        "Usage: `confetti [required group_name] [optional variant_prefix]`"+      showHelp = (putStrLn usage >> exitSuccess)+      confettiVersion =+        putStrLn ("confetti " ++ showVersion version) >> exitSuccess+  in case a of+       ["-h"]        -> showHelp+       ["--help"]    -> showHelp+       ["-v"]        -> confettiVersion+       ["--version"] -> confettiVersion+       [g]           -> return (MainArgs g Nothing)+       _             -> printFail usage >> exitWith (ExitFailure 1)++main :: IO ()+main = do+  args <- getArgs+  parsed <- parseMainArgs args+  printf+    "Setting %s to %s\n"+    (groupName parsed)+    (showPrefix $ variantPrefix parsed)+  specPath <- absolutePath "~/.confetti.yml"+  group <- parseGroup specPath (T.pack $ groupName parsed)+  let validatedGroup = group >>= validateSpec+  either (printFail . show) (runSpec (variantPrefix parsed)) validatedGroup++-- Run our configuration spec. Prints whatever error message we get back,+-- or a success message+runSpec :: ConfigVariantPrefix -> ConfigGroup -> IO ()+runSpec prefix group =+  applySpec (ConfigSpec group prefix) >>= \maybeError ->+    maybe (printSuccess "Success (ノ◕ヮ◕)ノ*:・゚✧") (printFail . show) maybeError
+ confetti.cabal view
@@ -0,0 +1,64 @@+name:                confetti+version:             0.3.1+synopsis:            A simple config file swapping tool+category:            Command Line Tools+description:         See the README on GitHub at <https://github.com/aviaviavi/confetti#readme>++license:             MIT+license-file:        LICENSE+author:              Avi Press+maintainer:          avipress@gmail.com+homepage:            https://github.com/aviaviavi/confetti+bug-reports:         https://github.com/aviaviavi/confetti/issues++build-type:          Simple+stability:           alpha (experimental)+cabal-version:       >=1.10++extra-source-files:+  README.md+  stack.yaml++source-repository head+  type:     git+  location: https://github.com/aviaviavi/confetti++library+  default-language:  Haskell2010+  hs-source-dirs:    src+  exposed-modules:   Lib+  build-depends:     base >= 4.8 && < 5+                   , directory+                   , text+                   , text >= 1.2.2.1+                   , unix+                   , MissingH+                   , time+                   , yaml+                   , filepath+  other-modules:+      Paths_confetti++executable confetti+  default-language:  Haskell2010+  hs-source-dirs:    app+  main-is:           Main.hs+  build-depends:     base >= 4.8 && < 5+                   , directory+                   , confetti+                   , text >= 1.2.2.1+  other-modules:+      Paths_confetti++test-suite confetti-test+  type:              exitcode-stdio-1.0+  default-language:  Haskell2010+  hs-source-dirs:    test+  main-is:           Main.hs+  build-depends:     base >= 4.8 && < 5+                   , confetti+                   , tasty >= 0.11+                   , tasty-hunit >= 0.9+                   , tasty-smallcheck >= 0.8+                   , text >= 1.2.2.1+
+ src/Lib.hs view
@@ -0,0 +1,354 @@+{-# LANGUAGE DeriveGeneric         #-}+{-# LANGUAGE OverloadedStrings     #-}++module Lib where++import           Control.Exception+import           Control.Monad+import           Data.List+import           Data.List.Utils+import           Data.Either.Utils+import           Data.Maybe+import           Data.Monoid+import           Data.Time.Clock.POSIX+import           Data.Yaml+import           GHC.Generics+import           System.Directory+import           System.FilePath.Posix+import           System.IO.Error+import           System.Posix.Files+import           Text.Printf+++import qualified Data.Text             as T++-- Errors from parsing or validating .confetti.yml+data ParseError a = ConfettiYamlNotFound | GroupNotFound a | ConfettiYamlInvalid a | DuplicateNameError a deriving (Generic)++instance (Show a) =>+         Show (ParseError a) where+  show (ConfettiYamlInvalid a) =+    "There was an issue parsing your ~/.confetti.yml: " ++ show a+  show (GroupNotFound a) =+    printf "No match for group %s found in your ~/.confetti.yml: " (show a)+  show ConfettiYamlNotFound =+    "No confetti spec file found! See https://github.com/aviaviavi/confetti if you need help setting one up"+  show (DuplicateNameError a) =+    printf+      "Multiple targets in this group share the same name: %s.\+       \ Confetti doesn't yet know how to figure out target to link a search match to,\+       \ but a future version will. Try breaking these targets into multiple groups."+      (show a)++-- Errors from applying our config spec+newtype ApplyError a = VariantsMissing [a]+instance (Show a) => Show (ApplyError a)  where+  show (VariantsMissing a) = "Couldn't find one or more of your variant files to use: " ++ show a++-- A config file version we want to swap in or out+type ConfigVariant = String++-- The config file version we want to swap in or out, with no directory+-- information, eg `credentials`, not `~/.aws/credentials`+type ConfigVariantFileName = String++-- The prefix specifies how we construct a variant.+-- If the target is config.json, and we want to link local.config.json,+-- (Just "local") would be the prefix+type ConfigVariantPrefix = Maybe String++-- Small helper for printing prefix+showPrefix :: ConfigVariantPrefix -> String+showPrefix prefix = case prefix of+  (Just p) -> show p+  Nothing  -> "bare matches in search paths"++-- The actual config file in question.+type ConfigTarget = String++-- Lets us specify paths where the variant files in a group can be found.+data SearchPath = SearchPath+  { path      :: FilePath+  , recursive :: Maybe Bool+  } deriving (Show, Eq, Generic)++instance FromJSON SearchPath++-- Represents a search result for a given target, variant and directory+data VariantSearch = VariantSearch+  { searchDirectory :: FilePath+  , fileName        :: ConfigVariantFileName+  , recursiveSearch :: Bool+  , result          :: Maybe ConfigVariant -- populated when find the file we want to swap in+  , linkToCreate    :: ConfigTarget+  } deriving (Show, Generic)++-- A set of configuration targets+data ConfigGroup = ConfigGroup+  { name        :: T.Text+  , targets     :: [FilePath]+  , searchPaths :: Maybe [SearchPath]+  } deriving (Show, Generic)++instance FromJSON ConfigGroup where+  parseJSON (Object x) =+    ConfigGroup <$> x .: "name" <*> x .: "targets" <*> x .:? "search_paths"+  parseJSON _ = fail "Expected an object"++data CommonConfigGroup = CommonConfigGroup+  { commonTargets     :: [FilePath]+  , commonSearchPaths :: Maybe [SearchPath]+  } deriving (Show, Generic)++instance FromJSON CommonConfigGroup where+  parseJSON (Object x) =+    CommonConfigGroup <$> x .: "targets" <*> x .:? "search_paths"+  parseJSON _ = fail "Expected an object"++-- A valid .confetti.yml gets parsed into this structure+data ParsedSpecFile = ParsedSpecFile+  { groups      :: [ConfigGroup]+  , commonGroup :: Maybe CommonConfigGroup+  } deriving (Show, Generic)++instance FromJSON ParsedSpecFile where+  parseJSON (Object x) =+    ParsedSpecFile <$> x .: "groups" <*> x .:? "common"+  parseJSON _ = fail "Expected an object"++-- A full config specification:+-- a group of files, and a variant to swap in for+-- every target in the group+data ConfigSpec = ConfigSpec+  { configGroup         :: ConfigGroup+  , configVariantPrefix :: ConfigVariantPrefix+  }++-- Given a yaml file and a group name, parse the group into a ConfigGroup, or+-- a ParseError.+-- If a `common` group is specified, that group will be combined with the parsed one+parseGroup :: FilePath -> T.Text -> IO (Either (ParseError T.Text) ConfigGroup)+parseGroup specFile groupName =+  doesFileExist specFile >>= \exists -> parseGroup' exists+  where+    parseGroup' exists+      | not exists = return $ Left ConfettiYamlNotFound+      | otherwise = do+        eitherSpec <- decodeFileEither specFile+        eitherGroup <-+          either+            (return .+             Left . ConfettiYamlInvalid . T.pack . prettyPrintParseException)+            (`findGroup` groupName)+            eitherSpec+        either+          (return . Left)+          (\g ->+             let spec = fromRight eitherSpec+             in if isJust $ commonGroup spec+                  then Right <$>+                       appendCommonGroup g (fromJust $ commonGroup spec)+                  else return $ Right g)+          eitherGroup++-- Combine whatever group we parsed with the common group, if one was+-- specified+appendCommonGroup :: ConfigGroup -> CommonConfigGroup -> IO ConfigGroup+appendCommonGroup g common = do+  cTargets <- mapM absolutePath $ commonTargets common+  cSearchPaths <- defaultSearchPaths cTargets (commonSearchPaths common)+  adjustedGroupSearchPaths <- defaultSearchPaths (targets g) (searchPaths g)+  return+    ConfigGroup+    { name = name g+    , targets = uniq $ targets g ++ cTargets+    , searchPaths = Just . uniq $ adjustedGroupSearchPaths <> cSearchPaths+    }++-- Expand all search paths. If search paths are empty, use the paths of the+-- supplied targets+defaultSearchPaths :: [ConfigTarget] -> Maybe [SearchPath] -> IO [SearchPath]+defaultSearchPaths ts ss =+  let unadjusted =+        fromMaybe+           (map+              (\t -> SearchPath {path = takeDirectory t, recursive = Just False})+              ts)+           ss+  in+  mapM+    (\s -> do+       absolute <- absolutePath (path s)+       return $ s {path = absolute})+    unadjusted++-- Any custom validation we want to do on a config group we've successfully parsed+-- goes here+validateSpec :: ConfigGroup -> Either (ParseError T.Text) ConfigGroup+validateSpec groupSpec =+  let targetFileNames = map takeFileName $ targets groupSpec+  in if length targetFileNames > length (uniq targetFileNames)+       then Left+              (DuplicateNameError $+               T.pack . head $ targetFileNames \\ uniq targetFileNames)+       else Right groupSpec++-- Finds a given group in a parsed spec file. Returns a GroupNotFound if the group+-- is missing+findGroup :: ParsedSpecFile+          -> T.Text+          -> IO (Either (ParseError T.Text) ConfigGroup)+findGroup spec groupName =+  sequence $+  maybe (Left $ GroupNotFound groupName) (Right . expandPathsForGroup) $+  find (\g -> name g == groupName) (groups spec)++-- Replaces ~ with the value of $HOME for all files in the group+expandPathsForGroup :: ConfigGroup -> IO ConfigGroup+expandPathsForGroup confGroup =+  mapM absolutePath (targets confGroup) >>= \expanded ->+    return $ confGroup {targets = expanded}++-- If a target config file is _not_ a symlink,+-- make a backup before we swap out the config+backUpIfNonSymLink :: FilePath -> IO ()+backUpIfNonSymLink file = do+  exists <- doesFileExist file+  isLink <-+    if exists+      then pathIsSymbolicLink file+      else return False+  when (exists && not isLink) $ createBackup file++-- Backs up a file, eg config.json -> config.json.$time.backup+createBackup :: FilePath -> IO ()+createBackup file =+  let newName =+        (round <$> getPOSIXTime) >>=+        (\t -> return $ file ++ "." ++ show t ++ ".backup")+  in newName >>= \backup -> copyFile file backup++removeIfExists :: FilePath -> IO ()+removeIfExists f = removeFile f `catch` handleExists+  where+    handleExists e+      | isDoesNotExistError e = return ()+      | otherwise = throwIO e++-- Given a list of variant configs, returns the variants that+-- do not exist+filterMissingVariants :: [ConfigVariant] -> IO [FilePath]+filterMissingVariants = filterM (fmap not . doesFileExist)++-- This is where the actual config swapping happens. For every ConfigTarget,+-- creates a symlink from the target -> variant.+-- For instance, for target = ~/.aws/credentials, and variant "work",+-- the link created would be+-- ~/.aws/credentials -> ~/.aws/work.credentials+linkTargets :: [VariantSearch] -> IO ()+linkTargets =+  mapM_+    (\pair -> createSymbolicLink (fromJust $ result pair) (linkToCreate pair))++-- Given a variant prefix, eg `dev`, and a target, eg `~/.aws/credentials`,+-- construct the full path of the variant, `~/.aws/dev.credentials`+makeVariant :: ConfigVariantPrefix -> ConfigTarget -> ConfigVariantFileName+makeVariant prefix target =+  let p = maybe "" (++ ".") prefix+  in p ++ takeFileName target++-- Given a prefix, a list of targets, construct a list of variant filenames, and search+-- for matches in all of the given search paths+searchVariants :: ConfigVariantPrefix+               -> [ConfigTarget]+               -> [SearchPath]+               -> IO [VariantSearch]+searchVariants variant targetFiles vPaths =+  concat <$>+  mapM (\target -> mapM (findVariantInPath variant target) vPaths) targetFiles++-- Get all the contents of a directory recursively+getRecursiveContents :: FilePath -> IO [FilePath]+getRecursiveContents topdir = do+  names <- getDirectoryContents topdir+  let properNames = filter (`notElem` [".", ".."]) names+  paths <-+    forM properNames $ \n -> do+      let nextPath = topdir </> n+      isDir <- doesDirectoryExist nextPath+      if isDir+        then getRecursiveContents nextPath+        else return [nextPath]+  return (concat paths)++-- Perform a search for a single file, and return the search result+findVariantInPath :: ConfigVariantPrefix+                  -> ConfigTarget+                  -> SearchPath+                  -> IO VariantSearch+findVariantInPath prefix target searchPath =+  let fileToFind = makeVariant prefix target+  in do pathName <- absolutePath $ path searchPath+        let isRecursive = fromMaybe False $ recursive searchPath+        searchResult <-+          if isRecursive+            then find (\f -> endswith fileToFind f && (target /= f)) <$>+                 getRecursiveContents pathName+            else do+              let potentialVariant = pathName </> fileToFind+              exists <- doesFileExist potentialVariant+              if exists+                then return $ Just potentialVariant+                else return Nothing+        return+          VariantSearch+          { searchDirectory = path searchPath+          , fileName = fileToFind+          , recursiveSearch = isRecursive+          , linkToCreate = target+          , result = searchResult+          }++-- Run the spec! Every target will be a symlink to the variant+-- file+applySpec :: ConfigSpec -> IO (Maybe (ApplyError FilePath))+applySpec spec = do+  let groupTargets = targets $ configGroup spec+  searchResults <-+    searchVariants+      (configVariantPrefix spec)+      groupTargets+      (fromMaybe+         (map+            (\t -> SearchPath {path = takeDirectory t, recursive = Just False})+            groupTargets)+         (searchPaths $ configGroup spec))+  mapM_ backUpIfNonSymLink groupTargets+  mapM_ removeIfExists groupTargets+  let confirmedVariantFiles = filter (isJust . result) searchResults+      foundFiles =+        uniq $ map (takeFileName . fromJust . result) confirmedVariantFiles+      allFiles = uniq $ map fileName searchResults+      missingVariants = allFiles \\ foundFiles+  if null missingVariants+    then do+      mapM_+        (\s -> printSuccess $ linkToCreate s ++ " -> " ++ fromJust (result s))+        confirmedVariantFiles+      linkTargets confirmedVariantFiles+      return Nothing+    else return $ Just (VariantsMissing missingVariants)++-- Expands ~ to $HOME in a path+absolutePath :: FilePath -> IO FilePath+absolutePath p = do+  home <- getHomeDirectory+  return $ replace "~" home p++-- Prints green+printSuccess :: String -> IO ()+printSuccess s = putStrLn $ "\x1b[32m" ++ s ++ "\x1B[0m"++-- Prints red+printFail :: String -> IO ()+printFail s = putStrLn $ "\x1b[31m" ++  s ++ "\x1B[0m"
+ stack.yaml view
@@ -0,0 +1,6 @@+flags: {}+extra-package-dbs: []+packages:+- .+extra-deps: []+resolver: lts-12.7
+ test/Main.hs view
@@ -0,0 +1,12 @@+import Test.Tasty+import Test.Tasty.HUnit+import Test.Tasty.SmallCheck++import Lib++main :: IO ()+main = defaultMain $ testGroup "all-tests" tests++-- TODO: some tests would be good for future avi to write.+tests :: [TestTree]+tests = []