diff --git a/.changeset/silly-ties-arrive.md b/.changeset/silly-ties-arrive.md new file mode 100644 index 00000000000..0713b57909b --- /dev/null +++ b/.changeset/silly-ties-arrive.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-script-runners": minor +"pnpm": minor +--- + +`pnpm exec` and `pnpm run` command support `--resume-from` option. When used, the command will executed from given package [#4690](https://github.com/pnpm/pnpm/issues/4690). diff --git a/exec/plugin-commands-script-runners/src/exec.ts b/exec/plugin-commands-script-runners/src/exec.ts index cff0728bb92..6eadf84eafa 100644 --- a/exec/plugin-commands-script-runners/src/exec.ts +++ b/exec/plugin-commands-script-runners/src/exec.ts @@ -5,7 +5,7 @@ import { makeNodeRequireOption } from '@pnpm/lifecycle' import { logger } from '@pnpm/logger' import { tryReadProjectManifest } from '@pnpm/read-project-manifest' import { sortPackages } from '@pnpm/sort-packages' -import { Project } from '@pnpm/types' +import { Project, ProjectsGraph } from '@pnpm/types' import execa from 'execa' import pLimit from 'p-limit' import pick from 'ramda/src/pick' @@ -16,6 +16,7 @@ import { PARALLEL_OPTION_HELP, shorthands as runShorthands, } from './run' +import { PnpmError } from '@pnpm/error' export const shorthands = { parallel: runShorthands.parallel, @@ -34,6 +35,7 @@ export function rcOptionsTypes () { 'workspace-concurrency', ], types), 'shell-mode': Boolean, + 'resume-from': String, } } @@ -66,6 +68,10 @@ The shell should understand the -c switch on UNIX or /d /s /c on Windows.', name: '--shell-mode', shortAlias: '-c', }, + { + description: 'command executed from given package', + name: '--resume-from', + }, ], }, ], @@ -74,6 +80,26 @@ The shell should understand the -c switch on UNIX or /d /s /c on Windows.', }) } +export function getResumedPackageChunks ({ + resumeFrom, + chunks, + selectedProjectsGraph, +}: { + resumeFrom: string + chunks: string[][] + selectedProjectsGraph: ProjectsGraph +}) { + const resumeFromPackagePrefix = Object.keys(selectedProjectsGraph) + .find((prefix) => selectedProjectsGraph[prefix]?.package.manifest.name === resumeFrom) + + if (!resumeFromPackagePrefix) { + throw new PnpmError('RESUME_FROM_NOT_FOUND', `Cannot find package ${resumeFrom}. Could not determine where to resume from.`) + } + + const chunkPosition = chunks.findIndex(chunk => chunk.includes(resumeFromPackagePrefix)) + return chunks.slice(chunkPosition) +} + export async function handler ( opts: Required> & { bail?: boolean @@ -83,6 +109,7 @@ export async function handler ( sort?: boolean workspaceConcurrency?: number shellMode?: boolean + resumeFrom?: string } & Pick, params: string[] ) { @@ -120,6 +147,15 @@ export async function handler ( } } } + + if (opts.resumeFrom) { + chunks = getResumedPackageChunks({ + resumeFrom: opts.resumeFrom, + chunks, + selectedProjectsGraph: opts.selectedProjectsGraph, + }) + } + const existsPnp = existsInDir.bind(null, '.pnp.cjs') const workspacePnpPath = opts.workspaceDir && await existsPnp(opts.workspaceDir) @@ -136,7 +172,7 @@ export async function handler ( const env = makeEnv({ extraEnv: { ...extraEnv, - PNPM_PACKAGE_NAME: opts.selectedProjectsGraph?.[prefix]?.package.manifest.name, + PNPM_PACKAGE_NAME: opts.selectedProjectsGraph[prefix]?.package.manifest.name, }, prependPaths: [ path.join(prefix, 'node_modules/.bin'), diff --git a/exec/plugin-commands-script-runners/src/run.ts b/exec/plugin-commands-script-runners/src/run.ts index 4bc80f790b5..2505790c2a7 100644 --- a/exec/plugin-commands-script-runners/src/run.ts +++ b/exec/plugin-commands-script-runners/src/run.ts @@ -69,6 +69,7 @@ export function cliOptionsTypes () { ...IF_PRESENT_OPTION, recursive: Boolean, reverse: Boolean, + 'resume-from': String, } } diff --git a/exec/plugin-commands-script-runners/src/runRecursive.ts b/exec/plugin-commands-script-runners/src/runRecursive.ts index 96b6d9c413d..d295c7f88e6 100644 --- a/exec/plugin-commands-script-runners/src/runRecursive.ts +++ b/exec/plugin-commands-script-runners/src/runRecursive.ts @@ -12,6 +12,7 @@ import { sortPackages } from '@pnpm/sort-packages' import pLimit from 'p-limit' import realpathMissing from 'realpath-missing' import { existsInDir } from './existsInDir' +import { getResumedPackageChunks } from './exec' export type RecursiveRunOpts = Pick> & { ifPresent?: boolean + resumeFrom?: string } export async function runRecursive ( @@ -41,7 +43,15 @@ export async function runRecursive ( const sortedPackageChunks = opts.sort ? sortPackages(opts.selectedProjectsGraph) : [Object.keys(opts.selectedProjectsGraph).sort()] - const packageChunks = opts.reverse ? sortedPackageChunks.reverse() : sortedPackageChunks + let packageChunks = opts.reverse ? sortedPackageChunks.reverse() : sortedPackageChunks + + if (opts.resumeFrom) { + packageChunks = getResumedPackageChunks({ + resumeFrom: opts.resumeFrom, + chunks: packageChunks, + selectedProjectsGraph: opts.selectedProjectsGraph, + }) + } const result = { fails: [], diff --git a/exec/plugin-commands-script-runners/test/exec.e2e.ts b/exec/plugin-commands-script-runners/test/exec.e2e.ts index 33998714ecc..d53d93d0c8b 100644 --- a/exec/plugin-commands-script-runners/test/exec.e2e.ts +++ b/exec/plugin-commands-script-runners/test/exec.e2e.ts @@ -552,3 +552,112 @@ testOnPosixOnly('pnpm recursive exec works with PnP', async () => { expect(outputs1).toStrictEqual(['project-1', 'project-2-prebuild', 'project-2', 'project-2-postbuild']) expect(outputs2).toStrictEqual(['project-1', 'project-3']) }) + +test('pnpm recursive exec --resume-from should work', async () => { + preparePackages([ + { + name: 'project-1', + version: '1.0.0', + dependencies: { + 'json-append': '1', + }, + scripts: { + build: 'node -e "process.stdout.write(\'project-1\')" | json-append ../output1.json', + }, + }, + { + name: 'project-2', + version: '1.0.0', + dependencies: { + 'json-append': '1', + 'project-1': '1', + }, + scripts: { + build: 'node -e "process.stdout.write(\'project-2\')" | json-append ../output1.json', + }, + }, + { + name: 'project-3', + version: '1.0.0', + dependencies: { + 'json-append': '1', + 'project-1': '1', + }, + scripts: { + build: 'node -e "process.stdout.write(\'project-3\')" | json-append ../output1.json', + }, + }, + { + name: 'project-4', + version: '1.0.0', + dependencies: { + 'json-append': '1', + }, + scripts: { + build: 'node -e "process.stdout.write(\'project-4\')" | json-append ../output1.json', + }, + }, + ]) + + const { selectedProjectsGraph } = await readProjects(process.cwd(), []) + await execa(pnpmBin, [ + 'install', + '-r', + '--registry', + REGISTRY_URL, + '--store-dir', + path.resolve(DEFAULT_OPTS.storeDir), + ]) + await exec.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + selectedProjectsGraph, + recursive: true, + sort: true, + resumeFrom: 'project-3', + }, ['npm', 'run', 'build']) + + const { default: outputs1 } = await import(path.resolve('output1.json')) + expect(outputs1).not.toContain('project-1') + expect(outputs1).not.toContain('project-4') + expect(outputs1).toContain('project-2') + expect(outputs1).toContain('project-3') +}) + +test('should throw error when the package specified by resume-from does not exist', async () => { + preparePackages([ + { + name: 'foo', + version: '1.0.0', + dependencies: { + 'json-append': '1', + }, + scripts: { + build: 'echo foo', + }, + }, + ]) + + const { selectedProjectsGraph } = await readProjects(process.cwd(), []) + await execa(pnpmBin, [ + 'install', + '-r', + '--registry', + REGISTRY_URL, + '--store-dir', + path.resolve(DEFAULT_OPTS.storeDir), + ]) + + try { + await exec.handler({ + ...DEFAULT_OPTS, + dir: process.cwd(), + selectedProjectsGraph, + recursive: true, + sort: true, + resumeFrom: 'project-2', + }, ['npm', 'run', 'build']) + } catch (err: any) { // eslint-disable-line + expect(err.code).toBe('ERR_PNPM_RESUME_FROM_NOT_FOUND') + } +}) diff --git a/exec/plugin-commands-script-runners/test/runRecursive.ts b/exec/plugin-commands-script-runners/test/runRecursive.ts index 6db7e513ff4..2ccc80d2921 100644 --- a/exec/plugin-commands-script-runners/test/runRecursive.ts +++ b/exec/plugin-commands-script-runners/test/runRecursive.ts @@ -832,3 +832,61 @@ test('`pnpm recursive run` should fail when no script in package with requiredSc expect(err.message).toContain('Missing script "build" in packages: project-1, project-3') expect(err.code).toBe('ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT') }) + +test('`pnpm -r --resume-from run` should executed from given package', async () => { + preparePackages([ + { + name: 'project-1', + version: '1.0.0', + scripts: { + build: 'node -e "process.stdout.write(\'project-1\')" | json-append ../output1.json', + }, + dependencies: { + 'json-append': '1', + }, + }, + { + name: 'project-2', + version: '1.0.0', + scripts: { + build: 'node -e "process.stdout.write(\'project-2\')" | json-append ../output1.json', + }, + dependencies: { + 'project-1': '1', + 'json-append': '1', + }, + }, + { + name: 'project-3', + version: '1.0.0', + scripts: { + build: 'node -e "process.stdout.write(\'project-3\')" | json-append ../output1.json', + }, + dependencies: { + 'project-1': '1', + 'json-append': '1', + }, + }, + ]) + 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, + resumeFrom: 'project-3', + workspaceDir: process.cwd(), + }, ['build']) + + const { default: output1 } = await import(path.resolve('output1.json')) + expect(output1).not.toContain('project-1') + expect(output1).toContain('project-2') + expect(output1).toContain('project-3') +})