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 +21/−0
- README.md +122/−0
- Setup.hs +2/−0
- app/Main.hs +54/−0
- confetti.cabal +64/−0
- src/Lib.hs +354/−0
- stack.yaml +6/−0
- test/Main.hs +12/−0
+ 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+==========++[](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 = []