Add a Debian package and bash completion

make deb builds dist/jarl_<version>_<arch>.deb with dpkg-deb alone (no
root), for the host or another architecture (DEB_ARCH=arm64). The package
creates the jarl user and directories and an example jarl.yaml on the
first install, restarts a running jarld on upgrades, never touches
/etc/jarl/jarl.yaml, stops jarld on removal, and keeps Jarl's data even
on purge.

make install and the package install bash completion for jarl and jarld.
Besides commands and flags it completes server and group names from the
CLI configuration, zone, view, ACL, TSIG key and user names from the
server, and record types. make dist includes the completion scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 15:10:57 +00:00
1 parent 0f2c318aaf
commit 352c9cb394
13 files changed
+555 -30

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
package main
// Shell completion. Cobra completes commands and flags by itself
// ('jarl completion bash'); this adds the values: server and group names
// from the configuration file, and zone, view, ACL, TSIG key, user and
// record type names from the server. Lookups on the server use the
// configured token and give up quickly, completing nothing, when the
// server can't be reached.
import (
"strings"
"time"
"github.com/spf13/cobra"
"jarl/internal/api"
"jarl/internal/rr"
)
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))
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
for _, name := range []string{"view", "user", "zone"} {
if c.LocalFlags().Lookup(name) != nil {
c.RegisterFlagCompletionFunc(name, completeFromServer(name))
}
}
if c.ValidArgsFunction == nil && c.Runnable() {
if fn := argCompletion(c); fn != nil {
c.ValidArgsFunction = fn
}
}
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
}
// argCompletion completes a command's arguments from the placeholders in
// its usage line ("add ZONE NAME TYPE DATA...").
func argCompletion(c *cobra.Command) cobra.CompletionFunc {
fields := strings.Fields(c.Use)
if len(fields) < 2 {
return nil
}
params := fields[1:]
parent := ""
if c.HasParent() {
parent = c.Parent().Name()
}
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) >= len(params) && !strings.HasSuffix(params[len(params)-1], "...") {
return nil, cobra.ShellCompDirectiveNoFileComp
}
p := params[min(len(args), len(params)-1)]
p = strings.Trim(strings.TrimSuffix(p, "..."), "[]")
if i := strings.Index(p, "|"); i >= 0 {
p = p[i+1:] // "[ID|NAME]": complete names
}
switch {
case p == "FILE":
return nil, cobra.ShellCompDirectiveDefault
case p == "ZONE":
return lookup(cmd, "zone")
case p == "VIEW":
return lookup(cmd, "view")
case p == "ACL":
return lookup(cmd, "acl")
case p == "USER":
return lookup(cmd, "user")
case p == "KEY" && parent == "tsig":
return lookup(cmd, "tsig")
case p == "TYPE":
var out []cobra.Completion
for _, t := range rr.Types() {
out = append(out, cobra.CompletionWithDesc(t.Type, t.Description))
}
return out, cobra.ShellCompDirectiveNoFileComp
case p == "NAME" && (parent == "server" || parent == "config" && cmd.Name() == "use"):
return completeServers(false)(cmd, args, toComplete)
case p == "NAME" && parent == "group" && len(args) == 0:
return completeGroups(cmd)
case p == "MEMBER":
all, d := completeServers(true)(cmd, args, toComplete)
var out []cobra.Completion
for _, c := range all {
name, _, _ := strings.Cut(c, "\t")
if name != "all" && name != args[0] {
out = append(out, c)
}
}
return out, d
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
// completeServers completes server names (and with groups, group names
// and "all") from the configuration file.
func completeServers(groups bool) cobra.CompletionFunc {
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// After a comma, complete the next item of a list.
prefix := ""
if i := strings.LastIndex(toComplete, ","); i >= 0 {
prefix = toComplete[:i+1]
}
var out []cobra.Completion
for _, name := range sortedKeys(cfg.Servers) {
out = append(out, cobra.CompletionWithDesc(prefix+name, cfg.Servers[name].URL))
}
if groups {
for _, name := range sortedKeys(cfg.Groups) {
out = append(out, cobra.CompletionWithDesc(prefix+name, "group: "+strings.Join(cfg.Groups[name], ", ")))
}
if len(cfg.Servers) > 0 {
out = append(out, cobra.CompletionWithDesc(prefix+"all", "every server"))
}
}
return out, cobra.ShellCompDirectiveNoFileComp
}
}
func completeGroups(cmd *cobra.Command) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return sortedKeys(cfg.Groups), cobra.ShellCompDirectiveNoFileComp
}
func completeFromServer(kind string) cobra.CompletionFunc {
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return lookup(cmd, kind)
}
}
// lookup asks the server for names of a kind of object.
func lookup(cmd *cobra.Command, kind string) ([]cobra.Completion, cobra.ShellCompDirective) {
none := cobra.ShellCompDirectiveNoFileComp
c, err := newClient()
if err != nil {
return nil, none
}
c.HTTP.Timeout = completeTimeout
seen := map[string]bool{}
var out []cobra.Completion
addName := func(name, desc string) {
if name != "" && !seen[name] {
seen[name] = true
out = append(out, cobra.CompletionWithDesc(name, desc))
}
}
switch kind {
case "zone":
var zones []api.Zone
if c.Get(ctx(), "/zones", nil, &zones) != nil {
return nil, none
}
// A zone in several views is one name; its description lists them.
views := map[string][]string{}
for _, z := range zones {
name := dot(z.Name)
if z.View != "" {
views[name] = append(views[name], z.View)
}
addName(name, z.Type)
}
for i, c := range out {
if vs := views[c[:strings.IndexByte(c, '\t')]]; len(vs) > 0 {
label := "view "
if len(vs) > 1 {
label = "views "
}
out[i] = c + ", " + label + strings.Join(vs, ", ")
}
}
case "view":
var views []api.View
if c.Get(ctx(), "/views", nil, &views) != nil {
return nil, none
}
for _, v := range views {
addName(v.Name, v.Comment)
}
case "acl":
var acls []api.ACL
if c.Get(ctx(), "/acls", nil, &acls) != nil {
return nil, none
}
for _, a := range acls {
addName(a.Name, a.Comment)
}
case "tsig":
var keys []api.TSIGKey
if c.Get(ctx(), "/tsig-keys", nil, &keys) != nil {
return nil, none
}
for _, k := range keys {
addName(k.Name, k.Algorithm)
}
case "user":
var users []api.User
if c.Get(ctx(), "/users", nil, &users) != nil {
return nil, none
}
for _, u := range users {
addName(u.Username, u.Comment)
}
}
return out, none
}
+1
View File
@@ -207,6 +207,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), acmeCmd(), searchCmd(), statsCmd())
add("changes", applyCmd(), changesetCmd(), historyCmd())
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
setupCompletion(root)
return root
}