Skip to content

Commit b20645b

Browse files
ceritiumclaude
andcommitted
Implement API-backed init command with project creation and linking
Add the ability for users to create new projects or link to existing ones during initialization via API calls to stacktodate.club. This improves the user experience by immediately registering projects and validating UUIDs. Key changes: - Add cmd/helpers/api.go with API integration functions: * CreateTechStack() - POST /api/tech_stacks to create new project * GetTechStack() - GET /api/tech_stacks/{id} to fetch and validate projects * ConvertStackToComponents() - Convert detected techs to API format * Common error handling for API responses (401, 404, 422, 5xx) - Enhanced cmd/init.go with interactive menu: * promptProjectChoice() - Menu: "Create new" or "Link existing" * createNewProject() - Create project via API with autodetected components * linkExistingProject() - Validate and link to existing project by UUID * New flow branches based on user choice - Updated cmd/push.go: * Use helpers.Component instead of local type * Use helpers.ConvertStackToComponents() from shared API module UX improvements: - Interactive menu for choosing new vs existing project - Progress messages during API calls - Clear error messages for authentication, validation, and network issues - Support for empty tech stacks with helpful warnings - Backward compatible with --uuid and --name flags for automation API endpoints used: - POST /api/tech_stacks - Create new project (returns UUID) - GET /api/tech_stacks/{id} - Validate and fetch project details Error handling: - 401: Invalid token with hint to update credentials - 404: Project not found with helpful message - 422: Validation errors from API - 5xx: API issues with retry suggestion - Network errors: Connection issues with helpful context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <[email protected]>
1 parent 4c805a7 commit b20645b

File tree

4 files changed

+293
-39
lines changed

4 files changed

+293
-39
lines changed

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+
}

cmd/init.go

