diff --git a/.gitignore b/.gitignore index c26b299..8848a96 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ build/ # Application binary stacktodate +stacktodate-cli # Claude Code .claude/ diff --git a/cmd/globalconfig/delete.go b/cmd/globalconfig/delete.go new file mode 100644 index 0000000..20e70c8 --- /dev/null +++ b/cmd/globalconfig/delete.go @@ -0,0 +1,47 @@ +package globalconfig + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Remove stored authentication token", + Long: `Remove your stored authentication token from keychain or credential storage.`, + Run: func(cmd *cobra.Command, args []string) { + // Confirm deletion + source, _, _ := helpers.GetTokenSource() + if source == "not configured" { + fmt.Println("No credentials to delete") + return + } + + fmt.Printf("This will remove your token from: %s\n", source) + fmt.Print("Are you sure you want to delete your credentials? (type 'yes' to confirm): ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + helpers.ExitOnError(err, "failed to read input") + } + + response = strings.TrimSpace(response) + if response != "yes" { + fmt.Println("Cancelled - credentials not deleted") + return + } + + // Delete the token + if err := helpers.DeleteToken(); err != nil { + helpers.ExitOnError(err, "") + } + + fmt.Println("✓ Credentials deleted successfully") + }, +} diff --git a/cmd/globalconfig/get.go b/cmd/globalconfig/get.go new file mode 100644 index 0000000..4b7ee32 --- /dev/null +++ b/cmd/globalconfig/get.go @@ -0,0 +1,34 @@ +package globalconfig + +import ( + "fmt" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/spf13/cobra" +) + +var getCmd = &cobra.Command{ + Use: "status", + Short: "Show current authentication configuration", + Long: `Display information about where your authentication token is stored and its status.`, + Run: func(cmd *cobra.Command, args []string) { + source, isSecure, err := helpers.GetTokenSource() + + if err != nil { + fmt.Println("Status: Not configured") + fmt.Println("") + fmt.Println("To set up authentication, run:") + fmt.Println(" stacktodate global-config set") + return + } + + fmt.Println("Status: Configured") + fmt.Printf("Source: %s\n", source) + + if !isSecure { + fmt.Println("") + fmt.Println("⚠️ Warning: Token stored in plain text file") + fmt.Println("For better security, use a system with OS keychain support") + } + }, +} diff --git a/cmd/globalconfig/globalconfig.go b/cmd/globalconfig/globalconfig.go new file mode 100644 index 0000000..5265265 --- /dev/null +++ b/cmd/globalconfig/globalconfig.go @@ -0,0 +1,15 @@ +package globalconfig + +import "github.com/spf13/cobra" + +var GlobalConfigCmd = &cobra.Command{ + Use: "global-config", + Short: "Manage global configuration and authentication", + Long: `Configure authentication tokens and other global settings for stacktodate-cli`, +} + +func init() { + GlobalConfigCmd.AddCommand(setCmd) + GlobalConfigCmd.AddCommand(getCmd) + GlobalConfigCmd.AddCommand(deleteCmd) +} diff --git a/cmd/globalconfig/set.go b/cmd/globalconfig/set.go new file mode 100644 index 0000000..5ab09d4 --- /dev/null +++ b/cmd/globalconfig/set.go @@ -0,0 +1,53 @@ +package globalconfig + +import ( + "fmt" + "strings" + "syscall" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "golang.org/x/term" + + "github.com/spf13/cobra" +) + +var setCmd = &cobra.Command{ + Use: "set", + Short: "Set up authentication token", + Long: `Set up your stacktodate API token for authentication.\n\nThe token will be securely stored in your system's keychain or credential store.`, + Run: func(cmd *cobra.Command, args []string) { + token, err := promptForToken() + if err != nil { + helpers.ExitOnError(err, "failed to read token") + } + + if token == "" { + helpers.ExitOnError(fmt.Errorf("token cannot be empty"), "") + } + + // Store the token + if err := helpers.SetToken(token); err != nil { + helpers.ExitOnError(err, "") + } + + source, _, _ := helpers.GetTokenSource() + fmt.Printf("✓ Token successfully configured\n") + fmt.Printf(" Storage: %s\n", source) + }, +} + +// promptForToken prompts the user for their API token without echoing it to the terminal +func promptForToken() (string, error) { + fmt.Print("Enter your stacktodate API token: ") + + // Read password without echoing + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("failed to read token: %w", err) + } + + fmt.Println() // Print newline after hidden input + + token := strings.TrimSpace(string(bytePassword)) + return token, nil +} diff --git a/cmd/helpers/api.go b/cmd/helpers/api.go new file mode 100644 index 0000000..fbd48fe --- /dev/null +++ b/cmd/helpers/api.go @@ -0,0 +1,167 @@ +package helpers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/stacktodate/stacktodate-cli/cmd/lib/cache" +) + +// Component represents a single technology in the stack +type Component struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ConvertStackToComponents converts the detected stack format to API component format +func ConvertStackToComponents(stack map[string]StackEntry) []Component { + components := make([]Component, 0) + + for name, entry := range stack { + components = append(components, Component{ + Name: name, + Version: entry.Version, + }) + } + + return components +} + +// TechStackRequest is used for POST /api/tech_stacks +type TechStackRequest struct { + TechStack struct { + Name string `json:"name"` + Components []Component `json:"components"` + } `json:"tech_stack"` +} + +// TechStackResponse is the response from both GET and POST tech stack endpoints +type TechStackResponse struct { + Success bool `json:"success,omitempty"` + Message string `json:"message,omitempty"` + TechStack struct { + ID string `json:"id"` + Name string `json:"name"` + Components []Component `json:"components"` + } `json:"tech_stack"` +} + +// CreateTechStack creates a new tech stack on the API +// Returns the newly created tech stack with UUID +func CreateTechStack(token, name string, components []Component) (*TechStackResponse, error) { + apiURL := cache.GetAPIURL() + url := fmt.Sprintf("%s/api/tech_stacks", apiURL) + + request := TechStackRequest{} + request.TechStack.Name = name + request.TechStack.Components = components + + var response TechStackResponse + if err := makeAPIRequest("POST", url, token, request, &response); err != nil { + return nil, err + } + + if !response.Success { + return nil, fmt.Errorf("API error: %s", response.Message) + } + + if response.TechStack.ID == "" { + return nil, fmt.Errorf("API response missing project ID") + } + + return &response, nil +} + +// GetTechStack retrieves an existing tech stack from the API by UUID +// This validates that the project exists and returns its details +func GetTechStack(token, uuid string) (*TechStackResponse, error) { + apiURL := cache.GetAPIURL() + url := fmt.Sprintf("%s/api/tech_stacks/%s", apiURL, uuid) + + var response TechStackResponse + if err := makeAPIRequest("GET", url, token, nil, &response); err != nil { + return nil, err + } + + if response.TechStack.ID == "" { + return nil, fmt.Errorf("API response missing project ID") + } + + return &response, nil +} + +// makeAPIRequest is a private helper that handles common API request logic +func makeAPIRequest(method, url, token string, requestBody interface{}, response interface{}) error { + var req *http.Request + var err error + + // Create request with body if provided + if requestBody != nil { + requestBodyJSON, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + req, err = http.NewRequest(method, url, bytes.NewBuffer(requestBodyJSON)) + } else { + req, err = http.NewRequest(method, url, nil) + } + + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + // Make request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to connect to StackToDate API: %w\n\nPlease check your internet connection and try again", err) + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // Handle error responses first + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("authentication failed: invalid or expired token\n\nPlease update your token with: stacktodate global-config set") + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("project not found: UUID does not exist\n\nPlease check the UUID or create a new project") + } + + if resp.StatusCode == http.StatusUnprocessableEntity { + var errResp struct { + Message string `json:"message"` + } + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Message != "" { + return fmt.Errorf("validation error: %s", errResp.Message) + } + return fmt.Errorf("validation error: the server rejected your request") + } + + if resp.StatusCode >= 500 { + return fmt.Errorf("StackToDate API is experiencing issues (status %d)\n\nPlease try again later", resp.StatusCode) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse successful response + if err := json.Unmarshal(body, response); err != nil { + return fmt.Errorf("failed to parse API response: %w", err) + } + + return nil +} diff --git a/cmd/helpers/credentials.go b/cmd/helpers/credentials.go new file mode 100644 index 0000000..19661a1 --- /dev/null +++ b/cmd/helpers/credentials.go @@ -0,0 +1,222 @@ +package helpers + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/zalando/go-keyring" + "gopkg.in/yaml.v3" +) + +const ( + serviceName = "stacktodate" + username = "token" +) + +// CredentialSource indicates where a credential came from +type CredentialSource string + +const ( + SourceEnvVar CredentialSource = "environment variable" + SourceKeyring CredentialSource = "OS keychain" + SourceFile CredentialSource = "config file" +) + +// CredentialInfo contains information about stored credentials +type CredentialInfo struct { + Token string + Source CredentialSource +} + +// credentialsFile represents the structure of the credentials YAML file +type credentialsFile struct { + Token string `yaml:"token"` +} + +// GetToken retrieves the API token using the priority order: +// 1. STD_TOKEN environment variable (highest priority) +// 2. OS Keychain (macOS/Linux/Windows) +// 3. Returns error if not found (Option B - fail securely) +func GetToken() (string, error) { + // Check environment variable first + if token := os.Getenv("STD_TOKEN"); token != "" { + return token, nil + } + + // Try to get from keychain + token, err := keyring.Get(serviceName, username) + if err == nil && token != "" { + return token, nil + } + + // Try to get from fallback file (for migration purposes, but don't use it by default) + if token, err := getTokenFromFile(); err == nil && token != "" { + return token, nil + } + + // No token found anywhere + return "", fmt.Errorf("no authentication token found\n\nSetup your token with one of these methods:\n 1. Interactive setup: stacktodate global-config set\n 2. Environment variable: export STD_TOKEN=\n\nFor more help: stacktodate global-config --help") +} + +// GetTokenWithSource retrieves the token and returns information about its source +func GetTokenWithSource() (*CredentialInfo, error) { + // Check environment variable first + if token := os.Getenv("STD_TOKEN"); token != "" { + return &CredentialInfo{ + Token: token, + Source: SourceEnvVar, + }, nil + } + + // Try to get from keychain + token, err := keyring.Get(serviceName, username) + if err == nil && token != "" { + return &CredentialInfo{ + Token: token, + Source: SourceKeyring, + }, nil + } + + // Try to get from fallback file + if token, err := getTokenFromFile(); err == nil && token != "" { + return &CredentialInfo{ + Token: token, + Source: SourceFile, + }, nil + } + + // No token found anywhere + return nil, fmt.Errorf("no authentication token found") +} + +// SetToken stores the token in the OS keychain +// Falls back to file storage if keychain is unavailable +// Per Option B: Fails if keychain is unavailable and no fallback +func SetToken(token string) error { + // Ensure config directory exists + if err := EnsureConfigDir(); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Try to store in keychain first + err := keyring.Set(serviceName, username, token) + if err == nil { + return nil + } + + // If keychain fails, also try file storage as a fallback + // This allows local development to work + if err := setTokenInFile(token); err != nil { + return fmt.Errorf("failed to store token securely:\n Keychain error: %v\n File storage error: %v\n\nFor CI/headless environments, use: export STD_TOKEN=", err, err) + } + + fmt.Println("⚠️ Warning: Token stored in plain text file at ~/.stacktodate/credentials.yaml") + fmt.Println("For better security, consider using a system with OS keychain support") + return nil +} + +// DeleteToken removes the token from keychain and file storage +func DeleteToken() error { + var keychainErr error + var fileErr error + + // Try to delete from keychain + keychainErr = keyring.Delete(serviceName, username) + + // Try to delete from file + fileErr = deleteTokenFromFile() + + // If both failed, return error + if keychainErr != nil && fileErr != nil { + return fmt.Errorf("failed to delete token: keychain error: %v, file error: %v", keychainErr, fileErr) + } + + return nil +} + +// GetTokenSource returns information about where the token is currently stored +func GetTokenSource() (string, bool, error) { + // Check environment variable + if os.Getenv("STD_TOKEN") != "" { + return "STD_TOKEN environment variable", true, nil + } + + // Check keychain + _, err := keyring.Get(serviceName, username) + if err == nil { + return "OS keychain", true, nil + } + + // Check file + if _, err := getTokenFromFile(); err == nil { + return "credentials file (~/.stacktodate/credentials.yaml)", false, nil + } + + return "not configured", false, fmt.Errorf("no token found") +} + +// EnsureConfigDir creates the ~/.stacktodate directory if it doesn't exist +func EnsureConfigDir() error { + configDir := getConfigDir() + return os.MkdirAll(configDir, 0700) +} + +// Helper functions + +func getConfigDir() string { + home, err := os.UserHomeDir() + if err != nil { + // Fallback to current directory if home can't be determined + return ".stacktodate" + } + return filepath.Join(home, ".stacktodate") +} + +func getCredentialsFilePath() string { + return filepath.Join(getConfigDir(), "credentials.yaml") +} + +func getTokenFromFile() (string, error) { + filePath := getCredentialsFilePath() + + content, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to read credentials file: %w", err) + } + + var creds credentialsFile + if err := yaml.Unmarshal(content, &creds); err != nil { + return "", fmt.Errorf("failed to parse credentials file: %w", err) + } + + return creds.Token, nil +} + +func setTokenInFile(token string) error { + filePath := getCredentialsFilePath() + + creds := credentialsFile{ + Token: token, + } + + content, err := yaml.Marshal(creds) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + + // Write with restricted permissions (0600 = read/write for owner only) + if err := os.WriteFile(filePath, content, 0600); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + + return nil +} + +func deleteTokenFromFile() error { + filePath := getCredentialsFilePath() + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete credentials file: %w", err) + } + return nil +} diff --git a/cmd/init.go b/cmd/init.go index a10408a..f96a0ba 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -25,6 +25,20 @@ var initCmd = &cobra.Command{ Long: `Initialize a new project with default configuration`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { + reader := bufio.NewReader(os.Stdin) + + // Check if token is configured, prompt if not + token, err := helpers.GetToken() + if err != nil { + fmt.Println("Authentication token not configured.") + fmt.Print("Would you like to set one up now? (y/n): ") + response, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(response)) == "y" { + fmt.Println("\nRun: stacktodate global-config set") + return + } + } + // Determine target directory targetDir := "." if len(args) > 0 { @@ -33,8 +47,6 @@ var initCmd = &cobra.Command{ fmt.Printf("Initializing project in: %s\n", targetDir) - reader := bufio.NewReader(os.Stdin) - // Detect project information in target directory var detectedTechs map[string]helpers.StackEntry if !skipAutodetect { @@ -49,24 +61,50 @@ var initCmd = &cobra.Command{ } } - // Get UUID - if uuid == "" { - fmt.Print("Enter UUID: ") - input, _ := reader.ReadString('\n') - uuid = strings.TrimSpace(input) - } + // NEW: Menu-based project selection (create new or link existing) + var projUUID, projName string + if uuid == "" && name == "" { + // Interactive mode: prompt user for choice + choice := promptProjectChoice(reader) + + if choice == 1 { + // Create new project on API + var createErr error + projUUID, projName, createErr = createNewProject(reader, detectedTechs, token) + if createErr != nil { + helpers.ExitOnError(createErr, "failed to create project") + } + } else { + // Link to existing project on API + var linkErr error + projUUID, projName, linkErr = linkExistingProject(reader, token) + if linkErr != nil { + helpers.ExitOnError(linkErr, "failed to link project") + } + } + } else { + // Non-interactive mode: use provided flags or fallback to old prompts + if uuid == "" { + fmt.Print("Enter UUID: ") + input, _ := reader.ReadString('\n') + projUUID = strings.TrimSpace(input) + } else { + projUUID = uuid + } - // Get Name - if name == "" { - fmt.Print("Enter name: ") - input, _ := reader.ReadString('\n') - name = strings.TrimSpace(input) + if name == "" { + fmt.Print("Enter name: ") + input, _ := reader.ReadString('\n') + projName = strings.TrimSpace(input) + } else { + projName = name + } } // Create config config := helpers.Config{ - UUID: uuid, - Name: name, + UUID: projUUID, + Name: projName, Stack: detectedTechs, } @@ -84,8 +122,8 @@ var initCmd = &cobra.Command{ fmt.Println("\nProject initialized successfully!") fmt.Println("Created stacktodate.yml with:") - fmt.Printf(" UUID: %s\n", uuid) - fmt.Printf(" Name: %s\n", name) + fmt.Printf(" UUID: %s\n", projUUID) + fmt.Printf(" Name: %s\n", projName) if len(detectedTechs) > 0 { fmt.Println(" Stack:") for tech, entry := range detectedTechs { @@ -95,6 +133,85 @@ var initCmd = &cobra.Command{ }, } +// promptProjectChoice displays a menu for choosing between creating a new project or linking an existing one +func promptProjectChoice(reader *bufio.Reader) int { + for { + fmt.Println("\nDo you want to:") + fmt.Println(" 1) Create a new project on StackToDate") + fmt.Println(" 2) Link to an existing project") + fmt.Print("\nEnter your choice (1 or 2): ") + + input, _ := reader.ReadString('\n') + choice := strings.TrimSpace(input) + + if choice == "1" { + return 1 + } else if choice == "2" { + return 2 + } + + fmt.Println("Invalid choice. Please enter 1 or 2.") + } +} + +// createNewProject prompts for project name and creates a new project via API +func createNewProject(reader *bufio.Reader, detectedTechs map[string]helpers.StackEntry, token string) (uuid, projName string, err error) { + fmt.Print("\nEnter project name: ") + input, _ := reader.ReadString('\n') + projName = strings.TrimSpace(input) + + if projName == "" { + return "", "", fmt.Errorf("project name cannot be empty") + } + + // Convert detected technologies to API components + components := helpers.ConvertStackToComponents(detectedTechs) + + if len(components) == 0 { + fmt.Println("⚠️ Warning: No technologies detected") + fmt.Println("You can add them later by editing stacktodate.yml and running 'stacktodate push'") + } + + fmt.Println("\nCreating project on StackToDate...") + + // Call API to create project + response, err := helpers.CreateTechStack(token, projName, components) + if err != nil { + return "", "", err + } + + uuid = response.TechStack.ID + fmt.Println("✓ Project created successfully!") + fmt.Printf(" UUID: %s\n", uuid) + fmt.Printf(" Name: %s\n\n", projName) + + return uuid, projName, nil +} + +// linkExistingProject prompts for UUID and links to an existing project via API +func linkExistingProject(reader *bufio.Reader, token string) (projUUID, projName string, err error) { + fmt.Print("\nEnter project UUID: ") + input, _ := reader.ReadString('\n') + projUUID = strings.TrimSpace(input) + + if projUUID == "" { + return "", "", fmt.Errorf("UUID cannot be empty") + } + + fmt.Println("\nValidating project UUID...") + + // Call API to fetch project details + response, err := helpers.GetTechStack(token, projUUID) + if err != nil { + return "", "", err + } + + projName = response.TechStack.Name + fmt.Printf("✓ Linked to existing project: %s\n\n", projName) + + return projUUID, projName, nil +} + // selectCandidates allows user to select from detected candidates func selectCandidates(reader *bufio.Reader, info DetectedInfo) map[string]helpers.StackEntry { selected := make(map[string]helpers.StackEntry) diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..b2fc5ee --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/stacktodate/stacktodate-cli/cmd/lib/cache" + "github.com/spf13/cobra" +) + +var openCmd = &cobra.Command{ + Use: "open", + Short: "Open the tech stack in your browser", + Long: `Open the project's tech stack page on StackToDate in your default web browser`, + Run: func(cmd *cobra.Command, args []string) { + // Load config to get UUID + config, err := helpers.LoadConfigWithDefaults(configFile, true) + if err != nil { + helpers.ExitOnError(err, "failed to load config") + } + + // Get API URL + apiURL := cache.GetAPIURL() + + // Build the tech stack URL + url := fmt.Sprintf("%s/tech_stacks/%s", apiURL, config.UUID) + + // Open in default browser + if err := openBrowser(url); err != nil { + helpers.ExitOnError(err, "failed to open browser") + } + + fmt.Printf("✓ Opening %s in your browser\n", url) + }, +} + +// openBrowser opens a URL in the default browser for the current operating system +func openBrowser(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + // macOS + cmd = exec.Command("open", url) + case "windows": + // Windows + cmd = exec.Command("cmd", "/c", "start", url) + case "linux": + // Linux - try xdg-open first, then fall back to others + cmd = exec.Command("xdg-open", url) + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + return cmd.Run() +} + +func init() { + rootCmd.AddCommand(openCmd) + openCmd.Flags().StringVarP(&configFile, "config", "c", "", "Path to stacktodate.yml config file (default: stacktodate.yml)") +} diff --git a/cmd/push.go b/cmd/push.go index f6b3c7f..4885834 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -16,22 +16,17 @@ var ( configFile string ) -type Component struct { - Name string `json:"name"` - Version string `json:"version"` -} - type PushRequest struct { - Components []Component `json:"components"` + Components []helpers.Component `json:"components"` } type PushResponse struct { Success bool `json:"success"` Message string `json:"message"` TechStack struct { - ID string `json:"id"` - Name string `json:"name"` - Components []Component `json:"components"` + ID string `json:"id"` + Name string `json:"name"` + Components []helpers.Component `json:"components"` } `json:"tech_stack"` } @@ -46,8 +41,8 @@ var pushCmd = &cobra.Command{ helpers.ExitOnError(err, "failed to load config") } - // Get token from environment - token, err := helpers.GetEnvRequired("STD_TOKEN") + // Get token from credentials (env var, keychain, or file) + token, err := helpers.GetToken() if err != nil { helpers.ExitOnError(err, "") } @@ -56,7 +51,7 @@ var pushCmd = &cobra.Command{ apiURL := cache.GetAPIURL() // Convert stack to components - components := convertStackToComponents(config.Stack) + components := helpers.ConvertStackToComponents(config.Stack) // Create request request := PushRequest{ @@ -72,19 +67,6 @@ var pushCmd = &cobra.Command{ }, } -func convertStackToComponents(stack map[string]helpers.StackEntry) []Component { - var components []Component - - for name, entry := range stack { - components = append(components, Component{ - Name: name, - Version: entry.Version, - }) - } - - return components -} - func pushToAPI(apiURL, techStackID, token string, request PushRequest) error { // Build URL url := fmt.Sprintf("%s/api/tech_stacks/%s/components", apiURL, techStackID) diff --git a/cmd/root.go b/cmd/root.go index 381850c..bbe5f98 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/stacktodate/stacktodate-cli/cmd/globalconfig" "github.com/stacktodate/stacktodate-cli/cmd/helpers" "github.com/stacktodate/stacktodate-cli/cmd/lib/versioncheck" "github.com/stacktodate/stacktodate-cli/internal/version" @@ -42,6 +43,7 @@ func init() { rootCmd.AddCommand(initCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(autodetectCmd) + rootCmd.AddCommand(globalconfig.GlobalConfigCmd) } // shouldAutoCheck determines if a command should trigger automatic version checks diff --git a/go.mod b/go.mod index 3bce8df..15c685b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,12 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 3926859..49f0645 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,10 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -6,6 +12,14 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=