diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 000572e..b069996 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.13" + ".": "0.1.0-alpha.14" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index d304fb3..dda9ad4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-2b3066c21238e834fda5197e6afbbfaaf41e189a1cc9b3c14a892b90d5a209ba.yml -openapi_spec_hash: 96d0b5cd724b242a0943129fdc79bdca -config_hash: 6ae0d102d10842637680db3e63e42c51 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/stainless%2Fstainless-v0-345f2d483473fda78e89643698c6084bbd6fe9b565044aa79ad1b21e6bdf4e09.yml +openapi_spec_hash: 6780cb3fbff80ca1791cac9f073019ca +config_hash: 5fc708f77aa1d07b7376eb5cbb78f389 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8b1fc..b08a84a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.1.0-alpha.14 (2025-06-17) + +Full Changelog: [v0.1.0-alpha.13...v0.1.0-alpha.14](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.13...v0.1.0-alpha.14) + +### Features + +* add --openapi-spec and --stainless-config flags to workspace init ([530581e](https://github.com/stainless-api/stainless-api-cli/commit/530581e8ee3bd6d52b4a3745d229de40f87e4167)) +* also automatically find openapi.json ([14eeeb4](https://github.com/stainless-api/stainless-api-cli/commit/14eeeb4c3635645059243d80509e4da7e05db28a)) +* **api:** manual updates ([f4e6172](https://github.com/stainless-api/stainless-api-cli/commit/f4e61722f341bb7248e40dc22473408e7eef5b05)) +* flesh out project create form ([c773199](https://github.com/stainless-api/stainless-api-cli/commit/c77319958ee6b285bd74191e1535a91bc77fbfce)) +* sdkjson generation API ([9f908f1](https://github.com/stainless-api/stainless-api-cli/commit/9f908f1ff8be1c8455c533f0e494a5b6cc5cf4b2)) + ## 0.1.0-alpha.13 (2025-06-17) Full Changelog: [v0.1.0-alpha.12...v0.1.0-alpha.13](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.12...v0.1.0-alpha.13) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 5ddd078..d334a0b 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -304,7 +304,10 @@ var buildsCompare = cli.Command{ } func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error { - cc := getAPICommandContext(cmd) + cc, err := getAPICommandContextWithWorkspaceDefaults(cmd) + if err != nil { + return err + } fmt.Fprintf(os.Stderr, "%s Creating build...\n", au.BrightCyan("✱")) params := stainlessv0.BuildNewParams{} res, err := cc.client.Builds.New( diff --git a/pkg/cmd/form_theme.go b/pkg/cmd/form_theme.go new file mode 100644 index 0000000..c024244 --- /dev/null +++ b/pkg/cmd/form_theme.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// GetFormTheme returns the standard huh theme used across all forms +func GetFormTheme() *huh.Theme { + theme := huh.ThemeBase() + theme.Group.Title = theme.Group.Title.Foreground(lipgloss.Color("8")).PaddingBottom(1) + + theme.Focused.Title = theme.Focused.Title.Foreground(lipgloss.Color("6")).Bold(true) + theme.Focused.Base = theme.Focused.Base.BorderForeground(lipgloss.Color("240")) + theme.Focused.Description = theme.Focused.Description.Foreground(lipgloss.Color("8")) + theme.Focused.TextInput.Placeholder = theme.Focused.TextInput.Placeholder.Foreground(lipgloss.Color("8")) + + theme.Blurred.Title = theme.Blurred.Title.Foreground(lipgloss.Color("6")).Bold(true) + theme.Blurred.Base = theme.Blurred.Base.Foreground(lipgloss.Color("251")) + theme.Blurred.Description = theme.Blurred.Description.Foreground(lipgloss.Color("8")) + theme.Blurred.TextInput.Placeholder = theme.Blurred.TextInput.Placeholder.Foreground(lipgloss.Color("8")) + + return theme +} + +// GetFormKeyMap returns the standard huh keymap used across all forms +func GetFormKeyMap() *huh.KeyMap { + keyMap := huh.NewDefaultKeyMap() + keyMap.Input.AcceptSuggestion = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "complete"), + ) + keyMap.Input.Next = key.NewBinding( + key.WithKeys("tab", "down", "enter"), + key.WithHelp("tab/↓/enter", "next"), + ) + keyMap.Input.Prev = key.NewBinding( + key.WithKeys("shift+tab", "up"), + key.WithHelp("shift+tab/↑", "previous"), + ) + return keyMap +} diff --git a/pkg/cmd/project.go b/pkg/cmd/project.go index 9a5074e..2c3f478 100644 --- a/pkg/cmd/project.go +++ b/pkg/cmd/project.go @@ -6,7 +6,10 @@ import ( "context" "fmt" "os" + "strings" + "github.com/charmbracelet/huh" + "github.com/logrusorgru/aurora/v4" "github.com/stainless-api/stainless-api-cli/pkg/jsonflag" "github.com/stainless-api/stainless-api-go" "github.com/stainless-api/stainless-api-go/option" @@ -52,6 +55,21 @@ var projectsCreate = cli.Command{ Path: "targets.-1", }, }, + &cli.StringFlag{ + Name: "openapi-spec", + Aliases: []string{"oas"}, + Usage: "Path to OpenAPI spec file", + }, + &cli.StringFlag{ + Name: "stainless-config", + Aliases: []string{"config"}, + Usage: "Path to Stainless config file", + }, + &cli.BoolFlag{ + Name: "init-workspace", + Usage: "Initialize workspace configuration after creating project", + Value: true, // Default to true + }, }, Action: handleProjectsCreate, HideHelpCommand: true, @@ -119,6 +137,190 @@ var projectsList = cli.Command{ } func handleProjectsCreate(ctx context.Context, cmd *cli.Command) error { + // Define available target languages + availableTargets := []huh.Option[string]{ + huh.NewOption("TypeScript", "typescript").Selected(true), + huh.NewOption("Python", "python").Selected(true), + huh.NewOption("Go", "go"), + huh.NewOption("Java", "java"), + huh.NewOption("Kotlin", "kotlin"), + huh.NewOption("Ruby", "ruby"), + huh.NewOption("Terraform", "terraform"), + huh.NewOption("C#", "csharp"), + huh.NewOption("PHP", "php"), + } + + // Get values from flags + org := cmd.String("org") + projectName := cmd.String("display-name") // Keep display-name flag for compatibility + if projectName == "" { + projectName = cmd.String("slug") // Also check slug flag for compatibility + } + targetsFlag := cmd.String("targets") + openAPISpec := cmd.String("openapi-spec") + stainlessConfig := cmd.String("stainless-config") + initWorkspace := cmd.Bool("init-workspace") + + // Convert comma-separated targets flag to slice for multi-select + var selectedTargets []string + if targetsFlag != "" { + for _, target := range strings.Split(targetsFlag, ",") { + selectedTargets = append(selectedTargets, strings.TrimSpace(target)) + } + } + + // Pre-fill OpenAPI spec and Stainless config if found and not provided via flags + if openAPISpec == "" { + openAPISpec = findOpenAPISpec() + } + if stainlessConfig == "" { + stainlessConfig = findStainlessConfig() + } + + // Check if all required values are provided via flags + // OpenAPI spec and Stainless config are optional + allValuesProvided := org != "" && projectName != "" + + if !allValuesProvided { + fmt.Println("Creating a new project...") + fmt.Println() + + // Fetch available organizations for suggestions + orgs := fetchUserOrgs(ctx) + + // Auto-fill with first organization if org is empty and orgs are available + if org == "" && len(orgs) > 0 { + org = orgs[0] + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("organization"). + Value(&org). + Suggestions(orgs). + Description("Enter the organization for this project"). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("organization is required") + } + return nil + }), + huh.NewInput(). + Title("project name"). + Value(&projectName). + DescriptionFunc(func() string { + if projectName == "" { + return "Project name, slug will be 'my-project'." + } + slug := nameToSlug(projectName) + return fmt.Sprintf("Project name, slug will be '%s'.", slug) + }, &projectName). + Placeholder("My Project"). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("project name is required") + } + return nil + }), + ).Title("Page (1/2)"), + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("targets"). + Description("Select target languages for code generation"). + Options(availableTargets...). + Value(&selectedTargets), + huh.NewInput(). + Title("OpenAPI spec path"). + Description("Relative path to your OpenAPI spec file"). + Placeholder("openapi.yml"). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return fmt.Errorf("openapi spec is required") + } + if _, err := os.Stat(s); os.IsNotExist(err) { + return fmt.Errorf("file '%s' does not exist", s) + } + return nil + }). + Value(&openAPISpec), + huh.NewInput(). + Title("Stainless config path (optional)"). + Description("Relative path to your Stainless config file"). + Placeholder("openapi.stainless.yml"). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + if _, err := os.Stat(s); os.IsNotExist(err) { + return fmt.Errorf("file '%s' does not exist", s) + } + return nil + }). + Value(&stainlessConfig), + ).Title("Page (2/2)"), + ).WithTheme(GetFormTheme()).WithKeyMap(GetFormKeyMap()) + + if err := form.Run(); err != nil { + return fmt.Errorf("failed to get project configuration: %v", err) + } + + // Generate slug from project name + slug := nameToSlug(projectName) + + fmt.Printf("%s organization: %s\n", aurora.Bold("✱"), org) + fmt.Printf("%s project name: %s\n", aurora.Bold("✱"), projectName) + fmt.Printf("%s slug: %s\n", aurora.Bold("✱"), slug) + if len(selectedTargets) > 0 { + fmt.Printf("%s targets: %s\n", aurora.Bold("✱"), strings.Join(selectedTargets, ", ")) + } + if openAPISpec != "" { + fmt.Printf("%s openapi spec: %s\n", aurora.Bold("✱"), openAPISpec) + } + if stainlessConfig != "" { + fmt.Printf("%s stainless config: %s\n", aurora.Bold("✱"), stainlessConfig) + } + fmt.Println() + + // Set the flag values so the JSONFlag middleware can pick them up + cmd.Set("org", org) + cmd.Set("display-name", projectName) + cmd.Set("slug", slug) + if len(selectedTargets) > 0 { + cmd.Set("targets", strings.Join(selectedTargets, ",")) + } + if openAPISpec != "" { + cmd.Set("openapi-spec", openAPISpec) + } + if stainlessConfig != "" { + cmd.Set("stainless-config", stainlessConfig) + } + } else { + // Generate slug from project name for non-interactive mode too + slug := nameToSlug(projectName) + cmd.Set("slug", slug) + } + + // Inject file contents into the API payload if files are provided or found + if openAPISpec != "" { + content, err := os.ReadFile(openAPISpec) + if err == nil { + // Inject the actual file content into the project creation payload + jsonflag.Register(jsonflag.Body, "revision.openapi\\.yml.content", string(content)) + } + } + + if stainlessConfig != "" { + content, err := os.ReadFile(stainlessConfig) + if err == nil { + // Inject the actual file content into the project creation payload + jsonflag.Register(jsonflag.Body, "revision.openapi\\.stainless\\.yml.content", string(content)) + } + } + + // Use the original logic - let the JSONFlag middleware handle parameter construction cc := getAPICommandContext(cmd) params := stainlessv0.ProjectNewParams{} res, err := cc.client.Projects.New( @@ -130,7 +332,27 @@ func handleProjectsCreate(ctx context.Context, cmd *cli.Command) error { return err } + if !allValuesProvided { + fmt.Printf("%s %s\n", aurora.BrightGreen("✱"), fmt.Sprintf("Project created successfully")) + } fmt.Printf("%s\n", ColorizeJSON(res.RawJSON(), os.Stdout)) + + // Initialize workspace if requested + if initWorkspace { + fmt.Println() + fmt.Printf("Initializing workspace configuration...\n") + + // Use the same project name (slug) for workspace initialization + slug := nameToSlug(projectName) + err := InitWorkspaceConfig(slug, openAPISpec, stainlessConfig) + if err != nil { + fmt.Printf("%s Failed to initialize workspace: %v\n", aurora.BrightRed("✱"), err) + return fmt.Errorf("project created but workspace initialization failed: %v", err) + } + + fmt.Printf("%s %s\n", aurora.BrightGreen("✱"), fmt.Sprintf("Workspace initialized")) + } + return nil } @@ -187,3 +409,55 @@ func handleProjectsList(ctx context.Context, cmd *cli.Command) error { fmt.Printf("%s\n", ColorizeJSON(res.RawJSON(), os.Stdout)) return nil } + +// fetchUserOrgs retrieves the list of organizations the user has access to +func fetchUserOrgs(ctx context.Context) []string { + client := stainlessv0.NewClient(getClientOptions()...) + + res, err := client.Orgs.List(ctx) + if err != nil { + // Return empty slice if we can't fetch orgs + return []string{} + } + + var orgs []string + for _, org := range res.Data { + if org.Slug != "" { + orgs = append(orgs, org.Slug) + } + } + + return orgs +} + +// nameToSlug converts a project name to a URL-friendly slug +func nameToSlug(name string) string { + // Convert to lowercase + slug := strings.ToLower(name) + + // Replace spaces and common punctuation with hyphens + slug = strings.ReplaceAll(slug, " ", "-") + slug = strings.ReplaceAll(slug, "_", "-") + slug = strings.ReplaceAll(slug, ".", "-") + slug = strings.ReplaceAll(slug, "/", "-") + slug = strings.ReplaceAll(slug, "\\", "-") + + // Remove any characters that aren't alphanumeric or hyphens + var result strings.Builder + for _, r := range slug { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + result.WriteRune(r) + } + } + slug = result.String() + + // Remove multiple consecutive hyphens + for strings.Contains(slug, "--") { + slug = strings.ReplaceAll(slug, "--", "-") + } + + // Trim hyphens from start and end + slug = strings.Trim(slug, "-") + + return slug +} diff --git a/pkg/cmd/workspace.go b/pkg/cmd/workspace.go index 98b6833..315e7ff 100644 --- a/pkg/cmd/workspace.go +++ b/pkg/cmd/workspace.go @@ -12,9 +12,7 @@ import ( "slices" "strings" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" "github.com/logrusorgru/aurora/v4" "github.com/stainless-api/stainless-api-go" "github.com/urfave/cli/v3" @@ -28,6 +26,16 @@ var initWorkspaceCommand = cli.Command{ Name: "project", Usage: "Project name to use for this workspace", }, + &cli.StringFlag{ + Name: "openapi-spec", + Aliases: []string{"oas"}, + Usage: "Path to OpenAPI spec file", + }, + &cli.StringFlag{ + Name: "stainless-config", + Aliases: []string{"config"}, + Usage: "Path to Stainless config file", + }, }, Action: handleInitWorkspace, HideHelpCommand: true, @@ -47,39 +55,28 @@ func handleInitWorkspace(ctx context.Context, cmd *cli.Command) error { } configPath := filepath.Join(dir, "stainless-workspace.json") fmt.Printf("Writing workspace config to: %s\n", aurora.Bold(configPath)) - fmt.Println() - // Set up form theme and keymap for use in multiple forms - keyMap := huh.NewDefaultKeyMap() - keyMap.Input.AcceptSuggestion = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "complete"), - ) - keyMap.Input.Next = key.NewBinding( - key.WithKeys("tab", "down", "enter"), - key.WithHelp("tab/↓/enter", "next"), - ) - keyMap.Input.Prev = key.NewBinding( - key.WithKeys("shift+tab", "up"), - key.WithHelp("shift+tab/↑", "previous"), - ) - - // Create custom theme with bullet point cursor and no borders - theme := huh.ThemeBase() - theme.Focused.Base = theme.Focused.Base.BorderStyle(lipgloss.NormalBorder()) - theme.Focused.Title = theme.Focused.Title.Bold(true) - - // Get project name from flag or prepare for interactive prompt + // Get values from flags or prepare for interactive prompt projectName := cmd.String("project") - var openAPISpec, stainlessConfig string + openAPISpec := cmd.String("openapi-spec") + stainlessConfig := cmd.String("stainless-config") + + // Pre-fill OpenAPI spec and Stainless config if found and not provided via flags + if openAPISpec == "" { + openAPISpec = findOpenAPISpec() + } + if stainlessConfig == "" { + stainlessConfig = findStainlessConfig() + } - // Pre-fill OpenAPI spec and Stainless config if found - openAPISpec = findOpenAPISpec() - stainlessConfig = findStainlessConfig() + // Skip interactive form if all values are provided via flags or auto-detected + // Project name is required, but openAPISpec and stainlessConfig are optional + allValuesProvided := projectName != "" && + (cmd.IsSet("openapi-spec") || openAPISpec != "") && + (cmd.IsSet("stainless-config") || stainlessConfig != "") - // If project name wasn't provided via flag, prompt for all fields interactively - if projectName == "" { + if !allValuesProvided { projectInfoMap := fetchUserProjects(ctx) form := huh.NewForm( @@ -101,7 +98,7 @@ func handleInitWorkspace(ctx context.Context, cmd *cli.Command) error { Placeholder("openapi.stainless.yml"). Value(&stainlessConfig), ), - ).WithTheme(theme).WithKeyMap(keyMap) + ).WithTheme(GetFormTheme()).WithKeyMap(GetFormKeyMap()) if err := form.Run(); err != nil { return fmt.Errorf("failed to get workspace configuration: %v", err) @@ -256,6 +253,7 @@ func GetProjectNameFromConfig() string { // findOpenAPISpec searches for common OpenAPI spec files in the current directory func findOpenAPISpec() string { commonOpenAPIFiles := []string{ + "openapi.json", "openapi.yml", "openapi.yaml", "api.yml",