playground/pgadmin: Port pgbundle to Go.

Ported pgbundle from Javascript to Go (pgadmin and bundle/bundler).
Removed Node.js dependencies from pgadmin.
Merged glob files into bundle config file; pgadmin now makes single
bundles from config file as well. This will allow reading more
bundle metadata from config file in the future.
Added verbose and empty flags to pgadmin bundle.
Bundler now has tests.

MultiPart: 1/3
Change-Id: I94eb51f1fa3ad7a8675219ea0d6e77e4cf66b724
diff --git a/go/src/v.io/x/playground/Makefile b/go/src/v.io/x/playground/Makefile
index bfe8309..5572cfe 100644
--- a/go/src/v.io/x/playground/Makefile
+++ b/go/src/v.io/x/playground/Makefile
@@ -48,7 +48,7 @@
 		migrate up
 
 .PHONY: bootstrap
-bootstrap: config/db.json pgadmin pgbundle
+bootstrap: config/db.json pgadmin
 	pgadmin \
 		--sqlconf=$< \
 		bundle bootstrap
@@ -58,17 +58,6 @@
 	@echo "on the appropriate examples in the config directory."
 	@exit 1;
 
-# TODO(ivanpi): Remove when pgbundle is ported to Go.
-.PHONY: pgbundle
-pgbundle:
-	PATH="$(PATH):$(V23_ROOT)/third_party/cout/node/bin" \
-		npm install --production "$(V23_ROOT)/release/projects/playground/pgbundle"
-
-.PHONY: clean
-clean:
-	@$(RM) -rf node_modules
-	@$(RM) -rf npm-debug.log
-
 # Temporary workaround for linking Go tests into Jenkins.
 .PHONY: test
 test:
diff --git a/go/src/v.io/x/playground/README.md b/go/src/v.io/x/playground/README.md
index b9cfadd..a26c74f 100644
--- a/go/src/v.io/x/playground/README.md
+++ b/go/src/v.io/x/playground/README.md
@@ -42,8 +42,7 @@
 
 Test your image (without running compilerd):
 
-    $ cd $V23_ROOT/release/projects/playground/client && make src/example_bundles
-    $ docker run -i playground < $V23_ROOT/release/projects/playground/client/bundles/fortune/bundle_js_go.json
+    $ $V23_ROOT/release/projects/playground/go/bin/pgadmin bundle make fortune js-go | docker run -i playground
 
 
 ## Running the playground server (compilerd)
@@ -170,13 +169,12 @@
 present in the database to use as templates for editing. The unpacked example
 source code can be found in the `bundles` directory, described in
 `bundles/config.json`. Each example is obtained by filtering files from a
-folder according to a glob-like configuration file and bundling them into a
-JSON object that the client can parse.
+folder according to a glob-like configuration and bundling them into a JSON
+object that the client can parse.
 
 Bundling and loading the examples into a fresh database, as well as updating,
 is handled by the `pgadmin` tool:
 
-    $ make pgbundle
     $ $V23_ROOT/release/projects/playground/go/bin/pgadmin -sqlconf=./config/db.json bundle bootstrap
 
 Or simply:
diff --git a/go/src/v.io/x/playground/builder/builder_v23_test.go b/go/src/v.io/x/playground/builder/builder_v23_test.go
index a450922..ccdd8df 100644
--- a/go/src/v.io/x/playground/builder/builder_v23_test.go
+++ b/go/src/v.io/x/playground/builder/builder_v23_test.go
@@ -7,13 +7,11 @@
 import (
 	"bytes"
 	"io"
-	"io/ioutil"
 	"os"
 	"path/filepath"
-	"strings"
 	"time"
 
-	"v.io/x/playground/lib/bundle"
+	"v.io/x/playground/lib/bundle/bundler"
 	_ "v.io/x/ref/runtime/factories/generic"
 	"v.io/x/ref/test/expect"
 	"v.io/x/ref/test/v23tests"
@@ -41,7 +39,6 @@
 	playgroundRoot = filepath.Join(vanadiumRoot, "release", "projects", "playground")
 
 	npmInstall(i, filepath.Join(vanadiumRoot, "release/javascript/core"))
-	npmInstall(i, filepath.Join(playgroundRoot, "pgbundle"))
 
 	return i.BuildGoPkg("v.io/x/playground/builder")
 }
@@ -53,37 +50,30 @@
 
 // Bundles a playground example and tests it using builder.
 // - dir is the root directory of example to test
-// - globFile is the path to the glob file with file patterns to use from dir
+// - globList is the list of glob patterns specifying files to use from dir
 // - args are the arguments to call builder with
-func runPGExample(i *v23tests.T, builder *v23tests.Binary, globFile, dir string, args ...string) *v23tests.Invocation {
-	nodeBin := i.BinaryFromPath(filepath.Join(nodejsRoot, "node"))
-	bundle := nodeBin.Run("./node_modules/.bin/pgbundle", "--verbose", globFile, dir)
+func runPGExample(i *v23tests.T, builder *v23tests.Binary, dir string, globList []string, args ...string) *v23tests.Invocation {
+	bundle, err := bundler.MakeBundleJson(dir, globList, false)
+	if err != nil {
+		i.Fatalf("%s: bundler: failed: %v", i.Caller(1), err)
+	}
 
 	tmp := i.NewTempDir("")
 	cwd := i.Pushd(tmp)
 	defer i.Popd()
 	old := filepath.Join(cwd, "node_modules")
 	if err := os.Symlink(old, filepath.Join(".", filepath.Base(old))); err != nil {
-		i.Fatalf("%s: symlink: failed: %v", i.Caller(2), err)
+		i.Fatalf("%s: symlink: failed: %v", i.Caller(1), err)
 	}
 
 	PATH := "PATH=" + i.BinDir() + ":" + nodejsRoot
 	if path := os.Getenv("PATH"); len(path) > 0 {
 		PATH += ":" + path
 	}
-	stdin := bytes.NewBufferString(bundle)
+	stdin := bytes.NewBuffer(bundle)
 	return builder.WithEnv(PATH).WithStdin(stdin).Start(args...)
 }
 
-// Sets up a glob file with the given files, then runs builder.
-func testWithFiles(i *v23tests.T, builder *v23tests.Binary, testdataDir string, files ...string) *v23tests.Invocation {
-	globFile := filepath.Join(i.NewTempDir(""), "test.bundle")
-	if err := ioutil.WriteFile(globFile, []byte(strings.Join(files, "\n")+"\n"), 0644); err != nil {
-		i.Fatalf("%s: write(%q): failed: %v", i.Caller(1), globFile, err)
-	}
-	return runPGExample(i, builder, globFile, testdataDir, "-verbose=true", "--includeV23Env=true", "--runTimeout=5s")
-}
-
 // Echoes invocation output to stdout/stderr in addition to checking for
 // expected patterns.
 func expectAndEcho(inv *v23tests.Invocation, patterns ...string) {
@@ -120,7 +110,7 @@
 			if len(authfile) > 0 {
 				files = append(files, authfile)
 			}
-			inv := testWithFiles(i, builderBin, testdataDir, files...)
+			inv := runPGExample(i, builderBin, testdataDir, files, "--verbose=true", "--includeV23Env=true", "--runTimeout=5s")
 			i.Logf("test: %s", c.name)
 			expectAndEcho(inv, patterns...)
 		}
@@ -148,7 +138,7 @@
 
 	bundlesDir := filepath.Join(playgroundRoot, "go", "src", "v.io", "x", "playground", "bundles")
 	bundlesCfgFile := filepath.Join(bundlesDir, "config.json")
-	bundlesCfg, err := bundle.ParseConfigFromFile(bundlesCfgFile, bundlesDir)
+	bundlesCfg, err := bundler.ParseConfigFromFile(bundlesCfgFile, bundlesDir)
 	if err != nil {
 		i.Fatalf("%s: failed parsing bundle config from %q: %v", i.Caller(0), bundlesCfgFile, err)
 	}
@@ -162,8 +152,8 @@
 				i.Fatalf("%s: unknown glob %q", i.Caller(0), globName)
 			}
 
