Skip to content

Commit c112b61

Browse files
authored
feat(install): add --dry-run option (npm-style preview) (#12449)
## Description Adds a `--dry-run` option to `pnpm install` with **npm-style preview semantics**: it runs a full dependency resolution and reports what a real install **would** add/remove/update, but writes **nothing** to disk (no lockfile, no `node_modules`, no `.modules.yaml`, no workspace-state file) and **always exits 0**. ``` $ pnpm install --dry-run Dry run complete. A real install would make the following changes (nothing was written to disk): Importers . + is-negative 1.0.0 Packages + is-negative@1.0.0 ``` When the lockfile is already up to date it prints `Dry run complete. pnpm-lock.yaml is up to date; a real install would make no changes.` Resolves #7340. ### Why this shape An earlier attempt (#12270, now closed) implemented `--dry-run` as `--frozen-lockfile --lockfile-only` — i.e. a fail-on-drift *lockfile validator*. That collides with the well-established meaning of `--dry-run` across npm/yarn ("preview, never fail") and duplicated existing behaviour (`pnpm install --frozen-lockfile --lockfile-only` already does that). This PR implements the intuitive preview meaning instead. ### How it works (pnpm) - Reuses the existing `lockfileCheck` callback (resolve fully, skip the lockfile write, hand back the before/after wanted lockfile) plus `lockfileOnly` (skip `node_modules`, the workspace-state file, and metadata-cache writes). - The frozen/headless fast path is disabled whenever `lockfileCheck` is set, so a check-only install always resolves and never materialises anything. - The before/after lockfiles are diffed (reusing the dedupe diff engine, now exported as `calcDedupeCheckIssues`) and rendered into the report. - `--dry-run` with a configured pnpr server is rejected (that path resolves/links through the server). ### Pacquet Ported in the second commit — `pacquet install --dry-run` forces the fresh-resolve path, skips every write (a new `dry_run` flag on `InstallWithFreshLockfile` skips the `pnpm-lock.yaml` save), and a new `dry_run` module diffs the existing lockfile against the freshly-resolved one and prints the same report.
1 parent 7cd5594 commit c112b61

33 files changed

Lines changed: 1087 additions & 49 deletions

File tree

.changeset/dry-run-install.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@pnpm/installing.dedupe.check": minor
3+
"@pnpm/installing.deps-installer": patch
4+
"@pnpm/installing.commands": minor
5+
"pnpm": minor
6+
---
7+
8+
Added a `--dry-run` option to `pnpm install`. It runs a full dependency resolution and reports what an install would change, but writes nothing to disk (no lockfile, no `node_modules`) and always exits with code 0. This mirrors the preview semantics of `npm install --dry-run` [#7340](https://github.com/pnpm/pnpm/issues/7340).

config/reader/src/Config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ export interface Config extends OptionsFromRootManifest {
8787
filter: string[]
8888
filterProd: string[]
8989
authConfig: Record<string, any>, // eslint-disable-line
90-
dryRun?: boolean // This option might be not supported ever
90+
/** When true, `pnpm install` resolves and reports what would change but writes nothing to disk. */
91+
dryRun?: boolean
9192
global?: boolean
9293
dir: string
9394
bin: string

installing/commands/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@pnpm/hooks.pnpmfile": "workspace:*",
5858
"@pnpm/installing.context": "workspace:*",
5959
"@pnpm/installing.dedupe.check": "workspace:*",
60+
"@pnpm/installing.dedupe.issues-renderer": "workspace:*",
6061
"@pnpm/installing.deps-installer": "workspace:*",
6162
"@pnpm/installing.env-installer": "workspace:*",
6263
"@pnpm/lockfile.fs": "workspace:*",

installing/commands/src/add.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,20 +305,25 @@ export async function handler (
305305
for (const pkg of opts.allowBuild) {
306306
mergedAllowBuilds[pkg] = true
307307
}
308-
return installDeps({
308+
await installDeps({
309309
...opts,
310310
allowBuilds: mergedAllowBuilds,
311311
rebuildHandler: commands?.rebuild,
312312
fetchFullMetadata: getFetchFullMetadata(opts),
313313
include,
314314
includeDirect: include,
315+
// `--dry-run` is an `install`-only preview; never let a config-level
316+
// `dry-run` turn `add` into a no-op check.
317+
dryRun: false,
315318
}, params)
319+
return
316320
}
317-
return installDeps({
321+
await installDeps({
318322
...opts,
319323
rebuildHandler: commands?.rebuild,
320324
fetchFullMetadata: getFetchFullMetadata(opts),
321325
include,
322326
includeDirect: include,
327+
dryRun: false,
323328
}, params)
324329
}

installing/commands/src/dedupe.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,14 @@ export async function handler (opts: DedupeCommandOptions, _params?: string[], c
6161
devDependencies: opts.dev !== false,
6262
optionalDependencies: opts.optional !== false,
6363
}
64-
return installDeps({
64+
await installDeps({
6565
...opts,
6666
rebuildHandler: commands?.rebuild,
6767
dedupe: true,
6868
include,
6969
includeDirect: include,
7070
lockfileCheck: opts.check ? dedupeDiffCheck : undefined,
71+
// `--dry-run` is an `install`-only preview; `dedupe` has its own `--check`.
72+
dryRun: false,
7173
}, [])
7274
}

installing/commands/src/install.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { docsUrl } from '@pnpm/cli.utils'
44
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
55
import { WANTED_LOCKFILE } from '@pnpm/constants'
66
import { PnpmError } from '@pnpm/error'
7+
import { calcDedupeCheckIssues, countDedupeCheckIssues } from '@pnpm/installing.dedupe.check'
8+
import { renderDedupeCheckIssues } from '@pnpm/installing.dedupe.issues-renderer'
9+
import type { DryRunInstallResult } from '@pnpm/installing.deps-installer'
710
import type { CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
811
import { pick } from 'ramda'
912
import { renderHelp } from 'render-help'
@@ -84,6 +87,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
8487
export const cliOptionsTypes = (): Record<string, unknown> => ({
8588
...rcOptionsTypes(),
8689
...pick(['force'], allTypes),
90+
'dry-run': Boolean,
8791
'fix-lockfile': Boolean,
8892
'update-checksums': Boolean,
8993
'resolution-only': Boolean,
@@ -138,6 +142,10 @@ For options that may be used with `-r`, see "pnpm help recursive"',
138142
description: 'Skip reinstall if the workspace state is up-to-date',
139143
name: '--optimistic-repeat-install',
140144
},
145+
{
146+
description: 'Report what an install would change without writing anything to disk (no lockfile, no node_modules). Resolution still runs against the registry.',
147+
name: '--dry-run',
148+
},
141149
{
142150
description: '`optionalDependencies` are not installed',
143151
name: '--no-optional',
@@ -304,6 +312,7 @@ export type InstallCommandOptions = Pick<Config,
304312
| 'deployAllFiles'
305313
| 'depth'
306314
| 'dev'
315+
| 'dryRun'
307316
| 'enableGlobalVirtualStore'
308317
| 'engineStrict'
309318
| 'excludeLinksFromLockfile'
@@ -389,7 +398,7 @@ export type InstallCommandOptions = Pick<Config,
389398
pnpmfile: string[]
390399
} & Partial<Pick<Config, 'ci' | 'modulesCacheMaxAge' | 'pnpmHomeDir' | 'preferWorkspacePackages' | 'strictDepBuilds' | 'useLockfile' | 'symlink'>>
391400

392-
export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }, _params?: string[], commands?: CommandHandlerMap): Promise<void> {
401+
export async function handler (opts: InstallCommandOptions & { _calledFromLink?: boolean }, _params?: string[], commands?: CommandHandlerMap): Promise<void | string> {
393402
if (opts.global && !opts._calledFromLink) {
394403
throw new PnpmError('GLOBAL_INSTALL_NOT_SUPPORTED',
395404
'"pnpm install -g" is not supported. Use "pnpm add -g <pkg>" to install global packages.')
@@ -416,5 +425,50 @@ export async function handler (opts: InstallCommandOptions & { _calledFromLink?:
416425
installDepsOptions.lockfileOnly = true
417426
installDepsOptions.forceFullResolution = true
418427
}
419-
return installDeps(installDepsOptions, [])
428+
if (opts.dryRun) {
429+
return dryRunInstall(installDepsOptions, opts)
430+
}
431+
await installDeps(installDepsOptions, [])
432+
}
433+
434+
/**
435+
* Runs a full resolution but writes nothing to disk (no lockfile, no
436+
* `node_modules`), then reports what a real install would change. Exits
437+
* successfully regardless of whether changes were found — mirroring the
438+
* preview semantics of `npm install --dry-run`.
439+
*/
440+
async function dryRunInstall (installDepsOptions: InstallDepsOptions, opts: InstallCommandOptions): Promise<string> {
441+
if (opts.pnprServer) {
442+
throw new PnpmError('CONFIG_CONFLICT_DRY_RUN_WITH_PNPR_SERVER',
443+
'Cannot use --dry-run with a configured pnpr server because the pnpr install path resolves and links through the server')
444+
}
445+
// `dryRun` makes the installer resolve fully and return the before/after
446+
// wanted lockfile without writing anything. `lockfileOnly` keeps it from
447+
// materializing `node_modules` and skips the metadata cache (resolution
448+
// skips fetching). The optimistic fast path is disabled so resolution
449+
// always runs.
450+
installDepsOptions.optimisticRepeatInstall = false
451+
installDepsOptions.lockfileOnly = true
452+
installDepsOptions.dryRun = true
453+
const dryRunResult = await installDeps(installDepsOptions, [])
454+
if (dryRunResult == null) {
455+
// No comparison was produced — this install configuration's resolve path
456+
// doesn't surface the dry-run lockfiles (e.g. a workspace without a
457+
// shared lockfile). Report that explicitly instead of claiming "up to
458+
// date", but keep `--dry-run`'s exit-0 contract.
459+
return 'Dry run complete. Could not compute the changes for this install configuration (no shared lockfile to compare).'
460+
}
461+
return renderDryRunReport(dryRunResult)
462+
}
463+
464+
function renderDryRunReport (dryRunResult: DryRunInstallResult): string {
465+
const issues = calcDedupeCheckIssues(dryRunResult.originalLockfile, dryRunResult.wantedLockfile, { includeImporterSpecifiers: true })
466+
if (countDedupeCheckIssues(issues) === 0) {
467+
return `Dry run complete. ${WANTED_LOCKFILE} is up to date; a real install would make no changes.`
468+
}
469+
return [
470+
'Dry run complete. A real install would make the following changes (nothing was written to disk):',
471+
'',
472+
renderDedupeCheckIssues(issues),
473+
].join('\n')
420474
}

installing/commands/src/installDeps.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { checkDepsStatus } from '@pnpm/deps.status'
1313
import { PnpmError } from '@pnpm/error'
1414
import { arrayOfWorkspacePackagesToMap } from '@pnpm/installing.context'
1515
import {
16+
type DryRunInstallResult,
1617
install,
1718
mutateModulesInSingleProject,
1819
type MutateModulesOptions,
@@ -175,12 +176,12 @@ export type InstallDepsOptions = Pick<Config,
175176
* subcommand — see `runPacquet.ts`'s `noRuntime` opt.
176177
*/
177178
isInstallCommand?: boolean
178-
} & Partial<Pick<Config, 'pnpmHomeDir' | 'strictDepBuilds' | 'useLockfile' | 'useGitBranchLockfile'>>
179+
} & Partial<Pick<Config, 'dryRun' | 'pnpmHomeDir' | 'strictDepBuilds' | 'useLockfile' | 'useGitBranchLockfile'>>
179180

180181
export async function installDeps (
181182
opts: InstallDepsOptions,
182183
params: string[]
183-
): Promise<void> {
184+
): Promise<DryRunInstallResult | undefined> {
184185
if (!opts.update && !opts.dedupe && params.length === 0 && opts.optimisticRepeatInstall) {
185186
const { upToDate, wantedLockfileToRestore } = await checkDepsStatus({
186187
...opts,
@@ -290,7 +291,7 @@ export async function installDeps (
290291
linkWorkspacePackages: Boolean(opts.linkWorkspacePackages),
291292
}).graph
292293

293-
await recursiveInstallThenUpdateWorkspaceState(allProjects,
294+
return recursiveInstallThenUpdateWorkspaceState(allProjects,
294295
params,
295296
{
296297
...opts,
@@ -303,7 +304,6 @@ export async function installDeps (
303304
},
304305
opts.update ? 'update' : (params.length === 0 ? 'install' : 'add')
305306
)
306-
return
307307
}
308308
}
309309
// `pnpm install ""` is going to be just `pnpm install`
@@ -408,8 +408,8 @@ export async function installDeps (
408408
rootDir: opts.dir as ProjectRootDir,
409409
targetDependenciesField: getSaveType(opts),
410410
}
411-
const { updatedCatalogs, updatedProject, ignoredBuilds, resolutionPolicyViolations } = await mutateModulesInSingleProject(mutatedProject, installOpts)
412-
if (opts.save !== false) {
411+
const { updatedCatalogs, updatedProject, ignoredBuilds, resolutionPolicyViolations, dryRunResult } = await mutateModulesInSingleProject(mutatedProject, installOpts)
412+
if (opts.save !== false && !opts.dryRun) {
413413
// Only pick entries when we'll actually persist. Otherwise the
414414
// info log would claim we added entries the workspace manifest
415415
// never saw, and the next install would re-prompt or fail
@@ -436,10 +436,10 @@ export async function installDeps (
436436
})
437437
}
438438
await handleIgnoredBuilds(opts, ignoredBuilds)
439-
return
439+
return dryRunResult
440440
}
441441

442-
const { updatedCatalogs, updatedManifest, ignoredBuilds, resolutionPolicyViolations } = await install(manifest, {
442+
const { updatedCatalogs, updatedManifest, ignoredBuilds, resolutionPolicyViolations, dryRunResult } = await install(manifest, {
443443
...installOpts,
444444
updatePackageManifest,
445445
updateMatching,
@@ -448,7 +448,7 @@ export async function installDeps (
448448
// from this install" — both package.json and the workspace manifest.
449449
// Skip the pick so the info log doesn't claim entries were added that
450450
// were never written; the next install will resurface them.
451-
if (opts.save !== false) {
451+
if (opts.save !== false && !opts.dryRun) {
452452
const policyUpdates = policyHandlers?.pickManifestUpdates(resolutionPolicyViolations)
453453
if (opts.update === true) {
454454
await Promise.all([
@@ -518,6 +518,7 @@ export async function installDeps (
518518
})
519519
}
520520
}
521+
return dryRunResult
521522
}
522523

523524
function selectProjectByDir (projects: Project[], searchedDir: string): ProjectsGraph | undefined {
@@ -532,7 +533,7 @@ async function recursiveInstallThenUpdateWorkspaceState (
532533
opts: RecursiveOptions & WorkspaceStateSettings,
533534
cmdFullName: CommandFullName,
534535
updatedCatalogs?: Catalogs
535-
): Promise<boolean | string> {
536+
): Promise<DryRunInstallResult | undefined> {
536537
const recursiveResult = await recursive(allProjects, params, opts, cmdFullName)
537538
if (!opts.lockfileOnly) {
538539
await updateWorkspaceState({
@@ -544,7 +545,7 @@ async function recursiveInstallThenUpdateWorkspaceState (
544545
configDependencies: opts.configDependencies,
545546
})
546547
}
547-
return recursiveResult.passed
548+
return recursiveResult.dryRunResult
548549
}
549550

550551
/**

installing/commands/src/prune.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function help (): string {
4848
export async function handler (
4949
opts: install.InstallCommandOptions
5050
): Promise<void> {
51-
return install.handler({
51+
await install.handler({
5252
...opts,
5353
modulesCacheMaxAge: 0,
5454
pruneDirectDependencies: true,

installing/commands/src/recursive.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { requireHooks } from '@pnpm/hooks.pnpmfile'
2222
import { arrayOfWorkspacePackagesToMap } from '@pnpm/installing.context'
2323
import {
2424
addDependenciesToPackage,
25+
type DryRunInstallResult,
2526
install,
2627
type InstallOptions,
2728
type MutatedProject,
@@ -65,6 +66,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
6566
| 'dedupePeerDependents'
6667
| 'dedupePeers'
6768
| 'depth'
69+
| 'dryRun'
6870
| 'globalPnpmfile'
6971
| 'hoistPattern'
7072
| 'hoistingLimits'
@@ -153,6 +155,11 @@ export interface RecursiveResult {
153155
* cache so that reverting a catalog entry is detected as an outdated state.
154156
*/
155157
updatedCatalogs?: Catalogs
158+
/**
159+
* Present only for a `dryRun` install over a shared workspace lockfile:
160+
* the before/after wanted lockfiles for the caller to diff.
161+
*/
162+
dryRunResult?: DryRunInstallResult
156163
}
157164

158165
export async function recursive (
@@ -329,12 +336,13 @@ export async function recursive (
329336
updatedProjects: mutatedPkgs,
330337
ignoredBuilds,
331338
resolutionPolicyViolations,
339+
dryRunResult,
332340
} = await mutateModules(mutatedImporters, {
333341
...installOpts,
334342
storeController: store.ctrl,
335343
resolutionVerifiers: store.resolutionVerifiers,
336344
})
337-
if (opts.save !== false) {
345+
if (opts.save !== false && !opts.dryRun) {
338346
// Only pick entries when we'll actually persist. Otherwise the
339347
// info log would claim entries were added that the workspace
340348
// manifest never saw, and the next install would re-prompt or
@@ -352,7 +360,7 @@ export async function recursive (
352360
await Promise.all(promises)
353361
}
354362
await handleIgnoredBuilds(opts, ignoredBuilds)
355-
return { passed: true, updatedCatalogs }
363+
return { passed: true, updatedCatalogs, dryRunResult }
356364
}
357365

358366
const pkgPaths = (Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]).sort()

installing/commands/src/remove.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ export async function handler (
191191
storeDir: store.dir,
192192
resolutionVerifiers: store.resolutionVerifiers,
193193
include,
194+
// `--dry-run` is an `install`-only preview; never let a config-level
195+
// `dry-run` turn `remove` into a no-op check.
196+
dryRun: false,
194197
})
195198
const allProjects = opts.allProjects ?? (
196199
opts.workspaceDir

0 commit comments

Comments
 (0)