Lines changed: 121 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ var initCmd = &cobra.Command{
2828
reader := bufio.NewReader(os.Stdin)
2929

3030
// Check if token is configured, prompt if not
31-
_, err := helpers.GetToken()
31+
token, err := helpers.GetToken()
3232
if err != nil {
3333
fmt.Println("Authentication token not configured.")
3434
fmt.Print("Would you like to set one up now? (y/n): ")
@@ -61,24 +61,50 @@ var initCmd = &cobra.Command{
6161
}
6262
}
6363

64-
// Get UUID
65-
if uuid == "" {
66-
fmt.Print("Enter UUID: ")
67-
input, _ := reader.ReadString('\n')
68-
uuid = strings.TrimSpace(input)
69-
}
64+
// NEW: Menu-based project selection (create new or link existing)
65+
var projUUID, projName string
66+
if uuid == "" && name == "" {
67+
// Interactive mode: prompt user for choice
68+
choice := promptProjectChoice(reader)
7069

71-
// Get Name
72-
if name == "" {
73-
fmt.Print("Enter name: ")
74-
input, _ := reader.ReadString('\n')
75-
name = strings.TrimSpace(input)
70+
if choice == 1 {
71+
// Create new project on API
72+
var createErr error
73+
projUUID, projName, createErr = createNewProject(reader, detectedTechs, token)
74+
if createErr != nil {
75+
helpers.ExitOnError(createErr, "failed to create project")
76+
}
77+
} else {
78+
// Link to existing project on API
79+
var linkErr error
80+
projUUID, projName, linkErr = linkExistingProject(reader, token)
81+
if linkErr != nil {
82+
helpers.ExitOnError(linkErr, "failed to link project")
83+
}
84+
}
85+
} else {
86+
// Non-interactive mode: use provided flags or fallback to old prompts
87+
if uuid == "" {
88+
fmt.Print("Enter UUID: ")
89+
input, _ := reader.ReadString('\n')
90+
projUUID = strings.TrimSpace(input)
91+
} else {
92+
projUUID = uuid
93+
}
94+
95+
if name == "" {
96+
fmt.Print("Enter name: ")
97+
input, _ := reader.ReadString('\n')
98+
projName = strings.TrimSpace(input)
99+
} else {
100+
projName = name
101+
}
76102
}
77103

78104
// Create config
79105
config := helpers.Config{
80-
UUID: uuid,
81-
Name: name,
106+
UUID: projUUID,
107+
Name: projName,
82108
Stack: detectedTechs,
83109
}
84110

@@ -96,8 +122,8 @@ var initCmd = &cobra.Command{
96122

97123
fmt.Println("\nProject initialized successfully!")
98124
fmt.Println("Created stacktodate.yml with:")
99-
fmt.Printf(" UUID: %s\n", uuid)
100-
fmt.Printf(" Name: %s\n", name)
125+
fmt.Printf(" UUID: %s\n", projUUID)
126+
fmt.Printf(" Name: %s\n", projName)
101127
if len(detectedTechs) > 0 {
102128
fmt.Println(" Stack:")
103129
for tech, entry := range detectedTechs {
@@ -107,6 +133,85 @@ var initCmd = &cobra.Command{
107133
},
108134
}
109135

136+
// promptProjectChoice displays a menu for choosing between creating a new project or linking an existing one
137+
func promptProjectChoice(reader *bufio.Reader) int {
138+
for {
139+
fmt.Println("\nDo you want to:")
140+
fmt.Println(" 1) Create a new project on StackToDate")
141+
fmt.Println(" 2) Link to an existing project")
142+
fmt.Print("\nEnter your choice (1 or 2): ")
143+
144+
input, _ := reader.ReadString('\n')
145+
choice := strings.TrimSpace(input)
146+
147+
if choice == "1" {
148+
return 1
149+
} else if choice == "2" {
150+
return 2
151+
}
152+
153+
fmt.Println("Invalid choice. Please enter 1 or 2.")
154+
}
155+
}
156+
157+
// createNewProject prompts for project name and creates a new project via API
158+
func createNewProject(reader *bufio.Reader, detectedTechs map[string]helpers.StackEntry, token string) (uuid, projName string, err error) {
159+
fmt.Print("\nEnter project name: ")
160+
input, _ := reader.ReadString('\n')
161+
projName = strings.TrimSpace(input)
162+
163+
if projName == "" {
164+
return "", "", fmt.Errorf("project name cannot be empty")
165+
}
166+
167+
// Convert detected technologies to API components
168+
components := helpers.ConvertStackToComponents(detectedTechs)
169+
170+
if len(components) == 0 {
171+
fmt.Println("⚠️ Warning: No technologies detected")
172+
fmt.Println("You can add them later by editing stacktodate.yml and running 'stacktodate push'")
173+
}
174+
175+
fmt.Println("\nCreating project on StackToDate...")
176+
177+
// Call API to create project
178+
response, err := helpers.CreateTechStack(token, projName, components)
179+
if err != nil {
180+
return "", "", err
181+
}
182+
183+
uuid = response.TechStack.ID
184+
fmt.Println("✓ Project created successfully!")
185+
fmt.Printf(" UUID: %s\n", uuid)
186+
fmt.Printf(" Name: %s\n\n", projName)
187+
188+
return uuid, projName, nil
189+
}
190+
191+
// linkExistingProject prompts for UUID and links to an existing project via API
192+
func linkExistingProject(reader *bufio.Reader, token string) (projUUID, projName string, err error) {
193+
fmt.Print("\nEnter project UUID: ")
194+
input, _ := reader.ReadString('\n')
195+
projUUID = strings.TrimSpace(input)
196+
197+
if projUUID == "" {
198+
return "", "", fmt.Errorf("UUID cannot be empty")
199+
}
200+
201+
fmt.Println("\nValidating project UUID...")
202+
203+
// Call API to fetch project details
204+
response, err := helpers.GetTechStack(token, projUUID)
205+
if err != nil {
206+
return "", "", err
207+
}
208+
209+
projName = response.TechStack.Name
210+
fmt.Printf("✓ Linked to existing project: %s\n\n", projName)
211+
212+
return projUUID, projName, nil
213+
}
214+
110215
// selectCandidates allows user to select from detected candidates
111216
func selectCandidates(reader *bufio.Reader, info DetectedInfo) map[string]helpers.StackEntry {
112217
selected := make(map[string]helpers.StackEntry)

cmd/push.go

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,17 @@ var (
1616
configFile string
1717
)
1818

19-
type Component struct {
20-
Name string `json:"name"`
21-
Version string `json:"version"`
22-
}
23-
2419
type PushRequest struct {
25-
Components []Component `json:"components"`
20+
Components []helpers.Component `json:"components"`
2621
}
2722

2823
type PushResponse struct {
2924
Success bool `json:"success"`
3025
Message string `json:"message"`
3126
TechStack struct {
32-
ID string `json:"id"`
33-
Name string `json:"name"`
34-
Components []Component `json:"components"`
27+
ID string `json:"id"`
28+
Name string `json:"name"`
29+
Components []helpers.Component `json:"components"`
3530
} `json:"tech_stack"`
3631
}
3732

@@ -56,7 +51,7 @@ var pushCmd = &cobra.Command{
5651
apiURL := cache.GetAPIURL()
5752

5853
// Convert stack to components
59-
components := convertStackToComponents(config.Stack)
54+
components := helpers.ConvertStackToComponents(config.Stack)
6055

6156
// Create request
6257
request := PushRequest{
@@ -72,19 +67,6 @@ var pushCmd = &cobra.Command{
7267
},
7368
}
7469

75-
func convertStackToComponents(stack map[string]helpers.StackEntry) []Component {
76-
var components []Component
77-
78-
for name, entry := range stack {
79-
components = append(components, Component{
80-
Name: name,
81-
Version: entry.Version,
82-
})
83-
}
84-
85-
return components
86-
}
87-
8870
func pushToAPI(apiURL, techStackID, token string, request PushRequest) error {
8971
// Build URL
9072
url := fmt.Sprintf("%s/api/tech_stacks/%s/components", apiURL, techStackID)

stacktodate-cli

16.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)