Last active
December 7, 2023 15:44
-
-
Save bradennapier/8febf677c2e735007c832bbd69d9106c to your computer and use it in GitHub Desktop.
Recursively find and install dependencies in subdirectories
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const path = require('path'); | |
const { promises: fs } = require('fs'); | |
const cp = require('child_process'); | |
/* | |
Recursively iterates and finds subdirectories which have a `package.json` file and does a dependency installation based | |
on the given configuration. | |
- can be setup to use yarn or npm | |
- can be setup to determine the command to use based on lock files so that if you set it to use yarn but a directory only | |
has a package-lock.json it will use npm for that directory (defaults to true). | |
- configure logging | |
- runs installations in parallel using cp.spawn | |
- can do dry runs to let you see what it would do first | |
- can be run as a function or auto run using env vars | |
- when run as a function, optionally provide array of directories to check | |
- returns a promise that resolves when completed | |
- allows setting max depth to look if needed | |
- knows to stop recursing if it finds a folder with yarn workspaces (configurable) | |
- allows skipping directories using a comma separated env var or by passing the config an array of strings to match | |
against or a function which receives the file name, file path, and the fs.Dirent obj and expects a boolean result. | |
*/ | |
// if you want to have it automatically run based upon | |
// process.cwd() | |
const AUTO_RUN = Boolean(process.env.RI_AUTO_RUN); | |
/** | |
* Creates a config object from environment variables which can then be | |
* overriden if executing via its exported function (config as second arg) | |
*/ | |
const getConfig = (config = {}) => ({ | |
// we want to use yarn by default but RI_USE_YARN=false will | |
// use npm instead | |
useYarn: process.env.RI_USE_YARN !== 'false', | |
// should we handle yarn workspaces? if this is true (default) | |
// then we will stop recursing if a package.json has the "workspaces" | |
// property and we will allow `yarn` to do its thing. | |
yarnWorkspaces: process.env.RI_YARN_WORKSPACES !== 'false', | |
// if truthy, will run extra checks to see if there is a package-lock.json | |
// or yarn.lock file in a given directory and use that installer if so. | |
detectLockFiles: process.env.RI_DETECT_LOCK_FILES !== 'false', | |
// what kind of logging should be done on the spawned processes? | |
// if this exists and it is not errors it will log everything | |
// otherwise it will only log stderr and spawn errors | |
log: process.env.RI_LOG || 'errors', | |
// max depth to recurse? | |
maxDepth: process.env.RI_MAX_DEPTH || Infinity, | |
// do not install at the root directory? | |
ignoreRoot: Boolean(process.env.RI_IGNORE_ROOT), | |
// an array (or comma separated string for env var) of directories | |
// to skip while recursing. if array, can pass functions which | |
// return a boolean after receiving the dir path and fs.Dirent args | |
// @see https://nodejs.org/api/fs.html#fs_class_fs_dirent | |
skipDirectories: process.env.RI_SKIP_DIRS | |
? process.env.RI_SKIP_DIRS.split(',').map(str => str.trim()) | |
: undefined, | |
// just run through and log the actions that would be taken? | |
dry: Boolean(process.env.RI_DRY_RUN), | |
...config | |
}); | |
function handleSpawnedProcess(dir, log, proc) { | |
return new Promise((resolve, reject) => { | |
proc.on('error', error => { | |
console.log(` | |
---------------- | |
[RI] | [ERROR] | Failed to Spawn Process | |
- Path: ${dir} | |
- Reason: ${error.message} | |
---------------- | |
`); | |
reject(error); | |
}); | |
if (log) { | |
proc.stderr.on('data', data => { | |
console.error(`[RI] | [${dir}] | ${data}`); | |
}); | |
} | |
if (log && log !== 'errors') { | |
proc.stdout.on('data', data => { | |
console.log(`[RI] | [${dir}] | ${data}`); | |
}); | |
} | |
proc.on('close', code => { | |
if (log && log !== 'errors') { | |
console.log(` | |
---------------- | |
[RI] | [COMPLETE] | Spawned Process Closed | |
- Path: ${dir} | |
- Code: ${code} | |
---------------- | |
`); | |
} | |
if (code === 0) { | |
resolve(); | |
} else { | |
reject(new Error(`[RI] | [ERROR] | [${dir}] | Failed to install with exit code ${code}`)); | |
} | |
}); | |
}); | |
} | |
async function recurseDirectory(rootDir, config) { | |
const { | |
useYarn, | |
yarnWorkspaces, | |
detectLockFiles, | |
log, | |
maxDepth, | |
ignoreRoot, | |
skipDirectories, | |
dry | |
} = config; | |
const installPromises = []; | |
function install(cmd, folder, relativeDir) { | |
const proc = cp.spawn(cmd, ['install'], { | |
cwd: folder, | |
env: process.env | |
}); | |
installPromises.push(handleSpawnedProcess(relativeDir, log, proc)); | |
} | |
function shouldSkipFile(filePath, file) { | |
if (!file.isDirectory() || file.name === 'node_modules') { | |
return true; | |
} | |
if (!skipDirectories) { | |
return false; | |
} | |
return skipDirectories.some(check => | |
typeof check === 'function' ? check(filePath, file) : check === file.name | |
); | |
} | |
async function getInstallCommand(folder) { | |
let cmd = useYarn ? 'yarn' : 'npm'; | |
if (detectLockFiles) { | |
const [hasYarnLock, hasPackageLock] = await Promise.all([ | |
fs | |
.readFile(path.join(folder, 'yarn.lock')) | |
.then(() => true) | |
.catch(() => false), | |
fs | |
.readFile(path.join(folder, 'package-lock.json')) | |
.then(() => true) | |
.catch(() => false) | |
]); | |
if (cmd === 'yarn' && !hasYarnLock && hasPackageLock) { | |
cmd = 'npm'; | |
} else if (cmd === 'npm' && !hasPackageLock && hasYarnLock) { | |
cmd = 'yarn'; | |
} | |
} | |
return cmd; | |
} | |
async function installRecursively(folder, depth = 0) { | |
if (dry || (log && log !== 'errors')) { | |
console.log('[RI] | Check Directory --> ', folder); | |
} | |
let pkg; | |
if (folder !== rootDir || !ignoreRoot) { | |
try { | |
// Check if package.json exists, if it doesnt this will error and move on | |
pkg = JSON.parse(await fs.readFile(path.join(folder, 'package.json'))); | |
// get the command that we should use. if lock checking is enabled it will | |
// also determine what installer to use based on the available lock files | |
const cmd = await getInstallCommand(folder); | |
const relativeDir = `${path.basename(rootDir)} -> ./${path.relative( | |
rootDir, | |
folder | |
)}`; | |
if (dry || (log && log !== 'errors')) { | |
console.log( | |
`[RI] | Performing (${cmd} install) at path "${relativeDir}"` | |
); | |
} | |
if (!dry) { | |
install(cmd, folder, relativeDir); | |
} | |
} catch { | |
// do nothing when error caught as it simply indicates package.json likely doesnt | |
// exist. | |
} | |
} | |
if ( | |
depth >= maxDepth || | |
(pkg && useYarn && yarnWorkspaces && pkg.workspaces) | |
) { | |
// if we have reached maxDepth or if our package.json in the current directory | |
// contains yarn workspaces then we use yarn for installing then this is the last | |
// directory we will attempt to install. | |
return; | |
} | |
const files = await fs.readdir(folder, { withFileTypes: true }); | |
return Promise.all( | |
files.map(file => { | |
const filePath = path.join(folder, file.name); | |
return shouldSkipFile(filePath, file) | |
? undefined | |
: installRecursively(filePath, depth + 1); | |
}) | |
); | |
} | |
await installRecursively(rootDir); | |
await Promise.all(installPromises); | |
} | |
async function startRecursiveInstall(directories, _config) { | |
const config = getConfig(_config); | |
const promise = Array.isArray(directories) | |
? Promise.all(directories.map(rootDir => recurseDirectory(rootDir, config))) | |
: recurseDirectory(directories, config); | |
await promise; | |
} | |
if (AUTO_RUN) { | |
startRecursiveInstall(process.cwd()); | |
} | |
module.exports = startRecursiveInstall; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment