From 90e4173ffc7b929ca717028f962b8d7024be526f Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 30 Jan 2026 02:35:11 +0200 Subject: [PATCH] refactor: migrate args parsing to cobra --- appconfig/appconfig.go | 105 --------------------- cmd/root.go | 210 +++++++++++++++++++++++++++++++++++++++++ config.go | 7 -- go.mod | 3 + go.sum | 9 ++ main.go | 26 ++--- 6 files changed, 232 insertions(+), 128 deletions(-) create mode 100644 cmd/root.go diff --git a/appconfig/appconfig.go b/appconfig/appconfig.go index 47adf91..a6a5688 100755 --- a/appconfig/appconfig.go +++ b/appconfig/appconfig.go @@ -1,9 +1,7 @@ package appconfig import ( - "errors" "fmt" - "io/fs" "os" "path/filepath" "strings" @@ -205,109 +203,6 @@ func SetVersion(v string) { AppVersion = v } -// boolPtr returns a pointer to a boolean value. -func boolPtr(b bool) *bool { - return &b -} - -// ParseCliConfig parses command-line arguments and returns an AppCliConfig. -func ParseCliConfig() *AppCliConfig { - args := os.Args[1:] - config := &AppCliConfig{ - ConfigFile: "", - Debug: nil, - CheckUpdates: nil, - Summary: nil, - Filter: []string{}, - LogFile: nil, - ShowLogFile: false, - ShowMachineID: false, - } - file := FindConfigFile() - for len(args) > 0 { - switch args[0] { - case "-d", "--debug": - config.Debug = boolPtr(true) - case "-D", "--no-debug": - config.Debug = boolPtr(false) - case "-u", "--update": - config.CheckUpdates = boolPtr(true) - case "-U", "--no-update": - config.CheckUpdates = boolPtr(false) - case "-s", "--summary": - config.Summary = boolPtr(true) - case "-S", "--no-summary": - config.Summary = boolPtr(false) - case "-f", "--filter": - if len(args) > 1 { - config.Filter = append(config.Filter, args[1]) - args = args[1:] - } - case "-l", "--log-file": - if len(args) > 1 && !strings.HasPrefix(args[1], "-") { - logFile := args[1] - config.LogFile = &logFile - args = args[1:] - } else { - // No value provided, just show log file path - config.ShowLogFile = true - } - case "-m", "--machine-id": - config.ShowMachineID = true - case "-h", "--help": - printHelp() - os.Exit(0) - case "-v", "--version": - printVersion() - os.Exit(0) - default: - if strings.HasPrefix(strings.TrimSpace(args[0]), "-test.") { - break - } - _, err := os.Stat(file) - exists := !errors.Is(err, fs.ErrNotExist) - if exists { - file = args[0] - } - } - args = args[1:] - } - // If only showing log file or machine ID, don't require config file - if config.ShowLogFile || config.ShowMachineID { - return config - } - if file == "" { - fmt.Fprintln(os.Stderr, "No config file found") - os.Exit(1) - } - config.ConfigFile = file - return config -} - -// printHelp prints the command-line help message. -func printHelp() { - fmt.Println("Usage: sofmani [options] [config_file]") - fmt.Println("Options:") - fmt.Println(" -d, --debug Enable debug mode") - fmt.Println(" -D, --no-debug Disable debug mode") - fmt.Println(" -u, --update Enable update checks") - fmt.Println(" -U, --no-update Disable update checks") - fmt.Println(" -s, --summary Enable installation summary (default)") - fmt.Println(" -S, --no-summary Disable installation summary") - fmt.Println(" -f, --filter Filter by installer name (can be used multiple times)") - fmt.Println(" -l, --log-file Set log file path, or show current path if no value given") - fmt.Println(" -m, --machine-id Show machine ID and exit") - fmt.Println(" -h, --help Show this help message") - fmt.Println(" -v, --version Show version") - fmt.Println("") - fmt.Println("For online documentation, see https://github.com/chenasraf/sofmani/tree/master/docs") -} - -// printVersion prints the application version. -func printVersion() { - fmt.Println(AppVersion) -} - // NewAppConfig creates a new AppConfig with default values. func NewAppConfig() AppConfig { return AppConfig{ diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..28e9fe9 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,210 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/chenasraf/sofmani/appconfig" + "github.com/chenasraf/sofmani/logger" + "github.com/chenasraf/sofmani/machine" + "github.com/spf13/cobra" +) + +var ( + // Flag variables + debug bool + noDebug bool + update bool + noUpdate bool + summary bool + noSummary bool + filter []string + logFile string + machineID bool + + // The parsed CLI config + cliConfig *appconfig.AppCliConfig + + // rootCmd represents the base command when called without any subcommands + rootCmd = &cobra.Command{ + Use: "sofmani [flags] [config_file]", + Short: "Software manifest installer", + Long: `Sofmani is a declarative software manifest installer. +It reads a configuration file and installs software based on the manifest. + +For online documentation, see https://github.com/chenasraf/sofmani/tree/master/docs`, + Args: cobra.MaximumNArgs(1), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Build AppCliConfig from parsed flags + cliConfig = buildCliConfig(cmd, args) + }, + Run: func(cmd *cobra.Command, args []string) { + // Handle --log-file without value: show log file path and exit + if cliConfig.ShowLogFile { + fmt.Println(logger.GetLogFile()) + return + } + + // Handle --machine-id: show machine ID and exit + if cliConfig.ShowMachineID { + fmt.Println(machine.GetMachineID()) + return + } + + // Run the main application logic + RunMain(cliConfig) + }, + } +) + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() { + // Pre-process args to handle -l/--log-file with space-separated value + // This maintains backward compatibility with the original CLI behavior + os.Args = preprocessArgs(os.Args) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// preprocessArgs handles the -l/--log-file flag which has an optional value: +// - "-l" alone or "--log-file" alone -> show current log path +// - "-l value" or "--log-file value" -> set log file to value +// This transforms the args so Cobra can handle them properly. +func preprocessArgs(args []string) []string { + result := make([]string, 0, len(args)) + i := 0 + for i < len(args) { + arg := args[i] + + // Check if this is -l or --log-file without an = sign + isLogFlag := arg == "-l" || arg == "--log-file" + + if isLogFlag { + // Check if there's a next argument that could be the value + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + // Next arg is the value - combine into -l=value format + result = append(result, "-l="+args[i+1]) + i += 2 + continue + } else { + // No value provided - use sentinel to indicate "show log path" + result = append(result, "-l=:show:") + i++ + continue + } + } + + result = append(result, arg) + i++ + } + return result +} + +// GetCliConfig returns the parsed CLI configuration. +func GetCliConfig() *appconfig.AppCliConfig { + return cliConfig +} + +func init() { + // Disable alphabetical sorting to control flag order in help output + rootCmd.Flags().SortFlags = false + + // Boolean flags with negation variants (grouped together) + rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug mode") + rootCmd.Flags().BoolVarP(&noDebug, "no-debug", "D", false, "Disable debug mode") + rootCmd.Flags().BoolVarP(&update, "update", "u", false, "Enable update checks") + rootCmd.Flags().BoolVarP(&noUpdate, "no-update", "U", false, "Disable update checks") + rootCmd.Flags().BoolVarP(&summary, "summary", "s", false, "Enable installation summary") + rootCmd.Flags().BoolVarP(&noSummary, "no-summary", "S", false, "Disable installation summary") + + // Filter flag (repeatable) + rootCmd.Flags().StringArrayVarP(&filter, "filter", "f", nil, "Filter by installer name (can be used multiple times)") + + // Log file flag - optional value handled via arg preprocessing + rootCmd.Flags().StringVarP(&logFile, "log-file", "l", "", "Set log file path (use flag alone to show current path)") + + // Machine ID flag + rootCmd.Flags().BoolVarP(&machineID, "machine-id", "m", false, "Show machine ID and exit") +} + +// SetVersion sets the version for the root command. +func SetVersion(version string) { + rootCmd.Version = version + // Use custom template to match original output format (just version number) + rootCmd.SetVersionTemplate("{{.Version}}\n") + appconfig.SetVersion(version) +} + +// buildCliConfig creates an AppCliConfig from the parsed Cobra flags. +func buildCliConfig(cmd *cobra.Command, args []string) *appconfig.AppCliConfig { + config := &appconfig.AppCliConfig{ + ConfigFile: "", + Debug: nil, + CheckUpdates: nil, + Summary: nil, + Filter: filter, + LogFile: nil, + ShowLogFile: false, + ShowMachineID: machineID, + } + + // Handle debug flag + if cmd.Flags().Changed("debug") { + config.Debug = boolPtr(true) + } + if cmd.Flags().Changed("no-debug") { + config.Debug = boolPtr(false) + } + + // Handle update flag + if cmd.Flags().Changed("update") { + config.CheckUpdates = boolPtr(true) + } + if cmd.Flags().Changed("no-update") { + config.CheckUpdates = boolPtr(false) + } + + // Handle summary flag + if cmd.Flags().Changed("summary") { + config.Summary = boolPtr(true) + } + if cmd.Flags().Changed("no-summary") { + config.Summary = boolPtr(false) + } + + // Handle log file flag + if cmd.Flags().Changed("log-file") { + if logFile == ":show:" { + // Flag was provided without a value + config.ShowLogFile = true + } else { + config.LogFile = &logFile + } + } + + // Handle config file positional argument + if len(args) > 0 { + config.ConfigFile = args[0] + } else if !config.ShowLogFile && !config.ShowMachineID { + // Find config file if not showing log file or machine ID + file := appconfig.FindConfigFile() + if file == "" { + fmt.Fprintln(os.Stderr, "No config file found") + os.Exit(1) + } + config.ConfigFile = file + } + + return config +} + +// boolPtr returns a pointer to a boolean value. +func boolPtr(b bool) *bool { + return &b +} + +// RunMain is set by main.go to run the main application logic. +var RunMain func(cliConfig *appconfig.AppCliConfig) diff --git a/config.go b/config.go index 619233d..039d236 100755 --- a/config.go +++ b/config.go @@ -4,13 +4,6 @@ import ( "github.com/chenasraf/sofmani/appconfig" ) -// LoadConfig loads the application configuration. -// It parses command-line arguments and then parses the configuration file. -func LoadConfig() (*appconfig.AppConfig, error) { - overrides := appconfig.ParseCliConfig() - return loadConfigFromCli(overrides) -} - // loadConfigFromCli loads the application configuration from pre-parsed CLI config. func loadConfigFromCli(overrides *appconfig.AppCliConfig) (*appconfig.AppConfig, error) { cfg, err := appconfig.ParseConfig(overrides) diff --git a/go.mod b/go.mod index c3941f2..2192ad8 100755 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/eschao/config v0.1.0 github.com/mattn/go-runewidth v0.0.19 github.com/samber/lo v1.47.0 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.9.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 @@ -14,7 +15,9 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.16.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 82b5bf9..fe130b3 100755 --- a/go.sum +++ b/go.sum @@ -1,20 +1,29 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eschao/config v0.1.0 h1:vtlNamzs6dC9pE0zyplqql16PFUUlst3VttQ+IT2/rk= github.com/eschao/config v0.1.0/go.mod h1:XMilcx0dPfk+tlJowGZPZdmdCRnd7AZuFhYA93tYBgA= +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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +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= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= diff --git a/main.go b/main.go index 72e67c1..cc270d0 100755 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/chenasraf/sofmani/appconfig" + "github.com/chenasraf/sofmani/cmd" "github.com/chenasraf/sofmani/installer" "github.com/chenasraf/sofmani/logger" "github.com/chenasraf/sofmani/machine" @@ -18,25 +19,18 @@ import ( //go:embed version.txt var appVersion []byte // appVersion is embedded from version.txt and contains the application version. +func init() { + cmd.RunMain = runMain +} + // main is the entry point of the application. func main() { - appconfig.SetVersion(strings.TrimSpace(string(appVersion))) - - // Parse CLI config first to check for --log-file flag - cliConfig := appconfig.ParseCliConfig() - - // Handle --log-file without value: show log file path and exit - if cliConfig.ShowLogFile { - fmt.Println(logger.GetLogFile()) - return - } - - // Handle --machine-id: show machine ID and exit - if cliConfig.ShowMachineID { - fmt.Println(machine.GetMachineID()) - return - } + cmd.SetVersion(strings.TrimSpace(string(appVersion))) + cmd.Execute() +} +// runMain runs the main application logic with the given CLI config. +func runMain(cliConfig *appconfig.AppCliConfig) { // Set custom log file if provided if cliConfig.LogFile != nil { logger.SetLogFile(*cliConfig.LogFile)