diff --git a/cmd/cobra.go b/cmd/cobra.go new file mode 100644 index 0000000..91125ce --- /dev/null +++ b/cmd/cobra.go @@ -0,0 +1,117 @@ +package xcaddycmd + +import ( + "fmt" + "log" + "os" + "os/exec" + + "github.com/caddyserver/xcaddy" + "github.com/caddyserver/xcaddy/internal/utils" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "xcaddy ", + Long: "xcaddy is a custom Caddy builder for advanced users and plugin developers.\n" + + "The xcaddy command has two primary uses:\n" + + "- Compile custom caddy binaries\n" + + "- A replacement for `go run` while developing Caddy plugins\n" + + "xcaddy accepts any Caddy command (except help and version) to pass through to the custom-built Caddy, notably `run` and `list-modules`. The command pass-through allows for iterative development process.\n\n" + + "Report bugs on https://github.com/caddyserver/xcaddy\n", + Short: "Caddy module development helper", + SilenceUsage: true, + Version: xcaddyVersion(), + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + binOutput := getCaddyOutputFile() + + // get current/main module name and the root directory of the main module + // + // make sure the module being developed is replaced + // so that the local copy is used + // + // replace directives only apply to the top-level/main go.mod, + // and since this tool is a carry-through for the user's actual + // go.mod, we need to transfer their replace directives through + // to the one we're making + execCmd := exec.Command(utils.GetGo(), "list", "-mod=readonly", "-m", "-json", "all") + execCmd.Stderr = os.Stderr + out, err := execCmd.Output() + if err != nil { + return fmt.Errorf("exec %v: %v: %s", cmd.Args, err, string(out)) + } + currentModule, moduleDir, replacements, err := parseGoListJson(out) + if err != nil { + return fmt.Errorf("json parse error: %v", err) + } + + // reconcile remaining path segments; for example if a module foo/a + // is rooted at directory path /home/foo/a, but the current directory + // is /home/foo/a/b, then the package to import should be foo/a/b + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("unable to determine current directory: %v", err) + } + importPath := normalizeImportPath(currentModule, cwd, moduleDir) + + // build caddy with this module plugged in + builder := xcaddy.Builder{ + Compile: xcaddy.Compile{ + Cgo: os.Getenv("CGO_ENABLED") == "1", + }, + CaddyVersion: caddyVersion, + Plugins: []xcaddy.Dependency{ + {PackagePath: importPath}, + }, + Replacements: replacements, + RaceDetector: raceDetector, + SkipBuild: skipBuild, + SkipCleanup: skipCleanup, + Debug: buildDebugOutput, + } + err = builder.Build(cmd.Context(), binOutput) + if err != nil { + return err + } + + // if requested, run setcap to allow binding to low ports + err = setcapIfRequested(binOutput) + if err != nil { + return err + } + + log.Printf("[INFO] Running %v\n\n", append([]string{binOutput}, args...)) + + execCmd = exec.Command(binOutput, args...) + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + err = execCmd.Start() + if err != nil { + return err + } + defer func() { + if skipCleanup { + log.Printf("[INFO] Skipping cleanup as requested; leaving artifact: %s", binOutput) + return + } + err = os.Remove(binOutput) + if err != nil && !os.IsNotExist(err) { + log.Printf("[ERROR] Deleting temporary binary %s: %v", binOutput, err) + } + }() + + return execCmd.Wait() + }, +} + +const fullDocsFooter = `Full documentation is available at: +https://github.com/caddyserver/xcaddy` + +func init() { + rootCmd.SetVersionTemplate("{{.Version}}\n") + rootCmd.SetHelpTemplate(rootCmd.HelpTemplate() + "\n" + fullDocsFooter + "\n") + rootCmd.AddCommand(buildCommand) + rootCmd.AddCommand(versionCommand) +} diff --git a/cmd/commands.go b/cmd/commands.go new file mode 100644 index 0000000..be75ccd --- /dev/null +++ b/cmd/commands.go @@ -0,0 +1,189 @@ +package xcaddycmd + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/caddyserver/xcaddy" + "github.com/caddyserver/xcaddy/internal/utils" + "github.com/spf13/cobra" +) + +func init() { + buildCommand.Flags().StringArray("with", []string{}, "caddy modules package path to include in the build") + buildCommand.Flags().String("output", "", "change the output file name") + buildCommand.Flags().StringArray("replace", []string{}, "like --with but for Go modules") + buildCommand.Flags().StringArray("embed", []string{}, "embeds directories into the built Caddy executable to use with the `embedded` file-system") +} + +var versionCommand = &cobra.Command{ + Use: "version", + Short: "Prints xcaddy version", + RunE: func(cm *cobra.Command, args []string) error { + fmt.Println(xcaddyVersion()) + return nil + }, +} + +var buildCommand = &cobra.Command{ + Use: `build [] + [--output ] + [--with ...] + [--replace ...] + [--embed <[alias]:path/to/dir>...]`, + Long: ` + is the core Caddy version to build; defaults to CADDY_VERSION env variable or latest. +This can be the keyword latest, which will use the latest stable tag, or any git ref such as: + +A tag like v2.0.1 +A branch like master +A commit like a58f240d3ecbb59285303746406cab50217f8d24 + +Flags: + --output changes the output file. + + --with can be used multiple times to add plugins by specifying the Go module name and optionally its version, similar to go get. Module name is required, but specific version and/or local replacement are optional. + + --replace is like --with, but does not add a blank import to the code; it only writes a replace directive to go.mod, which is useful when developing on Caddy's dependencies (ones that are not Caddy modules). Try this if you got an error when using --with, like cannot find module providing package. + + --embed can be used multiple times to embed directories into the built Caddy executable. The directory can be prefixed with a custom alias and a colon : to use it with the root directive and sub-directive. Example: xcaddy build --embed foo:./sites/foo --embed bar:./sites/bar (This allows you to serve 2 sites from 2 different embedded directories, which are referenced by aliases, from a single Caddy executable). +`, + Short: "Compile custom caddy binaries", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var output string + var plugins []xcaddy.Dependency + var replacements []xcaddy.Replace + var embedDir []string + var argCaddyVersion string + if len(args) > 0 { + argCaddyVersion = args[0] + } + withArgs, err := cmd.Flags().GetStringArray("with") + if err != nil { + return fmt.Errorf("unable to parse --with arguments: %s", err.Error()) + } + + replaceArgs, err := cmd.Flags().GetStringArray("replace") + if err != nil { + return fmt.Errorf("unable to parse --replace arguments: %s", err.Error()) + } + for _, withArg := range withArgs { + mod, ver, _, err := splitWith(withArg) + if err != nil { + return err + } + mod = strings.TrimSuffix(mod, "/") // easy to accidentally leave a trailing slash if pasting from a URL, but is invalid for Go modules + plugins = append(plugins, xcaddy.Dependency{ + PackagePath: mod, + Version: ver, + }) + } + + for _, withArg := range replaceArgs { + mod, ver, repl, err := splitWith(withArg) + if err != nil { + return err + } + // adjust relative replacements in current working directory since our temporary module is in a different directory + if strings.HasPrefix(repl, ".") { + repl, err = filepath.Abs(repl) + if err != nil { + log.Fatalf("[FATAL] %v", err) + } + log.Printf("[INFO] Resolved relative replacement %s to %s", withArg, repl) + } + replacements = append(replacements, xcaddy.NewReplace(xcaddy.Dependency{PackagePath: mod, Version: ver}.String(), repl)) + } + + output, err = cmd.Flags().GetString("output") + if err != nil { + return fmt.Errorf("unable to parse --output arguments: %s", err.Error()) + } + + embedDir, err = cmd.Flags().GetStringArray("embed") + if err != nil { + return fmt.Errorf("unable to parse --embed arguments: %s", err.Error()) + } + // prefer caddy version from command line argument over env var + if argCaddyVersion != "" { + caddyVersion = argCaddyVersion + } + + // ensure an output file is always specified + if output == "" { + output = getCaddyOutputFile() + } + + // perform the build + builder := xcaddy.Builder{ + Compile: xcaddy.Compile{ + Cgo: os.Getenv("CGO_ENABLED") == "1", + }, + CaddyVersion: caddyVersion, + Plugins: plugins, + Replacements: replacements, + RaceDetector: raceDetector, + SkipBuild: skipBuild, + SkipCleanup: skipCleanup, + Debug: buildDebugOutput, + BuildFlags: buildFlags, + ModFlags: modFlags, + } + for _, md := range embedDir { + if before, after, found := strings.Cut(md, ":"); found { + builder.EmbedDirs = append(builder.EmbedDirs, struct { + Dir string `json:"dir,omitempty"` + Name string `json:"name,omitempty"` + }{ + after, before, + }) + } else { + builder.EmbedDirs = append(builder.EmbedDirs, struct { + Dir string `json:"dir,omitempty"` + Name string `json:"name,omitempty"` + }{ + before, "", + }) + } + } + err = builder.Build(cmd.Root().Context(), output) + if err != nil { + log.Fatalf("[FATAL] %v", err) + } + + // done if we're skipping the build + if builder.SkipBuild { + return nil + } + + // if requested, run setcap to allow binding to low ports + err = setcapIfRequested(output) + if err != nil { + return err + } + + // prove the build is working by printing the version + if runtime.GOOS == utils.GetGOOS() && runtime.GOARCH == utils.GetGOARCH() { + if !filepath.IsAbs(output) { + output = "." + string(filepath.Separator) + output + } + fmt.Println() + fmt.Printf("%s version\n", output) + cmd := exec.Command(output, "version") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + log.Fatalf("[FATAL] %v", err) + } + } + + return nil + }, +} diff --git a/cmd/main.go b/cmd/main.go index c058192..9a898d3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -26,7 +26,6 @@ import ( "os/signal" "path" "path/filepath" - "runtime" "runtime/debug" "strings" @@ -49,160 +48,12 @@ func Main() { defer cancel() go trapSignals(ctx, cancel) - if len(os.Args) > 1 && os.Args[1] == "build" { - if err := runBuild(ctx, os.Args[2:]); err != nil { - log.Fatalf("[ERROR] %v", err) - } - return - } - - if len(os.Args) > 1 && os.Args[1] == "version" { - fmt.Println(xcaddyVersion()) - return - } - - if err := runDev(ctx, os.Args[1:]); err != nil { - log.Fatalf("[ERROR] %v", err) + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) } } -func runBuild(ctx context.Context, args []string) error { - // parse the command line args... rather primitively - var argCaddyVersion, output string - var plugins []xcaddy.Dependency - var replacements []xcaddy.Replace - var embedDir []string - for i := 0; i < len(args); i++ { - switch args[i] { - case "--with", "--replace": - arg := args[i] - if i == len(args)-1 { - return fmt.Errorf("expected value after %s flag", arg) - } - i++ - mod, ver, repl, err := splitWith(args[i]) - if err != nil { - return err - } - mod = strings.TrimSuffix(mod, "/") // easy to accidentally leave a trailing slash if pasting from a URL, but is invalid for Go modules - if arg == "--with" { - plugins = append(plugins, xcaddy.Dependency{ - PackagePath: mod, - Version: ver, - }) - } - - if arg != "--with" && repl == "" { - return fmt.Errorf("expected value after --replace flag") - } - if repl != "" { - // adjust relative replacements in current working directory since our temporary module is in a different directory - if strings.HasPrefix(repl, ".") { - repl, err = filepath.Abs(repl) - if err != nil { - log.Fatalf("[FATAL] %v", err) - } - log.Printf("[INFO] Resolved relative replacement %s to %s", args[i], repl) - } - replacements = append(replacements, xcaddy.NewReplace(xcaddy.Dependency{PackagePath: mod, Version: ver}.String(), repl)) - } - case "--output": - if i == len(args)-1 { - return fmt.Errorf("expected value after --output flag") - } - i++ - output = args[i] - case "--embed": - if i == len(args)-1 { - return fmt.Errorf("expected value after --embed flag") - } - i++ - embedDir = append(embedDir, args[i]) - default: - if argCaddyVersion != "" { - return fmt.Errorf("missing flag; caddy version already set at %s", argCaddyVersion) - } - argCaddyVersion = args[i] - } - } - - // prefer caddy version from command line argument over env var - if argCaddyVersion != "" { - caddyVersion = argCaddyVersion - } - - // ensure an output file is always specified - if output == "" { - output = getCaddyOutputFile() - } - - // perform the build - builder := xcaddy.Builder{ - Compile: xcaddy.Compile{ - Cgo: os.Getenv("CGO_ENABLED") == "1", - }, - CaddyVersion: caddyVersion, - Plugins: plugins, - Replacements: replacements, - RaceDetector: raceDetector, - SkipBuild: skipBuild, - SkipCleanup: skipCleanup, - Debug: buildDebugOutput, - BuildFlags: buildFlags, - ModFlags: modFlags, - } - for _, md := range embedDir { - if before, after, found := strings.Cut(md, ":"); found { - builder.EmbedDirs = append(builder.EmbedDirs, struct { - Dir string `json:"dir,omitempty"` - Name string `json:"name,omitempty"` - }{ - after, before, - }) - } else { - builder.EmbedDirs = append(builder.EmbedDirs, struct { - Dir string `json:"dir,omitempty"` - Name string `json:"name,omitempty"` - }{ - before, "", - }) - } - } - err := builder.Build(ctx, output) - if err != nil { - log.Fatalf("[FATAL] %v", err) - } - - // done if we're skipping the build - if builder.SkipBuild { - return nil - } - - // if requested, run setcap to allow binding to low ports - err = setcapIfRequested(output) - if err != nil { - return err - } - - // prove the build is working by printing the version - if runtime.GOOS == utils.GetGOOS() && runtime.GOARCH == utils.GetGOARCH() { - if !filepath.IsAbs(output) { - output = "." + string(filepath.Separator) + output - } - fmt.Println() - fmt.Printf("%s version\n", output) - cmd := exec.Command(output, "version") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - log.Fatalf("[FATAL] %v", err) - } - } - - return nil -} - func getCaddyOutputFile() string { f := "." + string(filepath.Separator) + "caddy" // compiling for Windows or compiling on windows without setting GOOS, use .exe extension @@ -212,88 +63,6 @@ func getCaddyOutputFile() string { return f } -func runDev(ctx context.Context, args []string) error { - binOutput := getCaddyOutputFile() - - // get current/main module name and the root directory of the main module - // - // make sure the module being developed is replaced - // so that the local copy is used - // - // replace directives only apply to the top-level/main go.mod, - // and since this tool is a carry-through for the user's actual - // go.mod, we need to transfer their replace directives through - // to the one we're making - cmd := exec.Command(utils.GetGo(), "list", "-mod=readonly", "-m", "-json", "all") - cmd.Stderr = os.Stderr - out, err := cmd.Output() - if err != nil { - return fmt.Errorf("exec %v: %v: %s", cmd.Args, err, string(out)) - } - currentModule, moduleDir, replacements, err := parseGoListJson(out) - if err != nil { - return fmt.Errorf("json parse error: %v", err) - } - - // reconcile remaining path segments; for example if a module foo/a - // is rooted at directory path /home/foo/a, but the current directory - // is /home/foo/a/b, then the package to import should be foo/a/b - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("unable to determine current directory: %v", err) - } - importPath := normalizeImportPath(currentModule, cwd, moduleDir) - - // build caddy with this module plugged in - builder := xcaddy.Builder{ - Compile: xcaddy.Compile{ - Cgo: os.Getenv("CGO_ENABLED") == "1", - }, - CaddyVersion: caddyVersion, - Plugins: []xcaddy.Dependency{ - {PackagePath: importPath}, - }, - Replacements: replacements, - RaceDetector: raceDetector, - SkipBuild: skipBuild, - SkipCleanup: skipCleanup, - Debug: buildDebugOutput, - } - err = builder.Build(ctx, binOutput) - if err != nil { - return err - } - - // if requested, run setcap to allow binding to low ports - err = setcapIfRequested(binOutput) - if err != nil { - return err - } - - log.Printf("[INFO] Running %v\n\n", append([]string{binOutput}, args...)) - - cmd = exec.Command(binOutput, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Start() - if err != nil { - return err - } - defer func() { - if skipCleanup { - log.Printf("[INFO] Skipping cleanup as requested; leaving artifact: %s", binOutput) - return - } - err = os.Remove(binOutput) - if err != nil && !os.IsNotExist(err) { - log.Printf("[ERROR] Deleting temporary binary %s: %v", binOutput, err) - } - }() - - return cmd.Wait() -} - func setcapIfRequested(output string) error { if os.Getenv("XCADDY_SETCAP") != "1" { return nil diff --git a/go.mod b/go.mod index 9925154..f03b4be 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,5 @@ require ( github.com/Masterminds/semver/v3 v3.2.1 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/josephspurrier/goversioninfo v1.4.0 + github.com/spf13/cobra v1.8.0 ) diff --git a/go.sum b/go.sum index a3d7699..a532f39 100644 --- a/go.sum +++ b/go.sum @@ -2,18 +2,26 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0 github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josephspurrier/goversioninfo v1.4.0 h1:Puhl12NSHUSALHSuzYwPYQkqa2E1+7SrtAPJorKK0C8= github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= 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/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -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= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=