summaryrefslogtreecommitdiff
path: root/internal/cli/cli_commands.go
blob: ff7669f8cd42e4ecd193d8f218f7bf8644739fd1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
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())
}