FELIX-1914: Add dev:show-tree to represent bundle dependencies

git-svn-id: https://svn.apache.org/repos/asf/felix/trunk@887233 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/karaf/assembly/pom.xml b/karaf/assembly/pom.xml
index 23f0497..cdf13cc 100644
--- a/karaf/assembly/pom.xml
+++ b/karaf/assembly/pom.xml
@@ -124,6 +124,10 @@
             <artifactId>org.apache.felix.karaf.shell.ssh</artifactId>
         </dependency>
         <dependency>
+            <groupId>org.apache.felix.karaf.shell</groupId>
+            <artifactId>org.apache.felix.karaf.shell.dev</artifactId>
+        </dependency>      
+        <dependency>
             <groupId>org.apache.felix.karaf.jaas</groupId>
             <artifactId>org.apache.felix.karaf.jaas.boot</artifactId>
         </dependency>
diff --git a/karaf/assembly/src/main/descriptors/unix-bin.xml b/karaf/assembly/src/main/descriptors/unix-bin.xml
index 9f16dcf..b38ab74 100644
--- a/karaf/assembly/src/main/descriptors/unix-bin.xml
+++ b/karaf/assembly/src/main/descriptors/unix-bin.xml
@@ -219,6 +219,7 @@
             <outputFileNameMapping>org/apache/felix/karaf/shell/${artifact.artifactId}/${artifact.baseVersion}/${artifact.artifactId}-${artifact.baseVersion}${dashClassifier?}.${artifact.extension}</outputFileNameMapping>
             <includes>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.console</include>
+                <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.dev</include>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.osgi</include>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.log</include>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.config</include>
diff --git a/karaf/assembly/src/main/descriptors/windows-bin.xml b/karaf/assembly/src/main/descriptors/windows-bin.xml
index 726e3f2..b5db894 100644
--- a/karaf/assembly/src/main/descriptors/windows-bin.xml
+++ b/karaf/assembly/src/main/descriptors/windows-bin.xml
@@ -211,6 +211,7 @@
             <outputFileNameMapping>org/apache/felix/karaf/shell/${artifact.artifactId}/${artifact.baseVersion}/${artifact.artifactId}-${artifact.baseVersion}${dashClassifier?}.${artifact.extension}</outputFileNameMapping>
             <includes>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.console</include>
+                <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.dev</include>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.osgi</include>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.log</include>
                 <include>org.apache.felix.karaf.shell:org.apache.felix.karaf.shell.config</include>
diff --git a/karaf/assembly/src/main/filtered-resources/etc/startup.properties b/karaf/assembly/src/main/filtered-resources/etc/startup.properties
index 1a79a4d..cc120f1 100644
--- a/karaf/assembly/src/main/filtered-resources/etc/startup.properties
+++ b/karaf/assembly/src/main/filtered-resources/etc/startup.properties
@@ -46,6 +46,7 @@
 org/apache/felix/karaf/shell/org.apache.felix.karaf.shell.config/${pom.version}/org.apache.felix.karaf.shell.config-${pom.version}.jar=30
 org/apache/felix/karaf/shell/org.apache.felix.karaf.shell.packages/${pom.version}/org.apache.felix.karaf.shell.packages-${pom.version}.jar=30
 org/apache/felix/karaf/shell/org.apache.felix.karaf.shell.commands/${pom.version}/org.apache.felix.karaf.shell.commands-${pom.version}.jar=30
+org/apache/felix/karaf/shell/org.apache.felix.karaf.shell.dev/${pom.version}/org.apache.felix.karaf.shell.dev-${pom.version}.jar=30
 org/apache/felix/karaf/jaas/org.apache.felix.karaf.jaas.config/${pom.version}/org.apache.felix.karaf.jaas.config-${pom.version}.jar=30
 org/apache/felix/karaf/jaas/org.apache.felix.karaf.jaas.modules/${pom.version}/org.apache.felix.karaf.jaas.modules-${pom.version}.jar=30
 org/apache/felix/karaf/admin/org.apache.felix.karaf.admin.core/${pom.version}/org.apache.felix.karaf.admin.core-${pom.version}.jar=30
