From a7cf7ea5612e239607740258e6987fe13f4ab9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Galisteo?= Date: Fri, 2 Jan 2026 20:36:22 +0100 Subject: [PATCH 1/4] Implement secure credential management and global-config command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new global-config command that allows users to securely set up their API token once and reuse it across all commands. Implements a three-tier credential resolution system with proper priority: 1. Environment variable (STD_TOKEN) - highest priority for CI/CD 2. OS Keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager) 3. Fallback to ~/.stacktodate/credentials.yaml file storage Key changes: - Add helpers/credentials.go with centralized credential management functions (GetToken, SetToken, DeleteToken, GetTokenSource) - Implement global-config command with three subcommands: * set: Interactive token setup with secure hidden input * status: Show current token configuration and storage location * delete: Remove stored credentials with confirmation - Update push command to use new credential system instead of requiring env var - Update init command to prompt for token setup if not configured - Add zalando/go-keyring dependency for cross-platform OS keychain support - Add golang.org/x/term dependency for secure password input (no echo) Benefits: - Users no longer need to set STD_TOKEN env var for local development - Token is stored securely in OS keychain by default - Seamless CI/CD integration via environment variables - Better error messages guide users to set up credentials 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- cmd/globalconfig/delete.go | 47 +++++++ cmd/globalconfig/get.go | 34 +++++ cmd/globalconfig/globalconfig.go | 15 +++ cmd/globalconfig/set.go | 53 ++++++++ cmd/helpers/credentials.go | 222 +++++++++++++++++++++++++++++++ cmd/init.go | 16 ++- cmd/push.go | 4 +- cmd/root.go | 2 + go.mod | 6 + go.sum | 14 ++ 10 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 cmd/globalconfig/delete.go create mode 100644 cmd/globalconfig/get.go create mode 100644 cmd/globalconfig/globalconfig.go create mode 100644 cmd/globalconfig/set.go create mode 100644 cmd/helpers/credentials.go 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/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..d50c9c1 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 + _, 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 { diff --git a/cmd/push.go b/cmd/push.go index f6b3c7f..eec8c3a 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -46,8 +46,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, "") } 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= From 41f7ad84ef7769a7c3ea2af42dd75eda72f26933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Galisteo?= Date: Fri, 2 Jan 2026 23:32:30 +0100 Subject: [PATCH 2/4] Implement API-backed init command with project creation and linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ability for users to create new projects or link to existing ones during initialization via API calls to stacktodate.club. This improves the user experience by immediately registering projects and validating UUIDs. Key changes: - Add cmd/helpers/api.go with API integration functions: * CreateTechStack() - POST /api/tech_stacks to create new project * GetTechStack() - GET /api/tech_stacks/{id} to fetch and validate projects * ConvertStackToComponents() - Convert detected techs to API format * Common error handling for API responses (401, 404, 422, 5xx) - Enhanced cmd/init.go with interactive menu: * promptProjectChoice() - Menu: "Create new" or "Link existing" * createNewProject() - Create project via API with autodetected components * linkExistingProject() - Validate and link to existing project by UUID * New flow branches based on user choice - Updated cmd/push.go: * Use helpers.Component instead of local type * Use helpers.ConvertStackToComponents() from shared API module UX improvements: - Interactive menu for choosing new vs existing project - Progress messages during API calls - Clear error messages for authentication, validation, and network issues - Support for empty tech stacks with helpful warnings - Backward compatible with --uuid and --name flags for automation API endpoints used: - POST /api/tech_stacks - Create new project (returns UUID) - GET /api/tech_stacks/{id} - Validate and fetch project details Error handling: - 401: Invalid token with hint to update credentials - 404: Project not found with helpful message - 422: Validation errors from API - 5xx: API issues with retry suggestion - Network errors: Connection issues with helpful context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- cmd/helpers/api.go | 167 +++++++++++++++++++++++++++++++++++++++++++++ cmd/init.go | 137 ++++++++++++++++++++++++++++++++----- cmd/push.go | 28 ++------ 3 files changed, 293 insertions(+), 39 deletions(-) create mode 100644 cmd/helpers/api.go 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/init.go b/cmd/init.go index d50c9c1..f96a0ba 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -28,7 +28,7 @@ var initCmd = &cobra.Command{ reader := bufio.NewReader(os.Stdin) // Check if token is configured, prompt if not - _, err := helpers.GetToken() + 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): ") @@ -61,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) - // Get Name - if name == "" { - fmt.Print("Enter name: ") - input, _ := reader.ReadString('\n') - name = strings.TrimSpace(input) + 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 + } + + 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, } @@ -96,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 { @@ -107,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/push.go b/cmd/push.go index eec8c3a..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"` } @@ -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) From d8edf87ad23470c5d66e88c03cd1f9c1ff9265d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Galisteo?= Date: Fri, 2 Jan 2026 23:37:53 +0100 Subject: [PATCH 3/4] Add open command to open tech stack in default browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new 'stacktodate open' command that opens the project's tech stack page on StackToDate in the user's default web browser. Features: - Reads project UUID from stacktodate.yml - Opens https://stacktodate.club/tech_stacks/{uuid} in default browser - Cross-platform support: macOS (open), Windows (start), Linux (xdg-open) - Respects STD_API_URL environment variable for custom API endpoints - Works with --config flag to specify custom config file path Usage: stacktodate open stacktodate open --config /path/to/stacktodate.yml This provides a quick way to view project details on the StackToDate website without needing to copy-paste the UUID or remember the URL format. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- cmd/open.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 cmd/open.go 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)") +} From 615d6aae77152e909303c3ae306488c2d62d2bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Galisteo?= Date: Sat, 3 Jan 2026 00:12:08 +0100 Subject: [PATCH 4/4] Remove compiled binary from version control and add to gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stacktodate-cli binary should not be committed to git as it: - Creates unnecessary repository bloat (12MB+) - Is platform-specific and won't work across different OS/architectures - Should be built from source instead - Makes it harder to track meaningful code changes Users should build the binary from source using: go build -o stacktodate-cli 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/