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	"os"
 13	"path/filepath"
 14
 15	"github.com/charmbracelet/huh"
 16	"github.com/spf13/cobra"
 17
 18	"git.secluded.site/lune/internal/config"
 19	"git.secluded.site/lune/internal/ui"
 20)
 21
 22// errQuit signals that the user wants to exit the wizard entirely.
 23var errQuit = errors.New("user quit")
 24
 25// errReset signals that the user reset configuration and should run fresh setup.
 26var errReset = errors.New("configuration reset")
 27
 28// errNonInteractive signals that init was run without a terminal.
 29var errNonInteractive = errors.New("non-interactive terminal")
 30
 31// errConfigExists signals that a config file already exists.
 32var errConfigExists = errors.New("config already exists")
 33
 34// wizardNav represents navigation direction in the wizard.
 35type wizardNav int
 36
 37const (
 38	navNext wizardNav = iota
 39	navBack
 40	navQuit
 41)
 42
 43// Cmd is the init command for interactive setup.
 44var Cmd = &cobra.Command{
 45	Use:   "init",
 46	Short: "Interactive setup wizard",
 47	Long: `Configure lune interactively.
 48
 49This command will guide you through:
 50  - Adding areas, goals, notebooks, and habits from Lunatask
 51  - Setting default area and notebook
 52  - Configuring and verifying your access token
 53
 54Use --generate-config to create an example config file for manual editing
 55when running non-interactively.`,
 56	RunE: runInit,
 57}
 58
 59func init() {
 60	Cmd.Flags().Bool("generate-config", false, "generate example config for manual editing")
 61}
 62
 63func runInit(cmd *cobra.Command, _ []string) error {
 64	generateConfig, _ := cmd.Flags().GetBool("generate-config")
 65	if generateConfig {
 66		return runGenerateConfig(cmd)
 67	}
 68
 69	if !ui.IsInteractive() {
 70		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("lune init requires an interactive terminal."))
 71		fmt.Fprintln(cmd.ErrOrStderr())
 72		fmt.Fprintln(cmd.ErrOrStderr(), "To configure lune non-interactively, run:")
 73		fmt.Fprintln(cmd.ErrOrStderr(), "  lune init --generate-config")
 74		fmt.Fprintln(cmd.ErrOrStderr())
 75		fmt.Fprintln(cmd.ErrOrStderr(), "Then edit the generated config file manually.")
 76
 77		return errNonInteractive
 78	}
 79
 80	cfg, err := config.Load()
 81	if errors.Is(err, config.ErrNotFound) {
 82		cfg = &config.Config{}
 83
 84		err = runFreshSetup(cmd, cfg)
 85		if errors.Is(err, errQuit) {
 86			fmt.Fprintln(cmd.OutOrStdout(), ui.Warning.Render("Setup cancelled."))
 87
 88			return nil
 89		}
 90
 91		return err
 92	}
 93
 94	if err != nil {
 95		return fmt.Errorf("loading config: %w", err)
 96	}
 97
 98	err = runReconfigure(cmd, cfg)
 99	if errors.Is(err, errQuit) {
100		return nil
101	}
102
103	if errors.Is(err, errReset) {
104		return runFreshSetup(cmd, cfg)
105	}
106
107	return err
108}
109
110// wizardStep is a function that runs a wizard step and returns navigation direction.
111type wizardStep func() wizardNav
112
113func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
114	printWelcome(cmd)
115
116	steps := []wizardStep{
117		func() wizardNav { return runUIPrefsStep(cfg) },
118		func() wizardNav { return runAreasStep(cfg) },
119		func() wizardNav { return runNotebooksStep(cfg) },
120		func() wizardNav { return runHabitsStep(cfg) },
121		func() wizardNav { return runDefaultsStep(cfg) },
122		func() wizardNav { return runAccessTokenStep(cmd) },
123	}
124
125	step := 0
126	for step < len(steps) {
127		nav := steps[step]()
128
129		switch nav {
130		case navNext:
131			step++
132		case navBack:
133			if step > 0 {
134				step--
135			}
136		case navQuit:
137			return errQuit
138		}
139	}
140
141	return saveWithSummary(cmd, cfg)
142}
143
144func printWelcome(cmd *cobra.Command) {
145	out := cmd.OutOrStdout()
146	fmt.Fprintln(out, ui.Bold.Render("Welcome to lune!"))
147	fmt.Fprintln(out)
148	fmt.Fprintln(out, "This wizard will help you configure lune for use with Lunatask.")
149	fmt.Fprintln(out)
150	fmt.Fprintln(out, "Since Lunatask is end-to-end encrypted, lune can't fetch your")
151	fmt.Fprintln(out, "areas, goals, notebooks, or habits automatically. You'll need to")
152	fmt.Fprintln(out, "copy IDs from the Lunatask app.")
153	fmt.Fprintln(out)
154	fmt.Fprintln(out, ui.Bold.Render("Where to find IDs:"))
155	fmt.Fprintln(out, "  Open any item's settings modal → click 'Copy [Item] ID' (bottom left)")
156	fmt.Fprintln(out)
157	fmt.Fprintln(out, "You can run 'lune init' again anytime to add or modify config.")
158	fmt.Fprintln(out)
159}
160
161func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
162	fmt.Fprintln(cmd.OutOrStdout(), ui.Bold.Render("lune configuration"))
163	fmt.Fprintln(cmd.OutOrStdout())
164
165	if err := ensureAccessToken(cmd); err != nil {
166		return err
167	}
168
169	handlers := map[string]func() error{
170		"areas":     func() error { return manageAreas(cfg) },
171		"notebooks": func() error { return manageNotebooks(cfg) },
172		"habits":    func() error { return manageHabits(cfg) },
173		"defaults":  func() error { return configureDefaults(cfg) },
174		"ui":        func() error { return configureUIPrefs(cfg) },
175		"apikey":    func() error { return configureAccessToken(cmd) },
176		"reset":     func() error { return resetConfig(cmd, cfg) },
177	}
178
179	for {
180		var choice string
181
182		err := huh.NewSelect[string]().
183			Title("What would you like to configure?").
184			Options(
185				huh.NewOption("Manage areas & goals", "areas"),
186				huh.NewOption("Manage notebooks", "notebooks"),
187				huh.NewOption("Manage habits", "habits"),
188				huh.NewOption("Set defaults", "defaults"),
189				huh.NewOption("UI preferences", "ui"),
190				huh.NewOption("Access token", "apikey"),
191				huh.NewOption("Reset all configuration", "reset"),
192				huh.NewOption("Done", choiceDone),
193			).
194			Value(&choice).
195			Run()
196		if err != nil {
197			if errors.Is(err, huh.ErrUserAborted) {
198				return errQuit
199			}
200
201			return err
202		}
203
204		if choice == choiceDone {
205			return saveWithSummary(cmd, cfg)
206		}
207
208		if handler, ok := handlers[choice]; ok {
209			if err := handler(); err != nil {
210				return err
211			}
212		}
213	}
214}
215
216func printConfigSummary(out io.Writer, cfg *config.Config) {
217	goalCount := 0
218	for _, area := range cfg.Areas {
219		goalCount += len(area.Goals)
220	}
221
222	fmt.Fprintln(out)
223	fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
224	fmt.Fprintf(out, "  Areas:     %d (%d goals)\n", len(cfg.Areas), goalCount)
225	fmt.Fprintf(out, "  Notebooks: %d\n", len(cfg.Notebooks))
226	fmt.Fprintf(out, "  Habits:    %d\n", len(cfg.Habits))
227
228	if cfg.Defaults.Area != "" {
229		fmt.Fprintf(out, "  Default area: %s\n", cfg.Defaults.Area)
230	}
231
232	if cfg.Defaults.Notebook != "" {
233		fmt.Fprintf(out, "  Default notebook: %s\n", cfg.Defaults.Notebook)
234	}
235
236	color := cfg.UI.Color
237	if color == "" {
238		color = colorAuto
239	}
240
241	fmt.Fprintf(out, "  Color: %s\n", color)
242	fmt.Fprintln(out)
243}
244
245func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
246	path, err := config.Path()
247	if err != nil {
248		return err
249	}
250
251	out := cmd.OutOrStdout()
252	printConfigSummary(out, cfg)
253
254	var save bool
255
256	err = huh.NewConfirm().
257		Title("Save configuration?").
258		Affirmative("Save").
259		Negative("Discard").
260		Value(&save).
261		Run()
262	if err != nil {
263		if errors.Is(err, huh.ErrUserAborted) {
264			fmt.Fprintln(out, ui.Warning.Render("Setup cancelled; configuration was not saved."))
265
266			return errQuit
267		}
268
269		return err
270	}
271
272	if save {
273		if err := cfg.Save(); err != nil {
274			return err
275		}
276
277		fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
278	} else {
279		fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
280	}
281
282	return nil
283}
284
285// exampleConfig is a commented TOML config for manual editing.
286const exampleConfig = `# lune configuration file
287# See: https://git.secluded.site/lune for documentation
288
289# UI preferences
290[ui]
291# Color output: "auto" (default), "always", or "never"
292color = "auto"
293
294# Default selections for commands
295[defaults]
296# Default area key (must match an area defined below)
297# area = "work"
298
299# Default notebook key (must match a notebook defined below)
300# notebook = "journal"
301
302# Areas of life from Lunatask
303# Find IDs in Lunatask: Open area settings → "Copy Area ID" (bottom left)
304#
305# [[areas]]
306# id = "00000000-0000-0000-0000-000000000000"
307# name = "Work"
308# key = "work"
309#
310#   # Goals within this area
311#   [[areas.goals]]
312#   id = "00000000-0000-0000-0000-000000000001"
313#   name = "Q1 Project"
314#   key = "q1-project"
315
316# Notebooks for notes
317# Find IDs in Lunatask: Open notebook settings → "Copy Notebook ID"
318#
319# [[notebooks]]
320# id = "00000000-0000-0000-0000-000000000000"
321# name = "Journal"
322# key = "journal"
323
324# Habits to track
325# Find IDs in Lunatask: Open habit settings → "Copy Habit ID"
326#
327# [[habits]]
328# id = "00000000-0000-0000-0000-000000000000"
329# name = "Exercise"
330# key = "exercise"
331`
332
333func runGenerateConfig(cmd *cobra.Command) error {
334	cfgPath, err := config.Path()
335	if err != nil {
336		return err
337	}
338
339	if _, err := os.Stat(cfgPath); err == nil {
340		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config already exists: "+cfgPath))
341		fmt.Fprintln(cmd.ErrOrStderr(), "Remove or rename it first, or edit it directly.")
342
343		return errConfigExists
344	}
345
346	dir := filepath.Dir(cfgPath)
347	if err := os.MkdirAll(dir, 0o700); err != nil {
348		return fmt.Errorf("creating config directory: %w", err)
349	}
350
351	if err := os.WriteFile(cfgPath, []byte(exampleConfig), 0o600); err != nil {
352		return fmt.Errorf("writing config: %w", err)
353	}
354
355	fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Generated example config: "+cfgPath))
356	fmt.Fprintln(cmd.OutOrStdout())
357	fmt.Fprintln(cmd.OutOrStdout(), "Edit this file to add your Lunatask areas, notebooks, and habits.")
358	fmt.Fprintln(cmd.OutOrStdout(), "Then configure your access token with: lune init --generate-config")
359	fmt.Fprintln(cmd.OutOrStdout())
360	fmt.Fprintln(cmd.OutOrStdout(), "To set your access token non-interactively, use your system keyring.")
361	fmt.Fprintln(cmd.OutOrStdout(), "The service is 'lune' and the key is 'access_token'.")
362
363	return nil
364}