diff --git a/karaf/pom.xml b/karaf/pom.xml
index d1e61b3..bf7be79 100644
--- a/karaf/pom.xml
+++ b/karaf/pom.xml
@@ -266,6 +266,11 @@
                 <version>${pom.version}</version>
             </dependency>
             <dependency>
+                <groupId>org.apache.felix.karaf.shell</groupId>
+                <artifactId>org.apache.felix.karaf.shell.dev</artifactId>
+                <version>${pom.version}</version>
+            </dependency> 
+            <dependency>
                 <groupId>org.apache.felix.karaf.jaas</groupId>
                 <artifactId>org.apache.felix.karaf.jaas.boot</artifactId>
                 <version>${pom.version}</version>
diff --git a/karaf/shell/dev/pom.xml b/karaf/shell/dev/pom.xml
new file mode 100644
index 0000000..ce90495
--- /dev/null
+++ b/karaf/shell/dev/pom.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <!--
+
+      Licensed to the Apache Software Foundation (ASF) under one or more
+      contributor license agreements.  See the NOTICE file distributed with
+      this work for additional information regarding copyright ownership.
+      The ASF licenses this file to You 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.
+  -->
+
+  <parent>
+    <artifactId>shell</artifactId>
+    <groupId>org.apache.felix.karaf.shell</groupId>
+    <version>1.3.0-SNAPSHOT</version>
+  </parent>
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <artifactId>org.apache.felix.karaf.shell.dev</artifactId>
+  <packaging>bundle</packaging>
+  <name>Apache Felix Karaf :: Shell Development Commands</name>
+
+  <dependencies>
+    <dependency>
+        <groupId>org.apache.felix.karaf.shell</groupId>
+        <artifactId>org.apache.felix.karaf.shell.console</artifactId>
+    </dependency>
+
+    <dependency>
+        <groupId>org.apache.felix</groupId>
+        <artifactId>org.osgi.core</artifactId>
+        <scope>provided</scope>
+    </dependency>
+
+    <dependency>
+        <groupId>org.apache.felix</groupId>
+        <artifactId>org.osgi.compendium</artifactId>
+        <scope>provided</scope>
+    </dependency>    
+  </dependencies>
+
+  <build>
+      <plugins>
+          <plugin>
+              <groupId>org.apache.felix</groupId>
+              <artifactId>maven-bundle-plugin</artifactId>
+              <configuration>
+                  <instructions>
+                      <Bundle-SymbolicName>${artifactId}</Bundle-SymbolicName>
+                      <Export-Package>${pom.artifactId}*;version=${project.version}</Export-Package>
+                      <Import-Package>
+                          !${pom.artifactId}*,
+                          org.osgi.service.command,
+                          org.apache.felix.gogo.commands,
+                          org.apache.felix.karaf.shell.console,
+                          *
+                      </Import-Package>
+                      <Private-Package>!*</Private-Package>
+                      <_versionpolicy>${bnd.version.policy}</_versionpolicy>
+                  </instructions>
+              </configuration>
+          </plugin>
+      </plugins>
+  </build>  
+
+</project>
diff --git a/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/ShowBundleTree.java b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/ShowBundleTree.java
new file mode 100644
index 0000000..feee325
--- /dev/null
+++ b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/ShowBundleTree.java
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev;
+
+import static java.lang.String.format;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.felix.gogo.commands.Argument;
+import org.apache.felix.gogo.commands.Command;
+import org.apache.felix.karaf.shell.console.OsgiCommandSupport;
+import org.apache.felix.karaf.shell.dev.util.Bundles;
+import org.apache.felix.karaf.shell.dev.util.Import;
+import org.apache.felix.karaf.shell.dev.util.Node;
+import org.apache.felix.karaf.shell.dev.util.Tree;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.ServiceReference;
+import org.osgi.service.packageadmin.ExportedPackage;
+import org.osgi.service.packageadmin.PackageAdmin;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Command for showing the full tree of bundles that have been used to resolve
+ * a given bundle.
+ */
+@Command(scope = "dev", name = "bundle-tree",
+         description = "Show the tree of bundles based on the wiring information")
+public class ShowBundleTree extends OsgiCommandSupport {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ShowBundleTree.class);
+
+    @Argument(index = 0, name = "id", description = "The ID of the bundle to check", required = true)
+    Long id;
+
+    // the package admin service reference
+    private PackageAdmin admin;
+
+    // a cache of all exported packages
+    private ExportedPackage[] allExportedPackages;
+
+    @Override
+    protected Object doExecute() throws Exception {
+        // Get package admin service.
+        ServiceReference ref = getBundleContext().getServiceReference(PackageAdmin.class.getName());
+        if (ref == null) {
+            System.out.println("PackageAdmin service is unavailable.");
+            return null;
+        }
+
+        // using the getService call ensures that the reference will be released at the end
+        admin = getService(PackageAdmin.class, ref);
+
+
+        Bundle bundle = getBundleContext().getBundle(id);
+        if (bundle == null) {
+            System.err.println("Bundle ID" + id + " is invalid");
+            return null;
+        }
+
+        // let's do the real work here
+        printHeader(bundle);
+        Tree<Bundle> tree = createTree(bundle);
+        printTree(tree);
+        printDuplicatePackages(tree);
+        return null;
+    }
+
+    /*
+     * Print the header
+     */
+    private void printHeader(Bundle bundle) {
+        System.out.printf("Bundle %s [%s] is currently %s%n",
+                bundle.getSymbolicName(),
+                bundle.getBundleId(),
+                Bundles.toString(bundle.getState()));
+    }
+
+    /*
+     * Print the dependency tree
+     */
+    private void printTree(Tree<Bundle> tree) {
+        System.out.printf("%n");
+        tree.write(System.out);
+    }
+
+    /*
+     * Check for bundles in the tree exporting the same package
+     * (possible cause for 'Unresolved constraint...' on a uses-conflict
+     */
+    private void printDuplicatePackages(Tree<Bundle> tree) {
+        Set<Bundle> bundles = tree.flatten();
+        Map<String, Set<Bundle>> exports = new HashMap<String, Set<Bundle>>();
+
+        for (Bundle bundle : bundles) {
+            ExportedPackage[] packages = admin.getExportedPackages(bundle);
+            if (packages != null) {
+                for (ExportedPackage p : packages) {
+                    if (exports.get(p.getName()) == null) {
+                        exports.put(p.getName(), new HashSet<Bundle>());
+                    }
+                    exports.get(p.getName()).add(bundle);
+                }
+            }
+        }
+
+        for (String pkg : exports.keySet()) {
+            if (exports.get(pkg).size() > 1) {
+                System.out.printf("%n");
+                System.out.printf("WARNING: multiple bundles are exporting package %s%n", pkg);
+                for (Bundle bundle : exports.get(pkg)) {
+                    System.out.printf("- %s%n", bundle);
+                }
+            }
+        }
+    }
+
+    /*
+     * Creates the bundle tree
+     */
+    protected Tree<Bundle> createTree(Bundle bundle) {
+        Tree<Bundle> tree = new Tree<Bundle>(bundle);
+        Set<Bundle> trail = new HashSet<Bundle>();
+        if (bundle.getState() >= Bundle.RESOLVED) {
+            createNode(tree, trail);
+        } else {
+            for (Import i : Import.parse(String.valueOf(bundle.getHeaders().get("Import-Package")))) {
+                for (ExportedPackage ep : admin.getExportedPackages(i.getPackage())) {
+                    if (ep.getVersion().compareTo(i.getVersion()) >= 0) {
+                        if (!bundle.equals(ep.getExportingBundle())) {
+                            Node child = tree.addChild(ep.getExportingBundle());
+                            System.out.printf("- using %s to resolve import %s%n", ep.getExportingBundle(), i);
+                            createNode(child, trail);
+                        }
+                    }
+                }
+            }
+        }
+        return tree;
+    }
+
+    /*
+     * Creates a node in the bundle tree
+     */
+    private void createNode(Node<Bundle> node, Set<Bundle> trail) {
+        Bundle bundle = node.getValue();
+        Set<Bundle> exporters = getWiredBundles(bundle);
+
+        for (Bundle exporter : exporters) {
+            if (trail.contains(exporter)) {
+                LOGGER.debug(format("Skipping %s because it already exists in the current tree branch", exporter));
+            } else {
+                trail.add(exporter);
+                Node child = node.addChild(exporter);
+                LOGGER.debug(format("Adding %s as a dependency for %s", exporter, bundle));
+                createNode(child, trail);
+                trail.remove(exporter);
+            }
+        }
+    }
+
+    /*
+     * Get the list of bundles from which the given bundle imports packages
+     */
+    private Set<Bundle> getWiredBundles(Bundle bundle) {
+        // the set of bundles from which the bundle imports packages
+        Set<Bundle> exporters = new LinkedHashSet<Bundle>();
+
+        for (ExportedPackage pkg : getAllExportedPackages()) {
+            Bundle[] bundles = pkg.getImportingBundles();
+            if (bundles != null) {
+                for (Bundle importingBundle : bundles) {
+                    if (bundle.equals(importingBundle)
+                            && !(pkg.getExportingBundle().getBundleId() == 0)
+                            && !(pkg.getExportingBundle().equals(bundle))) {
+                        exporters.add(pkg.getExportingBundle());
+                    }
+                }
+            }
+        }
+        return exporters;
+    }
+
+    /*
+     * Get the full list of package exports from PackageAdmin
+     */
+    private ExportedPackage[] getAllExportedPackages() {
+        if (allExportedPackages == null) {
+            allExportedPackages = admin.getExportedPackages((Bundle) null);
+        }
+        return allExportedPackages;
+    }
+}
diff --git a/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Bundles.java b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Bundles.java
new file mode 100644
index 0000000..acf70eb
--- /dev/null
+++ b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Bundles.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev.util;
+
+import org.osgi.framework.Bundle;
+
+/**
+ * A set of utility methods for working with {@link org.osgi.framework.Bundle}s
+ */
+public class Bundles {
+
+    /**
+     * Return a String representation of a bundle state
+     */
+    public static String toString(int state) {
+        switch (state) {
+            case Bundle.UNINSTALLED : return "UNINSTALLED";
+            case Bundle.INSTALLED : return "INSTALLED";
+            case Bundle.RESOLVED: return "RESOLVED";
+            case Bundle.STARTING : return "STARTING";
+            case Bundle.STOPPING : return "STOPPING";
+            case Bundle.ACTIVE : return "ACTIVE";
+            default : return "UNKNOWN";
+        }
+    }
+}
diff --git a/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Import.java b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Import.java
new file mode 100644
index 0000000..bea30f1
--- /dev/null
+++ b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Import.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev.util;
+
+import java.util.LinkedList;
+import java.util.List;
+
+import org.osgi.framework.Version;
+
+/**
+ * Simple class to model an OSGi Import-Package
+ */
+public class Import {
+
+    private final String packageName;
+    private final Version version;
+    private final String value;
+
+    /**
+     * Create a new import based on the string value found in MANIFEST.MF
+     *
+     * @param value the MANIFEST.MF value
+     */
+    protected Import(String value) {
+        super();
+        this.value = value;
+        if (value.contains(";")) {
+            this.packageName = value.split(";")[0];
+        } else {
+            this.packageName = value;
+        }
+        if (value.contains("version=")) {
+            this.version = extractVersion(value);
+        } else {
+            this.version = Version.emptyVersion;
+        }
+    }
+
+    /*
+     * Extract the version from the string
+     */
+    private Version extractVersion(String value) {
+        int begin = value.indexOf("version=") + 8;
+        int end = value.indexOf(";", begin);
+        if (end < 0) {
+            return Version.parseVersion(unquote(value.substring(begin)));
+        } else {
+            return Version.parseVersion(unquote(value.substring(begin, end)));
+        }
+    }
+
+    /*
+     * Remove leading/trailing quotes
+     */
+    private String unquote(String string) {
+        return string.replace("\"", "");
+    }
+
+    public String getPackage() {
+        return packageName;  
+    }
+
+    public Version getVersion() {
+        return version;
+    }
+
+    @Override
+    public String toString() {
+        return value;
+    }
+
+    /**
+     * Parse the value of an Import-Package META-INF header and return
+     * a list of Import instances
+     */
+    public static List<Import> parse(String value) {
+        LinkedList<Import> imports = new LinkedList<Import>();
+        for (String imp : value.split(",")) {
+            imports.add(new Import(imp));
+        }
+        return imports;
+    }
+}
diff --git a/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Node.java b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Node.java
new file mode 100644
index 0000000..450828d
--- /dev/null
+++ b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Node.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev.util;
+
+import java.io.PrintWriter;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Represents a node in a {@link org.apache.felix.karaf.shell.dev.util.Tree}
+ */
+public class Node<T> {
+    
+    private final T value;
+    private Node<T> parent;
+    private List<Node<T>> children = new LinkedList<Node<T>>();
+
+    /**
+     * Creates a new node. Only meant for internal use,
+     * new nodes should be added using the {@link #addChild(Object)} method
+     *
+     * @param value the node value
+     */
+    protected Node(T value) {
+        super();
+        this.value = value;
+    }
+
+    /**
+     * Creates a new node. Only meant for internal use,
+     * new nodes should be added using the {@link #addChild(Object)} method
+     *
+     * @param value the node value
+     */
+    protected Node(T value, Node<T> parent) {
+        this(value);
+        this.parent = parent;
+    }
+
+    /**
+     * Access the node's value
+     */
+    public T getValue() {
+        return value;
+    }
+
+    /**
+     * Access the node's child nodes
+     */
+    public List<Node<T>> getChildren() {
+        return children;
+    }
+
+    /**
+     * Adds a child to this node
+     *
+     * @param value the child's value
+     * @return the child node
+     */
+    public Node addChild(T value) {
+        Node node = new Node(value, this);
+        children.add(node);
+        return node;
+    }
+
+    /**
+     * Give a set of values in the tree.
+     *
+     * @return
+     */
+    public Set<T> flatten() {
+        Set<T> result = new HashSet<T>();
+        result.add(getValue());
+        for (Node<T> child : getChildren()) {
+            result.addAll(child.flatten());
+        }
+        return result;
+    }
+
+    /*
+     * Write this node to the PrintWriter.  It should be indented one step
+     * further for every element in the indents array.  If an element in the
+     * array is <code>true</code>, there should be a | to connect to the next
+     * sibling.
+     */
+    protected void write(PrintWriter writer, boolean... indents) {
+        for (boolean indent : indents) {
+            writer.printf("%-3s", indent ? "|" : "");
+        }
+        writer.printf("+- %s%n", value);
+        for (Node<T> child : getChildren()) {
+            child.write(writer, concat(indents, hasNextSibling()));
+        }
+    }
+
+    /*
+     * Is this node the last child node for its parent
+     * or is there a next sibling?
+     */
+    private boolean hasNextSibling() {
+        if (parent == null) {
+            return false;
+        } else {
+            return parent.getChildren().size() > 1
+                    && parent.getChildren().indexOf(this) < parent.getChildren().size() - 1;
+        }
+    }
+
+    /*
+     * Add an element to the end of the array
+     */
+    private boolean[] concat(boolean[] array, boolean element) {
+        boolean[] result = new boolean[array.length + 1];
+        for (int i = 0 ; i < array.length ; i++) {
+            result[i] = array[i];
+        }
+        result[array.length] = element;
+        return result;
+    }
+}
diff --git a/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Tree.java b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Tree.java
new file mode 100644
index 0000000..ae61eed5
--- /dev/null
+++ b/karaf/shell/dev/src/main/java/org/apache/felix/karaf/shell/dev/util/Tree.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev.util;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.io.PrintWriter;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Represents a tree that can be written to the console.
+ *
+ * The output will look like this:
+ * <pre>
+ * root
+ * +- child1
+ * |  +- grandchild
+ * +- child2
+ * </pre>
+ */
+public class Tree<T> extends Node<T> {
+
+    /**
+     * Creates a new tree with the given root node
+     *
+     * @param root the root node
+     */
+    public Tree(T root) {
+        super(root);
+    }
+
+    /**
+     * Write the tree to a PrintStream
+     * @param stream
+     */
+    public void write(PrintStream stream) {
+        write(new PrintWriter(stream));
+    }
+
+    /**
+     * Write the tree to a PrintWriter
+     * @param writer
+     */
+    public void write(PrintWriter writer) {
+        writer.printf("%s%n", getValue());
+        for (Node<T> child : getChildren()) {
+            child.write(writer);
+        }
+        writer.flush();
+    }
+}
diff --git a/karaf/shell/dev/src/main/resources/OSGI-INF/blueprint/shell-dev.xml b/karaf/shell/dev/src/main/resources/OSGI-INF/blueprint/shell-dev.xml
new file mode 100644
index 0000000..a695b7a
--- /dev/null
+++ b/karaf/shell/dev/src/main/resources/OSGI-INF/blueprint/shell-dev.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one or more
+    contributor license agreements.  See the NOTICE file distributed with
+    this work for additional information regarding copyright ownership.
+    The ASF licenses this file to You 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.
+
+-->
+<blueprint xmlns="http://www.osgi.org/xmlns/blueprint/v1.0.0">
+
+    <command-bundle xmlns="http://felix.apache.org/karaf/xmlns/shell/v1.0.0">
+        <command name="dev/show-tree">
+            <action class="org.apache.felix.karaf.shell.dev.ShowBundleTree"/>
+        </command>
+    </command-bundle>
+
+</blueprint>
diff --git a/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/BundlesTest.java b/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/BundlesTest.java
new file mode 100644
index 0000000..94c28a6
--- /dev/null
+++ b/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/BundlesTest.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev.util;
+
+import static org.junit.Assert.assertEquals;
+import org.junit.Test;
+import org.osgi.framework.Bundle;
+
+/**
+ * Test cases for {@link org.apache.felix.karaf.shell.dev.util.Bundles}
+ */
+public class BundlesTest {
+
+    @Test
+    public void testToString() {
+        assertEquals("UNINSTALLED", Bundles.toString(Bundle.UNINSTALLED));
+        assertEquals("INSTALLED", Bundles.toString(Bundle.INSTALLED));
+        assertEquals("RESOLVED", Bundles.toString(Bundle.RESOLVED));
+        assertEquals("STARTING", Bundles.toString(Bundle.STARTING));
+        assertEquals("STOPPING", Bundles.toString(Bundle.STOPPING));
+        assertEquals("ACTIVE", Bundles.toString(Bundle.ACTIVE));
+    }
+}
diff --git a/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/ImportTest.java b/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/ImportTest.java
new file mode 100644
index 0000000..cd9543a
--- /dev/null
+++ b/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/ImportTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev.util;
+
+import java.util.List;
+
+import static junit.framework.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import org.junit.Test;
+import org.osgi.framework.Version;
+
+/**
+ * Test cases for {@link org.apache.felix.karaf.shell.dev.util.Import}
+ */
+public class ImportTest {
+
+    @Test
+    public void createWithPackageName() {
+        Import i = new Import("org.wip.foo");
+        assertEquals("org.wip.foo", i.getPackage());
+    }
+
+    @Test
+    public void createWithPackageNameAndVersion() {
+        Import i = new Import("org.wip.bar;version=\"2.0.0\"");
+        assertEquals("org.wip.bar", i.getPackage());
+        assertEquals(new Version("2.0.0"), i.getVersion());
+    }
+
+    @Test
+    public void createListOfImports() {
+        List<Import> imports = Import.parse("org.wip.bar;version=\"2.0.0\",org.wip.foo");
+        assertNotNull(imports);
+        assertEquals(2, imports.size());
+        assertEquals("org.wip.bar", imports.get(0).getPackage());
+        assertEquals("org.wip.foo", imports.get(1).getPackage());
+    }
+}
diff --git a/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/TreeTest.java b/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/TreeTest.java
new file mode 100644
index 0000000..2132687
--- /dev/null
+++ b/karaf/shell/dev/src/test/java/org/apache/felix/karaf/shell/dev/util/TreeTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.felix.karaf.shell.dev.util;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import org.junit.Test;
+
+/**
+ * Test cases for {@link org.apache.felix.karaf.shell.dev.util.Tree}
+ * and {@link org.apache.felix.karaf.shell.dev.util.Node}
+ */
+public class TreeTest {
+
+    @Test
+    public void writeTreeWithOneChild() throws IOException {
+        Tree<String> tree = new Tree<String>("root");
+        tree.addChild("child");
+
+        BufferedReader reader = read(tree);
+
+        assertEquals("root"     , reader.readLine());
+        assertEquals("+- child" , reader.readLine());
+    }
+
+    @Test
+    public void writeTreeWithChildAndGrandChild() throws IOException {
+        Tree<String> tree = new Tree<String>("root");
+        Node<String> node = tree.addChild("child");
+        node.addChild("grandchild");
+
+        BufferedReader reader = read(tree);
+
+        assertEquals("root"            , reader.readLine());
+        assertEquals("+- child"        , reader.readLine());
+        assertEquals("   +- grandchild", reader.readLine());
+    }
+
+    @Test
+    public void writeTreeWithTwoChildrenAndOneGrandchild() throws IOException {
+        Tree<String> tree = new Tree<String>("root");
+        Node<String> child = tree.addChild("child1");
+        child.addChild("grandchild");
+        tree.addChild("child2");
+
+        BufferedReader reader = read(tree);
+
+        assertEquals("root"            , reader.readLine());
+        assertEquals("+- child1"       , reader.readLine());
+        assertEquals("|  +- grandchild", reader.readLine());
+        assertEquals("+- child2"       , reader.readLine());
+    }
+
+    @Test
+    public void flattenTree() throws IOException {
+        Tree<String> tree = new Tree<String>("root");
+        Node<String> child1 = tree.addChild("child1");
+        child1.addChild("grandchild");
+        Node child2 = tree.addChild("child2");
+        child2.addChild("grandchild");
+
+        Set<String> elements = tree.flatten();
+        assertNotNull(elements);
+        assertEquals(4, elements.size());
+        assertTrue(elements.contains("root"));
+        assertTrue(elements.contains("child1"));
+        assertTrue(elements.contains("child2"));
+        assertTrue(elements.contains("grandchild"));
+    }
+
+    private BufferedReader read(Tree<String> tree) {
+        StringWriter writer = new StringWriter();
+        tree.write(new PrintWriter(writer));
+
+        BufferedReader reader = new BufferedReader(new StringReader(writer.getBuffer().toString()));
+        return reader;
+    }
+}
diff --git a/karaf/shell/pom.xml b/karaf/shell/pom.xml
index c50ccee..5a6aa2d 100644
--- a/karaf/shell/pom.xml
+++ b/karaf/shell/pom.xml
@@ -42,6 +42,7 @@
         <module>packages</module>
         <module>ssh</module>
         <module>wrapper</module>
+        <module>dev</module>
     </modules>
 
 </project>