From e5d0bacbf746e09a9194822b231cb27090f58973 Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Thu, 10 Apr 2014 19:00:52 -0400
Subject: [PATCH] Implement simple JSON-based plugin registry and install command

---
 src/main/java/com/gitblit/manager/IPluginManager.java                  |   48 ++++
 src/main/java/com/gitblit/models/PluginRegistry.java                   |  143 +++++++++++++
 src/main/java/com/gitblit/manager/PluginManager.java                   |  250 ++++++++++++++++++++++
 src/main/java/com/gitblit/manager/GitblitManager.java                  |   36 +++
 releases.moxie                                                         |    1 
 src/main/distrib/data/gitblit.properties                               |   20 +
 src/main/java/com/gitblit/transport/ssh/commands/PluginDispatcher.java |  120 +++++++++-
 7 files changed, 588 insertions(+), 30 deletions(-)

diff --git a/releases.moxie b/releases.moxie
index 0f37bf2..89a7a5f 100644
--- a/releases.moxie
+++ b/releases.moxie
@@ -48,6 +48,7 @@
     - { name: 'git.sshBackend', defaultValue: 'NIO2' }
     - { name: 'git.sshCommandStartThreads', defaultValue: '2' }
     - { name: 'plugins.folder', defaultValue: '${baseFolder}/plugins' }
+    - { name: 'plugins.registry', defaultValue: 'http://gitblit.github.io/gitblit-registry/plugins.json' }
 }
 
 #
diff --git a/src/main/distrib/data/gitblit.properties b/src/main/distrib/data/gitblit.properties
index 1a613e2..c52423b 100644
--- a/src/main/distrib/data/gitblit.properties
+++ b/src/main/distrib/data/gitblit.properties
@@ -548,6 +548,18 @@
 # SINCE 1.4.0
 tickets.perPage = 25
 
+# The folder where plugins are loaded from.
+#
+# SINCE 1.5.0
+# RESTART REQUIRED
+# BASEFOLDER
+plugins.folder = ${baseFolder}/plugins
+
+# The registry of available plugins.
+#
+# SINCE 1.5.0
+plugins.registry = http://gitblit.github.io/gitblit-registry/plugins.json
+
 #
 # Groovy Integration
 #
@@ -1850,11 +1862,3 @@
 # SINCE 0.5.0
 # RESTART REQUIRED
 server.shutdownPort = 8081
-
-# Base folder for plugins.
-# This folder may contain Gitblit plugins
-#
-# SINCE 1.6.0
-# RESTART REQUIRED
-# BASEFOLDER
-plugins.folder = ${baseFolder}/plugins
diff --git a/src/main/java/com/gitblit/manager/GitblitManager.java b/src/main/java/com/gitblit/manager/GitblitManager.java
index 6b1cc8a..5a7d15a 100644
--- a/src/main/java/com/gitblit/manager/GitblitManager.java
+++ b/src/main/java/com/gitblit/manager/GitblitManager.java
@@ -61,6 +61,8 @@
 import com.gitblit.models.GitClientApplication;
 import com.gitblit.models.Mailing;
 import com.gitblit.models.Metric;
+import com.gitblit.models.PluginRegistry.PluginRegistration;
+import com.gitblit.models.PluginRegistry.PluginRelease;
 import com.gitblit.models.ProjectModel;
 import com.gitblit.models.RegistrantAccessPermission;
 import com.gitblit.models.RepositoryModel;
@@ -1180,6 +1182,10 @@
 		return repositoryManager.isIdle(repository);
 	}
 
+	/*
+	 * PLUGIN MANAGER
+	 */
+
 	@Override
 	public <T> List<T> getExtensions(Class<T> clazz) {
 		return pluginManager.getExtensions(clazz);
@@ -1196,6 +1202,36 @@
 	}
 
 	@Override
+	public boolean refreshRegistry() {
+		return pluginManager.refreshRegistry();
+	}
+
+	@Override
+	public boolean installPlugin(String url) {
+		return pluginManager.installPlugin(url);
+	}
+
+	@Override
+	public boolean installPlugin(PluginRelease pv) {
+		return pluginManager.installPlugin(pv);
+	}
+
+	@Override
+	public List<PluginRegistration> getRegisteredPlugins() {
+		return pluginManager.getRegisteredPlugins();
+	}
+
+	@Override
+	public PluginRegistration lookupPlugin(String idOrName) {
+		return pluginManager.lookupPlugin(idOrName);
+	}
+
+	@Override
+	public PluginRelease lookupRelease(String idOrName, String version) {
+		return pluginManager.lookupRelease(idOrName, version);
+	}
+
+	@Override
 	public List<PluginWrapper> getPlugins() {
 		return pluginManager.getPlugins();
 	}
diff --git a/src/main/java/com/gitblit/manager/IPluginManager.java b/src/main/java/com/gitblit/manager/IPluginManager.java
index 11b81ea..1f7f85e 100644
--- a/src/main/java/com/gitblit/manager/IPluginManager.java
+++ b/src/main/java/com/gitblit/manager/IPluginManager.java
@@ -15,8 +15,13 @@
  */
 package com.gitblit.manager;
 
+import java.util.List;
+
 import ro.fortsoft.pf4j.PluginManager;
 import ro.fortsoft.pf4j.PluginWrapper;
+
+import com.gitblit.models.PluginRegistry.PluginRegistration;
+import com.gitblit.models.PluginRegistry.PluginRelease;
 
 public interface IPluginManager extends IManager, PluginManager {
 
@@ -27,12 +32,51 @@
      * @return PluginWrapper that loaded the given class
      */
     PluginWrapper whichPlugin(Class<?> clazz);
-    
+
     /**
      * Delete the plugin represented by {@link PluginWrapper}.
-     * 
+     *
      * @param wrapper
      * @return true if successful
      */
     boolean deletePlugin(PluginWrapper wrapper);
+
+    /**
+     * Refresh the plugin registry.
+     */
+    boolean refreshRegistry();
+
+    /**
+     * Install the plugin from the specified url.
+     */
+    boolean installPlugin(String url);
+
+    /**
+     * Install the plugin.
+     */
+    boolean installPlugin(PluginRelease pr);
+
+    /**
+     * The list of all registered plugins.
+     *
+     * @return a list of registered plugins
+     */
+    List<PluginRegistration> getRegisteredPlugins();
+
+    /**
+     * Lookup a plugin registration from the plugin registries.
+     *
+     * @param idOrName
+     * @return a plugin registration or null
+     */
+    PluginRegistration lookupPlugin(String idOrName);
+
+    /**
+     * Lookup a plugin release.
+     *
+     * @param idOrName
+     * @param version (use null for the current version)
+     * @return the identified plugin version or null
+     */
+    PluginRelease lookupRelease(String idOrName, String version);
 }
diff --git a/src/main/java/com/gitblit/manager/PluginManager.java b/src/main/java/com/gitblit/manager/PluginManager.java
index e23aaec..7b03f50 100644
--- a/src/main/java/com/gitblit/manager/PluginManager.java
+++ b/src/main/java/com/gitblit/manager/PluginManager.java
@@ -15,30 +15,56 @@
  */
 package com.gitblit.manager;
 
+import java.io.BufferedInputStream;
 import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.Proxy;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import ro.fortsoft.pf4j.DefaultPluginManager;
+import ro.fortsoft.pf4j.PluginVersion;
 import ro.fortsoft.pf4j.PluginWrapper;
 
 import com.gitblit.Keys;
+import com.gitblit.models.PluginRegistry;
+import com.gitblit.models.PluginRegistry.PluginRegistration;
+import com.gitblit.models.PluginRegistry.PluginRelease;
+import com.gitblit.utils.Base64;
 import com.gitblit.utils.FileUtils;
+import com.gitblit.utils.JsonUtils;
+import com.gitblit.utils.StringUtils;
+import com.google.common.io.Files;
+import com.google.common.io.InputSupplier;
 
 /**
  * The plugin manager maintains the lifecycle of plugins. It is exposed as
  * Dagger bean. The extension consumers supposed to retrieve plugin  manager
  * from the Dagger DI and retrieve extensions provided by active plugins.
- * 
+ *
  * @author David Ostrovsky
- * 
+ *
  */
 public class PluginManager extends DefaultPluginManager implements IPluginManager {
 
 	private final Logger logger = LoggerFactory.getLogger(getClass());
-	
+
 	private final IRuntimeManager runtimeManager;
+
+	// timeout defaults of Maven 3.0.4 in seconds
+	private int connectTimeout = 20;
+
+	private int readTimeout = 12800;
 
 	public PluginManager(IRuntimeManager runtimeManager) {
 		super(runtimeManager.getFileOrFolder(Keys.plugins.folder, "${baseFolder}/plugins"));
@@ -60,13 +86,13 @@
 		stopPlugins();
 		return null;
 	}
-	
+
 	@Override
 	public boolean deletePlugin(PluginWrapper pw) {
 		File folder = runtimeManager.getFileOrFolder(Keys.plugins.folder, "${baseFolder}/plugins");
 		File pluginFolder = new File(folder, pw.getPluginPath());
 		File pluginZip = new File(folder, pw.getPluginPath() + ".zip");
-		
+
 		if (pluginFolder.exists()) {
 			FileUtils.delete(pluginFolder);
 		}
@@ -75,4 +101,218 @@
 		}
 		return true;
 	}
