diff --git a/NOTICE b/NOTICE index fb80040b33a..01f9df98246 100644 --- a/NOTICE +++ b/NOTICE @@ -151,10 +151,6 @@ mattn/go-isatty - https://github.com/mattn/go-isatty Copyright (c) Yasuhiro MATSUMOTO License - https://github.com/mattn/go-isatty/blob/master/LICENSE -mattn/go-runewidth - https://github.com/mattn/go-runewidth -Copyright (c) 2016 Yasuhiro Matsumoto -License - https://github.com/mattn/go-runewidth/blob/master/LICENSE - sabhiram/go-gitignore - https://github.com/sabhiram/go-gitignore Copyright (c) 2015 Shaba Abhiram License - https://github.com/sabhiram/go-gitignore/blob/master/LICENSE diff --git a/cmd/lakebox/list.go b/cmd/lakebox/list.go index 7e951ffd7be..5788693d31d 100644 --- a/cmd/lakebox/list.go +++ b/cmd/lakebox/list.go @@ -1,20 +1,35 @@ package lakebox import ( - "encoding/json" "fmt" - "strings" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" - "github.com/mattn/go-runewidth" + "github.com/databricks/cli/libs/flags" "github.com/spf13/cobra" ) -func newListCommand() *cobra.Command { - var outputJSON bool +// listRow embeds sandboxEntry so JSON output stays byte-identical to the +// raw API response. Text-mode fields are tagged `json:"-"` so they don't +// leak when the user passes `-o json`. +type listRow struct { + sandboxEntry + DisplayName string `json:"-"` + AutoStop string `json:"-"` + Default string `json:"-"` +} + +// State colors are picked from cmdio's RenderFuncMap palette. green, +// yellow, and blue all emit same-byte-width SGR sequences, so the STATUS +// column stays aligned under tabwriter even when colors vary per row. +const ( + listHeaderTemplate = `{{header "ID"}} {{header "NAME"}} {{header "STATUS"}} {{header "AUTOSTOP"}} {{header "DEFAULT"}}` + listRowTemplate = `{{range .}}{{.SandboxID | bold}} {{.DisplayName}} {{if eq .Status "Running"}}{{green "%s" .Status}}{{else if eq .Status "Creating"}}{{yellow "%s" .Status}}{{else}}{{blue "%s" .Status}}{{end}} {{.AutoStop | faint}} {{.Default | cyan}} +{{end}}` +) +func newListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List your Lakebox environments", @@ -25,8 +40,12 @@ current status and ID. Example: databricks lakebox list - databricks lakebox list --json`, + databricks lakebox list -o json`, PreRunE: root.MustWorkspaceClient, + Annotations: map[string]string{ + "headerTemplate": listHeaderTemplate, + "template": listRowTemplate, + }, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) @@ -84,10 +103,10 @@ Example: } _ = setSandboxes(ctx, profile, refs) - if jsonOutput(cmd, outputJSON) { - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(entries) + // JSON path: emit the raw entries (not the display-row wrapper) + // so consumers see the same shape as the underlying API. + if root.OutputType(cmd) == flags.OutputJSON { + return cmdio.Render(ctx, entries) } if len(entries) == 0 { @@ -95,109 +114,30 @@ Example: return nil } - out := cmd.OutOrStdout() - - // Compute column widths. AUTOSTOP holds short tokens like - // `never`, `15m`, `1h30m` — 8 chars covers them. NAME is - // rendered only when at least one entry sets a display name - // different from the ID — there's no point in a column of - // pet-names that duplicate the ID column. - // All column widths are measured in *terminal cells*, not - // bytes or runes — emoji and CJK glyphs render as 2 cells - // despite being 1 rune / multi-byte, and using len() here - // (which counts bytes) misaligns the row whenever a `--name` - // includes wide characters. runewidth.StringWidth gives the - // East-Asian-Width-corrected cell count. - idCol := 10 - autostopCol := 8 - nameCol := 4 - showName := false - for _, e := range entries { - if l := runewidth.StringWidth(e.SandboxID); l > idCol { - idCol = l - } - if l := runewidth.StringWidth(e.autoStopLabel()); l > autostopCol { - autostopCol = l + rows := make([]listRow, len(entries)) + for i, e := range entries { + // "-" stands in for an unset NAME (or a NAME that just + // echoes the ID and so carries no extra information). + // Keep it ASCII so it doesn't add an ANSI wrapper that + // would throw off the column. + name := e.Name + if name == "" || name == e.SandboxID { + name = "-" } - if e.Name != "" && e.Name != e.SandboxID { - showName = true - } - if l := runewidth.StringWidth(e.Name); l > nameCol { - nameCol = l - } - } - idCol += 2 - autostopCol += 2 - if showName { - nameCol += 2 - } - const statusCol = 10 - const defaultCol = 7 - - blank(out) - var header string - if showName { - header = fmt.Sprintf("%-*s %-*s %-*s %-*s %s", - idCol, "ID", nameCol, "NAME", statusCol, "STATUS", autostopCol, "AUTOSTOP", "DEFAULT") - } else { - header = fmt.Sprintf("%-*s %-*s %-*s %s", - idCol, "ID", statusCol, "STATUS", autostopCol, "AUTOSTOP", "DEFAULT") - } - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, header)) - - ruleLen := idCol + statusCol + autostopCol + defaultCol + 6 - if showName { - ruleLen += nameCol + 2 - } - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, strings.Repeat("─", ruleLen))) - - for _, e := range entries { - id := e.SandboxID def := "" - if id == defaultID { - def = cmdio.Cyan(ctx, "*") + if e.SandboxID == defaultID { + def = "*" } - // Pad each cell manually so visible-width alignment is - // preserved after the helpers wrap them with ANSI escapes. - idPad := max(idCol-runewidth.StringWidth(id), 0) - st := status(ctx, e.Status) - stPad := max(statusCol-runewidth.StringWidth(e.Status), 0) - as := e.autoStopLabel() - asPad := max(autostopCol-runewidth.StringWidth(as), 0) - idStr := cmdio.Bold(ctx, id) - if strings.EqualFold(e.Status, "running") { - idStr = cmdio.Bold(ctx, cmdio.Cyan(ctx, id)) - } - if showName { - nm := e.Name - if nm == "" || nm == id { - nm = "-" - } - nmPad := max(nameCol-runewidth.StringWidth(nm), 0) - nmStr := nm - if nm == "-" { - nmStr = cmdio.Faint(ctx, "-") - } - fmt.Fprintf(out, " %s%s %s%s %s%s %s%s %s\n", - idStr, strings.Repeat(" ", idPad), - nmStr, strings.Repeat(" ", nmPad), - st, strings.Repeat(" ", stPad), - cmdio.Faint(ctx, as), strings.Repeat(" ", asPad), - def) - } else { - fmt.Fprintf(out, " %s%s %s%s %s%s %s\n", - idStr, strings.Repeat(" ", idPad), - st, strings.Repeat(" ", stPad), - cmdio.Faint(ctx, as), strings.Repeat(" ", asPad), - def) + rows[i] = listRow{ + sandboxEntry: e, + DisplayName: name, + AutoStop: e.autoStopLabel(), + Default: def, } } - blank(out) - return nil + return cmdio.Render(ctx, rows) }, } - cmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON") - return cmd } diff --git a/cmd/lakebox/sshkey.go b/cmd/lakebox/sshkey.go index 5b7f3829aa9..1f0625606c2 100644 --- a/cmd/lakebox/sshkey.go +++ b/cmd/lakebox/sshkey.go @@ -1,16 +1,14 @@ package lakebox import ( - "encoding/json" "fmt" "os" - "strings" "time" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" - "github.com/mattn/go-runewidth" + "github.com/databricks/cli/libs/flags" "github.com/spf13/cobra" ) @@ -61,9 +59,24 @@ Example: return cmd } -func newSSHKeyListCommand() *cobra.Command { - var outputJSON bool +// sshKeyRow embeds sshKeyEntry so JSON output stays byte-identical to +// the raw API response. Text-mode fields are tagged `json:"-"` so they +// don't leak when the user passes `-o json`. +type sshKeyRow struct { + sshKeyEntry + DisplayName string `json:"-"` + Created string `json:"-"` + LastUsed string `json:"-"` + Local string `json:"-"` +} +const ( + sshKeyHeaderTemplate = `{{header "LOCAL"}} {{header "NAME"}} {{header "KEY HASH"}} {{header "CREATED"}} {{header "LAST USED"}}` + sshKeyRowTemplate = `{{range .}}{{.Local | cyan}} {{.DisplayName}} {{.KeyHash}} {{.Created | faint}} {{.LastUsed | faint}} +{{end}}` +) + +func newSSHKeyListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List SSH keys registered with the lakebox service", @@ -72,12 +85,16 @@ func newSSHKeyListCommand() *cobra.Command { Each row shows the server-assigned key hash (the identifier used to delete the key), the user-supplied name, and create / last-use timestamps. The locally-registered key (from 'databricks lakebox -register') is marked when its hash matches one of the listed entries. +register') is marked with a '*' in the LOCAL column. Examples: databricks lakebox ssh-key list - databricks lakebox ssh-key list --json`, + databricks lakebox ssh-key list -o json`, PreRunE: root.MustWorkspaceClient, + Annotations: map[string]string{ + "headerTemplate": sshKeyHeaderTemplate, + "template": sshKeyRowTemplate, + }, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() w := cmdctx.WorkspaceClient(ctx) @@ -91,10 +108,8 @@ Examples: return fmt.Errorf("failed to list ssh keys: %w", err) } - if jsonOutput(cmd, outputJSON) { - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(keys) + if root.OutputType(cmd) == flags.OutputJSON { + return cmdio.Render(ctx, keys) } if len(keys) == 0 { @@ -103,9 +118,10 @@ Examples: return nil } - // Best-effort: compute the hash of the locally-registered key so - // we can highlight which row belongs to this machine. Missing key - // file or read errors are non-fatal — just skip the marker. + // Best-effort: compute the hash of the locally-registered key + // so we can highlight which row belongs to this machine. + // Missing key file or read errors are non-fatal — just skip + // the marker. localHash := "" if path, err := lakeboxKeyPath(ctx); err == nil { if data, err := os.ReadFile(path + ".pub"); err == nil { @@ -113,68 +129,45 @@ Examples: } } - out := cmd.OutOrStdout() - blank(out) - - // Measure in terminal cells (runewidth) so wide / emoji - // glyphs in `--name` don't misalign the row. - nameCol := 4 - for _, k := range keys { - if l := runewidth.StringWidth(k.Name); l > nameCol { - nameCol = l - } - } - nameCol += 2 - const hashCol = 32 - const timeCol = 20 - - // Leading 4-char gutter reserves space for a per-row `*` marker on - // the key matching this machine; header and separator leave it blank. - header := fmt.Sprintf("%-*s %-*s %-*s %s", - nameCol, "NAME", hashCol, "KEY HASH", timeCol, "CREATED", "LAST USED") - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, header)) - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, strings.Repeat("─", nameCol+hashCol+timeCol+timeCol+6))) - + rows := make([]sshKeyRow, len(keys)) localFound := false - for _, k := range keys { - // Pad NAME manually from the raw width because cmdio.Faint - // wraps the cell in ANSI escapes that throw off `%-*s`. - displayName, visibleNameLen := k.Name, runewidth.StringWidth(k.Name) - if displayName == "" { - displayName = cmdio.Faint(ctx, "(unset)") - visibleNameLen = runewidth.StringWidth("(unset)") + for i, k := range keys { + name := k.Name + if name == "" { + name = "-" } - namePad := max(nameCol-visibleNameLen, 0) - gutter := " " + local := "" if localHash != "" && k.KeyHash == localHash { - gutter = " " + cmdio.Cyan(ctx, "*") + " " + local = "*" localFound = true } - fmt.Fprintf(out, "%s%s%s %-*s %-*s %s\n", - gutter, - displayName, strings.Repeat(" ", namePad), - hashCol, k.KeyHash, - timeCol, formatTimeShort(k.CreateTime), - formatTimeShort(k.LastUseTime)) + rows[i] = sshKeyRow{ + sshKeyEntry: k, + DisplayName: name, + Created: formatTimeShort(k.CreateTime), + LastUsed: formatTimeShort(k.LastUseTime), + Local: local, + } + } + + if err := cmdio.Render(ctx, rows); err != nil { + return err } - // Without a legend the `*` (or its absence) is opaque. Print the - // meaning either way so users can tell "no `*` on any row" apart - // from "this terminal doesn't print the marker". - blank(out) + + // Without a legend the `*` (or its absence) is opaque. Print + // the meaning either way so users can tell "no `*` on any + // row" apart from "this terminal doesn't print the marker". switch { case localFound: - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, cmdio.Cyan(ctx, "*")+" key matches the one on this machine")) + cmdio.LogString(ctx, " "+cmdio.Faint(ctx, cmdio.Cyan(ctx, "*")+" key matches the one on this machine")) case localHash != "": - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, "(no registered key matches this machine's local key — run `databricks lakebox register` to register it)")) + cmdio.LogString(ctx, " "+cmdio.Faint(ctx, "(no registered key matches this machine's local key — run `databricks lakebox register` to register it)")) default: - fmt.Fprintf(out, " %s\n", cmdio.Faint(ctx, "(no local lakebox key on this machine — run `databricks lakebox register` to create and register one)")) + cmdio.LogString(ctx, " "+cmdio.Faint(ctx, "(no local lakebox key on this machine — run `databricks lakebox register` to create and register one)")) } - blank(out) return nil }, } - - cmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON") return cmd } diff --git a/go.mod b/go.mod index ddeceadd2f1..f35dadb31ce 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,6 @@ require ( github.com/hexops/gotextdiff v1.0.3 // BSD-3-Clause github.com/jackc/pgx/v5 v5.9.2 // MIT github.com/mattn/go-isatty v0.0.22 // MIT - github.com/mattn/go-runewidth v0.0.23 // MIT github.com/palantir/pkg/yamlpatch v1.5.0 // BSD-3-Clause github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // BSD-2-Clause github.com/quasilyte/go-ruleguard/dsl v0.3.22 // BSD-3-Clause @@ -81,6 +80,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect