Skip to content
Closed
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
23 changes: 23 additions & 0 deletions .github/workflows/sync-stacktodate.yml
Original file line number Diff line number Diff line change
@@ -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 }}
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ build/

# Application binary
stacktodate
stacktodate-cli

# Claude Code
.claude/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 6 additions & 5 deletions cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions cmd/globalconfig/delete.go
Original file line number Diff line number Diff line change
@@ -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")
},
}
34 changes: 34 additions & 0 deletions cmd/globalconfig/get.go
Original file line number Diff line number Diff line change
@@ -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")
}
},
}
15 changes: 15 additions & 0 deletions cmd/globalconfig/globalconfig.go
Original file line number Diff line number Diff line change
@@ -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)
}
53 changes: 53 additions & 0 deletions cmd/globalconfig/set.go
Original file line number Diff line number Diff line change
@@ -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
}
167 changes: 167 additions & 0 deletions cmd/helpers/api.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading