Repository: goadesign/goa (go)

Issue:
Allow specifying protoc command
**Goal:** To reduce onboarding and setup I want to be able to run `go generate ./...` in a newly cloned project and have that work without any other dependencies other than the correct Go version. I also want to keep versions pinned (using go.mod if possible). For goa I can accomplish that by including a go generate comment like the following. This also ensures my project's goa matches the code generator when maintaining many goa-based projects. 

```go
//go:generate go run goa.design/goa/v3/cmd/goa gen ...
```

**Problem**: If goa needs to launch `protoc` to generate gRPC, the developer must have `protoc` installed and the two plugins that goa uses must be in protoc's path. I've been able to get around this by creating a wrapper script that can download the correct version of `protoc` from GitHub into a cache directory and `go install` the plugins into that same directory, then execute goa with its `PATH` prefixed with that directory. Special care must be taken to ensure this works across Windows/Mac/Linux.

**Solution**: Add a new flag to specify the command to run for protoc. I created https://github.com/duckbrain/goprotoc This is a `go run`able drop-in replacement for `protoc` that will also use `go run` for plugins, You could use  `-protoc "go generate github.com/duckbrain/goprotoc"` (and [soon](https://tip.golang.org/doc/modules/managing-dependencies#tools) `"go tool goprotoc"`) to get a zero dependency go generate with goa.

Pull request description:
Allow setting protoc command via Meta
Fixes #3623

Adds a meta value `protoc:cmd` to override the command (and prefix args).

If the meta value is provided for both the service and the API, the API will be used an API-wide default and the Service definition will be used as a Service-level override. This seems to make more sense than appending them together like we do with includes.

I wasn't sure if it made more sense to have `Meta("protoc:cmd")` (no values) do nothing or restore the default `protoc` if used as an override in the Service definition, so I made it error. Also considered not allowing it on the service definition to avoid the situation. 

---

Tested this with a private project as well.

Patch:
diff --git a/dsl/meta.go b/dsl/meta.go
index 60ffb196..b9451006 100644
--- a/dsl/meta.go
+++ b/dsl/meta.go
@@ -5,6 +5,12 @@ import (
 	"goa.design/goa/v3/expr"
 )
 
+// DefaultProtoc is the default command to be invoked for generating code from protobuf schemas.
+// You may use this to prepend arguments/flags to the command or revert to the default command.
+//
+// See also [Meta]; this is useful with the "protoc:cmd" key.
+const DefaultProtoc = expr.DefaultProtoc
+
 // Meta defines a set of key/value pairs that can be assigned to an object. Each
 // value consists of a slice of strings so that multiple invocation of the Meta
 // function on the same target using the same key builds up the slice.
