From cd99dfd3dd4a20535e4b668bda1d6a97d05da216 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Thu, 5 Mar 2026 21:31:18 -0500 Subject: [PATCH 1/2] feat: add `publish-registry` config option Adds a new `publish-registry` config option that allows setting a separate registry URL for `npm publish` and `npm unpublish`, while leaving the `registry` config in effect for all other operations like install and view. This enables workflows like using a local caching proxy (e.g. VSR, Verdaccio) for reads while publishing directly to the public npm registry, without needing per-package publishConfig or shell aliases. When set in .npmrc: registry=http://localhost:1337/npm publish-registry=https://registry.npmjs.org/ All installs/views go through the local proxy, while publishes go directly to npmjs.org. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/commands/publish.js | 5 ++ lib/commands/unpublish.js | 6 +- .../test/lib/commands/publish.js.test.cjs | 8 +++ test/lib/commands/publish.js | 58 +++++++++++++++++++ test/lib/commands/unpublish.js | 28 +++++++++ .../config/lib/definitions/definitions.js | 11 ++++ 6 files changed, 115 insertions(+), 1 deletion(-) diff --git a/lib/commands/publish.js b/lib/commands/publish.js index 3c8cbfb825129..d15e26b27b320 100644 --- a/lib/commands/publish.js +++ b/lib/commands/publish.js @@ -26,6 +26,7 @@ class Publish extends BaseCommand { 'access', 'dry-run', 'otp', + 'publish-registry', 'workspace', 'workspaces', 'include-workspace-root', @@ -82,6 +83,10 @@ class Publish extends BaseCommand { } const opts = { ...this.npm.flatOptions, progress: false } + const publishRegistry = this.npm.config.get('publish-registry') + if (publishRegistry) { + opts.registry = publishRegistry + } // you can publish name@version, ./foo.tgz, etc. // even though the default is the 'file:.' cwd. diff --git a/lib/commands/unpublish.js b/lib/commands/unpublish.js index 73d9d03804558..9e6bdf997071c 100644 --- a/lib/commands/unpublish.js +++ b/lib/commands/unpublish.js @@ -16,7 +16,7 @@ const LAST_REMAINING_VERSION_ERROR = 'Refusing to delete the last version of the class Unpublish extends BaseCommand { static description = 'Remove a package from the registry' static name = 'unpublish' - static params = ['dry-run', 'force', 'workspace', 'workspaces'] + static params = ['dry-run', 'force', 'publish-registry', 'workspace', 'workspaces'] static usage = ['[]'] static workspaces = true static ignoreImplicitWorkspace = false @@ -103,6 +103,10 @@ class Unpublish extends BaseCommand { } const opts = { ...this.npm.flatOptions } + const publishRegistry = this.npm.config.get('publish-registry') + if (publishRegistry) { + opts.registry = publishRegistry + } let manifest try { diff --git a/tap-snapshots/test/lib/commands/publish.js.test.cjs b/tap-snapshots/test/lib/commands/publish.js.test.cjs index e7507118a28f5..966588208320c 100644 --- a/tap-snapshots/test/lib/commands/publish.js.test.cjs +++ b/tap-snapshots/test/lib/commands/publish.js.test.cjs @@ -288,10 +288,18 @@ exports[`test/lib/commands/publish.js TAP public access > new package version 1` + @npm/test-package@1.0.0 ` +exports[`test/lib/commands/publish.js TAP publish-registry config overridden by publishConfig.registry > new package version 1`] = ` ++ @npmcli/test-package@1.0.0 +` + exports[`test/lib/commands/publish.js TAP re-loads publishConfig.registry if added during script process > new package version 1`] = ` + @npmcli/test-package@1.0.0 ` +exports[`test/lib/commands/publish.js TAP respects publish-registry config > new package version 1`] = ` ++ @npmcli/test-package@1.0.0 +` + exports[`test/lib/commands/publish.js TAP respects publishConfig.registry, runs appropriate scripts > new package version 1`] = ` > @npmcli/test-package@1.0.0 prepublishOnly diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index ad528c2c8dd3e..4dfe33f1dad3b 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -58,6 +58,64 @@ t.test('respects publishConfig.registry, runs appropriate scripts', async t => { t.same(logs.warn, ['Unknown publishConfig config "other". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.']) }) +t.test('respects publish-registry config', async t => { + const publishRegistry = alternateRegistry + const { joinedOutput, npm, registry } = await loadNpmWithRegistry(t, { + config: { + 'publish-registry': publishRegistry, + [`${publishRegistry.slice(6)}/:_authToken`]: 'test-other-token', + }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson, null, 2), + }, + registry: publishRegistry, + authorization: 'test-other-token', + }) + registry.getPackage(pkg, { times: 2, code: 404 }) + registry.putPackage(pkg, { packageJson: pkgJson, registry: publishRegistry }) + await npm.exec('publish', []) + t.matchSnapshot(joinedOutput(), 'new package version') +}) + +t.test('publish-registry config overridden by publishConfig.registry', async t => { + const publishRegistry = alternateRegistry + const thirdRegistry = 'https://third.registry.npmjs.org' + const packageJson = { + ...pkgJson, + publishConfig: { registry: thirdRegistry }, + } + const { joinedOutput, npm, registry } = await loadNpmWithRegistry(t, { + config: { + 'publish-registry': publishRegistry, + [`${thirdRegistry.slice(6)}/:_authToken`]: 'test-third-token', + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson, null, 2), + }, + registry: thirdRegistry, + authorization: 'test-third-token', + }) + registry.publish(pkg, { packageJson }) + await npm.exec('publish', []) + t.matchSnapshot(joinedOutput(), 'new package version') +}) + +t.test('publish-registry config does not affect install registry', async t => { + const publishRegistry = alternateRegistry + const { npm } = await loadNpmWithRegistry(t, { + config: { + 'publish-registry': publishRegistry, + ...auth, + }, + prefixDir: { + 'package.json': JSON.stringify(pkgJson, null, 2), + }, + authorization: token, + }) + t.equal(npm.config.get('registry'), 'https://registry.npmjs.org/') + t.ok(npm.config.get('publish-registry').startsWith(publishRegistry)) +}) + t.test('re-loads publishConfig.registry if added during script process', async t => { const initPackageJson = { ...pkgJson, diff --git a/test/lib/commands/unpublish.js b/test/lib/commands/unpublish.js index 4c8bc5e058afa..5cd5f00ee1d52 100644 --- a/test/lib/commands/unpublish.js +++ b/test/lib/commands/unpublish.js @@ -378,6 +378,34 @@ t.test('dryRun with no args', async t => { t.equal(joinedOutput(), '- test-package@1.0.0') }) +t.test('publish-registry config', async t => { + const alternateRegistry = 'https://other.registry.npmjs.org' + const { joinedOutput, npm } = await loadMockNpm(t, { + config: { + force: true, + 'publish-registry': alternateRegistry, + '//other.registry.npmjs.org/:_authToken': 'test-other-token', + }, + prefixDir: { + 'package.json': JSON.stringify({ + name: pkg, + version: '1.0.0', + }, null, 2), + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: alternateRegistry, + authorization: 'test-other-token', + }) + const manifest = registry.manifest({ name: pkg }) + await registry.package({ manifest, query: { write: true }, times: 2 }) + registry.unpublish({ manifest }) + await npm.exec('unpublish', []) + t.equal(joinedOutput(), '- test-package') +}) + t.test('publishConfig no spec', async t => { const alternateRegistry = 'https://other.registry.npmjs.org' const { logs, joinedOutput, npm } = await loadMockNpm(t, { diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 4c234699e2287..d92f0e14ad7e4 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1726,6 +1726,17 @@ const definitions = { `, flatten, }), + 'publish-registry': new Definition('publish-registry', { + default: null, + type: [null, url], + description: ` + The base URL of the npm registry to use for \`npm publish\` and + \`npm unpublish\`. When set, overrides \`registry\` for these + commands while leaving \`registry\` in effect for all other + operations like install and view. + `, + flatten, + }), registry: new Definition('registry', { default: 'https://registry.npmjs.org/', type: url, From 458125c70a4cf57860e15855eb9c9ff8fc1a8b94 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Thu, 5 Mar 2026 22:01:34 -0500 Subject: [PATCH 2/2] fix: use exact equality instead of startsWith for URL assertion Addresses CodeQL "Incomplete URL substring sanitization" warning. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/lib/commands/publish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 4dfe33f1dad3b..e1ce0bde3fd57 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -113,7 +113,7 @@ t.test('publish-registry config does not affect install registry', async t => { authorization: token, }) t.equal(npm.config.get('registry'), 'https://registry.npmjs.org/') - t.ok(npm.config.get('publish-registry').startsWith(publishRegistry)) + t.equal(npm.config.get('publish-registry'), alternateRegistry + '/') }) t.test('re-loads publishConfig.registry if added during script process', async t => {