From edc86feccd94df73d3a92ffac456ce8c3910d22c Mon Sep 17 00:00:00 2001 From: Ryan McGuire Date: Sat, 17 Jan 2026 17:06:32 -0500 Subject: [PATCH] frame out cli tool --- CLAUDE.md | 65 +++++++++++++++++ cmd/LICENSE | 21 ++++++ cmd/cmd/get.go | 21 ++++++ cmd/cmd/get_device.go | 102 +++++++++++++++++++++++++++ cmd/cmd/get_devices.go | 123 +++++++++++++++++++++++++++++++++ cmd/cmd/prerun.go | 86 +++++++++++++++++++++++ cmd/cmd/root.go | 68 ++++++++++++++++++ cmd/internal/config/config.go | 80 +++++++++++++++++++++ cmd/internal/util/constants.go | 12 ++++ cmd/internal/util/context.go | 43 ++++++++++++ cmd/internal/util/output.go | 101 +++++++++++++++++++++++++++ cmd/main.go | 28 ++++++++ go.mod | 16 +++++ go.sum | 34 +++++++++ 14 files changed, 800 insertions(+) create mode 100644 CLAUDE.md create mode 100644 cmd/LICENSE create mode 100644 cmd/cmd/get.go create mode 100644 cmd/cmd/get_device.go create mode 100644 cmd/cmd/get_devices.go create mode 100644 cmd/cmd/prerun.go create mode 100644 cmd/cmd/root.go create mode 100644 cmd/internal/config/config.go create mode 100644 cmd/internal/util/constants.go create mode 100644 cmd/internal/util/context.go create mode 100644 cmd/internal/util/output.go create mode 100644 cmd/main.go create mode 100644 go.sum diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..36c91cc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Test Commands + +```bash +# Build library packages +go build ./pkg/... + +# Build CLI tool +go build ./cmd/... + +# Run all tests +go test ./... + +# Run tests for a specific package +go test ./pkg/toughswitch/... + +# Run a single test +go test ./pkg/toughswitch/... -run TestClient_AddDel + +# Tidy module dependencies +go mod tidy +``` + +## Architecture + +This repository provides Go client libraries for interacting with Ubiquiti network devices via reverse-engineered REST APIs. + +### Package Structure + +- `pkg/toughswitch/` - Client for ToughSwitch devices (e.g., TS-8-PRO) +- `pkg/edgeos/` - Client for EdgeOS devices (EdgeRouter, EdgeSwitch) +- `cmd/` - Optional CLI tool using cobra (work in progress) + +### Client Design Pattern + +Both packages follow the same multi-device client pattern: + +1. **Client** - Top-level struct holding a map of `deviceClient` instances keyed by host +2. **deviceClient** - Per-device HTTP client with authentication state (token for ToughSwitch, cookies for EdgeOS) +3. **Config** - Device connection settings (host, credentials, TLS options, timeout) + +Key characteristics: +- Thread-safe: Uses `sync.RWMutex` for device map access and `sync.Mutex` for per-device operations +- Auto-login: Automatically authenticates on 401 responses and retries the request +- Concurrent multi-device: `GetAll*` methods use `sync.WaitGroup.Go()` for parallel queries + +### API Pattern + +Each package exposes: +- `MustNew(ctx, []Config)` - Constructor that accepts multiple device configs +- `Add(cfg)` / `Del(host)` - Dynamic device management +- `Get(ctx, host)` - Single device query +- `GetAll(ctx)` - Parallel query across all devices, returns `map[string]*Resource` + +### Authentication + +- **ToughSwitch**: Token-based via `x-auth-token` header from `/api/v1.0/user/login` +- **EdgeOS**: Cookie-based from `/api/login2` endpoint + +### Testing + +Tests use `mockTransport` implementing `http.RoundTripper` to mock HTTP responses without network calls. diff --git a/cmd/LICENSE b/cmd/LICENSE new file mode 100644 index 0000000..13ae518 --- /dev/null +++ b/cmd/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © 2026 Ryan McGuire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/cmd/cmd/get.go b/cmd/cmd/get.go new file mode 100644 index 0000000..d5a9871 --- /dev/null +++ b/cmd/cmd/get.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util" + "github.com/spf13/cobra" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get device information", + Long: `Get device information from configured Ubiquiti devices.`, +} + +func init() { + rootCmd.AddCommand(getCmd) + + getCmd.PersistentFlags().BoolP(util.FLAG_PRETTY, util.FLAG_PRETTY_P, false, + "pretty print output with indentation") + getCmd.PersistentFlags().BoolP(util.FLAG_COLOR, util.FLAG_COLOR_P, false, + "colorize YAML output") +} diff --git a/cmd/cmd/get_device.go b/cmd/cmd/get_device.go new file mode 100644 index 0000000..930cd59 --- /dev/null +++ b/cmd/cmd/get_device.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config" + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util" + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/edgeos" + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/toughswitch" + "github.com/spf13/cobra" +) + +var deviceCmd = &cobra.Command{ + Use: "device ", + Short: "Get information from a single device", + Long: `Get information from a single configured device by name.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeDeviceName, + RunE: runGetDevice, +} + +func init() { + getCmd.AddCommand(deviceCmd) +} + +func completeDeviceName(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + conf := util.ConfigFromContext(cmd.Context()) + if conf == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return conf.GetClientNames(), cobra.ShellCompDirectiveNoFileComp +} + +func runGetDevice(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + conf := util.ConfigFromContext(ctx) + logger := util.LoggerFromContext(ctx) + + deviceName := args[0] + clientConf := conf.GetClientByName(deviceName) + if clientConf == nil { + return fmt.Errorf("device %q not found in configuration", deviceName) + } + + pretty, _ := cmd.Flags().GetBool(util.FLAG_PRETTY) + colorize, _ := cmd.Flags().GetBool(util.FLAG_COLOR) + + logger.Debug().Str("device", deviceName).Str("type", string(clientConf.Type)).Msg("fetching device info") + + var result any + var err error + + switch clientConf.Type { + case config.TypeToughSwitch: + result, err = getToughSwitchDevice(ctx, clientConf) + case config.TypeEdgeOS: + result, err = getEdgeOSDevice(ctx, clientConf) + default: + return fmt.Errorf("unknown device type: %s", clientConf.Type) + } + + if err != nil { + return fmt.Errorf("failed to get device info: %w", err) + } + + return util.YAMLOutput(os.Stdout, result, pretty, colorize) +} + +func getToughSwitchDevice(ctx context.Context, clientConf *config.ClientConfig) (any, error) { + cfg := toughswitch.Config{ + Host: clientConf.Host, + Scheme: clientConf.Scheme, + Insecure: clientConf.Insecure, + Username: clientConf.User, + Password: clientConf.Pass, + Timeout: clientConf.Timeout, + } + + client := toughswitch.MustNew(ctx, []toughswitch.Config{cfg}) + return client.GetDevice(ctx, clientConf.Host) +} + +func getEdgeOSDevice(ctx context.Context, clientConf *config.ClientConfig) (any, error) { + cfg := edgeos.Config{ + Host: clientConf.Host, + Scheme: clientConf.Scheme, + Insecure: clientConf.Insecure, + Username: clientConf.User, + Password: clientConf.Pass, + Timeout: clientConf.Timeout, + } + + client := edgeos.MustNew(ctx, []edgeos.Config{cfg}) + return client.GetConfig(ctx, clientConf.Host) +} diff --git a/cmd/cmd/get_devices.go b/cmd/cmd/get_devices.go new file mode 100644 index 0000000..eef935d --- /dev/null +++ b/cmd/cmd/get_devices.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sync" + + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config" + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util" + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/edgeos" + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/pkg/toughswitch" + "github.com/spf13/cobra" +) + +var devicesCmd = &cobra.Command{ + Use: "devices", + Short: "Get information from all configured devices", + Long: `Get information from all configured devices in parallel.`, + RunE: runGetDevices, +} + +func init() { + getCmd.AddCommand(devicesCmd) +} + +// DeviceResult holds the result of fetching a single device. +type DeviceResult struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Host string `yaml:"host"` + Data any `yaml:"data,omitempty"` + Error string `yaml:"error,omitempty"` + Failed bool `yaml:"failed,omitempty"` +} + +func runGetDevices(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + conf := util.ConfigFromContext(ctx) + logger := util.LoggerFromContext(ctx) + + pretty, _ := cmd.Flags().GetBool(util.FLAG_PRETTY) + colorize, _ := cmd.Flags().GetBool(util.FLAG_COLOR) + + if len(conf.Clients) == 0 { + return fmt.Errorf("no devices configured") + } + + logger.Debug().Int("count", len(conf.Clients)).Msg("fetching all devices") + + results := make([]DeviceResult, len(conf.Clients)) + var wg sync.WaitGroup + var mu sync.Mutex + + for i, clientConf := range conf.Clients { + wg.Add(1) + go func(idx int, cc config.ClientConfig) { + defer wg.Done() + + result := DeviceResult{ + Name: cc.Name, + Type: string(cc.Type), + Host: cc.Host, + } + + var data any + var err error + + switch cc.Type { + case config.TypeToughSwitch: + data, err = fetchToughSwitch(ctx, &cc) + case config.TypeEdgeOS: + data, err = fetchEdgeOS(ctx, &cc) + default: + err = fmt.Errorf("unknown device type: %s", cc.Type) + } + + if err != nil { + result.Error = err.Error() + result.Failed = true + logger.Error().Err(err).Str("device", cc.Name).Msg("failed to fetch device") + } else { + result.Data = data + } + + mu.Lock() + results[idx] = result + mu.Unlock() + }(i, clientConf) + } + + wg.Wait() + + return util.YAMLOutput(os.Stdout, results, pretty, colorize) +} + +func fetchToughSwitch(ctx context.Context, clientConf *config.ClientConfig) (any, error) { + cfg := toughswitch.Config{ + Host: clientConf.Host, + Scheme: clientConf.Scheme, + Insecure: clientConf.Insecure, + Username: clientConf.User, + Password: clientConf.Pass, + Timeout: clientConf.Timeout, + } + + client := toughswitch.MustNew(ctx, []toughswitch.Config{cfg}) + return client.GetDevice(ctx, clientConf.Host) +} + +func fetchEdgeOS(ctx context.Context, clientConf *config.ClientConfig) (any, error) { + cfg := edgeos.Config{ + Host: clientConf.Host, + Scheme: clientConf.Scheme, + Insecure: clientConf.Insecure, + Username: clientConf.User, + Password: clientConf.Pass, + Timeout: clientConf.Timeout, + } + + client := edgeos.MustNew(ctx, []edgeos.Config{cfg}) + return client.GetConfig(ctx, clientConf.Host) +} diff --git a/cmd/cmd/prerun.go b/cmd/cmd/prerun.go new file mode 100644 index 0000000..529496b --- /dev/null +++ b/cmd/cmd/prerun.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config" + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util" + "github.com/rs/zerolog" + "github.com/spf13/cobra" +) + +// preRunFunc is a function that runs before command execution. +type preRunFunc func(cmd *cobra.Command, args []string) error + +// preRunFuncs is the list of functions to run before command execution. +var preRunFuncs = []preRunFunc{ + validateConfigFile, + prepareConfig, + prepareLogger, +} + +// preRun executes all registered pre-run functions in order. +func preRun(cmd *cobra.Command, args []string) error { + for _, fn := range preRunFuncs { + if err := fn(cmd, args); err != nil { + return err + } + } + return nil +} + +func prepareConfig(cmd *cobra.Command, _ []string) error { + configFile, _ := cmd.Root().PersistentFlags().GetString(util.FLAG_CONFIG_FILE) + conf, err := config.LoadConfig(&configFile) + if err != nil { + return err + } + + ctx := util.ContextWithConfig(cmd.Context(), conf) + cmd.SetContext(ctx) + return nil +} + +func prepareLogger(cmd *cobra.Command, _ []string) error { + conf := util.ConfigFromContext(cmd.Context()) + level, err := zerolog.ParseLevel(conf.LogLevel) + if err != nil { + return err + } + + var writer io.Writer + switch conf.LogFormat { + case "console": + writer = zerolog.NewConsoleWriter() + case "json": + writer = os.Stderr + } + + logger := zerolog.New(writer).Level(level) + ctx := util.ContextWithLogger(cmd.Context(), &logger) + cmd.SetContext(ctx) + + logger.Trace().Any("config", conf).Msg("config and logging prepared") + return nil +} + +// validateConfigFile checks that the config file exists if provided. +func validateConfigFile(cmd *cobra.Command, args []string) error { + configFile, _ := cmd.Root().PersistentFlags().GetString(util.FLAG_CONFIG_FILE) + if configFile == "" { + return nil + } + + if !strings.HasSuffix(configFile, ".yaml") && !strings.HasSuffix(configFile, ".json") { + return fmt.Errorf("config file must end in .yaml or .json: %s", configFile) + } + + if _, err := os.Stat(configFile); os.IsNotExist(err) { + return fmt.Errorf("config file does not exist: %s", configFile) + } + + return nil +} diff --git a/cmd/cmd/root.go b/cmd/cmd/root.go new file mode 100644 index 0000000..e24a2eb --- /dev/null +++ b/cmd/cmd/root.go @@ -0,0 +1,68 @@ +/* +Package cmd -- ubiquiti-clients test command + +Copyright © 2026 Ryan McGuire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package cmd + +import ( + "context" + "os" + "os/signal" + "syscall" + + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/util" + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "cmd", + Short: "Simple CLI tool for using ubiquiti-clients packages", +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + ctx, cncl := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cncl() + + err := rootCmd.ExecuteContext(ctx) + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringP(util.FLAG_CONFIG_FILE, util.FLAG_CONFIG_FILE_P, "", + "config file for ubiquiti-clients (yaml/json), env takes priority") + + rootCmd.RegisterFlagCompletionFunc(util.FLAG_CONFIG_FILE, completeConfigFile) + + cobra.EnableTraverseRunHooks = true + rootCmd.PersistentPreRunE = preRun +} + +// completeConfigFile provides shell completion for config file flag, +// returning only files ending in .yaml or .json. +func completeConfigFile(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"yaml", "json"}, cobra.ShellCompDirectiveFilterFileExt +} diff --git a/cmd/internal/config/config.go b/cmd/internal/config/config.go new file mode 100644 index 0000000..467e0dd --- /dev/null +++ b/cmd/internal/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "fmt" + "os" + "time" + + "github.com/caarlos0/env/v11" + yaml "github.com/oasdiff/yaml3" +) + +type ClientsConfig struct { + LogLevel string `json:"logLevel" yaml:"logLevel" env:"LOG_LEVEL" envDefault:"warn"` + LogFormat string `json:"logFormat" yaml:"logFormat" env:"LOG_FORMAT" envDefault:"console"` + + Clients []ClientConfig `json:"clients" yaml:"clients" envPrefix:"CLIENT_"` +} + +type ClientType string + +const ( + TypeEdgeOS ClientType = "edgeOS" + TypeToughSwitch ClientType = "toughSwitch" +) + +type ClientConfig struct { + Type ClientType `json:"type" yaml:"type" env:"TYPE"` + Name string `json:"name" yaml:"name" env:"NAME"` + Host string `json:"host" yaml:"host" env:"HOST"` + Scheme string `json:"scheme" yaml:"scheme" env:"SCHEME" envDefault:"https"` + User string `json:"user" yaml:"user" env:"USER"` + Pass string `json:"pass" yaml:"pass" env:"PASS"` + Insecure bool `json:"insecure" yaml:"insecure" env:"INSECURE" envDefault:"false"` + Timeout time.Duration `json:"timeout" yaml:"timeout" env:"TIMEOUT" envDefault:"10s"` +} + +// LoadConfig will load a file if given, layering env on-top of the config +// if present. Environment variables take the form: +// - LOG_LEVEL, LOG_FORMAT for top-level settings +// - CLIENT_0_NAME, CLIENT_0_HOST, CLIENT_0_TYPE, etc. for client array +func LoadConfig(configPath *string) (*ClientsConfig, error) { + conf, err := env.ParseAs[ClientsConfig]() + if err != nil { + return nil, fmt.Errorf("could not parse env config: %w", err) + } + + if configPath != nil && *configPath != "" { + file, err := os.Open(*configPath) + if err != nil { + return nil, fmt.Errorf("could not open config file: %w", err) + } + defer file.Close() + + decoder := yaml.NewDecoder(file) + if err := decoder.Decode(&conf); err != nil { + return nil, fmt.Errorf("could not decode config file: %w", err) + } + } + + return &conf, nil +} + +// GetClientByName returns a client config by its name, or nil if not found. +func (c *ClientsConfig) GetClientByName(name string) *ClientConfig { + for i := range c.Clients { + if c.Clients[i].Name == name { + return &c.Clients[i] + } + } + return nil +} + +// GetClientNames returns a list of all configured client names. +func (c *ClientsConfig) GetClientNames() []string { + names := make([]string, len(c.Clients)) + for i, client := range c.Clients { + names[i] = client.Name + } + return names +} diff --git a/cmd/internal/util/constants.go b/cmd/internal/util/constants.go new file mode 100644 index 0000000..6a7cb86 --- /dev/null +++ b/cmd/internal/util/constants.go @@ -0,0 +1,12 @@ +package util + +const ( + FLAG_CONFIG_FILE = "config" + FLAG_CONFIG_FILE_P = "f" + + FLAG_PRETTY = "pretty" + FLAG_PRETTY_P = "p" + + FLAG_COLOR = "color" + FLAG_COLOR_P = "c" +) diff --git a/cmd/internal/util/context.go b/cmd/internal/util/context.go new file mode 100644 index 0000000..111637e --- /dev/null +++ b/cmd/internal/util/context.go @@ -0,0 +1,43 @@ +package util + +import ( + "context" + + "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/internal/config" + "github.com/rs/zerolog" +) + +type CmdContextVal uint8 + +const ( + CTX_CONFIG CmdContextVal = iota + CTX_LOGGER +) + +func ContextWithConfig(baseCtx context.Context, config *config.ClientsConfig) context.Context { + return context.WithValue(baseCtx, CTX_CONFIG, config) +} + +func ConfigFromContext(ctx context.Context) *config.ClientsConfig { + val := ctx.Value(CTX_CONFIG) + conf, ok := val.(*config.ClientsConfig) + if !ok { + return nil + } + + return conf +} + +func ContextWithLogger(baseCtx context.Context, logger *zerolog.Logger) context.Context { + return context.WithValue(baseCtx, CTX_LOGGER, logger) +} + +func LoggerFromContext(ctx context.Context) *zerolog.Logger { + val := ctx.Value(CTX_LOGGER) + logger, ok := val.(*zerolog.Logger) + if !ok { + return nil + } + + return logger +} diff --git a/cmd/internal/util/output.go b/cmd/internal/util/output.go new file mode 100644 index 0000000..5741a92 --- /dev/null +++ b/cmd/internal/util/output.go @@ -0,0 +1,101 @@ +package util + +import ( + "io" + "regexp" + + "github.com/fatih/color" + yaml "github.com/oasdiff/yaml3" +) + +// YAMLOutput writes data as YAML to the given writer. +// If pretty is true, output is indented with 2 spaces. +// If colorize is true, YAML syntax is colorized. +func YAMLOutput(w io.Writer, data any, pretty, colorize bool) error { + encoder := yaml.NewEncoder(w) + defer encoder.Close() + + if pretty { + encoder.SetIndent(2) + } + + if !colorize { + return encoder.Encode(data) + } + + // For colored output, encode to bytes first, then colorize + var buf []byte + bufWriter := &byteWriter{buf: &buf} + bufEncoder := yaml.NewEncoder(bufWriter) + if pretty { + bufEncoder.SetIndent(2) + } + if err := bufEncoder.Encode(data); err != nil { + return err + } + bufEncoder.Close() + + colorized := colorizeYAML(string(buf)) + _, err := w.Write([]byte(colorized)) + return err +} + +type byteWriter struct { + buf *[]byte +} + +func (b *byteWriter) Write(p []byte) (n int, err error) { + *b.buf = append(*b.buf, p...) + return len(p), nil +} + +// colorizeYAML applies ANSI colors to YAML output. +func colorizeYAML(input string) string { + // Color definitions - wrap to match ReplaceAllStringFunc signature + keyColor := func(s string) string { return color.New(color.FgCyan).Sprint(s) } + stringColor := func(s string) string { return color.New(color.FgGreen).Sprint(s) } + numberColor := func(s string) string { return color.New(color.FgYellow).Sprint(s) } + boolColor := func(s string) string { return color.New(color.FgMagenta).Sprint(s) } + nullColor := func(s string) string { return color.New(color.FgRed).Sprint(s) } + + // Patterns for YAML elements + keyPattern := regexp.MustCompile(`(?m)^(\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(:)`) + stringPattern := regexp.MustCompile(`:\s*"([^"]*)"`) + quotedStringPattern := regexp.MustCompile(`:\s*'([^']*)'`) + numberPattern := regexp.MustCompile(`:\s*(-?\d+\.?\d*)\s*$`) + boolPattern := regexp.MustCompile(`:\s*(true|false)\s*$`) + nullPattern := regexp.MustCompile(`:\s*(null|~)\s*$`) + + result := input + + // Apply colors in order (specific patterns first) + result = nullPattern.ReplaceAllStringFunc(result, func(s string) string { + return regexp.MustCompile(`(null|~)`).ReplaceAllStringFunc(s, nullColor) + }) + + result = boolPattern.ReplaceAllStringFunc(result, func(s string) string { + return regexp.MustCompile(`(true|false)`).ReplaceAllStringFunc(s, boolColor) + }) + + result = numberPattern.ReplaceAllStringFunc(result, func(s string) string { + return regexp.MustCompile(`(-?\d+\.?\d*)\s*$`).ReplaceAllStringFunc(s, numberColor) + }) + + result = stringPattern.ReplaceAllStringFunc(result, func(s string) string { + return regexp.MustCompile(`"([^"]*)"`).ReplaceAllStringFunc(s, stringColor) + }) + + result = quotedStringPattern.ReplaceAllStringFunc(result, func(s string) string { + return regexp.MustCompile(`'([^']*)'`).ReplaceAllStringFunc(s, stringColor) + }) + + result = keyPattern.ReplaceAllStringFunc(result, func(s string) string { + matches := keyPattern.FindStringSubmatch(s) + if len(matches) >= 4 { + return matches[1] + keyColor(matches[2]) + matches[3] + } + return s + }) + + return result +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..4bb949c --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,28 @@ +/* +Copyright © 2026 Ryan McGuire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package main + +import "gitea.libretechconsulting.com/rmcguire/ubiquiti-clients/cmd/cmd" + +func main() { + cmd.Execute() +} diff --git a/go.mod b/go.mod index 3a5a6c5..557715b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,19 @@ module gitea.libretechconsulting.com/rmcguire/ubiquiti-clients go 1.25.5 + +require ( + github.com/caarlos0/env/v11 v11.3.1 + github.com/fatih/color v1.18.0 + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 + github.com/rs/zerolog v1.34.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e8e57bc --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=