diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-18 08:39:04 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-18 08:39:04 +0300 |
| commit | 7bca02bddcc88002e8edf72d2f20f526cf9d2668 (patch) | |
| tree | 7bd907e787628393d52a3281ce751d2f807421ac | |
| parent | 5874a7a5d3cac75b76bdf2f7793a05ce3c45b4d7 (diff) | |
refactor: split cli.go into focused files by responsibility
Extract cli.go into four dedicated files to improve separation of concerns
and readability: cli_flags.go (flag parsing), cli_backend.go (backend init),
cli_commands.go (command handlers), cli_dispatch.go (command routing).
Fix inaccurate comments on logMsg/warn that claimed declaration order matters
in Go; package-level functions are resolved across the whole file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/cli/cli.go | 723 | ||||
| -rw-r--r-- | internal/cli/cli_backend.go | 178 | ||||
| -rw-r--r-- | internal/cli/cli_commands.go | 262 | ||||
| -rw-r--r-- | internal/cli/cli_dispatch.go | 301 | ||||
| -rw-r--r-- | internal/cli/cli_flags.go | 43 |
5 files changed, 799 insertions, 708 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1f5a736..94c9f57 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,29 +2,29 @@ // It mirrors the Ruby CLI class (geheim.rb lines 551-713): parsing argv, // dispatching commands, and running an optional interactive readline shell. // Run() is the top-level entry point called by cmd/foostore/main.go. +// +// Responsibilities are split across several files to keep each focused: +// - cli.go — CLI struct, constructors (New/newCLI), Run, shell completion, logging helpers +// - cli_flags.go — argv flag parsing (parseBackendFlag, parseKDBXPathFlag, parseFlagValue) +// - cli_backend.go — backend factory (buildBackend, buildGeheimBackend, buildKeepassBackend, ...) +// - cli_dispatch.go — shell loop (shellLoop) and command dispatcher (dispatch, dispatchSimple, dispatchSearch) +// - cli_commands.go — concrete command handlers (cmdAdd, cmdImport, …) and action-function factories +// - migrate_kdbx.go — migrate-kdbx command and its helpers package cli import ( - "bufio" "context" "fmt" - "io" "os" - "os/exec" - "path/filepath" - "runtime" "strings" "time" "codeberg.org/snonux/foostore/internal/backend" "codeberg.org/snonux/foostore/internal/clipboard" "codeberg.org/snonux/foostore/internal/config" - "codeberg.org/snonux/foostore/internal/crypto" "codeberg.org/snonux/foostore/internal/git" - "codeberg.org/snonux/foostore/internal/keepass" "codeberg.org/snonux/foostore/internal/shell" "codeberg.org/snonux/foostore/internal/store" - "codeberg.org/snonux/foostore/internal/version" ) // CommandList is the canonical list of supported commands, ordered to match @@ -104,42 +104,6 @@ func (c *CLI) Run(ctx context.Context, argv []string) int { return c.run(ctx, strippedArgv) } -// parseBackendFlag scans argv for a "--backend VALUE" pair and returns the -// backend name and the remaining argv with that pair removed. Returns ("", argv) -// when no --backend flag is present. The flag may appear anywhere in argv. -// -// Note: only the space-separated form "--backend VALUE" is supported. -// The equals form "--backend=VALUE" is NOT parsed and will be silently ignored -// (treated as an unknown argument that propagates to the command dispatcher). -func parseBackendFlag(argv []string) (string, []string) { - return parseFlagValue("--backend", argv) -} - -// parseKDBXPathFlag scans argv for a "--kdbx-path VALUE" pair and returns the -// path and the remaining argv with that pair removed. Returns ("", argv) when -// no --kdbx-path flag is present. Overrides cfg.KDBXPath for the process -// lifetime without modifying the config file. -// -// Note: only the space-separated form "--kdbx-path VALUE" is supported. -func parseKDBXPathFlag(argv []string) (string, []string) { - return parseFlagValue("--kdbx-path", argv) -} - -// parseFlagValue is the shared implementation for single-value flag extraction. -// It scans argv for a "FLAG VALUE" pair, removes it, and returns the value and -// the remaining argv. Returns ("", argv) when the flag is absent. -func parseFlagValue(flag string, argv []string) (string, []string) { - for i, arg := range argv { - if arg == flag && i+1 < len(argv) { - remaining := make([]string, 0, len(argv)-2) - remaining = append(remaining, argv[:i]...) - remaining = append(remaining, argv[i+2:]...) - return argv[i+1], remaining - } - } - return "", argv -} - // newCLI initialises all dependencies: config, PIN/passphrase, cipher or // keepass credentials, store or keepass backend, git, clipboard, and // interactive shell. backendName overrides cfg.Backend when non-empty @@ -186,619 +150,6 @@ func newCLI(ctx context.Context, backendName, kdbxPath string) (*CLI, error) { return c, nil } -// resolveBackend returns the effective backend name given the flag override and -// the config value. flag takes precedence; config value is used next; the -// last-resort default is "keepass" (matching the config.defaultConfigWithHome -// default so that the two sources of truth stay in sync). -func resolveBackend(flagValue, cfgValue string) string { - if flagValue != "" { - return flagValue - } - if cfgValue != "" { - return cfgValue - } - return "keepass" -} - -// buildBackend constructs the Backend and its associated Gitter based on -// effectiveBackend ("geheim" or "keepass"). Returns the Backend and git client -// so the caller can wire them into the CLI struct. -func buildBackend(ctx context.Context, cfg *config.Config, effectiveBackend string) (backend.Backend, git.Gitter, error) { - switch effectiveBackend { - case "keepass": - return buildKeepassBackend(ctx, cfg) - default: - return buildGeheimBackend(cfg) - } -} - -// buildGeheimGit returns a *git.Git pointed at cfg.DataDir. The geheim data -// directory is always a git repository (it is the store itself), so a real -// git client is always appropriate here — unlike the keepass backend which -// may live outside a repo and needs a NoOp fallback. -func buildGeheimGit(cfg *config.Config) git.Gitter { - return git.New(cfg.DataDir) -} - -// buildGeheimBackend initialises the original AES-encrypted geheim backend: -// reads the PIN, builds the cipher, creates a *store.Store, and points git at -// cfg.DataDir via buildGeheimGit. -func buildGeheimBackend(cfg *config.Config) (backend.Backend, git.Gitter, error) { - pin, err := readPIN() - if err != nil { - return nil, nil, fmt.Errorf("reading PIN: %w", err) - } - - ciph, err := crypto.NewCipher(cfg.KeyFile, cfg.KeyLength, pin, cfg.AddToIV) - if err != nil { - return nil, nil, fmt.Errorf("initialising cipher: %w", err) - } - - g := buildGeheimGit(cfg) - - st, err := store.New(cfg, ciph, g) - if err != nil { - return nil, nil, fmt.Errorf("initialising store: %w", err) - } - - return st, g, nil -} - -// buildKeepassBackend reads the KeePass passphrase and optional key-file bytes, -// then instantiates a keepass.Backend. The git client is pointed at the -// directory that contains the .kdbx file. -// -// If that directory is a git repository (detected via git rev-parse -// --is-inside-work-tree), a real *git.Git is returned so that sync/status/ -// commit/reset operate normally against it. If the directory is not a git -// repository, a *git.NoOp is returned instead — its methods print an -// informational message ("kdbx file is not in a git repo; skipping") and return -// nil, keeping the UX transparent without crashing. -func buildKeepassBackend(ctx context.Context, cfg *config.Config) (backend.Backend, git.Gitter, error) { - passphrase, err := readKeepassPassphrase(cfg) - if err != nil { - return nil, nil, fmt.Errorf("reading keepass passphrase: %w", err) - } - - keyFileData, err := readKeepassKeyFile(cfg) - if err != nil { - return nil, nil, fmt.Errorf("reading keepass key file: %w", err) - } - - st, err := keepass.New(cfg, passphrase, keyFileData) - if err != nil { - return nil, nil, fmt.Errorf("initialising keepass backend: %w", err) - } - - g := buildKeepassGit(cfg.KDBXPath) - return st, g, nil -} - -// buildKeepassGit returns the appropriate Gitter for the directory containing -// the kdbx file. When the directory is a git repository, a real *git.Git is -// returned. Otherwise, a *git.NoOp is returned so that callers receive -// informational messages rather than errors when running git commands. -func buildKeepassGit(kdbxPath string) git.Gitter { - kdbxDir := filepath.Dir(kdbxPath) - if git.IsGitRepo(kdbxDir) { - return git.New(kdbxDir) - } - return git.NewNoOp() -} - -// readKeepassPassphrase resolves the KeePass database passphrase using the -// following priority: $PIN env var → cfg.KDBXPassFile → interactive prompt. -// This mirrors the geheim readPIN() priority while accommodating the extra -// KDBXPassFile option specific to the keepass backend. -func readKeepassPassphrase(cfg *config.Config) (string, error) { - // 1. $PIN environment variable — same as geheim backend for symmetry. - if pin := os.Getenv("PIN"); pin != "" { - return pin, nil - } - - // 2. KDBXPassFile — a file containing the password (trimmed of newlines). - if cfg.KDBXPassFile != "" { - pass, err := readPasswordFile(cfg.KDBXPassFile) - if err != nil { - return "", fmt.Errorf("reading KDBXPassFile: %w", err) - } - return pass, nil - } - - // 3. Interactive prompt — same mechanism as geheim's PIN prompt. - pass, err := shell.ReadPassword("< KeePass passphrase: ") - if err != nil { - return "", fmt.Errorf("reading passphrase from terminal: %w", err) - } - return pass, nil -} - -// readKeepassKeyFile reads the optional KeePass key-file bytes. Returns nil -// when cfg.KDBXKeyFile is empty (password-only authentication). -func readKeepassKeyFile(cfg *config.Config) ([]byte, error) { - if cfg.KDBXKeyFile == "" { - return nil, nil - } - data, err := os.ReadFile(cfg.KDBXKeyFile) - if err != nil { - return nil, fmt.Errorf("reading key file %q: %w", cfg.KDBXKeyFile, err) - } - return data, nil -} - -// readPIN returns the PIN string for encryption. If the $PIN environment -// variable is set, it is used directly (matching the Ruby ENV['PIN'] check). -// Otherwise the user is prompted with masked input via the shell package. -func readPIN() (string, error) { - if pin := os.Getenv("PIN"); pin != "" { - return pin, nil - } - - pin, err := shell.ReadPassword("< PIN: ") - if err != nil { - return "", fmt.Errorf("reading PIN from terminal: %w", err) - } - return pin, nil -} - -// run dispatches a single command (when argv is non-empty and no shell flag is -// set) or enters the interactive shell loop. Returns an exit code. -func (c *CLI) run(ctx context.Context, argv []string) int { - // Enter shell mode when: no arguments, $FOOSTORE_SHELL is set, or the first - // argument is "shell". Mirrors the Ruby shell_loop entry conditions. - enterShell := len(argv) == 0 || - os.Getenv("FOOSTORE_SHELL") != "" || - (len(argv) > 0 && argv[0] == "shell") - - if enterShell { - return c.shellLoop(ctx) - } - - return c.dispatch(ctx, argv) -} - -// shellLoop runs the interactive readline loop, reading commands until the -// user presses Ctrl+D (EOF) or types "exit". Mirrors Ruby#shell_loop. -// c.lastResult is updated by dispatch and accessible between iterations. -func (c *CLI) shellLoop(ctx context.Context) int { - ec := 0 - logMsg("Interactive mode (vi keys): Ctrl-] for normal mode, i for insert | Enter fuzzy picker | ctrl-t/y/o/e (or alt-t/y/o/e) in picker") - - for { - line, err := c.sh.ReadLine(ctx) - if err == io.EOF { - // Ctrl+D — clean exit. - break - } - if err != nil { - warn(fmt.Sprintf("readline error: %v", err)) - continue - } - - argv := strings.Fields(line) - if len(argv) == 0 { - // Empty input — run fzf picker. - result, fzfErr := c.st.FzfInteractive(ctx) - if fzfErr != nil { - warn(fzfErr.Error()) - continue - } - if result.Description != "" { - c.lastResult = result.Description - logMsg(fmt.Sprintf("Picked: %s", result.Description)) - ec = c.dispatchPickerAction(ctx, result) - } - continue - } - - // Handle "last" before dispatch so c.lastResult is printed correctly. - if argv[0] == "last" { - fmt.Println(c.lastResult) - continue - } - - // "exit" ends the shell loop. - if argv[0] == "exit" { - logMsg("Good bye") - break - } - - ec = c.dispatch(ctx, argv) - } - - return ec -} - -func pickerActionArgv(action store.PickerAction, description string) []string { - switch action { - case store.PickerCat: - return []string{"cat", description} - case store.PickerPaste: - return []string{"paste", description} - case store.PickerOpen: - return []string{"open", description} - case store.PickerEdit: - return []string{"edit", description} - default: - return nil - } -} - -func (c *CLI) dispatchPickerAction(ctx context.Context, result store.PickerResult) int { - argv := pickerActionArgv(result.Action, result.Description) - if len(argv) == 0 { - return 0 - } - return c.dispatch(ctx, argv) -} - -// dispatch routes a parsed argv slice to the appropriate handler. -// It returns an exit code and updates c.lastResult when a non-empty result -// is produced. The function is split into helpers to keep each branch under -// ~50 lines. -func (c *CLI) dispatch(ctx context.Context, argv []string) int { - if len(argv) == 0 { - result, err := c.st.Fzf(ctx) - if err != nil { - warn(err.Error()) - return 1 - } - if result != "" { - c.lastResult = result - } - return 0 - } - - cmd := argv[0] - - // Commands handled by dispatchSimple (no search term needed). - if ec, result, handled := c.dispatchSimple(ctx, argv, cmd); handled { - if result != "" { - c.lastResult = result - } - return ec - } - - // Commands that require a search term (argv[1] or fallback to c.lastResult). - ec, result := c.dispatchSearch(ctx, argv, cmd) - if result != "" { - c.lastResult = result - } - return ec -} - -// dispatchSimple handles commands that don't require a search term: -// ls, add, import, import_r, sync, status, commit, reset, fullcommit, -// shred, version, commands, help, shell, exit, last, and the fzf fallback. -// Returns (exitCode, lastResult, handled). handled=false when the command -// is not in this set and should fall through to dispatchSearch. -func (c *CLI) dispatchSimple(ctx context.Context, argv []string, cmd string) (int, string, bool) { - switch cmd { - case "ls": - indexes, err := c.st.Search(ctx, ".", store.ActionNone, nil, printIndex) - if err != nil { - warn(err.Error()) - return 1, "", true - } - logMsg(fmt.Sprintf("%d entries", len(indexes))) - return 0, "", true - - case "add": - return c.cmdAdd(ctx, argv), "", true - - case "import": - return c.cmdImport(ctx, argv), "", true - - case "import_r": - return c.cmdImportR(ctx, argv), "", true - - case "sync": - if err := c.g.Sync(ctx, c.cfg.SyncRepos); err != nil { - warn(err.Error()) - return 1, "", true - } - return 0, "", true - - case "status": - if err := c.g.Status(ctx); err != nil { - warn(err.Error()) - return 1, "", true - } - return 0, "", true - - case "commit": - if err := c.g.Commit(ctx); err != nil { - warn(err.Error()) - return 1, "", true - } - return 0, "", true - - case "reset": - if err := c.g.Reset(ctx); err != nil { - warn(err.Error()) - return 1, "", true - } - return 0, "", true - - case "fullcommit": - return c.cmdFullCommit(ctx), "", true - - case "shred": - if err := c.st.ShredAllExported(ctx); err != nil { - warn(err.Error()) - return 1, "", true - } - return 0, "", true - - case "migrate-kdbx": - return c.cmdMigrateKDBX(ctx, argv), "", true - - case "version": - logMsg(fmt.Sprintf("foostore %s", version.Version)) - return 0, "", true - - case "commands": - for _, name := range CommandList { - fmt.Println(name) - } - return 0, "", true - - case "help": - printHelp() - return 0, "", true - - case "shell": - // When typed in the shell loop, "shell" is intercepted by run() before - // dispatch is called, so this branch only fires in one-shot mode where - // switching to interactive mode is not meaningful. We print a notice and - // exit cleanly rather than silently doing nothing. - logMsg("Use foostore without arguments to enter interactive mode") - return 0, "", true - - case "exit": - logMsg("Good bye") - return 0, "", true - - case "last": - // In shell mode, "last" is handled before dispatch (shellLoop intercepts - // it and prints c.lastResult directly). In one-shot mode there is no - // persistent lastResult, so we just print empty. - fmt.Println(c.lastResult) - return 0, "", true - } - - // Not a simple command — let dispatchSearch handle it. - return 0, "", false -} - -// dispatchSearch handles commands that accept a search term: search, cat, -// paste, get, export, pathexport, open, edit, rm, and the catch-all. -// When no explicit term is supplied, c.lastResult is used as the fallback, -// mirroring Ruby's `search_term = argv.length < 2 ? last_result : argv[1]`. -func (c *CLI) dispatchSearch(ctx context.Context, argv []string, cmd string) (int, string) { - term := c.lastResult // fallback to last search result when no term given - if len(argv) > 1 { - term = argv[1] - } - - switch cmd { - case "search": - return c.cmdSearchOnly(ctx, term) - - case "get": - // "get" is an alias for "cat". - return c.cmdSearchAction(ctx, term, store.ActionCat, nil) - - case "rm": - if err := c.st.Remove(ctx, term, os.Stdin); err != nil { - warn(err.Error()) - return 1, "" - } - return 0, "" - - case "cat", "paste", "export", "pathexport", "open", "edit": - action := SearchActions[cmd] - actionFn := c.makeActionFn(ctx, action) - return c.cmdSearchAction(ctx, term, action, actionFn) - - default: - // Unknown command: treat as a search term, mirroring Ruby's else branch. - // This allows bare search terms to be typed without prefixing "search". - indexes, err := c.st.Search(ctx, cmd, store.ActionNone, nil, printIndex) - if err != nil { - warn(err.Error()) - return 1, "" - } - if len(indexes) > 0 { - return 0, indexes[0].Description - } - return 0, "" - } -} - -// cmdAdd reads data from stdin and stores a new secret under the given description. -func (c *CLI) cmdAdd(ctx context.Context, argv []string) int { - if len(argv) < 2 { - warn("add requires a description argument") - return 1 - } - desc := argv[1] - // Ruby uses log 'Data: ' which emits "> Data: \n" before reading stdin. - logMsg("Data: ") - - scanner := bufio.NewScanner(os.Stdin) - if !scanner.Scan() { - warn("no data provided") - return 1 - } - data := scanner.Text() - - if err := c.st.Add(ctx, desc, data); err != nil { - warn(err.Error()) - return 1 - } - return 0 -} - -// cmdImport imports a single file into the store. -// argv: import FILE [DEST] [force] -// -// Ruby dest_path logic (from Geheim#import): -// - No dest given: dest = normalised src_path (full path, "./" stripped) -// - dest contains a ".": dest is used literally (it is already a full dest path) -// - dest is a plain directory name: dest = "dir/basename(srcFile)" -func (c *CLI) cmdImport(ctx context.Context, argv []string) int { - if len(argv) < 2 { - warn("import requires a file argument") - return 1 - } - - srcFile := argv[1] - // Normalise source path the same way Ruby does. - normSrc := strings.ReplaceAll(srcFile, "//", "/") - normSrc = strings.TrimPrefix(normSrc, "./") - - dest := normSrc // default: full normalised path, matching Ruby's nil dest_dir branch - force := false - if len(argv) >= 3 { - arg2 := argv[2] - if arg2 == "force" { - force = true - } else if strings.Contains(arg2, ".") { - // dest_dir contains a "." → use it as the literal dest path. - dest = arg2 - force = len(argv) >= 4 - } else { - // Plain directory: dest = dir/basename(src), as Ruby does. - dest = arg2 + "/" + filepath.Base(srcFile) - dest = strings.ReplaceAll(dest, "//", "/") - force = len(argv) >= 4 - } - } - - if err := c.st.Import(ctx, srcFile, dest, force); err != nil { - warn(err.Error()) - return 1 - } - return 0 -} - -// cmdImportR recursively imports all files in a directory. -// argv: import_r DIR [DEST] -func (c *CLI) cmdImportR(ctx context.Context, argv []string) int { - if len(argv) < 2 { - warn("import_r requires a directory argument") - return 1 - } - - dir := argv[1] - destDir := "." // default destination is the store root - if len(argv) >= 3 { - destDir = argv[2] - } - - if err := c.st.ImportRecursive(ctx, dir, destDir); err != nil { - warn(err.Error()) - return 1 - } - return 0 -} - -// cmdFullCommit performs a sync → commit → sync sequence to ensure the local -// store is up-to-date before committing and then pushed afterwards. -func (c *CLI) cmdFullCommit(ctx context.Context) int { - if err := c.g.Sync(ctx, c.cfg.SyncRepos); err != nil { - warn(err.Error()) - return 1 - } - if err := c.g.Commit(ctx); err != nil { - warn(err.Error()) - return 1 - } - if err := c.g.Sync(ctx, c.cfg.SyncRepos); err != nil { - warn(err.Error()) - return 1 - } - return 0 -} - -// cmdSearchOnly runs a search without any action and returns the result. -func (c *CLI) cmdSearchOnly(ctx context.Context, term string) (int, string) { - indexes, err := c.st.Search(ctx, term, store.ActionNone, nil, printIndex) - if err != nil { - warn(err.Error()) - return 1, "" - } - if len(indexes) > 0 { - return 0, indexes[0].Description - } - return 0, "" -} - -// cmdSearchAction runs a search with the given action and optional callback. -func (c *CLI) cmdSearchAction(ctx context.Context, term string, action store.Action, actionFn func(context.Context, *store.Index, *store.Data) error) (int, string) { - indexes, err := c.st.Search(ctx, term, action, actionFn, printIndex) - if err != nil { - warn(err.Error()) - return 1, "" - } - if len(indexes) > 0 { - return 0, indexes[0].Description - } - return 0, "" -} - -func printIndex(idx *store.Index) { - fmt.Print(idx.String()) -} - -// makeActionFn returns the appropriate callback function for actions that -// require external tools (paste, open, edit). For actions handled internally -// by the store (cat, export, pathexport), nil is returned. -func (c *CLI) makeActionFn(ctx context.Context, action store.Action) func(context.Context, *store.Index, *store.Data) error { - switch action { - case store.ActionPaste: - return func(ctx context.Context, idx *store.Index, d *store.Data) error { - if idx.IsBinary() { - fmt.Println("Not displaying/pasting binary data!") - return nil - } - return c.clip.Paste(ctx, string(d.Content)) - } - - case store.ActionOpen: - return func(ctx context.Context, idx *store.Index, d *store.Data) error { - exportName := filepath.Base(idx.Description) - if err := d.Export(ctx, c.cfg.ExportDir, exportName); err != nil { - return err - } - path, err := openExported(ctx, c.cfg.ExportDir, exportName) - if err != nil { - return err - } - // Shred the exported file immediately after opening — mirrors Ruby's - // `shred_file(file: open_exported(...), delay: 0)` call. - return store.ShredFile(ctx, path) - } - - case store.ActionEdit: - return func(ctx context.Context, idx *store.Index, d *store.Data) error { - exportName := filepath.Base(idx.Description) - if err := d.Export(ctx, c.cfg.ExportDir, exportName); err != nil { - return err - } - if err := externalEdit(ctx, c.cfg.ExportDir, c.cfg.EditCmd, exportName); err != nil { - return err - } - return d.ReimportAfterExport(ctx) - } - - default: - // cat, export, pathexport are handled directly by the store. - return nil - } -} - // completionFn returns all CommandList entries that start with prefix. // When $PIN is set, it also includes index descriptions from the store, // matching the Ruby setup_readline completion_proc behaviour. @@ -832,51 +183,15 @@ func (c *CLI) completionFn(prefix string) []string { return results } -// openExported detects the current OS and opens the given file with an -// appropriate viewer. The OS detection extends the Ruby reference with -// xdg-open for Linux (Ruby used evince), runtime.GOOS fallbacks, and -// additional iTerm/Termux heuristics. Returns the full path on success. -func openExported(ctx context.Context, exportDir, file string) (string, error) { - fullPath := filepath.Join(exportDir, file) - - var openCmd string - switch { - case os.Getenv("UNAME") == "Darwin" || runtime.GOOS == "darwin": - openCmd = "open" - case os.Getenv("TERM_PROGRAM") == "iTerm.app": - openCmd = "open" - case strings.Contains(os.Getenv("PREFIX"), "com.termux") || runtime.GOOS == "android": - // Termux on Android. - openCmd = "termux-open" - case runtime.GOOS == "windows": - openCmd = "winopen" - default: - // Linux: prefer xdg-open; fall back to evince for PDFs. - openCmd = "xdg-open" - } +// ---- Logging helpers (mirror Ruby Log module) -------------------------------- - cmd := exec.CommandContext(ctx, openCmd, fullPath) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("opening %q with %q: %w", fullPath, openCmd, err) - } - return fullPath, nil -} +// logMsg prints a "> " prefixed message to stdout. +// Placed above printHelp for readability; Go resolves package-level names across the whole file. +func logMsg(msg string) { fmt.Printf("> %s\n", msg) } -// externalEdit launches cfg.EditCmd on the exported file and waits for it to -// exit, then the caller can reimport the (possibly modified) file. -func externalEdit(ctx context.Context, exportDir, editCmd, file string) error { - fullPath := filepath.Join(exportDir, file) - cmd := exec.CommandContext(ctx, editCmd, fullPath) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("editing %q with %q: %w", fullPath, editCmd, err) - } - return nil -} +// warn prints a "WARN " prefixed message to stderr. +// Placed above printHelp for readability; Go resolves package-level names across the whole file. +func warn(msg string) { fmt.Fprintf(os.Stderr, "WARN %s\n", msg) } // printHelp prints a brief usage summary, mirroring the Ruby CLI#help output. func printHelp() { @@ -903,11 +218,3 @@ commands help shell`) } - -// ---- Logging helpers (mirror Ruby Log module) -------------------------------- - -// logMsg prints a "> " prefixed message to stdout. -func logMsg(msg string) { fmt.Printf("> %s\n", msg) } - -// warn prints a "WARN " prefixed message to stderr. -func warn(msg string) { fmt.Fprintf(os.Stderr, "WARN %s\n", msg) } diff --git a/internal/cli/cli_backend.go b/internal/cli/cli_backend.go new file mode 100644 index 0000000..96058ae --- /dev/null +++ b/internal/cli/cli_backend.go @@ -0,0 +1,178 @@ +// Package cli — backend factory. +// +// This file is responsible for instantiating the correct storage backend +// (geheim AES-encrypted store or KeePass) and its associated git client based +// on the effective backend name resolved from the --backend flag and config +// file. None of the functions here touch the CLI struct or dispatch logic. +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "codeberg.org/snonux/foostore/internal/backend" + "codeberg.org/snonux/foostore/internal/config" + "codeberg.org/snonux/foostore/internal/crypto" + "codeberg.org/snonux/foostore/internal/git" + "codeberg.org/snonux/foostore/internal/keepass" + "codeberg.org/snonux/foostore/internal/shell" + "codeberg.org/snonux/foostore/internal/store" +) + +// resolveBackend returns the effective backend name given the flag override and +// the config value. flag takes precedence; config value is used next; the +// last-resort default is "keepass" (matching the config.defaultConfigWithHome +// default so that the two sources of truth stay in sync). +func resolveBackend(flagValue, cfgValue string) string { + if flagValue != "" { + return flagValue + } + if cfgValue != "" { + return cfgValue + } + return "keepass" +} + +// buildBackend constructs the Backend and its associated Gitter based on +// effectiveBackend ("geheim" or "keepass"). Returns the Backend and git client +// so the caller can wire them into the CLI struct. +func buildBackend(ctx context.Context, cfg *config.Config, effectiveBackend string) (backend.Backend, git.Gitter, error) { + switch effectiveBackend { + case "keepass": + return buildKeepassBackend(ctx, cfg) + default: + return buildGeheimBackend(cfg) + } +} + +// buildGeheimBackend initialises the original AES-encrypted geheim backend: +// reads the PIN, builds the cipher, creates a *store.Store, and points git at +// cfg.DataDir via buildGeheimGit. +func buildGeheimBackend(cfg *config.Config) (backend.Backend, git.Gitter, error) { + pin, err := readPIN() + if err != nil { + return nil, nil, fmt.Errorf("reading PIN: %w", err) + } + + ciph, err := crypto.NewCipher(cfg.KeyFile, cfg.KeyLength, pin, cfg.AddToIV) + if err != nil { + return nil, nil, fmt.Errorf("initialising cipher: %w", err) + } + + g := buildGeheimGit(cfg) + + st, err := store.New(cfg, ciph, g) + if err != nil { + return nil, nil, fmt.Errorf("initialising store: %w", err) + } + + return st, g, nil +} + +// buildGeheimGit returns a *git.Git pointed at cfg.DataDir. The geheim data +// directory is always a git repository (it is the store itself), so a real +// git client is always appropriate here — unlike the keepass backend which +// may live outside a repo and needs a NoOp fallback. +func buildGeheimGit(cfg *config.Config) git.Gitter { + return git.New(cfg.DataDir) +} + +// buildKeepassBackend reads the KeePass passphrase and optional key-file bytes, +// then instantiates a keepass.Backend. The git client is pointed at the +// directory that contains the .kdbx file. +// +// If that directory is a git repository (detected via git rev-parse +// --is-inside-work-tree), a real *git.Git is returned so that sync/status/ +// commit/reset operate normally against it. If the directory is not a git +// repository, a *git.NoOp is returned instead — its methods print an +// informational message ("kdbx file is not in a git repo; skipping") and return +// nil, keeping the UX transparent without crashing. +func buildKeepassBackend(ctx context.Context, cfg *config.Config) (backend.Backend, git.Gitter, error) { + passphrase, err := readKeepassPassphrase(cfg) + if err != nil { + return nil, nil, fmt.Errorf("reading keepass passphrase: %w", err) + } + + keyFileData, err := readKeepassKeyFile(cfg) + if err != nil { + return nil, nil, fmt.Errorf("reading keepass key file: %w", err) + } + + st, err := keepass.New(cfg, passphrase, keyFileData) + if err != nil { + return nil, nil, fmt.Errorf("initialising keepass backend: %w", err) + } + + g := buildKeepassGit(cfg.KDBXPath) + return st, g, nil +} + +// buildKeepassGit returns the appropriate Gitter for the directory containing +// the kdbx file. When the directory is a git repository, a real *git.Git is +// returned. Otherwise, a *git.NoOp is returned so that callers receive +// informational messages rather than errors when running git commands. +func buildKeepassGit(kdbxPath string) git.Gitter { + kdbxDir := filepath.Dir(kdbxPath) + if git.IsGitRepo(kdbxDir) { + return git.New(kdbxDir) + } + return git.NewNoOp() +} + +// readPIN returns the PIN string for encryption. If the $PIN environment +// variable is set, it is used directly (matching the Ruby ENV['PIN'] check). +// Otherwise the user is prompted with masked input via the shell package. +func readPIN() (string, error) { + if pin := os.Getenv("PIN"); pin != "" { + return pin, nil + } + + pin, err := shell.ReadPassword("< PIN: ") + if err != nil { + return "", fmt.Errorf("reading PIN from terminal: %w", err) + } + return pin, nil +} + +// readKeepassPassphrase resolves the KeePass database passphrase using the +// following priority: $PIN env var → cfg.KDBXPassFile → interactive prompt. +// This mirrors the geheim readPIN() priority while accommodating the extra +// KDBXPassFile option specific to the keepass backend. +func readKeepassPassphrase(cfg *config.Config) (string, error) { + // 1. $PIN environment variable — same as geheim backend for symmetry. + if pin := os.Getenv("PIN"); pin != "" { + return pin, nil + } + + // 2. KDBXPassFile — a file containing the password (trimmed of newlines). + // readPasswordFile is defined in migrate_kdbx.go and shared here. + if cfg.KDBXPassFile != "" { + pass, err := readPasswordFile(cfg.KDBXPassFile) + if err != nil { + return "", fmt.Errorf("reading KDBXPassFile: %w", err) + } + return pass, nil + } + + // 3. Interactive prompt — same mechanism as geheim's PIN prompt. + pass, err := shell.ReadPassword("< KeePass passphrase: ") + if err != nil { + return "", fmt.Errorf("reading passphrase from terminal: %w", err) + } + return pass, nil +} + +// readKeepassKeyFile reads the optional KeePass key-file bytes. Returns nil +// when cfg.KDBXKeyFile is empty (password-only authentication). +func readKeepassKeyFile(cfg *config.Config) ([]byte, error) { + if cfg.KDBXKeyFile == "" { + return nil, nil + } + data, err := os.ReadFile(cfg.KDBXKeyFile) + if err != nil { + return nil, fmt.Errorf("reading key file %q: %w", cfg.KDBXKeyFile, err) + } + return data, nil +} diff --git a/internal/cli/cli_commands.go b/internal/cli/cli_commands.go new file mode 100644 index 0000000..ff7669f --- /dev/null +++ b/internal/cli/cli_commands.go @@ -0,0 +1,262 @@ +// Package cli — command handler implementations. +// +// This file contains the concrete handler functions for each CLI command +// (add, import, import_r, fullcommit) and the action-function factory +// (makeActionFn) used by dispatchSearch for commands that invoke external +// tools (paste, open, edit). Helper utilities for opening and editing +// exported files live here as well. +// +// Logging helpers (logMsg, warn) and printHelp are in cli.go. +// Dispatch routing is in cli_dispatch.go. +package cli + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "codeberg.org/snonux/foostore/internal/store" +) + +// cmdAdd reads data from stdin and stores a new secret under the given description. +func (c *CLI) cmdAdd(ctx context.Context, argv []string) int { + if len(argv) < 2 { + warn("add requires a description argument") + return 1 + } + desc := argv[1] + // Ruby uses log 'Data: ' which emits "> Data: \n" before reading stdin. + logMsg("Data: ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + warn("no data provided") + return 1 + } + data := scanner.Text() + + if err := c.st.Add(ctx, desc, data); err != nil { + warn(err.Error()) + return 1 + } + return 0 +} + +// cmdImport imports a single file into the store. +// argv: import FILE [DEST] [force] +// +// Ruby dest_path logic (from Geheim#import): +// - No dest given: dest = normalised src_path (full path, "./" stripped) +// - dest contains a ".": dest is used literally (it is already a full dest path) +// - dest is a plain directory name: dest = "dir/basename(srcFile)" +func (c *CLI) cmdImport(ctx context.Context, argv []string) int { + if len(argv) < 2 { + warn("import requires a file argument") + return 1 + } + + srcFile := argv[1] + // Normalise source path the same way Ruby does. + normSrc := strings.ReplaceAll(srcFile, "//", "/") + normSrc = strings.TrimPrefix(normSrc, "./") + + dest, force := resolveImportDest(argv, srcFile, normSrc) + + if err := c.st.Import(ctx, srcFile, dest, force); err != nil { + warn(err.Error()) + return 1 + } + return 0 +} + +// resolveImportDest derives the destination path and force flag from the +// import argv according to the Ruby dest_dir rules: +// |
