@kikobeats/scritch
Version:
A small CLI to help you write sharable scripts for your team
518 lines (437 loc) • 14 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 getPromiseWithResolvers = () => {
if (typeof Promise.withResolvers === 'function') {
return Promise.withResolvers()
}
let resolvePromise
let rejectPromise
const promise = new Promise((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
return { promise, resolve: resolvePromise, reject: rejectPromise }
}
const scritch = async (
dir,
{ scriptsPath = 'scripts', env = {}, help: helpTree = null } = {}
) => {
const scriptsDir = path.resolve(dir, scriptsPath)
const scripts = await getScripts(scriptsDir)
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, helpTree }) })
let script = null
let matchLength = 0
for (let i = cli.input.length; i >= 1; i--) {
const candidate = cli.input.slice(0, i).join('/')
const found = scripts.find(s => s.name === candidate)
if (found) {
script = found
matchLength = i
break
}
}
if (!script) {
const prefix = cli.input.join('/')
const groupScripts = scripts.filter(s => s.name.startsWith(prefix + '/'))
if (groupScripts.length > 0) {
const subScripts = groupScripts.map(s => ({
...s,
name: s.name.slice(prefix.length + 1)
}))
const subHelpTree = resolveHelpSubTree(helpTree, cli.input)
const groupUsage = prefix.replace(/\//g, ' ')
console.log(`
Usage
${gray(`$ ${binaryName(pkg)} ${groupUsage} <command> [...args]`)}
Commands
${formatGroupedCommands(subScripts, subHelpTree)}
`)
return
}
return cli.showHelp()
}
const scriptArgs = process.argv.slice(2 + matchLength)
const wantsHelp =
scriptArgs.length === 0 ||
scriptArgs.includes('-h') ||
scriptArgs.includes('--help')
const explicitHelp = scriptArgs.includes('-h') || scriptArgs.includes('--help')
if (explicitHelp) {
const scriptHelp = tryScriptHelp(script, binaryName(pkg))
if (scriptHelp) {
console.log(scriptHelp)
return
}
}
if (wantsHelp) {
const segments = script.name.split('/')
const helpEntry = resolveHelpSubTree(helpTree, segments)
if (helpEntry && typeof helpEntry === 'object' && helpEntry.commands) {
console.log(formatSubcommandHelp(binaryName(pkg), script.name, helpEntry))
return
}
}
const { promise, resolve, reject } = getPromiseWithResolvers()
const stdoutSupportsColor = supportsColor.stdout
const subprocess = $(script.filePath, process.argv.slice(2 + matchLength), {
cwd: process.cwd(),
shell: true,
stdio: stdoutSupportsColor ? 'inherit' : 'pipe',
env: Object.assign(
{},
process.env,
{
PATH: `${pkgNodeModulesBinPath}:${scriptsDir}:${process.env.PATH}`,
SCRITCH_BIN_NAME: binaryName(pkg),
SCRITCH_SCRIPT_NAME: script.name,
SCRITCH_SCRIPT_PATH: script.filePath,
SCRITCH_SCRIPTS_DIR: scriptsDir
},
env
)
})
subprocess.catch(() => {})
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()
})
return promise
}
const getScripts = async scriptsDir => {
const dirents = (await readdirDeep(scriptsDir)).filter(
dirent => dirent.name !== path.resolve(scriptsDir, 'index.js')
)
return dirents.reduce((acc, dirent) => {
if (!isExecutable.sync(dirent.name)) return acc
const name = dirent.name
.replace(scriptsDir, '')
.replace(/^\//, '')
.replace(path.extname(dirent.name), '')
.replace(/\/index$/, '')
acc.push({ name, filePath: dirent.name })
return acc
}, [])
}
const isPlainObject = val =>
typeof val === 'object' && val !== null && !Array.isArray(val)
const binaryName = ({ name, bin }) =>
isPlainObject(bin) ? Object.keys(bin)[0] : name || 'cli'
const gray = text => styleText('gray', text)
const dim = text => styleText('dim', text)
const formatSubcommandHelp = (bin, scriptName, helpObj) => {
const { commands = {}, examples = [] } = helpObj
const entries = Object.entries(commands)
if (entries.length === 0) return ''
const maxLen = Math.max(...entries.map(([name]) => name.length))
const cmdLines = entries
.map(([name, desc]) => ` ${gray(name.padEnd(maxLen))} ${dim(desc)}`)
.join('\n')
const usage = scriptName.replace(/\//g, ' ')
let output = `
Usage
${gray(`$ ${bin} ${usage} <command> [...args]`)}
Commands
${cmdLines}
`
if (examples.length > 0) {
const exLines = examples.map(ex => ` ${gray(`$ ${ex}`)}`).join('\n')
output += `
Examples
${exLines}
`
}
return output
}
const tryScriptHelp = (script, bin) => {
try {
const mod = require(script.filePath)
if (typeof mod.help !== 'function') return null
const raw = mod.help()
if (typeof raw !== 'string' || !raw.trim()) return null
return formatScriptHelp(bin, script.name, raw)
} catch {
return null
}
}
const formatScriptHelp = (bin, scriptName, body) => {
const usage = scriptName.replace(/\//g, ' ')
const lines = body.split('\n')
const formatted = lines.map(line => {
const stripped = line.trimStart()
if (/^--\S/.test(stripped)) {
const match = stripped.match(/^(--\S+(?:,\s*-\S+)?(?:\s+<[^>]+>)?)\s{2,}(.+)$/)
if (match) {
return { kind: 'option', flag: match[1], desc: match[2] }
}
}
if (/^\S/.test(stripped) && stripped.endsWith(':')) {
return { kind: 'heading', text: stripped.slice(0, -1) }
}
return { kind: 'text', text: stripped }
})
const options = formatted.filter(l => l.kind === 'option')
const flagCol = options.length > 0
? Math.max(...options.map(o => o.flag.length)) + 2
: 0
const firstSectionIdx = formatted.findIndex(l => l.kind !== 'text')
const preamble = (firstSectionIdx === -1 ? formatted : formatted.slice(0, firstSectionIdx))
.filter(l => l.text)
const rest = firstSectionIdx === -1 ? [] : formatted.slice(firstSectionIdx)
const out = []
if (preamble.length > 0) {
out.push('')
for (const line of preamble) out.push(` ${dim(line.text)}`)
}
out.push(`\n Usage\n ${gray(`$ ${bin} ${usage} [options]`)}`)
let currentSection = null
for (const line of rest) {
if (line.kind === 'heading') {
currentSection = line.text
out.push(`\n ${line.text}`)
continue
}
if (line.kind === 'option') {
const gap = flagCol - line.flag.length
out.push(` ${gray(line.flag)}${' '.repeat(Math.max(2, gap))}${dim(line.desc)}`)
continue
}
if (line.kind === 'text' && line.text) {
out.push(` ${dim(line.text)}`)
}
}
out.push('')
return out.join('\n')
}
const kebabToCamel = s => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
const segmentKeys = seg => {
const keys = new Set([seg])
if (seg.includes('-')) keys.add(kebabToCamel(seg))
return [...keys]
}
const resolveHelpSubTree = (tree, segments) => {
if (!tree || typeof tree !== 'object') return null
let node = tree
for (const seg of segments) {
let next
for (const key of segmentKeys(seg)) {
if (Object.prototype.hasOwnProperty.call(node, key)) {
next = node[key]
break
}
}
if (next === undefined || typeof next !== 'object' || next === null) {
return null
}
node = next
}
return node
}
const lookupHelpDescription = (tree, segments) => {
if (!tree || typeof tree !== 'object' || segments.length === 0) return ''
let node = tree
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
const isLast = i === segments.length - 1
let next
for (const key of segmentKeys(seg)) {
if (Object.prototype.hasOwnProperty.call(node, key)) {
next = node[key]
break
}
}
if (next === undefined) return ''
if (isLast) {
if (typeof next === 'string') return next
if (isPlainObject(next) && typeof next.description === 'string') {
return next.description
}
return ''
}
if (typeof next !== 'object' || next === null) return ''
node = next
}
return ''
}
const formatCommandLine = (depth, name, description, descStartCol) => {
const indent = pad(depth)
if (!description) return `${indent}${gray(name)}`
const gap = descStartCol > 0 ? descStartCol - indent.length - name.length : 2
return `${indent}${gray(name)}${' '.repeat(Math.max(2, gap))}${dim(
description
)}`
}
const globalDescStartColumn = rows => {
let c = 0
for (const r of rows) {
if (!r.desc) continue
const end = pad(r.depth).length + r.name.length + 2
if (end > c) c = end
}
return c
}
const sortStrings = strings => [...strings].sort((a, b) => a.localeCompare(b))
const emptyNode = () => ({
leaves: [],
sub: Object.create(null)
})
const addPath = (node, parts) => {
if (parts.length === 1) {
node.leaves.push(parts[0])
return
}
const [head, ...rest] = parts
if (!node.sub[head]) node.sub[head] = emptyNode()
addPath(node.sub[head], rest)
}
const pad = depth => ' ' + ' '.repeat(depth)
const buildTopItems = scripts => {
const root = emptyNode()
for (const { name } of scripts) {
addPath(root, name.split('/'))
}
const topItems = []
for (const leaf of sortStrings(root.leaves)) {
topItems.push({ kind: 'leaf', name: leaf })
}
for (const key of sortStrings(Object.keys(root.sub))) {
topItems.push({ kind: 'group', name: key, node: root.sub[key] })
}
topItems.sort((a, b) => a.name.localeCompare(b.name))
return topItems
}
const collectHelpRows = (topItems, helpTree) => {
const rows = []
const walkNode = (node, depth, pathPrefix) => {
for (const leaf of sortStrings(node.leaves)) {
rows.push({
depth,
name: leaf,
desc: lookupHelpDescription(helpTree, [...pathPrefix, leaf])
})
}
for (const key of sortStrings(Object.keys(node.sub))) {
rows.push({
depth,
name: key,
desc: lookupHelpDescription(helpTree, [...pathPrefix, key])
})
walkNode(node.sub[key], depth + 1, [...pathPrefix, key])
}
}
for (const item of topItems) {
if (item.kind === 'leaf') {
rows.push({
depth: 0,
name: item.name,
desc: lookupHelpDescription(helpTree, [item.name])
})
} else {
rows.push({
depth: 0,
name: item.name,
desc: lookupHelpDescription(helpTree, [item.name])
})
walkNode(item.node, 1, [item.name])
}
}
return rows
}
const formatGroupBody = (node, depth, pathPrefix, helpTree, descStartCol) => {
const lines = []
for (const leaf of sortStrings(node.leaves)) {
const segments = [...pathPrefix, leaf]
const desc = lookupHelpDescription(helpTree, segments)
lines.push(formatCommandLine(depth, leaf, desc, descStartCol))
}
for (const key of sortStrings(Object.keys(node.sub))) {
const segments = [...pathPrefix, key]
const groupDesc = lookupHelpDescription(helpTree, segments)
lines.push(formatCommandLine(depth, key, groupDesc, descStartCol))
lines.push(
...formatGroupBody(
node.sub[key],
depth + 1,
[...pathPrefix, key],
helpTree,
descStartCol
)
)
}
return lines
}
const formatGroupedCommands = (scripts, helpTree) => {
const topItems = buildTopItems(scripts)
const rows = collectHelpRows(topItems, helpTree)
const descStartCol = globalDescStartColumn(rows)
const lines = []
for (let i = 0; i < topItems.length; i++) {
const item = topItems[i]
if (item.kind === 'leaf') {
const desc = lookupHelpDescription(helpTree, [item.name])
lines.push(formatCommandLine(0, item.name, desc, descStartCol))
} else {
const desc = lookupHelpDescription(helpTree, [item.name])
lines.push(formatCommandLine(0, item.name, desc, descStartCol))
lines.push(
...formatGroupBody(item.node, 1, [item.name], helpTree, descStartCol)
)
}
if (i < topItems.length - 1) {
const next = topItems[i + 1]
if (item.kind === 'group' || next.kind === 'group') lines.push('')
}
}
return lines.join('\n')
}
const help = ({ pkg, scripts, helpTree }) => `
Usage
${gray(`$ ${binaryName(pkg)} <command> [...args]`)}
Commands
${formatGroupedCommands(scripts, helpTree)}`
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)
if (subdir.isDirectory()) {
return path.basename(subdir.name).startsWith('_')
? []
: readdirDeep(subdir.name)
}
return subdir
})
)
return files
.reduce((a, f) => a.concat(f), [])
.filter(dirent => {
return (
!dirent.isDirectory() && !path.basename(dirent.name).startsWith('_')
)
})
}
const main = (...args) =>
scritch(...args).catch(error => console.error(error) || process.exit(1))
main.formatHelp = formatScriptHelp
module.exports = main