'triton profile -a' from stdin, JSON file or interactively
This commit is contained in:
parent
a5ee77a48e
commit
aeebcf19f0
2
TODO.txt
2
TODO.txt
@ -3,6 +3,8 @@ test suite:
|
||||
- TritonApi testing: test/integration/api-*.test.js
|
||||
- more test/unit/...
|
||||
|
||||
sub-user support (profiles, `triton account`, env, auth)
|
||||
|
||||
note in README that full UUIDs is much faster in the API
|
||||
|
||||
*type*: cloudapi changes to clarify: LX, docker, smartos, kvm instances
|
||||
|
100
lib/common.js
100
lib/common.js
@ -17,11 +17,13 @@ var read = require('read');
|
||||
var tty = require('tty');
|
||||
var util = require('util'),
|
||||
format = util.format;
|
||||
var wordwrap = require('wordwrap');
|
||||
|
||||
var errors = require('./errors'),
|
||||
InternalError = errors.InternalError;
|
||||
|
||||
|
||||
|
||||
// ---- support stuff
|
||||
|
||||
function objCopy(obj, target) {
|
||||
@ -541,6 +543,65 @@ function promptEnter(prompt, cb) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Prompt the user for a value.
|
||||
*
|
||||
* @params field {Object}
|
||||
* - field.desc {String} Optional. A description of the field to print
|
||||
* before prompting.
|
||||
* - field.key {String} The field name. Used as the prompt.
|
||||
* - field.default Optional default value.
|
||||
* - field.validate {Function} Optional. A validation/manipulation
|
||||
* function of the form:
|
||||
* function (value, cb)
|
||||
* which should callback with
|
||||
* cb([<error or null>, [<manipulated value>]])
|
||||
* examples:
|
||||
* cb(new Error('value is not a number'));
|
||||
* cb(); // value is fine as is
|
||||
* cb(null, Math.floor(Number(value))); // manip to a floored int
|
||||
* @params cb {Function} `function (err, value)`
|
||||
* If the user aborted, the `err` will be whatever the [read
|
||||
* package](https://www.npmjs.com/package/read) returns, i.e. a
|
||||
* string "cancelled".
|
||||
*/
|
||||
function promptField(field, cb) {
|
||||
var wrap = wordwrap(Math.min(process.stdout.columns, 78));
|
||||
function attempt(next) {
|
||||
read({
|
||||
// read/readline prompting messes up width with ANSI codes here.
|
||||
prompt: field.key + ':',
|
||||
default: field.default,
|
||||
edit: true
|
||||
}, function (err, result, isDefault) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
var value = result.trim();
|
||||
if (!field.validate) {
|
||||
return cb(null, value);
|
||||
}
|
||||
|
||||
field.validate(value, function (validationErr, newValue) {
|
||||
if (validationErr) {
|
||||
console.log(ansiStylize(
|
||||
wrap(validationErr.message), 'red'));
|
||||
attempt();
|
||||
} else {
|
||||
if (newValue !== undefined) {
|
||||
value = newValue;
|
||||
}
|
||||
cb(null, value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(ansiStylize(wrap(field.desc), 'bold'));
|
||||
attempt();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Edit the given text in $EDITOR (defaulting to `vi`) and return the edited
|
||||
* text.
|
||||
@ -571,6 +632,42 @@ function editInEditor(opts, cb) {
|
||||
}
|
||||
|
||||
|
||||
// http://en.wikipedia.org/wiki/ANSI_escape_code#graphics
|
||||
// Suggested colors (some are unreadable in common cases):
|
||||
// - Good: cyan, yellow (limited use), bold, green, magenta, red
|
||||
// - Bad: blue (not visible on cmd.exe), grey (same color as background on
|
||||
// Solarized Dark theme from <https://github.com/altercation/solarized>, see
|
||||
// issue #160)
|
||||
var colors = {
|
||||
'bold' : [1, 22],
|
||||
'italic' : [3, 23],
|
||||
'underline' : [4, 24],
|
||||
'inverse' : [7, 27],
|
||||
'white' : [37, 39],
|
||||
'grey' : [90, 39],
|
||||
'black' : [30, 39],
|
||||
'blue' : [34, 39],
|
||||
'cyan' : [36, 39],
|
||||
'green' : [32, 39],
|
||||
'magenta' : [35, 39],
|
||||
'red' : [31, 39],
|
||||
'yellow' : [33, 39]
|
||||
};
|
||||
|
||||
function ansiStylize(str, color) {
|
||||
if (!str)
|
||||
return '';
|
||||
var codes = colors[color];
|
||||
if (codes) {
|
||||
return '\033[' + codes[0] + 'm' + str +
|
||||
'\033[' + codes[1] + 'm';
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//---- exports
|
||||
|
||||
module.exports = {
|
||||
@ -591,6 +688,7 @@ module.exports = {
|
||||
getCliTableOptions: getCliTableOptions,
|
||||
promptYesNo: promptYesNo,
|
||||
promptEnter: promptEnter,
|
||||
editInEditor: editInEditor
|
||||
editInEditor: editInEditor,
|
||||
ansiStylize: ansiStylize
|
||||
};
|
||||
// vim: set softtabstop=4 shiftwidth=4:
|
||||
|
@ -64,30 +64,6 @@ var PROFILE_FIELDS = {
|
||||
|
||||
// --- internal support stuff
|
||||
|
||||
// TODO: improve this validation: use ConfigError's instead of asserts
|
||||
function _validateProfile(profile, profilePath) {
|
||||
assert.object(profile, 'profile');
|
||||
assert.string(profile.name, 'profile.name');
|
||||
assert.string(profile.url, 'profile.url');
|
||||
assert.string(profile.account, 'profile.account');
|
||||
assert.string(profile.keyId, 'profile.keyId');
|
||||
assert.optionalBool(profile.insecure, 'profile.insecure');
|
||||
assert.optionalString(profilePath, 'profilePath');
|
||||
|
||||
var bogusFields = [];
|
||||
Object.keys(profile).forEach(function (field) {
|
||||
if (!PROFILE_FIELDS[field]) {
|
||||
bogusFields.push(field);
|
||||
}
|
||||
});
|
||||
if (bogusFields.length) {
|
||||
throw new errors.ConfigError(format(
|
||||
'extraneous fields in "%s" profile: %s%s', profile.name,
|
||||
(profilePath ? profilePath + ': ' : ''), bogusFields.join(', ')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function configPathFromDir(configDir) {
|
||||
return path.resolve(configDir, 'config.json');
|
||||
}
|
||||
@ -204,6 +180,35 @@ function setConfigVar(opts, cb) {
|
||||
|
||||
// --- Profiles
|
||||
|
||||
function validateProfile(profile, profilePath) {
|
||||
assert.object(profile, 'profile');
|
||||
assert.optionalString(profilePath, 'profilePath');
|
||||
|
||||
try {
|
||||
assert.string(profile.name, 'profile.name');
|
||||
assert.string(profile.url, 'profile.url');
|
||||
assert.string(profile.account, 'profile.account');
|
||||
assert.string(profile.keyId, 'profile.keyId');
|
||||
assert.optionalBool(profile.insecure, 'profile.insecure');
|
||||
} catch (err) {
|
||||
throw new errors.ConfigError(err.message);
|
||||
}
|
||||
|
||||
var bogusFields = [];
|
||||
Object.keys(profile).forEach(function (field) {
|
||||
if (!PROFILE_FIELDS[field]) {
|
||||
bogusFields.push(field);
|
||||
}
|
||||
});
|
||||
if (bogusFields.length) {
|
||||
throw new errors.ConfigError(format(
|
||||
'extraneous fields in "%s" profile: %s%s', profile.name,
|
||||
(profilePath ? profilePath + ': ' : ''), bogusFields.join(', ')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Load the special 'env' profile, which handles details of getting
|
||||
* values from envvars. Typically we'd piggyback on dashdash's env support
|
||||
@ -231,7 +236,7 @@ function _loadEnvProfile() {
|
||||
envProfile.insecure = common.boolFromString(process.env.SDC_TESTING);
|
||||
}
|
||||
|
||||
_validateProfile(envProfile);
|
||||
validateProfile(envProfile);
|
||||
|
||||
return envProfile;
|
||||
}
|
||||
@ -255,11 +260,13 @@ function _profileFromPath(profilePath, name) {
|
||||
}
|
||||
profile.name = name;
|
||||
|
||||
_validateProfile(profile, profilePath);
|
||||
validateProfile(profile, profilePath);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function loadProfile(opts) {
|
||||
assert.string(opts.configDir, 'opts.configDir');
|
||||
assert.string(opts.name, 'opts.name');
|
||||
@ -322,22 +329,22 @@ function deleteProfile(opts) {
|
||||
|
||||
function saveProfileSync(opts) {
|
||||
assert.string(opts.configDir, 'opts.configDir');
|
||||
assert.string(opts.name, 'opts.name');
|
||||
assert.object(opts.profile, 'opts.profile');
|
||||
|
||||
if (opts.name === 'env') {
|
||||
var name = opts.profile.name;
|
||||
if (name === 'env') {
|
||||
throw new Error('cannot save "env" profile');
|
||||
}
|
||||
|
||||
_validateProfile(opts.profile);
|
||||
validateProfile(opts.profile);
|
||||
|
||||
var toSave = common.objCopy(opts.profile);
|
||||
delete toSave.name;
|
||||
|
||||
var profilePath = path.resolve(opts.configDir, 'profiles.d',
|
||||
opts.name + '.json');
|
||||
name + '.json');
|
||||
fs.writeFileSync(profilePath, JSON.stringify(toSave, null, 4), 'utf8');
|
||||
console.log('Saved profile "%s"', opts.name);
|
||||
console.log('Saved profile "%s"', name);
|
||||
}
|
||||
|
||||
|
||||
@ -346,6 +353,8 @@ function saveProfileSync(opts) {
|
||||
module.exports = {
|
||||
loadConfig: loadConfig,
|
||||
setConfigVar: setConfigVar,
|
||||
|
||||
validateProfile: validateProfile,
|
||||
loadProfile: loadProfile,
|
||||
loadAllProfiles: loadAllProfiles,
|
||||
deleteProfile: deleteProfile,
|
||||
|
@ -6,7 +6,11 @@
|
||||
|
||||
var assert = require('assert-plus');
|
||||
var format = require('util').format;
|
||||
var fs = require('fs');
|
||||
var read = require('read');
|
||||
var strsplit = require('strsplit');
|
||||
var sshpk = require('sshpk');
|
||||
var tilde = require('tilde-expansion');
|
||||
var vasync = require('vasync');
|
||||
|
||||
var common = require('./common');
|
||||
@ -174,7 +178,6 @@ function _editProfile(opts, cb) {
|
||||
|
||||
mod_config.saveProfileSync({
|
||||
configDir: cli.configDir,
|
||||
name: opts.name,
|
||||
profile: editedProfile
|
||||
});
|
||||
} catch (textErr) {
|
||||
@ -278,8 +281,199 @@ function _deleteProfile(opts, cb) {
|
||||
|
||||
|
||||
function _addProfile(opts, cb) {
|
||||
//XXX
|
||||
cb(new errors.InternalError('_addProfile not yet implemented'));
|
||||
assert.object(opts.cli, 'opts.cli');
|
||||
assert.optionalString(opts.file, 'opts.file');
|
||||
assert.func(cb, 'cb');
|
||||
var cli = opts.cli;
|
||||
|
||||
var context;
|
||||
var data;
|
||||
|
||||
vasync.pipeline({funcs: [
|
||||
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) {
|
||||
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(_, 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 profiles = mod_config.loadAllProfiles({
|
||||
configDir: cli.configDir,
|
||||
log: cli.log
|
||||
});
|
||||
|
||||
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 < profiles.length; i++) {
|
||||
if (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(
|
||||
'"%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(_, next) {
|
||||
try {
|
||||
var profiles = mod_config.loadAllProfiles({
|
||||
configDir: cli.configDir,
|
||||
log: cli.log
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
for (var i = 0; i < profiles.length; i++) {
|
||||
if (data.name === 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();
|
||||
}
|
||||
]}, cb);
|
||||
}
|
||||
|
||||
|
||||
@ -401,7 +595,12 @@ do_profile.help = [
|
||||
' {{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',
|
||||
'',
|
||||
' {{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');
|
||||
|
@ -29,7 +29,7 @@ function _listProfiles(cli, opts, args, cb) {
|
||||
var profiles;
|
||||
try {
|
||||
profiles = mod_config.loadAllProfiles({
|
||||
configDir: cli.tritonapi.config._configDir,
|
||||
configDir: cli.configDir,
|
||||
log: cli.log
|
||||
});
|
||||
} catch (e) {
|
||||
@ -69,30 +69,6 @@ function _listProfiles(cli, opts, args, cb) {
|
||||
cb();
|
||||
}
|
||||
|
||||
function _currentProfile(cli, opts, args, cb) {
|
||||
var profile = mod_config.loadProfile({
|
||||
configDir: cli.configDir,
|
||||
name: opts.current
|
||||
});
|
||||
|
||||
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('Switched to "%s" profile', profile.name);
|
||||
cb();
|
||||
});
|
||||
}
|
||||
|
||||
function do_profiles(subcmd, opts, args, cb) {
|
||||
if (opts.help) {
|
||||
return this.do_help('help', {}, [subcmd], cb);
|
||||
@ -100,11 +76,7 @@ function do_profiles(subcmd, opts, args, cb) {
|
||||
return cb(new errors.UsageError('too many args'));
|
||||
}
|
||||
|
||||
if (opts.current) {
|
||||
_currentProfile(this, opts, args, cb);
|
||||
} else {
|
||||
_listProfiles(this, opts, args, cb);
|
||||
}
|
||||
}
|
||||
|
||||
do_profiles.options = [
|
||||
|
@ -19,11 +19,14 @@
|
||||
"read": "1.0.7",
|
||||
"restify-clients": "1.1.0",
|
||||
"restify-errors": "3.0.0",
|
||||
"sshpk": "1.2.1",
|
||||
"smartdc-auth": "git+https://github.com/joyent/node-smartdc-auth.git#3be3c1e",
|
||||
"strsplit": "1.0.0",
|
||||
"tabula": "1.6.1",
|
||||
"tilde-expansion": "0.0.0",
|
||||
"vasync": "1.6.3",
|
||||
"verror": "1.6.0"
|
||||
"verror": "1.6.0",
|
||||
"wordwrap": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tape": "4.2.0"
|
||||
|
Reference in New Issue
Block a user