UNPKG

counsel

Version:

the end of boilerplate. automatically bake structure, opinions, and business rules into projects

337 lines (320 loc) 10.6 kB
import { CheckError, CounselError } from './errors' import { Context, // eslint-disable-line no-unused-vars CreateContextOptions, // eslint-disable-line no-unused-vars ContextWithRules, // eslint-disable-line no-unused-vars Dependency, // eslint-disable-line no-unused-vars Rule, // eslint-disable-line no-unused-vars WithMigrations, // eslint-disable-line no-unused-vars InstallDependenciesOptions // eslint-disable-line no-unused-vars } from './interfaces' import { createLogger, withEmoji } from './logger' import { dependencyToString, filterToInstallPackages, fromNameVersionObject } from './dependencies' import * as fs from 'fs-extra' import * as path from 'path' import * as project from './project' import * as rules from './rules' // eslint-disable-line no-unused-vars (compile time only import) import bluebird from 'bluebird' import execa from 'execa' import meow = require('meow') // eslint-disable-line no-unused-vars const Prompt = require('prompt-checkbox') /** * sugar function for calling plan + migrate */ export const apply = async (opts: ContextWithRules) => { const { ctx: { assumeYes, logger } } = opts logger.verbose('counsel::apply::start') logger.info(withEmoji('planning project migration', '🗓')) let migrations = await plan(opts) const rules = toRulesWithPlans(opts.rules) const report = rules .map((rule, i) => ({ rule, migration: migrations[i] })) .reduce((agg, curr) => { const { rule: { dependencies = [], devDependencies = [], name }, migration } = curr return { ...agg, [name]: { dependencies: dependencies.map(dependencyToString).join(','), devDependencies: devDependencies.map(dependencyToString).join(','), migration: migration ? 'READY' : 'NONE' } } }, {}) if (migrations.length && process.stdout.isTTY && !assumeYes) { logger.info('migration plan:') console.table(report) } if (migrations.length && !assumeYes) { const prompt = new Prompt({ name: 'rules', message: 'select the rules to be applied:', radio: true, choices: Object.keys(report), default: Object.keys(report) }) var choices: string[] = await prompt.run() // select migrations by plucking corresponding indexes where rule names match migrations = rules .map((rule, i) => { if (choices.includes(rule.name)) return migrations[i] return null }) .filter(Boolean) await prompt.ui.close() prompt.end(false) } logger.info(withEmoji('migrating project', '🔄')) const res = await migrate({ ...opts, migrations }) logger.verbose('counsel::apply::finish') return res } export const plan = async (opts: ContextWithRules) => { opts.ctx.logger.verbose('counsel::plan::start') const res = await bluebird.map(toRulesWithPlans(opts.rules), rule => Promise.resolve(rule.plan!({ ctx: opts.ctx, rule, fs, path })) ) opts.ctx.logger.verbose('counsel::plan::finish') return res.filter(Boolean) } /** * Migrate project by means of executing provided Migrations, generally * administering project-level side-effects */ export const migrate = async (opts: WithMigrations) => { const { migrations, ctx, rules } = opts opts.ctx.logger.verbose('counsel::migrate::start') if (!migrations.length) { opts.ctx.logger.info('counsel::migrate: no migrations') } const res = await bluebird.map(migrations.filter(Boolean), migration => Promise.resolve(migration!()) ) const [dependencies, devDependencies] = rules.reduce( ([dependencies, devDependencies], rule) => [ [...dependencies, ...(rule.dependencies || [])], [...devDependencies, ...(rule.devDependencies || [])] ], [[] as Dependency[], [] as Dependency[]] ) await migrateDependencies({ dependencies, ctx, development: false }) await migrateDependencies({ dependencies: devDependencies, ctx, development: true }) if (isTargetPackageDirty(ctx)) await writeTargetPackage(ctx) opts.ctx.logger.verbose('counsel::migrate::finish') return res } export const check = async (opts: ContextWithRules) => { opts.ctx.logger.verbose('counsel::check::start') const res = await bluebird.map(opts.rules.filter(rule => rule.check), rule => Promise.resolve(rule.check!({ ctx: opts.ctx, rule, fs, path })).catch( ({ message, stack }) => { throw new CheckError({ message, stack, rule }) } ) ) opts.ctx.logger.verbose('counsel::check::finish') return res } // support! export const createDefaultContext = async ( targetProjectDirname: string, options?: CreateContextOptions ) => { options = options || {} const { logLevel } = options const targetProjectPackageJsonFilename = path.resolve( targetProjectDirname, 'package.json' ) const targetProjectPackageJson = await fs.readJSON( targetProjectPackageJsonFilename ) const ctx: Context = { packageJsonPristine: { ...targetProjectPackageJson }, ctxKey: 'counsel', logger: createLogger({ logLevel }), packageJson: targetProjectPackageJson, packageJsonFilename: targetProjectPackageJsonFilename, projectDirname: targetProjectDirname } return ctx } export const getLocalctxFilename = async (targetProjectDirname?: string) => { for (const ext of ['ts', 'js']) { const basename = `.counsel.${ext}` const localctxFilename = path.join( targetProjectDirname || process.cwd(), basename ) const exists = await fs .lstat(localctxFilename) .then(() => true) .catch(() => false) if (exists) return localctxFilename } return null } export const importLocalctx = async (targetProjectDirname: string) => { const ctxFilename = await getLocalctxFilename(targetProjectDirname) if (!ctxFilename) return null if (ctxFilename.match(/\.ts$/)) { const transpileModule = require('typescript').transpileModule const tsSettingsContents = (await fs.readFile(ctxFilename)).toString() const commonJsCtx = transpileModule(tsSettingsContents, {}) try { const nodeEval = require('node-eval') const mod = nodeEval( commonJsCtx.outputText, path.join(targetProjectDirname, '.counsel.js') ) return mod } catch (err) { if (err.message && err.message.match(/Please pass/)) { throw new CounselError( [ 'failed to load configuration file. are all of your dependencies', 'installed?\n', `original error:\n\t${err.message}` ].join(' ') ) } throw err } } return require(ctxFilename) } export const init = async (logger: Context['logger']) => { const basename = '.counsel.ts' const ctxFilename = await getLocalctxFilename() if (ctxFilename) { return logger.warn(`ctx file already exists: ${ctxFilename}`) } const contents = await fs.readFile( path.join(__dirname, '.counsel-template.ts.template') ) await fs.writeFile(path.join(process.cwd(), basename), contents) logger.info(withEmoji(`ctx file ${basename} created successfully`, '⚙️')) } export const getPristineDependencies = (ctx: Context) => ctx.packageJsonPristine.dependencies || {} export const getPristineDevDependencies = (ctx: Context) => ctx.packageJsonPristine.devDependencies || {} export const loadProjectSettings = async ( targetProjectDirname: string, defaultctx: Context, cli?: meow.Result ) => { const localctx = await importLocalctx(targetProjectDirname) if (!localctx) return { ctx: defaultctx, rules: [] } const create = localctx.create || localctx if (typeof create !== 'function') { throw new Error('local counsel ctx must export create function') } return Promise.resolve(create({ ctx: defaultctx, rules: [], cli })) } /** * run `npm install` (or similar) for dev or std deps * @returns {boolean} indicator if packages were installed */ export const installDependencies = async (opts: InstallDependenciesOptions) => { const isDev = !!opts.development let bin = opts.packageManager let isYarnPackage if (!bin) { isYarnPackage = await project.isYarn(opts.ctx.projectDirname) bin = isYarnPackage ? 'yarn' : 'npm' } const installCmd = isYarnPackage ? 'add' : 'install' const toInstall = opts.dependencies let flag = '' if (isDev) flag = isYarnPackage ? '--dev' : '--save-dev' if (!toInstall.length) return false opts.ctx.logger.info( `installing ${isDev ? 'development' : ''} dependencies: ${toInstall .map(dependencyToString) .map(dependencyString => dependencyString.replace('@*', '')) .join(', ')}` ) try { const args = [ installCmd, flag, ...toInstall .map(dependencyToString) .map(dependencyString => dependencyString.replace('@*', '')) ].filter(Boolean) opts.ctx.logger.verbose(`${bin} ${args.join(' ')}`) await execa(bin, args, { cwd: opts.ctx.projectDirname, stdio: (process.env.NODE_ENV || '').match(/test/) ? 'ignore' : 'inherit' }) } catch (err) { if (err) return opts.ctx.logger.error(err) } return true } /** * Determines if the target project's package.json has changed (during rule application) */ export const isTargetPackageDirty = (ctx: Context) => { const pkg1 = JSON.stringify(ctx.packageJsonPristine) const pkg2 = JSON.stringify(ctx.packageJson) const res = pkg1 !== pkg2 ctx.logger.verbose(`counsel::isTargetPackageDirty?: ${res}`) return res } /** * write the project package.json if it's dirty */ export const writeTargetPackage = async (ctx: Context) => { return fs.writeFile( ctx.packageJsonFilename, JSON.stringify(ctx.packageJson, null, 2) ) } /** * migrate deps required by rule set */ export const migrateDependencies = async (opts: { ctx: Context dependencies: Dependency[] development: boolean }) => { const { dependencies, development, ctx } = opts const toInstall = filterToInstallPackages( fromNameVersionObject( development ? getPristineDevDependencies(ctx) : getPristineDependencies(ctx) ), dependencies, ctx.logger ) const isPackageDirty = await installDependencies({ ctx, dependencies: toInstall, development }) // istanbul ignore else if (isPackageDirty) { const tPkg = await fs.readJSON(ctx.packageJsonFilename) ctx.packageJson.dependencies = tPkg.dependencies } } export const toRulesWithPlans = (rules: Rule[]) => rules.filter(rule => rule.plan) export * from './errors' export * from './interfaces' export { rules }