-			inv := runPGExample(i, builderBin, glob.Path, example.Path, "-verbose=true", "--runTimeout=5s")
-			i.Logf("glob: %s (%q)", globName, glob.Path)
+			inv := runPGExample(i, builderBin, example.Path, glob.Patterns, "--verbose=true", "--runTimeout=5s")
+			i.Logf("glob: %s", globName)
 			expectAndEcho(inv, example.Output...)
 		}
 	}
diff --git a/go/src/v.io/x/playground/bundles/config.json b/go/src/v.io/x/playground/bundles/config.json
index 1487c9b..e5e6cbb 100644
--- a/go/src/v.io/x/playground/bundles/config.json
+++ b/go/src/v.io/x/playground/bundles/config.json
@@ -18,16 +18,32 @@
   ],
   "globs": {
     "go": {
-      "path": "go.bundle"
+      "patterns": [
+        "*.vdl",
+        "client/**/*.go",
+        "server/**/*.go"
+      ]
     },
     "js": {
-      "path": "js.bundle"
+      "patterns": [
+        "*.vdl",
+        "client/**/*.js",
+        "server/**/*.js"
+      ]
     },
     "js-go": {
-      "path": "js_go.bundle"
+      "patterns": [
+        "*.vdl",
+        "client/**/*.js",
+        "server/**/*.go"
+      ]
     },
     "go-js": {
-      "path": "go_js.bundle"
+      "patterns": [
+        "*.vdl",
+        "client/**/*.go",
+        "server/**/*.js"
+      ]
     }
   }
 }
