// 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" }