From 658684928ef7fbd8086443d9c90464a32256a1f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 08:09:11 +0000 Subject: [PATCH] Improve CLI visual design with bordered tables, status badges, and welcome screen - Redesign output package: boxed banner, bordered tables with zebra rows, dot-aligned key-value pairs, and colored status badges - Add Welcome screen with quick-start hints when running cipi-cli with no args - Use active/suspended, yes/no, and job status styling across apps, domains, and jobs - Polish spinner suffix during async job polling Co-authored-by: Andrea Pollastri --- cmd/apps.go | 28 ++-- cmd/configure.go | 26 ++-- cmd/db.go | 2 +- cmd/domains.go | 17 ++- cmd/jobs.go | 16 +-- cmd/root.go | 10 +- internal/api/client.go | 5 +- internal/output/output.go | 293 +++++++++++++++++++++++++++++++------- 8 files changed, 292 insertions(+), 105 deletions(-) diff --git a/cmd/apps.go b/cmd/apps.go index 33eb5d1..1ddde12 100644 --- a/cmd/apps.go +++ b/cmd/apps.go @@ -44,7 +44,7 @@ var appsListCmd = &cobra.Command{ } output.Header("Applications") - t := output.NewTable("APP", "DOMAIN", "PHP", "REPOSITORY", "BRANCH", "SUSPENDED") + t := output.NewTable("APP", "DOMAIN", "PHP", "REPOSITORY", "BRANCH", "STATUS") for _, app := range result.Data { t.Row( str(app, "app"), @@ -52,11 +52,11 @@ var appsListCmd = &cobra.Command{ str(app, "php"), truncate(str(app, "repository"), 40), str(app, "branch"), - str(app, "suspended"), + output.StatusSuspended(str(app, "suspended")), ) } t.Flush() - output.Dim.Printf(" Total: %d app(s)\n\n", len(result.Data)) + output.Footer("Total: %d app(s)", len(result.Data)) return nil }, } @@ -88,20 +88,20 @@ var appsShowCmd = &cobra.Command{ app := result.Data output.Header(fmt.Sprintf("App: %s", str(app, "app"))) - output.KeyValue(nil, "App", str(app, "app")) - output.KeyValue(nil, "Domain", str(app, "domain")) - output.KeyValue(nil, "PHP", str(app, "php")) - output.KeyValue(nil, "Repository", str(app, "repository")) - output.KeyValue(nil, "Branch", str(app, "branch")) - output.KeyValue(nil, "User", str(app, "user")) - output.KeyValue(nil, "Custom", str(app, "custom")) - output.KeyValue(nil, "Docroot", str(app, "docroot")) - output.KeyValue(nil, "Suspended", str(app, "suspended")) - output.KeyValue(nil, "Created", str(app, "created_at")) + output.KeyValueDots(nil, "App", str(app, "app")) + output.KeyValueDots(nil, "Domain", str(app, "domain")) + output.KeyValueDots(nil, "PHP", str(app, "php")) + output.KeyValueDots(nil, "Repository", str(app, "repository")) + output.KeyValueDots(nil, "Branch", str(app, "branch")) + output.KeyValueDots(nil, "User", str(app, "user")) + output.KeyValueDots(nil, "Custom", output.StatusYesNo(str(app, "custom"))) + output.KeyValueDots(nil, "Docroot", str(app, "docroot")) + output.KeyValueDots(nil, "Status", output.StatusSuspended(str(app, "suspended"))) + output.KeyValueDots(nil, "Created", str(app, "created_at")) if aliases, ok := app["aliases"].([]interface{}); ok && len(aliases) > 0 { fmt.Println() - output.Dim.Println(" Aliases") + output.Header("Aliases") for _, a := range aliases { fmt.Printf(" • %v\n", a) } diff --git a/cmd/configure.go b/cmd/configure.go index f70dc8e..3bc3249 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -119,9 +119,9 @@ func listProfiles() error { } output.Header("Profiles") - output.KeyValue(nil, "Config file", config.Path()) + output.KeyValueDots(nil, "Config file", config.Path()) if defaultProfile != "" { - output.KeyValue(nil, "Default", defaultProfile) + output.KeyValueDots(nil, "Default", defaultProfile) } fmt.Println() if len(names) == 0 { @@ -129,11 +129,7 @@ func listProfiles() error { return nil } for _, name := range names { - marker := "" - if name == defaultProfile { - marker = " (default)" - } - fmt.Printf(" %s%s\n", name, marker) + fmt.Printf("%s• %s\n", " ", output.ProfileName(name, name == defaultProfile)) } fmt.Println() return nil @@ -183,10 +179,10 @@ func showProfile(name string) error { } output.Header("Configuration") - output.KeyValue(nil, "Profile", name) - output.KeyValue(nil, "Config file", config.Path()) - output.KeyValue(nil, "Endpoint", profile.Endpoint) - output.KeyValue(nil, "Token", maskToken(profile.Token)) + output.KeyValueDots(nil, "Profile", name) + output.KeyValueDots(nil, "Config file", config.Path()) + output.KeyValueDots(nil, "Endpoint", profile.Endpoint) + output.KeyValueDots(nil, "Token", maskToken(profile.Token)) fmt.Println() return nil } @@ -219,9 +215,9 @@ func showAllProfiles() error { } output.Header("Configuration") - output.KeyValue(nil, "Config file", config.Path()) + output.KeyValueDots(nil, "Config file", config.Path()) if defaultProfile != "" { - output.KeyValue(nil, "Default", defaultProfile) + output.KeyValueDots(nil, "Default", defaultProfile) } fmt.Println() @@ -231,8 +227,8 @@ func showAllProfiles() error { return err } output.Header(name) - output.KeyValue(nil, "Endpoint", profile.Endpoint) - output.KeyValue(nil, "Token", maskToken(profile.Token)) + output.KeyValueDots(nil, "Endpoint", profile.Endpoint) + output.KeyValueDots(nil, "Token", maskToken(profile.Token)) fmt.Println() } return nil diff --git a/cmd/db.go b/cmd/db.go index 782fce9..e4eab91 100644 --- a/cmd/db.go +++ b/cmd/db.go @@ -53,7 +53,7 @@ var dbListCmd = &cobra.Command{ ) } t.Flush() - output.Dim.Printf(" Total: %d database(s)\n\n", len(dbs)) + output.Footer("Total: %d database(s)", len(dbs)) return nil }, } diff --git a/cmd/domains.go b/cmd/domains.go index 499e80f..5340e2a 100644 --- a/cmd/domains.go +++ b/cmd/domains.go @@ -59,24 +59,22 @@ var domainsCmd = &cobra.Command{ } output.Header("Domains") - t := output.NewTable("DOMAIN", "APP", "KIND", "TYPE", "PHP", "DOCROOT", "BRANCH", "REPOSITORY", "SUSPENDED") + t := output.NewTable("DOMAIN", "APP", "KIND", "TYPE", "PHP", "DOCROOT", "BRANCH", "REPOSITORY", "STATUS") suspendedApps := map[string]struct{}{} for _, row := range rows { - suspended := "no" if row.Suspended { - suspended = "yes" suspendedApps[row.App] = struct{}{} } t.Row( row.Domain, row.App, - row.Kind, + output.KindBadge(row.Kind), row.Type, row.PHP, row.Docroot, row.Branch, truncate(row.Repository, 40), - suspended, + output.StatusSuspended(boolSuspended(row.Suspended)), ) } t.Flush() @@ -85,11 +83,18 @@ var domainsCmd = &cobra.Command{ if len(suspendedApps) > 0 { suspNote = fmt.Sprintf(" — %d suspended", len(suspendedApps)) } - output.Dim.Printf(" %d domain(s) across %d app(s)%s\n\n", len(rows), len(result.Data), suspNote) + output.Footer("%d domain(s) across %d app(s)%s", len(rows), len(result.Data), suspNote) return nil }, } +func boolSuspended(v bool) string { + if v { + return "yes" + } + return "no" +} + func buildDomainRows(apps []map[string]interface{}) []domainRow { rows := make([]domainRow, 0, len(apps)) for _, app := range apps { diff --git a/cmd/jobs.go b/cmd/jobs.go index a764951..71df011 100644 --- a/cmd/jobs.go +++ b/cmd/jobs.go @@ -37,21 +37,11 @@ var jobsShowCmd = &cobra.Command{ } output.Header("Job Status") - output.KeyValue(nil, "ID", fmt.Sprintf("%v", job.ID)) - - switch job.Status { - case "completed", "success", "finished": - output.KeyValue(nil, "Status", output.Green.Sprint(job.Status)) - case "failed", "error": - output.KeyValue(nil, "Status", output.Red.Sprint(job.Status)) - case "pending", "processing", "running": - output.KeyValue(nil, "Status", output.Yellow.Sprint(job.Status)) - default: - output.KeyValue(nil, "Status", job.Status) - } + output.KeyValueDots(nil, "ID", fmt.Sprintf("%v", job.ID)) + output.KeyValueDots(nil, "Status", output.StatusJob(job.Status)) if job.Error != "" { - output.KeyValue(nil, "Error", output.Red.Sprint(job.Error)) + output.KeyValueDots(nil, "Error", output.Red.Sprint(job.Error)) } fmt.Println() diff --git a/cmd/root.go b/cmd/root.go index 0adfe05..e2c689c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,9 +27,7 @@ var rootCmd = &cobra.Command{ output.JSONOutput = jsonFlag }, Run: func(cmd *cobra.Command, args []string) { - output.Banner() - output.Dim.Println(" Use 'cipi-cli --help' to see available commands.") - fmt.Println() + output.Welcome() }, } @@ -44,9 +42,9 @@ var versionCmd = &cobra.Command{ }) return } - fmt.Println() - output.KeyValue(nil, "Version", Version) - output.KeyValue(nil, "Build", BuildTime) + output.Header("Version") + output.KeyValueDots(nil, "Version", Version) + output.KeyValueDots(nil, "Build", BuildTime) fmt.Println() }, } diff --git a/internal/api/client.go b/internal/api/client.go index b002695..d85bd6a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -12,6 +12,7 @@ import ( "github.com/briandowns/spinner" "github.com/cipi-sh/cli/internal/config" "github.com/cipi-sh/cli/internal/output" + "github.com/fatih/color" ) type Client struct { @@ -296,8 +297,8 @@ func (c *Client) DoAsync(method, path string, body interface{}) (*AsyncResponse, } func (c *Client) WaitForJob(jobID string) (*JobStatus, error) { - s := spinner.New(spinner.CharSets[14], 120*time.Millisecond) - s.Suffix = " Processing..." + s := spinner.New(spinner.CharSets[11], 100*time.Millisecond) + s.Suffix = " " + color.New(color.FgHiCyan).Sprint("Processing") + color.New(color.Faint).Sprint(" · job "+jobID) s.Color("cyan") s.Start() defer s.Stop() diff --git a/internal/output/output.go b/internal/output/output.go index ea260c8..997fb84 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "regexp" "strings" "unicode/utf8" @@ -19,28 +20,38 @@ var ( Yellow = color.New(color.FgYellow, color.Bold) Dim = color.New(color.Faint) White = color.New(color.FgWhite) - HiCyan = color.New(color.FgHiCyan) - HiGreen = color.New(color.FgHiGreen) - HiYellow = color.New(color.FgHiYellow) - HiMagenta = color.New(color.FgHiMagenta) + HiCyan = color.New(color.FgHiCyan, color.Bold) + HiGreen = color.New(color.FgHiGreen, color.Bold) + HiYellow = color.New(color.FgHiYellow, color.Bold) + HiMagenta = color.New(color.FgHiMagenta, color.Bold) + HiBlue = color.New(color.FgHiBlue, color.Bold) ) -var JSONOutput bool +var ( + JSONOutput bool + ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) +) + +const indent = " " func Success(msg string, args ...interface{}) { - Green.Printf(" ✓ "+msg+"\n", args...) + label := Green.Sprint("✓") + fmt.Printf("%s%s %s\n", indent, label, fmt.Sprintf(msg, args...)) } func Error(msg string, args ...interface{}) { - Red.Fprintf(os.Stderr, " ✗ "+msg+"\n", args...) + label := Red.Sprint("✗") + Red.Fprintf(os.Stderr, "%s%s %s\n", indent, label, fmt.Sprintf(msg, args...)) } func Warn(msg string, args ...interface{}) { - Yellow.Printf(" ! "+msg+"\n", args...) + label := Yellow.Sprint("!") + fmt.Printf("%s%s %s\n", indent, label, fmt.Sprintf(msg, args...)) } func Info(msg string, args ...interface{}) { - Cyan.Printf(" → "+msg+"\n", args...) + label := HiCyan.Sprint("→") + fmt.Printf("%s%s %s\n", indent, label, fmt.Sprintf(msg, args...)) } func PrintJSON(v interface{}) { @@ -51,8 +62,66 @@ func PrintJSON(v interface{}) { func Header(title string) { fmt.Println() - Bold.Printf(" %s\n", title) - Dim.Printf(" %s\n", strings.Repeat("─", len(title)+2)) + HiCyan.Printf("%s◆ %s\n", indent, Bold.Sprint(title)) + lineLen := min(utf8.RuneCountInString(title)+4, 52) + Dim.Printf("%s %s\n", indent, strings.Repeat("─", lineLen)) +} + +func Divider() { + Dim.Printf("%s%s\n", indent, strings.Repeat("·", 40)) +} + +func Footer(format string, args ...interface{}) { + Dim.Printf("%s%s\n", indent, fmt.Sprintf(format, args...)) +} + +const bannerInnerWidth = 40 + +func Banner() { + fmt.Println() + HiCyan.Println(indent + "╭──────────────────────────────────────────╮") + lines := []string{ + " ██████╗██╗██████╗ ██╗", + " ██╔════╝██║██╔══██╗██║", + " ██║ ██║██████╔╝██║", + " ██║ ██║██╔═══╝ ██║", + " ╚██████╗██║██║ ██║", + " ╚═════╝╚═╝╚═╝ ╚═╝", + } + for _, line := range lines { + printBannerLine(line) + } + HiCyan.Println(indent + "├──────────────────────────────────────────┤") + Dim.Printf("%s│%s│\n", indent, centerText("CLI for Cipi Server Panel", bannerInnerWidth)) + HiCyan.Println(indent + "╰──────────────────────────────────────────╯") + fmt.Println() +} + +func printBannerLine(content string) { + HiCyan.Print(indent + "│") + HiBlue.Print(padRight(content, bannerInnerWidth)) + HiCyan.Println("│") +} + +func centerText(s string, width int) string { + n := utf8.RuneCountInString(s) + if n >= width { + return " " + s + " " + } + pad := width - n + left := pad / 2 + right := pad - left + return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) +} + +func Welcome() { + Banner() + Dim.Println(indent + "Quick start") + fmt.Println() + ListItem("cipi-cli configure --profile prod", "Set up your API endpoint and token") + ListItem("cipi-cli prod apps list", "Manage apps on a specific server profile") + fmt.Println() + Dim.Printf("%sRun %s for the full command list.\n\n", indent, Cyan.Sprint("cipi-cli --help")) } type Table struct { @@ -69,66 +138,127 @@ func (t *Table) Row(values ...string) { } func (t *Table) Flush() { + if len(t.headers) == 0 { + return + } + colWidths := make([]int, len(t.headers)) for i, h := range t.headers { - colWidths[i] = utf8.RuneCountInString(h) + colWidths[i] = visibleWidth(h) } for _, row := range t.rows { for i, v := range row { if i < len(colWidths) { - if w := utf8.RuneCountInString(v); w > colWidths[i] { + if w := visibleWidth(v); w > colWidths[i] { colWidths[i] = w } } } } - gap := " " + var b strings.Builder + b.WriteString(indent) + b.WriteString("┌") + for i, w := range colWidths { + if i > 0 { + b.WriteString("─┬") + } + b.WriteString(strings.Repeat("─", w+2)) + } + b.WriteString("─┐\n") - hdr := make([]string, len(t.headers)) + b.WriteString(indent) + b.WriteString("│") for i, h := range t.headers { - hdr[i] = Dim.Sprint(padRight(h, colWidths[i])) + if i > 0 { + b.WriteString("│") + } + b.WriteString(" ") + b.WriteString(Dim.Sprint(padRight(h, colWidths[i]))) + b.WriteString(" ") } - fmt.Printf(" %s\n", strings.Join(hdr, gap)) + b.WriteString("│\n") - sep := make([]string, len(t.headers)) - for i, h := range t.headers { - sep[i] = Dim.Sprint(padRight(strings.Repeat("─", utf8.RuneCountInString(h)), colWidths[i])) + b.WriteString(indent) + b.WriteString("├") + for i, w := range colWidths { + if i > 0 { + b.WriteString("─┼") + } + b.WriteString(strings.Repeat("─", w+2)) } - fmt.Printf(" %s\n", strings.Join(sep, gap)) + b.WriteString("─┤\n") - for _, row := range t.rows { - cells := make([]string, len(t.headers)) + for rowIdx, row := range t.rows { + b.WriteString(indent) + b.WriteString("│") for i := 0; i < len(t.headers); i++ { + if i > 0 { + b.WriteString("│") + } v := "" if i < len(row) { v = row[i] } - cells[i] = padRight(v, colWidths[i]) + if rowIdx%2 == 1 && !strings.Contains(v, "\x1b[") { + v = Dim.Sprint(padRightVisible(v, colWidths[i])) + } else { + v = padRightVisible(v, colWidths[i]) + } + b.WriteString(" ") + b.WriteString(v) + b.WriteString(" ") } - fmt.Printf(" %s\n", strings.Join(cells, gap)) + b.WriteString("│\n") } + + b.WriteString(indent) + b.WriteString("└") + for i, w := range colWidths { + if i > 0 { + b.WriteString("─┴") + } + b.WriteString(strings.Repeat("─", w+2)) + } + b.WriteString("─┘\n") + + fmt.Print(b.String()) fmt.Println() } -func padRight(s string, width int) string { - n := utf8.RuneCountInString(s) - if n >= width { - return s +func KeyValue(w io.Writer, key, value string) { + if w == nil { + w = os.Stdout } - return s + strings.Repeat(" ", width-n) + Dim.Fprintf(w, "%s%-14s", indent, key) + Dim.Fprint(w, " ") + fmt.Fprintf(w, "%s\n", value) } -func KeyValue(w io.Writer, key, value string) { +func KeyValueDots(w io.Writer, key, value string) { if w == nil { w = os.Stdout } - Dim.Fprintf(w, " %-16s", key) + const keyWidth = 14 + dots := 28 - keyWidth + if dots < 2 { + dots = 2 + } + Dim.Fprintf(w, "%s%-14s %s ", indent, key, strings.Repeat("·", dots)) fmt.Fprintf(w, "%s\n", value) } +func ListItem(label, description string) { + HiMagenta.Printf("%s• %s\n", indent, label) + if description != "" { + Dim.Printf("%s %s\n", indent, description) + } +} + func Confirm(prompt string) bool { - Yellow.Printf(" ? %s [y/N] ", prompt) + fmt.Println() + Yellow.Printf("%s? %s ", indent, prompt) + Dim.Print("[y/N] ") var answer string fmt.Scanln(&answer) answer = strings.ToLower(strings.TrimSpace(answer)) @@ -136,27 +266,94 @@ func Confirm(prompt string) bool { } func ReadInput(prompt string) string { - Cyan.Printf(" %s: ", prompt) + Cyan.Printf("%s▸ %s ", indent, prompt) var input string fmt.Scanln(&input) return strings.TrimSpace(input) } func ReadSecret(prompt string) string { - Cyan.Printf(" %s: ", prompt) - var input string - fmt.Scanln(&input) - return strings.TrimSpace(input) + return ReadInput(prompt) } -func Banner() { - HiCyan.Println() - HiCyan.Println(" ██████╗██╗██████╗ ██╗") - HiCyan.Println(" ██╔════╝██║██╔══██╗██║") - HiCyan.Println(" ██║ ██║██████╔╝██║") - HiCyan.Println(" ██║ ██║██╔═══╝ ██║") - HiCyan.Println(" ╚██████╗██║██║ ██║") - HiCyan.Println(" ╚═════╝╚═╝╚═╝ ╚═╝") - Dim.Println(" CLI for Cipi Server Panel") - fmt.Println() +func StatusYesNo(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "yes", "true", "1": + return HiGreen.Sprint("yes") + case "no", "false", "0", "—", "": + return Dim.Sprint("no") + default: + return value + } +} + +func StatusSuspended(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "yes", "true", "1": + return Yellow.Sprint("suspended") + default: + return HiGreen.Sprint("active") + } +} + +func StatusJob(status string) string { + switch strings.ToLower(status) { + case "completed", "success", "finished": + return Green.Sprint(status) + case "failed", "error": + return Red.Sprint(status) + case "pending", "processing", "running": + return Yellow.Sprint(status) + default: + return status + } +} + +func KindBadge(kind string) string { + switch strings.ToLower(kind) { + case "primary": + return HiCyan.Sprint(kind) + case "alias": + return HiMagenta.Sprint(kind) + default: + return kind + } +} + +func ProfileName(name string, isDefault bool) string { + if isDefault { + return fmt.Sprintf("%s %s", Bold.Sprint(name), Dim.Sprint("(default)")) + } + return Bold.Sprint(name) +} + +func visibleWidth(s string) int { + return utf8.RuneCountInString(stripANSI(s)) +} + +func stripANSI(s string) string { + return ansiRE.ReplaceAllString(s, "") +} + +func padRight(s string, width int) string { + n := utf8.RuneCountInString(s) + if n >= width { + return s + } + return s + strings.Repeat(" ", width-n) +} + +func padRightVisible(s string, width int) string { + n := visibleWidth(s) + if n >= width { + return s + } + return s + strings.Repeat(" ", width-n) +} + +func min(a, b int) int { + if a < b { + return a + } + return b }