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}