1package cmd
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "slices"
9 "strings"
10
11 tea "charm.land/bubbletea/v2"
12 "charm.land/fang/v2"
13 "github.com/spf13/cobra"
14
15 "git.secluded.site/keld/internal/config"
16 "git.secluded.site/keld/internal/form"
17 "git.secluded.site/keld/internal/restic"
18 "git.secluded.site/keld/internal/theme"
19 "git.secluded.site/keld/internal/ui"
20 "git.secluded.site/keld/internal/ui/screens"
21)
22
23var (
24 flagPreset string
25 flagShowCmd bool
26 flagConfigFile string
27)
28
29const overrideArgumentsKey = "_arguments"
30
31var rootCmd = &cobra.Command{
32 Use: "keld [keld flags] <command> [restic flags...]",
33 Short: "A friendly wrapper around restic",
34 Long: "keld resolves layered TOML config presets and executes restic with the merged result.",
35 Example: ` keld backup
36 keld --preset home backup
37 keld --preset home@nas backup --tag daily /src
38 keld --show-command --preset home backup
39 keld --config ./keld.toml --preset home backup
40 keld backup --help
41 keld`,
42
43 SilenceUsage: true,
44 SilenceErrors: true,
45 TraverseChildren: true,
46
47 RunE: func(cmd *cobra.Command, _ []string) error {
48 // Bare keld with no subcommand requires a tty.
49 if !isInteractive() {
50 return fmt.Errorf("keld: no subcommand specified; non-interactive mode requires a subcommand (e.g. 'keld backup')")
51 }
52
53 commandName, preset, overrides, err := runInteractive("")
54 if err != nil {
55 return err
56 }
57 if commandName == "" {
58 // User cancelled.
59 return nil
60 }
61
62 return runCommand(commandName, cmd, nil, overrides, &preset)
63 },
64}
65
66func registerRootFlags() {
67 flags := rootCmd.PersistentFlags()
68 flags.StringVarP(&flagPreset, "preset", "P", "", "config preset to apply before running the command")
69 flags.BoolVar(&flagShowCmd, "show-command", false, "print the resolved restic command and exit")
70 flags.StringVarP(&flagConfigFile, "config", "C", "", "path to keld config file")
71}
72
73// runCommand resolves config and executes restic.
74//
75// sessionPreset signals that a TUI session already handled preset
76// selection. When non-nil, its value is used as the preset and the
77// standalone preset prompt is skipped. When nil, the subcommand was
78// invoked non-interactively (with arguments) and no prompting
79// occurs.
80func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessionOverrides map[string][]string, sessionPreset *string) error {
81 if flagConfigFile != "" {
82 if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
83 return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
84 }
85 }
86 if flagShowCmd {
87 if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
88 return fmt.Errorf("setting KELD_DRYRUN: %w", err)
89 }
90 }
91
92 preset := flagPreset
93 // Interactive mode when launched from root command or when a
94 // TUI session ran (sessionPreset != nil). The session handles
95 // preset selection, but runCommand may still need to collect
96 // values for commands without dedicated TUI screens (e.g.
97 // backup paths).
98 interactive := cmd == cmd.Root() || sessionPreset != nil
99
100 if sessionPreset != nil {
101 preset = *sessionPreset
102 }
103
104 // In non-interactive mode with no preset and multiple presets
105 // available, require explicit --preset selection.
106 if !interactive && preset == "" {
107 presets := config.Presets()
108 if len(presets) > 1 {
109 return fmt.Errorf("keld: multiple presets defined (%s); specify --preset in non-interactive mode", strings.Join(presets, ", "))
110 }
111 }
112
113 if preset != "" {
114 if err := validatePreset(preset); err != nil {
115 return err
116 }
117 }
118
119 overrides := mergeOverrides(parsePassthrough(rawArgs), sessionOverrides)
120
121 // In interactive mode, fill missing command-specific inputs
122 // via prompts. The session already handles preset selection,
123 // so only command-level prompts are needed here.
124 if interactive {
125 // Resolve config before prompting so we can skip questions for values
126 // already provided by presets.
127 peek, err := config.Resolve(preset, commandName, overrides)
128 if err != nil {
129 return err
130 }
131
132 cmdOverrides, err := promptForCommand(commandName, peek)
133 if err != nil {
134 return err
135 }
136 overrides = mergeOverrides(overrides, cmdOverrides)
137 }
138
139 cfg, err := config.Resolve(preset, commandName, overrides)
140 if err != nil {
141 return err
142 }
143
144 // In non-interactive mode, validate that required inputs are present.
145 if !interactive {
146 if commandName == "backup" && len(cfg.Arguments) == 0 {
147 return fmt.Errorf("keld backup: no paths specified; pass paths as arguments or set _arguments in the preset")
148 }
149 }
150
151 restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
152
153 if config.IsDryRun() {
154 fmt.Print(restic.DryRun(cfg))
155 return nil
156 }
157
158 return restic.Run(cfg)
159}
160
161func mergeOverrides(base, extra map[string][]string) map[string][]string {
162 if len(extra) == 0 {
163 return base
164 }
165 if base == nil {
166 base = make(map[string][]string, len(extra))
167 }
168 for key, values := range extra {
169 base[key] = values
170 }
171 return base
172}
173
174// Execute is the main entry point, called from main.go.
175func Execute() {
176 if err := fang.Execute(context.Background(), rootCmd); err != nil {
177 var exitErr interface{ ExitCode() int }
178 if errors.As(err, &exitErr) {
179 os.Exit(exitErr.ExitCode())
180 }
181 os.Exit(1)
182 }
183}
184
185// parsePassthrough converts restic-style CLI flags into a map suitable for
186// config.Resolve's cliOverrides parameter.
187func parsePassthrough(args []string) map[string][]string {
188 if len(args) == 0 {
189 return nil
190 }
191
192 overrides := make(map[string][]string)
193 var positional []string
194 for i := 0; i < len(args); i++ {
195 arg := args[i]
196 if arg == "--" {
197 if i+1 < len(args) {
198 positional = append(positional, args[i+1:]...)
199 }
200 break
201 }
202
203 if !isFlagToken(arg) {
204 positional = append(positional, arg)
205 continue
206 }
207
208 name := strings.TrimLeft(arg, "-")
209
210 // --flag=value form
211 if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 {
212 overrides[name[:eqIdx]] = append(overrides[name[:eqIdx]], name[eqIdx+1:])
213 continue
214 }
215
216 // Boolean flag (no next arg, or next arg is also a flag)
217 if i+1 >= len(args) || args[i+1] == "--" || isFlagToken(args[i+1]) {
218 if _, ok := overrides[name]; !ok {
219 overrides[name] = nil
220 }
221 continue
222 }
223
224 // --flag value form
225 i++
226 overrides[name] = append(overrides[name], args[i])
227 }
228
229 if len(positional) > 0 {
230 overrides[overrideArgumentsKey] = append([]string(nil), positional...)
231 }
232
233 if len(overrides) == 0 {
234 return nil
235 }
236
237 return overrides
238}
239
240func isFlagToken(arg string) bool {
241 return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
242}
243
244// runInteractive launches the unified TUI session with a command
245// menu, preset selector (if needed), and command-specific screens
246// (e.g. the restore flow). Returns the chosen command, preset, any
247// collected overrides, or ("", "", nil, nil) if the user cancelled.
248func runInteractive(preselectedCommand string) (command, preset string, overrides map[string][]string, err error) {
249 styles := theme.New(true)
250
251 var screenList []ui.Screen
252
253 // When a command is pre-selected (e.g. "keld restore"), skip the
254 // menu entirely. Otherwise show the interactive command menu.
255 var menuScreen *screens.Menu
256 if preselectedCommand == "" {
257 menuScreen = screens.NewMenu(menuItems, &styles)
258 screenList = append(screenList, menuScreen)
259 }
260
261 // Build the preset screen only when needed:
262 // - --preset on CLI: skip (already resolved).
263 // - Zero presets: skip (global defaults only).
264 // - One preset: auto-select, skip.
265 // - Multiple presets: show the filterable selector.
266 presets := config.Presets()
267 var presetScreen *screens.Preset
268 if flagPreset != "" {
269 preset = flagPreset
270 } else if len(presets) > 1 {
271 presetScreen = screens.NewPreset(presets, &styles)
272 screenList = append(screenList, presetScreen)
273 } else if len(presets) == 1 {
274 preset = presets[0]
275 }
276
277 // cmdScreens is populated by the resolve screen's builder function
278 // so the caller can extract results after the session completes.
279 var cmdScreens *commandScreens
280
281 // The resolve screen sits between menu/preset and command-specific
282 // screens. It resolves the config and dynamically builds the
283 // remaining screens via ExtendMsg.
284 resolveScreen := screens.NewResolve(func() []ui.Screen {
285 cmd := preselectedCommand
286 if menuScreen != nil {
287 cmd = menuScreen.Selection()
288 }
289 p := preset
290 if presetScreen != nil {
291 p = presetScreen.Value()
292 }
293
294 cmdScreens = buildCommandScreens(cmd, p, &styles)
295
296 // Build the confirmation screen. Its preview function
297 // resolves the config with all current screen values so
298 // it shows exactly what will be executed.
299 previewFn := func() string {
300 var overrides map[string][]string
301 if cmdScreens != nil {
302 overrides = extractCommandOverrides(cmd, cmdScreens)
303 }
304 cfg, err := config.Resolve(p, cmd, overrides)
305 if err != nil {
306 return fmt.Sprintf("config error: %v\n", err)
307 }
308 return restic.DryRun(cfg)
309 }
310 confirmScreen := screens.NewConfirm(previewFn, flagShowCmd)
311
312 // When the command has no dedicated TUI screens, the
313 // preview may be incomplete — additional prompts can
314 // follow after confirmation.
315 if cmdScreens == nil {
316 confirmScreen.SetPartial(true)
317 }
318
319 var result []ui.Screen
320 if cmdScreens != nil {
321 result = append(result, cmdScreens.list...)
322 }
323 result = append(result, confirmScreen)
324 return result
325 })
326 screenList = append(screenList, resolveScreen)
327
328 session := ui.New(screenList, &styles)
329 prog := tea.NewProgram(session)
330 result, err := prog.Run()
331 if err != nil {
332 return "", "", nil, fmt.Errorf("interactive session: %w", err)
333 }
334
335 s := result.(ui.Session)
336 if !s.Completed() {
337 return "", "", nil, nil
338 }
339
340 command = preselectedCommand
341 if menuScreen != nil {
342 command = menuScreen.Selection()
343 }
344 if !slices.Contains(knownCommands, command) {
345 return "", "", nil, fmt.Errorf("unknown menu command %q", command)
346 }
347
348 if presetScreen != nil {
349 preset = presetScreen.Value()
350 }
351
352 // Extract overrides from the completed command-specific screens.
353 overrides = extractCommandOverrides(command, cmdScreens)
354
355 return command, preset, overrides, nil
356}
357
358// commandScreens holds the screens built for a command's interactive
359// flow along with typed references to each screen. This lets
360// runInteractive read results from the completed screens without
361// resorting to double-pointer output parameters.
362type commandScreens struct {
363 list []ui.Screen
364 snapshot *screens.Snapshot
365 filePicker *screens.FilePicker
366 target *screens.Target
367 overwrite *screens.Overwrite
368}
369
370// buildCommandScreens resolves the config for the given command and
371// preset, then builds the command-specific screens that need user
372// input. Returns nil when the command needs no interactive screens.
373func buildCommandScreens(command, preset string, styles *theme.Styles) *commandScreens {
374 switch command {
375 case "restore":
376 return buildRestoreScreens(preset, styles)
377 default:
378 return nil
379 }
380}
381
382// buildRestoreScreens creates the restore flow screens, skipping any
383// that are already provided by the resolved config.
384func buildRestoreScreens(preset string, styles *theme.Styles) *commandScreens {
385 cfg, err := config.Resolve(preset, "restore", nil)
386 if err != nil {
387 // Config resolution failed. Return no screens so the session
388 // completes immediately; runCommand will call config.Resolve
389 // again and surface the error to the user.
390 return nil
391 }
392
393 cs := &commandScreens{}
394
395 // Step 1: Snapshot ID (skip if already provided as an argument).
396 if len(cfg.Arguments) == 0 {
397 loader := screens.SnapshotLoader(func() ([]restic.Snapshot, error) {
398 return restic.ListSnapshots(cfg)
399 })
400 cs.snapshot = screens.NewSnapshot(loader, styles)
401 cs.list = append(cs.list, cs.snapshot)
402 }
403
404 // Step 2: File selection (skip if include flags set or snapshot
405 // uses snapshotID:subfolder syntax). The file picker needs the
406 // snapshot ID — it reads it from a closure.
407 if !hasIncludeFlags(cfg) {
408 // Determine the snapshot ID source: from the snapshot screen
409 // if it exists, otherwise from the resolved config.
410 snapshotIDFn := func() string {
411 if cs.snapshot != nil {
412 return cs.snapshot.Value()
413 }
414 if len(cfg.Arguments) > 0 {
415 return cfg.Arguments[0]
416 }
417 return ""
418 }
419
420 // Build a loader that checks for the snapshotID:subfolder
421 // skip condition at call time (the ID may not be known until
422 // the snapshot screen completes).
423 fileLoader := func(snapshotID string) ([]restic.LsNode, error) {
424 if strings.Contains(snapshotID, ":") || snapshotID == "" {
425 // Signal that listing should be skipped. Returning
426 // nil nodes causes the picker to auto-advance.
427 return nil, nil
428 }
429 return restic.RunLs(cfg, snapshotID)
430 }
431
432 cs.filePicker = screens.NewFilePicker(fileLoader, snapshotIDFn, styles)
433 cs.list = append(cs.list, cs.filePicker)
434 }
435
436 // Step 3: Target directory (skip if already set).
437 if !cfg.HasFlag("target") {
438 cs.target = screens.NewTarget(styles)
439 cs.list = append(cs.list, cs.target)
440 }
441
442 // Step 4: Overwrite behaviour (skip if already set).
443 if !cfg.HasFlag("overwrite") {
444 cs.overwrite = screens.NewOverwrite(styles)
445 cs.list = append(cs.list, cs.overwrite)
446 }
447
448 return cs
449}
450
451// extractCommandOverrides reads the completed screen values and
452// returns a map suitable for passing to runCommand as overrides.
453func extractCommandOverrides(command string, cs *commandScreens) map[string][]string {
454 if command != "restore" || cs == nil {
455 return nil
456 }
457
458 overrides := make(map[string][]string)
459
460 if cs.snapshot != nil {
461 if v := cs.snapshot.Value(); v != "" {
462 overrides[overrideArgumentsKey] = []string{v}
463 }
464 }
465
466 if cs.filePicker != nil {
467 if includes := cs.filePicker.Includes(); len(includes) > 0 {
468 overrides["include"] = includes
469 }
470 }
471
472 if cs.target != nil {
473 if v := cs.target.Value(); v != "" {
474 overrides["target"] = []string{v}
475 }
476 }
477
478 if cs.overwrite != nil {
479 if v := cs.overwrite.Value(); v != "" {
480 overrides["overwrite"] = []string{v}
481 }
482 }
483
484 if len(overrides) == 0 {
485 return nil
486 }
487 return overrides
488}
489
490// validatePreset checks that the given preset name matches one of the usable
491// presets derived from the config. Returns a descriptive error if not found.
492func validatePreset(preset string) error {
493 known := config.Presets()
494 for _, p := range known {
495 if p == preset {
496 return nil
497 }
498 }
499 if len(known) == 0 {
500 return fmt.Errorf("unknown preset %q (no presets defined in config)", preset)
501 }
502 return fmt.Errorf("unknown preset %q; available presets: %s", preset, strings.Join(known, ", "))
503}
504
505// promptForCommand collects required inputs for commands that need them.
506// The resolved config is checked first so prompts are skipped for values
507// the preset already provides.
508// Returns CLI overrides to merge, or nil if the command needs no extra input.
509func promptForCommand(command string, cfg *config.ResolvedConfig) (map[string][]string, error) {
510 switch command {
511 case "restore":
512 // Handled by the unified TUI session; nothing to prompt here.
513 return nil, nil
514 case "backup":
515 return promptBackup(cfg)
516 default:
517 return nil, nil
518 }
519}
520
521// includeFlags lists the restic flags that provide explicit file
522// selection for restore. When any of these are already present in the
523// resolved config, the interactive file picker is skipped — the user
524// has already told restic what to include.
525var includeFlags = []string{"include", "i", "include-file", "iinclude", "iinclude-file"}
526
527// hasIncludeFlags reports whether the resolved config already contains
528// any include-style flags.
529func hasIncludeFlags(cfg *config.ResolvedConfig) bool {
530 for _, name := range includeFlags {
531 if cfg.HasFlag(name) {
532 return true
533 }
534 }
535 return false
536}
537
538// promptBackup collects backup paths when none are configured in the preset.
539func promptBackup(cfg *config.ResolvedConfig) (map[string][]string, error) {
540 if len(cfg.Arguments) > 0 {
541 return nil, nil
542 }
543
544 paths, err := form.BackupPaths()
545 if err != nil {
546 return nil, fmt.Errorf("backup paths: %w", err)
547 }
548 return map[string][]string{
549 overrideArgumentsKey: paths,
550 }, nil
551}