diff --git a/.github/workflows/sync-stacktodate.yml b/.github/workflows/sync-stacktodate.yml new file mode 100644 index 0000000..f88868e --- /dev/null +++ b/.github/workflows/sync-stacktodate.yml @@ -0,0 +1,23 @@ +name: Sync Stack To Date + +on: + push: + branches: [ master, main ] + +jobs: + sync-stacktodate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download stacktodate binary + run: | + curl -L https://github.com/stacktodate/stacktodate-cli/releases/latest/download/stacktodate_linux_amd64.tar.gz | tar xz + chmod +x stacktodate + + - name: Check stacktodate config + run: ./stacktodate check + + - name: Push stacktodate config + run: ./stacktodate push + env: + STD_TOKEN: ${{ secrets.STD_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 950e3b1..297bed5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,3 +26,9 @@ jobs: - name: Run tests run: go test -v ./... + + - name: Build stacktodate + run: go build -o stacktodate + + - name: Check stacktodate config + run: ./stacktodate check 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/README.md b/README.md index 148907f..cce7bae 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # StackToDate +![Tech Stack Badge](https://stacktodate.club/tech_stacks/1fe0b376-1df7-4848-bf2d-525acdce6b82/badge) + Official command-line interface for [Stack To Date](https://stacktodate.club/) — a service that helps development teams track technology lifecycle statuses and plan for end-of-life (EOL) upgrades. ## About Stack To Date diff --git a/cmd/check.go b/cmd/check.go index c4208b7..2bcf03e 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -44,6 +44,11 @@ var checkCmd = &cobra.Command{ Short: "Check if detected versions match stacktodate.yml", Long: `Verify that the versions in stacktodate.yml match the currently detected versions in your project. Useful for CI/CD pipelines.`, Run: func(cmd *cobra.Command, args []string) { + // Use default config file if not specified + if checkConfigFile == "" { + checkConfigFile = "stacktodate.yml" + } + // Load config without requiring UUID config, err := helpers.LoadConfig(checkConfigFile) if err != nil { @@ -53,11 +58,7 @@ var checkCmd = &cobra.Command{ // Resolve absolute path for directory management absConfigPath, err := helpers.ResolveAbsPath(checkConfigFile) if err != nil { - if checkConfigFile == "" { - absConfigPath, _ = helpers.ResolveAbsPath("stacktodate.yml") - } else { - helpers.ExitOnError(err, "failed to resolve config path") - } + helpers.ExitOnError(err, "failed to resolve config path") } // Get config directory 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/lib/installer/installer.go b/cmd/lib/installer/installer.go new file mode 100644 index 0000000..2a10548 --- /dev/null +++ b/cmd/lib/installer/installer.go @@ -0,0 +1,117 @@ +package installer + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// InstallMethod represents how stacktodate was installed +type InstallMethod int + +const ( + Unknown InstallMethod = iota + Homebrew + Binary +) + +// String returns a string representation of the install method +func (m InstallMethod) String() string { + switch m { + case Homebrew: + return "homebrew" + case Binary: + return "binary" + default: + return "unknown" + } +} + +// DetectInstallMethod attempts to determine how stacktodate was installed +func DetectInstallMethod() InstallMethod { + // Try to detect Homebrew installation first + if IsHomebrew() { + return Homebrew + } + + // Default to binary download + return Binary +} + +// IsHomebrew checks if stacktodate was installed via Homebrew +func IsHomebrew() bool { + // Method 1: Check executable path for Homebrew-specific directories + executable, err := os.Executable() + if err == nil { + if isHomebrewPath(executable) { + return true + } + } + + // Method 2: Verify with brew command (silent check) + if isBrewInstalled() { + return true + } + + return false +} + +// isHomebrewPath checks if the executable path looks like a Homebrew installation +func isHomebrewPath(execPath string) bool { + // Common Homebrew paths + homebrewPatterns := []string{ + "/Cellar/stacktodate/", // Intel Macs, Linux + "/opt/homebrew/Cellar/stacktodate", // Apple Silicon Macs + "/opt/homebrew/bin/stacktodate", + "/usr/local/bin/stacktodate", + "/usr/local/Cellar/stacktodate/", + } + + for _, pattern := range homebrewPatterns { + if strings.Contains(execPath, pattern) { + return true + } + } + + return false +} + +// isBrewInstalled checks if the brew command recognizes stacktodate +func isBrewInstalled() bool { + // Run: brew list stacktodate + // This will succeed (exit code 0) if stacktodate is installed via Homebrew + cmd := exec.Command("brew", "list", "stacktodate") + + // Redirect output to /dev/null (we don't need the output) + cmd.Stdout = nil + cmd.Stderr = nil + + // Silent execution - we only care about the exit code + return cmd.Run() == nil +} + +// GetUpgradeInstructions returns the appropriate upgrade instructions based on install method +func GetUpgradeInstructions(method InstallMethod, version string) string { + switch method { + case Homebrew: + return "Upgrade: brew upgrade stacktodate" + + case Binary: + return fmt.Sprintf("Download: https://github.com/stacktodate/stacktodate-cli/releases/tag/%s", version) + + default: + return fmt.Sprintf("Visit: https://github.com/stacktodate/stacktodate-cli/releases/tag/%s", version) + } +} + +// GetInstallerDownloadURL returns the download URL appropriate for the install method +func GetInstallerDownloadURL(method InstallMethod) string { + switch method { + case Homebrew: + return "https://github.com/stacktodate/homebrew-stacktodate" + + default: + return "https://github.com/stacktodate/stacktodate-cli/releases/latest" + } +} diff --git a/cmd/lib/installer/installer_test.go b/cmd/lib/installer/installer_test.go new file mode 100644 index 0000000..6824e46 --- /dev/null +++ b/cmd/lib/installer/installer_test.go @@ -0,0 +1,101 @@ +package installer + +import ( + "testing" +) + +func TestInstallMethodString(t *testing.T) { + tests := []struct { + method InstallMethod + expected string + }{ + {Homebrew, "homebrew"}, + {Binary, "binary"}, + {Unknown, "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if got := tt.method.String(); got != tt.expected { + t.Fatalf("expected %s, got %s", tt.expected, got) + } + }) + } +} + +func TestIsHomebrewPath(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + {"Intel Mac Cellar", "/usr/local/Cellar/stacktodate/0.2.0/bin/stacktodate", true}, + {"Apple Silicon", "/opt/homebrew/Cellar/stacktodate/0.2.0/bin/stacktodate", true}, + {"Apple Silicon bin", "/opt/homebrew/bin/stacktodate", true}, + {"Standard usr local bin", "/usr/local/bin/stacktodate", true}, + {"Binary download", "/Users/username/Downloads/stacktodate", false}, + {"Build from source", "/Users/username/projects/stacktodate-cli/stacktodate", false}, + {"Go workspace", "/home/user/go/bin/stacktodate", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isHomebrewPath(tt.path); got != tt.expected { + t.Fatalf("expected %v, got %v for path %s", tt.expected, got, tt.path) + } + }) + } +} + +func TestGetUpgradeInstructions(t *testing.T) { + tests := []struct { + name string + method InstallMethod + version string + expected string + }{ + {"Homebrew", Homebrew, "v0.3.0", "Upgrade: brew upgrade stacktodate"}, + {"Homebrew without v", Homebrew, "0.3.0", "Upgrade: brew upgrade stacktodate"}, + {"Binary with v", Binary, "v0.3.0", "Download: https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0"}, + {"Binary without v", Binary, "0.3.0", "Download: https://github.com/stacktodate/stacktodate-cli/releases/tag/0.3.0"}, + {"Unknown", Unknown, "v0.3.0", "Visit: https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetUpgradeInstructions(tt.method, tt.version); got != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func TestGetInstallerDownloadURL(t *testing.T) { + tests := []struct { + name string + method InstallMethod + expected string + }{ + {"Homebrew", Homebrew, "https://github.com/stacktodate/homebrew-stacktodate"}, + {"Binary", Binary, "https://github.com/stacktodate/stacktodate-cli/releases/latest"}, + {"Unknown", Unknown, "https://github.com/stacktodate/stacktodate-cli/releases/latest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetInstallerDownloadURL(tt.method); got != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func TestDetectInstallMethod(t *testing.T) { + // This test just verifies the function runs without panic + // Actual detection result depends on environment + method := DetectInstallMethod() + + if method != Homebrew && method != Binary && method != Unknown { + t.Fatalf("unexpected install method: %v", method) + } +} diff --git a/cmd/lib/versioncheck/versioncheck.go b/cmd/lib/versioncheck/versioncheck.go new file mode 100644 index 0000000..970fb3e --- /dev/null +++ b/cmd/lib/versioncheck/versioncheck.go @@ -0,0 +1,240 @@ +package versioncheck + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + cacheDirName = ".stacktodate" + cacheFileName = "version-cache.json" + cacheTTL = 24 * time.Hour + githubAPIURL = "https://api.github.com/repos/stacktodate/stacktodate-cli/releases/latest" + httpTimeout = 10 * time.Second +) + +// getUserHomeDir returns the user's home directory (can be overridden for testing) +var getUserHomeDir = os.UserHomeDir + +// VersionCache represents the cached version information +type VersionCache struct { + Timestamp time.Time `json:"timestamp"` + LatestVersion string `json:"latestVersion"` + ReleaseURL string `json:"releaseUrl"` +} + +// GitHubRelease represents the GitHub API response for a release +type GitHubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` +} + +// GetCachePath returns the full path to the version cache file +func GetCachePath() (string, error) { + home, err := getUserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + cacheDir := filepath.Join(home, cacheDirName) + cachePath := filepath.Join(cacheDir, cacheFileName) + return cachePath, nil +} + +// IsCacheValid checks if a valid cache file exists and is not expired +func IsCacheValid() bool { + cachePath, err := GetCachePath() + if err != nil { + return false + } + + info, err := os.Stat(cachePath) + if err != nil { + return false + } + + return time.Since(info.ModTime()) < cacheTTL +} + +// LoadCache loads the version cache from disk +func LoadCache() (*VersionCache, error) { + cachePath, err := GetCachePath() + if err != nil { + return nil, err + } + + content, err := os.ReadFile(cachePath) + if err != nil { + return nil, fmt.Errorf("failed to read cache file: %w", err) + } + + var cache VersionCache + if err := json.Unmarshal(content, &cache); err != nil { + return nil, fmt.Errorf("failed to parse cache file: %w", err) + } + + return &cache, nil +} + +// SaveCache saves the version information to cache +func SaveCache(latestVersion, releaseURL string) error { + cachePath, err := GetCachePath() + if err != nil { + return err + } + + // Ensure cache directory exists + cacheDir := filepath.Dir(cachePath) + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + cache := VersionCache{ + Timestamp: time.Now(), + LatestVersion: latestVersion, + ReleaseURL: releaseURL, + } + + data, err := json.Marshal(cache) + if err != nil { + return fmt.Errorf("failed to marshal cache: %w", err) + } + + if err := os.WriteFile(cachePath, data, 0644); err != nil { + return fmt.Errorf("failed to write cache file: %w", err) + } + + return nil +} + +// FetchLatestFromGitHub fetches the latest release information from GitHub API +func FetchLatestFromGitHub() (*GitHubRelease, error) { + client := &http.Client{ + Timeout: httpTimeout, + } + + req, err := http.NewRequest("GET", githubAPIURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + // GitHub API requires User-Agent header + req.Header.Set("User-Agent", "stacktodate-cli") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching from GitHub: %w", err) + } + defer resp.Body.Close() + + // Handle rate limiting + if resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("rate limit exceeded") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, string(body)) + } + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return &release, nil +} + +// CompareVersions compares two semantic versions and returns true if latest is newer +// Handles 'dev' versions (always considered older) and different formats +func CompareVersions(current, latest string) (bool, error) { + // Special case: dev version is always older + if current == "dev" { + return true, nil + } + + // Strip 'v' prefix if present + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + + // Split versions by '.' + currentParts := strings.Split(current, ".") + latestParts := strings.Split(latest, ".") + + // Compare each numeric part + maxLen := len(currentParts) + if len(latestParts) > maxLen { + maxLen = len(latestParts) + } + + for i := 0; i < maxLen; i++ { + currPart := 0 + latPart := 0 + + if i < len(currentParts) { + val, err := strconv.Atoi(strings.TrimSpace(currentParts[i])) + if err != nil { + return false, fmt.Errorf("invalid current version format: %s", current) + } + currPart = val + } + + if i < len(latestParts) { + val, err := strconv.Atoi(strings.TrimSpace(latestParts[i])) + if err != nil { + return false, fmt.Errorf("invalid latest version format: %s", latest) + } + latPart = val + } + + if latPart > currPart { + return true, nil // Newer version available + } else if currPart > latPart { + return false, nil // Current is newer + } + } + + // All parts are equal + return false, nil +} + +// GetLatestVersion retrieves the latest version, checking cache first and fetching from GitHub if needed +// Implements graceful degradation: uses stale cache if network fails +func GetLatestVersion() (string, string, error) { + // Check if cache is still valid + if IsCacheValid() { + cache, err := LoadCache() + if err == nil && cache != nil { + return cache.LatestVersion, cache.ReleaseURL, nil + } + } + + // Cache is invalid or missing, fetch new data from GitHub + release, err := FetchLatestFromGitHub() + if err != nil { + // If fetch fails, try to use stale cache as fallback + cache, cacheErr := LoadCache() + if cacheErr == nil && cache != nil { + // Stale cache is better than nothing + return cache.LatestVersion, cache.ReleaseURL, nil + } + // Both fetch and fallback failed + return "", "", fmt.Errorf("failed to fetch version: %w", err) + } + + // Successfully fetched, save to cache for future use + if saveErr := SaveCache(release.TagName, release.HTMLURL); saveErr != nil { + // Cache save failure is not fatal - we still have the fetched data + // Silently ignore and continue + } + + return release.TagName, release.HTMLURL, nil +} diff --git a/cmd/lib/versioncheck/versioncheck_test.go b/cmd/lib/versioncheck/versioncheck_test.go new file mode 100644 index 0000000..78ba487 --- /dev/null +++ b/cmd/lib/versioncheck/versioncheck_test.go @@ -0,0 +1,255 @@ +package versioncheck + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + current string + latest string + isNewer bool + shouldErr bool + }{ + // Normal cases + {"newer patch version", "0.2.2", "0.2.3", true, false}, + {"newer minor version", "0.2.2", "0.3.0", true, false}, + {"newer major version", "0.2.2", "1.0.0", true, false}, + {"same version", "0.2.2", "0.2.2", false, false}, + {"current is newer", "0.3.0", "0.2.2", false, false}, + + // Dev version + {"dev is always older", "dev", "0.2.2", true, false}, + {"dev current with newer", "dev", "1.0.0", true, false}, + + // With v prefix + {"with v prefix - newer", "v0.2.2", "v0.3.0", true, false}, + {"with v prefix - same", "v0.2.2", "v0.2.2", false, false}, + {"mixed v prefix", "v0.2.2", "0.3.0", true, false}, + + // Different length versions + {"shorter current vs longer latest", "0.2", "0.2.1", true, false}, + {"longer current vs shorter latest", "0.2.1", "0.2", false, false}, + + // Invalid versions + {"invalid current", "invalid", "0.2.2", false, true}, + {"invalid latest", "0.2.2", "invalid", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isNewer, err := CompareVersions(tt.current, tt.latest) + + if (err != nil) != tt.shouldErr { + t.Fatalf("expected error: %v, got: %v", tt.shouldErr, err != nil) + } + + if isNewer != tt.isNewer { + t.Fatalf("expected isNewer: %v, got: %v", tt.isNewer, isNewer) + } + }) + } +} + +func TestCachePath(t *testing.T) { + cachePath, err := GetCachePath() + if err != nil { + t.Fatalf("GetCachePath failed: %v", err) + } + + // Should contain .stacktodate and version-cache.json + if !filepath.IsAbs(cachePath) { + t.Fatalf("cache path should be absolute, got: %s", cachePath) + } + + if !strings.Contains(cachePath, ".stacktodate") { + t.Fatalf("cache path should contain .stacktodate, got: %s", cachePath) + } + + if !strings.Contains(cachePath, "version-cache.json") { + t.Fatalf("cache path should contain version-cache.json, got: %s", cachePath) + } +} + +func TestSaveAndLoadCache(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override home directory for testing + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Test save + testVersion := "v0.3.0" + testURL := "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0" + + if err := SaveCache(testVersion, testURL); err != nil { + t.Fatalf("SaveCache failed: %v", err) + } + + // Verify cache file exists + cachePath, _ := GetCachePath() + if _, err := os.Stat(cachePath); err != nil { + t.Fatalf("cache file should exist, got error: %v", err) + } + + // Test load + cache, err := LoadCache() + if err != nil { + t.Fatalf("LoadCache failed: %v", err) + } + + if cache.LatestVersion != testVersion { + t.Fatalf("expected version %s, got %s", testVersion, cache.LatestVersion) + } + + if cache.ReleaseURL != testURL { + t.Fatalf("expected URL %s, got %s", testURL, cache.ReleaseURL) + } +} + +func TestIsCacheValid(t *testing.T) { + tmpDir := t.TempDir() + + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Test: cache doesn't exist + if IsCacheValid() { + t.Fatalf("empty cache should be invalid") + } + + // Create a valid cache + if err := SaveCache("v0.3.0", "https://example.com"); err != nil { + t.Fatalf("SaveCache failed: %v", err) + } + + // Test: cache exists and is fresh + if !IsCacheValid() { + t.Fatalf("fresh cache should be valid") + } + + // Modify file timestamp to be older than TTL + cachePath, _ := GetCachePath() + oldTime := time.Now().Add(-25 * time.Hour) + if err := os.Chtimes(cachePath, oldTime, oldTime); err != nil { + t.Fatalf("failed to modify file times: %v", err) + } + + // Test: cache exists but is expired + if IsCacheValid() { + t.Fatalf("expired cache should be invalid") + } +} + +func TestFetchLatestFromGitHub(t *testing.T) { + // Create a test server that mimics GitHub API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify User-Agent header + if ua := r.Header.Get("User-Agent"); ua != "stacktodate-cli" { + w.WriteHeader(http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + response := GitHubRelease{ + TagName: "v0.3.0", + HTMLURL: "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0", + PublishedAt: "2024-01-01T00:00:00Z", + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // TODO: This test would need to mock the HTTP client to test properly + // For now, we'll test the parsing logic independently + t.Run("parse release response", func(t *testing.T) { + jsonData := `{ + "tag_name": "v0.3.0", + "html_url": "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0", + "published_at": "2024-01-01T00:00:00Z" + }` + + var release GitHubRelease + if err := json.Unmarshal([]byte(jsonData), &release); err != nil { + t.Fatalf("failed to parse release: %v", err) + } + + if release.TagName != "v0.3.0" { + t.Fatalf("expected tag v0.3.0, got %s", release.TagName) + } + + if release.HTMLURL != "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0" { + t.Fatalf("unexpected URL") + } + }) +} + +func TestGetLatestVersionWithCache(t *testing.T) { + tmpDir := t.TempDir() + + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Create a valid cache + testVersion := "v0.3.0" + testURL := "https://github.com/stacktodate/stacktodate-cli/releases/tag/v0.3.0" + if err := SaveCache(testVersion, testURL); err != nil { + t.Fatalf("SaveCache failed: %v", err) + } + + // Get latest version should return from cache + version, url, err := GetLatestVersion() + if err != nil { + t.Fatalf("GetLatestVersion failed: %v", err) + } + + if version != testVersion { + t.Fatalf("expected version %s, got %s", testVersion, version) + } + + if url != testURL { + t.Fatalf("expected URL %s, got %s", testURL, url) + } +} + +func TestLoadCacheNonExistent(t *testing.T) { + tmpDir := t.TempDir() + + originalGetHomeDir := getUserHomeDir + getUserHomeDir = func() (string, error) { + return tmpDir, nil + } + defer func() { + getUserHomeDir = originalGetHomeDir + }() + + // Try to load non-existent cache + _, err := LoadCache() + if err == nil { + t.Fatalf("LoadCache should fail for non-existent cache") + } +} 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 3529480..bbe5f98 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,8 +2,11 @@ package cmd 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" "github.com/spf13/cobra" ) @@ -12,6 +15,13 @@ var rootCmd = &cobra.Command{ Use: "stacktodate", Short: "Official CLI for Stack To Date", Long: `stacktodate - Track technology lifecycle statuses and plan for end-of-life upgrades`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Only check on specific commands that should trigger automatic checks + cmdName := cmd.Name() + if shouldAutoCheck(cmdName) { + showCachedUpdateNotification() + } + }, Run: func(cmd *cobra.Command, args []string) { ver, _ := cmd.Flags().GetBool("version") if ver { @@ -33,4 +43,50 @@ 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 +func shouldAutoCheck(cmdName string) bool { + // Commands that should trigger automatic version checks + autoCheckCommands := map[string]bool{ + "init": true, + "update": true, + "check": true, + "push": true, + "autodetect": true, + } + + return autoCheckCommands[cmdName] +} + +// showCachedUpdateNotification shows an update notification if cache indicates a new version is available +// This only checks the cache (no network calls) to avoid any performance impact +func showCachedUpdateNotification() { + // Skip if update checking is disabled + if os.Getenv("STD_DISABLE_VERSION_CHECK") == "1" { + return + } + + // Only check if cache is valid (don't fetch from network) + if !versioncheck.IsCacheValid() { + return + } + + // Load cache + cache, err := versioncheck.LoadCache() + if err != nil || cache == nil { + return + } + + // Compare versions + current := version.GetVersion() + isNewer, err := versioncheck.CompareVersions(current, cache.LatestVersion) + if err != nil || !isNewer { + return + } + + // Show simple notification (don't disrupt command output) + fmt.Fprintf(os.Stderr, "\nA new version of stacktodate is available: %s → %s\n", current, cache.LatestVersion) + fmt.Fprintf(os.Stderr, "Run 'stacktodate version --check-updates' for upgrade instructions.\n\n") } diff --git a/cmd/update.go b/cmd/update.go index 3d759c7..1dc5e12 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -87,6 +87,7 @@ var updateCmd = &cobra.Command{ var updateConfigFile string func init() { + rootCmd.AddCommand(updateCmd) // Flags for update command updateCmd.Flags().StringVarP(&updateConfigFile, "config", "c", "stacktodate.yml", "Path to stacktodate.yml config file (default: stacktodate.yml)") updateCmd.Flags().BoolVar(&skipAutodetect, "skip-autodetect", false, "Skip autodetection of project technologies") diff --git a/cmd/version.go b/cmd/version.go index f070b93..51aacb1 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -2,16 +2,80 @@ package cmd import ( "fmt" + "os" + "github.com/stacktodate/stacktodate-cli/cmd/lib/installer" + "github.com/stacktodate/stacktodate-cli/cmd/lib/versioncheck" "github.com/stacktodate/stacktodate-cli/internal/version" "github.com/spf13/cobra" ) +var checkUpdates bool + var versionCmd = &cobra.Command{ Use: "version", Short: "Print the version number", Long: `Display the current version of stacktodate`, Run: func(cmd *cobra.Command, args []string) { fmt.Println(version.GetFullVersion()) + + if checkUpdates { + checkForUpdates(true) + } }, } + +func init() { + rootCmd.AddCommand(versionCmd) + versionCmd.Flags().BoolVar(&checkUpdates, "check-updates", false, "Check for newer versions available") +} + +// checkForUpdates checks for a newer version and displays update information +func checkForUpdates(verbose bool) { + latest, releaseURL, err := versioncheck.GetLatestVersion() + if err != nil { + if verbose { + fmt.Fprintf(os.Stderr, "Unable to check for updates: %v\n", err) + } + return + } + + current := version.GetVersion() + isNewer, err := versioncheck.CompareVersions(current, latest) + if err != nil { + if verbose { + fmt.Fprintf(os.Stderr, "Unable to compare versions: %v\n", err) + } + return + } + + if isNewer { + installMethod := installer.DetectInstallMethod() + instructions := installer.GetUpgradeInstructions(installMethod, latest) + + if verbose { + fmt.Printf("\n%s\n", formatUpdateMessage(current, latest, releaseURL, instructions)) + } else { + // Silent notification for automatic checks + fmt.Fprintf(os.Stderr, "\nA new version of stacktodate is available: %s → %s\n", current, latest) + fmt.Fprintf(os.Stderr, "Run 'stacktodate version --check-updates' for upgrade instructions.\n\n") + } + } else if verbose { + fmt.Println("\nYou are using the latest version.") + } +} + +// formatUpdateMessage creates a formatted update notification message +func formatUpdateMessage(current, latest, releaseURL, instructions string) string { + return fmt.Sprintf(` +Update Available +================ + +Current version: %s +Latest version: %s + +%s + +Release notes: %s +`, current, latest, instructions, releaseURL) +} 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= diff --git a/stacktodate.yml b/stacktodate.yml new file mode 100644 index 0000000..6c35789 --- /dev/null +++ b/stacktodate.yml @@ -0,0 +1,6 @@ +uuid: 1fe0b376-1df7-4848-bf2d-525acdce6b82 +name: stacktodate-cli +stack: + go: + version: "1.25" + source: go.mod