UNPKG

develarms

Version:

Alternative devDependencies resolver that doesn't waste disk space

339 lines (297 loc) 9.17 kB
#!/usr/bin/env node /*! * develarms * @author Satoshi Soma (https://github.com/amekusa) */ import { readFileSync, writeFileSync } from 'node:fs'; import process from 'node:process'; import cp from 'node:child_process'; import semver from 'semver'; import { Command, Option } from 'commander'; const version = '2.3.0'; let options = { dryRun: false, verbose: false, config: 'package.json', configKey: 'develarms', }; let config; async function main() { let app = new Command(); app.name('develarms') .version(version) .description('Alternative `devDependencies` resolver') .option('-c, --config <file>', 'config file', 'package.json') .option('-k, --config-key <key>', 'key of config object', 'develarms') .option('-n, --dry-run', 'do not actually perform the operation') .option('-v, --verbose', 'output detailed messages for debug') .hook('preAction', (app, cmd) => { options = Object.assign(options, app.opts()); debug('options:', options); debug('command options:', cmd.opts()); config = new Config(options.config); config.load(); debug(`config loaded:`, config.data); }); app.command('list') .alias('ls') .description('list dependencies') .option('--json', 'output in JSON format') .action(list); app.command('install') .alias('i') .alias('add') .description('install dependencies') .argument('[packages...]', '(optional) packages to add to deps') .option('-g, --global', 'install globally') .option('-F, --force', 'skip checking existing pkgs and install unconditionally') .action(install); app.command('uninstall') .alias('rm') .description('uninstall dependencies') .argument('<packages...>', 'packages to remove from deps') .action(uninstall); app.command('upgrade') .alias('up') .alias('bump') .description('upgrade dependencies') .argument('[packages...]', '(optional) packages to upgrade. if omitted, all the deps will be upgraded') .addOption(new Option('-t, --target <target>', 'which version the deps should upgrade to').choices(['major', 'minor', 'patch']).default('major')) .action(upgrade); await app.parseAsync(); } async function list() { let opts = this.opts(); let deps = config.get(options.configKey, {}); if (opts.json) return log(JSON.stringify(deps)); for (let key in deps) log(`${key}:`, deps[key]); } async function install() { let pkgs = this.args; let opts = this.opts(); // Add new packages to deps if (pkgs && pkgs.length) { let installs = {}; let tasks = []; for (let i = 0; i < pkgs.length; i++) { tasks.push(pkgInfo(pkgs[i]).then(data => { installs[data.name] = '^' + data.version; }).catch(error)); } await Promise.all(tasks); config.assign({ [options.configKey]: installs }).sync().save(); } // Populate deps to resolve let deps = config.get(options.configKey, {}); pkgs = Object.keys(deps); if (!pkgs.length) return log(`No dependencies.`); log(`Resolving dependencies:`, deps, `...`); // Populate existing packages let exist = {}; if (opts.force) { exist.local = {}; exist.global = {}; } else { await Promise.all([ // Locals exec(`npm ls ${pkgs.join(' ')} --json --depth=0`).then(out => { exist.local = JSON.parse(out).dependencies || {}; }).catch(() => { exist.local = {}; }), // Globals exec(`npm ls -g ${pkgs.join(' ')} --json --depth=0`).then(out => { exist.global = JSON.parse(out).dependencies || {}; }).catch(() => { exist.global = {}; }) ]); log(`Existing dependencies:`, exist); } // Calculate which packages should be installed let installs = []; LOOP1: for (let i in deps) { let item = deps[i]; if (typeof item == 'string') item = { version: item }; // Support one-liner if (!item.version) { warn(`ignored dependency '${i}' due to a lack of 'version' info`); continue; } for (let scope in exist) { if (i in exist[scope] && semver.satisfies(exist[scope][i].version, item.version)) { log(`You already have a sufficient version of '${i}' in ${scope}.`); log(` - Existing: ${exist[scope][i].version}`); log(` - Required: ${item.version}`); continue LOOP1; } } installs.push(i+'@'+item.version); } if (!installs.length) return log(`Nothing to install.`); // Install packages log(`Installing ${installs.join(', ')} ...`); let args = ''; if (options.dryRun) args += ' --dry-run'; if (opts.global) args += ' --global'; return exec(`npm install --no-save${args} ${installs.join(' ')}`).then(() => { log(`Installation complete.`); log(`All the dependencies have been resolved.`); }).catch(error); } async function uninstall(pkgs) { let deps = config.get(options.configKey, {}); let uninstalls = []; for (let item of pkgs) { if (item in deps) uninstalls.push(item); else warn(`ignored '${item}' since it is not listed in the config`); } if (!uninstalls.length) return log(`Nothing to uninstall.`); let args = ''; if (options.dryRun) args += ' --dry-run'; return exec(`npm uninstall --no-save${args} ${uninstalls.join(' ')}`).then(() => { let deps = config.sync().get(options.configKey, {}); for (let item of uninstalls) { if (item in deps) delete deps[item]; } if (!Object.keys(deps).length) config.remove(options.configKey); config.save(); log(`Uninstallation complete.`); }).catch(error); } async function upgrade() { let pkgs = this.args; let opts = this.opts(); let deps = config.get(options.configKey, {}); let upgrades = {}; if (pkgs && pkgs.length) { for (let i = 0; i < pkgs.length; i++) { if (pkgs[i] in deps) upgrades[pkgs[i]] = deps[pkgs[i]]; else warn(`ignored '${pkgs[i]}' since it is not listed in the config`); } } else Object.assign(upgrades, deps); let tasks = []; for (let i in upgrades) { let v = semver.coerce(upgrades[i]); let suffix = ''; switch (opts.target) { case 'minor': suffix = `@${v.major}`; break; case 'patch': suffix = `@${v.major}.${v.minor}`; break; } tasks.push(pkgInfo(i + suffix).then(data => { upgrades[i] = '^' + data.version; log(`${i}: ${deps[i]} => ${upgrades[i]}`); }).catch(error)); } return Promise.all(tasks).then(() => { config.sync().assign({ [options.configKey]: upgrades }).save(); log(`Upgraded the dependencies.`); log(`Run 'develarms i' to install.`); }); } // ---- Utils -------- * function error(...msg) { console.error(`[${RED('ERROR')}]`, ...msg); process.exit(1); } function warn(...msg) { console.warn(`[${ylw('WARN')}]`, ...msg); } function debug(...msg) { if (options.verbose) console.debug(`[${cyn('INFO')}]`, ...msg); } function dryRun(fn, ...msg) { if (options.dryRun) console.debug(`[${mag('DRYRUN')}]`, ...msg); else return fn(); } function log(...msg) { console.log(...msg); } function exec(cmd) { debug(`CMD:`, cmd); return new Promise((resolve, reject) => { cp.exec(cmd, {}, function(err, out) { if (!err) return resolve(out); return reject(err); }); }); } function merge(x, y, recurse = 8) { if (recurse && x && y && typeof x == 'object' && typeof y == 'object' && !Array.isArray(x) && !Array.isArray(y)) { for (let key in y) x[key] = merge(x[key], y[key], recurse - 1); } else return y; return x; } function write(file, content) { return dryRun(() => { return writeFileSync(file, content); }, `write: '${file}'\n-----CONTENT-----\n${content}\n=======EOF=======`); } function pkgInfo(pkg, props = 'name version') { return exec(`npm view --json ${pkg} ${props}`).then(resp => { let data = JSON.parse(resp); if (typeof data != 'object') throw `invalid response from npm`; if (Array.isArray(data)) data = data[data.length - 1]; return data; }); } class Config { constructor(file) { this.file = file; this.data = null; } load() { let loaded; try { loaded = JSON.parse(readFileSync(this.file)); } catch (e) { error(e.message) } this.data = loaded; return this; } has(key) { return this.data && (key in this.data); } get(key, fallback = undefined) { return this.has(key) ? this.data[key] : fallback; } remove(key, fallback = undefined) { let r = fallback; if (this.has(key)) { r = this.data[key]; delete this.data[key]; } return r; } set(key, value) { this.data[key] = value; return this; } assign(data) { this.data = merge(this.data, data, 8); return this; } sync() { let _data = this.data; this.load(); this.assign(_data); return this; } save() { try { write(this.file, JSON.stringify(this.data, null, 2)); } catch (e) { error(e.message) } return this; } } // ---- Color Utils -------- * const ESC = '\x1b['; const RST = `${ESC}0m`; function red(str) { return `${ESC}0;31m${str}${RST}` } function RED(str) { return `${ESC}1;31m${str}${RST}` } function grn(str) { return `${ESC}0;32m${str}${RST}` } function GRN(str) { return `${ESC}1;32m${str}${RST}` } function ylw(str) { return `${ESC}0;33m${str}${RST}` } function YLW(str) { return `${ESC}1;33m${str}${RST}` } function mag(str) { return `${ESC}0;35m${str}${RST}` } function MAG(str) { return `${ESC}1;35m${str}${RST}` } function cyn(str) { return `${ESC}0;36m${str}${RST}` } function CYN(str) { return `${ESC}1;36m${str}${RST}` } main();