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 remotes, _ := LoadRemotes("")
245
246 name, url, found := resolveTarget(flagRemote, remotes)
247 if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") {
248 return nil, false
249 }
250 if !found {
251 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
252 name, url, found = resolveTarget(envRemote, remotes)
253 }
254 }
255 if !found && remotes != nil && remotes.Active != "" {
256 if entry, ok := remotes.Remotes[remotes.Active]; ok {
257 name = remotes.Active
258 url = entry.URL
259 found = true
260 }
261 }
262 if !found {
263 return nil, false
264 }
265
266 return &ResolvedRemote{
267 Name: name,
268 URL: url,
269 Token: resolveTokenForTarget(flagToken, name, remotes),
270 InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
271 }, true
272}
273
274// resolveTarget checks if value is a saved remote name or an ad-hoc URL.
275func resolveTarget(value string, remotes *ClientConfig) (name, url string, found bool) {
276 if value == "" {
277 return "", "", false
278 }
279 if remotes != nil {
280 if entry, ok := remotes.Remotes[value]; ok {
281 return value, entry.URL, true
282 }
283 }
284 if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
285 return "", value, true
286 }
287 return "", "", false
288}
289
290// AddRemote adds a new remote to the remotes configuration.
291func AddRemote(name, url, token string, insecureTLS bool) error {
292 config, err := LoadRemotes("")
293 if err != nil {
294 return err
295 }
296
297 config.Remotes[name] = RemoteEntry{
298 URL: url,
299 Token: token,
300 InsecureTLS: insecureTLS,
301 }
302
303 return SaveRemotes("", config)
304}
305
306// RemoveRemote removes a remote from the remotes configuration.
307func RemoveRemote(name string) error {
308 config, err := LoadRemotes("")
309 if err != nil {
310 return err
311 }
312
313 delete(config.Remotes, name)
314
315 // Clear active if it was the removed remote
316 if config.Active == name {
317 config.Active = ""
318 }
319
320 return SaveRemotes("", config)
321}
322
323// SetActiveRemote sets the active remote.
324func SetActiveRemote(name string) error {
325 config, err := LoadRemotes("")
326 if err != nil {
327 return err
328 }
329
330 // Verify remote exists
331 if _, ok := config.Remotes[name]; !ok {
332 return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
333 }
334
335 config.Active = name
336
337 return SaveRemotes("", config)
338}
339
340// ClearRemoteToken removes the token and token_env fields for a named remote
341// from the remotes TOML config, and deletes the pass store entry if present.
342func ClearRemoteToken(name string) error {
343 // Delete from pass (best-effort, no error if pass unavailable)
344 _ = passDeleteToken(name)
345
346 // Clear TOML fields
347 config, err := LoadRemotes("")
348 if err != nil {
349 return err
350 }
351
352 entry, ok := config.Remotes[name]
353 if !ok {
354 return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
355 }
356
357 entry.Token = ""
358 entry.TokenEnv = ""
359 config.Remotes[name] = entry
360
361 return SaveRemotes("", config)
362}
363
364// ListRemotes returns all saved remotes.
365func ListRemotes() (map[string]RemoteEntry, string, error) {
366 config, err := LoadRemotes("")
367 if err != nil {
368 return nil, "", err
369 }
370
371 return config.Remotes, config.Active, nil
372}