diff --git a/CHANGES.md b/CHANGES.md index 7b296e7..66e8818 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,26 @@ # node-triton changelog +## 4.0.0 (not yet released) + +- Add the ability to create a profile copying from an existing profile, + via `triton profile create --copy NAME`. + +- [backwards incompat] #66 `triton profile` now has list, get, etc. sub-commands. + One backwards incompatible change here is that `triton profile NAME` is + now `triton profile get NAME`. Note that for bwcompat `triton profiles` is + a shortcut for `triton profile list`. + +- [backwards incompat] #66 `triton image` now has list, get sub-commands. + One backwards incompatible change here is that `triton image ID|NAME` is + now `triton image get ID|NAME`. Note that for bwcompat `triton images` is + a shortcut for `triton image list`. + +- [backwards incompat] #66 `triton package` now has list, get sub-commands. + One backwards incompatible change here is that `triton package ID|NAME` is + now `triton package get ID|NAME`. Note that for bwcompat `triton packages` is + a shortcut for `triton package list`. + + ## 3.6.1 (not yet released) (nothing yet) diff --git a/lib/cli.js b/lib/cli.js index 49246ce..3fb9160 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -192,7 +192,6 @@ function CLI() { }, helpSubcmds: [ 'help', - 'profiles', 'profile', { group: 'Instances (aka VMs/Machines/Containers)' }, 'create-instance', diff --git a/lib/do_profile.js b/lib/do_profile.js deleted file mode 100644 index aa39546..0000000 --- a/lib/do_profile.js +++ /dev/null @@ -1,631 +0,0 @@ -/* - * 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; diff --git a/lib/do_profile/do_create.js b/lib/do_profile/do_create.js new file mode 100644 index 0000000..d4f4e18 --- /dev/null +++ b/lib/do_profile/do_create.js @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2015 Joyent Inc. + * + * `triton profile create ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var fs = require('fs'); +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 _createProfile(opts, cb) { + assert.object(opts.cli, 'opts.cli'); + assert.optionalString(opts.file, 'opts.file'); + assert.optionalString(opts.copy, 'opts.copy'); + assert.func(cb, 'cb'); + var cli = opts.cli; + var log = cli.log; + + 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 getCopy(ctx, next) { + if (!opts.copy) { + return next(); + } + for (var i = 0; i < ctx.profiles.length; i++) { + if (ctx.profiles[i].name === opts.copy) { + ctx.copy = ctx.profiles[i]; + break; + } + } + if (!ctx.copy) { + next(new errors.UsageError(format( + 'no such profile from which to copy: "%s"', opts.copy))); + } else { + 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(ctx, next) { + if (!opts.file || opts.file === '-') { + return next(); + } + ctx.filePath = 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 defaults = {}; + if (ctx.copy) { + defaults = ctx.copy; + delete defaults.name; // we don't copy a profile name + } else { + defaults.url = 'https://us-sw-1.api.joyent.com'; + + var possibleDefaultFp = '~/.ssh/id_rsa'; + if (fs.existsSync(common.tildeSync(possibleDefaultFp))) { + defaults.keyId = possibleDefaultFp; + } + } + + var fields = [ { + desc: 'A profile name. A short string to identify a ' + + 'CloudAPI endpoint to the `triton` CLI.', + key: 'name', + default: defaults.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: defaults.url, + key: 'url' + }, { + desc: 'Your account login name.', + key: 'account', + default: defaults.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. Alternatively, You may enter a local ' + + 'path to a public or private SSH key to have the ' + + 'fingerprint calculated for you.', + default: defaults.keyId, + 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 = {}; + + /* + * There are some value profile fields that we don't yet prompt + * for -- because they are experimental, optional, or I'm just + * unsure about adding them yet. :) We should still *copy* those + * over for a `triton profile create --copy ...`. + * + * Eventually the need for this block should go away. + */ + if (ctx.copy) { + var promptKeys = fields.map( + function (field) { return field.key; }); + Object.keys(ctx.copy).forEach(function (key) { + if (promptKeys.indexOf(key) === -1) { + data[key] = ctx.copy[key]; + } + }); + } + + 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(ctx, next) { + // We ignore 'curr'. For now at least. + delete data.curr; + + try { + mod_config.validateProfile(data, ctx.filePath); + } 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_create(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 0) { + return cb(new errors.UsageError('too many arguments')); + } else if (opts.copy && opts.file) { + return cb(new errors.UsageError( + 'cannot specify --file and --copy at the same time')); + } + + _createProfile({ + cli: this.top, + file: opts.file, + copy: opts.copy + }, cb); +} + +do_create.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['file', 'f'], + type: 'string', + helpArg: 'FILE', + help: 'A JSON file (of the same form as "triton profile get -j") ' + + 'with the profile, or "-" to read JSON from stdin.' + }, + { + names: ['copy'], + type: 'string', + helpArg: 'NAME', + help: 'A profile from which to copy values.' + } +]; + + +do_create.help = [ + 'Create a Triton CLI profile.', + '', + 'Usage:', + ' {{name}} create ', + '', + '{{options}}', + '', + 'Examples:', + ' triton profile create # interactively create a profile', + ' triton profile create --copy env # ... copying from "env" profile', + '', + ' # Or non-interactively create from stdin or a file:', + ' cat a-profile.json | triton profile create -f -', + ' triton profile create -f another-profile.json' +].join('\n'); + + +module.exports = do_create; diff --git a/lib/do_profile/do_delete.js b/lib/do_profile/do_delete.js new file mode 100644 index 0000000..25c1418 --- /dev/null +++ b/lib/do_profile/do_delete.js @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2015 Joyent Inc. + * + * `triton profile delete ...` + */ + +var assert = require('assert-plus'); +var vasync = require('vasync'); + +var common = require('../common'); +var errors = require('../errors'); +var mod_config = require('../config'); +var profilecommon = require('./profilecommon'); + + + +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(); + } + }); + }, + + // If we are deleting the current profile, then revert the current + // profile to 'env'. + function handleConfigVar(_, next) { + if (profile.name === cli.tritonapi.profile.name) { + profilecommon.setCurrentProfile({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 do_delete(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 1) { + return cb(new errors.UsageError('NAME argument is required')); + } + + _deleteProfile({ + cli: this.top, + name: args[0], + force: Boolean(opts.force) + }, cb); +} + +do_delete.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['force', 'f'], + type: 'bool', + help: 'Force deletion.' + } +]; + +do_delete.help = [ + 'Delete a Triton CLI profile.', + '', + 'Usage:', + ' {{name}} delete NAME', + '', + '{{options}}' +].join('\n'); + + +do_delete.aliases = ['rm']; + +module.exports = do_delete; diff --git a/lib/do_profile/do_edit.js b/lib/do_profile/do_edit.js new file mode 100644 index 0000000..f331e1c --- /dev/null +++ b/lib/do_profile/do_edit.js @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2015 Joyent Inc. + * + * `triton profile edit ...` + */ + +var assert = require('assert-plus'); +var format = require('util').format; +var strsplit = require('strsplit'); + +var common = require('../common'); +var errors = require('../errors'); +var mod_config = require('../config'); + + +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 do_edit(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length > 1) { + return cb(new errors.UsageError('too many arguments')); + } + + _editProfile({ + cli: this.top, + name: args[0] || this.top.tritonapi.profile.name + }, cb); +} + +do_edit.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + } +]; + +do_edit.help = [ + 'Edit a Triton CLI profile in your $EDITOR.', + '', + 'Usage:', + ' {{name}} edit [NAME]', + '', + '{{options}}' +].join('\n'); + + +module.exports = do_edit; diff --git a/lib/do_profile/do_get.js b/lib/do_profile/do_get.js new file mode 100644 index 0000000..466b8e5 --- /dev/null +++ b/lib/do_profile/do_get.js @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2015 Joyent Inc. + * + * `triton profile get ...` + */ + +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'); +var profilecommon = require('./profilecommon'); + + +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 do_get(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length > 1) { + return cb(new errors.UsageError('too many arguments')); + } + + _showProfile({ + cli: this.top, + name: args[0] || this.top.tritonapi.profile.name, + json: opts.json + }, cb); +} + +do_get.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + }, + { + names: ['json', 'j'], + type: 'bool', + help: 'JSON output.' + } +]; + +do_get.help = [ + 'Get a Triton CLI profile.', + '', + 'Usage:', + ' {{name}} get [NAME]', + '', + '{{options}}', + 'If NAME is not specified, the current profile is shown.' +].join('\n'); + + +module.exports = do_get; diff --git a/lib/do_profile/do_list.js b/lib/do_profile/do_list.js new file mode 100644 index 0000000..1bc5813 --- /dev/null +++ b/lib/do_profile/do_list.js @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2015 Joyent Inc. + * + * `triton profiles ...` + */ + +var tabula = require('tabula'); + +var common = require('../common'); +var errors = require('../errors'); +var mod_config = require('../config'); + + +var sortDefault = 'name'; +var columnsDefault = 'name,curr,account,user,url'; +var columnsDefaultLong = 'name,curr,account,user,url,insecure,keyId'; + +function _listProfiles(cli, opts, args, cb) { + var columns = columnsDefault; + if (opts.o) { + columns = opts.o; + } else if (opts.long) { + columns = columnsDefaultLong; + } + columns = columns.split(','); + + var sort = opts.s.split(','); + + // Load all the profiles. "env" is a special one managed by the CLI. + var profiles; + try { + profiles = mod_config.loadAllProfiles({ + configDir: cli.configDir, + log: cli.log + }); + } catch (e) { + return cb(e); + } + + // Current profile: Set 'curr' field. Apply CLI overrides. + for (i = 0; i < profiles.length; i++) { + var profile = profiles[i]; + if (profile.name === cli.tritonapi.profile.name) { + cli._applyProfileOverrides(profile); + if (opts.json) { + profile.curr = true; + } else { + profile.curr = '*'; // tabular + } + } else { + if (opts.json) { + profile.curr = false; + } else { + profile.curr = ''; // tabular + } + } + } + + // Display. + var i; + if (opts.json) { + common.jsonStream(profiles); + } else { + tabula(profiles, { + skipHeader: opts.H, + columns: columns, + sort: sort + }); + } + cb(); +} + + +function do_list(subcmd, opts, args, cb) { + if (opts.help) { + return this.do_help('help', {}, [subcmd], cb); + } else if (args.length > 0) { + return cb(new errors.UsageError('too many args')); + } + + _listProfiles(this.top, opts, args, cb); +} + +do_list.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + } +].concat(common.getCliTableOptions({ + includeLong: true, + sortDefault: sortDefault +})); +do_list.help = [ + /* BEGIN JSSTYLED */ + 'List Triton CLI profiles.', + '', + 'A profile is a configured Triton CloudAPI endpoint and associated info.', + 'I.e. the URL, account name, SSH key fingerprint, etc. information required', + 'to call a CloudAPI endpoint in a Triton datacenter. You can then switch', + 'between profiles with `triton -p PROFILE`, the TRITON_PROFILE environment', + 'variable, or by setting your current profile.', + '', + 'The "CURR" column indicates which profile is the current one.', + '', + 'Usage:', + ' {{name}} list', + '', + '{{options}}' + /* END JSSTYLED */ +].join('\n'); + + +do_list.aliases = ['ls']; + +module.exports = do_list; diff --git a/lib/do_profile/do_set_current.js b/lib/do_profile/do_set_current.js new file mode 100644 index 0000000..28eb2c0 --- /dev/null +++ b/lib/do_profile/do_set_current.js @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2015 Joyent Inc. + * + * `triton profile set-current ...` + */ + +var errors = require('../errors'); +var profilecommon = require('./profilecommon'); + + + +function do_set_current(subcmd, opts, args, cb) { + if (opts.help) { + this.do_help('help', {}, [subcmd], cb); + return; + } else if (args.length !== 1) { + return cb(new errors.UsageError('NAME argument is required')); + } + + profilecommon.setCurrentProfile({cli: this.top, name: args[0]}, cb); +} + +do_set_current.options = [ + { + names: ['help', 'h'], + type: 'bool', + help: 'Show this help.' + } +]; + +do_set_current.help = [ + 'Set the current Triton CLI profile.', + '', + 'Usage:', + ' {{name}} set-current NAME', + '', + '{{options}}', + 'The "current" profile is the one used by default, unless overridden by', + '`triton -p PROFILE-NAME ...` or the TRITON_PROFILE environment variable.' +].join('\n'); + + +module.exports = do_set_current; diff --git a/lib/do_profile/index.js b/lib/do_profile/index.js new file mode 100644 index 0000000..58615cc --- /dev/null +++ b/lib/do_profile/index.js @@ -0,0 +1,66 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2015 Joyent, Inc. + * + * `triton image ...` + */ + +var Cmdln = require('cmdln').Cmdln; +var util = require('util'); + + + +// ---- CLI class + +function ProfileCLI(top) { + this.top = top; + Cmdln.call(this, { + name: top.name + ' profile', + /* BEGIN JSSTYLED */ + desc: [ + 'List, get, create and update 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.' + ].join('\n'), + /* END JSSTYLED */ + helpOpts: { + minHelpCol: 24 /* line up with option help */ + }, + helpSubcmds: [ + 'help', + 'list', + 'get', + 'set-current', + 'create', + 'edit', + 'delete' + ] + }); +} +util.inherits(ProfileCLI, Cmdln); + +ProfileCLI.prototype.init = function init(opts, args, cb) { + this.log = this.top.log; + Cmdln.prototype.init.apply(this, arguments); +}; + +ProfileCLI.prototype.do_list = require('./do_list'); +ProfileCLI.prototype.do_get = require('./do_get'); +ProfileCLI.prototype.do_set_current = require('./do_set_current'); +ProfileCLI.prototype.do_create = require('./do_create'); +ProfileCLI.prototype.do_delete = require('./do_delete'); +ProfileCLI.prototype.do_edit = require('./do_edit'); + +// TODO: Would like to `triton profile update foo account=trentm ...` +// And then would like that same key=value syntax optional for create. +//ProfileCLI.prototype.do_update = require('./do_update'); + +module.exports = ProfileCLI; diff --git a/lib/do_profile/profilecommon.js b/lib/do_profile/profilecommon.js new file mode 100644 index 0000000..d650b6d --- /dev/null +++ b/lib/do_profile/profilecommon.js @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2015 Joyent Inc. + * + * Shared stuff for `triton profile ...` handling. + */ + +var assert = require('assert-plus'); + +var mod_config = require('../config'); + + + +function setCurrentProfile(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(); + }); +} + + +module.exports = { + setCurrentProfile: setCurrentProfile +}; diff --git a/lib/do_profiles.js b/lib/do_profiles.js index ddf2653..4ca8775 100644 --- a/lib/do_profiles.js +++ b/lib/do_profiles.js @@ -1,110 +1,29 @@ /* - * Copyright (c) 2015 Joyent Inc. - * - * `triton profiles ...` + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -var common = require('./common'); -var errors = require('./errors'); -var mod_config = require('./config'); -var tabula = require('tabula'); +/* + * Copyright 2015 Joyent, Inc. + * + * `triton profiles ...` bwcompat shortcut for `triton profile list ...`. + */ - -var sortDefault = 'name'; -var columnsDefault = 'name,curr,account,user,url'; -var columnsDefaultLong = 'name,curr,account,user,url,insecure,keyId'; - -function _listProfiles(cli, opts, args, cb) { - var columns = columnsDefault; - if (opts.o) { - columns = opts.o; - } else if (opts.long) { - columns = columnsDefaultLong; - } - columns = columns.split(','); - - var sort = opts.s.split(','); - - // Load all the profiles. "env" is a special one managed by the CLI. - var profiles; - try { - profiles = mod_config.loadAllProfiles({ - configDir: cli.configDir, - log: cli.log - }); - } catch (e) { - return cb(e); - } - - // Current profile: Set 'curr' field. Apply CLI overrides. - for (i = 0; i < profiles.length; i++) { - var profile = profiles[i]; - if (profile.name === cli.tritonapi.profile.name) { - cli._applyProfileOverrides(profile); - if (opts.json) { - profile.curr = true; - } else { - profile.curr = '*'; // tabular - } - } else { - if (opts.json) { - profile.curr = false; - } else { - profile.curr = ''; // tabular - } - } - } - - // Display. - var i; - if (opts.json) { - common.jsonStream(profiles); - } else { - tabula(profiles, { - skipHeader: opts.H, - columns: columns, - sort: sort - }); - } - cb(); +function do_profiles(subcmd, opts, args, callback) { + var subcmdArgv = ['node', 'triton', 'profile', 'list'].concat(args); + this.dispatch('profile', subcmdArgv, callback); } -function do_profiles(subcmd, opts, args, cb) { - if (opts.help) { - return this.do_help('help', {}, [subcmd], cb); - } else if (args.length > 0) { - return cb(new errors.UsageError('too many args')); - } - - _listProfiles(this, opts, args, cb); -} - -do_profiles.options = [ - { - names: ['help', 'h'], - type: 'bool', - help: 'Show this help.' - } -].concat(common.getCliTableOptions({ - includeLong: true, - sortDefault: sortDefault -})); do_profiles.help = [ - 'List `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.', - '', - 'The "CURR" column indicates which profile is the current one.', + 'A shortcut for "triton profile list".', '', 'Usage:', - ' {{name}} profiles', - '', - '{{options}}' + ' {{name}} profiles ...' ].join('\n'); +do_profiles.aliases = ['imgs']; +do_profiles.hidden = true; module.exports = do_profiles; diff --git a/test/integration/cli-profiles.test.js b/test/integration/cli-profiles.test.js index 8e92b04..00982d6 100644 --- a/test/integration/cli-profiles.test.js +++ b/test/integration/cli-profiles.test.js @@ -34,8 +34,8 @@ if (opts.skip) { } test('triton profiles (read only)', function (tt) { - tt.test(' triton profile env', function (t) { - h.safeTriton(t, {json: true, args: ['profile', '-j', 'env']}, + tt.test(' triton profile get env', function (t) { + h.safeTriton(t, {json: true, args: ['profile', 'get', '-j', 'env']}, function (p) { t.equal(p.account, h.CONFIG.profile.account, @@ -54,7 +54,7 @@ test('triton profiles (read only)', function (tt) { test('triton profiles (read/write)', opts, function (tt) { tt.test(' triton profile create', function (t) { - h.safeTriton(t, ['profile', '-a', PROFILE_FILE], + h.safeTriton(t, ['profile', 'create', '-f', PROFILE_FILE], function (stdout) { t.ok(stdout.match(/^Saved profile/), 'stdout correct'); @@ -64,7 +64,7 @@ test('triton profiles (read/write)', opts, function (tt) { tt.test(' triton profile get', function (t) { h.safeTriton(t, - {json: true, args: ['profile', '-j', PROFILE_DATA.name]}, + {json: true, args: ['profile', 'get', '-j', PROFILE_DATA.name]}, function (p) { t.deepEqual(p, PROFILE_DATA, 'profile matched'); @@ -74,7 +74,7 @@ test('triton profiles (read/write)', opts, function (tt) { }); tt.test(' triton profile delete', function (t) { - h.safeTriton(t, ['profile', '-df', PROFILE_DATA.name], + h.safeTriton(t, ['profile', 'delete', '-f', PROFILE_DATA.name], function (stdout) { t.ok(stdout.match(/^Deleted profile/), 'stdout correct');