UNPKG

canihaz

Version:

canihaz allows you to lazy install npm modules because not every dependency is needed.

390 lines (334 loc) 13.2 kB
'use strict'; /**! * canihaz.js: Optional, Async installation of npm packages. No more bloated * dependencies list that is not used every time. * * @copyright (c) 2012 observe.it (observe.it) <opensource@observe.it> * MIT Licensed */ /** * Native modules. */ var queue = new(require('events').EventEmitter) , exec = require('child_process').exec , path = require('path') , fs = require('fs'); /** * Third party modules. */ var mkdirp = require('mkdirp') , semver = require('semver') , which = require('which') , npm; // Find the location of the npm installation so we can spawn it try { npm = which.sync('npm'); } catch (e) { npm = 'npm'; } // Set the maxListeners to 100 as we might have 10 concurrent installation // requests. queue.setMaxListeners(100); /** * Simple helper function for debugging, if no process.env.DEBUG is set it will * degrade to emiting the events over the queue. * * @type {Function} */ var debug = process.env && process.env.DEBUG && ~process.env.DEBUG.indexOf('canihaz') ? console.log.bind(console, '[debug::canihaz]') : function debug(line) { queue.emit('canihaz debug', line); }; /** * Require all the things. * * Options: * - dot: Should we create a special dot folder for storage? This is saved in * the home directory of the user. Should be a string. * - home: The location of the home folders, as this is operating system * specific or you might want to override this if you want to store the dot * folder in a different location. Should be string. * - location: The location of the package.json that we need to parse and read out * the possible dependencies for lazy installation. * - key: Which property should we scan for the optional dependencies? This * allows you to also lazy install optionalDependencies for example. * - resuse: Should expose an interface for reusing node_modules that are * bundled in dependencies? This allows you to cut down on dependencies even * more. * * @param {Object} config * @api public */ module.exports = function canihazitplxktnxilubai(config) { config = config || {}; var configuration = { dot: config.dot || false , home: config.home || process.env.HOME || process.env.USERPROFILE , location: config.location || path.resolve(__dirname, '../..') , key: config.key || 'canihaz' , reuse: false // will be used later }; // The installation location, this will be replaced if the dot folder option // is set. configuration.installation = config.installation || configuration.location; // For legacy reasons we accept a string as argument which would be used as // dot folder. if (typeof config === 'string') configuration.dot = config; if (configuration.dot) { configuration.installation = path.resolve( configuration.home , '.'+ configuration.dot ); } // Parse out the dependencies from the package.json, so we could expose them // to our exporting statement. var dependencies = {}; try { dependencies = require( path.join(configuration.location, 'package.json') )[configuration.key]; debug('found dependencies in the package.json: '+ JSON.stringify(dependencies, null, 2)); } catch (e) {} /** * Install all the things. * * @param {String} name * @param {String} version * @param {Function} callback */ function has(name, version, cb) { var regular = typeof name === 'string' && typeof version === 'string' && typeof cb === 'function' && version === '' || semver.satisfies(version); if (!regular) { var args = Array.prototype.slice.call(arguments, 0) , fetched = {} , order = [] , error; cb = args.pop(); debug('doing a multi installation for :'+ JSON.stringify(args)); return args.forEach(function install(lib) { var name, version, checker; if (typeof lib === 'object') { name = lib.name; version = lib.version; } else { name = lib; version = ''; } // Add module name to the order array, so we can return the libraries // back in order to the callback order.push(name); // Check if we have a `native` version, so we can actually take // advantage of our caching system. But if we have a version we have to // degrade our uncached requiretron3000 if (name in has && !version) { checker = has[name]; } else { checker = requiretron3000.bind(undefined, configuration, name, version); } checker(function fetching(err, library) { fetched[name] = library; // If we have an Error, save it, but don't override it always have the // same, first error stored if (err && !error) error = err; if (Object.keys(fetched).length < args.length) return; var applies = [error]; // Add the libraries back in the same order as supplied in the // arguments. order.forEach(function add(item) { applies.push(fetched[item]); }); if (error) debug('failed to process multiple dependencies due to '+ error); cb.apply(cb, applies); }); }); } // Defer the call the requiretron3000 and make it happen debug('installing '+ name +'@'+ version); return requiretron3000(configuration, name, version, cb); } // Expose the dependencies in a better API format by adding them to the // returned function. This allows you to do: // // canihaz.modulename(function () { .. }); // // instead of doing: // // canihaz('modulename', '0.0.x', function () { .. }); // // So it has much better API and does automatic version resolution which can // be managed from one single location, and that is the package.json file of // the module that uses this module. Object.keys(dependencies).forEach(function iterate(name) { var version = dependencies[name] , cache; Object.defineProperty(has, name, { value: function findPackage(callback) { if (cache) return process.nextTick(function loadCacheAsync() { debug('cache hit for '+ name +'@'+ version); callback(undefined, cache); }); requiretron3000(configuration, name, version, function installed(err, pkg) { if (pkg && !err) cache = pkg; callback(err, pkg); }); } }); }); return has; }; /** * Expose the queue so we can do some testing against it. * * @type {EventEmitter} * @api private */ module.exports.queue = queue; /** * Proudly introducing the requiretron3000, the brand spanking new require system * that automatically installs dependencies that are not installed. * * @param {Object} config the configuration object of the module * @param {String} name the name of the module that needs to be installed * @param {String} version the version of the module * @param {Function} cb * @api private */ function requiretron3000(config, name, version, cb) { var pkgversion , x; // Try to require the module, to see if it's installed, maybe globally or what // ever.. somewhere else.. but in order to check if it satisfies the version // number we need to find the path, parse the package.json and see if it's // a correct match try { x = require.resolve(name); pkgversion = require(path.join( // Resolve returns the full path of where the entry point of // a module is so we need to find the `root` folder of the module x.slice(0, x.lastIndexOf(name)) , 'node_modules', name, 'package.json' )).version; // Make sure it satisfies the semver, if it does, require all the things as // we have a match, whoop whoop if (!version || semver.satisfies(pkgversion, version)) { return cb(undefined, require(name)); } } catch (e) {} // Oh, okay, maybe it's not installed there ;( NEXT! try { x = path.join(config.installation, 'node_modules', name); pkgversion = require(path.join(x, 'package.json')).version; if (!version || semver.satisfies(pkgversion, version)) { return cb(undefined, require(x)); } } catch (e) {} // Well, fuck, not installed there either ;9 so we should install it after we // have ensured that we have an installation directory available for this // module ensure(config.installation, function ensured(err) { if (err) return cb(err); install(config.installation, name, version || '', cb); }); } /** * Install the package in the given location * * @param {String} cwd current working directory where we spawn the child * @param {String} name name of the npm module to install * @param {String} version version number or supply an empty string * @param {Function} cb callback, all done <3 * @api private */ function install(cwd, name, version, cb) { var noRegistry; if (version) { // * installations is basically just an installation without a version, so // just ignore this version if (version === '*') version = ''; // If it doesn't get through the `validRange` it's probably not a Semver but // an url like git@github or a tarball if (version && semver.validRange(version)) { // If the version number contains spaces we need to wrap it in double quotes // as we are most likely dealing with version range installation that contains // silly shit such as: >=0.1.0 <0.2.0 if (version && /\s/.test(version)) version = '"'+ version +'"'; if (version) version = '@'+ version; } else if (version) { // This is probably not a valid version but a tarbal or git url, we cannot // override the name variable or we cannot require it again so we have to // use a other variable to change the "name" that we want to install noRegistry = version; version = ''; } } var installation = (noRegistry || name) + version; // Check if we already have an installation running for this module, if so, we // are gonna register a listener for the installation if (queue.listeners(installation).length) return queue.once(installation, cb); // No fresh installations for this module, so add a listener so we can flag // the queue that we have installations waiting for this module. queue.once(installation, cb); // Spawn the npm binary and have it do it's magic, this way it's using the // users configured defaults and we don't have to worry about that. If this // installation doesn't work, the regular installation wouldn't have worked // either. Wrapping in qoutes is required for Windows compatibility. var command = '"'+ npm+'"'; // Please note that we need to add command flags BEFORE add the install // command, if it's appended behind, it could cause installations to fail. // I've seen this happen with git based installations command += ' --parseable'; // Parsable output command += ' --no-save'; // Prevent npm > @5 to install dependencies. command += ' install '+ installation.trim(); debug('spawning npm: '+ command + ', in cwd: '+ cwd); exec(command , { cwd: cwd // Where should we spawn the installation } , function done(err, stdout, stderr) { var library; try { library = require(path.join(cwd, 'node_modules', name)); } catch(e) { // only set the error if we don't have an error all ready if (!err) err = e; } if (err) debug('installation of '+ name + ' generated an issue: '+ err); if (stderr) debug('installation of '+ name + ' generated stderr: '+ stderr); queue.emit(installation, err, library); } ); } /** * Ensure that the directory exists, if not create it.. * * @param {String} dir * @param {Function} cb * @api private */ function ensure(dir, cb) { // npm has a really funky way of figuring out where to put the dependencies, // if the folder doesn't have a `node_modules` folder it tries to figure out // if there is a node_modules folder in the parent and it will attempt to // install the module there. This will cause a miss match for us we because we // are requiring modules from an absolute path, not the npm magical path. To // combat this behaviour we need to make sure that we also create the // `node_modules` folder in the given directory. So npm knows that we really, // REALLY want to install it in the `cwd` that we gave the spawned process dir = path.join(dir, 'node_modules'); // If the fs.stat returns an error we are pretty sure that it doesn't exist, // so we should create it fs.stat(dir, function exists(err, stat) { if (err) return mkdirp(dir, function mkdir(err) { if (err) return cb(new Error('Failed to create or locate the path')); debug('successfully generated the dependencies directory ' + dir); cb(); }); if (!stat.isDirectory()) { debug('we cant install dependencies in the supplied path, its not a folder'); return cb(new Error('The given path should be a directory')); } cb(); }); }