From f716fb8dd1c130f3803b9fee9a652d4879bfc5e0 Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 9 Oct 2026 22:46:43 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 13 ++++++ cmd/jarl/complete.go | 2 +- cmd/jarl/main.go | 98 +++++++++++++++++++++++++++++++++++++++++-- cmd/jarl/main_test.go | 42 +++++++++++++++++++ cmd/jarl/zones.go | 51 ++++++++++++++++++++-- docs/cli.md | 23 ++++++++-- scripts/e2e.sh | 6 +++ 7 files changed, 224 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10426d9..55f6cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ All notable changes to Jarl are documented here. The format follows the command too, and turning signing off gives the matching `jarl record delete`. +- CLI output for scripts: `-o id` prints just the IDs of what a command + shows or lists (`jarl zone show adbraun.lol -o id`), `-o + field=view,soa.serial` just those fields, tab separated. `-q` on `show` + and `list` prints the IDs too. With a change (`jarl record add ... -o + id`) they apply to the changed object. + ### Changed - The web UI and API can listen on ports below 1024, such as 443: @@ -40,6 +46,13 @@ All notable changes to Jarl are documented here. The format follows are separated by a faint line instead, and a full line marks where the next name starts. +### Fixed + +- `jarl record list`: one long value (a DKIM key) padded every row to its + length, so on a terminal each row wrapped onto a second, blank-looking + line. Long data is now shortened to fit the terminal; `--wide` shows it + in full, as does output to a pipe or file. + ## [0.2.0] - 2026-10-09 ### Added diff --git a/cmd/jarl/complete.go b/cmd/jarl/complete.go index e5e5e05..b74bcbb 100644 --- a/cmd/jarl/complete.go +++ b/cmd/jarl/complete.go @@ -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"} { diff --git a/cmd/jarl/main.go b/cmd/jarl/main.go index 286f0f7..c67fa69 100644 --- a/cmd/jarl/main.go +++ b/cmd/jarl/main.go @@ -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 { diff --git a/cmd/jarl/main_test.go b/cmd/jarl/main_test.go index 28f5eb0..e6d6d8b 100644 --- a/cmd/jarl/main_test.go +++ b/cmd/jarl/main_test.go @@ -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) + } + } +} diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index 36439db..ba5125a 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -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]) + "…" + } + } +} diff --git a/docs/cli.md b/docs/cli.md index a30821b..9a61d95 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -139,10 +139,11 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents. | Flag | Meaning | |------|---------| | `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. | +| `-o id`, `-o field=NAME[,NAME...]` | Just the IDs, or just those fields, one line per object (fields separated by tabs). See [Scripting tips](#scripting-tips). | | `-n, --dry-run` | Validate and show the diff without changing anything. | | `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. | | `--force` | Overwrite BIND files that were changed outside Jarl (drift). | -| `-q, --quiet` | Print only errors, or just the ID where a command creates something. | +| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). | ## Exit codes @@ -440,8 +441,24 @@ See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone. ## Scripting tips -* `-o json` gives full API objects. Pipe them to `jq`. -* `-q` with `create` commands prints just the ID or token. +* `-o id` prints just the IDs of what a `show` or `list` command finds, one + per line, and `-o field=...` picks fields (their names are those of + `-o json`; a dot reaches inside, as in `soa.serial`; a field an object + doesn't have prints as empty). For changes, they apply to the changed + object: + + ```sh + ZONE=$(jarl zone show adbraun.lol --view external -o id) + jarl record list adbraun.lol --name www -t A -o id | xargs -n1 jarl record disable --id + jarl zone show adbraun.lol -o field=view,soa.serial # external 2026100901 + jarl zone list -o field=id,name,view + ID=$(jarl record add adbraun.lol www A 192.0.2.10 -o id) # the new record's ID + ``` +* `-q` with `create` commands prints just the ID or token, and with `show` + and `list` just the IDs. Commands without IDs (status, checks) print + nothing but errors. +* `jarl record list` on a terminal shortens data too long for a line + (`…`); `--wide` shows it in full, and so does output to a pipe or file. * Check `$?` against the exit code table. * `jarl record delete ... --missing-ok` succeeds when nothing matches, which keeps cleanup scripts idempotent. So does `record disable|enable diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 5746461..67f27b9 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -170,6 +170,12 @@ contains "\[disabled\]" run "disabling twice says so (exit 5)" 5 $J record disable adbraun.test web A contains "already disabled" run "record enable with PTR" 0 $J record enable adbraun.test web A --ptr +run "record list -o id" 0 $J record list adbraun.test --name web -t A -o id +if echo "$OUT" | grep -qx '[0-9][0-9]*'; then ok "-o id is just the ID"; else bad "-o id gave '$OUT'"; fi +run "disable by the listed ID" 0 $J record disable --id "$OUT" +run "zone show -o field" 0 $J zone show adbraun.test -o field=name,record_count,disabled_count +contains "^adbraun.test. [0-9]* 1$" +run "enable by ID" 0 $J record enable --id "$($J record list adbraun.test --name web -t A -o id)" expect_dig "enabled web A back" "192.168.1.30" web.adbraun.test A expect_dig "enabled PTR 30 back" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR run "CNAME conflict rejected (exit 6)" 6 $J record add adbraun.test www A 192.168.1.99