root.go

  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}