feat: treat bare workspace: protocol as workspace:* (#10436)

* feat: treat bare `workspace:` protocol as `workspace:*`

* chore: add chageset

* test(exportable-manifest): add test for `workspace` with explicit versions

* test: add tests and update changesets

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Shunta Takemoto
2026-01-26 15:06:01 +09:00
committed by Zoltan Kochan
parent 5c7ee66fd5
commit ed87c99359
10 changed files with 98 additions and 9 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/resolve-workspace-range": minor
"@pnpm/exportable-manifest": minor
"@pnpm/npm-resolver": minor
pnpm: minor
---
Support bare `workspace:` protocol without version specifier. It is now treated as `workspace:*` and resolves to the concrete version during publish [#10436](https://github.com/pnpm/pnpm/pull/10436).

View File

@@ -36,6 +36,7 @@
"copyfiles",
"corejs",
"corepack",
"corge",
"cowsay",
"cves",
"cwsay",
@@ -83,6 +84,7 @@
"forgejo",
"fsevents",
"gabor",
"garply",
"gcttmf",
"getattr",
"ghsa",
@@ -90,6 +92,7 @@
"gitea",
"globalconfig",
"globstar",
"grault",
"gruntfile",
"gwhitney",
"haptics",
@@ -181,6 +184,7 @@
"pkgname",
"pkgs",
"plotly",
"plugh",
"pnpmfile",
"pnpmfiles",
"pnpmjs",

View File

@@ -136,13 +136,14 @@ async function replaceWorkspaceProtocol (depName: string, depSpec: string, dir:
return depSpec
}
// Dependencies with bare "*", "^" and "~" versions
const versionAliasSpecParts = /^workspace:(.*?)@?([\^~*])$/.exec(depSpec)
// Dependencies with bare "*", "^", "~" versions, or no version (workspace:)
const versionAliasSpecParts = /^workspace:(?:(.+)@)?([\^~*])?$/.exec(depSpec)
if (versionAliasSpecParts != null) {
modulesDir = modulesDir ?? path.join(dir, 'node_modules')
const manifest = await readAndCheckManifest(depName, path.join(modulesDir, depName))
const semverRangeToken = versionAliasSpecParts[2] !== '*' ? versionAliasSpecParts[2] : ''
const specifierSuffix: string | undefined = versionAliasSpecParts[2]
const semverRangeToken = specifierSuffix === '^' || specifierSuffix === '~' ? specifierSuffix : ''
if (depName !== manifest.name) {
return `npm:${manifest.name!}@${semverRangeToken}${manifest.version}`
}

View File

@@ -96,9 +96,13 @@ test('workspace deps are replaced', async () => {
baz: 'workspace:baz@^',
foo: 'workspace:*',
qux: 'workspace:^',
quux: 'workspace:',
waldo: 'workspace:^',
xerox: 'workspace:../xerox',
xeroxAlias: 'workspace:../xerox',
corge: 'workspace:1.0.0',
grault: 'workspace:^1.0.0',
garply: 'workspace:plugh@2.0.0',
},
peerDependencies: {
foo: 'workspace:>= || ^3.9.0',
@@ -127,6 +131,10 @@ test('workspace deps are replaced', async () => {
name: 'qux',
version: '1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay',
},
{
name: 'quux',
version: '7.8.9',
},
{
name: 'waldo',
version: '1.9.0',
@@ -135,6 +143,18 @@ test('workspace deps are replaced', async () => {
name: 'xerox',
version: '4.5.6',
},
{
name: 'corge',
version: '1.0.0',
},
{
name: 'grault',
version: '1.0.0',
},
{
name: 'plugh',
version: '2.0.0',
},
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
@@ -151,9 +171,13 @@ test('workspace deps are replaced', async () => {
baz: '^1.2.3',
foo: '4.5.6',
qux: '^1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay',
quux: '7.8.9',
waldo: '^1.9.0',
xerox: '4.5.6',
xeroxAlias: 'npm:xerox@4.5.6',
corge: '1.0.0',
grault: '^1.0.0',
garply: 'npm:plugh@2.0.0',
},
peerDependencies: {
baz: '^1.0.0 || >1.2.3',

View File

@@ -7,7 +7,7 @@ export function workspacePrefToNpm (workspaceBareSpecifier: string): string {
}
const { alias, version } = parseResult
const versionPart = version === '^' || version === '~' ? '*' : version
const versionPart = version === '^' || version === '~' || version === '' ? '*' : version
return alias
? `npm:${alias}@${versionPart}`
: versionPart

View File

@@ -2,8 +2,10 @@ import { workspacePrefToNpm } from '../lib/workspacePrefToNpm.js'
describe('workspacePrefToNpm', () => {
test('resolve workspace only version aliases', async () => {
expect(workspacePrefToNpm('workspace:^')).toStrictEqual('*')
expect(workspacePrefToNpm('workspace:~')).toStrictEqual('*')
expect(workspacePrefToNpm('workspace:')).toBe('*')
expect(workspacePrefToNpm('workspace:*')).toBe('*')
expect(workspacePrefToNpm('workspace:^')).toBe('*')
expect(workspacePrefToNpm('workspace:~')).toBe('*')
})
test('resolve package name aliases', async () => {

View File

@@ -25,8 +25,9 @@
],
"scripts": {
"start": "tsc --watch",
"test": "pnpm run compile",
"lint": "eslint \"src/**/*.ts\"",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"_test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest",
"test": "pnpm run compile && pnpm run _test",
"prepublishOnly": "pnpm run compile",
"compile": "tsc --build && pnpm run lint --fix"
},

View File

@@ -1,7 +1,7 @@
import semver from 'semver'
export function resolveWorkspaceRange (range: string, versions: string[]): string | null {
if (range === '*' || range === '^' || range === '~') {
if (range === '*' || range === '^' || range === '~' || range === '') {
return semver.maxSatisfying(versions, '*', {
includePrerelease: true,
})

View File

@@ -0,0 +1,31 @@
import { resolveWorkspaceRange } from '@pnpm/resolve-workspace-range'
describe('resolveWorkspaceRange', () => {
const versions = ['1.0.0', '2.0.0', '3.0.0-beta.1']
test('resolves * to max version including prereleases', () => {
expect(resolveWorkspaceRange('*', versions)).toBe('3.0.0-beta.1')
})
test('resolves ^ to max version including prereleases', () => {
expect(resolveWorkspaceRange('^', versions)).toBe('3.0.0-beta.1')
})
test('resolves ~ to max version including prereleases', () => {
expect(resolveWorkspaceRange('~', versions)).toBe('3.0.0-beta.1')
})
test('resolves empty string (bare workspace:) to max version including prereleases', () => {
expect(resolveWorkspaceRange('', versions)).toBe('3.0.0-beta.1')
})
test('resolves semver range', () => {
expect(resolveWorkspaceRange('^1.0.0', versions)).toBe('1.0.0')
expect(resolveWorkspaceRange('^2.0.0', versions)).toBe('2.0.0')
expect(resolveWorkspaceRange('>=1.0.0', versions)).toBe('2.0.0')
})
test('returns null when no version satisfies range', () => {
expect(resolveWorkspaceRange('^4.0.0', versions)).toBeNull()
})
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}