Detailed changes
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,160 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+ }
+}
@@ -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
+ }
}
@@ -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)
}
})
}
@@ -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
},
@@ -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
},
@@ -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
},
@@ -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
},
@@ -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
},
@@ -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
},
@@ -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
},
@@ -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
}
@@ -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,
}
@@ -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
},
@@ -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
},
@@ -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
},
@@ -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) {
@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,128 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+ }
+}
@@ -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
+}