root.go

  1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"slices"
  8	"strings"
  9
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/fang/v2"
 12	"github.com/spf13/cobra"
 13
 14	"git.secluded.site/keld/internal/config"
 15	"git.secluded.site/keld/internal/form"
 16	"git.secluded.site/keld/internal/menu"
 17	"git.secluded.site/keld/internal/restic"
 18)
 19
 20var (
 21	flagPreset     string
 22	flagShowCmd    bool
 23	flagConfigFile string
 24)
 25
 26const overrideArgumentsKey = "_arguments"
 27
 28var rootCmd = &cobra.Command{
 29	Use:   "keld [keld flags] <command> [restic flags...]",
 30	Short: "A friendly wrapper around restic",
 31	Long:  "keld resolves layered TOML config presets and executes restic with the merged result.",
 32	Example: `  keld backup
 33	keld --preset home backup
 34	keld --preset home@nas backup --tag daily /src
 35	keld --show-command --preset home backup
 36	keld --config ./keld.toml --preset home backup
 37	keld backup --help
 38	keld`,
 39
 40	SilenceUsage:     true,
 41	SilenceErrors:    true,
 42	TraverseChildren: true,
 43
 44	RunE: func(cmd *cobra.Command, _ []string) error {
 45		selected, err := runMenu()
 46		if err != nil {
 47			return fmt.Errorf("menu: %w", err)
 48		}
 49		if selected == "" || selected == "quit" {
 50			return nil
 51		}
 52		if !slices.Contains(knownCommands, selected) {
 53			return fmt.Errorf("unknown menu command %q", selected)
 54		}
 55
 56		return runCommand(selected, cmd, nil)
 57	},
 58}
 59
 60func registerRootFlags() {
 61	flags := rootCmd.PersistentFlags()
 62	flags.StringVarP(&flagPreset, "preset", "P", "", "config preset to apply before running the command")
 63	flags.BoolVar(&flagShowCmd, "show-command", false, "print the resolved restic command and exit")
 64	flags.StringVarP(&flagConfigFile, "config", "C", "", "path to keld config file")
 65}
 66
 67func runCommand(commandName string, cmd *cobra.Command, rawArgs []string) error {
 68	if flagConfigFile != "" {
 69		if err := os.Setenv("KELD_CONFIG_FILE", flagConfigFile); err != nil {
 70			return fmt.Errorf("setting KELD_CONFIG_FILE: %w", err)
 71		}
 72	}
 73	if flagShowCmd {
 74		if err := os.Setenv("KELD_DRYRUN", "1"); err != nil {
 75			return fmt.Errorf("setting KELD_DRYRUN: %w", err)
 76		}
 77	}
 78
 79	preset := flagPreset
 80	interactive := cmd == cmd.Root()
 81
 82	if preset != "" {
 83		if err := validatePreset(preset); err != nil {
 84			return err
 85		}
 86	}
 87
 88	overrides := parsePassthrough(rawArgs)
 89
 90	// In interactive mode, fill missing preset/command inputs via prompts.
 91	if interactive {
 92		if preset == "" {
 93			p, err := promptPreset()
 94			if err != nil {
 95				return err
 96			}
 97			preset = p
 98		}
 99
100		// Resolve config before prompting so we can skip questions for values
101		// already provided by presets.
102		peek, err := config.Resolve(preset, commandName, overrides)
103		if err != nil {
104			return err
105		}
106
107		cmdOverrides, err := promptForCommand(commandName, peek)
108		if err != nil {
109			return err
110		}
111		overrides = mergeOverrides(overrides, cmdOverrides)
112	}
113
114	cfg, err := config.Resolve(preset, commandName, overrides)
115	if err != nil {
116		return err
117	}
118
119	restic.WarnUnknownFlags(cfg.Command, cfg.Flags)
120
121	if config.IsDryRun() {
122		fmt.Print(restic.DryRun(cfg))
123		return nil
124	}
125
126	return restic.Run(cfg)
127}
128
129func mergeOverrides(base, extra map[string][]string) map[string][]string {
130	if len(extra) == 0 {
131		return base
132	}
133	if base == nil {
134		base = make(map[string][]string, len(extra))
135	}
136	for key, values := range extra {
137		base[key] = values
138	}
139	return base
140}
141
142// Execute is the main entry point, called from main.go.
143func Execute() {
144	if err := fang.Execute(context.Background(), rootCmd); err != nil {
145		os.Exit(1)
146	}
147}
148
149// parsePassthrough converts restic-style CLI flags into a map suitable for
150// config.Resolve's cliOverrides parameter.
151func parsePassthrough(args []string) map[string][]string {
152	if len(args) == 0 {
153		return nil
154	}
155
156	overrides := make(map[string][]string)
157	var positional []string
158	for i := 0; i < len(args); i++ {
159		arg := args[i]
160		if arg == "--" {
161			if i+1 < len(args) {
162				positional = append(positional, args[i+1:]...)
163			}
164			break
165		}
166
167		if !isFlagToken(arg) {
168			positional = append(positional, arg)
169			continue
170		}
171
172		name := strings.TrimLeft(arg, "-")
173
174		// --flag=value form
175		if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 {
176			overrides[name[:eqIdx]] = append(overrides[name[:eqIdx]], name[eqIdx+1:])
177			continue
178		}
179
180		// Boolean flag (no next arg, or next arg is also a flag)
181		if i+1 >= len(args) || args[i+1] == "--" || isFlagToken(args[i+1]) {
182			if _, ok := overrides[name]; !ok {
183				overrides[name] = nil
184			}
185			continue
186		}
187
188		// --flag value form
189		i++
190		overrides[name] = append(overrides[name], args[i])
191	}
192
193	if len(positional) > 0 {
194		overrides[overrideArgumentsKey] = append([]string(nil), positional...)
195	}
196
197	if len(overrides) == 0 {
198		return nil
199	}
200
201	return overrides
202}
203
204func isFlagToken(arg string) bool {
205	return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--"
206}
207
208// runMenu launches the interactive BubbleTea menu and returns the chosen
209// command, or "" if the user quit.
210func runMenu() (string, error) {
211	m := menu.New(menuItems)
212	p := tea.NewProgram(m)
213	result, err := p.Run()
214	if err != nil {
215		return "", err
216	}
217	model, ok := result.(menu.Model)
218	if !ok {
219		return "", fmt.Errorf("unexpected menu model type %T", result)
220	}
221	return model.Choice(), nil
222}
223
224// validatePreset checks that the given preset name matches one of the usable
225// presets derived from the config. Returns a descriptive error if not found.
226func validatePreset(preset string) error {
227	known := config.Presets()
228	for _, p := range known {
229		if p == preset {
230			return nil
231		}
232	}
233	if len(known) == 0 {
234		return fmt.Errorf("unknown preset %q (no presets defined in config)", preset)
235	}
236	return fmt.Errorf("unknown preset %q; available presets: %s", preset, strings.Join(known, ", "))
237}
238
239// promptPreset shows an interactive preset selector when presets are defined
240// in the config. Returns "" (global-only) if no presets exist.
241func promptPreset() (string, error) {
242	presets := config.Presets()
243	if len(presets) == 0 {
244		return "", nil
245	}
246	selected, err := form.SelectPreset(presets)
247	if err != nil {
248		return "", fmt.Errorf("preset selection: %w", err)
249	}
250	return selected, nil
251}
252
253// promptForCommand collects required inputs for commands that need them.
254// The resolved config is checked first so prompts are skipped for values
255// the preset already provides.
256// Returns CLI overrides to merge, or nil if the command needs no extra input.
257func promptForCommand(command string, cfg *config.ResolvedConfig) (map[string][]string, error) {
258	switch command {
259	case "restore":
260		return promptRestore(cfg)
261	case "backup":
262		return promptBackup(cfg)
263	default:
264		return nil, nil
265	}
266}
267
268// promptRestore collects the snapshot ID and target directory for restore,
269// skipping prompts for values already present in the resolved config.
270func promptRestore(cfg *config.ResolvedConfig) (map[string][]string, error) {
271	hasSnapshotID := len(cfg.Arguments) > 0
272	hasTarget := cfg.HasFlag("target")
273
274	if hasSnapshotID && hasTarget {
275		return nil, nil
276	}
277
278	snapshotID, target, err := form.RestoreInputs(hasSnapshotID, hasTarget)
279	if err != nil {
280		return nil, fmt.Errorf("restore inputs: %w", err)
281	}
282
283	overrides := make(map[string][]string)
284	if !hasSnapshotID {
285		overrides[overrideArgumentsKey] = []string{snapshotID}
286	}
287	if !hasTarget {
288		overrides["target"] = []string{target}
289	}
290	return overrides, nil
291}
292
293// promptBackup collects backup paths when none are configured in the preset.
294func promptBackup(cfg *config.ResolvedConfig) (map[string][]string, error) {
295	if len(cfg.Arguments) > 0 {
296		return nil, nil
297	}
298
299	paths, err := form.BackupPaths()
300	if err != nil {
301		return nil, fmt.Errorf("backup paths: %w", err)
302	}
303	return map[string][]string{
304		overrideArgumentsKey: paths,
305	}, nil
306}