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 if _, err := fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n"); err != nil {
101 _, _ = os.Stderr.WriteString("Warning: failed to write remote migration notice\n")
102 }
103
104 config.Remotes["default"] = RemoteEntry{
105 URL: legacy.Client.Remote,
106 Token: legacy.Client.Token,
107 TokenEnv: legacy.Client.TokenEnv,
108 InsecureTLS: legacy.Client.InsecureTLS,
109 }
110
111 if config.Active == "" {
112 config.Active = "default"
113 }
114
115 if err := SaveRemotes("", config); err != nil {
116 if _, writeErr := fmt.Fprintf(os.Stderr, "Warning: failed to save migrated remotes: %v\n", err); writeErr != nil {
117 _, _ = os.Stderr.WriteString("Warning: failed to write remote migration warning\n")
118 }
119 }
120}
121
122func defaultGordonTomlPath() string {
123 configDir, err := os.UserConfigDir()
124 if err != nil {
125 configDir = os.Getenv("HOME")
126 }
127 return filepath.Join(configDir, "gordon", "gordon.toml")
128}
129
130// SaveRemotes saves the remotes configuration.
131func SaveRemotes(path string, config *ClientConfig) error {
132 if path == "" {
133 path = DefaultRemotesPath()
134 }
135
136 // Ensure directory exists
137 dir := filepath.Dir(path)
138 if err := os.MkdirAll(dir, 0700); err != nil {
139 return fmt.Errorf("failed to create config directory: %w", err)
140 }
141
142 data, err := toml.Marshal(config)
143 if err != nil {
144 return fmt.Errorf("failed to marshal remotes: %w", err)
145 }
146
147 if err := os.WriteFile(path, data, 0600); err != nil {
148 return fmt.Errorf("failed to write remotes: %w", err)
149 }
150
151 return nil
152}
153
154func resolveRemoteEntryToken(remote *RemoteEntry) string {
155 if remote == nil {
156 return ""
157 }
158 if remote.Token != "" {
159 return remote.Token
160 }
161 if remote.TokenEnv != "" {
162 return os.Getenv(remote.TokenEnv)
163 }
164 return ""
165}
166
167// ResolveTokenForRemote resolves the token for a named remote.
168// Precedence: TOML token field > token_env > pass store.
169func ResolveTokenForRemote(name string, entry RemoteEntry) string {
170 if token := resolveRemoteEntryToken(&entry); token != "" {
171 return token
172 }
173 if passAvailable() {
174 if token, err := passReadToken(name); err == nil && token != "" {
175 return token
176 }
177 }
178 return ""
179}
180
181// ResolvedRemote holds the fully resolved remote target.
182// Name is empty for ad-hoc URLs passed directly via flag or env.
183type ResolvedRemote struct {
184 Name string
185 URL string
186 Token string
187 InsecureTLS bool
188}
189
190// DisplayName returns the remote name, or the URL if unnamed (ad-hoc).
191func (r *ResolvedRemote) DisplayName() string {
192 if r.Name != "" {
193 return r.Name
194 }
195 return r.URL
196}
197
198// resolveTokenForTarget resolves the authentication token.
199// Precedence: flag > GORDON_TOKEN env > stored token for named remote.
200func resolveTokenForTarget(flagToken, name string, remotes *ClientConfig) string {
201 if flagToken != "" {
202 return flagToken
203 }
204 if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
205 return envToken
206 }
207 if name != "" && remotes != nil {
208 if entry, ok := remotes.Remotes[name]; ok {
209 return ResolveTokenForRemote(name, entry)
210 }
211 }
212 return ""
213}
214
215// resolveInsecureForTarget resolves InsecureTLS setting.
216// Precedence: flag > GORDON_INSECURE env > remote entry field.
217func resolveInsecureForTarget(flagInsecure bool, name string, remotes *ClientConfig) bool {
218 if flagInsecure {
219 return true
220 }
221 if env := os.Getenv("GORDON_INSECURE"); env != "" {
222 if v, err := strconv.ParseBool(env); err == nil && v {
223 return true
224 }
225 }
226 if name != "" && remotes != nil {
227 if entry, ok := remotes.Remotes[name]; ok {
228 return entry.InsecureTLS
229 }
230 }
231 return false
232}
233
234// Resolve resolves the remote target from flags, environment, and config.
235// Returns nil, false when no remote is configured (local mode).
236//
237// URL precedence: flag > GORDON_REMOTE env > active remote.
238// The flag and env values are treated as a saved remote name first;
239// if no match and the value starts with http:// or https://, used as ad-hoc URL.
240//
241// Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env).
242// InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field.
243func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) {
244 resolved, found, _ := ResolveStrict(flagRemote, flagToken, flagInsecure)
245 return resolved, found
246}
247
248// ResolveStrict resolves the remote target like Resolve, but returns an error
249// when an explicit remote selector is invalid instead of silently falling back
250// to local mode.
251func ResolveStrict(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool, error) {
252 remotes, err := LoadRemotes("")
253 if err != nil {
254 return nil, false, fmt.Errorf("loading remotes: %w", err)
255 }
256
257 name, url, found := resolveTarget(flagRemote, remotes)
258 if !found && flagRemote != "" {
259 return nil, false, fmt.Errorf("remote %q: %w", flagRemote, domain.ErrRemoteNotFound)
260 }
261 if !found {
262 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
263 name, url, found = resolveTarget(envRemote, remotes)
264 if !found {
265 return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE: %w", envRemote, domain.ErrRemoteNotFound)
266 }
267 }
268 }
269 if !found && remotes != nil && remotes.Active != "" {
270 if entry, ok := remotes.Remotes[remotes.Active]; ok {
271 name = remotes.Active
272 url = entry.URL
273 found = true
274 }
275 }
276 if !found {
277 return nil, false, nil
278 }
279
280 return &ResolvedRemote{
281 Name: name,
282 URL: url,
283 Token: resolveTokenForTarget(flagToken, name, remotes),
284 InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
285 }, true, nil
286}
287
288// resolveTarget checks if value is a saved remote name or an ad-hoc URL.
289func resolveTarget(value string, remotes *ClientConfig) (name, url string, found bool) {
290 if value == "" {
291 return "", "", false
292 }
293 if remotes != nil {
294 if entry, ok := remotes.Remotes[value]; ok {
295 return value, entry.URL, true
296 }
297 }
298 if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
299 return "", value, true
300 }
301 return "", "", false
302}
303
304// AddRemote adds a new remote to the remotes configuration.
305func AddRemote(name, url, token string, insecureTLS bool) error {
306 config, err := LoadRemotes("")
307 if err != nil {
308 return err
309 }
310
311 config.Remotes[name] = RemoteEntry{
312 URL: url,
313 Token: token,
314 InsecureTLS: insecureTLS,
315 }
316
317 return SaveRemotes("", config)
318}
319
320// RemoveRemote removes a remote from the remotes configuration.
321func RemoveRemote(name string) error {
322 config, err := LoadRemotes("")
323 if err != nil {
324 return err
325 }
326
327 delete(config.Remotes, name)
328
329 // Clear active if it was the removed remote
330 if config.Active == name {
331 config.Active = ""
332 }
333
334 return SaveRemotes("", config)
335}
336
337// SetActiveRemote sets the active remote.
338func SetActiveRemote(name string) error {
339 config, err := LoadRemotes("")
340 if err != nil {
341 return err
342 }
343
344 // Verify remote exists
345 if _, ok := config.Remotes[name]; !ok {
346 return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
347 }
348
349 config.Active = name
350
351 return SaveRemotes("", config)
352}
353
354// ClearRemoteToken removes the token and token_env fields for a named remote
355// from the remotes TOML config, and deletes the pass store entry if present.
356func ClearRemoteToken(name string) error {
357 // Delete from pass (best-effort, no error if pass unavailable)
358 _ = passDeleteToken(name)
359
360 // Clear TOML fields
361 config, err := LoadRemotes("")
362 if err != nil {
363 return err
364 }
365
366 entry, ok := config.Remotes[name]
367 if !ok {
368 return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
369 }
370
371 entry.Token = ""
372 entry.TokenEnv = ""
373 config.Remotes[name] = entry
374
375 return SaveRemotes("", config)
376}
377
378// ListRemotes returns all saved remotes.
379func ListRemotes() (map[string]RemoteEntry, string, error) {
380 config, err := LoadRemotes("")
381 if err != nil {
382 return nil, "", err
383 }
384
385 return config.Remotes, config.Active, nil
386}