diff --git a/go/src/v.io/x/playground/bundles/go.bundle b/go/src/v.io/x/playground/bundles/go.bundle
deleted file mode 100644
index 93e083b..0000000
--- a/go/src/v.io/x/playground/bundles/go.bundle
+++ /dev/null
@@ -1,3 +0,0 @@
-*.vdl
-client/**/*.go
-server/**/*.go
diff --git a/go/src/v.io/x/playground/bundles/go_js.bundle b/go/src/v.io/x/playground/bundles/go_js.bundle
deleted file mode 100644
index dd4fb3e..0000000
--- a/go/src/v.io/x/playground/bundles/go_js.bundle
+++ /dev/null
@@ -1,3 +0,0 @@
-*.vdl
-client/**/*.go
-server/**/*.js
diff --git a/go/src/v.io/x/playground/bundles/js.bundle b/go/src/v.io/x/playground/bundles/js.bundle
deleted file mode 100644
index ae990f5..0000000
--- a/go/src/v.io/x/playground/bundles/js.bundle
+++ /dev/null
@@ -1,3 +0,0 @@
-*.vdl
-client/**/*.js
-server/**/*.js
diff --git a/go/src/v.io/x/playground/bundles/js_go.bundle b/go/src/v.io/x/playground/bundles/js_go.bundle
deleted file mode 100644
index 33edac3..0000000
--- a/go/src/v.io/x/playground/bundles/js_go.bundle
+++ /dev/null
@@ -1,3 +0,0 @@
-*.vdl
-client/**/*.js
-server/**/*.go
diff --git a/go/src/v.io/x/playground/lib/bundle/bundle.go b/go/src/v.io/x/playground/lib/bundle/bundle.go
new file mode 100644
index 0000000..7767b9f
--- /dev/null
+++ b/go/src/v.io/x/playground/lib/bundle/bundle.go
@@ -0,0 +1,22 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Representation of a playground bundle.
+// A bundle consists of a set of code files in a hierarchical file system. File
+// type is inferred from the extension.
+
+package bundle
+
+// TODO(ivanpi): Add validity check (file extensions, etc) and refactor builder
+// and storage to use the same structure.
+
+type Bundle struct {
+	Files []*CodeFile `json:"files"`
+	// TODO(ivanpi): Add slug, title, description? Merge with compilerd.BundleFullResponse?
+}
+
+type CodeFile struct {
+	Name string `json:"name"`
+	Body string `json:"body"`
+}
diff --git a/go/src/v.io/x/playground/lib/bundle/bundler/bundler.go b/go/src/v.io/x/playground/lib/bundle/bundler/bundler.go
new file mode 100644
index 0000000..3b9f749
--- /dev/null
+++ b/go/src/v.io/x/playground/lib/bundle/bundler/bundler.go
@@ -0,0 +1,210 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Implements bundling playground example files from a specified directory,
+// filtered using a specified glob list, into a JSON object compatible with
+// the playground client.
+
+package bundler
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"math"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+
+	"v.io/x/playground/lib/bundle"
+
+	"github.com/bmatcuk/doublestar"
+)
+
+// Bundles files using MakeBundle and returns the JSON serialized bindle.
+func MakeBundleJson(rootPath string, globList []string, empty bool) ([]byte, error) {
+	bundle, err := MakeBundle(rootPath, globList, empty)
+	if err != nil {
+		return nil, err
+	}
+	return json.Marshal(bundle)
+}
+
+// Bundles files in rootPath, filtered by globList. If empty is set, omits file
+// contents (includes only paths and metadata).
+func MakeBundle(rootPath string, globList []string, empty bool) (*bundle.Bundle, error) {
+	rootPath = filepath.Clean(rootPath)
+	// The root path must exist and be a directory.
+	if fi, err := os.Lstat(rootPath); err != nil {
+		if os.IsNotExist(err) {
+			return nil, fmt.Errorf("root path %q does not exist", rootPath)
+		} else {
+			return nil, fmt.Errorf("error checking root path %q: %v", rootPath, err)
+		}
+	} else if !fi.IsDir() {
+		return nil, fmt.Errorf("root path %q is not a directory", rootPath)
+	}
+
+	allPaths := make([]string, 0)
+	// Recursively list all regular files in rootPath.
+	if err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.Mode().IsRegular() {
+			// Slash is trimmed separately because rootPath may or may not end with
+			// a slash.
+			allPaths = append(allPaths, strings.TrimPrefix(strings.TrimPrefix(path, rootPath), "/"))
+		}
+		return nil
+	}); err != nil {
+		return nil, fmt.Errorf("error listing files in %q: %v", rootPath, err)
+	}
+
+	matchingPaths := make(map[string]bool)
+	unmatchedGlobs := make([]string, 0)
+	// Apply each glob to each file. Each glob must match at least one file; each
+	// file is included at most once, even if it matches multiple globs.
+	for _, glob := range globList {
+		matched := false
+		// Globs only need to match a suffix of the file path, so a leading '**' is
+		// added.
+		suffixGlob := "**/" + glob
+		for _, path := range allPaths {
+			if ok, err := doublestar.Match(suffixGlob, path); err != nil {
+				return nil, fmt.Errorf("error applying glob %q: %v", suffixGlob, err)
+			} else if ok {
+				matched = true
+				matchingPaths[path] = true
+			}
+		}
+		if !matched {
+			unmatchedGlobs = append(unmatchedGlobs, glob)
+		}
+	}
+	// If any glob matches no files, bundling fails.
+	if len(unmatchedGlobs) > 0 {
+		return nil, fmt.Errorf("error bundling %q: unmatched patterns %v", rootPath, unmatchedGlobs)
+	}
+
+	files := make([]*indexedCodeFile, 0, len(matchingPaths))
+	// Extract sorting indices and strip out "// +build ignore".
+	for path, _ := range matchingPaths {
+		contents, err := ioutil.ReadFile(filepath.Join(rootPath, path))
+		if err != nil {
+			return nil, fmt.Errorf("error reading file %q: %v", path, err)
+		}
+		files = append(files, filterAndIndexCodeFile(path, contents, empty))
+	}
+
+	sort.Sort(sortByIndexAndName(files))
+
+	// TODO(ivanpi): Add slug, description, etc?
+	var res bundle.Bundle
+
+	for _, icf := range files {
+		res.Files = append(res.Files, icf.CodeFile)
+	}
+
+	return &res, nil
+}
+
+type indexedCodeFile struct {
+	*bundle.CodeFile
+	index int64
+}
+
+// Sorts code files first by index, then by name (path).
+type sortByIndexAndName []*indexedCodeFile
+
+var _ sort.Interface = (*sortByIndexAndName)(nil)
+
+func (s sortByIndexAndName) Len() int      { return len(s) }
+func (s sortByIndexAndName) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
+func (s sortByIndexAndName) Less(i, j int) bool {
+	if s[i].index == s[j].index {
+		return s[i].Name < s[j].Name
+	} else {
+		return s[i].index < s[j].index
+	}
+}
+
+// Strips the first encountered "// +build ignore", extracts and strips the
+// sort index and strips leading blank lines.
+func filterAndIndexCodeFile(name string, contents []byte, empty bool) *indexedCodeFile {
+	lines := bytes.Split(contents, []byte("\n"))
+	lines = stripBuildIgnore(lines)
+	var index int64
+	lines, index = getAndStripIndex(lines)
+	lines = stripLeadingBlankLines(lines)
+	if empty {
+		lines = nil
+	}
+	return &indexedCodeFile{
+		CodeFile: &bundle.CodeFile{
+			Name: name,
+			Body: string(bytes.Join(lines, []byte("\n"))),
+		},
+		index: index,
+	}
+}
+
+// Strips the first encountered "// +build ignore" line in the file and
+// returns the remaining lines.
+func stripBuildIgnore(file [][]byte) [][]byte {
+	res := make([][]byte, 0, len(file))
+	found := false
+	re := regexp.MustCompile(`^//\s*\+build\s+ignore$`)
+	for _, line := range file {
+		if !found && re.Match(bytes.TrimSpace(line)) {
+			found = true
+			continue
+		}
+		res = append(res, line)
+	}
+	return res
+}
+
+// Strips the first encountered "// pg-index=<num>" line in the file and
+// returns the index value and remaining lines. Files with no specified
+// index or invalid index are given an infinite index.
+func getAndStripIndex(file [][]byte) ([][]byte, int64) {
+	res := make([][]byte, 0, len(file))
+	var index int64 = math.MaxInt64
+	found := false
+	re := regexp.MustCompile(`^//\s*pg-index=(-?\d+)$`)
+	if re.NumSubexp() != 1 {
+		panic("cannot happen: regexp has <> 1 subexp")
+	}
+	for _, line := range file {
+		if !found {
+			if match := re.FindSubmatch(bytes.TrimSpace(line)); match != nil {
+				if len(match) < 2 {
+					panic("cannot happen: missing submatch")
+				}
+				if parsed, err := strconv.ParseInt(string(match[1]), 10, 64); err == nil {
+					found = true
+					index = parsed
+					continue
+				}
+				// TODO(ivanpi): Warn otherwise (e.g. index overflow)?
+			}
+		}
+		res = append(res, line)
+	}
+	return res, index
+}
+
+// Strips all blank lines at the beginning of the file.
+func stripLeadingBlankLines(file [][]byte) [][]byte {
+	res := append([][]byte(nil), file...)
+	for len(res) > 0 && len(bytes.TrimSpace(res[0])) == 0 {
+		res = res[1:]
+	}
+	return res
+}
diff --git a/go/src/v.io/x/playground/lib/bundle/bundler/bundler_test.go b/go/src/v.io/x/playground/lib/bundle/bundler/bundler_test.go
new file mode 100644
index 0000000..93c7671
--- /dev/null
+++ b/go/src/v.io/x/playground/lib/bundle/bundler/bundler_test.go
@@ -0,0 +1,324 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package bundler
+
+import (
+	"io/ioutil"
+	"math"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"v.io/x/playground/lib/bundle"
+)
+
+type testFile struct {
+	// File contents, line by line. Lines must start with '+++' if they should
+	// remain in the bundled file, or '---' if they should be filtered out.
+	contents []string
+	// Sort index expected to be parsed from the file.
+	index int64
+}
+
+var testFiles = map[string]*testFile{
+	"alpha/one/A-5.a": {
+		[]string{
+			"---",
+			"---// +build ignore",
+			"---",
+			"---\t",
+			"+++lalala",
+			"+++\t",
+		},
+		math.MaxInt64,
+	},
+	"alpha/one/B-3.b": {
+		[]string{
+			"---// pg-index=12",
+			"---",
+			"+++hello, world",
+		},
+		12,
+	},
+	"alpha/one/foo/C-1.a": {
+		[]string{
+			"+++//  pg-index=0x32",
+			"---//\tpg-index=-123 ",
+			"+++// pg-index=222",
+			"+++// +build arm",
+			"+++",
+			"---  // +build\tignore",
+			"+++// +build ignore ",
+		},
+		-123,
+	},
+	"alpha/two/D-6.d": {
+		[]string{
+			"+++// pg-index=-12345678123456781234",
+			"+++foo",
+			"+++",
+		},
+		math.MaxInt64,
+	},
+	"beta/E-2.b": {
+		[]string{
+			"--- ",
+			"+++header",
+			"---\t// pg-index=1",
+			"+++ ",
+			"+++foobar",
+			"+++",
+		},
+		1,
+	},
+	"beta/one/F-7.a": {
+		[]string{
+			"---",
+			"--- ",
+			"---",
+		},
+		math.MaxInt64,
+	},
+	"beta/two/G-4": {
+		[]string{
+			"+++Elbereth",
+			"---// +build ignore ",
+			"+++",
+			"--- // pg-index=42",
+		},
+		42,
+	},
+}
+
+// Strips '+++' and '---' prefixes from file contents. If filter is set, omits
+// lines starting with '---'.
+func (tf *testFile) getContents(t *testing.T, filter bool) string {
+	filtered := make([]string, 0, len(tf.contents))
+	for _, line := range tf.contents {
+		if strings.HasPrefix(line, "+++") {
+			filtered = append(filtered, strings.TrimPrefix(line, "+++"))
+		} else if strings.HasPrefix(line, "---") {
+			if !filter {
+				filtered = append(filtered, strings.TrimPrefix(line, "---"))
+			}
+		} else {
+			t.Fatalf("Test file line %q missing '+++' or '---' prefix", line)
+		}
+	}
+	return strings.Join(filtered, "\n")
+}
+
+func TestFilterAndIndexCodeFile(t *testing.T) {
+	for filePath, fileDesc := range testFiles {
+		icf := filterAndIndexCodeFile(filePath, []byte(fileDesc.getContents(t, false)), false)
+		if got, want := icf.CodeFile.Name, filePath; got != want {
+			t.Errorf("Expected indexed file name %s, got %s", want, got)
+		}
+		if got, want := icf.CodeFile.Body, fileDesc.getContents(t, true); got != want {
+			t.Errorf("Expected indexed file %s contents %q, got %q", filePath, want, got)
+		}
+		if got, want := icf.index, fileDesc.index; got != want {
+			t.Errorf("Expected indexed file %s index %d, got %d", filePath, want, got)
+		}
+	}
+}
+
+// Writes testFiles to a temporary directory hierachy and passes the root path
+// to the test function.
+func testWithFiles(t *testing.T, test func(t *testing.T, dir string)) {
+	dir, err := ioutil.TempDir("", "pg-test-bundler-")
+	if err != nil {
+		t.Fatalf("Failed to create temporary directory: %v", err)
+	}
+	defer func() {
+		if err := os.RemoveAll(dir); err != nil {
+			t.Errorf("Failed to remove temporary directory: %v", err)
+		}
+	}()
+	for filePath, fileDesc := range testFiles {
+		fileContents := fileDesc.getContents(t, false)
+		fullPath := filepath.Join(dir, filePath)
+		if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
+			t.Fatalf("Failed to mkdir %q: %v", filepath.Dir(filePath), err)
+		}
+		if err := ioutil.WriteFile(fullPath, []byte(fileContents), 0644); err != nil {
+			t.Fatalf("Failed to write %q: %v", filePath, err)
+		}
+	}
+	test(t, dir)
+}
+
+func TestBundleWalkFail(t *testing.T) {
+	testWithFiles(t, func(t *testing.T, dir string) {
+		// rootPath points to a nonexistent dir
+		if _, err := MakeBundle(filepath.Join(dir, "nosuchdir"), []string{"*"}, true); err == nil {
+			t.Errorf("Expected bundling to fail with rootPath pointing to a nonexistent dir")
+		}
+
+		// rootPath points to a regular file, not dir
+		regularFilePath := filepath.Join(dir, "regular.txt")
+		if err := ioutil.WriteFile(regularFilePath, []byte("foobar"), 0755); err != nil {
+			t.Fatalf("Failed writing dummy regular file: %v", err)
+		}
+		if _, err := MakeBundle(filepath.Join(dir, regularFilePath), []string{"*"}, true); err == nil {
+			t.Errorf("Expected bundling to fail with rootPath pointing to a regular file")
+		}
+
+		// root dir contains inaccessible dir
+		protectedDirPath := filepath.Join(dir, "alpha", "secrets")
+		if err := os.MkdirAll(protectedDirPath, 0000); err != nil {
+			t.Fatalf("Failed creating dummy read-protected dir: %v", err)
+		}
+		if _, err := MakeBundle(dir, []string{"*"}, true); err == nil {
+			t.Errorf("Expected bundling to fail with read-protected dir in rootPath tree")
+		}
+	})
+}
+
+func printBundleFiles(t *testing.T, bundle *bundle.Bundle) {
+	gotFiles := make([]string, 0, len(bundle.Files))
+	for _, file := range bundle.Files {
+		gotFiles = append(gotFiles, file.Name)
+	}
+	t.Logf("Bundle files: %s.", strings.Join(gotFiles, "; "))
+}
+
+func printExpectFiles(t *testing.T, expectFiles []string) {
+	t.Logf("Expect files: %s.", strings.Join(expectFiles, "; "))
+}
+
+// Checks that the bundle contains files in expectFiles, in the same order,
+// with correctly filtered contents.
+func checkBundleFiles(t *testing.T, bundle *bundle.Bundle, prefix string, expectFiles []string, empty bool) {
+	if got, want := len(bundle.Files), len(expectFiles); got != want {
+		t.Errorf("Expected %d files in bundle, got %d", want, got)
+		printBundleFiles(t, bundle)
+		printExpectFiles(t, expectFiles)
+		return
+	}
+	for i, file := range bundle.Files {
+		if got, want := file.Name, expectFiles[i]; got != want {
+			t.Errorf("Bundle file mismatch at position %d: expected %s, got %s", i, want, got)
+			printBundleFiles(t, bundle)
+			printExpectFiles(t, expectFiles)
+			return
+		}
+		if empty {
+			if len(file.Body) > 0 {
+				t.Errorf("Expected bundle file %s to be empty", file.Name)
+			}
+		} else {
+			expectFileDesc, ok := testFiles[filepath.Join(prefix, expectFiles[i])]
+			if !ok {
+				t.Fatalf("Unknown expected bundle file %s for prefix %s", expectFiles[i], prefix)
+			} else if got, want := file.Body, expectFileDesc.getContents(t, true); got != want {
+				t.Errorf("Expected bundle file %s contents %q, got %q", want, got)
+			}
+		}
+	}
+}
+
+// Makes a bundle rooted in dir+prefix, filtered by globList. Expects bundle
+// to contain files in expectFiles, in the same order. If expectFiles is nil,
+// expects bundling to fail.
+func runBundle(t *testing.T, dir, prefix string, globList []string, expectFiles *[]string, empty bool) {
+	bundle, err := MakeBundle(filepath.Join(dir, prefix), globList, empty)
+	if expectFiles == nil {
+		if err == nil {
+			t.Errorf("Expected bundling to fail for prefix %q, globList %v", prefix, globList)
+			printBundleFiles(t, bundle)
+		}
+	} else {
+		if err != nil {
+			t.Errorf("Expected bundling to succeed for prefix %q, globList %v, got error: %v", prefix, globList, err)
+		} else {
+			checkBundleFiles(t, bundle, prefix, *expectFiles, empty)
+		}
+	}
+}
+
+func TestMakeBundle(t *testing.T) {
+	testWithFiles(t, func(t *testing.T, dir string) {
+		// all files, test proper sorting
+		runBundle(t, dir, "", []string{
+			"*",
+		}, &[]string{
+			"alpha/one/foo/C-1.a",
+			"beta/E-2.b",
+			"alpha/one/B-3.b",
+			"beta/two/G-4",
+			"alpha/one/A-5.a",
+			"alpha/two/D-6.d",
+			"beta/one/F-7.a",
+		}, true)
+
+		// no files, empty pattern
+		runBundle(t, dir, "", []string{}, &[]string{}, false)
+
+		// all files containing 'one' in path, suffix match is implied
+		runBundle(t, dir, "", []string{
+			"one/**",
+		}, &[]string{
+			"alpha/one/foo/C-1.a",
+			"alpha/one/B-3.b",
+			"alpha/one/A-5.a",
+			"beta/one/F-7.a",
+		}, false)
+
+		// more complex patterns
+		runBundle(t, dir, "", []string{
+			"alpha/*/*.*",
+			"beta/*",
+			"*-?",
+		}, &[]string{
+			"beta/E-2.b",
+			"alpha/one/B-3.b",
+			"beta/two/G-4",
+			"alpha/one/A-5.a",
+			"alpha/two/D-6.d",
+		}, true)
+
+		// files matched by multiple patterns should be included once
+		runBundle(t, dir, "", []string{
+			"*.a",
+			"beta/**",
+		}, &[]string{
+			"alpha/one/foo/C-1.a",
+			"beta/E-2.b",
+			"beta/two/G-4",
+			"alpha/one/A-5.a",
+			"beta/one/F-7.a",
+		}, false)
+
+		// pattern matches no regular files (but matches a folder), bundling fails
+		runBundle(t, dir, "", []string{
+			"foo",
+		}, nil, false)
+
+		// only one of the patterns matches no files, bundling still fails
+		runBundle(t, dir, "", []string{
+			"alpha/*/*.*",
+			"beta/*",
+			"idontexist",
+			"*-?",
+		}, nil, false)
+
+		// pathnames relative to bundling root
+		runBundle(t, dir, "alpha", []string{
+			"two/**",
+			"*.a",
+		}, &[]string{
+			"one/foo/C-1.a",
+			"one/A-5.a",
+			"two/D-6.d",
+		}, false)
+
+		// globs match relative to bundling root, so this fails
+		runBundle(t, dir, "alpha", []string{
+			"alpha/two/**",
+		}, nil, false)
+	})
+}
diff --git a/go/src/v.io/x/playground/lib/bundle/config.go b/go/src/v.io/x/playground/lib/bundle/bundler/config.go
similarity index 77%
rename from go/src/v.io/x/playground/lib/bundle/config.go
rename to go/src/v.io/x/playground/lib/bundle/bundler/config.go
index 6dffe13..419891b 100644
--- a/go/src/v.io/x/playground/lib/bundle/config.go
+++ b/go/src/v.io/x/playground/lib/bundle/bundler/config.go
@@ -4,10 +4,10 @@
 
 // Representation of the example bundle configuration file, parsed from JSON.
 // The configuration file specifies combinations of example folders and
