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}