Files
pnpm/network
Khải d4a1d734b6 feat: pnpm login (#11094)
* refactor: extract web auth QR code and polling into @pnpm/network.web-auth

Extract generateQrCode() and pollForWebAuthToken() from releasing/commands
into a new shared package so that both `pnpm publish` and the upcoming
`pnpm login` can reuse the web-based authentication flow with QR code
display and doneUrl polling.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* feat: implement `pnpm login` command

Add `pnpm login` (and `pnpm adduser` alias) for authenticating with npm
registries. The command:

- Tries web-based login first (POST /-/v1/login), displaying a QR code
  and polling for the token using @pnpm/network.web-auth
- Falls back to classic username/password/email login (PUT /-/user/
  org.couchdb.user:<username>) when web login is not supported (404/405)
- Saves the received auth token to the user's global rc file

Also fixes a tsgo build issue in releasing/commands where
OtpWebAuthFetchOptions was used as a local type alias but was only
available as a re-exported name.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: resolve spellcheck issues in login test

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: correct alphabetical ordering for meta-updater

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: add meta-updater generated tsconfig files

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: add explicit return type to prompt mock for tsgo compatibility

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: use @pnpm/network.fetch instead of globalThis.fetch

Switch from globalThis.fetch to fetchWithAgent from @pnpm/network.fetch
so that pnpm login respects proxy settings (httpProxy/httpsProxy/noProxy),
custom SSL certificates (ca/cert/key), strictSsl, and retry configuration.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: improve login fetch types and use URL constructor

- Type LoginContext.fetch using WebAuthFetchOptions/WebAuthFetchResponse
  from @pnpm/network.web-auth, extended with text() and wider method
- Replace regex-based URL construction with new URL() constructor
- Remove redundant LoginFetchInit type

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: match publish pattern for dependency injection

- Static DEFAULT_CONTEXT constant instead of createDefaultContext factory
- context = DEFAULT_CONTEXT default parameter instead of context?: Partial
- Destructure context in function signatures for natural calling
- Use plain fetch from @pnpm/network.fetch (like SHARED_CONTEXT in publish)
- Context contains only side-effect functions and modules, not config

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: use typeof fetch instead of custom fetch types

Remove LoginFetchOptions and LoginFetchResponse. Type LoginContext.fetch
as typeof fetch from @pnpm/network.fetch directly, eliminating all casts.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: remove placeholder username from login success message

Web login doesn't return a username, so just report the registry.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: use tempDir from @pnpm/prepare instead of manual tmp dirs

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: update tsconfig references for @pnpm/prepare

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: inject readSettings/writeSettings for fully pure tests

Add readSettings and writeSettings to LoginContext so tests need no
filesystem side effects. Remove @pnpm/prepare devDependency.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: remove DEFAULT_CONTEXT from tests, use pure test context

Tests now construct their own TEST_CONTEXT with all no-op mocks,
eliminating any reliance on real side-effectful functions.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: use distinct opts per test, assert URLs and config paths

Each test now uses a different registry and configDir to verify URL
construction, config key generation, and save path are correct for
non-default options.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: throw on unexpected mock calls instead of silent fallbacks

All mock functions in TEST_CONTEXT now throw on unexpected calls,
ensuring tests fail loudly if the code makes unanticipated side effects.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: use IANA-reserved example.com domains in test URLs

Replace custom.registry.io and private.reg.co with example.com and
example.org (RFC 2606 reserved) to prevent domain squatting risks.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: use deterministic Date mock instead of native Date

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: assert globalInfo calls, throw on unexpected ones

Default globalInfo in TEST_CONTEXT now throws. Each test overrides it
to capture messages and asserts the expected output.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: use inferred type for fetch url parameter in tests

Drop explicit `string` annotation so the parameter matches the
`RequestInfo` type expected by the fetch signature.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: resolve type errors in login test mock fetch

Use mockResponse helper with `as any` cast to satisfy the Response
type, and String(url) for RequestInfo-to-string conversion.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: add tsconfig.lint.tsbuildinfo to .gitignore

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: replace typeof fetch with explicit LoginFetchResponse/LoginFetchOptions types

Derive the fetch signature from actual call-site usage instead of
coupling to the concrete @pnpm/network.fetch type. This lets test
mocks return plain objects without casts.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: gitignore generated pn/pnpx/pnx artifacts

These files are created by setup.js during preinstall and should not
be tracked.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: remove unnecessary backwards-compat aliases from otp.ts

Remove Otp-prefixed re-exports (OtpWebAuthFetchOptions,
OtpWebAuthFetchResponse, OtpWebAuthTimeoutError) that only existed as
backwards-compatibility shims. Update the test to import directly from
@pnpm/network.web-auth. Restore the named OtpDate interface that was
unnecessarily inlined.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test(web-auth): add comprehensive unit tests for @pnpm/network.web-auth

Add dependency-injected unit tests covering:
- WebAuthTimeoutError: properties, code, hint, message
- generateQrCode: basic output and input differentiation
- pollForWebAuthToken: happy path, fetch argument passing,
  Retry-After handling (valid, non-finite, null, sub-interval,
  capped to remaining timeout, timeout during retry wait),
  error recovery (fetch throws, non-ok response, json parse error,
  missing token, empty token, multiple consecutive errors),
  custom timeout, poll interval timing

All tests use fake Date.now() and setTimeout — no real timers or
side effects.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix(web-auth): fix TS2339 compile errors in test assertions

Replace `.catch((e: WebAuthTimeoutError) => e)` pattern with
`rejects.toMatchObject()` to avoid `string | WebAuthTimeoutError`
union type issue when accessing `.timeout` property.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* feat(web-auth,login): extract shared OTP handling and add OTP support to login

- Create `withOtpHandling<T>()` in `@pnpm/network.web-auth` that wraps
  any operation with EOTP challenge detection, web auth flow, and
  classic OTP prompting.
- Refactor `publishWithOtpHandling` to delegate to the shared function.
- Add OTP handling to `pnpm login`'s classic (CouchDB) login flow:
  detects 401 + `www-authenticate: otp` header and retries with the
  OTP code (or web auth token) in the `npm-otp` header.
- Remove overly strict `this: this` constraints from WebAuthFetchResponse
  interfaces to improve cross-package type compatibility.
- Add 13 unit tests for `withOtpHandling` (classic + webauth flows).
- Add 4 login OTP tests (classic OTP, webauth OTP, non-401, non-otp 401).

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix(login): use word-boundary regex for URL assertion in test

Replace `m.includes(url)` with a regex that checks the URL is
bounded by whitespace or string boundaries, addressing the CodeQL
"incomplete URL substring sanitization" finding.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(login): use toContainEqual + stringMatching for URL assertion

Replace manual `.some()` with Jest's `toContainEqual(expect.stringMatching(...))`
for better error messages on failure.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(web-auth): use expect.any(String) instead of typeof check

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(web-auth): consolidate multi-property assertions

Use toMatchObject and toEqual instead of separate per-property expects.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* docs: explain why npm-auth-type header is sent unconditionally

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: remove unused re-exports and add missing test coverage

Remove dead re-exports of OtpHandlingPromptOptions and
OtpHandlingPromptResponse from releasing/commands/src/publish/otp.ts.

Add tests for:
- LOGIN_MISSING_CREDENTIALS (empty username in classic login)
- LOGIN_NO_TOKEN (registry returns success without token)
- LOGIN_INVALID_RESPONSE (web login returns incomplete response)
- isWebLoginNotSupported with 405 status code

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(login): rename readSettings/writeSettings to safeReadIniFile/writeIniFile

Use the actual function names in the LoginContext interface instead of
abstract names, matching the implementations they wrap.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(otp): remove unnecessary re-exports from otp.ts

OtpNonInteractiveError, OtpSecondChallengeError, and OtpHandlingEnquirer
were re-exported only for the test file, which can import them directly
from @pnpm/network.web-auth.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(otp): remove unused SHARED_CONTEXT re-export

All consumers already import SHARED_CONTEXT directly from
./utils/shared-context.js, making this re-export dead code.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(login): extract LoginDate and LoginEnquirer interfaces

Extract named interfaces for the Date and enquirer members of
LoginContext instead of inlining their types.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: stop renaming

Claude Code Web didn't rename them thoroughly, so I had to do it myself

* docs: correct the lines

Why did Claude Code Web misaligned?

* refactor: strictly type `LoginFetchOptions.headers`

* docs: remove redundant comments

* refactor: inline `npm-otp`

* refactor: inline `headers`

* feat: add `WebLoginError.responseText`

* refactor: rename `statusCode` into `httpStatus`

* refactor(login): extract ClassicLoginError subclass from PnpmError

Extract the LOGIN_FAILED error into a dedicated ClassicLoginError class
with httpStatus and responseText properties, matching the WebLoginError
pattern.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: remove unnecessary import

* docs(changeset): correct a changeset

* docs(changeset): re-add `releasing.commands`

* refactor(web-auth): split monolithic test file into per-module files

Split index.test.ts into four files matching the source structure:
- WebAuthTimeoutError.test.ts
- generateQrCode.test.ts
- pollForWebAuthToken.test.ts
- withOtpHandling.test.ts

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: remove unnecessary `as const`

* refactor: remove unnecessary `as const`

* chore: undo Claude's BS

* refactor: extract `LoginEnquirerOptions`

* refactor: move types closer to their usesites

* refactor: remove simple type alias

* fix: type errors

* refactor(login): inject readIniFile instead of safeReadIniFile in context

The context object should only contain external dependencies. safeReadIniFile
is a local wrapper, not an external dependency, so inject readIniFile (from
read-ini-file) instead and pass it to safeReadIniFile as a parameter.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* test(login): add coverage for safeReadIniFile ENOENT handling

Test that login succeeds with empty settings when the config file does
not exist (ENOENT), and that non-ENOENT errors (e.g. EACCES) are
properly propagated.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: fix ugliness

* refactor: just pass context object

* refactor: destructure `context`

* refactor: pass the `context` object

* refactor: destructure `context`

* refactor: pass `context` object directly

* refactor: remove unnecessary parenthesis

* fix: remove unused import

* refactor: remove unnecessary parentheses from single-param arrows in tests

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: extract `LoginFetchResponseHeaders`

* fix(login): remove inline default from --registry option description

No other pnpm command includes "(default: ...)" in option descriptions.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor(tests): enforce realistic mock response behavior

- Add createMockResponse helpers that enforce single body consumption
  (calling text() or json() twice, or both, throws an error)
- Default headers.get to throwing on unexpected calls, forcing tests
  to explicitly provide headers when the code under test reads them
- Replace all inline response objects with createMockResponse calls

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: formatting

* refactor: reuse

* docs: clarify what the error is actually about

* docs: consistent error message

* refactor: use consistent error message convention in test mocks

Capitalize and use "Unexpected call to <thing>" pattern instead of
AI-generated "unexpected X call" messages.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: expand inline process mock objects to multi-line

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor(login): extract PnpmError subclasses and use stricter test assertions

Extract LoginNonInteractiveError, LoginInvalidResponseError,
LoginMissingCredentialsError, and LoginNoTokenError subclasses instead
of throwing PnpmError directly.

Update test assertions to use the const promise pattern with
toHaveProperty checks on both code and message, matching the
convention used elsewhere in the codebase.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: undo ai's nonsensical deletion

* refactor: simplify

* refactor: rename OtpHandling* types to Otp* for brevity

OtpHandlingContext → OtpContext
OtpHandlingEnquirer → OtpEnquirer
OtpHandlingPromptOptions → OtpPromptOptions
OtpHandlingPromptResponse → OtpPromptResponse

The OtpHandling prefix was named after the function (withOtpHandling)
rather than the domain concept.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: extract `OtpDate`

* refactor: reuse

* fix: eslint

* refactor: add OtpRequiredError with body validation and globalWarn

- Add OtpRequiredError class with static fromUnknown() that validates
  the EOTP error body shape and returns either a validated error or an
  OtpBodyWarning when fields have unexpected types
- Add globalWarn to OtpContext so withOtpHandling can warn on bad body
  shapes instead of silently dropping them
- Update throwIfOtpRequired in login.ts to pass raw body through so
  validation happens in withOtpHandling via fromUnknown
- Add tests for bad body shapes (wrong types for authUrl/doneUrl)
- Add tests for OtpRequiredError.fromUnknown
- Propagate globalWarn through LoginContext, DEFAULT_CONTEXT,
  SHARED_CONTEXT, and all test mocks

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* docs: remove misleading comment from throwIfOtpRequired

The comment referenced downstream machinery (OtpRequiredError.fromUnknown)
that the reader shouldn't need to know about at this call site.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace Object.assign hack with OtpRequiredError in throwIfOtpRequired

throwIfOtpRequired now validates the raw response body via
OtpRequiredError.fromUnknown and throws a proper OtpRequiredError
instead of monkey-patching properties onto a plain Error.

withOtpHandling skips re-validation when the caught error is already
an OtpRequiredError instance.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* chore(git): revert an imperfect fix

This reverts commit f91efc1d9e.

* chore(git): revert would-be irrelevant change

This reverts commit 646c09cc66.

* chore(git): revert an imperfect fix

This reverts commit 45ff1ca601.

* refactor: replace Object.assign hack with ArtificialOtpError

Add ArtificialOtpError class that implements OtpError and validates
unknown body shapes via fromUnknownBody static method, warning on
unexpected types instead of silently dropping them.

Add globalWarn to OtpContext and propagate through LoginContext,
DEFAULT_CONTEXT, SHARED_CONTEXT, and all test mocks.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: rename ArtificialOtpError to SyntheticOtpError

"Synthetic" better conveys that the error is programmatically
constructed from raw data, not that it's fake.

Also fix grammatical error in JSDoc ("meant to thrown" → "meant to be thrown").

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: eslint

Claude Code Web got it wrong this time

(or maybe because it inherited from my sketch diff? I'm not sure)

* fix: eslint

Ah! I got it. Claude Code Web was at fault here: It renamed "artificial"
to "synthetic" without re-ordering

Dumb AI!

* fix: formatting

Once again caused by Claude Code.

Anyway,

The exact equivalent refactor should have been `void warnings.push(msg)`,
if you really want to be pedantic, that is.

TypeScript, however, allows a `void` function to return any type. Reason
being that they shall all be discarded anyway.

* refactor: remove unnecessary re-assignment

* test: remove unnecessary assertion

* refactor: make default globalInfo and globalWarn mocks throw on unexpected calls

Replace no-op defaults with throwing mocks in createOtpMockContext
and createMockContext. Tests that expect these to be called now
explicitly override them.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: use toEqual with stringContaining for array assertions

Replace toHaveLength + indexed toContain pairs with single
toEqual([expect.stringContaining(...)]) assertions.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace globalInfo no-ops with jest.fn() and add assertions

For error tests: remove globalInfo override entirely, letting the
default throwing mock catch unexpected calls.

For success tests: use jest.fn() and assert globalInfo was called
with the expected arguments.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace manual array collectors with jest.fn()

Replace infoMessages/warnings arrays and push callbacks with
jest.fn() and assertions on .mock.calls. This is more idiomatic
and eliminates the boilerplate array + push pattern.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace remaining globalInfo no-ops with jest.fn() in otp.test.ts

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): throw on unexpected second call instead of returning 'never'

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add missing globalInfo assertion in classic OTP test

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add missing globalInfo assertion in otp webauth polling test

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add @jest/globals import for jest.fn()

jest is not a global in ESM mode (--experimental-vm-modules).
Add import { jest } from '@jest/globals' to all test files using
jest.fn(), and add @jest/globals devDependency to network/web-auth.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* chore(deps): update lockfile

* fix: eslint

* fix(test): add globalInfo mock to EACCES readIniFile test

The test triggers web login (which calls globalInfo with the QR code)
before reaching readIniFile. Without a globalInfo override, the
default throwing mock causes the test to fail at the wrong point.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add missing globalInfo assertion in EACCES readIniFile test

Extract inline jest.fn() to const and assert it was called with
the web login QR code URL.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: convert functions with 3+ args to params objects

Per the style guide: "Functions should have no more than two or three
arguments. If a function needs more parameters, use a single options
object instead."

- withOtpHandling(operation, context, fetchOptions) → withOtpHandling({ operation, context, fetchOptions })
- pollForWebAuthToken(doneUrl, context, fetchOptions, timeoutMs) → pollForWebAuthToken({ doneUrl, context, fetchOptions, timeoutMs })
- webLogin(registry, fetchOptions, context) → webLogin({ registry, fetchOptions, context })
- classicLogin(registry, context, fetchOptions) → classicLogin({ registry, context, fetchOptions })

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: sort params object properties alphabetically

Sort interface properties, function signature destructuring, and
call site arguments in alphabetical order to match the convention
used by publishWithOtpHandling.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: adopt otp.test.ts patterns in login and web-auth tests

- Build context and opts as separate variables, then call login/
  withOtpHandling/pollForWebAuthToken on a clean line
- Add createMockContext to login.test.ts
- Convert createMockContext to arrow functions (single return
  expression), keep createMockResponse as function declaration
  (has local state)

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: eslint

* refactor: inline the one-off function

* fix(login): avoid sending 'npm-otp: undefined' header on initial request

When otp is undefined (first attempt before OTP challenge), the header
'npm-otp': undefined could be coerced to the string "undefined" by
some HTTP implementations. Use conditional spread instead.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* docs(login): explain why npm-otp header is conditionally spread

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* docs(otp): explain why otp: undefined is safe in publishOptions spread

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): use path.join in assertions for Windows compatibility

path.join produces backslashes on Windows, so hardcoded forward-slash
paths in assertions fail on Windows CI.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: import order — standard library before external deps

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-27 12:00:22 +01:00
..
2026-03-27 12:00:22 +01:00