-// applicable glob files for bundling default examples, as well as expected
+// applicable glob specs for bundling default examples, as well as expected
 // output for verifying their correctness.
 
-package bundle
+package bundler
 
 import (
 	"encoding/json"
@@ -19,10 +19,10 @@
 // Description of the bundle configuration file format.
 const BundleConfigFileDescription = `File must contain a JSON object of the following form:
    {
-    "examples": [ <Example> ... ], (array of Example objects)
-    "globs": { "<glob_name>":<Glob> ... } (map of glob names to Glob objects; glob names should be human-readable but URL-friendly)
+    "examples": [ <Example> ... ], (array of Example descriptors)
+    "globs": { "<glob_name>":<Glob> ... } (map of glob names to Glob descriptors; glob names should be human-readable but URL-friendly)
    }
-Example objects have the form:
+Example descriptors have the form:
    {
    	"name": "<name>", (example names should be human-readable but URL-friendly)
    	"path": "<path/to/example/dir>", (path to directory containing files to be filtered by globs and bundled)
@@ -31,10 +31,10 @@
    	"output": [ "<expected_output_regex>" ... ] (expected output specification for this example, for any applicable glob;
    		each regex must match at least one output line for the test to succeed)
    }
-Glob objects have the form:
+Glob descriptors have the form:
    {
-   	"path": "<path/to/glob_file>" (path to file containing a list of glob patterns;
-   		files from the example directory matching at least one pattern will be included in the bundle;
+   	"patterns": [ "<pattern>" ... ] (list of glob patterns, with syntax as accepted by github.com/bmatcuk/doublestar;
+   		files from the example directory with path suffix matching at least one pattern will be included in the bundle;
    		each glob pattern must match at least one file for the bundling to succeed)
    }
 Non-absolute paths are interpreted relative to a configurable directory, usually the configuration file directory.`
@@ -43,11 +43,11 @@
 type Config struct {
 	// List of Example folder descriptors.
 	Examples []*Example `json:"examples"`
-	// Maps glob names to Glob file descriptors.
+	// Maps glob names to Glob spec descriptors.
 	Globs map[string]*Glob `json:"globs"`
 }
 
-// Represents an example folder. Each specified glob file is applied to the
+// Represents an example folder. Each specified glob spec is applied to the
 // folder to produce a separate bundle, representing different implementations
 // of the same example.
 type Example struct {
@@ -55,16 +55,16 @@
 	Name string `json:"name"`
 	// Path to example directory.
 	Path string `json:"path"`
-	// Names of globs to apply to the directory.
+	// Names of glob specs to apply to the directory.
 	Globs []string `json:"globs"`
 	// Expected output regexes for testing.
 	Output []string `json:"output"`
 }
 
-// Represents a glob file for filtering bundled files.
+// Represents a glob spec for filtering bundled files.
 type Glob struct {
-	// Path to glob file.
-	Path string `json:"path"`
+	// List of glob patterns.
+	Patterns []string `json:"patterns"`
 }
 
 // Parses configuration from file and normalizes non-absolute paths relative to
@@ -82,15 +82,11 @@
 	return &cfg, nil
 }
 
