feat: support to run multi script commands specified with regex selector (#5871)

This commit is contained in:
Shinobu Hayashi
2023-02-02 10:27:40 +09:00
committed by GitHub
parent 59ee536787
commit 9ac6940442
6 changed files with 334 additions and 39 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/plugin-commands-script-runners": minor
"pnpm": minor
---
Support script selector with RegExp such as `pnpm run /build:.*/` and execute the matched scripts with the RegExp [#5871](https://github.com/pnpm/pnpm/pull/5871).

View File

@@ -0,0 +1,23 @@
import { PnpmError } from '@pnpm/error'
export function tryBuildRegExpFromCommand (command: string): RegExp | null {
// https://github.com/stdlib-js/regexp-regexp/blob/6428051ac9ef7c9d03468b19bdbb1dc6fc2a5509/lib/regexp.js
const regExpDetectRegExpScriptCommand = /^\/((?:\\\/|[^/])+)\/([dgimuys]*)$/
const match = command.match(regExpDetectRegExpScriptCommand)
// if the passed script selector is not in the format of RegExp literal like /build:.*/, return null and handle it as a string script command
if (!match) {
return null
}
// if the passed RegExp script selector includes flag, report the error because RegExp flag is not useful for script selector and pnpm does not support this.
if (match[2]) {
throw new PnpmError('UNSUPPORTED_SCRIPT_COMMAND_FORMAT', 'RegExp flags are not supported in script command selector')
}
try {
return new RegExp(match[1])
} catch {
return null
}
}

View File

@@ -1,4 +1,5 @@
import path from 'path'
import pLimit from 'p-limit'
import {
docsUrl,
readProjectManifestOnly,
@@ -13,11 +14,11 @@ import {
makeNodeRequireOption,
RunLifecycleHookOptions,
} from '@pnpm/lifecycle'
import { ProjectManifest } from '@pnpm/types'
import { PackageScripts, ProjectManifest } from '@pnpm/types'
import pick from 'ramda/src/pick'
import realpathMissing from 'realpath-missing'
import renderHelp from 'render-help'
import { runRecursive, RecursiveRunOpts } from './runRecursive'
import { runRecursive, RecursiveRunOpts, getSpecifiedScripts as getSpecifiedScriptWithoutStartCommand } from './runRecursive'
import { existsInDir } from './existsInDir'
import { handler as exec } from './exec'
@@ -43,6 +44,11 @@ export const RESUME_FROM_OPTION_HELP = {
name: '--resume-from',
}
export const SEQUENTIAL_OPTION_HELP = {
description: 'Run the specified scripts one by one',
name: '--sequential',
}
export const shorthands = {
parallel: [
'--workspace-concurrency=Infinity',
@@ -50,6 +56,9 @@ export const shorthands = {
'--stream',
'--recursive',
],
sequential: [
'--workspace-concurrency=1',
],
}
export function rcOptionsTypes () {
@@ -112,6 +121,7 @@ For options that may be used with `-r`, see "pnpm help recursive"',
PARALLEL_OPTION_HELP,
RESUME_FROM_OPTION_HELP,
...UNIVERSAL_OPTIONS,
SEQUENTIAL_OPTION_HELP,
],
},
FILTERING,
@@ -159,7 +169,10 @@ export async function handler (
: undefined
return printProjectCommands(manifest, rootManifest ?? undefined)
}
if (scriptName !== 'start' && !manifest.scripts?.[scriptName]) {
const specifiedScripts = getSpecifiedScripts(manifest.scripts ?? {}, scriptName)
if (specifiedScripts.length < 1) {
if (opts.ifPresent) return
if (opts.fallbackCommandUsed) {
if (opts.argv == null) throw new Error('Could not fallback because opts.argv.original was not passed to the script runner')
@@ -170,9 +183,9 @@ export async function handler (
}
if (opts.workspaceDir) {
const { manifest: rootManifest } = await tryReadProjectManifest(opts.workspaceDir, opts)
if (rootManifest?.scripts?.[scriptName]) {
if (getSpecifiedScripts(rootManifest?.scripts ?? {}, scriptName).length > 0 && specifiedScripts.length < 1) {
throw new PnpmError('NO_SCRIPT', `Missing script: ${scriptName}`, {
hint: `But ${scriptName} is present in the root of the workspace,
hint: `But script matched with ${scriptName} is present in the root of the workspace,
so you may run "pnpm -w run ${scriptName}"`,
})
}
@@ -203,21 +216,11 @@ so you may run "pnpm -w run ${scriptName}"`,
}
}
try {
if (
opts.enablePrePostScripts &&
manifest.scripts?.[`pre${scriptName}`] &&
!manifest.scripts[scriptName].includes(`pre${scriptName}`)
) {
await runLifecycleHook(`pre${scriptName}`, manifest, lifecycleOpts)
}
await runLifecycleHook(scriptName, manifest, { ...lifecycleOpts, args: passedThruArgs })
if (
opts.enablePrePostScripts &&
manifest.scripts?.[`post${scriptName}`] &&
!manifest.scripts[scriptName].includes(`post${scriptName}`)
) {
await runLifecycleHook(`post${scriptName}`, manifest, lifecycleOpts)
}
const limitRun = pLimit(opts.workspaceConcurrency ?? 4)
const _runScript = runScript.bind(null, { manifest, lifecycleOpts, runScriptOptions: { enablePrePostScripts: opts.enablePrePostScripts ?? false }, passedThruArgs })
await Promise.all(specifiedScripts.map(script => limitRun(() => _runScript(script))))
} catch (err: any) { // eslint-disable-line
if (opts.bail !== false) {
throw err
@@ -300,6 +303,48 @@ ${renderCommands(rootScripts)}`
return output
}
export interface RunScriptOptions {
enablePrePostScripts: boolean
}
export const runScript: (opts: {
manifest: ProjectManifest
lifecycleOpts: RunLifecycleHookOptions
runScriptOptions: RunScriptOptions
passedThruArgs: string[]
}, scriptName: string) => Promise<void> = async function (opts, scriptName) {
if (
opts.runScriptOptions.enablePrePostScripts &&
opts.manifest.scripts?.[`pre${scriptName}`] &&
!opts.manifest.scripts[scriptName].includes(`pre${scriptName}`)
) {
await runLifecycleHook(`pre${scriptName}`, opts.manifest, opts.lifecycleOpts)
}
await runLifecycleHook(scriptName, opts.manifest, { ...opts.lifecycleOpts, args: opts.passedThruArgs })
if (
opts.runScriptOptions.enablePrePostScripts &&
opts.manifest.scripts?.[`post${scriptName}`] &&
!opts.manifest.scripts[scriptName].includes(`post${scriptName}`)
) {
await runLifecycleHook(`post${scriptName}`, opts.manifest, opts.lifecycleOpts)
}
}
function renderCommands (commands: string[][]) {
return commands.map(([scriptName, script]) => ` ${scriptName}\n ${script}`).join('\n')
}
function getSpecifiedScripts (scripts: PackageScripts, scriptName: string) {
const specifiedSelector = getSpecifiedScriptWithoutStartCommand(scripts, scriptName)
if (specifiedSelector.length > 0) {
return specifiedSelector
}
// if a user passes start command as scriptName, `node server.js` will be executed as a fallback, so return start command even if start command is not defined in package.json
if (scriptName === 'start') {
return [scriptName]
}
return []
}

View File

@@ -3,7 +3,6 @@ import { RecursiveSummary, throwOnCommandFail } from '@pnpm/cli-utils'
import { Config } from '@pnpm/config'
import { PnpmError } from '@pnpm/error'
import {
runLifecycleHook,
makeNodeRequireOption,
RunLifecycleHookOptions,
} from '@pnpm/lifecycle'
@@ -13,6 +12,9 @@ import pLimit from 'p-limit'
import realpathMissing from 'realpath-missing'
import { existsInDir } from './existsInDir'
import { getResumedPackageChunks } from './exec'
import { runScript } from './run'
import { tryBuildRegExpFromCommand } from './regexpCommand'
import { PackageScripts } from '@pnpm/types'
export type RecursiveRunOpts = Pick<Config,
| 'enablePrePostScripts'
@@ -73,7 +75,7 @@ export async function runRecursive (
const missingScriptPackages: string[] = packageChunks
.flat()
.map((prefix) => opts.selectedProjectsGraph[prefix])
.filter((pkg) => !pkg.package.manifest.scripts?.[scriptName])
.filter((pkg) => getSpecifiedScripts(pkg.package.manifest.scripts ?? {}, scriptName).length < 1)
.map((pkg) => pkg.package.manifest.name ?? pkg.package.dir)
if (missingScriptPackages.length) {
throw new PnpmError('RECURSIVE_RUN_NO_SCRIPT', `Missing script "${scriptName}" in packages: ${missingScriptPackages.join(', ')}`)
@@ -81,7 +83,14 @@ export async function runRecursive (
}
for (const chunk of packageChunks) {
await Promise.all(chunk.map(async (prefix: string) =>
const selectedScripts = chunk.map(prefix => {
const pkg = opts.selectedProjectsGraph[prefix]
const specifiedScripts = getSpecifiedScripts(pkg.package.manifest.scripts ?? {}, scriptName)
return specifiedScripts.map(script => ({ prefix, scriptName: script }))
}).flat()
await Promise.all(selectedScripts.map(async ({ prefix, scriptName }) =>
limitRun(async () => {
const pkg = opts.selectedProjectsGraph[prefix]
if (
@@ -113,21 +122,9 @@ export async function runRecursive (
...makeNodeRequireOption(pnpPath),
}
}
if (
opts.enablePrePostScripts &&
pkg.package.manifest.scripts?.[`pre${scriptName}`] &&
!pkg.package.manifest.scripts[scriptName].includes(`pre${scriptName}`)
) {
await runLifecycleHook(`pre${scriptName}`, pkg.package.manifest, lifecycleOpts)
}
await runLifecycleHook(scriptName, pkg.package.manifest, { ...lifecycleOpts, args: passedThruArgs })
if (
opts.enablePrePostScripts &&
pkg.package.manifest.scripts?.[`post${scriptName}`] &&
!pkg.package.manifest.scripts[scriptName].includes(`post${scriptName}`)
) {
await runLifecycleHook(`post${scriptName}`, pkg.package.manifest, lifecycleOpts)
}
const _runScript = runScript.bind(null, { manifest: pkg.package.manifest, lifecycleOpts, runScriptOptions: { enablePrePostScripts: opts.enablePrePostScripts ?? false }, passedThruArgs })
await _runScript(scriptName)
result.passes++
} catch (err: any) { // eslint-disable-line
logger.info(err)
@@ -164,3 +161,20 @@ export async function runRecursive (
throwOnCommandFail('pnpm recursive run', result)
}
export function getSpecifiedScripts (scripts: PackageScripts, scriptName: string) {
// if scripts in package.json has script which is equal to scriptName a user passes, return it.
if (scripts[scriptName]) {
return [scriptName]
}
const scriptSelector = tryBuildRegExpFromCommand(scriptName)
// if scriptName which a user passes is RegExp (like /build:.*/), multiple scripts to execute will be selected with RegExp
if (scriptSelector) {
const scriptKeys = Object.keys(scripts)
return scriptKeys.filter(script => script.match(scriptSelector))
}
return []
}

View File

@@ -411,7 +411,7 @@ test('if a script is not found but is present in the root, print an info message
}
expect(err).toBeTruthy()
expect(err.hint).toMatch(/But build is present in the root/)
expect(err.hint).toMatch(/But script matched with build is present in the root/)
})
test('scripts work with PnP', async () => {
@@ -465,3 +465,123 @@ test('pnpm run with custom shell', async () => {
expect((await import(path.resolve('shell-input.json'))).default).toStrictEqual(['-c', 'foo bar'])
})
test('pnpm run with RegExp script selector should work', async () => {
prepare({
scripts: {
'build:a': 'node -e "require(\'fs\').writeFileSync(\'./output-build-a.txt\', \'a\', \'utf8\')"',
'build:b': 'node -e "require(\'fs\').writeFileSync(\'./output-build-b.txt\', \'b\', \'utf8\')"',
'build:c': 'node -e "require(\'fs\').writeFileSync(\'./output-build-c.txt\', \'c\', \'utf8\')"',
build: 'node -e "require(\'fs\').writeFileSync(\'./output-build-a.txt\', \'should not run\', \'utf8\')"',
'lint:a': 'node -e "require(\'fs\').writeFileSync(\'./output-lint-a.txt\', \'a\', \'utf8\')"',
'lint:b': 'node -e "require(\'fs\').writeFileSync(\'./output-lint-b.txt\', \'b\', \'utf8\')"',
'lint:c': 'node -e "require(\'fs\').writeFileSync(\'./output-lint-c.txt\', \'c\', \'utf8\')"',
lint: 'node -e "require(\'fs\').writeFileSync(\'./output-lint-a.txt\', \'should not run\', \'utf8\')"',
},
})
await run.handler({
dir: process.cwd(),
extraBinPaths: [],
extraEnv: {},
rawConfig: {},
}, ['/^(lint|build):.*/'])
expect(await fs.readFile('output-build-a.txt', { encoding: 'utf-8' })).toEqual('a')
expect(await fs.readFile('output-build-b.txt', { encoding: 'utf-8' })).toEqual('b')
expect(await fs.readFile('output-build-c.txt', { encoding: 'utf-8' })).toEqual('c')
expect(await fs.readFile('output-lint-a.txt', { encoding: 'utf-8' })).toEqual('a')
expect(await fs.readFile('output-lint-b.txt', { encoding: 'utf-8' })).toEqual('b')
expect(await fs.readFile('output-lint-c.txt', { encoding: 'utf-8' })).toEqual('c')
})
test('pnpm run with RegExp script selector should work also for pre/post script', async () => {
prepare({
scripts: {
'build:a': 'node -e "require(\'fs\').writeFileSync(\'./output-a.txt\', \'a\', \'utf8\')"',
'prebuild:a': 'node -e "require(\'fs\').writeFileSync(\'./output-pre-a.txt\', \'pre-a\', \'utf8\')"',
},
})
await run.handler({
dir: process.cwd(),
extraBinPaths: [],
extraEnv: {},
rawConfig: {},
enablePrePostScripts: true,
}, ['/build:.*/'])
expect(await fs.readFile('output-a.txt', { encoding: 'utf-8' })).toEqual('a')
expect(await fs.readFile('output-pre-a.txt', { encoding: 'utf-8' })).toEqual('pre-a')
})
test('pnpm run with RegExp script selector should work parallel as a default behavior (parallel execution limits number is four)', async () => {
prepare({
scripts: {
'build:a': 'node -e "let i = 20;setInterval(() => {if (!--i) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-a.json\');},50)"',
'build:b': 'node -e "let i = 40;setInterval(() => {if (!--i) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-b.json\');},25)"',
},
})
await execa('pnpm', ['add', 'json-append@1'])
await run.handler({
dir: process.cwd(),
extraBinPaths: [],
extraEnv: {},
rawConfig: {},
}, ['/build:.*/'])
const { default: outputsA } = await import(path.resolve('output-a.json'))
const { default: outputsB } = await import(path.resolve('output-b.json'))
expect(Math.max(outputsA[0], outputsB[0]) < Math.min(outputsA[outputsA.length - 1], outputsB[outputsB.length - 1])).toBeTruthy()
})
test('pnpm run with RegExp script selector should work sequentially with --workspace-concurrency=1', async () => {
prepare({
scripts: {
'build:a': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-a.json\');},16)"',
'build:b': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-b.json\');},16)"',
},
})
await execa('pnpm', ['add', 'json-append@1'])
await run.handler({
dir: process.cwd(),
extraBinPaths: [],
extraEnv: {},
rawConfig: {},
workspaceConcurrency: 1,
}, ['/build:.*/'])
const { default: outputsA } = await import(path.resolve('output-a.json'))
const { default: outputsB } = await import(path.resolve('output-b.json'))
expect(outputsA[0] < outputsB[0] && outputsA[1] < outputsB[1]).toBeTruthy()
})
test('pnpm run with RegExp script selector with flag should throw error', async () => {
prepare({
scripts: {
'build:a': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-a.json\');},16)"',
'build:b': 'node -e "let i = 2;setInterval(() => {if (!i--) process.exit(0); require(\'json-append\').append(Date.now(),\'./output-b.json\');},16)"',
},
})
let err!: Error
try {
await run.handler({
dir: process.cwd(),
extraBinPaths: [],
extraEnv: {},
rawConfig: {},
workspaceConcurrency: 1,
}, ['/build:.*/i'])
} catch (_err: any) { // eslint-disable-line
err = _err
}
expect(err.message).toBe('RegExp flags are not supported in script command selector')
})

View File

@@ -1,3 +1,4 @@
import { promises as fs } from 'fs'
import path from 'path'
import { preparePackages } from '@pnpm/prepare'
import { run } from '@pnpm/plugin-commands-script-runners'
@@ -890,3 +891,89 @@ test('`pnpm -r --resume-from run` should executed from given package', async ()
expect(output1).toContain('project-2')
expect(output1).toContain('project-3')
})
test('pnpm run with RegExp script selector should work on recursive', async () => {
preparePackages([
{
name: 'project-1',
version: '1.0.0',
scripts: {
'build:a': 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-a.txt\', \'1-a\', \'utf8\')"',
'build:b': 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-b.txt\', \'1-b\', \'utf8\')"',
'build:c': 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-c.txt\', \'1-c\', \'utf8\')"',
build: 'node -e "require(\'fs\').writeFileSync(\'../output-build-1-a.txt\', \'should not run\', \'utf8\')"',
'lint:a': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-a.txt\', \'1-a\', \'utf8\')"',
'lint:b': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-b.txt\', \'1-b\', \'utf8\')"',
'lint:c': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-c.txt\', \'1-c\', \'utf8\')"',
lint: 'node -e "require(\'fs\').writeFileSync(\'../output-lint-1-a.txt\', \'should not run\', \'utf8\')"',
},
},
{
name: 'project-2',
version: '1.0.0',
scripts: {
'build:a': 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-a.txt\', \'2-a\', \'utf8\')"',
'build:b': 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-b.txt\', \'2-b\', \'utf8\')"',
'build:c': 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-c.txt\', \'2-c\', \'utf8\')"',
build: 'node -e "require(\'fs\').writeFileSync(\'../output-build-2-a.txt\', \'should not run\', \'utf8\')"',
'lint:a': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-a.txt\', \'2-a\', \'utf8\')"',
'lint:b': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-b.txt\', \'2-b\', \'utf8\')"',
'lint:c': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-c.txt\', \'2-c\', \'utf8\')"',
lint: 'node -e "require(\'fs\').writeFileSync(\'../output-lint-2-a.txt\', \'should not run\', \'utf8\')"',
},
},
{
name: 'project-3',
version: '1.0.0',
scripts: {
'build:a': 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-a.txt\', \'3-a\', \'utf8\')"',
'build:b': 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-b.txt\', \'3-b\', \'utf8\')"',
'build:c': 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-c.txt\', \'3-c\', \'utf8\')"',
build: 'node -e "require(\'fs\').writeFileSync(\'../output-build-3-a.txt\', \'should not run\', \'utf8\')"',
'lint:a': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-a.txt\', \'3-a\', \'utf8\')"',
'lint:b': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-b.txt\', \'3-b\', \'utf8\')"',
'lint:c': 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-c.txt\', \'3-c\', \'utf8\')"',
lint: 'node -e "require(\'fs\').writeFileSync(\'../output-lint-3-a.txt\', \'should not run\', \'utf8\')"',
},
},
])
await execa(pnpmBin, [
'install',
'-r',
'--registry',
REGISTRY_URL,
'--store-dir',
path.resolve(DEFAULT_OPTS.storeDir),
])
await run.handler({
...DEFAULT_OPTS,
...await readProjects(process.cwd(), [{ namePattern: '*' }]),
dir: process.cwd(),
recursive: true,
rootProjectManifest: {
name: 'test-workspaces',
private: true,
},
workspaceDir: process.cwd(),
}, ['/^(lint|build):.*/'])
expect(await fs.readFile('output-build-1-a.txt', { encoding: 'utf-8' })).toEqual('1-a')
expect(await fs.readFile('output-build-1-b.txt', { encoding: 'utf-8' })).toEqual('1-b')
expect(await fs.readFile('output-build-1-c.txt', { encoding: 'utf-8' })).toEqual('1-c')
expect(await fs.readFile('output-build-2-a.txt', { encoding: 'utf-8' })).toEqual('2-a')
expect(await fs.readFile('output-build-2-b.txt', { encoding: 'utf-8' })).toEqual('2-b')
expect(await fs.readFile('output-build-2-c.txt', { encoding: 'utf-8' })).toEqual('2-c')
expect(await fs.readFile('output-build-3-a.txt', { encoding: 'utf-8' })).toEqual('3-a')
expect(await fs.readFile('output-build-3-b.txt', { encoding: 'utf-8' })).toEqual('3-b')
expect(await fs.readFile('output-build-3-c.txt', { encoding: 'utf-8' })).toEqual('3-c')
expect(await fs.readFile('output-lint-1-a.txt', { encoding: 'utf-8' })).toEqual('1-a')
expect(await fs.readFile('output-lint-1-b.txt', { encoding: 'utf-8' })).toEqual('1-b')
expect(await fs.readFile('output-lint-1-c.txt', { encoding: 'utf-8' })).toEqual('1-c')
expect(await fs.readFile('output-lint-2-a.txt', { encoding: 'utf-8' })).toEqual('2-a')
expect(await fs.readFile('output-lint-2-b.txt', { encoding: 'utf-8' })).toEqual('2-b')
expect(await fs.readFile('output-lint-2-c.txt', { encoding: 'utf-8' })).toEqual('2-c')
expect(await fs.readFile('output-lint-3-a.txt', { encoding: 'utf-8' })).toEqual('3-a')
expect(await fs.readFile('output-lint-3-b.txt', { encoding: 'utf-8' })).toEqual('3-b')
expect(await fs.readFile('output-lint-3-c.txt', { encoding: 'utf-8' })).toEqual('3-c')
})