From c4657483903643c7f5e9b88f1ee5f6702c5d4c6f Mon Sep 17 00:00:00 2001 From: Bruce Hill Date: Mon, 16 Mar 2026 16:22:48 -0400 Subject: [PATCH 01/25] Treat empty strings as unset --- pkg/cmd/cmdutil.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index f271454..dfb61ac 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -41,11 +41,11 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), } - if cmd.IsSet("api-key") { + if cmd.IsSet("api-key") && cmd.String("api-key") != "" { opts = append(opts, option.WithAPIKey(cmd.String("api-key"))) } - if cmd.IsSet("project") { + if cmd.IsSet("project") && cmd.String("project") != "" { opts = append(opts, option.WithProject(cmd.String("project"))) } From 64c31cf020960b1dc7ed20840aa850073d5587ff Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:24:09 +0000 Subject: [PATCH 02/25] fix: only set client options when the corresponding CLI flag or env var is explicitly set From d6f49850f5572ccff1f65a40a54efde6f0427fcc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:52:37 +0000 Subject: [PATCH 03/25] chore(internal): version bump From 23b822757485c5aab9796ea36733a20d79363b3b Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Wed, 18 Mar 2026 15:14:59 -0400 Subject: [PATCH 04/25] fix: fill project property more uniformly --- pkg/cmd/build.go | 1 + pkg/cmd/cmdutil.go | 9 --------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 4500849..fbc01ea 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -272,6 +272,7 @@ var buildsList = cli.Command{ }, }, Action: handleBuildsList, + Before: before, HideHelpCommand: true, } diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index dfb61ac..2eab7e7 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -18,7 +18,6 @@ import ( "syscall" "github.com/stainless-api/stainless-api-cli/internal/jsonview" - "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go/option" "github.com/charmbracelet/x/term" @@ -73,14 +72,6 @@ func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { } } - if project := os.Getenv("STAINLESS_PROJECT"); project == "" { - workspaceConfig := workspace.Config{} - found, err := workspaceConfig.Find() - if err == nil && found && workspaceConfig.Project != "" { - cmd.Set("project", workspaceConfig.Project) - } - } - return opts } From 73a12cd0a3a9bf2392ada6c3873a2c8ad23c373e Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Wed, 18 Mar 2026 15:32:02 -0400 Subject: [PATCH 05/25] refactor: auto-set Before hook on all subcommands via traversal --- pkg/cmd/auth.go | 3 --- pkg/cmd/build.go | 4 ---- pkg/cmd/builddiagnostic.go | 1 - pkg/cmd/buildtargetoutput.go | 1 - pkg/cmd/cmd.go | 18 ++++++++++++++++++ pkg/cmd/dev.go | 1 - pkg/cmd/lint.go | 1 - pkg/cmd/mcp.go | 1 - pkg/cmd/org.go | 2 -- pkg/cmd/project.go | 4 ---- pkg/cmd/projectbranch.go | 6 ------ pkg/cmd/projectconfig.go | 2 -- pkg/cmd/workspace.go | 2 -- 13 files changed, 18 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/auth.go b/pkg/cmd/auth.go index 0b0ac8c..1cc3009 100644 --- a/pkg/cmd/auth.go +++ b/pkg/cmd/auth.go @@ -39,7 +39,6 @@ var authLogin = cli.Command{ Usage: "Open browser for authentication (use --browser=false to skip)", }, }, - Before: before, Action: handleAuthLogin, HideHelpCommand: true, } @@ -47,7 +46,6 @@ var authLogin = cli.Command{ var authLogout = cli.Command{ Name: "logout", Usage: "Log out and remove saved credentials", - Before: before, Action: handleAuthLogout, HideHelpCommand: true, } @@ -55,7 +53,6 @@ var authLogout = cli.Command{ var authStatus = cli.Command{ Name: "status", Usage: "Check authentication status", - Before: before, Action: handleAuthStatus, HideHelpCommand: true, } diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index fbc01ea..a6b5fe9 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -158,7 +158,6 @@ var buildsCreate = requestflag.WithInnerFlags(cli.Command{ }, }, Action: handleBuildsCreate, - Before: before, HideHelpCommand: true, }, map[string][]requestflag.HasOuterFlag{ "target-commit-messages": { @@ -229,7 +228,6 @@ var buildsRetrieve = cli.Command{ }, }, Action: handleBuildsRetrieve, - Before: before, HideHelpCommand: true, } @@ -272,7 +270,6 @@ var buildsList = cli.Command{ }, }, Action: handleBuildsList, - Before: before, HideHelpCommand: true, } @@ -305,7 +302,6 @@ var buildsCompare = requestflag.WithInnerFlags(cli.Command{ }, }, Action: handleBuildsCompare, - Before: before, HideHelpCommand: true, }, map[string][]requestflag.HasOuterFlag{ "base": { diff --git a/pkg/cmd/builddiagnostic.go b/pkg/cmd/builddiagnostic.go index 1a472f8..a8109fe 100644 --- a/pkg/cmd/builddiagnostic.go +++ b/pkg/cmd/builddiagnostic.go @@ -52,7 +52,6 @@ var buildsDiagnosticsList = cli.Command{ Usage: "The maximum number of items to return (use -1 for unlimited).", }, }, - Before: before, Action: handleBuildsDiagnosticsList, HideHelpCommand: true, } diff --git a/pkg/cmd/buildtargetoutput.go b/pkg/cmd/buildtargetoutput.go index a7b1cfc..da7b48a 100644 --- a/pkg/cmd/buildtargetoutput.go +++ b/pkg/cmd/buildtargetoutput.go @@ -62,7 +62,6 @@ var buildsTargetOutputsRetrieve = cli.Command{ QueryPath: "path", }, }, - Before: before, Action: handleBuildsTargetOutputsRetrieve, } diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index ae89793..07bf489 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -240,6 +240,24 @@ stl builds create --branch `, }, HideHelpCommand: true, } + + // Recursively set Before on all subcommands that have an Action. + // This ensures workspace config is always loaded without needing to + // manually add Before: before to every command definition. + // Excludes: + // - "init": the workspace Before would cause stale config values to be treated as user-supplied flags. + // - "__complete": workspace warnings on stderr become bogus completion + // candidates in shells that merge stderr into stdout (e.g. PowerShell). + var setBefore func(cmd *cli.Command) + setBefore = func(cmd *cli.Command) { + for _, sub := range cmd.Commands { + if sub.Action != nil && sub.Before == nil && sub.Name != "init" && sub.Name != "__complete" { + sub.Before = before + } + setBefore(sub) + } + } + setBefore(Command) } func before(ctx context.Context, cmd *cli.Command) (context.Context, error) { diff --git a/pkg/cmd/dev.go b/pkg/cmd/dev.go index a42653c..9cddfc0 100644 --- a/pkg/cmd/dev.go +++ b/pkg/cmd/dev.go @@ -62,7 +62,6 @@ var devCommand = cli.Command{ Usage: "Run in 'watch' mode to loop and rebuild when files change.", }, }, - Before: before, Action: runPreview, } diff --git a/pkg/cmd/lint.go b/pkg/cmd/lint.go index ad1fa46..bd99937 100644 --- a/pkg/cmd/lint.go +++ b/pkg/cmd/lint.go @@ -45,7 +45,6 @@ var lintCommand = cli.Command{ Usage: "Watch for files to change and re-run linting", }, }, - Before: before, Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("watch") { // Clear the screen and move the cursor to the top diff --git a/pkg/cmd/mcp.go b/pkg/cmd/mcp.go index f9507af..fcb5bbb 100644 --- a/pkg/cmd/mcp.go +++ b/pkg/cmd/mcp.go @@ -15,7 +15,6 @@ var mcpCommand = cli.Command{ Name: "mcp", Usage: "Run Stainless MCP server", Description: "Wrapper around @stainless-api/mcp@latest with environment variables set", - Before: before, Action: handleMCP, ArgsUsage: "[MCP_ARGS...]", HideHelpCommand: true, diff --git a/pkg/cmd/org.go b/pkg/cmd/org.go index 7682f51..a8bc462 100644 --- a/pkg/cmd/org.go +++ b/pkg/cmd/org.go @@ -25,7 +25,6 @@ var orgsRetrieve = cli.Command{ Required: true, }, }, - Before: before, Action: handleOrgsRetrieve, HideHelpCommand: true, } @@ -35,7 +34,6 @@ var orgsList = cli.Command{ Usage: "List organizations accessible to the current authentication method.", Suggest: true, Flags: []cli.Flag{}, - Before: before, Action: handleOrgsList, HideHelpCommand: true, } diff --git a/pkg/cmd/project.go b/pkg/cmd/project.go index 8135aa8..62cc145 100644 --- a/pkg/cmd/project.go +++ b/pkg/cmd/project.go @@ -51,7 +51,6 @@ var projectsCreate = cli.Command{ BodyPath: "targets", }, }, - Before: before, Action: handleProjectsCreate, HideHelpCommand: true, } @@ -65,7 +64,6 @@ var projectsRetrieve = cli.Command{ Name: "project", }, }, - Before: before, Action: handleProjectsRetrieve, HideHelpCommand: true, } @@ -83,7 +81,6 @@ var projectsUpdate = cli.Command{ BodyPath: "display_name", }, }, - Before: before, Action: handleProjectsUpdate, HideHelpCommand: true, } @@ -114,7 +111,6 @@ var projectsList = cli.Command{ Usage: "The maximum number of items to return (use -1 for unlimited).", }, }, - Before: before, Action: handleProjectsList, HideHelpCommand: true, } diff --git a/pkg/cmd/projectbranch.go b/pkg/cmd/projectbranch.go index 9e27dbc..51de70a 100644 --- a/pkg/cmd/projectbranch.go +++ b/pkg/cmd/projectbranch.go @@ -42,7 +42,6 @@ var projectsBranchesCreate = cli.Command{ BodyPath: "force", }, }, - Before: before, Action: handleProjectsBranchesCreate, HideHelpCommand: true, } @@ -60,7 +59,6 @@ var projectsBranchesRetrieve = cli.Command{ Required: true, }, }, - Before: before, Action: handleProjectsBranchesRetrieve, HideHelpCommand: true, } @@ -90,7 +88,6 @@ var projectsBranchesList = cli.Command{ Usage: "The maximum number of items to return (use -1 for unlimited).", }, }, - Before: before, Action: handleProjectsBranchesList, HideHelpCommand: true, } @@ -108,7 +105,6 @@ var projectsBranchesDelete = cli.Command{ Required: true, }, }, - Before: before, Action: handleProjectsBranchesDelete, HideHelpCommand: true, } @@ -133,7 +129,6 @@ var projectsBranchesRebase = cli.Command{ QueryPath: "base", }, }, - Before: before, Action: handleProjectsBranchesRebase, HideHelpCommand: true, } @@ -156,7 +151,6 @@ var projectsBranchesReset = cli.Command{ QueryPath: "target_config_sha", }, }, - Before: before, Action: handleProjectsBranchesReset, HideHelpCommand: true, } diff --git a/pkg/cmd/projectconfig.go b/pkg/cmd/projectconfig.go index 9e4f2b2..dc8b6f2 100644 --- a/pkg/cmd/projectconfig.go +++ b/pkg/cmd/projectconfig.go @@ -36,7 +36,6 @@ var projectsConfigsRetrieve = cli.Command{ QueryPath: "include", }, }, - Before: before, Action: handleProjectsConfigsRetrieve, HideHelpCommand: true, } @@ -64,7 +63,6 @@ var projectsConfigsGuess = cli.Command{ BodyPath: "branch", }, }, - Before: before, Action: handleProjectsConfigsGuess, HideHelpCommand: true, } diff --git a/pkg/cmd/workspace.go b/pkg/cmd/workspace.go index 7023925..8e0ce8c 100644 --- a/pkg/cmd/workspace.go +++ b/pkg/cmd/workspace.go @@ -64,7 +64,6 @@ var workspaceInit = cli.Command{ Value: true, }, }, - Before: before, Action: handleInit, HideHelpCommand: true, } @@ -72,7 +71,6 @@ var workspaceInit = cli.Command{ var workspaceStatus = cli.Command{ Name: "status", Usage: "Show workspace configuration status", - Before: before, Action: handleWorkspaceStatus, HideHelpCommand: true, } From 742717047b54e4f56e5a1d5f4447fc4e633fcd1d Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Fri, 13 Mar 2026 18:30:51 -0400 Subject: [PATCH 06/25] feat: add git Show and CurrentBranch helpers --- pkg/git/git.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/pkg/git/git.go b/pkg/git/git.go index 1f35dcd..27a0368 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -79,3 +79,29 @@ func Fetch(dir, url string, refspecs ...string) error { } return nil } + +// Show returns the contents of a file at a given ref +func Show(dir, ref, path string) ([]byte, error) { + cmd := exec.Command("git", "-C", dir, "show", ref+":"+path) + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil, err + } + return stdout.Bytes(), nil +} + +// CurrentBranch returns the current branch name +func CurrentBranch(dir string) (string, error) { + cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + branch := strings.TrimSpace(stdout.String()) + if branch == "" { + return "", fmt.Errorf("could not determine current git branch") + } + return branch, nil +} From c094b0f12cca78e4028311a8624696a842b643ea Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Fri, 13 Mar 2026 18:30:58 -0400 Subject: [PATCH 07/25] feat(components/diagnostics): rewrite diagnostics view with Rust-style formatting --- pkg/cmd/cmd.go | 27 ++- pkg/cmd/dev.go | 1 + pkg/cmd/lint.go | 3 +- pkg/components/diagnostics/model.go | 12 +- .../testdata/view_diagnostics.snapshot | 23 ++ pkg/components/diagnostics/view.go | 216 ++++++++---------- pkg/components/diagnostics/view_test.go | 129 +++++++++++ pkg/workspace/config.go | 4 + 8 files changed, 285 insertions(+), 130 deletions(-) create mode 100644 pkg/components/diagnostics/testdata/view_diagnostics.snapshot create mode 100644 pkg/components/diagnostics/view_test.go diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 07bf489..7511661 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -13,9 +13,9 @@ import ( "strings" "github.com/stainless-api/stainless-api-cli/internal/autocomplete" + "github.com/stainless-api/stainless-api-cli/internal/requestflag" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/workspace" - "github.com/stainless-api/stainless-api-cli/internal/requestflag" docs "github.com/urfave/cli-docs/v3" "github.com/urfave/cli/v3" ) @@ -265,23 +265,42 @@ func before(ctx context.Context, cmd *cli.Command) (context.Context, error) { if _, err := wc.Find(); err != nil { console.Warn("%s", err) } - ctx = context.WithValue(ctx, "workspace_config", wc) var names []string for _, flag := range cmd.Flags { names = append(names, flag.Names()...) } + // Set the in-memory version of the workspace to the values that the flags override + if cmd.IsSet("project") { + wc.Project = cmd.String("project") + } + if cmd.IsSet("openapi-spec") { + wc.OpenAPISpec = cmd.String("openapi-spec") + } + if cmd.IsSet("stainless-config") { + wc.StainlessConfig = cmd.String("stainless-config") + } + if slices.Contains(names, "project") && wc.Project != "" && !cmd.IsSet("project") { cmd.Set("project", wc.Project) } - if slices.Contains(names, "openapi-spec") && wc.OpenAPISpec != "" && !cmd.IsSet("openapi-spec") && !cmd.IsSet("revision") { + + // if any of the revisions are supplied, then it's more confusing to partially fill in data from the + // workspace so we just don't. + isRevisionSupplied := cmd.IsSet("stainless-config") || cmd.IsSet("openapi-spec") || cmd.IsSet("revision") + + if slices.Contains(names, "openapi-spec") && wc.OpenAPISpec != "" && !isRevisionSupplied { cmd.Set("openapi-spec", wc.OpenAPISpec) } - if slices.Contains(names, "stainless-config") && wc.StainlessConfig != "" && !cmd.IsSet("stainless-config") && !cmd.IsSet("revision") { + if slices.Contains(names, "stainless-config") && wc.StainlessConfig != "" && !isRevisionSupplied { cmd.Set("stainless-config", wc.StainlessConfig) } + // Store workspace config after merging CLI overrides so downstream + // consumers always see the effective paths. + ctx = context.WithValue(ctx, "workspace_config", wc) + return ctx, nil } diff --git a/pkg/cmd/dev.go b/pkg/cmd/dev.go index 9cddfc0..14c57fb 100644 --- a/pkg/cmd/dev.go +++ b/pkg/cmd/dev.go @@ -252,6 +252,7 @@ func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Conf downloads, cmd.Bool("watch"), ) + model.Diagnostics.WorkspaceConfig = wc p := console.NewProgram(model) finalModel, err := p.Run() diff --git a/pkg/cmd/lint.go b/pkg/cmd/lint.go index bd99937..8f25b93 100644 --- a/pkg/cmd/lint.go +++ b/pkg/cmd/lint.go @@ -137,7 +137,7 @@ func (m lintModel) View() string { content = m.spinner.View() + " Linting" } } else { - content = diagnostics.ViewDiagnostics(m.diagnostics, -1) + content = diagnostics.ViewDiagnostics(m.diagnostics, -1, workspace.Relative(m.wc.OpenAPISpec), workspace.Relative(m.wc.StainlessConfig)) if m.skipped { content += "\nContinuing..." } else if m.watching { @@ -208,6 +208,7 @@ func runLinter(ctx context.Context, cmd *cli.Command, canSkip bool) error { ctx: ctx, cmd: cmd, client: client, + wc: wc, stopPolling: make(chan struct{}), help: help.New(), } diff --git a/pkg/components/diagnostics/model.go b/pkg/components/diagnostics/model.go index 3cb892e..6fa472b 100644 --- a/pkg/components/diagnostics/model.go +++ b/pkg/components/diagnostics/model.go @@ -5,16 +5,18 @@ import ( "errors" tea "github.com/charmbracelet/bubbletea" + "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go" ) var ErrUserCancelled = errors.New("user cancelled") type Model struct { - Diagnostics []stainless.BuildDiagnostic - Client stainless.Client - Ctx context.Context - Err error + Diagnostics []stainless.BuildDiagnostic + Client stainless.Client + Ctx context.Context + Err error + WorkspaceConfig workspace.Config } type FetchDiagnosticsMsg []stainless.BuildDiagnostic @@ -58,7 +60,7 @@ func (m Model) View() string { if m.Diagnostics == nil { return "" } - return ViewDiagnostics(m.Diagnostics, 10) + return ViewDiagnostics(m.Diagnostics, 10, workspace.Relative(m.WorkspaceConfig.OpenAPISpec), workspace.Relative(m.WorkspaceConfig.StainlessConfig)) } func (m Model) FetchDiagnostics(buildID string) tea.Cmd { diff --git a/pkg/components/diagnostics/testdata/view_diagnostics.snapshot b/pkg/components/diagnostics/testdata/view_diagnostics.snapshot new file mode 100644 index 0000000..f7e937d --- /dev/null +++ b/pkg/components/diagnostics/testdata/view_diagnostics.snapshot @@ -0,0 +1,23 @@ +(no diagnostics) + +(no diagnostics) + +error: failed to fetch diagnostics: connection refused + +error[MissingField]: The field 'name' is required but missing + --> openapi.yml: /paths/~1users/post/requestBody + --> stainless.yml: /endpoints/~1users/post + +fatal[FatalError]: Build failed due to configuration error + --> openapi.yml: /paths/~1users + Check your stainless.yml for syntax errors. + See docs for details. + +warning[DeprecatedUsage]: The x-deprecated extension is deprecated + --> openapi.yml: /paths/~1foo/get + +... and 1 more diagnostics + +error[MissingField]: Field 'id' is required + --> specs/openapi.json: /paths/~1pets/get + --> .stainless/stainless.yaml: /endpoints/~1pets/get diff --git a/pkg/components/diagnostics/view.go b/pkg/components/diagnostics/view.go index 542fe62..cfed9ee 100644 --- a/pkg/components/diagnostics/view.go +++ b/pkg/components/diagnostics/view.go @@ -2,161 +2,137 @@ package diagnostics import ( "fmt" - "os" "strings" - "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" - "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-go" - "golang.org/x/term" ) -// ViewDiagnosticsError renders an error when fetching diagnostics fails -func ViewDiagnosticsError(err error) string { - var s strings.Builder - errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - s.WriteString(console.SProperty(0, "build diagnostics", errorStyle.Render("(error: "+err.Error()+")"))) - return s.String() -} +var ( + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) + noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + codeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + refStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) -// ViewDiagnosticIcon returns a colored icon for a diagnostic level -func ViewDiagnosticIcon(level stainless.BuildDiagnosticLevel) string { +// levelLabel returns the colored level prefix and bracket-wrapped code for a diagnostic. +func levelLabel(level stainless.BuildDiagnosticLevel, code string) string { + var levelStr string switch level { case stainless.BuildDiagnosticLevelFatal: - return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true).Render("(F)") + levelStr = errorStyle.Render("fatal") + code = errorStyle.UnsetBold().Render("[" + code + "]") case stainless.BuildDiagnosticLevelError: - return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("(E)") + levelStr = errorStyle.Render("error") + code = errorStyle.UnsetBold().Render("[" + code + "]") case stainless.BuildDiagnosticLevelWarning: - return lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("(W)") + levelStr = warningStyle.Render("warning") + code = warningStyle.UnsetBold().Render("[" + code + "]") case stainless.BuildDiagnosticLevelNote: - return lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Render("(i)") + levelStr = noteStyle.Render("note") + code = noteStyle.Render("[" + code + "]") default: - return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("•") + levelStr = code + code = "" + } + if code != "" { + return levelStr + code } + return levelStr } -var renderer *glamour.TermRenderer - -func init() { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 || width > 120 { - width = 120 - } - renderer, _ = glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(width), - ) +// ViewDiagnosticsError renders an error when fetching diagnostics fails +func ViewDiagnosticsError(err error) string { + return errorStyle.Render("error") + ": failed to fetch diagnostics: " + err.Error() + "\n" } -// renderMarkdown renders markdown content using glamour -func renderMarkdown(content string) string { - if renderer == nil { - return content +// ViewDiagnostics renders build diagnostics in Rust-style formatting. +// Notes are hidden by default. oasLabel and configLabel are the filenames +// shown in source references (e.g. "openapi.json", "stainless.yaml"). +func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int, oasLabel, configLabel string) string { + if oasLabel == "" { + oasLabel = "openapi.yml" } - - rendered, err := renderer.Render(content) - if err != nil { - return content + if configLabel == "" { + configLabel = "stainless.yml" } - - return strings.Trim(rendered, "\n ") -} - -// countDiagnosticsBySeverity counts diagnostics by severity level -func countDiagnosticsBySeverity(diagnostics []stainless.BuildDiagnostic) (fatal, errors, warnings, notes int) { - for _, diag := range diagnostics { - switch diag.Level { - case stainless.BuildDiagnosticLevelFatal: - fatal++ - case stainless.BuildDiagnosticLevelError: - errors++ - case stainless.BuildDiagnosticLevelWarning: - warnings++ - case stainless.BuildDiagnosticLevelNote: - notes++ + // Filter out notes + var visible []stainless.BuildDiagnostic + for _, d := range diagnostics { + if d.Level != stainless.BuildDiagnosticLevelNote { + visible = append(visible, d) } } - return -} -// ViewDiagnostics renders build diagnostics with formatting -func ViewDiagnostics(diagnostics []stainless.BuildDiagnostic, maxDiagnostics int) string { + if len(visible) == 0 { + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + return grayStyle.Render("(no diagnostics)") + "\n" + } + var s strings.Builder - if len(diagnostics) > 0 { - // Count diagnostics by severity - fatal, errors, warnings, notes := countDiagnosticsBySeverity(diagnostics) + truncated := false + shown := len(visible) + if maxDiagnostics >= 0 && len(visible) > maxDiagnostics { + truncated = true + shown = maxDiagnostics + } - // Create summary string - var summaryParts []string - if fatal > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d fatal", fatal)) - } - if errors > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d errors", errors)) - } - if warnings > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d warnings", warnings)) - } - if notes > 0 { - summaryParts = append(summaryParts, fmt.Sprintf("%d notes", notes)) + rendered := 0 + for _, diag := range visible { + if maxDiagnostics >= 0 && rendered >= maxDiagnostics { + break } - summary := strings.Join(summaryParts, ", ") - if summary != "" { - summary = fmt.Sprintf(" (%s)", summary) + if rendered > 0 { + s.WriteString("\n") } - - var sub strings.Builder - - if maxDiagnostics >= 0 && len(diagnostics) > maxDiagnostics { - sub.WriteString(fmt.Sprintf("Showing first %d of %d diagnostics:\n", maxDiagnostics, len(diagnostics))) + rendered++ + + // Header: error[Code]: message + s.WriteString(levelLabel(diag.Level, diag.Code)) + s.WriteString(": ") + s.WriteString(diag.Message) + s.WriteString("\n") + + // Source references + if diag.OasRef != "" { + s.WriteString(refStyle.Render(" --> " + oasLabel + ": " + diag.OasRef)) + s.WriteString("\n") + } + if diag.ConfigRef != "" { + s.WriteString(refStyle.Render(" --> " + configLabel + ": " + diag.ConfigRef)) + s.WriteString("\n") } - for i, diag := range diagnostics { - if maxDiagnostics >= 0 && i >= maxDiagnostics { - break - } - - levelIcon := ViewDiagnosticIcon(diag.Level) - codeStyle := lipgloss.NewStyle().Bold(true) - - if i > 0 { - sub.WriteString("\n") - } - sub.WriteString(fmt.Sprintf("%s %s\n", levelIcon, codeStyle.Render(diag.Code))) - sub.WriteString(fmt.Sprintf("%s\n", renderMarkdown(diag.Message))) - - if diag.Code == "FatalError" { - switch more := diag.More.AsAny().(type) { - case stainless.BuildDiagnosticMoreMarkdown: - sub.WriteString(fmt.Sprintf("%s\n", renderMarkdown(more.Markdown))) - case stainless.BuildDiagnosticMoreRaw: - sub.WriteString(fmt.Sprintf("%s\n", more.Raw)) + // Additional content from More field + if diag.More.AsAny() != nil { + switch more := diag.More.AsAny().(type) { + case stainless.BuildDiagnosticMoreMarkdown: + text := strings.TrimSpace(more.Markdown) + if text != "" { + for _, line := range strings.Split(text, "\n") { + s.WriteString(" ") + s.WriteString(line) + s.WriteString("\n") + } + } + case stainless.BuildDiagnosticMoreRaw: + text := strings.TrimSpace(more.Raw) + if text != "" { + for _, line := range strings.Split(text, "\n") { + s.WriteString(" ") + s.WriteString(line) + s.WriteString("\n") + } } - } - - // Show source references if available - if diag.OasRef != "" { - refStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - sub.WriteString(fmt.Sprintf(" %s\n", refStyle.Render("OpenAPI: "+diag.OasRef))) - } - if diag.ConfigRef != "" { - refStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - sub.WriteString(fmt.Sprintf(" %s\n", refStyle.Render("Config: "+diag.ConfigRef))) } } + } - s.WriteString(console.SProperty(0, "build diagnostics", summary)) - s.WriteString(lipgloss.NewStyle(). - Padding(0). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("208")). - Render(strings.TrimRight(sub.String(), "\n")), - ) - } else { - s.WriteString(console.SProperty(0, "build diagnostics", "(no errors or warnings)")) + if truncated { + s.WriteString(fmt.Sprintf("\n... and %d more diagnostics\n", len(visible)-shown)) } return s.String() diff --git a/pkg/components/diagnostics/view_test.go b/pkg/components/diagnostics/view_test.go new file mode 100644 index 0000000..52d389b --- /dev/null +++ b/pkg/components/diagnostics/view_test.go @@ -0,0 +1,129 @@ +package diagnostics + +import ( + "encoding/json" + "errors" + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stainless-api/stainless-api-go" +) + +var update = flag.Bool("update", false, "update snapshot files") + +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.ANSI) + os.Exit(m.Run()) +} + +func mustDiags(t *testing.T, jsonStr string) []stainless.BuildDiagnostic { + t.Helper() + var d []stainless.BuildDiagnostic + if err := json.Unmarshal([]byte(jsonStr), &d); err != nil { + t.Fatalf("failed to unmarshal diagnostics JSON: %v", err) + } + return d +} + +func snapshot(t *testing.T, name string, got string) { + t.Helper() + path := filepath.Join("testdata", name+".snapshot") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("snapshot file %s not found; run with -update to create it: %v", path, err) + } + if string(want) != got { + t.Errorf("snapshot mismatch for %s\nwant: %q\ngot: %q\nrun with -update to update", name, string(want), got) + } +} + +func TestViewDiagnostics(t *testing.T) { + var out strings.Builder + + // no diagnostics + out.WriteString(ViewDiagnostics(nil, 10, "", "")) + out.WriteString("\n") + + // notes only (hidden, treated as empty) + out.WriteString(ViewDiagnostics(mustDiags(t, `[ + {"code": "StyleSuggestion", "level": "note", "message": "Consider camelCase", "ignored": false, "more": null} + ]`), 10, "", "")) + out.WriteString("\n") + + // fetch error + out.WriteString(ViewDiagnosticsError(errors.New("connection refused"))) + out.WriteString("\n") + + // mixed: errors, warnings, notes, refs, more content, truncation (default labels) + out.WriteString(ViewDiagnostics(mustDiags(t, `[ + { + "code": "MissingField", + "level": "error", + "message": "The field 'name' is required but missing", + "ignored": false, + "more": null, + "oas_ref": "/paths/~1users/post/requestBody", + "config_ref": "/endpoints/~1users/post" + }, + { + "code": "FatalError", + "level": "fatal", + "message": "Build failed due to configuration error", + "ignored": false, + "more": {"type": "markdown", "markdown": "Check your stainless.yml for syntax errors.\nSee docs for details."}, + "oas_ref": "/paths/~1users" + }, + { + "code": "DeprecatedUsage", + "level": "warning", + "message": "The x-deprecated extension is deprecated", + "ignored": false, + "more": null, + "oas_ref": "/paths/~1foo/get" + }, + { + "code": "StyleSuggestion", + "level": "note", + "message": "Consider using camelCase", + "ignored": false, + "more": null + }, + { + "code": "Err3", + "level": "error", + "message": "Truncated away", + "ignored": false, + "more": null + } + ]`), 3, "", "")) + out.WriteString("\n") + + // custom labels (e.g. relative paths from workspace config) + out.WriteString(ViewDiagnostics(mustDiags(t, `[ + { + "code": "MissingField", + "level": "error", + "message": "Field 'id' is required", + "ignored": false, + "more": null, + "oas_ref": "/paths/~1pets/get", + "config_ref": "/endpoints/~1pets/get" + } + ]`), 10, "specs/openapi.json", ".stainless/stainless.yaml")) + + snapshot(t, "view_diagnostics", out.String()) +} diff --git a/pkg/workspace/config.go b/pkg/workspace/config.go index d64653d..c17318f 100644 --- a/pkg/workspace/config.go +++ b/pkg/workspace/config.go @@ -18,6 +18,10 @@ func Resolve(baseDir, path string) string { } func Relative(path string) string { + if path == "" { + return "" + } + cwd, err := os.Getwd() if err != nil { return path From 270e85bd52601694c07ddd264b952b8ab0a18cc4 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Fri, 13 Mar 2026 18:31:03 -0400 Subject: [PATCH 08/25] feat(components/build): rewrite build pipeline view as single-line with header --- pkg/components/build/model.go | 22 +- .../testdata/view_build_pipeline.snapshot | 55 +++++ pkg/components/build/view.go | 220 +++++++++++++----- pkg/components/build/view_test.go | 197 ++++++++++++++++ pkg/stainlessutils/stainlessutils.go | 2 +- 5 files changed, 440 insertions(+), 56 deletions(-) create mode 100644 pkg/components/build/testdata/view_build_pipeline.snapshot create mode 100644 pkg/components/build/view_test.go diff --git a/pkg/components/build/model.go b/pkg/components/build/model.go index c9aaec2..5a1679b 100644 --- a/pkg/components/build/model.go +++ b/pkg/components/build/model.go @@ -12,7 +12,9 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/git" "github.com/stainless-api/stainless-api-cli/pkg/stainlessutils" @@ -30,6 +32,7 @@ type Model struct { Downloads map[stainless.Target]DownloadStatus // When a BuildTarget has a commit available, this target will download it, if it has been specified in the initialization. Err error // This will be populated if the model concludes with an error CommitOnly bool // When true, only show the commit step in the pipeline view + Spinner spinner.Model // Spinner for in-progress animation } type DownloadStatus struct { @@ -61,19 +64,27 @@ func NewModel(client stainless.Client, ctx context.Context, build stainless.Buil } } + s := spinner.New() + s.Spinner = spinner.MiniDot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + return Model{ Build: build, Client: client, Ctx: ctx, Branch: branch, Downloads: downloads, + Spinner: s, } } func (m Model) Init() tea.Cmd { - return func() tea.Msg { - return TickMsg(time.Now()) - } + return tea.Batch( + m.Spinner.Tick, + func() tea.Msg { + return TickMsg(time.Now()) + }, + ) } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { @@ -120,6 +131,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } } + case spinner.TickMsg: + var cmd tea.Cmd + m.Spinner, cmd = m.Spinner.Update(msg) + cmds = append(cmds, cmd) + case ErrorMsg: m.Err = msg cmds = append(cmds, tea.Quit) diff --git a/pkg/components/build/testdata/view_build_pipeline.snapshot b/pkg/components/build/testdata/view_build_pipeline.snapshot new file mode 100644 index 0000000..a779286 --- /dev/null +++ b/pkg/components/build/testdata/view_build_pipeline.snapshot @@ -0,0 +1,55 @@ +typescript  queued ○ download + + +typescript  queued ○ download + + +typescript  generating | ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+100/-30) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (unchanged) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+50/-10) with warning diagnostic(s) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+50/-10) with error diagnostic(s) ○ lint ○ build ○ test ○ download + + +typescript  fatal error ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/pull/42merge conflict #42]8;; ○ lint ○ build ○ test ○ download + + +typescript  payment required ○ lint ○ build ○ test ○ download + + +typescript  cancelled ○ lint ○ build ○ test ○ download + + +typescript  timed out ○ lint ○ build ○ test ○ download + + +typescript  no-op ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/def5678901234def5678]8;; (+3/-3) ○ lint ○ build ○ test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ○ lint ● build  test ○ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ✓ download + + +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ⚠ download + Error: connection refused + + diff --git a/pkg/components/build/view.go b/pkg/components/build/view.go index 1a524e6..233b841 100644 --- a/pkg/components/build/view.go +++ b/pkg/components/build/view.go @@ -3,93 +3,207 @@ package build import ( "fmt" "strings" + "time" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/lipgloss" "github.com/stainless-api/stainless-api-cli/pkg/console" "github.com/stainless-api/stainless-api-cli/pkg/stainlessutils" "github.com/stainless-api/stainless-api-go" ) +var ( + headerLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("6")).Bold(true) + headerIDStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) + +// ViewHeader renders a styled header with a label badge, build ID, config commit, and relative timestamp. +func ViewHeader(label string, b stainless.Build) string { + var s strings.Builder + s.WriteString("\n") + s.WriteString(headerLabelStyle.Render(" " + label + " ")) + if b.ID != "" { + s.WriteString(" ") + s.WriteString(headerIDStyle.Render(b.ID)) + } + configCommit := b.ConfigCommit + if len(configCommit) > 7 { + configCommit = configCommit[:7] + } + if configCommit != "" { + s.WriteString(" ") + s.WriteString(headerIDStyle.Render(configCommit)) + } + if !b.CreatedAt.IsZero() { + s.WriteString(" ") + s.WriteString(headerIDStyle.Render(relativeTime(b.CreatedAt))) + } + s.WriteString("\n\n") + return s.String() +} + +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + m := int(d.Minutes()) + if m == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", m) + case d < 24*time.Hour: + h := int(d.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + default: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} + func (m Model) View() string { if m.Err != nil { return m.Err.Error() } - return View(m.Build, m.Downloads, m.CommitOnly) -} - -func View(build stainless.Build, downloads map[stainless.Target]DownloadStatus, commitOnly bool) string { s := strings.Builder{} - buildObj := stainlessutils.NewBuild(build) + buildObj := stainlessutils.NewBuild(m.Build) languages := buildObj.Languages() - // Target rows with colors for _, target := range languages { - pipeline := ViewBuildPipeline(build, target, downloads, commitOnly) - langStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - - s.WriteString(fmt.Sprintf("%s %s\n", langStyle.Render(fmt.Sprintf("%-13s", string(target))), pipeline)) + s.WriteString(ViewBuildPipeline(m.Build, target, m.Downloads, m.CommitOnly, m.Spinner)) } - // s.WriteString("\n") - - // completed := 0 - // building := 0 - // for _, target := range languages { - // buildTarget := buildObj.BuildTarget(target) - // if buildTarget != nil { - // if buildTarget.IsCompleted() { - // completed++ - // } else if buildTarget.IsInProgress() { - // building++ - // } - // } - // } - - // statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - // statusText := fmt.Sprintf("%d completed, %d building, %d pending\n", - // completed, building, len(languages)-completed-building) - // s.WriteString(statusStyle.Render(statusText)) - return s.String() } -// View renders the build pipeline for a target -func ViewBuildPipeline(build stainless.Build, target stainless.Target, downloads map[stainless.Target]DownloadStatus, commitOnly bool) string { +// commitStatusWidth is the fixed visible width for the commit status column, +// based on the longest expected content: "71d249c (unchanged) with error diagnostic(s)" +const commitStatusWidth = 44 + +// ViewBuildPipeline renders the build pipeline for a target on a single line. +// Format: +func ViewBuildPipeline(build stainless.Build, target stainless.Target, downloads map[stainless.Target]DownloadStatus, commitOnly bool, sp spinner.Model) string { + langStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + buildObj := stainlessutils.NewBuild(build) buildTarget := buildObj.BuildTarget(target) if buildTarget == nil { return "" } - stepOrder := buildTarget.Steps() - var pipeline strings.Builder - - for _, step := range stepOrder { - if commitOnly && step != "commit" { - continue - } - status, url, conclusion := buildTarget.StepInfo(step) - if status == "" { - continue // Skip steps that don't exist for this target - } - if pipeline.Len() > 0 { - pipeline.WriteString(" ") + // Build commit status text + var commitStatus strings.Builder + commitStep := buildTarget.Commit + switch commitStep.Status { + case "", "not_started", "queued": + commitStatus.WriteString(grayStyle.Render("queued")) + case "in_progress": + commitStatus.WriteString(grayStyle.Render("generating ") + sp.View()) + case "completed": + conclusion := commitStep.Conclusion + switch conclusion { + case "merge_conflict", "upstream_merge_conflict": + pr := commitStep.MergeConflictPr + prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%.0f", pr.Repo.Owner, pr.Repo.Name, pr.Number) + commitStatus.WriteString(yellowStyle.Render(console.Hyperlink(prURL, fmt.Sprintf("merge conflict #%.0f", pr.Number)))) + case "fatal": + commitStatus.WriteString(redStyle.Render("fatal error")) + case "payment_required": + commitStatus.WriteString(redStyle.Render("payment required")) + case "cancelled": + commitStatus.WriteString(grayStyle.Render("cancelled")) + case "timed_out": + commitStatus.WriteString(redStyle.Render("timed out")) + case "noop": + commitStatus.WriteString(grayStyle.Render("no-op")) + case "success", "note", "warning", "error", "version_bump": + // These conclusions all produce a commit + commit := commitStep.Commit + sha := commit.Sha + if len(sha) > 7 { + sha = sha[:7] + } + commitURL := fmt.Sprintf("https://github.com/%s/%s/commit/%s", commit.Repo.Owner, commit.Repo.Name, commit.Sha) + additions := commit.Stats.Additions + deletions := commit.Stats.Deletions + commitStatus.WriteString(console.Hyperlink(commitURL, sha)) + if additions > 0 || deletions > 0 { + commitStatus.WriteString(" " + grayStyle.Render("(") + + greenStyle.Render(fmt.Sprintf("+%d", additions)) + + grayStyle.Render("/") + + redStyle.Render(fmt.Sprintf("-%d", deletions)) + + grayStyle.Render(")")) + } else { + commitStatus.WriteString(" " + grayStyle.Render("(unchanged)")) + } + switch conclusion { + case "error": + commitStatus.WriteString(" with " + redStyle.Render("error") + " diagnostic(s)") + case "warning": + commitStatus.WriteString(" with " + yellowStyle.Render("warning") + " diagnostic(s)") + } + default: + commitStatus.WriteString(grayStyle.Render(conclusion)) } - // align our naming of the commit step with the version in the Studio - if step == "commit" { - step = "codegen" + } + + // Pad commit status to fixed width so step symbols align vertically + statusStr := commitStatus.String() + if pad := commitStatusWidth - lipgloss.Width(statusStr); pad > 0 { + statusStr += strings.Repeat(" ", pad) + } + + // Build the line + var line strings.Builder + line.WriteString(langStyle.Render(fmt.Sprintf("%-13s", string(target))) + " ") + line.WriteString(statusStr) + + // Collect post-commit steps + download (only when commit step is completed) + var stepParts []string + if !commitOnly && commitStep.Status == "completed" { + for _, step := range buildTarget.Steps() { + if step == "commit" { + continue + } + stepStatus, stepURL, stepConclusion := buildTarget.StepInfo(step) + if stepStatus == "" { + continue + } + stepLabel := step + if stepURL != "" { + stepLabel = console.Hyperlink(stepURL, step) + } + stepParts = append(stepParts, ViewStepSymbol(stepStatus, stepConclusion)+" "+stepLabel) } - pipeline.WriteString(ViewStepSymbol(status, conclusion) + " " + console.Hyperlink(url, step)) } if download, ok := downloads[target]; ok { - pipeline.WriteString(" " + ViewStepSymbol(download.Status, download.Conclusion) + " " + "download") + stepParts = append(stepParts, ViewStepSymbol(download.Status, download.Conclusion)+" "+"download") if download.Conclusion == "failure" && download.Error != "" { errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - pipeline.WriteString("\n" + errorStyle.Render(" Error: "+download.Error)) + line.WriteString(" " + strings.Join(stepParts, " ")) + line.WriteString("\n" + errorStyle.Render(" Error: "+download.Error)) + line.WriteString("\n") + return line.String() } } - return pipeline.String() + if len(stepParts) > 0 { + line.WriteString(" " + strings.Join(stepParts, " ")) + } + line.WriteString("\n") + + return line.String() } // ViewStepSymbol returns a colored symbol for a build step status @@ -114,6 +228,8 @@ func ViewStepSymbol(status, conclusion string) string { return redStyle.Render("⚠") case "fatal": return redStyle.Render("✗") + case "cancelled", "skipped": + return grayStyle.Render("⊘") case "merge_conflict", "upstream_merge_conflict": return yellowStyle.Render("m") default: diff --git a/pkg/components/build/view_test.go b/pkg/components/build/view_test.go new file mode 100644 index 0000000..b4135e1 --- /dev/null +++ b/pkg/components/build/view_test.go @@ -0,0 +1,197 @@ +package build + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stainless-api/stainless-api-go" +) + +var update = flag.Bool("update", false, "update snapshot files") + +func TestMain(m *testing.M) { + lipgloss.SetColorProfile(termenv.ANSI) + os.Exit(m.Run()) +} + +func mustBuild(t *testing.T, jsonStr string) stainless.Build { + t.Helper() + var b stainless.Build + if err := json.Unmarshal([]byte(jsonStr), &b); err != nil { + t.Fatalf("failed to unmarshal build JSON: %v", err) + } + return b +} + +func newSpinner() spinner.Model { + return spinner.New() +} + +// snapshot compares got against the snapshot file testdata/.snapshot. +// When -update is passed, it writes/overwrites the snapshot file instead. +func snapshot(t *testing.T, name string, got string) { + t.Helper() + path := filepath.Join("testdata", name+".snapshot") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("snapshot file %s not found; run with -update to create it: %v", path, err) + } + if string(want) != got { + t.Errorf("snapshot mismatch for %s\nwant: %q\ngot: %q\nrun with -update to update", name, string(want), got) + } +} + +const checkSteps = `"lint": {"status": "not_started"}, "build": {"status": "not_started"}, "test": {"status": "not_started"}` + +func TestViewBuildPipeline(t *testing.T) { + sp := newSpinner() + var out strings.Builder + dl := map[stainless.Target]DownloadStatus{"typescript": {Status: "not_started"}} + + // queued (not_started) + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "not_started"}, `+checkSteps+`, "status": "not_started", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // queued (queued status) + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "queued"}, `+checkSteps+`, "status": "not_started", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // in_progress + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "in_progress"}, `+checkSteps+`, "status": "codegen", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // success with changes + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 100, "deletions": 30, "total": 130}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // success unchanged + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 0, "deletions": 0, "total": 0}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // warning conclusion + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "warning", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 50, "deletions": 10, "total": 60}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // error conclusion + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "error", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 50, "deletions": 10, "total": 60}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // fatal + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "fatal"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // merge_conflict + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "merge_conflict", "merge_conflict_pr": {"number": 42, "repo": {"owner": "org", "name": "repo"}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // payment_required + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "payment_required"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // cancelled + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "cancelled"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // timed_out + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "timed_out"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // noop + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "noop"}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // version_bump + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "version_bump", "commit": {"sha": "def5678901234", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 3, "deletions": 3, "total": 6}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // post-commit steps (lint/build/test in various states) + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, "lint": {"status": "not_started"}, "build": {"status": "in_progress"}, "test": {"status": "completed", "conclusion": "success", "url": ""}, "status": "postgen", "object": "build_target", "install_url": ""}} + }`), "typescript", dl, false, sp)) + out.WriteString("\n\n") + + // commitOnly hides post-commit steps + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, `+checkSteps+`, "status": "postgen", "object": "build_target", "install_url": ""}} + }`), "typescript", nil, true, sp)) + out.WriteString("\n\n") + + // download success + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", map[stainless.Target]DownloadStatus{"typescript": {Status: "completed", Conclusion: "success"}}, true, sp)) + out.WriteString("\n\n") + + // download failure + out.WriteString(ViewBuildPipeline(mustBuild(t, `{ + "id": "build_1", + "targets": {"typescript": {"commit": {"status": "completed", "conclusion": "success", "commit": {"sha": "abc1234567890", "tree_oid": "tree", "repo": {"owner": "org", "name": "repo"}, "stats": {"additions": 10, "deletions": 5, "total": 15}}}, `+checkSteps+`, "status": "completed", "object": "build_target", "install_url": ""}} + }`), "typescript", map[stainless.Target]DownloadStatus{"typescript": {Status: "completed", Conclusion: "failure", Error: "connection refused"}}, true, sp)) + out.WriteString("\n\n") + + // nil target + out.WriteString(ViewBuildPipeline(mustBuild(t, `{"id": "build_1", "targets": {}}`), "typescript", nil, false, sp)) + + snapshot(t, "view_build_pipeline", out.String()) +} diff --git a/pkg/stainlessutils/stainlessutils.go b/pkg/stainlessutils/stainlessutils.go index 5e02012..9d2dab3 100644 --- a/pkg/stainlessutils/stainlessutils.go +++ b/pkg/stainlessutils/stainlessutils.go @@ -205,9 +205,9 @@ func (bt *BuildTarget) StepInfo(step string) (status, url, conclusion string) { } if u, ok := stepUnion.(stainless.CheckStepUnion); ok { status = u.Status + url = u.URL if u.Status == "completed" { conclusion = u.Completed.Conclusion - url = u.Completed.URL } } return From b7cc41a9d81e5442aeffeca81730f51c94b83f1e Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Fri, 13 Mar 2026 18:31:06 -0400 Subject: [PATCH 09/25] feat(cmd/dev): refactor dev command to use compare builds --- pkg/cmd/dev.go | 292 ++++++++++++++++-------------------- pkg/components/dev/model.go | 27 +++- pkg/components/dev/view.go | 38 +++-- 3 files changed, 168 insertions(+), 189 deletions(-) diff --git a/pkg/cmd/dev.go b/pkg/cmd/dev.go index 14c57fb..225bd9c 100644 --- a/pkg/cmd/dev.go +++ b/pkg/cmd/dev.go @@ -8,15 +8,14 @@ import ( "errors" "fmt" "os" - "os/exec" "path" - "strings" + "path/filepath" "time" - "github.com/charmbracelet/huh" "github.com/stainless-api/stainless-api-cli/pkg/components/build" "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/git" "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go" "github.com/stainless-api/stainless-api-go/option" @@ -46,14 +45,9 @@ var devCommand = cli.Command{ Usage: "Path to Stainless config file", }, &cli.StringFlag{ - Name: "branch", - Aliases: []string{"b"}, - Usage: "Which branch to use", - }, - &cli.StringSliceFlag{ - Name: "target", - Aliases: []string{"t"}, - Usage: "The target build language(s)", + Name: "base", + Value: "HEAD", + Usage: "Git ref to use as the base revision for comparison", }, &cli.BoolFlag{ Name: "watch", @@ -67,7 +61,6 @@ var devCommand = cli.Command{ func runPreview(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("watch") { - // Clear the screen and move the cursor to the top fmt.Print("\033[2J\033[H") os.Stdout.Sync() } @@ -76,53 +69,8 @@ func runPreview(ctx context.Context, cmd *cli.Command) error { wc := getWorkspace(ctx) - gitUser, err := getGitUsername() - if err != nil { - console.Warn("Couldn't get a git user: %s", err) - gitUser = "user" - } - - var selectedBranch string - if cmd.IsSet("branch") { - selectedBranch = cmd.String("branch") - } else { - selectedBranch, err = chooseBranch(gitUser) - if err != nil { - return err - } - } - console.Property("branch", selectedBranch) - - // Phase 2: Language selection - var selectedTargets []string - targetInfos := getAvailableTargetInfo(ctx, client, cmd.String("project"), wc) - if cmd.IsSet("target") { - selectedTargets = cmd.StringSlice("target") - for _, target := range selectedTargets { - if !isValidTarget(targetInfos, stainless.Target(target)) { - return fmt.Errorf("invalid language target: %s", target) - } - } - } else { - selectedTargets, err = chooseSelectedTargets(targetInfos) - } - - if len(selectedTargets) == 0 { - return fmt.Errorf("no languages selected") - } - - console.Property("targets", strings.Join(selectedTargets, ", ")) - - // Convert string targets to stainless.Target - targets := make([]stainless.Target, len(selectedTargets)) - for i, target := range selectedTargets { - targets[i] = stainless.Target(target) - } - - // Phase 3: Start build and monitor progress in a loop for { - // Start the build process - if err := runDevBuild(ctx, client, wc, cmd, selectedBranch, targets); err != nil { + if err := runDevBuild(ctx, client, wc, cmd); err != nil { if errors.Is(err, build.ErrUserCancelled) { return nil } @@ -133,96 +81,139 @@ func runPreview(ctx context.Context, cmd *cli.Command) error { break } - // Clear the screen and move the cursor to the top fmt.Print("\nRebuilding...\n\n\033[2J\033[H") os.Stdout.Sync() - console.Property("branch", selectedBranch) - console.Property("targets", strings.Join(selectedTargets, ", ")) } return nil } -func chooseBranch(gitUser string) (string, error) { +// generateEphemeralBranches creates a paired set of ephemeral branch names +// for a compare build: one for the base and one for the head. +func generateEphemeralBranches(branchName string) (baseBranch, headBranch string) { now := time.Now() randomBytes := make([]byte, 3) rand.Read(randomBytes) - randomSuffix := base64.RawURLEncoding.EncodeToString(randomBytes) - randomBranch := fmt.Sprintf("%s/%d%02d%02d-%s", gitUser, now.Year(), now.Month(), now.Day(), randomSuffix) - - branchOptions := []huh.Option[string]{} - if currentBranch, err := getCurrentGitBranch(); err == nil && currentBranch != "main" && currentBranch != "master" { - branchOptions = append(branchOptions, - huh.NewOption(currentBranch, currentBranch), - ) + entropy := fmt.Sprintf("%d%02d%02d-%s", now.Year(), now.Month(), now.Day(), base64.RawURLEncoding.EncodeToString(randomBytes)) + baseBranch = fmt.Sprintf("ephemeral-base-%s/%s", entropy, branchName) + headBranch = fmt.Sprintf("ephemeral-%s/%s", entropy, branchName) + return +} + +// readFileInputMap reads files from disk and returns them as a file input map +// suitable for a build revision. +func readFileInputMap(oasPath, configPath string) (map[string]shared.FileInputUnionParam, error) { + files := make(map[string]shared.FileInputUnionParam) + + if oasPath != "" { + content, err := os.ReadFile(oasPath) + if err != nil { + return nil, fmt.Errorf("failed to read openapi-spec file: %v", err) + } + files["openapi"+path.Ext(oasPath)] = shared.FileInputParamOfFileInputContent(string(content)) } - branchOptions = append(branchOptions, - huh.NewOption(fmt.Sprintf("%s/dev", gitUser), fmt.Sprintf("%s/dev", gitUser)), - huh.NewOption(fmt.Sprintf("%s/", gitUser), randomBranch), - ) - var selectedBranch string - branchForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("branch"). - Description("Select a Stainless project branch to use for development"). - Options(branchOptions...). - Value(&selectedBranch), - ), - ).WithTheme(console.GetFormTheme(0)) - - if err := branchForm.Run(); err != nil { - return selectedBranch, fmt.Errorf("branch selection failed: %v", err) + if configPath != "" { + content, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read stainless-config file: %v", err) + } + files["stainless"+path.Ext(configPath)] = shared.FileInputParamOfFileInputContent(string(content)) } - return selectedBranch, nil + return files, nil } -func chooseSelectedTargets(targetInfos []TargetInfo) ([]string, error) { - targetOptions := targetInfoToOptions(targetInfos) - - var selectedTargets []string - targetForm := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("targets"). - Description("Select targets to generate (space to select, enter to confirm, select none to select all):"). - Options(targetOptions...). - Value(&selectedTargets), - ), - ).WithTheme(console.GetFormTheme(0)) - - if err := targetForm.Run(); err != nil { - return nil, fmt.Errorf("target selection failed: %v", err) +// gitShowFileInputMap tries to read files at a given git ref and returns them +// as a file input map. Returns nil (not error) if any file can't be read from git. +func gitShowFileInputMap(repoDir, ref, oasPath, configPath string) map[string]shared.FileInputUnionParam { + files := make(map[string]shared.FileInputUnionParam) + + if oasPath != "" { + relPath, err := filepath.Rel(repoDir, oasPath) + if err != nil { + return nil + } + content, err := git.Show(repoDir, ref, relPath) + if err != nil { + return nil + } + files["openapi"+path.Ext(oasPath)] = shared.FileInputParamOfFileInputContent(string(content)) } - return selectedTargets, nil + + if configPath != "" { + relPath, err := filepath.Rel(repoDir, configPath) + if err != nil { + return nil + } + content, err := git.Show(repoDir, ref, relPath) + if err != nil { + return nil + } + files["stainless"+path.Ext(configPath)] = shared.FileInputParamOfFileInputContent(string(content)) + } + + return files } -func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Config, cmd *cli.Command, branch string, languages []stainless.Target) error { - projectName := cmd.String("project") - buildReq := stainless.BuildNewParams{ - Project: stainless.String(projectName), - Branch: stainless.String(branch), - Targets: languages, - AllowEmpty: stainless.Bool(true), +// gitRepoRoot returns the top-level directory of the git repo, or "" if not in a repo. +func gitRepoRoot(dir string) string { + sha, err := git.RevParse(dir, "--show-toplevel") + if err != nil { + return "" } + return sha +} - if name, oas, err := convertFileFlag(cmd, "openapi-spec"); err != nil { - return err - } else if oas != nil { - if buildReq.Revision.OfFileInputMap == nil { - buildReq.Revision.OfFileInputMap = make(map[string]shared.FileInputUnionParam) +func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Config, cmd *cli.Command) error { + projectName := cmd.String("project") + oasPath := cmd.String("openapi-spec") + configPath := cmd.String("stainless-config") + + // Determine git state and branch name + branchName := "main" + repoDir := gitRepoRoot(".") + inGitRepo := repoDir != "" + + if inGitRepo { + if b, err := git.CurrentBranch(repoDir); err == nil { + branchName = b } - buildReq.Revision.OfFileInputMap["openapi"+path.Ext(name)] = shared.FileInputParamOfFileInputContent(string(oas)) } + baseBranch, headBranch := generateEphemeralBranches(branchName) - if name, config, err := convertFileFlag(cmd, "stainless-config"); err != nil { + // Build head revision from current files on disk + headFiles, err := readFileInputMap(oasPath, configPath) + if err != nil { return err - } else if config != nil { - if buildReq.Revision.OfFileInputMap == nil { - buildReq.Revision.OfFileInputMap = make(map[string]shared.FileInputUnionParam) + } + + // Build base revision: try git show at --base ref, otherwise fall back to "main" + var baseRevision stainless.BuildCompareParamsBaseRevisionUnion + + baseRef := cmd.String("base") + if inGitRepo && oasPath != "" { + files := gitShowFileInputMap(repoDir, baseRef, oasPath, configPath) + if len(files) > 0 { + baseRevision.OfFileInputMap = files + } else { + baseRevision.OfString = stainless.String("main") } - buildReq.Revision.OfFileInputMap["stainless"+path.Ext(name)] = shared.FileInputParamOfFileInputContent(string(config)) + } else { + baseRevision.OfString = stainless.String("main") + } + + compareReq := stainless.BuildCompareParams{ + Project: stainless.String(projectName), + Base: stainless.BuildCompareParamsBase{ + Branch: baseBranch, + Revision: baseRevision, + }, + Head: stainless.BuildCompareParamsHead{ + Branch: headBranch, + Revision: stainless.BuildCompareParamsHeadRevisionUnion{ + OfFileInputMap: headFiles, + }, + }, } downloads := make(map[stainless.Target]string) @@ -230,28 +221,28 @@ func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Conf downloads[stainless.Target(targetName)] = targetConfig.OutputPath } - model := dev.NewModel( - client, - ctx, - branch, - func() (*stainless.Build, error) { + model := dev.NewModel(dev.ModelConfig{ + Client: client, + Ctx: ctx, + Branch: headBranch, + Start: func() (*stainless.Build, error) { options := []option.RequestOption{} if cmd.Bool("debug") { options = append(options, debugMiddlewareOption) } - build, err := client.Builds.New( + resp, err := client.Builds.Compare( ctx, - buildReq, + compareReq, options..., ) if err != nil { - return nil, fmt.Errorf("failed to create build: %v", err) + return nil, fmt.Errorf("failed to create compare build: %v", err) } - return build, err + return &resp.Head, nil }, - downloads, - cmd.Bool("watch"), - ) + DownloadPaths: downloads, + Watch: cmd.Bool("watch"), + }) model.Diagnostics.WorkspaceConfig = wc p := console.NewProgram(model) @@ -266,37 +257,6 @@ func runDevBuild(ctx context.Context, client stainless.Client, wc workspace.Conf return nil } -func getGitUsername() (string, error) { - cmd := exec.Command("git", "config", "user.name") - output, err := cmd.Output() - if err != nil { - return "", err - } - - username := strings.TrimSpace(string(output)) - if username == "" { - return "", fmt.Errorf("git username not configured") - } - - // Convert to lowercase and replace spaces with hyphens for branch name - return strings.ToLower(strings.ReplaceAll(username, " ", "-")), nil -} - -func getCurrentGitBranch() (string, error) { - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - output, err := cmd.Output() - if err != nil { - return "", err - } - - branch := strings.TrimSpace(string(output)) - if branch == "" { - return "", fmt.Errorf("could not determine current git branch") - } - - return branch, nil -} - type GenerateSpecParams struct { Project string `json:"project"` Source struct { diff --git a/pkg/components/dev/model.go b/pkg/components/dev/model.go index 61aac63..95a191a 100644 --- a/pkg/components/dev/model.go +++ b/pkg/components/dev/model.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/stainless-api/stainless-api-cli/pkg/components/build" "github.com/stainless-api/stainless-api-cli/pkg/components/diagnostics" @@ -36,15 +37,25 @@ type Model struct { type ErrorMsg error type FileChangeMsg struct{} -func NewModel(client stainless.Client, ctx context.Context, branch string, fn func() (*stainless.Build, error), downloadPaths map[stainless.Target]string, watch bool) Model { +type ModelConfig struct { + Client stainless.Client + Ctx context.Context + Branch string + Start func() (*stainless.Build, error) + DownloadPaths map[stainless.Target]string + Watch bool +} + +func NewModel(cfg ModelConfig) Model { return Model{ - start: fn, - Client: client, - Ctx: ctx, - Branch: branch, + start: cfg.Start, + Client: cfg.Client, + Ctx: cfg.Ctx, + Branch: cfg.Branch, + Watch: cfg.Watch, Help: help.New(), - Build: build.NewModel(client, ctx, stainless.Build{}, branch, downloadPaths), - Diagnostics: diagnostics.NewModel(client, ctx, nil), + Build: build.NewModel(cfg.Client, cfg.Ctx, stainless.Build{}, cfg.Branch, cfg.DownloadPaths), + Diagnostics: diagnostics.NewModel(cfg.Client, cfg.Ctx, nil), } } @@ -81,7 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - case build.TickMsg, build.DownloadMsg, build.ErrorMsg: + case build.TickMsg, build.DownloadMsg, build.ErrorMsg, spinner.TickMsg: m.Build, cmd = m.Build.Update(msg) cmds = append(cmds, cmd) diff --git a/pkg/components/dev/view.go b/pkg/components/dev/view.go index c9dd85a..ba51624 100644 --- a/pkg/components/dev/view.go +++ b/pkg/components/dev/view.go @@ -2,14 +2,20 @@ package dev import ( "fmt" + "path/filepath" "slices" "strings" "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/stainless-api/stainless-api-cli/pkg/components/build" "github.com/stainless-api/stainless-api-cli/pkg/console" ) +var ( + grayStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) + func (m Model) View() string { if m.Err != nil { return m.Err.Error() @@ -38,38 +44,40 @@ var parts = []ViewPart{ { Name: "header", View: func(m *Model, s *strings.Builder) { - buildIDStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("6")).Bold(true) - if m.Build.ID != "" { - fmt.Fprintf(s, "\n\n%s %s\n\n", buildIDStyle.Render(" BUILD "), m.Build.ID) - } else { - fmt.Fprintf(s, "\n\n%s\n\n", buildIDStyle.Render(" BUILD ")) - } + s.WriteString(build.ViewHeader("PREVIEW", m.Build.Build)) }, }, { Name: "build diagnostics", View: func(m *Model, s *strings.Builder) { if m.Diagnostics.Diagnostics == nil { - s.WriteString(console.SProperty(0, "build diagnostics", "(waiting for build to finish)")) + s.WriteString("\n") + s.WriteString(grayStyle.Render("waiting for build diagnostics")) + s.WriteString("\n") } else { + s.WriteString("\n") s.WriteString(m.Diagnostics.View()) } }, }, { - Name: "studio", + Name: "build_status", View: func(m *Model, s *strings.Builder) { - 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(console.SProperty(0, "studio", console.Hyperlink(url, url))) - } + s.WriteString("\n") + + // Targets + s.WriteString(m.Build.View()) }, }, { - Name: "build_status", + Name: "studio", View: func(m *Model, s *strings.Builder) { - s.WriteString("\n") - s.WriteString(m.Build.View()) + 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") + s.WriteString(grayStyle.Render(console.Hyperlink(url, "Open in Studio"))) + s.WriteString("\n") + } }, }, { From 0c0c4ee740627b83995989538d8808943b6ce53d Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Fri, 13 Mar 2026 18:31:10 -0400 Subject: [PATCH 10/25] feat(cmd/build): use build component view for builds list --- pkg/cmd/build.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index a6b5fe9..01c7340 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -621,6 +621,22 @@ func handleBuildsList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } + + if format == "auto" && isTerminal(os.Stdout) { + for iter.Next() { + if maxItems == 0 { + break + } + maxItems-- + b := iter.Current() + fmt.Print(cbuild.ViewHeader("BUILD", b)) + m := cbuild.Model{Build: b} + fmt.Print(m.View()) + fmt.Println() + } + return iter.Err() + } + return ShowJSONIterator(os.Stdout, "builds list", iter, format, transform, maxItems) } } From 407ce3128688896f29f019eac428c15ded055355 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Fri, 13 Mar 2026 18:31:13 -0400 Subject: [PATCH 11/25] feat(cmd/builddiagnostic): use diagnostics component for diagnostics list --- pkg/cmd/builddiagnostic.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/cmd/builddiagnostic.go b/pkg/cmd/builddiagnostic.go index a8109fe..5321048 100644 --- a/pkg/cmd/builddiagnostic.go +++ b/pkg/cmd/builddiagnostic.go @@ -9,6 +9,8 @@ import ( "github.com/stainless-api/stainless-api-cli/internal/apiquery" "github.com/stainless-api/stainless-api-cli/internal/requestflag" + "github.com/stainless-api/stainless-api-cli/pkg/components/diagnostics" + "github.com/stainless-api/stainless-api-cli/pkg/workspace" "github.com/stainless-api/stainless-api-go" "github.com/stainless-api/stainless-api-go/option" "github.com/tidwall/gjson" @@ -67,6 +69,8 @@ func handleBuildsDiagnosticsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } + wc := getWorkspace(ctx) + params := stainless.BuildDiagnosticListParams{} options, err := flagOptions( @@ -107,6 +111,21 @@ func handleBuildsDiagnosticsList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } + if format == "auto" && isTerminal(os.Stdout) { + var diags []stainless.BuildDiagnostic + for iter.Next() { + if maxItems >= 0 && len(diags) >= int(maxItems) { + break + } + diags = append(diags, iter.Current()) + } + if err := iter.Err(); err != nil { + return err + } + fmt.Print(diagnostics.ViewDiagnostics(diags, int(maxItems), workspace.Relative(wc.OpenAPISpec), workspace.Relative(wc.StainlessConfig))) + return nil + } + return ShowJSONIterator(os.Stdout, "builds:diagnostics list", iter, format, transform, maxItems) } } From e16abc3a768322b84c089967e114d8a485b97238 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Mon, 16 Mar 2026 18:23:05 -0400 Subject: [PATCH 12/25] fix: read check step conclusion from top-level field The deprecated nested completed.conclusion field was being read instead of the top-level conclusion field on CheckStepUnion. --- pkg/components/build/testdata/view_build_pipeline.snapshot | 2 +- pkg/stainlessutils/stainlessutils.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/components/build/testdata/view_build_pipeline.snapshot b/pkg/components/build/testdata/view_build_pipeline.snapshot index a779286..533cf80 100644 --- a/pkg/components/build/testdata/view_build_pipeline.snapshot +++ b/pkg/components/build/testdata/view_build_pipeline.snapshot @@ -40,7 +40,7 @@ typescript  ]8;;https://github.com/org/repo/commit/def5678901234def5678]8;; (+3/-3) ○ lint ○ build ○ test ○ download -typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ○ lint ● build  test ○ download +typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) ○ lint ● build ✓ test ○ download typescript  ]8;;https://github.com/org/repo/commit/abc1234567890abc1234]8;; (+10/-5) diff --git a/pkg/stainlessutils/stainlessutils.go b/pkg/stainlessutils/stainlessutils.go index 9d2dab3..be5eed0 100644 --- a/pkg/stainlessutils/stainlessutils.go +++ b/pkg/stainlessutils/stainlessutils.go @@ -207,7 +207,7 @@ func (bt *BuildTarget) StepInfo(step string) (status, url, conclusion string) { status = u.Status url = u.URL if u.Status == "completed" { - conclusion = u.Completed.Conclusion + conclusion = u.Conclusion } } return From 83b7995c29b9435d1eee2f2edcaaddf1a5457725 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Mon, 16 Mar 2026 18:23:10 -0400 Subject: [PATCH 13/25] feat(components/dev): remove config section and waiting message from preview --- pkg/components/dev/view.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/pkg/components/dev/view.go b/pkg/components/dev/view.go index ba51624..f0466c4 100644 --- a/pkg/components/dev/view.go +++ b/pkg/components/dev/view.go @@ -2,7 +2,6 @@ package dev import ( "fmt" - "path/filepath" "slices" "strings" @@ -50,11 +49,7 @@ var parts = []ViewPart{ { Name: "build diagnostics", View: func(m *Model, s *strings.Builder) { - if m.Diagnostics.Diagnostics == nil { - s.WriteString("\n") - s.WriteString(grayStyle.Render("waiting for build diagnostics")) - s.WriteString("\n") - } else { + if m.Diagnostics.Diagnostics != nil { s.WriteString("\n") s.WriteString(m.Diagnostics.View()) } @@ -64,9 +59,11 @@ var parts = []ViewPart{ Name: "build_status", View: func(m *Model, s *strings.Builder) { s.WriteString("\n") - - // Targets - s.WriteString(m.Build.View()) + if m.Build.ID == "" { + s.WriteString(m.Build.Spinner.View() + " " + grayStyle.Render("Creating build...") + "\n") + } else { + s.WriteString(m.Build.View()) + } }, }, { From cba8ce92565082a607a0510d54c3b6b56e95ac93 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Mon, 16 Mar 2026 18:23:13 -0400 Subject: [PATCH 14/25] feat(components/build): single newline after header, show download path --- pkg/components/build/view.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pkg/components/build/view.go b/pkg/components/build/view.go index 233b841..acc93c1 100644 --- a/pkg/components/build/view.go +++ b/pkg/components/build/view.go @@ -2,6 +2,8 @@ package build import ( "fmt" + "os" + "path/filepath" "strings" "time" @@ -38,7 +40,7 @@ func ViewHeader(label string, b stainless.Build) string { s.WriteString(" ") s.WriteString(headerIDStyle.Render(relativeTime(b.CreatedAt))) } - s.WriteString("\n\n") + s.WriteString("\n") return s.String() } @@ -188,7 +190,17 @@ func ViewBuildPipeline(build stainless.Build, target stainless.Target, downloads } if download, ok := downloads[target]; ok { - stepParts = append(stepParts, ViewStepSymbol(download.Status, download.Conclusion)+" "+"download") + downloadLabel := "download" + if download.Path != "" { + displayPath := download.Path + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, displayPath); err == nil { + displayPath = rel + } + } + downloadLabel += " " + grayStyle.Render("("+displayPath+")") + } + stepParts = append(stepParts, ViewStepSymbol(download.Status, download.Conclusion)+" "+downloadLabel) if download.Conclusion == "failure" && download.Error != "" { errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) line.WriteString(" " + strings.Join(stepParts, " ")) From 63df786562d05cae6d20d891a71a42dc6d286562 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Mon, 16 Mar 2026 18:23:19 -0400 Subject: [PATCH 15/25] feat(cmd/build): add spacing between header and targets in builds list --- pkg/cmd/build.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index 01c7340..c020aed 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -630,6 +630,7 @@ func handleBuildsList(ctx context.Context, cmd *cli.Command) error { maxItems-- b := iter.Current() fmt.Print(cbuild.ViewHeader("BUILD", b)) + fmt.Println() m := cbuild.Model{Build: b} fmt.Print(m.View()) fmt.Println() From fcb4d8846b0a84dcc64f05e00fa4a8bdd7a23f45 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Mon, 16 Mar 2026 18:26:45 -0400 Subject: [PATCH 16/25] fix(components/dev): preserve preview content on quit Render the full view even when there's an error instead of short-circuiting, so content persists after ctrl+c. --- pkg/components/dev/view.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/components/dev/view.go b/pkg/components/dev/view.go index f0466c4..e582889 100644 --- a/pkg/components/dev/view.go +++ b/pkg/components/dev/view.go @@ -16,10 +16,6 @@ var ( ) func (m Model) View() string { - if m.Err != nil { - return m.Err.Error() - } - s := strings.Builder{} idx := slices.IndexFunc(parts, func(part ViewPart) bool { @@ -30,6 +26,10 @@ func (m Model) View() string { parts[i].View(&m, &s) } + if m.Err != nil && m.Err != ErrUserCancelled { + s.WriteString("\n" + m.Err.Error() + "\n") + } + return s.String() } From 397114c2f0ac3587405c67f25f3a52f0ead4b277 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Wed, 18 Mar 2026 18:15:51 -0400 Subject: [PATCH 17/25] refactor: don't use deprecated .Completed property --- pkg/cmd/build.go | 2 +- pkg/stainlessutils/stainlessutils.go | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index c020aed..fc56f6c 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -523,7 +523,7 @@ func (c buildCompletionModel) IsCompleted() bool { // Check if download is completed (if applicable) downloadIsCompleted := true - if buildTarget.IsCommitCompleted() && stainlessutils.IsGoodCommitConclusion(buildTarget.Commit.Completed.Conclusion) { + if buildTarget.IsCommitCompleted() && buildTarget.IsGoodCommitConclusion() { if download, ok := c.Build.Downloads[target]; ok { downloadIsCompleted = download.Status == "completed" } diff --git a/pkg/stainlessutils/stainlessutils.go b/pkg/stainlessutils/stainlessutils.go index be5eed0..848c8df 100644 --- a/pkg/stainlessutils/stainlessutils.go +++ b/pkg/stainlessutils/stainlessutils.go @@ -188,18 +188,18 @@ func (bt *BuildTarget) StepInfo(step string) (status, url, conclusion string) { if u, ok := stepUnion.(stainless.BuildTargetCommitUnion); ok { status = u.Status if u.Status == "completed" { - conclusion = u.Completed.Conclusion + conclusion = u.Conclusion // Use merge conflict PR URL if available, otherwise use commit URL - if u.Completed.JSON.MergeConflictPr.Valid() { + if u.JSON.MergeConflictPr.Valid() { url = fmt.Sprintf("https://github.com/%s/%s/pull/%.0f", - u.Completed.MergeConflictPr.Repo.Owner, - u.Completed.MergeConflictPr.Repo.Name, - u.Completed.MergeConflictPr.Number) - } else if u.Completed.JSON.Commit.Valid() { + u.MergeConflictPr.Repo.Owner, + u.MergeConflictPr.Repo.Name, + u.MergeConflictPr.Number) + } else if u.JSON.Commit.Valid() { url = fmt.Sprintf("https://github.com/%s/%s/commit/%s", - u.Completed.Commit.Repo.Owner, - u.Completed.Commit.Repo.Name, - u.Completed.Commit.Sha) + u.Commit.Repo.Owner, + u.Commit.Repo.Name, + u.Commit.Sha) } } } From 31a38dcc710de76b127fc76d01be7fadce7430c9 Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Wed, 18 Mar 2026 18:16:16 -0400 Subject: [PATCH 18/25] fix: make download error display better --- pkg/components/build/model.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/components/build/model.go b/pkg/components/build/model.go index 5a1679b..cfba8f0 100644 --- a/pkg/components/build/model.go +++ b/pkg/components/build/model.go @@ -157,7 +157,12 @@ func (m Model) downloadTarget(target stainless.Target) tea.Cmd { params, ) if err != nil { - return ErrorMsg(err) + return DownloadMsg{ + Target: target, + Status: "completed", + Conclusion: "failure", + Error: err.Error(), + } } err = PullOutputWithRetry(outputRes.Output, outputRes.URL, outputRes.Ref, m.Branch, m.Downloads[target].Path, console.NewGroup(true), 3) if err != nil { From a057170713df9e759738dc76d2325a7a00120a2a Mon Sep 17 00:00:00 2001 From: Young-Jin Park Date: Mon, 16 Mar 2026 18:00:17 -0400 Subject: [PATCH 19/25] chore: automatically generate demo gifs --- assets/auth-login.gif | Bin 0 -> 51683 bytes assets/auth-login.tape | 18 + assets/build:diagnostics-list.gif | Bin 0 -> 127822 bytes assets/builds-diagnostics-list.tape | 14 + assets/builds-list.gif | Bin 0 -> 97587 bytes assets/builds-list.tape | 14 + assets/demo.gif | Bin 0 -> 803114 bytes assets/demo.tape | 48 +++ assets/preview.gif | Bin 0 -> 117700 bytes assets/preview.tape | 16 + internal/cmd/mock-server/main.go | 28 ++ internal/mockstainless/builders.go | 201 ++++++++++++ internal/mockstainless/mock.go | 454 ++++++++++++++++++++++++++ internal/mockstainless/progressive.go | 119 +++++++ internal/mockstainless/server.go | 191 +++++++++++ pkg/cmd/auth_test.go | 45 +-- scripts/build-demo-gif | 138 ++++++++ 17 files changed, 1246 insertions(+), 40 deletions(-) create mode 100644 assets/auth-login.gif create mode 100644 assets/auth-login.tape create mode 100644 assets/build:diagnostics-list.gif create mode 100644 assets/builds-diagnostics-list.tape create mode 100644 assets/builds-list.gif create mode 100644 assets/builds-list.tape create mode 100644 assets/demo.gif create mode 100644 assets/demo.tape create mode 100644 assets/preview.gif create mode 100644 assets/preview.tape create mode 100644 internal/cmd/mock-server/main.go create mode 100644 internal/mockstainless/builders.go create mode 100644 internal/mockstainless/mock.go create mode 100644 internal/mockstainless/progressive.go create mode 100644 internal/mockstainless/server.go create mode 100755 scripts/build-demo-gif diff --git a/assets/auth-login.gif b/assets/auth-login.gif new file mode 100644 index 0000000000000000000000000000000000000000..0241be3f5f7fdad9f33edbbafb69c4a6e815ec44 GIT binary patch literal 51683 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