Skip to content

Commit ff24497

Browse files
authored
Merge pull request #1 from stacktodate/products-from-stacktodate
Products from stacktodate
2 parents 26c2339 + 549952e commit ff24497

File tree

18 files changed

+999
-227
lines changed

18 files changed

+999
-227
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
- name: Set up Go
2424
uses: actions/setup-go@v5
2525
with:
26-
go-version: '1.21'
26+
go-version: '1.25'
2727
cache: true
2828

2929
- name: Run GoReleaser

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
go-version: ['1.21', '1.22']
14+
go-version: ['1.24', '1.25']
1515

1616
steps:
1717
- uses: actions/checkout@v4

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,48 @@ Options:
149149
- `--skip-autodetect`: Keep existing stack without detection
150150
- `--no-interactive`: Use first candidate without prompting
151151

152+
### Check technology versions
153+
154+
Verify that your `stacktodate.yml` matches the currently detected versions in your project. Perfect for CI/CD pipelines:
155+
156+
```bash
157+
stacktodate check
158+
```
159+
160+
This command:
161+
- Reads your `stacktodate.yml` file
162+
- Detects current versions in your project
163+
- Compares them and reports any differences
164+
- Exits with code 0 if all versions match, 1 if there are differences
165+
166+
Options:
167+
- `--config, -c`: Path to stacktodate.yml file (default: `stacktodate.yml`)
168+
- `--format, -f`: Output format: `text` (default) or `json` for CI/CD integration
169+
170+
**Output Example (text format):**
171+
```
172+
Technology Check Results
173+
========================
174+
175+
MATCH (3):
176+
ruby: 3.2.0 == 3.2.0 ✓
177+
nodejs: 18.0.0 == 18.0.0 ✓
178+
python: 3.11 == 3.11 ✓
179+
180+
MISMATCH (1):
181+
rails: 7.0.0 != 7.1.0 (config has 7.1.0)
182+
183+
Summary: 3 match, 1 mismatch, 0 missing
184+
Exit code: 1 (has differences)
185+
```
186+
187+
**CI/CD Integration (JSON format):**
188+
```bash
189+
stacktodate check --format json
190+
```
191+
192+
Returns structured JSON output suitable for parsing in CI/CD pipelines.
193+
152194
### Push to Stack To Date
153195

154196
Upload your detected tech stack to the Stack To Date platform for monitoring and lifecycle tracking:

cmd/autodetect.go

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ package cmd
22

33
import (
44
"fmt"
5-
"os"
65

6+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
77
"github.com/spf13/cobra"
88
)
99

@@ -19,25 +19,18 @@ var autodetectCmd = &cobra.Command{
1919
targetDir = args[0]
2020
}
2121

22-
// Change to target directory for detection
23-
originalDir, err := os.Getwd()
24-
if err != nil {
25-
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
26-
os.Exit(1)
27-
}
28-
29-
if targetDir != "." {
30-
if err := os.Chdir(targetDir); err != nil {
31-
fmt.Fprintf(os.Stderr, "Error changing to directory %s: %v\n", targetDir, err)
32-
os.Exit(1)
33-
}
34-
defer os.Chdir(originalDir)
35-
}
36-
3722
fmt.Printf("Scanning directory: %s\n", targetDir)
3823

39-
// Detect project information
40-
info := DetectProjectInfo()
41-
PrintDetectedInfo(info)
24+
// Execute detection in target directory
25+
err := helpers.WithWorkingDir(targetDir, func() error {
26+
// Detect project information
27+
info := DetectProjectInfo()
28+
PrintDetectedInfo(info)
29+
return nil
30+
})
31+
32+
if err != nil {
33+
helpers.ExitOnError(err, "failed to scan directory")
34+
}
4235
},
4336
}

