diff --git a/cmd/common/fetchService.go b/cmd/common/fetchService.go index 59454cd..dbc567c 100644 --- a/cmd/common/fetchService.go +++ b/cmd/common/fetchService.go @@ -9,9 +9,13 @@ import ( "fmt" "log" "os" + "path/filepath" "sort" "strings" + "github.com/eiannone/keyboard" + "github.com/spf13/viper" + "github.com/atotto/clipboard" "github.com/pterm/pterm" @@ -33,7 +37,9 @@ type Config struct { } type Environment struct { - Token string `yaml:"token"` + Endpoint string `yaml:"endpoint"` + Proxy string `yaml:"proxy"` + Token string `yaml:"token"` } // FetchService handles the execution of gRPC commands for all services @@ -63,18 +69,65 @@ func FetchService(serviceName string, verb string, resourceName string, options } func loadConfig() (*Config, error) { - configPath := fmt.Sprintf("%s/.cfctl/config.yaml", os.Getenv("HOME")) - data, err := os.ReadFile(configPath) + home, err := os.UserHomeDir() if err != nil { - return nil, fmt.Errorf("could not read config file: %w", err) + return nil, fmt.Errorf("failed to get home directory: %v", err) + } + + // Load main config + mainV := viper.New() + mainConfigPath := filepath.Join(home, ".cfctl", "config.yaml") + mainV.SetConfigFile(mainConfigPath) + if err := mainV.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %v", err) + } + + currentEnv := mainV.GetString("environment") + if currentEnv == "" { + return nil, fmt.Errorf("no environment set in config") + } + + // Try to get environment config from main config first + var envConfig *Environment + if mainEnvConfig := mainV.Sub(fmt.Sprintf("environments.%s", currentEnv)); mainEnvConfig != nil { + envConfig = &Environment{ + Endpoint: mainEnvConfig.GetString("endpoint"), + Token: mainEnvConfig.GetString("token"), + Proxy: mainEnvConfig.GetString("proxy"), + } + } + + // If not found in main config or token is empty, try cache config + if envConfig == nil || envConfig.Token == "" { + cacheV := viper.New() + cacheConfigPath := filepath.Join(home, ".cfctl", "cache", "config.yaml") + cacheV.SetConfigFile(cacheConfigPath) + if err := cacheV.ReadInConfig(); err == nil { + if cacheEnvConfig := cacheV.Sub(fmt.Sprintf("environments.%s", currentEnv)); cacheEnvConfig != nil { + if envConfig == nil { + envConfig = &Environment{ + Endpoint: cacheEnvConfig.GetString("endpoint"), + Token: cacheEnvConfig.GetString("token"), + Proxy: cacheEnvConfig.GetString("proxy"), + } + } else if envConfig.Token == "" { + envConfig.Token = cacheEnvConfig.GetString("token") + } + } + } } - var config Config - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("could not unmarshal config: %w", err) + if envConfig == nil { + return nil, fmt.Errorf("environment '%s' not found in config files", currentEnv) } - return &config, nil + // Convert Environment to Config + return &Config{ + Environment: currentEnv, + Environments: map[string]Environment{ + currentEnv: *envConfig, + }, + }, nil } func fetchJSONResponse(config *Config, serviceName string, verb string, resourceName string, options *FetchOptions) ([]byte, error) { @@ -94,6 +147,11 @@ func fetchJSONResponse(config *Config, serviceName string, verb string, resource creds := credentials.NewTLS(tlsConfig) opts = append(opts, grpc.WithTransportCredentials(creds)) + opts = append(opts, grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(10*1024*1024), // 10MB + grpc.MaxCallSendMsgSize(10*1024*1024), // 10MB + )) + // Establish the connection conn, err := grpc.Dial(hostPort, opts...) if err != nil { @@ -267,10 +325,17 @@ func printData(data map[string]interface{}, options *FetchOptions) { func printTable(data map[string]interface{}) string { if results, ok := data["results"].([]interface{}); ok { - pageSize := 5 + pageSize := 10 currentPage := 0 - totalItems := len(results) - totalPages := (totalItems + pageSize - 1) / pageSize + searchTerm := "" + filteredResults := results + + // Initialize keyboard + if err := keyboard.Open(); err != nil { + fmt.Println("Failed to initialize keyboard:", err) + return "" + } + defer keyboard.Close() // Extract headers headers := []string{} @@ -284,6 +349,15 @@ func printTable(data map[string]interface{}) string { } for { + if searchTerm != "" { + filteredResults = filterResults(results, searchTerm) + } else { + filteredResults = results + } + + totalItems := len(filteredResults) + totalPages := (totalItems + pageSize - 1) / pageSize + tableData := pterm.TableData{headers} // Calculate current page items @@ -293,6 +367,13 @@ func printTable(data map[string]interface{}) string { endIdx = totalItems } + // Clear screen + fmt.Print("\033[H\033[2J") + + if searchTerm != "" { + fmt.Printf("Search: %s (Found: %d items)\n", searchTerm, totalItems) + } + // Add rows for current page for _, result := range results[startIdx:endIdx] { if row, ok := result.(map[string]interface{}); ok { @@ -310,31 +391,85 @@ func printTable(data map[string]interface{}) string { // Print table pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() - // Print pagination info and controls fmt.Printf("\nPage %d of %d (Total items: %d)\n", currentPage+1, totalPages, totalItems) - fmt.Println("Navigation: [p]revious page, [n]ext page, [q]uit") + fmt.Println("Navigation: [p]revious page, [n]ext page, [/]search, [c]lear search, [q]uit") - // Get user input - var input string - fmt.Scanln(&input) + // Get keyboard input + char, _, err := keyboard.GetKey() + if err != nil { + fmt.Println("Error reading keyboard input:", err) + return "" + } - switch strings.ToLower(input) { - case "n": + switch char { + case 'n', 'N': if currentPage < totalPages-1 { currentPage++ + } else { + currentPage = 0 } - case "p": + case 'p', 'P': if currentPage > 0 { currentPage-- + } else { + currentPage = totalPages - 1 } - case "q": + case 'q', 'Q': return "" + case 'c', 'C': + searchTerm = "" + currentPage = 0 + case '/': + fmt.Print("\nEnter search term: ") + keyboard.Close() + var input string + fmt.Scanln(&input) + searchTerm = input + currentPage = 0 + keyboard.Open() } } + } else { + // 단일 객체인 경우 (get 명령어) + headers := make([]string, 0) + for key := range data { + headers = append(headers, key) + } + sort.Strings(headers) + + tableData := pterm.TableData{ + {"Field", "Value"}, + } + + for _, header := range headers { + value := formatTableValue(data[header]) + tableData = append(tableData, []string{header, value}) + } + + pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() } return "" } +func filterResults(results []interface{}, searchTerm string) []interface{} { + var filtered []interface{} + searchTerm = strings.ToLower(searchTerm) + + for _, result := range results { + if row, ok := result.(map[string]interface{}); ok { + // 모든 필드에서 검색 + for _, value := range row { + strValue := strings.ToLower(fmt.Sprintf("%v", value)) + if strings.Contains(strValue, searchTerm) { + filtered = append(filtered, result) + break + } + } + } + } + return filtered +} + func formatTableValue(val interface{}) string { switch v := val.(type) { case nil: @@ -369,39 +504,49 @@ func formatTableValue(val interface{}) string { } func printCSV(data map[string]interface{}) string { - var buf bytes.Buffer + // CSV writer 생성 + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + if results, ok := data["results"].([]interface{}); ok { - writer := csv.NewWriter(&buf) - var headers []string + if len(results) == 0 { + return "" + } + + headers := make([]string, 0) + if firstRow, ok := results[0].(map[string]interface{}); ok { + for key := range firstRow { + headers = append(headers, key) + } + sort.Strings(headers) + writer.Write(headers) + } - // Extract headers for _, result := range results { if row, ok := result.(map[string]interface{}); ok { - if headers == nil { - for key := range row { - headers = append(headers, key) - } - writer.Write(headers) + rowData := make([]string, len(headers)) + for i, header := range headers { + rowData[i] = formatTableValue(row[header]) } - - // Extract row values - var rowValues []string - for _, key := range headers { - if val, ok := row[key]; ok { - rowValues = append(rowValues, formatCSVValue(val)) - } else { - rowValues = append(rowValues, "") - } - } - writer.Write(rowValues) + writer.Write(rowData) } } + } else { + headers := []string{"Field", "Value"} + writer.Write(headers) + + fields := make([]string, 0) + for field := range data { + fields = append(fields, field) + } + sort.Strings(fields) - writer.Flush() - output := buf.String() - fmt.Print(output) // Print to console - return output + for _, field := range fields { + row := []string{field, formatTableValue(data[field])} + writer.Write(row) + } } + return "" } diff --git a/cmd/common/fetchVerb.go b/cmd/common/fetchVerb.go index f047ddd..687ce6b 100644 --- a/cmd/common/fetchVerb.go +++ b/cmd/common/fetchVerb.go @@ -146,58 +146,78 @@ func watchResource(serviceName, verb, resource string, options *FetchOptions) er sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt) - // Map to store seen results - seenResults := make(map[string]bool) - - // Create a copy of options for initial fetch - initialOptions := *options - - // Fetch and display initial data - initialData, err := FetchService(serviceName, verb, resource, &initialOptions) + seenItems := make(map[string]bool) + + initialData, err := FetchService(serviceName, verb, resource, &FetchOptions{ + Parameters: options.Parameters, + JSONParameter: options.JSONParameter, + FileParameter: options.FileParameter, + APIVersion: options.APIVersion, + OutputFormat: "", + CopyToClipboard: false, + }) if err != nil { return err } - // Process initial results if results, ok := initialData["results"].([]interface{}); ok { + var recentItems []map[string]interface{} + for _, item := range results { if m, ok := item.(map[string]interface{}); ok { identifier := generateIdentifier(m) - seenResults[identifier] = true + seenItems[identifier] = true + + recentItems = append(recentItems, m) + if len(recentItems) > 20 { + recentItems = recentItems[1:] + } } } - } - fmt.Printf("\nWatching for changes... (Ctrl+C to quit)\n") + if len(recentItems) > 0 { + fmt.Printf("Recent items:\n") + printNewItems(recentItems) + } + } - // Create options for subsequent fetches without output - watchOptions := *options - watchOptions.OutputFormat = "" + fmt.Printf("\nWatching for changes... (Ctrl+C to quit)\n\n") for { select { case <-ticker.C: - newData, err := FetchService(serviceName, verb, resource, &watchOptions) + newData, err := FetchService(serviceName, verb, resource, &FetchOptions{ + Parameters: options.Parameters, + JSONParameter: options.JSONParameter, + FileParameter: options.FileParameter, + APIVersion: options.APIVersion, + OutputFormat: "", + CopyToClipboard: false, + }) if err != nil { continue } + var newItems []map[string]interface{} if results, ok := newData["results"].([]interface{}); ok { - newItems := []map[string]interface{}{} - for _, item := range results { if m, ok := item.(map[string]interface{}); ok { identifier := generateIdentifier(m) - if !seenResults[identifier] { - seenResults[identifier] = true + if !seenItems[identifier] { newItems = append(newItems, m) + seenItems[identifier] = true } } } + } - if len(newItems) > 0 { - printNewItems(newItems) - } + if len(newItems) > 0 { + fmt.Printf("Found %d new items at %s:\n", + len(newItems), + time.Now().Format("2006-01-02 15:04:05")) + + printNewItems(newItems) + fmt.Println() } case <-sigChan: @@ -207,8 +227,11 @@ func watchResource(serviceName, verb, resource string, options *FetchOptions) er } } -// generateIdentifier creates a unique identifier for an item based on its contents func generateIdentifier(item map[string]interface{}) string { + if id, ok := item["job_task_id"]; ok { + return fmt.Sprintf("%v", id) + } + var keys []string for k := range item { keys = append(keys, k) @@ -222,23 +245,20 @@ func generateIdentifier(item map[string]interface{}) string { return strings.Join(parts, ",") } -// printNewItems displays new items in table format func printNewItems(items []map[string]interface{}) { if len(items) == 0 { return } - // Prepare table data tableData := pterm.TableData{} - // Extract headers from first item headers := make([]string, 0) for key := range items[0] { headers = append(headers, key) } sort.Strings(headers) + tableData = append(tableData, headers) - // Convert each item to a table row for _, item := range items { row := make([]string, len(headers)) for i, header := range headers { @@ -249,6 +269,5 @@ func printNewItems(items []map[string]interface{}) { tableData = append(tableData, row) } - // Render the table - pterm.DefaultTable.WithData(tableData).Render() + pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() } diff --git a/cmd/other/apiResources.go b/cmd/other/apiResources.go index 0394e03..86c3d79 100644 --- a/cmd/other/apiResources.go +++ b/cmd/other/apiResources.go @@ -111,12 +111,17 @@ var ApiResourcesCmd = &cobra.Command{ } } - // Continue with the rest of the code... endpoint := envConfig.GetString("endpoint") - proxy := envConfig.GetBool("proxy") - if !proxy || !strings.Contains(endpoint, "identity") { - log.Fatalf("Endpoint for environment '%s' is not valid for fetching resources.", currentEnv) + if !strings.Contains(endpoint, "identity") { + parts := strings.Split(endpoint, "://") + if len(parts) == 2 { + hostParts := strings.Split(parts[1], ".") + if len(hostParts) >= 4 { + env := hostParts[2] // dev or stg + endpoint = fmt.Sprintf("grpc+ssl://identity.api.%s.spaceone.dev:443", env) + } + } } // Fetch endpointsMap dynamically diff --git a/cmd/other/config.go b/cmd/other/config.go index cc97db4..930155b 100644 --- a/cmd/other/config.go +++ b/cmd/other/config.go @@ -73,6 +73,7 @@ var configInitURLCmd = &cobra.Command{ return } + // Initialize the environment if appFlag { envName = fmt.Sprintf("%s-app", envName) updateConfig(envName, urlStr, "app") @@ -80,6 +81,29 @@ var configInitURLCmd = &cobra.Command{ envName = fmt.Sprintf("%s-user", envName) updateConfig(envName, urlStr, "user") } + + // Update the current environment in the main config + configDir := GetConfigDir() + mainConfigPath := filepath.Join(configDir, "config.yaml") + mainV := viper.New() + mainV.SetConfigFile(mainConfigPath) + + // Read existing config or create new one + if err := mainV.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + pterm.Error.Printf("Error reading config file: %v\n", err) + return + } + } + + // Set the new environment as current + mainV.Set("environment", envName) + if err := mainV.WriteConfig(); err != nil { + pterm.Error.Printf("Failed to update current environment: %v\n", err) + return + } + + pterm.Success.Printf("Switched to '%s' environment.\n", envName) }, } @@ -159,6 +183,11 @@ var envCmd = &cobra.Command{ appEnvMap := appV.GetStringMap("environments") userEnvMap := userV.GetStringMap("environments") + if currentEnv == switchEnv { + pterm.Info.Printf("Already in '%s' environment.\n", currentEnv) + return + } + if _, existsApp := appEnvMap[switchEnv]; !existsApp { if _, existsUser := userEnvMap[switchEnv]; !existsUser { home, _ := os.UserHomeDir() @@ -442,20 +471,39 @@ Available Services are fetched dynamically from the backend.`, // Construct new endpoint newEndpoint := fmt.Sprintf("grpc+ssl://%s.api.%s.spaceone.dev:443", service, prefix) - // Update endpoint in cache config only - cacheV.Set(fmt.Sprintf("environments.%s.endpoint", currentEnv), newEndpoint) + // Update the appropriate config file based on environment type + if strings.HasSuffix(currentEnv, "-app") { + // Update endpoint in main config for app environments + appV.Set(fmt.Sprintf("environments.%s.endpoint", currentEnv), newEndpoint) + if service != "identity" { + appV.Set(fmt.Sprintf("environments.%s.proxy", currentEnv), false) + } else { + appV.Set(fmt.Sprintf("environments.%s.proxy", currentEnv), true) + } - // Update proxy based on service in cache config only - if service != "identity" { - cacheV.Set(fmt.Sprintf("environments.%s.proxy", currentEnv), false) + if err := appV.WriteConfig(); err != nil { + pterm.Error.Printf("Failed to update config.yaml: %v\n", err) + return + } } else { - cacheV.Set(fmt.Sprintf("environments.%s.proxy", currentEnv), true) - } + // Update endpoint in cache config for user environments + cachePath := filepath.Join(GetConfigDir(), "cache", "config.yaml") + if err := loadConfig(cacheV, cachePath); err != nil { + pterm.Error.Println(err) + return + } - // Save updated cache config - if err := cacheV.WriteConfig(); err != nil { - pterm.Error.Printf("Failed to update cache/config.yaml: %v\n", err) - return + cacheV.Set(fmt.Sprintf("environments.%s.endpoint", currentEnv), newEndpoint) + if service != "identity" { + cacheV.Set(fmt.Sprintf("environments.%s.proxy", currentEnv), false) + } else { + cacheV.Set(fmt.Sprintf("environments.%s.proxy", currentEnv), true) + } + + if err := cacheV.WriteConfig(); err != nil { + pterm.Error.Printf("Failed to update cache/config.yaml: %v\n", err) + return + } } pterm.Success.Printf("Updated endpoint for '%s' to '%s'.\n", currentEnv, newEndpoint) @@ -650,14 +698,22 @@ func getBaseURL(v *viper.Viper) (string, error) { // getToken retrieves the token for the current environment. func getToken(v *viper.Viper) (string, error) { + home, _ := os.UserHomeDir() currentEnv := getCurrentEnvironment(v) if currentEnv == "" { return "", fmt.Errorf("no environment is set") } - token := v.GetString(fmt.Sprintf("environments.%s.token", currentEnv)) - - if token == "" { + // Check if the environment is app or user type + if strings.HasSuffix(currentEnv, "-app") { + // For app environments, check only in main config + token := v.GetString(fmt.Sprintf("environments.%s.token", currentEnv)) + if token == "" { + return "", fmt.Errorf("no token found for app environment '%s' in %s/.cfctl/config.yaml", currentEnv, home) + } + return token, nil + } else if strings.HasSuffix(currentEnv, "-user") { + // For user environments, check only in cache config cacheV := viper.New() cachePath := filepath.Join(GetConfigDir(), "cache", "config.yaml") @@ -665,14 +721,14 @@ func getToken(v *viper.Viper) (string, error) { return "", fmt.Errorf("failed to load cache config: %v", err) } - token = cacheV.GetString(fmt.Sprintf("environments.%s.token", currentEnv)) - } - - if token == "" { - return "", fmt.Errorf("no token found for environment '%s' in either config.yaml or cache/config.yaml", currentEnv) + token := cacheV.GetString(fmt.Sprintf("environments.%s.token", currentEnv)) + if token == "" { + return "", fmt.Errorf("no token found for user environment '%s' in %s", currentEnv, cachePath) + } + return token, nil } - return token, nil + return "", fmt.Errorf("environment '%s' has invalid suffix (must end with -app or -user)", currentEnv) } // GetConfigDir returns the directory where config files are stored @@ -941,27 +997,6 @@ func convertToSlice(s []interface{}) []interface{} { return result } -// removeEnvironmentField removes the 'environment' field from the given Viper instance -func removeEnvironmentField(v *viper.Viper) error { - config := make(map[string]interface{}) - if err := v.Unmarshal(&config); err != nil { - return fmt.Errorf("failed to unmarshal config: %w", err) - } - - delete(config, "environment") - - data, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal updated config: %w", err) - } - - if err := os.WriteFile(v.ConfigFileUsed(), data, 0644); err != nil { - return fmt.Errorf("failed to write updated config to file: %w", err) - } - - return nil -} - // constructEndpoint generates the gRPC endpoint string from baseURL func constructEndpoint(baseURL string) (string, error) { if !strings.Contains(baseURL, "://") { diff --git a/go.mod b/go.mod index c9907b1..d04b3e1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.1 require ( github.com/atotto/clipboard v0.1.4 + github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 github.com/jhump/protoreflect v1.17.0 github.com/pterm/pterm v0.12.79 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index e655a6f..3907db6 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= +github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=