From 27edae992ed4439983a323bdbbc56db17701348e Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Dec 2025 12:04:18 -0700 Subject: [PATCH] refactor(cmd/init): extract to subpackage - Split monolithic init.go into focused files: areas, notebooks, habits, defaults, apikey, ui - Move API key configuration to last step with ping validation - Add spinner feedback during API key verification - Make keyring errors blocking instead of silent - Add bounds checks on slice operations - Handle ErrUserAborted consistently across all huh forms - Change parseEditIndex to (int, bool) signature - Add unit tests for validators Assisted-by: Claude Opus 4.5 via Amp --- cmd/init.go | 28 --- cmd/init/apikey.go | 191 ++++++++++++++++++ cmd/init/areas.go | 406 ++++++++++++++++++++++++++++++++++++++ cmd/init/defaults.go | 132 +++++++++++++ cmd/init/habits.go | 202 +++++++++++++++++++ cmd/init/init.go | 203 +++++++++++++++++++ cmd/init/notebooks.go | 205 +++++++++++++++++++ cmd/init/ui.go | 269 +++++++++++++++++++++++++ cmd/init/ui_test.go | 194 ++++++++++++++++++ cmd/root.go | 3 +- go.mod | 9 +- go.sum | 28 ++- internal/client/client.go | 76 ++++++- 13 files changed, 1907 insertions(+), 39 deletions(-) delete mode 100644 cmd/init.go create mode 100644 cmd/init/apikey.go create mode 100644 cmd/init/areas.go create mode 100644 cmd/init/defaults.go create mode 100644 cmd/init/habits.go create mode 100644 cmd/init/init.go create mode 100644 cmd/init/notebooks.go create mode 100644 cmd/init/ui.go create mode 100644 cmd/init/ui_test.go diff --git a/cmd/init.go b/cmd/init.go deleted file mode 100644 index 5e48966512b1e7936e134b046078052e81c56a0b..0000000000000000000000000000000000000000 --- a/cmd/init.go +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize lune configuration interactively", - Long: `Interactively set up your lune configuration. - -This command will guide you through: - - Verifying your LUNATASK_API_KEY - - Adding areas, goals, notebooks, and habits from Lunatask - - Setting default area and notebook`, - RunE: func(cmd *cobra.Command, args []string) error { - // TODO: implement interactive setup with huh - fmt.Fprintln(cmd.OutOrStdout(), "Interactive setup not yet implemented") - - return nil - }, -} diff --git a/cmd/init/apikey.go b/cmd/init/apikey.go new file mode 100644 index 0000000000000000000000000000000000000000..fe062732b7527a6f28ebf554c5f747361c004755 --- /dev/null +++ b/cmd/init/apikey.go @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "git.secluded.site/go-lunatask" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/huh/spinner" + "github.com/spf13/cobra" + + "git.secluded.site/lune/internal/client" + "git.secluded.site/lune/internal/ui" +) + +func configureAPIKey(cmd *cobra.Command) error { + out := cmd.OutOrStdout() + + if envKey := os.Getenv(client.EnvAPIKey); envKey != "" { + fmt.Fprintln(out, ui.Success.Render("API key found in "+client.EnvAPIKey+" environment variable.")) + + if err := validateWithSpinner(envKey); err != nil { + return fmt.Errorf("API key validation failed: %w", err) + } + + fmt.Fprintln(out, ui.Success.Render("✓ Authentication successful")) + + return nil + } + + hasKey, keyringErr := client.HasKeyringKey() + if keyringErr != nil { + fmt.Fprintln(out, ui.Error.Render("Failed to access system keyring: "+keyringErr.Error())) + fmt.Fprintln(out, ui.Muted.Render("Please resolve the keyring issue and try again.")) + + return fmt.Errorf("keyring access failed: %w", keyringErr) + } + + if hasKey { + shouldPrompt, err := handleExistingAPIKey(out) + if err != nil { + return err + } + + if !shouldPrompt { + return nil + } + } + + return promptForAPIKey(out) +} + +func handleExistingAPIKey(out io.Writer) (bool, error) { + existingKey, err := client.GetAPIKey() + if err != nil { + return false, fmt.Errorf("reading API key from keyring: %w", err) + } + + fmt.Fprintln(out, ui.Muted.Render("API key found in system keyring.")) + + if err := validateWithSpinner(existingKey); err != nil { + fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error())) + } else { + fmt.Fprintln(out, ui.Success.Render("✓ Authentication successful")) + } + + var action string + + err = huh.NewSelect[string](). + Title("API key is already configured"). + Options( + huh.NewOption("Keep existing key", "keep"), + huh.NewOption("Replace with new key", "replace"), + huh.NewOption("Delete from keyring", actionDelete), + ). + Value(&action). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return false, nil + } + + return false, err + } + + switch action { + case "keep": + return false, nil + case actionDelete: + if err := client.DeleteAPIKey(); err != nil { + return false, fmt.Errorf("deleting API key: %w", err) + } + + fmt.Fprintln(out, ui.Success.Render("API key removed from keyring.")) + + return false, nil + default: + return true, nil + } +} + +func promptForAPIKey(out io.Writer) error { + fmt.Fprintln(out) + fmt.Fprintln(out, ui.Muted.Render("You can get your API key from:")) + fmt.Fprintln(out, ui.Muted.Render(" Lunatask → Settings → Integrations → API")) + fmt.Fprintln(out) + + var apiKey string + + for { + err := huh.NewInput(). + Title("Lunatask API Key"). + Description("Paste your API key (it will be stored securely in your system keyring)."). + EchoMode(huh.EchoModePassword). + Value(&apiKey). + Validate(func(s string) error { + if s == "" { + return errKeyRequired + } + + return nil + }). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(out, ui.Warning.Render("Skipped API key setup.")) + + return nil + } + + return err + } + + if err := validateWithSpinner(apiKey); err != nil { + fmt.Fprintln(out, ui.Error.Render("✗ Authentication failed: "+err.Error())) + fmt.Fprintln(out, ui.Muted.Render("Please check your API key and try again.")) + fmt.Fprintln(out) + + apiKey = "" + + continue + } + + fmt.Fprintln(out, ui.Success.Render("✓ Authentication successful")) + + break + } + + if err := client.SetAPIKey(apiKey); err != nil { + return fmt.Errorf("saving API key to keyring: %w", err) + } + + fmt.Fprintln(out, ui.Success.Render("API key saved to system keyring.")) + + return nil +} + +func validateWithSpinner(apiKey string) error { + var validationErr error + + err := spinner.New(). + Title("Verifying API key..."). + Action(func() { + validationErr = validateAPIKeyWithPing(apiKey) + }). + Run() + if err != nil { + return err + } + + return validationErr +} + +func validateAPIKeyWithPing(apiKey string) error { + c := lunatask.NewClient(apiKey, lunatask.UserAgent("lune/init")) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := c.Ping(ctx) + + return err +} diff --git a/cmd/init/areas.go b/cmd/init/areas.go new file mode 100644 index 0000000000000000000000000000000000000000..b87667a7f1d771c837d5845f031b7dffd844582c --- /dev/null +++ b/cmd/init/areas.go @@ -0,0 +1,406 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/huh" + + "git.secluded.site/lune/internal/config" +) + +func maybeConfigureAreas(cfg *config.Config) error { + var configure bool + + err := huh.NewConfirm(). + Title("Configure areas & goals?"). + Description("Areas organize your life (Work, Health, etc). Goals belong to areas."). + Affirmative("Yes"). + Negative("Not now"). + Value(&configure). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if configure { + return manageAreas(cfg) + } + + return nil +} + +func manageAreas(cfg *config.Config) error { + for { + options := buildAreaOptions(cfg.Areas) + + choice, err := runListSelect("Areas", "Manage your areas and their goals.", options) + if err != nil { + return err + } + + if choice == choiceBack { + return nil + } + + if choice == choiceAdd { + if err := addArea(cfg); err != nil { + return err + } + + continue + } + + idx, ok := parseEditIndex(choice) + if !ok { + continue + } + + if err := manageAreaActions(cfg, idx); err != nil { + return err + } + } +} + +func buildAreaOptions(areas []config.Area) []huh.Option[string] { + options := []huh.Option[string]{ + huh.NewOption("Add new area", choiceAdd), + } + + for idx, area := range areas { + goalCount := len(area.Goals) + label := fmt.Sprintf("%s (%s) - %d goal(s)", area.Name, area.Key, goalCount) + options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx))) + } + + options = append(options, huh.NewOption("Back", choiceBack)) + + return options +} + +func addArea(cfg *config.Config) error { + area, err := editArea(nil, cfg) + if err != nil { + if errors.Is(err, errUserAborted) { + return nil + } + + return err + } + + cfg.Areas = append(cfg.Areas, *area) + + return maybeManageGoals(cfg, len(cfg.Areas)-1) +} + +func manageAreaActions(cfg *config.Config, idx int) error { + if idx < 0 || idx >= len(cfg.Areas) { + return fmt.Errorf("%w: area %d", errIndexOutRange, idx) + } + + area := &cfg.Areas[idx] + + action, err := runActionSelect(fmt.Sprintf("Area: %s (%s)", area.Name, area.Key), true) + if err != nil { + return err + } + + return handleAreaAction(cfg, idx, area, action) +} + +func handleAreaAction(cfg *config.Config, idx int, area *config.Area, action itemAction) error { + switch action { + case itemActionEdit: + if updated, err := editArea(area, cfg); err != nil && !errors.Is(err, errUserAborted) { + return err + } else if updated != nil { + cfg.Areas[idx] = *updated + } + case itemActionGoals: + return manageGoals(cfg, idx) + case itemActionDelete: + return deleteArea(cfg, idx) + case itemActionNone: + // User cancelled or went back + } + + return nil +} + +func editArea(existing *config.Area, cfg *config.Config) (*config.Area, error) { + area := config.Area{} + if existing != nil { + area = *existing + } + + err := runItemForm(&area.Name, &area.Key, &area.ID, itemFormConfig{ + itemType: "area", + namePlaceholder: "Personal", + keyPlaceholder: "personal", + keyValidator: validateAreaKey(cfg, existing), + }) + if err != nil { + return nil, err + } + + return &area, nil +} + +func validateAreaKey(cfg *config.Config, existing *config.Area) func(string) error { + return func(input string) error { + if err := validateKeyFormat(input); err != nil { + return err + } + + for idx := range cfg.Areas { + if existing != nil && &cfg.Areas[idx] == existing { + continue + } + + if cfg.Areas[idx].Key == input { + return errKeyDuplicate + } + } + + return nil + } +} + +func maybeManageGoals(cfg *config.Config, areaIdx int) error { + var manage bool + + err := huh.NewConfirm(). + Title("Add goals for this area?"). + Affirmative("Yes"). + Negative("Not now"). + Value(&manage). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if manage { + return manageGoals(cfg, areaIdx) + } + + return nil +} + +func manageGoals(cfg *config.Config, areaIdx int) error { + if areaIdx < 0 || areaIdx >= len(cfg.Areas) { + return fmt.Errorf("%w: area %d", errIndexOutRange, areaIdx) + } + + area := &cfg.Areas[areaIdx] + + for { + options := buildGoalOptions(area.Goals) + + choice, err := runListSelect( + fmt.Sprintf("Goals for: %s (%s)", area.Name, area.Key), + "", + options, + ) + if err != nil { + return err + } + + if choice == choiceDone { + return nil + } + + if choice == choiceAdd { + if err := addGoal(area); err != nil { + return err + } + + continue + } + + idx, ok := parseEditIndex(choice) + if !ok { + continue + } + + if err := manageGoalActions(area, idx); err != nil { + return err + } + } +} + +func buildGoalOptions(goals []config.Goal) []huh.Option[string] { + options := []huh.Option[string]{ + huh.NewOption("Add new goal", choiceAdd), + } + + for idx, goal := range goals { + label := fmt.Sprintf("%s (%s)", goal.Name, goal.Key) + options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx))) + } + + options = append(options, huh.NewOption("Done", choiceDone)) + + return options +} + +func addGoal(area *config.Area) error { + goal, err := editGoal(nil, area) + if err != nil { + if errors.Is(err, errUserAborted) { + return nil + } + + return err + } + + area.Goals = append(area.Goals, *goal) + + return nil +} + +func manageGoalActions(area *config.Area, idx int) error { + if idx < 0 || idx >= len(area.Goals) { + return fmt.Errorf("%w: goal %d", errIndexOutRange, idx) + } + + goal := &area.Goals[idx] + + action, err := runActionSelect(fmt.Sprintf("Goal: %s (%s)", goal.Name, goal.Key), false) + if err != nil { + return err + } + + switch action { + case itemActionEdit: + updated, err := editGoal(goal, area) + if err != nil && !errors.Is(err, errUserAborted) { + return err + } + + if updated != nil { + area.Goals[idx] = *updated + } + case itemActionDelete: + return deleteGoal(area, idx) + case itemActionNone, itemActionGoals: + // User cancelled or went back; goals not applicable here + } + + return nil +} + +func editGoal(existing *config.Goal, area *config.Area) (*config.Goal, error) { + goal := config.Goal{} + if existing != nil { + goal = *existing + } + + err := runItemForm(&goal.Name, &goal.Key, &goal.ID, itemFormConfig{ + itemType: "goal", + namePlaceholder: "Learn Gaelic", + keyPlaceholder: "gaelic", + keyValidator: validateGoalKey(area, existing), + }) + if err != nil { + return nil, err + } + + return &goal, nil +} + +func validateGoalKey(area *config.Area, existing *config.Goal) func(string) error { + return func(input string) error { + if err := validateKeyFormat(input); err != nil { + return err + } + + for idx := range area.Goals { + if existing != nil && &area.Goals[idx] == existing { + continue + } + + if area.Goals[idx].Key == input { + return errKeyDuplicate + } + } + + return nil + } +} + +func deleteArea(cfg *config.Config, idx int) error { + if idx < 0 || idx >= len(cfg.Areas) { + return fmt.Errorf("%w: area %d", errIndexOutRange, idx) + } + + area := cfg.Areas[idx] + + var confirm bool + + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete area '%s'?", area.Name)). + Description(fmt.Sprintf("This will also remove %d goal(s). This cannot be undone.", len(area.Goals))). + Affirmative("Delete"). + Negative("Cancel"). + Value(&confirm). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if confirm { + cfg.Areas = append(cfg.Areas[:idx], cfg.Areas[idx+1:]...) + if cfg.Defaults.Area == area.Key { + cfg.Defaults.Area = "" + } + } + + return nil +} + +func deleteGoal(area *config.Area, idx int) error { + if idx < 0 || idx >= len(area.Goals) { + return fmt.Errorf("%w: goal %d", errIndexOutRange, idx) + } + + goal := area.Goals[idx] + + var confirm bool + + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete goal '%s'?", goal.Name)). + Description("This cannot be undone."). + Affirmative("Delete"). + Negative("Cancel"). + Value(&confirm). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if confirm { + area.Goals = append(area.Goals[:idx], area.Goals[idx+1:]...) + } + + return nil +} diff --git a/cmd/init/defaults.go b/cmd/init/defaults.go new file mode 100644 index 0000000000000000000000000000000000000000..dac36d0e4360de301ac37aef07ca00f2befeb670 --- /dev/null +++ b/cmd/init/defaults.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/huh" + + "git.secluded.site/lune/internal/config" +) + +func configureDefaults(cfg *config.Config) error { + hasAreas := len(cfg.Areas) > 0 + hasNotebooks := len(cfg.Notebooks) > 0 + + if !hasAreas && !hasNotebooks { + return handleNoDefaultsAvailable(cfg) + } + + if hasAreas { + if err := selectDefaultArea(cfg); err != nil { + return err + } + } + + if hasNotebooks { + if err := selectDefaultNotebook(cfg); err != nil { + return err + } + } + + return nil +} + +func handleNoDefaultsAvailable(cfg *config.Config) error { + var action string + + err := huh.NewSelect[string](). + Title("No areas or notebooks configured"). + Description("You need at least one area or notebook to set defaults."). + Options( + huh.NewOption("Add an area now", "area"), + huh.NewOption("Add a notebook now", "notebook"), + huh.NewOption("Back", choiceBack), + ). + Value(&action). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + switch action { + case "area": + return addArea(cfg) + case "notebook": + return addNotebook(cfg) + } + + return nil +} + +func selectDefaultArea(cfg *config.Config) error { + areaOptions := []huh.Option[string]{ + huh.NewOption("None", ""), + } + for _, area := range cfg.Areas { + areaOptions = append(areaOptions, huh.NewOption( + fmt.Sprintf("%s (%s)", area.Name, area.Key), + area.Key, + )) + } + + defaultArea := cfg.Defaults.Area + + err := huh.NewSelect[string](). + Title("Default area"). + Description("Used when no area is specified in commands."). + Options(areaOptions...). + Value(&defaultArea). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + cfg.Defaults.Area = defaultArea + + return nil +} + +func selectDefaultNotebook(cfg *config.Config) error { + notebookOptions := []huh.Option[string]{ + huh.NewOption("None", ""), + } + for _, notebook := range cfg.Notebooks { + notebookOptions = append(notebookOptions, huh.NewOption( + fmt.Sprintf("%s (%s)", notebook.Name, notebook.Key), + notebook.Key, + )) + } + + defaultNotebook := cfg.Defaults.Notebook + + err := huh.NewSelect[string](). + Title("Default notebook"). + Description("Used when no notebook is specified in commands."). + Options(notebookOptions...). + Value(&defaultNotebook). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + cfg.Defaults.Notebook = defaultNotebook + + return nil +} diff --git a/cmd/init/habits.go b/cmd/init/habits.go new file mode 100644 index 0000000000000000000000000000000000000000..389026838a25e390f6381c91cc94675f02fbc539 --- /dev/null +++ b/cmd/init/habits.go @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/huh" + + "git.secluded.site/lune/internal/config" +) + +func maybeConfigureHabits(cfg *config.Config) error { + var configure bool + + err := huh.NewConfirm(). + Title("Configure habits?"). + Description("Track habits from the command line."). + Affirmative("Yes"). + Negative("Not now"). + Value(&configure). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if configure { + return manageHabits(cfg) + } + + return nil +} + +func manageHabits(cfg *config.Config) error { + for { + options := buildHabitOptions(cfg.Habits) + + choice, err := runListSelect("Habits", "Manage your trackable habits.", options) + if err != nil { + return err + } + + if choice == choiceBack { + return nil + } + + if choice == choiceAdd { + if err := addHabit(cfg); err != nil { + return err + } + + continue + } + + idx, ok := parseEditIndex(choice) + if !ok { + continue + } + + if err := manageHabitActions(cfg, idx); err != nil { + return err + } + } +} + +func buildHabitOptions(habits []config.Habit) []huh.Option[string] { + options := []huh.Option[string]{ + huh.NewOption("Add new habit", choiceAdd), + } + + for idx, habit := range habits { + label := fmt.Sprintf("%s (%s)", habit.Name, habit.Key) + options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx))) + } + + options = append(options, huh.NewOption("Back", choiceBack)) + + return options +} + +func addHabit(cfg *config.Config) error { + habit, err := editHabit(nil, cfg) + if err != nil { + if errors.Is(err, errUserAborted) { + return nil + } + + return err + } + + cfg.Habits = append(cfg.Habits, *habit) + + return nil +} + +func manageHabitActions(cfg *config.Config, idx int) error { + if idx < 0 || idx >= len(cfg.Habits) { + return fmt.Errorf("%w: habit %d", errIndexOutRange, idx) + } + + habit := &cfg.Habits[idx] + + action, err := runActionSelect(fmt.Sprintf("Habit: %s (%s)", habit.Name, habit.Key), false) + if err != nil { + return err + } + + switch action { + case itemActionEdit: + updated, err := editHabit(habit, cfg) + if err != nil && !errors.Is(err, errUserAborted) { + return err + } + + if updated != nil { + cfg.Habits[idx] = *updated + } + case itemActionDelete: + return deleteHabit(cfg, idx) + case itemActionNone, itemActionGoals: + // User cancelled or went back; goals not applicable here + } + + return nil +} + +func editHabit(existing *config.Habit, cfg *config.Config) (*config.Habit, error) { + habit := config.Habit{} + if existing != nil { + habit = *existing + } + + err := runItemForm(&habit.Name, &habit.Key, &habit.ID, itemFormConfig{ + itemType: "habit", + namePlaceholder: "Study Gaelic", + keyPlaceholder: "gaelic", + keyValidator: validateHabitKey(cfg, existing), + }) + if err != nil { + return nil, err + } + + return &habit, nil +} + +func validateHabitKey(cfg *config.Config, existing *config.Habit) func(string) error { + return func(input string) error { + if err := validateKeyFormat(input); err != nil { + return err + } + + for idx := range cfg.Habits { + if existing != nil && &cfg.Habits[idx] == existing { + continue + } + + if cfg.Habits[idx].Key == input { + return errKeyDuplicate + } + } + + return nil + } +} + +func deleteHabit(cfg *config.Config, idx int) error { + if idx < 0 || idx >= len(cfg.Habits) { + return fmt.Errorf("%w: habit %d", errIndexOutRange, idx) + } + + habit := cfg.Habits[idx] + + var confirm bool + + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete habit '%s'?", habit.Name)). + Description("This cannot be undone."). + Affirmative("Delete"). + Negative("Cancel"). + Value(&confirm). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if confirm { + cfg.Habits = append(cfg.Habits[:idx], cfg.Habits[idx+1:]...) + } + + return nil +} diff --git a/cmd/init/init.go b/cmd/init/init.go new file mode 100644 index 0000000000000000000000000000000000000000..9a6075a8553bd0db169e97f63b0c29cfa760c80a --- /dev/null +++ b/cmd/init/init.go @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package init provides the interactive setup wizard for lune. +package init + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/ui" +) + +// 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 API key`, + RunE: runInit, +} + +func runInit(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if errors.Is(err, config.ErrNotFound) { + cfg = &config.Config{} + + return runFreshSetup(cmd, cfg) + } + + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + return runReconfigure(cmd, cfg) +} + +func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error { + printWelcome(cmd) + + if err := configureUIPrefs(cfg); err != nil { + return err + } + + if err := maybeConfigureAreas(cfg); err != nil { + return err + } + + if err := maybeConfigureNotebooks(cfg); err != nil { + return err + } + + if err := maybeConfigureHabits(cfg); err != nil { + return err + } + + if err := configureDefaults(cfg); err != nil { + return err + } + + if err := configureAPIKey(cmd); err != nil { + return err + } + + 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, ui.Muted.Render("Since Lunatask is end-to-end encrypted, lune can't fetch your")) + fmt.Fprintln(out, ui.Muted.Render("areas, goals, notebooks, or habits automatically. You'll need to")) + fmt.Fprintln(out, ui.Muted.Render("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, ui.Muted.Render("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()) + + 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 configureAPIKey(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("API key", "apikey"), + huh.NewOption("Reset all configuration", "reset"), + huh.NewOption("Done", choiceDone), + ). + Value(&choice). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if choice == choiceDone { + return saveWithSummary(cmd, cfg) + } + + if handler, ok := handlers[choice]; ok { + if err := handler(); err != nil { + return err + } + } + } +} + +func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error { + path, err := config.Path() + if err != nil { + return err + } + + goalCount := 0 + for _, area := range cfg.Areas { + goalCount += len(area.Goals) + } + + out := cmd.OutOrStdout() + 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) + } + + fmt.Fprintf(out, " Color: %s\n", cfg.UI.Color) + fmt.Fprintln(out) + + 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("Changes discarded.")) + + return nil + } + + 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 +} diff --git a/cmd/init/notebooks.go b/cmd/init/notebooks.go new file mode 100644 index 0000000000000000000000000000000000000000..e8b6913d1e1c1cbfe2eeda0a77de4951903f5e4b --- /dev/null +++ b/cmd/init/notebooks.go @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/huh" + + "git.secluded.site/lune/internal/config" +) + +func maybeConfigureNotebooks(cfg *config.Config) error { + var configure bool + + err := huh.NewConfirm(). + Title("Configure notebooks?"). + Description("Notebooks organize your notes in Lunatask."). + Affirmative("Yes"). + Negative("Not now"). + Value(&configure). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if configure { + return manageNotebooks(cfg) + } + + return nil +} + +func manageNotebooks(cfg *config.Config) error { + for { + options := buildNotebookOptions(cfg.Notebooks) + + choice, err := runListSelect("Notebooks", "Manage your notebooks.", options) + if err != nil { + return err + } + + if choice == choiceBack { + return nil + } + + if choice == choiceAdd { + if err := addNotebook(cfg); err != nil { + return err + } + + continue + } + + idx, ok := parseEditIndex(choice) + if !ok { + continue + } + + if err := manageNotebookActions(cfg, idx); err != nil { + return err + } + } +} + +func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] { + options := []huh.Option[string]{ + huh.NewOption("Add new notebook", choiceAdd), + } + + for idx, notebook := range notebooks { + label := fmt.Sprintf("%s (%s)", notebook.Name, notebook.Key) + options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx))) + } + + options = append(options, huh.NewOption("Back", choiceBack)) + + return options +} + +func addNotebook(cfg *config.Config) error { + notebook, err := editNotebook(nil, cfg) + if err != nil { + if errors.Is(err, errUserAborted) { + return nil + } + + return err + } + + cfg.Notebooks = append(cfg.Notebooks, *notebook) + + return nil +} + +func manageNotebookActions(cfg *config.Config, idx int) error { + if idx < 0 || idx >= len(cfg.Notebooks) { + return fmt.Errorf("%w: notebook %d", errIndexOutRange, idx) + } + + notebook := &cfg.Notebooks[idx] + + action, err := runActionSelect(fmt.Sprintf("Notebook: %s (%s)", notebook.Name, notebook.Key), false) + if err != nil { + return err + } + + switch action { + case itemActionEdit: + updated, err := editNotebook(notebook, cfg) + if err != nil && !errors.Is(err, errUserAborted) { + return err + } + + if updated != nil { + cfg.Notebooks[idx] = *updated + } + case itemActionDelete: + return deleteNotebook(cfg, idx) + case itemActionNone, itemActionGoals: + // User cancelled or went back; goals not applicable here + } + + return nil +} + +func editNotebook(existing *config.Notebook, cfg *config.Config) (*config.Notebook, error) { + notebook := config.Notebook{} + if existing != nil { + notebook = *existing + } + + err := runItemForm(¬ebook.Name, ¬ebook.Key, ¬ebook.ID, itemFormConfig{ + itemType: "notebook", + namePlaceholder: "Gaelic Notes", + keyPlaceholder: "gaelic", + keyValidator: validateNotebookKey(cfg, existing), + }) + if err != nil { + return nil, err + } + + return ¬ebook, nil +} + +func validateNotebookKey(cfg *config.Config, existing *config.Notebook) func(string) error { + return func(input string) error { + if err := validateKeyFormat(input); err != nil { + return err + } + + for idx := range cfg.Notebooks { + if existing != nil && &cfg.Notebooks[idx] == existing { + continue + } + + if cfg.Notebooks[idx].Key == input { + return errKeyDuplicate + } + } + + return nil + } +} + +func deleteNotebook(cfg *config.Config, idx int) error { + if idx < 0 || idx >= len(cfg.Notebooks) { + return fmt.Errorf("%w: notebook %d", errIndexOutRange, idx) + } + + notebook := cfg.Notebooks[idx] + + var confirm bool + + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete notebook '%s'?", notebook.Name)). + Description("This cannot be undone."). + Affirmative("Delete"). + Negative("Cancel"). + Value(&confirm). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if confirm { + cfg.Notebooks = append(cfg.Notebooks[:idx], cfg.Notebooks[idx+1:]...) + if cfg.Defaults.Notebook == notebook.Key { + cfg.Defaults.Notebook = "" + } + } + + return nil +} diff --git a/cmd/init/ui.go b/cmd/init/ui.go new file mode 100644 index 0000000000000000000000000000000000000000..602abef08cc260017c08aa517c04f0784d4fd0b3 --- /dev/null +++ b/cmd/init/ui.go @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init + +import ( + "errors" + "fmt" + "os" + "regexp" + "strconv" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/ui" + "git.secluded.site/lune/internal/validate" +) + +const ( + choiceBack = "back" + choiceAdd = "add" + choiceDone = "done" + actionEdit = "edit" + actionDelete = "delete" + actionGoals = "goals" +) + +var ( + errKeyRequired = errors.New("key is required") + errKeyFormat = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')") + errKeyDuplicate = errors.New("this key is already in use") + errIDRequired = errors.New("ID is required") + errIDFormat = errors.New("invalid UUID format") + errUserAborted = errors.New("user aborted") + errIndexOutRange = errors.New("index out of range") +) + +func configureUIPrefs(cfg *config.Config) error { + color := cfg.UI.Color + if color == "" { + color = "auto" + } + + err := huh.NewSelect[string](). + Title("Color output"). + Description("When should lune use colored output?"). + Options( + huh.NewOption("Auto (detect terminal capability)", "auto"), + huh.NewOption("Always", "always"), + huh.NewOption("Never", "never"), + ). + Value(&color). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + cfg.UI.Color = color + + return nil +} + +func resetConfig(cmd *cobra.Command, cfg *config.Config) error { + path, err := config.Path() + if err != nil { + return err + } + + var confirm bool + + err = huh.NewConfirm(). + Title("Reset all configuration?"). + Description(fmt.Sprintf("This will delete %s\nYou'll need to run 'lune init' again.", path)). + Affirmative("Reset"). + Negative("Cancel"). + Value(&confirm). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + + return err + } + + if confirm { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing config: %w", err) + } + + fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset.")) + + *cfg = config.Config{} + } + + return nil +} + +func runListSelect(title, description string, options []huh.Option[string]) (string, error) { + var choice string + + selector := huh.NewSelect[string](). + Title(title). + Options(options...). + Value(&choice) + + if description != "" { + selector = selector.Description(description) + } + + if err := selector.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return choiceBack, nil + } + + return "", err + } + + return choice, nil +} + +func parseEditIndex(choice string) (int, bool) { + if !strings.HasPrefix(choice, "edit:") { + return 0, false + } + + idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:")) + if err != nil { + return 0, false + } + + return idx, true +} + +var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +func validateKeyFormat(input string) error { + if input == "" { + return errKeyRequired + } + + if !keyPattern.MatchString(input) { + return errKeyFormat + } + + return nil +} + +func validateID(input string) error { + if input == "" { + return errIDRequired + } + + if err := validate.ID(input); err != nil { + return errIDFormat + } + + return nil +} + +type itemFormConfig struct { + itemType string + namePlaceholder string + keyPlaceholder string + keyValidator func(string) error +} + +func titleCase(s string) string { + if s == "" { + return s + } + + return strings.ToUpper(s[:1]) + s[1:] +} + +// itemAction represents the result of an action selection dialog. +type itemAction int + +const ( + itemActionNone itemAction = iota + itemActionEdit + itemActionDelete + itemActionGoals +) + +// runActionSelect shows an action selection dialog for an item and returns the chosen action. +func runActionSelect(title string, includeGoals bool) (itemAction, error) { + var action string + + options := []huh.Option[string]{ + huh.NewOption("Edit", actionEdit), + } + + if includeGoals { + options = append(options, huh.NewOption("Manage goals", actionGoals)) + } + + options = append(options, + huh.NewOption("Delete", actionDelete), + huh.NewOption("Back", choiceBack), + ) + + err := huh.NewSelect[string](). + Title(title). + Options(options...). + Value(&action). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return itemActionNone, nil + } + + return itemActionNone, err + } + + switch action { + case actionEdit: + return itemActionEdit, nil + case actionDelete: + return itemActionDelete, nil + case actionGoals: + return itemActionGoals, nil + default: + return itemActionNone, nil + } +} + +func runItemForm(name, key, itemID *string, cfg itemFormConfig) error { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Name"). + Description("Display name for this "+cfg.itemType+"."). + Placeholder(cfg.namePlaceholder). + Value(name). + Validate(huh.ValidateNotEmpty()), + huh.NewInput(). + Title("Key"). + Description("Short alias for CLI use (lowercase, no spaces)."). + Placeholder(cfg.keyPlaceholder). + Value(key). + Validate(cfg.keyValidator), + huh.NewInput(). + Title("ID"). + Description("Settings → 'Copy "+titleCase(cfg.itemType)+" ID' (bottom left)."). + Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"). + Value(itemID). + Validate(validateID), + ), + ) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return errUserAborted + } + + return err + } + + return nil +} diff --git a/cmd/init/ui_test.go b/cmd/init/ui_test.go new file mode 100644 index 0000000000000000000000000000000000000000..83aa1c6320dd9f45819d67f513e1827dd05dfc1c --- /dev/null +++ b/cmd/init/ui_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init //nolint:testpackage // testing internal validators + +import ( + "errors" + "testing" +) + +func TestValidateKeyFormat_Valid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + {"simple key", "work"}, + {"key with hyphen", "q1-goals"}, + {"key with numbers", "area2"}, + {"multiple hyphens", "my-special-area"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := validateKeyFormat(tc.input); err != nil { + t.Errorf("validateKeyFormat(%q) = %v, want nil", tc.input, err) + } + }) + } +} + +func TestValidateKeyFormat_Invalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr error + }{ + {"empty key", "", errKeyRequired}, + {"uppercase letters", "Work", errKeyFormat}, + {"spaces", "my area", errKeyFormat}, + {"underscores", "my_area", errKeyFormat}, + {"leading hyphen", "-work", errKeyFormat}, + {"trailing hyphen", "work-", errKeyFormat}, + {"double hyphen", "my--area", errKeyFormat}, + {"special characters", "work@home", errKeyFormat}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := validateKeyFormat(tc.input); !errors.Is(err, tc.wantErr) { + t.Errorf("validateKeyFormat(%q) = %v, want %v", tc.input, err, tc.wantErr) + } + }) + } +} + +func TestValidateID_Valid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + {"valid UUID lowercase", "123e4567-e89b-12d3-a456-426614174000"}, + {"valid UUID uppercase", "123E4567-E89B-12D3-A456-426614174000"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := validateID(tc.input); err != nil { + t.Errorf("validateID(%q) = %v, want nil", tc.input, err) + } + }) + } +} + +func TestValidateID_Invalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr error + }{ + {"empty ID", "", errIDRequired}, + {"too short", "123e4567-e89b", errIDFormat}, + {"random string", "not-a-uuid", errIDFormat}, + {"wrong characters", "123e4567-e89b-12d3-a456-42661417zzzz", errIDFormat}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := validateID(tc.input); !errors.Is(err, tc.wantErr) { + t.Errorf("validateID(%q) = %v, want %v", tc.input, err, tc.wantErr) + } + }) + } +} + +func TestParseEditIndex_Valid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantIdx int + }{ + {"index 0", "edit:0", 0}, + {"index 5", "edit:5", 5}, + {"large index", "edit:999", 999}, + {"negative index", "edit:-1", -1}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + idx, valid := parseEditIndex(testCase.input) + if !valid { + t.Errorf("parseEditIndex(%q) valid = false, want true", testCase.input) + } + + if idx != testCase.wantIdx { + t.Errorf("parseEditIndex(%q) idx = %d, want %d", testCase.input, idx, testCase.wantIdx) + } + }) + } +} + +func TestParseEditIndex_Invalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + {"back choice", "back"}, + {"add choice", "add"}, + {"done choice", "done"}, + {"empty prefix", "edit:"}, + {"non-numeric", "edit:abc"}, + {"empty string", ""}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + _, valid := parseEditIndex(testCase.input) + if valid { + t.Errorf("parseEditIndex(%q) valid = true, want false", testCase.input) + } + }) + } +} + +func TestTitleCase(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {"area", "Area"}, + {"goal", "Goal"}, + {"notebook", "Notebook"}, + {"habit", "Habit"}, + {"a", "A"}, + {"", ""}, + {"already Capitalized", "Already Capitalized"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + + if got := titleCase(tc.input); got != tc.want { + t.Errorf("titleCase(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index b82ccf579e8420748d4561b52d15d03f91a90682..fb3ce7c3ecc61077cba1008c5a2b84a74e908f54 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "os" "git.secluded.site/lune/cmd/habit" + initcmd "git.secluded.site/lune/cmd/init" "git.secluded.site/lune/cmd/journal" "git.secluded.site/lune/cmd/note" "git.secluded.site/lune/cmd/person" @@ -38,7 +39,7 @@ func init() { rootCmd.AddGroup(&cobra.Group{ID: "resources", Title: "Resources"}) rootCmd.AddGroup(&cobra.Group{ID: "shortcuts", Title: "Shortcuts"}) - rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(initcmd.Cmd) rootCmd.AddCommand(pingCmd) rootCmd.AddCommand(task.Cmd) diff --git a/go.mod b/go.mod index 58dcdde416ee873662662fe3307cc6cf91d99d17..913b86f2f5c1e6828b718b68d41c1c168882b076 100644 --- a/go.mod +++ b/go.mod @@ -6,20 +6,23 @@ require ( git.secluded.site/go-lunatask v0.1.0-rc7 github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/fang v0.4.4 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.6 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/huh v0.8.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251217160852-6b0c0e26fad9 // indirect github.com/charmbracelet/x/ansi v0.11.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect @@ -31,8 +34,10 @@ require ( github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 3da14adcd1c64cea01c7d18fb87870630e3dbe55..2c329febfe0f0d5c8073f5cbf4a0412a267ba417 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= git.secluded.site/go-lunatask v0.1.0-rc7 h1:kzwAN9h4zVTo0OBs4B23ba5mAqxus6nYU62LElUkdnw= git.secluded.site/go-lunatask v0.1.0-rc7/go.mod h1:sWUQxme1z7qfsfS59nU5hqPvsRCt+HBmT/yBeIn6Fmc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -14,14 +18,16 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg= +github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/ultraviolet v0.0.0-20251217160852-6b0c0e26fad9 h1:dsDBRP9Iyco0EjVpCsAzl8VGbxk04fP3sa80ySJSAZw= @@ -30,6 +36,10 @@ github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9G github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 h1:xGojlO6kHCDB1k6DolME79LG0u90TzVd8atGhmxFRIo= github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= @@ -42,6 +52,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -49,12 +61,20 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -93,10 +113,14 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= diff --git a/internal/client/client.go b/internal/client/client.go index 41b1e9a69087b5368b71359107a02ca883f060b8..6186967c90ff16e478f652384c70d925211a2d5c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -7,28 +7,92 @@ package client import ( "errors" + "fmt" "os" "runtime/debug" "git.secluded.site/go-lunatask" + "github.com/zalando/go-keyring" ) // EnvAPIKey is the environment variable name for the Lunatask API key. const EnvAPIKey = "LUNATASK_API_KEY" //nolint:gosec // not a credential, just env var name -// ErrNoAPIKey indicates the API key environment variable is not set. -var ErrNoAPIKey = errors.New("LUNATASK_API_KEY environment variable not set") +const ( + keyringService = "lune" + keyringUser = "api-key" +) + +// ErrNoAPIKey indicates the API key is not available from env or keyring. +var ErrNoAPIKey = errors.New("LUNATASK_API_KEY not set and no key found in system keyring") -// New creates a Lunatask client from the LUNATASK_API_KEY environment variable. +// New creates a Lunatask client, checking env var first, then system keyring. func New() (*lunatask.Client, error) { - token := os.Getenv(EnvAPIKey) - if token == "" { - return nil, ErrNoAPIKey + if token := os.Getenv(EnvAPIKey); token != "" { + return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil + } + + token, err := keyring.Get(keyringService, keyringUser) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil, ErrNoAPIKey + } + + return nil, fmt.Errorf("accessing system keyring: %w", err) } return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil } +// GetAPIKey returns the API key from keyring. Returns empty string and nil error +// if not found; returns error for keyring access problems. +func GetAPIKey() (string, error) { + token, err := keyring.Get(keyringService, keyringUser) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return "", nil + } + + return "", fmt.Errorf("accessing system keyring: %w", err) + } + + return token, nil +} + +// SetAPIKey stores the API key in the system keyring. +func SetAPIKey(token string) error { + if err := keyring.Set(keyringService, keyringUser, token); err != nil { + return fmt.Errorf("keyring set: %w", err) + } + + return nil +} + +// DeleteAPIKey removes the API key from the system keyring. +func DeleteAPIKey() error { + if err := keyring.Delete(keyringService, keyringUser); err != nil { + return fmt.Errorf("keyring delete: %w", err) + } + + return nil +} + +// HasKeyringKey checks if an API key is stored in the keyring. +// Returns (true, nil) if found, (false, nil) if not found, +// or (false, error) if there was a keyring access problem. +func HasKeyringKey() (bool, error) { + _, err := keyring.Get(keyringService, keyringUser) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return false, nil + } + + return false, fmt.Errorf("accessing system keyring: %w", err) + } + + return true, nil +} + // version returns the module version from build info, or "dev" if unavailable. func version() string { info, ok := debug.ReadBuildInfo()