package cli import ( "fmt" "io" "sort" "strings" "github.com/bnema/gordon/internal/adapters/in/cli/remote" "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" "github.com/spf13/cobra" ) // newRemotesCmd creates the remotes command group. func newRemotesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "remotes", Short: "Manage saved remote Gordon instances", Long: `Manage saved remote Gordon instances. Remotes allow you to save frequently used remote Gordon instances and quickly switch between them. Configuration is stored in ~/.config/gordon/remotes.toml`, } cmd.AddCommand(newRemotesListCmd()) cmd.AddCommand(newRemotesAddCmd()) cmd.AddCommand(newRemotesRemoveCmd()) cmd.AddCommand(newRemotesUseCmd()) cmd.AddCommand(newRemotesSetTokenCmd()) return cmd } // newRemotesListCmd creates the remotes list command. func newRemotesListCmd() *cobra.Command { var jsonOut bool cmd := &cobra.Command{ Use: "list", Short: "List saved remotes", RunE: func(cmd *cobra.Command, args []string) error { return runRemotesList(cmd.OutOrStdout(), jsonOut) }, } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } func runRemotesList(out io.Writer, jsonOut bool) error { remotes, active, err := remote.ListRemotes() if err != nil { return fmt.Errorf("failed to list remotes: %w", err) } if len(remotes) == 0 { return remotesListEmpty(out, jsonOut) } names := sortedRemoteNames(remotes) if jsonOut { return remotesListJSON(out, remotes, names, active) } if err := cliWriteLine(out, cliRenderTitle("Saved Remotes")); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } rows := make([][]string, 0, len(names)) for _, name := range names { r := remotes[name] status := "" if name == active { status = styles.Theme.Success.Render("active") } tokenStatus := styles.Theme.Muted.Render("none") if token := remote.ResolveTokenForRemote(name, r); token != "" { tokenStatus = styles.Theme.Success.Render("set") } else if r.TokenEnv != "" { tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) } tlsStatus := "" if r.InsecureTLS { tlsStatus = styles.Theme.Warning.Render("insecure") } rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) } table := components.NewTable( components.WithColumns([]components.TableColumn{ {Title: "Name", Width: 15}, {Title: "URL", Width: 35}, {Title: "Token", Width: 15}, {Title: "TLS", Width: 10}, {Title: "Status", Width: 10}, }), components.WithRows(rows), ) _, err = fmt.Fprintln(out, table.View()) return err } func remotesListEmpty(out io.Writer, jsonOut bool) error { if jsonOut { return writeJSON(out, []map[string]any{}) } if err := cliWriteLine(out, cliRenderMuted("No remotes configured")); err != nil { return err } if err := cliWriteLine(out, ""); err != nil { return err } if err := cliWriteLine(out, "Add a remote with:"); err != nil { return err } return cliWriteLine(out, styles.Theme.Bold.Render(" gordon remotes add ")) } func sortedRemoteNames(remotes map[string]remote.RemoteEntry) []string { names := make([]string, 0, len(remotes)) for name := range remotes { names = append(names, name) } sort.Strings(names) return names } func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names []string, active string) error { payload := make([]map[string]any, 0, len(names)) for _, name := range names { r := remotes[name] payload = append(payload, map[string]any{ "name": name, "url": r.URL, "active": name == active, "insecure_tls": r.InsecureTLS, }) } return writeJSON(out, payload) } // newRemotesAddCmd creates the remotes add command. func newRemotesAddCmd() *cobra.Command { var tokenFile string var tokenEnv string var insecureTLS bool var jsonOut bool cmd := &cobra.Command{ Use: "add ", Short: "Add a new remote", Long: `Add a new saved remote. Examples: gordon remotes add prod https://gordon.mydomain.com gordon remotes add prod https://gordon.mydomain.com --token-file ./token gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN gordon remotes add dev https://dev.internal --insecure`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] url := normalizeURL(args[1]) var finalToken string if tokenFile != "" { var err error finalToken, err = readProtectedSecretFile(tokenFile) if err != nil { return fmt.Errorf("read token: %w", err) } } if tokenEnv != "" { // Store token_env reference instead of actual token if err := addRemoteWithEnv(name, url, tokenEnv, insecureTLS); err != nil { return fmt.Errorf("failed to add remote: %w", err) } } else { if err := remote.AddRemote(name, url, finalToken, insecureTLS); err != nil { return fmt.Errorf("failed to add remote: %w", err) } } if jsonOut { return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "url": url, "added": true}) } if err := cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url))); err != nil { return err } if tokenFile == "" && tokenEnv == "" { return cliWriteLine(cmd.OutOrStdout(), styles.Theme.Muted.Render("Tip: Add a token with --token-file or --token-env for authenticated access")) } return nil }, } cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token") cmd.Flags().BoolVar(&insecureTLS, "insecure", false, "Skip TLS certificate verification") cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") return cmd } // addRemoteWithEnv adds a remote with a token environment variable reference. func addRemoteWithEnv(name, url, tokenEnv string, insecureTLS bool) error { config, err := remote.LoadRemotes("") if err != nil { return err } config.Remotes[name] = remote.RemoteEntry{ URL: url, TokenEnv: tokenEnv, InsecureTLS: insecureTLS, } return remote.SaveRemotes("", config) } // newRemotesRemoveCmd creates the remotes remove command. func newRemotesRemoveCmd() *cobra.Command { var force bool cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a remote", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] // Check if remote exists remotes, active, err := remote.ListRemotes() if err != nil { return fmt.Errorf("failed to list remotes: %w", err) } if _, exists := remotes[name]; !exists { fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) return nil } // Confirm unless --force if !force { message := fmt.Sprintf("Remove remote '%s'?", name) if name == active { message = fmt.Sprintf("Remove remote '%s'? (currently active)", name) } confirmed, err := components.RunConfirm(message) if err != nil { return err } if !confirmed { fmt.Println(styles.Theme.Muted.Render("Cancelled")) return nil } } if err := remote.RemoveRemote(name); err != nil { return fmt.Errorf("failed to remove remote: %w", err) } fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote removed: %s", name))) return nil }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation") return cmd } // newRemotesUseCmd creates the remotes use command. func newRemotesUseCmd() *cobra.Command { return &cobra.Command{ Use: "use ", Short: "Set the active remote", Long: `Set a remote as the active default. When a remote is active, it will be used automatically for remote commands without needing to specify --remote. Examples: gordon remotes use prod gordon routes list # Uses prod remote automatically`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] if err := remote.SetActiveRemote(name); err != nil { if err.Error() == fmt.Sprintf("remote '%s' not found", name) { fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) fmt.Println() fmt.Println("Available remotes:") remotes, _, _ := remote.ListRemotes() for remoteName := range remotes { fmt.Printf(" %s\n", remoteName) } return nil } return fmt.Errorf("failed to set active remote: %w", err) } fmt.Println(styles.RenderSuccess(fmt.Sprintf("Active remote set to '%s'", name))) return nil }, } } // newRemotesSetTokenCmd creates the remotes set-token command. func newRemotesSetTokenCmd() *cobra.Command { var tokenFile string var jsonOut bool cmd := &cobra.Command{ Use: "set-token --token-file ", Short: "Set the token for a remote", Long: `Set or update the authentication token for a saved remote. This is useful when: - The server uses token-based authentication (not password auth) - You have a pre-generated token from 'gordon auth token generate' - You want to update an expired token For servers with password authentication, use 'gordon auth login' instead. Examples: gordon remotes set-token prod --token-file ./token`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] token, err := readProtectedSecretFile(tokenFile) if err != nil { return fmt.Errorf("read token: %w", err) } // Verify remote exists remotes, _, err := remote.ListRemotes() if err != nil { return fmt.Errorf("failed to list remotes: %w", err) } if _, exists := remotes[name]; !exists { if jsonOut { return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": false, "error": "remote not found"}) } return cliWriteLine(cmd.OutOrStdout(), styles.RenderError(fmt.Sprintf("Remote '%s' not found", name))) } if err := remote.UpdateRemoteToken(name, token); err != nil { return fmt.Errorf("failed to update token: %w", err) } if jsonOut { return writeJSON(cmd.OutOrStdout(), map[string]any{"name": name, "updated": true}) } return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Token updated for remote '%s'", name))) }, } cmd.Flags().StringVar(&tokenFile, "token-file", "", "Read authentication token from a mode 0600 file") cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") _ = cmd.MarkFlagRequired("token-file") return cmd } // normalizeURL ensures the URL has a protocol scheme, defaulting to https. func normalizeURL(url string) string { if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { return url } return "https://" + url }