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