1package remote
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strconv"
8 "strings"
9
10 "github.com/pelletier/go-toml/v2"
11
12 "github.com/bnema/gordon/internal/domain"
13)
14
15// ClientConfig represents the client-mode configuration.
16type ClientConfig struct {
17 Remotes map[string]RemoteEntry `toml:"remotes"`
18 Active string `toml:"active"` // Active remote name
19}
20
21// RemoteEntry represents a saved remote in [remotes.*].
22type RemoteEntry struct {
23 URL string `toml:"url"`
24 Token string `toml:"token,omitempty"`
25 TokenEnv string `toml:"token_env,omitempty"`
26 InsecureTLS bool `toml:"insecure_tls,omitempty"`
27}
28
29// DefaultRemotesPath returns the default remotes config path.
30func DefaultRemotesPath() string {
31 configDir, err := os.UserConfigDir()
32 if err != nil {
33 configDir = os.Getenv("HOME")
34 }
35 return filepath.Join(configDir, "gordon", "remotes.toml")
36}
37
38// LoadRemotes loads the remotes configuration.
39func LoadRemotes(path string) (*ClientConfig, error) {
40 if path == "" {
41 path = DefaultRemotesPath()
42 }
43
44 data, err := os.ReadFile(path)
45 if err != nil {
46 if os.IsNotExist(err) {
47 config := &ClientConfig{
48 Remotes: make(map[string]RemoteEntry),
49 }
50 migrateClientConfig(config)
51 return config, nil
52 }
53 return nil, fmt.Errorf("failed to read remotes: %w", err)
54 }
55
56 var config ClientConfig
57 if err := toml.Unmarshal(data, &config); err != nil {
58 return nil, fmt.Errorf("failed to parse remotes: %w", err)
59 }
60
61 if config.Remotes == nil {
62 config.Remotes = make(map[string]RemoteEntry)
63 }
64
65 migrateClientConfig(&config)
66
67 return &config, nil
68}
69
70// migrateClientConfig reads the legacy gordon.toml [client] section and
71// creates a "default" remote entry if one doesn't already exist.
72func migrateClientConfig(config *ClientConfig) {
73 gordonPath := defaultGordonTomlPath()
74 data, err := os.ReadFile(gordonPath)
75 if err != nil {
76 return
77 }
78
79 var legacy struct {
80 Client struct {
81 Mode string `toml:"mode"`
82 Remote string `toml:"remote"`
83 Token string `toml:"token"`
84 TokenEnv string `toml:"token_env"`
85 InsecureTLS bool `toml:"insecure_tls"`
86 } `toml:"client"`
87 }
88 if err := toml.Unmarshal(data, &legacy); err != nil {
89 return
90 }
91
92 if legacy.Client.Mode != "remote" || legacy.Client.Remote == "" {
93 return
94 }
95
96 if _, exists := config.Remotes["default"]; exists {
97 return
98 }
99
100 fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n")
101
102 config.Remotes["default"] = RemoteEntry{
103 URL: legacy.Client.Remote,
104 Token: legacy.Client.Token,
105 TokenEnv: legacy.Client.TokenEnv,
106 InsecureTLS: legacy.Client.InsecureTLS,
107 }
108
109 if config.Active == "" {
110 config.Active = "default"
111 }
112
113 _ = SaveRemotes("", config)
114}
115
116func defaultGordonTomlPath() string {
117 configDir, err := os.UserConfigDir()
118 if err != nil {
119 configDir = os.Getenv("HOME")
120 }
121 return filepath.Join(configDir, "gordon", "gordon.toml")
122}
123
124// SaveRemotes saves the remotes configuration.
125func SaveRemotes(path string, config *ClientConfig) error {
126 if path == "" {
127 path = DefaultRemotesPath()
128 }
129
130 // Ensure directory exists
131 dir := filepath.Dir(path)
132 if err := os.MkdirAll(dir, 0700); err != nil {
133 return fmt.Errorf("failed to create config directory: %w", err)
134 }
135
136 data, err := toml.Marshal(config)
137 if err != nil {
138 return fmt.Errorf("failed to marshal remotes: %w", err)
139 }
140
141 if err := os.WriteFile(path, data, 0600); err != nil {
142 return fmt.Errorf("failed to write remotes: %w", err)
143 }
144
145 return nil
146}
147
148func resolveRemoteEntryToken(remote *RemoteEntry) string {
149 if remote == nil {
150 return ""
151 }
152 if remote.Token != "" {
153 return remote.Token
154 }
155 if remote.TokenEnv != "" {
156 return os.Getenv(remote.TokenEnv)
157 }
158 return ""
159}
160
161// ResolveTokenForRemote resolves the token for a named remote.
162// Precedence: TOML token field > token_env > pass store.
163func ResolveTokenForRemote(name string, entry RemoteEntry) string {
164 if token := resolveRemoteEntryToken(&entry); token != "" {
165 return token
166 }
167 if passAvailable() {
168 if token, err := passReadToken(name); err == nil && token != "" {
169 return token
170 }
171 }
172 return ""
173}
174
175// ResolvedRemote holds the fully resolved remote target.
176// Name is empty for ad-hoc URLs passed directly via flag or env.
177type ResolvedRemote struct {
178 Name string
179 URL string
180 Token string
181 InsecureTLS bool
182}
183
184// DisplayName returns the remote name, or the URL if unnamed (ad-hoc).
185func (r *ResolvedRemote) DisplayName() string {
186 if r.Name != "" {
187 return r.Name
188 }
189 return r.URL
190}
191
192// resolveTokenForTarget resolves the authentication token.
193// Precedence: flag > GORDON_TOKEN env > stored token for named remote.
194func resolveTokenForTarget(flagToken, name string, remotes *ClientConfig) string {
195 if flagToken != "" {
196 return flagToken
197 }
198 if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
199 return envToken
200 }
201 if name != "" && remotes != nil {
202 if entry, ok := remotes.Remotes[name]; ok {
203 return ResolveTokenForRemote(name, entry)
204 }
205 }
206 return ""
207}
208
209// resolveInsecureForTarget resolves InsecureTLS setting.
210// Precedence: flag > GORDON_INSECURE env > remote entry field.
211func resolveInsecureForTarget(flagInsecure bool, name string, remotes *ClientConfig) bool {
212 if flagInsecure {
213 return true
214 }
215 if env := os.Getenv("GORDON_INSECURE"); env != "" {
216 if v, err := strconv.ParseBool(env); err == nil && v {
217 return true
218 }
219 }
220 if name != "" && remotes != nil {
221 if entry, ok := remotes.Remotes[name]; ok {
222 return entry.InsecureTLS
223 }
224 }
225 return false
226}
227
228// Resolve resolves the remote target from flags, environment, and config.
229// Returns nil, false when no remote is configured (local mode).
230//
231// URL precedence: flag > GORDON_REMOTE env > active remote.
232// The flag and env values are treated as a saved remote name first;
233// if no match and the value starts with http:// or https://, used as ad-hoc URL.
234//
235// Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env).
236// InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field.
237func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) {
238 remotes, _ := LoadRemotes("")
239
240 name, url, found := resolveTarget(flagRemote, remotes)
241 if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") {
242 return nil, false
243 }
244 if !found {
245 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
246 name, url, found = resolveTarget(envRemote, remotes)
247 }
248 }
249 if !found && remotes != nil && remotes.Active != "" {
250 if entry, ok := remotes.Remotes[remotes.Active]; ok {
251 name = remotes.Active
252 url = entry.URL
253 found = true
254 }
255 }
256 if !found {
257 return nil, false
258 }
259
260 return &ResolvedRemote{
261 Name: name,
262 URL: url,
263 Token: resolveTokenForTarget(flagToken, name, remotes),
264 InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
265 }, true
266}
267
268// resolveTarget checks if value is a saved remote name or an ad-hoc URL.
269func resolveTarget(value string, remotes *ClientConfig) (name, url string, found bool) {
270 if value == "" {
271 return "", "", false
272 }
273 if remotes != nil {
274 if entry, ok := remotes.Remotes[value]; ok {
275 return value, entry.URL, true
276 }
277 }
278 if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
279 return "", value, true
280 }
281 return "", "", false
282}
283
284// AddRemote adds a new remote to the remotes configuration.
285func AddRemote(name, url, token string, insecureTLS bool) error {
286 config, err := LoadRemotes("")
287 if err != nil {
288 return err
289 }
290
291 config.Remotes[name] = RemoteEntry{
292 URL: url,
293 Token: token,
294 InsecureTLS: insecureTLS,
295 }
296
297 return SaveRemotes("", config)
298}
299
300// RemoveRemote removes a remote from the remotes configuration.
301func RemoveRemote(name string) error {
302 config, err := LoadRemotes("")
303 if err != nil {
304 return err
305 }
306
307 delete(config.Remotes, name)
308
309 // Clear active if it was the removed remote
310 if config.Active == name {
311 config.Active = ""
312 }
313
314 return SaveRemotes("", config)
315}
316
317// SetActiveRemote sets the active remote.
318func SetActiveRemote(name string) error {
319 config, err := LoadRemotes("")
320 if err != nil {
321 return err
322 }
323
324 // Verify remote exists
325 if _, ok := config.Remotes[name]; !ok {
326 return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
327 }
328
329 config.Active = name
330
331 return SaveRemotes("", config)
332}
333
334// ClearRemoteToken removes the token and token_env fields for a named remote
335// from the remotes TOML config, and deletes the pass store entry if present.
336func ClearRemoteToken(name string) error {
337 // Delete from pass (best-effort, no error if pass unavailable)
338 _ = passDeleteToken(name)
339
340 // Clear TOML fields
341 config, err := LoadRemotes("")
342 if err != nil {
343 return err
344 }
345
346 entry, ok := config.Remotes[name]
347 if !ok {
348 return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
349 }
350
351 entry.Token = ""
352 entry.TokenEnv = ""
353 config.Remotes[name] = entry
354
355 return SaveRemotes("", config)
356}
357
358// ListRemotes returns all saved remotes.
359func ListRemotes() (map[string]RemoteEntry, string, error) {
360 config, err := LoadRemotes("")
361 if err != nil {
362 return nil, "", err
363 }
364
365 return config.Remotes, config.Active, nil
366}