Skip to content

Commit 86f898e

Browse files
authored
Merge pull request #5 from stacktodate/fix-workflow-ci
Fix workflow ci
2 parents dcb6e74 + 615d6aa commit 86f898e

File tree

13 files changed

+765
-42
lines changed

13 files changed

+765
-42
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ build/
3232

3333
# Application binary
3434
stacktodate
35+
stacktodate-cli
3536

3637
# Claude Code
3738
.claude/

cmd/globalconfig/delete.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package globalconfig
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var deleteCmd = &cobra.Command{
14+
Use: "delete",
15+
Short: "Remove stored authentication token",
16+
Long: `Remove your stored authentication token from keychain or credential storage.`,
17+
Run: func(cmd *cobra.Command, args []string) {
18+
// Confirm deletion
19+
source, _, _ := helpers.GetTokenSource()
20+
if source == "not configured" {
21+
fmt.Println("No credentials to delete")
22+
return
23+
}
24+
25+
fmt.Printf("This will remove your token from: %s\n", source)
26+
fmt.Print("Are you sure you want to delete your credentials? (type 'yes' to confirm): ")
27+
28+
reader := bufio.NewReader(os.Stdin)
29+
response, err := reader.ReadString('\n')
30+
if err != nil {
31+
helpers.ExitOnError(err, "failed to read input")
32+
}
33+
34+
response = strings.TrimSpace(response)
35+
if response != "yes" {
36+
fmt.Println("Cancelled - credentials not deleted")
37+
return
38+
}
39+
40+
// Delete the token
41+
if err := helpers.DeleteToken(); err != nil {
42+
helpers.ExitOnError(err, "")
43+
}
44+
45+
fmt.Println("✓ Credentials deleted successfully")
46+
},
47+
}

cmd/globalconfig/get.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package globalconfig
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
var getCmd = &cobra.Command{
11+
Use: "status",
12+
Short: "Show current authentication configuration",
13+
Long: `Display information about where your authentication token is stored and its status.`,
14+
Run: func(cmd *cobra.Command, args []string) {
15+
source, isSecure, err := helpers.GetTokenSource()
16+
17+
if err != nil {
18+
fmt.Println("Status: Not configured")
19+
fmt.Println("")
20+
fmt.Println("To set up authentication, run:")
21+
fmt.Println(" stacktodate global-config set")
22+
return
23+
}
24+
25+
fmt.Println("Status: Configured")
26+
fmt.Printf("Source: %s\n", source)
27+
28+
if !isSecure {
29+
fmt.Println("")
30+
fmt.Println("⚠️ Warning: Token stored in plain text file")
31+
fmt.Println("For better security, use a system with OS keychain support")
32+
}
33+
},
34+
}

cmd/globalconfig/globalconfig.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package globalconfig
2+
3+
import "github.com/spf13/cobra"
4+
5+
var GlobalConfigCmd = &cobra.Command{
6+
Use: "global-config",
7+
Short: "Manage global configuration and authentication",
8+
Long: `Configure authentication tokens and other global settings for stacktodate-cli`,
9+
}
10+
11+
func init() {
12+
GlobalConfigCmd.AddCommand(setCmd)
13+
GlobalConfigCmd.AddCommand(getCmd)
14+
GlobalConfigCmd.AddCommand(deleteCmd)
15+
}

cmd/globalconfig/set.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package globalconfig
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"syscall"
7+
8+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
9+
"golang.org/x/term"
10+
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var setCmd = &cobra.Command{
15+
Use: "set",
16+
Short: "Set up authentication token",
17+
Long: `Set up your stacktodate API token for authentication.\n\nThe token will be securely stored in your system's keychain or credential store.`,
18+
Run: func(cmd *cobra.Command, args []string) {
19+
token, err := promptForToken()
20+
if err != nil {
21+
helpers.ExitOnError(err, "failed to read token")
22+
}
23+
24+
if token == "" {
25+
helpers.ExitOnError(fmt.Errorf("token cannot be empty"), "")
26+
}
27+
28+
// Store the token
29+
if err := helpers.SetToken(token); err != nil {
30+
helpers.ExitOnError(err, "")
31+
}
32+
33+
source, _, _ := helpers.GetTokenSource()
34+
fmt.Printf("✓ Token successfully configured\n")
35+
fmt.Printf(" Storage: %s\n", source)
36+
},
37+
}
38+
39+
// promptForToken prompts the user for their API token without echoing it to the terminal
40+
func promptForToken() (string, error) {
41+
fmt.Print("Enter your stacktodate API token: ")
42+
43+
// Read password without echoing
44+
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
45+
if err != nil {
46+
return "", fmt.Errorf("failed to read token: %w", err)
47+
}
48+
49+
fmt.Println() // Print newline after hidden input
50+
51+
token := strings.TrimSpace(string(bytePassword))
52+
return token, nil
53+
}

