token.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package config
  6
  7import (
  8	"context"
  9	"errors"
 10	"fmt"
 11	"io"
 12	"time"
 13
 14	"git.secluded.site/go-lunatask"
 15	"github.com/charmbracelet/huh"
 16	"github.com/spf13/cobra"
 17
 18	"git.secluded.site/lunatask-mcp-server/internal/client"
 19	"git.secluded.site/lunatask-mcp-server/internal/ui"
 20)
 21
 22const tokenValidationTimeout = 10 * time.Second
 23
 24// handleKeyringError prints keyring error messages and returns a wrapped error.
 25func handleKeyringError(out io.Writer, err error) error {
 26	_, _ = fmt.Fprintln(out, ui.Error.Render("Failed to access system keyring: "+err.Error()))
 27	_, _ = fmt.Fprintln(out, "Please resolve the keyring issue and try again.")
 28	_, _ = fmt.Fprintln(out)
 29	_, _ = fmt.Fprintln(out, "Alternative: set LUNATASK_ACCESS_TOKEN environment variable.")
 30
 31	return fmt.Errorf("keyring access failed: %w", err)
 32}
 33
 34//nolint:nestif // wizard flow with multiple paths
 35func configureAccessToken(cmd *cobra.Command) error {
 36	out := cmd.OutOrStdout()
 37
 38	// Check for environment variable first
 39	if client.HasEnvToken() {
 40		_, _ = fmt.Fprintln(out, ui.Success.Render("Access token found in LUNATASK_ACCESS_TOKEN environment variable."))
 41
 42		token, _, _ := client.GetToken()
 43		if err := validateWithSpinner(token); err != nil {
 44			_, _ = fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error()))
 45		} else {
 46			_, _ = fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
 47		}
 48
 49		var action string
 50
 51		err := huh.NewSelect[string]().
 52			Title("Environment variable is set").
 53			Description("The env var takes priority over keyring storage.").
 54			Options(
 55				huh.NewOption("Keep using environment variable", "keep"),
 56				huh.NewOption("Also store in keyring (for when env not set)", "store"),
 57			).
 58			Value(&action).
 59			Run()
 60		if err != nil {
 61			if errors.Is(err, huh.ErrUserAborted) {
 62				return errQuit
 63			}
 64
 65			return err
 66		}
 67
 68		if action == "keep" {
 69			return nil
 70		}
 71
 72		return promptForToken(out)
 73	}
 74
 75	hasToken, keyringErr := client.HasKeyringToken()
 76	if keyringErr != nil {
 77		return handleKeyringError(out, keyringErr)
 78	}
 79
 80	if hasToken {
 81		shouldPrompt, err := handleExistingToken(out)
 82		if err != nil {
 83			return err
 84		}
 85
 86		if !shouldPrompt {
 87			return nil
 88		}
 89	}
 90
 91	return promptForToken(out)
 92}
 93
 94// ensureAccessToken checks for a token and prompts for one if missing or invalid.
 95// Used at the start of reconfigure mode.
 96func ensureAccessToken(cmd *cobra.Command) error {
 97	out := cmd.OutOrStdout()
 98
 99	// Check environment variable first
100	if client.HasEnvToken() {
101		return nil
102	}
103
104	hasToken, keyringErr := client.HasKeyringToken()
105	if keyringErr != nil {
106		return handleKeyringError(out, keyringErr)
107	}
108
109	if !hasToken {
110		_, _ = fmt.Fprintln(out, ui.Warning.Render("No access token found."))
111		_, _ = fmt.Fprintln(out, "Set LUNATASK_ACCESS_TOKEN or store in system keyring.")
112		_, _ = fmt.Fprintln(out)
113
114		return promptForToken(out)
115	}
116
117	existingToken, _, err := client.GetToken()
118	if err != nil {
119		return fmt.Errorf("reading access token: %w", err)
120	}
121
122	if err := validateWithSpinner(existingToken); err != nil {
123		_, _ = fmt.Fprintln(out, ui.Warning.Render("Existing access token failed validation: "+err.Error()))
124
125		var replace bool
126
127		confirmErr := huh.NewConfirm().
128			Title("Would you like to provide a new access token?").
129			Affirmative("Yes").
130			Negative("No").
131			Value(&replace).
132			Run()
133		if confirmErr != nil {
134			if errors.Is(confirmErr, huh.ErrUserAborted) {
135				return errQuit
136			}
137
138			return confirmErr
139		}
140
141		if replace {
142			return promptForToken(out)
143		}
144	}
145
146	return nil
147}
148
149func handleExistingToken(out io.Writer) (bool, error) {
150	existingToken, _, err := client.GetToken()
151	if err != nil {
152		return false, fmt.Errorf("reading access token: %w", err)
153	}
154
155	_, _ = fmt.Fprintln(out, "Access token found in system keyring.")
156
157	if err := validateWithSpinner(existingToken); err != nil {
158		_, _ = fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error()))
159	} else {
160		_, _ = fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
161	}
162
163	var action string
164
165	err = huh.NewSelect[string]().
166		Title("Access token is already configured").
167		Options(
168			huh.NewOption("Keep existing token", "keep"),
169			huh.NewOption("Replace with new token", "replace"),
170			huh.NewOption("Delete from keyring", "delete"),
171		).
172		Value(&action).
173		Run()
174	if err != nil {
175		if errors.Is(err, huh.ErrUserAborted) {
176			return false, errQuit
177		}
178
179		return false, err
180	}
181
182	switch action {
183	case "keep":
184		return false, nil
185	case "delete":
186		if err := client.DeleteKeyringToken(); err != nil {
187			return false, fmt.Errorf("deleting access token: %w", err)
188		}
189
190		_, _ = fmt.Fprintln(out, ui.Success.Render("Access token removed from keyring."))
191
192		return false, nil
193	default:
194		return true, nil
195	}
196}
197
198func promptForToken(out io.Writer) error {
199	_, _ = fmt.Fprintln(out)
200	_, _ = fmt.Fprintln(out, "You can get your access token from:")
201	_, _ = fmt.Fprintln(out, "  Lunatask β†’ Settings β†’ Access Tokens")
202	_, _ = fmt.Fprintln(out)
203
204	var token string
205
206	for {
207		err := huh.NewInput().
208			Title("Lunatask Access Token").
209			Description("Paste your access token (it will be stored securely in your system keyring).").
210			EchoMode(huh.EchoModePassword).
211			Value(&token).
212			Validate(func(s string) error {
213				if s == "" {
214					return errTokenRequired
215				}
216
217				return nil
218			}).
219			Run()
220		if err != nil {
221			if errors.Is(err, huh.ErrUserAborted) {
222				return errQuit
223			}
224
225			return err
226		}
227
228		if err := validateWithSpinner(token); err != nil {
229			_, _ = fmt.Fprintln(out, ui.Error.Render("βœ— Authentication failed: "+err.Error()))
230			_, _ = fmt.Fprintln(out, "Please check your access token and try again.")
231			_, _ = fmt.Fprintln(out)
232
233			token = ""
234
235			continue
236		}
237
238		_, _ = fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
239
240		break
241	}
242
243	if err := client.SetKeyringToken(token); err != nil {
244		return fmt.Errorf("saving access token to keyring: %w", err)
245	}
246
247	_, _ = fmt.Fprintln(out, ui.Success.Render("Access token saved to system keyring."))
248
249	return nil
250}
251
252func validateWithSpinner(token string) error {
253	return ui.SpinVoid("Verifying access token…", func() error {
254		return validateTokenWithPing(token)
255	})
256}
257
258func validateTokenWithPing(token string) error {
259	c := lunatask.NewClient(token, lunatask.UserAgent("lunatask-mcp-server/config"))
260
261	ctx, cancel := context.WithTimeout(context.Background(), tokenValidationTimeout)
262	defer cancel()
263
264	_, err := c.Ping(ctx)
265
266	return err
267}