UNPKG

jitsu

Version:

Flawless command line deployment of Node.js apps to the cloud

853 lines (740 loc) 25.8 kB
/* * package.js: Utilities for working with package.json files. * * (C) 2010, Nodejitsu Inc. * */ var fs = require('fs'), path = require('path'), existsSync = fs.existsSync || path.existsSync, util = require('util'), punycode = require('punycode'), spawnCommand = require('spawn-command'), zlib = require('zlib'), async = require('flatiron').common.async, analyzer = require('require-analyzer'), semver = require('semver'), jitsu = require('../jitsu'), ladder = require('ladder'), fstream = require('fstream'), ProgressBar = require('progress'), fstreamNpm = require('fstream-npm'), tar = require('tar'); var package = exports; // // ### function get (dir, callback) // #### @dir {string} Directory to get the package.json from // #### @options {object} Ignored // #### @callback {function} Continuation to respond to when complete // Attempts to read the package.json from the specified `dir`. If it is // unable to do, walks the user through creating a new one from scratch. // package.get = function (dir, options, callback) { if (!callback) { callback = options; options = {}; } package.read(dir, function (err, pkg) { if (err) { if (err.toString() === "Error: Invalid package.json file") { jitsu.log.error(err.toString()); return callback( 'Please make sure ' + (path.join(dir, '/package.json')).grey + ' is valid JSON', false, false ); } return package.create(dir, callback); } package.validate(pkg, dir, options, function (err, updated) { return err ? callback(err) : callback(null, updated); }); }); }; // // ### function read (dir, callback) // #### @dir {string} Directory to read the package.json from // #### @callback {function} Continuation to pass control to when complete // Attempts to read the package.json file out of the specified directory. // package.read = function (dir, callback) { var file = path.resolve(path.join(dir, 'package.json')); fs.readFile(file, function (err, data) { if (err) { return callback(err); } data = data.toString(); if (!data.length) { return callback(new Error('package.json is empty')); } try { data = JSON.parse(data.toString()); } catch (ex) { return callback(new Error('Invalid package.json file')); } callback(null, data); }); }; // // ### function tryRead (dir, callback, success) // #### @dir {string} Directory to try to read the package.json from. // #### @callback {function} Continuation to respond to on error. // #### @success {function} Continuation to respond to on success. // Attempts to read the package.json file from the specified `dir`; responds // to `callback` on error and `success` if the read operation worked. // package.tryRead = function (dir, callback, success) { package.read(dir, function (err, pkg) { return err ? callback(new Error('No package found at ' + (dir + '/package.json').grey), true) : success(pkg); }); }; // // ### function createPackage (dir, callback) // #### @dir {string} Directory to create the package.json in // #### @callback {function} Continuation to respond to when complete // Walks the user through creating a simple package.json in the specified // `dir` then writes to file and responds to `callback`. // package.create = function (dir, callback) { var help = [ '', 'A package.json stores meta-data about an app', 'In order to continue we\'ll need to gather some information about the app', '', 'Press ^C at any time to quit.', 'to select a default value, press ENTER' ]; jitsu.log.warn('There is no package.json file in ' + dir.grey); jitsu.log.warn('Creating package.json at ' + (path.join(dir, '/package.json')).grey); help.forEach(function (line) { jitsu.log.help(line); }); fillPackage(null, dir, function (err, pkg) { if (err) { return callback(err); } package.write(pkg, dir, true, function (err, pkg) { if (err) { return callback(err); } tryAnalyze(pkg, dir, callback); }); }); }; // // ### function validate (pkg, dir, callback) // #### @pkg {Object} Parsed package.json to validate // #### @dir {string} Directory containing the package.json file // #### @options {object} Ignored // #### @callback {function} Continuation to respond to when complete // Validates the specified `pkg` against the properties list // returned from `package.properties(dir)`. // Also prompts the user if there are any missing properties. // package.validate = function (pkg, dir, options, callback) { if (!callback) { callback = options; options = {}; } var properties = package.properties(dir), missing = [], invalid = []; function checkProperty (desc, next) { var nested = desc.name.split('.'), value = pkg[nested[0]]; if (nested.length > 1 && value) { value = value[nested[1]]; } // Handle missing values if (!value) { missing.push(desc); } // handle invalid values function isValid(desc) { if (desc.validator) { if (desc.validator instanceof RegExp) { return !desc.validator.test(value); } return !desc.validator(value); } return false; } if (value && isValid(desc)) { if (nested.length > 1) { delete pkg[nested[0]][nested[1]]; } else { delete pkg[nested[0]]; } invalid.push(desc); } next(); } async.forEach(properties, checkProperty, function () { if (missing.length <= 0 && invalid.length <= 0) { return tryAnalyze(pkg, dir, callback); } var help, missingNames = missing.map(function (prop) { return ' ' + (prop.message || prop.name).grey; }), invalidNames = invalid.map(function (prop) { return ' ' + (prop.message || prop.name).grey; }); help = [ '' ]; if (missingNames.length) { help = help.concat([ 'The package.json file is missing required fields:', '', missingNames.join(', '), '' ]); } if (invalidNames.length) { help = help.concat([ 'The package.json file has invalid required fields:', '', invalidNames.join(', '), '' ]); } help = help.concat([ 'Prompting user for required fields.', 'Press ^C at any time to quit.', '' ]); help.forEach(function (line) { jitsu.log.warn(line); }); fillPackage(pkg, dir, function (err, pkg) { if (err) { return callback(err); } package.write(pkg, dir, true, function (err, pkg) { return err ? callback(err) : tryAnalyze(pkg, dir, callback); }); }); }); }; // // ### function writePackage (pkg, dir, callback) // #### @pkg {Object} Data for the package.json // #### @dir {string} Directory to write the package.json in // #### @callback {function} Continuation to respond to when complete // Prompts the user about writing the new package.json file. If the user // OKs the operation, attempts to write to file. Otherwise restarts the // create operation in the specified `dir`. // package.write = function (pkg, dir, create, callback) { function doWrite(err, result) { if (err) { return cb(err); } if (!result) { return create ? package.create(dir, callback) : callback(new Error('Save package.json cancelled.')); } fs.readFile(path.resolve(path.join(dir, 'package.json')), function (e, data) { var offset = data ? ladder(data.toString()) : 2; fs.writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg, null, offset) + '\n', function (err) { return err ? callback(err) : callback(null, pkg, dir); }); }); } if (!callback) { callback = create; create = null; } delete pkg.analyzed; jitsu.log.warn('About to write ' + path.join(dir, 'package.json').magenta); // // analyze and throw warnings if any dependencies have version of '*' // policeDependencies(pkg); jitsu.inspect.putObject(pkg, 2); return jitsu.argv.release ? doWrite(null, true) : jitsu.prompt.confirm('Is this ' + 'ok?'.green.bold, { default: 'yes'}, doWrite); }; // // ### function analyzeDependencies (pkg, dir, callback) // #### @pkg {Object} Parsed package.json to check dependencies for. // #### @dir {string} Directory containing the package.json file. // #### @callback {function} Continuation to respond to when complete. // Analyzes the dependencies in `pkg` using the `require-analyzer` module. // package.analyzeDependencies = function (pkg, dir, callback) { jitsu.log.info('Analyzing application dependencies in ' + pkg.scripts.start.magenta); analyzer.analyze({ target: path.join(dir, pkg.scripts.start) }, function (err, pkgs) { // // Create a hash of `'package': '>= version'` for the new dependencies // var versions = analyzer.extractVersions(pkgs), updates; if (package.newDependencies(pkg.dependencies, versions)) { // // If there are new dependencies, indicate this to the user. // jitsu.log.info('Found new dependencies. They will be added automatically'); // // Extract, merge, and display the updates found by `require-analyzer` // updates = analyzer.updates(pkg.dependencies, versions); updates = analyzer.merge({}, updates.added, updates.updated); jitsu.inspect.putObject(updates); // // Update the package.json dependencies // pkg.dependencies = analyzer.merge({}, pkg.dependencies || {}, updates); } callback(null, pkg, updates); }); }; // // ### function createPackage (dir, callback) // #### @dir {string} Directory to create the package *.tgz file from // #### @version {string} Optional version to name saved file. // #### @callback {function} Continuation to pass control to when complete // Creates a *.tgz package file from the specified directory `dir`. // package.createTarball = function (dir, version, callback) { if (!callback) { callback = version; version = null; } package.read(dir, function (err, pkg) { if (err) { return callback(err); } if (dir.slice(-1) === '/') { dir = dir.slice(0, -1); } var name = [jitsu.config.get('username'), pkg.name, version || pkg.version].join('-') + '.tgz', tarball = path.join(jitsu.config.get('tmproot'), name); fstreamNpm({ path: dir, ignoreFiles: ['.jitsuignore', '.npmignore', '.gitignore', 'package.json'] }) .on('error', callback) .pipe(tar.Pack()) .on('error', callback) .pipe(zlib.Gzip()) .on('error', callback) .pipe(fstream.Writer({ type: "File", path: tarball })) .on('close', function () { callback(null, pkg, tarball); }); }); }; // // ### function updateTarball (version, pkg, existing, callback) // #### @version {string} **Optional** Version to use for the updated tarball // #### @pkg {Object} Current package.json file on disk // #### @existing {Object} Remote package.json stored at Nodejitsu // #### @callback {function} Continuation to respond to when complete. // // package.updateTarball = function (version, pkg, existing, firstSnapshot, callback) { if (!callback) { callback = firstSnapshot; firstSnapshot = false; } function executeCreate (err) { if (err) { return callback(err, true); } version = version || pkg.version; jitsu.package.createTarball(process.cwd(), version, function (err, ign, filename) { if (err) { return callback(err, true) } jitsu.log.info('Creating snapshot ' + version.grey); jitsu.log.silly('Filename: ' + filename); fs.stat(filename, function (err, stat) { var bar; if (err) return callback(err); // XXX Is 70mb enough? Please warning message if (stat.size > 70 * 1024 * 1024) { jitsu.log.warn('Snapshot is larger than ' + '70M'.magenta + '!'); jitsu.log.warn('Keep size below this limit for successful deploy.'); } var emitter = jitsu.snapshots.create(pkg.name, version, filename, function (err, res) { if (err) { return callback(err); } res.pipe(process.stdout); res.on('end', function () { if (res.trailers && res.trailers['x-build-failure']) { var error = new Error('Nodejitsu Error: Build Failure, please check the build failure above'); error.result = { error: res.trailers['x-build-failure'].toString() }; return callback(error); } jitsu.log.info('Done creating snapshot ' + version.magenta); callback(null, version, pkg); }); }); if (emitter && !jitsu.config.get('raw') && process.stdout.isTTY ) { var size; emitter.on('start', function (stats) { size = stats.size; bar = new ProgressBar('info'.green + ':\t Uploading: [:bar] :percent',{ complete : '=', incomplete: ' ', width : 30 , total : stats.size }); }); emitter.on('data', function (length) { if (bar) bar.tick(length > size ? size : length); }); emitter.on('end', function () { // fix for bar that sometimes hangs at 99% if (bar) { bar.tick(bar.total - bar.curr); } console.log(); }); } }); }); } var start = pkg.scripts.start; if (start.match(/(--watch)/)) { jitsu.log.warn("Using the '--watch' flag may eventually cause issues as its only"); jitsu.log.warn("intended for development usage."); } var old = false; if (!firstSnapshot) { jitsu.log.silly('Existing version: ' + existing.version.magenta); jitsu.log.silly('Local version: ' + pkg.version.magenta); old = semver.gte(existing.version, pkg.version); if (old) { // // If the existing version is greater than the version in the // package.json file on disk, update it and then write back to disk. // jitsu.log.warn('Local package version appears to be old'); jitsu.log.warn('The ' + 'package.json'.grey + ' version will be incremented automatically'); // // Check for release args then .jitsuconf or default to 'build' // var release = jitsu.argv.release || jitsu.config.get('release') || 'build'; // // Pattern matching to see if release is version number or type // If is version number, set release to version number specified // if (jitsu.argv.release) { if (typeof(jitsu.argv.release) == 'string') { var releaseIsVersionNumber = jitsu.argv.release.match(/^(?:(\d+)\.)?(?:(\d+)\.)?(\*|\d+)/g); if (releaseIsVersionNumber) { pkg.version = jitsu.argv.release; } else { pkg.version = semver.inc(existing.version, release); } } // // if no release argument specified use .jitsuconf // } else { pkg.version = semver.inc(existing.version, release); } // // Default to build if user inputs as -r arg something ridiculous // if (pkg.version === null) { pkg.version = semver.inc(existing.version, jitsu.config.get('release') || 'build'); } } } return old ? package.write(pkg, process.cwd(), executeCreate) : executeCreate(); }; // // ### function newDependencies (current, updated) // #### @current {Object} Set of current dependencies // #### @updated {Object} Set of updated dependencies // Returns a value indicating if there are any new dependencies // in `updated` as compared to `current`. // package.newDependencies = function (current, updated) { var updates = analyzer.updates(current, updated); return Object.keys(updates.added).length > 0 || Object.keys(updates.updated).length > 0; }; // // ### function properties (dir) // #### @dir {string} Directory in which the package.json properties are being used // Returns a new set of properties to be consumed by `jitsu.prompt` to walk a user // through creating a new `package.json` file. // package.properties = function (dir) { return [ { name: 'name', unique: true, message: 'Application name', validator: /^(?!\.)(?!_)(?!node_modules)(?!favicon.ico)[^\/@\s\+%:\n]+$/, warning: 'The application name must follow the rules for npm package names.\n'+ ' They must not start with a \'.\' or \'_\', contain any whitespace \n'+ ' characters or any of the following characters(between quotes): "/@+%:". \n'+ ' Additionally, the name may not be \'node_modules\' or \'favicon.ico\'.', default: path.basename(dir) }, { name: 'subdomain', unique: true, message: 'Subdomain name',//+ warning: 'The subdomain must follow the rules for ARPANET host names. They must\n'+ ' start with a letter, end with a letter or digit, and have as interior\n'+ ' characters only letters, digits, and hyphen. There are also some\n'+ ' restrictions on the length. Labels must be 63 characters or less.\n'+ ' There are a few exceptions, underscores may be used as an interior \n'+ ' character and unicode characters may be used that are supported under\n'+ ' punycode.', validator: function(s){ var reValidSubdomain = /^[a-zA-Z]$|^[a-zA-Z][a-zA-Z\d]$|^[a-zA-Z][\w\-]{1,61}[a-zA-Z\d]$/; if(s.indexOf('.') !== -1) { // We will support multiple level subdomains this for now warn user... jitsu.log.warn("**WARNING** Do not use multiple level subdomains, they will be going away soon!"); var subdomainNames = s.split('.'), names = subdomainNames.map(punycode.toASCII); return !names.some(function(name){return !reValidSubdomain.test(name);}); } else { return reValidSubdomain.test(punycode.toASCII(s)); } }, help: [ '', 'The ' + 'subdomain '.grey + 'is where the app will reside', 'The app will then become accessible at: http://' + 'subdomain'.grey + '.jit.su', '' ], default: jitsu.config.get('username') + '-' + path.basename(dir) }, { name: 'scripts.start', message: 'scripts.start', conform: function (script) { // // Support `scripts.start` starting with executable (`node` or `coffee`). // var split = script.split(' '); if (~['node', 'coffee'].indexOf(split[0])) { script = split.slice(1).join(' '); } try { fs.statSync(path.join(dir, script)); return true; } catch (ex) { return false; } }, warning: 'Start script was not found in ' + dir.magenta, default: searchStartScript(dir) }, { name: 'version', unique: false, conform: semver.valid, default: '0.0.0' }, { name: 'engines.node', unique: false, message: 'engines', conform: semver.validRange, default: '0.10.x' } ]; }; // // ### function available (pkg, dir, callback, createPackage) // #### @pkg {Object} Current package.json file on disk // #### @dir {string} Directory in which the package.json properties are being used // #### @callback {function} Continuation to respond to when complete. // #### @createPackage {function} Function needed to make it recursive // Prompts for appname and subdomain until the combination is available // package.available = function (pkg, dir, callback, createPackage) { jitsu.apps.available(pkg, function (err, isAvailable) { var props, fields = []; function removeAppname(){ delete pkg.name; fields.push('name'); } function removeSubdomain(){ delete pkg.subdomain; fields.push('subdomain'); } function addProps(){ props = package.properties(dir).filter(function (p) { return fields.indexOf(p.name) !== -1; }); // auto-suggest a new domain field based on username for (var p in props) { if (props[p].name === 'subdomain') { props[p].default = props[p].default + '.' + jitsu.config.get('username'); } } jitsu.prompt.addProperties(pkg, props, createPackage); return; } if (err) { jitsu.log.error('There was an error while checking app name / subdomain availability.'); return callback(err); } else if (!isAvailable.available) { // only appname is taken if(!isAvailable.appname && isAvailable.subdomain){ jitsu.log.error('The app name requested is already in use'); jitsu.prompt.confirm('It appears you have already used this appname before ('+pkg.name.magenta+'). ' + 'Overwrite?'.green.bold, { default: 'yes'}, function (err, result) { if (err) { return callback(err); } if (!result){ removeAppname(); addProps(); } else { callback(null, pkg); } }); } // only subdomain is taken else if(isAvailable.appname && !isAvailable.subdomain){ jitsu.log.error('The subdomain requested is already in use'); removeSubdomain(); addProps(); } //both are taken else if(!isAvailable.appname && !isAvailable.subdomain){ jitsu.log.error('The subdomain and app name requested are already in use'); jitsu.prompt.confirm('This app already exists! ('+pkg.name.magenta+'). ' + 'Do you want to deploy over it?'.green.bold, { default: 'yes'}, function (err, result) { if (err) { return callback(err); } if (!result){ removeAppname(); removeSubdomain(); addProps(); } else { callback(null, pkg); } }); } } else { //nothing is wrong callback(null, pkg); } }); }; package.runScript = function (pkg, action, callback) { var command = pkg.scripts[action]; if (!command) { // // If there's no such script, it's all fine. // return callback(); } var child = spawnCommand(command, { customFds: [0, 1, 2] }); child.on('exit', function (code) { if (code !== 0) { return callback(new Error('`' + command + '` exited with code ' + code)); } callback(); }); }; function searchStartScript(dir) { var scripts = ['server', 'app', 'index', 'bin/server'], script, i; for (i in scripts) { script = path.join(dir, scripts[i]); if (existsSync(script)) { return 'node ' + scripts[i]; } else if (existsSync(script + '.js')) { return 'node ' + scripts[i] + '.js'; } else if (existsSync(script + '.coffee')) { return 'coffee ' + scripts[i] + '.coffee'; } } } function tryAnalyze (target, dir, callback) { if (target.analyzed) { return callback(null, target); } var noanalyze = !((jitsu.config.get('analyze') === 'true') || (jitsu.config.get('analyze') === true)) || ((jitsu.config.get('noanalyze') === 'true') || (jitsu.config.get('noanalyze') === true)) || ((target.analyze === 'false') || (target.analyze === false)); if (noanalyze) { jitsu.log.info('Skipping require-analyzer because ' + 'noanalyze'.magenta + ' option is set'); return callback(null, target); } package.analyzeDependencies(target, dir, function (err, addedDeps, updates) { if (err) { return callback(err); } target.analyzed = true; return updates ? package.write(addedDeps, dir, true, callback) : callback(null, addedDeps); }); } function policeDependencies (pkg) { dependencies = pkg.dependencies; for (var key in dependencies) { if (dependencies[key].toString().match(/(\*)/)) { jitsu.log.warn("Using '" + "*".magenta + "' as a version for dependencies may eventually cause issues"); jitsu.log.warn('Use specific versions for dependencies to avoid future problems'); jitsu.log.warn('See: ' + 'http://package.json.jit.su'.grey + ' for more information'); return false; } } } function fillPackage (base, dir, callback) { base = base || {}; var subdomain, descriptors, missing; missing = ['name', 'subdomain', 'version'].filter(function (prop) { return !base[prop] }); if (!(base.scripts && base.scripts.start)) { missing.push('scripts.start'); } if (!(base.engines && base.engines.node)) { missing.push('engines.node'); } descriptors = package.properties(dir).filter(function (descriptor) { if (descriptor.name == 'subdomain') { subdomain = descriptor; } return missing.indexOf(descriptor.name) !== -1; }); jitsu.prompt.addProperties(base, descriptors, function createPackage (err, result) { if (err) { // // TODO: Something here... // jitsu.log.error('Unable to add properties to package description.'); jitsu.log.error(util.inspect(err)); return callback(err); } var isUnique = descriptors.filter(function (descriptor) { return descriptor.unique; }).length; result.scripts = result.scripts || {}; if (isUnique) { package.available(result, dir, callback, createPackage); } else { callback(null, result); // TODO } }); }