From b316630c336c6d80949883722c741559c38b1772 Mon Sep 17 00:00:00 2001 From: meganemura <290611+meganemura@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:27:55 +0000 Subject: [PATCH 1/2] fix(unpublish): check the last version against the full packument `npm unpublish @` refuses to delete the last version without --force, but the check read the abbreviated packument while libnpmpublish decides from the full one. When the abbreviated document listed a version the full one did not, the check passed and libnpmpublish deleted the whole package. A full packument with no versions also passed the check and deleted the whole package. Read the full packument for the check, and treat no versions as the last version. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CykpGv4PTktW9jr8y5EYkR --- lib/commands/unpublish.js | 10 ++++--- test/lib/commands/unpublish.js | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/lib/commands/unpublish.js b/lib/commands/unpublish.js index fc390a2b9edff..b7ae8dbd9a9ee 100644 --- a/lib/commands/unpublish.js +++ b/lib/commands/unpublish.js @@ -20,13 +20,15 @@ class Unpublish extends BaseCommand { static ignoreImplicitWorkspace = false static async getKeysOfVersions (name, opts) { + // libnpmpublish reads the full packument, so check against the same one. const packument = await pacote.packument(name, { ...opts, spec: name, query: { write: true }, + fullMetadata: true, _isRoot: true, }) - return Object.keys(packument.versions) + return Object.keys(packument.versions || {}) } static async completion (args, npm) { @@ -136,10 +138,12 @@ class Unpublish extends BaseCommand { } const versions = await Unpublish.getKeysOfVersions(spec.name, opts) - if (versions.length === 1 && spec.rawSpec === versions[0] && !force) { + const lastVersion = versions.length === 0 || + (versions.length === 1 && spec.rawSpec === versions[0]) + if (lastVersion && !force) { throw this.usageError(LAST_REMAINING_VERSION_ERROR) } - if (versions.length === 1) { + if (versions.length <= 1) { pkgVersion = '' } diff --git a/test/lib/commands/unpublish.js b/test/lib/commands/unpublish.js index 4c8bc5e058afa..e49124a8dadf6 100644 --- a/test/lib/commands/unpublish.js +++ b/test/lib/commands/unpublish.js @@ -166,6 +166,55 @@ t.test('unpublish @version last version', async t => { ) }) +t.test('unpublish @version last version in the full packument only', async t => { + const { npm } = await loadMockNpm(t, { + config: { + ...auth, + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + const abbreviated = registry.manifest({ name: pkg, versions: ['1.0.0', '1.0.1'] }) + const full = registry.manifest({ name: pkg, versions: ['1.0.0'] }) + registry.nock + .get(`/${pkg}`).query({ write: true }) + .matchHeader('accept', accept => !accept.startsWith('application/vnd.npm.install-v1+json')) + .reply(200, full) + .get(`/${pkg}`).query({ write: true }) + .matchHeader('accept', accept => accept.startsWith('application/vnd.npm.install-v1+json')) + .optionally() + .reply(200, abbreviated) + + await t.rejects( + npm.exec('unpublish', ['test-package@1.0.0']), + /Refusing to delete the last version of the package/ + ) +}) + +t.test('unpublish @version of a packument with no versions', async t => { + const { npm } = await loadMockNpm(t, { + config: { + ...auth, + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + const manifest = registry.manifest({ name: pkg }) + delete manifest.versions + await registry.package({ manifest, query: { write: true } }) + + await t.rejects( + npm.exec('unpublish', ['test-package@1.0.0']), + /Refusing to delete the last version of the package/ + ) +}) + t.test('no version found in package.json no force', async t => { const { npm } = await loadMockNpm(t, { config: { From 91f1ff21a41ed825714f64dbe808e81264e72af7 Mon Sep 17 00:00:00 2001 From: meganemura <290611+meganemura@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:23:47 +0000 Subject: [PATCH 2/2] fix(unpublish): refuse to unpublish a version when the registry returns no versions When a specific version is requested and the registry returns a packument with no versions, libnpmpublish deletes the whole package. Refuse instead, even with --force, since the requested version cannot be verified. Drop the fullMetadata read: the full and abbreviated packuments are expected to agree, and it did not change the outcome. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013XZoh3ni1qhXKcwpBwN5bP --- lib/commands/unpublish.js | 11 +++++------ test/lib/commands/unpublish.js | 24 +++++++++--------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/lib/commands/unpublish.js b/lib/commands/unpublish.js index b7ae8dbd9a9ee..8b55ef9476d62 100644 --- a/lib/commands/unpublish.js +++ b/lib/commands/unpublish.js @@ -20,12 +20,10 @@ class Unpublish extends BaseCommand { static ignoreImplicitWorkspace = false static async getKeysOfVersions (name, opts) { - // libnpmpublish reads the full packument, so check against the same one. const packument = await pacote.packument(name, { ...opts, spec: name, query: { write: true }, - fullMetadata: true, _isRoot: true, }) return Object.keys(packument.versions || {}) @@ -138,12 +136,13 @@ class Unpublish extends BaseCommand { } const versions = await Unpublish.getKeysOfVersions(spec.name, opts) - const lastVersion = versions.length === 0 || - (versions.length === 1 && spec.rawSpec === versions[0]) - if (lastVersion && !force) { + if (versions.length === 0 && spec.type === 'version') { + throw this.usageError(`Refusing to delete ${spec.name}@${spec.rawSpec}: the registry returned no versions for ${spec.name}.`) + } + if (versions.length === 1 && spec.rawSpec === versions[0] && !force) { throw this.usageError(LAST_REMAINING_VERSION_ERROR) } - if (versions.length <= 1) { + if (versions.length === 1) { pkgVersion = '' } diff --git a/test/lib/commands/unpublish.js b/test/lib/commands/unpublish.js index e49124a8dadf6..26520ea2119bc 100644 --- a/test/lib/commands/unpublish.js +++ b/test/lib/commands/unpublish.js @@ -166,7 +166,7 @@ t.test('unpublish @version last version', async t => { ) }) -t.test('unpublish @version last version in the full packument only', async t => { +t.test('unpublish @version of a packument with no versions', async t => { const { npm } = await loadMockNpm(t, { config: { ...auth, @@ -177,26 +177,20 @@ t.test('unpublish @version last version in the full packument only', async registry: npm.config.get('registry'), authorization: 'test-auth-token', }) - const abbreviated = registry.manifest({ name: pkg, versions: ['1.0.0', '1.0.1'] }) - const full = registry.manifest({ name: pkg, versions: ['1.0.0'] }) - registry.nock - .get(`/${pkg}`).query({ write: true }) - .matchHeader('accept', accept => !accept.startsWith('application/vnd.npm.install-v1+json')) - .reply(200, full) - .get(`/${pkg}`).query({ write: true }) - .matchHeader('accept', accept => accept.startsWith('application/vnd.npm.install-v1+json')) - .optionally() - .reply(200, abbreviated) + const manifest = registry.manifest({ name: pkg }) + delete manifest.versions + await registry.package({ manifest, query: { write: true } }) await t.rejects( npm.exec('unpublish', ['test-package@1.0.0']), - /Refusing to delete the last version of the package/ + /the registry returned no versions for test-package/ ) }) -t.test('unpublish @version of a packument with no versions', async t => { +t.test('unpublish @version of a packument with no versions with --force', async t => { const { npm } = await loadMockNpm(t, { config: { + force: true, ...auth, }, }) @@ -206,12 +200,12 @@ t.test('unpublish @version of a packument with no versions', async t => { authorization: 'test-auth-token', }) const manifest = registry.manifest({ name: pkg }) - delete manifest.versions + manifest.versions = {} await registry.package({ manifest, query: { write: true } }) await t.rejects( npm.exec('unpublish', ['test-package@1.0.0']), - /Refusing to delete the last version of the package/ + /the registry returned no versions for test-package/ ) })