Automatically register plugin bindings

If a plugin has no modules declared in the manifest, automatically
generate the modules for the plugin based on the class files that
appear in the plugin and the @Export annotations that appear on these
concrete classes.

For any non-abstract command that extends SshCommand (or really the
internal MINA SSHD Command interface that Gerrit uses), plugins may
declare the command with @Export("name") to bind the implementation
as that SSH command, e.g.:

  @Export("print-hello")
  class Hello extend SshCommand {

Likewise HTTP servlets can also be bound to URLs, but this only works
for standard servlet mappings like "/foo" or "/foo/*". Regex style
bindings must use the Guice ServletModule declared in the manifest:

  @Export("/print-hello")
  class Hello extends HttpServlet {

Change-Id: Iae4cffcd62d7d2911d3f2705e226fbe21434be68
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
new file mode 100644
index 0000000..2d957f2
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.plugins;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.annotations.Export;
+import com.google.gerrit.server.plugins.InvalidPluginException;
+import com.google.gerrit.server.plugins.ModuleGenerator;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.servlet.ServletModule;
+
+import java.util.Map;
+
+import javax.servlet.http.HttpServlet;
+
+class HttpAutoRegisterModuleGenerator extends ServletModule
+    implements ModuleGenerator {
+  private final Map<String, Class<HttpServlet>> serve = Maps.newHashMap();
+
+  @Override
+  protected void configureServlets() {
+    for (Map.Entry<String, Class<HttpServlet>> e : serve.entrySet()) {
+      bind(e.getValue()).in(Scopes.SINGLETON);
+      serve(e.getKey()).with(e.getValue());
+    }
+  }
+
+  @Override
+  public void setPluginName(String name) {
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public void export(Export export, Class<?> type)
+      throws InvalidPluginException {
+    if (HttpServlet.class.isAssignableFrom(type)) {
+      Class<HttpServlet> old = serve.get(export.value());
+      if (old != null) {
+        throw new InvalidPluginException(String.format(
+            "@Export(\"%s\") has duplicate bindings:\n  %s\n  %s",
+            export.value(), old.getName(), type.getName()));
+      }
+      serve.put(export.value(), (Class<HttpServlet>) type);
+    } else {
+      throw new InvalidPluginException(String.format(
+          "Class %s with @Export(\"%s\") must extend %s",
+          type.getName(), export.value(),
+          HttpServlet.class.getName()));
+    }
+  }
+
+  @Override
+  public Module create() throws InvalidPluginException {
+    return this;
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
index 0ad90c2..2e5001b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd.plugins;
 
+import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
 import com.google.inject.internal.UniqueAnnotations;
@@ -32,5 +33,8 @@
     bind(ReloadPluginListener.class)
       .annotatedWith(UniqueAnnotations.create())
       .to(HttpPluginServlet.class);
+
+    bind(ModuleGenerator.class)
+      .to(HttpAutoRegisterModuleGenerator.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
new file mode 100644
index 0000000..5c8885b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
@@ -0,0 +1,254 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import static com.google.gerrit.server.plugins.PluginGuiceEnvironment.is;
+
+import com.google.gerrit.extensions.annotations.Export;
+import com.google.inject.Module;
+
+import org.eclipse.jgit.util.IO;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Attribute;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Enumeration;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+class AutoRegisterModules {
+  private static final int SKIP_ALL = ClassReader.SKIP_CODE
+      | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
+  private final String pluginName;
+  private final JarFile jarFile;
+  private final ClassLoader classLoader;
+  private final ModuleGenerator sshGen;
+  private final ModuleGenerator httpGen;
+
+  Module sysModule;
+  Module sshModule;
+  Module httpModule;
+
+  AutoRegisterModules(String pluginName,
+      PluginGuiceEnvironment env,
+      JarFile jarFile,
+      ClassLoader classLoader) {
+    this.pluginName = pluginName;
+    this.jarFile = jarFile;
+    this.classLoader = classLoader;
+    this.sshGen = env.hasSshModule() ? env.newSshModuleGenerator() : null;
+    this.httpGen = env.hasHttpModule() ? env.newHttpModuleGenerator() : null;
+  }
+
+  AutoRegisterModules discover() throws InvalidPluginException {
+    if (sshGen != null) {
+      sshGen.setPluginName(pluginName);
+    }
+    if (httpGen != null) {
+      httpGen.setPluginName(pluginName);
+    }
+
+    scan();
+
+    if (sshGen != null) {
+      sshModule = sshGen.create();
+    }
+    if (httpGen != null) {
+      httpModule = httpGen.create();
+    }
+    return this;
+  }
+
+  private void scan() throws InvalidPluginException {
+    Enumeration<JarEntry> e = jarFile.entries();
+    while (e.hasMoreElements()) {
+      JarEntry entry = e.nextElement();
+      if (skip(entry)) {
+        continue;
+      }
+
+      ClassData def = new ClassData();
+      try {
+        new ClassReader(read(entry)).accept(def, SKIP_ALL);
+      } catch (IOException err) {
+        throw new InvalidPluginException("Cannot auto-register", err);
+      } catch (RuntimeException err) {
+        PluginLoader.log.warn(String.format(
+            "Plugin %s has invaild class file %s inside of %s",
+            pluginName, entry.getName(), jarFile.getName()), err);
+        continue;
+      }
+
+      if (def.exportedAsName != null) {
+        if (def.isConcrete()) {
+          export(def);
+        } else {
+          PluginLoader.log.warn(String.format(
+              "Plugin %s tries to export abstract class %s",
+              pluginName, def.className));
+        }
+      }
+    }
+  }
+
+  private void export(ClassData def) throws InvalidPluginException {
+    Class<?> clazz;
+    try {
+      clazz = Class.forName(def.className, false, classLoader);
+    } catch (ClassNotFoundException err) {
+      throw new InvalidPluginException(String.format(
+          "Cannot load %s with @Export(\"%s\")",
+          def.className, def.exportedAsName), err);
+    }
+
+    Export export = clazz.getAnnotation(Export.class);
+    if (export == null) {
+      PluginLoader.log.warn(String.format(
+          "In plugin %s asm incorrectly parsed %s with @Export(\"%s\")",
+          pluginName, clazz.getName(), def.exportedAsName));
+      return;
+    }
+
+    if (is("org.apache.sshd.server.Command", clazz)) {
+      if (sshGen != null) {
+        sshGen.export(export, clazz);
+      }
+    } else if (is("javax.servlet.http.HttpServlet", clazz)) {
+      if (httpGen != null) {
+        httpGen.export(export, clazz);
+      }
+    } else {
+      throw new InvalidPluginException(String.format(
+          "Class %s with @Export(\"%s\") not supported",
+          clazz.getName(), export.value()));
+    }
+  }
+
+  private static boolean skip(JarEntry entry) {
+    if (!entry.getName().endsWith(".class")) {
+      return true; // Avoid non-class resources.
+    }
+    if (entry.getSize() <= 0) {
+      return true; // Directories have 0 size.
+    }
+    if (entry.getSize() >= 1024 * 1024) {
+      return true; // Do not scan huge class files.
+    }
+    return false;
+  }
+
+  private byte[] read(JarEntry entry) throws IOException {
+    byte[] data = new byte[(int) entry.getSize()];
+    InputStream in = jarFile.getInputStream(entry);
+    try {
+      IO.readFully(in, data, 0, data.length);
+    } finally {
+      in.close();
+    }
+    return data;
+  }
+
+  private static class ClassData implements ClassVisitor {
+    private static final String EXPORT = Type.getType(Export.class).getDescriptor();
+    String className;
+    int access;
+    String exportedAsName;
+
+    boolean isConcrete() {
+      return (access & Opcodes.ACC_ABSTRACT) == 0
+          && (access & Opcodes.ACC_INTERFACE) == 0;
+    }
+
+    @Override
+    public void visit(int version, int access, String name, String signature,
+        String superName, String[] interfaces) {
+      this.className = Type.getObjectType(name).getClassName();
+      this.access = access;
+    }
+
+    @Override
+    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
+      if (visible && EXPORT.equals(desc)) {
+        return new AbstractAnnotationVisitor() {
+          @Override
+          public void visit(String name, Object value) {
+            exportedAsName = (String) value;
+          }
+        };
+      }
+      return null;
+    }
+
+    @Override
+    public void visitSource(String arg0, String arg1) {
+    }
+
+    @Override
+    public void visitOuterClass(String arg0, String arg1, String arg2) {
+    }
+
+    @Override
+    public MethodVisitor visitMethod(int arg0, String arg1, String arg2,
+        String arg3, String[] arg4) {
+      return null;
+    }
+
+    @Override
+    public void visitInnerClass(String arg0, String arg1, String arg2, int arg3) {
+    }
+
+    @Override
+    public FieldVisitor visitField(int arg0, String arg1, String arg2,
+        String arg3, Object arg4) {
+      return null;
+    }
+
+    @Override
+    public void visitEnd() {
+    }
+
+    @Override
+    public void visitAttribute(Attribute arg0) {
+    }
+  }
+
+  private static abstract class AbstractAnnotationVisitor implements
+      AnnotationVisitor {
+    @Override
+    public AnnotationVisitor visitAnnotation(String arg0, String arg1) {
+      return null;
+    }
+
+    @Override
+    public AnnotationVisitor visitArray(String arg0) {
+      return null;
+    }
+
+    @Override
+    public void visitEnum(String arg0, String arg1, String arg2) {
+    }
+
+    @Override
+    public void visitEnd() {
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InvalidPluginException.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InvalidPluginException.java
new file mode 100644
index 0000000..31be10c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InvalidPluginException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+public class InvalidPluginException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public InvalidPluginException(String message) {
+    super(message);
+  }
+
+  public InvalidPluginException(String message, Throwable why) {
+    super(message, why);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java
new file mode 100644
index 0000000..92e3b1d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ModuleGenerator.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.gerrit.extensions.annotations.Export;
+import com.google.inject.Module;
+
+public interface ModuleGenerator {
+  void setPluginName(String name);
+
+  void export(Export export, Class<?> type) throws InvalidPluginException;
+
+  Module create() throws InvalidPluginException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
index b4bee88..a97eb1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
@@ -23,7 +23,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Module;
-import com.google.inject.servlet.GuiceFilter;
 
 import org.eclipse.jgit.storage.file.FileSnapshot;
 
@@ -38,7 +37,7 @@
   static {
     // Guice logs warnings about multiple injectors being created.
     // Silence this in case HTTP plugins are used.
-    java.util.logging.Logger.getLogger(GuiceFilter.class.getName())
+    java.util.logging.Logger.getLogger("com.google.inject.servlet.GuiceFilter")
         .setLevel(java.util.logging.Level.OFF);
   }
 
@@ -47,6 +46,7 @@
   private final FileSnapshot snapshot;
   private final JarFile jarFile;
   private final Manifest manifest;
+  private final ClassLoader classLoader;
   private Class<? extends Module> sysModule;
   private Class<? extends Module> sshModule;
   private Class<? extends Module> httpModule;
@@ -61,6 +61,7 @@
       FileSnapshot snapshot,
       JarFile jarFile,
       Manifest manifest,
+      ClassLoader classLoader,
       @Nullable Class<? extends Module> sysModule,
       @Nullable Class<? extends Module> sshModule,
       @Nullable Class<? extends Module> httpModule) {
@@ -69,6 +70,7 @@
     this.snapshot = snapshot;
     this.jarFile = jarFile;
     this.manifest = manifest;
+    this.classLoader = classLoader;
     this.sysModule = sysModule;
     this.sshModule = sshModule;
     this.httpModule = httpModule;
@@ -110,25 +112,48 @@
     Injector root = newRootInjector(env);
     manager = new LifecycleManager();
 
+    AutoRegisterModules auto = null;
+    if (sysModule == null && sshModule == null && httpModule == null) {
+      auto = new AutoRegisterModules(name, env, jarFile, classLoader);
+      auto.discover();
+    }
+
     if (sysModule != null) {
       sysInjector = root.createChildInjector(root.getInstance(sysModule));
       manager.add(sysInjector);
+    } else if (auto != null && auto.sysModule != null) {
+      sysInjector = root.createChildInjector(auto.sysModule);
+      manager.add(sysInjector);
     } else {
       sysInjector = root;
     }
 
-    if (sshModule != null && env.hasSshModule()) {
-      sshInjector = sysInjector.createChildInjector(
-          env.getSshModule(),
-          sysInjector.getInstance(sshModule));
-      manager.add(sshInjector);
+    if (env.hasSshModule()) {
+      if (sshModule != null) {
+        sshInjector = sysInjector.createChildInjector(
+            env.getSshModule(),
+            sysInjector.getInstance(sshModule));
+        manager.add(sshInjector);
+      } else if (auto != null && auto.sshModule != null) {
+        sshInjector = sysInjector.createChildInjector(
+            env.getSshModule(),
+            auto.sshModule);
+        manager.add(sshInjector);
+      }
     }
 
-    if (httpModule != null && env.hasHttpModule()) {
-      httpInjector = sysInjector.createChildInjector(
-          env.getHttpModule(),
-          sysInjector.getInstance(httpModule));
-      manager.add(httpInjector);
+    if (env.hasHttpModule()) {
+      if (httpModule != null) {
+        httpInjector = sysInjector.createChildInjector(
+            env.getHttpModule(),
+            sysInjector.getInstance(httpModule));
+        manager.add(httpInjector);
+      } else if (auto != null && auto.httpModule != null) {
+        httpInjector = sysInjector.createChildInjector(
+            env.getHttpModule(),
+            auto.httpModule);
+        manager.add(httpInjector);
+      }
     }
 
     manager.start();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 0e8a95d..d259cba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -22,6 +22,7 @@
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 
@@ -48,6 +49,9 @@
   private Module sshModule;
   private Module httpModule;
 
+  private Provider<ModuleGenerator> sshGen;
+  private Provider<ModuleGenerator> httpGen;
+
   @Inject
   PluginGuiceEnvironment(Injector sysInjector, CopyConfigModule ccm) {
     this.sysInjector = sysInjector;
@@ -79,6 +83,7 @@
 
   public void setSshInjector(Injector injector) {
     sshModule = copy(injector);
+    sshGen = injector.getProvider(ModuleGenerator.class);
     onStart.addAll(listeners(injector, StartPluginListener.class));
     onReload.addAll(listeners(injector, ReloadPluginListener.class));
   }
@@ -91,8 +96,13 @@
     return sshModule;
   }
 
+  ModuleGenerator newSshModuleGenerator() {
+    return sshGen.get();
+  }
+
   public void setHttpInjector(Injector injector) {
     httpModule = copy(injector);
+    httpGen = injector.getProvider(ModuleGenerator.class);
     onStart.addAll(listeners(injector, StartPluginListener.class));
     onReload.addAll(listeners(injector, ReloadPluginListener.class));
   }
@@ -105,6 +115,10 @@
     return httpModule;
   }
 
+  ModuleGenerator newHttpModuleGenerator() {
+    return httpGen.get();
+  }
+
   void onStartPlugin(Plugin plugin) {
     for (StartPluginListener l : onStart) {
       l.onStartPlugin(plugin);
@@ -202,22 +216,22 @@
     return true;
   }
 
-  private static boolean is(String name, Class<?> type) {
-    Class<?> p = type;
-    while (p != null) {
-      if (name.equals(p.getName())) {
+  static boolean is(String name, Class<?> type) {
+    while (type != null) {
+      if (name.equals(type.getName())) {
         return true;
       }
-      p = p.getSuperclass();
-    }
 
-    Class<?>[] interfaces = type.getInterfaces();
-    if (interfaces != null) {
-      for (Class<?> i : interfaces) {
-        if (is(name, i)) {
-          return true;
+      Class<?>[] interfaces = type.getInterfaces();
+      if (interfaces != null) {
+        for (Class<?> i : interfaces) {
+          if (is(name, i)) {
+            return true;
+          }
         }
       }
+
+      type = type.getSuperclass();
     }
     return false;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index 330dc46..16cd78c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -337,6 +337,7 @@
       return new Plugin(name,
           srcJar, snapshot,
           jarFile, manifest,
+          pluginLoader,
           sysModule, sshModule, httpModule);
     } finally {
       if (!keep) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
new file mode 100644
index 0000000..b843893
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.annotations.Export;
+import com.google.gerrit.server.plugins.InvalidPluginException;
+import com.google.gerrit.server.plugins.ModuleGenerator;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+
+import org.apache.sshd.server.Command;
+
+import java.util.Map;
+
+class SshAutoRegisterModuleGenerator
+    extends AbstractModule
+    implements ModuleGenerator {
+  private final Map<String, Class<Command>> commands = Maps.newHashMap();
+  private CommandName command;
+
+  @Override
+  protected void configure() {
+    bind(Commands.key(command))
+        .toProvider(new DispatchCommandProvider(command));
+    for (Map.Entry<String, Class<Command>> e : commands.entrySet()) {
+      bind(Commands.key(command, e.getKey())).to(e.getValue());
+    }
+  }
+
+  public void setPluginName(String name) {
+    command = Commands.named(name);
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public void export(Export export, Class<?> type)
+      throws InvalidPluginException {
+    Preconditions.checkState(command != null, "pluginName must be provided");
+    if (Command.class.isAssignableFrom(type)) {
+      Class<Command> old = commands.get(export.value());
+      if (old != null) {
+        throw new InvalidPluginException(String.format(
+            "@Export(\"%s\") has duplicate bindings:\n  %s\n  %s",
+            export.value(), old.getName(), type.getName()));
+      }
+      commands.put(export.value(), (Class<Command>) type);
+    } else {
+      throw new InvalidPluginException(String.format(
+          "Class %s with @Export(\"%s\") must extend %s or implement %s",
+          type.getName(), export.value(),
+          SshCommand.class.getName(), Command.class.getName()));
+    }
+  }
+
+  @Override
+  public Module create() throws InvalidPluginException {
+    Preconditions.checkState(command != null, "pluginName must be provided");
+    return this;
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
index 284f231..a814111 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.git.QueueProvider;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
 import com.google.gerrit.server.project.ProjectControl;
@@ -92,6 +93,7 @@
     install(new LifecycleModule() {
       @Override
       protected void configure() {
+        bind(ModuleGenerator.class).to(SshAutoRegisterModuleGenerator.class);
         bind(SshPluginStarterCallback.class);
         bind(StartPluginListener.class)
           .annotatedWith(UniqueAnnotations.create())