-// Canonicalizes example and glob file paths and resolves them relative to
-// baseDir.
+// Canonicalizes example file paths and resolves them relative to baseDir.
 func (c *Config) NormalizePaths(baseDir string) {
 	for _, e := range c.Examples {
 		e.Path = normalizePath(e.Path, baseDir)
 	}
-	for _, g := range c.Globs {
-		g.Path = normalizePath(g.Path, baseDir)
-	}
 }
 
 // If path is not absolute, resolves path relative to baseDir. Otherwise,
diff --git a/go/src/v.io/x/playground/lib/bundle/pgbundle.go b/go/src/v.io/x/playground/lib/bundle/pgbundle.go
deleted file mode 100644
index ff20260..0000000
--- a/go/src/v.io/x/playground/lib/bundle/pgbundle.go
+++ /dev/null
@@ -1,71 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Implements bundling playground example files from a specified directory,
-// filtered using a specified glob file, into a JSON object compatible with
-// the playground client.
-// Currently implemented as a thin wrapper around the `pgbundle` executable
-// written in Node.js. Assumes that pgbundle has been installed in PATH or
-// by running `make pgbundle` in pgPackageDir.
-
-// TODO(ivanpi): Port pgbundle to Go.
-
-package bundle
-
-import (
-	"fmt"
-	"io"
-	"os"
-	"os/exec"
-	"path/filepath"
-)
-
-const (
-	nodeBinDir   = "${V23_ROOT}/third_party/cout/node/bin"
-	pgPackageDir = "${V23_ROOT}/release/projects/playground/go/src/v.io/x/playground"
-)
-
-const BundleUsage = `
-<glob_file>: Path to file containing a list of glob patterns, one per line.
-             The bundle includes only files with path suffixes matching one
-             of the globs. Each glob must match at least one file, otherwise
-             bundling fails with a non-zero exit code.
-<root_path>: Path to directory where files matching glob patterns are taken
-             from.
-`
-
-// Bundles files from rootPath matching patterns in globFile. See BundleUsage.
-// Errors and (if verbose) informational messages are written to errout.
-func Bundle(errout io.Writer, globFile, rootPath string, verbose bool) (outBundle []byte, rerr error) {
-	nodePath, err := resolveBinary("node", nodeBinDir)
-	if err != nil {
-		return nil, err
-	}
-	pgBundlePath, err := resolveBinary("pgbundle", filepath.Join(pgPackageDir, "node_modules", ".bin"))
-	if err != nil {
-		return nil, err
-	}
-	verboseFlag := "--no-verbose"
-	if verbose {
-		verboseFlag = "--verbose"
-	}
-	cmdPGBundle := exec.Command(nodePath, pgBundlePath, verboseFlag, globFile, rootPath)
-	cmdPGBundle.Dir = os.ExpandEnv(pgPackageDir)
-	cmdPGBundle.Stderr = errout
-	return cmdPGBundle.Output()
-}
-
-// Searches for a binary with the given name in PATH. If not found, defaults to
-// the binary with the same name in defaultDir.
-func resolveBinary(binName, defaultDir string) (string, error) {
-	var errPath, errDef error
-	binPath, errPath := exec.LookPath(binName)
-	if errPath != nil {
-		binPath, errDef = exec.LookPath(filepath.Join(os.ExpandEnv(defaultDir), binName))
-	}
-	if errDef != nil {
-		return "", fmt.Errorf("failed to resolve binary %q: %v; %v", binName, errPath, errDef)
-	}
-	return binPath, nil
-}
diff --git a/go/src/v.io/x/playground/pgadmin/bundle.go b/go/src/v.io/x/playground/pgadmin/bundle.go
index 7ce2de4..e0d9a79 100644
--- a/go/src/v.io/x/playground/pgadmin/bundle.go
+++ b/go/src/v.io/x/playground/pgadmin/bundle.go
@@ -3,10 +3,10 @@
 // license that can be found in the LICENSE file.
 
 // Bundle commands support bundling playground examples into JSON objects