+
+	@Override
+	public boolean refreshRegistry() {
+		String dr = "http://gitblit.github.io/gitblit-registry/plugins.json";
+		String url = runtimeManager.getSettings().getString(Keys.plugins.registry, dr);
+		try {
+			return download(url);
+		} catch (Exception e) {
+			logger.error(String.format("Failed to retrieve plugins.json from %s", url), e);
+		}
+		return false;
+	}
+
+	protected List<PluginRegistry> getRegistries() {
+		List<PluginRegistry> list = new ArrayList<PluginRegistry>();
+		File folder = runtimeManager.getFileOrFolder(Keys.plugins.folder, "${baseFolder}/plugins");
+		FileFilter jsonFilter = new FileFilter() {
+			@Override
+			public boolean accept(File file) {
+				return !file.isDirectory() && file.getName().toLowerCase().endsWith(".json");
+			}
+		};
+
+		File [] files = folder.listFiles(jsonFilter);
+		if (files == null || files.length == 0) {
+			// automatically retrieve the registry if we don't have a local copy
+			refreshRegistry();
+			files = folder.listFiles(jsonFilter);
+		}
+
+		if (files == null || files.length == 0) {
+			return list;
+		}
+
+		for (File file : files) {
+			PluginRegistry registry = null;
+			try {
+				String json = FileUtils.readContent(file, "\n");
+				registry = JsonUtils.fromJsonString(json, PluginRegistry.class);
+			} catch (Exception e) {
+				logger.error("Failed to deserialize " + file, e);
+			}
+			if (registry != null) {
+				list.add(registry);
+			}
+		}
+		return list;
+	}
+
+	@Override
+	public List<PluginRegistration> getRegisteredPlugins() {
+		List<PluginRegistration> list = new ArrayList<PluginRegistration>();
+		Map<String, PluginRegistration> map = new TreeMap<String, PluginRegistration>();
+		for (PluginRegistry registry : getRegistries()) {
+			List<PluginRegistration> registrations = registry.registrations;
+			list.addAll(registrations);
+			for (PluginRegistration reg : registrations) {
+				reg.installedRelease = null;
+				map.put(reg.id, reg);
+			}
+		}
+		for (PluginWrapper pw : getPlugins()) {
+			String id = pw.getDescriptor().getPluginId();
+			PluginVersion pv = pw.getDescriptor().getVersion();
+			PluginRegistration reg = map.get(id);
+			if (reg != null) {
+				reg.installedRelease = pv.toString();
+			}
+		}
+		return list;
+	}
+
+	@Override
+	public PluginRegistration lookupPlugin(String idOrName) {
+		for (PluginRegistry registry : getRegistries()) {
+			PluginRegistration reg = registry.lookup(idOrName);
+			if (reg != null) {
+				return reg;
+			}
+		}
+		return null;
+	}
+
+	@Override
+	public PluginRelease lookupRelease(String idOrName, String version) {
+		for (PluginRegistry registry : getRegistries()) {
+			PluginRegistration reg = registry.lookup(idOrName);
+			if (reg != null) {
+				PluginRelease pv;
+				if (StringUtils.isEmpty(version)) {
+					pv = reg.getCurrentRelease();
+				} else {
+					pv = reg.getRelease(version);
+				}
+				if (pv != null) {
+					return pv;
+				}
+			}
+		}
+		return null;
+	}
+
+
+	/**
+	 * Installs the plugin from the plugin version.
+	 *
+	 * @param pv
+	 * @throws IOException
+	 * @return true if successful
+	 */
+	@Override
+	public boolean installPlugin(PluginRelease pv) {
+		return installPlugin(pv.url);
+	}
+
+	/**
+	 * Installs the plugin from the url.
+	 *
+	 * @param url
+	 * @return true if successful
+	 */
+	@Override
+	public boolean installPlugin(String url) {
+		try {
+			if (!download(url)) {
+				return false;
+			}
+			// TODO stop, unload, load
+		} catch (IOException e) {
+			logger.error("Failed to install plugin from " + url, e);
+		}
+		return true;
+	}
+
+	/**
+	 * Download a file to the plugins folder.
+	 *
+	 * @param url
+	 * @return
+	 * @throws IOException
+	 */
+	protected boolean download(String url) throws IOException {
+		File pFolder = runtimeManager.getFileOrFolder(Keys.plugins.folder, "${baseFolder}/plugins");
+		File tmpFile = new File(pFolder, StringUtils.getSHA1(url) + ".tmp");
+		if (tmpFile.exists()) {
+			tmpFile.delete();
+		}
+
+		URL u = new URL(url);
+		final URLConnection conn = getConnection(u);
+
+		// try to get the server-specified last-modified date of this artifact
+		long lastModified = conn.getHeaderFieldDate("Last-Modified", System.currentTimeMillis());
+
+		Files.copy(new InputSupplier<InputStream>() {
+			 @Override
+			public InputStream getInput() throws IOException {
+				 return new BufferedInputStream(conn.getInputStream());
+			}
+		}, tmpFile);
+
+		File destFile = new File(pFolder, StringUtils.getLastPathElement(u.getPath()));
+		if (destFile.exists()) {
+			destFile.delete();
+		}
+		tmpFile.renameTo(destFile);
+		destFile.setLastModified(lastModified);
+
+		return true;
+	}
+
+	protected URLConnection getConnection(URL url) throws IOException {
+		java.net.Proxy proxy = getProxy(url);
+		HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy);
+		if (java.net.Proxy.Type.DIRECT != proxy.type()) {
+			String auth = getProxyAuthorization(url);
+			conn.setRequestProperty("Proxy-Authorization", auth);
+		}
+
+		String username = null;
+		String password = null;
+		if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
+			// set basic authentication header
+			String auth = Base64.encodeBytes((username + ":" + password).getBytes());
+			conn.setRequestProperty("Authorization", "Basic " + auth);
+		}
+
+		// configure timeouts
+		conn.setConnectTimeout(connectTimeout * 1000);
+		conn.setReadTimeout(readTimeout * 1000);
+
+		switch (conn.getResponseCode()) {
+		case HttpURLConnection.HTTP_MOVED_TEMP:
+		case HttpURLConnection.HTTP_MOVED_PERM:
+			// handle redirects by closing this connection and opening a new
+			// one to the new location of the requested resource
+			String newLocation = conn.getHeaderField("Location");
+			if (!StringUtils.isEmpty(newLocation)) {
+				logger.info("following redirect to {0}", newLocation);
+				conn.disconnect();
+				return getConnection(new URL(newLocation));
+			}
+		}
+
+		return conn;
+	}
+
+	protected Proxy getProxy(URL url) {
+		return java.net.Proxy.NO_PROXY;
+	}
+
+	protected String getProxyAuthorization(URL url) {
+		return "";
+	}
 }
diff --git a/src/main/java/com/gitblit/models/PluginRegistry.java b/src/main/java/com/gitblit/models/PluginRegistry.java
new file mode 100644
index 0000000..c81a0f2
--- /dev/null
+++ b/src/main/java/com/gitblit/models/PluginRegistry.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * 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.gitblit.models;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import org.parboiled.common.StringUtils;
+
+import ro.fortsoft.pf4j.PluginVersion;
+
+/**
+ * Represents a list of plugin registrations.
+ */
+public class PluginRegistry implements Serializable {
+
+	private static final long serialVersionUID = 1L;
+
+	public final String name;
+
+	public final List<PluginRegistration> registrations;
+
+	public PluginRegistry(String name) {
+		this.name = name;
+		registrations = new ArrayList<PluginRegistration>();
+	}
+
+	public PluginRegistration lookup(String idOrName) {
+		for (PluginRegistration registration : registrations) {
+			if (registration.id.equalsIgnoreCase(idOrName)
+					|| registration.name.equalsIgnoreCase(idOrName)) {
+				return registration;
+			}
+		}
+		return null;
+	}
+
+	@Override
+	public String toString() {
+		return getClass().getSimpleName();
+	}
+
+	public static enum InstallState {
+		NOT_INSTALLED, INSTALLED, CAN_UPDATE, UNKNOWN
+	}
+
+	/**
+	 * Represents a plugin registration.
+	 */
+	public static class PluginRegistration implements Serializable {
+
+		private static final long serialVersionUID = 1L;
+
+		public final String id;
+
+		public String name;
+
+		public String description;
+
+		public String provider;
+
+		public String projectUrl;
+
+		public String currentRelease;
+
+		public transient String installedRelease;
+
+		public List<PluginRelease> releases;
+
+		public PluginRegistration(String id) {
+			this.id = id;
+			this.releases = new ArrayList<PluginRelease>();
+		}
+
+		public PluginRelease getCurrentRelease() {
+			PluginRelease current = null;
+			if (!StringUtils.isEmpty(currentRelease)) {
+				current = getRelease(currentRelease);
+			}
+
+			if (current == null) {
+				Date date = new Date(0);
+				for (PluginRelease pv : releases) {
+					if (pv.date.after(date)) {
+						current = pv;
+					}
+				}
+			}
+			return current;
+		}
+
+		public PluginRelease getRelease(String version) {
+			for (PluginRelease pv : releases) {
+				if (pv.version.equalsIgnoreCase(version)) {
+					return pv;
+				}
+			}
+			return null;
+		}
+
+		public InstallState getInstallState() {
+			if (StringUtils.isEmpty(installedRelease)) {
+				return InstallState.NOT_INSTALLED;
+			}
+			PluginVersion ir = PluginVersion.createVersion(installedRelease);
+			PluginVersion cr = PluginVersion.createVersion(currentRelease);
+			switch (ir.compareTo(cr)) {
+			case -1:
+				return InstallState.UNKNOWN;
+			case 1:
+				return InstallState.CAN_UPDATE;
+			default:
+				return InstallState.INSTALLED;
+			}
+		}
+
+		@Override
+		public String toString() {
+			return id;
+		}
+	}
+
+	public static class PluginRelease {
+		public String version;
+		public Date date;
+		public String url;
+	}
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/commands/PluginDispatcher.java b/src/main/java/com/gitblit/transport/ssh/commands/PluginDispatcher.java
index 5c413db..ba6f30d 100644
--- a/src/main/java/com/gitblit/transport/ssh/commands/PluginDispatcher.java
+++ b/src/main/java/com/gitblit/transport/ssh/commands/PluginDispatcher.java
@@ -19,6 +19,7 @@
 import java.util.List;
 
 import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
 import ro.fortsoft.pf4j.PluginDependency;
 import ro.fortsoft.pf4j.PluginDescriptor;
@@ -26,6 +27,8 @@
 import ro.fortsoft.pf4j.PluginWrapper;
 
 import com.gitblit.manager.IGitblit;
+import com.gitblit.models.PluginRegistry.PluginRegistration;
+import com.gitblit.models.PluginRegistry.PluginRelease;
 import com.gitblit.models.UserModel;
 import com.gitblit.utils.FlipTable;
 import com.gitblit.utils.FlipTable.Borders;
@@ -46,7 +49,8 @@
 		register(user, StopPlugin.class);
 		register(user, ShowPlugin.class);
 		register(user, RemovePlugin.class);
-		register(user, UploadPlugin.class);
+		register(user, InstallPlugin.class);
+		register(user, AvailablePlugins.class);
 	}
 
 	@CommandMetaData(name = "list", aliases = { "ls" }, description = "List the loaded plugins")
