diff --git a/gcli.js b/gcli.js index 5708a0d9..eb544be8 100644 --- a/gcli.js +++ b/gcli.js @@ -19,53 +19,16 @@ exports.gcliHome = __dirname; -var system = require('./lib/gcli/api').createSystem(); +var system = require('./lib/gcli/system').createSystem(); -/* - * GCLI is built from a number of components (called items) composed as - * required for each environment. - * When adding to or removing from this list, we should keep the basics in sync - * with the other environments. - * See: - * - lib/gcli/index.js: Generic basic set (without commands) - * - lib/gcli/demo.js: Adds demo commands to basic set for use in web demo - * - gcli.js: Add commands to basic set for use in Node command line - * - mozilla/gcli/index.js: From scratch listing for Firefox - * - lib/gcli/connectors/index.js: Client only items when executing remotely - * - lib/gcli/connectors/direct.js: Test items for connecting to in-process GCLI - */ var items = [ - require('./lib/gcli/index').items, - - require('./lib/gcli/cli').items, - require('./lib/gcli/commands/clear').items, - // require('./lib/gcli/commands/connect').items, - require('./lib/gcli/commands/context').items, - require('./lib/gcli/commands/exec').items, - require('./lib/gcli/commands/global').items, - require('./lib/gcli/commands/help').items, - require('./lib/gcli/commands/intro').items, - require('./lib/gcli/commands/lang').items, - require('./lib/gcli/commands/mocks').items, - require('./lib/gcli/commands/pref').items, - require('./lib/gcli/commands/preflist').items, - require('./lib/gcli/commands/test').items, - - require('./lib/gcli/commands/demo/alert').items, - // require('./lib/gcli/commands/demo/bugs').items, - // require('./lib/gcli/commands/demo/demo').items, - require('./lib/gcli/commands/demo/echo').items, - // require('./lib/gcli/commands/demo/edit').items, - // require('./lib/gcli/commands/demo/git').items, - // require('./lib/gcli/commands/demo/hg').items, - require('./lib/gcli/commands/demo/sleep').items, - // require('./lib/gcli/commands/demo/theme').items, + require('./lib/gcli/items/basic').items, + require('./lib/gcli/items/ui').items, + require('./lib/gcli/items/remote').items, + require('./lib/gcli/items/standard').items, + require('./lib/gcli/items/demo').items, + require('./lib/gcli/items/server').items, - require('./lib/gcli/commands/server/exit').items, - require('./lib/gcli/commands/server/firefox').items, - require('./lib/gcli/commands/server/orion').items, - require('./lib/gcli/commands/server/server').items, - require('./lib/gcli/commands/server/standard').items ].reduce(function(prev, curr) { return prev.concat(curr); }, []); system.addItems(items); @@ -116,7 +79,7 @@ function logResults(output) { requisition.updateExec(command) .then(logResults) .then(extraActions) - .then(null, util.errorHandler); + .catch(util.errorHandler); /** * Start a NodeJS REPL to execute commands @@ -133,10 +96,8 @@ function startRepl() { if (command.length !== 0) { requisition.updateExec(command) .then(logResults) - .then( - function() { callback(); }, - function(ex) { util.errorHandler(ex); callback(); } - ); + .then(function() { callback(); }) + .catch(function(ex) { util.errorHandler(ex); callback(); }); } }; diff --git a/index.html b/index.html index 6f1cc562..918264f9 100644 --- a/index.html +++ b/index.html @@ -25,19 +25,18 @@ paths: { i18n: '../scripts/i18n', text: '../scripts/text' } }); - var modules = [ 'gcli/index', 'gcli/demo', 'gcli/test/index' ]; + var modules = [ 'gcli/index', 'gcli/items/demo', 'gcli/test/index' ]; require(modules, function(gcli, demo, test) { - // Add demo commands. You'll probably want to replace this with - // your own set of commands - gcli.addItems(demo.items); + // Add the commands/types/converters as required + var system = gcli.createSystem(); + system.addItems(gcli.items); // Common infrastructure: types, etc + system.addItems(gcli.commandItems); // Common set of useful commands + system.addItems(demo.items); // Extra demo commands - // To run commands remotely, - // Hook GCLI into this page, using gcli-root above - var options = {}; - gcli.createTerminal(options).then(function() { - // Run the unit test at each startup. - test.run(options); - }).then(null, console.error); + gcli.createTerminal(system).then(function(terminal) { + terminal.language.showIntro(); // Intro text + test.run(terminal, false); // Run the unit test at each startup + }).catch(console.error.bind(console)); }); diff --git a/lib/gcli/api.js b/lib/gcli/api.js deleted file mode 100644 index c2d0f71e..00000000 --- a/lib/gcli/api.js +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2012, Mozilla Foundation and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -var Promise = require('./util/promise').Promise; -var Commands = require('./commands/commands').Commands; -var Connectors = require('./connectors/connectors').Connectors; -var Converters = require('./converters/converters').Converters; -var Fields = require('./fields/fields').Fields; -var Languages = require('./languages/languages').Languages; -var Settings = require('./settings').Settings; -var Types = require('./types/types').Types; - -/** - * This is the heart of the API that we expose to the outside - */ -exports.createSystem = function() { - - var components = { - connector: new Connectors(), - converter: new Converters(), - field: new Fields(), - language: new Languages(), - type: new Types() - }; - components.setting = new Settings(components.type); - components.command = new Commands(components.type); - - var getItemType = function(item) { - if (item.item) { - return item.item; - } - // Some items are registered using the constructor so we need to check - // the prototype for the the type of the item - return (item.prototype && item.prototype.item) ? - item.prototype.item : 'command'; - }; - - var addItem = function(item) { - components[getItemType(item)].add(item); - }; - - var removeItem = function(item) { - components[getItemType(item)].remove(item); - }; - - /** - * loadableModules is a lookup of names to module loader functions (like - * the venerable 'require') to which we can pass a name and get back a - * JS object (or a promise of a JS object). This allows us to have custom - * loaders to get stuff from the filesystem etc. - */ - var loadableModules = {}; - - /** - * loadedModules is a lookup by name of the things returned by the functions - * in loadableModules so we can track what we need to unload / reload. - */ - var loadedModules = {}; - - var unloadModule = function(name) { - var existingModule = loadedModules[name]; - if (existingModule != null) { - existingModule.items.forEach(removeItem); - } - delete loadedModules[name]; - }; - - var loadModule = function(name) { - var existingModule = loadedModules[name]; - unloadModule(name); - - // And load the new items - try { - var loader = loadableModules[name]; - return Promise.resolve(loader(name)).then(function(newModule) { - if (existingModule === newModule) { - return; - } - - if (newModule == null) { - throw 'Module \'' + name + '\' not found'; - } - - if (newModule.items == null || typeof newModule.items.forEach !== 'function') { - console.log('Exported properties: ' + Object.keys(newModule).join(', ')); - throw 'Module \'' + name + '\' has no \'items\' array export'; - } - - newModule.items.forEach(addItem); - - loadedModules[name] = newModule; - }); - } - catch (ex) { - console.error(ex); - return Promise.reject('Failure when loading \'' + name + '\''); - } - }; - - var pendingChanges = false; - - var api = { - addItems: function(items) { - items.forEach(addItem); - }, - - removeItems: function(items) { - items.forEach(removeItem); - }, - - addItemsByModule: function(names, options) { - options = options || {}; - if (typeof names === 'string') { - names = [ names ]; - } - names.forEach(function(name) { - if (options.loader == null) { - options.loader = function(name) { - return require(name); - }; - } - loadableModules[name] = options.loader; - - if (options.delayedLoad) { - pendingChanges = true; - } - else { - loadModule(name).then(null, console.error); - } - }); - }, - - removeItemsByModule: function(name) { - delete loadableModules[name]; - unloadModule(name); - }, - - load: function() { - if (!pendingChanges) { - return Promise.resolve(); - } - - // clone loadedModules, so we can remove what is left at the end - var modules = Object.keys(loadedModules).map(function(name) { - return loadedModules[name]; - }); - - var promises = Object.keys(loadableModules).map(function(name) { - delete modules[name]; - return loadModule(name); - }); - - Object.keys(modules).forEach(unloadModule); - pendingChanges = false; - - return Promise.all(promises); - } - }; - - Object.defineProperty(api, 'commands', { - get: function() { return components.command; }, - set: function(commands) { components.command = commands; }, - enumerable: true - }); - - Object.defineProperty(api, 'connectors', { - get: function() { return components.connector; }, - enumerable: true - }); - - Object.defineProperty(api, 'converters', { - get: function() { return components.converter; }, - enumerable: true - }); - - Object.defineProperty(api, 'fields', { - get: function() { return components.field; }, - enumerable: true - }); - - Object.defineProperty(api, 'languages', { - get: function() { return components.language; }, - enumerable: true - }); - - Object.defineProperty(api, 'settings', { - get: function() { return components.setting; }, - enumerable: true - }); - - Object.defineProperty(api, 'types', { - get: function() { return components.type; }, - set: function(types) { - components.type = types; - components.command.types = types; - components.setting.types = types; - }, - enumerable: true - }); - - return api; -}; diff --git a/lib/gcli/cli.js b/lib/gcli/cli.js index 687cd7d5..0fdaee9f 100644 --- a/lib/gcli/cli.js +++ b/lib/gcli/cli.js @@ -100,16 +100,6 @@ var removeMapping = function(requisition) { instances.splice(index, 1); }; -/** - * Some manual intervention is needed in parsing the { command. - */ -function getEvalCommand(commands) { - if (getEvalCommand._cmd == null) { - getEvalCommand._cmd = commands.get(evalCmd.name); - } - return getEvalCommand._cmd; -} - /** * Assignment is a link between a parameter and the data for that parameter. * The data for the parameter is available as in the preferred type and as @@ -318,7 +308,7 @@ var evalCmd = { var reply = customEval(args.javascript); return context.typedData(typeof reply, reply); }, - isCommandRegexp: /^\s*{\s*/ + isCommandRegexp: /^\s*\{\s*/ }; exports.items = [ evalCmd ]; @@ -704,7 +694,8 @@ Object.defineProperty(Requisition.prototype, 'status', { */ Requisition.prototype.getStatusMessage = function() { if (this.commandAssignment.getStatus() !== Status.VALID) { - return l10n.lookup('cliUnknownCommand'); + return l10n.lookupFormat('cliUnknownCommand2', + [ this.commandAssignment.arg.text ]); } var assignments = this.getAssignments(); @@ -1786,8 +1777,8 @@ Requisition.prototype._split = function(args) { if (args[0].type === 'ScriptArgument') { // Special case: if the user enters { console.log('foo'); } then we need to // use the hidden 'eval' command - conversion = new Conversion(getEvalCommand(this.system.commands), - new ScriptArgument()); + var command = this.system.commands.get(evalCmd.name); + conversion = new Conversion(command, new ScriptArgument()); this._setAssignmentInternal(this.commandAssignment, conversion); return; } @@ -2045,7 +2036,7 @@ Requisition.prototype.exec = function(options) { typed = typed.replace(/\s*}\s*$/, ''); } - var output = new Output(this.conversionContext, { + var output = new Output({ command: command, args: args, typed: typed, @@ -2134,14 +2125,13 @@ exports.Requisition = Requisition; /** * A simple object to hold information about the output of a command */ -function Output(context, options) { +function Output(options) { options = options || {}; this.command = options.command || ''; this.args = options.args || {}; this.typed = options.typed || ''; this.canonical = options.canonical || ''; this.hidden = options.hidden === true ? true : false; - this.converters = context.system.converters; this.type = undefined; this.data = undefined; @@ -2186,7 +2176,8 @@ Output.prototype.complete = function(data, error) { * Call converters.convert using the data in this Output object */ Output.prototype.convert = function(type, conversionContext) { - return this.converters.convert(this.data, this.type, type, conversionContext); + var converters = conversionContext.system.converters; + return converters.convert(this.data, this.type, type, conversionContext); }; Output.prototype.toJson = function() { diff --git a/lib/gcli/commands/commands.js b/lib/gcli/commands/commands.js index be2cf775..e76ea8b7 100644 --- a/lib/gcli/commands/commands.js +++ b/lib/gcli/commands/commands.js @@ -161,8 +161,11 @@ function Command(types, commandSpec) { /** * JSON serializer that avoids non-serializable data + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. */ -Command.prototype.toJson = function() { +Command.prototype.toJson = function(customProps) { var json = { item: 'command', name: this.name, @@ -170,6 +173,7 @@ Command.prototype.toJson = function() { returnType: this.returnType, isParent: (this.exec == null) }; + if (this.description !== l10n.lookup('canonDescNone')) { json.description = this.description; } @@ -179,6 +183,15 @@ Command.prototype.toJson = function() { if (this.hidden != null) { json.hidden = this.hidden; } + + if (Array.isArray(customProps)) { + customProps.forEach(function(prop) { + if (this[prop] != null) { + json[prop] = this[prop]; + } + }.bind(this)); + } + return json; }; @@ -327,9 +340,14 @@ exports.Parameter = Parameter; /** * A store for a list of commands + * @param types Each command uses a set of Types to parse its parameters so the + * Commands container needs access to the list of available types. + * @param location String that, if set will force all commands to have a + * matching runAt property to be accepted */ -function Commands(types) { +function Commands(types, location) { this.types = types; + this.location = location; // A lookup hash of our registered commands this._commands = {}; @@ -344,12 +362,16 @@ function Commands(types) { /** * Add a command to the list of known commands. - * This function is exposed to the outside world (via gcli/index). It is - * documented in docs/index.md for all the world to see. * @param commandSpec The command and its metadata. - * @return The new command + * @return The new command, or null if a location property has been set and the + * commandSpec doesn't have a matching runAt property. */ Commands.prototype.add = function(commandSpec) { + if (this.location != null && commandSpec.runAt != null && + commandSpec.runAt !== this.location) { + return; + } + if (this._commands[commandSpec.name] != null) { // Roughly commands.remove() without the event call, which we do later delete this._commands[commandSpec.name]; @@ -416,14 +438,17 @@ Commands.prototype.getAll = function() { /** * Get access to the stored commandMetaDatas (i.e. before they were made into * instances of Command/Parameters) so we can remote them. + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. */ -Commands.prototype.getCommandSpecs = function() { +Commands.prototype.getCommandSpecs = function(customProps) { var commandSpecs = []; Object.keys(this._commands).forEach(function(name) { var command = this._commands[name]; if (!command.noRemote) { - commandSpecs.push(command.toJson()); + commandSpecs.push(command.toJson(customProps)); } }.bind(this)); diff --git a/lib/gcli/commands/connect.js b/lib/gcli/commands/connect.js index bc7d7184..beb24083 100644 --- a/lib/gcli/commands/connect.js +++ b/lib/gcli/commands/connect.js @@ -18,11 +18,12 @@ var l10n = require('../util/l10n'); var cli = require('../cli'); +var GcliFront = require('../connectors/remoted').GcliFront; /** * A lookup of the current connection */ -var connections = {}; +var fronts = {}; /** * 'connection' type @@ -32,8 +33,8 @@ var connection = { name: 'connection', parent: 'selection', lookup: function() { - return Object.keys(connections).map(function(prefix) { - return { name: prefix, value: connections[prefix] }; + return Object.keys(fronts).map(function(prefix) { + return { name: prefix, value: fronts[prefix] }; }); } }; @@ -87,19 +88,19 @@ var connect = { returnType: 'string', exec: function(args, context) { - if (connections[args.prefix] != null) { + if (fronts[args.prefix] != null) { throw new Error(l10n.lookupFormat('connectDupReply', [ args.prefix ])); } - var connector = args.method || context.system.connectors.get('xhr'); + args.method = args.method || context.system.connectors.get('xhr'); - return connector.connect(args.url).then(function(connection) { - // Nasty: stash the prefix on the connection to help us tidy up - connection.prefix = args.prefix; - connections[args.prefix] = connection; + return GcliFront.create(args.method, args.url).then(function(front) { + // Nasty: stash the prefix on the front to help us tidy up + front.prefix = args.prefix; + fronts[args.prefix] = front; - return connection.call('specs').then(function(specs) { - var remoter = this.createRemoter(args.prefix, connection); + return front.specs().then(function(specs) { + var remoter = this.createRemoter(args.prefix, front); var commands = cli.getMapping(context).requisition.system.commands; commands.addProxyCommands(specs, remoter, args.prefix, args.url); @@ -116,7 +117,7 @@ var connect = { * When we register a set of remote commands, we need to provide a proxy * executor. This is that executor. */ - createRemoter: function(prefix, connection) { + createRemoter: function(prefix, front) { return function(cmdArgs, context) { var typed = context.typed; @@ -126,12 +127,7 @@ var connect = { typed = typed.substring(prefix.length).replace(/^ */, ''); } - var data = { - typed: typed, - args: cmdArgs - }; - - return connection.call('execute', data).then(function(reply) { + return front.execute(typed).then(function(reply) { var typedData = context.typedData(reply.type, reply.data); if (!reply.error) { return typedData; @@ -162,11 +158,11 @@ var disconnect = { returnType: 'string', exec: function(args, context) { - var connection = args.prefix; - return connection.disconnect().then(function() { + var front = args.prefix; + return front.connection.disconnect().then(function() { var commands = cli.getMapping(context).requisition.system.commands; - var removed = commands.removeProxyCommands(connection.prefix); - delete connections[connection.prefix]; + var removed = commands.removeProxyCommands(front.prefix); + delete fronts[front.prefix]; return l10n.lookupFormat('disconnectReply', [ removed.length ]); }); } diff --git a/lib/gcli/commands/demo/bugs.js b/lib/gcli/commands/demo/bugs.js deleted file mode 100644 index ef1ce409..00000000 --- a/lib/gcli/commands/demo/bugs.js +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2012, Mozilla Foundation and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -var Promise = require('../../util/promise').Promise; - -exports.items = [ - { - item: 'converter', - from: 'bugz', - to: 'view', - exec: function(bugz, context) { - return { - html: - '
\n' + - ' Open GCLI meta-bugs\n' + - ' (i.e. this search):\n' + - '
\n' + - '| ID | \n' + - 'Milestone | \n' + - 'Pri | \n' + - 'Summary | \n' + - '
|---|---|---|---|
| ${bug.id} | \n' + - '${bug.target_milestone} | \n' + - '${bug.priority} | \n' + - '${bug.summary} | \n' + - '
| ${patch.name} | \n' + - '${patch.comment} | \n' +
- ' \n' + - ' goto\n' + - ' | \n' + - '
| ${className} | \n' + - 'Lorem ipsum dolor sit amet ↑ → ↓ ← ██████████ | \n' + - '
gcli-' + name + '
";\n' + '\n' + 'function test() {\n' + - ' return Task.spawn(function() {\n' + + ' return Task.spawn(*function() {\n' + ' let options = yield helpers.openTab(TEST_URI);\n' + ' yield helpers.openToolbar(options);\n' + - ' gcli.addItems(mockCommands.items);\n' + + ' options.requisition.system.addItems(mockCommands.items);\n' + '\n' + ' yield helpers.runTests(options, exports);\n' + '\n' + - ' gcli.removeItems(mockCommands.items);\n' + + ' options.requisition.system.removeItems(mockCommands.items);\n' + ' yield helpers.closeToolbar(options);\n' + ' yield helpers.closeTab(options);\n' + ' }).then(finish, helpers.handleError);\n' + diff --git a/lib/gcli/commands/server/server.js b/lib/gcli/commands/server/server.js index 746d5cdb..45494228 100644 --- a/lib/gcli/commands/server/server.js +++ b/lib/gcli/commands/server/server.js @@ -115,7 +115,6 @@ exports.items = [ }); }); - app.use('/lib', function(req, res) { var filename = main.gcliHome + '/lib' + url.parse(req.url).pathname; var webOverride = filename.replace(/\/lib\/gcli\//, '/web/gcli/'); @@ -193,7 +192,7 @@ var xhrsocket = { res.status(500).send(text); }; - return Promise.resolve(reply).then(onResolve).then(null, onReject); + return Promise.resolve(reply).then(onResolve).catch(onReject); }); }); } @@ -249,31 +248,40 @@ var websocket = { var remoter = new Remoter(requisition); remoter.addListener(function(name, data) { - console.log('EMIT ' + name + ' ' + debugStr(data, 30)); + // console.log('EMIT ' + name + ' ' + debugStr(data, 30)); socket.emit('event', { name: name, data: data }); }); Object.keys(remoter.exposed).forEach(function(command) { socket.on(command, function(request) { - console.log('SOCKET ' + command + ' ' + debugStr(request.data, 30)); + // Handle errors from exceptions an promise rejections + var onError = function(err) { + console.log('SOCKET ' + command + + '(' + debugStr(request.data, 30) + ') Exception'); + util.errorHandler(err); + + socket.emit('reply', { + id: request.id, + exception: '' + err + }); + }; try { var func = remoter.exposed[command]; var reply = func.call(remoter, request.data); Promise.resolve(reply).then(function(data) { + console.log('SOCKET ' + command + + '(' + debugStr(request.data, 30) + ') → ' + + debugStr(data, 20) + ')'); + socket.emit('reply', { id: request.id, reply: data }); - }); + }, onError); } catch (ex) { - console.trace(); - console.error(ex); - socket.emit('reply', { - id: request.id, - exception: ex.toString() - }); + onError(ex); } }); }); diff --git a/lib/gcli/connectors/connectors.js b/lib/gcli/connectors/connectors.js index cf543823..1578f9c5 100644 --- a/lib/gcli/connectors/connectors.js +++ b/lib/gcli/connectors/connectors.js @@ -138,14 +138,21 @@ Connectors.prototype.getAll = function() { }.bind(this)); }; +var defaultConnectorName; + /** - * Get access to a connector by name. If name is undefined then use the first - * registered connector as a default. + * Get access to a connector by name. If name is undefined then first try to + * use the same connector that we used last time, and if there was no last + * time, then just use the first registered connector as a default. */ Connectors.prototype.get = function(name) { if (name == null) { - name = Object.keys(this._registered)[0]; + name = (defaultConnectorName == null) ? + Object.keys(this._registered)[0] : + defaultConnectorName; } + + defaultConnectorName = name; return this._registered[name]; }; diff --git a/lib/gcli/connectors/direct.js b/lib/gcli/connectors/direct.js index 249637d3..2913628c 100644 --- a/lib/gcli/connectors/direct.js +++ b/lib/gcli/connectors/direct.js @@ -16,60 +16,12 @@ 'use strict'; -var system = require('../api').createSystem(); -var Commands = require('../commands/commands').Commands; -var Types = require('../types/types').Types; -var Requisition = require('../cli').Requisition; var Promise = require('../util/promise').Promise; - +var createSystem = require('../system').createSystem; +var Requisition = require('../cli').Requisition; var Remoter = require('./remoted').Remoter; var Connection = require('./connectors').Connection; -system.types = new Types(); -system.commands = new Commands(system.types); - -var items = [ - require('../types/delegate').items, - require('../types/selection').items, - - require('../types/array').items, - require('../types/boolean').items, - require('../types/command').items, - require('../types/date').items, - require('../types/file').items, - require('../types/javascript').items, - require('../types/node').items, - require('../types/number').items, - require('../types/resource').items, - require('../types/setting').items, - require('../types/string').items, - require('../types/union').items, - require('../types/url').items, - - require('../cli').items, - - require('../commands/clear').items, - require('../commands/connect').items, - require('../commands/context').items, - require('../commands/exec').items, - require('../commands/global').items, - require('../commands/help').items, - require('../commands/intro').items, - require('../commands/lang').items, - require('../commands/mocks').items, - require('../commands/preflist').items, - require('../commands/pref').items, - require('../commands/test').items, - require('../commands/demo/alert').items, - require('../commands/demo/echo').items, - require('../commands/demo/sleep').items - -].reduce(function(prev, curr) { return prev.concat(curr); }, []); - -system.addItems(items); - -var requisition = new Requisition(system); - exports.items = [ { // Communicate with a 'remote' server that isn't remote at all @@ -82,14 +34,21 @@ exports.items = [ } ]; -/** - * Direct connection is mostly for testing. Async is closer to what things like - * websocket will actually give us, but sync gives us clearer stack traces. - */ -var asyncCall = false; - function DirectConnection() { this._emit = this._emit.bind(this); + + // The items to use in our new command line + var items = [ + require('../items/basic').items, + require('../items/standard').items, + require('../items/demo').items, + ].reduce(function(prev, curr) { return prev.concat(curr); }, []); + + // This is the 'server' + var system = createSystem(); + system.addItems(items); + var requisition = new Requisition(system); + this.remoter = new Remoter(requisition); this.remoter.addListener(this._emit); } @@ -98,26 +57,10 @@ DirectConnection.prototype = Object.create(Connection.prototype); DirectConnection.prototype.call = function(command, data) { return new Promise(function(resolve, reject) { - if (asyncCall) { - setTimeout(function() { - this._call(resolve, reject, command, data); - }, 1); - } - else { - this._call(resolve, reject, command, data); - } - }.bind(this)); -}; - -DirectConnection.prototype._call = function(resolve, reject, command, data) { - try { var func = this.remoter.exposed[command]; var reply = func.call(this.remoter, data); resolve(reply); - } - catch (ex) { - reject(ex); - } + }.bind(this)); }; DirectConnection.prototype.disconnect = function() { diff --git a/lib/gcli/connectors/index.js b/lib/gcli/connectors/index.js index 5e353193..9f98e9e2 100644 --- a/lib/gcli/connectors/index.js +++ b/lib/gcli/connectors/index.js @@ -16,170 +16,55 @@ 'use strict'; -var api = require('../api'); -var Commands = require('../commands/commands').Commands; -var Types = require('../types/types').Types; +var createSystem = require('../system').createSystem; +var connectFront = require('../system').connectFront; +var GcliFront = require('./remoted').GcliFront; // Patch-up IE9 require('../util/legacy'); -/* - * GCLI is built from a number of components (called items) composed as - * required for each environment. - * When adding to or removing from this list, we should keep the basics in sync - * with the other environments. - * See: - * - lib/gcli/index.js: Generic basic set (without commands) - * - lib/gcli/demo.js: Adds demo commands to basic set for use in web demo - * - gcli.js: Add commands to basic set for use in Node command line - * - mozilla/gcli/index.js: From scratch listing for Firefox - * - lib/gcli/connectors/index.js: Client only items when executing remotely - * - lib/gcli/connectors/direct.js: Test items for connecting to in-process GCLI - */ -var items = [ - // First we need to add the local types which other types depend on - require('../types/delegate').items, - require('../types/selection').items, - require('../types/array').items, - - require('../types/boolean').items, - require('../types/command').items, - require('../types/date').items, - require('../types/file').items, - require('../types/javascript').items, - require('../types/node').items, - require('../types/number').items, - require('../types/resource').items, - require('../types/setting').items, - require('../types/string').items, - require('../types/union').items, - require('../types/url').items, - - require('../fields/fields').items, - require('../fields/delegate').items, - require('../fields/selection').items, - - require('../ui/intro').items, - require('../ui/focus').items, - - require('../converters/converters').items, - require('../converters/basic').items, - require('../converters/html').items, - require('../converters/terminal').items, - - require('../languages/command').items, - require('../languages/javascript').items, - - require('./direct').items, - // require('./rdp').items, // Firefox remote debug protocol - require('./websocket').items, - require('./xhr').items, - - require('../commands/context').items, - -].reduce(function(prev, curr) { return prev.concat(curr); }, []); - -/** - * These are the commands stored on the remote side that have converters which - * we'll need to present the data - */ -var requiredConverters = [ - require('../cli').items, - - require('../commands/clear').items, - require('../commands/connect').items, - require('../commands/exec').items, - require('../commands/global').items, - require('../commands/help').items, - require('../commands/intro').items, - require('../commands/lang').items, - require('../commands/preflist').items, - require('../commands/pref').items, - require('../commands/test').items, - -].reduce(function(prev, curr) { return prev.concat(curr); }, []) - .filter(function(item) { return item.item === 'converter'; }); - /** * Connect to a remote system and setup the commands/types/converters etc needed * to make it all work */ -exports.connect = function(options) { +exports.createSystem = function(options) { options = options || {}; - var system = api.createSystem(); - - // Ugly hack, to aid testing - exports.api = system; - - options.types = system.types = new Types(); - options.commands = system.commands = new Commands(system.types); + var system = createSystem(); + // The items that are always needed on the client + var items = [ + require('../items/basic').items, + require('../items/ui').items, + require('../items/remote').items, + // The context command makes no sense on the server + require('../commands/context').items, + ].reduce(function(prev, curr) { return prev.concat(curr); }, []); system.addItems(items); - system.addItems(requiredConverters); - - var connector = system.connectors.get(options.connector); - return connector.connect(options.url).then(function(connection) { - options.connection = connection; - connection.on('commandsChanged', function(specs) { - exports.addItems(system, specs, connection); - }); - return connection.call('specs').then(function(specs) { - exports.addItems(system, specs, connection); - return connection; - }); - }); -}; - -exports.addItems = function(gcli, specs, connection) { - exports.removeRemoteItems(gcli, connection); - var remoteItems = exports.addLocalFunctions(specs, connection); - gcli.addItems(remoteItems); -}; + // These are the commands stored on the remote side that have converters which + // we'll need to present the data. Ideally front.specs() would transfer these, + // that doesn't happen yet so we add them manually + var requiredConverters = [ + require('../cli').items, + require('../commands/clear').items, + require('../commands/connect').items, + require('../commands/exec').items, + require('../commands/global').items, + require('../commands/help').items, + require('../commands/intro').items, + require('../commands/lang').items, + require('../commands/preflist').items, + require('../commands/pref').items, + require('../commands/test').items, + ].reduce(function(prev, curr) { return prev.concat(curr); }, []) + .filter(function(item) { return item.item === 'converter'; }); + system.addItems(requiredConverters); -/** - * Take the data from the 'specs' command (or the 'commandsChanged' event) and - * add function to proxy the execution back over the connection - */ -exports.addLocalFunctions = function(specs, connection) { - // Inject an 'exec' function into the commands, and the connection into - // all the remote types - specs.forEach(function(commandSpec) { - // - commandSpec.connection = connection; - commandSpec.params.forEach(function(param) { - param.type.connection = connection; + var connector = system.connectors.get(options.method); + return GcliFront.create(connector, options.url).then(function(front) { + return connectFront(system, front).then(function() { + return system; }); - - if (!commandSpec.isParent) { - commandSpec.exec = function(args, context) { - var data = { - typed: (context.prefix ? context.prefix + ' ' : '') + context.typed - }; - - return connection.call('execute', data).then(function(reply) { - var typedData = context.typedData(reply.type, reply.data); - if (!reply.error) { - return typedData; - } - else { - throw typedData; - } - }); - }; - } - - commandSpec.isProxy = true; - }); - - return specs; -}; - -exports.removeRemoteItems = function(gcli, connection) { - gcli.commands.getAll().forEach(function(command) { - if (command.connection === connection) { - gcli.commands.remove(command); - } }); }; diff --git a/lib/gcli/connectors/rdp.js b/lib/gcli/connectors/rdp.js deleted file mode 100644 index cde7a8e1..00000000 --- a/lib/gcli/connectors/rdp.js +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2012, Mozilla Foundation and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -var Cu = require('chrome').Cu; - -var debuggerSocketConnect = Cu.import('resource://gre/modules/devtools/dbg-client.jsm', {}).debuggerSocketConnect; -var DebuggerClient = Cu.import('resource://gre/modules/devtools/dbg-client.jsm', {}).DebuggerClient; - -var Promise = require('../util/promise').Promise; -var Connection = require('./connectors').Connection; - -/** - * What port should we use by default? - */ -Object.defineProperty(exports, 'defaultPort', { - get: function() { - var Services = Cu.import('resource://gre/modules/Services.jsm', {}).Services; - try { - return Services.prefs.getIntPref('devtools.debugger.chrome-debugging-port'); - } - catch (ex) { - console.error('Can\'t use default port from prefs. Using 9999'); - return 9999; - } - }, - enumerable: true -}); - -exports.items = [ - { - item: 'connector', - name: 'rdp', - - connect: function(url) { - return RdpConnection.create(url); - } - } -]; - -/** - * RdpConnection uses the Firefox Remote Debug Protocol - */ -function RdpConnection(url) { - throw new Error('Use RdpConnection.create'); -} - -/** - * Asynchronous construction - */ -RdpConnection.create = function(url) { - this.host = url; - this.port = undefined; // TODO: Split out the port number - - this.requests = {}; - this.nextRequestId = 0; - - this._emit = this._emit.bind(this); - - return new Promise(function(resolve, reject) { - this.transport = debuggerSocketConnect(this.host, this.port); - this.client = new DebuggerClient(this.transport); - this.client.connect(function() { - this.client.listTabs(function(response) { - this.actor = response.gcliActor; - resolve(); - }.bind(this)); - }.bind(this)); - }.bind(this)); -}; - -RdpConnection.prototype = Object.create(Connection.prototype); - -RdpConnection.prototype.call = function(command, data) { - return new Promise(function(resolve, reject) { - var request = { to: this.actor, type: command, data: data }; - - this.client.request(request, function(response) { - resolve(response.commandSpecs); - }); - }.bind(this)); -}; - -RdpConnection.prototype.disconnect = function() { - return new Promise(function(resolve, reject) { - this.client.close(function() { - resolve(); - }); - - delete this._emit; - }.bind(this)); -}; - - -/** - * A Request is a command typed at the client which lives until the command - * has finished executing on the server - */ -function Request(actor, typed, args) { - this.json = { - to: actor, - type: 'execute', - typed: typed, - args: args, - requestId: 'id-' + Request._nextRequestId++, - }; - - this.promise = new Promise(function(resolve, reject) { - this._resolve = resolve; - }.bind(this)); -} - -Request._nextRequestId = 0; - -/** - * Called by the connection when a remote command has finished executing - * @param error boolean indicating output state - * @param type the type of the returned data - * @param data the data itself - */ -Request.prototype.complete = function(error, type, data) { - this._resolve({ - error: error, - type: type, - data: data - }); -}; diff --git a/lib/gcli/connectors/remoted.js b/lib/gcli/connectors/remoted.js index 91e6900f..92cc3586 100644 --- a/lib/gcli/connectors/remoted.js +++ b/lib/gcli/connectors/remoted.js @@ -78,11 +78,16 @@ Remoter.prototype.removeListener = function(action) { Remoter.prototype.exposed = { /** * Retrieve a list of the remotely executable commands + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. */ - specs: method(function() { - return this.requisition.system.commands.getCommandSpecs(); + specs: method(function(customProps) { + return this.requisition.system.commands.getCommandSpecs(customProps); }, { - request: {}, + request: { + customProps: Arg(0, "nullable:array:string") + }, response: RetVal("json") }), @@ -127,7 +132,7 @@ Remoter.prototype.exposed = { * - message: The message to display to the user * - predictions: An array of suggested values for the given parameter */ - typeparse: method(function(typed, param) { + parseType: method(function(typed, param) { return this.requisition.update(typed).then(function() { var assignment = this.requisition.getAssignment(param); @@ -151,14 +156,14 @@ Remoter.prototype.exposed = { * Get the incremented value of some type * @return a promise of a string containing the new argument text */ - typeincrement: method(function(typed, param) { + incrementType: method(function(typed, param) { return this.requisition.update(typed).then(function() { var assignment = this.requisition.getAssignment(param); return this.requisition.increment(assignment).then(function() { var arg = assignment.arg; return arg == null ? undefined : arg.text; }); - }); + }.bind(this)); }, { request: { typed: Arg(0, "string"), // The command string @@ -168,16 +173,16 @@ Remoter.prototype.exposed = { }), /** - * See typeincrement + * See incrementType */ - typedecrement: method(function(typed, param) { + decrementType: method(function(typed, param) { return this.requisition.update(typed).then(function() { var assignment = this.requisition.getAssignment(param); return this.requisition.decrement(assignment).then(function() { var arg = assignment.arg; return arg == null ? undefined : arg.text; }); - }); + }.bind(this)); }, { request: { typed: Arg(0, "string"), // The command string @@ -187,38 +192,37 @@ Remoter.prototype.exposed = { }), /** - * Perform a lookup on a selection type to get the allowed values + * Call type.lookup() on a selection type to get the allowed values */ - selectioninfo: method(function(commandName, paramName, action) { - var command = this.requisition.system.commands.get(commandName); - if (command == null) { - throw new Error('No command called \'' + commandName + '\''); - } + getSelectionLookup: method(function(commandName, paramName) { + var type = getType(this.requisition, commandName, paramName); - var type; - command.params.forEach(function(param) { - if (param.name === paramName) { - type = param.type; - } + var context = this.requisition.executionContext; + return type.lookup(context).map(function(info) { + // lookup returns an array of objects with name/value properties and + // the values might not be JSONable, so remove them + return { name: info.name }; }); - if (type == null) { - throw new Error('No parameter called \'' + paramName + '\' in \'' + - commandName + '\''); - } + }, { + request: { + commandName: Arg(0, "string"), // The command containing the parameter in question + paramName: Arg(1, "string"), // The name of the parameter + }, + response: RetVal("json") + }), - switch (action) { - case 'lookup': - return type.lookup(this.requisition.executionContext); - case 'data': - return type.data(this.requisition.executionContext); - default: - throw new Error('Action must be either \'lookup\' or \'data\''); - } + /** + * Call type.data() on a selection type to get the allowed values + */ + getSelectionData: method(function(commandName, paramName) { + var type = getType(this.requisition, commandName, paramName); + + var context = this.requisition.executionContext; + return type.data(context); }, { request: { commandName: Arg(0, "string"), // The command containing the parameter in question - paramName: Arg(1, "string"), // The name of the parameter - action: Arg(2, "string") // 'lookup' or 'data' depending on the function to call + paramName: Arg(1, "string"), // The name of the parameter }, response: RetVal("json") }), @@ -243,7 +247,7 @@ Remoter.prototype.exposed = { /** * Examine the filesystem for file matches */ - parsefile: method(function(typed, filetype, existing, matches) { + parseFile: method(function(typed, filetype, existing, matches) { var options = { filetype: filetype, existing: existing, @@ -273,3 +277,141 @@ Remoter.prototype.exposed = { response: RetVal("json") }) }; + +/** + * Helper for #getSelectionLookup and #getSelectionData that finds a type + * instance given a commandName and paramName + */ +function getType(requisition, commandName, paramName) { + var command = requisition.system.commands.get(commandName); + if (command == null) { + throw new Error('No command called \'' + commandName + '\''); + } + + var type; + command.params.forEach(function(param) { + if (param.name === paramName) { + type = param.type; + } + }); + + if (type == null) { + throw new Error('No parameter called \'' + paramName + '\' in \'' + + commandName + '\''); + } + + return type; +} + + + +/** + * Asynchronous construction. Use GcliFront(); + * @private + */ +function GcliFront() { + throw new Error('Use GcliFront.create().then(front => ...)'); +} + +/** + * + */ +GcliFront.create = function(connector, url) { + return connector.connect(url).then(function(connection) { + var front = Object.create(GcliFront.prototype); + return front._init(connection); + }); +}; + +/** + * Asynchronous construction. Use GcliFront(); + * @private + */ +GcliFront.prototype._init = function(connection) { + this.connection = connection; + return this; +}; + +GcliFront.prototype.on = function(eventName, action) { + this.connection.on(eventName, action); +}; + +GcliFront.prototype.off = function(eventName, action) { + this.connection.off(eventName, action); +}; + + +GcliFront.prototype.specs = function() { + var data = { + }; + return this.connection.call('specs', data); +}; + +GcliFront.prototype.execute = function(typed) { + var data = { + typed: typed + }; + return this.connection.call('execute', data); +}; + +GcliFront.prototype.parseFile = function(typed, filetype, existing, matches) { + var data = { + typed: typed, + filetype: filetype, + existing: existing, + matches: matches + }; + return this.connection.call('parseFile', data); +}; + +GcliFront.prototype.parseType = function(typed, param) { + var data = { + typed: typed, + param: param + }; + return this.connection.call('parseType', data); +}; + +GcliFront.prototype.incrementType = function(typed, param) { + var data = { + typed: typed, + param: param + }; + return this.connection.call('incrementType', data); +}; + +GcliFront.prototype.decrementType = function(typed, param) { + var data = { + typed: typed, + param: param + }; + return this.connection.call('decrementType', data); +}; + +GcliFront.prototype.getSelectionLookup = function(commandName, paramName) { + var data = { + commandName: commandName, + paramName: paramName + }; + return this.connection.call('getSelectionLookup', data); +}; + +GcliFront.prototype.getSelectionData = function(commandName, paramName) { + var data = { + commandName: commandName, + paramName: paramName + }; + return this.connection.call('getSelectionData', data); +}; + +GcliFront.prototype.system = function(cmd, args, cwd, env) { + var data = { + cmd: cmd, + args: args, + cwd: cwd, + env: env + }; + return this.connection.call('system', data); +}; + +exports.GcliFront = GcliFront; diff --git a/lib/gcli/connectors/xhr.js b/lib/gcli/connectors/xhr.js index 78c37265..2926940e 100644 --- a/lib/gcli/connectors/xhr.js +++ b/lib/gcli/connectors/xhr.js @@ -49,7 +49,7 @@ XhrConnection.prototype = Object.create(Connection.prototype); * See server.js:remoted for details on the remoted functions * @param command The name of the exposed feature. * @param data The block of data to pass to the exposed feature - * @return A promise of the data returned by the remote feature + * @return Promise of the data returned by the remote feature */ XhrConnection.prototype.call = function(command, data) { return new Promise(function(resolve, reject) { diff --git a/lib/gcli/converters/basic.js b/lib/gcli/converters/basic.js index fdb41d4c..2efd9abb 100644 --- a/lib/gcli/converters/basic.js +++ b/lib/gcli/converters/basic.js @@ -54,6 +54,14 @@ exports.items = [ return util.createElement(conversionContext.document, 'span'); } }, + { + item: 'converter', + from: 'json', + to: 'dom', + exec: function(json, conversionContext) { + return nodeFromDataToString(JSON.stringify(json), conversionContext); + } + }, { item: 'converter', from: 'number', @@ -71,5 +79,13 @@ exports.items = [ from: 'undefined', to: 'string', exec: function(data) { return ''; } + }, + { + item: 'converter', + from: 'json', + to: 'string', + exec: function(json, conversionContext) { + return JSON.stringify(json); + } } ]; diff --git a/lib/gcli/converters/converters.js b/lib/gcli/converters/converters.js index 38b2bdc6..545755a4 100644 --- a/lib/gcli/converters/converters.js +++ b/lib/gcli/converters/converters.js @@ -208,6 +208,15 @@ Converters.prototype.get = function(from, to) { return converter; }; +/** + * Get all the registered converters. Most for debugging + */ +Converters.prototype.getAll = function() { + return Object.keys(this._registered.from).map(function(name) { + return this._registered.from[name]; + }.bind(this)); +}; + /** * Helper for get to pick the best fallback converter */ diff --git a/lib/gcli/demo.js b/lib/gcli/demo.js deleted file mode 100644 index 62a8e230..00000000 --- a/lib/gcli/demo.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2012, Mozilla Foundation and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -/* - * GCLI is built from a number of components (called items) composed as - * required for each environment. - * When adding to or removing from this list, we should keep the basics in sync - * with the other environments. - * See: - * - lib/gcli/index.js: Generic basic set (without commands) - * - lib/gcli/demo.js: Adds demo commands to basic set for use in web demo - * - gcli.js: Add commands to basic set for use in Node command line - * - mozilla/gcli/index.js: From scratch listing for Firefox - * - lib/gcli/connectors/index.js: Client only items when executing remotely - * - lib/gcli/connectors/direct.js: Test items for connecting to in-process GCLI - */ -exports.items = [ - require('./cli').items, - require('./commands/clear').items, - require('./commands/connect').items, - require('./commands/context').items, - require('./commands/exec').items, - require('./commands/global').items, - require('./commands/help').items, - require('./commands/intro').items, - require('./commands/lang').items, - require('./commands/mocks').items, - require('./commands/pref').items, - require('./commands/preflist').items, - require('./commands/test').items, - - require('./commands/demo/alert').items, - require('./commands/demo/bugs').items, - require('./commands/demo/demo').items, - require('./commands/demo/echo').items, - require('./commands/demo/edit').items, - // require('./commands/demo/git').items, - // require('./commands/demo/hg').items, - require('./commands/demo/sleep').items, - require('./commands/demo/theme').items, - - // Exclude Node commands on web -].reduce(function(prev, curr) { return prev.concat(curr); }, []); diff --git a/lib/gcli/fields/fields.js b/lib/gcli/fields/fields.js index d8897b27..c9718473 100644 --- a/lib/gcli/fields/fields.js +++ b/lib/gcli/fields/fields.js @@ -199,6 +199,13 @@ Fields.prototype.get = function(type, options) { return new FieldConstructor(type, options); }; +/** + * Get all the registered fields. Most for debugging + */ +Fields.prototype.getAll = function() { + return this._fieldCtors.slice(); +}; + exports.Fields = Fields; /** diff --git a/lib/gcli/fields/selection.js b/lib/gcli/fields/selection.js index 1aa45f6e..d3e7597b 100644 --- a/lib/gcli/fields/selection.js +++ b/lib/gcli/fields/selection.js @@ -75,7 +75,7 @@ SelectionField.prototype.setConversion = function(conversion) { prediction; }, this); this.menu.show(items, conversion.arg.text); - }.bind(this), util.errorHandler); + }.bind(this)).catch(util.errorHandler); }; SelectionField.prototype.itemClicked = function(ev) { @@ -85,7 +85,7 @@ SelectionField.prototype.itemClicked = function(ev) { this.type.parse(arg, context).then(function(conversion) { this.onFieldChange({ conversion: conversion }); this.setMessage(conversion.message); - }.bind(this)).then(null, util.errorHandler); + }.bind(this)).catch(util.errorHandler); }; SelectionField.prototype.getConversion = function() { diff --git a/lib/gcli/index.js b/lib/gcli/index.js index e34c22a2..57e4e7d6 100644 --- a/lib/gcli/index.js +++ b/lib/gcli/index.js @@ -16,92 +16,44 @@ 'use strict'; -var api = require('./api'); -var Terminal = require('./ui/terminal').Terminal; - -// Patch-up old browsers -require('./util/legacy'); - /* - * GCLI is built from a number of components (called items) composed as - * required for each environment. - * When adding to or removing from this list, we should keep the basics in sync - * with the other environments. - * See: - * - lib/gcli/index.js: Generic basic set (without commands) - * - lib/gcli/demo.js: Adds demo commands to basic set for use in web demo - * - gcli.js: Add commands to basic set for use in Node command line - * - mozilla/gcli/index.js: From scratch listing for Firefox - * - lib/gcli/connectors/index.js: Client only items when executing remotely - * - lib/gcli/connectors/direct.js: Test items for connecting to in-process GCLI + * The intent of this module is to pull together all the parts needed to start + * a command line to make it easier to get started. + * + * Basic usage is like this: + * + * var gcli = require('gcli/index'); + * + * // A system is a set of commands/types/etc that the command line uses + * var system = gcli.createSystem(); + * system.addItems(gcli.items); + * system.addItems(gcli.commandItems); + * system.addItems([ + * // Your own commands go here + * ]); + * + * // Create the UI + * gcli.createTerminal(system).then(function(terminal) { + * // Take any actions when the command line starts for example + * terminal.language.showIntro(); + * }); */ -exports.items = [ - require('./types/delegate').items, - require('./types/selection').items, - require('./types/array').items, - - require('./types/boolean').items, - require('./types/command').items, - require('./types/date').items, - require('./types/file').items, - require('./types/javascript').items, - require('./types/node').items, - require('./types/number').items, - require('./types/resource').items, - require('./types/setting').items, - require('./types/string').items, - require('./types/union').items, - require('./types/url').items, - - require('./fields/fields').items, - require('./fields/delegate').items, - require('./fields/selection').items, - require('./ui/focus').items, - require('./ui/intro').items, - - require('./converters/converters').items, - require('./converters/basic').items, - require('./converters/html').items, - require('./converters/terminal').items, - - require('./languages/command').items, - require('./languages/javascript').items, - - // require('./connectors/direct').items, // Loopback for testing only - // require('./connectors/rdp').items, // Firefox remote debug protocol - require('./connectors/websocket').items, - require('./connectors/xhr').items, - - // No commands in the basic set -].reduce(function(prev, curr) { return prev.concat(curr); }, []); - -var system = api.createSystem(); +// Patch-up old browsers +require('./util/legacy'); -// Export the system API by adding it to our exports -Object.keys(system).forEach(function(key) { - exports[key] = system[key]; -}); +exports.createSystem = require('./system').createSystem; -system.addItems(exports.items); +var Terminal = require('./ui/terminal').Terminal; +exports.createTerminal = Terminal.create.bind(Terminal); /** - * createTerminal() calls 'Terminal.create()' but returns an object which - * exposes a much restricted set of functions rather than all those exposed - * by Terminal. - * This allows for robust testing without exposing too many internals. - * @param options See Terminal.create() for a description of the available - * options. + * This is all the items we need for a basic GCLI (except for the commands) */ -exports.createTerminal = function(options) { - options = options || {}; - if (options.settings != null) { - system.settings.setDefaults(options.settings); - } +exports.items = [ + require('./items/basic').items, + require('./items/ui').items, + require('./items/remote').items, +].reduce(function(prev, curr) { return prev.concat(curr); }, []); - return Terminal.create(system, options).then(function(terminal) { - options.terminal = terminal; - terminal.language.showIntro(); - return terminal; - }); -}; +exports.commandItems = require('./items/standard').items; diff --git a/lib/gcli/items/basic.js b/lib/gcli/items/basic.js new file mode 100644 index 00000000..6bb660ea --- /dev/null +++ b/lib/gcli/items/basic.js @@ -0,0 +1,50 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * This is a list of the types and converters that will be needed in almost all + * command lines. The types are listed in 2 sets because delegate and selection + * are depended on by others, (e.g boolean depends on selection) and array is + * built into cli.js parsing somewhat. + * + * Keeping this module small helps reduce bringing in unwanted dependencies. + */ +exports.items = [ + require('../types/delegate').items, + require('../types/selection').items, + require('../types/array').items, + + require('../types/boolean').items, + require('../types/command').items, + require('../types/date').items, + require('../types/file').items, + require('../types/javascript').items, + require('../types/node').items, + require('../types/number').items, + require('../types/resource').items, + require('../types/setting').items, + require('../types/string').items, + require('../types/union').items, + require('../types/url').items, + + require('../converters/converters').items, + require('../converters/basic').items, + require('../converters/html').items, + require('../converters/terminal').items, + +].reduce(function(prev, curr) { return prev.concat(curr); }, []); diff --git a/lib/gcli/commands/demo/edit.js b/lib/gcli/items/demo.js similarity index 56% rename from lib/gcli/commands/demo/edit.js rename to lib/gcli/items/demo.js index 62bb0883..88d6f390 100644 --- a/lib/gcli/commands/demo/edit.js +++ b/lib/gcli/items/demo.js @@ -16,24 +16,15 @@ 'use strict'; +/** + * This is a list of demo commands. + * + * Keeping this module small helps reduce bringing in unwanted dependencies. + */ exports.items = [ - { - item: 'command', - name: 'edit', - description: 'Edit a file', - params: [ - { - name: 'resource', - type: { name: 'resource', include: 'text/css' }, - description: 'The resource to edit' - } - ], - returnType: 'html', - exec: function(args, context) { - return args.resource.loadContents().then(function(data) { - return 'This is just a demo
' + - ''; - }); - } - } -]; + require('../commands/demo/alert').items, + require('../commands/demo/demo').items, + require('../commands/demo/echo').items, + require('../commands/demo/sleep').items, + +].reduce(function(prev, curr) { return prev.concat(curr); }, []); diff --git a/lib/gcli/items/remote.js b/lib/gcli/items/remote.js new file mode 100644 index 00000000..402ae545 --- /dev/null +++ b/lib/gcli/items/remote.js @@ -0,0 +1,29 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * This is a list of the available connectors when linking to GCLIs together + * + * Keeping this module small helps reduce bringing in unwanted dependencies. + */ +exports.items = [ + require('../connectors/websocket').items, + require('../connectors/xhr').items, + require('../connectors/direct').items, // Generally for testing only + +].reduce(function(prev, curr) { return prev.concat(curr); }, []); diff --git a/lib/gcli/items/server.js b/lib/gcli/items/server.js new file mode 100644 index 00000000..1697e89b --- /dev/null +++ b/lib/gcli/items/server.js @@ -0,0 +1,31 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * This is a list of the commands designed to run in nodejs / io.js. + * + * Keeping this module small helps reduce bringing in unwanted dependencies. + */ +exports.items = [ + require('../commands/server/exit').items, + require('../commands/server/firefox').items, + require('../commands/server/orion').items, + require('../commands/server/server').items, + require('../commands/server/standard').items + +].reduce(function(prev, curr) { return prev.concat(curr); }, []); diff --git a/lib/gcli/items/standard.js b/lib/gcli/items/standard.js new file mode 100644 index 00000000..1781379a --- /dev/null +++ b/lib/gcli/items/standard.js @@ -0,0 +1,40 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * This is a list of the standard commands that are likely to be in most + * command lines. + * + * Keeping this module small helps reduce bringing in unwanted dependencies. + */ +exports.items = [ + require('../cli').items, + require('../commands/clear').items, + require('../commands/connect').items, + require('../commands/context').items, + require('../commands/exec').items, + require('../commands/global').items, + require('../commands/help').items, + require('../commands/intro').items, + require('../commands/lang').items, + require('../commands/mocks').items, + require('../commands/pref').items, + require('../commands/preflist').items, + require('../commands/test').items, + +].reduce(function(prev, curr) { return prev.concat(curr); }, []); diff --git a/lib/gcli/items/ui.js b/lib/gcli/items/ui.js new file mode 100644 index 00000000..374364b2 --- /dev/null +++ b/lib/gcli/items/ui.js @@ -0,0 +1,36 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * This is a list of the fields, settings and languages that we need to build + * a user interface. + * + * Keeping this module small helps reduce bringing in unwanted dependencies. + */ +exports.items = [ + require('../fields/fields').items, + require('../fields/delegate').items, + require('../fields/selection').items, + + require('../ui/focus').items, + require('../ui/intro').items, + + require('../languages/command').items, + require('../languages/javascript').items, + +].reduce(function(prev, curr) { return prev.concat(curr); }, []); diff --git a/lib/gcli/languages/command.js b/lib/gcli/languages/command.js index f423a8e4..e8ec20af 100644 --- a/lib/gcli/languages/command.js +++ b/lib/gcli/languages/command.js @@ -184,7 +184,7 @@ var commandLanguage = exports.commandLanguage = { var isNew = (this.assignment !== newAssignment); this.assignment = newAssignment; - this.terminal.updateCompletion(); + this.terminal.updateCompletion().catch(util.errorHandler); if (isNew) { this.updateHints(); @@ -286,7 +286,10 @@ var commandLanguage = exports.commandLanguage = { } this.terminal.history.add(input); - this.terminal.unsetChoice(); + this.terminal.unsetChoice().catch(util.errorHandler); + + this.terminal._previousValue = this.terminal.inputElement.value; + this.terminal.inputElement.value = ''; return this.requisition.exec().then(function() { this.textChanged(); @@ -496,7 +499,7 @@ var commandLanguage = exports.commandLanguage = { this.terminal.scrollToBottom(); data.throbEle.style.display = ev.output.completed ? 'none' : 'block'; }.bind(this)); - }.bind(this)).then(null, console.error); + }.bind(this)).catch(console.error); this.terminal.addElement(data.rowinEle); this.terminal.addElement(data.rowoutEle); diff --git a/lib/gcli/languages/languages.js b/lib/gcli/languages/languages.js index 49e0f1ec..3c5e8845 100644 --- a/lib/gcli/languages/languages.js +++ b/lib/gcli/languages/languages.js @@ -51,8 +51,9 @@ var baseLanguage = { }, handleTab: function() { - this.terminal.unsetChoice(); - return RESOLVED; + return this.terminal.unsetChoice().then(function() { + return RESOLVED; + }, util.errorHandler); }, handleInput: function(input) { @@ -62,8 +63,9 @@ var baseLanguage = { }.bind(this)); } - this.terminal.unsetChoice(); - return RESOLVED; + return this.terminal.unsetChoice().then(function() { + return RESOLVED; + }, util.errorHandler); }, handleReturn: function(input) { @@ -80,7 +82,7 @@ var baseLanguage = { this.focusManager.outputted(); - this.terminal.unsetChoice(); + this.terminal.unsetChoice().catch(util.errorHandler); this.terminal.inputElement.value = ''; }.bind(this)); }, diff --git a/lib/gcli/nls/strings.js b/lib/gcli/nls/strings.js index 96b7c1b7..c9b7d785 100644 --- a/lib/gcli/nls/strings.js +++ b/lib/gcli/nls/strings.js @@ -38,7 +38,7 @@ var i18n = { cliOptions: 'Available Options', // The error message when the user types a command that isn't registered - cliUnknownCommand: 'Invalid Command', + cliUnknownCommand2: 'Invalid Command: \'%1$S\'.', // A parameter should have a value, but doesn't cliIncompleteParam: 'Value required for \'%1$S\'.', diff --git a/lib/gcli/settings.js b/lib/gcli/settings.js index 9f23f833..42231042 100644 --- a/lib/gcli/settings.js +++ b/lib/gcli/settings.js @@ -46,7 +46,7 @@ Settings.prototype.setDefaults = function(newValues) { if (this._settingValues[name] === undefined) { this._settingValues[name] = newValues[name]; } - }); + }.bind(this)); }; /** @@ -67,7 +67,6 @@ Settings.prototype.getAll = function(filter) { /** * Add a new setting - * @return The new Setting object */ Settings.prototype.add = function(prefSpec) { var type = this._types.createType(prefSpec.type); diff --git a/lib/gcli/system.js b/lib/gcli/system.js new file mode 100644 index 00000000..c62f8d8a --- /dev/null +++ b/lib/gcli/system.js @@ -0,0 +1,323 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var Promise = require('./util/promise').Promise; +var util = require('./util/util'); +var Commands = require('./commands/commands').Commands; +var Connectors = require('./connectors/connectors').Connectors; +var Converters = require('./converters/converters').Converters; +var Fields = require('./fields/fields').Fields; +var Languages = require('./languages/languages').Languages; +var Settings = require('./settings').Settings; +var Types = require('./types/types').Types; + +/** + * This is the heart of the API that we expose to the outside. + * @param options Object that customizes how the system acts. Valid properties: + * - commands, connectors, converters, fields, languages, settings, types: + * Custom configured manager objects for these item types + * - location: a system with a location will ignore commands that don't have a + * matching runAt property. This is principly for client/server setups where + * we import commands from the server to the client, so a system with + * `{ location: 'client' }` will silently ignore commands with + * `{ runAt: 'server' }`. Any system without a location will accept commands + * with any runAt property (including none). + */ +exports.createSystem = function(options) { + options = options || {}; + var location = options.location; + + // The plural/singular thing may make you want to scream, but it allows us + // to say components[getItemType(item)], so a lookup here (and below) saves + // multiple lookups in the middle of the code + var components = { + connector: options.connectors || new Connectors(), + converter: options.converters || new Converters(), + field: options.fields || new Fields(), + language: options.languages || new Languages(), + type: options.types || new Types() + }; + components.setting = new Settings(components.type); + components.command = new Commands(components.type, location); + + var getItemType = function(item) { + if (item.item) { + return item.item; + } + // Some items are registered using the constructor so we need to check + // the prototype for the the type of the item + return (item.prototype && item.prototype.item) ? + item.prototype.item : 'command'; + }; + + var addItem = function(item) { + try { + components[getItemType(item)].add(item); + } + catch (ex) { + if (item != null) { + console.error('While adding: ' + item.name); + } + throw ex; + } + }; + + var removeItem = function(item) { + components[getItemType(item)].remove(item); + }; + + /** + * loadableModules is a lookup of names to module loader functions (like + * the venerable 'require') to which we can pass a name and get back a + * JS object (or a promise of a JS object). This allows us to have custom + * loaders to get stuff from the filesystem etc. + */ + var loadableModules = {}; + + /** + * loadedModules is a lookup by name of the things returned by the functions + * in loadableModules so we can track what we need to unload / reload. + */ + var loadedModules = {}; + + var unloadModule = function(name) { + var existingModule = loadedModules[name]; + if (existingModule != null) { + existingModule.items.forEach(removeItem); + } + delete loadedModules[name]; + }; + + var loadModule = function(name) { + var existingModule = loadedModules[name]; + unloadModule(name); + + // And load the new items + try { + var loader = loadableModules[name]; + return Promise.resolve(loader(name)).then(function(newModule) { + if (existingModule === newModule) { + return; + } + + if (newModule == null) { + throw 'Module \'' + name + '\' not found'; + } + + if (newModule.items == null || typeof newModule.items.forEach !== 'function') { + console.log('Exported properties: ' + Object.keys(newModule).join(', ')); + throw 'Module \'' + name + '\' has no \'items\' array export'; + } + + newModule.items.forEach(addItem); + + loadedModules[name] = newModule; + }); + } + catch (ex) { + console.error('Failed to load module ' + name + ': ' + ex); + console.error(ex.stack); + } + }; + + var pendingChanges = false; + + var system = { + addItems: function(items) { + items.forEach(addItem); + }, + + removeItems: function(items) { + items.forEach(removeItem); + }, + + addItemsByModule: function(names, options) { + options = options || {}; + if (typeof names === 'string') { + names = [ names ]; + } + names.forEach(function(name) { + if (options.loader == null) { + options.loader = function(name) { + return require(name); + }; + } + loadableModules[name] = options.loader; + + if (options.delayedLoad) { + pendingChanges = true; + } + else { + loadModule(name).catch(console.error); + } + }); + }, + + removeItemsByModule: function(name) { + delete loadableModules[name]; + unloadModule(name); + }, + + load: function() { + if (!pendingChanges) { + return Promise.resolve(); + } + + // clone loadedModules, so we can remove what is left at the end + var modules = Object.keys(loadedModules).map(function(name) { + return loadedModules[name]; + }); + + var promises = Object.keys(loadableModules).map(function(name) { + delete modules[name]; + return loadModule(name); + }); + + Object.keys(modules).forEach(unloadModule); + pendingChanges = false; + + return Promise.all(promises); + }, + + toString: function() { + return 'System [' + + 'commands:' + components.command.getAll().length + ', ' + + 'connectors:' + components.connector.getAll().length + ', ' + + 'converters:' + components.converter.getAll().length + ', ' + + 'fields:' + components.field.getAll().length + ', ' + + 'settings:' + components.setting.getAll().length + ', ' + + 'types:' + components.type.getTypeNames().length + ']'; + } + }; + + Object.defineProperty(system, 'commands', { + get: function() { return components.command; }, + enumerable: true + }); + + Object.defineProperty(system, 'connectors', { + get: function() { return components.connector; }, + enumerable: true + }); + + Object.defineProperty(system, 'converters', { + get: function() { return components.converter; }, + enumerable: true + }); + + Object.defineProperty(system, 'fields', { + get: function() { return components.field; }, + enumerable: true + }); + + Object.defineProperty(system, 'languages', { + get: function() { return components.language; }, + enumerable: true + }); + + Object.defineProperty(system, 'settings', { + get: function() { return components.setting; }, + enumerable: true + }); + + Object.defineProperty(system, 'types', { + get: function() { return components.type; }, + enumerable: true + }); + + return system; +}; + +/** + * Connect a local system with another at the other end of a connector + * @param system System to which we're adding commands + * @param front Front which allows access to the remote system from which we + * import commands + * @param customProps Array of strings specifying additional properties defined + * on remote commands that should be considered part of the metadata for the + * commands imported into the local system + */ +exports.connectFront = function(system, front, customProps) { + front.on('commandsChanged', function(specs) { + syncItems(system, front, customProps).catch(util.errorHandler); + }); + + return syncItems(system, front, customProps); +}; + +/** + * Remove the items in this system that came from a previous sync action, and + * re-add them. See connectFront() for explanation of properties + */ +function syncItems(system, front, customProps) { + return front.specs(customProps).then(function(specs) { + // Go through all the commands removing any that are associated with the + // given front. The method of association is the hack in addLocalFunctions. + system.commands.getAll().forEach(function(command) { + if (command.front === front) { + system.commands.remove(command); + } + }); + + var remoteItems = addLocalFunctions(specs, front); + system.addItems(remoteItems); + + return system; + }); +}; + +/** + * Take the data from the 'specs' command (or the 'commandsChanged' event) and + * add function to proxy the execution back over the front + */ +function addLocalFunctions(specs, front) { + // Inject an 'exec' function into the commands, and the front into + // all the remote types + specs.forEach(function(commandSpec) { + // HACK: Tack the front to the command so we know how to remove it + // in syncItems() below + commandSpec.front = front; + + // TODO: syncItems() doesn't remove types, so do we need this? + commandSpec.params.forEach(function(param) { + if (typeof param.type !== 'string') { + param.type.front = front; + } + }); + + if (!commandSpec.isParent) { + commandSpec.exec = function(args, context) { + var typed = (context.prefix ? context.prefix + ' ' : '') + context.typed; + + return front.execute(typed).then(function(reply) { + var typedData = context.typedData(reply.type, reply.data); + if (!reply.error) { + return typedData; + } + else { + throw typedData; + } + }); + }; + } + + commandSpec.isProxy = true; + }); + + return specs; +} diff --git a/lib/gcli/test/index.js b/lib/gcli/test/index.js index 33d18359..b49387b2 100644 --- a/lib/gcli/test/index.js +++ b/lib/gcli/test/index.js @@ -28,9 +28,7 @@ require('./suite'); /** * Some tricks to make studying the command line state easier */ -var addDebugAids = exports.addDebugAids = function(options, terminal) { - var requisition = terminal.language.requisition; - +var addDebugAids = exports.addDebugAids = function(options) { window.createDebugCheck = function() { helpers._createDebugCheck(options).then(function() { // Don't inline this - chrome console suckage @@ -40,7 +38,7 @@ var addDebugAids = exports.addDebugAids = function(options, terminal) { window.summaryJson = function() { var args = [ 'Requisition: ' ]; - var summary = requisition._summaryJson; + var summary = options.terminal.language.requisition._summaryJson; Object.keys(summary).forEach(function(name) { args.push(' ' + name + '='); args.push(summary[name]); @@ -48,8 +46,8 @@ var addDebugAids = exports.addDebugAids = function(options, terminal) { console.log.apply(console, args); console.log('Focus: ' + - 'tooltip=', terminal.focusManager._shouldShowTooltip(), - 'output=', terminal.focusManager._shouldShowOutput()); + 'tooltip=', options.terminal.focusManager._shouldShowTooltip(), + 'output=', options.terminal.focusManager._shouldShowOutput()); }; document.addEventListener('keyup', function(ev) { @@ -71,19 +69,20 @@ var addDebugAids = exports.addDebugAids = function(options, terminal) { * - Runs the unit tests automatically on startup * - Registers a 'test' command to re-run the unit tests */ -exports.run = function(options) { - var requisition = options.terminal.language.requisition; - - options.window = window; - options.automator = createTerminalAutomator(options.terminal); - options.requisition = requisition; - options.isNode = false; - options.isFirefox = false; - options.isPhantomjs = (window.navigator.userAgent.indexOf('hantom') !== -1); - options.isRemote = (options.connection != null); - options.hideExec = true; +exports.run = function(terminal, isRemote) { + var options = { + terminal: terminal, + window: window, + automator: createTerminalAutomator(terminal), + requisition: terminal.language.requisition, + isNode: false, + isFirefox: false, + isPhantomjs: (window.navigator.userAgent.indexOf('hantom') !== -1), + isRemote: isRemote, + hideExec: true + }; - addDebugAids(options, options.terminal); + addDebugAids(options); // phantom-test.js does phantom.exit() on `document.complete = true` var closeIfPhantomJs = function() { @@ -94,7 +93,7 @@ exports.run = function(options) { // PhantomJS may tell us to tell the server to shutdown if (window.location.href.indexOf('shutdown=true') > 0) { - shutdownServer(requisition).then(closeIfPhantomJs, closeIfPhantomJs); + shutdownServer(terminal.language.requisition).then(closeIfPhantomJs, closeIfPhantomJs); return; } @@ -111,7 +110,7 @@ exports.run = function(options) { console.log(helpers.timingSummary); document.testStatus = examiner.status.name; - setMocks(options, false).then(closeIfPhantomJs).then(null, function(ex) { + setMocks(options, false).then(closeIfPhantomJs).catch(function(ex) { console.error(ex); closeIfPhantomJs(); }); diff --git a/lib/gcli/test/testCanon.js b/lib/gcli/test/testCanon.js index f223db76..938d3062 100644 --- a/lib/gcli/test/testCanon.js +++ b/lib/gcli/test/testCanon.js @@ -195,6 +195,9 @@ exports.testAltCommands = function(options) { { name: 'num', type: 'number' }, { name: 'opt', type: { name: 'selection', data: [ '1', '2', '3' ] } }, ], + customProp1: 'localValue', + customProp2: true, + customProp3: 42, exec: function(args, context) { return context.commandName + ':' + args.str + ':' + args.num + ':' + args.opt; @@ -211,6 +214,24 @@ exports.testAltCommands = function(options) { '],"isParent":false}]', 'JSON.stringify(commandSpecs)'); + var customProps = [ 'customProp1', 'customProp2', 'customProp3', ]; + var commandSpecs2 = altCommands.getCommandSpecs(customProps); + assert.is(JSON.stringify(commandSpecs2), + '[{' + + '"item":"command",' + + '"name":"tss",' + + '"params":[' + + '{"name":"str","type":"string"},' + + '{"name":"num","type":"number"},' + + '{"name":"opt","type":{"name":"selection","data":["1","2","3"]}}' + + '],' + + '"isParent":false,' + + '"customProp1":"localValue",' + + '"customProp2":true,' + + '"customProp3":42' + + '}]', + 'JSON.stringify(commandSpecs)'); + var remoter = function(args, context) { assert.is(context.commandName, 'tss', 'commandName is tss'); diff --git a/lib/gcli/test/testCli2.js b/lib/gcli/test/testCli2.js index 1a8ecaf6..3b242336 100644 --- a/lib/gcli/test/testCli2.js +++ b/lib/gcli/test/testCli2.js @@ -581,7 +581,7 @@ exports.testNestedCommand = function(options) { } }, { - skipIf: options.isPhantomjs, + skipIf: options.isPhantomjs, // PhantomJS gets predictions wrong setup: 'tsn x', check: { input: 'tsn x', diff --git a/lib/gcli/test/testCompletion1.js b/lib/gcli/test/testCompletion1.js index afcff226..1b1821f0 100644 --- a/lib/gcli/test/testCompletion1.js +++ b/lib/gcli/test/testCompletion1.js @@ -159,7 +159,7 @@ exports.testActivate = function(options) { } }, { - skipIf: options.isPhantomjs, + skipIf: options.isPhantomjs, // PhantomJS gets predictions wrong setup: 'tsg d', check: { hints: ' [options] -> ccc' diff --git a/lib/gcli/test/testExec.js b/lib/gcli/test/testExec.js index 30b0e263..018e2b16 100644 --- a/lib/gcli/test/testExec.js +++ b/lib/gcli/test/testExec.js @@ -399,7 +399,7 @@ exports.testExecNode = function(options) { return helpers.audit(options, [ { - skipIf: options.isNoDom, + skipIf: options.isNoDom || options.isRemote, setup: 'tse :root', check: { input: 'tse :root', diff --git a/lib/gcli/test/testFile.js b/lib/gcli/test/testFile.js index c00dc4bf..78a434fa 100644 --- a/lib/gcli/test/testFile.js +++ b/lib/gcli/test/testFile.js @@ -23,10 +23,7 @@ var local = false; exports.testBasic = function(options) { return helpers.audit(options, [ { - // These tests require us to be using node directly or to be in - // PhantomJS connected to an execute enabled node server or to be in - // firefox. - skipRemainingIf: options.isPhantomjs || options.isFirefox, + skipRemainingIf: options.isFirefox, // No file implementation in Firefox setup: 'tsfile open /', check: { input: 'tsfile open /', diff --git a/lib/gcli/test/testPref1.js b/lib/gcli/test/testPref1.js index b227a8a1..4082a301 100644 --- a/lib/gcli/test/testPref1.js +++ b/lib/gcli/test/testPref1.js @@ -135,6 +135,7 @@ exports.testPrefSetStatus = function(options) { } }, { + skipIf: options.isRemote, setup: 'pref set tempTBool 4', check: { typed: 'pref set tempTBool 4', diff --git a/lib/gcli/test/testRemoteWs.js b/lib/gcli/test/testRemoteWs.js index 2f5f15aa..b67c7240 100644 --- a/lib/gcli/test/testRemoteWs.js +++ b/lib/gcli/test/testRemoteWs.js @@ -59,8 +59,8 @@ exports.testRemoteWebsocket = function(options) { check: { args: { prefix: { - value: function(connection) { - assert.is(connection.prefix, 'remote', 'disconnecting remote'); + value: function(front) { + assert.is(front.prefix, 'remote', 'disconnecting remote'); } } } @@ -88,8 +88,8 @@ exports.testRemoteWebsocket = function(options) { check: { args: { prefix: { - value: function(connection) { - assert.is(connection.prefix, 'remote', 'disconnecting remote'); + value: function(front) { + assert.is(front.prefix, 'remote', 'disconnecting remote'); } } } @@ -442,8 +442,8 @@ exports.testRemoteWebsocket = function(options) { unassigned: [ ], args: { prefix: { - value: function(connection) { - assert.is(connection.prefix, 'remote', 'disconnecting remote'); + value: function(front) { + assert.is(front.prefix, 'remote', 'disconnecting remote'); }, arg: ' remote', status: 'VALID', diff --git a/lib/gcli/test/testRemoteXhr.js b/lib/gcli/test/testRemoteXhr.js index e0c6359b..1792be19 100644 --- a/lib/gcli/test/testRemoteXhr.js +++ b/lib/gcli/test/testRemoteXhr.js @@ -59,8 +59,8 @@ exports.testRemoteXhr = function(options) { check: { args: { prefix: { - value: function(connection) { - assert.is(connection.prefix, 'remote', 'disconnecting remote'); + value: function(front) { + assert.is(front.prefix, 'remote', 'disconnecting remote'); } } } @@ -88,8 +88,8 @@ exports.testRemoteXhr = function(options) { check: { args: { prefix: { - value: function(connection) { - assert.is(connection.prefix, 'remote', 'disconnecting remote'); + value: function(front) { + assert.is(front.prefix, 'remote', 'disconnecting remote'); } } } @@ -442,8 +442,8 @@ exports.testRemoteXhr = function(options) { unassigned: [ ], args: { prefix: { - value: function(connection) { - assert.is(connection.prefix, 'remote', 'disconnecting remote'); + value: function(front) { + assert.is(front.prefix, 'remote', 'disconnecting remote'); }, arg: ' remote', status: 'VALID', diff --git a/lib/gcli/test/testUnion.js b/lib/gcli/test/testUnion.js index 8a27a0e1..b5b7a802 100644 --- a/lib/gcli/test/testUnion.js +++ b/lib/gcli/test/testUnion.js @@ -102,7 +102,7 @@ exports.testDefault = function(options) { } }, { - skipIf: options.isPhantomjs, // Phantom goes weird with predictions + skipIf: options.isPhantomjs, // PhantomJS gets predictions wrong setup: 'unionc1 5', check: { input: 'unionc1 5', @@ -136,7 +136,7 @@ exports.testDefault = function(options) { } }, { - skipRemainingIf: options.isPhantomjs, + skipIf: options.isPhantomjs, // PhantomJS URL type is broken setup: 'unionc2 on', check: { input: 'unionc2 on', diff --git a/lib/gcli/test/testUrl.js b/lib/gcli/test/testUrl.js index 54edee36..5a332b80 100644 --- a/lib/gcli/test/testUrl.js +++ b/lib/gcli/test/testUrl.js @@ -22,7 +22,7 @@ var helpers = require('./helpers'); exports.testDefault = function(options) { return helpers.audit(options, [ { - skipRemainingIf: options.isPhantomjs, + skipRemainingIf: options.isPhantomjs, // PhantomJS URL type is broken setup: 'urlc', check: { input: 'urlc', diff --git a/lib/gcli/testharness/examiner.js b/lib/gcli/testharness/examiner.js index e6d51438..edabd3c6 100644 --- a/lib/gcli/testharness/examiner.js +++ b/lib/gcli/testharness/examiner.js @@ -379,6 +379,14 @@ Test.prototype.run = function(options) { * Object.toString could be a lot better */ function toString(err) { + if (err === null) { + return 'null'; + } + + if (err === undefined) { + return 'undefined'; + } + // Convert err to a string if (typeof err === 'string') { return err; diff --git a/lib/gcli/types/delegate.js b/lib/gcli/types/delegate.js index 50597f6b..1e9cb99f 100644 --- a/lib/gcli/types/delegate.js +++ b/lib/gcli/types/delegate.js @@ -29,14 +29,6 @@ exports.items = [ item: 'type', name: 'delegate', - constructor: function() { - if (typeof this.delegateType !== 'function' && - typeof this.delegateType !== 'string') { - throw new Error('Instances of DelegateType need typeSpec.delegateType' + - ' to be a function that returns a type'); - } - }, - getSpec: function(commandName, paramName) { return { name: 'delegate', @@ -47,9 +39,7 @@ exports.items = [ // Child types should implement this method to return an instance of the type // that should be used. If no type is available, or some sort of temporary // placeholder is required, BlankType can be used. - delegateType: function(context) { - throw new Error('Not implemented'); - }, + delegateType: undefined, stringify: function(value, context) { return this.getType(context).then(function(delegated) { @@ -80,6 +70,10 @@ exports.items = [ }, getType: function(context) { + if (this.delegateType === undefined) { + return Promise.resolve(this.types.createType('blank')); + } + var type = this.delegateType(context); if (typeof type.parse !== 'function') { type = this.types.createType(type); @@ -87,8 +81,8 @@ exports.items = [ return Promise.resolve(type); }, - // DelegateType is designed to be inherited from, so DelegateField needs a way - // to check if something works like a delegate without using 'name' + // DelegateType is designed to be inherited from, so DelegateField needs a + // way to check if something works like a delegate without using 'name' isDelegate: true, // Technically we perhaps should proxy this, except that properties are @@ -114,8 +108,7 @@ exports.items = [ }, parse: function(arg, context) { - var args = { typed: context.typed, param: this.param }; - return this.connection.call('typeparse', args).then(function(json) { + return this.front.parseType(context.typed, this.param).then(function(json) { var status = Status.fromString(json.status); var val = { stringified: arg.text }; return new Conversion(val, arg, status, json.message, json.predictions); @@ -123,15 +116,13 @@ exports.items = [ }, decrement: function(value, context) { - var args = { typed: context.typed, param: this.param }; - return this.connection.call('typedecrement', args).then(function(json) { + return this.front.decrementType(context.typed, this.param).then(function(json) { return { stringified: json.arg }; }); }, increment: function(value, context) { - var args = { typed: context.typed, param: this.param }; - return this.connection.call('typeincrement', args).then(function(json) { + return this.front.incrementType(context.typed, this.param).then(function(json) { return { stringified: json.arg }; }); } diff --git a/lib/gcli/types/selection.js b/lib/gcli/types/selection.js index 5ec3d38f..1ab4fea7 100644 --- a/lib/gcli/types/selection.js +++ b/lib/gcli/types/selection.js @@ -126,19 +126,11 @@ SelectionType.prototype.getLookup = function(context) { var reply; if (this.remoteLookup) { - reply = this.connection.call('selectioninfo', { - action: 'lookup', - commandName: this.commandName, - paramName: this.paramName - }); + reply = this.front.getSelectionLookup(this.commandName, this.paramName); reply = resolve(reply, context); } else if (this.remoteData) { - reply = this.connection.call('selectioninfo', { - action: 'data', - commandName: this.commandName, - paramName: this.paramName - }); + reply = this.front.getSelectionData(this.commandName, this.paramName); reply = resolve(reply, context).then(this._dataToLookup); } else if (typeof this.lookup === 'function') { @@ -227,7 +219,7 @@ exports.findPredictions = function(arg, lookup) { } // Exact hidden matches. If 'hidden: true' then we only allow exact matches - // All the tests after here check that !option.value.hidden + // All the tests after here check that !isHidden(option) for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { option = lookup[i]; if (option.name === arg.text) { @@ -238,7 +230,7 @@ exports.findPredictions = function(arg, lookup) { // Start with prefix matching for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { option = lookup[i]; - if (option._gcliLowerName.indexOf(match) === 0 && !option.value.hidden) { + if (option._gcliLowerName.indexOf(match) === 0 && !isHidden(option)) { if (predictions.indexOf(option) === -1) { predictions.push(option); } @@ -249,7 +241,7 @@ exports.findPredictions = function(arg, lookup) { if (predictions.length < (maxPredictions / 2)) { for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { option = lookup[i]; - if (option._gcliLowerName.indexOf(match) !== -1 && !option.value.hidden) { + if (option._gcliLowerName.indexOf(match) !== -1 && !isHidden(option)) { if (predictions.indexOf(option) === -1) { predictions.push(option); } @@ -261,7 +253,7 @@ exports.findPredictions = function(arg, lookup) { if (predictions.length === 0) { var names = []; lookup.forEach(function(opt) { - if (!opt.value.hidden) { + if (!isHidden(opt)) { names.push(opt.name); } }); @@ -306,11 +298,21 @@ exports.convertPredictions = function(arg, predictions) { Promise.resolve(predictions)); }; +/** + * Checking that an option is hidden involves messing in properties on the + * value right now (which isn't a good idea really) we really should be marking + * that on the option, so this encapsulates the problem + */ +function isHidden(option) { + return option.hidden === true || + (option.value != null && option.value.hidden); +} + SelectionType.prototype.getBlank = function(context) { var predictFunc = function(context2) { return Promise.resolve(this.getLookup(context2)).then(function(lookup) { return lookup.filter(function(option) { - return !option.value.hidden; + return !isHidden(option); }).slice(0, Conversion.maxPredictions - 1); }); }.bind(this); diff --git a/lib/gcli/types/setting.js b/lib/gcli/types/setting.js index 509b9514..26c6f406 100644 --- a/lib/gcli/types/setting.js +++ b/lib/gcli/types/setting.js @@ -25,12 +25,15 @@ exports.items = [ cacheable: true, lookup: function(context) { var settings = context.system.settings; + + // Lazily add a settings.onChange listener to clear the cache if (!this._registeredListener) { settings.onChange.add(function(ev) { this.clearCache(); }, this); this._registeredListener = true; } + return settings.getAll().map(function(setting) { return { name: setting.name, value: setting }; }); diff --git a/lib/gcli/types/types.js b/lib/gcli/types/types.js index e92d2626..5b672c71 100644 --- a/lib/gcli/types/types.js +++ b/lib/gcli/types/types.js @@ -946,8 +946,8 @@ function Type() { } /** - * Get a JSONable data structure that entirely describes this type - * @param commandName/paramName The names of the command and parameter which we + * Get a JSONable data structure that entirely describes this type. + * commandName and paramName are the names of the command and parameter which we * are remoting to help the server get back to the remoted action. */ Type.prototype.getSpec = function(commandName, paramName) { diff --git a/lib/gcli/ui/menu.js b/lib/gcli/ui/menu.js index fef02103..6ba00e76 100644 --- a/lib/gcli/ui/menu.js +++ b/lib/gcli/ui/menu.js @@ -256,7 +256,7 @@ function getHighlightingProxy(item, match, document) { } /** - * @return The current choice index + * @return {int} current choice index */ Menu.prototype.getChoiceIndex = function() { return this._choice == null ? 0 : this._choice; diff --git a/lib/gcli/ui/terminal.js b/lib/gcli/ui/terminal.js index 2e7629d2..8ddf687e 100644 --- a/lib/gcli/ui/terminal.js +++ b/lib/gcli/ui/terminal.js @@ -42,11 +42,12 @@ function Terminal() { /** * A wrapper to take care of the functions concerning an input element - * @param components Object that links to other UI components. GCLI provided: + * @param options Object that links to other UI components. GCLI provided: * - requisition * - document */ Terminal.create = function(system, options) { + options = options || {}; if (resourcesPromise == null) { resourcesPromise = Promise.all([ host.staticRequire(module, './terminal.css'), @@ -150,7 +151,7 @@ Terminal.prototype._init = function(system, options, terminalCss, terminalHtml) this.focusManager.addMonitoredElement(this.tooltipElement, 'tooltip'); this.focusManager.addMonitoredElement(this.inputElement, 'input'); - this.onInputChange.add(this.updateCompletion, this); + this.onInputChange.add(this._updateCompletionWithErrorHandler, this); host.script.onOutput.add(this.onOutput); @@ -185,7 +186,7 @@ Terminal.prototype.destroy = function() { this.field.onFieldChange.remove(this.fieldChanged, this); this.field.destroy(); - this.onInputChange.remove(this.updateCompletion, this); + this.onInputChange.remove(this._updateCompletionWithErrorHandler, this); // Remove the output elements so they free the event handers util.clearElement(this.displayElement); @@ -265,7 +266,7 @@ Terminal.prototype._updateLanguage = function(language) { } this.language.updateHints(); - this.updateCompletion(); + this.updateCompletion().catch(util.errorHandler); this.promptElement.innerHTML = this.language.prompt; }; @@ -392,7 +393,7 @@ Terminal.prototype.onKeyDown = function(ev) { * if something went wrong. */ Terminal.prototype.onKeyUp = function(ev) { - this.handleKeyUp(ev).then(null, util.errorHandler); + this.handleKeyUp(ev).catch(util.errorHandler); }; /** @@ -584,6 +585,14 @@ Terminal.prototype.updateCompletion = function() { }.bind(this)); }; +/** + * Call updateCompletion, but log if something is wrong. To be called by + * event handlers that can't react to rejected promises. + */ +Terminal.prototype._updateCompletionWithErrorHandler = function() { + this.updateCompletion().catch(util.errorHandler); +}; + /** * The terminal acts on UP/DOWN if there is a menu showing */ diff --git a/lib/gcli/util/host.js b/lib/gcli/util/host.js index 9e84437d..0012a4fd 100644 --- a/lib/gcli/util/host.js +++ b/lib/gcli/util/host.js @@ -75,7 +75,7 @@ exports.Highlighter = Highlighter; /** * Helper to execute an arbitrary OS-level command. * @param context From which we get the shell containing cwd and env - * @param execSpec Object containing some of the following properties: + * @param spawnSpec Object containing some of the following properties: * - cmd (string): The command to execute (required) * - args (string[]): The arguments to pass to the command (default: []) * - cwd (string): The current working directory diff --git a/lib/gcli/util/util.js b/lib/gcli/util/util.js index 800e416c..4e205f4b 100644 --- a/lib/gcli/util/util.js +++ b/lib/gcli/util/util.js @@ -275,7 +275,7 @@ exports.promiseEach = function(array, action, scope) { }; var reply = action.call(scope, array[index], index, array); - Promise.resolve(reply).then(onSuccess).then(null, onFailure); + Promise.resolve(reply).then(onSuccess).catch(onFailure); }; callNext(0); diff --git a/remote.html b/remote.html index 6c586514..987d105f 100644 --- a/remote.html +++ b/remote.html @@ -27,12 +27,11 @@ }); require([ 'gcli/index', 'gcli/test/index', 'gcli/connectors/index' ], function(gcli, test, cnx) { - var options = { connector: 'websocket' }; - cnx.connect(options).then(function() { - gcli.createTerminal(options).then(function() { - test.run(options); + cnx.createSystem({ connector: 'websocket' }).then(function(system) { + gcli.createTerminal(system).then(function(terminal) { + test.run(terminal, true); }); - }).then(null, console.error.bind(console)); + }).catch(console.error.bind(console)); }); diff --git a/web/gcli/types/fileparser.js b/web/gcli/types/fileparser.js index b8e6c2b8..969c92b1 100644 --- a/web/gcli/types/fileparser.js +++ b/web/gcli/types/fileparser.js @@ -18,6 +18,8 @@ var Promise = require('../util/promise').Promise; var Status = require('./types').Status; +var util = require('../util/util'); +var GcliFront = require('../connectors/remoted').GcliFront; /** * Helper for the parse() function from the file type. @@ -38,23 +40,19 @@ var Status = require('./types').Status; * and can contain a boolean 'complete' property */ exports.parse = function(context, typed, options) { - var data = { - typed: typed, - filetype: options.filetype, - existing: options.existing, - matches: options.matches == null ? undefined : options.matches.source - }; + var matches = options.matches == null ? undefined : options.matches.source; - var connectors = context.system.connectors; - return connectors.get().connect().then(function(connection) { - return connection.call('parsefile', data).then(function(reply) { + var connector = context.system.connectors.get(); + return GcliFront.create(connector).then(function(front) { + return front.parseFile(typed, options.filetype, + options.existing, matches).then(function(reply) { reply.status = Status.fromString(reply.status); if (reply.predictions != null) { reply.predictor = function() { return Promise.resolve(reply.predictions); }; } - connection.disconnect(); + front.connection.disconnect().catch(util.errorHandler); return reply; }); }); diff --git a/web/gcli/util/host.js b/web/gcli/util/host.js index fef711d8..d4d06483 100644 --- a/web/gcli/util/host.js +++ b/web/gcli/util/host.js @@ -19,6 +19,7 @@ var util = require('./util'); var Promise = require('../util/promise').Promise; +var GcliFront = require('../connectors/remoted').GcliFront; /** * Markup a web page to highlight a collection of elements @@ -70,22 +71,20 @@ exports.Highlighter = Highlighter; */ exports.spawn = function(context, spawnSpec) { // Make sure we're only sending strings across XHR + var cmd = '' + spawnSpec.cmd; var cleanArgs = (spawnSpec.args || []).map(function(arg) { return '' + arg; }); + var cwd = '' + spawnSpec.cwd; var cleanEnv = Object.keys(spawnSpec.env || {}).reduce(function(prev, key) { prev[key] = '' + spawnSpec.env[key]; return prev; }, {}); - return context.system.connectors.get().connect().then(function(connection) { - return connection.call('system', { - cmd: '' + spawnSpec.cmd, - args: cleanArgs, - cwd: '' + spawnSpec.cwd, - env: cleanEnv - }).then(function(reply) { - connection.disconnect(); + var connector = context.system.connectors.get(); + return GcliFront.create(connector).then(function(front) { + return front.system(cmd, cleanArgs, cwd, cleanEnv).then(function(reply) { + front.connection.disconnect().catch(util.errorHandler); return reply; }); });