From df1c4f45150cbd2d26074bf0e2f58dfc94e1355e Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Dec 2025 14:34:30 -0700 Subject: [PATCH] refactor(init): deep links, nav, token rename - Add internal/deeplink package for parsing lunatask:// URLs - Resource commands (task/note/person) now accept deep links or UUIDs - Init wizard gains Back/Next navigation between steps - Rename "API key" to "access token" throughout - Remove LUNATASK_API_KEY env var fallback; keyring-only auth Assisted-by: Claude Opus 4.5 via Crush --- AGENTS.md | 8 +- cmd/init/apikey.go | 89 +++++++--------- cmd/init/areas.go | 122 +++++++++++----------- cmd/init/defaults.go | 6 +- cmd/init/habits.go | 96 +++++++++-------- cmd/init/init.go | 97 +++++++++++------ cmd/init/notebooks.go | 96 +++++++++-------- cmd/init/steps.go | 160 +++++++++++++++++++++++++++++ cmd/init/ui.go | 155 ++++++++++++++++++++++------ cmd/init/ui_test.go | 68 ++++++++---- cmd/note/delete.go | 7 +- cmd/note/get.go | 7 +- cmd/note/update.go | 5 +- cmd/person/delete.go | 7 +- cmd/person/get.go | 7 +- cmd/person/timeline.go | 5 +- cmd/person/update.go | 5 +- cmd/ping.go | 6 +- cmd/root.go | 2 +- cmd/task/delete.go | 7 +- cmd/task/get.go | 7 +- cmd/task/update.go | 5 +- internal/client/client.go | 32 +++--- internal/deeplink/deeplink.go | 92 +++++++++++++++++ internal/deeplink/deeplink_test.go | 128 +++++++++++++++++++++++ internal/validate/validate.go | 18 ++++ 26 files changed, 888 insertions(+), 349 deletions(-) create mode 100644 cmd/init/steps.go create mode 100644 internal/deeplink/deeplink.go create mode 100644 internal/deeplink/deeplink_test.go diff --git a/AGENTS.md b/AGENTS.md index 75db0c3a32b1f7e7dbb0a2215f610eee8d85f962..82fa415c66d8bae92ad8cef8912e1e65010284c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ cmd/ journal/ → journal add habit/ → habit track internal/ - client/ → Lunatask API client factory (reads LUNATASK_API_KEY) + client/ → Lunatask API client factory (uses system keyring) config/ → TOML config at ~/.config/lunatask/config.toml ui/ → Lipgloss styles (Success, Warning, Error, Muted, Bold) validate/ → Input validation (UUID format) @@ -109,10 +109,10 @@ The `.golangci.yaml` disables several linters for `cmd/`: These patterns are intentional. -### API key +### Access token -Read from `LUNATASK_API_KEY` env var via `internal/client.New()`. No interactive -prompts—fail fast with `client.ErrNoAPIKey` if unset. +Read from system keyring via `internal/client.New()`. No interactive +prompts—fail fast with `client.ErrNoToken` if not configured. ### Output formatting diff --git a/cmd/init/apikey.go b/cmd/init/apikey.go index fe062732b7527a6f28ebf554c5f747361c004755..f2a71fe16c2c5ee22c7ddd87aced779783e75b25 100644 --- a/cmd/init/apikey.go +++ b/cmd/init/apikey.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "io" - "os" "time" "git.secluded.site/go-lunatask" @@ -21,31 +20,19 @@ import ( "git.secluded.site/lune/internal/ui" ) -func configureAPIKey(cmd *cobra.Command) error { +func configureAccessToken(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() + hasToken, keyringErr := client.HasKeyringToken() 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.")) + fmt.Fprintln(out, "Please resolve the keyring issue and try again.") return fmt.Errorf("keyring access failed: %w", keyringErr) } - if hasKey { - shouldPrompt, err := handleExistingAPIKey(out) + if hasToken { + shouldPrompt, err := handleExistingToken(out) if err != nil { return err } @@ -55,18 +42,18 @@ func configureAPIKey(cmd *cobra.Command) error { } } - return promptForAPIKey(out) + return promptForToken(out) } -func handleExistingAPIKey(out io.Writer) (bool, error) { - existingKey, err := client.GetAPIKey() +func handleExistingToken(out io.Writer) (bool, error) { + existingToken, err := client.GetToken() if err != nil { - return false, fmt.Errorf("reading API key from keyring: %w", err) + return false, fmt.Errorf("reading access token from keyring: %w", err) } - fmt.Fprintln(out, ui.Muted.Render("API key found in system keyring.")) + fmt.Fprintln(out, "Access token found in system keyring.") - if err := validateWithSpinner(existingKey); err != nil { + if err := validateWithSpinner(existingToken); err != nil { fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error())) } else { fmt.Fprintln(out, ui.Success.Render("✓ Authentication successful")) @@ -75,17 +62,17 @@ func handleExistingAPIKey(out io.Writer) (bool, error) { var action string err = huh.NewSelect[string](). - Title("API key is already configured"). + Title("Access token is already configured"). Options( - huh.NewOption("Keep existing key", "keep"), - huh.NewOption("Replace with new key", "replace"), + huh.NewOption("Keep existing token", "keep"), + huh.NewOption("Replace with new token", "replace"), huh.NewOption("Delete from keyring", actionDelete), ). Value(&action). Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return false, nil + return false, errQuit } return false, err @@ -95,11 +82,11 @@ func handleExistingAPIKey(out io.Writer) (bool, error) { case "keep": return false, nil case actionDelete: - if err := client.DeleteAPIKey(); err != nil { - return false, fmt.Errorf("deleting API key: %w", err) + if err := client.DeleteToken(); err != nil { + return false, fmt.Errorf("deleting access token: %w", err) } - fmt.Fprintln(out, ui.Success.Render("API key removed from keyring.")) + fmt.Fprintln(out, ui.Success.Render("Access token removed from keyring.")) return false, nil default: @@ -107,20 +94,20 @@ func handleExistingAPIKey(out io.Writer) (bool, error) { } } -func promptForAPIKey(out io.Writer) error { +func promptForToken(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, "You can get your access token from:") + fmt.Fprintln(out, " Lunatask → Settings → Access Tokens") fmt.Fprintln(out) - var apiKey string + var token string for { err := huh.NewInput(). - Title("Lunatask API Key"). - Description("Paste your API key (it will be stored securely in your system keyring)."). + Title("Lunatask Access Token"). + Description("Paste your access token (it will be stored securely in your system keyring)."). EchoMode(huh.EchoModePassword). - Value(&apiKey). + Value(&token). Validate(func(s string) error { if s == "" { return errKeyRequired @@ -131,20 +118,18 @@ func promptForAPIKey(out io.Writer) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(out, ui.Warning.Render("Skipped API key setup.")) - - return nil + return errQuit } return err } - if err := validateWithSpinner(apiKey); err != nil { + if err := validateWithSpinner(token); 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, "Please check your access token and try again.") fmt.Fprintln(out) - apiKey = "" + token = "" continue } @@ -154,22 +139,22 @@ func promptForAPIKey(out io.Writer) error { break } - if err := client.SetAPIKey(apiKey); err != nil { - return fmt.Errorf("saving API key to keyring: %w", err) + if err := client.SetToken(token); err != nil { + return fmt.Errorf("saving access token to keyring: %w", err) } - fmt.Fprintln(out, ui.Success.Render("API key saved to system keyring.")) + fmt.Fprintln(out, ui.Success.Render("Access token saved to system keyring.")) return nil } -func validateWithSpinner(apiKey string) error { +func validateWithSpinner(token string) error { var validationErr error err := spinner.New(). - Title("Verifying API key..."). + Title("Verifying access token..."). Action(func() { - validationErr = validateAPIKeyWithPing(apiKey) + validationErr = validateTokenWithPing(token) }). Run() if err != nil { @@ -179,8 +164,8 @@ func validateWithSpinner(apiKey string) error { return validationErr } -func validateAPIKeyWithPing(apiKey string) error { - c := lunatask.NewClient(apiKey, lunatask.UserAgent("lune/init")) +func validateTokenWithPing(token string) error { + c := lunatask.NewClient(token, lunatask.UserAgent("lune/init")) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/cmd/init/areas.go b/cmd/init/areas.go index b87667a7f1d771c837d5845f031b7dffd844582c..d6e034eb664e5339fd93622550db13de36911ce9 100644 --- a/cmd/init/areas.go +++ b/cmd/init/areas.go @@ -13,64 +13,52 @@ import ( "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) +func manageAreas(cfg *config.Config) error { + nav := manageAreasAsStep(cfg) + if nav == navQuit { + return errQuit } return nil } -func manageAreas(cfg *config.Config) error { +// manageAreasAsStep runs areas management as a wizard step with Back/Next navigation. +func manageAreasAsStep(cfg *config.Config) wizardNav { for { - options := buildAreaOptions(cfg.Areas) + options := buildAreaStepOptions(cfg.Areas) - choice, err := runListSelect("Areas", "Manage your areas and their goals.", options) + choice, err := runListSelect( + "Areas & Goals", + "Areas organize your life (Work, Health, etc). Goals belong to areas.", + options, + ) if err != nil { - return err - } - - if choice == choiceBack { - return nil + return navQuit } - if choice == choiceAdd { - if err := addArea(cfg); err != nil { - return err + switch choice { + case choiceBack: + return navBack + case choiceNext: + return navNext + case choiceAdd: + if err := addArea(cfg); errors.Is(err, errQuit) { + return navQuit + } + default: + idx, ok := parseEditIndex(choice) + if !ok { + continue } - continue - } - - idx, ok := parseEditIndex(choice) - if !ok { - continue - } - - if err := manageAreaActions(cfg, idx); err != nil { - return err + if err := manageAreaActions(cfg, idx); errors.Is(err, errQuit) { + return navQuit + } } } } -func buildAreaOptions(areas []config.Area) []huh.Option[string] { +func buildAreaStepOptions(areas []config.Area) []huh.Option[string] { options := []huh.Option[string]{ huh.NewOption("Add new area", choiceAdd), } @@ -81,7 +69,10 @@ func buildAreaOptions(areas []config.Area) []huh.Option[string] { options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx))) } - options = append(options, huh.NewOption("Back", choiceBack)) + options = append(options, + huh.NewOption("← Back", choiceBack), + huh.NewOption("Next →", choiceNext), + ) return options } @@ -89,7 +80,7 @@ func buildAreaOptions(areas []config.Area) []huh.Option[string] { func addArea(cfg *config.Config) error { area, err := editArea(nil, cfg) if err != nil { - if errors.Is(err, errUserAborted) { + if errors.Is(err, errBack) { return nil } @@ -119,9 +110,16 @@ func manageAreaActions(cfg *config.Config, idx int) error { 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) { + updated, err := editArea(area, cfg) + if err != nil { + if errors.Is(err, errBack) { + return nil + } + return err - } else if updated != nil { + } + + if updated != nil { cfg.Areas[idx] = *updated } case itemActionGoals: @@ -142,10 +140,11 @@ func editArea(existing *config.Area, cfg *config.Config) (*config.Area, error) { } err := runItemForm(&area.Name, &area.Key, &area.ID, itemFormConfig{ - itemType: "area", - namePlaceholder: "Personal", - keyPlaceholder: "personal", - keyValidator: validateAreaKey(cfg, existing), + itemType: "area", + namePlaceholder: "Personal", + keyPlaceholder: "personal", + keyValidator: validateAreaKey(cfg, existing), + supportsDeepLink: true, }) if err != nil { return nil, err @@ -185,7 +184,7 @@ func maybeManageGoals(cfg *config.Config, areaIdx int) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err @@ -258,7 +257,7 @@ func buildGoalOptions(goals []config.Goal) []huh.Option[string] { func addGoal(area *config.Area) error { goal, err := editGoal(nil, area) if err != nil { - if errors.Is(err, errUserAborted) { + if errors.Is(err, errBack) { return nil } @@ -285,7 +284,11 @@ func manageGoalActions(area *config.Area, idx int) error { switch action { case itemActionEdit: updated, err := editGoal(goal, area) - if err != nil && !errors.Is(err, errUserAborted) { + if err != nil { + if errors.Is(err, errBack) { + return nil + } + return err } @@ -308,10 +311,11 @@ func editGoal(existing *config.Goal, area *config.Area) (*config.Goal, error) { } err := runItemForm(&goal.Name, &goal.Key, &goal.ID, itemFormConfig{ - itemType: "goal", - namePlaceholder: "Learn Gaelic", - keyPlaceholder: "gaelic", - keyValidator: validateGoalKey(area, existing), + itemType: "goal", + namePlaceholder: "Learn Gaelic", + keyPlaceholder: "gaelic", + keyValidator: validateGoalKey(area, existing), + supportsDeepLink: true, }) if err != nil { return nil, err @@ -358,7 +362,7 @@ func deleteArea(cfg *config.Config, idx int) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err @@ -392,7 +396,7 @@ func deleteGoal(area *config.Area, idx int) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err diff --git a/cmd/init/defaults.go b/cmd/init/defaults.go index dac36d0e4360de301ac37aef07ca00f2befeb670..35a18060d5fa69bfa69e6b516b8ae99aa71a0ab2 100644 --- a/cmd/init/defaults.go +++ b/cmd/init/defaults.go @@ -51,7 +51,7 @@ func handleNoDefaultsAvailable(cfg *config.Config) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err @@ -88,7 +88,7 @@ func selectDefaultArea(cfg *config.Config) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err @@ -120,7 +120,7 @@ func selectDefaultNotebook(cfg *config.Config) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err diff --git a/cmd/init/habits.go b/cmd/init/habits.go index 389026838a25e390f6381c91cc94675f02fbc539..dbba8820c18e20bfbefd027442c9337ad9fabbde 100644 --- a/cmd/init/habits.go +++ b/cmd/init/habits.go @@ -13,64 +13,52 @@ import ( "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) +func manageHabits(cfg *config.Config) error { + nav := manageHabitsAsStep(cfg) + if nav == navQuit { + return errQuit } return nil } -func manageHabits(cfg *config.Config) error { +// manageHabitsAsStep runs habits management as a wizard step with Back/Next navigation. +func manageHabitsAsStep(cfg *config.Config) wizardNav { for { - options := buildHabitOptions(cfg.Habits) + options := buildHabitStepOptions(cfg.Habits) - choice, err := runListSelect("Habits", "Manage your trackable habits.", options) + choice, err := runListSelect( + "Habits", + "Track habits from the command line.", + options, + ) if err != nil { - return err + return navQuit } - if choice == choiceBack { - return nil - } - - if choice == choiceAdd { - if err := addHabit(cfg); err != nil { - return err + switch choice { + case choiceBack: + return navBack + case choiceNext: + return navNext + case choiceAdd: + if err := addHabit(cfg); errors.Is(err, errQuit) { + return navQuit + } + default: + idx, ok := parseEditIndex(choice) + if !ok { + continue } - continue - } - - idx, ok := parseEditIndex(choice) - if !ok { - continue - } - - if err := manageHabitActions(cfg, idx); err != nil { - return err + if err := manageHabitActions(cfg, idx); errors.Is(err, errQuit) { + return navQuit + } } } } -func buildHabitOptions(habits []config.Habit) []huh.Option[string] { +func buildHabitStepOptions(habits []config.Habit) []huh.Option[string] { options := []huh.Option[string]{ huh.NewOption("Add new habit", choiceAdd), } @@ -80,7 +68,10 @@ func buildHabitOptions(habits []config.Habit) []huh.Option[string] { options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx))) } - options = append(options, huh.NewOption("Back", choiceBack)) + options = append(options, + huh.NewOption("← Back", choiceBack), + huh.NewOption("Next →", choiceNext), + ) return options } @@ -88,7 +79,7 @@ func buildHabitOptions(habits []config.Habit) []huh.Option[string] { func addHabit(cfg *config.Config) error { habit, err := editHabit(nil, cfg) if err != nil { - if errors.Is(err, errUserAborted) { + if errors.Is(err, errBack) { return nil } @@ -115,7 +106,11 @@ func manageHabitActions(cfg *config.Config, idx int) error { switch action { case itemActionEdit: updated, err := editHabit(habit, cfg) - if err != nil && !errors.Is(err, errUserAborted) { + if err != nil { + if errors.Is(err, errBack) { + return nil + } + return err } @@ -138,10 +133,11 @@ func editHabit(existing *config.Habit, cfg *config.Config) (*config.Habit, error } err := runItemForm(&habit.Name, &habit.Key, &habit.ID, itemFormConfig{ - itemType: "habit", - namePlaceholder: "Study Gaelic", - keyPlaceholder: "gaelic", - keyValidator: validateHabitKey(cfg, existing), + itemType: "habit", + namePlaceholder: "Study Gaelic", + keyPlaceholder: "gaelic", + keyValidator: validateHabitKey(cfg, existing), + supportsDeepLink: false, }) if err != nil { return nil, err @@ -188,7 +184,7 @@ func deleteHabit(cfg *config.Config, idx int) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err diff --git a/cmd/init/init.go b/cmd/init/init.go index 9a6075a8553bd0db169e97f63b0c29cfa760c80a..2fe4bcc87fac89966eee374db7d5b4c0f48db207 100644 --- a/cmd/init/init.go +++ b/cmd/init/init.go @@ -16,6 +16,21 @@ import ( "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") + +// 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", @@ -25,7 +40,7 @@ var Cmd = &cobra.Command{ 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`, + - Configuring and verifying your access token`, RunE: runInit, } @@ -34,41 +49,61 @@ func runInit(cmd *cobra.Command, _ []string) error { if errors.Is(err, config.ErrNotFound) { cfg = &config.Config{} - return runFreshSetup(cmd, cfg) + 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) } - return runReconfigure(cmd, cfg) -} - -func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error { - printWelcome(cmd) - - if err := configureUIPrefs(cfg); err != nil { - return err + err = runReconfigure(cmd, cfg) + if errors.Is(err, errQuit) { + return nil } - if err := maybeConfigureAreas(cfg); err != nil { - return err + if errors.Is(err, errReset) { + return runFreshSetup(cmd, cfg) } - if err := maybeConfigureNotebooks(cfg); err != nil { - return err - } + return err +} - if err := maybeConfigureHabits(cfg); err != nil { - return err - } +// wizardStep is a function that runs a wizard step and returns navigation direction. +type wizardStep func() wizardNav - if err := configureDefaults(cfg); err != nil { - return err - } +func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error { + printWelcome(cmd) - if err := configureAPIKey(cmd); err != nil { - return err + 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) @@ -80,14 +115,14 @@ func printWelcome(cmd *cobra.Command) { 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, "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, ui.Muted.Render("You can run 'lune init' again anytime to add or modify config.")) + fmt.Fprintln(out, "You can run 'lune init' again anytime to add or modify config.") fmt.Fprintln(out) } @@ -101,7 +136,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error { "habits": func() error { return manageHabits(cfg) }, "defaults": func() error { return configureDefaults(cfg) }, "ui": func() error { return configureUIPrefs(cfg) }, - "apikey": func() error { return configureAPIKey(cmd) }, + "apikey": func() error { return configureAccessToken(cmd) }, "reset": func() error { return resetConfig(cmd, cfg) }, } @@ -116,7 +151,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error { huh.NewOption("Manage habits", "habits"), huh.NewOption("Set defaults", "defaults"), huh.NewOption("UI preferences", "ui"), - huh.NewOption("API key", "apikey"), + huh.NewOption("Access token", "apikey"), huh.NewOption("Reset all configuration", "reset"), huh.NewOption("Done", choiceDone), ). @@ -124,7 +159,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err @@ -183,7 +218,7 @@ func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error { if errors.Is(err, huh.ErrUserAborted) { fmt.Fprintln(out, ui.Warning.Render("Changes discarded.")) - return nil + return errQuit } return err diff --git a/cmd/init/notebooks.go b/cmd/init/notebooks.go index e8b6913d1e1c1cbfe2eeda0a77de4951903f5e4b..9fad259896ed9491d73378f8eaba46c3c16a1437 100644 --- a/cmd/init/notebooks.go +++ b/cmd/init/notebooks.go @@ -13,64 +13,52 @@ import ( "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) +func manageNotebooks(cfg *config.Config) error { + nav := manageNotebooksAsStep(cfg) + if nav == navQuit { + return errQuit } return nil } -func manageNotebooks(cfg *config.Config) error { +// manageNotebooksAsStep runs notebooks management as a wizard step with Back/Next navigation. +func manageNotebooksAsStep(cfg *config.Config) wizardNav { for { - options := buildNotebookOptions(cfg.Notebooks) + options := buildNotebookStepOptions(cfg.Notebooks) - choice, err := runListSelect("Notebooks", "Manage your notebooks.", options) + choice, err := runListSelect( + "Notebooks", + "Notebooks organize your notes in Lunatask.", + options, + ) if err != nil { - return err + return navQuit } - if choice == choiceBack { - return nil - } - - if choice == choiceAdd { - if err := addNotebook(cfg); err != nil { - return err + switch choice { + case choiceBack: + return navBack + case choiceNext: + return navNext + case choiceAdd: + if err := addNotebook(cfg); errors.Is(err, errQuit) { + return navQuit + } + default: + idx, ok := parseEditIndex(choice) + if !ok { + continue } - continue - } - - idx, ok := parseEditIndex(choice) - if !ok { - continue - } - - if err := manageNotebookActions(cfg, idx); err != nil { - return err + if err := manageNotebookActions(cfg, idx); errors.Is(err, errQuit) { + return navQuit + } } } } -func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] { +func buildNotebookStepOptions(notebooks []config.Notebook) []huh.Option[string] { options := []huh.Option[string]{ huh.NewOption("Add new notebook", choiceAdd), } @@ -80,7 +68,10 @@ func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] { options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx))) } - options = append(options, huh.NewOption("Back", choiceBack)) + options = append(options, + huh.NewOption("← Back", choiceBack), + huh.NewOption("Next →", choiceNext), + ) return options } @@ -88,7 +79,7 @@ func buildNotebookOptions(notebooks []config.Notebook) []huh.Option[string] { func addNotebook(cfg *config.Config) error { notebook, err := editNotebook(nil, cfg) if err != nil { - if errors.Is(err, errUserAborted) { + if errors.Is(err, errBack) { return nil } @@ -115,7 +106,11 @@ func manageNotebookActions(cfg *config.Config, idx int) error { switch action { case itemActionEdit: updated, err := editNotebook(notebook, cfg) - if err != nil && !errors.Is(err, errUserAborted) { + if err != nil { + if errors.Is(err, errBack) { + return nil + } + return err } @@ -138,10 +133,11 @@ func editNotebook(existing *config.Notebook, cfg *config.Config) (*config.Notebo } err := runItemForm(¬ebook.Name, ¬ebook.Key, ¬ebook.ID, itemFormConfig{ - itemType: "notebook", - namePlaceholder: "Gaelic Notes", - keyPlaceholder: "gaelic", - keyValidator: validateNotebookKey(cfg, existing), + itemType: "notebook", + namePlaceholder: "Gaelic Notes", + keyPlaceholder: "gaelic", + keyValidator: validateNotebookKey(cfg, existing), + supportsDeepLink: true, }) if err != nil { return nil, err @@ -188,7 +184,7 @@ func deleteNotebook(cfg *config.Config, idx int) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err diff --git a/cmd/init/steps.go b/cmd/init/steps.go new file mode 100644 index 0000000000000000000000000000000000000000..4d878a5e71123e330c9e13a8b2d4a64c0a3ba0ec --- /dev/null +++ b/cmd/init/steps.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package init + +import ( + "errors" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "git.secluded.site/lune/internal/config" +) + +// runUIPrefsStep runs the UI preferences configuration step. +func runUIPrefsStep(cfg *config.Config) wizardNav { + 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 navQuit + } + + return navQuit + } + + cfg.UI.Color = color + + return navNext +} + +// runAreasStep runs the areas configuration step. +func runAreasStep(cfg *config.Config) wizardNav { + return manageAreasAsStep(cfg) +} + +// runNotebooksStep runs the notebooks configuration step. +func runNotebooksStep(cfg *config.Config) wizardNav { + return manageNotebooksAsStep(cfg) +} + +// runHabitsStep runs the habits configuration step. +func runHabitsStep(cfg *config.Config) wizardNav { + return manageHabitsAsStep(cfg) +} + +// runDefaultsStep runs the defaults configuration step. +func runDefaultsStep(cfg *config.Config) wizardNav { + hasAreas := len(cfg.Areas) > 0 + hasNotebooks := len(cfg.Notebooks) > 0 + + if !hasAreas && !hasNotebooks { + return navNext + } + + if hasAreas { + nav := selectDefaultAreaWithNav(cfg) + if nav != navNext { + return nav + } + } + + if hasNotebooks { + return selectDefaultNotebookWithNav(cfg) + } + + return navNext +} + +// runAccessTokenStep runs the access token configuration step. +func runAccessTokenStep(cmd *cobra.Command) wizardNav { + err := configureAccessToken(cmd) + if errors.Is(err, errQuit) { + return navQuit + } + + if err != nil { + return navQuit + } + + return navNext +} + +// selectDefaultAreaWithNav selects the default area and returns navigation. +func selectDefaultAreaWithNav(cfg *config.Config) wizardNav { + options := []huh.Option[string]{huh.NewOption("None", "")} + for _, area := range cfg.Areas { + options = append(options, huh.NewOption(area.Name+" ("+area.Key+")", area.Key)) + } + + choice, nav := runDefaultSelect("Default area", "Used when no area is specified.", cfg.Defaults.Area, options) + if nav != navNext || choice == "" { + return nav + } + + cfg.Defaults.Area = choice + + return navNext +} + +// selectDefaultNotebookWithNav selects the default notebook and returns navigation. +func selectDefaultNotebookWithNav(cfg *config.Config) wizardNav { + options := []huh.Option[string]{huh.NewOption("None", "")} + for _, nb := range cfg.Notebooks { + options = append(options, huh.NewOption(nb.Name+" ("+nb.Key+")", nb.Key)) + } + + desc := "Used when no notebook is specified." + choice, nav := runDefaultSelect("Default notebook", desc, cfg.Defaults.Notebook, options) + + if nav != navNext || choice == "" { + return nav + } + + cfg.Defaults.Notebook = choice + + return navNext +} + +func runDefaultSelect(title, desc, current string, options []huh.Option[string]) (string, wizardNav) { + options = append(options, + huh.NewOption("← Back", choiceBack), + huh.NewOption("Next →", choiceNext), + ) + + value := current + + err := huh.NewSelect[string](). + Title(title). + Description(desc). + Options(options...). + Value(&value). + Run() + if err != nil { + return "", navQuit + } + + switch value { + case choiceBack: + return "", navBack + case choiceNext: + return "", navNext + default: + return value, navNext + } +} diff --git a/cmd/init/ui.go b/cmd/init/ui.go index 602abef08cc260017c08aa517c04f0784d4fd0b3..209a99cd544f2aea2b06e8bb66501eaee861f57b 100644 --- a/cmd/init/ui.go +++ b/cmd/init/ui.go @@ -16,12 +16,13 @@ import ( "github.com/spf13/cobra" "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/deeplink" "git.secluded.site/lune/internal/ui" - "git.secluded.site/lune/internal/validate" ) const ( choiceBack = "back" + choiceNext = "next" choiceAdd = "add" choiceDone = "done" actionEdit = "edit" @@ -30,13 +31,14 @@ const ( ) var ( + errNameRequired = errors.New("name is required") 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") + errRefRequired = errors.New("reference is required") + errRefFormat = errors.New("expected UUID or lunatask:// deep link") errIndexOutRange = errors.New("index out of range") + errBack = errors.New("user went back") ) func configureUIPrefs(cfg *config.Config) error { @@ -57,7 +59,7 @@ func configureUIPrefs(cfg *config.Config) error { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err @@ -78,14 +80,14 @@ func resetConfig(cmd *cobra.Command, cfg *config.Config) error { err = huh.NewConfirm(). Title("Reset all configuration?"). - Description(fmt.Sprintf("This will delete %s\nYou'll need to run 'lune init' again.", path)). + Description(fmt.Sprintf("This will delete %s and restart the setup wizard.", path)). Affirmative("Reset"). Negative("Cancel"). Value(&confirm). Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return nil + return errQuit } return err @@ -97,8 +99,11 @@ func resetConfig(cmd *cobra.Command, cfg *config.Config) error { } fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset.")) + fmt.Fprintln(cmd.OutOrStdout()) *cfg = config.Config{} + + return errReset } return nil @@ -118,7 +123,7 @@ func runListSelect(title, description string, options []huh.Option[string]) (str if err := selector.Run(); err != nil { if errors.Is(err, huh.ErrUserAborted) { - return choiceBack, nil + return "", errQuit } return "", err @@ -154,23 +159,35 @@ func validateKeyFormat(input string) error { return nil } -func validateID(input string) error { +func validateReference(input string, supportsDeepLink bool) (string, error) { if input == "" { - return errIDRequired + return "", errRefRequired } - if err := validate.ID(input); err != nil { - return errIDFormat + if supportsDeepLink { + id, err := deeplink.ParseID(input) + if err != nil { + return "", errRefFormat + } + + return id, nil } - return nil + // Habits don't support deep links, only raw UUIDs + id, err := deeplink.ParseID(input) + if err != nil { + return "", errRefFormat + } + + return id, nil } type itemFormConfig struct { - itemType string - namePlaceholder string - keyPlaceholder string - keyValidator func(string) error + itemType string + namePlaceholder string + keyPlaceholder string + keyValidator func(string) error + supportsDeepLink bool } func titleCase(s string) string { @@ -215,7 +232,7 @@ func runActionSelect(title string, includeGoals bool) (itemAction, error) { Run() if err != nil { if errors.Is(err, huh.ErrUserAborted) { - return itemActionNone, nil + return itemActionNone, errQuit } return itemActionNone, err @@ -234,36 +251,116 @@ func runActionSelect(title string, includeGoals bool) (itemAction, error) { } func runItemForm(name, key, itemID *string, cfg itemFormConfig) error { - form := huh.NewForm( + var save bool + + requireNonEmpty := false + + for { + form := buildItemFormGroup(name, key, itemID, cfg, &requireNonEmpty, &save) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return errBack + } + + return err + } + + if !save { + return errBack + } + + // If any required field is empty, re-run with strict validation + if *name == "" || *key == "" || *itemID == "" { + requireNonEmpty = true + + continue + } + + // Normalise reference to UUID + parsedID, _ := validateReference(*itemID, cfg.supportsDeepLink) + *itemID = parsedID + + return nil + } +} + +func buildItemFormGroup( + name, key, itemID *string, + cfg itemFormConfig, + requireNonEmpty *bool, + save *bool, +) *huh.Form { + refDescription := "Settings → 'Copy " + titleCase(cfg.itemType) + " ID' (bottom left)." + if cfg.supportsDeepLink { + refDescription = "Paste UUID or lunatask:// deep link." + } + + return huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Name"). Description("Display name for this "+cfg.itemType+"."). Placeholder(cfg.namePlaceholder). Value(name). - Validate(huh.ValidateNotEmpty()), + Validate(makeNameValidator(requireNonEmpty)), huh.NewInput(). Title("Key"). Description("Short alias for CLI use (lowercase, no spaces)."). Placeholder(cfg.keyPlaceholder). Value(key). - Validate(cfg.keyValidator), + Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)), huh.NewInput(). - Title("ID"). - Description("Settings → 'Copy "+titleCase(cfg.itemType)+" ID' (bottom left)."). + Title("Reference"). + Description(refDescription). Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"). Value(itemID). - Validate(validateID), + Validate(makeRefValidator(requireNonEmpty, cfg.supportsDeepLink)), + huh.NewConfirm(). + Title("Save this "+cfg.itemType+"?"). + Affirmative("Save"). + Negative("Cancel"). + Value(save), ), ) +} - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return errUserAborted +func makeNameValidator(requireNonEmpty *bool) func(string) error { + return func(input string) error { + if *requireNonEmpty && input == "" { + return errNameRequired } - return err + return nil } +} - return nil +func makeKeyValidator(requireNonEmpty *bool, formatValidator func(string) error) func(string) error { + return func(input string) error { + if input == "" { + if *requireNonEmpty { + return errKeyRequired + } + + return nil + } + + return formatValidator(input) + } +} + +func makeRefValidator(requireNonEmpty *bool, supportsDeepLink bool) func(string) error { + return func(input string) error { + if input == "" { + if *requireNonEmpty { + return errRefRequired + } + + return nil + } + + _, err := validateReference(input, supportsDeepLink) + + return err + } } diff --git a/cmd/init/ui_test.go b/cmd/init/ui_test.go index 83aa1c6320dd9f45819d67f513e1827dd05dfc1c..2d7602efb62c18f91bfee53a6fd33f657c6e0b4c 100644 --- a/cmd/init/ui_test.go +++ b/cmd/init/ui_test.go @@ -62,48 +62,74 @@ func TestValidateKeyFormat_Invalid(t *testing.T) { } } -func TestValidateID_Valid(t *testing.T) { +func TestValidateReference_Valid(t *testing.T) { t.Parallel() + const ( + uuidLower = "123e4567-e89b-12d3-a456-426614174000" + uuidArea = "3bbf1923-64ae-4bcf-96a9-9bb86c799dab" + uuidGoal = "9d79e922-9ca8-4b8c-9aa5-dd98bb2492b2" + uuidNote = "e230ef3e-d9a5-4211-9dfb-515cc892d6e5" + ) + tests := []struct { - name string - input string + name string + input string + supportsDeepLink bool + wantID string }{ - {"valid UUID lowercase", "123e4567-e89b-12d3-a456-426614174000"}, - {"valid UUID uppercase", "123E4567-E89B-12D3-A456-426614174000"}, + {"valid UUID lowercase", uuidLower, false, uuidLower}, + {"valid UUID uppercase", "123E4567-E89B-12D3-A456-426614174000", false, uuidLower}, + {"deep link area", "lunatask://areas/" + uuidArea, true, uuidArea}, + {"deep link goal", "lunatask://goals/" + uuidGoal, true, uuidGoal}, + {"deep link note", "lunatask://notes/" + uuidNote, true, uuidNote}, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { t.Parallel() - if err := validateID(tc.input); err != nil { - t.Errorf("validateID(%q) = %v, want nil", tc.input, err) + id, err := validateReference(testCase.input, testCase.supportsDeepLink) + if err != nil { + t.Errorf("validateReference(%q, %v) = %v, want nil", + testCase.input, testCase.supportsDeepLink, err) + } + + if id != testCase.wantID { + t.Errorf("validateReference(%q, %v) = %q, want %q", + testCase.input, testCase.supportsDeepLink, id, testCase.wantID) } }) } } -func TestValidateID_Invalid(t *testing.T) { +func TestValidateReference_Invalid(t *testing.T) { t.Parallel() + const validUUID = "123e4567-e89b-12d3-a456-426614174000" + tests := []struct { - name string - input string - wantErr error + name string + input string + supportsDeepLink bool + wantErr error }{ - {"empty ID", "", errIDRequired}, - {"too short", "123e4567-e89b", errIDFormat}, - {"random string", "not-a-uuid", errIDFormat}, - {"wrong characters", "123e4567-e89b-12d3-a456-42661417zzzz", errIDFormat}, + {"empty reference", "", true, errRefRequired}, + {"too short", "123e4567-e89b", true, errRefFormat}, + {"random string", "not-a-uuid", true, errRefFormat}, + {"wrong characters", "123e4567-e89b-12d3-a456-42661417zzzz", false, errRefFormat}, + {"invalid scheme", "http://areas/" + validUUID, true, errRefFormat}, + {"invalid resource", "lunatask://invalid/" + validUUID, true, errRefFormat}, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + for _, testCase := range tests { + t.Run(testCase.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) + _, err := validateReference(testCase.input, testCase.supportsDeepLink) + if !errors.Is(err, testCase.wantErr) { + t.Errorf("validateReference(%q, %v) = %v, want %v", + testCase.input, testCase.supportsDeepLink, err, testCase.wantErr) } }) } diff --git a/cmd/note/delete.go b/cmd/note/delete.go index cc7bec485bfbd859d08301a35e04009d56e3b479..5a9d764e8a5cc719bf77ca60f683c97bddca7e24 100644 --- a/cmd/note/delete.go +++ b/cmd/note/delete.go @@ -18,13 +18,14 @@ var DeleteCmd = &cobra.Command{ Short: "Delete a note", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } force, _ := cmd.Flags().GetBool("force") if !force { - if !ui.Confirm(fmt.Sprintf("Delete note %s?", args[0])) { + if !ui.Confirm(fmt.Sprintf("Delete note %s?", id)) { fmt.Fprintln(cmd.OutOrStdout(), "Cancelled") return nil @@ -32,7 +33,7 @@ var DeleteCmd = &cobra.Command{ } // TODO: implement note deletion - fmt.Fprintf(cmd.OutOrStdout(), "Deleting note %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Deleting note %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/note/get.go b/cmd/note/get.go index e7390a87fb1634bf8d2a137c755af0af92701178..c14e1ce218e4adbc49fed87d372903f9f452c5d8 100644 --- a/cmd/note/get.go +++ b/cmd/note/get.go @@ -14,15 +14,16 @@ import ( // GetCmd retrieves a note by ID. Exported for potential use by shortcuts. var GetCmd = &cobra.Command{ Use: "get ID", - Short: "Get a note by ID", + Short: "Get a note by ID or reference", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } // TODO: implement note get - fmt.Fprintf(cmd.OutOrStdout(), "Getting note %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Getting note %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/note/update.go b/cmd/note/update.go index 01b1bf33fadd22f74b38b2b3514bed46602da3f1..8dff187a64e629c58de0d4631a5d7d9742088e58 100644 --- a/cmd/note/update.go +++ b/cmd/note/update.go @@ -18,12 +18,13 @@ var UpdateCmd = &cobra.Command{ Short: "Update a note", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } // TODO: implement note update - fmt.Fprintf(cmd.OutOrStdout(), "Updating note %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Updating note %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/person/delete.go b/cmd/person/delete.go index 1fcd6a49fdfe8b18026cb5668d3383b7c770ca53..ca56f3381541f9eb53f8e2a6b2ab3cf694a1e236 100644 --- a/cmd/person/delete.go +++ b/cmd/person/delete.go @@ -18,13 +18,14 @@ var DeleteCmd = &cobra.Command{ Short: "Delete a person", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } force, _ := cmd.Flags().GetBool("force") if !force { - if !ui.Confirm(fmt.Sprintf("Delete person %s?", args[0])) { + if !ui.Confirm(fmt.Sprintf("Delete person %s?", id)) { fmt.Fprintln(cmd.OutOrStdout(), "Cancelled") return nil @@ -32,7 +33,7 @@ var DeleteCmd = &cobra.Command{ } // TODO: implement person deletion - fmt.Fprintf(cmd.OutOrStdout(), "Deleting person %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Deleting person %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/person/get.go b/cmd/person/get.go index 24de858e95ddf8d8e1ba805566758569a856b3cd..08432af76b8ec0fe2d4991b053bb2e1f0c6f4da5 100644 --- a/cmd/person/get.go +++ b/cmd/person/get.go @@ -14,15 +14,16 @@ import ( // GetCmd retrieves a person by ID. Exported for potential use by shortcuts. var GetCmd = &cobra.Command{ Use: "get ID", - Short: "Get a person by ID", + Short: "Get a person by ID or reference", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } // TODO: implement person get - fmt.Fprintf(cmd.OutOrStdout(), "Getting person %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Getting person %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/person/timeline.go b/cmd/person/timeline.go index 0b6d0e92497734489bc1236de56fff8e6454102d..9bba9be47a0c823800067b97dcea5c7eb429e9ce 100644 --- a/cmd/person/timeline.go +++ b/cmd/person/timeline.go @@ -20,12 +20,13 @@ var TimelineCmd = &cobra.Command{ Use "-" as content flag value to read from stdin.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } // TODO: implement timeline note creation - fmt.Fprintf(cmd.OutOrStdout(), "Adding timeline note to person %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Adding timeline note to person %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/person/update.go b/cmd/person/update.go index 2bdb031cd4c4ee0413b0e9fdacf65ab6fe7758b8..de91ccff4414be14fe2f7a6514eef67e30b950d7 100644 --- a/cmd/person/update.go +++ b/cmd/person/update.go @@ -18,12 +18,13 @@ var UpdateCmd = &cobra.Command{ Short: "Update a person", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } // TODO: implement person update - fmt.Fprintf(cmd.OutOrStdout(), "Updating person %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Updating person %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/ping.go b/cmd/ping.go index f762eb416645271cdde12273ac90990187f88381..23c921248a7aa60f5448f9b0781c8336bfc5aea0 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -15,12 +15,12 @@ import ( var pingCmd = &cobra.Command{ Use: "ping", - Short: "Verify your API key is valid", + Short: "Verify your access token is valid", RunE: func(cmd *cobra.Command, _ []string) error { c, err := client.New() if err != nil { - if errors.Is(err, client.ErrNoAPIKey) { - fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("LUNATASK_API_KEY not set")) + if errors.Is(err, client.ErrNoToken) { + fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("No access token configured; run 'lune init'")) return err } diff --git a/cmd/root.go b/cmd/root.go index fb3ce7c3ecc61077cba1008c5a2b84a74e908f54..db2de9312abb7d1ad0c4838d84d920a76d3b41f5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,7 +30,7 @@ var rootCmd = &cobra.Command{ Long: `lune is a command-line interface for Lunatask, the encrypted all-in-one productivity app for tasks, habits, journaling, and more. -Set LUNATASK_API_KEY in your environment to authenticate.`, +Run 'lune init' to configure your access token and get started.`, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/task/delete.go b/cmd/task/delete.go index 0cd697c802b178a8c110cab3a9dc4ae711e67740..cfb4bd902cd9a0330859ce7b2ba98c9c6c481454 100644 --- a/cmd/task/delete.go +++ b/cmd/task/delete.go @@ -18,13 +18,14 @@ var DeleteCmd = &cobra.Command{ Short: "Delete a task", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } force, _ := cmd.Flags().GetBool("force") if !force { - if !ui.Confirm(fmt.Sprintf("Delete task %s?", args[0])) { + if !ui.Confirm(fmt.Sprintf("Delete task %s?", id)) { fmt.Fprintln(cmd.OutOrStdout(), "Cancelled") return nil @@ -32,7 +33,7 @@ var DeleteCmd = &cobra.Command{ } // TODO: implement task deletion - fmt.Fprintf(cmd.OutOrStdout(), "Deleting task %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Deleting task %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/task/get.go b/cmd/task/get.go index 376017d4657e2159f00756d69ec312250b77fc32..5bb1365c4c183cdf1d16a3c51a01266831b38eea 100644 --- a/cmd/task/get.go +++ b/cmd/task/get.go @@ -14,15 +14,16 @@ import ( // GetCmd retrieves a task by ID. Exported for potential use by shortcuts. var GetCmd = &cobra.Command{ Use: "get ID", - Short: "Get a task by ID", + Short: "Get a task by ID or reference", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } // TODO: implement task get - fmt.Fprintf(cmd.OutOrStdout(), "Getting task %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Getting task %s (not yet implemented)\n", id) return nil }, diff --git a/cmd/task/update.go b/cmd/task/update.go index f658b0fca1a615aa7a20cd7af4e68c9395e03bb1..216b48c329122c8f90406bbd9eaa0dc686ad4fe9 100644 --- a/cmd/task/update.go +++ b/cmd/task/update.go @@ -18,12 +18,13 @@ var UpdateCmd = &cobra.Command{ Short: "Update a task", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if err := validate.ID(args[0]); err != nil { + id, err := validate.Reference(args[0]) + if err != nil { return err } // TODO: implement task update - fmt.Fprintf(cmd.OutOrStdout(), "Updating task %s (not yet implemented)\n", args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Updating task %s (not yet implemented)\n", id) return nil }, diff --git a/internal/client/client.go b/internal/client/client.go index 6186967c90ff16e478f652384c70d925211a2d5c..0d0bc25a026055ce9604162e04350348a555b3ef 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -8,34 +8,26 @@ 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 - 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") +// ErrNoToken indicates no access token is available in the system keyring. +var ErrNoToken = errors.New("no access token found in system keyring; run 'lune init' to configure") -// New creates a Lunatask client, checking env var first, then system keyring. +// New creates a Lunatask client using the access token from system keyring. func New() (*lunatask.Client, error) { - 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, ErrNoToken } return nil, fmt.Errorf("accessing system keyring: %w", err) @@ -44,9 +36,9 @@ func New() (*lunatask.Client, error) { return lunatask.NewClient(token, lunatask.UserAgent("lune/"+version())), nil } -// GetAPIKey returns the API key from keyring. Returns empty string and nil error +// GetToken returns the access token from keyring. Returns empty string and nil error // if not found; returns error for keyring access problems. -func GetAPIKey() (string, error) { +func GetToken() (string, error) { token, err := keyring.Get(keyringService, keyringUser) if err != nil { if errors.Is(err, keyring.ErrNotFound) { @@ -59,8 +51,8 @@ func GetAPIKey() (string, error) { return token, nil } -// SetAPIKey stores the API key in the system keyring. -func SetAPIKey(token string) error { +// SetToken stores the access token in the system keyring. +func SetToken(token string) error { if err := keyring.Set(keyringService, keyringUser, token); err != nil { return fmt.Errorf("keyring set: %w", err) } @@ -68,8 +60,8 @@ func SetAPIKey(token string) error { return nil } -// DeleteAPIKey removes the API key from the system keyring. -func DeleteAPIKey() error { +// DeleteToken removes the access token from the system keyring. +func DeleteToken() error { if err := keyring.Delete(keyringService, keyringUser); err != nil { return fmt.Errorf("keyring delete: %w", err) } @@ -77,10 +69,10 @@ func DeleteAPIKey() error { return nil } -// HasKeyringKey checks if an API key is stored in the keyring. +// HasKeyringToken checks if an access token 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) { +func HasKeyringToken() (bool, error) { _, err := keyring.Get(keyringService, keyringUser) if err != nil { if errors.Is(err, keyring.ErrNotFound) { diff --git a/internal/deeplink/deeplink.go b/internal/deeplink/deeplink.go new file mode 100644 index 0000000000000000000000000000000000000000..c4284064cb6d15a1cbc922e0f32cf97305adcfbb --- /dev/null +++ b/internal/deeplink/deeplink.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package deeplink parses and builds Lunatask deep links. +package deeplink + +import ( + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// Resource represents a Lunatask resource type that supports deep linking. +type Resource string + +// Supported Lunatask resource types for deep linking. +const ( + Area Resource = "areas" + Goal Resource = "goals" + Task Resource = "tasks" + Note Resource = "notes" + Person Resource = "people" + Notebook Resource = "notebooks" +) + +const ( + scheme = "lunatask://" + deepLinkPartCount = 2 +) + +// ErrInvalidReference indicates the input is neither a valid UUID nor deep link. +var ErrInvalidReference = errors.New("invalid reference: expected UUID or lunatask:// deep link") + +// ErrUnsupportedResource indicates the deep link resource type is not recognised. +var ErrUnsupportedResource = errors.New("unsupported resource type in deep link") + +// ParseID extracts a UUID from either a raw UUID string or a Lunatask deep link. +// Accepts formats: +// - UUID: "3bbf1923-64ae-4bcf-96a9-9bb86c799dab" +// - Deep link: "lunatask://areas/3bbf1923-64ae-4bcf-96a9-9bb86c799dab" +func ParseID(input string) (string, error) { + input = strings.TrimSpace(input) + + // Try parsing as UUID first + if parsed, err := uuid.Parse(input); err == nil { + return parsed.String(), nil + } + + // Try parsing as deep link + if !strings.HasPrefix(input, scheme) { + return "", fmt.Errorf("%w: %s", ErrInvalidReference, input) + } + + path := strings.TrimPrefix(input, scheme) + parts := strings.Split(path, "/") + + if len(parts) != deepLinkPartCount { + return "", fmt.Errorf("%w: %s", ErrInvalidReference, input) + } + + resource := parts[0] + id := parts[1] + + // Validate resource type + switch Resource(resource) { + case Area, Goal, Task, Note, Person, Notebook: + // Valid resource + default: + return "", fmt.Errorf("%w: %s", ErrUnsupportedResource, resource) + } + + // Validate and normalise the UUID + parsed, err := uuid.Parse(id) + if err != nil { + return "", fmt.Errorf("%w: %s", ErrInvalidReference, input) + } + + return parsed.String(), nil +} + +// Build constructs a Lunatask deep link for the given resource and ID. +func Build(resource Resource, id string) (string, error) { + parsed, err := uuid.Parse(id) + if err != nil { + return "", fmt.Errorf("%w: %s", ErrInvalidReference, id) + } + + return fmt.Sprintf("%s%s/%s", scheme, resource, parsed.String()), nil +} diff --git a/internal/deeplink/deeplink_test.go b/internal/deeplink/deeplink_test.go new file mode 100644 index 0000000000000000000000000000000000000000..375da5a7fe3f196cec4218aa1c1511ca5c81d785 --- /dev/null +++ b/internal/deeplink/deeplink_test.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package deeplink_test + +import ( + "errors" + "testing" + + "git.secluded.site/lune/internal/deeplink" +) + +func TestParseID_Valid(t *testing.T) { + t.Parallel() + + const ( + uuidLower = "123e4567-e89b-12d3-a456-426614174000" + uuidArea = "3bbf1923-64ae-4bcf-96a9-9bb86c799dab" + uuidGoal = "9d79e922-9ca8-4b8c-9aa5-dd98bb2492b2" + ) + + tests := []struct { + name string + input string + wantID string + }{ + {"UUID lowercase", uuidLower, uuidLower}, + {"UUID uppercase", "123E4567-E89B-12D3-A456-426614174000", uuidLower}, + {"deep link areas", "lunatask://areas/" + uuidArea, uuidArea}, + {"deep link goals", "lunatask://goals/" + uuidGoal, uuidGoal}, + {"deep link tasks", "lunatask://tasks/" + uuidArea, uuidArea}, + {"deep link notes", "lunatask://notes/" + uuidArea, uuidArea}, + {"deep link people", "lunatask://people/" + uuidArea, uuidArea}, + {"deep link notebooks", "lunatask://notebooks/" + uuidArea, uuidArea}, + {"with whitespace", " " + uuidLower + " ", uuidLower}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + id, err := deeplink.ParseID(testCase.input) + if err != nil { + t.Errorf("ParseID(%q) error = %v, want nil", testCase.input, err) + } + + if id != testCase.wantID { + t.Errorf("ParseID(%q) = %q, want %q", testCase.input, id, testCase.wantID) + } + }) + } +} + +func TestParseID_Invalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr error + }{ + {"empty", "", deeplink.ErrInvalidReference}, + {"random text", "not-a-uuid", deeplink.ErrInvalidReference}, + {"invalid UUID", "123e4567-e89b-12d3-a456-42661417zzzz", deeplink.ErrInvalidReference}, + {"wrong scheme", "http://areas/123e4567-e89b-12d3-a456-426614174000", deeplink.ErrInvalidReference}, + {"invalid resource", "lunatask://habits/123e4567-e89b-12d3-a456-426614174000", deeplink.ErrUnsupportedResource}, + {"missing path", "lunatask://", deeplink.ErrInvalidReference}, + {"extra path", "lunatask://areas/foo/bar", deeplink.ErrInvalidReference}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + _, err := deeplink.ParseID(testCase.input) + if !errors.Is(err, testCase.wantErr) { + t.Errorf("ParseID(%q) error = %v, want %v", testCase.input, err, testCase.wantErr) + } + }) + } +} + +func TestBuild(t *testing.T) { + t.Parallel() + + const uuid = "3bbf1923-64ae-4bcf-96a9-9bb86c799dab" + + tests := []struct { + name string + resource deeplink.Resource + id string + want string + }{ + {"area", deeplink.Area, uuid, "lunatask://areas/" + uuid}, + {"goal", deeplink.Goal, uuid, "lunatask://goals/" + uuid}, + {"task", deeplink.Task, uuid, "lunatask://tasks/" + uuid}, + {"note", deeplink.Note, uuid, "lunatask://notes/" + uuid}, + {"person", deeplink.Person, uuid, "lunatask://people/" + uuid}, + {"notebook", deeplink.Notebook, uuid, "lunatask://notebooks/" + uuid}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got, err := deeplink.Build(testCase.resource, testCase.id) + if err != nil { + t.Errorf("Build(%v, %q) error = %v, want nil", + testCase.resource, testCase.id, err) + } + + if got != testCase.want { + t.Errorf("Build(%v, %q) = %q, want %q", + testCase.resource, testCase.id, got, testCase.want) + } + }) + } +} + +func TestBuild_InvalidID(t *testing.T) { + t.Parallel() + + _, err := deeplink.Build(deeplink.Area, "not-a-uuid") + if !errors.Is(err, deeplink.ErrInvalidReference) { + t.Errorf("Build(Area, \"not-a-uuid\") error = %v, want %v", err, deeplink.ErrInvalidReference) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 0cd3d4de3ce4aa78c52b26be5995939387ba0bb5..52658779a90c672a5c6ab1da82d222f43bad13e2 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -10,11 +10,16 @@ import ( "fmt" "github.com/google/uuid" + + "git.secluded.site/lune/internal/deeplink" ) // ErrInvalidID indicates an ID is not a valid UUID. var ErrInvalidID = errors.New("invalid ID: expected UUID format") +// ErrInvalidReference indicates the input is not a valid UUID or deep link. +var ErrInvalidReference = errors.New("invalid reference: expected UUID or lunatask:// deep link") + // ID validates that the given string is a valid Lunatask ID (UUID). func ID(id string) error { if _, err := uuid.Parse(id); err != nil { @@ -23,3 +28,16 @@ func ID(id string) error { return nil } + +// Reference parses a UUID or Lunatask deep link and returns the normalised UUID. +// Accepts formats: +// - UUID: "3bbf1923-64ae-4bcf-96a9-9bb86c799dab" +// - Deep link: "lunatask://areas/3bbf1923-64ae-4bcf-96a9-9bb86c799dab" +func Reference(input string) (string, error) { + id, err := deeplink.ParseID(input) + if err != nil { + return "", fmt.Errorf("%w: %s", ErrInvalidReference, input) + } + + return id, nil +}