refactor: migrate args parsing to cobra

This commit is contained in:
2026-01-30 02:35:11 +02:00
parent f762d98933
commit 90e4173ffc
6 changed files with 232 additions and 128 deletions

View File

@@ -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{

210
cmd/root.go Normal file
View File

@@ -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)

View File

@@ -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)

3
go.mod
View File

@@ -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

9
go.sum
View File

@@ -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=

24
main.go
View File

@@ -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)