Files
vibedns/internal/cli/cli.go
T
2026-08-16 21:18:45 -05:00

211 lines
5.2 KiB
Go

// Package cli implements the command line interface.
//
// `serve` is the default when no subcommand is given, so running the binary
// with no arguments starts the server, which is the overwhelmingly common case.
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/owen/vibedns/internal/config"
"github.com/owen/vibedns/internal/version"
)
// Command is one subcommand.
type Command struct {
Name string
Summary string
Usage string
Run func(ctx context.Context, env *Env, args []string) error
}
// Env carries what every command needs.
type Env struct {
Boot config.Bootstrap
Stdout io.Writer
Stderr io.Writer
Log *slog.Logger
}
// ExitError carries a specific process exit status.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string { return e.Err.Error() }
func (e *ExitError) Unwrap() error { return e.Err }
// Exit wraps an error with an exit code.
func Exit(code int, format string, args ...any) error {
return &ExitError{Code: code, Err: fmt.Errorf(format, args...)}
}
// commands is the full command table.
func commands() []*Command {
return []*Command{
serveCommand(),
versionCommand(),
configCommand(),
adminCommand(),
databaseCommand(),
}
}
// Main parses arguments and runs the selected command.
func Main(ctx context.Context, args []string, stdout, stderr io.Writer) int {
env := &Env{Boot: config.DefaultBootstrap(), Stdout: stdout, Stderr: stderr}
// Find the subcommand: the first argument that is not a flag. This lets
// both `vibedns --db x serve` and `vibedns serve --db x` work.
name := ""
rest := args
for i, a := range args {
if !strings.HasPrefix(a, "-") {
name = a
rest = append(append([]string{}, args[:i]...), args[i+1:]...)
break
}
}
switch name {
case "help", "-h", "--help", "":
if name == "" && !wantsHelp(args) {
// No subcommand: serve.
return run(ctx, env, serveCommand(), args)
}
usage(stdout)
return 0
}
for _, c := range commands() {
if c.Name == name {
return run(ctx, env, c, rest)
}
}
fmt.Fprintf(stderr, "unknown command %q\n\n", name)
usage(stderr)
return 2
}
func wantsHelp(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
return true
}
}
return false
}
func run(ctx context.Context, env *Env, c *Command, args []string) int {
err := c.Run(ctx, env, args)
if err == nil {
return 0
}
if errors.Is(err, flag.ErrHelp) {
return 0
}
var ee *ExitError
if errors.As(err, &ee) {
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, ee.Err)
return ee.Code
}
fmt.Fprintf(env.Stderr, "%s: %v\n", c.Name, err)
return 1
}
func usage(w io.Writer) {
fmt.Fprintf(w, `%s %s — authoritative DNS server, recursive resolver and filtering appliance
Usage:
vibedns [command] [flags]
Commands:
`, version.Name, version.Version)
for _, c := range commands() {
fmt.Fprintf(w, " %-20s %s\n", c.Name, c.Summary)
}
fmt.Fprintf(w, `
Running with no command starts the server.
Common flags:
--db PATH SQLite database file (default %s)
--http ADDR management interface address (default %s)
--dns ADDR DNS listen address for UDP and TCP (default %s)
--log-level LEVEL debug, info, warn or error
--log-format FORMAT text or json
Environment variables:
%-22s database file
%-22s management interface address
%-22s DNS listen address
%-22s initial administrator username
%-22s initial administrator password
%-22s log level
%-22s log format
Examples:
vibedns
vibedns serve --db /var/lib/vibedns/dns.db --http 127.0.0.1:8080
vibedns config check
vibedns admin reset-password
vibedns database backup --output /var/backups
vibedns version
`,
config.DefaultDBPath, config.DefaultHTTPAddr, config.DefaultDNSAddr,
config.EnvDBPath, config.EnvHTTPAddr, config.EnvDNSAddr,
config.EnvAdminUsername, config.EnvAdminPassword,
config.EnvLogLevel, config.EnvLogFormat)
}
// newFlagSet builds a flag set that prints its own usage on error.
func newFlagSet(env *Env, c *Command) *flag.FlagSet {
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
fs.SetOutput(env.Stderr)
fs.Usage = func() {
fmt.Fprintf(env.Stderr, "%s\n\n", c.Usage)
fs.PrintDefaults()
}
return fs
}
// versionCommand prints build information.
func versionCommand() *Command {
return &Command{
Name: "version",
Summary: "print version and build information",
Usage: "Usage: vibedns version",
Run: func(ctx context.Context, env *Env, args []string) error {
fmt.Fprint(env.Stdout, version.Long())
return nil
},
}
}
// confirm asks for an interactive yes/no answer. It returns false when stdin
// is not a terminal, so a piped invocation can never be silently destructive.
func confirm(env *Env, prompt string) bool {
fi, err := os.Stdin.Stat()
if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 {
fmt.Fprintf(env.Stderr,
"%s\nRefusing to continue without an interactive confirmation; pass --yes to proceed.\n", prompt)
return false
}
fmt.Fprintf(env.Stdout, "%s [y/N]: ", prompt)
var answer string
_, _ = fmt.Fscanln(os.Stdin, &answer)
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}