From 7bca02bddcc88002e8edf72d2f20f526cf9d2668 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 18 Apr 2026 08:39:04 +0300 Subject: 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 --- internal/cli/cli_commands.go | 262 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 internal/cli/cli_commands.go (limited to 'internal/cli/cli_commands.go') 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: +// - No dest given: use the full normalised source path. +// - dest contains ".": treat it as the full literal dest path. +// - dest is a plain name: dest = "dir/basename(src)". +func resolveImportDest(argv []string, srcFile, normSrc string) (dest string, force bool) { + dest = normSrc // default: full normalised path, matching Ruby's nil dest_dir branch + 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 + } + } + return dest, force +} + +// 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 +} + +// 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 c.makePasteActionFn() + case store.ActionOpen: + return c.makeOpenActionFn(ctx) + case store.ActionEdit: + return c.makeEditActionFn(ctx) + default: + // cat, export, pathexport are handled directly by the store. + return nil + } +} + +// makePasteActionFn returns an action callback that pastes the decrypted +// content to the OS clipboard, skipping binary entries. +func (c *CLI) makePasteActionFn() func(context.Context, *store.Index, *store.Data) error { + 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)) + } +} + +// makeOpenActionFn returns an action callback that exports the entry to a +// temporary file, opens it with the OS-appropriate viewer, and then shreds the +// export immediately after the viewer exits. The outer _ parameter is unused +// because the cfg reference is captured via the receiver. +func (c *CLI) makeOpenActionFn(_ context.Context) func(context.Context, *store.Index, *store.Data) error { + 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) + } +} + +// makeEditActionFn returns an action callback that exports the entry, opens it +// in the configured editor, and reimports the (possibly modified) file when the +// editor exits. The outer _ parameter is unused because cfg is captured via +// the receiver. +func (c *CLI) makeEditActionFn(_ context.Context) func(context.Context, *store.Index, *store.Data) error { + 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) + } +} + +// 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) + + openCmd := resolveOpenCmd() + 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 +} + +// resolveOpenCmd returns the OS-appropriate command for opening a file with +// its default application. Checks environment variables first (UNAME, iTerm, +// Termux) before falling back to runtime.GOOS. +func resolveOpenCmd() string { + switch { + case os.Getenv("UNAME") == "Darwin" || runtime.GOOS == "darwin": + return "open" + case os.Getenv("TERM_PROGRAM") == "iTerm.app": + return "open" + case strings.Contains(os.Getenv("PREFIX"), "com.termux") || runtime.GOOS == "android": + // Termux on Android. + return "termux-open" + case runtime.GOOS == "windows": + return "winopen" + default: + // Linux: prefer xdg-open; fall back to evince for PDFs. + return "xdg-open" + } +} + +// 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 +} + +// printIndex prints a single store index entry to stdout. Used as the +// printFn callback for store.Search throughout the dispatch layer. +func printIndex(idx *store.Index) { + fmt.Print(idx.String()) +} -- cgit v1.2.3