@kikobeats/scritch
Version:
A small CLI to help you write sharable scripts for your team
132 lines (107 loc) • 3.96 kB
JavaScript
const stripAnsiStream = require('strip-ansi-stream')
const supportsColor = require('supports-color')
const isExecutable = require('executable')
const { readdir } = require('fs/promises')
const { styleText } = require('node:util')
const readPkgUp = require('read-pkg-up')
const $ = require('tinyspawn')
const path = require('path')
const meow = require('meow')
// Prevent caching of this module so module.parent is always accurate
delete require.cache[__filename]
const parentDir = path.dirname(module.parent.filename)
const scritch = async (dir, { scriptsPath = 'scripts', env = {} } = {}) => {
const scriptsDir = path.resolve(dir, scriptsPath)
const scripts = await getScripts(scriptsDir)
// Lookup package for CLI
const { packageJson: pkg, path: pkgPath } = readPkgUp.sync({
cwd: parentDir,
normalize: false
})
const pkgRootPath = path.dirname(pkgPath)
const pkgNodeModulesBinPath = path.join(pkgRootPath, 'node_modules', '.bin')
const cli = meow({ pkg, help: help({ pkg, scripts }) })
const script = scripts.find(script => script.name === cli.input[0])
if (!script) return cli.showHelp()
return new Promise(async (resolve, reject) => {
const stdoutSupportsColor = supportsColor.stdout
// Spawn matching script
const subprocess = $(script.filePath, process.argv.slice(3), {
cwd: process.cwd(),
shell: true,
// only pipe if it does not support color as we lose ability to retain color otherwise
stdio: stdoutSupportsColor ? 'inherit' : 'pipe',
env: Object.assign(
{},
process.env,
{
PATH: `${pkgNodeModulesBinPath}:${scriptsDir}:${process.env.PATH}`,
SCRITCH_SCRIPT_NAME: script.name,
SCRITCH_SCRIPT_PATH: script.filePath,
SCRITCH_SCRIPTS_DIR: scriptsDir
},
env
)
})
if (!stdoutSupportsColor) {
subprocess.stdout.pipe(stripAnsiStream()).pipe(process.stdout)
subprocess.stderr.pipe(stripAnsiStream()).pipe(process.stderr)
}
subprocess.on('error', err => reject(err))
subprocess.on('close', code => {
if (code !== 0) process.exitCode = code
resolve()
})
})
}
const getScripts = async scriptsDir => {
const dirents = (await readdirDeep(scriptsDir)).filter(
dirent => dirent.name !== path.resolve(scriptsDir, 'index.js')
)
return dirents.reduce((acc, dirent) => {
const name = dirent.name
.replace(parentDir, '') // without parent dir
.replace(/^\//, '') // without relative path slash
.replace(path.extname(dirent.name), '') // without extension
.replace(/\/index$/, '') // without index file
const filePath = dirent.name
if (!isExecutable.sync(filePath)) {
throw new Error(`Expected path to be executable: "${filePath}"`)
}
acc.push({ name, filePath })
return acc
}, [])
}
const isPlainObject = val =>
typeof val === 'object' && val !== null && !Array.isArray(val)
const binaryName = ({ name, bin }) =>
isPlainObject(bin) ? Object.keys(bin)[0] : name ? name : 'cli'
const gray = text => styleText('gray', text)
const help = ({ pkg, scripts }) => `
Usage
${gray(`$ ${binaryName(pkg)} <script> [...args]`)}
Scripts
${gray(
scripts
.map((script, index) => `${index === 0 ? '' : ' '}- ${script.name}`)
.join('\n')
)}`
const readdirDeep = async dir => {
const subdirs = await readdir(dir, { withFileTypes: true })
const files = await Promise.all(
subdirs.map(subdir => {
subdir.name = path.resolve(dir, subdir.name)
return subdir.isDirectory() ? readdirDeep(subdir.name) : subdir
})
)
return files
.reduce((a, f) => a.concat(f), [])
.filter(dirent => {
return (
!dirent.isDirectory() && !path.basename(dirent.name).startsWith('_')
)
})
}
module.exports = (...args) =>
scritch(...args).catch(error => console.error(error) || process.exit(1))