mdoc 0.2.0.0 → 0.3.0.0
raw patch · 14 files changed
+371/−55 lines, 14 files
Files
- examples/pass.1 +137/−0
- mdoc.cabal +4/−1
- src/Mdoc/Data/Argument.hs +4/−4
- src/Mdoc/Data/Command.hs +20/−0
- src/Mdoc/Data/Described.hs +0/−12
- src/Mdoc/Data/List.hs +17/−12
- src/Mdoc/Data/Page.hs +18/−4
- src/Mdoc/Data/Synopsis.hs +1/−4
- src/Mdoc/Examples/Pass.hs +100/−0
- src/Mdoc/Prelude.hs +1/−1
- src/Mdoc/Syntax/Mdoc.hs +16/−0
- src/Options/Applicative/Mdoc.hs +50/−12
- test/Mdoc/Data/SynopsisSpec.hs +1/−5
- test/Mdoc/ExamplesSpec.hs +2/−0
+ examples/pass.1 view
@@ -0,0 +1,137 @@+.\" vim: ft=nroff.mustache+.Dd $Mdocdate: September 20 2026 $+.Dt PASS 1+.Os+.Sh NAME+.Nm pass+.Nd stores, retrieves, generates, and synchronizes passwords securely+.Sh SYNOPSIS+.Nm+.Bk -words+.Ar command+.Ek+.Pp+.Nm+.Cm init+.Bk -words+.Op Fl p Ar sub\-folder+.Ar gpg\-id+.Ek+.Pp+.Nm+.Cm ls+.Bk -words+.Ar subfolder+.Ek+.Pp+.Nm+.Cm grep+.Bk -words+.Op Ar GREPOPTIONS+.Ar search\-string+.Ek+.Pp+.Nm+.Cm find+.Bk -words+.Ar pass\-names+.Op Ar pass\-names ...+.Ek+.Pp+.Nm+.Cm show+.Bk -words+.Op Fl c Ar line\-number+.Op Fl q Ar line\-number+.Ar pass\-name+.Ek+.Pp+.Nm+.Cm help+.Pp+.Nm+.Cm version+.Sh DESCRIPTION+.Nm+is a very simple password store that keeps passwords inside+.Xr gpg2 1+encrypted files inside a simple directory tree residing at+.Pa ~/.password-store .+The pass utility provides a series of commands for manipulating the password+store, allowing the user to add, remove, edit, synchronize, generate, and+manipulate passwords.+.Pp+If no COMMAND is specified, COMMAND defaults to either+.Cm show+or+.Cm ls ,+depending on the type of specifier in ARGS. Alternatively, if+.Ar PASSWORD_STORE_ENABLE_EXTENSIONS+is set to "true", and the file+.Pa .extensions/COMMAND.bash+exists inside the password store and is executable, then it is sourced into the+environment, passing any arguments and environment variables. Extensions+existing in a system- wide directory, only installable by the administrator, are+always enabled.+.Pp+Otherwise COMMAND must be one of the valid commands listed below.+.Pp+Several of the commands below rely on or provide additional functionality if the+password store directory is also a git repository. If the password store+directory is a git repository, all password store modification commands will+cause a corresponding git commit. Sub- directories may be separate nested git+repositories, and pass will use the inner-most directory relative to the current+password. See the+.Ar EXTENDED GIT EXAMPLE+section for a detailed description using init and+.Xr git 1+.Pp+The+.Cm init+command must be run before other commands in order to initialize the password+store with the correct gpg key id. Passwords are encrypted using the gpg key set+with+.Cm init .+.Pp+There is a corresponding bash completion script for use with tab completing+password names in+.Xr bash 1 .+.Ss COMMANDS+.Bl -tag -width indent+.It Cm init+Initialize new password storage and use gpg\-id for encryption. Multiple+gpg\-ids may be specified, in order to encrypt each password with multiple ids.+This command must be run first before a password store can be used. If the+specified gpg\-id is different from the key used in any existing files, these+files will be reencrypted to use the new id. Note that use of gpg\-agent(1) is+recommended so that the batch decryption does not require as much user+intervention. If \-\-path or \-p is specified, along with an argument, a+specific gpg\-id or set of gpg\-ids is assigned for that specific sub folder of+the password store. If only one gpg\-id is given, and it is an empty string,+then the current .gpg\-id file for the specified sub\-folder (or root if+unspecified) is removed.+.It Cm ls+List names of passwords inside the tree at subfolder by using the tree(1)+program. This command is alternatively named list.+.It Cm grep+Searches inside each decrypted password file for search\-string, and displays+line containing matched string along with filename. Uses grep(1) for matching.+GREPOPTIONS are passed to grep(1) as\-is. (Note: the GREP_OPTIONS environment+variable functions as well.)+.It Cm find+List names of passwords inside the tree that match pass\-names by using the+tree(1) program. This command is alternatively named search.+.It Cm show+Decrypt and print a password named pass\-name. If \-\-clip or \-c is specified,+do not print the password but instead copy the first (or otherwise specified)+line to the clipboard using xclip(1) or wl\-clipboard(1) and then restore the+clipboard after 45 (or PASSWORD_STORE_CLIP_TIME) seconds. If \-\-qrcode or \-q+is specified, do not print the password but instead display a QR code using+qrencode(1) either to the terminal or graphically if supported.+.It Cm help+Show usage message.+.It Cm version+Show version information.+.El+.Sh EXIT STATUS+.Ex -std
mdoc.cabal view
@@ -1,6 +1,6 @@ cabal-version: 1.18 name: mdoc-version: 0.2.0.0+version: 0.3.0.0 license: AGPL-3 license-file: COPYING maintainer: Pat Brisbin@@ -37,6 +37,7 @@ examples/grep.1 examples/mdoc-dump.1 examples/mdoc-dump.1.html+ examples/pass.1 examples/person.5 examples/style.css @@ -54,6 +55,7 @@ Env.Mdoc Mdoc Mdoc.Data.Argument+ Mdoc.Data.Command Mdoc.Data.Config Mdoc.Data.Described Mdoc.Data.EnvVar@@ -72,6 +74,7 @@ Mdoc.Dump.Options Mdoc.Examples.Grep Mdoc.Examples.OptEnvConf+ Mdoc.Examples.Pass Mdoc.Examples.Person Mdoc.Input Mdoc.Optics
src/Mdoc/Data/Argument.hs view
@@ -37,8 +37,8 @@ instance Pretty Argument where pretty Argument {schema, optionality} = case optionality of- Required -> "Ar" <+> pretty schema- _ -> "Op Ar" <+> pretty schema+ Required -> "Ar" <+> pretty (esc $ pack schema)+ _ -> "Op Ar" <+> pretty (esc $ pack schema) data ShortArg = ShortArg { name :: Char@@ -49,7 +49,7 @@ pretty (ShortArg {name, argument}) = prefix (pretty $ esc $ T.singleton name) <+> pretty Ar- <+> pretty argument.schema+ <+> pretty (esc $ pack argument.schema) where prefix :: Doc ann -> Doc ann prefix = case argument.optionality of@@ -66,7 +66,7 @@ prefix (pretty (esc $ pack name)) <+> pretty Ns <+> pretty Ar- <+> pretty argument.schema+ <+> pretty (esc $ pack argument.schema) where prefix :: Doc ann -> Doc ann prefix = case argument.optionality of
+ src/Mdoc/Data/Command.hs view
@@ -0,0 +1,20 @@+module Mdoc.Data.Command+ ( Command (..)+ ) where++import Mdoc.Prelude++import Mdoc.Data.Synopsis (Synopsis)+import Mdoc.Syntax.Mdoc++data Command = Command+ { index :: Int+ , name :: String+ , synopsis :: Synopsis+ , description :: Maybe Mdoc+ }+ deriving stock (Eq, Generic, Show)+ deriving anyclass (ToJSON)++instance Ord Command where+ compare = comparing (.index)
src/Mdoc/Data/Described.hs view
@@ -18,7 +18,6 @@ import Data.Aeson (object, (.=)) import Data.Function (on)-import Data.Text qualified as T import Mdoc.Data.Optionality import Mdoc.Optics import Mdoc.Pretty@@ -77,14 +76,3 @@ -- | Set a 'Described's 'helpLines' from a 'Text' setHelpText :: Text -> Described a -> Described a setHelpText = maybe id setHelp . textToMdoc--textToMdoc :: Text -> Maybe Mdoc-textToMdoc =- fmap- ( Mdoc- . intersperse (MacroLine Pp [])- . map (TextLine . esc)- . toList- )- . nonEmpty- . T.lines
src/Mdoc/Data/List.hs view
@@ -19,17 +19,16 @@ import Mdoc.Prelude -import Data.Aeson (object, (.=))+import Data.Aeson (Value (..), object, (.=))+import Data.Aeson.KeyMap qualified as KeyMap import Data.Kind (Constraint, Type) import Data.List.NonEmpty qualified as NE import Data.Text qualified as T-import Mdoc.Data.Described-import Mdoc.Pretty import Mdoc.Syntax type List :: forall {k}. k -> Type -> Type newtype List t a = List- { items :: NonEmpty (Described a)+ { items :: NonEmpty a } deriving stock (Eq, Generic, Show) deriving (Semigroup) via Generically (List t a)@@ -41,14 +40,14 @@ , "items" .= items ] where- items :: NonEmpty (Described a)+ items :: NonEmpty a items = NE.sort $ NE.nub l.items -fromNonEmpty :: NonEmpty (Described a) -> List t a+fromNonEmpty :: NonEmpty a -> List t a fromNonEmpty = foldMap1 singleton -singleton :: Described a -> List t a-singleton described = List {items = pure described}+singleton :: a -> List t a+singleton a = List {items = pure a} data Indent @@ -56,21 +55,27 @@ type Width :: forall {k}. k -> Type -> Constraint class Width t a where- renderedWidth :: Proxy t -> NonEmpty (Described a) -> MacroArg+ renderedWidth :: Proxy t -> NonEmpty a -> MacroArg instance Width Indent a where renderedWidth _ _ = "indent" -instance Pretty a => Width ByItem a where+instance ToJSON a => Width ByItem a where renderedWidth _ = Quoted . esc . maximumBy (comparing T.length) . fmap headWidth -headWidth :: Pretty a => Described a -> Text-headWidth = go . T.words . renderPlain . pretty . (.item)+headWidth :: ToJSON a => a -> Text+headWidth = go . T.words . getHead . toJSON where+ getHead :: Value -> Text+ getHead v = fromMaybe "" $ do+ Object km <- pure v+ String t <- KeyMap.lookup "head" km+ pure t+ go :: [Text] -> Text go = \case [] -> ""
src/Mdoc/Data/Page.hs view
@@ -13,12 +13,15 @@ , addSwitch , addOption , addArgument+ , addCommand+ , addCommands , addConfig -- * @ENVIRONMENT@ , addEnvVar - -- * Other, rarel-used elements+ -- * Other, rarely-used elements+ , getSynopsis , setSynopsis , setPrologue , setEpilogue@@ -27,6 +30,7 @@ import Mdoc.Prelude import Mdoc.Data.Argument+import Mdoc.Data.Command import Mdoc.Data.Config import Mdoc.Data.Described import Mdoc.Data.EnvVar@@ -41,9 +45,10 @@ data Page = Page { synopsis :: Maybe Synopsis , prologue :: Maybe Mdoc- , options :: Maybe (List Indent Positional)- , configs :: Maybe (List Indent Config)- , environment :: Maybe (List ByItem EnvVar)+ , options :: Maybe (List Indent (Described Positional))+ , commands :: Maybe (List Indent Command)+ , configs :: Maybe (List Indent (Described Config))+ , environment :: Maybe (List ByItem (Described EnvVar)) , epilogue :: Maybe Mdoc } deriving stock (Eq, Generic, Show)@@ -65,6 +70,12 @@ & field @"synopsis" <?>~ Synopsis.singleton described & field @"options" <?>~ List.singleton described +addCommand :: Command -> Page -> Page+addCommand c m = m & field @"commands" <?>~ List.singleton c++addCommands :: [Command] -> Page -> Page+addCommands = appEndo . foldMap (Endo . addCommand)+ addConfig :: Described Config -> Page -> Page addConfig described m = m & field @"configs" <?>~ List.singleton described@@ -72,6 +83,9 @@ addEnvVar :: Described EnvVar -> Page -> Page addEnvVar described m = m & field @"environment" <?>~ List.singleton described++getSynopsis :: Page -> Synopsis+getSynopsis p = fromMaybe mempty p.synopsis setSynopsis :: Mdoc -> Page -> Page setSynopsis s = field @"synopsis" ?~ SynopsisCustom s
src/Mdoc/Data/Synopsis.hs view
@@ -64,11 +64,8 @@ unAnnotate $ vsep $ catMaybes- [ Just ".Nm"- , Just ".Bk -words"- , (".Op Fl" <+>) . pretty . esc . pack . toList <$> nonEmpty u.chars+ [ (".Op Fl" <+>) . pretty . esc . pack . toList <$> nonEmpty u.chars , vsep . toList <$> nonEmpty (u.shorts <> u.longs <> u.args)- , Just ".Ek" ] buildUsage :: [Described Positional] -> Usage ann
+ src/Mdoc/Examples/Pass.hs view
@@ -0,0 +1,100 @@+-- |+--+-- Module : Mdoc.Examples.Pass+-- Copyright : (c) 2026 Patrick Brisbin+-- License : AGPL-3+-- Maintainer : pbrisbin@gmail.com+-- Stability : experimental+-- Portability : POSIX+module Mdoc.Examples.Pass+ ( pass1+ ) where++import Mdoc.Prelude++import Mdoc.Data.Named+import Mdoc.Data.Page (setPrologue)+import Mdoc.Syntax+import Options.Applicative+import Options.Applicative.Mdoc++pass1 :: Named+pass1 =+ mempty+ & (<> getPage passOpt)+ & setPrologue+ ( Mdoc+ [ MacroLine Nm []+ , TextLine "is a very simple password store that keeps passwords inside"+ , MacroLine Xr ["gpg2", "1"]+ , TextLine "encrypted files inside a simple directory tree residing at"+ , MacroLine Pa ["~/.password-store", "."]+ , TextLine+ "The pass utility provides a series of commands for manipulating the password store, allowing the user to add, remove, edit, synchronize, generate, and manipulate passwords."+ , MacroLine Pp []+ , TextLine "If no COMMAND is specified, COMMAND defaults to either"+ , MacroLine Cm ["show"]+ , TextLine "or"+ , MacroLine Cm ["ls", ","]+ , TextLine "depending on the type of specifier in ARGS. Alternatively, if"+ , MacroLine Ar ["PASSWORD_STORE_ENABLE_EXTENSIONS"]+ , TextLine "is set to \"true\", and the file"+ , MacroLine Pa [".extensions/COMMAND.bash"]+ , TextLine+ "exists inside the password store and is executable, then it is sourced into the environment, passing any arguments and environment variables. Extensions existing in a system- wide directory, only installable by the administrator, are always enabled."+ , MacroLine Pp []+ , TextLine "Otherwise COMMAND must be one of the valid commands listed below."+ , MacroLine Pp []+ , TextLine+ "Several of the commands below rely on or provide additional functionality if the password store directory is also a git repository. If the password store directory is a git repository, all password store modification commands will cause a corresponding git commit. Sub- directories may be separate nested git repositories, and pass will use the inner-most directory relative to the current password. See the"+ , MacroLine Ar ["EXTENDED", "GIT", "EXAMPLE"]+ , TextLine "section for a detailed description using init and"+ , MacroLine Xr ["git", "1"]+ , MacroLine Pp []+ , TextLine "The"+ , MacroLine Cm ["init"]+ , TextLine+ "command must be run before other commands in order to initialize the password store with the correct gpg key id. Passwords are encrypted using the gpg key set with"+ , MacroLine Cm ["init", "."]+ , MacroLine Pp []+ , TextLine+ "There is a corresponding bash completion script for use with tab completing password names in"+ , MacroLine Xr ["bash", "1", "."]+ ]+ )+ & name "pass" "stores, retrieves, generates, and synchronizes passwords securely"++{- FOURMOLU_DISABLE -}+passOpt :: Parser ()+passOpt = void $ subparser (mconcat+ [ command "init" $ info initOpt $ progDesc "Initialize new password storage and use gpg-id for encryption. Multiple gpg-ids may be specified, in order to encrypt each password with multiple ids. This command must be run first before a password store can be used. If the specified gpg-id is different from the key used in any existing files, these files will be reencrypted to use the new id. Note that use of gpg-agent(1) is recommended so that the batch decryption does not require as much user intervention. If --path or -p is specified, along with an argument, a specific gpg-id or set of gpg-ids is assigned for that specific sub folder of the password store. If only one gpg-id is given, and it is an empty string, then the current .gpg-id file for the specified sub-folder (or root if unspecified) is removed."+ , command "ls" $ info lsOpt $ progDesc "List names of passwords inside the tree at subfolder by using the tree(1) program. This command is alternatively named list."+ , command "grep" $ info grepOpt $ progDesc "Searches inside each decrypted password file for search-string, and displays line containing matched string along with filename. Uses grep(1) for matching. GREPOPTIONS are passed to grep(1) as-is. (Note: the GREP_OPTIONS environment variable functions as well.)"+ , command "find" $ info findOpt $ progDesc "List names of passwords inside the tree that match pass-names by using the tree(1) program. This command is alternatively named search."+ , command "show" $ info showOpt $ progDesc "Decrypt and print a password named pass-name. If --clip or -c is specified, do not print the password but instead copy the first (or otherwise specified) line to the clipboard using xclip(1) or wl-clipboard(1) and then restore the clipboard after 45 (or PASSWORD_STORE_CLIP_TIME) seconds. If --qrcode or -q is specified, do not print the password but instead display a QR code using qrencode(1) either to the terminal or graphically if supported."+ , command "help" $ info (pure ()) $ progDesc "Show usage message."+ , command "version" $ info (pure ()) $ progDesc "Show version information."+ ])++initOpt :: Parser ()+initOpt = void $ (,)+ <$> optional (option (str @Text) (short 'p' <> long "path" <> metavar "sub-folder"))+ <*> argument (str @Text) (metavar "gpg-id")++lsOpt :: Parser ()+lsOpt = void $ argument (str @Text) (metavar "subfolder") -- sic++grepOpt :: Parser ()+grepOpt = void $ (,)+ <$> optional (argument (str @Text) (metavar "GREPOPTIONS"))+ <*> argument (str @Text) (metavar "search-string")++findOpt :: Parser ()+findOpt = void $ some1 $ argument (str @Text) (metavar "pass-names")++showOpt :: Parser ()+showOpt = void $ (,,)+ <$> optional (option (str @Text) (short 'c' <> long "clip" <> metavar "line-number"))+ <*> optional (option (str @Text) (short 'q' <> long "qrcode" <> metavar "line-number"))+ <*> argument (str @Text) (metavar "pass-name")+{- FOURMOLU_ENABLE -}
src/Mdoc/Prelude.hs view
@@ -24,7 +24,7 @@ import Data.Aeson as X (ToJSON (..)) import Data.Bifunctor as X (bimap, first, second) import Data.Foldable as X (for_, toList, traverse_)-import Data.Foldable1 as X (foldMap1, maximumBy)+import Data.Foldable1 as X (Foldable1, foldMap1, maximumBy, toNonEmpty) import Data.Function as X ((&)) import Data.Functor as X ((<&>)) import Data.Functor.Identity as X (Identity)
src/Mdoc/Syntax/Mdoc.hs view
@@ -11,11 +11,16 @@ -- * Pretty , prettyMdoc++ -- * Util+ , textToMdoc ) where import Mdoc.Prelude +import Data.Text qualified as T import Mdoc.Pretty+import Mdoc.Syntax.MacroName import Mdoc.Syntax.MdocLine newtype Mdoc = Mdoc@@ -30,3 +35,14 @@ instance Pretty Mdoc where pretty = unAnnotate . prettyMdoc++textToMdoc :: Text -> Maybe Mdoc+textToMdoc =+ fmap+ ( Mdoc+ . intersperse (MacroLine Pp [])+ . map (TextLine . esc)+ . toList+ )+ . nonEmpty+ . T.lines
src/Options/Applicative/Mdoc.hs view
@@ -16,8 +16,11 @@ import Mdoc.Prelude +import Control.Monad (foldM)+import Control.Monad.State (MonadState (..), evalState, modify) import Data.List (sort) import Mdoc.Data.Argument+import Mdoc.Data.Command import Mdoc.Data.Described import Mdoc.Data.Flag import Mdoc.Data.Option@@ -32,8 +35,24 @@ import Prettyprinter.Render.Text qualified as Pretty getPage :: Parser a -> Page-getPage p = foldOptTree 0 mempty optionToMan1 $ treeMapParser (const void) p+getPage p = foldOptTree optionToMan1 $ treeMapParser (const void) p +getCommand :: Int -> String -> O.ParserInfo x -> Command+getCommand index name pinfo =+ Command+ { index+ , name+ , description = textToMdoc =<< docToText doc+ , synopsis = getSynopsis $ getPage $ O.infoParser pinfo+ }+ where+ doc =+ mconcat+ [ O.infoProgDesc pinfo+ , O.infoHeader pinfo+ , O.infoFooter pinfo+ ]+ optionToMan1 :: Int -> Page@@ -54,7 +73,12 @@ schema <- metavar let argument = Argument {index, schema, optionality = Required} pure $ addArgument (argument <$ d) acc- O.CmdReader {} -> acc -- TODO+ O.CmdReader _mGroup cmds ->+ let+ commands :: [Command]+ commands = zipWith (uncurry . getCommand) [1 ..] $ reverse cmds+ in+ addCommands commands acc where o = d.item metavar = guarded (not . null) $ O.optMetaVar o@@ -73,30 +97,44 @@ O.OptLong x -> GNUFlag x xs foldOptTree- :: Int- -> Page- -> (Int -> Page -> Described (O.Option x) -> Page)+ :: (Int -> Page -> Described (O.Option x) -> Page) -> O.OptTree (O.Option x) -> Page-foldOptTree index acc f = \case+foldOptTree f = flip evalState 0 . foldOptTreeM f mempty++foldOptTreeM+ :: MonadState Int m+ => (Int -> Page -> Described (O.Option x) -> Page)+ -> Page+ -> O.OptTree (O.Option x)+ -> m Page+foldOptTreeM f acc = \case O.Leaf o -> case O.optVisibility o of- O.Visible ->- f index acc+ O.Visible -> do+ index <- get+ modify (+ 1)+ pure+ $ f index acc $ Described { item = o , optionality = maybe Required Defaulted (O.optShowDefault o) , multiple = False , help = textToMdoc =<< docToText (O.optHelp o) }- O.Internal -> acc- O.Hidden -> acc+ O.Internal -> pure acc+ O.Hidden -> pure acc O.MultNode ts -> recur f ts O.AltNode O.MarkDefault ts -> recur fAsOptional ts O.AltNode O.NoDefault ts -> recur fAsRequired ts- O.BindNode t -> foldOptTree (index + 1) acc fAsMultiple t+ O.BindNode t -> foldOptTreeM fAsMultiple acc t where- recur g = maybe acc (foldMap1 $ foldOptTree (index + 1) acc g) . nonEmpty+ recur+ :: MonadState Int m+ => (Int -> Page -> Described (O.Option x) -> Page)+ -> [O.OptTree (O.Option x)]+ -> m Page+ recur g = foldM (foldOptTreeM g) acc fAsOptional i m d = f i m $ d {optionality = Optional} fAsRequired i m d = f i m $ d {optionality = Required}
test/Mdoc/Data/SynopsisSpec.hs view
@@ -21,8 +21,4 @@ it "renders an empty synopsis" $ do let synopsis = Synopsis.fromList [] - synopsis- `shouldRender` [ ".Nm"- , ".Bk -words"- , ".Ek"- ]+ synopsis `shouldRender` [""]
test/Mdoc/ExamplesSpec.hs view
@@ -21,6 +21,7 @@ import Mdoc.Data.Named import Mdoc.Examples.Grep import Mdoc.Examples.OptEnvConf+import Mdoc.Examples.Pass import Mdoc.Examples.Person import Mdoc.Input (parseMdocBytes) import Mdoc.Parse (errorBundlePretty, exitParseError)@@ -36,6 +37,7 @@ spec = do it "grep.1" $ exampleGolden man1 grep1 1 it "example.1" $ exampleGolden man1 example1 1+ it "pass.1" $ exampleGolden man1 pass1 1 it "conf.5" $ exampleGolden man5 conf5 5 it "example.5" $ exampleGolden man5 example5 5 it "person.5" $ exampleGolden man5 person5 5