diff --git a/cmd/lets/main.go b/cmd/lets/main.go index e993910..32189c6 100644 --- a/cmd/lets/main.go +++ b/cmd/lets/main.go @@ -1,268 +1,14 @@ package main import ( - "context" - "errors" "os" - "os/signal" - "strings" - "syscall" - "github.com/lets-cli/lets/internal/cmd" - "github.com/lets-cli/lets/internal/config" - "github.com/lets-cli/lets/internal/env" - "github.com/lets-cli/lets/internal/executor" - "github.com/lets-cli/lets/internal/logging" - "github.com/lets-cli/lets/internal/set" - "github.com/lets-cli/lets/internal/upgrade" - "github.com/lets-cli/lets/internal/upgrade/registry" - "github.com/lets-cli/lets/internal/workdir" - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" + "github.com/lets-cli/lets/internal/cli" ) var Version = "0.0.0-dev" var BuildDate = "" func main() { - ctx := getContext() - - configDir := os.Getenv("LETS_CONFIG_DIR") - - logging.InitLogging(os.Stdout, os.Stderr) - - rootCmd := cmd.CreateRootCommand(Version, BuildDate) - rootCmd.InitDefaultHelpFlag() - rootCmd.InitDefaultVersionFlag() - reinitCompletionCmd := cmd.InitCompletionCmd(rootCmd, nil) - cmd.InitSelfCmd(rootCmd, Version) - rootCmd.InitDefaultHelpCmd() - - command, args, err := rootCmd.Traverse(os.Args[1:]) - if err != nil { - log.Errorf("lets: traverse commands error: %s", err) - os.Exit(getExitCode(err, 1)) - } - - rootFlags, err := parseRootFlags(args) - if err != nil { - log.Errorf("lets: parse flags error: %s", err) - os.Exit(1) - } - - if rootFlags.version { - if err := cmd.PrintVersionMessage(rootCmd); err != nil { - log.Errorf("lets: print version error: %s", err) - os.Exit(1) - } - - os.Exit(0) - } - - debugLevel := env.SetDebugLevel(rootFlags.debug) - - if debugLevel > 0 { - log.SetLevel(log.DebugLevel) - } - - if rootFlags.config == "" { - rootFlags.config = os.Getenv("LETS_CONFIG") - } - - cfg, err := config.Load(rootFlags.config, configDir, Version) - if err != nil { - if failOnConfigError(rootCmd, command, rootFlags) { - log.Errorf("lets: config error: %s", err) - os.Exit(1) - } - } - - if cfg != nil { - reinitCompletionCmd(cfg) - cmd.InitSubCommands(rootCmd, cfg, rootFlags.all, os.Stdout) - } - - if rootFlags.init { - wd, err := os.Getwd() - if err == nil { - err = workdir.InitLetsFile(wd, Version) - } - - if err != nil { - log.Errorf("lets: can not create lets.yaml: %s", err) - os.Exit(1) - } - - os.Exit(0) - } - - if rootFlags.upgrade { - upgrader, err := upgrade.NewBinaryUpgrader(registry.NewGithubRegistry(ctx), Version) - if err == nil { - err = upgrader.Upgrade() - } - - if err != nil { - log.Errorf("lets: can not self-upgrade binary: %s", err) - os.Exit(1) - } - - os.Exit(0) - } - - showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) || (len(os.Args) == 1) - - if showUsage { - if err := cmd.PrintRootHelpMessage(rootCmd); err != nil { - log.Errorf("lets: print help error: %s", err) - os.Exit(1) - } - - os.Exit(0) - } - - if err := rootCmd.ExecuteContext(ctx); err != nil { - var depErr *executor.DependencyError - if errors.As(err, &depErr) { - executor.PrintDependencyTree(depErr, os.Stderr) - } - - log.Errorf("lets: %s", err.Error()) - os.Exit(getExitCode(err, 1)) - } -} - -// getContext returns context and kicks of a goroutine -// which waits for SIGINT, SIGTERM and cancels global context. -// -// Note that since we setting stdin to command we run, that command -// will receive SIGINT, SIGTERM at the same time as we here, -// so command's process can begin finishing earlier than cancel will say it to. -func getContext() context.Context { - ch := make(chan os.Signal, 1) - signal.Notify(ch, os.Interrupt, syscall.SIGTERM) - - ctx, cancel := context.WithCancel(context.Background()) - - go func() { - sig := <-ch - log.Printf("lets: signal received: %s", sig) - cancel() - }() - - return ctx -} - -func getExitCode(err error, defaultCode int) int { - var exitCoder interface{ ExitCode() int } - if errors.As(err, &exitCoder) { - return exitCoder.ExitCode() - } - - return defaultCode -} - -// do not fail on config error in it is help (-h, --help) or --init or completion command. -func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *flags) bool { - rootCommands := set.NewSet("completion", "help", "lsp") - return (root.Flags().NFlag() == 0 && !rootCommands.Contains(current.Name())) && !rootFlags.help && !rootFlags.init -} - -type flags struct { - config string - debug int - help bool - version bool - all bool - init bool - upgrade bool -} - -// We can not parse --config and --debug flags using cobra.Command.ParseFlags -// -// until we read config and initialize all subcommands. -// Otherwise root command will parse all flags gready. -// -// For example in 'lets --config lets.my.yaml mysubcommand --config=myconfig' -// -// cobra will parse all --config flags, but take only latest -// -// --config=myconfig, and this is wrong. -func parseRootFlags(args []string) (*flags, error) { - f := &flags{} - // if first arg is not a flag, then it is subcommand - if len(args) > 0 && !strings.HasPrefix(args[0], "-") { - return f, nil - } - - visited := set.NewSet[string]() - - isFlagVisited := func(name string) bool { - if visited.Contains(name) { - return true - } - - visited.Add(name) - - return false - } - - idx := 0 - for idx < len(args) { - arg := args[idx] - if !strings.HasPrefix(arg, "-") { - // stop if arg is not a flag, it is probably a subcommand - break - } - - name, value, found := strings.Cut(arg, "=") - switch name { - case "--config", "-c": - if !isFlagVisited("config") { - if found { - if value == "" { - return nil, errors.New("--config must be set to value") - } - - f.config = value - } else if len(args[idx:]) > 0 { - f.config = args[idx+1] - idx += 2 - - continue - } - } - case "--debug", "-d", "-dd": - if !isFlagVisited("debug") { - f.debug = 1 - if arg == "-dd" { - f.debug = 2 - } - } - case "--help", "-h": - if !isFlagVisited("help") { - f.help = true - } - case "--version", "-v": - if !isFlagVisited("version") { - f.version = true - } - case "--all": - if !isFlagVisited("all") { - f.all = true - } - case "--init": - if !isFlagVisited("init") { - f.init = true - } - case "--upgrade": - if !isFlagVisited("upgrade") { - f.upgrade = true - } - } - - idx += 1 //nolint:revive,golint - } - - return f, nil + os.Exit(cli.Main(Version, BuildDate)) } diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 41f61b2..63c9bff 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -13,6 +13,7 @@ title: Changelog * `[Added]` When a command or its `depends` chain fails, print an indented tree to stderr showing the full chain with the failing command highlighted * `[Added]` Support `env_file` in global config and commands. File names are expanded after `env` is resolved, and values loaded from env files override values from `env`. * `[Changed]` Migrate the LSP YAML parser from the CGO-based tree-sitter bindings to pure-Go [`gotreesitter`](https://github.com/odvcencio/gotreesitter), removing the C toolchain requirement from normal builds and release packaging. +* `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher. ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..eb0b110 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,267 @@ +package cli + +import ( + "context" + "errors" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/lets-cli/lets/internal/cmd" + "github.com/lets-cli/lets/internal/config" + "github.com/lets-cli/lets/internal/env" + "github.com/lets-cli/lets/internal/executor" + "github.com/lets-cli/lets/internal/logging" + "github.com/lets-cli/lets/internal/set" + "github.com/lets-cli/lets/internal/upgrade" + "github.com/lets-cli/lets/internal/upgrade/registry" + "github.com/lets-cli/lets/internal/workdir" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func Main(version string, buildDate string) int { + ctx := getContext() + + configDir := os.Getenv("LETS_CONFIG_DIR") + + logging.InitLogging(os.Stdout, os.Stderr) + + rootCmd := cmd.CreateRootCommand(version, buildDate) + rootCmd.InitDefaultHelpFlag() + rootCmd.InitDefaultVersionFlag() + reinitCompletionCmd := cmd.InitCompletionCmd(rootCmd, nil) + cmd.InitSelfCmd(rootCmd, version) + rootCmd.InitDefaultHelpCmd() + + command, args, err := rootCmd.Traverse(os.Args[1:]) + if err != nil { + log.Errorf("lets: traverse commands error: %s", err) + return getExitCode(err, 1) + } + + rootFlags, err := parseRootFlags(args) + if err != nil { + log.Errorf("lets: parse flags error: %s", err) + return 1 + } + + if rootFlags.version { + if err := cmd.PrintVersionMessage(rootCmd); err != nil { + log.Errorf("lets: print version error: %s", err) + return 1 + } + + return 0 + } + + debugLevel := env.SetDebugLevel(rootFlags.debug) + + if debugLevel > 0 { + log.SetLevel(log.DebugLevel) + } + + if rootFlags.config == "" { + rootFlags.config = os.Getenv("LETS_CONFIG") + } + + cfg, err := config.Load(rootFlags.config, configDir, version) + if err != nil { + if failOnConfigError(rootCmd, command, rootFlags) { + log.Errorf("lets: config error: %s", err) + return 1 + } + } + + if cfg != nil { + reinitCompletionCmd(cfg) + cmd.InitSubCommands(rootCmd, cfg, rootFlags.all, os.Stdout) + } + + if rootFlags.init { + wd, err := os.Getwd() + if err == nil { + err = workdir.InitLetsFile(wd, version) + } + + if err != nil { + log.Errorf("lets: can not create lets.yaml: %s", err) + return 1 + } + + return 0 + } + + if rootFlags.upgrade { + upgrader, err := upgrade.NewBinaryUpgrader(registry.NewGithubRegistry(ctx), version) + if err == nil { + err = upgrader.Upgrade() + } + + if err != nil { + log.Errorf("lets: can not self-upgrade binary: %s", err) + return 1 + } + + return 0 + } + + showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) || (len(os.Args) == 1) + + if showUsage { + if err := cmd.PrintRootHelpMessage(rootCmd); err != nil { + log.Errorf("lets: print help error: %s", err) + return 1 + } + + return 0 + } + + if err := rootCmd.ExecuteContext(ctx); err != nil { + var depErr *executor.DependencyError + if errors.As(err, &depErr) { + executor.PrintDependencyTree(depErr, os.Stderr) + } + + log.Errorf("lets: %s", err.Error()) + return getExitCode(err, 1) + } + + return 0 +} + +// getContext returns context and kicks of a goroutine +// which waits for SIGINT, SIGTERM and cancels global context. +// +// Note that since we setting stdin to command we run, that command +// will receive SIGINT, SIGTERM at the same time as we here, +// so command's process can begin finishing earlier than cancel will say it to. +func getContext() context.Context { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + sig := <-ch + log.Printf("lets: signal received: %s", sig) + cancel() + }() + + return ctx +} + +func getExitCode(err error, defaultCode int) int { + var exitCoder interface{ ExitCode() int } + if errors.As(err, &exitCoder) { + return exitCoder.ExitCode() + } + + return defaultCode +} + +// do not fail on config error in it is help (-h, --help) or --init or completion command. +func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *flags) bool { + rootCommands := set.NewSet("completion", "help", "lsp") + return (root.Flags().NFlag() == 0 && !rootCommands.Contains(current.Name())) && !rootFlags.help && !rootFlags.init +} + +type flags struct { + config string + debug int + help bool + version bool + all bool + init bool + upgrade bool +} + +// We can not parse --config and --debug flags using cobra.Command.ParseFlags +// +// until we read config and initialize all subcommands. +// Otherwise root command will parse all flags gready. +// +// For example in 'lets --config lets.my.yaml mysubcommand --config=myconfig' +// +// cobra will parse all --config flags, but take only latest +// +// --config=myconfig, and this is wrong. +func parseRootFlags(args []string) (*flags, error) { + f := &flags{} + // if first arg is not a flag, then it is subcommand + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + return f, nil + } + + visited := set.NewSet[string]() + + isFlagVisited := func(name string) bool { + if visited.Contains(name) { + return true + } + + visited.Add(name) + + return false + } + + idx := 0 + for idx < len(args) { + arg := args[idx] + if !strings.HasPrefix(arg, "-") { + // stop if arg is not a flag, it is probably a subcommand + break + } + + name, value, found := strings.Cut(arg, "=") + switch name { + case "--config", "-c": + if !isFlagVisited("config") { + if found { + if value == "" { + return nil, errors.New("--config must be set to value") + } + + f.config = value + } else if len(args[idx:]) > 0 { + f.config = args[idx+1] + idx += 2 + + continue + } + } + case "--debug", "-d", "-dd": + if !isFlagVisited("debug") { + f.debug = 1 + if arg == "-dd" { + f.debug = 2 + } + } + case "--help", "-h": + if !isFlagVisited("help") { + f.help = true + } + case "--version", "-v": + if !isFlagVisited("version") { + f.version = true + } + case "--all": + if !isFlagVisited("all") { + f.all = true + } + case "--init": + if !isFlagVisited("init") { + f.init = true + } + case "--upgrade": + if !isFlagVisited("upgrade") { + f.upgrade = true + } + } + + idx += 1 //nolint:revive,golint + } + + return f, nil +}