diff --git a/packages/xo-cli/.editorconfig b/packages/xo-cli/.editorconfig new file mode 100644 index 000000000..e35119a31 --- /dev/null +++ b/packages/xo-cli/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespaces = true diff --git a/packages/xo-cli/.gitignore b/packages/xo-cli/.gitignore new file mode 100644 index 000000000..2ccbe4656 --- /dev/null +++ b/packages/xo-cli/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/packages/xo-cli/.jshintrc b/packages/xo-cli/.jshintrc new file mode 100644 index 000000000..97fda4de7 --- /dev/null +++ b/packages/xo-cli/.jshintrc @@ -0,0 +1,90 @@ +{ + // Julien Fontanet JSHint configuration + // + // Changes from defaults: + // - all enforcing options (except `++` & `--`) enabled + // - single quotes + // - indentation set to 2 instead of 4 + // - almost all relaxing options disabled + // - allow expression statements (necessary for chai.expect()) + // - allow global strict (most of my devs are in Node.js or Browserify) + // - environments are set to Browserify, mocha & Node.js + // + // See http://jshint.com/docs/ for more details + + "maxerr" : 50, // {int} Maximum error before stopping + + // Enforcing + "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) + "camelcase" : true, // true: Identifiers must be in camelCase + "curly" : true, // true: Require {} for every new block or scope + "eqeqeq" : true, // true: Require triple equals (===) for comparison + "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() + "immed" : true, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` + "indent" : 2, // {int} Number of spaces to use for indentation + "latedef" : true, // true: Require variables/functions to be defined before being used + "newcap" : true, // true: Require capitalization of all constructor functions e.g. `new F()` + "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` + "noempty" : true, // true: Prohibit use of empty blocks + "nonew" : true, // true: Prohibit use of constructors for side-effects (without assignment) + "plusplus" : false, // true: Prohibit use of `++` & `--` + "quotmark" : "single", // Quotation mark consistency: + // false : do nothing (default) + // true : ensure whatever is used is consistent + // "single" : require single quotes + // "double" : require double quotes + "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) + "unused" : true, // true: Require all defined variables be used + "strict" : true, // true: Requires all functions run in ES5 Strict Mode + "maxparams" : 4, // {int} Max number of formal params allowed per function + "maxdepth" : 3, // {int} Max depth of nested blocks (within functions) + "maxstatements" : 20, // {int} Max number statements per function + "maxcomplexity" : 7, // {int} Max cyclomatic complexity per function + "maxlen" : 80, // {int} Max number of characters per line + + // Relaxing + "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) + "boss" : false, // true: Tolerate assignments where comparisons would be expected + "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. + "eqnull" : false, // true: Tolerate use of `== null` + "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) + "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`) + "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) + // (ex: `for each`, multiple try/catch, function expression…) + "evil" : false, // true: Tolerate use of `eval` and `new Function()` + "expr" : true, // true: Tolerate `ExpressionStatement` as Programs + "funcscope" : false, // true: Tolerate defining variables inside control statements + "globalstrict" : true, // true: Allow global "use strict" (also enables 'strict') + "iterator" : false, // true: Tolerate using the `__iterator__` property + "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block + "laxbreak" : false, // true: Tolerate possibly unsafe line breakings + "laxcomma" : false, // true: Tolerate comma-first style coding + "loopfunc" : false, // true: Tolerate functions being defined in loops + "multistr" : false, // true: Tolerate multi-line strings + "proto" : false, // true: Tolerate using the `__proto__` property + "scripturl" : false, // true: Tolerate script-targeted URLs + "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` + "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation + "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` + "validthis" : false, // true: Tolerate using this in a non-constructor function + + // Environments + "browser" : false, // Web Browser (window, document, etc) + "browserify" : true, // Browserify (node.js code in the browser) + "couch" : false, // CouchDB + "devel" : true, // Development/debugging (alert, confirm, etc) + "dojo" : false, // Dojo Toolkit + "jquery" : false, // jQuery + "mocha" : true, // mocha + "mootools" : false, // MooTools + "node" : true, // Node.js + "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) + "prototypejs" : false, // Prototype and Scriptaculous + "rhino" : false, // Rhino + "worker" : false, // Web Workers + "wsh" : false, // Windows Scripting Host + "yui" : false, // Yahoo User Interface + + // Custom Globals + "globals" : {} // additional predefined global variables +} diff --git a/packages/xo-cli/.travis.yml b/packages/xo-cli/.travis.yml new file mode 100644 index 000000000..ae52e87e6 --- /dev/null +++ b/packages/xo-cli/.travis.yml @@ -0,0 +1,9 @@ +language: node_js +node_js: + - 'stable' + - '6' + - '4' + +# Use containers. +# http://docs.travis-ci.com/user/workers/container-based-infrastructure/ +sudo: false diff --git a/packages/xo-cli/README.md b/packages/xo-cli/README.md new file mode 100644 index 000000000..3b297b984 --- /dev/null +++ b/packages/xo-cli/README.md @@ -0,0 +1,130 @@ +# XO-CLI +[![Build Status](https://img.shields.io/travis/vatesfr/xo-cli/master.svg)](http://travis-ci.org/vatesfr/xo-cli) +[![Dependency Status](https://david-dm.org/vatesfr/xo-cli/status.svg?theme=shields.io)](https://david-dm.org/vatesfr/xo-cli) +[![devDependency Status](https://david-dm.org/vatesfr/xo-cli/dev-status.svg?theme=shields.io)](https://david-dm.org/vatesfr/xo-cli#info=devDependencies) + +> Basic CLI for Xen-Orchestra + +## Installation + +#### [npm](https://npmjs.org/package/xo-cli) + +``` +npm install -g xo-cli +``` + +## Usage + +``` +> xo-cli --help +Usage: + + xo-cli --register [] [] [] + Registers the XO instance to use. + + xo-cli --unregister + Remove stored credentials. + + xo-cli --list-commands [--json] []... + Returns the list of available commands on the current XO instance. + + The patterns can be used to filter on command names. + + xo-cli --list-objects [--]… [=]... + Returns a list of XO objects. + + -- + Restricts displayed properties to those listed. + + = + Restricted displayed objects to those matching the patterns. + + xo-cli [=]... + Executes a command on the current XO instance. +``` + +#### Register your XO instance + +``` +> xo-cli --register http://xo.my-company.net admin@admin.net admin +Successfully logged with admin@admin.net +``` + +Note: only a token will be saved in the configuration file. + +#### List available objects + +Prints all objects: + +``` +> xo-cli --list-objects +``` + +It is possible to filter on object properties, for instance to prints +all VM templates: + +``` +> xo-cli --list-objects type=VM-template +``` + +#### List available commands + +``` +> xo-cli --list-commands +``` + +Commands can be filtered using patterns: + +``` +> xo-cli --list-commands '{user,group}.*' +``` + +#### Execute a command + +The same syntax is used for all commands: `xo-cli =...` + +E.g., adding a new server: + +``` +> xo-cli server.add host=my.server.net username=root password=secret-password +42 +``` + +The return value is the identifier of this new server in XO. + +Parameters (except `true` and `false` which are correctly parsed as +booleans) are assumed to be strings, for other types, you may use JSON +encoding by prefixing with `json:`: + +``` +> xo-cli foo.bar baz='json:[1, 2, 3]' +``` + +##### VM export + +``` +> xo-cli vm.export vm=a01667e0-8e29-49fc-a550-17be4226783c @=vm.xva +``` + +##### VM import + + ``` +> xo-cli vm.import host=60a6939e-8b0a-4352-9954-5bde44bcdf7d @=vm.xva +``` + +## Contributing + +Contributions are *very* welcome, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xo-cli/issues) + you've encountered; +- fork and create a pull request. + +## License + +XO-CLI is released under the [AGPL +v3](http://www.gnu.org/licenses/agpl-3.0-standalone.html). diff --git a/packages/xo-cli/config.js b/packages/xo-cli/config.js new file mode 100644 index 000000000..f93f9d091 --- /dev/null +++ b/packages/xo-cli/config.js @@ -0,0 +1,54 @@ +'use strict' + +// =================================================================== + +var promisify = require('bluebird').promisify + +var readFile = promisify(require('fs').readFile) +var writeFile = promisify(require('fs').writeFile) + +var assign = require('lodash/assign') +var l33t = require('l33teral') +var mkdirp = promisify(require('mkdirp')) +var xdgBasedir = require('xdg-basedir') + +// =================================================================== + +var configPath = xdgBasedir.config + '/xo-cli' +var configFile = configPath + '/config.json' + +// =================================================================== + +var load = exports.load = function () { + return readFile(configFile).then(JSON.parse).catch(function () { + return {} + }) +} + +exports.get = function (path) { + return load().then(function (config) { + return l33t(config).tap(path) + }) +} + +var save = exports.save = function (config) { + return mkdirp(configPath).then(function () { + return writeFile(configFile, JSON.stringify(config)) + }) +} + +exports.set = function (data) { + return load().then(function (config) { + return save(assign(config, data)) + }) +} + +exports.unset = function (paths) { + return load().then(function (config) { + var l33tConfig = l33t(config) + ;[].concat(paths).forEach(function (path) { + l33tConfig.purge(path, true) + }) + return save(config) + }) +} diff --git a/packages/xo-cli/index.js b/packages/xo-cli/index.js new file mode 100755 index 000000000..4ab7e8076 --- /dev/null +++ b/packages/xo-cli/index.js @@ -0,0 +1,397 @@ +#!/usr/bin/env node + +'use strict' + +var Bluebird = require('bluebird') +Bluebird.longStackTraces() + +var createReadStream = require('fs').createReadStream +var createWriteStream = require('fs').createWriteStream +var resolveUrl = require('url').resolve +var stat = require('fs-promise').stat + +var chalk = require('chalk') +var eventToPromise = require('event-to-promise') +var filter = require('lodash/filter') +var forEach = require('lodash/forEach') +var getKeys = require('lodash/keys') +var got = require('got') +var humanFormat = require('human-format') +var identity = require('lodash/identity') +var isObject = require('lodash/isObject') +var micromatch = require('micromatch') +var multiline = require('multiline') +var nicePipe = require('nice-pipe') +var pairs = require('lodash/toPairs') +var pick = require('lodash/pick') +var prettyMs = require('pretty-ms') +var progressStream = require('progress-stream') +var Xo = require('xo-lib').default + +// ------------------------------------------------------------------- + +var config = require('./config') + +// =================================================================== + +function connect () { + return config.load().bind({}).then(function (config) { + if (!config.server) { + throw new Error('no server to connect to!') + } + + if (!config.token) { + throw new Error('no token available') + } + + var xo = new Xo({ url: config.server }) + + return xo.open().then(function () { + return xo.signIn({ token: config.token }) + }).then(function () { + return xo + }) + }) +} + +function _startsWith (string, search) { + return string.lastIndexOf(search, 0) === 0 +} + +var FLAG_RE = /^--([^=]+)(?:=([^]*))?$/ +function extractFlags (args) { + var flags = {} + + var i = 0 + var n = args.length + var matches + while (i < n && (matches = args[i].match(FLAG_RE))) { + var value = matches[2] + + flags[matches[1]] = value === undefined ? true : value + ++i + } + args.splice(0, i) + + return flags +} + +var PARAM_RE = /^([^=]+)=([^]*)$/ +function parseParameters (args) { + var params = {} + forEach(args, function (arg) { + var matches + if (!(matches = arg.match(PARAM_RE))) { + throw new Error('invalid arg: ' + arg) + } + var name = matches[1] + var value = matches[2] + + if (_startsWith(value, 'json:')) { + value = JSON.parse(value.slice(5)) + } + + if (name === '@') { + params['@'] = value + return + } + + if (value === 'true') { + value = true + } else if (value === 'false') { + value = false + } + + params[name] = value + }) + + return params +} + +var humanFormatOpts = { + unit: 'B', + scale: 'binary' +} + +function printProgress (progress) { + if (progress.length) { + console.warn('%s% of %s @ %s/s - ETA %s', + Math.round(progress.percentage), + humanFormat(progress.length, humanFormatOpts), + humanFormat(progress.speed, humanFormatOpts), + prettyMs(progress.eta * 1e3) + ) + } else { + console.warn('%s @ %s/s', + humanFormat(progress.transferred, humanFormatOpts), + humanFormat(progress.speed, humanFormatOpts) + ) + } +} + +function wrap (val) { + return function wrappedValue () { + return val + } +} + +// =================================================================== + +var help = wrap((function (pkg) { + return multiline.stripIndent(function () { /* + Usage: + + $name --register [] [] [] + Registers the XO instance to use. + + $name --unregister + Remove stored credentials. + + $name --list-commands [--json] []... + Returns the list of available commands on the current XO instance. + + The patterns can be used to filter on command names. + + $name --list-objects [--]… [=]... + Returns a list of XO objects. + + -- + Restricts displayed properties to those listed. + + = + Restricted displayed objects to those matching the patterns. + + $name [=]... + Executes a command on the current XO instance. + + $name v$version + */ }).replace(/<([^>]+)>|\$(\w+)/g, function (_, arg, key) { + if (arg) { + return '<' + chalk.yellow(arg) + '>' + } + + if (key === 'name') { + return chalk.bold(pkg[key]) + } + + return pkg[key] + }) +})(require('./package'))) + +// ------------------------------------------------------------------- + +function main (args) { + if (!args || !args.length || args[0] === '-h') { + return help() + } + + var fnName = args[0].replace(/^--|-\w/g, function (match) { + if (match === '--') { + return '' + } + + return match[1].toUpperCase() + }) + if (fnName in exports) { + return exports[fnName](args.slice(1)) + } + + return exports.call(args) +} +exports = module.exports = main + +// ------------------------------------------------------------------- + +exports.help = help + +function register (args) { + var xo = new Xo({ url: args[0] }) + return xo.open().then(function () { + return xo.signIn({ + email: args[1], + password: args[2] + }) + }).then(function () { + console.log('Successfully logged with', xo.user.email) + + return xo.call('token.create') + }).then(function (token) { + return config.set({ + server: args[0], + token: token + }) + }) +} +exports.register = register + +function unregister () { + return config.unset([ + 'server', + 'token' + ]) +} +exports.unregister = unregister + +function listCommands (args) { + return connect().then(function getMethodsInfo (xo) { + return xo.call('system.getMethodsInfo') + }).then(function formatMethodsInfo (methods) { + var json = false + var patterns = [] + forEach(args, function (arg) { + if (arg === '--json') { + json = true + } else { + patterns.push(arg) + } + }) + + if (patterns.length) { + methods = pick(methods, micromatch(Object.keys(methods), patterns)) + } + + if (json) { + return methods + } + + methods = pairs(methods) + methods.sort(function (a, b) { + a = a[0] + b = b[0] + if (a < b) { + return -1 + } + return +(a > b) + }) + + var str = [] + forEach(methods, function (method) { + var name = method[0] + var info = method[1] + str.push(chalk.bold.blue(name)) + forEach(info.params || [], function (info, name) { + str.push(' ') + if (info.optional) { + str.push('[') + } + str.push(name, '=<', info.type || 'unknown', '>') + if (info.optional) { + str.push(']') + } + }) + str.push('\n') + if (info.description) { + str.push(' ', info.description, '\n') + } + }) + return str.join('') + }) +} +exports.listCommands = listCommands + +function listObjects (args) { + var properties = getKeys(extractFlags(args)) + var filterProperties = properties.length + ? function (object) { + return pick(object, properties) + } + : identity + + var sieve = args.length ? parseParameters(args) : null + + return connect().then(function getXoObjects (xo) { + return xo.call('xo.getAllObjects') + }).then(function filterObjects (objects) { + objects = filter(objects, sieve) + + const stdout = process.stdout + stdout.write('[\n') + for (var i = 0, n = objects.length; i < n;) { + stdout.write(JSON.stringify(filterProperties(objects[i]), null, 2)) + stdout.write(++i < n ? ',\n' : '\n') + } + stdout.write(']') + }) +} +exports.listObjects = listObjects + +function call (args) { + if (!args.length) { + throw new Error('missing command name') + } + + var method = args.shift() + var params = parseParameters(args) + + var file = params['@'] + delete params['@'] + + var baseUrl + return connect().then(function (xo) { + // FIXME: do not use private properties. + baseUrl = xo._url.replace(/^ws/, 'http') + + return xo.call(method, params) + }).then(function handleResult (result) { + var keys, key, url + if ( + isObject(result) && + (keys = getKeys(result)).length === 1 + ) { + key = keys[0] + + if (key === '$getFrom') { + url = resolveUrl(baseUrl, result[key]) + var output = createWriteStream(file) + + var progress = progressStream({ time: 1e3 }, printProgress) + + return eventToPromise(nicePipe([ + got.stream(url).on('response', function (response) { + var length = response.headers['content-length'] + if (length) { + progress.length(length) + } + }), + progress, + output + ]), 'finish') + } + + if (key === '$sendTo') { + url = resolveUrl(baseUrl, result[key]) + + return stat(file).then(function (stats) { + var length = stats.size + + var input = nicePipe([ + createReadStream(file), + progressStream({ + length: length, + time: 1e3 + }, printProgress) + ]) + + return got.post(url, { + body: input, + headers: { + 'content-length': length + }, + method: 'POST' + }).then(function (response) { + return response.body + }) + }) + } + } + + return result + }) +} +exports.call = call + +// =================================================================== + +if (!module.parent) { + require('exec-promise')(exports) +} diff --git a/packages/xo-cli/package.json b/packages/xo-cli/package.json new file mode 100644 index 000000000..5322e050f --- /dev/null +++ b/packages/xo-cli/package.json @@ -0,0 +1,57 @@ +{ + "name": "xo-cli", + "version": "0.8.2", + "license": "AGPL-3.0", + "description": "Basic CLI for Xen-Orchestra", + "keywords": [ + "xo", + "xen-orchestra", + "xen", + "orchestra" + ], + "homepage": "https://github.com/vatesfr/xo-cli", + "bugs": "https://github.com/vatesfr/xo-cli/issues", + "author": "Julien Fontanet ", + "preferGlobal": true, + "bin": { + "xo-cli": "index.js" + }, + "files": [ + "*.js" + ], + "repository": { + "type": "git", + "url": "https://github.com/vatesfr/xo-cli" + }, + "dependencies": { + "bluebird": "^3.4.6", + "chalk": "^1.1.1", + "event-to-promise": "^0.7.0", + "exec-promise": "^0.6.1", + "fs-promise": "^1.0.0", + "got": "^6.5.0", + "human-format": "^0.7.0", + "l33teral": "^3.0.2", + "lodash": "^4.16.4", + "micromatch": "^2.2.0", + "mkdirp": "^0.5.0", + "multiline": "^1.0.2", + "nice-pipe": "0.0.0", + "pretty-ms": "^2.1.0", + "progress-stream": "^1.1.1", + "xdg-basedir": "^2.0.0", + "xo-lib": "^0.8.0" + }, + "devDependencies": { + "standard": "^8.1.0" + }, + "scripts": { + "lint": "standard", + "posttest": "npm run lint" + }, + "greenkeeper": { + "ignore": [ + "nice-pipe" + ] + } +}