Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 12 additions & 19 deletions cmd/autodetect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package cmd

import (
"fmt"
"os"

"github.com/stacktodate/stacktodate-cli/cmd/helpers"
"github.com/spf13/cobra"
)

Expand All @@ -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")
}
},
}
241 changes: 241 additions & 0 deletions cmd/check.go
Original file line number Diff line number Diff line change
@@ -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)")
}
Loading