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:
adamandClaude Opus 5.5 committed 2026-10-09 22:46:43 +00:00
1 parent 113a6913a2
commit f716fb8dd1
7 files changed
+224 -11

No files matched your search

+13
View File
@@ -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 the command too, and turning signing off gives the matching `jarl record
delete`. 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 ### Changed
- The web UI and API can listen on ports below 1024, such as 443: - 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 are separated by a faint line instead, and a full line marks where the
next name starts. 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 ## [0.2.0] - 2026-10-09
### Added ### Added
+1 -1
View File
@@ -24,7 +24,7 @@ const completeTimeout = 3 * time.Second
// setupCompletion adds value completion to the command tree. // setupCompletion adds value completion to the command tree.
func setupCompletion(root *cobra.Command) { func setupCompletion(root *cobra.Command) {
root.RegisterFlagCompletionFunc("server", completeServers(true)) 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) var walk func(c *cobra.Command)
walk = func(c *cobra.Command) { walk = func(c *cobra.Command) {
for _, name := range []string{"view", "user", "zone"} { for _, name := range []string{"view", "user", "zone"} {
+94 -4
View File
@@ -3,6 +3,7 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -11,6 +12,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "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.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.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.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.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.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:"}) 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) { add := func(group string, cmds ...*cobra.Command) {
for _, c := range cmds { for _, c := range cmds {
@@ -262,7 +264,15 @@ func ctx() context.Context { return context.Background() }
// ---- output // ---- output
func output(v any, table func(w io.Writer)) error { 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": case "json":
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ") enc.SetIndent("", " ")
@@ -286,7 +296,79 @@ func output(v any, table func(w io.Writer)) error {
table(tw) table(tw)
return tw.Flush() 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 { func dot(name string) string {
@@ -391,6 +473,14 @@ func report(resp *api.MutationResponse) error {
if flagOutput == "json" || flagOutput == "yaml" { if flagOutput == "json" || flagOutput == "yaml" {
return output(resp, nil) 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 w := os.Stdout
r := client.ParseResult(resp.Result) r := client.ParseResult(resp.Result)
switch { switch {
+42
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "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
View File
@@ -3,12 +3,14 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"golang.org/x/term"
"io" "io"
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -407,7 +409,7 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
func recordCmd() *cobra.Command { func recordCmd() *cobra.Command {
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"} cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
var view, name, typ, search, comment, ttlArg string var view, name, typ, search, comment, ttlArg string
var ptr, missingOK, yes, onlyDisabled bool var ptr, missingOK, yes, onlyDisabled, wide bool
var id int64 var id int64
list := &cobra.Command{ list := &cobra.Command{
@@ -431,7 +433,7 @@ func recordCmd() *cobra.Command {
return err return err
} }
return output(recs, func(w io.Writer) { 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 { for _, r := range recs {
ttl := fmt.Sprint(r.EffectiveTTL) ttl := fmt.Sprint(r.EffectiveTTL)
if r.TTL == nil { if r.TTL == nil {
@@ -441,7 +443,13 @@ func recordCmd() *cobra.Command {
if r.Disabled { if r.Disabled {
comment = strings.TrimSpace("[disabled] " + comment) 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().StringVarP(&typ, "type", "t", "", "only this type")
list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this") 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().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{ add := &cobra.Command{
Use: "add ZONE NAME TYPE DATA...", Use: "add ZONE NAME TYPE DATA...",
@@ -724,3 +733,39 @@ func setTTL(body map[string]any, ttl string) error {
} }
return nil 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]) + "…"
}
}
}
+20 -3
View File
@@ -139,10 +139,11 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
| Flag | Meaning | | Flag | Meaning |
|------|---------| |------|---------|
| `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. | | `-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. | | `-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. | | `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). | | `--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 ## Exit codes
@@ -440,8 +441,24 @@ See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone.
## Scripting tips ## Scripting tips
* `-o json` gives full API objects. Pipe them to `jq`. * `-o id` prints just the IDs of what a `show` or `list` command finds, one
* `-q` with `create` commands prints just the ID or token. 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. * Check `$?` against the exit code table.
* `jarl record delete ... --missing-ok` succeeds when nothing matches, which * `jarl record delete ... --missing-ok` succeeds when nothing matches, which
keeps cleanup scripts idempotent. So does `record disable|enable keeps cleanup scripts idempotent. So does `record disable|enable
+6
View File
@@ -170,6 +170,12 @@ contains "\[disabled\]"
run "disabling twice says so (exit 5)" 5 $J record disable adbraun.test web A run "disabling twice says so (exit 5)" 5 $J record disable adbraun.test web A
contains "already disabled" contains "already disabled"
run "record enable with PTR" 0 $J record enable adbraun.test web A --ptr 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 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 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 run "CNAME conflict rejected (exit 6)" 6 $J record add adbraun.test www A 192.168.1.99