diff --git a/README.md b/README.md index 3134560..3297da4 100644 --- a/README.md +++ b/README.md @@ -515,6 +515,7 @@ createos sandbox create --shape s-1vcpu-1gb --name my-box --ssh-key ~/.ssh/id_ed createos sandbox create --shape s-1vcpu-512mb --ingress --auto-pause 1h createos sandbox list createos sandbox list --all +createos sandbox list --wide createos sandbox list --status paused --quiet | xargs createos sandbox rm --force createos sandbox get createos sandbox exec my-box -- uname -a diff --git a/cmd/cronjobs/activities.go b/cmd/cronjobs/activities.go index 4fac97b..ea75a54 100644 --- a/cmd/cronjobs/activities.go +++ b/cmd/cronjobs/activities.go @@ -71,7 +71,7 @@ func newCronjobsActivitiesCommand() *cli.Command { log, }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/cronjobs/list.go b/cmd/cronjobs/list.go index 3d49d7b..0a4a234 100644 --- a/cmd/cronjobs/list.go +++ b/cmd/cronjobs/list.go @@ -53,7 +53,7 @@ func newCronjobsListCommand() *cli.Command { cj.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/deployments/helpers.go b/cmd/deployments/helpers.go index c221554..2495550 100644 --- a/cmd/deployments/helpers.go +++ b/cmd/deployments/helpers.go @@ -8,6 +8,7 @@ import ( "github.com/NodeOps-app/createos-cli/internal/api" "github.com/NodeOps-app/createos-cli/internal/cmdutil" + "github.com/NodeOps-app/createos-cli/internal/output" "github.com/NodeOps-app/createos-cli/internal/terminal" ) @@ -82,7 +83,7 @@ func pickDeployment(client *api.APIClient, projectID string, statusFilter []stri }) } fmt.Println() - if err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err = output.RenderTable(tableData); err != nil { pterm.Error.Println("could not display table") } fmt.Println() diff --git a/cmd/deployments/list.go b/cmd/deployments/list.go index e85387e..8982cbe 100644 --- a/cmd/deployments/list.go +++ b/cmd/deployments/list.go @@ -50,7 +50,7 @@ func newDeploymentsListCommand() *cli.Command { d.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err := output.RenderTable(tableData); err != nil { pterm.Error.Println("could not display table") } }) diff --git a/cmd/domains/create.go b/cmd/domains/create.go index 6f5e9a0..b538155 100644 --- a/cmd/domains/create.go +++ b/cmd/domains/create.go @@ -8,6 +8,7 @@ import ( "github.com/NodeOps-app/createos-cli/internal/api" "github.com/NodeOps-app/createos-cli/internal/cmdutil" + "github.com/NodeOps-app/createos-cli/internal/output" ) func newDomainsCreateCommand() *cli.Command { @@ -82,6 +83,6 @@ func printDNSRecords(d api.Domain) { tableData = append(tableData, []string{"TXT", txt.Name + "." + d.Name, txt.Value}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck fmt.Println() } diff --git a/cmd/domains/list.go b/cmd/domains/list.go index 3915547..0cbdc4b 100644 --- a/cmd/domains/list.go +++ b/cmd/domains/list.go @@ -70,7 +70,7 @@ func newDomainsListCommand() *cli.Command { } tableData = append(tableData, []string{d.ID, d.Name, env, icon + " " + d.Status, msg}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/env/list.go b/cmd/env/list.go index 0bf40e6..ad4f282 100644 --- a/cmd/env/list.go +++ b/cmd/env/list.go @@ -57,7 +57,7 @@ func newEnvListCommand() *cli.Command { tableData = append(tableData, []string{k, val}) } - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err := output.RenderTable(tableData); err != nil { return err } fmt.Println() diff --git a/cmd/environments/list.go b/cmd/environments/list.go index 063cc7b..37fbf54 100644 --- a/cmd/environments/list.go +++ b/cmd/environments/list.go @@ -62,7 +62,7 @@ func newEnvironmentsListCommand() *cli.Command { env.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err := output.RenderTable(tableData); err != nil { pterm.Error.Println("could not display table") } fmt.Println() diff --git a/cmd/oauth/list.go b/cmd/oauth/list.go index 82cf09b..d347bef 100644 --- a/cmd/oauth/list.go +++ b/cmd/oauth/list.go @@ -40,7 +40,7 @@ func newListCommand() *cli.Command { item.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err := output.RenderTable(tableData); err != nil { pterm.Error.Println("could not display table") } fmt.Println() diff --git a/cmd/projects/list.go b/cmd/projects/list.go index e3b8e05..0254bd4 100644 --- a/cmd/projects/list.go +++ b/cmd/projects/list.go @@ -43,7 +43,7 @@ func newListCommand() *cli.Command { p.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck }) return nil }, diff --git a/cmd/sandbox/catalog.go b/cmd/sandbox/catalog.go index 5713b9d..dae37f8 100644 --- a/cmd/sandbox/catalog.go +++ b/cmd/sandbox/catalog.go @@ -42,7 +42,7 @@ func runShapes(c *cli.Context) error { fmt.Sprintf("%d MB", s.DefaultDiskMib), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck pterm.Println() pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --shape ")) }) @@ -93,7 +93,7 @@ func runRootfs(c *cli.Context) error { } table = append(table, []string{e.Name, e.Description, status}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck } else { table := pterm.TableData{{"Name", "Default"}} for _, name := range cat.Rootfs { @@ -103,7 +103,7 @@ func runRootfs(c *cli.Context) error { } table = append(table, []string{name, def}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck } pterm.Println() pterm.Println(pterm.Gray(" Pick one when creating: createos sandbox create --rootfs ")) diff --git a/cmd/sandbox/devices.go b/cmd/sandbox/devices.go index d0b2e3b..4d91335 100644 --- a/cmd/sandbox/devices.go +++ b/cmd/sandbox/devices.go @@ -276,7 +276,7 @@ func runDeviceList(c *cli.Context) error { } rows = append(rows, []string{d.Name, d.ClientIP, d.OS, d.ID, mark}) } - return pterm.DefaultTable.WithHasHeader().WithData(rows).Render() + return output.RenderTable(rows) } func newDevicesRemoveCommand() *cli.Command { diff --git a/cmd/sandbox/disk.go b/cmd/sandbox/disk.go index d06b3d6..768694f 100644 --- a/cmd/sandbox/disk.go +++ b/cmd/sandbox/disk.go @@ -194,7 +194,7 @@ func runDiskList(c *cli.Context) error { d.CreatedAt.Local().Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck }) return nil } diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go index 9476b8a..b23067b 100644 --- a/cmd/sandbox/list.go +++ b/cmd/sandbox/list.go @@ -29,7 +29,10 @@ Examples: createos sandbox list --status paused # Pipe-friendly: IDs only - createos sandbox list --quiet`, + createos sandbox list --quiet + + # Include lower-priority columns + createos sandbox list --wide`, Flags: []cli.Flag{ &cli.IntFlag{ Name: "limit", @@ -55,6 +58,10 @@ Examples: Aliases: []string{"q"}, Usage: "Show only the IDs (great for scripting)", }, + &cli.BoolFlag{ + Name: "wide", + Usage: "Show extra columns", + }, }, Action: runList, } @@ -129,24 +136,44 @@ func runList(c *cli.Context) error { pterm.Println(pterm.Gray(" Create one with: createos sandbox create")) return } - tableData := pterm.TableData{ - {"ID", "Name", "Status", "Size", "IP", "Created"}, - } - for _, r := range rows { - tableData = append(tableData, []string{ - r.ID, - strOrDash(r.Name), - r.Status, - r.Shape, - ptrOrDash(r.IP), - r.CreatedAt.Local().Format("2006-01-02 15:04"), - }) - } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + tableData := sandboxListTable(rows, output.TerminalWidth(), c.Bool("wide")) + _ = output.RenderTable(tableData) //nolint:errcheck }) return nil } +func sandboxListTable(rows []api.SandboxView, width int, wide bool) pterm.TableData { + columns := []string{"ID", "Name", "Status", "Size", "IP"} + if wide { + columns = append(columns, "Created") + } + switch { + case width < 70: + columns = []string{"Name", "Status", "Size"} + case width < 90: + columns = []string{"ID", "Name", "Status", "Size"} + } + + tableData := make(pterm.TableData, 0, 1+len(rows)) + tableData = append(tableData, columns) + for _, r := range rows { + values := map[string]string{ + "ID": r.ID, + "Name": strOrDash(r.Name), + "Status": r.Status, + "Size": r.Shape, + "IP": ptrOrDash(r.IP), + "Created": r.CreatedAt.Local().Format("2006-01-02 15:04"), + } + row := make([]string, 0, len(columns)) + for _, col := range columns { + row = append(row, values[col]) + } + tableData = append(tableData, row) + } + return tableData +} + // strOrDash collapses a nullable pointer to "-" when empty so the // table doesn't show ugly blank cells. func strOrDash(s *string) string { diff --git a/cmd/sandbox/list_test.go b/cmd/sandbox/list_test.go new file mode 100644 index 0000000..ec509d2 --- /dev/null +++ b/cmd/sandbox/list_test.go @@ -0,0 +1,50 @@ +package sandbox + +import ( + "testing" + "time" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func TestSandboxListTableBreakpoints(t *testing.T) { + name := "demo" + ip := "10.0.0.1" + rows := []api.SandboxView{{ + ID: "sb-01m0example", + Name: &name, + Status: "running", + Shape: "s-1vcpu-1gb", + IP: &ip, + CreatedAt: time.Date(2026, 8, 25, 17, 59, 0, 0, time.UTC), + }} + + tests := []struct { + name string + width int + wide bool + headers []string + }{ + {name: "compact", width: 69, headers: []string{"Name", "Status", "Size"}}, + {name: "medium", width: 89, headers: []string{"ID", "Name", "Status", "Size"}}, + {name: "default", width: 120, headers: []string{"ID", "Name", "Status", "Size", "IP"}}, + {name: "wide", width: 120, wide: true, headers: []string{"ID", "Name", "Status", "Size", "IP", "Created"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + table := sandboxListTable(rows, tt.width, tt.wide) + if len(table) == 0 { + t.Fatal("expected table data") + } + if len(table[0]) != len(tt.headers) { + t.Fatalf("header count = %d, want %d: %#v", len(table[0]), len(tt.headers), table[0]) + } + for i, want := range tt.headers { + if table[0][i] != want { + t.Fatalf("header[%d] = %q, want %q; headers = %#v", i, table[0][i], want, table[0]) + } + } + }) + } +} diff --git a/cmd/sandbox/network.go b/cmd/sandbox/network.go index 48e44d7..0d5d977 100644 --- a/cmd/sandbox/network.go +++ b/cmd/sandbox/network.go @@ -110,7 +110,7 @@ func runNetworkList(c *cli.Context) error { n.CreatedAt.Local().Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck }) return nil } @@ -177,7 +177,7 @@ func runNetworkShow(c *cli.Context) error { } table = append(table, []string{m.SandboxID, m.Name, m.Status, m.IP, reachable}) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck pterm.Println(pterm.Gray(" Tip: inside any of these sandboxes you can `ping ` or curl by name.")) } }) diff --git a/cmd/sandbox/process.go b/cmd/sandbox/process.go index 268cb21..8b90ed8 100644 --- a/cmd/sandbox/process.go +++ b/cmd/sandbox/process.go @@ -454,7 +454,7 @@ func runProcessList(c *cli.Context) error { processLocalDateTime(p.CreatedAt), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck }) return nil } diff --git a/cmd/sandbox/template.go b/cmd/sandbox/template.go index ca51eaa..a3a09c9 100644 --- a/cmd/sandbox/template.go +++ b/cmd/sandbox/template.go @@ -155,7 +155,7 @@ func runTemplateList(c *cli.Context) error { t.CreatedAt.Local().Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + _ = output.RenderTable(table) //nolint:errcheck pterm.Println() pterm.Println(pterm.Gray(" Spawn from a ready template: createos sandbox create --rootfs ")) }) diff --git a/cmd/scale/scale.go b/cmd/scale/scale.go index 795451a..c17ee70 100644 --- a/cmd/scale/scale.go +++ b/cmd/scale/scale.go @@ -178,7 +178,7 @@ func updateScale(c *cli.Context, client *api.APIClient, projectID, envID string) {"CPU", fmt.Sprintf("%dm", current.CPU), fmt.Sprintf("%dm", req.CPU)}, {"Memory", fmt.Sprintf("%dMB", current.Memory), fmt.Sprintf("%dMB", req.Memory)}, } - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err := output.RenderTable(tableData); err != nil { return err } fmt.Println() diff --git a/cmd/templates/list.go b/cmd/templates/list.go index 3387f84..33fea41 100644 --- a/cmd/templates/list.go +++ b/cmd/templates/list.go @@ -59,7 +59,7 @@ func newTemplatesListCommand() *cli.Command { }) } - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err := output.RenderTable(tableData); err != nil { return err } fmt.Println() diff --git a/cmd/users/consents_list.go b/cmd/users/consents_list.go index 5a2c0ae..ba6db15 100644 --- a/cmd/users/consents_list.go +++ b/cmd/users/consents_list.go @@ -48,7 +48,7 @@ func newOAuthConsentsListCommand() *cli.Command { } tableData = append(tableData, []string{clientID, clientName, clientURI}) } - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + if err := output.RenderTable(tableData); err != nil { pterm.Error.Println("could not display table") } fmt.Println() diff --git a/cmd/vms/list.go b/cmd/vms/list.go index ae29735..71e6dab 100644 --- a/cmd/vms/list.go +++ b/cmd/vms/list.go @@ -52,7 +52,7 @@ func newVMListCommand() *cli.Command { vm.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/webhooks/get.go b/cmd/webhooks/get.go index 6e66232..58d74c9 100644 --- a/cmd/webhooks/get.go +++ b/cmd/webhooks/get.go @@ -69,7 +69,7 @@ func newWebhooksGetCommand() *cli.Command { d.CreatedAt.Local().Format("2006-01-02 15:04"), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck fmt.Println() }) return nil diff --git a/cmd/webhooks/list.go b/cmd/webhooks/list.go index 09c950f..d1521a8 100644 --- a/cmd/webhooks/list.go +++ b/cmd/webhooks/list.go @@ -52,7 +52,7 @@ func newWebhooksListCommand() *cli.Command { ep.ID, ep.URL, events, status, fmt.Sprintf("%d", ep.FailureCount), }) } - _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck + _ = output.RenderTable(tableData) //nolint:errcheck fmt.Println() }) return nil diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 0000000..2dd598a --- /dev/null +++ b/internal/output/table.go @@ -0,0 +1,198 @@ +package output + +import ( + "os" + "strconv" + "strings" + "unicode/utf8" + + "github.com/pterm/pterm" + "golang.org/x/term" +) + +const ( + defaultTableWidth = 120 + minColumnWidth = 4 + tableSeparator = " " + tableWidthPadding = 2 +) + +// RenderTable prints a pterm table constrained to the current terminal width. +// Long cells are shortened before rendering so rows do not wrap in narrow panes. +func RenderTable(data pterm.TableData) error { + return pterm.DefaultTable. + WithHasHeader(). + WithSeparator(tableSeparator). + WithData(FitTable(data, TerminalWidth())). + Render() +} + +// TerminalWidth returns the usable width for human-facing terminal output. +func TerminalWidth() int { + if raw := strings.TrimSpace(os.Getenv("COLUMNS")); raw != "" { + width, err := strconv.Atoi(raw) + if err == nil && width > 0 { + return usableTableWidth(width) + } + } + + width, _, err := term.GetSize(int(os.Stdout.Fd())) // #nosec G115 -- stdout fd is a small well-known descriptor + if err != nil || width <= 0 { + return defaultTableWidth + } + return usableTableWidth(width) +} + +func usableTableWidth(width int) int { + if width > tableWidthPadding { + return width - tableWidthPadding + } + return width +} + +// FitTable returns a copy of data with cell contents shortened to fit width. +func FitTable(data pterm.TableData, width int) pterm.TableData { + if len(data) == 0 || width <= 0 { + return data + } + + cols := maxColumns(data) + if cols == 0 { + return data + } + + maxWidths := columnWidths(data, cols) + if tableWidth(maxWidths) <= width { + return cloneTable(data) + } + + minWidths := minimumWidths(data[0], maxWidths) + targets := append([]int(nil), maxWidths...) + for tableWidth(targets) > width { + idx := widestReducibleColumn(targets, minWidths) + if idx < 0 { + break + } + targets[idx]-- + } + + fitted := make(pterm.TableData, len(data)) + for i, row := range data { + fitted[i] = make([]string, len(row)) + for j, cell := range row { + if j >= len(targets) { + fitted[i][j] = cell + continue + } + fitted[i][j] = truncateCell(cell, targets[j]) + } + } + return fitted +} + +func cloneTable(data pterm.TableData) pterm.TableData { + out := make(pterm.TableData, len(data)) + for i, row := range data { + out[i] = append([]string(nil), row...) + } + return out +} + +func maxColumns(data pterm.TableData) int { + cols := 0 + for _, row := range data { + if len(row) > cols { + cols = len(row) + } + } + return cols +} + +func columnWidths(data pterm.TableData, cols int) []int { + widths := make([]int, cols) + for _, row := range data { + for i, cell := range row { + if w := cellWidth(cell); w > widths[i] { + widths[i] = w + } + } + } + return widths +} + +func minimumWidths(header []string, maxWidths []int) []int { + mins := make([]int, len(maxWidths)) + for i, maxWidth := range maxWidths { + headerWidth := 0 + if i < len(header) { + headerWidth = cellWidth(header[i]) + } + min := minColumnWidth + if headerWidth > min { + min = headerWidth + } + if min > maxWidth { + min = maxWidth + } + mins[i] = min + } + return mins +} + +func widestReducibleColumn(widths, mins []int) int { + idx := -1 + for i, width := range widths { + if width <= mins[i] { + continue + } + if idx == -1 || width > widths[idx] { + idx = i + } + } + return idx +} + +func tableWidth(widths []int) int { + total := 0 + for _, width := range widths { + total += width + } + if len(widths) > 1 { + total += (len(widths) - 1) * len(tableSeparator) + } + return total +} + +func cellWidth(s string) int { + width := 0 + for _, line := range strings.Split(s, "\n") { + if w := utf8.RuneCountInString(line); w > width { + width = w + } + } + return width +} + +func truncateCell(s string, width int) string { + if width <= 0 || cellWidth(s) <= width { + return s + } + s = strings.ReplaceAll(s, "\n", " ") + if width <= 3 { + return firstRunes(s, width) + } + return firstRunes(s, width-3) + "..." +} + +func firstRunes(s string, n int) string { + if n <= 0 { + return "" + } + for i := range s { + if n == 0 { + return s[:i] + } + n-- + } + return s +} diff --git a/internal/output/table_test.go b/internal/output/table_test.go new file mode 100644 index 0000000..26f69a9 --- /dev/null +++ b/internal/output/table_test.go @@ -0,0 +1,42 @@ +package output + +import ( + "testing" + + "github.com/pterm/pterm" +) + +func TestFitTableTruncatesToWidth(t *testing.T) { + data := pterm.TableData{ + {"ID", "Name", "Status"}, + {"sb-01m0vqbdbmzctm5bw7ms2b44s9h", "jovial-northcutt-xm9z", "running"}, + } + + fitted := FitTable(data, 40) + widths := columnWidths(fitted, maxColumns(fitted)) + if got := tableWidth(widths); got > 40 { + t.Fatalf("table width = %d, want <= 40; data = %#v", got, fitted) + } + + if fitted[1][0] == data[1][0] { + t.Fatalf("expected long ID to be truncated") + } +} + +func TestFitTableLeavesSmallTableAlone(t *testing.T) { + data := pterm.TableData{ + {"ID", "Name"}, + {"sb-1", "demo"}, + } + + fitted := FitTable(data, 80) + if fitted[1][0] != "sb-1" || fitted[1][1] != "demo" { + t.Fatalf("unexpected fitted table: %#v", fitted) + } +} + +func TestUsableTableWidthLeavesPadding(t *testing.T) { + if got := usableTableWidth(100); got != 98 { + t.Fatalf("usableTableWidth(100) = %d, want 98", got) + } +}