Skip to content

Commit 80f10b3

Browse files
authoredDec 24, 2022
feat: add run and exec commands for executing commands in monorepo packages (#1151)
The only reason we keep lerna about is for running scripts and commands in monorepo packages. Unlike npm it's clever enough to figure out the dependency graph of monorepo packages before running the commands or scripts in order to ensure that they've been run in a modules dependencies before being run in that module. This PR adds two new commands to aegir that do the same thing: 1. exec - this will run a binary command in each monorepo package 2. run - this will run one or more npm scripts in each package E.g. run `ls` in every package ```console $ aegir exec ls ``` E.g. clean and build every package ```console $ aegir run clean build ``` Both commands support forwarding args: E.g. lint every package, fixing errors ```console $ aegir run lint -- --fix ```
1 parent 8ee941b commit 80f10b3

File tree

8 files changed

+354
-1
lines changed

8 files changed

+354
-1
lines changed
 

‎src/cmds/exec.js

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { loadUserConfig } from '../config/user.js'
2+
import execCmd from '../exec.js'
3+
4+
/**
5+
* @typedef {import("yargs").Argv} Argv
6+
* @typedef {import("yargs").Arguments} Arguments
7+
* @typedef {import("yargs").CommandModule} CommandModule
8+
*/
9+
10+
const EPILOG = `Example:
11+
12+
$ aegir exec david -- update
13+
`
14+
15+
/** @type {CommandModule} */
16+
export default {
17+
command: 'exec <command>',
18+
describe: 'Run a command in each project of a monorepo',
19+
/**
20+
* @param {Argv} yargs
21+
*/
22+
builder: async (yargs) => {
23+
const userConfig = await loadUserConfig()
24+
25+
return yargs
26+
.epilog(EPILOG)
27+
.options({
28+
bail: {
29+
type: 'boolean',
30+
describe: '',
31+
default: userConfig.build.bundle
32+
}
33+
})
34+
},
35+
36+
/**
37+
* @param {any} argv
38+
*/
39+
async handler (argv) {
40+
await execCmd.run(argv)
41+
}
42+
}

‎src/cmds/run.js

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { loadUserConfig } from '../config/user.js'
2+
import runCmd from '../run.js'
3+
4+
/**
5+
* @typedef {import("yargs").Argv} Argv
6+
* @typedef {import("yargs").Arguments} Arguments
7+
* @typedef {import("yargs").CommandModule} CommandModule
8+
*/
9+
10+
const EPILOG = `Example:
11+
12+
$ aegir run clean build
13+
`
14+
15+
/** @type {CommandModule} */
16+
export default {
17+
command: 'run <scripts..>',
18+
describe: 'Run one or more npm scripts in each project of a monorepo',
19+
/**
20+
* @param {Argv} yargs
21+
*/
22+
builder: async (yargs) => {
23+
const userConfig = await loadUserConfig()
24+
25+
return yargs
26+
.epilog(EPILOG)
27+
.options({
28+
bail: {
29+
type: 'boolean',
30+
describe: '',
31+
default: userConfig.build.bundle
32+
}
33+
})
34+
.positional('script', {
35+
array: true
36+
})
37+
},
38+
39+
/**
40+
* @param {any} argv
41+
*/
42+
async handler (argv) {
43+
await runCmd.run(argv)
44+
}
45+
}

‎src/config/user.js

+6
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ const defaults = {
131131
'@types/*',
132132
'aegir'
133133
]
134+
},
135+
exec: {
136+
bail: true
137+
},
138+
run: {
139+
bail: true
134140
}
135141
}
136142

‎src/exec.js

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { everyMonorepoProject } from './utils.js'
2+
import { execa } from 'execa'
3+
import kleur from 'kleur'
4+
5+
/**
6+
* @typedef {import("./types").GlobalOptions} GlobalOptions
7+
* @typedef {import("./types").ExecOptions} ExecOptions
8+
*/
9+
10+
export default {
11+
/**
12+
* @param {GlobalOptions & ExecOptions & { command: string }} ctx
13+
*/
14+
async run (ctx) {
15+
const forwardOptions = ctx['--'] ? ctx['--'] : []
16+
17+
await everyMonorepoProject(process.cwd(), async (project) => {
18+
console.info('') // eslint-disable-line no-console
19+
console.info(kleur.grey(`${project.manifest.name} > ${ctx.command} ${forwardOptions.join(' ')}`)) // eslint-disable-line no-console
20+
21+
try {
22+
await execa(ctx.command, forwardOptions, {
23+
cwd: project.dir,
24+
stderr: 'inherit',
25+
stdout: 'inherit'
26+
})
27+
} catch (/** @type {any} */ err) {
28+
if (ctx.bail !== false) {
29+
throw err
30+
}
31+
32+
console.info(kleur.red(err.stack)) // eslint-disable-line no-console
33+
}
34+
})
35+
}
36+
}

