commit 9b536ce45584456c9a5abe3cceaa78408261f0b0 parent 87da1a20d339586a37b6b1fe952d3ed27ae48876 Author: Sean Enck <sean@ttypty.com> Date: Sat, 7 Dec 2024 15:34:44 -0500 cleanup what is in the app directory Diffstat:
26 files changed, 570 insertions(+), 482 deletions(-)
diff --git a/cmd/main.go b/cmd/main.go @@ -10,6 +10,7 @@ import ( "time" "github.com/seanenck/lockbox/internal/app" + "github.com/seanenck/lockbox/internal/app/commands" "github.com/seanenck/lockbox/internal/config" "github.com/seanenck/lockbox/internal/platform" "github.com/seanenck/lockbox/internal/platform/clip" @@ -32,10 +33,10 @@ func handleEarly(command string, args []string) (bool, error) { return true, nil } switch command { - case app.VersionCommand: + case commands.Version: fmt.Printf("version: %s\n", version) return true, nil - case app.ClearCommand: + case commands.Clear: return true, clearClipboard() } return false, nil @@ -68,27 +69,27 @@ func run() error { return err } switch command { - case app.ReKeyCommand: + case commands.ReKey: return app.ReKey(p) - case app.ListCommand: + case commands.List: return app.List(p) - case app.MoveCommand: + case commands.Move: return app.Move(p) - case app.InsertCommand, app.MultiLineCommand: + case commands.Insert, commands.MultiLine: mode := app.SingleLineInsert - if command == app.MultiLineCommand { + if command == commands.MultiLine { mode = app.MultiLineInsert } return app.Insert(p, mode) - case app.RemoveCommand: + case commands.Remove: return app.Remove(p) - case app.JSONCommand: + case commands.JSON: return app.JSON(p) - case app.ShowCommand, app.ClipCommand: - return app.ShowClip(p, command == app.ShowCommand) - case app.ConvCommand: + case commands.Show, commands.Clip: + return app.ShowClip(p, command == commands.Show) + case commands.Conv: return app.Conv(p) - case app.TOTPCommand: + case commands.TOTP: args, err := app.NewTOTPArguments(sub, config.EnvTOTPEntry.Get()) if err != nil { return err @@ -98,7 +99,7 @@ func run() error { return app.Insert(p, app.TOTPInsert) } return args.Do(app.NewDefaultTOTPOptions(p)) - case app.PasswordGenerateCommand: + case commands.PasswordGenerate: return app.GeneratePassword(p) default: return fmt.Errorf("unknown command: %s", command) diff --git a/internal/app/commands/core.go b/internal/app/commands/core.go @@ -0,0 +1,79 @@ +// Package commands defines available commands within the app +package commands + +const ( + // TOTP is the parent of totp and by defaults generates a rotating token + TOTP = "totp" + // Conv handles text conversion of the data store + Conv = "conv" + // Clear is a callback to manage clipboard clearing + Clear = "clear" + // Clip will copy values to the clipboard + Clip = "clip" + // Find is for simplistic searching of entries + Find = "find" + // Insert adds a value + Insert = "insert" + // List lists all entries + List = "ls" + // Move will move source to destination + Move = "mv" + // Show will show the value in an entry + Show = "show" + // Version displays version information + Version = "version" + // Help shows usage + Help = "help" + // HelpAdvanced shows advanced help + HelpAdvanced = "verbose" + // HelpConfig shows configuration information + HelpConfig = "config" + // Remove removes an entry + Remove = "rm" + // Env shows environment information used by lockbox + Env = "env" + // TOTPClip is the argument for copying totp codes to clipboard + TOTPClip = Clip + // TOTPMinimal is the argument for getting the short version of a code + TOTPMinimal = "minimal" + // TOTPList will list the totp-enabled entries + TOTPList = List + // TOTPOnce will perform like a normal totp request but not refresh + TOTPOnce = "once" + // CompletionsBash is the command to generate bash completions + CompletionsBash = "bash" + // Completions are used to generate shell completions + Completions = "completions" + // ReKey will rekey the underlying database + ReKey = "rekey" + // MultiLine handles multi-line inserts (when not piped) + MultiLine = "multiline" + // TOTPShow is for showing the TOTP token + TOTPShow = Show + // TOTPInsert is for inserting totp tokens + TOTPInsert = Insert + // JSON handles JSON outputs + JSON = "json" + // CompletionsZsh is the command to generate zsh completions + CompletionsZsh = "zsh" + // CompletionsFish is the command to generate fish completions + CompletionsFish = "fish" + // PasswordGenerate is the command to do password generation + PasswordGenerate = "pwgen" +) + +var ( + // CompletionTypes are shell completions that are known + CompletionTypes = []string{CompletionsBash, CompletionsFish, CompletionsZsh} + // ReKeyFlags are the flags used for re-keying + ReKeyFlags = struct { + KeyFile string + NoKey string + }{"keyfile", "nokey"} +) + +// ReKeyArgs is the base definition of re-keying args +type ReKeyArgs struct { + NoKey bool + KeyFile string +} diff --git a/internal/app/completions.go b/internal/app/completions.go @@ -1,159 +0,0 @@ -// Package app common objects -package app - -import ( - "bytes" - "embed" - "fmt" - "slices" - "sort" - "text/template" - - "github.com/seanenck/lockbox/internal/config" -) - -type ( - // Completions handles the inputs to completions for templating - Completions struct { - InsertCommand string - TOTPListCommand string - RemoveCommand string - ClipCommand string - ShowCommand string - MultiLineCommand string - MoveCommand string - TOTPCommand string - DoTOTPList string - DoList string - Executable string - JSONCommand string - HelpCommand string - HelpAdvancedCommand string - HelpConfigCommand string - ExportCommand string - Options []CompletionOption - TOTPSubCommands []CompletionOption - Conditionals Conditionals - } - // Conditionals help control completion flow - Conditionals struct { - Not struct { - ReadOnly string - CanClip string - CanTOTP string - AskMode string - Ever string - CanPasswordGen string - } - Exported []string - } - // CompletionOption are conditional wrapped logic for options that may be disabled - CompletionOption struct { - Conditional string - Key string - } -) - -//go:embed shell/* -var shell embed.FS - -func (c Completions) newGenOptions(defaults []string, kv map[string]string) []CompletionOption { - opt := []CompletionOption{} - for _, a := range defaults { - opt = append(opt, CompletionOption{c.Conditionals.Not.Ever, a}) - } - var keys []string - for k := range kv { - keys = append(keys, k) - } - sort.Strings(keys) - for _, key := range keys { - check := kv[key] - opt = append(opt, CompletionOption{check, key}) - } - return opt -} - -func newConditionals() Conditionals { - const shellIsNotText = `[ "%s" != "%s" ]` - c := Conditionals{} - registerIsNotEqual := func(key interface{ Key() string }, right string) string { - k := key.Key() - c.Exported = append(c.Exported, k) - return fmt.Sprintf(shellIsNotText, fmt.Sprintf("$%s", k), right) - } - c.Not.ReadOnly = registerIsNotEqual(config.EnvReadOnly, config.YesValue) - c.Not.CanClip = registerIsNotEqual(config.EnvClipEnabled, config.NoValue) - c.Not.CanTOTP = registerIsNotEqual(config.EnvTOTPEnabled, config.NoValue) - c.Not.AskMode = registerIsNotEqual(config.EnvPasswordMode, string(config.AskKeyMode)) - c.Not.CanPasswordGen = registerIsNotEqual(config.EnvPasswordGenEnabled, config.NoValue) - c.Not.Ever = fmt.Sprintf(shellIsNotText, "1", "0") - return c -} - -// GenerateCompletions handles creating shell completion outputs -func GenerateCompletions(completionType, exe string) ([]string, error) { - if !slices.Contains(completionTypes, completionType) { - return nil, fmt.Errorf("unknown completion request: %s", completionType) - } - c := Completions{ - Executable: exe, - InsertCommand: InsertCommand, - RemoveCommand: RemoveCommand, - TOTPListCommand: TOTPListCommand, - ClipCommand: ClipCommand, - ShowCommand: ShowCommand, - MultiLineCommand: MultiLineCommand, - JSONCommand: JSONCommand, - HelpCommand: HelpCommand, - HelpAdvancedCommand: HelpAdvancedCommand, - HelpConfigCommand: HelpConfigCommand, - TOTPCommand: TOTPCommand, - MoveCommand: MoveCommand, - DoList: fmt.Sprintf("%s %s", exe, ListCommand), - DoTOTPList: fmt.Sprintf("%s %s %s", exe, TOTPCommand, TOTPListCommand), - ExportCommand: fmt.Sprintf("%s %s %s", exe, EnvCommand, CompletionsCommand), - } - c.Conditionals = newConditionals() - - c.Options = c.newGenOptions([]string{EnvCommand, HelpCommand, ListCommand, ShowCommand, VersionCommand, JSONCommand}, - map[string]string{ - ClipCommand: c.Conditionals.Not.CanClip, - TOTPCommand: c.Conditionals.Not.CanTOTP, - MoveCommand: c.Conditionals.Not.ReadOnly, - RemoveCommand: c.Conditionals.Not.ReadOnly, - InsertCommand: c.Conditionals.Not.ReadOnly, - MultiLineCommand: c.Conditionals.Not.ReadOnly, - PasswordGenerateCommand: c.Conditionals.Not.CanPasswordGen, - }) - c.TOTPSubCommands = c.newGenOptions([]string{TOTPMinimalCommand, TOTPOnceCommand, TOTPShowCommand}, - map[string]string{ - TOTPClipCommand: c.Conditionals.Not.CanClip, - TOTPInsertCommand: c.Conditionals.Not.ReadOnly, - }) - using, err := readShell(completionType) - if err != nil { - return nil, err - } - s, err := templateScript(using, c) - if err != nil { - return nil, err - } - return []string{s}, nil -} - -func readShell(file string) (string, error) { - return readEmbedded(fmt.Sprintf("%s.sh", file), "shell", shell) -} - -func templateScript(script string, c Completions) (string, error) { - t, err := template.New("t").Parse(script) - if err != nil { - return "", err - } - var buf bytes.Buffer - if err := t.Execute(&buf, c); err != nil { - return "", err - } - return buf.String(), nil -} diff --git a/internal/app/completions/core.go b/internal/app/completions/core.go @@ -0,0 +1,158 @@ +// Package completions generations shell completions +package completions + +import ( + "bytes" + "embed" + "fmt" + "slices" + "sort" + "text/template" + + "github.com/seanenck/lockbox/internal/app/commands" + "github.com/seanenck/lockbox/internal/config" + "github.com/seanenck/lockbox/internal/util" +) + +type ( + // Template handles the inputs to completions for templating + Template struct { + InsertCommand string + TOTPListCommand string + RemoveCommand string + ClipCommand string + ShowCommand string + MultiLineCommand string + MoveCommand string + TOTPCommand string + DoTOTPList string + DoList string + Executable string + JSONCommand string + HelpCommand string + HelpAdvancedCommand string + HelpConfigCommand string + ExportCommand string + Options []CompletionOption + TOTPSubCommands []CompletionOption + Conditionals Conditionals + } + // Conditionals help control completion flow + Conditionals struct { + Not struct { + ReadOnly string + CanClip string + CanTOTP string + AskMode string + Ever string + CanPasswordGen string + } + Exported []string + } + // CompletionOption are conditional wrapped logic for options that may be disabled + CompletionOption struct { + Conditional string + Key string + } +) + +//go:embed shell/* +var shell embed.FS + +func (c Template) newGenOptions(defaults []string, kv map[string]string) []CompletionOption { + opt := []CompletionOption{} + for _, a := range defaults { + opt = append(opt, CompletionOption{c.Conditionals.Not.Ever, a}) + } + var keys []string + for k := range kv { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + check := kv[key] + opt = append(opt, CompletionOption{check, key}) + } + return opt +} + +// NewConditionals creates the conditional components of completions +func NewConditionals() Conditionals { + const shellIsNotText = `[ "%s" != "%s" ]` + c := Conditionals{} + registerIsNotEqual := func(key interface{ Key() string }, right string) string { + k := key.Key() + c.Exported = append(c.Exported, k) + return fmt.Sprintf(shellIsNotText, fmt.Sprintf("$%s", k), right) + } + c.Not.ReadOnly = registerIsNotEqual(config.EnvReadOnly, config.YesValue) + c.Not.CanClip = registerIsNotEqual(config.EnvClipEnabled, config.NoValue) + c.Not.CanTOTP = registerIsNotEqual(config.EnvTOTPEnabled, config.NoValue) + c.Not.AskMode = registerIsNotEqual(config.EnvPasswordMode, string(config.AskKeyMode)) + c.Not.CanPasswordGen = registerIsNotEqual(config.EnvPasswordGenEnabled, config.NoValue) + c.Not.Ever = fmt.Sprintf(shellIsNotText, "1", "0") + return c +} + +// Generate handles creating shell completion outputs +func Generate(completionType, exe string) ([]string, error) { + if !slices.Contains(commands.CompletionTypes, completionType) { + return nil, fmt.Errorf("unknown completion request: %s", completionType) + } + c := Template{ + Executable: exe, + InsertCommand: commands.Insert, + RemoveCommand: commands.Remove, + TOTPListCommand: commands.TOTPList, + ClipCommand: commands.Clip, + ShowCommand: commands.Show, + MultiLineCommand: commands.MultiLine, + JSONCommand: commands.JSON, + HelpCommand: commands.Help, + HelpAdvancedCommand: commands.HelpAdvanced, + HelpConfigCommand: commands.HelpConfig, + TOTPCommand: commands.TOTP, + MoveCommand: commands.Move, + DoList: fmt.Sprintf("%s %s", exe, commands.List), + DoTOTPList: fmt.Sprintf("%s %s %s", exe, commands.TOTP, commands.TOTPList), + ExportCommand: fmt.Sprintf("%s %s %s", exe, commands.Env, commands.Completions), + } + c.Conditionals = NewConditionals() + + c.Options = c.newGenOptions([]string{commands.Env, commands.Help, commands.List, commands.Show, commands.Version, commands.JSON}, + map[string]string{ + commands.Clip: c.Conditionals.Not.CanClip, + commands.TOTP: c.Conditionals.Not.CanTOTP, + commands.Move: c.Conditionals.Not.ReadOnly, + commands.Remove: c.Conditionals.Not.ReadOnly, + commands.Insert: c.Conditionals.Not.ReadOnly, + commands.MultiLine: c.Conditionals.Not.ReadOnly, + commands.PasswordGenerate: c.Conditionals.Not.CanPasswordGen, + }) + c.TOTPSubCommands = c.newGenOptions([]string{commands.TOTPMinimal, commands.TOTPOnce, commands.TOTPShow}, + map[string]string{ + commands.TOTPClip: c.Conditionals.Not.CanClip, + commands.TOTPInsert: c.Conditionals.Not.ReadOnly, + }) + using, err := util.ReadDirFile("shell", fmt.Sprintf("%s.sh", completionType), shell) + if err != nil { + return nil, err + } + s, err := templateScript(using, c) + if err != nil { + return nil, err + } + return []string{s}, nil +} + +func templateScript(script string, c Template) (string, error) { + t, err := template.New("t").Parse(script) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := t.Execute(&buf, c); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/internal/app/completions/core_test.go b/internal/app/completions/core_test.go @@ -0,0 +1,66 @@ +package completions_test + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/seanenck/lockbox/internal/app/completions" + "github.com/seanenck/lockbox/internal/util" +) + +func TestCompletions(t *testing.T) { + for k, v := range map[string]string{ + "zsh": "typeset -A opt_args", + "fish": "set -f commands", + "bash": "local cur opts", + } { + testCompletion(t, k, v) + } +} + +func TestConditionals(t *testing.T) { + c := completions.NewConditionals() + sort.Strings(c.Exported) + need := []string{"LOCKBOX_CLIP_ENABLED", "LOCKBOX_CREDENTIALS_PASSWORD_MODE", "LOCKBOX_PWGEN_ENABLED", "LOCKBOX_READONLY", "LOCKBOX_TOTP_ENABLED"} + if len(c.Exported) != len(need) || fmt.Sprintf("%v", c.Exported) != fmt.Sprintf("%v", need) { + t.Errorf("invalid exports: %v", c.Exported) + } + fields := util.ListFields(c.Not) + if len(fields) != len(need)+1 { + t.Errorf("invalid fields: %v", fields) + } + for _, n := range need { + value := "false" + switch n { + case "LOCKBOX_READONLY": + value = "true" + case "LOCKBOX_CREDENTIALS_PASSWORD_MODE": + value = "ask" + } + found := false + for _, f := range fields { + if fmt.Sprintf(`[ "$%s" != "%s" ]`, n, value) == f { + found = true + break + } + } + if !found { + t.Errorf("needed conditional %s not found: %v", n, fields) + } + } +} + +func testCompletion(t *testing.T, completionMode, need string) { + v, err := completions.Generate(completionMode, "lb") + if err != nil { + t.Errorf("invalid error: %v", err) + } + if len(v) != 1 { + t.Errorf("invalid result: %v", v) + } + if !strings.Contains(v[0], need) { + t.Errorf("invalid output, bad shell generation: %v", v) + } +} diff --git a/internal/app/shell/bash.sh b/internal/app/completions/shell/bash.sh diff --git a/internal/app/shell/fish.sh b/internal/app/completions/shell/fish.sh diff --git a/internal/app/shell/zsh.sh b/internal/app/completions/shell/zsh.sh diff --git a/internal/app/completions_test.go b/internal/app/completions_test.go @@ -1,31 +0,0 @@ -package app_test - -import ( - "strings" - "testing" - - "github.com/seanenck/lockbox/internal/app" -) - -func TestCompletions(t *testing.T) { - for k, v := range map[string]string{ - "zsh": "typeset -A opt_args", - "fish": "set -f commands", - "bash": "local cur opts", - } { - testCompletion(t, k, v) - } -} - -func testCompletion(t *testing.T, completionMode, need string) { - v, err := app.GenerateCompletions(completionMode, "lb") - if err != nil { - t.Errorf("invalid error: %v", err) - } - if len(v) != 1 { - t.Errorf("invalid result: %v", v) - } - if !strings.Contains(v[0], need) { - t.Errorf("invalid output, bad shell generation: %v", v) - } -} diff --git a/internal/app/core.go b/internal/app/core.go @@ -2,88 +2,12 @@ package app import ( - "bytes" - "embed" "fmt" "io" "os" - "path/filepath" - "sort" - "strings" - "text/template" "github.com/seanenck/lockbox/internal/backend" "github.com/seanenck/lockbox/internal/platform" - "github.com/seanenck/lockbox/internal/util" -) - -const ( - // TOTPCommand is the parent of totp and by defaults generates a rotating token - TOTPCommand = "totp" - // ConvCommand handles text conversion of the data store - ConvCommand = "conv" - // ClearCommand is a callback to manage clipboard clearing - ClearCommand = "clear" - // ClipCommand will copy values to the clipboard - ClipCommand = "clip" - // FindCommand is for simplistic searching of entries - FindCommand = "find" - // InsertCommand adds a value - InsertCommand = "insert" - // ListCommand lists all entries - ListCommand = "ls" - // MoveCommand will move source to destination - MoveCommand = "mv" - // ShowCommand will show the value in an entry - ShowCommand = "show" - // VersionCommand displays version information - VersionCommand = "version" - // HelpCommand shows usage - HelpCommand = "help" - // HelpAdvancedCommand shows advanced help - HelpAdvancedCommand = "verbose" - // HelpConfigCommand shows configuration information - HelpConfigCommand = "config" - // RemoveCommand removes an entry - RemoveCommand = "rm" - // EnvCommand shows environment information used by lockbox - EnvCommand = "env" - // TOTPClipCommand is the argument for copying totp codes to clipboard - TOTPClipCommand = ClipCommand - // TOTPMinimalCommand is the argument for getting the short version of a code - TOTPMinimalCommand = "minimal" - // TOTPListCommand will list the totp-enabled entries - TOTPListCommand = ListCommand - // TOTPOnceCommand will perform like a normal totp request but not refresh - TOTPOnceCommand = "once" - // CompletionsBashCommand is the command to generate bash completions - CompletionsBashCommand = "bash" - // CompletionsCommand are used to generate shell completions - CompletionsCommand = "completions" - // ReKeyCommand will rekey the underlying database - ReKeyCommand = "rekey" - // MultiLineCommand handles multi-line inserts (when not piped) - MultiLineCommand = "multiline" - // TOTPShowCommand is for showing the TOTP token - TOTPShowCommand = ShowCommand - // TOTPInsertCommand is for inserting totp tokens - TOTPInsertCommand = InsertCommand - // JSONCommand handles JSON outputs - JSONCommand = "json" - // CompletionsZshCommand is the command to generate zsh completions - CompletionsZshCommand = "zsh" - // CompletionsFishCommand is the command to generate fish completions - CompletionsFishCommand = "fish" - docDir = "doc" - textFile = ".txt" - // PasswordGenerateCommand is the command to do password generation - PasswordGenerateCommand = "pwgen" -) - -var ( - //go:embed doc/* - docs embed.FS - completionTypes = []string{CompletionsBashCommand, CompletionsFishCommand, CompletionsZshCommand} ) type ( @@ -107,32 +31,6 @@ type ( args []string tx *backend.Transaction } - // Documentation is how documentation segments are templated - Documentation struct { - Executable string - MoveCommand string - RemoveCommand string - ReKeyCommand string - CompletionsCommand string - CompletionsEnv string - HelpCommand string - HelpConfigCommand string - ReKey struct { - KeyFile string - NoKey string - } - Hooks struct { - Mode struct { - Pre string - Post string - } - Action struct { - Remove string - Insert string - Move string - } - } - } ) // NewDefaultCommand creates a new app command @@ -198,118 +96,3 @@ func (a DefaultCommand) Password() (string, error) { func (a *DefaultCommand) Input(interactive bool) ([]byte, error) { return platform.GetUserInputPassword(interactive) } - -func subCommand(parent, name, args, desc string) string { - return commandText(args, fmt.Sprintf("%s %s", parent, name), desc) -} - -func command(name, args, desc string) string { - return commandText(args, name, desc) -} - -func commandText(args, name, desc string) string { - arguments := "" - if len(args) > 0 { - arguments = fmt.Sprintf("[%s]", args) - } - return fmt.Sprintf(" %-18s %-10s %s", name, arguments, desc) -} - -// Usage return usage information -func Usage(verbose bool, exe string) ([]string, error) { - var results []string - results = append(results, command(ClipCommand, "entry", "copy the entry's value into the clipboard")) - results = append(results, command(CompletionsCommand, "<shell>", "generate completions via auto-detection")) - for _, c := range completionTypes { - results = append(results, subCommand(CompletionsCommand, c, "", fmt.Sprintf("generate %s completions", c))) - } - results = append(results, command(EnvCommand, "", "display environment variable information")) - results = append(results, command(HelpCommand, "", "show this usage information")) - results = append(results, subCommand(HelpCommand, HelpAdvancedCommand, "", "display verbose help information")) - results = append(results, subCommand(HelpCommand, HelpConfigCommand, "", "display verbose configuration information")) - results = append(results, command(InsertCommand, "entry", "insert a new entry into the store")) - results = append(results, command(JSONCommand, "filter", "display detailed information")) - results = append(results, command(ListCommand, "", "list entries")) - results = append(results, command(MoveCommand, "src dst", "move an entry from source to destination")) - results = append(results, command(MultiLineCommand, "entry", "insert a multiline entry into the store")) - results = append(results, command(PasswordGenerateCommand, "", "generate a password")) - results = append(results, command(ReKeyCommand, "", "rekey/reinitialize the database credentials")) - results = append(results, command(RemoveCommand, "entry", "remove an entry from the store")) - results = append(results, command(ShowCommand, "entry", "show the entry's value")) - results = append(results, command(TOTPCommand, "entry", "display an updating totp generated code")) - results = append(results, subCommand(TOTPCommand, TOTPClipCommand, "entry", "copy totp code to clipboard")) - results = append(results, subCommand(TOTPCommand, TOTPInsertCommand, "entry", "insert a new totp entry into the store")) - results = append(results, subCommand(TOTPCommand, TOTPListCommand, "", "list entries with totp settings")) - results = append(results, subCommand(TOTPCommand, TOTPOnceCommand, "entry", "display the first generated code")) - results = append(results, subCommand(TOTPCommand, TOTPMinimalCommand, "entry", "display one generated code (no details)")) - results = append(results, subCommand(TOTPCommand, TOTPShowCommand, "entry", "show the totp entry")) - results = append(results, command(VersionCommand, "", "display version information")) - sort.Strings(results) - usage := []string{fmt.Sprintf("%s usage:", exe)} - if verbose { - results = append(results, "") - document := Documentation{ - Executable: filepath.Base(exe), - MoveCommand: MoveCommand, - RemoveCommand: RemoveCommand, - ReKeyCommand: ReKeyCommand, - CompletionsCommand: CompletionsCommand, - HelpCommand: HelpCommand, - HelpConfigCommand: HelpConfigCommand, - } - document.ReKey.KeyFile = setDocFlag(reKeyFlags.KeyFile) - document.ReKey.NoKey = reKeyFlags.NoKey - document.Hooks.Mode.Pre = string(backend.HookPre) - document.Hooks.Mode.Post = string(backend.HookPost) - document.Hooks.Action.Insert = string(backend.InsertAction) - document.Hooks.Action.Remove = string(backend.RemoveAction) - document.Hooks.Action.Move = string(backend.MoveAction) - files, err := docs.ReadDir(docDir) - if err != nil { - return nil, err - } - var buf bytes.Buffer - for _, f := range files { - n := f.Name() - if !strings.HasSuffix(n, textFile) { - continue - } - header := fmt.Sprintf("[%s]", strings.TrimSuffix(filepath.Base(n), textFile)) - s, err := processDoc(header, n, document) - if err != nil { - return nil, err - } - buf.WriteString(s) - } - results = append(results, strings.Split(strings.TrimSpace(buf.String()), "\n")...) - } - return append(usage, results...), nil -} - -func processDoc(header, file string, doc Documentation) (string, error) { - b, err := readEmbedded(file, docDir, docs) - if err != nil { - return "", err - } - t, err := template.New("d").Parse(string(b)) - if err != nil { - return "", err - } - var buf bytes.Buffer - if err := t.Execute(&buf, doc); err != nil { - return "", err - } - return fmt.Sprintf("%s\n%s", header, util.TextWrap(0, buf.String())), nil -} - -func setDocFlag(f string) string { - return fmt.Sprintf("-%s=", f) -} - -func readEmbedded(file, dir string, e embed.FS) (string, error) { - b, err := e.ReadFile(filepath.Join(dir, file)) - if err != nil { - return "", err - } - return string(b), err -} diff --git a/internal/app/core_test.go b/internal/app/core_test.go @@ -1,26 +0,0 @@ -package app_test - -import ( - "strings" - "testing" - - "github.com/seanenck/lockbox/internal/app" -) - -func TestUsage(t *testing.T) { - u, _ := app.Usage(false, "lb") - if len(u) != 27 { - t.Errorf("invalid usage, out of date? %d", len(u)) - } - u, _ = app.Usage(true, "lb") - if len(u) != 100 { - t.Errorf("invalid verbose usage, out of date? %d", len(u)) - } - for _, usage := range u { - for _, l := range strings.Split(usage, "\n") { - if len(l) > 80 { - t.Errorf("usage line > 80 (%d), line: %s", len(l), l) - } - } - } -} diff --git a/internal/app/help/core.go b/internal/app/help/core.go @@ -0,0 +1,160 @@ +// Package help manages usage information +package help + +import ( + "bytes" + "embed" + "fmt" + "path/filepath" + "sort" + "strings" + "text/template" + + "github.com/seanenck/lockbox/internal/app/commands" + "github.com/seanenck/lockbox/internal/backend" + "github.com/seanenck/lockbox/internal/util" +) + +const ( + docDir = "doc" + textFile = ".txt" +) + +//go:embed doc/* +var docs embed.FS + +type ( + // Documentation is how documentation segments are templated + Documentation struct { + Executable string + MoveCommand string + RemoveCommand string + ReKeyCommand string + CompletionsCommand string + CompletionsEnv string + HelpCommand string + HelpConfigCommand string + ReKey struct { + KeyFile string + NoKey string + } + Hooks struct { + Mode struct { + Pre string + Post string + } + Action struct { + Remove string + Insert string + Move string + } + } + } +) + +func subCommand(parent, name, args, desc string) string { + return commandText(args, fmt.Sprintf("%s %s", parent, name), desc) +} + +func command(name, args, desc string) string { + return commandText(args, name, desc) +} + +func commandText(args, name, desc string) string { + arguments := "" + if len(args) > 0 { + arguments = fmt.Sprintf("[%s]", args) + } + return fmt.Sprintf(" %-18s %-10s %s", name, arguments, desc) +} + +// Usage return usage information +func Usage(verbose bool, exe string) ([]string, error) { + var results []string + results = append(results, command(commands.Clip, "entry", "copy the entry's value into the clipboard")) + results = append(results, command(commands.Completions, "<shell>", "generate completions via auto-detection")) + for _, c := range commands.CompletionTypes { + results = append(results, subCommand(commands.Completions, c, "", fmt.Sprintf("generate %s completions", c))) + } + results = append(results, command(commands.Env, "", "display environment variable information")) + results = append(results, command(commands.Help, "", "show this usage information")) + results = append(results, subCommand(commands.Help, commands.HelpAdvanced, "", "display verbose help information")) + results = append(results, subCommand(commands.Help, commands.HelpConfig, "", "display verbose configuration information")) + results = append(results, command(commands.Insert, "entry", "insert a new entry into the store")) + results = append(results, command(commands.JSON, "filter", "display detailed information")) + results = append(results, command(commands.List, "", "list entries")) + results = append(results, command(commands.Move, "src dst", "move an entry from source to destination")) + results = append(results, command(commands.MultiLine, "entry", "insert a multiline entry into the store")) + results = append(results, command(commands.PasswordGenerate, "", "generate a password")) + results = append(results, command(commands.ReKey, "", "rekey/reinitialize the database credentials")) + results = append(results, command(commands.Remove, "entry", "remove an entry from the store")) + results = append(results, command(commands.Show, "entry", "show the entry's value")) + results = append(results, command(commands.TOTP, "entry", "display an updating totp generated code")) + results = append(results, subCommand(commands.TOTP, commands.TOTPClip, "entry", "copy totp code to clipboard")) + results = append(results, subCommand(commands.TOTP, commands.TOTPInsert, "entry", "insert a new totp entry into the store")) + results = append(results, subCommand(commands.TOTP, commands.TOTPList, "", "list entries with totp settings")) + results = append(results, subCommand(commands.TOTP, commands.TOTPOnce, "entry", "display the first generated code")) + results = append(results, subCommand(commands.TOTP, commands.TOTPMinimal, "entry", "display one generated code (no details)")) + results = append(results, subCommand(commands.TOTP, commands.TOTPShow, "entry", "show the totp entry")) + results = append(results, command(commands.Version, "", "display version information")) + sort.Strings(results) + usage := []string{fmt.Sprintf("%s usage:", exe)} + if verbose { + results = append(results, "") + document := Documentation{ + Executable: filepath.Base(exe), + MoveCommand: commands.Move, + RemoveCommand: commands.Remove, + ReKeyCommand: commands.ReKey, + CompletionsCommand: commands.Completions, + HelpCommand: commands.Help, + HelpConfigCommand: commands.HelpConfig, + } + document.ReKey.KeyFile = setDocFlag(commands.ReKeyFlags.KeyFile) + document.ReKey.NoKey = commands.ReKeyFlags.NoKey + document.Hooks.Mode.Pre = string(backend.HookPre) + document.Hooks.Mode.Post = string(backend.HookPost) + document.Hooks.Action.Insert = string(backend.InsertAction) + document.Hooks.Action.Remove = string(backend.RemoveAction) + document.Hooks.Action.Move = string(backend.MoveAction) + files, err := docs.ReadDir(docDir) + if err != nil { + return nil, err + } + var buf bytes.Buffer + for _, f := range files { + n := f.Name() + if !strings.HasSuffix(n, textFile) { + continue + } + header := fmt.Sprintf("[%s]", strings.TrimSuffix(filepath.Base(n), textFile)) + s, err := processDoc(header, n, document) + if err != nil { + return nil, err + } + buf.WriteString(s) + } + results = append(results, strings.Split(strings.TrimSpace(buf.String()), "\n")...) + } + return append(usage, results...), nil +} + +func processDoc(header, file string, doc Documentation) (string, error) { + b, err := util.ReadDirFile(docDir, file, docs) + if err != nil { + return "", err + } + t, err := template.New("d").Parse(string(b)) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := t.Execute(&buf, doc); err != nil { + return "", err + } + return fmt.Sprintf("%s\n%s", header, util.TextWrap(0, buf.String())), nil +} + +func setDocFlag(f string) string { + return fmt.Sprintf("-%s=", f) +} diff --git a/internal/app/help/core_test.go b/internal/app/help/core_test.go @@ -0,0 +1,26 @@ +package help_test + +import ( + "strings" + "testing" + + "github.com/seanenck/lockbox/internal/app/help" +) + +func TestUsage(t *testing.T) { + u, _ := help.Usage(false, "lb") + if len(u) != 27 { + t.Errorf("invalid usage, out of date? %d", len(u)) + } + u, _ = help.Usage(true, "lb") + if len(u) != 100 { + t.Errorf("invalid verbose usage, out of date? %d", len(u)) + } + for _, usage := range u { + for _, l := range strings.Split(usage, "\n") { + if len(l) > 80 { + t.Errorf("usage line > 80 (%d), line: %s", len(l), l) + } + } + } +} diff --git a/internal/app/doc/clipboard.txt b/internal/app/help/doc/clipboard.txt diff --git a/internal/app/doc/completions.txt b/internal/app/help/doc/completions.txt diff --git a/internal/app/doc/database.txt b/internal/app/help/doc/database.txt diff --git a/internal/app/doc/globs.txt b/internal/app/help/doc/globs.txt diff --git a/internal/app/doc/hooks.txt b/internal/app/help/doc/hooks.txt diff --git a/internal/app/doc/rekey.txt b/internal/app/help/doc/rekey.txt diff --git a/internal/app/doc/toml.txt b/internal/app/help/doc/toml.txt diff --git a/internal/app/doc/totp.txt b/internal/app/help/doc/totp.txt diff --git a/internal/app/info.go b/internal/app/info.go @@ -7,8 +7,12 @@ import ( "io" "os" "path/filepath" + "slices" "strings" + "github.com/seanenck/lockbox/internal/app/commands" + "github.com/seanenck/lockbox/internal/app/completions" + "github.com/seanenck/lockbox/internal/app/help" "github.com/seanenck/lockbox/internal/config" ) @@ -35,16 +39,16 @@ func exeName() (string, error) { func info(command string, args []string) ([]string, error) { switch command { - case HelpCommand: + case commands.Help: if len(args) > 1 { return nil, errors.New("invalid help command") } isAdvanced := false if len(args) == 1 { switch args[0] { - case HelpAdvancedCommand: + case commands.HelpAdvanced: isAdvanced = true - case HelpConfigCommand: + case commands.HelpConfig: data, err := config.DefaultTOML() if err != nil { return nil, err @@ -58,21 +62,21 @@ func info(command string, args []string) ([]string, error) { if err != nil { return nil, err } - results, err := Usage(isAdvanced, exe) + results, err := help.Usage(isAdvanced, exe) if err != nil { return nil, err } return results, nil - case EnvCommand: + case commands.Env: var set []string switch len(args) { case 0: case 1: sub := args[0] - if sub != CompletionsCommand { + if sub != commands.Completions { return nil, fmt.Errorf("unknown env subset: %s", sub) } - set = newConditionals().Exported + set = completions.NewConditionals().Exported default: return nil, errors.New("invalid env command, too many arguments") } @@ -81,7 +85,7 @@ func info(command string, args []string) ([]string, error) { env = []string{""} } return env, nil - case CompletionsCommand: + case commands.Completions: shell := "" exe, err := exeName() if err != nil { @@ -95,13 +99,10 @@ func info(command string, args []string) ([]string, error) { default: return nil, errors.New("invalid completions subcommand") } - switch shell { - case CompletionsZshCommand, CompletionsBashCommand, CompletionsFishCommand: - break - default: + if !slices.Contains(commands.CompletionTypes, shell) { return nil, fmt.Errorf("unknown completion type: %s", shell) } - return GenerateCompletions(shell, exe) + return completions.Generate(shell, exe) } return nil, nil } diff --git a/internal/app/rekey.go b/internal/app/rekey.go @@ -5,17 +5,9 @@ import ( "errors" "flag" "strings" -) - -var reKeyFlags = struct { - KeyFile string - NoKey string -}{"keyfile", "nokey"} -type reKeyArgs struct { - NoKey bool - KeyFile string -} + "github.com/seanenck/lockbox/internal/app/commands" +) // ReKey handles entry rekeying func ReKey(cmd UserInputOptions) error { @@ -41,17 +33,17 @@ func ReKey(cmd UserInputOptions) error { return cmd.Transaction().ReKey(pass, vars.KeyFile) } -func readArgs(args []string) (reKeyArgs, error) { +func readArgs(args []string) (commands.ReKeyArgs, error) { set := flag.NewFlagSet("rekey", flag.ExitOnError) - keyFile := set.String(reKeyFlags.KeyFile, "", "new keyfile") - noKey := set.Bool(reKeyFlags.NoKey, false, "disable password/key credential") + keyFile := set.String(commands.ReKeyFlags.KeyFile, "", "new keyfile") + noKey := set.Bool(commands.ReKeyFlags.NoKey, false, "disable password/key credential") if err := set.Parse(args); err != nil { - return reKeyArgs{}, err + return commands.ReKeyArgs{}, err } noPass := *noKey file := *keyFile if strings.TrimSpace(file) == "" && noPass { - return reKeyArgs{}, errors.New("a key or keyfile must be passed for rekey") + return commands.ReKeyArgs{}, errors.New("a key or keyfile must be passed for rekey") } - return reKeyArgs{KeyFile: file, NoKey: noPass}, nil + return commands.ReKeyArgs{KeyFile: file, NoKey: noPass}, nil } diff --git a/internal/app/totp.go b/internal/app/totp.go @@ -10,6 +10,7 @@ import ( coreotp "github.com/pquerna/otp" otp "github.com/pquerna/otp/totp" + "github.com/seanenck/lockbox/internal/app/commands" "github.com/seanenck/lockbox/internal/backend" "github.com/seanenck/lockbox/internal/config" "github.com/seanenck/lockbox/internal/platform/clip" @@ -257,21 +258,21 @@ func NewTOTPArguments(args []string, tokenType string) (*TOTPArguments, error) { sub := args[0] needs := true switch sub { - case TOTPListCommand: + case commands.TOTPList: needs = false if len(args) != 1 { return nil, errors.New("list takes no arguments") } opts.Mode = ListTOTPMode - case TOTPInsertCommand: + case commands.TOTPInsert: opts.Mode = InsertTOTPMode - case TOTPShowCommand: + case commands.TOTPShow: opts.Mode = ShowTOTPMode - case TOTPClipCommand: + case commands.TOTPClip: opts.Mode = ClipTOTPMode - case TOTPMinimalCommand: + case commands.TOTPMinimal: opts.Mode = MinimalTOTPMode - case TOTPOnceCommand: + case commands.TOTPOnce: opts.Mode = OnceTOTPMode default: return nil, ErrUnknownTOTPMode diff --git a/internal/util/readfile.go b/internal/util/readfile.go @@ -0,0 +1,14 @@ +package util + +import ( + "path/filepath" +) + +// ReadDirFile will read a dir+file +func ReadDirFile(dir, file string, e interface{ ReadFile(string) ([]byte, error) }) (string, error) { + b, err := e.ReadFile(filepath.Join(dir, file)) + if err != nil { + return "", err + } + return string(b), err +} diff --git a/internal/util/readfile_test.go b/internal/util/readfile_test.go @@ -0,0 +1,23 @@ +package util_test + +import ( + "testing" + + "github.com/seanenck/lockbox/internal/util" +) + +type mockReadFile struct{} + +func (m mockReadFile) ReadFile(path string) ([]byte, error) { + return []byte(path), nil +} + +func TestReadEmbed(t *testing.T) { + read, err := util.ReadDirFile("xyz", "zzz", mockReadFile{}) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if string(read) != "xyz/zzz" { + t.Errorf("invalid read: %s", string(read)) + } +}