diff --git a/.changeset/frozen-lockfile-config-deps.md b/.changeset/frozen-lockfile-config-deps.md new file mode 100644 index 0000000000..eae2ddee7e --- /dev/null +++ b/.changeset/frozen-lockfile-config-deps.md @@ -0,0 +1,6 @@ +--- +"@pnpm/installing.env-installer": minor +"pnpm": minor +--- + +Throws `FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE` when attempting to install configuration dependencies with `--frozen-lockfile` active and the env lockfile is missing or out-of-date. Previously, the operation would silently rewrite the workspace file or resolve in-memory. diff --git a/AGENTS.md b/AGENTS.md index 027e01cba2..23b6af30a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Example: ``` --- -"@pnpm/installing.deps-installer"lling.deps-installer": minor +"@pnpm/installing.deps-installer": minor "pnpm": minor --- diff --git a/installing/env-installer/src/installConfigDeps.ts b/installing/env-installer/src/installConfigDeps.ts index 149e3bb1fe..a83de1f7ac 100644 --- a/installing/env-installer/src/installConfigDeps.ts +++ b/installing/env-installer/src/installConfigDeps.ts @@ -18,6 +18,7 @@ import { migrateConfigDepsToLockfile } from './migrateConfigDeps.js' import type { NormalizedConfigDep } from './parseIntegrity.js' export interface InstallConfigDepsOpts { + frozenLockfile?: boolean registries: Registries rootDir: string store: StoreController @@ -105,6 +106,9 @@ async function normalizeForInstall ( } // No env lockfile yet — migrate from old inline integrity format + if (opts.frozenLockfile) { + throw new PnpmError('FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE', 'Cannot migrate configDependencies with "frozen-lockfile" because the lockfile is not up to date') + } return migrateConfigDepsToLockfile(configDepsOrLockfile, opts) } diff --git a/installing/env-installer/src/resolveAndInstallConfigDeps.ts b/installing/env-installer/src/resolveAndInstallConfigDeps.ts index 41fb4da85e..c9e53fc3ce 100644 --- a/installing/env-installer/src/resolveAndInstallConfigDeps.ts +++ b/installing/env-installer/src/resolveAndInstallConfigDeps.ts @@ -85,6 +85,10 @@ export async function resolveAndInstallConfigDeps ( depsToResolve.push({ name, specifier }) } + if (opts.frozenLockfile && (lockfileChanged || depsToResolve.length > 0)) { + throw new PnpmError('FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE', 'Cannot update configDependencies with "frozen-lockfile" because the lockfile is not up to date') + } + if (depsToResolve.length === 0) { if (lockfileChanged) { await writeEnvLockfile(opts.rootDir, envLockfile) diff --git a/installing/env-installer/src/resolveConfigDeps.ts b/installing/env-installer/src/resolveConfigDeps.ts index ef8e76b8dc..0ab5d3736c 100644 --- a/installing/env-installer/src/resolveConfigDeps.ts +++ b/installing/env-installer/src/resolveConfigDeps.ts @@ -23,6 +23,10 @@ export type ResolveConfigDepsOpts = CreateFetchFromRegistryOptions & ResolverFac } export async function resolveConfigDeps (configDeps: string[], opts: ResolveConfigDepsOpts): Promise { + if (opts.frozenLockfile) { + throw new PnpmError('FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE', 'Cannot resolve configDependencies with "frozen-lockfile" because the lockfile is not up to date') + } + const fetch = createFetchFromRegistry(opts) const getAuthHeader = createGetAuthHeaderByURI({ allSettings: opts.userConfig!, userSettings: opts.userConfig }) const { resolveFromNpm } = createNpmResolver(fetch, getAuthHeader, opts) diff --git a/installing/env-installer/test/installConfigDeps.ts b/installing/env-installer/test/installConfigDeps.ts index 49c0514268..3b9a2db0de 100644 --- a/installing/env-installer/test/installConfigDeps.ts +++ b/installing/env-installer/test/installConfigDeps.ts @@ -139,6 +139,25 @@ test('migration: installs from old inline integrity format and creates env lockf expect((envLockfile.packages['@pnpm.e2e/foo@100.0.0'].resolution as { integrity: string }).integrity).toBe(integrity) }) +test('migration fails with frozenLockfile when no env lockfile exists', async () => { + prepareEmpty() + const { storeController, storeDir } = createTempStore() + + const integrity = getIntegrity('@pnpm.e2e/foo', '100.0.0') + const configDeps: Record = { + '@pnpm.e2e/foo': `100.0.0+${integrity}`, + } + await expect(installConfigDeps(configDeps, { + registries: { + default: registry, + }, + rootDir: process.cwd(), + store: storeController, + storeDir, + frozenLockfile: true, + })).rejects.toThrow('Cannot migrate configDependencies with "frozen-lockfile"') +}) + test('installation fails if the config dependency does not have a checksum (old format)', async () => { prepareEmpty() const { storeController, storeDir } = createTempStore({ diff --git a/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts b/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts index c1c2b65368..e63d6b26e5 100644 --- a/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts +++ b/installing/env-installer/test/resolveAndInstallConfigDeps.test.ts @@ -201,3 +201,47 @@ test('handles mixed old-format and new-format config deps together', async () => expect(envLockfile!.packages['@pnpm.e2e/foo@100.0.0']).toBeDefined() expect(envLockfile!.packages['@pnpm.e2e/bar@100.0.0']).toBeDefined() }) + +test('fails with frozenLockfile when old-format deps need migration', async () => { + prepareEmpty() + const opts = createOpts() + + const integrity = getIntegrity('@pnpm.e2e/foo', '100.0.0') + await expect(resolveAndInstallConfigDeps({ + '@pnpm.e2e/foo': `100.0.0+${integrity}`, + }, { ...opts, frozenLockfile: true })).rejects.toThrow('Cannot update configDependencies with "frozen-lockfile"') +}) + +test('fails with frozenLockfile when new-format deps need resolution', async () => { + prepareEmpty() + const opts = createOpts() + + await expect(resolveAndInstallConfigDeps({ + '@pnpm.e2e/foo': '100.0.0', + }, { ...opts, frozenLockfile: true })).rejects.toThrow('Cannot update configDependencies with "frozen-lockfile"') +}) + +test('succeeds with frozenLockfile when env lockfile is up-to-date', async () => { + prepareEmpty() + const opts = createOpts() + + // Pre-create complete env lockfile + const lockfile = createEnvLockfile() + lockfile.importers['.'].configDependencies['@pnpm.e2e/foo'] = { + specifier: '100.0.0', + version: '100.0.0', + } + lockfile.packages['@pnpm.e2e/foo@100.0.0'] = { + resolution: { integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0') }, + } + lockfile.snapshots['@pnpm.e2e/foo@100.0.0'] = {} + await writeEnvLockfile(process.cwd(), lockfile) + + await resolveAndInstallConfigDeps({ + '@pnpm.e2e/foo': '100.0.0', + }, { ...opts, frozenLockfile: true }) + + const manifest = loadJsonFileSync<{ name: string, version: string }>('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json') + expect(manifest.name).toBe('@pnpm.e2e/foo') + expect(manifest.version).toBe('100.0.0') +}) diff --git a/installing/env-installer/test/resolveConfigDeps.test.ts b/installing/env-installer/test/resolveConfigDeps.test.ts index 2f30003aa7..a9c2888f0e 100644 --- a/installing/env-installer/test/resolveConfigDeps.test.ts +++ b/installing/env-installer/test/resolveConfigDeps.test.ts @@ -44,3 +44,20 @@ test('configuration dependency is resolved', async () => { }) expect(envLockfile!.snapshots['@pnpm.e2e/foo@100.0.0']).toStrictEqual({}) }) + +test('fails with frozenLockfile', async () => { + prepareEmpty() + const { storeController, storeDir } = createTempStore() + + await expect(resolveConfigDeps(['@pnpm.e2e/foo@100.0.0'], { + registries: { + default: registry, + }, + rootDir: process.cwd(), + cacheDir: path.resolve('cache'), + userConfig: {}, + store: storeController, + storeDir, + frozenLockfile: true, + })).rejects.toThrow('Cannot resolve configDependencies with "frozen-lockfile"') +}) diff --git a/pnpm/src/getConfig.ts b/pnpm/src/getConfig.ts index 1dca256678..a6f4caa30a 100644 --- a/pnpm/src/getConfig.ts +++ b/pnpm/src/getConfig.ts @@ -47,12 +47,17 @@ export async function getConfig ( export async function installConfigDepsAndLoadHooks (config: Config): Promise { if (config.configDependencies) { const store = await createStoreController(config) - await resolveAndInstallConfigDeps(config.configDependencies, { - ...config, - store: store.ctrl, - storeDir: store.dir, - rootDir: config.lockfileDir ?? config.rootProjectManifestDir, - }) + try { + await resolveAndInstallConfigDeps(config.configDependencies, { + ...config, + store: store.ctrl, + storeDir: store.dir, + rootDir: config.lockfileDir ?? config.rootProjectManifestDir, + frozenLockfile: config.frozenLockfile, + }) + } finally { + await store.ctrl.close() + } } if (!config.ignorePnpmfile) { config.tryLoadDefaultPnpmfile = config.pnpmfile == null diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index d2b89b0033..b3aa6fbc75 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -152,6 +152,7 @@ export async function main (inputArgv: string[]): Promise { const hint = err['hint'] ? err['hint'] : `For help, run: pnpm help${cmd ? ` ${cmd}` : ''}` printError(err.message, hint) process.exitCode = 1 + await finishWorkers() return } if (cmd == null && cliOptions.version) { diff --git a/pnpm/test/install/configDeps.ts b/pnpm/test/install/configDeps.ts new file mode 100644 index 0000000000..9dafe9f655 --- /dev/null +++ b/pnpm/test/install/configDeps.ts @@ -0,0 +1,48 @@ +import { readEnvLockfile } from '@pnpm/lockfile.fs' +import { prepare } from '@pnpm/prepare' +import { getIntegrity } from '@pnpm/registry-mock' +import { writeYamlFileSync } from 'write-yaml-file' + +import { execPnpm, execPnpmSync } from '../utils/index.js' + +test('installing configDependencies migrating to env lockfile', async () => { + prepare() + writeYamlFileSync('pnpm-workspace.yaml', { + configDependencies: { + '@pnpm.e2e/foo': '100.0.0+' + getIntegrity('@pnpm.e2e/foo', '100.0.0'), + }, + }) + + await execPnpm(['install']) + + const envLockfile = await readEnvLockfile(process.cwd()) + expect(envLockfile?.importers['.'].configDependencies['@pnpm.e2e/foo'].version).toBe('100.0.0') +}) + +test('installing configDependencies fails with --frozen-lockfile if env lockfile is missing', async () => { + prepare() + writeYamlFileSync('pnpm-workspace.yaml', { + configDependencies: { + '@pnpm.e2e/foo': '100.0.0+' + getIntegrity('@pnpm.e2e/foo', '100.0.0'), + }, + }) + + const result = execPnpmSync(['install', '--frozen-lockfile']) + expect(result.status).toBe(1) + expect(result.stderr.toString()).toContain('Cannot update configDependencies with "frozen-lockfile" because the lockfile is not up to date') +}) + +test('installing configDependencies succeeds with --frozen-lockfile if env lockfile is present and up-to-date', async () => { + prepare() + writeYamlFileSync('pnpm-workspace.yaml', { + configDependencies: { + '@pnpm.e2e/foo': '100.0.0+' + getIntegrity('@pnpm.e2e/foo', '100.0.0'), + }, + }) + + // First install to generate the env lockfile + await execPnpm(['install']) + + // Second install with frozen-lockfile should succeed + await execPnpm(['install', '--frozen-lockfile']) +})