/* * Copyright (c) 2015 Joyent Inc. * * `triton profile ...` */ var assert = require('assert-plus'); var format = require('util').format; var fs = require('fs'); var strsplit = require('strsplit'); var sshpk = require('sshpk'); var tilde = require('tilde-expansion'); var vasync = require('vasync'); var common = require('./common'); var errors = require('./errors'); var mod_config = require('./config'); function _showProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.func(cb, 'cb'); var cli = opts.cli; try { var profile = mod_config.loadProfile({ configDir: cli.configDir, name: opts.name }); } catch (err) { return cb(err); } if (profile.name === cli.tritonapi.profile.name) { cli._applyProfileOverrides(profile); profile.curr = true; } else { profile.curr = false; } if (opts.json) { console.log(JSON.stringify(profile)); } else { console.log('name: %s', profile.name); Object.keys(profile).sort().forEach(function (key) { if (key === 'name') return; if (profile[key] !== undefined) console.log('%s: %s', key, profile[key]); }); } } function _currentProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.func(cb, 'cb'); var cli = opts.cli; try { var profile = mod_config.loadProfile({ configDir: cli.configDir, name: opts.name }); } catch (err) { return cb(err); } if (cli.tritonapi.profile.name === profile.name) { console.log('"%s" is already the current profile', profile.name); return cb(); } mod_config.setConfigVar({ configDir: cli.configDir, name: 'profile', value: profile.name }, function (err) { if (err) { return cb(err); } console.log('Set "%s" as current profile', profile.name); cb(); }); } function _yamlishFromProfile(profile) { assert.object(profile, 'profile'); var keys = []; var skipKeys = ['curr', 'name']; Object.keys(profile).forEach(function (key) { if (skipKeys.indexOf(key) === -1) { keys.push(key); } }); keys = keys.sort(); var lines = []; keys.forEach(function (key) { lines.push(format('%s: %s', key, profile[key])); }); return lines.join('\n') + '\n'; } function _profileFromYamlish(yamlish) { assert.string(yamlish, 'yamlish'); var profile = {}; var bools = ['insecure']; var lines = yamlish.split(/\n/g); lines.forEach(function (line) { var commentIdx = line.indexOf('#'); if (commentIdx !== -1) { line = line.slice(0, commentIdx); } line = line.trim(); if (!line) { return; } var parts = strsplit(line, ':', 2); var key = parts[0].trim(); var value = parts[1].trim(); if (bools.indexOf(key) !== -1) { value = common.boolFromString(value); } profile[key] = value; }); return profile; } function _editProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.func(cb, 'cb'); var cli = opts.cli; if (opts.name === 'env') { return cb(new errors.UsageError('cannot edit "env" profile')); } try { var profile = mod_config.loadProfile({ configDir: cli.configDir, name: opts.name }); } catch (err) { return cb(err); } var filename = format('profile-%s.txt', profile.name); var origText = _yamlishFromProfile(profile); function editAttempt(text) { common.editInEditor({ text: text, filename: filename }, function (err, afterText, changed) { if (err) { return cb(new errors.TritonError(err)); } else if (!changed) { console.log('No change to profile'); return cb(); } try { var editedProfile = _profileFromYamlish(afterText); editedProfile.name = profile.name; if (_yamlishFromProfile(editedProfile) === origText) { // This YAMLish is the closest to a canonical form we have. console.log('No change to profile'); return cb(); } mod_config.saveProfileSync({ configDir: cli.configDir, profile: editedProfile }); } catch (textErr) { console.error('Error with your changes: %s', textErr); common.promptEnter( 'Press to re-edit, Ctrl+C to abort.', function (aborted) { if (aborted) { console.log('\nAborting. ' + 'No change made to profile'); cb(); } else { editAttempt(afterText); } }); return; } cb(); }); } editAttempt(origText); } function _deleteProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.bool(opts.force, 'opts.force'); assert.func(cb, 'cb'); var cli = opts.cli; if (opts.name === 'env') { return cb(new errors.UsageError('cannot delete "env" profile')); } try { var profile = mod_config.loadProfile({ configDir: cli.configDir, name: opts.name }); } catch (err) { if (opts.force) { cb(); } else { cb(err); } return; } if (profile.name === cli.tritonapi.profile.name && !opts.force) { return cb(new errors.TritonError( 'cannot delete the current profile (use --force to override)')); } vasync.pipeline({funcs: [ function confirm(_, next) { if (opts.force) { return next(); } common.promptYesNo({ msg: 'Delete profile "' + opts.name + '"? [y/n] ' }, function (answer) { if (answer !== 'y') { console.error('Aborting'); next(true); // early abort signal } else { next(); } }); }, function handleConfigVar(_, next) { if (profile.name === cli.tritonapi.profile.name) { _currentProfile({name: 'env', cli: cli}, next); } else { next(); } }, function deleteIt(_, next) { try { mod_config.deleteProfile({ configDir: cli.configDir, name: opts.name }); } catch (delErr) { return next(delErr); } console.log('Deleted profile "%s"', opts.name); next(); } ]}, function (err) { if (err === true) { err = null; } cb(err); }); } function _addProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.optionalString(opts.file, 'opts.file'); assert.func(cb, 'cb'); var cli = opts.cli; var log = cli.log; var context; var data; vasync.pipeline({arg: {}, funcs: [ function getExistingProfiles(ctx, next) { try { ctx.profiles = mod_config.loadAllProfiles({ configDir: cli.configDir, log: cli.log }); } catch (err) { return next(err); } next(); }, function gatherDataStdin(_, next) { if (opts.file !== '-') { return next(); } var stdin = ''; process.stdin.resume(); process.stdin.on('data', function (chunk) { stdin += chunk; }); process.stdin.on('end', function () { try { data = JSON.parse(stdin); } catch (err) { log.trace({stdin: stdin}, 'invalid profile JSON on stdin'); return next(new errors.TritonError( format('invalid profile JSON on stdin: %s', err))); } next(); }); }, function gatherDataFile(_, next) { if (!opts.file || opts.file === '-') { return next(); } context = opts.file; var input = fs.readFileSync(opts.file); try { data = JSON.parse(input); } catch (err) { return next(new errors.TritonError(format( 'invalid profile JSON in "%s": %s', opts.file, err))); } next(); }, function gatherDataInteractive(ctx, next) { if (opts.file) { return next(); } else if (!process.stdin.isTTY) { return next(new errors.UsageError('cannot interactively ' + 'create profile: stdin is not a TTY')); } else if (!process.stdout.isTTY) { return next(new errors.UsageError('cannot interactively ' + 'create profile: stdout is not a TTY')); } var fields = [ { desc: 'A profile name. A short string to identify a ' + 'CloudAPI endpoint to the `triton` CLI.', key: 'name', validate: function validateName(value, valCb) { var regex = /^[a-z][a-z0-9_.-]*$/; if (!regex.test(value)) { return valCb(new Error('Must start with a lowercase ' + 'letter followed by lowercase letters, numbers ' + 'and "_", "." and "-".')); } for (var i = 0; i < ctx.profiles.length; i++) { if (ctx.profiles[i].name === value) { return valCb(new Error(format( 'Profile "%s" already exists.', value))); } } valCb(); } }, { desc: 'The CloudAPI endpoint URL.', default: 'https://us-sw-1.api.joyent.com', key: 'url' }, { desc: 'Your account login name.', key: 'account', validate: function validateAccount(value, valCb) { var regex = /^[^\\]{3,}$/; if (value.length < 3) { return valCb(new Error( 'Must be at least 3 characters')); } if (!regex.test(value)) { return valCb(new Error('Must not container a "\\"')); } valCb(); } }, { desc: 'The fingerprint of the SSH key you have registered ' + 'for your account. You may enter a local path to a ' + 'public or private key to have the fingerprint ' + 'calculated for you.', key: 'keyId', validate: function validateKeyId(value, valCb) { // First try as a fingerprint. try { sshpk.parseFingerprint(value); return valCb(); } catch (fpErr) { } // Try as a local path. tilde(value, function (keyPath) { fs.stat(keyPath, function (statErr, stats) { if (statErr || !stats.isFile()) { return valCb(new Error(format( '"%s" is neither a valid fingerprint, ' + 'nor an existing file', value))); } fs.readFile(keyPath, function (readErr, keyData) { if (readErr) { return valCb(readErr); } var keyType = (keyPath.slice(-4) === '.pub' ? 'ssh' : 'pem'); try { var key = sshpk.parseKey(keyData, keyType); } catch (keyErr) { return valCb(keyErr); } var newVal = key.fingerprint('md5').toString(); console.log('Fingerprint: %s', newVal); valCb(null, newVal); }); }); }); } } ]; data = {}; vasync.forEachPipeline({ inputs: fields, func: function getField(field, nextField) { if (field.key !== 'name') console.log(); common.promptField(field, function (err, value) { data[field.key] = value; nextField(err); }); } }, function (err) { console.log(); next(err); }); }, function guardAlreadyExists(ctx, next) { for (var i = 0; i < ctx.profiles.length; i++) { if (data.name === ctx.profiles[i].name) { return next(new errors.TritonError(format( 'profile "%s" already exists', data.name))); } } next(); }, function validateIt(_, next) { // We ignore 'curr'. For now at least. delete data.curr; try { mod_config.validateProfile(data, context); } catch (err) { return next(err); } next(); }, function saveIt(_, next) { try { mod_config.saveProfileSync({ configDir: cli.configDir, profile: data }); } catch (err) { return next(err); } next(); }, function setCurrIfTheOnlyProfile(ctx, next) { if (ctx.profiles.length !== 0) { next(); return; } mod_config.setConfigVar({ configDir: cli.configDir, name: 'profile', value: data.name }, function (err) { if (err) { next(err); return; } console.log('Set "%s" as current profile (because it is ' + 'your only profile)', data.name); next(); }); } ]}, cb); } function do_profile(subcmd, opts, args, cb) { if (opts.help) { this.do_help('help', {}, [subcmd], cb); return; } // Which action? var actions = []; if (opts.add) { actions.push('add'); } if (opts.current) { actions.push('current'); } if (opts.edit) { actions.push('edit'); } if (opts['delete']) { actions.push('delete'); } var action; if (actions.length === 0) { action = 'show'; } else if (actions.length > 1) { return cb(new errors.UsageError( 'only one action option may be used at once')); } else { action = actions[0]; } // Arg count validation. if (args.length > 1) { return cb(new errors.UsageError('too many arguments')); } else if (args.length === 0 && ['current', 'delete'].indexOf(action) !== -1) { return cb(new errors.UsageError('NAME argument is required')); } switch (action) { case 'show': _showProfile({ cli: this, name: args[0] || this.tritonapi.profile.name, json: opts.json }, cb); break; case 'current': _currentProfile({cli: this, name: args[0]}, cb); break; case 'edit': _editProfile({ cli: this, name: args[0] || this.tritonapi.profile.name }, cb); break; case 'delete': _deleteProfile({ cli: this, name: args[0], force: Boolean(opts.force) }, cb); break; case 'add': _addProfile({cli: this, file: args[0]}, cb); break; default: return cb(new errors.InternalError('unknown action: ' + action)); } } do_profile.options = [ { names: ['help', 'h'], type: 'bool', help: 'Show this help.' }, { names: ['json', 'j'], type: 'bool', help: 'JSON output when showing a profile.' }, { names: ['force', 'f'], type: 'bool', help: 'Force deletion.' }, { group: 'Action Options' }, { names: ['current', 'c'], type: 'bool', help: 'Switch to the named profile.' }, { names: ['edit', 'e'], type: 'bool', help: 'Edit the named profile in your $EDITOR.' }, { names: ['add', 'a'], type: 'bool', help: 'Add a new profile.' }, { names: ['delete', 'd'], type: 'bool', help: 'Delete the named profile.' } ]; do_profile.help = [ 'Show, add, edit and delete `triton` CLI profiles.', '', 'A profile is a configured Triton CloudAPI endpoint. I.e. the', 'url, account, key, etc. information required to call a CloudAPI.', 'You can then switch between profiles with `triton -p PROFILE`', 'or the TRITON_PROFILE environment variable.', '', 'Usage:', ' {{name}} profile [NAME] # show NAME or current profile', ' {{name}} profile -e|--edit [NAME] # edit a profile in $EDITOR', ' {{name}} profile -c|--current NAME # set NAME as current profile', ' {{name}} profile -d|--delete NAME # delete a profile', '', ' {{name}} profile -a|--add [FILE]', ' # Add a new profile. FILE must be a file path (to JSON of', ' # the form from `triton profile -j`, "curr" field is ', ' # ignored) or "-" to pass the profile in on stdin.', ' # Or exclude FILE to interactively add.', '', '{{options}}' ].join('\n'); module.exports = do_profile;