cmd/helpers/api.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package helpers
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
10+
"github.com/stacktodate/stacktodate-cli/cmd/lib/cache"
11+
)
12+
13+
// Component represents a single technology in the stack
14+
type Component struct {
15+
Name string `json:"name"`
16+
Version string `json:"version"`
17+
}
18+
19+
// ConvertStackToComponents converts the detected stack format to API component format
20+
func ConvertStackToComponents(stack map[string]StackEntry) []Component {
21+
components := make([]Component, 0)
22+
23+
for name, entry := range stack {
24+
components = append(components, Component{
25+
Name: name,
26+
Version: entry.Version,
27+
})
28+
}
29+
30+
return components
31+
}
32+
33+
// TechStackRequest is used for POST /api/tech_stacks
34+
type TechStackRequest struct {
35+
TechStack struct {
36+
Name string `json:"name"`
37+
Components []Component `json:"components"`
38+
} `json:"tech_stack"`
39+
}
40+
41+
// TechStackResponse is the response from both GET and POST tech stack endpoints
42+
type TechStackResponse struct {
43+
Success bool `json:"success,omitempty"`
44+
Message string `json:"message,omitempty"`
45+
TechStack struct {
46+
ID string `json:"id"`
47+
Name string `json:"name"`
48+
Components []Component `json:"components"`
49+
} `json:"tech_stack"`
50+
}
51+
52+
// CreateTechStack creates a new tech stack on the API
53+
// Returns the newly created tech stack with UUID
54+
func CreateTechStack(token, name string, components []Component) (*TechStackResponse, error) {
55+
apiURL := cache.GetAPIURL()
56+
url := fmt.Sprintf("%s/api/tech_stacks", apiURL)
57+
58+
request := TechStackRequest{}
59+
request.TechStack.Name = name
60+
request.TechStack.Components = components
61+
62+
var response TechStackResponse
63+
if err := makeAPIRequest("POST", url, token, request, &response); err != nil {
64+
return nil, err
65+
}
66+
67+
if !response.Success {
68+
return nil, fmt.Errorf("API error: %s", response.Message)
69+
}
70+
71+
if response.TechStack.ID == "" {
72+
return nil, fmt.Errorf("API response missing project ID")
73+
}
74+
75+
return &response, nil
76+
}
77+
78+
// GetTechStack retrieves an existing tech stack from the API by UUID
79+
// This validates that the project exists and returns its details
80+
func GetTechStack(token, uuid string) (*TechStackResponse, error) {
81+
apiURL := cache.GetAPIURL()
82+
url := fmt.Sprintf("%s/api/tech_stacks/%s", apiURL, uuid)
83+
84+
var response TechStackResponse
85+
if err := makeAPIRequest("GET", url, token, nil, &response); err != nil {
86+
return nil, err
87+
}
88+
89+
if response.TechStack.ID == "" {
90+
return nil, fmt.Errorf("API response missing project ID")
91+
}
92+
93+
return &response, nil
94+
}
95+
96+
// makeAPIRequest is a private helper that handles common API request logic
97+
func makeAPIRequest(method, url, token string, requestBody interface{}, response interface{}) error {
98+
var req *http.Request
99+
var err error
100+
101+
// Create request with body if provided
102+
if requestBody != nil {
103+
requestBodyJSON, err := json.Marshal(requestBody)
104+
if err != nil {
105+
return fmt.Errorf("failed to marshal request: %w", err)
106+
}
107+
req, err = http.NewRequest(method, url, bytes.NewBuffer(requestBodyJSON))
108+
} else {
109+
req, err = http.NewRequest(method, url, nil)
110+
}
111+
112+
if err != nil {
113+
return fmt.Errorf("failed to create request: %w", err)
114+
}
115+
116+
// Set headers
117+
req.Header.Set("Content-Type", "application/json")
118+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
119+
120+
// Make request
121+
client := &http.Client{}
122+
resp, err := client.Do(req)
123+
if err != nil {
124+
return fmt.Errorf("failed to connect to StackToDate API: %w\n\nPlease check your internet connection and try again", err)
125+
}
126+
defer resp.Body.Close()
127+
128+
// Read response body
129+
body, err := io.ReadAll(resp.Body)
130+
if err != nil {
131+
return fmt.Errorf("failed to read response: %w", err)
132+
}
133+
134+
// Handle error responses first
135+
if resp.StatusCode == http.StatusUnauthorized {
136+
return fmt.Errorf("authentication failed: invalid or expired token\n\nPlease update your token with: stacktodate global-config set")
137+
}
138+
139+
if resp.StatusCode == http.StatusNotFound {
140+
return fmt.Errorf("project not found: UUID does not exist\n\nPlease check the UUID or create a new project")
141+
}
142+
143+
if resp.StatusCode == http.StatusUnprocessableEntity {
144+
var errResp struct {
145+
Message string `json:"message"`
146+
}
147+
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Message != "" {
148+
return fmt.Errorf("validation error: %s", errResp.Message)
149+
}
150+
return fmt.Errorf("validation error: the server rejected your request")
151+
}
152+
153+
if resp.StatusCode >= 500 {
154+
return fmt.Errorf("StackToDate API is experiencing issues (status %d)\n\nPlease try again later", resp.StatusCode)
155+
}
156+
157+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
158+
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
159+
}
160+
161+
// Parse successful response
162+
if err := json.Unmarshal(body, response); err != nil {
163+
return fmt.Errorf("failed to parse API response: %w", err)
164+
}
165+
166+
return nil
167+
}

0 commit comments

Comments
 (0)