@@ -82,7 +86,7 @@
 
 			stdout.println(FlipTable.of(headers, data, Borders.BODY_HCOLS));
 		}
-		
+
 		@Override
 		protected void asTabbed(List<PluginWrapper> list) {
 			for (PluginWrapper pw : list) {
@@ -95,7 +99,7 @@
 			}
 		}
 	}
-	
+
 	@CommandMetaData(name = "start", description = "Start a plugin")
 	public static class StartPlugin extends SshCommand {
 
@@ -128,7 +132,7 @@
 				}
 			}
 		}
-		
+
 		protected void start(PluginWrapper pw) throws UnloggedFailure {
 			String id = pw.getDescriptor().getPluginId();
 			if (pw.getPluginState() == PluginState.STARTED) {
@@ -143,7 +147,7 @@
 			}
 		}
 	}
-	
+
 
 	@CommandMetaData(name = "stop", description = "Stop a plugin")
 	public static class StopPlugin extends SshCommand {
@@ -177,7 +181,7 @@
 			}
 			}
 		}
-		
+
 		protected void stop(PluginWrapper pw) throws UnloggedFailure {
 			String id = pw.getDescriptor().getPluginId();
 			if (pw.getPluginState() == PluginState.STOPPED) {
@@ -192,7 +196,7 @@
 			}
 		}
 	}
-	
+
 	@CommandMetaData(name = "show", description = "Show the details of a plugin")
 	public static class ShowPlugin extends SshCommand {
 
@@ -230,7 +234,7 @@
 					String ext = exts.get(i);
 					data[0] = new Object[] { ext.toString(), ext.toString() };
 				}
-				extensions = FlipTable.of(headers, data, Borders.COLS);		
+				extensions = FlipTable.of(headers, data, Borders.COLS);
 			}
 
 			// DEPENDENCIES
@@ -246,9 +250,9 @@
 					PluginDependency dep = deps.get(i);
 					data[0] = new Object[] { dep.getPluginId(), dep.getPluginVersion() };
 				}
-				dependencies = FlipTable.of(headers, data, Borders.COLS);		
+				dependencies = FlipTable.of(headers, data, Borders.COLS);
 			}
-			
+
 			String[] headers = { d.getPluginId() };
 			Object[][] data = new Object[5][];
 			data[0] = new Object[] { fields };
@@ -256,10 +260,10 @@
 			data[2] = new Object[] { extensions };
 			data[3] = new Object[] { "DEPENDENCIES" };
 			data[4] = new Object[] { dependencies };
-			stdout.println(FlipTable.of(headers, data));		
+			stdout.println(FlipTable.of(headers, data));
 		}
 	}
-	
+
 	@CommandMetaData(name = "remove", aliases= { "rm", "del" }, description = "Remove a plugin", hidden = true)
 	public static class RemovePlugin extends SshCommand {
 
@@ -282,12 +286,98 @@
 			}
 		}
 	}
