Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions node_modules/npm-package-arg/lib/npa.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ function isAliasSpec (spec) {
return spec.toLowerCase().startsWith('npm:')
}

function isJsrSpec (spec) {
if (!spec) {
return false
}
return spec.toLowerCase().startsWith('jsr:')
}

function resolve (name, spec, where, arg) {
const res = new Result({
raw: arg,
Expand All @@ -98,6 +105,8 @@ function resolve (name, spec, where, arg) {
return fromFile(res, where)
} else if (isAliasSpec(spec)) {
return fromAlias(res, where)
} else if (isJsrSpec(spec)) {
return fromJsr(res, where)
}

const hosted = HostedGit.fromUrl(spec, {
Expand Down Expand Up @@ -453,6 +462,62 @@ function fromAlias (res, where) {
return res
}

function fromJsr (res, where) {
// Remove 'jsr:' prefix
const jsrSpec = res.rawSpec.substr(4)

// Parse the JSR specifier to extract name and version
// JSR format: @scope/name or @scope/name@version
const nameEnd = jsrSpec.indexOf('@', 1) // Skip the leading @ in @scope
const jsrName = nameEnd > 0 ? jsrSpec.slice(0, nameEnd) : jsrSpec
const versionSpec = nameEnd > 0 ? jsrSpec.slice(nameEnd + 1) : ''

// Validate that JSR package is scoped
if (!jsrName.startsWith('@') || !jsrName.includes('/')) {
throw new Error(`JSR packages must be scoped (e.g., jsr:@scope/name): ${res.raw}`)
}

// Validate the package name
const valid = validatePackageName(jsrName)
if (!valid.validForOldPackages) {
throw invalidPackageName(jsrName, valid, res.raw)
}

// Transform @scope/name to @jsr/scope__name
// Extract scope and package name
const scopeEnd = jsrName.indexOf('/')
const scope = jsrName.slice(1, scopeEnd) // Remove leading @ from scope
const packageName = jsrName.slice(scopeEnd + 1)
const transformedName = `@jsr/${scope}__${packageName}`

// Set the transformed name
res.setName(transformedName)
res.registry = true

// Preserve the original JSR spec for saving
res.saveSpec = `jsr:${jsrName}${versionSpec ? '@' + versionSpec : ''}`

// Determine the type based on version specifier
const spec = versionSpec || '*'
res.rawSpec = spec
res.fetchSpec = spec

const version = semver.valid(spec, true)
const range = semver.validRange(spec, true)
if (version) {
res.type = 'version'
} else if (range) {
res.type = 'range'
} else {
if (encodeURIComponent(spec) !== spec) {
throw invalidTagName(spec, res.raw)
}
res.type = 'tag'
}

return res
}

function fromRegistry (res) {
res.registry = true
const spec = res.rawSpec.trim()
Expand Down
228 changes: 228 additions & 0 deletions test/lib/commands/install-jsr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
const {
cleanCwd,
cleanTime,
cleanDate,
cleanPackumentCache,
} = require('../../fixtures/clean-snapshot.js')

const { resolve } = require('node:path')
const { readFileSync } = require('node:fs')
const path = require('node:path')
const t = require('tap')

t.cleanSnapshot = (str) => cleanPackumentCache(cleanDate(cleanTime(cleanCwd(str))))

const {
loadNpmWithRegistry: loadMockNpm,
} = require('../../fixtures/mock-npm')

const packageJson = {
name: '@npmcli/test-jsr-package',
version: '1.0.0',
}

const jsrPackage = {
'package.json': JSON.stringify({
name: '@jsr/std__testing',
version: '1.0.0',
description: 'Testing utilities from JSR',
}),
'index.js': 'module.exports = {}',
}

const readPackageJson = (prefix) =>
JSON.parse(readFileSync(resolve(prefix, 'package.json'), 'utf8'))

t.test('JSR protocol support', async t => {
await t.test('install JSR package without version', async t => {
const { npm, registry } = await loadMockNpm(t, {
config: {
audit: false,
// Configure @jsr:registry to use the mock registry for testing
'@jsr:registry': 'https://registry.npmjs.org/',
},
prefixDir: {
'package.json': JSON.stringify(packageJson),
'jsr-std-testing': jsrPackage,
},
})

// The JSR package @std/testing will be transformed to @jsr/std__testing
const manifest = registry.manifest({
name: '@jsr/std__testing',
})
await registry.package({ manifest })
await registry.tarball({
manifest: manifest.versions['1.0.0'],
tarball: path.join(npm.prefix, 'jsr-std-testing'),
})

await npm.exec('install', ['jsr:@std/testing'])

const pkg = readPackageJson(npm.prefix)
t.ok(pkg.dependencies, 'has dependencies')
t.match(pkg.dependencies, {
'@jsr/std__testing': '^1.0.0',
}, 'JSR package was added to dependencies with transformed name')
})

await t.test('install JSR package with specific version', async t => {
const jsrPackageV110 = {
'package.json': JSON.stringify({
name: '@jsr/std__testing',
version: '1.1.0',
}),
'index.js': 'module.exports = {}',
}

const { npm, registry } = await loadMockNpm(t, {
config: {
audit: false,
'@jsr:registry': 'https://registry.npmjs.org/',
},
prefixDir: {
'package.json': JSON.stringify(packageJson),
'jsr-std-testing': jsrPackageV110,
},
})

const manifest = registry.manifest({
name: '@jsr/std__testing',
versions: ['1.0.0', '1.1.0', '2.0.0'],
})
await registry.package({ manifest })
await registry.tarball({
manifest: manifest.versions['1.1.0'],
tarball: path.join(npm.prefix, 'jsr-std-testing'),
})

await npm.exec('install', ['jsr:@std/[email protected]'])

const pkg = readPackageJson(npm.prefix)
t.match(pkg.dependencies, {
'@jsr/std__testing': '1.1.0',
}, 'JSR package with specific version was installed')
})

await t.test('install JSR package with version range', async t => {
const jsrPackageV110 = {
'package.json': JSON.stringify({
name: '@jsr/std__testing',
version: '1.1.0',
}),
'index.js': 'module.exports = {}',
}

const { npm, registry } = await loadMockNpm(t, {
config: {
audit: false,
'@jsr:registry': 'https://registry.npmjs.org/',
},
prefixDir: {
'package.json': JSON.stringify(packageJson),
'jsr-std-testing': jsrPackageV110,
},
})

const manifest = registry.manifest({
name: '@jsr/std__testing',
versions: ['1.0.0', '1.1.0', '2.0.0'],
})
await registry.package({ manifest })
await registry.tarball({
manifest: manifest.versions['1.1.0'],
tarball: path.join(npm.prefix, 'jsr-std-testing'),
})

await npm.exec('install', ['jsr:@std/testing@^1.0.0'])

const pkg = readPackageJson(npm.prefix)
t.match(pkg.dependencies, {
'@jsr/std__testing': '^1.1.0',
}, 'JSR package with range was installed and resolved to latest matching version')
})

await t.test('install JSR package with alias', async t => {
const { npm, registry } = await loadMockNpm(t, {
config: {
audit: false,
'@jsr:registry': 'https://registry.npmjs.org/',
},
prefixDir: {
'package.json': JSON.stringify(packageJson),
'jsr-std-testing': jsrPackage,
},
})

const manifest = registry.manifest({
name: '@jsr/std__testing',
})
await registry.package({ manifest })
await registry.tarball({
manifest: manifest.versions['1.0.0'],
tarball: path.join(npm.prefix, 'jsr-std-testing'),
})

await npm.exec('install', ['testing@jsr:@std/testing'])

const pkg = readPackageJson(npm.prefix)
// When using alias syntax, npm should save it as: "testing": "jsr:@std/testing@^1.0.0"
t.ok(pkg.dependencies.testing || pkg.dependencies['@jsr/std__testing'], 'JSR package was added')
if (pkg.dependencies.testing) {
t.match(pkg.dependencies.testing, /jsr:@std\/testing/, 'alias points to JSR package')
}
})

await t.test('JSR package validation', async t => {
await t.test('reject unscoped JSR packages', async t => {
const { npm } = await loadMockNpm(t, {
config: {
audit: false,
},
prefixDir: {
'package.json': JSON.stringify(packageJson),
},
})

await t.rejects(
npm.exec('install', ['jsr:unscoped-package']),
/JSR packages must be scoped/,
'rejects unscoped JSR package'
)
})

await t.test('reject JSR package without slash', async t => {
const { npm } = await loadMockNpm(t, {
config: {
audit: false,
},
prefixDir: {
'package.json': JSON.stringify(packageJson),
},
})

await t.rejects(
npm.exec('install', ['jsr:@scopeonly']),
/JSR packages must be scoped/,
'rejects JSR package without name part'
)
})
})

await t.test('JSR registry configuration', async t => {
const { npm } = await loadMockNpm(t, {
config: {
audit: false,
},
prefixDir: {
'package.json': JSON.stringify(packageJson),
},
})

t.equal(
npm.config.get('@jsr:registry'),
'https://npm.jsr.io/',
'@jsr:registry is configured with default value'
)
})
})
11 changes: 11 additions & 0 deletions workspaces/config/lib/definitions/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,17 @@ const definitions = {
`,
flatten,
}),
'@jsr:registry': new Definition('@jsr:registry', {
default: 'https://npm.jsr.io/',
type: url,
description: `
The base URL of the JSR (JavaScript Registry) npm-compatible layer.
Used when installing packages with the jsr: protocol.
`,
flatten: (key, obj, flatOptions) => {
flatOptions[key] = obj[key]
},
}),
'replace-registry-host': new Definition('replace-registry-host', {
default: 'npmjs',
hint: '<npmjs|never|always> | hostname',
Expand Down