diff --git a/cmd/src/cmd.go b/cmd/src/cmd.go index 93d0accbe3..9182dc57ea 100644 --- a/cmd/src/cmd.go +++ b/cmd/src/cmd.go @@ -6,8 +6,6 @@ import ( "log" "os" "slices" - - "github.com/sourcegraph/src-cli/internal/cmderrors" ) // command is a subcommand handler and its flag set. @@ -68,11 +66,12 @@ func (c commander) run(flagSet *flag.FlagSet, cmdName, usageText string, args [] // Find the subcommand to execute. name := flagSet.Arg(0) + + // Command is legacy, so lets execute the old way for _, cmd := range c { if !cmd.matches(name) { continue } - // Read global configuration now. var err error cfg, err = readConfig() @@ -80,31 +79,12 @@ func (c commander) run(flagSet *flag.FlagSet, cmdName, usageText string, args [] log.Fatal("reading config: ", err) } - // Parse subcommand flags. - args := flagSet.Args()[1:] - if err := cmd.flagSet.Parse(args); err != nil { - fmt.Printf("Error parsing subcommand flags: %s\n", err) - panic(fmt.Sprintf("all registered commands should use flag.ExitOnError: error: %s", err)) - } - - // Execute the subcommand. - if err := cmd.handler(flagSet.Args()[1:]); err != nil { - if _, ok := err.(*cmderrors.UsageError); ok { - log.Printf("error: %s\n\n", err) - cmd.flagSet.SetOutput(os.Stderr) - flag.CommandLine.SetOutput(os.Stderr) - cmd.flagSet.Usage() - os.Exit(2) - } - if e, ok := err.(*cmderrors.ExitCodeError); ok { - if e.HasError() { - log.Println(e) - } - os.Exit(e.Code()) - } + exitCode, err := runLegacy(cmd, flagSet) + if err != nil { log.Fatal(err) } - os.Exit(0) + os.Exit(exitCode) + } log.Printf("%s: unknown subcommand %q", cmdName, name) log.Fatalf("Run '%s help' for usage.", cmdName) diff --git a/cmd/src/main.go b/cmd/src/main.go index 93be07c4bf..1bc131c0eb 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -93,7 +93,18 @@ func main() { log.SetFlags(0) log.SetPrefix("") - commands.run(flag.CommandLine, "src", usageText, normalizeDashHelp(os.Args[1:])) + ranMigratedCmd, exitCode, err := maybeRunMigratedCommand() + if ranMigratedCmd { + if err != nil { + log.Println(err) + } + os.Exit(exitCode) + } + + // if we didn't run a migrated command, then lets try running the legacy version + if !ranMigratedCmd { + commands.run(flag.CommandLine, "src", usageText, normalizeDashHelp(os.Args[1:])) + } } // normalizeDashHelp converts --help to -help since Go's flag parser only supports single dash. diff --git a/cmd/src/run_migration_compat.go b/cmd/src/run_migration_compat.go new file mode 100644 index 0000000000..c50f2cd34a --- /dev/null +++ b/cmd/src/run_migration_compat.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + + "github.com/sourcegraph/src-cli/internal/clicompat" + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/urfave/cli/v3" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +var migratedCommands = map[string]*cli.Command{ + "version": versionCommand, +} + +func maybeRunMigratedCommand() (isMigrated bool, exitCode int, err error) { + // need to figure out if a migrated command has been requested + flag.Parse() + subCommand := flag.CommandLine.Arg(0) + _, isMigrated = migratedCommands[subCommand] + if !isMigrated { + return + } + cfg, err = readConfig() + if err != nil { + log.Fatal("reading config: ", err) + } + + exitCode, err = runMigrated() + return +} + +// migratedRootCommand constructs a root 'src' command and adds +// MigratedCommands as subcommands to it +func migratedRootCommand() *cli.Command { + names := make([]string, 0, len(migratedCommands)) + for name := range migratedCommands { + names = append(names, name) + } + sort.Strings(names) + + commands := make([]*cli.Command, 0, len(names)) + for _, name := range names { + commands = append(commands, migratedCommands[name]) + } + + return clicompat.WrapRoot(&cli.Command{ + Name: "src", + HideVersion: true, + Commands: commands, + }) +} + +// runMigrated runs the command within urfave/cli framework +func runMigrated() (int, error) { + ctx := context.Background() + + err := migratedRootCommand().Run(ctx, os.Args) + if errors.HasType[*cmderrors.UsageError](err) { + return 2, nil + } + var exitErr cli.ExitCoder + if errors.AsInterface(err, &exitErr) { + return exitErr.ExitCode(), err + } + return 0, err +} + +// runLegacy runs the command using the original commander framework +func runLegacy(cmd *command, flagSet *flag.FlagSet) (int, error) { + // Parse subcommand flags. + args := flagSet.Args()[1:] + if err := cmd.flagSet.Parse(args); err != nil { + fmt.Printf("Error parsing subcommand flags: %s\n", err) + panic(fmt.Sprintf("all registered commands should use flag.ExitOnError: error: %s", err)) + } + + // Execute the subcommand. + if err := cmd.handler(flagSet.Args()[1:]); err != nil { + if _, ok := err.(*cmderrors.UsageError); ok { + log.Printf("error: %s\n\n", err) + cmd.flagSet.SetOutput(os.Stderr) + flag.CommandLine.SetOutput(os.Stderr) + cmd.flagSet.Usage() + return 2, nil + } + if e, ok := err.(*cmderrors.ExitCodeError); ok { + if e.HasError() { + log.Println(e) + } + return e.Code(), nil + } + return 1, err + } + return 0, nil +} diff --git a/cmd/src/version.go b/cmd/src/version.go index 024ab0600e..6186159368 100644 --- a/cmd/src/version.go +++ b/cmd/src/version.go @@ -3,63 +3,73 @@ package main import ( "context" "encoding/json" - "flag" "fmt" "io" "net/http" + "os" "github.com/sourcegraph/sourcegraph/lib/errors" "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/clicompat" "github.com/sourcegraph/src-cli/internal/version" + + "github.com/urfave/cli/v3" ) -func init() { - usage := ` -Examples: +const versionExamples = `Examples: Get the src-cli version and the Sourcegraph instance's recommended version: $ src version ` - flagSet := flag.NewFlagSet("version", flag.ExitOnError) - - var ( - clientOnly = flagSet.Bool("client-only", false, "If true, only the client version will be printed.") - apiFlags = api.NewFlags(flagSet) - ) - - handler := func(args []string) error { - fmt.Printf("Current version: %s\n", version.BuildTag) - if clientOnly != nil && *clientOnly { - return nil +var versionCommand = clicompat.Wrap(&cli.Command{ + Name: "version", + Usage: "display and compare the src-cli version against the recommended version for your instance", + UsageText: "src version [options]", + OnUsageError: clicompat.OnUsageError, + Description: ` +` + versionExamples, + Flags: clicompat.WithAPIFlags( + &cli.BoolFlag{ + Name: "client-only", + Usage: "If true, only the client version will be printed.", + }, + ), + HideVersion: true, + Action: func(ctx context.Context, c *cli.Command) error { + args := VersionArgs{ + Client: cfg.apiClient(clicompat.APIFlagsFromCmd(c), os.Stdout), + ClientOnly: c.Bool("client-only"), } + return versionHandler(args) + }, +}) + +type VersionArgs struct { + ClientOnly bool + Client api.Client + Output io.Writer +} - client := cfg.apiClient(apiFlags, flagSet.Output()) - recommendedVersion, err := getRecommendedVersion(context.Background(), client) - if err != nil { - return errors.Wrap(err, "failed to get recommended version for Sourcegraph deployment") - } - if recommendedVersion == "" { - fmt.Println("Recommended version: ") - fmt.Println("This Sourcegraph instance does not support this feature.") - return nil - } - fmt.Printf("Recommended version: %s or later\n", recommendedVersion) +func versionHandler(args VersionArgs) error { + fmt.Printf("Current version: %s\n", version.BuildTag) + if args.ClientOnly { return nil } - // Register the command. - commands = append(commands, &command{ - flagSet: flagSet, - handler: handler, - usageFunc: func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src %s':\n", flagSet.Name()) - flagSet.PrintDefaults() - fmt.Println(usage) - }, - }) + recommendedVersion, err := getRecommendedVersion(context.Background(), args.Client) + if err != nil { + return errors.Wrap(err, "failed to get recommended version for Sourcegraph deployment") + } + if recommendedVersion == "" { + fmt.Println("Recommended version: ") + fmt.Println("This Sourcegraph instance does not support this feature.") + return nil + } + fmt.Printf("Recommended version: %s or later\n", recommendedVersion) + return nil } func getRecommendedVersion(ctx context.Context, client api.Client) (string, error) { diff --git a/go.mod b/go.mod index 274db0a7c8..75f66dfa62 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/sourcegraph/src-cli -go 1.25.8 +go 1.26 require ( cloud.google.com/go/storage v1.50.0 @@ -83,6 +83,7 @@ require ( github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/tliron/commonlog v0.2.19 // indirect github.com/tliron/kutil v0.3.27 // indirect + github.com/urfave/cli/v3 v3.8.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index e85cdd9f5a..eda4224b98 100644 --- a/go.sum +++ b/go.sum @@ -370,6 +370,8 @@ github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= github.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg= github.com/tliron/kutil v0.3.27 h1:Wb0V5jdbTci6Let1tiGY741J/9FIynmV/pCsPDPsjcM= github.com/tliron/kutil v0.3.27/go.mod h1:AHeLNIFBSKBU39ELVHZdkw2f/ez2eKGAAGoxwBlhMi8= +github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= +github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= diff --git a/internal/api/flags.go b/internal/api/flags.go index 32a255e371..be2397e818 100644 --- a/internal/api/flags.go +++ b/internal/api/flags.go @@ -48,6 +48,19 @@ func NewFlags(flagSet *flag.FlagSet) *Flags { } } +// NewFlagsFromValues instantiates a new Flags structure from explicit values. +// This is used by cli/v3 compatibility adapters that do not operate on a +// standard flag.FlagSet. +func NewFlagsFromValues(dump, getCurl, trace, insecureSkipVerify, userAgentTelemetry bool) *Flags { + return &Flags{ + dump: new(dump), + getCurl: new(getCurl), + trace: new(trace), + insecureSkipVerify: new(insecureSkipVerify), + userAgentTelemetry: new(userAgentTelemetry), + } +} + func defaultFlags() *Flags { telemetry := defaultUserAgentTelemetry() d := false diff --git a/internal/clicompat/api_flags.go b/internal/clicompat/api_flags.go new file mode 100644 index 0000000000..736d9997cb --- /dev/null +++ b/internal/clicompat/api_flags.go @@ -0,0 +1,49 @@ +package clicompat + +import ( + "os" + + "github.com/sourcegraph/src-cli/internal/api" + "github.com/urfave/cli/v3" +) + +// WithAPIFlags appends the standard API-related flags used by legacy src +func WithAPIFlags(baseFlags ...cli.Flag) []cli.Flag { + var flagTable = []struct { + name string + value bool + text string + }{ + {"dump-requests", false, "Log GraphQL requests and responses to stdout"}, + {"get-curl", false, "Print the curl command for executing this query and exit (WARNING: includes printing your access token!)"}, + {"trace", false, "Log the trace ID for requests. See https://docs.sourcegraph.com/admin/observability/tracing"}, + {"insecure-skip-verify", false, "Skip validation of TLS certificates against trusted chains"}, + {"user-agent-telemetry", defaultAPIUserAgentTelemetry(), "Include the operating system and architecture in the User-Agent sent with requests to Sourcegraph"}, + } + + flags := append([]cli.Flag{}, baseFlags...) + for _, item := range flagTable { + flags = append(flags, &cli.BoolFlag{ + Name: item.name, + Value: item.value, + Usage: item.text, + }) + } + + return flags +} + +// APIFlagsFromCmd reads the shared API-related flags from a command into api.Flags +func APIFlagsFromCmd(cmd *cli.Command) *api.Flags { + return api.NewFlagsFromValues( + cmd.Bool("dump-requests"), + cmd.Bool("get-curl"), + cmd.Bool("trace"), + cmd.Bool("insecure-skip-verify"), + cmd.Bool("user-agent-telemetry"), + ) +} + +func defaultAPIUserAgentTelemetry() bool { + return os.Getenv("SRC_DISABLE_USER_AGENT_TELEMETRY") == "" +} diff --git a/internal/clicompat/errors.go b/internal/clicompat/errors.go new file mode 100644 index 0000000000..f06886b4bf --- /dev/null +++ b/internal/clicompat/errors.go @@ -0,0 +1,16 @@ +package clicompat + +import ( + "context" + "fmt" + "os" + + "github.com/sourcegraph/src-cli/internal/cmderrors" + "github.com/urfave/cli/v3" +) + +func OnUsageError(ctx context.Context, cmd *cli.Command, err error, isSubCommand bool) error { + fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) + cli.DefaultPrintHelp(os.Stderr, cmd.CustomHelpTemplate, cmd) + return cmderrors.Usage(err.Error()) +} diff --git a/internal/clicompat/help.go b/internal/clicompat/help.go new file mode 100644 index 0000000000..f458ad6fbb --- /dev/null +++ b/internal/clicompat/help.go @@ -0,0 +1,56 @@ +package clicompat + +import "github.com/urfave/cli/v3" + +// LegacyCommandHelpTemplate formats leaf command help in a style closer to the +// existing flag.FlagSet-based help output. +const LegacyCommandHelpTemplate = `Usage of '{{.FullName}}': +{{range .VisibleFlags}} {{printf " -%s\n\t\t\t\t%s\n" .Name .Usage}}{{end}}{{if .Description}} + {{trim .Description}} +{{end}} +` + +// LegacyRootCommandHelpTemplate formats root command help while preserving a +// command's UsageText when it is provided. +const LegacyRootCommandHelpTemplate = `{{if .UsageText}}{{trim .UsageText}} +{{else}}Usage of '{{.FullName}}': +{{end}}{{if .VisibleFlags}}{{range .VisibleFlags}}{{println .}}{{end}}{{end}}{{if .VisibleCommands}} +{{range .VisibleCommands}}{{printf "\t%s\t%s\n" .Name .Usage}}{{end}}{{end}}{{if .Description}} +{{trim .Description}} +{{end}} +` + +// Wrap sets common options on a sub commands to ensure consistency for help and error handling +func Wrap(cmd *cli.Command) *cli.Command { + if cmd == nil { + return nil + } + + cmd.CustomHelpTemplate = LegacyCommandHelpTemplate + cmd.OnUsageError = OnUsageError + return cmd +} + +// WrapRoot sets common options on a root command to ensure consistency for help and error handling +func WrapRoot(cmd *cli.Command) *cli.Command { + if cmd == nil { + return nil + } + + cmd.CustomRootCommandHelpTemplate = LegacyRootCommandHelpTemplate + cmd.OnUsageError = OnUsageError + return cmd +} + +// WithLegacyHelp applies both root and leaf legacy help templates. +func WithLegacyHelp(cmd *cli.Command) *cli.Command { + if cmd == nil { + return nil + } + + cmd.CustomHelpTemplate = LegacyCommandHelpTemplate + cmd.CustomRootCommandHelpTemplate = LegacyRootCommandHelpTemplate + cmd.OnUsageError = OnUsageError + + return cmd +}