diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 90cd9cd..7238aea 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -52,6 +52,8 @@ jobs: binary: shipyard-linux-amd64 - os: macos-latest binary: shipyard-darwin-arm64 + - os: macos-13 + binary: shipyard-darwin-amd64 runs-on: ${{ matrix.os }} steps: - name: Checkout diff --git a/auth/auth.go b/auth/auth.go index 0b342b7..3f9b018 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -10,20 +10,13 @@ import ( // APIToken tries to read a token for the Shipyard API // from the environment variable or loaded config (in that order). func APIToken() (string, error) { - // Check if we're in test mode (same detection as spinner) - if buildURL := os.Getenv("SHIPYARD_BUILD_URL"); buildURL == "http://localhost:8000" { - return "test-token-from-test-mode", nil - } - - // Check environment variable first if token := os.Getenv("SHIPYARD_API_TOKEN"); token != "" { return token, nil } - - // Fall back to viper config + token := viper.GetString("api_token") if token == "" { - return "", errors.New("missing token") + return "", errors.New("token is missing, set the 'SHIPYARD_API_TOKEN' environment variable or 'api_token' config value") } return token, nil } diff --git a/commands/env/get.go b/commands/env/get.go index d51e255..6a06a40 100644 --- a/commands/env/get.go +++ b/commands/env/get.go @@ -7,6 +7,7 @@ import ( "os" "strconv" + "github.com/fatih/color" "github.com/shipyard/shipyard-cli/pkg/client" "github.com/shipyard/shipyard-cli/pkg/completion" "github.com/shipyard/shipyard-cli/pkg/display" @@ -129,7 +130,15 @@ func handleGetAllEnvironments(c client.Client) error { params["org"] = org } + // Start spinner + spinner := display.NewSpinner("Fetching info please standby...") + spinner.Start() + body, err := c.Requester.Do(http.MethodGet, uri.CreateResourceURI("", "environment", "", "", params), "application/json", nil) + + // Stop spinner immediately after API call + spinner.Stop() + if err != nil { return err } @@ -148,15 +157,50 @@ func handleGetAllEnvironments(c client.Client) error { return nil } + // Detect duplicate UUIDs and generate colors + duplicateUUIDs := display.GetDuplicateUUIDs(r.Data) + duplicateColors := display.GenerateDuplicateColors(duplicateUUIDs) + var data [][]string for i := range r.Data { i := i - data = append(data, display.FormattedEnvironment(&r.Data[i])...) + data = append(data, display.FormattedEnvironmentWithDuplicateColors(&r.Data[i], duplicateColors)...) } columns := []string{"App", "UUID", "Ready", "Repo", "PR#", "URL"} display.RenderTable(os.Stdout, columns, data) if r.Links.Next != "" { - display.Println(fmt.Sprintf("Table is truncated, fetch the next page %d.", r.Links.NextPage())) + nextPage := r.Links.NextPage() + cmd := " shipyard get environments --page " + strconv.Itoa(nextPage) + + // Add current flags to the command + if name := viper.GetString("name"); name != "" { + cmd += " --name \"" + name + "\"" + } + if orgName := viper.GetString("org-name"); orgName != "" { + cmd += " --org-name \"" + orgName + "\"" + } + if repoName := viper.GetString("repo-name"); repoName != "" { + cmd += " --repo-name \"" + repoName + "\"" + } + if branch := viper.GetString("branch"); branch != "" { + cmd += " --branch \"" + branch + "\"" + } + if pullRequestNumber := viper.GetString("pull-request-number"); pullRequestNumber != "" { + cmd += " --pull-request-number \"" + pullRequestNumber + "\"" + } + if deleted := viper.GetBool("deleted"); deleted { + cmd += " --deleted" + } + if pageSize := viper.GetInt("page-size"); pageSize != 0 && pageSize != 20 { + cmd += " --page-size " + strconv.Itoa(pageSize) + } + if viper.GetBool("json") { + cmd += " --json" + } + cmd += " " + + styledCmd := color.New(color.FgHiWhite, color.BgBlue).Sprint(cmd) + display.Println(fmt.Sprintf("Table is truncated, fetch the next page %d. %s", nextPage, styledCmd)) } return nil } @@ -167,7 +211,15 @@ func handleGetEnvironmentByID(c client.Client, id string) error { params["org"] = org } + // Start spinner + spinner := display.NewSpinner("Fetching info please standby...") + spinner.Start() + body, err := c.Requester.Do(http.MethodGet, uri.CreateResourceURI("", "environment", id, "", params), "application/json", nil) + + // Stop spinner immediately after API call + spinner.Stop() + if err != nil { return err } @@ -182,7 +234,8 @@ func handleGetEnvironmentByID(c client.Client, id string) error { return err } - data := display.FormattedEnvironment(&r.Data) + var data [][]string + data = append(data, display.FormattedEnvironment(&r.Data)...) columns := []string{"App", "UUID", "Ready", "Repo", "PR#", "URL"} display.RenderTable(os.Stdout, columns, data) return nil diff --git a/commands/services/services.go b/commands/services/services.go index 5a2454f..a94a4ce 100644 --- a/commands/services/services.go +++ b/commands/services/services.go @@ -34,7 +34,16 @@ func NewGetServicesCmd(c client.Client) *cobra.Command { func handleGetServicesCmd(c client.Client) error { id := viper.GetString("env") + + // Start spinner + spinner := display.NewSpinner("Fetching info please standby...") + spinner.Start() + svcs, err := c.AllServices(id) + + // Stop spinner immediately after API call + spinner.Stop() + if err != nil { return fmt.Errorf("failed to get services for environment %s: %w", id, err) } @@ -47,13 +56,13 @@ func handleGetServicesCmd(c client.Client) error { } data = append(data, []string{ - s.Name, + display.FormatColoredAppName(s.Name), ports, - s.URL, + display.FormatClickableURL(s.URL), }) } - columns := []string{"Name", "Ports", "URL"} + columns := []string{"Services", "Ports", "URL"} display.RenderTable(os.Stdout, columns, data) return nil } diff --git a/commands/volumes/snapshots.go b/commands/volumes/snapshots.go index b0287f8..939912a 100644 --- a/commands/volumes/snapshots.go +++ b/commands/volumes/snapshots.go @@ -7,6 +7,7 @@ import ( "os" "strconv" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -83,7 +84,22 @@ func handleGetVolumeSnapshotsCmd(c client.Client) error { columns := []string{"From", "Sequence", "Status", "Type"} display.RenderTable(os.Stdout, columns, data) if resp.Links.Next != "" { - display.Println(fmt.Sprintf("Table is truncated, fetch the next page %d.", resp.Links.NextPage())) + nextPage := resp.Links.NextPage() + cmd := " shipyard get volumes snapshots --page " + strconv.Itoa(nextPage) + " " + + // Add current flags to the command + if env := viper.GetString("env"); env != "" { + cmd += " --env \"" + env + "\"" + } + if pageSize := viper.GetInt("page-size"); pageSize != 0 && pageSize != 20 { + cmd += " --page-size " + strconv.Itoa(pageSize) + } + if viper.GetBool("json") { + cmd += " --json" + } + + styledCmd := color.New(color.FgHiWhite, color.BgBlue).Sprint(cmd) + display.Println(fmt.Sprintf("Table is truncated, fetch the next page %d. %s", nextPage, styledCmd)) } return nil } diff --git a/go.mod b/go.mod index bcc1c48..9495855 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/dsnet/compress v0.0.1 github.com/fatih/color v1.18.0 github.com/google/go-cmp v0.7.0 + github.com/jedib0t/go-pretty/v6 v6.6.8 github.com/mattn/go-isatty v0.0.20 - github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 @@ -48,7 +48,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -59,8 +59,8 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/oauth2 v0.15.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.15.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 9c6f5b7..5c27363 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc= +github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -115,7 +117,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= @@ -131,8 +132,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.1.6 h1:Fx2POJZfKRQcM1pH49qSZiYeu319wji004qX+GDovrU= github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q= @@ -146,8 +145,9 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -222,10 +222,10 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/pkg/display/table.go b/pkg/display/table.go index 220c828..1b177da 100644 --- a/pkg/display/table.go +++ b/pkg/display/table.go @@ -1,38 +1,298 @@ package display import ( - "fmt" + "hash/fnv" "io" + "os" "strconv" + "strings" - "github.com/olekukonko/tablewriter" + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/mattn/go-isatty" "github.com/shipyard/shipyard-cli/pkg/types" ) // RenderTable writes data in tabular form with given column names to the provided writer. func RenderTable(out io.Writer, columns []string, data [][]string) { - table := tablewriter.NewWriter(out) - table.SetHeader(columns) - - table.SetAutoMergeCellsByColumnIndex([]int{0, 1, 5}) - table.SetAutoWrapText(false) - table.SetAutoFormatHeaders(true) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetAlignment(tablewriter.ALIGN_LEFT) - table.SetCenterSeparator("") - table.SetColumnSeparator("") - table.SetBorder(false) - table.SetHeaderLine(true) - table.SetTablePadding("\t") - - for _, v := range data { - table.Append(v) - } - table.Render() + t := table.NewWriter() + t.SetOutputMirror(out) + + // Set header + headerRow := table.Row{} + for _, col := range columns { + headerRow = append(headerRow, col) + } + t.AppendHeader(headerRow) + + // Add data rows + for _, row := range data { + dataRow := table.Row{} + for i, cell := range row { + // Ensure we don't exceed the number of columns + if i < len(columns) { + dataRow = append(dataRow, cell) + } + } + // Ensure we have the right number of columns - pad with empty strings if needed + for len(dataRow) < len(columns) { + dataRow = append(dataRow, "") + } + t.AppendRow(dataRow) + } + + // Configure table style + t.SetStyle(table.Style{ + Name: "CustomStyle", + Box: table.BoxStyle{ + BottomLeft: "", + BottomRight: "", + BottomSeparator: "", + Left: "", + LeftSeparator: "", + MiddleHorizontal: "-", + MiddleSeparator: "", + MiddleVertical: "", + PaddingLeft: "", + PaddingRight: "\t", + Right: "", + RightSeparator: "", + TopLeft: "", + TopRight: "", + TopSeparator: "", + UnfinishedRow: "", + }, + Color: table.ColorOptions{}, + Format: table.FormatOptions{}, + HTML: table.HTMLOptions{}, + Options: table.Options{ + DrawBorder: false, + SeparateColumns: false, + SeparateFooter: false, + SeparateHeader: true, + SeparateRows: false, + }, + Title: table.TitleOptions{}, + }) + + t.Render() _, _ = io.WriteString(out, "\n") } +// FormatReadyStatus formats a boolean Ready status with colors +func FormatReadyStatus(ready bool) string { + if ready { + green := color.New(color.FgGreen) + return green.Sprint("Yes") + } + red := color.New(color.FgRed) + return red.Sprint("No") +} + +// supportsOSC8 detects if the current terminal supports OSC 8 hyperlinks +func supportsOSC8() bool { + // Check if we're in a terminal + if !isatty.IsTerminal(os.Stdout.Fd()) { + return false + } + + termProgram := os.Getenv("TERM_PROGRAM") + term := os.Getenv("TERM") + + // Known terminals that support OSC 8 + supportedTerminals := map[string]bool{ + "iTerm.app": true, + "WezTerm": true, + "Alacritty": true, + "kitty": true, + "Hyper": true, + "tabby": true, + "Terminus": true, + "vscode": true, + "Windows Terminal": true, + } + + if supportedTerminals[termProgram] { + return true + } + + // Check for specific terminal features + if strings.Contains(term, "kitty") || + strings.Contains(term, "xterm-kitty") || + termProgram == "gnome-terminal" || + termProgram == "konsole" || + os.Getenv("KONSOLE_VERSION") != "" || + os.Getenv("VTE_VERSION") != "" { + return true + } + + // Apple Terminal and most basic terminals don't support OSC 8 + if termProgram == "Apple_Terminal" || term == "xterm-256color" { + return false + } + + // Default to false for unknown terminals + return false +} + +// FormatClickableURL formats a URL as a clickable terminal link using OSC 8 escape sequences +// Falls back to underlined turquoise URL if terminal doesn't support OSC 8 +func FormatClickableURL(url string) string { + if url == "" { + return "" + } + + if supportsOSC8() { + // OSC 8 escape sequence: \033]8;;URL\033\\TEXT\033]8;;\033\\ + return "\033]8;;" + url + "\033\\" + url + "\033]8;;\033\\" + } + + // Fallback: return underlined turquoise URL + cyan := color.New(color.FgCyan, color.Underline) + return cyan.Sprint(url) +} + +// FormatColoredAppName assigns a consistent color to app names based on hash +func FormatColoredAppName(appName string) string { + if appName == "" { + return "-" + } + + // Available colors with black background (avoiding red and green which are used for Ready status) + colors := []*color.Color{ + color.New(color.FgBlue, color.BgBlack), + color.New(color.FgMagenta, color.BgBlack), + color.New(color.FgCyan, color.BgBlack), + color.New(color.FgYellow, color.BgBlack), + color.New(color.FgHiBlue, color.BgBlack), + color.New(color.FgHiMagenta, color.BgBlack), + color.New(color.FgHiCyan, color.BgBlack), + color.New(color.FgHiYellow, color.BgBlack), + } + + // Hash the app name to get consistent color assignment + h := fnv.New32a() + h.Write([]byte(appName)) + colorIndex := h.Sum32() % uint32(len(colors)) + + return colors[colorIndex].Sprint(" " + appName + " ") +} + +// FormatPRNumber formats PR numbers, using branch name for null values +func FormatPRNumber(prNumber, branchName string) string { + if prNumber == "" || prNumber == "0" { + // Create green background with yellow text for branch names + branchStyle := color.New(color.BgGreen, color.FgBlack) + return branchStyle.Sprint(" " + branchName + " ") + } + return prNumber +} + +// FormatClickableUUID formats a UUID as a clickable link to shipyard.build details page +// Falls back to plain UUID if terminal doesn't support OSC 8 +func FormatClickableUUID(uuid string) string { + if uuid == "" { + return "" + } + + detailsURL := "https://shipyard.build/application/" + uuid + "/detail" + + if supportsOSC8() { + // OSC 8 escape sequence: \033]8;;URL\033\\TEXT\033]8;;\033\\ + return "\033]8;;" + detailsURL + "\033\\" + uuid + "\033]8;;\033\\" + } + + // Fallback: return plain UUID + return uuid +} + +// FormatClickableUUIDWithBackground formats a UUID with background color for duplicates +func FormatClickableUUIDWithBackground(uuid string, bgColor *color.Color) string { + if uuid == "" { + return "" + } + + detailsURL := "https://shipyard.build/application/" + uuid + "/detail" + + var formattedUUID string + if supportsOSC8() { + // OSC 8 escape sequence: \033]8;;URL\033\\TEXT\033]8;;\033\\ + formattedUUID = "\033]8;;" + detailsURL + "\033\\" + uuid + "\033]8;;\033\\" + } else { + formattedUUID = uuid + } + + if bgColor != nil { + if supportsOSC8() { + // For clickable links, apply color to the visible text only + coloredUUID := bgColor.Sprint(uuid) + return "\033]8;;" + detailsURL + "\033\\" + coloredUUID + "\033]8;;\033\\" + } else { + return bgColor.Sprint(formattedUUID) + } + } + return formattedUUID +} + +// GetDuplicateUUIDs identifies UUIDs that appear more than once in the final table +func GetDuplicateUUIDs(envs []types.Environment) map[string]bool { + uuidCounts := make(map[string]int) + + // Count occurrences of each UUID based on how many times they'll appear in the table + // Each environment UUID appears once per project in that environment + for _, env := range envs { + projectCount := len(env.Attributes.Projects) + if projectCount == 0 { + projectCount = 1 // Ensure at least one row per environment + } + uuidCounts[env.ID] += projectCount + } + + // Identify duplicates (UUIDs that appear more than once in the table) + duplicates := make(map[string]bool) + for uuid, count := range uuidCounts { + if count > 1 { + duplicates[uuid] = true + } + } + + return duplicates +} + +// GenerateDuplicateColors creates consistent background colors for duplicate UUIDs +func GenerateDuplicateColors(duplicateUUIDs map[string]bool) map[string]*color.Color { + if len(duplicateUUIDs) == 0 { + return nil + } + + // Available background colors (avoiding red/green used for Ready status) + backgroundColors := []color.Attribute{ + color.BgBlue, + color.BgMagenta, + color.BgCyan, + color.BgYellow, + color.BgHiBlue, + color.BgHiMagenta, + color.BgHiCyan, + color.BgHiYellow, + } + + colorMap := make(map[string]*color.Color) + + for uuid := range duplicateUUIDs { + // Use hash of UUID to get consistent color assignment + h := fnv.New32a() + h.Write([]byte(uuid)) + selectedColorIndex := int(h.Sum32()) % len(backgroundColors) + + c := color.New(color.FgBlack, backgroundColors[selectedColorIndex]) + colorMap[uuid] = c + } + + return colorMap +} + // FormattedEnvironment takes an environment, extracts data from it, and prepares it // to be in tabular format. If the environment value is nil, the program will panic. func FormattedEnvironment(env *types.Environment) [][]string { @@ -40,17 +300,41 @@ func FormattedEnvironment(env *types.Environment) [][]string { for _, p := range env.Attributes.Projects { pr := strconv.Itoa(p.PullRequestNumber) - if pr == "0" { - pr = "" + + data = append(data, []string{ + FormatColoredAppName(env.Attributes.Name), + FormatClickableUUID(env.ID), + FormatReadyStatus(env.Attributes.Ready), + p.RepoName, + FormatPRNumber(pr, p.Branch), + FormatClickableURL(env.Attributes.URL), + }) + } + + return data +} + +// FormattedEnvironmentWithDuplicateColors takes an environment and duplicate color mapping, +// extracts data from it, and prepares it to be in tabular format with background colors for duplicate UUIDs. +func FormattedEnvironmentWithDuplicateColors(env *types.Environment, duplicateColors map[string]*color.Color) [][]string { + data := make([][]string, 0, len(env.Attributes.Projects)) + + for _, p := range env.Attributes.Projects { + pr := strconv.Itoa(p.PullRequestNumber) + + // Get background color for UUID if it's a duplicate + var bgColor *color.Color + if duplicateColors != nil { + bgColor = duplicateColors[env.ID] } data = append(data, []string{ - env.Attributes.Name, - env.ID, - fmt.Sprintf("%t", env.Attributes.Ready), + FormatColoredAppName(env.Attributes.Name), + FormatClickableUUIDWithBackground(env.ID, bgColor), + FormatReadyStatus(env.Attributes.Ready), p.RepoName, - pr, - env.Attributes.URL, + FormatPRNumber(pr, p.Branch), + FormatClickableURL(env.Attributes.URL), }) } diff --git a/pkg/mcp/transport/stdio_test.go b/pkg/mcp/transport/stdio_test.go index 6c69fa6..d37ef92 100644 --- a/pkg/mcp/transport/stdio_test.go +++ b/pkg/mcp/transport/stdio_test.go @@ -284,16 +284,28 @@ func TestStdioTransport_LargeMessage(t *testing.T) { func TestStdioTransport_ConcurrentReadWrite(t *testing.T) { var output bytes.Buffer - testInput := "concurrent test\n" + + // Use io.Pipe so the read loop blocks after the first line instead of + // hitting EOF immediately. A strings.NewReader returns EOF right after + // the data is consumed, which races with ReadMessage's select on + // msgChan vs errChan. + pr, pw := io.Pipe() + defer pr.Close() transport := &StdioTransport{ - reader: bufio.NewReader(strings.NewReader(testInput)), + reader: bufio.NewReader(pr), writer: bufio.NewWriter(&output), } ctx := context.Background() transport.Start(ctx) + // Write test input through the pipe + go func() { + _, _ = pw.Write([]byte("concurrent test\n")) + // Don't close pw — let readLoop block on the next read + }() + var wg sync.WaitGroup wg.Add(2) @@ -332,6 +344,9 @@ func TestStdioTransport_ConcurrentReadWrite(t *testing.T) { t.Fatal("Timeout waiting for concurrent operations") } + // Stop transport to unblock readLoop before checking output + transport.Stop() + if !strings.Contains(output.String(), "concurrent response") { t.Error("Expected concurrent write to succeed") } diff --git a/pkg/types/types.go b/pkg/types/types.go index 99e9de4..b424416 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -15,6 +15,7 @@ type Environment struct { type Project struct { PullRequestNumber int `json:"pull_request_number"` RepoName string `json:"repo_name"` + Branch string `json:"branch"` } type EnvironmentAttributes struct { diff --git a/tests/cli_test.go b/tests/cli_test.go index ed1c566..26ab114 100644 --- a/tests/cli_test.go +++ b/tests/cli_test.go @@ -76,24 +76,22 @@ func TestGetAllEnvironments(t *testing.T) { t.Parallel() c := newCmd(test.args) if err := c.cmd.Run(); err != nil { - t.Logf("Command failed: %v", err) - t.Logf("Stderr: %q", c.stdErr.String()) - t.Logf("Expected output: %q", test.output) - // Only check stderr for error cases that have expected output if test.output != "" { if diff := cmp.Diff(c.stdErr.String(), test.output); diff != "" { t.Error(diff) } + } else { + t.Fatalf("command unexpectedly failed: %v\nstderr: %s", err, c.stdErr.String()) } return } - + // If we expected an error but got success, that's wrong if test.output != "" { t.Errorf("Expected error %q but command succeeded", test.output) return } - + var resp types.RespManyEnvs if err := json.Unmarshal(c.stdOut.Bytes(), &resp); err != nil { t.Fatal(err) @@ -145,24 +143,22 @@ func TestGetEnvironmentByID(t *testing.T) { t.Parallel() c := newCmd(test.args) if err := c.cmd.Run(); err != nil { - t.Logf("Command failed: %v", err) - t.Logf("Stderr: %q", c.stdErr.String()) - t.Logf("Expected output: %q", test.output) - // Only check stderr for error cases that have expected output if test.output != "" { if diff := cmp.Diff(c.stdErr.String(), test.output); diff != "" { t.Error(diff) } + } else { + t.Fatalf("command unexpectedly failed: %v\nstderr: %s", err, c.stdErr.String()) } return } - + // If we expected an error but got success, that's wrong if test.output != "" { t.Errorf("Expected error %q but command succeeded", test.output) return } - + var resp types.Response if err := json.Unmarshal(c.stdOut.Bytes(), &resp); err != nil { t.Fatal(err) @@ -211,20 +207,17 @@ func TestRebuildEnvironment(t *testing.T) { c := newCmd(test.args) err := c.cmd.Run() if err != nil { - t.Logf("Rebuild command failed: %v", err) - t.Logf("Stderr: %q", c.stdErr.String()) - t.Logf("Expected output: %q", test.output) - // Only check stderr for error cases that have expected output if test.output != "" { if diff := cmp.Diff(c.stdErr.String(), test.output); diff != "" { t.Error(diff) } + } else { + t.Fatalf("command unexpectedly failed: %v\nstderr: %s", err, c.stdErr.String()) } return } - + // For rebuild tests, success cases have specific success messages - // Error cases should have failed above and not reach here if diff := cmp.Diff(c.stdOut.String(), test.output); diff != "" { t.Error(diff) }