Visitar URL original
Named catalog dependencies in a relative injected workspace package fail during resolution · Issue #17280 · pnpm/pnpm · GitHub
Skip to content

Named catalog dependencies in a relative injected workspace package fail during resolution #17280

Description

@zaverden

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.

  1. Save the following complete script as repro.mjs.
  2. With pnpm 12.15.0 on PATH, run node repro.mjs. Alternatively pass the absolute path to a standalone native binary: node repro.mjs /absolute/path/to/pnpm.
  3. 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

  1. added theissue type on Oct 23, 2026
  2. zkochan commented on Oct 23, 2026

    @zkochan
    Member

    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 the workspace:* 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 their catalog: 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).

  3. added a commit that references this issue on Oct 23, 2026
    917d689
  4. added a commit that references this issue on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions