Starting Travelsync integration

Change-Id: I07b0ddf1060c4a5f4b9167597e7ddb29f384530e
diff --git a/.gitignore b/.gitignore
index 21261d6..36dd53c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
 /ifc
 /node_modules
 /server-root
+/bin
+/tmp
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 90a9d76..7da5f6a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
 PATH := node_modules/.bin:$(PATH)
-PATH := $(PATH):$(V23_ROOT)/third_party/cout/node/bin
+PATH := $(PATH):$(V23_ROOT)/third_party/cout/node/bin:$(V23_ROOT)/release/go/bin
 
 .DEFAULT_GOAL := all
 
@@ -9,12 +9,12 @@
 server_static := $(patsubst src/static/%,server-root/%,$(wildcard src/static/*))
 tests := $(patsubst %.js,%,$(shell find test -name "*.js"))
 
-out_dirs := ifc server-root node_modules
+out_dirs := ifc server-root node_modules bin
 
 .DELETE_ON_ERROR:
 
 .PHONY: all
-all: static js
+all: static js bin
 	@true
 
 .PHONY: static
@@ -23,14 +23,20 @@
 .PHONY: js
 js: server-root/bundle.js
 
+bin:
+	@v23 go build -a -o $@/syncbased v.io/syncbase/x/ref/services/syncbase/syncbased
+	@touch $@
+
 ifc: src/ifc/*
 	@VDLPATH=src vdl generate -lang=javascript -js-out-dir=. ifc
 
 node_modules: package.json
 	@npm prune
 	@npm install
-	@npm install $(V23_ROOT)/release/javascript/core/ #TODO: remove
-	@touch node_modules # if npm does nothing, we don't want to keep trying
+	@ # TODO(rosswang): remove these two
+	@npm install $(V23_ROOT)/release/javascript/core/
+	@npm install $(V23_ROOT)/roadmap/javascript/syncbase/
+	@touch $@ # if npm does nothing, we don't want to keep trying
 
 server-root:
 	@mkdir server-root
@@ -57,6 +63,24 @@
 start: all
 	@static server-root -p $(port)
 
+.PHONY: bootstrap
+bootstrap: creds syncbase
+
+.PHONY: creds
+creds:
+	@principal seekblessings --v23.credentials tmp/creds
+
+.PHONY: syncbase
+syncbase: bin
+	@bash ./tools/start_services.sh
+
+.PHONY: clean-all
+clean-all: clean clean-tmp
+
 .PHONY: clean
 clean:
 	rm -rf $(out_dirs)
+
+.PHONY: clean-tmp
+clean-tmp:
+	rm -rf tmp
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ab650b9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,46 @@
+# Travel Planner
+
+An example travel planner using Vanadium.
+
+## Dependencies
+
+If you have a `$V23_ROOT` setup you can install Node.js from
+`$V23_ROOT/third_party` by running:
+
+    v23 profile install nodejs
+
+Optionally, it is possible to use your own install of Node.js if you would like
+to use a more recent version.
+
+In order to run the local syncbase instance via `make bootstrap` or related
+targets, you will need to ensure that the standard Vanadium binaries have been
+built by running:
+
+    v23 go install v.io/...
+
+## Building
+
+The default make task will install any modules listed in the `package.json` and
+build a browser bundle from `src/index.js` via browserify.
+
+    make
+
+It is possible to have the build happen automatically anytime a JavaScript file
+changes using the watch tool:
+
+    watch make
+
+## Running locally
+
+Local instances require a blessed syncbase instance. To attain blessings and
+start syncbase, use:
+
+    make bootstrap
+
+To run a local dev server use:
+
+    make start
+
+If you would like to change the host and or port that is used:
+
+    make start port=<port>
diff --git a/mocks/vanadium.js b/mocks/vanadium.js
index f1354a2..7b4b725 100644
--- a/mocks/vanadium.js
+++ b/mocks/vanadium.js
@@ -3,6 +3,7 @@
 // license that can be found in the LICENSE file.
 
 var defineClass = require('../src/util/define-class');
+var Deferred = require('vanadium/src/lib/deferred');
 
 var MockRuntime = defineClass({
   publics: {
@@ -29,11 +30,16 @@
   publics: {
     init: function(config, callback) {
       this.t.ok(config, 'has config');
-      this.callback = callback;
+      this.deferred = new Deferred(callback);
+      return this.deferred.promise;
     },
 
     finishInit: function(err, runtime) {
-      this.callback(err, runtime);
+      if (err) {
+        this.deferred.reject(err);
+      } else {
+        this.deferred.resolve(runtime);
+      }
     }
   },
 
diff --git a/package.json b/package.json
index 1c4c638..9a0ee71 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
   "dependencies": {
     "global": "^4.3.0",
     "jquery": "^2.1.4",
+    "es6-promisify": "^2.0.0",
     "uuid": "^2.0.1"
   }
 }
diff --git a/src/components/message.js b/src/components/message.js
index 49c08a9..e51c489 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -8,23 +8,25 @@
 var INFO = 'INFO';
 var ERROR = 'ERROR';
 
+function info(text) {
+  return {
+    type: INFO,
+    text: text
+  };
+}
+
+function error(text) {
+  return {
+    type: ERROR,
+    text: text
+  };
+}
+
 module.exports = {
   INFO: INFO,
   ERROR: ERROR,
-
-  info: function(text) {
-    return {
-      type: INFO,
-      text: text
-    };
-  },
-
-  error: function(text) {
-    return {
-      type: ERROR,
-      text: text
-    };
-  },
+  info: info,
+  error: error,
 
   Message: defineClass({
     publics: {
@@ -46,12 +48,39 @@
       },
 
       set: function(message) {
+        if (!message) {
+          this.onLowerPriority();
+          return;
+        }
+
+        if (typeof message === 'string') {
+          message = info(message);
+        }
+
+        var self = this;
+
         this.setType(message.type);
         this.setText(message.text);
+
+        if (message.promise) {
+          message.promise.then(function(message) {
+            self.set(message);
+          }, function(err) {
+            self.set(error(err));
+          });
+        } else {
+          this.onLowerPriority();
+        }
       }
     },
 
-    constants: ['$'],
+    constants: [ '$' ],
+    events: {
+      /**
+       * Event raised when the message is no longer pending user action.
+       */
+      onLowerPriority: 'memory once'
+    },
 
     init: function(initial) {
       this.$ = $('<li>');
diff --git a/src/components/messages.js b/src/components/messages.js
index 0b39920..5c5c956 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -13,30 +13,31 @@
     TTL: 9000,
     FADE: 1000,
     SLIDE_UP: 300,
-    OPEN_CLOSE: 500
+    OPEN_CLOSE: 400
   },
 
   publics: {
     close: function() {
       var self = this;
+      var $messages = this.$messages;
 
       if (this.isOpen()) {
-        if (this.$messages.children().length) {
+        if ($messages.children().length) {
           var scrollOffset =
-            this.$messages.scrollTop() + self.$messages.height();
+            $messages.scrollTop() + $messages.height();
 
           this.$messages
             .addClass('animating')
             .animate({ height: 0 }, {
               duration: this.OPEN_CLOSE,
               progress: function() {
-                self.$messages.scrollTop(
-                  scrollOffset - self.$messages.height());
+                $messages.scrollTop(
+                  scrollOffset - $messages.height());
               },
               complete: function() {
-                self.$messages.removeClass('animating');
+                $messages.removeClass('animating');
                 self.$.addClass('headlines');
-                self.$messages.attr('style', null);
+                $messages.attr('style', null);
               }
             });
         } else {
@@ -46,8 +47,7 @@
     },
 
     isClosed: function() {
-      return this.$.hasClass('headlines') &&
-        !this.$messages.hasClass('animating');
+      return this.$.hasClass('headlines');
     },
 
     isOpen: function() {
@@ -56,28 +56,28 @@
     },
 
     open: function() {
-      var self = this;
+      var $messages = this.$messages;
 
       if (!this.isOpen()) {
-        var $animating = this.$.find('.animating');
-        $animating.stop(true);
-        $animating.removeClass('animating');
-        $animating.attr('style', null);
+        this.$.find('.animating')
+          .stop(true)
+          .removeClass('animating')
+          .attr('style', null);
 
         this.$.removeClass('headlines');
-        if (this.$messages.children().length) {
-          var goalHeight = this.$messages.height();
-          this.$messages
+        if ($messages.children().length) {
+          var goalHeight = $messages.height();
+          $messages
             .addClass('animating')
             .height(0)
             .animate({ height: goalHeight }, {
               duration: this.OPEN_CLOSE,
               progress: function() {
-                self.$messages.scrollTop(self.$messages.prop('scrollHeight'));
+                $messages.scrollTop($messages.prop('scrollHeight'));
               },
               complete: function() {
-                self.$messages.removeClass('animating');
-                self.$messages.attr('style', null);
+                $messages.removeClass('animating');
+                $messages.attr('style', null);
               }
             });
         }
@@ -85,6 +85,8 @@
     },
 
     push: function(messageData) {
+      var self = this;
+
       var messageObject = new message.Message(messageData);
       this.$messages.append(messageObject.$);
 
@@ -107,17 +109,28 @@
          * It would be best to use CSS animations, but at this time that would
          * mean sacrificing either auto-height or flow-affecting sliding.
          */
-        messageObject.$.addClass('animating');
         messageObject.$
-          .slideDown(this.SLIDE_DOWN)
-          .delay(this.TTL)
-          .animate({ opacity: 0 }, this.FADE)
-          .slideUp(this.SLIDE_UP, function() {
-            messageObject.$
-              .removeClass('animating')
-              .attr('style', null);
-          });
+          .addClass('animating')
+          .hide()
+          .slideDown(this.SLIDE_DOWN);
       }
+
+      messageObject.onLowerPriority.add(function() {
+        messageObject.$.addClass('history');
+
+        if (self.isClosed()) {
+          messageObject.$
+            .addClass('animating')
+            .show()
+            .delay(Messages.TTL)
+            .animate({ opacity: 0 }, Messages.FADE)
+            .slideUp(Messages.SLIDE_UP, function() {
+              messageObject.$
+                .removeClass('animating')
+                .attr('style', null);
+            });
+        }
+      });
     },
 
     toggle: function() {
diff --git a/src/static/index.css b/src/static/index.css
index f62f541..f7dad01 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -75,11 +75,12 @@
 
 .messages .handle {
   background-color: rgba(192, 192, 192, .95);
-  border: 1px solid #aaa;
+  border-bottom: 1px solid #aaa;
   border-radius: 2px;
   color: #444;
   cursor: pointer;
   text-align: center;
+  transition: background-color .2s, border-bottom .2s;
 
   -webkit-touch-callout: none;
   -webkit-user-select: none;
@@ -91,7 +92,8 @@
 
 .messages.headlines .handle {
   background-color: rgba(255, 255, 255, .5);
-  border: initial;
+  border-bottom: initial;
+  transition: background-color .2s, border-bottom .2s;
 }
 
 .handle:before {
@@ -130,10 +132,13 @@
   background-color: rgba(0, 0, 0, .6);
   border-radius: 4px;
   color: white;
-  display: none;
   margin-top: 3px;
 }
 
+.messages.headlines li.history {
+  display: none;
+}
+
 .messages li:before {
   font-weight: bold;
 }
diff --git a/src/strings.js b/src/strings.js
index a6a76d6..f73188a 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -4,6 +4,8 @@
 
 function getStrings(locale) {
   return {
+    'Connected to all services.': 'Connected to all services.',
+    'Connecting...': 'Connecting...',
     'Destination': 'Destination',
     destination: function(n) {
       return 'Destination ' + n;
diff --git a/src/travel.js b/src/travel.js
index f511382..939bca6 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -29,8 +29,10 @@
       this.map.message(message.error(err.toString()));
     },
 
-    info: function (info) {
-      this.map.message(message.info(info));
+    info: function (info, promise) {
+      var messageData = message.info(info);
+      messageData.promise = promise;
+      this.map.message(messageData);
     }
   },
 
@@ -39,20 +41,24 @@
     var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
     var travel = this;
 
+    this.map = new Map(opts);
     this.sync = new TravelSync();
 
     var reportError = $.proxy(this, 'error');
 
-    vanadiumWrapper.init(opts.vanadium).then(
-      function(wrapper) {
+    this.info(strings['Connecting...'], vanadiumWrapper.init(opts.vanadium)
+      .then(function(wrapper) {
         wrapper.onCrash.add(reportError);
 
         var identity = new Identity(wrapper.getAccountName());
         identity.mountName = makeMountName(identity);
-        travel.sync.start(identity.mountName, wrapper).catch(reportError);
-      }, reportError);
-
-    this.map = new Map(opts);
+        return travel.sync.start(identity.mountName, wrapper);
+      }).then(function() {
+        return strings['Connected to all services.'];
+      }, function(err) {
+        console.error(err);
+        throw err;
+      }));
 
     var directionsServiceStatusStrings = buildStatusErrorStringMap(
       this.map.maps.DirectionsStatus, strings.DirectionsStatus);
diff --git a/src/travelsync.js b/src/travelsync.js
index 5caf3e5..1717000 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -2,17 +2,90 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//TODO(rosswang): either expect ES6, use our own polyfill, or export this in V23
+var Promise = require('vanadium/src/lib/promise');
+
+var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
 
 var vdlTravel = require('../ifc');
 
 var TravelSync = defineClass({
-  events: ['onMessage', 'onPlanUpdate', 'onStatusUpdate'],
+  publics: {
+    start: function(mountName, v) {
+      var self = this;
+      var startSyncbase = v.syncbase('/localhost:4001/syncbase').then(
+        function(syncbase) {
+          self.syncbase = syncbase;
+          syncbase.onError.add(self.onError);
+          syncbase.onUpdate.add(self.processUpdates.bind(self));
+        });
+
+      return Promise.all([
+        v.server(mountName, this.server),
+        startSyncbase
+      ]);
+    },
+
+    message: function(messageContent) {
+
+    },
+
+    pushTrip: function() {
+    },
+
+    pushStatus: function() {
+    }
+  },
+
+  privates: {
+    marshal: function(x) {
+      return JSON.stringify(x);
+    },
+
+    unmarshal: function(x) {
+      return JSON.parse(x);
+    },
+
+    processUpdates: function(data) {
+      var self = this;
+      if (data.messages) {
+        /* Dispatch new messages in time order, though don't put them before
+         * local messages. */
+        var newMessages = [];
+        $.each(data.messages, function(id, serializedMessage) {
+          if (!self.messages[id]) {
+            var message = self.unmarshal(serializedMessage);
+            newMessages.push(message);
+            self.messages[id] = message;
+          }
+        });
+        newMessages.sort(function(a, b) {
+          return a.timestamp < b.timestamp? -1 :
+                 a.timestamp > b.timestamp?  1 :
+                                             0;
+        });
+
+        self.onMessages(newMessages);
+      }
+    }
+  },
+
+  events: {
+    onError: 'memory',
+    /**
+     * @param messages array of {content, timestamp} pair objects.
+     */
+    onMessages: '',
+    onPlanUpdate: '',
+    onStatusUpdate: ''
+  },
+
   init: function() {
     this.tripPlan = [];
     this.tripStatus = {};
+    this.messages = {};
 
-    // TODO: sync initial state
     this.server = new vdlTravel.TravelSync();
 
     var travelSync = this;
@@ -33,15 +106,6 @@
       travelSync.tripStatus = status;
       travelSync.onStatusUpdate(status);
     };
-  },
-  publics: {
-    start: function(mountName, v) {
-      return v.server(mountName, this.server);
-    },
-    pushTrip: function() {
-    },
-    pushStatus: function() {
-    }
   }
 });
 
diff --git a/src/vanadium-wrapper.js b/src/vanadium-wrapper/index.js
similarity index 78%
rename from src/vanadium-wrapper.js
rename to src/vanadium-wrapper/index.js
index 0662bad..d6f52c4 100644
--- a/src/vanadium-wrapper.js
+++ b/src/vanadium-wrapper/index.js
@@ -2,10 +2,10 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-var $ = require('./util/jquery');
-
 var vanadiumDefault = require('vanadium');
-var defineClass = require('./util/define-class');
+var defineClass = require('../util/define-class');
+
+var SyncbaseWrapper = require('./syncbase-wrapper');
 
 var VanadiumWrapper = defineClass({
   init: function(runtime) {
@@ -34,6 +34,13 @@
      */
     server: function(endpoint, server) {
       return this.runtime.newServer().serve(endpoint, server);
+    },
+
+    /**
+     * @param endpoint Vanadium name
+     */
+    syncbase: function(endpoint) {
+      return SyncbaseWrapper.start(this.runtime.getContext(), endpoint);
     }
   },
 
@@ -56,16 +63,8 @@
       appName: 'Travel Planner'
     };
 
-    var async = $.Deferred();
-
-    vanadium.init(config, function(err, runtime) {
-      if (err) {
-        async.reject(err);
-      } else {
-        async.resolve(new VanadiumWrapper(runtime));
-      }
+    return vanadium.init(config).then(function(runtime) {
+      return new VanadiumWrapper(runtime);
     });
-
-    return async.promise();
   }
 };
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
new file mode 100644
index 0000000..a7f6cba
--- /dev/null
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -0,0 +1,117 @@
+// 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 promisify = require('es6-promisify');
+var syncbase = require('syncbase');
+
+var defineClass = require('../util/define-class');
+
+/**
+ * Create app, db, and table structure in Syncbase.
+ */
+function setUp(context, app, db) {
+  function nonfatals(err) {
+    switch (err.id) {
+      case 'v.io/v23/verror.Exist':
+        console.info(err.msg);
+        return;
+      default:
+        throw err;
+    }
+  }
+
+  //TODO(rosswang) If {} will remain empty, can it be omitted?
+  return promisify(app.create.bind(app))(context, {})
+    .catch(nonfatals)
+    .then(function() {
+      return promisify(db.create.bind(db))(context, {});
+    })
+    .catch(nonfatals)
+    .then(function() {
+      return promisify(db.createTable.bind(db))(context, 't', {});
+    })
+    .catch(nonfatals);
+}
+
+/**
+ * Translate Syncbase hierarchical keys to object structure for easier
+ * processing. '.' is chosen as the separator; '/' is reserved in Syncbase.
+ */
+function recursiveSet(root, key, value) {
+  var matches = /\.?([^\.]*)(.*)/.exec(key);
+  var member = matches[1];
+  var remaining = matches[2];
+
+  if (remaining) {
+    var child = root[member];
+    if (!child) {
+      child = root[member] = {};
+    }
+    recursiveSet(child, remaining, value);
+  } else {
+    root[member] = value;
+  }
+}
+
+var SyncbaseWrapper = defineClass({
+  statics: {
+    start: function(context, mountName) {
+      var service = syncbase.newService(mountName);
+      var app = service.app('travel');
+      var db = app.noSqlDatabase('db');
+
+      return setUp(context, app, db).then(function() {
+        return new SyncbaseWrapper(context, db);
+      });
+    }
+  },
+
+  publics: {
+    refresh: function() {
+      var self = this;
+      var isHeader = true;
+
+      var query = 'select k, v from t';
+      var newData = {};
+      this.db.exec(this.context, query, function(err) {
+        if (err) {
+          self.onError(err);
+        } else {
+          self.data = newData;
+          self.onUpdate(newData);
+        }
+      }).on('data', function(row) {
+        if (isHeader) {
+          isHeader = false;
+        } else {
+          recursiveSet(newData, row[0], row[1]);
+        }
+      }).on('error', function(err) {
+        self.onError(err);
+      });
+    }
+  },
+
+  events: {
+    onError: 'memory',
+    onUpdate: 'memory'
+  },
+
+  init: function(context, db) {
+    var self = this;
+    this.context = context;
+    this.db = db;
+    this.data = {};
+
+    // Start the watch loop to periodically poll for changes from sync.
+    // TODO(rosswang): Remove this once we have client watch.
+    this.watchLoop = function() {
+      self.refresh();
+      setTimeout(self.watchLoop, 500);
+    };
+    process.nextTick(self.watchLoop);
+  }
+});
+
+module.exports = SyncbaseWrapper;
diff --git a/test/travel.js b/test/travel.js
index 2aca67c..b4d3ded 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -24,27 +24,6 @@
   cleanDom();
 });
 
-test('message display', function(t) {
-  var travel = new Travel({
-    vanadiumWrapper: mockVanadiumWrapper,
-    maps: mockMaps
-  });
-
-  var $messages = $('.messages ul');
-  t.ok($messages.length, 'message display exists');
-  t.equals($messages.children().length, 0, 'message display is empty');
-
-  travel.info('Test message.');
-
-  var $messageItem = $messages.children();
-  t.equals($messageItem.length, 1, 'message display shows 1 message');
-  t.equals($messageItem.text(), 'Test message.',
-    'message displays message text');
-
-  t.end();
-  cleanDom();
-});
-
 test('domRoot', function(t) {
   var $root = $('<div>');
   var root = $root[0];
diff --git a/test/vanadium-wrapper.js b/test/vanadium-wrapper.js
index 19fbf4f..6ff997d 100644
--- a/test/vanadium-wrapper.js
+++ b/test/vanadium-wrapper.js
@@ -26,9 +26,10 @@
     }
   };
 
-  vanadiumWrapper.init(mockVanadium).then(
+  var promise = vanadiumWrapper.init(mockVanadium).then(
     function(v) {
       context.vanadiumWrapper = v;
+      return context;
     },
     function(err) {
       t.fail('init error');
@@ -36,26 +37,27 @@
 
   mockVanadium.finishInit(null, mockRuntime);
 
-  return context;
+  return promise;
 }
 
 test('crashBefore', function(t) {
-  var crashTest = setUpCrashTest(t);
+  setUpCrashTest(t).then(function(crashTest) {
+    crashTest.crash('I lost the game.');
+    crashTest.bindCrashHandler();
+    t.equal(crashTest.crashErr, 'I lost the game.');
 
-  crashTest.crash('I lost the game.');
-  crashTest.bindCrashHandler();
-  t.equal(crashTest.crashErr, 'I lost the game.');
-
-  t.end();
+    t.end();
+  });
 });
 
 test('crashAfter', function(t) {
-  var crashTest = setUpCrashTest(t);
-  crashTest.bindCrashHandler();
-  t.notOk(crashTest.crashErr, 'no crash yet');
+  setUpCrashTest(t).then(function(crashTest) {
+    crashTest.bindCrashHandler();
+    t.notOk(crashTest.crashErr, 'no crash yet');
 
-  crashTest.crash('I lost the game.');
-  t.equal(crashTest.crashErr, 'I lost the game.');
+    crashTest.crash('I lost the game.');
+    t.equal(crashTest.crashErr, 'I lost the game.');
 
-  t.end();
+    t.end();
+  });
 });
\ No newline at end of file
diff --git a/tools/start_services.sh b/tools/start_services.sh
new file mode 100644
index 0000000..6432e60
--- /dev/null
+++ b/tools/start_services.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# 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.
+
+# Expects credentials in tmp/creds, generated as follows:
+#
+# make creds
+
+set -euo pipefail
+trap kill_child_processes INT TERM EXIT
+silence() {
+  "$@" &> /dev/null || true
+}
+# Copied from chat example app.
+kill_child_processes() {
+  # Attempt to stop child processes using the TERM signal.
+  if [[ -n "$(jobs -p -r)" ]]; then
+    silence pkill -P $$
+    sleep 1
+    # Kill any remaining child processes using the KILL signal.
+    if [[ -n "$(jobs -p -r)" ]]; then
+      silence sudo -u "${SUDO_USER}" pkill -9 -P $$
+    fi
+  fi
+}
+main() {
+  local -r TMP=tmp
+  local -r PORT=${PORT-4000}
+  local -r MOUNTTABLED_ADDR=":$((PORT+1))"
+  local -r SYNCBASED_ADDR=":$((PORT+2))"
+  mkdir -p $TMP
+  # TODO(rosswang): Run mounttabled and syncbased each with its own blessing
+  # extension.
+  ${V23_ROOT}/release/go/bin/mounttabled \
+    --v23.tcp.address=${MOUNTTABLED_ADDR} \
+    --v23.credentials=${TMP}/creds &
+  ./bin/syncbased \
+    --v=5 \
+    --alsologtostderr=false \
+    --root-dir=${TMP}/syncbase_${PORT} \
+    --name=syncbase \
+    --v23.namespace.root=/${MOUNTTABLED_ADDR} \
+    --v23.tcp.address=${SYNCBASED_ADDR} \
+    --v23.credentials=${TMP}/creds \
+    --v23.permissions.literal='{"Admin":{"In":["..."]},"Write":{"In":["..."]},"Read":{"In":["..."]},"Resolve":{"In":["..."]},"Debug":{"In":["..."]}}'
+  tail -f /dev/null  # wait forever
+}
+main "$@"