UNPKG

xo-cli

Version:
701 lines (573 loc) 17.9 kB
#!/usr/bin/env node import { createReadStream, createWriteStream, readFileSync } from 'fs' import { PassThrough, pipeline } from 'stream' import { stat } from 'fs/promises' import chalk from 'chalk' import forEach from 'lodash/forEach.js' import fromCallback from 'promise-toolbox/fromCallback' import getKeys from 'lodash/keys.js' import getopts from 'getopts' import hrp from 'http-request-plus' import humanFormat from 'human-format' import identity from 'lodash/identity.js' import isObject from 'lodash/isObject.js' import micromatch from 'micromatch' import os from 'os' import pairs from 'lodash/toPairs.js' import pick from 'lodash/pick.js' import prettyMs from 'pretty-ms' import progressStream from 'progress-stream' import pw from 'pw' import XoLib from 'xo-lib' // ------------------------------------------------------------------- import * as config from './config.mjs' import { inspect } from 'util' import { rest } from './rest.mjs' const Xo = XoLib.default // =================================================================== async function connect() { const { allowUnauthorized, server, token } = await config.load() if (server === undefined) { const errorMessage = 'Please use `xo-cli --register` to associate with an XO instance first.\n\n' + help() throw errorMessage } if (token === undefined) { throw new Error('no token available') } const xo = new Xo({ rejectUnauthorized: !allowUnauthorized, url: server }) await xo.open() try { await xo.signIn({ token }) } catch (error) { await xo.close() throw error } return xo } async function parseRegisterArgs(args, tokenDescription, client, acceptToken = false) { const { allowUnauthorized, expiresIn, otp, token, _: opts, } = getopts(args, { alias: { allowUnauthorized: 'au', token: 't', }, boolean: ['allowUnauthorized'], stopEarly: true, string: ['expiresIn', 'otp', 'token'], }) const result = { allowUnauthorized, expiresIn: expiresIn || undefined, url: opts[0], } if (token !== '') { if (!acceptToken) { // eslint-disable-next-line no-throw-literal throw '`token` option is not accepted by this command' } result.token = token } else { const [ , email, password = await new Promise(function (resolve) { process.stdout.write('Password: ') pw(resolve) }), ] = opts result.token = await _createToken({ ...result, client, description: tokenDescription, email, otp: otp !== '' ? otp : undefined, password, }) } return result } async function _createToken({ allowUnauthorized, client, description, email, expiresIn, otp, password, url }) { const xo = new Xo({ rejectUnauthorized: !allowUnauthorized, url }) await xo.open() try { await xo.signIn({ email, otp, password }) console.warn('Successfully logged with', xo.user.email) return await xo.call('token.create', { client, description, expiresIn }).catch(error => { // if invalid parameter error, retry without client and description for backward compatibility if (error.code === 10) { return xo.call('token.create', { expiresIn }) } throw error }) } finally { await xo.close() } } function createOutputStream(path) { if (path !== undefined && path !== '-') { return createWriteStream(path) } // introduce a through stream because stdout is not a normal stream! const stream = new PassThrough() stream.pipe(process.stdout) return stream } // patch stdout and stderr to stop writing after an EPIPE error // // See https://github.com/vatesfr/xen-orchestra/issues/6680 ;[process.stdout, process.stderr].forEach(stream => { let write = stream.write stream.on('error', function onError(error) { if (error.code === 'EPIPE') { stream.off('error', onError) write = noop } }) stream.write = function () { return write.apply(this, arguments) } }) const FLAG_RE = /^--([^=]+)(?:=([^]*))?$/ function extractFlags(args) { const flags = {} let i = 0 const n = args.length let matches while (i < n && (matches = args[i].match(FLAG_RE))) { const value = matches[2] flags[matches[1]] = value === undefined ? true : value ++i } args.splice(0, i) return flags } const noop = Function.prototype const PARAM_RE = /^([^=]+)=([^]*)$/ function parseParameters(args) { const params = {} forEach(args, function (arg) { let matches if (!(matches = arg.match(PARAM_RE))) { throw new Error('invalid arg: ' + arg) } const name = matches[1] let value = matches[2] if (value.startsWith('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 } const 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 } } // =================================================================== const PACKAGE_JSON = JSON.parse(readFileSync(new URL('package.json', import.meta.url))) const help = wrap( (function (pkg) { return `Usage: $name --register [--allowUnauthorized] [--expiresIn <duration>] [--otp <otp>] <XO-Server URL> <username> [<password>] $name --register [--allowUnauthorized] [--expiresIn <duration>] --token <token> <XO-Server URL> Registers the XO instance to use. --allowUnauthorized, --au Accept invalid certificate (e.g. self-signed). --expiresIn <duration> Can be used to change the validity duration of the authorization token (default: one month). --otp <otp> One-time password if required for this user. --token <token> An authentication token to use instead of username/password. $name --createToken <params>… Create an authentication token for XO API. <params>… Accept the same parameters as --register, see its usage. $name --unregister Remove stored credentials. $name --list-commands [--json] [<pattern>]... Returns the list of available commands on the current XO instance. The patterns can be used to filter on command names. $name --list-objects [--<property>]… [<property>=<value>]... Returns a list of XO objects. --<property> Restricts displayed properties to those listed. <property>=<value> Restricted displayed objects to those matching the patterns. $name <command> [--json] [<name>=<value>]... Executes a command on the current XO instance. --json Prints the result in JSON format. $name rest del <resource> Delete the resource. Examples: $name rest del tasks/<task id> $name rest del vms/<vm id>/tags/<tag> $name rest get <collection> [fields=<fields>] [filter=<filter>] [limit=<limit>] List objects in a REST API collection. <collection> Full path of the collection to list fields=<fields> When provided, returns a collection of objects containing the requested fields instead of the simply the objects' paths. The field names must be separated by commas. filter=<filter> List only objects that match the filter Syntax: https://xen-orchestra.com/docs/manage_infrastructure.html#filter-syntax limit=<limit> Maximum number of objects to list, e.g. \`limit=10\` Examples: $name rest get $name rest get tasks filter='status:pending' $name rest get vms fields=name_label,power_state $name rest get [--output <file>] <object> [wait | wait=result] Show an object from the REST API. --output <file> If specified, the response will be saved in <file> instead of being parsed. If <file> ends with \`/\`, it will be considered as the directory in which to save the response, and the filename will be last part of the <object> path. <object> Full path of the object to show wait If the object is a task, waits for it to be updated before returning. wait=result If the object is a task, waits for it to be finished before returning. Examples: $name rest get vms/<VM UUID> $name rest get tasks/<task id>/actions wait=result $name rest patch <object> <name>=<value>... Update properties of an object (not all properties are writable). <object> Full path of the object to update <name>=<value>... Properties to update on the object Examples: $name rest patch vms/<VM UUID> name_label='My VM' name_description='Its description $name rest post <action> <name>=<value>... Execute an action. <action> Full path of the action to execute <name>=<value>... Paramaters to pass to the action Examples: $name rest post tasks/<task id>/actions/abort $name rest post vms/<VM UUID>/actions/snapshot name_label='My snapshot' $name rest put <collection>/<item id> <name>=<value>... Put a item in a collection <collection>/<item id> Full path of the item to add <name>=<value>... Properties of the item Examples: $name rest put vms/<vm id>/tags/<tag> $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] }) })(PACKAGE_JSON) ) // ------------------------------------------------------------------- const COMMANDS = { __proto__: null } async function main(args) { if (!args || !args.length || args[0] === '-h') { return help() } const fnName = args[0].replace(/^--|-\w/g, function (match) { if (match === '--') { return '' } return match[1].toUpperCase() }) try { if (fnName in COMMANDS) { return await COMMANDS[fnName](args.slice(1)) } return await COMMANDS.call(args).catch(error => { if (!(error != null && error.code === 10 && 'errors' in error.data)) { throw error } const lines = [error.message] const { errors } = error.data errors.forEach(error => { let { instancePath } = error instancePath = instancePath.length === 0 ? '@' : '@.' + instancePath lines.push(` property ${instancePath}: ${error.message}`) }) throw lines.join('\n') }) } catch (error) { // `promise-toolbox/fromEvent` uses `addEventListener` by default wich makes // `ws/WebSocket` (used by `xo-lib`) emit DOM `Event` objects which are not // correctly displayed by `exec-promise`. // // Extracts the original error for a better display. throw typeof error === 'object' && 'error' in error ? error.error : error } } // ------------------------------------------------------------------- COMMANDS.rest = rest // ------------------------------------------------------------------- COMMANDS.help = help async function createToken(args) { const { token } = await parseRegisterArgs(args, 'xo-cli --createToken') console.warn('Authentication token created') console.warn() console.log(token) } COMMANDS.createToken = createToken async function register(args) { let { clientId } = await config.load() if (clientId === undefined) { clientId = Math.random().toString(36).slice(2) } const { name, version } = PACKAGE_JSON const label = `${name}@${version} - ${os.hostname()} - ${os.type()} ${os.machine()}` const opts = await parseRegisterArgs(args, label, { id: clientId }, true) await config.set({ allowUnauthorized: opts.allowUnauthorized, clientId, server: opts.url, token: opts.token, }) } COMMANDS.register = register function unregister() { return config.unset(['server', 'token']) } COMMANDS.unregister = unregister async function listCommands(args) { const xo = await connect() try { let methods = await xo.call('system.getMethodsInfo') let json = false const 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) }) const str = [] forEach(methods, function (method) { const name = method[0] const info = method[1] str.push(chalk.bold.blue(name)) forEach(info.params || [], function (info, name) { str.push(' ') const { optional = Object.hasOwn(info, 'default') } = info if (optional) { str.push('[') } const type = info.type str.push(name, '=<', type == null ? 'unknown type' : Array.isArray(type) ? type.join('|') : type, '>') if (optional) { str.push(']') } }) str.push('\n') if (info.description) { str.push(' ', info.description, '\n') } }) return str.join('') } finally { await xo.close() } } COMMANDS.listCommands = listCommands async function listObjects(args) { const properties = getKeys(extractFlags(args)) const filterProperties = properties.length ? function (object) { return pick(object, properties) } : identity const filter = args.length ? parseParameters(args) : undefined const xo = await connect() try { const objects = await xo.call('xo.getAllObjects', { filter }) const stdout = process.stdout stdout.write('[\n') const keys = Object.keys(objects) for (let i = 0, n = keys.length; i < n; ) { stdout.write(JSON.stringify(filterProperties(objects[keys[i]]), null, 2)) stdout.write(++i < n ? ',\n' : '\n') } stdout.write(']\n') } finally { await xo.close() } } COMMANDS.listObjects = listObjects function ensurePathParam(method, value) { if (typeof value !== 'string') { const error = method + ' requires the @ parameter to be a path (e.g. @=/tmp/config.json)' throw error } } async function call(args) { const jsonOutput = args[1] === '--json' if (jsonOutput) { args.splice(1, 1) } if (!args.length) { throw new Error('missing command name') } const method = args.shift() const params = parseParameters(args) const file = params['@'] delete params['@'] const xo = await connect() try { // FIXME: do not use private properties. const baseUrl = xo._url.replace(/^ws/, 'http') const httpOptions = { rejectUnauthorized: !(await config.load()).allowUnauthorized, } const result = await xo.call(method, params) let keys, key, url if (isObject(result) && (keys = getKeys(result)).length === 1) { key = keys[0] if (key === '$getFrom') { ensurePathParam(method, file) url = new URL(result[key], baseUrl) const output = createOutputStream(file) const response = await hrp(url, httpOptions) const progress = progressStream( { length: response.headers['content-length'], time: 1e3, }, printProgress ) return fromCallback(pipeline, response, progress, output) } if (key === '$sendTo') { ensurePathParam(method, file) url = new URL(result[key], baseUrl) const length = file === '-' ? undefined : (await stat(file)).size const input = pipeline( file === '-' ? process.stdin : createReadStream(file), progressStream( { length, time: 1e3, }, printProgress ), noop ) const response = await hrp(url, { ...httpOptions, body: input, headers: length && { 'content-length': length, }, method: 'POST', }) return response.text() } } return jsonOutput ? JSON.stringify(result, null, 2) : result } finally { await xo.close() } } COMMANDS.call = call // =================================================================== // don't call process.exit() to avoid truncated output main(process.argv.slice(2)).then( result => { if (result !== undefined) { if (Number.isInteger(result)) { process.exitCode = result } else { const { stdout } = process stdout.write( typeof result === 'string' ? result : inspect(result, { colors: Boolean(stdout.isTTY), depth: null, sorted: true, }) ) stdout.write('\n') } } }, error => { const { stderr } = process stderr.write(chalk.bold.red('✖')) stderr.write(' ') stderr.write( typeof error === 'string' ? error : inspect(error, { colors: Boolean(stderr.isTTY), depth: null, sorted: true, }) ) stderr.write('\n') process.exitCode = 1 } )