init.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5// Package init provides the interactive setup wizard for lune.
  6package init
  7
  8import (
  9	"errors"
 10	"fmt"
 11	"io"
 12
 13	"github.com/charmbracelet/huh"
 14	"github.com/spf13/cobra"
 15
 16	"git.secluded.site/lune/internal/config"
 17	"git.secluded.site/lune/internal/ui"
 18)
 19
 20// errQuit signals that the user wants to exit the wizard entirely.
 21var errQuit = errors.New("user quit")
 22
 23// errReset signals that the user reset configuration and should run fresh setup.
 24var errReset = errors.New("configuration reset")
 25
 26// wizardNav represents navigation direction in the wizard.
 27type wizardNav int
 28
 29const (
 30	navNext wizardNav = iota
 31	navBack
 32	navQuit
 33)
 34
 35// Cmd is the init command for interactive setup.
 36var Cmd = &cobra.Command{
 37	Use:   "init",
 38	Short: "Interactive setup wizard",
 39	Long: `Configure lune interactively.
 40
 41This command will guide you through:
 42  - Adding areas, goals, notebooks, and habits from Lunatask
 43  - Setting default area and notebook
 44  - Configuring and verifying your access token`,
 45	RunE: runInit,
 46}
 47
 48func runInit(cmd *cobra.Command, _ []string) error {
 49	cfg, err := config.Load()
 50	if errors.Is(err, config.ErrNotFound) {
 51		cfg = &config.Config{}
 52
 53		err = runFreshSetup(cmd, cfg)
 54		if errors.Is(err, errQuit) {
 55			fmt.Fprintln(cmd.OutOrStdout(), ui.Warning.Render("Setup cancelled."))
 56
 57			return nil
 58		}
 59
 60		return err
 61	}
 62
 63	if err != nil {
 64		return fmt.Errorf("loading config: %w", err)
 65	}
 66
 67	err = runReconfigure(cmd, cfg)
 68	if errors.Is(err, errQuit) {
 69		return nil
 70	}
 71
 72	if errors.Is(err, errReset) {
 73		return runFreshSetup(cmd, cfg)
 74	}
 75
 76	return err
 77}
 78
 79// wizardStep is a function that runs a wizard step and returns navigation direction.
 80type wizardStep func() wizardNav
 81
 82func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
 83	printWelcome(cmd)
 84
 85	steps := []wizardStep{
 86		func() wizardNav { return runUIPrefsStep(cfg) },
 87		func() wizardNav { return runAreasStep(cfg) },
 88		func() wizardNav { return runNotebooksStep(cfg) },
 89		func() wizardNav { return runHabitsStep(cfg) },
 90		func() wizardNav { return runDefaultsStep(cfg) },
 91		func() wizardNav { return runAccessTokenStep(cmd) },
 92	}
 93
 94	step := 0
 95	for step < len(steps) {
 96		nav := steps[step]()
 97
 98		switch nav {
 99		case navNext:
100			step++
101		case navBack:
102			if step > 0 {
103				step--
104			}
105		case navQuit:
106			return errQuit
107		}
108	}
109
110	return saveWithSummary(cmd, cfg)
111}
112
113func printWelcome(cmd *cobra.Command) {
114	out := cmd.OutOrStdout()
115	fmt.Fprintln(out, ui.Bold.Render("Welcome to lune!"))
116	fmt.Fprintln(out)
117	fmt.Fprintln(out, "This wizard will help you configure lune for use with Lunatask.")
118	fmt.Fprintln(out)
119	fmt.Fprintln(out, "Since Lunatask is end-to-end encrypted, lune can't fetch your")
120	fmt.Fprintln(out, "areas, goals, notebooks, or habits automatically. You'll need to")
121	fmt.Fprintln(out, "copy IDs from the Lunatask app.")
122	fmt.Fprintln(out)
123	fmt.Fprintln(out, ui.Bold.Render("Where to find IDs:"))
124	fmt.Fprintln(out, "  Open any item's settings modal → click 'Copy [Item] ID' (bottom left)")
125	fmt.Fprintln(out)
126	fmt.Fprintln(out, "You can run 'lune init' again anytime to add or modify config.")
127	fmt.Fprintln(out)
128}
129
130func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
131	fmt.Fprintln(cmd.OutOrStdout(), ui.Bold.Render("lune configuration"))
132	fmt.Fprintln(cmd.OutOrStdout())
133
134	handlers := map[string]func() error{
135		"areas":     func() error { return manageAreas(cfg) },
136		"notebooks": func() error { return manageNotebooks(cfg) },
137		"habits":    func() error { return manageHabits(cfg) },
138		"defaults":  func() error { return configureDefaults(cfg) },
139		"ui":        func() error { return configureUIPrefs(cfg) },
140		"apikey":    func() error { return configureAccessToken(cmd) },
141		"reset":     func() error { return resetConfig(cmd, cfg) },
142	}
143
144	for {
145		var choice string
146
147		err := huh.NewSelect[string]().
148			Title("What would you like to configure?").
149			Options(
150				huh.NewOption("Manage areas & goals", "areas"),
151				huh.NewOption("Manage notebooks", "notebooks"),
152				huh.NewOption("Manage habits", "habits"),
153				huh.NewOption("Set defaults", "defaults"),
154				huh.NewOption("UI preferences", "ui"),
155				huh.NewOption("Access token", "apikey"),
156				huh.NewOption("Reset all configuration", "reset"),
157				huh.NewOption("Done", choiceDone),
158			).
159			Value(&choice).
160			Run()
161		if err != nil {
162			if errors.Is(err, huh.ErrUserAborted) {
163				return errQuit
164			}
165
166			return err
167		}
168
169		if choice == choiceDone {
170			return saveWithSummary(cmd, cfg)
171		}
172
173		if handler, ok := handlers[choice]; ok {
174			if err := handler(); err != nil {
175				return err
176			}
177		}
178	}
179}
180
181func printConfigSummary(out io.Writer, cfg *config.Config) {
182	goalCount := 0
183	for _, area := range cfg.Areas {
184		goalCount += len(area.Goals)
185	}
186
187	fmt.Fprintln(out)
188	fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
189	fmt.Fprintf(out, "  Areas:     %d (%d goals)\n", len(cfg.Areas), goalCount)
190	fmt.Fprintf(out, "  Notebooks: %d\n", len(cfg.Notebooks))
191	fmt.Fprintf(out, "  Habits:    %d\n", len(cfg.Habits))
192
193	if cfg.Defaults.Area != "" {
194		fmt.Fprintf(out, "  Default area: %s\n", cfg.Defaults.Area)
195	}
196
197	if cfg.Defaults.Notebook != "" {
198		fmt.Fprintf(out, "  Default notebook: %s\n", cfg.Defaults.Notebook)
199	}
200
201	color := cfg.UI.Color
202	if color == "" {
203		color = colorAuto
204	}
205
206	fmt.Fprintf(out, "  Color: %s\n", color)
207	fmt.Fprintln(out)
208}
209
210func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
211	path, err := config.Path()
212	if err != nil {
213		return err
214	}
215
216	out := cmd.OutOrStdout()
217	printConfigSummary(out, cfg)
218
219	var save bool
220
221	err = huh.NewConfirm().
222		Title("Save configuration?").
223		Affirmative("Save").
224		Negative("Discard").
225		Value(&save).
226		Run()
227	if err != nil {
228		if errors.Is(err, huh.ErrUserAborted) {
229			fmt.Fprintln(out, ui.Warning.Render("Setup cancelled; configuration was not saved."))
230
231			return errQuit
232		}
233
234		return err
235	}
236
237	if save {
238		if err := cfg.Save(); err != nil {
239			return err
240		}
241
242		fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
243	} else {
244		fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
245	}
246
247	return nil
248}