Something went wrong. Try again.
A delightful CLI for Lunatask.app
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>//// SPDX-License-Identifier: AGPL-3.0-or-later
// Package init provides the interactive setup wizard for lune.package init
import ( "errors" "fmt" "io" "os" "path/filepath"
"github.com/charmbracelet/huh" "github.com/spf13/cobra"
"git.secluded.site/lune/internal/config" "git.secluded.site/lune/internal/ui")
// errQuit signals that the user wants to exit the wizard entirely.var errQuit = errors.New("user quit")
// errReset signals that the user reset configuration and should run fresh setup.var errReset = errors.New("configuration reset")
// errNonInteractive signals that init was run without a terminal.var errNonInteractive = errors.New("non-interactive terminal")
// errConfigExists signals that a config file already exists.var errConfigExists = errors.New("config already exists")
// wizardNav represents navigation direction in the wizard.type wizardNav int
const ( navNext wizardNav = iota navBack navQuit)
// Cmd is the init command for interactive setup.var Cmd = &cobra.Command{ Use: "init", Short: "Interactive setup wizard", Long: `Configure lune interactively.
This command will guide you through: - Adding areas, goals, notebooks, and habits from Lunatask - Setting default area and notebook - Configuring and verifying your access token
Use --generate-config to create an example config file for manual editingwhen running non-interactively.`, RunE: runInit,}
func init() { Cmd.Flags().Bool("generate-config", false, "generate example config for manual editing")}
func runInit(cmd *cobra.Command, _ []string) error { generateConfig, _ := cmd.Flags().GetBool("generate-config") if generateConfig { return runGenerateConfig(cmd) }
if !ui.IsInteractive() { fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("lune init requires an interactive terminal.")) fmt.Fprintln(cmd.ErrOrStderr()) fmt.Fprintln(cmd.ErrOrStderr(), "To configure lune non-interactively, run:") fmt.Fprintln(cmd.ErrOrStderr(), " lune init --generate-config") fmt.Fprintln(cmd.ErrOrStderr()) fmt.Fprintln(cmd.ErrOrStderr(), "Then edit the generated config file manually.")
return errNonInteractive }
cfg, err := config.Load() if errors.Is(err, config.ErrNotFound) { cfg = &config.Config{}
err = runFreshSetup(cmd, cfg) if errors.Is(err, errQuit) { fmt.Fprintln(cmd.OutOrStdout(), ui.Warning.Render("Setup cancelled."))
return nil }
return err }
if err != nil { return fmt.Errorf("loading config: %w", err) }
err = runReconfigure(cmd, cfg) if errors.Is(err, errQuit) { return nil }
if errors.Is(err, errReset) { return runFreshSetup(cmd, cfg) }
return err}
// wizardStep is a function that runs a wizard step and returns navigation direction.type wizardStep func() wizardNav
func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error { printWelcome(cmd)
steps := []wizardStep{ func() wizardNav { return runUIPrefsStep(cfg) }, func() wizardNav { return runAreasStep(cfg) }, func() wizardNav { return runNotebooksStep(cfg) }, func() wizardNav { return runHabitsStep(cfg) }, func() wizardNav { return runDefaultsStep(cfg) }, func() wizardNav { return runAccessTokenStep(cmd) }, }
step := 0 for step < len(steps) { nav := steps[step]()
switch nav { case navNext: step++ case navBack: if step > 0 { step-- } case navQuit: return errQuit } }
return saveWithSummary(cmd, cfg)}
func printWelcome(cmd *cobra.Command) { out := cmd.OutOrStdout() fmt.Fprintln(out, ui.Bold.Render("Welcome to lune!")) fmt.Fprintln(out) fmt.Fprintln(out, "This wizard will help you configure lune for use with Lunatask.") fmt.Fprintln(out) fmt.Fprintln(out, "Since Lunatask is end-to-end encrypted, lune can't fetch your") fmt.Fprintln(out, "areas, goals, notebooks, or habits automatically. You'll need to") fmt.Fprintln(out, "copy IDs from the Lunatask app.") fmt.Fprintln(out) fmt.Fprintln(out, ui.Bold.Render("Where to find IDs:")) fmt.Fprintln(out, " Open any item's settings modal → click 'Copy [Item] ID' (bottom left)") fmt.Fprintln(out) fmt.Fprintln(out, "You can run 'lune init' again anytime to add or modify config.") fmt.Fprintln(out)}
func runReconfigure(cmd *cobra.Command, cfg *config.Config) error { fmt.Fprintln(cmd.OutOrStdout(), ui.Bold.Render("lune configuration")) fmt.Fprintln(cmd.OutOrStdout())
if err := ensureAccessToken(cmd); err != nil { return err }
handlers := map[string]func() error{ "areas": func() error { return manageAreas(cfg) }, "notebooks": func() error { return manageNotebooks(cfg) }, "habits": func() error { return manageHabits(cfg) }, "defaults": func() error { return configureDefaults(cfg) }, "ui": func() error { return configureUIPrefs(cfg) }, "apikey": func() error { return configureAccessToken(cmd) }, "reset": func() error { return resetConfig(cmd, cfg) }, }
for { var choice string
err := huh.NewSelect[string](). Title("What would you like to configure?"). Options( huh.NewOption("Manage areas & goals", "areas"), huh.NewOption("Manage notebooks", "notebooks"), huh.NewOption("Manage habits", "habits"), huh.NewOption("Set defaults", "defaults"), huh.NewOption("UI preferences", "ui"), huh.NewOption("Access token", "apikey"), huh.NewOption("Reset all configuration", "reset"), huh.NewOption("Done", choiceDone), ). Value(&choice). Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { return errQuit }
return err }
if choice == choiceDone { return saveWithSummary(cmd, cfg) }
if handler, ok := handlers[choice]; ok { if err := handler(); err != nil { return err } } }}
func printConfigSummary(out io.Writer, cfg *config.Config) { goalCount := 0 for _, area := range cfg.Areas { goalCount += len(area.Goals) }
fmt.Fprintln(out) fmt.Fprintln(out, ui.Bold.Render("Configuration summary:")) fmt.Fprintf(out, " Areas: %d (%d goals)\n", len(cfg.Areas), goalCount) fmt.Fprintf(out, " Notebooks: %d\n", len(cfg.Notebooks)) fmt.Fprintf(out, " Habits: %d\n", len(cfg.Habits))
if cfg.Defaults.Area != "" { fmt.Fprintf(out, " Default area: %s\n", cfg.Defaults.Area) }
if cfg.Defaults.Notebook != "" { fmt.Fprintf(out, " Default notebook: %s\n", cfg.Defaults.Notebook) }
color := cfg.UI.Color if color == "" { color = colorAuto }
fmt.Fprintf(out, " Color: %s\n", color) fmt.Fprintln(out)}
func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error { path, err := config.Path() if err != nil { return err }
out := cmd.OutOrStdout() printConfigSummary(out, cfg)
var save bool
err = huh.NewConfirm(). Title("Save configuration?"). Affirmative("Save"). Negative("Discard"). Value(&save). Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { fmt.Fprintln(out, ui.Warning.Render("Setup cancelled; configuration was not saved."))
return errQuit }
return err }
if save { if err := cfg.Save(); err != nil { return err }
fmt.Fprintln(out, ui.Success.Render("Config saved to "+path)) } else { fmt.Fprintln(out, ui.Warning.Render("Changes discarded.")) }
return nil}
// exampleConfig is a commented TOML config for manual editing.const exampleConfig = `# lune configuration file# See: https://git.secluded.site/lune for documentation
# UI preferences[ui]# Color output: "auto" (default), "always", or "never"color = "auto"
# Default selections for commands[defaults]# Default area key (must match an area defined below)# area = "work"
# Default notebook key (must match a notebook defined below)# notebook = "journal"
# Areas of life from Lunatask# Find IDs in Lunatask: Open area settings → "Copy Area ID" (bottom left)## [[areas]]# id = "00000000-0000-0000-0000-000000000000"# name = "Work"# key = "work"## # Goals within this area# [[areas.goals]]# id = "00000000-0000-0000-0000-000000000001"# name = "Q1 Project"# key = "q1-project"
# Notebooks for notes# Find IDs in Lunatask: Open notebook settings → "Copy Notebook ID"## [[notebooks]]# id = "00000000-0000-0000-0000-000000000000"# name = "Journal"# key = "journal"
# Habits to track# Find IDs in Lunatask: Open habit settings → "Copy Habit ID"## [[habits]]# id = "00000000-0000-0000-0000-000000000000"# name = "Exercise"# key = "exercise"`
func runGenerateConfig(cmd *cobra.Command) error { cfgPath, err := config.Path() if err != nil { return err }
if _, err := os.Stat(cfgPath); err == nil { fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config already exists: "+cfgPath)) fmt.Fprintln(cmd.ErrOrStderr(), "Remove or rename it first, or edit it directly.")
return errConfigExists }
dir := filepath.Dir(cfgPath) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("creating config directory: %w", err) }
if err := os.WriteFile(cfgPath, []byte(exampleConfig), 0o600); err != nil { return fmt.Errorf("writing config: %w", err) }
fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Generated example config: "+cfgPath)) fmt.Fprintln(cmd.OutOrStdout()) fmt.Fprintln(cmd.OutOrStdout(), "Edit this file to add your Lunatask areas, notebooks, and habits.") fmt.Fprintln(cmd.OutOrStdout(), "Then configure your access token with: lune init --generate-config") fmt.Fprintln(cmd.OutOrStdout()) fmt.Fprintln(cmd.OutOrStdout(), "To set your access token non-interactively, use your system keyring.") fmt.Fprintln(cmd.OutOrStdout(), "The service is 'lune' and the key is 'access_token'.")
return nil}