cmd/check.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
8+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
type CheckResult struct {
13+
Status string `json:"status"`
14+
Summary CheckSummary `json:"summary"`
15+
Results CheckResults `json:"results"`
16+
}
17+
18+
type CheckSummary struct {
19+
Matches int `json:"matches"`
20+
Mismatches int `json:"mismatches"`
21+
MissingConfig int `json:"missing_config"`
22+
}
23+
24+
type CheckResults struct {
25+
Matched []ComparisonEntry `json:"matched"`
26+
Mismatched []ComparisonEntry `json:"mismatched"`
27+
MissingConfig []ComparisonEntry `json:"missing_config"`
28+
}
29+
30+
type ComparisonEntry struct {
31+
Name string `json:"name"`
32+
Version string `json:"version,omitempty"`
33+
Detected string `json:"detected,omitempty"`
34+
Source string `json:"source,omitempty"`
35+
}
36+
37+
var (
38+
checkConfigFile string
39+
checkFormat string
40+
)
41+
42+
var checkCmd = &cobra.Command{
43+
Use: "check",
44+
Short: "Check if detected versions match stacktodate.yml",
45+
Long: `Verify that the versions in stacktodate.yml match the currently detected versions in your project. Useful for CI/CD pipelines.`,
46+
Run: func(cmd *cobra.Command, args []string) {
47+
// Load config without requiring UUID
48+
config, err := helpers.LoadConfig(checkConfigFile)
49+
if err != nil {
50+
helpers.ExitWithError(2, "failed to load config: %v", err)
51+
}
52+
53+
// Resolve absolute path for directory management
54+
absConfigPath, err := helpers.ResolveAbsPath(checkConfigFile)
55+
if err != nil {
56+
if checkConfigFile == "" {
57+
absConfigPath, _ = helpers.ResolveAbsPath("stacktodate.yml")
58+
} else {
59+
helpers.ExitOnError(err, "failed to resolve config path")
60+
}
61+
}
62+
63+
// Get config directory
64+
configDir, err := helpers.GetConfigDir(absConfigPath)
65+
if err != nil {
66+
helpers.ExitOnError(err, "failed to get config directory")
67+
}
68+
69+
// Detect current versions in config directory
70+
var detectedStack map[string]helpers.StackEntry
71+
err = helpers.WithWorkingDir(configDir, func() error {
72+
detectedInfo := DetectProjectInfo()
73+
detectedStack = normalizeDetectedToStack(detectedInfo)
74+
return nil
75+
})
76+
if err != nil {
77+
helpers.ExitOnError(err, "failed to detect versions")
78+
}
79+
80+
// Compare stacks
81+
result := compareStacks(config.Stack, detectedStack)
82+
83+
// Output results
84+
if checkFormat == "json" {
85+
outputJSON(result)
86+
} else {
87+
outputText(result)
88+
}
89+
90+
// Exit with appropriate code
91+
if result.Status != "match" {
92+
os.Exit(1)
93+
}
94+
},
95+
}
96+
97+
func normalizeDetectedToStack(info DetectedInfo) map[string]helpers.StackEntry {
98+
normalized := make(map[string]helpers.StackEntry)
99+
100+
if len(info.Ruby) > 0 {
101+
normalized["ruby"] = helpers.StackEntry{
102+
Version: info.Ruby[0].Value,
103+
Source: info.Ruby[0].Source,
104+
}
105+
}
106+
107+
if len(info.Rails) > 0 {
108+
normalized["rails"] = helpers.StackEntry{
109+
Version: info.Rails[0].Value,
110+
Source: info.Rails[0].Source,
111+
}
112+
}
113+
114+
if len(info.Node) > 0 {
115+
normalized["nodejs"] = helpers.StackEntry{
116+
Version: info.Node[0].Value,
117+
Source: info.Node[0].Source,
118+
}
119+
}
120+
121+
if len(info.Go) > 0 {
122+
normalized["go"] = helpers.StackEntry{
123+
Version: info.Go[0].Value,
124+
Source: info.Go[0].Source,
125+
}
126+
}
127+
128+
if len(info.Python) > 0 {
129+
normalized["python"] = helpers.StackEntry{
130+
Version: info.Python[0].Value,
131+
Source: info.Python[0].Source,
132+
}
133+
}
134+
135+
return normalized
136+
}
137+
138+
func compareStacks(configStack, detectedStack map[string]helpers.StackEntry) CheckResult {
139+
result := CheckResult{
140+
Results: CheckResults{
141+
Matched: []ComparisonEntry{},
142+
Mismatched: []ComparisonEntry{},
143+
MissingConfig: []ComparisonEntry{},
144+
},
145+
}
146+
147+
// Check all items in config
148+
for tech, configEntry := range configStack {
149+
if detectedEntry, exists := detectedStack[tech]; exists {
150+
if configEntry.Version == detectedEntry.Version {
151+
result.Results.Matched = append(result.Results.Matched, ComparisonEntry{
152+
Name: tech,
153+
Version: configEntry.Version,
154+
Detected: detectedEntry.Version,
155+
Source: detectedEntry.Source,
156+
})
157+
result.Summary.Matches++
158+
} else {
159+
result.Results.Mismatched = append(result.Results.Mismatched, ComparisonEntry{
160+
Name: tech,
161+
Version: configEntry.Version,
162+
Detected: detectedEntry.Version,
163+
Source: detectedEntry.Source,
164+
})
165+
result.Summary.Mismatches++
166+
}
167+
} else {
168+
result.Results.MissingConfig = append(result.Results.MissingConfig, ComparisonEntry{
169+
Name: tech,
170+
Version: configEntry.Version,
171+
Source: configEntry.Source,
172+
})
173+
result.Summary.MissingConfig++
174+
}
175+
}
176+
177+
// Determine overall status
178+
if result.Summary.Mismatches == 0 && result.Summary.MissingConfig == 0 {
179+
result.Status = "match"
180+
} else {
181+
result.Status = "mismatch"
182+
}
183+
184+
return result
185+
}
186+
187+
func outputText(result CheckResult) {
188+
fmt.Println("Technology Check Results")
189+
fmt.Println("========================")
190+
fmt.Println()
191+
192+
if len(result.Results.Matched) > 0 {
193+
fmt.Printf("MATCH (%d):\n", len(result.Results.Matched))
194+
for _, entry := range result.Results.Matched {
195+
fmt.Printf(" %-12s %s == %s ✓\n", entry.Name+":", entry.Version, entry.Detected)
196+
}
197+
fmt.Println()
198+
}
199+
200+
if len(result.Results.Mismatched) > 0 {
201+
fmt.Printf("MISMATCH (%d):\n", len(result.Results.Mismatched))
202+
for _, entry := range result.Results.Mismatched {
203+
fmt.Printf(" %-12s %s != %s (config has %s)\n", entry.Name+":", entry.Detected, entry.Version, entry.Version)
204+
}
205+
fmt.Println()
206+
}
207+
208+
if len(result.Results.MissingConfig) > 0 {
209+
fmt.Printf("MISSING FROM DETECTION (%d):\n", len(result.Results.MissingConfig))
210+
for _, entry := range result.Results.MissingConfig {
211+
fmt.Printf(" %-12s %s (in config but not detected)\n", entry.Name+":", entry.Version)
212+
}
213+
fmt.Println()
214+
}
215+
216+
fmt.Printf("Summary: %d match, %d mismatch, %d missing\n",
217+
result.Summary.Matches,
218+
result.Summary.Mismatches,
219+
result.Summary.MissingConfig)
220+
221+
if result.Status == "mismatch" {
222+
fmt.Println("Exit code: 1 (has differences)")
223+
} else {
224+
fmt.Println("Exit code: 0 (all match)")
225+
}
226+
}
227+
228+
func outputJSON(result CheckResult) {
229+
data, err := json.MarshalIndent(result, "", " ")
230+
if err != nil {
231+
fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
232+
os.Exit(2)
233+
}
234+
fmt.Println(string(data))
235+
}
236+
237+
func init() {
238+
rootCmd.AddCommand(checkCmd)
239+
checkCmd.Flags().StringVarP(&checkConfigFile, "config", "c", "", "Path to stacktodate.yml config file (default: stacktodate.yml)")
240+
checkCmd.Flags().StringVarP(&checkFormat, "format", "f", "text", "Output format: text or json (default: text)")
241+
}

0 commit comments

Comments
 (0)