‎src/index.js

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import releaseCmd from './cmds/release.js'
1717
import testDependantCmd from './cmds/test-dependant.js'
1818
import testCmd from './cmds/test.js'
1919
import docsCmd from './cmds/docs.js'
20+
import execCmd from './cmds/exec.js'
21+
import runCmd from './cmds/run.js'
2022

2123
/**
2224
* @typedef {import('./types').BuildOptions} BuildOptions
@@ -89,6 +91,8 @@ async function main () {
8991
res.command(releaseCmd)
9092
res.command(testDependantCmd)
9193
res.command(testCmd)
94+
res.command(execCmd)
95+
res.command(runCmd)
9296

9397
try {
9498
await res.parse()

‎src/run.js

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { everyMonorepoProject } from './utils.js'
2+
import { execa } from 'execa'
3+
import kleur from 'kleur'
4+
5+
/**
6+
* @typedef {import("./types").GlobalOptions} GlobalOptions
7+
* @typedef {import("./types").RunOptions} RunOptions
8+
*/
9+
10+
export default {
11+
/**
12+
* @param {GlobalOptions & RunOptions & { scripts: string[] }} ctx
13+
*/
14+
async run (ctx) {
15+
const scripts = ctx.scripts
16+
17+
if (scripts == null || scripts.length === 0) {
18+
throw new Error('Please specify a script')
19+
}
20+
21+
const forwardArgs = ctx['--'] == null ? [] : ['--', ...ctx['--']]
22+
23+
await everyMonorepoProject(process.cwd(), async (project) => {
24+
for (const script of scripts) {
25+
if (project.manifest.scripts[script] == null) {
26+
continue
27+
}
28+
29+
console.info('') // eslint-disable-line no-console
30+
console.info(kleur.grey(`${project.manifest.name} > npm run ${script} ${forwardArgs.join(' ')}`)) // eslint-disable-line no-console
31+
32+
try {
33+
await execa('npm', ['run', script, ...forwardArgs], {
34+
cwd: project.dir,
35+
stderr: 'inherit',
36+
stdout: 'inherit'
37+
})
38+
} catch (/** @type {any} */ err) {
39+
if (ctx.bail !== false) {
40+
throw err
41+
}
42+
43+
console.info(kleur.red(err.stack)) // eslint-disable-line no-console
44+
}
45+
}
46+
})
47+
}
48+
}

‎src/types.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ interface Options extends GlobalOptions {
3232
* Options for the `dependency-check` command
3333
*/
3434
dependencyCheck: DependencyCheckOptions
35+
/**
36+
* Options for the `exec` command
37+
*/
38+
exec: ExecOptions
39+
/**
40+
* Options for the `run` command
41+
*/
42+
run: RunOptions
3543
}
3644

