@solum/cli
Version:
š§ DevOps toolchain for multi-repos
681 lines (649 loc) ⢠18 kB
JavaScript
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,
})