From 16cfde66ec71125d692ea828eba2a5f9b3cc54fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kh=E1=BA=A3i?= Date: Tue, 7 Apr 2026 16:19:42 +0700 Subject: [PATCH] feat: `pnpm logout` (#11213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): implement `pnpm logout` command Adds a new `pnpm logout` command that logs users out of npm registries. The command revokes the authentication token on the registry via DELETE /-/user/token/{token}, then removes it from the local auth.ini config file. Token revocation is best-effort: local cleanup always proceeds even if the registry is unreachable or doesn't support revocation. Uses the same dependency injection pattern as `pnpm login` for comprehensive testability. https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa * fix(auth): address review feedback on pnpm logout - Rename revokeToken to tryRevokeToken for self-documenting code - Extract token removal into removeTokenFromAuthIni function - Remove redundant comments that restate function names - Fix toHaveProperty to use array syntax for keys containing dots (avoids Jest property path parsing pitfall) - Add globalWarn when token is found in authConfig but not in auth.ini, informing the user it must be removed manually from .npmrc - Add tests for the .npmrc-only warning case https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa * fix(auth): fix Windows CI failure in logout test Use path.join in test expectations for the warning message path, since path.join produces backslashes on Windows. https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa * refactor(auth): use jest.fn() for fetch assertions in logout tests Replace manual fetchedUrls arrays with jest.fn() mocks and use toHaveBeenCalledWith for cleaner, more idiomatic assertions. https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa * refactor: destructure `context` * refactor: literal types for `method` * refactor(auth): test cleanup per review feedback - Rename mockFetch to fetch for shorthand property syntax - Use platform-aware configDir in warning tests instead of path.join on Unix-style paths https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa * style(auth): remove redundant return in createMockResponse arrow Single-statement return-with-braces arrow function converted to expression-body form. https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa * fix(auth): address Copilot review on pnpm logout - Send Authorization: Bearer header in the DELETE token revocation request, otherwise the registry returns 401 and the token is not actually revoked - Make tryRevokeToken return a boolean indicating whether the token was actually revoked, and use it to choose the right warning when the token is not in auth.ini - Drop the misleading "(token removed locally)" suffix from the registry-failure log messages, since the local removal may not happen - Extract getRegistryConfigKey and safeReadIniFile from login.ts and logout.ts into a shared module to prevent the two commands from drifting apart over time - Add tests asserting the Authorization header is sent and that the warning correctly distinguishes between revoked and not-revoked cases https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa * fix(auth): throw on logout when nothing actually happened When the registry rejects the token revocation AND the token is not in auth.ini, neither side effect of logout actually happened — the user is still authenticated locally and on the registry. Throwing an ERR_PNPM_LOGOUT_FAILED error in this case avoids the misleading "Logged out of ..." success message and gives a non-zero exit code. https://claude.ai/code/session_016fw5sdGFtBiB9QapMKEuXa --------- Co-authored-by: Claude --- .changeset/implement-pnpm-logout.md | 6 + auth/commands/src/index.ts | 3 +- auth/commands/src/login.ts | 22 +- auth/commands/src/logout.ts | 213 +++++++++++++++ auth/commands/src/shared.ts | 18 ++ auth/commands/test/logout.test.ts | 409 ++++++++++++++++++++++++++++ pnpm/src/cmd/index.ts | 3 +- pnpm/src/cmd/notImplemented.ts | 1 - 8 files changed, 653 insertions(+), 22 deletions(-) create mode 100644 .changeset/implement-pnpm-logout.md create mode 100644 auth/commands/src/logout.ts create mode 100644 auth/commands/src/shared.ts create mode 100644 auth/commands/test/logout.test.ts diff --git a/.changeset/implement-pnpm-logout.md b/.changeset/implement-pnpm-logout.md new file mode 100644 index 0000000000..de9b39cf32 --- /dev/null +++ b/.changeset/implement-pnpm-logout.md @@ -0,0 +1,6 @@ +--- +"@pnpm/auth.commands": minor +"pnpm": minor +--- + +Added `pnpm logout` command for logging out of npm registries. Revokes the authentication token on the registry and removes it from the local configuration. diff --git a/auth/commands/src/index.ts b/auth/commands/src/index.ts index aeaa1e520d..9ac7a6555e 100644 --- a/auth/commands/src/index.ts +++ b/auth/commands/src/index.ts @@ -1,3 +1,4 @@ import * as login from './login.js' +import * as logout from './logout.js' -export { login } +export { login, logout } diff --git a/auth/commands/src/login.ts b/auth/commands/src/login.ts index aa2a29e252..b1a0a8e371 100644 --- a/auth/commands/src/login.ts +++ b/auth/commands/src/login.ts @@ -1,7 +1,6 @@ import { execFile } from 'node:child_process' import path from 'node:path' import readline from 'node:readline' -import util from 'node:util' import { docsUrl } from '@pnpm/cli.utils' import { type Config, types as allTypes } from '@pnpm/config.reader' @@ -24,6 +23,8 @@ import { readIniFile } from 'read-ini-file' import { renderHelp } from 'render-help' import { writeIniFile } from 'write-ini-file' +import { getRegistryConfigKey, safeReadIniFile } from './shared.js' + export function rcOptionsTypes (): Record { return { registry: allTypes.registry } } @@ -101,7 +102,7 @@ export interface LoginFetchResponseHeaders { } export interface LoginFetchOptions { - method?: string + method?: 'GET' | 'POST' | 'PUT' headers?: { accept: 'application/json' 'content-type': 'application/json' @@ -360,23 +361,6 @@ async function throwIfOtpRequired (globalWarn: LoginContext['globalWarn'], respo throw SyntheticOtpError.fromUnknownBody(globalWarn, body) } -function getRegistryConfigKey (registryUrl: string): string { - const url = new URL(registryUrl) - return `//${url.host}${url.pathname}` -} - -async function safeReadIniFile ( - readIniFile: LoginContext['readIniFile'], - configPath: string -): Promise { - try { - return await readIniFile(configPath) - } catch (err: unknown) { - if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') return {} - throw err - } -} - class LoginNonInteractiveError extends PnpmError { constructor () { super('LOGIN_NON_INTERACTIVE', 'The login command requires an interactive terminal') diff --git a/auth/commands/src/logout.ts b/auth/commands/src/logout.ts new file mode 100644 index 0000000000..3d0a8647e6 --- /dev/null +++ b/auth/commands/src/logout.ts @@ -0,0 +1,213 @@ +import path from 'node:path' + +import { docsUrl } from '@pnpm/cli.utils' +import { type Config, types as allTypes } from '@pnpm/config.reader' +import { PnpmError } from '@pnpm/error' +import { globalInfo, globalWarn } from '@pnpm/logger' +import { fetch } from '@pnpm/network.fetch' +import normalizeRegistryUrl from 'normalize-registry-url' +import { readIniFile } from 'read-ini-file' +import { renderHelp } from 'render-help' +import { writeIniFile } from 'write-ini-file' + +import { getRegistryConfigKey, safeReadIniFile } from './shared.js' + +export function rcOptionsTypes (): Record { + return { registry: allTypes.registry } +} + +export function cliOptionsTypes (): Record { + return { + ...rcOptionsTypes(), + } +} + +export const commandNames = ['logout'] + +export function help (): string { + return renderHelp({ + description: 'Log out of an npm registry.', + descriptionLists: [ + { + title: 'Options', + list: [ + { + description: 'The registry to log out of', + name: '--registry ', + }, + ], + }, + ], + url: docsUrl('logout'), + usages: ['pnpm logout [--registry ]'], + }) +} + +export type LogoutCommandOptions = Pick & { + registry?: string +} + +export async function handler ( + opts: LogoutCommandOptions +): Promise { + return logout({ opts }) +} + +export interface LogoutFetchResponse { + ok: boolean + status: number + text: () => Promise +} + +export interface LogoutFetchOptions { + method?: 'DELETE' + headers?: { + authorization: `Bearer ${string}` + } + retry?: { + factor?: number + maxTimeout?: number + minTimeout?: number + randomize?: boolean + retries?: number + } + timeout?: number +} + +export interface LogoutContext { + fetch: (url: string, options?: LogoutFetchOptions) => Promise + globalInfo: (message: string) => void + globalWarn: (message: string) => void + readIniFile: (configPath: string) => Promise + writeIniFile: (configPath: string, settings: Record) => Promise +} + +export const DEFAULT_CONTEXT: LogoutContext = { + fetch, + globalInfo, + globalWarn, + readIniFile, + writeIniFile, +} + +export interface LogoutParams { + context?: LogoutContext + opts: LogoutCommandOptions +} + +export async function logout ({ context = DEFAULT_CONTEXT, opts }: LogoutParams): Promise { + const { globalWarn, readIniFile } = context + const registry = normalizeRegistryUrl(opts.registry ?? 'https://registry.npmjs.org/') + const registryConfigKey = getRegistryConfigKey(registry) + const tokenKey = `${registryConfigKey}:_authToken` + + const token = opts.authConfig?.[tokenKey] as string | undefined + + if (!token) { + throw new LogoutNotLoggedInError(registry) + } + + const revokedOnRegistry = await tryRevokeToken({ context, opts, registry, token }) + + const configPath = path.join(opts.configDir, 'auth.ini') + const authIniSettings = await safeReadIniFile(readIniFile, configPath) as Record + + if (tokenKey in authIniSettings) { + await removeTokenFromAuthIni({ context, configPath, authIniSettings, tokenKey }) + } else if (revokedOnRegistry) { + globalWarn( + `The auth token for ${registry} was not found in ${configPath}. ` + + 'It may be configured in .npmrc or another config file. ' + + 'The token was revoked on the registry but must be removed manually from that config file.' + ) + } else { + throw new LogoutFailedError(registry, configPath) + } + + return `Logged out of ${registry}` +} + +interface TryRevokeTokenParams { + context: Pick + opts: Pick + registry: string + token: string +} + +async function tryRevokeToken ({ + context: { fetch, globalInfo }, + opts, + registry, + token, +}: TryRevokeTokenParams): Promise { + const revokeUrl = new URL(`-/user/token/${encodeURIComponent(token)}`, registry).href + + try { + const response = await fetch(revokeUrl, { + method: 'DELETE', + headers: { + authorization: `Bearer ${token}`, + }, + retry: { + factor: opts.fetchRetryFactor, + maxTimeout: opts.fetchRetryMaxtimeout, + minTimeout: opts.fetchRetryMintimeout, + retries: opts.fetchRetries, + }, + timeout: opts.fetchTimeout, + }) + + if (!response.ok) { + globalInfo(`Registry returned HTTP ${response.status} when revoking token`) + return false + } + return true + } catch { + globalInfo('Could not reach the registry to revoke the token') + return false + } +} + +interface RemoveTokenFromAuthIniParams { + context: Pick + configPath: string + authIniSettings: Record + tokenKey: string +} + +async function removeTokenFromAuthIni ({ + context: { writeIniFile }, + configPath, + authIniSettings, + tokenKey, +}: RemoveTokenFromAuthIniParams): Promise { + delete authIniSettings[tokenKey] + await writeIniFile(configPath, authIniSettings) +} + +class LogoutNotLoggedInError extends PnpmError { + constructor (registry: string) { + super('NOT_LOGGED_IN', `Not logged in to ${registry}, so can't log out`) + } +} + +class LogoutFailedError extends PnpmError { + constructor (registry: string, configPath: string) { + super( + 'LOGOUT_FAILED', + `Failed to log out of ${registry}. The registry rejected the token revocation request, ` + + `and the token was not found in ${configPath}. ` + + 'The token may be configured in .npmrc or another config file ' + + 'and must be removed manually, and may still need to be revoked on the registry.' + ) + } +} diff --git a/auth/commands/src/shared.ts b/auth/commands/src/shared.ts new file mode 100644 index 0000000000..e4e4e5be70 --- /dev/null +++ b/auth/commands/src/shared.ts @@ -0,0 +1,18 @@ +import util from 'node:util' + +export function getRegistryConfigKey (registryUrl: string): string { + const url = new URL(registryUrl) + return `//${url.host}${url.pathname}` +} + +export async function safeReadIniFile ( + readIniFile: (configPath: string) => Promise, + configPath: string +): Promise { + try { + return await readIniFile(configPath) + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') return {} + throw err + } +} diff --git a/auth/commands/test/logout.test.ts b/auth/commands/test/logout.test.ts new file mode 100644 index 0000000000..b9ccc7ceaf --- /dev/null +++ b/auth/commands/test/logout.test.ts @@ -0,0 +1,409 @@ +import path from 'node:path' + +import { jest } from '@jest/globals' + +import { logout, type LogoutContext, type LogoutFetchResponse } from '../src/logout.js' + +const TEST_CONTEXT: LogoutContext = { + fetch: async url => { + throw new Error(`Unexpected call to fetch: ${url}`) + }, + globalInfo: message => { + throw new Error(`Unexpected call to globalInfo: ${message}`) + }, + globalWarn: message => { + throw new Error(`Unexpected call to globalWarn: ${message}`) + }, + readIniFile: async path => { + throw new Error(`Unexpected call to readIniFile: ${path}`) + }, + writeIniFile: async path => { + throw new Error(`Unexpected call to writeIniFile: ${path}`) + }, +} + +const createMockResponse = (init: { + ok: boolean + status: number + text?: string +}): LogoutFetchResponse => ({ + ok: init.ok, + status: init.status, + text: async () => init.text ?? '', +}) + +const createMockContext = (overrides?: Partial): LogoutContext => ({ + ...TEST_CONTEXT, + ...overrides, +}) + +describe('logout', () => { + it('should throw when not logged in', async () => { + const context = createMockContext() + const opts = { + configDir: '/mock/config', + dir: '/mock', + authConfig: {}, + } + const promise = logout({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_NOT_LOGGED_IN') + await expect(promise).rejects.toHaveProperty(['message'], "Not logged in to https://registry.npmjs.org/, so can't log out") + }) + + it('should throw when not logged in to a custom registry', async () => { + const context = createMockContext() + const opts = { + configDir: '/mock/config', + dir: '/mock', + authConfig: {}, + registry: 'https://npm.example.com/', + } + const promise = logout({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_NOT_LOGGED_IN') + await expect(promise).rejects.toHaveProperty(['message'], "Not logged in to https://npm.example.com/, so can't log out") + }) + + it('should revoke token on registry and remove from auth.ini', async () => { + const fetch = jest.fn(async () => createMockResponse({ ok: true, status: 200 })) + let savedPath = '' + let savedSettings: Record = {} + + const context = createMockContext({ + fetch, + readIniFile: async () => ({ + '//registry.npmjs.org/:_authToken': 'my-token-123', + 'other-setting': 'value', + }), + writeIniFile: async (configPath, settings) => { + savedPath = configPath + savedSettings = settings + }, + }) + + const opts = { + configDir: '/custom/config', + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'my-token-123', + }, + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://registry.npmjs.org/') + expect(fetch).toHaveBeenCalledWith( + 'https://registry.npmjs.org/-/user/token/my-token-123', + expect.objectContaining({ + method: 'DELETE', + headers: { authorization: 'Bearer my-token-123' }, + }) + ) + expect(savedPath).toBe(path.join('/custom/config', 'auth.ini')) + expect(savedSettings).toEqual({ 'other-setting': 'value' }) + expect(savedSettings).not.toHaveProperty(['//registry.npmjs.org/:_authToken']) + }) + + it('should logout from a custom registry', async () => { + const fetch = jest.fn(async () => createMockResponse({ ok: true, status: 200 })) + let savedSettings: Record = {} + + const context = createMockContext({ + fetch, + readIniFile: async () => ({ + '//npm.example.com/:_authToken': 'custom-token', + }), + writeIniFile: async (_configPath, settings) => { + savedSettings = settings + }, + }) + + const opts = { + configDir: '/config', + dir: '/mock', + authConfig: { + '//npm.example.com/:_authToken': 'custom-token', + }, + registry: 'https://npm.example.com/', + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://npm.example.com/') + expect(fetch).toHaveBeenCalledWith( + 'https://npm.example.com/-/user/token/custom-token', + expect.objectContaining({ + method: 'DELETE', + headers: { authorization: 'Bearer custom-token' }, + }) + ) + expect(savedSettings).not.toHaveProperty(['//npm.example.com/:_authToken']) + }) + + it('should still remove token locally when registry returns non-ok response', async () => { + const globalInfo = jest.fn() + let savedSettings: Record = {} + + const context = createMockContext({ + globalInfo, + fetch: async () => createMockResponse({ ok: false, status: 404, text: 'Not Found' }), + readIniFile: async () => ({ + '//registry.npmjs.org/:_authToken': 'old-token', + }), + writeIniFile: async (_configPath, settings) => { + savedSettings = settings + }, + }) + + const opts = { + configDir: '/config', + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'old-token', + }, + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://registry.npmjs.org/') + expect(savedSettings).not.toHaveProperty(['//registry.npmjs.org/:_authToken']) + expect(globalInfo).toHaveBeenCalledWith('Registry returned HTTP 404 when revoking token') + }) + + it('should still remove token locally when fetch throws a network error', async () => { + const globalInfo = jest.fn() + let savedSettings: Record = {} + + const context = createMockContext({ + globalInfo, + fetch: async () => { + throw new Error('ECONNREFUSED') + }, + readIniFile: async () => ({ + '//registry.npmjs.org/:_authToken': 'net-err-token', + }), + writeIniFile: async (_configPath, settings) => { + savedSettings = settings + }, + }) + + const opts = { + configDir: '/config', + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'net-err-token', + }, + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://registry.npmjs.org/') + expect(savedSettings).not.toHaveProperty(['//registry.npmjs.org/:_authToken']) + expect(globalInfo).toHaveBeenCalledWith('Could not reach the registry to revoke the token') + }) + + it('should warn when token is not in auth.ini (e.g. from .npmrc)', async () => { + const globalWarn = jest.fn() + const configDir = process.platform === 'win32' ? 'Z:\\config' : '/config' + + const context = createMockContext({ + globalWarn, + fetch: async () => createMockResponse({ ok: true, status: 200 }), + readIniFile: async () => ({}), + writeIniFile: async () => { + throw new Error('writeIniFile should not be called when token is not in auth.ini') + }, + }) + + const opts = { + configDir, + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'npmrc-only-token', + }, + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://registry.npmjs.org/') + expect(globalWarn).toHaveBeenCalledWith( + expect.stringContaining(`was not found in ${path.join(configDir, 'auth.ini')}`) + ) + expect(globalWarn).toHaveBeenCalledWith( + expect.stringContaining('The token was revoked on the registry but must be removed manually') + ) + }) + + it('should throw when registry call fails and token is not in auth.ini', async () => { + const globalInfo = jest.fn() + const configDir = process.platform === 'win32' ? 'Z:\\config' : '/config' + + const context = createMockContext({ + globalInfo, + fetch: async () => createMockResponse({ ok: false, status: 401, text: 'Unauthorized' }), + readIniFile: async () => ({}), + writeIniFile: async () => { + throw new Error('writeIniFile should not be called when token is not in auth.ini') + }, + }) + + const opts = { + configDir, + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'orphan-token', + }, + } + + const promise = logout({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGOUT_FAILED') + await expect(promise).rejects.toHaveProperty( + ['message'], + expect.stringContaining('Failed to log out of https://registry.npmjs.org/') + ) + await expect(promise).rejects.toHaveProperty( + ['message'], + expect.stringContaining('may still need to be revoked on the registry') + ) + expect(globalInfo).toHaveBeenCalledWith('Registry returned HTTP 401 when revoking token') + }) + + it('should warn when auth.ini does not exist (ENOENT) and token comes from another source', async () => { + const globalWarn = jest.fn() + const configDir = process.platform === 'win32' ? 'Z:\\nonexistent\\config' : '/nonexistent/config' + + const context = createMockContext({ + globalWarn, + fetch: async () => createMockResponse({ ok: true, status: 200 }), + readIniFile: async () => { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }) + }, + writeIniFile: async () => { + throw new Error('writeIniFile should not be called when auth.ini does not exist') + }, + }) + + const opts = { + configDir, + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'token-in-npmrc', + }, + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://registry.npmjs.org/') + expect(globalWarn).toHaveBeenCalledWith( + expect.stringContaining(`was not found in ${path.join(configDir, 'auth.ini')}`) + ) + }) + + it('should propagate non-ENOENT errors from readIniFile', async () => { + const context = createMockContext({ + fetch: async () => createMockResponse({ ok: true, status: 200 }), + readIniFile: async () => { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + }, + writeIniFile: async () => {}, + }) + + const opts = { + configDir: '/broken/config', + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'some-token', + }, + } + + const promise = logout({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'EACCES') + }) + + it('should URL-encode the token when revoking', async () => { + const fetch = jest.fn(async () => createMockResponse({ ok: true, status: 200 })) + const globalWarn = jest.fn() + + const context = createMockContext({ + globalWarn, + fetch, + readIniFile: async () => ({}), + writeIniFile: async () => {}, + }) + + const opts = { + configDir: '/config', + dir: '/mock', + authConfig: { + '//registry.npmjs.org/:_authToken': 'token/with+special=chars', + }, + } + + await logout({ context, opts }) + + expect(fetch).toHaveBeenCalledWith( + 'https://registry.npmjs.org/-/user/token/token%2Fwith%2Bspecial%3Dchars', + expect.anything() + ) + }) + + it('should normalize the registry URL', async () => { + let savedSettings: Record = {} + + const context = createMockContext({ + fetch: async () => createMockResponse({ ok: true, status: 200 }), + readIniFile: async () => ({ + '//example.org/:_authToken': 'tok', + }), + writeIniFile: async (_configPath, settings) => { + savedSettings = settings + }, + }) + + const opts = { + configDir: '/config', + dir: '/mock', + authConfig: { + '//example.org/:_authToken': 'tok', + }, + registry: 'https://example.org', + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://example.org/') + expect(savedSettings).not.toHaveProperty(['//example.org/:_authToken']) + }) + + it('should handle registry with a path', async () => { + const fetch = jest.fn(async () => createMockResponse({ ok: true, status: 200 })) + let savedSettings: Record = {} + + const context = createMockContext({ + fetch, + readIniFile: async () => ({ + '//example.com/npm/:_authToken': 'path-token', + }), + writeIniFile: async (_configPath, settings) => { + savedSettings = settings + }, + }) + + const opts = { + configDir: '/config', + dir: '/mock', + authConfig: { + '//example.com/npm/:_authToken': 'path-token', + }, + registry: 'https://example.com/npm/', + } + + const result = await logout({ context, opts }) + + expect(result).toBe('Logged out of https://example.com/npm/') + expect(fetch).toHaveBeenCalledWith( + 'https://example.com/npm/-/user/token/path-token', + expect.anything() + ) + expect(savedSettings).not.toHaveProperty(['//example.com/npm/:_authToken']) + }) +}) diff --git a/pnpm/src/cmd/index.ts b/pnpm/src/cmd/index.ts index bbbc5a24bb..6b024530d2 100644 --- a/pnpm/src/cmd/index.ts +++ b/pnpm/src/cmd/index.ts @@ -1,4 +1,4 @@ -import { login } from '@pnpm/auth.commands' +import { login, logout } from '@pnpm/auth.commands' import { approveBuilds, ignoredBuilds, rebuild } from '@pnpm/building.commands' import { cache } from '@pnpm/cache.commands' import type { CommandHandlerMap, CompletionFunc } from '@pnpm/cli.command' @@ -150,6 +150,7 @@ const commands: CommandDefinition[] = [ link, list, login, + logout, ll, licenses, outdated, diff --git a/pnpm/src/cmd/notImplemented.ts b/pnpm/src/cmd/notImplemented.ts index 8759b49e03..170405528c 100644 --- a/pnpm/src/cmd/notImplemented.ts +++ b/pnpm/src/cmd/notImplemented.ts @@ -10,7 +10,6 @@ const NOT_IMPLEMENTED_COMMANDS = [ 'find', 'home', 'issues', - 'logout', 'owner', 'ping', 'prefix',