-// compatible with the playground client. Glob files allow specifying file
+// compatible with the playground client. Glob filters allow specifying file
 // subsets for different implementations of the same example. Bundles specified
-// in a configuration file can be loaded into the database as named, default
-// examples.
+// in a configuration file can be individually bundled or loaded into the
+// database as named, default examples.
 
 package main
 
@@ -18,7 +18,7 @@
 	"v.io/x/lib/cmdline"
 	"v.io/x/lib/dbutil"
 	"v.io/x/playground/lib"
-	"v.io/x/playground/lib/bundle"
+	"v.io/x/playground/lib/bundle/bundler"
 	"v.io/x/playground/lib/storage"
 )
 
@@ -35,16 +35,21 @@
 var cmdBundleMake = &cmdline.Command{
 	Runner: cmdline.RunnerFunc(runBundleMake),
 	Name:   "make",
-	Short:  "Make a single manually specified bundle",
+	Short:  "Make a single bundle from config file",
 	Long: `
-Bundles the example specified by <root_path>, as filtered by <glob_file>, into
-a JSON object compatible with the playground client.
+Bundles the example named <example>, as filtered by <glob_spec>, specified
+in the bundle config file into a JSON object compatible with the playground
+client.
 `,
-	ArgsName: "<glob_file> <root_path>",
-	ArgsLong: bundle.BundleUsage,
+	ArgsName: "<example> <glob_spec>",
+	ArgsLong: `
+<example>: Name of example in config file to be bundled.
+
+<glob_spec>: Name of glob spec in config file to apply when bundling example.
+             Glob spec must be referenced by the example as a valid choice.
+`,
 }
 