@@ -124,6 +130,32 @@ import (
 //	    })
 //	})
 //
+// - "protoc:cmd" provides an alternate command to execute for protoc with
+// optional arguments. Applicable to API and service definitions only. If used
+// on an API definition the include paths are used for all services, unless
+// specified otherwise for specific services. The first value will be used as
+// the command, and the following values will be used as initial arguments to
+// that command. The given command will have additional arguments appended and
+// is expected to behave similar to protoc.
+//
+// Can be used to specify custom options or alternate implementations. The
+// default command can be specified using DefaultProtoc.
+//
+//	// Use Go run to run a drop-in replacement for protoc.
+//	var _ = API("myapi", func() {
+//	    Meta("protoc:cmd", "go", "run", "github.com/duckbrain/goprotoc")
+//	})
+//
+//	// Specify the full path to protoc and turn on fatal warnings.
+//	var _ = Service("service1", func() {
+//	    Meta("protoc:cmd", "/usr/bin/protoc", "--fatal_warnings")
+//	})
+//
+//	// Restore defaults for a specific service.
+//	var _ = Service("service2", func() {
+//	    Meta("protoc:cmd", DefaultProtoc)
+//	})
+//
 // - "protoc:include" provides the list of import paths used to invoke protoc.
 // Applicable to API and service definitions only. If used on an API definition
 // the include paths are used for all services.
diff --git a/expr/root.go b/expr/root.go
index fb215c40..94b34d88 100644
--- a/expr/root.go
+++ b/expr/root.go
@@ -11,6 +11,9 @@ import (
 // Root is the root object built by the DSL.
 var Root = new(RootExpr)
 
+// DefaultProtoc is the default command to be invoked for generating code from protobuf schemas.
+const DefaultProtoc = "protoc"
+
 type (
 	// RootExpr is the struct built by the DSL on process start.
 	RootExpr struct {
diff --git a/grpc/codegen/proto.go b/grpc/codegen/proto.go
index 1a1f6bf8..09598be2 100644
--- a/grpc/codegen/proto.go
+++ b/grpc/codegen/proto.go
@@ -83,7 +83,19 @@ func protoFile(genpkg string, svc *expr.GRPCServiceExpr) *codegen.File {
 	runProtoc := func(path string) error {
 		includes := svc.ServiceExpr.Meta["protoc:include"]
 		includes = append(includes, expr.Root.API.Meta["protoc:include"]...)
-		return protoc(path, includes)
+
+		cmd := defaultProtocCmd
+		if c, ok := expr.Root.API.Meta["protoc:cmd"]; ok {
+			cmd = c
+		}
+		if c, ok := svc.ServiceExpr.Meta["protoc:cmd"]; ok {
+			cmd = c
+		}
+		if len(cmd) == 0 {
+			return fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`)
+		}
+
+		return protoc(cmd, path, includes)
 	}
 
 	return &codegen.File{
@@ -100,7 +112,9 @@ func pkgName(svc *expr.GRPCServiceExpr, svcName string) string {
 	return codegen.SnakeCase(svcName)
 }
 
-func protoc(path string, includes []string) error {
+var defaultProtocCmd = []string{expr.DefaultProtoc}
+
+func protoc(protocCmd []string, path string, includes []string) error {
 	dir := filepath.Dir(path)
 	if err := os.MkdirAll(dir, 0750); err != nil {
 		return err
@@ -117,7 +131,7 @@ func protoc(path string, includes []string) error {
 	for _, include := range includes {
 		args = append(args, "-I", include)
 	}
-	cmd := exec.Command("protoc", args...)
+	cmd := exec.Command(protocCmd[0], append(protocCmd[1:len(protocCmd):len(protocCmd)], args...)...)
 	cmd.Dir = filepath.Dir(path)
 
 	if output, err := cmd.CombinedOutput(); err != nil {


Test patch:
diff --git a/grpc/codegen/proto_test.go b/grpc/codegen/proto_test.go
index d3441e41..1d05bb98 100644
--- a/grpc/codegen/proto_test.go
+++ b/grpc/codegen/proto_test.go
@@ -1,6 +1,9 @@
 package codegen
 
 import (
+	"os"
+	"os/exec"
+	"path/filepath"
 	"runtime"
 	"strings"
 	"testing"
@@ -49,7 +52,7 @@ func TestProtoFiles(t *testing.T) {
 			}
 			assert.Equal(t, c.Code, code)
 			fpath := codegen.CreateTempFile(t, code)
-			assert.NoError(t, protoc(fpath, nil), "error occurred when compiling proto file %q", fpath)
+			assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath)
 		})
 	}
 }
@@ -85,7 +88,51 @@ func TestMessageDefSection(t *testing.T) {
 			}
 			assert.Equal(t, c.Code, msgCode)
 			fpath := codegen.CreateTempFile(t, code+msgCode)
-			assert.NoError(t, protoc(fpath, nil), "error occurred when compiling proto file %q", fpath)
+			assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath)
+		})
+	}
+}
+
+func TestProtoc(t *testing.T) {
+	const code = testdata.UnaryRPCsProtoCode
+
+	fakeBin := filepath.Join(os.TempDir(), t.Name()+"-fakeprotoc")
+	if runtime.GOOS == "windows" {
+		fakeBin += ".exe"
+	}
+	out, err := exec.Command("go", "build", "-o", fakeBin, "./testdata/protoc").CombinedOutput()
+	t.Log("go build output: ", string(out))
+	require.NoError(t, err, "compile a fake protoc that requires a prefix")
+	t.Cleanup(func() { assert.NoError(t, os.Remove(fakeBin)) })
+
+	cases := []struct {
+		Name string
+		Cmd  []string
+	}{
+		{"protoc", defaultProtocCmd},
+		{"fakepc", []string{fakeBin, "required-ignored-arg"}},
+	}
+
+	var firstOutput string
+
+	for _, c := range cases {
+		t.Run(c.Name, func(t *testing.T) {
+			dir, err := os.MkdirTemp("", strings.ReplaceAll(t.Name(), "/", "-"))
+			require.NoError(t, err)
+			t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) })
+			fpath := filepath.Join(dir, "schema")
+			require.NoError(t, os.WriteFile(fpath, []byte(code), 0o600), "error occured writing proto schema")
+			require.NoError(t, protoc(c.Cmd, fpath, nil), "error occurred when compiling proto file with the standard protoc %q", fpath)
+
+			fcontents, err := os.ReadFile(fpath + ".pb.go")
+			require.NoError(t, err)
+
+			if firstOutput == "" {
+				firstOutput = string(fcontents)
+				return
+			}
+
+			assert.Equal(t, firstOutput, string(fcontents))
 		})
 	}
 }
diff --git a/grpc/codegen/testdata/protoc/main.go b/grpc/codegen/testdata/protoc/main.go
new file mode 100644
index 00000000..e7c0a784
--- /dev/null
+++ b/grpc/codegen/testdata/protoc/main.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		fmt.Println("must pass a prefix arg to be ignored, to test it being passed")
+		os.Exit(1)
+	}
+	cmd := exec.Command("protoc", os.Args[2:]...)
+	fmt.Println(cmd)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Run()
+	os.Exit(cmd.ProcessState.ExitCode())
+}