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	if err := ensureAccessToken(cmd); err != nil {
135		return err
136	}
137
138	handlers := map[string]func() error{
139		"areas":     func() error { return manageAreas(cfg) },
140		"notebooks": func() error { return manageNotebooks(cfg) },
141		"habits":    func() error { return manageHabits(cfg) },
142		"defaults":  func() error { return configureDefaults(cfg) },
143		"ui":        func() error { return configureUIPrefs(cfg) },
144		"apikey":    func() error { return configureAccessToken(cmd) },
145		"reset":     func() error { return resetConfig(cmd, cfg) },
146	}
147
148	for {
149		var choice string
150
151		err := huh.NewSelect[string]().
152			Title("What would you like to configure?").
153			Options(
154				huh.NewOption("Manage areas & goals", "areas"),
155				huh.NewOption("Manage notebooks", "notebooks"),
156				huh.NewOption("Manage habits", "habits"),
157				huh.NewOption("Set defaults", "defaults"),
158				huh.NewOption("UI preferences", "ui"),
159				huh.NewOption("Access token", "apikey"),
160				huh.NewOption("Reset all configuration", "reset"),
161				huh.NewOption("Done", choiceDone),
162			).
163			Value(&choice).
164			Run()
165		if err != nil {
166			if errors.Is(err, huh.ErrUserAborted) {
167				return errQuit
168			}
169
170			return err
171		}
172
173		if choice == choiceDone {
174			return saveWithSummary(cmd, cfg)
175		}
176
177		if handler, ok := handlers[choice]; ok {
178			if err := handler(); err != nil {
179				return err
180			}
181		}
182	}
183}
184
185func printConfigSummary(out io.Writer, cfg *config.Config) {
186	goalCount := 0
187	for _, area := range cfg.Areas {
188		goalCount += len(area.Goals)
189	}
190
191	fmt.Fprintln(out)
192	fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
193	fmt.Fprintf(out, "  Areas:     %d (%d goals)\n", len(cfg.Areas), goalCount)
194	fmt.Fprintf(out, "  Notebooks: %d\n", len(cfg.Notebooks))
195	fmt.Fprintf(out, "  Habits:    %d\n", len(cfg.Habits))
196
197	if cfg.Defaults.Area != "" {
198		fmt.Fprintf(out, "  Default area: %s\n", cfg.Defaults.Area)
199	}
200
201	if cfg.Defaults.Notebook != "" {
202		fmt.Fprintf(out, "  Default notebook: %s\n", cfg.Defaults.Notebook)
203	}
204
205	color := cfg.UI.Color
206	if color == "" {
207		color = colorAuto
208	}
209
210	fmt.Fprintf(out, "  Color: %s\n", color)
211	fmt.Fprintln(out)
212}
213
214func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
215	path, err := config.Path()
216	if err != nil {
217		return err
218	}
219
220	out := cmd.OutOrStdout()
221	printConfigSummary(out, cfg)
222
223	var save bool
224
225	err = huh.NewConfirm().
226		Title("Save configuration?").
227		Affirmative("Save").
228		Negative("Discard").
229		Value(&save).
230		Run()
231	if err != nil {
232		if errors.Is(err, huh.ErrUserAborted) {
233			fmt.Fprintln(out, ui.Warning.Render("Setup cancelled; configuration was not saved."))
234
235			return errQuit
236		}
237
238		return err
239	}
240
241	if save {
242		if err := cfg.Save(); err != nil {
243			return err
244		}
245
246		fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
247	} else {
248		fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
249	}
250
251	return nil
252}