feat(cli): add auth login command for remote password authentication
- commit
- db5f4493cc602318bbe6705b7667b3ab9b10c15a
- author
- bnema · · 2026-01-18 03:17 UTC
- parent
- 4a71618
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +208 -0
M internal/adapters/in/cli/auth.go +118 -0
| @@ -13,6 +13,8 @@ import ( | |||
| 13 | 13 | "github.com/spf13/viper" | |
| 14 | 14 | "golang.org/x/term" | |
| 15 | 15 | ||
| 16 | + | "gordon/internal/adapters/in/cli/remote" | |
| 17 | + | "gordon/internal/adapters/in/cli/ui/styles" | |
| 16 | 18 | "gordon/internal/adapters/out/secrets" | |
| 17 | 19 | "gordon/internal/adapters/out/tokenstore" | |
| 18 | 20 | "gordon/internal/app" | |
| @@ -32,6 +34,7 @@ func newAuthCmd() *cobra.Command { | |||
| 32 | 34 | cmd.AddCommand(newAuthTokenCmd()) | |
| 33 | 35 | cmd.AddCommand(newAuthPasswordCmd()) | |
| 34 | 36 | cmd.AddCommand(newAuthInternalCmd()) | |
| 37 | + | cmd.AddCommand(newAuthLoginCmd()) | |
| 35 | 38 | ||
| 36 | 39 | return cmd | |
| 37 | 40 | } | |
| @@ -197,6 +200,121 @@ available while Gordon is running.`, | |||
| 197 | 200 | } | |
| 198 | 201 | } | |
| 199 | 202 | ||
| 203 | + | // newAuthLoginCmd creates the login command for remote authentication. | |
| 204 | + | func newAuthLoginCmd() *cobra.Command { | |
| 205 | + | var ( | |
| 206 | + | remoteName string | |
| 207 | + | username string | |
| 208 | + | ) | |
| 209 | + | ||
| 210 | + | cmd := &cobra.Command{ | |
| 211 | + | Use: "login", | |
| 212 | + | Short: "Authenticate with a Gordon server", | |
| 213 | + | Long: `Authenticate with a Gordon server using password authentication. | |
| 214 | + | ||
| 215 | + | This command prompts for your username and password, authenticates with the | |
| 216 | + | remote server's /auth/password endpoint, and stores the returned token. | |
| 217 | + | ||
| 218 | + | The token is stored in your remote configuration and used for subsequent | |
| 219 | + | CLI operations. | |
| 220 | + | ||
| 221 | + | Note: This command only works with servers configured for password authentication. | |
| 222 | + | For servers using token-based auth, use 'gordon remote set-token' instead. | |
| 223 | + | ||
| 224 | + | Examples: | |
| 225 | + | gordon auth login Login using active remote | |
| 226 | + | gordon auth login --remote prod Login to specific remote | |
| 227 | + | gordon auth login --username admin Pre-fill username`, | |
| 228 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 229 | + | return runAuthLogin(remoteName, username) | |
| 230 | + | }, | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | |
| 234 | + | cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") | |
| 235 | + | ||
| 236 | + | return cmd | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // runAuthLogin authenticates with a remote Gordon server. | |
| 240 | + | func runAuthLogin(remoteName, username string) error { | |
| 241 | + | // Load remotes config | |
| 242 | + | config, err := remote.LoadRemotes("") | |
| 243 | + | if err != nil { | |
| 244 | + | return fmt.Errorf("failed to load remotes: %w", err) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // Determine which remote to use | |
| 248 | + | if remoteName == "" { | |
| 249 | + | remoteName = config.Active | |
| 250 | + | } | |
| 251 | + | if remoteName == "" { | |
| 252 | + | return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | remoteConfig, ok := config.Remotes[remoteName] | |
| 256 | + | if !ok { | |
| 257 | + | return fmt.Errorf("remote '%s' not found", remoteName) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // Prompt for username if not provided | |
| 261 | + | if username == "" { | |
| 262 | + | fmt.Print("Username: ") | |
| 263 | + | reader := bufio.NewReader(os.Stdin) | |
| 264 | + | input, err := reader.ReadString('\n') | |
| 265 | + | if err != nil { | |
| 266 | + | return fmt.Errorf("failed to read username: %w", err) | |
| 267 | + | } | |
| 268 | + | username = strings.TrimSpace(input) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | if username == "" { | |
| 272 | + | return fmt.Errorf("username cannot be empty") | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Prompt for password (hidden input) | |
| 276 | + | fmt.Print("Password: ") | |
| 277 | + | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |
| 278 | + | if err != nil { | |
| 279 | + | // Fallback for non-terminal input | |
| 280 | + | reader := bufio.NewReader(os.Stdin) | |
| 281 | + | password, readErr := reader.ReadString('\n') | |
| 282 | + | if readErr != nil { | |
| 283 | + | return fmt.Errorf("failed to read password: %w", readErr) | |
| 284 | + | } | |
| 285 | + | passwordBytes = []byte(strings.TrimSpace(password)) | |
| 286 | + | } | |
| 287 | + | fmt.Println() // newline after hidden password input | |
| 288 | + | ||
| 289 | + | if len(passwordBytes) == 0 { | |
| 290 | + | return fmt.Errorf("password cannot be empty") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // Create remote client | |
| 294 | + | client := remote.NewClient(remoteConfig.URL) | |
| 295 | + | ||
| 296 | + | // Authenticate | |
| 297 | + | ctx := context.Background() | |
| 298 | + | fmt.Printf("Authenticating with %s...\n", remoteName) | |
| 299 | + | ||
| 300 | + | resp, err := client.Authenticate(ctx, username, string(passwordBytes)) | |
| 301 | + | if err != nil { | |
| 302 | + | return fmt.Errorf("authentication failed: %w", err) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // Update remote config with new token | |
| 306 | + | if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil { | |
| 307 | + | return fmt.Errorf("failed to save token: %w", err) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | fmt.Println() | |
| 311 | + | fmt.Println(styles.RenderSuccess("Authentication successful!")) | |
| 312 | + | fmt.Printf("Token stored for remote '%s'\n", remoteName) | |
| 313 | + | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | |
| 314 | + | ||
| 315 | + | return nil | |
| 316 | + | } | |
| 317 | + | ||
| 200 | 318 | // runShowInternalAuth displays the internal registry credentials. | |
| 201 | 319 | func runShowInternalAuth() error { | |
| 202 | 320 | creds, err := app.GetInternalCredentials() | |
| @@ -13,6 +13,8 @@ import ( | |||||
| 13 | "github.com/spf13/viper" | 13 | "github.com/spf13/viper" | ||
| 14 | "golang.org/x/term" | 14 | "golang.org/x/term" | ||
| 15 | 15 | ||||
| 16 | + | "gordon/internal/adapters/in/cli/remote" | |||
| 17 | + | "gordon/internal/adapters/in/cli/ui/styles" | |||
| 16 | "gordon/internal/adapters/out/secrets" | 18 | "gordon/internal/adapters/out/secrets" | ||
| 17 | "gordon/internal/adapters/out/tokenstore" | 19 | "gordon/internal/adapters/out/tokenstore" | ||
| 18 | "gordon/internal/app" | 20 | "gordon/internal/app" | ||
| @@ -32,6 +34,7 @@ func newAuthCmd() *cobra.Command { | |||||
| 32 | cmd.AddCommand(newAuthTokenCmd()) | 34 | cmd.AddCommand(newAuthTokenCmd()) | ||
| 33 | cmd.AddCommand(newAuthPasswordCmd()) | 35 | cmd.AddCommand(newAuthPasswordCmd()) | ||
| 34 | cmd.AddCommand(newAuthInternalCmd()) | 36 | cmd.AddCommand(newAuthInternalCmd()) | ||
| 37 | + | cmd.AddCommand(newAuthLoginCmd()) | |||
| 35 | 38 | ||||
| 36 | return cmd | 39 | return cmd | ||
| 37 | } | 40 | } | ||
| @@ -197,6 +200,121 @@ available while Gordon is running.`, | |||||
| 197 | } | 200 | } | ||
| 198 | } | 201 | } | ||
| 199 | 202 | ||||
| 203 | + | // newAuthLoginCmd creates the login command for remote authentication. | |||
| 204 | + | func newAuthLoginCmd() *cobra.Command { | |||
| 205 | + | var ( | |||
| 206 | + | remoteName string | |||
| 207 | + | username string | |||
| 208 | + | ) | |||
| 209 | + | ||||
| 210 | + | cmd := &cobra.Command{ | |||
| 211 | + | Use: "login", | |||
| 212 | + | Short: "Authenticate with a Gordon server", | |||
| 213 | + | Long: `Authenticate with a Gordon server using password authentication. | |||
| 214 | + | ||||
| 215 | + | This command prompts for your username and password, authenticates with the | |||
| 216 | + | remote server's /auth/password endpoint, and stores the returned token. | |||
| 217 | + | ||||
| 218 | + | The token is stored in your remote configuration and used for subsequent | |||
| 219 | + | CLI operations. | |||
| 220 | + | ||||
| 221 | + | Note: This command only works with servers configured for password authentication. | |||
| 222 | + | For servers using token-based auth, use 'gordon remote set-token' instead. | |||
| 223 | + | ||||
| 224 | + | Examples: | |||
| 225 | + | gordon auth login Login using active remote | |||
| 226 | + | gordon auth login --remote prod Login to specific remote | |||
| 227 | + | gordon auth login --username admin Pre-fill username`, | |||
| 228 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 229 | + | return runAuthLogin(remoteName, username) | |||
| 230 | + | }, | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | |||
| 234 | + | cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication") | |||
| 235 | + | ||||
| 236 | + | return cmd | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // runAuthLogin authenticates with a remote Gordon server. | |||
| 240 | + | func runAuthLogin(remoteName, username string) error { | |||
| 241 | + | // Load remotes config | |||
| 242 | + | config, err := remote.LoadRemotes("") | |||
| 243 | + | if err != nil { | |||
| 244 | + | return fmt.Errorf("failed to load remotes: %w", err) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Determine which remote to use | |||
| 248 | + | if remoteName == "" { | |||
| 249 | + | remoteName = config.Active | |||
| 250 | + | } | |||
| 251 | + | if remoteName == "" { | |||
| 252 | + | return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'") | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | remoteConfig, ok := config.Remotes[remoteName] | |||
| 256 | + | if !ok { | |||
| 257 | + | return fmt.Errorf("remote '%s' not found", remoteName) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // Prompt for username if not provided | |||
| 261 | + | if username == "" { | |||
| 262 | + | fmt.Print("Username: ") | |||
| 263 | + | reader := bufio.NewReader(os.Stdin) | |||
| 264 | + | input, err := reader.ReadString('\n') | |||
| 265 | + | if err != nil { | |||
| 266 | + | return fmt.Errorf("failed to read username: %w", err) | |||
| 267 | + | } | |||
| 268 | + | username = strings.TrimSpace(input) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | if username == "" { | |||
| 272 | + | return fmt.Errorf("username cannot be empty") | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Prompt for password (hidden input) | |||
| 276 | + | fmt.Print("Password: ") | |||
| 277 | + | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |||
| 278 | + | if err != nil { | |||
| 279 | + | // Fallback for non-terminal input | |||
| 280 | + | reader := bufio.NewReader(os.Stdin) | |||
| 281 | + | password, readErr := reader.ReadString('\n') | |||
| 282 | + | if readErr != nil { | |||
| 283 | + | return fmt.Errorf("failed to read password: %w", readErr) | |||
| 284 | + | } | |||
| 285 | + | passwordBytes = []byte(strings.TrimSpace(password)) | |||
| 286 | + | } | |||
| 287 | + | fmt.Println() // newline after hidden password input | |||
| 288 | + | ||||
| 289 | + | if len(passwordBytes) == 0 { | |||
| 290 | + | return fmt.Errorf("password cannot be empty") | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // Create remote client | |||
| 294 | + | client := remote.NewClient(remoteConfig.URL) | |||
| 295 | + | ||||
| 296 | + | // Authenticate | |||
| 297 | + | ctx := context.Background() | |||
| 298 | + | fmt.Printf("Authenticating with %s...\n", remoteName) | |||
| 299 | + | ||||
| 300 | + | resp, err := client.Authenticate(ctx, username, string(passwordBytes)) | |||
| 301 | + | if err != nil { | |||
| 302 | + | return fmt.Errorf("authentication failed: %w", err) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // Update remote config with new token | |||
| 306 | + | if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil { | |||
| 307 | + | return fmt.Errorf("failed to save token: %w", err) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | fmt.Println() | |||
| 311 | + | fmt.Println(styles.RenderSuccess("Authentication successful!")) | |||
| 312 | + | fmt.Printf("Token stored for remote '%s'\n", remoteName) | |||
| 313 | + | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | |||
| 314 | + | ||||
| 315 | + | return nil | |||
| 316 | + | } | |||
| 317 | + | ||||
| 200 | // runShowInternalAuth displays the internal registry credentials. | 318 | // runShowInternalAuth displays the internal registry credentials. | ||
| 201 | func runShowInternalAuth() error { | 319 | func runShowInternalAuth() error { | ||
| 202 | creds, err := app.GetInternalCredentials() | 320 | creds, err := app.GetInternalCredentials() | ||
A internal/adapters/in/cli/remote/auth.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // PasswordRequest represents the request body for POST /auth/password. | |
| 13 | + | type PasswordRequest struct { | |
| 14 | + | Username string `json:"username"` | |
| 15 | + | Password string `json:"password"` | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // PasswordResponse represents the response from POST /auth/password. | |
| 19 | + | type PasswordResponse struct { | |
| 20 | + | Token string `json:"token"` | |
| 21 | + | ExpiresIn int `json:"expires_in"` | |
| 22 | + | IssuedAt string `json:"issued_at"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Authenticate calls POST /auth/password and returns a token. | |
| 26 | + | // This method does NOT require an existing token since it's used to obtain one. | |
| 27 | + | func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) { | |
| 28 | + | url := c.baseURL + "/auth/password" | |
| 29 | + | ||
| 30 | + | reqBody := PasswordRequest{ | |
| 31 | + | Username: username, | |
| 32 | + | Password: password, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | jsonBody, err := json.Marshal(reqBody) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, fmt.Errorf("failed to marshal request body: %w", err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) | |
| 41 | + | if err != nil { | |
| 42 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | req.Header.Set("Content-Type", "application/json") | |
| 46 | + | req.Header.Set("Accept", "application/json") | |
| 47 | + | ||
| 48 | + | resp, err := c.httpClient.Do(req) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, fmt.Errorf("failed to connect: %w", err) | |
| 51 | + | } | |
| 52 | + | defer resp.Body.Close() | |
| 53 | + | ||
| 54 | + | if resp.StatusCode >= 400 { | |
| 55 | + | body, _ := io.ReadAll(resp.Body) | |
| 56 | + | var errResp struct { | |
| 57 | + | Error string `json:"error"` | |
| 58 | + | } | |
| 59 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |
| 60 | + | return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |
| 61 | + | } | |
| 62 | + | return nil, fmt.Errorf("%s: %s", resp.Status, string(body)) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | var result PasswordResponse | |
| 66 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |
| 67 | + | return nil, fmt.Errorf("failed to decode response: %w", err) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | return &result, nil | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // UpdateRemoteToken updates the token for a named remote. | |
| 74 | + | func UpdateRemoteToken(name, token string) error { | |
| 75 | + | config, err := LoadRemotes("") | |
| 76 | + | if err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | remote, ok := config.Remotes[name] | |
| 81 | + | if !ok { | |
| 82 | + | return fmt.Errorf("remote '%s' not found", name) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | remote.Token = token | |
| 86 | + | remote.TokenEnv = "" // Clear token_env when setting token directly | |
| 87 | + | config.Remotes[name] = remote | |
| 88 | + | ||
| 89 | + | return SaveRemotes("", config) | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "net/http" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // PasswordRequest represents the request body for POST /auth/password. | |||
| 13 | + | type PasswordRequest struct { | |||
| 14 | + | Username string `json:"username"` | |||
| 15 | + | Password string `json:"password"` | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // PasswordResponse represents the response from POST /auth/password. | |||
| 19 | + | type PasswordResponse struct { | |||
| 20 | + | Token string `json:"token"` | |||
| 21 | + | ExpiresIn int `json:"expires_in"` | |||
| 22 | + | IssuedAt string `json:"issued_at"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Authenticate calls POST /auth/password and returns a token. | |||
| 26 | + | // This method does NOT require an existing token since it's used to obtain one. | |||
| 27 | + | func (c *Client) Authenticate(ctx context.Context, username, password string) (*PasswordResponse, error) { | |||
| 28 | + | url := c.baseURL + "/auth/password" | |||
| 29 | + | ||||
| 30 | + | reqBody := PasswordRequest{ | |||
| 31 | + | Username: username, | |||
| 32 | + | Password: password, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | jsonBody, err := json.Marshal(reqBody) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, fmt.Errorf("failed to marshal request body: %w", err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody)) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | req.Header.Set("Content-Type", "application/json") | |||
| 46 | + | req.Header.Set("Accept", "application/json") | |||
| 47 | + | ||||
| 48 | + | resp, err := c.httpClient.Do(req) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, fmt.Errorf("failed to connect: %w", err) | |||
| 51 | + | } | |||
| 52 | + | defer resp.Body.Close() | |||
| 53 | + | ||||
| 54 | + | if resp.StatusCode >= 400 { | |||
| 55 | + | body, _ := io.ReadAll(resp.Body) | |||
| 56 | + | var errResp struct { | |||
| 57 | + | Error string `json:"error"` | |||
| 58 | + | } | |||
| 59 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |||
| 60 | + | return nil, fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |||
| 61 | + | } | |||
| 62 | + | return nil, fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | var result PasswordResponse | |||
| 66 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |||
| 67 | + | return nil, fmt.Errorf("failed to decode response: %w", err) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | return &result, nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // UpdateRemoteToken updates the token for a named remote. | |||
| 74 | + | func UpdateRemoteToken(name, token string) error { | |||
| 75 | + | config, err := LoadRemotes("") | |||
| 76 | + | if err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | remote, ok := config.Remotes[name] | |||
| 81 | + | if !ok { | |||
| 82 | + | return fmt.Errorf("remote '%s' not found", name) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | remote.Token = token | |||
| 86 | + | remote.TokenEnv = "" // Clear token_env when setting token directly | |||
| 87 | + | config.Remotes[name] = remote | |||
| 88 | + | ||||
| 89 | + | return SaveRemotes("", config) | |||
| 90 | + | } | |||