-	
-	@CommandMetaData(name = "receive", aliases= { "upload" }, description = "Upload a plugin to the server", hidden = true)
-	public static class UploadPlugin extends SshCommand {
+
+	@CommandMetaData(name = "install", description = "Download and installs a plugin", hidden = true)
+	public static class InstallPlugin extends SshCommand {
+
+		@Argument(index = 0, required = true, metaVar = "<URL>|<ID>|<NAME>", usage = "the id, name, or the url of the plugin to download and install")
+		protected String urlOrIdOrName;
+
+		@Option(name = "--version", usage = "The specific version to install")
+		private String version;
 
 		@Override
 		public void run() throws UnloggedFailure {
+			IGitblit gitblit = getContext().getGitblit();
+			try {
+				String ulc = urlOrIdOrName.toLowerCase();
+				if (ulc.startsWith("http://") || ulc.startsWith("https://")) {
+					if (gitblit.installPlugin(urlOrIdOrName)) {
+						stdout.println(String.format("Installed %s", urlOrIdOrName));
+					} else {
+						new UnloggedFailure(1, String.format("Failed to install %s", urlOrIdOrName));
+					}
+				} else {
+					PluginRelease pv = gitblit.lookupRelease(urlOrIdOrName, version);
+					if (pv == null) {
+						throw new UnloggedFailure(1,  String.format("Plugin \"%s\" is not in the registry!", urlOrIdOrName));
+					}
+					if (gitblit.installPlugin(pv)) {
+						stdout.println(String.format("Installed %s", urlOrIdOrName));
+					} else {
+						throw new UnloggedFailure(1, String.format("Failed to install %s", urlOrIdOrName));
+					}
+				}
+			} catch (Exception e) {
+				log.error("Failed to install " + urlOrIdOrName, e);
+				throw new UnloggedFailure(1, String.format("Failed to install %s", urlOrIdOrName), e);
+			}
+		}
+	}
+
+	@CommandMetaData(name = "available", description = "List the available plugins")
+	public static class AvailablePlugins extends ListFilterCommand<PluginRegistration> {
+
+		@Option(name = "--refresh", aliases = { "-r" }, usage = "refresh the plugin registry")
+		protected boolean refresh;
+
+		@Override
+		protected List<PluginRegistration> getItems() throws UnloggedFailure {
+			IGitblit gitblit = getContext().getGitblit();
+			if (refresh) {
+				gitblit.refreshRegistry();
+			}
+			List<PluginRegistration> list = gitblit.getRegisteredPlugins();
+			return list;
+		}
+
+		@Override
+		protected boolean matches(String filter, PluginRegistration t) {
+			return t.id.matches(filter) || t.name.matches(filter);
+		}
+
+		@Override
+		protected void asTable(List<PluginRegistration> list) {
+			String[] headers;
+			if (verbose) {
+				String [] h = { "Name", "Description", "Installed", "Release", "State", "Id", "Provider" };
+				headers = h;
+			} else {
+				String [] h = { "Name", "Description", "Installed", "Release", "State" };
+				headers = h;
+			}
+			Object[][] data = new Object[list.size()][];
+			for (int i = 0; i < list.size(); i++) {
+				PluginRegistration p = list.get(i);
+				if (verbose) {
+					data[i] = new Object[] {p.name, p.description, p.installedRelease, p.currentRelease, p.getInstallState(), p.id, p.provider};
+				} else {
+					data[i] = new Object[] {p.name, p.description, p.installedRelease, p.currentRelease, p.getInstallState()};
+				}
+			}
+
+			stdout.println(FlipTable.of(headers, data, Borders.BODY_HCOLS));
+		}
+
+		@Override
+		protected void asTabbed(List<PluginRegistration> list) {
+			for (PluginRegistration p : list) {
+				if (verbose) {
+					outTabbed(p.name, p.description, p.currentRelease, p.getInstallState(), p.id, p.provider);
+				} else {
+					outTabbed(p.name, p.description, p.currentRelease, p.getInstallState());
+				}
+			}
 		}
 	}
 }

--
Gitblit v1.9.1