diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52ed8dc..3f7a839 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.25' cache: true - name: Run GoReleaser diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f1fb18..950e3b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: ['1.21', '1.22'] + go-version: ['1.24', '1.25'] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index ad9ee02..148907f 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,48 @@ Options: - `--skip-autodetect`: Keep existing stack without detection - `--no-interactive`: Use first candidate without prompting +### Check technology versions + +Verify that your `stacktodate.yml` matches the currently detected versions in your project. Perfect for CI/CD pipelines: + +```bash +stacktodate check +``` + +This command: +- Reads your `stacktodate.yml` file +- Detects current versions in your project +- Compares them and reports any differences +- Exits with code 0 if all versions match, 1 if there are differences + +Options: +- `--config, -c`: Path to stacktodate.yml file (default: `stacktodate.yml`) +- `--format, -f`: Output format: `text` (default) or `json` for CI/CD integration + +**Output Example (text format):** +``` +Technology Check Results +======================== + +MATCH (3): + ruby: 3.2.0 == 3.2.0 ✓ + nodejs: 18.0.0 == 18.0.0 ✓ + python: 3.11 == 3.11 ✓ + +MISMATCH (1): + rails: 7.0.0 != 7.1.0 (config has 7.1.0) + +Summary: 3 match, 1 mismatch, 0 missing +Exit code: 1 (has differences) +``` + +**CI/CD Integration (JSON format):** +```bash +stacktodate check --format json +``` + +Returns structured JSON output suitable for parsing in CI/CD pipelines. + ### Push to Stack To Date Upload your detected tech stack to the Stack To Date platform for monitoring and lifecycle tracking: diff --git a/cmd/autodetect.go b/cmd/autodetect.go index 219afc9..5287f0a 100644 --- a/cmd/autodetect.go +++ b/cmd/autodetect.go @@ -2,8 +2,8 @@ package cmd import ( "fmt" - "os" + "github.com/stacktodate/stacktodate-cli/cmd/helpers" "github.com/spf13/cobra" ) @@ -19,25 +19,18 @@ var autodetectCmd = &cobra.Command{ targetDir = args[0] } - // Change to target directory for detection - originalDir, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) - os.Exit(1) - } - - if targetDir != "." { - if err := os.Chdir(targetDir); err != nil { - fmt.Fprintf(os.Stderr, "Error changing to directory %s: %v\n", targetDir, err) - os.Exit(1) - } - defer os.Chdir(originalDir) - } - fmt.Printf("Scanning directory: %s\n", targetDir) - // Detect project information - info := DetectProjectInfo() - PrintDetectedInfo(info) + // Execute detection in target directory + err := helpers.WithWorkingDir(targetDir, func() error { + // Detect project information + info := DetectProjectInfo() + PrintDetectedInfo(info) + return nil + }) + + if err != nil { + helpers.ExitOnError(err, "failed to scan directory") + } }, } diff --git a/cmd/check.go b/cmd/check.go new file mode 100644 index 0000000..c4208b7 --- /dev/null +++ b/cmd/check.go @@ -0,0 +1,241 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/spf13/cobra" +) + +type CheckResult struct { + Status string `json:"status"` + Summary CheckSummary `json:"summary"` + Results CheckResults `json:"results"` +} + +type CheckSummary struct { + Matches int `json:"matches"` + Mismatches int `json:"mismatches"` + MissingConfig int `json:"missing_config"` +} + +type CheckResults struct { + Matched []ComparisonEntry `json:"matched"` + Mismatched []ComparisonEntry `json:"mismatched"` + MissingConfig []ComparisonEntry `json:"missing_config"` +} + +type ComparisonEntry struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Detected string `json:"detected,omitempty"` + Source string `json:"source,omitempty"` +} + +var ( + checkConfigFile string + checkFormat string +) + +var checkCmd = &cobra.Command{ + Use: "check", + 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) { + // Load config without requiring UUID + config, err := helpers.LoadConfig(checkConfigFile) + if err != nil { + helpers.ExitWithError(2, "failed to load config: %v", err) + } + + // 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") + } + } + + // Get config directory + configDir, err := helpers.GetConfigDir(absConfigPath) + if err != nil { + helpers.ExitOnError(err, "failed to get config directory") + } + + // Detect current versions in config directory + var detectedStack map[string]helpers.StackEntry + err = helpers.WithWorkingDir(configDir, func() error { + detectedInfo := DetectProjectInfo() + detectedStack = normalizeDetectedToStack(detectedInfo) + return nil + }) + if err != nil { + helpers.ExitOnError(err, "failed to detect versions") + } + + // Compare stacks + result := compareStacks(config.Stack, detectedStack) + + // Output results + if checkFormat == "json" { + outputJSON(result) + } else { + outputText(result) + } + + // Exit with appropriate code + if result.Status != "match" { + os.Exit(1) + } + }, +} + +func normalizeDetectedToStack(info DetectedInfo) map[string]helpers.StackEntry { + normalized := make(map[string]helpers.StackEntry) + + if len(info.Ruby) > 0 { + normalized["ruby"] = helpers.StackEntry{ + Version: info.Ruby[0].Value, + Source: info.Ruby[0].Source, + } + } + + if len(info.Rails) > 0 { + normalized["rails"] = helpers.StackEntry{ + Version: info.Rails[0].Value, + Source: info.Rails[0].Source, + } + } + + if len(info.Node) > 0 { + normalized["nodejs"] = helpers.StackEntry{ + Version: info.Node[0].Value, + Source: info.Node[0].Source, + } + } + + if len(info.Go) > 0 { + normalized["go"] = helpers.StackEntry{ + Version: info.Go[0].Value, + Source: info.Go[0].Source, + } + } + + if len(info.Python) > 0 { + normalized["python"] = helpers.StackEntry{ + Version: info.Python[0].Value, + Source: info.Python[0].Source, + } + } + + return normalized +} + +func compareStacks(configStack, detectedStack map[string]helpers.StackEntry) CheckResult { + result := CheckResult{ + Results: CheckResults{ + Matched: []ComparisonEntry{}, + Mismatched: []ComparisonEntry{}, + MissingConfig: []ComparisonEntry{}, + }, + } + + // Check all items in config + for tech, configEntry := range configStack { + if detectedEntry, exists := detectedStack[tech]; exists { + if configEntry.Version == detectedEntry.Version { + result.Results.Matched = append(result.Results.Matched, ComparisonEntry{ + Name: tech, + Version: configEntry.Version, + Detected: detectedEntry.Version, + Source: detectedEntry.Source, + }) + result.Summary.Matches++ + } else { + result.Results.Mismatched = append(result.Results.Mismatched, ComparisonEntry{ + Name: tech, + Version: configEntry.Version, + Detected: detectedEntry.Version, + Source: detectedEntry.Source, + }) + result.Summary.Mismatches++ + } + } else { + result.Results.MissingConfig = append(result.Results.MissingConfig, ComparisonEntry{ + Name: tech, + Version: configEntry.Version, + Source: configEntry.Source, + }) + result.Summary.MissingConfig++ + } + } + + // Determine overall status + if result.Summary.Mismatches == 0 && result.Summary.MissingConfig == 0 { + result.Status = "match" + } else { + result.Status = "mismatch" + } + + return result +} + +func outputText(result CheckResult) { + fmt.Println("Technology Check Results") + fmt.Println("========================") + fmt.Println() + + if len(result.Results.Matched) > 0 { + fmt.Printf("MATCH (%d):\n", len(result.Results.Matched)) + for _, entry := range result.Results.Matched { + fmt.Printf(" %-12s %s == %s ✓\n", entry.Name+":", entry.Version, entry.Detected) + } + fmt.Println() + } + + if len(result.Results.Mismatched) > 0 { + fmt.Printf("MISMATCH (%d):\n", len(result.Results.Mismatched)) + for _, entry := range result.Results.Mismatched { + fmt.Printf(" %-12s %s != %s (config has %s)\n", entry.Name+":", entry.Detected, entry.Version, entry.Version) + } + fmt.Println() + } + + if len(result.Results.MissingConfig) > 0 { + fmt.Printf("MISSING FROM DETECTION (%d):\n", len(result.Results.MissingConfig)) + for _, entry := range result.Results.MissingConfig { + fmt.Printf(" %-12s %s (in config but not detected)\n", entry.Name+":", entry.Version) + } + fmt.Println() + } + + fmt.Printf("Summary: %d match, %d mismatch, %d missing\n", + result.Summary.Matches, + result.Summary.Mismatches, + result.Summary.MissingConfig) + + if result.Status == "mismatch" { + fmt.Println("Exit code: 1 (has differences)") + } else { + fmt.Println("Exit code: 0 (all match)") + } +} + +func outputJSON(result CheckResult) { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) + os.Exit(2) + } + fmt.Println(string(data)) +} + +func init() { + rootCmd.AddCommand(checkCmd) + checkCmd.Flags().StringVarP(&checkConfigFile, "config", "c", "", "Path to stacktodate.yml config file (default: stacktodate.yml)") + checkCmd.Flags().StringVarP(&checkFormat, "format", "f", "text", "Output format: text or json (default: text)") +} diff --git a/cmd/check_test.go b/cmd/check_test.go new file mode 100644 index 0000000..3ddf100 --- /dev/null +++ b/cmd/check_test.go @@ -0,0 +1,183 @@ +package cmd + +import ( + "testing" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" +) + +func TestNormalizeDetectedToStack(t *testing.T) { + info := DetectedInfo{ + Ruby: []Candidate{ + {Value: "3.2.0", Source: ".ruby-version"}, + }, + Rails: []Candidate{ + {Value: "7.0.0", Source: "Gemfile"}, + }, + Node: []Candidate{ + {Value: "18.0.0", Source: ".nvmrc"}, + }, + Go: []Candidate{ + {Value: "1.21", Source: "go.mod"}, + }, + Python: []Candidate{ + {Value: "3.11", Source: ".python-version"}, + }, + } + + result := normalizeDetectedToStack(info) + + tests := []struct { + tech string + version string + source string + }{ + {"ruby", "3.2.0", ".ruby-version"}, + {"rails", "7.0.0", "Gemfile"}, + {"nodejs", "18.0.0", ".nvmrc"}, + {"go", "1.21", "go.mod"}, + {"python", "3.11", ".python-version"}, + } + + for _, tt := range tests { + entry, exists := result[tt.tech] + if !exists { + t.Errorf("normalizeDetectedToStack: expected %s to exist in result", tt.tech) + continue + } + + if entry.Version != tt.version { + t.Errorf("normalizeDetectedToStack for %s: expected version %s, got %s", tt.tech, tt.version, entry.Version) + } + + if entry.Source != tt.source { + t.Errorf("normalizeDetectedToStack for %s: expected source %s, got %s", tt.tech, tt.source, entry.Source) + } + } +} + +func TestCompareStacks_AllMatch(t *testing.T) { + configStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + "nodejs": {Version: "18.0.0", Source: ".nvmrc"}, + } + + detectedStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + "nodejs": {Version: "18.0.0", Source: ".nvmrc"}, + } + + result := compareStacks(configStack, detectedStack) + + if result.Status != "match" { + t.Errorf("compareStacks: expected status 'match', got %s", result.Status) + } + + if result.Summary.Matches != 2 { + t.Errorf("compareStacks: expected 2 matches, got %d", result.Summary.Matches) + } + + if result.Summary.Mismatches != 0 { + t.Errorf("compareStacks: expected 0 mismatches, got %d", result.Summary.Mismatches) + } +} + +func TestCompareStacks_WithMismatch(t *testing.T) { + configStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + "rails": {Version: "7.1.0", Source: "Gemfile"}, + } + + detectedStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + "rails": {Version: "7.0.0", Source: "Gemfile"}, + } + + result := compareStacks(configStack, detectedStack) + + if result.Status != "mismatch" { + t.Errorf("compareStacks: expected status 'mismatch', got %s", result.Status) + } + + if result.Summary.Matches != 1 { + t.Errorf("compareStacks: expected 1 match, got %d", result.Summary.Matches) + } + + if result.Summary.Mismatches != 1 { + t.Errorf("compareStacks: expected 1 mismatch, got %d", result.Summary.Mismatches) + } +} + +func TestCompareStacks_MissingConfig(t *testing.T) { + configStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + "nodejs": {Version: "18.0.0", Source: ".nvmrc"}, + } + + detectedStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + } + + result := compareStacks(configStack, detectedStack) + + if result.Status != "mismatch" { + t.Errorf("compareStacks: expected status 'mismatch', got %s", result.Status) + } + + if result.Summary.MissingConfig != 1 { + t.Errorf("compareStacks: expected 1 missing config, got %d", result.Summary.MissingConfig) + } + + if len(result.Results.MissingConfig) != 1 { + t.Errorf("compareStacks: expected 1 item in MissingConfig, got %d", len(result.Results.MissingConfig)) + } +} + +func TestCompareStacks_Complex(t *testing.T) { + configStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + "rails": {Version: "7.1.0", Source: "Gemfile"}, + "nodejs": {Version: "18.0.0", Source: ".nvmrc"}, + "python": {Version: "3.10", Source: ".python-version"}, + } + + detectedStack := map[string]helpers.StackEntry{ + "ruby": {Version: "3.2.0", Source: ".ruby-version"}, + "rails": {Version: "7.0.0", Source: "Gemfile"}, + "nodejs": {Version: "18.0.0", Source: ".nvmrc"}, + "go": {Version: "1.21", Source: "go.mod"}, + } + + result := compareStacks(configStack, detectedStack) + + if result.Status != "mismatch" { + t.Errorf("compareStacks: expected status 'mismatch', got %s", result.Status) + } + + if result.Summary.Matches != 2 { + t.Errorf("compareStacks: expected 2 matches, got %d", result.Summary.Matches) + } + + if result.Summary.Mismatches != 1 { + t.Errorf("compareStacks: expected 1 mismatch, got %d", result.Summary.Mismatches) + } + + if result.Summary.MissingConfig != 1 { + t.Errorf("compareStacks: expected 1 missing config, got %d", result.Summary.MissingConfig) + } +} + +func TestCompareStacks_Empty(t *testing.T) { + configStack := map[string]helpers.StackEntry{} + detectedStack := map[string]helpers.StackEntry{} + + result := compareStacks(configStack, detectedStack) + + if result.Status != "match" { + t.Errorf("compareStacks: expected status 'match', got %s", result.Status) + } + + if result.Summary.Matches != 0 { + t.Errorf("compareStacks: expected 0 matches, got %d", result.Summary.Matches) + } +} diff --git a/cmd/detect.go b/cmd/detect.go index 35c36ff..ae771da 100644 --- a/cmd/detect.go +++ b/cmd/detect.go @@ -1,13 +1,11 @@ package cmd import ( - "encoding/json" "fmt" - "io" - "net/http" "regexp" "strings" + "github.com/stacktodate/stacktodate-cli/cmd/lib/cache" "github.com/stacktodate/stacktodate-cli/cmd/lib/detectors" ) @@ -23,14 +21,6 @@ type DetectedInfo struct { Docker []detectors.Candidate } -type EOLProduct struct { - Cycle string `json:"cycle"` - Release string `json:"release"` - LTS interface{} `json:"lts"` // Can be bool or string (date) - Support interface{} `json:"support"` // Can be bool or string (date) - EOL interface{} `json:"eol"` // Can be bool or string (date) -} - // cleanVersion removes version operators and extracts the core version // Examples: ~> 7.1.0 -> 7.1.0, >= 18.0.0 -> 18.0.0, <= 3.11 -> 3.11 func cleanVersion(version string) string { @@ -64,7 +54,7 @@ func cleanCandidateVersions(candidates []detectors.Candidate) []detectors.Candid return candidates } -// truncateCandidateVersions truncates all candidate versions to match endoflife.date API cycles +// truncateCandidateVersions truncates all candidate versions to match stacktodate.club API cycles func truncateCandidateVersions(candidates []detectors.Candidate, product string) []detectors.Candidate { for i := range candidates { candidates[i].Value = truncateVersionToEOLCycle(product, candidates[i].Value) @@ -145,7 +135,7 @@ func DetectProjectInfo() DetectedInfo { } } - // Truncate versions to match endoflife.date API cycles + // Truncate versions to match stacktodate.club API cycles info.Ruby = truncateCandidateVersions(info.Ruby, "ruby") info.Rails = truncateCandidateVersions(info.Rails, "rails") info.Node = truncateCandidateVersions(info.Node, "nodejs") @@ -155,39 +145,49 @@ func DetectProjectInfo() DetectedInfo { return info } -// truncateVersionToEOLCycle truncates a version to match the format used by endoflife.date API -// It tries to find the best matching cycle by progressively truncating the version -// Examples: 3.11.0 -> 3.11, 18.0.0 -> 18, 7.1.0 -> 7.1 -func truncateVersionToEOLCycle(product, version string) string { - if product == "" || version == "" { - return version +// mapProductNameToCacheKey maps internal product names to stacktodate.club API keys +func mapProductNameToCacheKey(product string) string { + mapping := map[string]string{ + "ruby": "ruby", + "rails": "rails", + "nodejs": "nodejs", + "go": "go", + "python": "python", } - url := fmt.Sprintf("https://endoflife.date/api/%s.json", product) - resp, err := http.Get(url) - if err != nil { - return version + if key, exists := mapping[product]; exists { + return key } - defer resp.Body.Close() + return product +} - if resp.StatusCode != http.StatusOK { +// truncateVersionToEOLCycle truncates a version to match the format used by stacktodate.club API +// It tries to find the best matching cycle by progressively truncating the version +// Examples: 3.11.0 -> 3.11, 18.0.0 -> 18, 7.1.0 -> 7.1 +func truncateVersionToEOLCycle(product, version string) string { + if product == "" || version == "" { return version } - body, err := io.ReadAll(resp.Body) + // Get products from cache (auto-fetches if needed or stale) + products, err := cache.GetProducts() if err != nil { + // Graceful fallback: return original version if cache fetch fails return version } - var products []EOLProduct - if err := json.Unmarshal(body, &products); err != nil { + // Map product name to cache key + cacheKey := mapProductNameToCacheKey(product) + cachedProduct := cache.GetProductByKey(cacheKey, products) + if cachedProduct == nil { + // Product not found in cache, return original version return version } // Build a set of available cycles for quick lookup cycles := make(map[string]bool) - for _, p := range products { - cycles[p.Cycle] = true + for _, release := range cachedProduct.Releases { + cycles[release.ReleaseCycle] = true } // If exact match exists, return as-is @@ -197,7 +197,7 @@ func truncateVersionToEOLCycle(product, version string) string { // Split version into parts parts := strings.Split(version, ".") - + // Try major.minor (e.g., 3.11 from 3.11.0) if len(parts) >= 2 { majorMinor := parts[0] + "." + parts[1] @@ -205,7 +205,7 @@ func truncateVersionToEOLCycle(product, version string) string { return majorMinor } } - + // Try major only (e.g., 18 from 18.0.0) if len(parts) >= 1 { major := parts[0] @@ -223,40 +223,30 @@ func getEOLStatus(product, version string) string { return "" } - url := fmt.Sprintf("https://endoflife.date/api/%s.json", product) - resp, err := http.Get(url) - if err != nil { - return "" - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "" - } - - body, err := io.ReadAll(resp.Body) + // Get products from cache (auto-fetches if needed or stale) + products, err := cache.GetProducts() if err != nil { + // Graceful fallback: return empty string if cache fetch fails return "" } - var products []EOLProduct - if err := json.Unmarshal(body, &products); err != nil { + // Map product name to cache key + cacheKey := mapProductNameToCacheKey(product) + cachedProduct := cache.GetProductByKey(cacheKey, products) + if cachedProduct == nil { + // Product not found in cache, return empty string return "" } - // Find the matching cycle - for _, p := range products { - if p.Cycle == version { - // Check if EOL is false (bool) or empty - if eolBool, ok := p.EOL.(bool); ok && !eolBool { + // Find the matching release cycle + for _, release := range cachedProduct.Releases { + if release.ReleaseCycle == version { + // Check if EOL is empty (still supported) + if release.EOL == "" { return " (supported)" } - if eolStr, ok := p.EOL.(string); ok { - if eolStr == "" || eolStr == "false" { - return " (supported)" - } - return fmt.Sprintf(" (EOL: %s)", eolStr) - } + // Return EOL date + return fmt.Sprintf(" (EOL: %s)", release.EOL) } } diff --git a/cmd/fetch_catalog.go b/cmd/fetch_catalog.go new file mode 100644 index 0000000..a254f9f --- /dev/null +++ b/cmd/fetch_catalog.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/stacktodate/stacktodate-cli/cmd/lib/cache" + "github.com/spf13/cobra" +) + +var fetchCatalogCmd = &cobra.Command{ + Use: "fetch-catalog", + Short: "Fetch and cache the product catalog from stacktodate.club", + Long: `Fetch the complete list of products and their release information from stacktodate.club API +and store it locally for faster version detection and truncation. + +The catalog is cached in ~/.stacktodate/products-cache.json and automatically refreshed +once every 24 hours. You can use this command to manually refresh the cache at any time.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintf(os.Stderr, "Fetching product catalog from stacktodate.club...\n") + + if err := cache.FetchAndCache(); err != nil { + helpers.ExitOnError(err, "failed to fetch catalog") + } + + // Load and display info about cached products + products, err := cache.LoadCache() + if err != nil { + helpers.ExitOnError(err, "failed to load cached products") + } + + cachePath, _ := cache.GetCachePath() + fmt.Fprintf(os.Stderr, "✓ Successfully cached %d products\n", len(products.Products)) + fmt.Fprintf(os.Stderr, "Cache location: %s\n", cachePath) + }, +} + +func init() { + rootCmd.AddCommand(fetchCatalogCmd) +} diff --git a/cmd/helpers/config.go b/cmd/helpers/config.go new file mode 100644 index 0000000..220260d --- /dev/null +++ b/cmd/helpers/config.go @@ -0,0 +1,55 @@ +package helpers + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config represents the stacktodate.yml structure +type Config struct { + UUID string `yaml:"uuid"` + Name string `yaml:"name"` + Stack map[string]StackEntry `yaml:"stack,omitempty"` +} + +// StackEntry represents a single technology entry in the stack +type StackEntry struct { + Version string `yaml:"version"` + Source string `yaml:"source"` +} + +// LoadConfig reads and parses a config file from the given path +// If path is empty, uses "stacktodate.yml" as default +func LoadConfig(configPath string) (*Config, error) { + if configPath == "" { + configPath = "stacktodate.yml" + } + + content, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("reading config file %s: %w", configPath, err) + } + + var config Config + if err := yaml.Unmarshal(content, &config); err != nil { + return nil, fmt.Errorf("parsing config file %s: %w", configPath, err) + } + + return &config, nil +} + +// LoadConfigWithDefaults loads a config file with optional UUID validation +func LoadConfigWithDefaults(configPath string, requireUUID bool) (*Config, error) { + config, err := LoadConfig(configPath) + if err != nil { + return nil, err + } + + if requireUUID && config.UUID == "" { + return nil, fmt.Errorf("uuid not found in config file") + } + + return config, nil +} diff --git a/cmd/helpers/env.go b/cmd/helpers/env.go new file mode 100644 index 0000000..10e92dc --- /dev/null +++ b/cmd/helpers/env.go @@ -0,0 +1,23 @@ +package helpers + +import ( + "fmt" + "os" +) + +// GetEnvRequired retrieves an environment variable or returns an error if not set +func GetEnvRequired(key string) (string, error) { + value := os.Getenv(key) + if value == "" { + return "", fmt.Errorf("environment variable %s not set", key) + } + return value, nil +} + +// GetEnvOrDefault retrieves an environment variable or returns the default value if not set +func GetEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/cmd/helpers/error.go b/cmd/helpers/error.go new file mode 100644 index 0000000..5f0318e --- /dev/null +++ b/cmd/helpers/error.go @@ -0,0 +1,20 @@ +package helpers + +import ( + "fmt" + "os" +) + +// ExitWithError prints formatted error message to stderr and exits with given code +func ExitWithError(exitCode int, format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) + os.Exit(exitCode) +} + +// ExitOnError is a convenience wrapper that exits with code 1 +func ExitOnError(err error, format string, args ...interface{}) { + if err != nil { + args = append(args, err) + ExitWithError(1, format+": %v", args...) + } +} diff --git a/cmd/helpers/fs.go b/cmd/helpers/fs.go new file mode 100644 index 0000000..fd45c6c --- /dev/null +++ b/cmd/helpers/fs.go @@ -0,0 +1,54 @@ +package helpers + +import ( + "fmt" + "os" + "path/filepath" +) + +// ResolveAbsPath resolves a path to its absolute form +func ResolveAbsPath(path string) (string, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolving path %s: %w", path, err) + } + return absPath, nil +} + +// GetConfigDir returns the directory containing the config file +func GetConfigDir(configPath string) (string, error) { + absPath, err := ResolveAbsPath(configPath) + if err != nil { + return "", err + } + + dir := filepath.Dir(absPath) + if dir == "" { + dir = "." + } + + return dir, nil +} + +// WithWorkingDir executes the given function in the specified directory, +// then restores the original working directory. Errors from either +// directory change or the function are returned. +func WithWorkingDir(targetDir string, fn func() error) error { + if targetDir == "." || targetDir == "" { + // No need to change directory + return fn() + } + + originalDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + if err := os.Chdir(targetDir); err != nil { + return fmt.Errorf("changing to directory %s: %w", targetDir, err) + } + + defer os.Chdir(originalDir) + + return fn() +} diff --git a/cmd/init.go b/cmd/init.go index 150b435..a10408a 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,34 +1,24 @@ package cmd import ( -"bufio" -"fmt" -"os" -"strconv" -"strings" - -"github.com/spf13/cobra" -"gopkg.in/yaml.v3" + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var ( -uuid string -name string -skipAutodetect bool -noInteractive bool + uuid string + name string + skipAutodetect bool + noInteractive bool ) -type StackEntry struct { - Version string `yaml:"version"` - Source string `yaml:"source"` -} - -type Config struct { - UUID string `yaml:"uuid"` - Name string `yaml:"name"` - Stack map[string]StackEntry `yaml:"stack,omitempty"` -} - var initCmd = &cobra.Command{ Use: "init [path]", Short: "Initialize a new project", @@ -41,32 +31,22 @@ var initCmd = &cobra.Command{ targetDir = args[0] } - // Change to target directory for detection - originalDir, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) - os.Exit(1) - } - - if targetDir != "." { - if err := os.Chdir(targetDir); err != nil { - fmt.Fprintf(os.Stderr, "Error changing to directory %s: %v\n", targetDir, err) - os.Exit(1) - } - defer os.Chdir(originalDir) - } - fmt.Printf("Initializing project in: %s\n", targetDir) reader := bufio.NewReader(os.Stdin) - // Detect project information - var info DetectedInfo - var detectedTechs map[string]StackEntry + // Detect project information in target directory + var detectedTechs map[string]helpers.StackEntry if !skipAutodetect { - info = DetectProjectInfo() - PrintDetectedInfo(info) - detectedTechs = selectCandidates(reader, info) + err := helpers.WithWorkingDir(targetDir, func() error { + info := DetectProjectInfo() + PrintDetectedInfo(info) + detectedTechs = selectCandidates(reader, info) + return nil + }) + if err != nil { + helpers.ExitOnError(err, "failed to detect project") + } } // Get UUID @@ -84,7 +64,7 @@ var initCmd = &cobra.Command{ } // Create config - config := Config{ + config := helpers.Config{ UUID: uuid, Name: name, Stack: detectedTechs, @@ -93,15 +73,13 @@ var initCmd = &cobra.Command{ // Marshal to YAML data, err := yaml.Marshal(&config) if err != nil { - fmt.Fprintf(os.Stderr, "Error creating configuration: %v\n", err) - os.Exit(1) + helpers.ExitOnError(err, "failed to create configuration") } // Write to file err = os.WriteFile("stacktodate.yml", data, 0644) if err != nil { - fmt.Fprintf(os.Stderr, "Error writing stacktodate.yml: %v\n", err) - os.Exit(1) + helpers.ExitOnError(err, "failed to write stacktodate.yml") } fmt.Println("\nProject initialized successfully!") @@ -118,8 +96,8 @@ var initCmd = &cobra.Command{ } // selectCandidates allows user to select from detected candidates -func selectCandidates(reader *bufio.Reader, info DetectedInfo) map[string]StackEntry { - selected := make(map[string]StackEntry) +func selectCandidates(reader *bufio.Reader, info DetectedInfo) map[string]helpers.StackEntry { + selected := make(map[string]helpers.StackEntry) // Ruby if len(info.Ruby) > 0 { @@ -165,10 +143,10 @@ func selectCandidates(reader *bufio.Reader, info DetectedInfo) map[string]StackE } // selectFromCandidates lets user choose one candidate or none -func selectFromCandidates(reader *bufio.Reader, tech string, candidates []Candidate) StackEntry { +func selectFromCandidates(reader *bufio.Reader, tech string, candidates []Candidate) helpers.StackEntry { if noInteractive { // In non-interactive mode, use the first candidate - return StackEntry{ + return helpers.StackEntry{ Version: candidates[0].Value, Source: candidates[0].Source, } @@ -186,7 +164,7 @@ func selectFromCandidates(reader *bufio.Reader, tech string, candidates []Candid choice := strings.TrimSpace(input) if choice == "0" || choice == "" { - return StackEntry{} + return helpers.StackEntry{} } idx, err := strconv.Atoi(choice) @@ -195,7 +173,7 @@ func selectFromCandidates(reader *bufio.Reader, tech string, candidates []Candid continue } - return StackEntry{ + return helpers.StackEntry{ Version: candidates[idx-1].Value, Source: candidates[idx-1].Source, } diff --git a/cmd/lib/cache/cache.go b/cmd/lib/cache/cache.go new file mode 100644 index 0000000..15d1a21 --- /dev/null +++ b/cmd/lib/cache/cache.go @@ -0,0 +1,199 @@ +package cache + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +type ProductsCache struct { + Timestamp time.Time `json:"timestamp"` + Products []Product `json:"products"` +} + +type Product struct { + Key string `json:"key"` + Name string `json:"name"` + Releases []Release `json:"releases"` +} + +type Release struct { + ReleaseCycle string `json:"releaseCycle"` + ReleaseDate string `json:"releaseDate"` + Support string `json:"support,omitempty"` + Extended string `json:"extended,omitempty"` + EOL string `json:"eol,omitempty"` + LTS bool `json:"lts"` +} + +const cacheFileName = "products-cache.json" +const cacheDirName = ".stacktodate" +const cacheTTL = 24 * time.Hour + +// GetCachePath returns the full path to the cache file +func GetCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + cacheDir := filepath.Join(home, cacheDirName) + return filepath.Join(cacheDir, cacheFileName), nil +} + +// IsCacheValid checks if cache exists and is less than 24 hours old +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 cached products from disk +func LoadCache() (*ProductsCache, error) { + cachePath, err := GetCachePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, fmt.Errorf("failed to read cache file: %w", err) + } + + var cache ProductsCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("failed to parse cache file: %w", err) + } + + return &cache, nil +} + +// SaveCache saves products to cache file with timestamp +func SaveCache(products []Product) error { + cachePath, err := GetCachePath() + if err != nil { + return err + } + + // Create directory if it doesn't exist + cacheDir := filepath.Dir(cachePath) + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + cache := ProductsCache{ + Timestamp: time.Now(), + Products: products, + } + + data, err := json.MarshalIndent(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 +} + +// FetchAndCache downloads products from stacktodate.club API and caches them +func FetchAndCache() error { + return FetchAndCacheWithURL(GetAPIURL()) +} + +// FetchAndCacheWithURL is the internal function that fetches with a specific URL +func FetchAndCacheWithURL(apiURL string) error { + url := fmt.Sprintf("%s/api/v1/products", apiURL) + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to fetch from API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var products []Product + if err := json.Unmarshal(body, &products); err != nil { + return fmt.Errorf("failed to parse API response: %w", err) + } + + if err := SaveCache(products); err != nil { + return err + } + + return nil +} + +// GetAPIURL returns the API URL from environment or default +func GetAPIURL() string { + apiURL := os.Getenv("STD_API_URL") + if apiURL == "" { + apiURL = "https://stacktodate.club" + } + return apiURL +} + +// GetProducts returns cached products, fetching if necessary +// It handles cache expiration and auto-fetches if cache is stale +func GetProducts() ([]Product, error) { + // Check if cache is valid + if IsCacheValid() { + cache, err := LoadCache() + if err == nil && cache != nil { + return cache.Products, nil + } + } + + // Cache is invalid or missing, fetch new data + if err := FetchAndCache(); err != nil { + // If fetch fails, try to return stale cache as fallback + cache, err2 := LoadCache() + if err2 == nil && cache != nil { + return cache.Products, nil + } + // Both fetch and fallback failed + return nil, fmt.Errorf("failed to fetch products and no valid cache available: %w", err) + } + + // Return the newly cached products + cache, err := LoadCache() + if err != nil { + return nil, err + } + + return cache.Products, nil +} + +// GetProductByKey finds a product in the cache by its key +func GetProductByKey(key string, products []Product) *Product { + for i := range products { + if products[i].Key == key { + return &products[i] + } + } + return nil +} + diff --git a/cmd/push.go b/cmd/push.go index ed0e8d0..f6b3c7f 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -6,10 +6,10 @@ import ( "fmt" "io" "net/http" - "os" + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/stacktodate/stacktodate-cli/cmd/lib/cache" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) var ( @@ -40,44 +40,20 @@ var pushCmd = &cobra.Command{ Short: "Push tech stack components to the API", Long: `Push the components defined in stacktodate.yml to the remote API`, Run: func(cmd *cobra.Command, args []string) { - // Determine config file - configPath := configFile - if configPath == "" { - configPath = "stacktodate.yml" - } - - // Read stacktodate.yml - content, err := os.ReadFile(configPath) + // Load config with UUID validation + config, err := helpers.LoadConfigWithDefaults(configFile, true) if err != nil { - fmt.Fprintf(os.Stderr, "Error reading config file %s: %v\n", configPath, err) - os.Exit(1) - } - - // Parse YAML - var config Config - if err := yaml.Unmarshal(content, &config); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", configPath, err) - os.Exit(1) - } - - // Validate config - if config.UUID == "" { - fmt.Fprintf(os.Stderr, "Error: uuid not found in %s\n", configPath) - os.Exit(1) + helpers.ExitOnError(err, "failed to load config") } // Get token from environment - token := os.Getenv("STD_TOKEN") - if token == "" { - fmt.Fprintf(os.Stderr, "Error: STD_TOKEN environment variable not set\n") - os.Exit(1) + token, err := helpers.GetEnvRequired("STD_TOKEN") + if err != nil { + helpers.ExitOnError(err, "") } // Get API URL from environment or use default - apiURL := os.Getenv("STD_API_URL") - if apiURL == "" { - apiURL = "https://stacktodate.club" - } + apiURL := cache.GetAPIURL() // Convert stack to components components := convertStackToComponents(config.Stack) @@ -89,15 +65,14 @@ var pushCmd = &cobra.Command{ // Make API call if err := pushToAPI(apiURL, config.UUID, token, request); err != nil { - fmt.Fprintf(os.Stderr, "Error pushing to API: %v\n", err) - os.Exit(1) + helpers.ExitOnError(err, "failed to push to API") } fmt.Printf("✓ Successfully pushed %d components\n", len(components)) }, } -func convertStackToComponents(stack map[string]StackEntry) []Component { +func convertStackToComponents(stack map[string]helpers.StackEntry) []Component { var components []Component for name, entry := range stack { diff --git a/cmd/root.go b/cmd/root.go index 020353e..3529480 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,8 +2,8 @@ package cmd import ( "fmt" - "os" + "github.com/stacktodate/stacktodate-cli/cmd/helpers" "github.com/stacktodate/stacktodate-cli/internal/version" "github.com/spf13/cobra" ) @@ -24,8 +24,7 @@ var rootCmd = &cobra.Command{ func Execute() { if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + helpers.ExitWithError(1, "%v", err) } } diff --git a/cmd/update.go b/cmd/update.go index 16eacd1..3d759c7 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,13 +1,13 @@ package cmd import ( - "bufio" - "fmt" - "os" - "path/filepath" + "bufio" + "fmt" + "os" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" + "github.com/stacktodate/stacktodate-cli/cmd/helpers" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // updateCmd updates an existing stacktodate.yml file's stack using autodetect @@ -17,63 +17,44 @@ var updateCmd = &cobra.Command{ Long: "Run autodetect and update the provided stacktodate.yml's stack, preserving uuid and name.", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - // Read config path from flag or default - targetFile := updateConfigFile - if targetFile == "" { - targetFile = "stacktodate.yml" - } - - // Resolve absolute path for robust read/write regardless of chdir - absTargetFile, err := filepath.Abs(targetFile) + // Load existing config without requiring UUID + config, err := helpers.LoadConfig(updateConfigFile) if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving absolute path for %s: %v\n", targetFile, err) - os.Exit(1) + helpers.ExitOnError(err, "failed to load config") } - // Read existing config - content, err := os.ReadFile(absTargetFile) + // Resolve absolute path + absTargetFile, err := helpers.ResolveAbsPath(updateConfigFile) if err != nil { - fmt.Fprintf(os.Stderr, "Error reading config file %s: %v\n", targetFile, err) - os.Exit(1) - } - - var config Config - if err := yaml.Unmarshal(content, &config); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", targetFile, err) - os.Exit(1) + if updateConfigFile == "" { + absTargetFile, _ = helpers.ResolveAbsPath("stacktodate.yml") + } else { + helpers.ExitOnError(err, "failed to resolve config path") + } } - // Change to directory of the target file to run detection there - originalDir, err := os.Getwd() + // Get target directory + targetDir, err := helpers.GetConfigDir(absTargetFile) if err != nil { - fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) - os.Exit(1) + helpers.ExitOnError(err, "failed to get config directory") } - targetDir := filepath.Dir(absTargetFile) - if targetDir == "" { - targetDir = "." - } + fmt.Printf("Updating stack in: %s\n", updateConfigFile) - if targetDir != "." { - if err := os.Chdir(targetDir); err != nil { - fmt.Fprintf(os.Stderr, "Error changing to directory %s: %v\n", targetDir, err) - os.Exit(1) - } - defer os.Chdir(originalDir) - } - - fmt.Printf("Updating stack in: %s\n", targetFile) - - // Detect project information + // Detect project information in target directory reader := bufio.NewReader(os.Stdin) - var info DetectedInfo - var detectedTechs map[string]StackEntry + var detectedTechs map[string]helpers.StackEntry if !skipAutodetect { - info = DetectProjectInfo() - PrintDetectedInfo(info) - detectedTechs = selectCandidates(reader, info) + err = helpers.WithWorkingDir(targetDir, func() error { + info := DetectProjectInfo() + PrintDetectedInfo(info) + detectedTechs = selectCandidates(reader, info) + return nil + }) + if err != nil { + helpers.ExitOnError(err, "failed to detect project") + } } else { // If autodetect is skipped, keep existing stack detectedTechs = config.Stack @@ -85,14 +66,12 @@ var updateCmd = &cobra.Command{ // Marshal to YAML data, err := yaml.Marshal(&config) if err != nil { - fmt.Fprintf(os.Stderr, "Error creating configuration: %v\n", err) - os.Exit(1) + helpers.ExitOnError(err, "failed to create configuration") } // Write back to the original absolute file path if err := os.WriteFile(absTargetFile, data, 0644); err != nil { - fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", targetFile, err) - os.Exit(1) + helpers.ExitOnError(err, "failed to write config") } fmt.Println("\nStack updated successfully!") diff --git a/go.mod b/go.mod index 1d25302..3bce8df 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stacktodate/stacktodate-cli -go 1.21 +go 1.25 require ( github.com/spf13/cobra v1.8.0