UNPKG

@solum/cli

Version:

🧊 DevOps toolchain for multi-repos

681 lines (649 loc) • 18 kB
#!/usr/bin/env node const _ = require('rubico') const fse = require('fs-extra') const nodePath = require('path') const execa = require('execa') const pRetry = require('p-retry') const semver = require('semver') const columnify = require('columnify') const ProgressBar = require('progress') const git = require('./git') const prettifyJSON = x => JSON.stringify(x, null, 2) const USAGE = [ 'usage: slm [--version] [-v] [--help] [-h] <command> [<args>]', '', 'commands:', '', ' list, ls list all modules', ' s[tatus] git status on all modules', ' ch[eckout] <branch> conservative git checkout branch for all modules', ' fetch fetch remotes for all modules', ' merge conservative git merge current branch with upstream for all modules', ' pull conservative git pull upstream into current branch for all modules', ' push conservative git push current branch to upstream for all modules', ' dist <major|minor|patch> version bump && publish all modules, skip unchanged modules', ' clean git clean -fxdq for all modules', ' i[nstall] install all module dependencies into module local node_modules, does not create package-lock', ' link, ln symlink all modules selectively into all modules\' local node_modules', // ' test <path|module> run module tests defined by test script or mocha at module root', // ' run <script> <path|module> run module script', ].join('\n') // path => package const getPackage = _.flow( x => nodePath.join(x, 'package.json'), fse.readFile, _.toString, JSON.parse, ) // path => package_lock const getPackageLock = _.flow( x => nodePath.join(x, 'package-lock.json'), fse.readFile, _.toString, JSON.parse, ) // { path, package } => status const writePackage = _.flow( _.diverge([ _.flow(_.get('path'), x => nodePath.join(x, 'package.json')), _.flow(_.get('package'), prettifyJSON, x => `${x}\n`), ]), _.spread(fse.writeFile), ) const writePackageLock = _.flow( _.diverge([ _.flow(_.get('path'), x => nodePath.join(x, 'package-lock.json')), _.flow(_.get('package_lock'), prettifyJSON, x => `${x}\n`), ]), _.spread(fse.writeFile), ) // { paths } => [module_path] const findModules = _.flow( _.get('paths'), _.map(_.diverge([ _.id, _.tryCatch(x => fse.readdir(x), _.noop), ])), _.filter(_.flow(_.get(1), _.exists)), _.map( _.switch( _.and( _.flow(_.get(1), _.has('.git')), _.flow(_.get(1), _.has('package.json')), ), _.get(0), x => findModules({ paths: _.map.sync(y => nodePath.join(x[0], y))(x[1]), }), ), ), _.flattenAll, _.uniq, ) // home_path => string => string const replaceHome = home => _.replaceOne(home, '~') const checkSlmPath = _.switch( _.get('SLM_PATH')(process.env), _.noop, () => console.warn( 'WARNING: SLM_PATH not set, using ~ (this could be prohibitively slow)', ), ) // { command } => status const notACommand = _.flow( _.get('command'), x => `${x} is not a command. See slm --help.`, console.log, ) // module_path => info const getInfo = _.flow( _.diverge({ path: _.id, fmt_path: replaceHome(process.env.HOME), repo: _.tryCatch(git.getRepo, console.error), origin_url: _.tryCatch(git.getRemoteUrl('origin'), console.error), tags: _.tryCatch(git.getTags, console.error), branch: _.tryCatch(git.getCurrentBranchName, console.error), status: _.tryCatch(git.status, console.error), ahead_behind: _.tryCatch(git.getCurrentAheadBehind, console.error), package: _.tryCatch(getPackage, _.noop), package_lock: _.tryCatch(getPackageLock, _.noop), }), _.put( ['module', _.get('package.name')], ['version', _.get('package.version')], ['vmodule', _.flow( _.diverge([ _.get('package.name'), _.get('package.version'), ]), _.join('@'), )], ['semver', _.flow( _.get('package.version'), x => `v${x}`, )], ), ) // { path } => info const refreshInfo = _.flow(_.get('path'), getInfo) // { paths } => () const list = _.flow( findModules, _.map(_.flow(getInfo, _.put(['path', _.get('fmt_path')]))), _.sortBy('module', 1), x => columnify(x, { columns: ['module', 'version', 'path', 'branch'], minWidth: 20, }), console.log, ) // { ahead, behind } => string const fmtAheadBehind = x => { let y = [] if (x.ahead > 0) y.push(`ahead ${x.ahead}`) if (x.behind > 0) y.push(`behind ${x.behind}`) if (_.isEmpty(y)) return '' return `[${_.join(', ')(y)}]` } // [file_path, status_flag] => string const fmtStatus = x => `\n${x[1]} ${x[0]}` // { path, branch, ahead_behind, status: [[file_path, status_flag]] => string const fmtRepo = _.flow.sync( _.diverge.sync([ _.flow.sync(_.get('path'), replaceHome(process.env.HOME)), _.get('branch'), _.flow.sync(_.get('ahead_behind', {}), fmtAheadBehind), _.flow.sync(_.get('status', []), _.map.sync(fmtStatus), _.join('')), ]), _.join(' '), ) // { paths } => [git_status] const status = _.flow( findModules, _.map(getInfo), _.filter( _.or( _.flow(_.get('ahead_behind'), _.any(_.not(_.eq(_.id, 0)))), _.flow(_.get('status'), _.not(_.isEmpty)), ), ), _.map(_.flow(fmtRepo, console.log)), ) // { paths, argv } => () const checkout = x => { const branch = _.get(['argv', 0])(x) return _.flow( findModules, _.map(getInfo), _.map(_.tryCatch( _.effect(git.checkout(branch)), _.noop, )), _.filter(_.exists), _.map(_.flow( refreshInfo, _.diverge([_.get('module'), _.get('branch')]), x => `switched to branch ${x[1]} ${x[0]}`, console.log, )), )(x) } const spaces = l => { let s = '' for (let i = 0; i < l; i++) s += ' ' return s } const neatLogCollection = (x, lineStart = '') => { if (x.length === 0) return process.stdout.write(lineStart) process.stdout.write(`${x[0]}`) let curLineLength = lineStart.length + x[0].length for (const s of x.slice(1)) { if (curLineLength + s.length > process.stdout.columns - 20) { const nextLine = `\n${spaces(lineStart.length)}${s}` process.stdout.write(nextLine) curLineLength = nextLine.length } else { process.stdout.write(` ${s}`) curLineLength += s.length } } process.stdout.write('\n\n') } // { paths } => [git_status] const fetchAll = _.flow( findModules, _.map(getInfo), x => { const bar = new ProgressBar(':bar :current/:total modules', { head: '~', complete: '~', incomplete: ' ', total: x.length, width: process.stdout.columns - 30, }) neatLogCollection( _.map.sync(_.get('origin_url'))(x), `fetching (${x.length}) `, ) bar.render({ module: '' }) return _.map(_.flow( _.tryCatch( x => execa('git', [ `--git-dir=${nodePath.join(x.path, '.git')}`, `--work-tree=${x.path}`, 'fetch', '--all', ]), e => bar.interrupt(`fetch failed: ${e}`), ), () => bar.tick(), ))(x) }, ) // { paths } => () const merge = _.flow( findModules, _.map(getInfo), _.map(_.switch( _.and( _.flow(_.get('branch'), _.split('...'), _.size, _.eq(_.id, 2)), _.flow(_.get('status'), _.isEmpty), _.flow(_.get('ahead_behind'), _.gt(_.get('behind'), 0)), _.flow(_.get('ahead_behind'), _.eq(_.get('ahead'), 0)), ), _.flow( _.tryCatch( _.effect(git.merge), e => console.error(`merge failed: ${e}`), ), _.diverge([_.get('module'), _.get('branch')]), _.join(' '), x => `merged ${x}`, console.log, ), _.noop, )), ) // { paths, argv } => () const push = input => { const isDryRun = _.or.sync(_.has('--dry-run'), _.has('-n'))(input.argv) return _.flow( findModules, _.map(getInfo), _.filter(_.and( _.flow(_.get('branch'), _.split('...'), _.size, _.eq(_.id, 2)), _.flow(_.get('ahead_behind'), _.gt(_.get('ahead'), 0)), _.flow(_.get('ahead_behind'), _.eq(_.get('behind'), 0)), )), _.effect(x => neatLogCollection( _.map.sync(_.flow.sync( _.diverge.sync([ _.get('origin_url'), _.flow.sync(_.get('branch'), _.split('...'), _.get(0)), ]), x => `${x[0]}(${x[1]})`, ))(x), `pushing (${x.length}) `, )), x => { const bar = new ProgressBar(':bar :current/:total modules', { head: '~', complete: '~', incomplete: ' ', total: x.length, width: process.stdout.columns - 30, }) if (x.length > 0) bar.render() return _.map(_.flow( _.switch(isDryRun, _.id, _.tryCatch( _.effect(x => execa('git', [ `--git-dir=${nodePath.join(x.path, '.git')}`, `--work-tree=${x.path}`, 'push', ])), console.error, )), _.effect(() => bar.tick()), ))(x) }, )(input) } // bump => { path, package, package_lock, is_dry_run, version_bump } => { ... } const bumpVersion = _.flow( _.put(['next_version', _.flow( _.diverge([ _.get('package.version'), _.get('version_bump'), ]), _.spread(semver.inc), )]), _.put( ['vmodule', _.flow( _.diverge([ _.get('package.name'), _.get('next_version'), ]), _.join('@'), )], ['package', x => _.flow( _.get('package'), _.put(['version', _.get('next_version')(x)]), )(x)], ['package_lock', _.switch( _.has('package_lock'), x => _.flow( _.get('package_lock'), _.put(['version', _.get('next_version')(x)]), )(x), _.noop, )], ), _.switch( _.get('is_dry_run'), _.id, _.effect(_.series( writePackage, _.switch(_.has('package_lock'), writePackageLock, _.noop), _.tryCatch( x => execa('git', [ `--git-dir=${nodePath.join(x.path, '.git')}`, `--work-tree=${x.path}`, 'add', '.', ]), console.error, ), _.tryCatch( x => execa('git', [ `--git-dir=${nodePath.join(x.path, '.git')}`, `--work-tree=${x.path}`, 'commit', '-m', x.next_version, ]), console.error, ), _.tryCatch( x => execa('git', [ `--git-dir=${nodePath.join(x.path, '.git')}`, `--work-tree=${x.path}`, 'tag', `v${x.next_version}`, ]), console.error, ), _.tryCatch( x => execa('git', [ `--git-dir=${nodePath.join(x.path, '.git')}`, `--work-tree=${x.path}`, 'push', '-f', 'origin', `v${x.next_version}`, ]), console.error, ), )), ), ) // { paths, argv } => () const dist = input => { const versionBump = _.flow.sync( _.get(0), _.switch.sync( _.member(['major', 'minor', 'patch']), _.id, _.noop, ), )(input.argv) if (!versionBump) return console.error('Error: <major|minor|patch> required') const isDryRun = _.or.sync(_.has('--dry-run'), _.has('-n'))(input.argv) return _.flow( findModules, _.map(getInfo), _.filter(_.and( _.eq(_.get('branch'), 'master...origin/master'), x => _.switch( _.flow( _.get('tags', []), _.has(_.get('semver')(x)), ), _.flow( _.get('path'), git.descendantOfHead(_.get('semver')(x)), ), false, )(x), _.flow(_.get('status'), _.isEmpty), _.flow(_.get('ahead_behind'), _.eq(_.get('behind'), 0)), _.flow(_.get('ahead_behind'), _.gte(_.get('ahead'), 0)), _.not(_.get('package.nopack')), )), _.effect(x => neatLogCollection( _.map.sync(_.get('vmodule'))(x), `bumping ${versionBump} & publishing (${x.length}) `, )), _.map(_.flow( _.put( ['version_bump', versionBump], ['is_dry_run', isDryRun], ), bumpVersion, )), async x => { const bar = new ProgressBar(':bar :current/:total modules', { head: '~', complete: '~', incomplete: ' ', total: x.length, width: process.stdout.columns - 30, }) if (x.length > 0) bar.render() return _.map(_.flow( _.switch( _.get('is_dry_run'), _.flow( _.get('vmodule'), x => `+ ${x}`, ), _.tryCatch( _.flow( x => pRetry(() => execa('npm', ['publish', x.path]), { onFailedAttempt: e => bar.interrupt( `publish failed for ${x.vmodule}, retrying ${e.attemptNumber - 1}/5`, ), retries: 5, }), _.get('stdout'), ), e => bar.interrupt(`giving up publish: ${e}`) ), ), _.effect(() => bar.tick()), ))(x) }, _.map(x => console.log(x)), )(input) } // { paths } => () const clean = _.flow( findModules, _.map(getInfo), _.map(_.flow( _.effect(_.tryCatch( x => execa('git', [ `--git-dir=${nodePath.join(x.path, '.git')}`, `--work-tree=${x.path}`, 'clean', '-fxdq', ]), e => console.error(`clean failed: ${e}`), )), x => `cleaned ${x.path}`, console.log, )), ) // { paths, argv } => () const install = input => { const isDryRun = _.or.sync(_.has('--dry-run'), _.has('-n'))(input.argv) return _.flow( findModules, _.map(getInfo), _.effect(x => neatLogCollection( _.map.sync(_.get('module'))(x), `installing (${x.length}) `, )), x => { const bar = new ProgressBar(':bar :current/:total modules', { head: '~', complete: '~', incomplete: ' ', total: x.length, width: process.stdout.columns - 30, }) if (x.length > 0) bar.render() return _.map(_.flow( _.switch(isDryRun, _.id, _.effect(_.tryCatch( x => execa('npm', [ 'i', `--cache=${nodePath.join(x.path, '.npm')}`, '-C', x.path, '--no-package-lock', ]), e => bar.interrupt(`install failed: ${e}`), ))), () => bar.tick(), ))(x) }, )(input) } // { paths, argv } => () const link = input => { const isDryRun = _.or.sync(_.has('--dry-run'), _.has('-n'))(input.argv) return _.flow( findModules, _.map(getInfo), x => { const moduleStore = _.reduce.sync( (a, b) => { a[b.module] = b; return a }, {}, )(x) return _.map(_.put(['symlinks', _.flow( _.diverge([ _.get('package.dependencies', {}), _.get('package.devDependencies', {}), ]), _.map(Object.keys), _.flatten, _.map(_.lookup(moduleStore)), _.filter(_.exists), )]))(x) }, _.filter(_.flow(_.get('symlinks'), _.gt(_.size, 0))), _.switch(isDryRun, _.id, _.effect(_.map(mod => _.flow( _.get('symlinks'), _.map(_.flow( _.diverge([ _.get('path'), _.flow( _.diverge([_.get('path')(mod), 'node_modules', _.get('module')]), _.spread(nodePath.join), ), ]), _.effect(_.tryCatch( _.flow(_.get(1), fse.remove), e => console.error(`remove failed: ${e}`), )), _.effect(_.tryCatch( _.spread(fse.ensureSymlink), e => console.error(`symlink failed: ${e}`), )), )), )(mod)))), _.map(_.flow( x => { let y = '' const [first, ...rest] = x.symlinks y += `ā”Œ ${first.fmt_path} (${first.module})` for (const a of rest) { y += `\nā”œ ${a.fmt_path} (${a.module})` } y += `\n└┄► ${nodePath.join(x.fmt_path, 'node_modules')}` return y }, console.log, )), )(input) } const main = _.flow( _.switch( _.or( _.flow(_.get('command'), _.not(_.exists)), _.eq(_.get('command'), '--help'), _.eq(_.get('command'), '-h'), ), _.flow(_.get('usage'), console.log), _.or( _.eq(_.get('command'), '--version'), _.eq(_.get('command'), '-v'), ), _.flow( _.get('__dirname'), getPackage, _.get('version'), x => `v${x}`, console.log, ), _.or( _.eq(_.get('command'), 'list'), _.eq(_.get('command'), 'ls'), ), _.series(checkSlmPath, list), _.or( _.eq(_.get('command'), 'status'), _.eq(_.get('command'), 's'), ), _.series(checkSlmPath, status), _.or( _.eq(_.get('command'), 'checkout'), _.eq(_.get('command'), 'ch'), ), _.series(checkSlmPath, checkout), _.eq(_.get('command'), 'fetch'), _.series(checkSlmPath, fetchAll), _.eq(_.get('command'), 'merge'), _.series(checkSlmPath, merge), _.eq(_.get('command'), 'pull'), _.series(fetchAll, merge), _.or( _.eq(_.get('command'), 'push'), _.eq(_.get('command'), 'p'), ), _.series(checkSlmPath, push), _.eq(_.get('command'), 'dist'), _.series(checkSlmPath, dist), _.eq(_.get('command'), 'clean'), _.series(checkSlmPath, clean), _.or( _.eq(_.get('command'), 'install'), _.eq(_.get('command'), 'i'), ), _.series(checkSlmPath, install), _.or( _.eq(_.get('command'), 'link'), _.eq(_.get('command'), 'ln'), ), _.series(checkSlmPath, link), notACommand, ), ) main({ paths: _.split(':')(process.env.SLM_PATH || process.env.HOME), command: _.get(2)(process.argv), argv: _.slice(3)(process.argv), usage: USAGE, __dirname, })