Skip to content

Commit 7558e46

Browse files
committed
feat: flesh out project create form
1 parent ad5a769 commit 7558e46

File tree

4 files changed

+327
-28
lines changed

4 files changed

+327
-28
lines changed

pkg/cmd/build.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,10 @@ var buildsCompare = cli.Command{
304304
}
305305

306306
func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error {
307-
cc := getAPICommandContext(cmd)
307+
cc, err := getAPICommandContextWithWorkspaceDefaults(cmd)
308+
if err != nil {
309+
return err
310+
}
308311
fmt.Fprintf(os.Stderr, "%s Creating build...\n", au.BrightCyan("✱"))
309312
params := stainlessv0.BuildNewParams{}
310313
res, err := cc.client.Builds.New(

pkg/cmd/form_theme.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package cmd
2+
3+
import (
4+
"github.com/charmbracelet/bubbles/key"
5+
"github.com/charmbracelet/huh"
6+
"github.com/charmbracelet/lipgloss"
7+
)
8+
9+
// GetFormTheme returns the standard huh theme used across all forms
10+
func GetFormTheme() *huh.Theme {
11+
theme := huh.ThemeBase()
12+
theme.Group.Title = theme.Group.Title.Foreground(lipgloss.Color("8")).PaddingBottom(1)
13+
14+
theme.Focused.Title = theme.Focused.Title.Foreground(lipgloss.Color("6")).Bold(true)
15+
theme.Focused.Base = theme.Focused.Base.BorderForeground(lipgloss.Color("240"))
16+
theme.Focused.Description = theme.Focused.Description.Foreground(lipgloss.Color("8"))
17+
theme.Focused.TextInput.Placeholder = theme.Focused.TextInput.Placeholder.Foreground(lipgloss.Color("8"))
18+
19+
theme.Blurred.Title = theme.Blurred.Title.Foreground(lipgloss.Color("6")).Bold(true)
20+
theme.Blurred.Base = theme.Blurred.Base.Foreground(lipgloss.Color("251"))
21+
theme.Blurred.Description = theme.Blurred.Description.Foreground(lipgloss.Color("8"))
22+
theme.Blurred.TextInput.Placeholder = theme.Blurred.TextInput.Placeholder.Foreground(lipgloss.Color("8"))
23+
24+
return theme
25+
}
26+
27+
// GetFormKeyMap returns the standard huh keymap used across all forms
28+
func GetFormKeyMap() *huh.KeyMap {
29+
keyMap := huh.NewDefaultKeyMap()
30+
keyMap.Input.AcceptSuggestion = key.NewBinding(
31+
key.WithKeys("tab"),
32+
key.WithHelp("tab", "complete"),
33+
)
34+
keyMap.Input.Next = key.NewBinding(
35+
key.WithKeys("tab", "down", "enter"),
36+
key.WithHelp("tab/↓/enter", "next"),
37+
)
38+
keyMap.Input.Prev = key.NewBinding(
39+
key.WithKeys("shift+tab", "up"),
40+
key.WithHelp("shift+tab/↑", "previous"),
41+
)
42+
return keyMap
43+
}

pkg/cmd/project.go

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import (
66
"context"
77
"fmt"
88
"os"
9+
"strings"
910

11+
"github.com/charmbracelet/huh"
12+
"github.com/logrusorgru/aurora/v4"
1013
"github.com/stainless-api/stainless-api-cli/pkg/jsonflag"
1114
"github.com/stainless-api/stainless-api-go"
1215
"github.com/stainless-api/stainless-api-go/option"
@@ -52,6 +55,21 @@ var projectsCreate = cli.Command{
5255
Path: "targets.-1",
5356
},
5457
},
58+
&cli.StringFlag{
59+
Name: "openapi-spec",
60+
Aliases: []string{"oas"},
61+
Usage: "Path to OpenAPI spec file",
62+
},
63+
&cli.StringFlag{
64+
Name: "stainless-config",
65+
Aliases: []string{"config"},
66+
Usage: "Path to Stainless config file",
67+
},
68+
&cli.BoolFlag{
69+
Name: "init-workspace",
70+
Usage: "Initialize workspace configuration after creating project",
71+
Value: true, // Default to true
72+
},
5573
},
5674
Action: handleProjectsCreate,
5775
HideHelpCommand: true,
@@ -119,6 +137,190 @@ var projectsList = cli.Command{
119137
}
120138

121139
func handleProjectsCreate(ctx context.Context, cmd *cli.Command) error {
140+
// Define available target languages
141+
availableTargets := []huh.Option[string]{
142+
huh.NewOption("TypeScript", "typescript").Selected(true),
143+
huh.NewOption("Python", "python").Selected(true),
144+
huh.NewOption("Go", "go"),
145+
huh.NewOption("Java", "java"),
146+
huh.NewOption("Kotlin", "kotlin"),
147+
huh.NewOption("Ruby", "ruby"),
148+
huh.NewOption("Terraform", "terraform"),
149+
huh.NewOption("C#", "csharp"),
150+
huh.NewOption("PHP", "php"),
151+
}
152+
153+
// Get values from flags
154+
org := cmd.String("org")
155+
projectName := cmd.String("display-name") // Keep display-name flag for compatibility
156+
if projectName == "" {
157+
projectName = cmd.String("slug") // Also check slug flag for compatibility
158+
}
159+
targetsFlag := cmd.String("targets")
160+
openAPISpec := cmd.String("openapi-spec")
161+
stainlessConfig := cmd.String("stainless-config")
162+
initWorkspace := cmd.Bool("init-workspace")
163+
164+
// Convert comma-separated targets flag to slice for multi-select
165+
var selectedTargets []string
166+
if targetsFlag != "" {
167+
for _, target := range strings.Split(targetsFlag, ",") {
168+
selectedTargets = append(selectedTargets, strings.TrimSpace(target))
169+
}
170+
}
171+
172+
// Pre-fill OpenAPI spec and Stainless config if found and not provided via flags
173+
if openAPISpec == "" {
174+
openAPISpec = findOpenAPISpec()
175+
}
176+
if stainlessConfig == "" {
177+
stainlessConfig = findStainlessConfig()
178+
}
179+
180+
// Check if all required values are provided via flags
181+
// OpenAPI spec and Stainless config are optional
182+
allValuesProvided := org != "" && projectName != ""
183+
184+
if !allValuesProvided {
185+
fmt.Println("Creating a new project...")
186+
fmt.Println()
187+
188+
// Fetch available organizations for suggestions
189+
orgs := fetchUserOrgs(ctx)
190+
191+
// Auto-fill with first organization if org is empty and orgs are available
192+
if org == "" && len(orgs) > 0 {
193+
org = orgs[0]
194+
}
195+
196+
form := huh.NewForm(
197+
huh.NewGroup(
198+
huh.NewInput().
199+
Title("organization").
200+
Value(&org).
201+
Suggestions(orgs).
202+
Description("Enter the organization for this project").
203+
Validate(func(s string) error {
204+
if strings.TrimSpace(s) == "" {
205+
return fmt.Errorf("organization is required")
206+
}
207+
return nil
208+
}),
209+
huh.NewInput().
210+
Title("project name").
211+
Value(&projectName).
212+
DescriptionFunc(func() string {
213+
if projectName == "" {
214+
return "Project name, slug will be 'my-project'."
215+
}
216+
slug := nameToSlug(projectName)
217+
return fmt.Sprintf("Project name, slug will be '%s'.", slug)
218+
}, &projectName).
219+
Placeholder("My Project").
220+
Validate(func(s string) error {
221+
if strings.TrimSpace(s) == "" {
222+
return fmt.Errorf("project name is required")
223+
}
224+
return nil
225+
}),
226+
).Title("Page (1/2)"),
227+
huh.NewGroup(
228+
huh.NewMultiSelect[string]().
229+
Title("targets").
230+
Description("Select target languages for code generation").
231+
Options(availableTargets...).
232+
Value(&selectedTargets),
233+
huh.NewInput().
234+
Title("OpenAPI spec path").
235+
Description("Relative path to your OpenAPI spec file").
236+
Placeholder("openapi.yml").
237+
Validate(func(s string) error {
238+
s = strings.TrimSpace(s)
239+
if s == "" {
240+
return fmt.Errorf("openapi spec is required")
241+
}
242+
if _, err := os.Stat(s); os.IsNotExist(err) {
243+
return fmt.Errorf("file '%s' does not exist", s)
244+
}
245+
return nil
246+
}).
247+
Value(&openAPISpec),
248+
huh.NewInput().
249+
Title("Stainless config path (optional)").
250+
Description("Relative path to your Stainless config file").
251+
Placeholder("openapi.stainless.yml").
252+
Validate(func(s string) error {
253+
s = strings.TrimSpace(s)
254+
if s == "" {
255+
return nil
256+
}
257+
if _, err := os.Stat(s); os.IsNotExist(err) {
258+
return fmt.Errorf("file '%s' does not exist", s)
259+
}
260+
return nil
261+
}).
262+
Value(&stainlessConfig),
263+
).Title("Page (2/2)"),
264+
).WithTheme(GetFormTheme()).WithKeyMap(GetFormKeyMap())
265+
266+
if err := form.Run(); err != nil {
267+
return fmt.Errorf("failed to get project configuration: %v", err)
268+
}
269+
270+
// Generate slug from project name
271+
slug := nameToSlug(projectName)
272+
273+
fmt.Printf("%s organization: %s\n", aurora.Bold("✱"), org)
274+
fmt.Printf("%s project name: %s\n", aurora.Bold("✱"), projectName)
275+
fmt.Printf("%s slug: %s\n", aurora.Bold("✱"), slug)
276+
if len(selectedTargets) > 0 {
277+
fmt.Printf("%s targets: %s\n", aurora.Bold("✱"), strings.Join(selectedTargets, ", "))
278+
}
279+
if openAPISpec != "" {
280+
fmt.Printf("%s openapi spec: %s\n", aurora.Bold("✱"), openAPISpec)
281+
}
282+
if stainlessConfig != "" {
283+
fmt.Printf("%s stainless config: %s\n", aurora.Bold("✱"), stainlessConfig)
284+
}
285+
fmt.Println()
286+
287+
// Set the flag values so the JSONFlag middleware can pick them up
288+
cmd.Set("org", org)
289+
cmd.Set("display-name", projectName)
290+
cmd.Set("slug", slug)
291+
if len(selectedTargets) > 0 {
292+
cmd.Set("targets", strings.Join(selectedTargets, ","))
293+
}
294+
if openAPISpec != "" {
295+
cmd.Set("openapi-spec", openAPISpec)
296+
}
297+
if stainlessConfig != "" {
298+
cmd.Set("stainless-config", stainlessConfig)
299+
}
300+
} else {
301+
// Generate slug from project name for non-interactive mode too
302+
slug := nameToSlug(projectName)
303+
cmd.Set("slug", slug)
304+
}
305+
306+
// Inject file contents into the API payload if files are provided or found
307+
if openAPISpec != "" {
308+
content, err := os.ReadFile(openAPISpec)
309+
if err == nil {
310+
// Inject the actual file content into the project creation payload
311+
jsonflag.Register(jsonflag.Body, "revision.openapi\\.yml.content", string(content))
312+
}
313+
}
314+
315+
if stainlessConfig != "" {
316+
content, err := os.ReadFile(stainlessConfig)
317+
if err == nil {
318+
// Inject the actual file content into the project creation payload
319+
jsonflag.Register(jsonflag.Body, "revision.openapi\\.stainless\\.yml.content", string(content))
320+
}
321+
}
322+
323+
// Use the original logic - let the JSONFlag middleware handle parameter construction
122324
cc := getAPICommandContext(cmd)
123325
params := stainlessv0.ProjectNewParams{}
124326
res, err := cc.client.Projects.New(
@@ -130,7 +332,27 @@ func handleProjectsCreate(ctx context.Context, cmd *cli.Command) error {
130332
return err
131333
}
132334

335+
if !allValuesProvided {
336+
fmt.Printf("%s %s\n", aurora.BrightGreen("✱"), fmt.Sprintf("Project created successfully"))
337+
}
133338
fmt.Printf("%s\n", ColorizeJSON(res.RawJSON(), os.Stdout))
339+
340+
// Initialize workspace if requested
341+
if initWorkspace {
342+
fmt.Println()
343+
fmt.Printf("Initializing workspace configuration...\n")
344+
345+
// Use the same project name (slug) for workspace initialization
346+
slug := nameToSlug(projectName)
347+
err := InitWorkspaceConfig(slug, openAPISpec, stainlessConfig)
348+
if err != nil {
349+
fmt.Printf("%s Failed to initialize workspace: %v\n", aurora.BrightRed("✱"), err)
350+
return fmt.Errorf("project created but workspace initialization failed: %v", err)
351+
}
352+
353+
fmt.Printf("%s %s\n", aurora.BrightGreen("✱"), fmt.Sprintf("Workspace initialized"))
354+
}
355+
134356
return nil
135357
}
136358

@@ -187,3 +409,55 @@ func handleProjectsList(ctx context.Context, cmd *cli.Command) error {
187409
fmt.Printf("%s\n", ColorizeJSON(res.RawJSON(), os.Stdout))
188410
return nil
189411
}
412+
413+
// fetchUserOrgs retrieves the list of organizations the user has access to
414+
func fetchUserOrgs(ctx context.Context) []string {
415+
client := stainlessv0.NewClient(getClientOptions()...)
416+
417+
res, err := client.Orgs.List(ctx)
418+
if err != nil {
419+
// Return empty slice if we can't fetch orgs
420+
return []string{}
421+
}
422+
423+
var orgs []string
424+
for _, org := range res.Data {
425+
if org.Slug != "" {
426+
orgs = append(orgs, org.Slug)
427+
}
428+
}
429+
430+
return orgs
431+
}
432+
433+
// nameToSlug converts a project name to a URL-friendly slug
434+
func nameToSlug(name string) string {
435+
// Convert to lowercase
436+
slug := strings.ToLower(name)
437+
438+
// Replace spaces and common punctuation with hyphens
439+
slug = strings.ReplaceAll(slug, " ", "-")
440+
slug = strings.ReplaceAll(slug, "_", "-")
441+
slug = strings.ReplaceAll(slug, ".", "-")
442+
slug = strings.ReplaceAll(slug, "/", "-")
443+
slug = strings.ReplaceAll(slug, "\\", "-")
444+
445+
// Remove any characters that aren't alphanumeric or hyphens
446+
var result strings.Builder
447+
for _, r := range slug {
448+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
449+
result.WriteRune(r)
450+
}
451+
}
452+
slug = result.String()
453+
454+
// Remove multiple consecutive hyphens
455+
for strings.Contains(slug, "--") {
456+
slug = strings.ReplaceAll(slug, "--", "-")
457+
}
458+
459+
// Trim hyphens from start and end
460+
slug = strings.Trim(slug, "-")
461+
462+
return slug
463+
}

0 commit comments

Comments
 (0)