Script-friendly CLI output, and keep record list rows on one line
-o id and -o field=NAME,... print just the IDs or chosen fields; -q on show and list prints the IDs. jarl record list shortens data too long for the terminal (--wide shows it in full). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
113a6913a2
commit
f716fb8dd1
7 files changed
+224
-11
No files matched your search
@@ -24,7 +24,7 @@ const completeTimeout = 3 * time.Second
|
||||
// setupCompletion adds value completion to the command tree.
|
||||
func setupCompletion(root *cobra.Command) {
|
||||
root.RegisterFlagCompletionFunc("server", completeServers(true))
|
||||
root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions([]string{"table", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp))
|
||||
root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions([]string{"table", "json", "yaml", "id", "field="}, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace))
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
for _, name := range []string{"view", "user", "zone"} {
|
||||
|
||||
+94
-4
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -237,10 +239,10 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
|
||||
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
|
||||
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
|
||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json or yaml")
|
||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
|
||||
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors (and IDs where useful)")
|
||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors and IDs (with show and list: just the IDs, like -o id)")
|
||||
root.AddGroup(&cobra.Group{ID: "dns", Title: "DNS commands:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
||||
add := func(group string, cmds ...*cobra.Command) {
|
||||
for _, c := range cmds {
|
||||
@@ -262,7 +264,15 @@ func ctx() context.Context { return context.Background() }
|
||||
// ---- output
|
||||
|
||||
func output(v any, table func(w io.Writer)) error {
|
||||
switch flagOutput {
|
||||
format := flagOutput
|
||||
quietIDs := flagQuiet && (format == "table" || format == "")
|
||||
if quietIDs {
|
||||
format = "id"
|
||||
}
|
||||
if fieldFormat(format) {
|
||||
return outputFields(v, format, quietIDs)
|
||||
}
|
||||
switch format {
|
||||
case "json":
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
@@ -286,7 +296,79 @@ func output(v any, table func(w io.Writer)) error {
|
||||
table(tw)
|
||||
return tw.Flush()
|
||||
}
|
||||
return fmt.Errorf("unknown output format %q (use table, json or yaml)", flagOutput)
|
||||
return fmt.Errorf("unknown output format %q (use table, json, yaml, id or field=NAME)", flagOutput)
|
||||
}
|
||||
|
||||
// outputFields prints fields of each object (or of the one object), one
|
||||
// line per object with the fields separated by tabs, for scripts:
|
||||
// "id" prints the IDs, "field=view,soa.serial" those fields. Names are the
|
||||
// JSON field names (see -o json); a dot reaches into nested objects. A
|
||||
// field an object doesn't have prints as empty (the API leaves out empty
|
||||
// fields).
|
||||
func outputFields(v any, format string, quiet bool) error {
|
||||
fields := []string{"id"}
|
||||
if f, ok := strings.CutPrefix(format, "field="); ok {
|
||||
fields = strings.Split(f, ",")
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.UseNumber()
|
||||
var x any
|
||||
if err := dec.Decode(&x); err != nil {
|
||||
return err
|
||||
}
|
||||
items, ok := x.([]any)
|
||||
if !ok {
|
||||
items = []any{x}
|
||||
}
|
||||
if quiet && !slices.ContainsFunc(items, func(it any) bool { _, ok := lookupField(it, "id"); return ok }) {
|
||||
return nil // -q on something without IDs: only errors
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, it := range items {
|
||||
var vals []string
|
||||
for _, f := range fields {
|
||||
val, _ := lookupField(it, strings.TrimSpace(f))
|
||||
vals = append(vals, fieldText(val))
|
||||
}
|
||||
out.WriteString(strings.Join(vals, "\t") + "\n")
|
||||
}
|
||||
_, err = io.WriteString(os.Stdout, out.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// fieldFormat reports whether an -o format prints fields (id, field=...).
|
||||
func fieldFormat(format string) bool {
|
||||
return format == "id" || strings.HasPrefix(format, "field=")
|
||||
}
|
||||
|
||||
func lookupField(v any, path string) (any, bool) {
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if v, ok = m[part]; !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func fieldText(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case json.Number, bool:
|
||||
return fmt.Sprint(x)
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func dot(name string) string {
|
||||
@@ -391,6 +473,14 @@ func report(resp *api.MutationResponse) error {
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(resp, nil)
|
||||
}
|
||||
if fieldFormat(flagOutput) {
|
||||
// The changed object, if the response has it ("jarl record add
|
||||
// ... -o id" prints the new record's ID).
|
||||
if len(resp.Object) > 0 {
|
||||
return outputFields(resp.Object, flagOutput, false)
|
||||
}
|
||||
return outputFields(resp, flagOutput, false)
|
||||
}
|
||||
w := os.Stdout
|
||||
r := client.ParseResult(resp.Result)
|
||||
switch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -190,3 +191,44 @@ func TestListQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputFields(t *testing.T) {
|
||||
capture := func(f func() error) string {
|
||||
t.Helper()
|
||||
r, w, _ := os.Pipe()
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
err := f()
|
||||
os.Stdout = old
|
||||
w.Close()
|
||||
b, _ := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
zones := []map[string]any{
|
||||
{"id": 12, "name": "adbraun.lol.", "view": "external", "soa": map[string]any{"serial": 2026100901}},
|
||||
{"id": 3, "name": "lab.adbraun.lol.", "soa": map[string]any{"serial": 7}},
|
||||
}
|
||||
defer func(o string, q bool) { flagOutput, flagQuiet = o, q }(flagOutput, flagQuiet)
|
||||
for _, tc := range []struct {
|
||||
format string
|
||||
quiet bool
|
||||
v any
|
||||
want string
|
||||
}{
|
||||
{"id", false, zones, "12\n3\n"},
|
||||
{"id", false, zones[0], "12\n"},
|
||||
{"field=name,view,soa.serial", false, zones, "adbraun.lol.\texternal\t2026100901\nlab.adbraun.lol.\t\t7\n"},
|
||||
{"field=soa", false, zones[1], "{\"serial\":7}\n"},
|
||||
{"table", true, zones, "12\n3\n"},
|
||||
{"table", true, map[string]any{"status": "ok"}, ""}, // -q without IDs: nothing
|
||||
} {
|
||||
flagOutput, flagQuiet = tc.format, tc.quiet
|
||||
got := capture(func() error { return output(tc.v, func(io.Writer) { t.Error("table printed") }) })
|
||||
if got != tc.want {
|
||||
t.Errorf("-o %s (quiet %v) = %q, want %q", tc.format, tc.quiet, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-3
@@ -3,12 +3,14 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/term"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -407,7 +409,7 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
func recordCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||
var view, name, typ, search, comment, ttlArg string
|
||||
var ptr, missingOK, yes, onlyDisabled bool
|
||||
var ptr, missingOK, yes, onlyDisabled, wide bool
|
||||
var id int64
|
||||
|
||||
list := &cobra.Command{
|
||||
@@ -431,7 +433,7 @@ func recordCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(recs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tNAME\tTTL\tTYPE\tDATA\tCOMMENT")
|
||||
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
||||
for _, r := range recs {
|
||||
ttl := fmt.Sprint(r.EffectiveTTL)
|
||||
if r.TTL == nil {
|
||||
@@ -441,7 +443,13 @@ func recordCmd() *cobra.Command {
|
||||
if r.Disabled {
|
||||
comment = strings.TrimSpace("[disabled] " + comment)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.RelativeName, ttl, r.Type, r.Data, comment)
|
||||
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
||||
}
|
||||
if !wide {
|
||||
fitColumn(rows, 4)
|
||||
}
|
||||
for _, row := range rows {
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -451,6 +459,7 @@ func recordCmd() *cobra.Command {
|
||||
list.Flags().StringVarP(&typ, "type", "t", "", "only this type")
|
||||
list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this")
|
||||
list.Flags().BoolVar(&onlyDisabled, "disabled", false, "only disabled records")
|
||||
list.Flags().BoolVarP(&wide, "wide", "w", false, "show long data in full (on a terminal it is shortened to fit)")
|
||||
|
||||
add := &cobra.Command{
|
||||
Use: "add ZONE NAME TYPE DATA...",
|
||||
@@ -724,3 +733,39 @@ func setTTL(body map[string]any, ttl string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fitColumn shortens column col of a table (header row first) so that the
|
||||
// rows fit the terminal: the table pads every row to its longest cell, so
|
||||
// one long TXT record would make every row wrap. Nothing is shortened when
|
||||
// standard output isn't a terminal.
|
||||
func fitColumn(rows [][]string, col int) {
|
||||
fd := int(os.Stdout.Fd())
|
||||
if !term.IsTerminal(fd) {
|
||||
return
|
||||
}
|
||||
width, _, err := term.GetSize(fd)
|
||||
if err != nil || width <= 0 {
|
||||
return
|
||||
}
|
||||
widths := make([]int, len(rows[0]))
|
||||
for _, row := range rows {
|
||||
for i, c := range row {
|
||||
widths[i] = max(widths[i], utf8.RuneCountInString(c))
|
||||
}
|
||||
}
|
||||
used := 0
|
||||
for i, w := range widths {
|
||||
if i != col {
|
||||
used += w + 2 // the table's padding
|
||||
}
|
||||
}
|
||||
if col == len(widths)-1 {
|
||||
used -= 2
|
||||
}
|
||||
limit := max(width-used-2, 24)
|
||||
for _, row := range rows[1:] {
|
||||
if r := []rune(row[col]); len(r) > limit {
|
||||
row[col] = string(r[:limit-1]) + "…"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user