3745
/**
@@ -309,6 +317,20 @@ interface DependencyCheckOptions {
309317
productionInput: string[]
310318
}
311319

320+
interface ExecOptions {
321+
/**
322+
* If false, the command will continue to be run in other packages
323+
*/
324+
bail?: boolean
325+
}
326+
327+
interface RunOptions {
328+
/**
329+
* If false, the command will continue to be run in other packages
330+
*/
331+
bail?: boolean
332+
}
333+
312334
export type {
313335
PartialOptions,
314336
Options,
@@ -319,5 +341,7 @@ export type {
319341
LintOptions,
320342
TestOptions,
321343
ReleaseOptions,
322-
DependencyCheckOptions
344+
DependencyCheckOptions,
345+
ExecOptions,
346+
RunOptions
323347
}

‎src/utils.js

+148
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import envPaths from 'env-paths'
2020
import lockfile from 'proper-lockfile'
2121
import { fileURLToPath } from 'url'
2222
import Listr from 'listr'
23+
import glob from 'it-glob'
2324

2425
const __dirname = path.dirname(fileURLToPath(import.meta.url))
2526
const EnvPaths = envPaths('aegir', { suffix: '' })
@@ -300,3 +301,150 @@ export function findBinary (bin) {
300301
// let shell work it out or error
301302
return bin
302303
}
304+
305+
/**
306+
* @typedef {object} Project
307+
* @property {any} manifest
308+
* @property {string} dir
309+
* @property {string[]} siblingDependencies
310+
* @property {string[]} dependencies
311+
* @property {boolean} run
312+
*/
313+
314+
/**
315+
* @param {string} projectDir
316+
* @param {(project: Project) => Promise<void>} fn
317+
*/
318+
export async function everyMonorepoProject (projectDir, fn) {
319+
const manifest = fs.readJSONSync(path.join(projectDir, 'package.json'))
320+
const workspaces = manifest.workspaces
321+
322+
if (!workspaces || !Array.isArray(workspaces)) {
323+
throw new Error('No monorepo workspaces found')
324+
}
325+
326+
/** @type {Record<string, Project>} */
327+
const projects = await parseProjects(projectDir, workspaces)
328+
329+
checkForCircularDependencies(projects)
330+
331+
/**
332+
* @param {Project} project
333+
*/
334+
async function run (project) {
335+
if (project.run) {
336+
return
337+
}
338+
339+
for (const siblingDep of project.siblingDependencies) {
340+
await run(projects[siblingDep])
341+
}
342+
343+
if (project.run) {
344+
return
345+
}
346+
347+
project.run = true
348+
await fn(project)
349+
}
350+
351+
for (const project of Object.values(projects)) {
352+
await run(project)
353+
}
354+
}
355+
356+
/**
357+
*
358+
* @param {string} projectDir
359+
* @param {string[]} workspaces
360+
*/
361+
async function parseProjects (projectDir, workspaces) {
362+
/** @type {Record<string, Project>} */
363+
const projects = {}
364+
365+
for (const workspace of workspaces) {
366+
for await (const subProjectDir of glob('.', workspace, {
367+
cwd: projectDir,
368+
absolute: true
369+
})) {
370+
const pkg = fs.readJSONSync(path.join(subProjectDir, 'package.json'))
371+
372+
projects[pkg.name] = {
373+
manifest: pkg,
374+
dir: subProjectDir,
375+
siblingDependencies: [],
376+
run: false,
377+
dependencies: [
378+
...Object.keys(pkg.dependencies ?? {}),
379+
...Object.keys(pkg.devDependencies ?? {}),
380+
...Object.keys(pkg.optionalDependencies ?? {}),
381+
...Object.keys(pkg.peerDependencies ?? {})
382+
]
383+
}
384+
}
385+
}
386+
387+
for (const project of Object.values(projects)) {
388+
for (const dep of project.dependencies) {
389+
if (projects[dep] != null) {
390+
project.siblingDependencies.push(dep)
391+
}
392+
}
393+
}
394+
395+
return projects
396+
}
397+
398+
/**
399+
* @param {Record<string, Project>} projects
400+
*/
401+
function checkForCircularDependencies (projects) {
402+
/**
403+
* @param {Project} project
404+
* @param {string} target
405+
* @param {Set<string>} checked
406+
* @param {string[]} chain
407+
* @returns {string[] | undefined}
408+
*/
409+
function dependsOn (project, target, checked, chain) {
410+
chain = [...chain, project.manifest.name]
411+
412+
if (project.manifest.name === target) {
413+
return chain
414+
}
415+
416+
for (const dep of project.siblingDependencies) {
417+
if (checked.has(dep)) {
418+
// already checked this dep
419+
return
420+
}
421+
422+
checked.add(dep)
423+
424+
if (dep === target) {
425+
// circular dependency detected
426+
chain.push(target)
427+
return chain
428+
}
429+
430+
const subChain = dependsOn(projects[dep], target, checked, chain)
431+
432+
if (subChain != null) {
433+
return subChain
434+
}
435+
}
436+
}
437+
438+
// check for circular dependencies
439+
for (const project of Object.values(projects)) {
440+
for (const siblingDep of project.siblingDependencies) {
441+
const sibling = projects[siblingDep]
442+
443+
const chain = dependsOn(sibling, project.manifest.name, new Set([sibling.manifest.name]), [project.manifest.name])
444+
445+
if (chain != null) {
446+
throw new Error(`Circular dependency detected: ${chain.join(' -> ')}`)
447+
}
448+
}
449+
}
450+
}

0 commit comments

Comments
 (0)
Please sign in to comment.