-// TODO(ivanpi): Make a single bundle from config file instead of manually specified.
 // TODO(ivanpi): Add bundle metadata (title, description) via config file.
 // TODO(ivanpi): Iterate over config file, applying commands to bundles (similar to POSIX find)?
 var cmdBundleBootstrap = &cmdline.Command{
@@ -65,52 +70,85 @@
 var (
 	flagBundleCfgFile string
 	flagBundleDir     string
+	flagEmpty         bool
 )
 
 func init() {
-	cmdBundleBootstrap.Flags.StringVar(&flagBundleCfgFile, "bundleconf", defaultBundleCfg, "Path to bundle config file. "+bundle.BundleConfigFileDescription)
-	cmdBundleBootstrap.Flags.StringVar(&flagBundleDir, "bundledir", "", "Path relative to which paths in the bundle config file are interpreted. If empty, defaults to the config file directory.")
+	cmdBundle.Flags.StringVar(&flagBundleCfgFile, "bundleconf", defaultBundleCfg, "Path to bundle config file. "+bundler.BundleConfigFileDescription)
+	cmdBundle.Flags.StringVar(&flagBundleDir, "bundledir", "", "Path relative to which paths in the bundle config file are interpreted. If empty, defaults to the config file directory.")
+	cmdBundle.Flags.BoolVar(&flagEmpty, "empty", false, "Omit file contents in bundle, include only paths and metadata.")
 }
 
-// Bundles an example from the specified folder using the specified glob file.
-// TODO(ivanpi): Expose --verbose and --empty options.
+// Bundles an example from the specified folder using the specified glob.
 func runBundleMake(env *cmdline.Env, args []string) error {
 	if len(args) != 2 {
 		return env.UsageErrorf("exactly two arguments expected")
 	}
-	bOut, err := bundle.Bundle(env.Stderr, args[0], args[1], true)
+	exampleName, globName := args[0], args[1]
+	emptyFlagWarn(env)
+
+	bundleCfg, err := parseBundleConfig(env)
 	if err != nil {
-		return fmt.Errorf("Bundling failed: %v", err)
+		return err
 	}
-	fmt.Fprintln(env.Stdout, string(bOut))
-	return nil
+
+	glob, globExists := bundleCfg.Globs[globName]
+	if !globExists {
+		return fmt.Errorf("Unknown glob: %s", globName)
+	}
+
+	for _, example := range bundleCfg.Examples {
+		if example.Name == exampleName {
+			globValid := false
+			for _, gn := range example.Globs {
+				if gn == globName {
+					globValid = true
+				}
+			}
+			if !globValid {
+				return fmt.Errorf("Invalid glob for example %s: %s", example.Name, globName)
+			}
+
+			bOut, err := bundler.MakeBundleJson(example.Path, glob.Patterns, flagEmpty)
+			if err != nil {
+				return fmt.Errorf("Bundling %s with %s failed: %v", example.Name, globName, err)
+			}
+			fmt.Fprintln(env.Stdout, string(bOut))
+			if logVerbose() {
+				fmt.Fprintf(env.Stderr, "Bundled %s using %s\n", example.Name, globName)
+			}
+
+			return nil
+		}
+	}
+	return fmt.Errorf("Unknown example: %s", exampleName)
 }
 
 // Returns a cmdline.RunnerFunc for loading all bundles specified in the bundle
 // config file into the database as default bundles.
 func runBundleBootstrap(env *cmdline.Env, args []string) error {
-	bundleDir := os.ExpandEnv(flagBundleDir)
-	// If bundleDir is empty, interpret paths relative to bundleCfg directory.
-	if bundleDir == "" {
-		bundleDir = filepath.Dir(os.ExpandEnv(flagBundleCfgFile))
-	}
-	bundleCfg, err := bundle.ParseConfigFromFile(os.ExpandEnv(flagBundleCfgFile), bundleDir)
+	emptyFlagWarn(env)
+	bundleCfg, err := parseBundleConfig(env)
 	if err != nil {
-		return fmt.Errorf("Failed parsing bundle config from %q: %v", os.ExpandEnv(flagBundleCfgFile), err)
+		return err
 	}
 
 	var newDefBundles []*storage.NewBundle
 	for _, example := range bundleCfg.Examples {
-		fmt.Fprintf(env.Stdout, "Bundling example: %s (%q)\n", example.Name, example.Path)
+		if logVerbose() {
+			fmt.Fprintf(env.Stderr, "Bundling example: %s (%q)\n", example.Name, example.Path)
+		}
 
 		for _, globName := range example.Globs {
 			glob, globExists := bundleCfg.Globs[globName]
 			if !globExists {
-				return fmt.Errorf("Unknown glob %q", globName)
+				return fmt.Errorf("Unknown glob: %s", globName)
 			}
-			fmt.Fprintf(env.Stdout, "> glob: %s (%q)\n", globName, glob.Path)
+			if logVerbose() {
+				fmt.Fprintf(env.Stderr, "> glob: %s\n", globName)
+			}
 
-			bOut, err := bundle.Bundle(env.Stderr, glob.Path, example.Path, false)
+			bOut, err := bundler.MakeBundleJson(example.Path, glob.Patterns, flagEmpty)
 			if err != nil {
 				return fmt.Errorf("Bundling %s with %s failed: %v", example.Name, globName, err)
 			}
@@ -126,41 +164,65 @@
 	}
 
 	if *flagDryRun {
-		fmt.Fprintf(env.Stdout, "Run without dry run to load %d bundles into database\n", len(newDefBundles))
+		fmt.Fprintf(env.Stderr, "Run without dry run to load %d bundles into database\n", len(newDefBundles))
 	} else {
 		// Unmark old default bundles and store new ones.
 		if err := storage.ReplaceDefaultBundles(newDefBundles); err != nil {
 			return fmt.Errorf("Failed to replace default bundles: %v", err)
 		}
-		fmt.Fprintf(env.Stdout, "Successfully loaded %d bundles into database\n", len(newDefBundles))
+		if logVerbose() {
+			fmt.Fprintf(env.Stderr, "Successfully loaded %d bundles into database\n", len(newDefBundles))
+		}
 	}
 	return nil
 }
 
+func emptyFlagWarn(env *cmdline.Env) {
+	if logVerbose() && flagEmpty {
+		fmt.Fprintf(env.Stderr, "Flag -empty set, omitting file contents\n")
+	}
+}
+
+func parseBundleConfig(env *cmdline.Env) (*bundler.Config, error) {
+	bundleCfgFile := os.ExpandEnv(flagBundleCfgFile)
+	bundleDir := os.ExpandEnv(flagBundleDir)
+	// If bundleDir is empty, interpret paths relative to bundleCfg directory.
+	if bundleDir == "" {
+		bundleDir = filepath.Dir(bundleCfgFile)
+	}
+	bundleCfg, err := bundler.ParseConfigFromFile(bundleCfgFile, bundleDir)
+	if err != nil {
+		return nil, fmt.Errorf("Failed parsing bundle config from %q: %v", bundleCfgFile, err)
+	}
+	return bundleCfg, nil
+}
+
 // runWithStorage is a wrapper method that handles opening and closing the
 // database connections used by `v.io/x/playground/lib/storage`.
 func runWithStorage(fx cmdline.RunnerFunc) cmdline.RunnerFunc {
 	return func(env *cmdline.Env, args []string) (rerr error) {
-		if *flagSQLConf == "" {
-			return env.UsageErrorf("SQL configuration file (-sqlconf) must be provided")
-		}
-
-		// Parse SQL configuration file and set up TLS.
-		dbConf, err := dbutil.ActivateSqlConfigFromFile(*flagSQLConf)
-		if err != nil {
-			return fmt.Errorf("Error parsing SQL configuration: %v", err)
-		}
-		// Connect to storage backend.
-		if err := storage.Connect(dbConf); err != nil {
-			return fmt.Errorf("Error opening database connection: %v", err)
-		}
-		// Best effort close.
-		defer func() {
-			if cerr := storage.Close(); cerr != nil {
-				cerr = fmt.Errorf("Failed closing database connection: %v", cerr)
-				rerr = lib.MergeErrors(rerr, cerr, "\n")
+		if !*flagDryRun {
+			if *flagSQLConf == "" {
+				return env.UsageErrorf("SQL configuration file (-sqlconf) must be provided")
 			}
-		}()
+
+			// Parse SQL configuration file and set up TLS.
+			dbConf, err := dbutil.ActivateSqlConfigFromFile(*flagSQLConf)
+			if err != nil {
+				return fmt.Errorf("Error parsing SQL configuration: %v", err)
+			}
+			// Connect to storage backend.
+			if err := storage.Connect(dbConf); err != nil {
+				return fmt.Errorf("Error opening database connection: %v", err)
+			}
+			// Best effort close.
+			defer func() {
+				if cerr := storage.Close(); cerr != nil {
+					cerr = fmt.Errorf("Failed closing database connection: %v", cerr)
+					rerr = lib.MergeErrors(rerr, cerr, "\n")
+				}
+			}()
+		}
 
 		// Run wrapped function.
 		return fx(env, args)
diff --git a/go/src/v.io/x/playground/pgadmin/main.go b/go/src/v.io/x/playground/pgadmin/main.go
index ef53569..6b3be43 100644
--- a/go/src/v.io/x/playground/pgadmin/main.go
+++ b/go/src/v.io/x/playground/pgadmin/main.go
@@ -28,8 +28,13 @@
 }
 
 var (
-	flagDryRun = flag.Bool("n", false, "Show what commands will run, but do not execute them.")
+	flagDryRun  = flag.Bool("n", false, "Show necessary database modifications, but do not apply them.")
+	flagVerbose = flag.Bool("v", true, "Show more verbose output.")
 
 	// Path to SQL configuration file, as described in v.io/x/lib/dbutil/mysql.go. Required parameter for most commands.
 	flagSQLConf = flag.String("sqlconf", "", "Path to SQL configuration file. "+dbutil.SqlConfigFileDescription)
 )
+
+func logVerbose() bool {
+	return *flagDryRun || *flagVerbose
+}
diff --git a/go/src/v.io/x/playground/pgadmin/migrate.go b/go/src/v.io/x/playground/pgadmin/migrate.go
index c66eae4..1542791 100644
--- a/go/src/v.io/x/playground/pgadmin/migrate.go
+++ b/go/src/v.io/x/playground/pgadmin/migrate.go
@@ -93,9 +93,9 @@
 				return fmt.Errorf("Failed getting migrations to apply: %v", err)
 			}
 			for i, m := range planned {
-				fmt.Fprintf(env.Stdout, "#%d: %q\n", i, m.Migration.Id)
+				fmt.Fprintf(env.Stderr, "#%d: %q\n", i, m.Migration.Id)
 				for _, q := range m.Queries {
-					fmt.Fprint(env.Stdout, q)
+					fmt.Fprint(env.Stderr, q)
 				}
 			}
 			return nil
@@ -104,7 +104,9 @@
 			if err != nil {
 				return fmt.Errorf("Migration FAILED (applied %d migrations): %v", amount, err)
 			}
-			fmt.Fprintf(env.Stdout, "Successfully applied %d migrations\n", amount)
+			if logVerbose() {
+				fmt.Fprintf(env.Stderr, "Successfully applied %d migrations\n", amount)
+			}
 			return nil
 		}
 	}
diff --git a/pgbundle/.gitignore b/pgbundle/.gitignore
deleted file mode 100644
index 93f1361..0000000
--- a/pgbundle/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-node_modules
-npm-debug.log
diff --git a/pgbundle/.jshintignore b/pgbundle/.jshintignore
deleted file mode 100644
index 3c3629e..0000000
--- a/pgbundle/.jshintignore
+++ /dev/null
@@ -1 +0,0 @@
-node_modules
diff --git a/pgbundle/.jshintrc b/pgbundle/.jshintrc
deleted file mode 100644
index b264540..0000000
--- a/pgbundle/.jshintrc
+++ /dev/null
@@ -1,27 +0,0 @@
-{
-  "camelcase": true,
-  "eqeqeq": true,
-  "expr": true,
-  "forin": true,
-  "freeze": true,
-  "immed": true,
-  "indent": 2,
-  "latedef": "nofunc",
-  "maxlen": 80,
-  "newcap": true,
-  "noarg": true,
-  "nonbsp": true,
-  "nonew": true,
-  "quotmark": "single",
-  "sub": true,
-  "trailing": true,
-  "undef": true,
-  "unused": "vars",
-
-  "browser": true,
-  "devel": true,
-  "node": true,
-
-  "globals": {
-  }
-}
diff --git a/pgbundle/Makefile b/pgbundle/Makefile
deleted file mode 100644
index 93ff2e1..0000000
--- a/pgbundle/Makefile
+++ /dev/null
@@ -1,25 +0,0 @@
-PATH := node_modules/.bin:$(PATH)
-PATH := $(PATH):$(V23_ROOT)/third_party/cout/node/bin
-SHELL := /bin/bash -euo pipefail
-
-.DEFAULT_GOAL := node_modules
-
-.DELETE_ON_ERROR:
-
-node_modules: package.json
-	@npm prune
-	@npm install
-	@touch $@
-
-.PHONY: clean
-clean:
-	@$(RM) -rf node_modules
-	@$(RM) -rf npm-debug.log
-
-.PHONY: dependency-check
-dependency-check: package.json node_modules
-	dependency-check $<
-
-.PHONY: lint
-lint: dependency-check
-	jshint .
diff --git a/pgbundle/bin/pgbundle b/pgbundle/bin/pgbundle
deleted file mode 100755
index 92d278b..0000000
--- a/pgbundle/bin/pgbundle
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env node
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-require('../index.js').run();
diff --git a/pgbundle/index.js b/pgbundle/index.js
deleted file mode 100644
index 8fd5193..0000000
--- a/pgbundle/index.js
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var _ = require('lodash');
-var fs = require('fs');
-var glob = require('glob');
-var path = require('path');
-
-module.exports = {run: run};
-
-function usage() {
-  console.error('Usage: pgbundle [options] <glob_file> <root_path>');
-  console.error('Arguments: <glob_file>: Path to file containing a list of' +
-    ' glob patterns, one per line. The bundle includes only files with path' +
-    ' suffixes matching one of the globs. Each glob must match at least one' +
-    ' file, otherwise bundling fails with a non-zero exit code.');
-  console.error('           <root_path>: Path to directory where files' +
-    ' matching glob patterns are taken from.');
-  console.error('Options: --verbose: Enable verbose output.');
-  console.error('         --empty: Omit file contents in bundle, include only' +
-    ' paths and metadata.');
-  process.exit(1);
-}
-
-// Strip the first encountered "// +build ignore" line in the file and return
-// the remaining lines.
-function stripBuildIgnore(lines) {
-  var found = false;
-  return _.filter(lines, function(line) {
-    if (!found && (_.trim(line) === '// +build ignore')) {
-      found = true;
-      return false;
-    }
-    return true;
-  });
-}
-
-// Strip the first encountered "// pg-index=<num>" line in the file and return
-// the index value and remaining lines.
-function getIndex(lines) {
-  var index = null;
-  lines = _.filter(lines, function(line) {
-    var match = _.trim(line).match(/^\/\/\s*pg-index=(\d+)/);
-    if (!index && match && match[1]) {
-      index = match[1];
-      return false;
-    }
-    return true;
-  });
-  return {
-    index: index ? Number(index) : Infinity,
-    lines: lines
-  };
-}
-
-// Strip all blank lines at the beginning of the file.
-function stripLeadingBlankLines(lines) {
-  var nb = 0;
-  for (; nb < lines.length && _.trim(lines[nb]) === ''; ++nb) /* no-op */;
-  return _.slice(lines, nb);
-}
-
-// Main function.
-function run() {
-  // Get the flags and positional arguments from process.argv.
-  var argv = require('minimist')(process.argv.slice(2), {
-    boolean: ['verbose', 'empty']
-  });
-
-  // Make sure the glob file and the root path path are specified.
-  if (!argv._ || argv._.length !== 2) {
-    return usage();
-  }
-
-  var globFile = argv._[0];
-  var dir = argv._[1];
-  // Read glob file, filtering out empty lines.
-  var patterns = _.filter(
-    fs.readFileSync(globFile, {encoding: 'utf8'}).split('\n'));
-  // The root path must be a directory.
-  if (!fs.lstatSync(dir).isDirectory()) {
-    return usage();
-  }
-
-  var unmatched = [];
-
-  // Apply each glob pattern to the directory.
-  var relpaths = _.flatten(_.map(patterns, function(pattern) {
-    var match = glob.sync('**/' + pattern, {
-      cwd: dir,
-      nodir: true
-    });
-    if (match.length === 0) {
-      unmatched.push(pattern);
-    }
-    return match;
-  }));
-
-  // If any pattern matched zero files, halt with a non-zero exit code.
-  // TODO(ivanpi): Allow optional patterns, e.g. prefixed by '?'?
-  if (unmatched.length > 0) {
-    console.warn('Error bundling "%s": unmatched patterns %j', dir, unmatched);
-    process.exit(2);
-  }
-
-  var out = {files: []};
-
-  // Loop over each file.
-  _.each(relpaths, function(relpath) {
-    var abspath = path.resolve(dir, relpath);
-    var lines = fs.readFileSync(abspath, {encoding: 'utf8'}).split('\n');
-
-    lines = stripBuildIgnore(lines);
-    var indexAndLines = getIndex(lines);
-    var index = indexAndLines.index;
-    lines = indexAndLines.lines;
-    lines = stripLeadingBlankLines(lines);
-
-    out.files.push({
-      name: relpath,
-      body: argv.empty ? '' : lines.join('\n'),
-      index: index
-    });
-  });
-
-  out.files = _.sortBy(out.files, 'index');
-
-  // Drop the index fields -- we don't need them anymore.
-  out.files = _.map(out.files, function(f) {
-    return _.omit(f, 'index');
-  });
-
-  // Write the bundle to stdout.
-  process.stdout.write(JSON.stringify(out) + '\n');
-
-  if (argv.verbose) {
-    console.warn('Bundled "%s" using "%s"', dir, globFile);
-  }
-}
diff --git a/pgbundle/package.json b/pgbundle/package.json
deleted file mode 100644
index 9fc00e0..0000000
--- a/pgbundle/package.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
-  "private": true,
-  "name": "pgbundle",
-  "description": "Vanadium playground file bundler",
-  "version": "0.0.1",
-  "bin": "./bin/pgbundle",
-  "bugs": {
-    "url": "https://github.com/vanadium/issues/issues"
-  },
-  "dependencies": {
-    "glob": "^4.3.5",
-    "lodash": "^3.0.0",
-    "minimist": "^1.1.0"
-  },
-  "homepage": "https://vanadium.googlesource.com/release.projects.playground/+/master/pgbundle",
-  "devDependencies": {
-    "dependency-check": "^2.4.0",
-    "jshint": "^2.6.0"
-  },
-  "repository": {
-    "type": "git",
-    "url": "https://vanadium.googlesource.com/release.projects.playground"
-  },
-  "license": "BSD"
-}