Repository navigation
Description
Verify latest release
- I verified that the issue exists in the latest pnpm release
pnpm version
12.15.0
Which area(s) of pnpm are affected? (leave empty if unsure)
Dependencies resolver
Link to the code that reproduces this issue or a replay of the bug
No response
Reproduction steps
The real use case is a build workspace that installs a local package via a relative workspace:../peer selector with injectWorkspacePackages: true. Both the consuming package and the injected package use the same named catalog. The catalog is present in the consuming package's generated pnpm-workspace.yaml. Source manifests retain their catalog: references.
This example has one importing project (app) and a sibling local package (peer). Its catalog name includes a slash, as in our build workspaces. The only registry dependency is the public colors@1.4.0 package. No private packages, project files, hooks, lifecycle scripts, or registry credentials are needed. The reproduction isolates user configuration, cache and store.
- Save the following complete script as
repro.mjs. - With pnpm 12.15.0 on
PATH, runnode repro.mjs. Alternatively pass the absolute path to a standalone native binary:node repro.mjs /absolute/path/to/pnpm. - The script creates an independent temporary directory, prints each exit status and saves the command output there. The failing cases deliberately assert the observed bug; a future fixed version will report that the case no longer reproduces.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const pnpm = process.argv[2] || 'pnpm';
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-catalog-peer-'));
const env = Object.fromEntries(Object.entries(process.env)
.filter(([key]) => !/^(npm|pnpm)_config_/i.test(key)));
const home = path.join(root, 'home');
fs.mkdirSync(home);
fs.writeFileSync(path.join(home, 'empty.npmrc'), '');
Object.assign(env, {
HOME: home, USERPROFILE: home, CI: '', NO_COLOR: '1',
XDG_CONFIG_HOME: path.join(home, 'config'),
XDG_CACHE_HOME: path.join(home, 'cache'),
XDG_DATA_HOME: path.join(home, 'data'),
XDG_STATE_HOME: path.join(home, 'state'),
npm_config_userconfig: path.join(home, 'empty.npmrc'),
});
const write = (name, value) => {
const filename = path.join(root, name);
fs.mkdirSync(path.dirname(filename), { recursive: true });
fs.writeFileSync(filename, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
};
const peerManifest = (specifier) => ({
name: 'peer', version: '1.0.0', dependencies: { colors: specifier },
});
const config = {
packages: [],
injectWorkspacePackages: true,
ignoreScripts: true,
ignorePnpmfile: true,
preferFrozenLockfile: true,
trustLockfile: true,
registry: 'https://registry.npmjs.org/',
minimumReleaseAge: 0,
catalogs: { 'project/common': { colors: '1.4.0' } },
};
const run = (label, expected, lockfileOnly = true) => {
const args = ['install', '--no-frozen-lockfile', '--reporter=append-only',
'--store-dir', path.join(root, 'store')];
if (lockfileOnly) args.push('--lockfile-only');
const result = spawnSync(pnpm, args, {
cwd: path.join(root, 'app'), env, encoding: 'utf8', timeout: 60000,
});
if (result.error) throw result.error;
console.log(`\n${label}: exit ${result.status}\n${result.stdout}${result.stderr}`);
fs.writeFileSync(path.join(root, `${label}.log`), result.stdout + result.stderr);
if (expected === 'success') {
assert.equal(result.status, 0, label);
} else {
assert.notEqual(result.status, 0, `${label}: this case no longer reproduces`);
assert.match(result.stdout + result.stderr, /ERR_PNPM_UNSUPPORTED_PROTOCOL/);
assert.match(result.stdout + result.stderr, /catalog:project\/common/);
}
};
console.log('Reproduction:', root);
console.log('Node:', process.version, 'OS:', os.platform(), os.release(), os.arch());
const version = spawnSync(pnpm, ['--version'], { env, encoding: 'utf8' });
assert.equal(version.status, 0, version.stderr);
console.log('pnpm:', version.stdout.trim());
write('app/package.json', {
name: 'app', version: '1.0.0', private: true,
dependencies: { peer: 'workspace:../peer', colors: 'catalog:project/common' },
});
write('peer/package.json', peerManifest('1.4.0'));
write('app/pnpm-workspace.yaml', config); // JSON is valid YAML.
run('1-seed-lockfile-with-literal-peer-dependency', 'success');
// Restore the real case: the injected peer itself uses the named catalog.
write('peer/package.json', peerManifest('catalog:project/common'));
run('2-catalog-peer-existing-lockfile', 'success');
config.preferFrozenLockfile = false;
write('app/pnpm-workspace.yaml', config);
run('3-catalog-peer-recalculate', 'failure');
fs.unlinkSync(path.join(root, 'app/pnpm-lock.yaml'));
run('4-catalog-peer-new-lockfile', 'failure');
run('5-catalog-peer-full-install-new-lockfile', 'failure', false);
// Change only the peer's dependency, leaving app's catalog reference intact.
write('peer/package.json', peerManifest('1.4.0'));
run('6-control-only-peer-catalog-expanded', 'success');
const lock = fs.readFileSync(path.join(root, 'app/pnpm-lock.yaml'), 'utf8');
assert.match(lock, /peer@file:\.\.\/peer/);
assert.match(lock, /colors: 1\.4\.0/);
console.log('Observed the three failures and the existing-lockfile/literal-version controls.');Observed results on the official 12.15.0 binary:
| Step | Result |
|---|---|
Seed a valid lockfile with a literal version in peer and the catalog reference in app |
Exit 0 |
Change only peer to use the catalog; reuse the existing lockfile |
Exit 0 |
Resolve the same graph with preferFrozenLockfile: false |
Exit 1 |
| Resolve with no lockfile | Exit 1 |
Full install with no lockfile, without --lockfile-only |
Exit 1 |
Change only peer back to literal 1.4.0, leaving app's catalog reference intact |
Exit 0 |
Describe the Bug
During a real dependency-graph resolution, pnpm does not expand the named catalog reference in the relative injected package. It passes colors@catalog:project/common to the ordinary resolver and fails:
Error: ERR_PNPM_UNSUPPORTED_PROTOCOL
× installing dependencies
├─▶ Failed to resolve dependency tree: Unsupported protocol "catalog:" in
│ the dependency specifier "catalog:project/common"
│
│ Failed to resolve colors@catalog:project/common
│ This error happened while installing the dependencies of @
╰─▶ Unsupported protocol "catalog:" in the dependency specifier
"catalog:project/common"
The catalog definition is available: app's direct dependency resolves through it, and expanding only peer's reference to 1.4.0 makes the same graph succeed. Reusing an already-resolved lockfile can also succeed; that does not exercise the failing resolution path.
Source observation: in the 12.15.0 catalog-context gate, child catalog resolution requires resolved_via == "workspace" and a file: ID. However, relative workspace:. selectors are handed to the local resolver, whose directory result is tagged local-filesystem. The package therefore misses the child catalog context despite being injected.
The same catalog-context condition remains on main, checked at commit cb496f54277a6a36eb124cb61560ef0c6f87e238. This main check is source analysis only; the executable reproduction above was run on the official 12.15.0 release binary. No source modifications or test-only instrumentation were used
Expected Behavior
A relative injected workspace package should resolve its catalog:project/common dependency through the effective workspace catalog, just as the consuming project's direct dependency does. Both graph recalculation and a fresh install should succeed, with peer@file:../peer depending on colors: 1.4.0 in the resolved lockfile. The original manifests should keep their catalog references.
Which Node.js version are you using?
v26.12.0
Which operating systems have you used?
- macOS
- Windows
- Linux
If your OS is a Linux based, which one it is? (Include the version if relevant)
No response
Contributing a fix
- I am working on a fix
Activity
zkochan commentedon Oct 23, 2026
A fix is open in #17297. It covers the whole issue in both pnpm 11 and pnpm 12: the local resolver now tags a
workspace:specifier as resolved via the workspace, the tag theworkspace:*form already gets, so the existing catalog gate in the dependency resolver applies to a package injected through a relative path. Both the lockfile-only recalculation and a fresh install from the reproduction pass, and the manifests keep theircatalog:references.#17295 is an earlier open PR for the same issue with a different design (it threads the written specifier into the catalog gate instead). The maintainers will pick one.
Written by an agent (Claude Code, claude-fable-5-1).
fix(local-resolver): resolve catalog dependencies of a package inject…
Update pnpm to v12.16.1 (#2164)