diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 581b75cc..920a145e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-alpha.87" + ".": "0.1.0-alpha.88" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8bb2d7..242890f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.1.0-alpha.88 (2026-04-09) + +Full Changelog: [v0.1.0-alpha.87...v0.1.0-alpha.88](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.87...v0.1.0-alpha.88) + +### Features + +* add console.IsInteractive() helper ([d2bd149](https://github.com/stainless-api/stainless-api-cli/commit/d2bd149865971de55adf17296f685f5c4c78b072)) +* **components/dev:** forward error from build ([a4d78c6](https://github.com/stainless-api/stainless-api-cli/commit/a4d78c6b86282ca1f68d02f4c1bacc06a6502820)) +* **dev:** require project parameter ([ee8b94d](https://github.com/stainless-api/stainless-api-cli/commit/ee8b94d20030127b3f0893917d8e793fa6ed5965)) +* **init/builds create:** use dev component instead ([6e06b1e](https://github.com/stainless-api/stainless-api-cli/commit/6e06b1e0e180989faaf497bcd1ba074eafcd0428)) +* **init:** don't overwrite config files by default ([96f9d11](https://github.com/stainless-api/stainless-api-cli/commit/96f9d116a27385c6ab1366abe001026eed17158a)) +* **init:** support non-interactive mode for stl init ([7fbcaeb](https://github.com/stainless-api/stainless-api-cli/commit/7fbcaeb617ade079c002bac50cd4ebb935df349b)) +* **init:** upload --stainless-config when creating a new project ([d96e641](https://github.com/stainless-api/stainless-api-cli/commit/d96e6416d2001f753540007235b7d17a77a03095)) + + +### Bug Fixes + +* handle openapi target in BuildTarget lookup ([d09eede](https://github.com/stainless-api/stainless-api-cli/commit/d09eede6ca8f6f4ea7d9e42d77e39a4d2400228b)) +* **init:** require --openapi-spec for non-interactive project creation ([1b338ad](https://github.com/stainless-api/stainless-api-cli/commit/1b338ad116591b18eee735bdd3354d82e82b6a4b)) +* **init:** respect --targets flag for existing projects ([c6444a0](https://github.com/stainless-api/stainless-api-cli/commit/c6444a098b86d9072ebbb551e868550f5e0df216)) + + +### Refactors + +* **init:** extract askSelectTargets from init flow ([62c6102](https://github.com/stainless-api/stainless-api-cli/commit/62c610247ee9bd411836a9b683fbf74c01772954)) + ## 0.1.0-alpha.87 (2026-03-30) Full Changelog: [v0.1.0-alpha.86...v0.1.0-alpha.87](https://github.com/stainless-api/stainless-api-cli/compare/v0.1.0-alpha.86...v0.1.0-alpha.87) diff --git a/internal/mockstainless/server.go b/internal/mockstainless/server.go index 8a81d39e..7779fc50 100644 --- a/internal/mockstainless/server.go +++ b/internal/mockstainless/server.go @@ -76,6 +76,50 @@ func newServeMux(m *Mock) http.Handler { writeJSON(w, http.StatusOK, Page(m.Projects)) }) + mux.HandleFunc("POST /v0/projects", func(w http.ResponseWriter, r *http.Request) { + body := mustReadBody(r) + slug := gjson.GetBytes(body, "slug").String() + displayName := gjson.GetBytes(body, "display_name").String() + org := gjson.GetBytes(body, "org").String() + + if slug == "" { + writeJSON(w, http.StatusBadRequest, M{"error": "slug is required"}) + return + } + if displayName == "" { + displayName = slug + } + + var targets []any + gjson.GetBytes(body, "targets").ForEach(func(_, v gjson.Result) bool { + targets = append(targets, v.String()) + return true + }) + if len(targets) == 0 { + targets = []any{"typescript", "python", "go"} + } + + project := M{ + "slug": slug, + "display_name": displayName, + "object": "project", + "org": org, + "config_repo": fmt.Sprintf("https://github.com/%s/%s", org, slug), + "targets": targets, + } + + m.mu.Lock() + m.Projects = append(m.Projects, project) + m.mu.Unlock() + + // Create a build so the post-creation build-wait step succeeds. + if len(m.Builds) > 0 { + m.CreateBuildFromTemplate(m.Builds[0]) + } + + writeJSON(w, http.StatusOK, project) + }) + mux.HandleFunc("GET /v0/projects/{project}", func(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("project") for _, p := range m.Projects { @@ -84,9 +128,7 @@ func newServeMux(m *Mock) http.Handler { return } } - if len(m.Projects) > 0 { - writeJSON(w, http.StatusOK, m.Projects[0]) - } + writeJSON(w, http.StatusNotFound, M{"error": "project not found"}) }) mux.HandleFunc("PATCH /v0/projects/{project}", func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index fc56f6cf..cad1e78a 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -11,10 +11,10 @@ import ( "path" "strings" - tea "github.com/charmbracelet/bubbletea" "github.com/stainless-api/stainless-api-cli/internal/apiquery" "github.com/stainless-api/stainless-api-cli/internal/requestflag" cbuild "github.com/stainless-api/stainless-api-cli/pkg/components/build" + cdev "github.com/stainless-api/stainless-api-cli/pkg/components/dev" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/stainlessutils" "github.com/stainless-api/stainless-api-cli/pkg/workspace" @@ -25,24 +25,15 @@ import ( "github.com/urfave/cli/v3" ) -// WaitMode represents the level of waiting for build completion -type WaitMode int - -const ( - WaitNone WaitMode = iota // Don't wait - WaitCommit // Wait for commit only - WaitAll // Wait for everything including workflows -) - // parseWaitMode converts the --wait flag string to a WaitMode -func parseWaitMode(wait string) (WaitMode, error) { +func parseWaitMode(wait string) (cdev.WaitMode, error) { switch wait { case "none", "false": // Accept both "none" and "false" for backwards compatibility - return WaitNone, nil + return cdev.WaitNone, nil case "commit": - return WaitCommit, nil + return cdev.WaitCommit, nil case "all": - return WaitAll, nil + return cdev.WaitAll, nil default: return 0, fmt.Errorf("invalid --wait value: %q (must be 'none', 'commit', or 'all')", wait) } @@ -429,20 +420,26 @@ func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error { buildGroup.Property("build_id", build.ID) - if waitMode > WaitNone { + if waitMode > cdev.WaitNone { console.Spacer() - buildModel := cbuild.NewModel(client, ctx, *build, cmd.String("branch"), downloadPaths) - buildModel.CommitOnly = waitMode == WaitCommit - model := tea.Model(buildCompletionModel{ - Build: buildModel, - WaitMode: waitMode, + devModel := cdev.NewModel(cdev.ModelConfig{ + Client: client, + Ctx: ctx, + Branch: cmd.String("branch"), + Start: func() (*stainless.Build, error) { return build, nil }, + DownloadPaths: downloadPaths, + Label: "BUILD", + WaitMode: waitMode, + Indent: " ", }) - model, err = console.NewProgram(model).Run() + devModel.Build.CommitOnly = waitMode == cdev.WaitCommit + model, err := console.NewProgram(devModel).Run() if err != nil { console.Warn("%s", err.Error()) } - b := model.(buildCompletionModel).Build - build = &b.Build + if m, ok := model.(cdev.Model); ok { + build = &m.Build.Build + } console.Spacer() } @@ -454,7 +451,7 @@ func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error { } // Only check for failures if we waited for the build - if waitMode == WaitNone { + if waitMode == cdev.WaitNone { return nil } @@ -475,7 +472,7 @@ func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error { } // Only check workflow failures if we waited for them - if waitMode >= WaitAll { + if waitMode >= cdev.WaitAll { if bt.Lint.Conclusion == "failure" || bt.Test.Conclusion == "failure" || bt.Build.Conclusion == "failure" { failures = append(failures, fmt.Errorf("%s workflow failed", target)) } @@ -489,65 +486,6 @@ func handleBuildsCreate(ctx context.Context, cmd *cli.Command) error { return nil } -type buildCompletionModel struct { - Build cbuild.Model - WaitMode WaitMode -} - -func (c buildCompletionModel) Init() tea.Cmd { - return c.Build.Init() -} - -func (c buildCompletionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - c.Build, cmd = c.Build.Update(msg) - - if c.IsCompleted() { - return c, tea.Sequence( - cmd, - tea.Quit, - ) - } - - return c, cmd -} - -func (c buildCompletionModel) IsCompleted() bool { - b := stainlessutils.NewBuild(c.Build.Build) - for _, target := range b.Languages() { - buildTarget := b.BuildTarget(target) - - if buildTarget == nil { - return false - } - - // Check if download is completed (if applicable) - downloadIsCompleted := true - if buildTarget.IsCommitCompleted() && buildTarget.IsGoodCommitConclusion() { - if download, ok := c.Build.Downloads[target]; ok { - downloadIsCompleted = download.Status == "completed" - } - } - - // Check if target is done based on wait mode - done := buildTarget.IsCommitCompleted() - if c.WaitMode >= WaitAll { - done = buildTarget.IsCompleted() - } - - if !done || !downloadIsCompleted { - return false - } - } - - return true -} - -func (c buildCompletionModel) View() string { - c.Build.CommitOnly = c.WaitMode == WaitCommit - return c.Build.View() -} - func handleBuildsRetrieve(ctx context.Context, cmd *cli.Command) error { client := stainless.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() diff --git a/pkg/cmd/dev.go b/pkg/cmd/dev.go index 64e66462..4623691b 100644 --- a/pkg/cmd/dev.go +++ b/pkg/cmd/dev.go @@ -164,6 +164,9 @@ func gitRepoRoot(dir string) string { func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Config, cmd *cli.Command) error { projectName := cmd.String("project") + if projectName == "" { + return fmt.Errorf("project is required: use --project or set it in .stainless/workspace.json") + } oasPath := cmd.String("openapi-spec") configPath := cmd.String("stainless-config") diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index b296d1e2..ce1418b5 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -13,7 +13,7 @@ import ( "strings" "time" - cbuild "github.com/stainless-api/stainless-api-cli/pkg/components/build" + cdev "github.com/stainless-api/stainless-api-cli/pkg/components/dev" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/stainlessutils" "github.com/stainless-api/stainless-api-cli/pkg/workspace" @@ -58,6 +58,11 @@ var initCommand = cli.Command{ 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: "workspace-init", Usage: "Initialize workspace configuration", @@ -139,7 +144,15 @@ func handleInit(ctx context.Context, cmd *cli.Command) error { console.Spacer() - return initializeWorkspace(ctx, cmd, client, projectName, project.Targets) + targets := project.Targets + if cmd.IsSet("targets") { + targets = nil + for target := range strings.SplitSeq(cmd.String("targets"), ",") { + targets = append(targets, stainless.Target(strings.TrimSpace(target))) + } + } + + return initializeWorkspace(ctx, cmd, client, projectName, targets) } func ensureExistingWorkspaceIsDeleted(cmd *cli.Command) error { @@ -194,6 +207,9 @@ func askSelectOrganization(cmd *cli.Command, orgs []string) (string, error) { case len(orgs) == 1: org = orgs[0] default: + if !console.IsInteractive() { + return "", fmt.Errorf("multiple organizations found; specify one with --org") + } err := console.Field(huh.NewSelect[string](). Title("org"). Description("Enter the organization for this project"). @@ -218,6 +234,9 @@ func fetchUserProjects(ctx context.Context, client stainless.Client, org string) // askSelectProject prompts the user to select from existing projects or create a new one func askSelectProject(projects []stainless.Project) (string, *stainless.Project, error) { + if !console.IsInteractive() { + return "", nil, fmt.Errorf("specify a project with --project") + } options := make([]huh.Option[*stainless.Project], 0, len(projects)+1) options = append(options, huh.NewOption("", (*stainless.Project)(nil))) projects = slices.SortedFunc(slices.Values(projects), func(p1, p2 stainless.Project) int { @@ -245,10 +264,70 @@ func askSelectProject(projects []stainless.Project) (string, *stainless.Project, return picked.Slug, picked, nil } +// askSelectTargets resolves which targets to use. If --targets is set, it parses the flag. +// If defaults are provided (e.g. from an existing project), they are returned as-is. +// Otherwise, shows an interactive multi-select prompt. +func askSelectTargets(cmd *cli.Command, defaults []stainless.Target, group *console.Group) ([]stainless.Target, error) { + if cmd.IsSet("targets") { + var targets []stainless.Target + for target := range strings.SplitSeq(cmd.String("targets"), ",") { + targets = append(targets, stainless.Target(strings.TrimSpace(target))) + } + if len(targets) == 0 { + return nil, fmt.Errorf("You must select at least one target!") + } + return targets, nil + } + + if len(defaults) > 0 { + return defaults, nil + } + + if !console.IsInteractive() { + return nil, fmt.Errorf("specify targets with --targets (comma-separated, e.g. --targets python,typescript)") + } + + allTargets := slices.DeleteFunc(getAllTargetInfo(), func(item TargetInfo) bool { + return item.Name == "node" // Remove node (deprecated option) + }) + + options := make([]huh.Option[stainless.Target], len(allTargets)) + for i, target := range allTargets { + options[i] = huh.NewOption(target.DisplayName, stainless.Target(target.Name)).Selected(target.DefaultSelected) + } + + var selected []stainless.Target + field := huh.NewMultiSelect[stainless.Target](). + Title("targets"). + Description("Select target languages for code generation"). + Options(options...). + Validate(func(selected []stainless.Target) error { + if len(selected) == 0 { + return fmt.Errorf("You must select at least one target!") + } + return nil + }). + Value(&selected) + + var err error + if group != nil { + err = group.Field(field) + } else { + err = console.Field(field) + } + if err != nil { + return nil, err + } + return selected, nil +} + func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Client, org, projectName string) (string, *stainless.Project, error) { group := console.Property("project", "(new)") if projectName == "" { + if !console.IsInteractive() { + return "", nil, fmt.Errorf("specify a project name with --project") + } err := group.Field(huh.NewInput(). Title("name"). Description("Enter a display name for your new project"). @@ -272,37 +351,9 @@ func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Cl group.Property("name", projectName) // Determine targets - var selectedTargets []stainless.Target - if cmd.IsSet("targets") { - for target := range strings.SplitSeq(cmd.String("targets"), ",") { - selectedTargets = append(selectedTargets, stainless.Target(strings.TrimSpace(target))) - } - if len(selectedTargets) == 0 { - return "", nil, fmt.Errorf("You must select at least one target!") - } - } else { - allTargets := slices.DeleteFunc(getAllTargetInfo(), func(item TargetInfo) bool { - return item.Name == "node" // Remove node (deprecated option) - }) - - options := make([]huh.Option[stainless.Target], len(allTargets)) - for i, target := range allTargets { - options[i] = huh.NewOption(target.DisplayName, stainless.Target(target.Name)).Selected(target.DefaultSelected) - } - err := group.Field(huh.NewMultiSelect[stainless.Target](). - Title("targets"). - Description("Select target languages for code generation"). - Options(options...). - Validate(func(selected []stainless.Target) error { - if len(selected) == 0 { - return fmt.Errorf("You must select at least one target!") - } - return nil - }). - Value(&selectedTargets)) - if err != nil { - return "", nil, err - } + selectedTargets, err := askSelectTargets(cmd, nil, &group) + if err != nil { + return "", nil, err } group.Property("targets", fmt.Sprintf("%v", selectedTargets)) @@ -317,7 +368,7 @@ func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Cl } // Get OpenAPI spec content - oasContent, err := askExistingOpenAPISpec(group) + oasContent, err := askExistingOpenAPISpec(cmd, group) if err != nil { return "", nil, err } @@ -337,6 +388,27 @@ func askCreateProject(ctx context.Context, cmd *cli.Command, client stainless.Cl }, } + if cmd.IsSet("stainless-config") { + configPath := cmd.String("stainless-config") + if configBytes, err := os.ReadFile(configPath); err == nil { + group.Property("stainless_config", configPath) + + var configName string + trimmedConfig := strings.TrimSpace(string(configBytes)) + if strings.HasPrefix(trimmedConfig, "{") { + configName = "stainless.json" + } else { + configName = "stainless.yml" + } + + params.Revision[configName] = stainless.FileInputUnionParam{ + OfFileInputContent: &stainless.FileInputContentParam{ + Content: string(configBytes), + }, + } + } + } + var project *stainless.Project err = group.Spinner("Creating project...", func() error { options := []option.RequestOption{} @@ -411,8 +483,6 @@ func initializeWorkspace(ctx context.Context, cmd *cli.Command, client stainless console.Spacer() - console.Info("Waiting for build to complete...") - // Try to get the latest build for this project (which should have been created automatically) build, err := getLatestBuild(ctx, client, projectSlug, "main") if err != nil { @@ -424,14 +494,20 @@ func initializeWorkspace(ctx context.Context, cmd *cli.Command, client stainless downloadPaths[stainless.Target(targetName)] = targetConfig.OutputPath } - buildModel := cbuild.NewModel(client, ctx, *build, "main", downloadPaths) - buildModel.CommitOnly = true - model := buildCompletionModel{ - Build: buildModel, - WaitMode: WaitCommit, - } + devModel := cdev.NewModel(cdev.ModelConfig{ + Client: client, + Ctx: ctx, + Branch: "main", + Start: func() (*stainless.Build, error) { return build, nil }, + DownloadPaths: downloadPaths, + Label: "BUILD", + WaitMode: cdev.WaitCommit, + Indent: " ", + }) + devModel.Build.CommitOnly = true + devModel.Diagnostics.WorkspaceConfig = config - _, err = console.NewProgram(model).Run() + _, err = console.NewProgram(devModel).Run() if err != nil { console.Warn("%s", err.Error()) } @@ -467,7 +543,21 @@ func initializeWorkspace(ctx context.Context, cmd *cli.Command, client stainless // askExistingOpenAPISpec provides the location of an _existing_ openapi spec. We first ask how the user would like // provide the openapi spec, either 1. from computer, 2. from url, or 3. from an example. Then, we should fan // out to the various options. -func askExistingOpenAPISpec(group console.Group) (content string, err error) { +func askExistingOpenAPISpec(cmd *cli.Command, group console.Group) (content string, err error) { + if !console.IsInteractive() { + if cmd.IsSet("openapi-spec") { + specPath := cmd.String("openapi-spec") + fileBytes, err := os.ReadFile(specPath) + if err != nil { + return "", fmt.Errorf("failed to read OpenAPI spec from %s: %w", specPath, err) + } + group.Property("openapi_spec", specPath) + return string(fileBytes), nil + } + return "", fmt.Errorf("specify an OpenAPI spec with --openapi-spec") + + } + type Source string const ( SourceComputer Source = "computer" @@ -582,6 +672,11 @@ func askOpenAPISpecLocation(group console.Group) (string, error) { } } + if !console.IsInteractive() { + group.Property("openapi_spec", suggestion) + return suggestion, nil + } + path := suggestion err := group.Field(huh.NewInput(). Title("openapi_spec"). @@ -611,6 +706,11 @@ func chooseStainlessConfigLocation(group console.Group) (string, error) { } } + if !console.IsInteractive() { + group.Property("stainless_config", suggestion) + return suggestion, nil + } + path := suggestion err := group.Field(huh.NewInput(). Title("stainless_config"). @@ -663,7 +763,7 @@ func downloadConfigFiles(ctx context.Context, client stainless.Client, wc worksp return nil } - shouldOverwrite, _, err := group.Confirm(nil, "", fmt.Sprintf("File %s already exists", path), "Do you want to overwrite it?", true) + shouldOverwrite, _, err := group.Confirm(nil, "", fmt.Sprintf("File %s already exists, and it differs from the version that exists in main", path), "Do you want to overwrite it?", false) if err != nil { return fmt.Errorf("failed to confirm file overwrite: %w", err) } @@ -731,36 +831,47 @@ func configureTargets(slug string, targets []stainless.Target, config *workspace targetConfigs[target] = &workspace.TargetConfig{OutputPath: defaultPath} } - // Create form fields for each target - pathVars := make(map[stainless.Target]*string, len(targets)) - fields := make([]huh.Field, 0, len(targets)) + if console.IsInteractive() { + // Create form fields for each target + pathVars := make(map[stainless.Target]*string, len(targets)) + fields := make([]huh.Field, 0, len(targets)) - for _, target := range targets { - pathVar := targetConfigs[target].OutputPath - pathVars[target] = &pathVar - fields = append(fields, huh.NewInput(). - Title(fmt.Sprintf("%s output path", target)). - Value(pathVars[target])) - } + for _, target := range targets { + pathVar := targetConfigs[target].OutputPath + pathVars[target] = &pathVar + fields = append(fields, huh.NewInput(). + Title(fmt.Sprintf("%s output path", target)). + Value(pathVars[target])) + } - // Run the form - form := huh.NewForm(huh.NewGroup(fields...)). - WithTheme(console.GetFormTheme(1)). - WithKeyMap(console.GetFormKeyMap()) - if err := form.Run(); err != nil { - return fmt.Errorf("failed to get target output paths: %v", err) - } + // Run the form + form := huh.NewForm(huh.NewGroup(fields...)). + WithTheme(console.GetFormTheme(1)). + WithKeyMap(console.GetFormKeyMap()) + if err := form.Run(); err != nil { + return fmt.Errorf("failed to get target output paths: %v", err) + } - // Update config with user-provided paths (convert to absolute) - for target, pathVar := range pathVars { - if path := strings.TrimSpace(*pathVar); path != "" { - absPath, err := filepath.Abs(path) + // Update config with user-provided paths (convert to absolute) + for target, pathVar := range pathVars { + if path := strings.TrimSpace(*pathVar); path != "" { + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to get absolute path for %s target: %w", target, err) + } + targetConfigs[target] = &workspace.TargetConfig{OutputPath: absPath} + } else { + delete(targetConfigs, target) + } + } + } else { + // Non-interactive: use default paths + for target, tc := range targetConfigs { + absPath, err := filepath.Abs(tc.OutputPath) if err != nil { return fmt.Errorf("failed to get absolute path for %s target: %w", target, err) } targetConfigs[target] = &workspace.TargetConfig{OutputPath: absPath} - } else { - delete(targetConfigs, target) } } diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go new file mode 100644 index 00000000..bc9dcd73 --- /dev/null +++ b/pkg/cmd/init_test.go @@ -0,0 +1,324 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestInitNonInteractive(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + + // Flag configuration + project string + targets string // comma-separated, empty means omit flag + oasFlag string // empty means omit flag + configFlag string // empty means omit flag + + isNewProject bool + expectError bool + expectErrorMsg string + + // Assertions on the created workspace + wantTargets []string + } + + cases := []testCase{ + // ── New project ────────────────────────────────────────────── + { + name: "new project with targets and config", + project: "brand-new", + targets: "python,typescript", + oasFlag: "openapi.json", + configFlag: "stainless.yml", + isNewProject: true, + wantTargets: []string{"python", "typescript"}, + }, + { + name: "new project with targets, no config", + project: "brand-new-no-cfg", + targets: "go", + oasFlag: "openapi.json", + isNewProject: true, + wantTargets: []string{"go"}, + }, + { + name: "new project without targets fails", + project: "brand-new-no-tgt", + oasFlag: "openapi.json", + isNewProject: true, + expectError: true, + expectErrorMsg: "--targets", + }, + { + name: "new project without openapi-spec fails", + project: "brand-new-no-oas", + targets: "python", + isNewProject: true, + expectError: true, + expectErrorMsg: "--openapi-spec", + }, + // ── Existing project ───────────────────────────────────────── + { + name: "existing project with all flags", + project: "acme-api", + targets: "python,typescript", + oasFlag: "openapi.json", + configFlag: "stainless.yml", + wantTargets: []string{"python", "typescript"}, + }, + { + name: "existing project without targets uses server targets", + project: "acme-api", + oasFlag: "openapi.json", + configFlag: "stainless.yml", + wantTargets: []string{"typescript", "python", "go"}, + }, + { + name: "existing project without config", + project: "acme-api", + targets: "typescript", + oasFlag: "openapi.json", + wantTargets: []string{"typescript"}, + }, + { + name: "existing project with config, no explicit targets", + project: "acme-api", + oasFlag: "openapi.json", + configFlag: "stainless.yml", + wantTargets: []string{"typescript", "python", "go"}, + }, + // ── Missing required flags ─────────────────────────────────── + { + name: "no project flag fails", + oasFlag: "openapi.json", + expectError: true, + expectErrorMsg: "--project", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Each subtest gets its own mock server to avoid shared state. + server := newMockServer(t) + dir := t.TempDir() + + // Create dummy spec/config files in the working directory. + oasContent := `{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{}}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "openapi.json"), []byte(oasContent), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stainless.yml"), []byte("client:\n name: Test\n"), 0644)) + + args := []string{"init", "--api-key", "test-key"} + if tc.project != "" { + args = append(args, "--project", tc.project) + } + if tc.targets != "" { + args = append(args, "--targets", tc.targets) + } + if tc.oasFlag != "" { + args = append(args, "--openapi-spec", tc.oasFlag) + } + if tc.configFlag != "" { + args = append(args, "--stainless-config", tc.configFlag) + } + + output := runCLIWithExpectation(t, dir, server.URL(), tc.expectError, args...) + + if tc.expectError { + if tc.expectErrorMsg != "" { + assert.Contains(t, output, tc.expectErrorMsg) + } + return + } + + // Verify .stainless/workspace.json was created. + wsPath := filepath.Join(dir, ".stainless", "workspace.json") + data, err := os.ReadFile(wsPath) + require.NoError(t, err, "workspace.json should exist") + + ws := gjson.ParseBytes(data) + assert.Equal(t, tc.project, ws.Get("project").String(), "workspace project") + + // Paths in workspace.json are relative to .stainless/, so + // a file at dir/openapi.json becomes ../openapi.json. + if tc.oasFlag != "" { + assert.Equal(t, "../"+tc.oasFlag, ws.Get("openapi_spec").String(), "workspace openapi_spec") + } + if tc.configFlag != "" { + assert.Equal(t, "../"+tc.configFlag, ws.Get("stainless_config").String(), "workspace stainless_config") + } + + // Verify targets were configured. + targets := ws.Get("targets") + for _, want := range tc.wantTargets { + assert.True(t, targets.Get(want).Exists(), "target %q should be configured", want) + } + + // Verify correct API calls were made. + if tc.isNewProject { + req := findRequest(t, server.Requests(), "POST", "/v0/projects") + assert.Equal(t, tc.project, gjson.Get(req.Body, "slug").String()) + } else { + findRequest(t, server.Requests(), "GET", "/v0/projects") + } + }) + } +} + +func TestInitNonInteractiveRequests(t *testing.T) { + t.Parallel() + + t.Run("new project sends openapi spec content in revision", func(t *testing.T) { + t.Parallel() + server := newMockServer(t) + dir := t.TempDir() + + oasContent := `{"openapi":"3.1.0","info":{"title":"ReqTest","version":"1.0.0"}}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(oasContent), 0644)) + + runCLI(t, dir, server.URL(), "init", + "--api-key", "test-key", + "--project", "req-test-project", + "--targets", "python", + "--openapi-spec", "spec.json", + ) + + req := findRequest(t, server.Requests(), "POST", "/v0/projects") + assert.Equal(t, "req-test-project", gjson.Get(req.Body, "slug").String()) + assert.Equal(t, oasContent, gjson.Get(req.Body, "revision.openapi\\.json.content").String()) + assert.False(t, gjson.Get(req.Body, "revision.stainless\\.yml").Exists()) + assert.False(t, gjson.Get(req.Body, "revision.stainless\\.json").Exists()) + }) + + t.Run("new project sends stainless config when flag provided", func(t *testing.T) { + t.Parallel() + server := newMockServer(t) + dir := t.TempDir() + + oasContent := `{"openapi":"3.1.0","info":{"title":"CfgTest","version":"1.0.0"}}` + cfgContent := "client:\n name: CfgTest\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(oasContent), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "cfg.yml"), []byte(cfgContent), 0644)) + + runCLI(t, dir, server.URL(), "init", + "--api-key", "test-key", + "--project", "cfg-test-project", + "--targets", "typescript", + "--openapi-spec", "spec.json", + "--stainless-config", "cfg.yml", + ) + + req := findRequest(t, server.Requests(), "POST", "/v0/projects") + assert.Equal(t, oasContent, gjson.Get(req.Body, "revision.openapi\\.json.content").String()) + assert.Equal(t, cfgContent, gjson.Get(req.Body, "revision.stainless\\.yml.content").String()) + }) + + t.Run("new project sends targets in create request", func(t *testing.T) { + t.Parallel() + server := newMockServer(t) + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(`{}`), 0644)) + + runCLI(t, dir, server.URL(), "init", + "--api-key", "test-key", + "--project", "tgt-test-project", + "--targets", "python,go", + "--openapi-spec", "spec.json", + ) + + req := findRequest(t, server.Requests(), "POST", "/v0/projects") + targetsResult := gjson.Get(req.Body, "targets") + require.True(t, targetsResult.IsArray()) + var got []string + for _, v := range targetsResult.Array() { + got = append(got, v.String()) + } + assert.ElementsMatch(t, []string{"python", "go"}, got) + }) + + t.Run("existing project does not POST to create", func(t *testing.T) { + t.Parallel() + server := newMockServer(t) + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(`{}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "cfg.yml"), []byte("client:\n name: E\n"), 0644)) + + runCLI(t, dir, server.URL(), "init", + "--api-key", "test-key", + "--project", "acme-api", + "--openapi-spec", "spec.json", + "--stainless-config", "cfg.yml", + ) + + assertNoRequest(t, server.Requests(), "POST", "/v0/projects") + }) +} + +func TestInitNonInteractiveWorkspaceContents(t *testing.T) { + t.Parallel() + + t.Run("workspace.json has correct structure", func(t *testing.T) { + t.Parallel() + server := newMockServer(t) + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "my-spec.json"), []byte(`{}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "my-config.yml"), []byte("x: 1\n"), 0644)) + + runCLI(t, dir, server.URL(), "init", + "--api-key", "test-key", + "--project", "acme-api", + "--targets", "python,go", + "--openapi-spec", "my-spec.json", + "--stainless-config", "my-config.yml", + ) + + data, err := os.ReadFile(filepath.Join(dir, ".stainless", "workspace.json")) + require.NoError(t, err) + + var ws map[string]any + require.NoError(t, json.Unmarshal(data, &ws)) + assert.Equal(t, "acme-api", ws["project"]) + assert.Equal(t, "../my-spec.json", ws["openapi_spec"]) + assert.Equal(t, "../my-config.yml", ws["stainless_config"]) + + targets, ok := ws["targets"].(map[string]any) + require.True(t, ok, "targets should be a map") + assert.Contains(t, targets, "python") + assert.Contains(t, targets, "go") + }) + + t.Run("default stainless_config path when flag omitted", func(t *testing.T) { + t.Parallel() + server := newMockServer(t) + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "spec.json"), []byte(`{}`), 0644)) + + runCLI(t, dir, server.URL(), "init", + "--api-key", "test-key", + "--project", "acme-api", + "--targets", "typescript", + "--openapi-spec", "spec.json", + ) + + data, err := os.ReadFile(filepath.Join(dir, ".stainless", "workspace.json")) + require.NoError(t, err) + + ws := gjson.ParseBytes(data) + configPath := ws.Get("stainless_config").String() + assert.NotEmpty(t, configPath, "stainless_config should have a default value") + }) +} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 0538e9da..cc292095 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.0-alpha.87" // x-release-please-version +const Version = "0.1.0-alpha.88" // x-release-please-version diff --git a/pkg/cmd/workspace_integration_test.go b/pkg/cmd/workspace_integration_test.go index 0c37bfc3..a6d56157 100644 --- a/pkg/cmd/workspace_integration_test.go +++ b/pkg/cmd/workspace_integration_test.go @@ -35,7 +35,11 @@ type workspaceFixture struct { func TestWorkspaceProjectAutofillIntegration(t *testing.T) { t.Parallel() - server := newMockServer(t) + server := newMockServer(t, func(m *mockstainless.Mock) { + // Register the fixture project slugs so GET /v0/projects/{project} returns 200. + mockstainless.WithProject(mockstainless.MockProject{Name: "workspace-project", Org: "acme-corp"})(m) + mockstainless.WithProject(mockstainless.MockProject{Name: "flag-project", Org: "acme-corp"})(m) + }) t.Run("workspace", func(t *testing.T) { fixture := newWorkspaceFixture(t) diff --git a/pkg/components/dev/model.go b/pkg/components/dev/model.go index 95a191a1..de6eecca 100644 --- a/pkg/components/dev/model.go +++ b/pkg/components/dev/model.go @@ -16,6 +16,15 @@ import ( var ErrUserCancelled = errors.New("user cancelled") +// WaitMode represents the level of waiting for build completion. +type WaitMode int + +const ( + WaitNone WaitMode = iota // Don't wait + WaitCommit // Wait for commit only + WaitAll // Wait for everything including workflows +) + type Model struct { Err error @@ -26,6 +35,9 @@ type Model struct { start func() (*stainless.Build, error) Branch string view string + label string + waitMode WaitMode + Indent string // models @@ -44,15 +56,25 @@ type ModelConfig struct { Start func() (*stainless.Build, error) DownloadPaths map[stainless.Target]string Watch bool + Label string // Header label, defaults to "PREVIEW" + WaitMode WaitMode // When non-zero, auto-quits after diagnostics are fetched and build targets reach completion + Indent string // Prefix for every non-empty output line (e.g. " ") } func NewModel(cfg ModelConfig) Model { + label := cfg.Label + if label == "" { + label = "PREVIEW" + } return Model{ start: cfg.Start, Client: cfg.Client, Ctx: cfg.Ctx, Branch: cfg.Branch, Watch: cfg.Watch, + label: label, + waitMode: cfg.WaitMode, + Indent: cfg.Indent, Help: help.New(), Build: build.NewModel(cfg.Client, cfg.Ctx, stainless.Build{}, cfg.Branch, cfg.DownloadPaths), Diagnostics: diagnostics.NewModel(cfg.Client, cfg.Ctx, nil), @@ -95,6 +117,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case build.TickMsg, build.DownloadMsg, build.ErrorMsg, spinner.TickMsg: m.Build, cmd = m.Build.Update(msg) cmds = append(cmds, cmd) + if m.Build.Err != nil { + m.Err = m.Build.Err + } case diagnostics.FetchDiagnosticsMsg: m.Diagnostics, cmd = m.Diagnostics.Update(msg) @@ -139,6 +164,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // File change detected, exit with success cmds = append(cmds, tea.Quit) } + + // Auto-quit when WaitMode is set and build targets have reached completion + if m.waitMode > WaitNone && m.diagnosticsFetched() && m.isComplete() { + return m, tea.Sequence(tea.Batch(cmds...), tea.Quit) + } + return m, tea.Batch(cmds...) } @@ -163,3 +194,37 @@ func (m Model) FullHelp() [][]key.Binding { return [][]key.Binding{{key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl-c", "quit"))}} } } + +func (m Model) diagnosticsFetched() bool { + return m.Diagnostics.Diagnostics != nil || m.Diagnostics.Err != nil +} + +func (m Model) isComplete() bool { + buildObj := stainlessutils.NewBuild(m.Build.Build) + for _, target := range buildObj.Languages() { + buildTarget := buildObj.BuildTarget(target) + if buildTarget == nil { + return false + } + + // Check if download is completed (if applicable) + if buildTarget.IsCommitCompleted() && buildTarget.IsGoodCommitConclusion() { + if download, ok := m.Build.Downloads[target]; ok { + if download.Status != "completed" { + return false + } + } + } + + // Check if target is done based on wait mode + done := buildTarget.IsCommitCompleted() + if m.waitMode >= WaitAll { + done = buildTarget.IsCompleted() + } + + if !done { + return false + } + } + return true +} diff --git a/pkg/components/dev/view.go b/pkg/components/dev/view.go index e5828899..b4e5cd2c 100644 --- a/pkg/components/dev/view.go +++ b/pkg/components/dev/view.go @@ -30,7 +30,21 @@ func (m Model) View() string { s.WriteString("\n" + m.Err.Error() + "\n") } - return s.String() + return m.applyIndent(s.String()) +} + +// applyIndent prefixes every non-empty line with m.Indent. +func (m Model) applyIndent(s string) string { + if m.Indent == "" { + return s + } + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = m.Indent + line + } + } + return strings.Join(lines, "\n") } // ViewPart represents a single part of the build view @@ -43,7 +57,7 @@ var parts = []ViewPart{ { Name: "header", View: func(m *Model, s *strings.Builder) { - s.WriteString(build.ViewHeader("PREVIEW", m.Build.Build)) + s.WriteString(build.ViewHeader(m.label, m.Build.Build)) }, }, { @@ -69,6 +83,9 @@ var parts = []ViewPart{ { Name: "studio", View: func(m *Model, s *strings.Builder) { + if !m.Watch { + return + } if m.Build.ID != "" { url := fmt.Sprintf("https://app.stainless.com/%s/%s/studio?branch=%s", m.Build.Org, m.Build.Project, m.Branch) s.WriteString("\n") @@ -80,6 +97,9 @@ var parts = []ViewPart{ { Name: "help", View: func(m *Model, s *strings.Builder) { + if !m.Watch { + return + } s.WriteString("\n") s.WriteString(m.Help.View(m)) }, @@ -100,7 +120,7 @@ func (m *Model) updateView(targetState string) tea.Cmd { return nil } - output := ViewBuildRange(m, m.view, targetState) + output := m.applyIndent(ViewBuildRange(m, m.view, targetState)) // Update model state m.view = targetState diff --git a/pkg/console/print.go b/pkg/console/print.go index 10498392..afb7619e 100644 --- a/pkg/console/print.go +++ b/pkg/console/print.go @@ -15,6 +15,12 @@ import ( "golang.org/x/term" ) +// IsInteractive returns true if stderr is a terminal (TTY). +// Use this to decide whether to show interactive prompts. +func IsInteractive() bool { + return term.IsTerminal(int(os.Stderr.Fd())) +} + // NewProgram wraps tea.NewProgram with better handling for tty environments func NewProgram(model tea.Model, opts ...tea.ProgramOption) *tea.Program { // Always output to stderr, in case we want to also output JSON so that the json is redirectable e.g. to jq. diff --git a/pkg/stainlessutils/stainlessutils.go b/pkg/stainlessutils/stainlessutils.go index de63b019..0a313a1a 100644 --- a/pkg/stainlessutils/stainlessutils.go +++ b/pkg/stainlessutils/stainlessutils.go @@ -71,6 +71,10 @@ func (b *Build) BuildTarget(target stainless.Target) *BuildTarget { if b.Targets.JSON.Csharp.Valid() { return NewBuildTarget(&b.Targets.Csharp, target) } + case "openapi": + if b.Targets.JSON.OpenAPI.Valid() { + return NewBuildTarget(&b.Targets.OpenAPI, target) + } } return nil }