From b93bab6ac2820cd3557434ea2f8da59e824e855c Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 03:08:01 +0900 Subject: [PATCH 1/8] refactor: replace curl-fetch with ky + ky-cookies --- .gitignore | 2 +- package.json | 7 +- pnpm-lock.yaml | 69 ++++++++++- skills/npm-pkg-settings/SKILL.md | 8 +- src/context/login.ts | 2 +- src/create-client.ts | 6 +- src/create-fetch.ts | 12 ++ src/curl-fetch.ts | 115 ------------------ src/types.ts | 16 ++- src/utils/authenticated-get.ts | 4 +- tests/index.ts | 6 - tests/specs/api/add-maintainer.ts | 4 +- tests/specs/api/get-package-access.ts | 4 +- tests/specs/api/get-username.ts | 4 +- tests/specs/api/link-trusted-publisher.ts | 4 +- tests/specs/api/list-packages.ts | 4 +- tests/specs/api/set-publishing-access.ts | 4 +- tests/specs/context/perform-login.ts | 4 +- tests/specs/curl-fetch/curl-fetch.ts | 121 ------------------- tests/specs/curl-fetch/parse-headers.ts | 40 ------ tests/specs/curl-fetch/parse-raw-response.ts | 106 ---------------- tests/specs/utils/authenticated-get.ts | 4 +- tests/specs/utils/npm-fetch.ts | 4 +- tests/specs/utils/submit-with-otp.ts | 4 +- 24 files changed, 116 insertions(+), 438 deletions(-) create mode 100644 src/create-fetch.ts delete mode 100644 src/curl-fetch.ts delete mode 100644 tests/specs/curl-fetch/curl-fetch.ts delete mode 100644 tests/specs/curl-fetch/parse-headers.ts delete mode 100644 tests/specs/curl-fetch/parse-raw-response.ts diff --git a/.gitignore b/.gitignore index 1342f40..a2cfcd6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ dist .eslintcache # Project-specific -.npm-pkg-settings.session.txt +.npm-pkg-settings.cookies.json # Agent skills from npm packages (managed by skills-npm) **/skills/npm-* diff --git a/package.json b/package.json index 1431825..f981010 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,14 @@ "ansis": "^4.2.0", "cleye": "^2.2.1", "jsqr": "^1.4.0", + "ky": "^1.14.3", + "ky-cookies": "^1.0.0", "linkedom": "^0.18.12", - "nano-spawn": "^2.0.0", "otplib": "^13.3.0", "pngjs": "^7.0.0", - "tasuku": "3.0.0-beta.3" + "tasuku": "3.0.0-beta.3", + "tough-cookie": "^6.0.1", + "tough-cookie-file-store": "^3.3.0" }, "devDependencies": { "@types/node": "^25.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3cb7fc..f13f003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,15 @@ importers: jsqr: specifier: ^1.4.0 version: 1.4.0 + ky: + specifier: ^1.14.3 + version: 1.14.3 + ky-cookies: + specifier: ^1.0.0 + version: 1.0.0(@types/tough-cookie@4.0.5)(ky@1.14.3)(tough-cookie@6.0.1) linkedom: specifier: ^0.18.12 version: 0.18.12 - nano-spawn: - specifier: ^2.0.0 - version: 2.0.0 otplib: specifier: ^13.3.0 version: 13.3.0 @@ -32,6 +35,12 @@ importers: tasuku: specifier: 3.0.0-beta.3 version: 3.0.0-beta.3 + tough-cookie: + specifier: ^6.0.1 + version: 6.0.1 + tough-cookie-file-store: + specifier: ^3.3.0 + version: 3.3.0 devDependencies: '@types/node': specifier: ^25.3.0 @@ -850,6 +859,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2079,6 +2091,18 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + ky-cookies@1.0.0: + resolution: {integrity: sha512-j0FMtoPvEE18eiyLSJFiNLwaK0Ij+G7a9Ohc25boEZjEDFHYBRL1dUheGtUX8cUlbbLt18TG8tz8t4FZjZYH9w==} + engines: {node: '>=20'} + peerDependencies: + '@types/tough-cookie': '>=4' + ky: '>=1' + tough-cookie: '>=6' + + ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} + engines: {node: '>=18'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2678,10 +2702,25 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie-file-store@3.3.0: + resolution: {integrity: sha512-FbO/cOi/jp4wweo8soVNG/ZjDsgpBZWqaxWwu7gRKvsjg/Qt44kStp87VLfJnin749DlTbZDYvV1wuSr5jly2g==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -3523,6 +3562,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tough-cookie@4.0.5': {} + '@types/unist@3.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -4955,6 +4996,14 @@ snapshots: kind-of@6.0.3: {} + ky-cookies@1.0.0(@types/tough-cookie@4.0.5)(ky@1.14.3)(tough-cookie@6.0.1): + dependencies: + '@types/tough-cookie': 4.0.5 + ky: 1.14.3 + tough-cookie: 6.0.1 + + ky@1.14.3: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5861,10 +5910,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie-file-store@3.3.0: + dependencies: + tough-cookie: 6.0.1 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/skills/npm-pkg-settings/SKILL.md b/skills/npm-pkg-settings/SKILL.md index 83521b8..236ca84 100644 --- a/skills/npm-pkg-settings/SKILL.md +++ b/skills/npm-pkg-settings/SKILL.md @@ -5,7 +5,7 @@ description: Manages npm package publish settings — listing packages, viewing/ # npm-pkg-settings -CLI and programmatic API for managing npm package publish settings. Works by automating the npm website via browser-impersonating HTTP requests (`curl_chrome145`), since npm has no public API for package settings. Automatically handles OTP generation and submission during all operations. +CLI and programmatic API for managing npm package publish settings. Works by automating the npm website (using ky + ky-cookies for HTTP and session management), since npm has no public API for package settings. Automatically handles OTP generation and submission during all operations. ## Setup @@ -33,10 +33,10 @@ npm-pkg-settings decode-secret 'otpauth-migration://offline?data=...' ### Session and gitignore -The CLI persists a browser session to `.npm-pkg-settings.session.txt`. Add to `.gitignore`: +The CLI persists cookies to `.npm-pkg-settings.cookies.json`. Add to `.gitignore`: ``` -.npm-pkg-settings.session.txt +.npm-pkg-settings.cookies.json .env ``` @@ -110,7 +110,7 @@ const client = createClient({ otpSecret: process.env.NPM_OTP_SECRET!, username: process.env.NPM_USERNAME, // optional — enables login() password: process.env.NPM_PASSWORD, // optional — enables login() - sessionFile: '.npm-pkg-settings.session.txt', // optional, this is the default + sessionFile: '.npm-pkg-settings.cookies.json', // optional, this is the default }) ``` diff --git a/src/context/login.ts b/src/context/login.ts index 752b192..79e6c32 100644 --- a/src/context/login.ts +++ b/src/context/login.ts @@ -48,7 +48,7 @@ export const performLogin = async ( // Follow redirect to OTP page if (loginResponse.status >= 300 && loginResponse.status < 400) { - const { location } = loginResponse.headers; + const location = loginResponse.headers.get('location'); if (location) { const otpPage = await npmFetch(context, location); const otpPageBody = await otpPage.text(); diff --git a/src/create-client.ts b/src/create-client.ts index 1a91cf8..b96eb14 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -1,4 +1,4 @@ -import { createFetch } from './curl-fetch.ts'; +import { createFetch, defaultSessionFile } from './create-fetch.ts'; import { performLogin } from './context/login.ts'; import { getUsername } from './api/get-username.ts'; import { listPackages } from './api/list-packages.ts'; @@ -9,11 +9,11 @@ import { addMaintainer } from './api/add-maintainer.ts'; import type { CreateClientOptions, NpmClient } from './types.ts'; import { defaultOtpGenerator } from './utils/default-otp-generator.ts'; -export const defaultSessionFile = '.npm-pkg-settings.session.txt'; +export { defaultSessionFile }; export const createClient = (options: CreateClientOptions): NpmClient => { const context = { - fetch: createFetch({ sessionFile: options.sessionFile ?? defaultSessionFile }), + fetch: createFetch(options.sessionFile ?? defaultSessionFile), otpSecret: options.otpSecret, otpGenerator: defaultOtpGenerator, cachedUsername: options.username, diff --git a/src/create-fetch.ts b/src/create-fetch.ts new file mode 100644 index 0000000..7c62cd9 --- /dev/null +++ b/src/create-fetch.ts @@ -0,0 +1,12 @@ +import ky from 'ky'; +import { CookieJar } from 'tough-cookie'; +import FileCookieStore from 'tough-cookie-file-store'; +import { withCookies } from 'ky-cookies'; + +export const defaultSessionFile = '.npm-pkg-settings.cookies.json'; + +export const createFetch = (sessionFile: string) => ky.create({ + throwHttpErrors: false, + redirect: 'manual', + ...withCookies(new CookieJar(new FileCookieStore(sessionFile))), +}); diff --git a/src/curl-fetch.ts b/src/curl-fetch.ts deleted file mode 100644 index d431b04..0000000 --- a/src/curl-fetch.ts +++ /dev/null @@ -1,115 +0,0 @@ -import defaultSpawn from 'nano-spawn'; - -export type SpawnFunction = ( - command: string, - arguments_: string[], -) => Promise<{ stdout: string }>; - -type CurlFetchOptions = { - sessionFile?: string; - spawn?: SpawnFunction; -}; - -export type CurlRequestInit = { - method?: string; - headers?: Record; - body?: URLSearchParams | string; - redirect?: 'follow' | 'manual'; -}; - -export type CurlResponseLike = { - status: number; - statusText: string; - headers: Record; - ok: boolean; - text(): Promise; - json(): Promise; -}; - -const createResponse = ( - status: number, - statusText: string, - headers: Record, - body: string, -): CurlResponseLike => ({ - status, - statusText, - headers, - ok: status >= 200 && status < 300, - text: async () => body, - json: async () => JSON.parse(body), -}); - -const parseResponseHeaders = (raw: string): Record => { - const headers: Record = {}; - for (const line of raw.split('\r\n')) { - const index = line.indexOf(': '); - if (index === -1) { - continue; - } - headers[line.slice(0, index).toLowerCase()] = line.slice(index + 2); - } - return headers; -}; - -export const createFetch = (options: CurlFetchOptions = {}) => { - const { sessionFile, spawn = defaultSpawn } = options; - - return async (url: string, init?: CurlRequestInit): Promise => { - const method = init?.method ?? 'GET'; - - const arguments_: string[] = [ - '-s', - '-D', - '-', - '-o', - '-', - '-X', - method, - ]; - - if (sessionFile) { - arguments_.push('-b', sessionFile, '-c', sessionFile); - } - - if (init?.redirect === 'follow') { - arguments_.push('-L'); - } else { - arguments_.push('--max-redirs', '0'); - } - - if (init?.headers) { - for (const [key, value] of Object.entries(init.headers)) { - arguments_.push('-H', `${key}: ${value}`); - } - } - - if (init?.body) { - const bodyString = init.body instanceof URLSearchParams - ? init.body.toString() - : init.body; - arguments_.push( - '-H', 'content-type: application/x-www-form-urlencoded', '-d', bodyString, - ); - } - - arguments_.push(url); - - const result = await spawn('curl_chrome145', arguments_); - - const separator = result.stdout.indexOf('\r\n\r\n'); - if (separator === -1) { - return createResponse(0, '', {}, result.stdout); - } - const headerSection = result.stdout.slice(0, separator); - const body = result.stdout.slice(separator + 4); - - const statusMatch = headerSection.match(/^HTTP\/\S+ (\d+)\s*(.*)/); - const status = statusMatch ? Number(statusMatch[1]) : 0; - const statusText = statusMatch?.[2]?.trim() ?? ''; - - const headers = parseResponseHeaders(headerSection); - - return createResponse(status, statusText, headers, body); - }; -}; diff --git a/src/types.ts b/src/types.ts index de23ffd..68672bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,20 @@ -import type { CurlRequestInit, CurlResponseLike } from './curl-fetch.ts'; +export type OtpGenerator = (secret: string) => Promise; -export type CurlFetch = (url: string, init?: CurlRequestInit) => Promise; +export type NpmFetchResponse = { + status: number; + headers: { get(name: string): string | null }; + text(): Promise; + json(): Promise; +}; -export type OtpGenerator = (secret: string) => Promise; +export type NpmFetch = ( + url: string, + options?: Record, +) => Promise; // Internal session state export type NpmContext = { - fetch: CurlFetch; + fetch: NpmFetch; otpSecret: string; otpGenerator: OtpGenerator; cachedUsername?: string; diff --git a/src/utils/authenticated-get.ts b/src/utils/authenticated-get.ts index 126a8fa..e459894 100644 --- a/src/utils/authenticated-get.ts +++ b/src/utils/authenticated-get.ts @@ -25,7 +25,7 @@ const handleOtpEscalation = async ( }); if (otpResponse.status >= 300 && otpResponse.status < 400) { - const { location } = otpResponse.headers; + const location = otpResponse.headers.get('location'); if (location) { const finalResponse = await npmFetch(context, location); return { @@ -50,7 +50,7 @@ export const authenticatedGet = async ( // Follow redirect (escalation or login) if (response.status >= 300 && response.status < 400) { - const { location } = response.headers; + const location = response.headers.get('location'); if (location) { response = await npmFetch(context, location); } diff --git a/tests/index.ts b/tests/index.ts index 90376c2..52130e1 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -7,12 +7,6 @@ describe('npm-pkg-settings', () => { import('./specs/parsers/package-access.ts'); }); - describe('curl-fetch', () => { - import('./specs/curl-fetch/curl-fetch.ts'); - import('./specs/curl-fetch/parse-headers.ts'); - import('./specs/curl-fetch/parse-raw-response.ts'); - }); - describe('utils', () => { import('./specs/utils/npm-fetch.ts'); import('./specs/utils/generate-otp.ts'); diff --git a/tests/specs/api/add-maintainer.ts b/tests/specs/api/add-maintainer.ts index 8dd350e..aa59fb7 100644 --- a/tests/specs/api/add-maintainer.ts +++ b/tests/specs/api/add-maintainer.ts @@ -4,9 +4,7 @@ import type { NpmContext } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/api/get-package-access.ts b/tests/specs/api/get-package-access.ts index 49f9340..13f2d5f 100644 --- a/tests/specs/api/get-package-access.ts +++ b/tests/specs/api/get-package-access.ts @@ -4,9 +4,7 @@ import type { NpmContext } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/api/get-username.ts b/tests/specs/api/get-username.ts index b60909c..af86350 100644 --- a/tests/specs/api/get-username.ts +++ b/tests/specs/api/get-username.ts @@ -4,9 +4,7 @@ import type { NpmContext } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/api/link-trusted-publisher.ts b/tests/specs/api/link-trusted-publisher.ts index 7f37520..5266862 100644 --- a/tests/specs/api/link-trusted-publisher.ts +++ b/tests/specs/api/link-trusted-publisher.ts @@ -4,9 +4,7 @@ import type { NpmContext } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/api/list-packages.ts b/tests/specs/api/list-packages.ts index 5dbfd66..fae13a7 100644 --- a/tests/specs/api/list-packages.ts +++ b/tests/specs/api/list-packages.ts @@ -4,9 +4,7 @@ import type { NpmContext } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/api/set-publishing-access.ts b/tests/specs/api/set-publishing-access.ts index 52fccf6..4727d82 100644 --- a/tests/specs/api/set-publishing-access.ts +++ b/tests/specs/api/set-publishing-access.ts @@ -4,9 +4,7 @@ import type { NpmContext } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/context/perform-login.ts b/tests/specs/context/perform-login.ts index 011a9ec..f24d704 100644 --- a/tests/specs/context/perform-login.ts +++ b/tests/specs/context/perform-login.ts @@ -4,9 +4,7 @@ import type { NpmContext } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/curl-fetch/curl-fetch.ts b/tests/specs/curl-fetch/curl-fetch.ts deleted file mode 100644 index 1e1401d..0000000 --- a/tests/specs/curl-fetch/curl-fetch.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, test, expect } from 'manten'; -import { createFetch, type SpawnFunction } from '../../../src/curl-fetch.ts'; - -const mockResponse = 'HTTP/1.1 200 OK\r\n\r\n'; - -const captureSpawn = () => { - const calls: { command: string; - arguments_: string[]; }[] = []; - const spawn: SpawnFunction = async (command, arguments_) => { - calls.push({ - command, - arguments_, - }); - return { stdout: mockResponse }; - }; - return { - spawn, - calls, - }; -}; - -describe('createFetch', () => { - test('calls curl_chrome145', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com'); - expect(calls[0].command).toBe('curl_chrome145'); - }); - - test('passes URL as last argument', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com/path'); - expect(calls[0].arguments_.at(-1)).toBe('https://example.com/path'); - }); - - test('defaults to GET method', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com'); - expect(calls[0].arguments_).toContain('GET'); - }); - - test('sets POST method', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { method: 'POST' }); - expect(calls[0].arguments_).toContain('POST'); - }); - - test('adds cookie file args when provided', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ - sessionFile: '/tmp/jar.txt', - spawn, - }); - await fetch('https://example.com'); - expect(calls[0].arguments_).toContain('-b'); - expect(calls[0].arguments_).toContain('/tmp/jar.txt'); - expect(calls[0].arguments_).toContain('-c'); - }); - - test('omits cookie args when no sessionFile', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com'); - expect(calls[0].arguments_).not.toContain('-b'); - expect(calls[0].arguments_).not.toContain('-c'); - }); - - test('adds -L for redirect follow', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { redirect: 'follow' }); - expect(calls[0].arguments_).toContain('-L'); - }); - - test('adds --max-redirs 0 for redirect manual', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { redirect: 'manual' }); - expect(calls[0].arguments_).toContain('--max-redirs'); - expect(calls[0].arguments_).toContain('0'); - }); - - test('passes custom headers', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { - headers: { - 'x-custom': 'value', - authorization: 'Bearer token', - }, - }); - expect(calls[0].arguments_).toContain('x-custom: value'); - expect(calls[0].arguments_).toContain('authorization: Bearer token'); - }); - - test('passes URLSearchParams body with content-type', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { - method: 'POST', - body: new URLSearchParams({ user: 'alice' }), - }); - expect(calls[0].arguments_).toContain('-d'); - expect(calls[0].arguments_).toContain('user=alice'); - expect(calls[0].arguments_).toContain('content-type: application/x-www-form-urlencoded'); - }); - - test('passes string body', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { - method: 'POST', - body: 'raw-body', - }); - expect(calls[0].arguments_).toContain('-d'); - expect(calls[0].arguments_).toContain('raw-body'); - }); -}); diff --git a/tests/specs/curl-fetch/parse-headers.ts b/tests/specs/curl-fetch/parse-headers.ts deleted file mode 100644 index 9dc89e7..0000000 --- a/tests/specs/curl-fetch/parse-headers.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, test, expect } from 'manten'; -import { createFetch, type SpawnFunction } from '../../../src/curl-fetch.ts'; - -const mockSpawn = (raw: string): SpawnFunction => async () => ({ stdout: raw }); - -describe('response header parsing', () => { - test('parses standard HTTP headers', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nSet-Cookie: sid=abc\r\n\r\nbody'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['content-type']).toBe('text/html'); - expect(response.headers['set-cookie']).toBe('sid=abc'); - }); - - test('lowercases header names', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nX-Custom-Header: value\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['x-custom-header']).toBe('value'); - }); - - test('handles colons in header values', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nLocation: https://example.com:443/path\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers.location).toBe('https://example.com:443/path'); - }); - - test('skips status line (no colon-space separator)', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['content-type']).toBe('text/html'); - expect(Object.keys(response.headers)).toHaveLength(1); - }); -}); diff --git a/tests/specs/curl-fetch/parse-raw-response.ts b/tests/specs/curl-fetch/parse-raw-response.ts deleted file mode 100644 index 807ff98..0000000 --- a/tests/specs/curl-fetch/parse-raw-response.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, test, expect } from 'manten'; -import { createFetch, type SpawnFunction } from '../../../src/curl-fetch.ts'; - -const mockSpawn = (raw: string): SpawnFunction => async () => ({ stdout: raw }); - -describe('response parsing', () => { - test('splits headers and body on double CRLF', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\nhello'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(200); - expect(response.ok).toBe(true); - expect(response.statusText).toBe('OK'); - expect(response.headers['content-type']).toBe('text/html'); - expect(await response.text()).toBe('hello'); - }); - - test('parses 302 redirect', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/2 302 Found\r\nLocation: /otp\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(302); - expect(response.ok).toBe(false); - expect(response.headers.location).toBe('/otp'); - }); - - test('parses 403 status', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 403 Forbidden\r\n\r\nblocked'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(403); - expect(response.statusText).toBe('Forbidden'); - expect(await response.text()).toBe('blocked'); - }); - - test('returns status 0 for malformed input', async () => { - const fetch = createFetch({ - spawn: mockSpawn('garbage\r\n\r\nbody'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(0); - }); - - test('handles missing header-body separator', async () => { - const fetch = createFetch({ - spawn: mockSpawn('raw output with no separator'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(0); - expect(response.statusText).toBe(''); - expect(await response.text()).toBe('raw output with no separator'); - }); - - test('handles empty output', async () => { - const fetch = createFetch({ - spawn: mockSpawn(''), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(0); - expect(await response.text()).toBe(''); - }); - - test('json() parses body as JSON', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\n\r\n{"key":"value"}'), - }); - const response = await fetch('http://example.com'); - expect(await response.json()).toEqual({ key: 'value' }); - }); - - test('json() throws on invalid JSON', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\n\r\n{invalid json}'), - }); - const response = await fetch('http://example.com'); - await expect(response.json()).rejects.toThrow(); - }); - - test('ok is true for 2xx status', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 201 Created\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.ok).toBe(true); - expect(response.status).toBe(201); - }); - - test('ok is false for non-2xx status', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 500 Internal Server Error\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.ok).toBe(false); - }); - - test('handles duplicate headers (last value wins)', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nX-Val: first\r\nX-Val: second\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['x-val']).toBe('second'); - }); -}); diff --git a/tests/specs/utils/authenticated-get.ts b/tests/specs/utils/authenticated-get.ts index daa9b85..564bff8 100644 --- a/tests/specs/utils/authenticated-get.ts +++ b/tests/specs/utils/authenticated-get.ts @@ -4,9 +4,7 @@ import { authenticatedGet } from '../../../src/utils/authenticated-get.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/utils/npm-fetch.ts b/tests/specs/utils/npm-fetch.ts index 071e1cf..7bd67a7 100644 --- a/tests/specs/utils/npm-fetch.ts +++ b/tests/specs/utils/npm-fetch.ts @@ -4,9 +4,7 @@ import { npmFetch } from '../../../src/utils/npm-fetch.ts'; const mockResponse = (status: number, body: string) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers: {} as Record, + headers: { get: () => null }, text: async () => body, json: async () => JSON.parse(body), }); diff --git a/tests/specs/utils/submit-with-otp.ts b/tests/specs/utils/submit-with-otp.ts index 35e199d..c20cee3 100644 --- a/tests/specs/utils/submit-with-otp.ts +++ b/tests/specs/utils/submit-with-otp.ts @@ -4,9 +4,7 @@ import { submitWithOtp } from '../../../src/utils/submit-with-otp.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); From e40e5f050e2d40531bbcbbde7710bee8122d8602 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 03:27:24 +0900 Subject: [PATCH 2/8] test: add smoke test for Cloudflare TLS fingerprint bypass --- tests/index.ts | 4 ++++ tests/specs/smoke/cloudflare-bypass.ts | 33 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/specs/smoke/cloudflare-bypass.ts diff --git a/tests/index.ts b/tests/index.ts index 52130e1..605249f 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -32,6 +32,10 @@ describe('npm-pkg-settings', () => { import('./specs/create-client.ts'); }); + describe('smoke', () => { + import('./specs/smoke/cloudflare-bypass.ts'); + }); + describe('cli', () => { import('./specs/cli/parse-trusted-publisher.ts'); import('./specs/cli/decode-secret.ts'); diff --git a/tests/specs/smoke/cloudflare-bypass.ts b/tests/specs/smoke/cloudflare-bypass.ts new file mode 100644 index 0000000..c7d81aa --- /dev/null +++ b/tests/specs/smoke/cloudflare-bypass.ts @@ -0,0 +1,33 @@ +import { test, expect } from 'manten'; +import { createFetch, defaultSessionFile } from '../../../src/create-fetch.ts'; +import { npmFetch } from '../../../src/utils/npm-fetch.ts'; + +/** + * Smoke test: verify the HTTP client can reach npm without being blocked + * by Cloudflare's bot detection. + * + * Cloudflare fingerprints TLS handshakes (JA3/JA4) and HTTP/2 framing + * to distinguish real browsers from automated clients. If the fetch + * implementation uses Node.js's default TLS stack, requests will be + * challenged with a 403 "Just a moment..." page. + * + * This test catches HTTP client swaps that break Cloudflare bypass. + */ +test('fetch is not blocked by Cloudflare', async () => { + const context = { + fetch: createFetch(defaultSessionFile), + otpSecret: '', + otpGenerator: async () => '', + }; + + const response = await npmFetch(context, '', { + headers: { 'x-spiferack': '1' }, + }); + + const cfMitigated = response.headers.get('cf-mitigated'); + const body = await response.text(); + + expect(cfMitigated).not.toBe('challenge'); + expect(body).not.toContain('Just a moment'); + expect(response.status).not.toBe(403); +}); From ab24d21172809572e12d38a057ccdb576a477c00 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 03:35:56 +0900 Subject: [PATCH 3/8] fix: replace ky with impit for Cloudflare TLS fingerprint bypass ky uses Node.js native fetch (undici) which has a distinct TLS fingerprint (JA3/JA4) that Cloudflare detects and blocks with a challenge page. impit uses a Rust-based TLS stack (patched rustls) that impersonates Chrome's TLS handshake. Uses chrome142 fingerprint which produces the same JA4 as curl_chrome145: t13d1516h2_8daaf6152771_d8a2da3f94cd --- package.json | 3 +- pnpm-lock.yaml | 121 ++++++++++++++++++++++++++++++++------------ src/create-fetch.ts | 17 ++++--- 3 files changed, 101 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index f981010..a4fe342 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,8 @@ "dependencies": { "ansis": "^4.2.0", "cleye": "^2.2.1", + "impit": "^0.11.0", "jsqr": "^1.4.0", - "ky": "^1.14.3", - "ky-cookies": "^1.0.0", "linkedom": "^0.18.12", "otplib": "^13.3.0", "pngjs": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f13f003..6c95daa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,15 +14,12 @@ importers: cleye: specifier: ^2.2.1 version: 2.2.1 + impit: + specifier: ^0.11.0 + version: 0.11.0 jsqr: specifier: ^1.4.0 version: 1.4.0 - ky: - specifier: ^1.14.3 - version: 1.14.3 - ky-cookies: - specifier: ^1.0.0 - version: 1.0.0(@types/tough-cookie@4.0.5)(ky@1.14.3)(tough-cookie@6.0.1) linkedom: specifier: ^0.18.12 version: 0.18.12 @@ -859,9 +856,6 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/tough-cookie@4.0.5': - resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1844,6 +1838,58 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + impit-darwin-arm64@0.11.0: + resolution: {integrity: sha512-XZcgJQ49hVGoa+bXmXqkvSucyo99X13zggMjqg5lU1SYChpgtsmDG2OyhmKt+if07Y+HtB6EAlCBLl6HPPlbbQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + impit-darwin-x64@0.11.0: + resolution: {integrity: sha512-lyz/HnElBkr/e13pTrBWDocfjVVR6eKrOZmnVeCEPxYlNuPCOslhHp2p+1BdgIQO/8s2X2MVeS0zVSXq3hNp+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + impit-linux-arm64-gnu@0.11.0: + resolution: {integrity: sha512-xdvbpoPnrAHDtetZjfD6/zqswhg9CRAd7f3YyBFzzNg3aoBRhgMFrdD2BLEWAvaq1PZkj+/emCaPZfJZJfPuJg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + impit-linux-arm64-musl@0.11.0: + resolution: {integrity: sha512-w7isVF4RfVynopjGSP+a3/6KJmL7MzdEw2niIi9YjRnCRDPi4XEmxDm9XScB7vUE8E4s7LT8QKa/SIb3MEpvFQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + impit-linux-x64-gnu@0.11.0: + resolution: {integrity: sha512-Y0NBSiFn79G2CXUx2J+a8W2e53ZS7smohsZX18XY8i9LxLf2FQ6pt7e1eWVDHEdo298r5w4EFPc46Gdg6npw+A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + impit-linux-x64-musl@0.11.0: + resolution: {integrity: sha512-UmahyHiqcNTCYnAgW+SjQwfyqfALYxXeN3Likuf/aS/tzlTP/CPmtNu1O+Vl7G8dZaWRlBNtAwvLeKlkUKBL5A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + impit-win32-arm64-msvc@0.11.0: + resolution: {integrity: sha512-LppsL30N+EgVx1tsBNsYz2vf8aM+RXpin/9Szzs8G82PD5QmOnrhJE6JTpzyfVYebjn/UdJcIqFzfcZRCciLOg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + impit-win32-x64-msvc@0.11.0: + resolution: {integrity: sha512-fXIrgD8EdxDBic90DvcJ350lbeHYTQGybNEQNW7zQrSMEht0A/r7mm9yI1VhhSOiT7glCl96CAvG3mTSGqAipA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + impit@0.11.0: + resolution: {integrity: sha512-968YrfzZN5CCgHs/n/yAbPgetq+bOreQOI9UQXmHK3srRs24g+m9CNGL8tRWUIZCK0tnc+baBJ0nw+8saHz0qw==} + engines: {node: '>= 20'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2091,18 +2137,6 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - ky-cookies@1.0.0: - resolution: {integrity: sha512-j0FMtoPvEE18eiyLSJFiNLwaK0Ij+G7a9Ohc25boEZjEDFHYBRL1dUheGtUX8cUlbbLt18TG8tz8t4FZjZYH9w==} - engines: {node: '>=20'} - peerDependencies: - '@types/tough-cookie': '>=4' - ky: '>=1' - tough-cookie: '>=6' - - ky@1.14.3: - resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} - engines: {node: '>=18'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3562,8 +3596,6 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/tough-cookie@4.0.5': {} - '@types/unist@3.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -4731,6 +4763,41 @@ snapshots: ignore@7.0.5: {} + impit-darwin-arm64@0.11.0: + optional: true + + impit-darwin-x64@0.11.0: + optional: true + + impit-linux-arm64-gnu@0.11.0: + optional: true + + impit-linux-arm64-musl@0.11.0: + optional: true + + impit-linux-x64-gnu@0.11.0: + optional: true + + impit-linux-x64-musl@0.11.0: + optional: true + + impit-win32-arm64-msvc@0.11.0: + optional: true + + impit-win32-x64-msvc@0.11.0: + optional: true + + impit@0.11.0: + optionalDependencies: + impit-darwin-arm64: 0.11.0 + impit-darwin-x64: 0.11.0 + impit-linux-arm64-gnu: 0.11.0 + impit-linux-arm64-musl: 0.11.0 + impit-linux-x64-gnu: 0.11.0 + impit-linux-x64-musl: 0.11.0 + impit-win32-arm64-msvc: 0.11.0 + impit-win32-x64-msvc: 0.11.0 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4996,14 +5063,6 @@ snapshots: kind-of@6.0.3: {} - ky-cookies@1.0.0(@types/tough-cookie@4.0.5)(ky@1.14.3)(tough-cookie@6.0.1): - dependencies: - '@types/tough-cookie': 4.0.5 - ky: 1.14.3 - tough-cookie: 6.0.1 - - ky@1.14.3: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 diff --git a/src/create-fetch.ts b/src/create-fetch.ts index 7c62cd9..7e5450e 100644 --- a/src/create-fetch.ts +++ b/src/create-fetch.ts @@ -1,12 +1,15 @@ -import ky from 'ky'; +import { Impit } from 'impit'; import { CookieJar } from 'tough-cookie'; import FileCookieStore from 'tough-cookie-file-store'; -import { withCookies } from 'ky-cookies'; export const defaultSessionFile = '.npm-pkg-settings.cookies.json'; -export const createFetch = (sessionFile: string) => ky.create({ - throwHttpErrors: false, - redirect: 'manual', - ...withCookies(new CookieJar(new FileCookieStore(sessionFile))), -}); +export const createFetch = (sessionFile: string) => { + const impit = new Impit({ + browser: 'chrome142', + cookieJar: new CookieJar(new FileCookieStore(sessionFile)), + followRedirects: false, + }); + + return (url: string, options?: Record) => impit.fetch(url, options); +}; From 32254894c2c0531e70c253c45dc7a17622014876 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 04:13:04 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20deeper=20impit=20integration=20?= =?UTF-8?q?=E2=80=94=20eliminate=20indirection=20layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete create-fetch.ts — inline Impit creation into create-client.ts - Delete npm-fetch.ts — bake base URL into context.fetch - Simplify NpmContext.fetch type — drop NpmFetch/NpmFetchResponse - All call sites: npmFetch(context, path) → context.fetch(path) - Fix leading-slash edge case in URL resolution - Smoke test uses bare Impit instance --- src/api/get-username.ts | 3 +- src/api/list-packages.ts | 4 +- src/context/login.ts | 11 ++-- src/create-client.ts | 19 ++++++- src/create-fetch.ts | 15 ----- src/types.ts | 22 +++---- src/utils/authenticated-get.ts | 10 ++-- src/utils/npm-fetch.ts | 12 ---- src/utils/submit-with-otp.ts | 6 +- tests/index.ts | 1 - tests/specs/smoke/cloudflare-bypass.ts | 34 +++++------ tests/specs/utils/npm-fetch.ts | 79 -------------------------- 12 files changed, 54 insertions(+), 162 deletions(-) delete mode 100644 src/create-fetch.ts delete mode 100644 src/utils/npm-fetch.ts delete mode 100644 tests/specs/utils/npm-fetch.ts diff --git a/src/api/get-username.ts b/src/api/get-username.ts index 9772c7b..ddb4655 100644 --- a/src/api/get-username.ts +++ b/src/api/get-username.ts @@ -1,12 +1,11 @@ import type { NpmContext } from '../types.ts'; -import { npmFetch } from '../utils/npm-fetch.ts'; export const getUsername = async (context: NpmContext): Promise => { if (context.cachedUsername) { return context.cachedUsername; } - const response = await npmFetch(context, '', { + const response = await context.fetch('', { headers: { 'x-spiferack': '1' }, }); if (response.status !== 200) { diff --git a/src/api/list-packages.ts b/src/api/list-packages.ts index 705cad5..aa37bd6 100644 --- a/src/api/list-packages.ts +++ b/src/api/list-packages.ts @@ -1,5 +1,4 @@ import type { NpmContext, PackageListItem } from '../types.ts'; -import { npmFetch } from '../utils/npm-fetch.ts'; import { getUsername } from './get-username.ts'; export const listPackages = async (context: NpmContext): Promise => { @@ -10,8 +9,7 @@ export const listPackages = async (context: NpmContext): Promise => { - const response = await npmFetch(context, '', { + const response = await context.fetch('', { headers: { 'x-spiferack': '1' }, }); if (response.status !== 200) { @@ -29,7 +28,7 @@ export const performLogin = async ( } // GET /login to get CSRF token - const loginPage = await npmFetch(context, 'login'); + const loginPage = await context.fetch('login'); if (loginPage.status !== 200) { throw new Error(`Failed to fetch login page (status ${loginPage.status})`); } @@ -37,7 +36,7 @@ export const performLogin = async ( const { csrfToken } = parseLoginPage(await loginPage.text()); // POST /login with credentials - const loginResponse = await npmFetch(context, 'login', { + const loginResponse = await context.fetch('login', { method: 'POST', body: new URLSearchParams({ username: credentials.username, @@ -50,13 +49,13 @@ export const performLogin = async ( if (loginResponse.status >= 300 && loginResponse.status < 400) { const location = loginResponse.headers.get('location'); if (location) { - const otpPage = await npmFetch(context, location); + const otpPage = await context.fetch(location); const otpPageBody = await otpPage.text(); if (otpPage.status === 200 && otpPageBody.includes('One-time Password')) { const { action, csrfToken: otpCsrf, formName } = parseOtpPage(otpPageBody); const otp = await generateOtp(context); - const otpResponse = await npmFetch(context, action, { + const otpResponse = await context.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, diff --git a/src/create-client.ts b/src/create-client.ts index b96eb14..03471b7 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -1,4 +1,6 @@ -import { createFetch, defaultSessionFile } from './create-fetch.ts'; +import { Impit } from 'impit'; +import { CookieJar } from 'tough-cookie'; +import FileCookieStore from 'tough-cookie-file-store'; import { performLogin } from './context/login.ts'; import { getUsername } from './api/get-username.ts'; import { listPackages } from './api/list-packages.ts'; @@ -9,11 +11,22 @@ import { addMaintainer } from './api/add-maintainer.ts'; import type { CreateClientOptions, NpmClient } from './types.ts'; import { defaultOtpGenerator } from './utils/default-otp-generator.ts'; -export { defaultSessionFile }; +export const defaultSessionFile = '.npm-pkg-settings.cookies.json'; + +const npmBaseUrl = 'https://www.npmjs.com'; export const createClient = (options: CreateClientOptions): NpmClient => { + const impit = new Impit({ + browser: 'chrome142', + cookieJar: new CookieJar(new FileCookieStore(options.sessionFile ?? defaultSessionFile)), + followRedirects: false, + }); + const context = { - fetch: createFetch(options.sessionFile ?? defaultSessionFile), + fetch: (path: string, init?: Record) => impit.fetch( + path.startsWith('http') ? path : `${npmBaseUrl}/${path.replace(/^\//, '')}`, + init, + ), otpSecret: options.otpSecret, otpGenerator: defaultOtpGenerator, cachedUsername: options.username, diff --git a/src/create-fetch.ts b/src/create-fetch.ts deleted file mode 100644 index 7e5450e..0000000 --- a/src/create-fetch.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Impit } from 'impit'; -import { CookieJar } from 'tough-cookie'; -import FileCookieStore from 'tough-cookie-file-store'; - -export const defaultSessionFile = '.npm-pkg-settings.cookies.json'; - -export const createFetch = (sessionFile: string) => { - const impit = new Impit({ - browser: 'chrome142', - cookieJar: new CookieJar(new FileCookieStore(sessionFile)), - followRedirects: false, - }); - - return (url: string, options?: Record) => impit.fetch(url, options); -}; diff --git a/src/types.ts b/src/types.ts index 68672bc..392037c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,20 +1,16 @@ export type OtpGenerator = (secret: string) => Promise; -export type NpmFetchResponse = { - status: number; - headers: { get(name: string): string | null }; - text(): Promise; - json(): Promise; -}; - -export type NpmFetch = ( - url: string, - options?: Record, -) => Promise; - // Internal session state export type NpmContext = { - fetch: NpmFetch; + fetch: ( + path: string, + init?: Record, + ) => Promise<{ + status: number; + headers: { get(name: string): string | null }; + text(): Promise; + json(): Promise; + }>; otpSecret: string; otpGenerator: OtpGenerator; cachedUsername?: string; diff --git a/src/utils/authenticated-get.ts b/src/utils/authenticated-get.ts index e459894..be00cf6 100644 --- a/src/utils/authenticated-get.ts +++ b/src/utils/authenticated-get.ts @@ -1,6 +1,6 @@ import type { NpmContext } from '../types.ts'; import { parseOtpPage } from '../parsers/otp.ts'; -import { npmFetch } from './npm-fetch.ts'; + import { generateOtp } from './generate-otp.ts'; const handleOtpEscalation = async ( @@ -15,7 +15,7 @@ const handleOtpEscalation = async ( const { action, csrfToken, formName } = parseOtpPage(body); const otp = await generateOtp(context); - const otpResponse = await npmFetch(context, action, { + const otpResponse = await context.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, @@ -27,7 +27,7 @@ const handleOtpEscalation = async ( if (otpResponse.status >= 300 && otpResponse.status < 400) { const location = otpResponse.headers.get('location'); if (location) { - const finalResponse = await npmFetch(context, location); + const finalResponse = await context.fetch(location); return { status: finalResponse.status, body: await finalResponse.text(), @@ -46,13 +46,13 @@ export const authenticatedGet = async ( path: string, ): Promise<{ status: number; body: string; }> => { - let response = await npmFetch(context, path); + let response = await context.fetch(path); // Follow redirect (escalation or login) if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); if (location) { - response = await npmFetch(context, location); + response = await context.fetch(location); } } diff --git a/src/utils/npm-fetch.ts b/src/utils/npm-fetch.ts deleted file mode 100644 index eb205de..0000000 --- a/src/utils/npm-fetch.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { NpmContext } from '../types.ts'; - -const baseUrl = 'https://www.npmjs.com'; - -export const npmFetch = ( - context: NpmContext, - path: string, - init?: Parameters[1], -) => context.fetch( - path.startsWith('http') ? path : `${baseUrl}/${path}`, - init, -); diff --git a/src/utils/submit-with-otp.ts b/src/utils/submit-with-otp.ts index 4da1b12..d11e6d0 100644 --- a/src/utils/submit-with-otp.ts +++ b/src/utils/submit-with-otp.ts @@ -1,6 +1,6 @@ import type { NpmContext } from '../types.ts'; import { parseOtpPage } from '../parsers/otp.ts'; -import { npmFetch } from './npm-fetch.ts'; + import { generateOtp } from './generate-otp.ts'; export const submitWithOtp = async ( @@ -8,7 +8,7 @@ export const submitWithOtp = async ( path: string, body: URLSearchParams, ) => { - const response = await npmFetch(context, path, { + const response = await context.fetch(path, { method: 'POST', body, }); @@ -18,7 +18,7 @@ export const submitWithOtp = async ( const { action, csrfToken, formName } = parseOtpPage(responseBody); const otp = await generateOtp(context); - const otpResponse = await npmFetch(context, action, { + const otpResponse = await context.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, diff --git a/tests/index.ts b/tests/index.ts index 605249f..dc442d1 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -8,7 +8,6 @@ describe('npm-pkg-settings', () => { }); describe('utils', () => { - import('./specs/utils/npm-fetch.ts'); import('./specs/utils/generate-otp.ts'); import('./specs/utils/authenticated-get.ts'); import('./specs/utils/submit-with-otp.ts'); diff --git a/tests/specs/smoke/cloudflare-bypass.ts b/tests/specs/smoke/cloudflare-bypass.ts index c7d81aa..e44ebef 100644 --- a/tests/specs/smoke/cloudflare-bypass.ts +++ b/tests/specs/smoke/cloudflare-bypass.ts @@ -1,33 +1,27 @@ +import { Impit } from 'impit'; import { test, expect } from 'manten'; -import { createFetch, defaultSessionFile } from '../../../src/create-fetch.ts'; -import { npmFetch } from '../../../src/utils/npm-fetch.ts'; /** - * Smoke test: verify the HTTP client can reach npm without being blocked - * by Cloudflare's bot detection. + * Smoke test: verify impit's Chrome TLS fingerprint bypasses + * Cloudflare's bot detection on npm. * * Cloudflare fingerprints TLS handshakes (JA3/JA4) and HTTP/2 framing - * to distinguish real browsers from automated clients. If the fetch - * implementation uses Node.js's default TLS stack, requests will be - * challenged with a 403 "Just a moment..." page. + * to distinguish real browsers from automated clients. If the TLS + * fingerprint doesn't match a known browser, requests are challenged + * with a 403 "Just a moment..." page. * - * This test catches HTTP client swaps that break Cloudflare bypass. + * This test catches upgrades or config changes that break the bypass. */ -test('fetch is not blocked by Cloudflare', async () => { - const context = { - fetch: createFetch(defaultSessionFile), - otpSecret: '', - otpGenerator: async () => '', - }; +test('impit chrome142 is not blocked by Cloudflare', async () => { + const impit = new Impit({ + browser: 'chrome142', + followRedirects: false, + }); - const response = await npmFetch(context, '', { + const response = await impit.fetch('https://www.npmjs.com/', { headers: { 'x-spiferack': '1' }, }); - const cfMitigated = response.headers.get('cf-mitigated'); - const body = await response.text(); - - expect(cfMitigated).not.toBe('challenge'); - expect(body).not.toContain('Just a moment'); + expect(response.headers.get('cf-mitigated')).not.toBe('challenge'); expect(response.status).not.toBe(403); }); diff --git a/tests/specs/utils/npm-fetch.ts b/tests/specs/utils/npm-fetch.ts deleted file mode 100644 index 7bd67a7..0000000 --- a/tests/specs/utils/npm-fetch.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; -import { npmFetch } from '../../../src/utils/npm-fetch.ts'; - -const mockResponse = (status: number, body: string) => ({ - status, - headers: { get: () => null }, - text: async () => body, - json: async () => JSON.parse(body), -}); - -describe('npmFetch', () => { - test('prepends base URL for relative paths', async () => { - let capturedUrl = ''; - const context: NpmContext = { - fetch: async (url) => { - capturedUrl = url; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, 'login'); - expect(capturedUrl).toBe('https://www.npmjs.com/login'); - }); - - test('passes full URL unchanged for absolute paths', async () => { - let capturedUrl = ''; - const context: NpmContext = { - fetch: async (url) => { - capturedUrl = url; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, 'https://custom.example.com/path'); - expect(capturedUrl).toBe('https://custom.example.com/path'); - }); - - test('forwards init options to fetch', async () => { - let capturedInit: unknown; - const context: NpmContext = { - fetch: async (_url, init) => { - capturedInit = init; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, 'test', { - method: 'POST', - headers: { 'x-custom': 'value' }, - }); - - expect(capturedInit).toEqual({ - method: 'POST', - headers: { 'x-custom': 'value' }, - }); - }); - - test('handles empty path for root URL', async () => { - let capturedUrl = ''; - const context: NpmContext = { - fetch: async (url) => { - capturedUrl = url; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, ''); - expect(capturedUrl).toBe('https://www.npmjs.com/'); - }); -}); From 8b91e63bdb5450a3afaf225e4cc049adbc2c77e1 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 04:16:48 +0900 Subject: [PATCH 5/8] refactor: use new URL() for path resolution --- src/create-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/create-client.ts b/src/create-client.ts index 03471b7..a1afdc6 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -13,7 +13,7 @@ import { defaultOtpGenerator } from './utils/default-otp-generator.ts'; export const defaultSessionFile = '.npm-pkg-settings.cookies.json'; -const npmBaseUrl = 'https://www.npmjs.com'; +const npmBaseUrl = 'https://www.npmjs.com/'; export const createClient = (options: CreateClientOptions): NpmClient => { const impit = new Impit({ @@ -24,7 +24,7 @@ export const createClient = (options: CreateClientOptions): NpmClient => { const context = { fetch: (path: string, init?: Record) => impit.fetch( - path.startsWith('http') ? path : `${npmBaseUrl}/${path.replace(/^\//, '')}`, + new URL(path, npmBaseUrl).href, init, ), otpSecret: options.otpSecret, From 06e8c3aa95eddbc9d2a798eec88e953b3eb86c34 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 04:25:43 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20rename=20context=20=E2=86=92=20?= =?UTF-8?q?client=20across=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/add-maintainer.ts | 8 ++--- src/api/get-package-access.ts | 10 +++--- src/api/get-username.ts | 12 +++---- src/api/link-trusted-publisher.ts | 8 ++--- src/api/list-packages.ts | 8 ++--- src/api/set-publishing-access.ts | 8 ++--- src/context/login.ts | 22 ++++++------- src/create-client.ts | 16 +++++----- src/index.ts | 2 +- src/types.ts | 2 +- src/utils/authenticated-get.ts | 18 +++++------ src/utils/generate-otp.ts | 4 +-- src/utils/submit-with-otp.ts | 10 +++--- tests/specs/api/add-maintainer.ts | 16 +++++----- tests/specs/api/get-package-access.ts | 24 +++++++------- tests/specs/api/get-username.ts | 24 +++++++------- tests/specs/api/link-trusted-publisher.ts | 24 +++++++------- tests/specs/api/list-packages.ts | 20 ++++++------ tests/specs/api/set-publishing-access.ts | 20 ++++++------ tests/specs/context/perform-login.ts | 38 +++++++++++------------ tests/specs/utils/authenticated-get.ts | 32 +++++++++---------- tests/specs/utils/generate-otp.ts | 6 ++-- tests/specs/utils/submit-with-otp.ts | 24 +++++++------- 23 files changed, 178 insertions(+), 178 deletions(-) diff --git a/src/api/add-maintainer.ts b/src/api/add-maintainer.ts index 384a35c..0ed946f 100644 --- a/src/api/add-maintainer.ts +++ b/src/api/add-maintainer.ts @@ -1,16 +1,16 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { submitWithOtp } from '../utils/submit-with-otp.ts'; import { getAccessPageWithCsrf } from './get-package-access.ts'; export const addMaintainer = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, npmUsername: string, ) => { - const settings = await getAccessPageWithCsrf(context, packageName); + const settings = await getAccessPageWithCsrf(client, packageName); const accessPath = `package/${packageName}/access`; - const result = await submitWithOtp(context, accessPath, new URLSearchParams({ + const result = await submitWithOtp(client, accessPath, new URLSearchParams({ add: npmUsername, csrftoken: settings.csrfToken, })); diff --git a/src/api/get-package-access.ts b/src/api/get-package-access.ts index b9ba740..4e1e055 100644 --- a/src/api/get-package-access.ts +++ b/src/api/get-package-access.ts @@ -1,21 +1,21 @@ -import type { NpmContext, PackageSettings } from '../types.ts'; +import type { NpmInternalClient, PackageSettings } from '../types.ts'; import { parsePackageAccess } from '../parsers/package-access.ts'; import { authenticatedGet } from '../utils/authenticated-get.ts'; export const getPackageAccess = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, ): Promise => { - const { csrfToken: _, ...settings } = await getAccessPageWithCsrf(context, packageName); + const { csrfToken: _, ...settings } = await getAccessPageWithCsrf(client, packageName); return settings; }; // Internal: returns csrfToken too (needed by mutation functions) export const getAccessPageWithCsrf = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, ) => { - const response = await authenticatedGet(context, `package/${packageName}/access`); + const response = await authenticatedGet(client, `package/${packageName}/access`); if (response.status !== 200) { throw new Error(`Failed to fetch access page for ${packageName} (status ${response.status})`); } diff --git a/src/api/get-username.ts b/src/api/get-username.ts index ddb4655..a347798 100644 --- a/src/api/get-username.ts +++ b/src/api/get-username.ts @@ -1,11 +1,11 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; -export const getUsername = async (context: NpmContext): Promise => { - if (context.cachedUsername) { - return context.cachedUsername; +export const getUsername = async (client: NpmInternalClient): Promise => { + if (client.cachedUsername) { + return client.cachedUsername; } - const response = await context.fetch('', { + const response = await client.fetch('', { headers: { 'x-spiferack': '1' }, }); if (response.status !== 200) { @@ -18,6 +18,6 @@ export const getUsername = async (context: NpmContext): Promise => { throw new Error('Not logged in — no user found in session'); } - context.cachedUsername = username; + client.cachedUsername = username; return username; }; diff --git a/src/api/link-trusted-publisher.ts b/src/api/link-trusted-publisher.ts index 0cf1866..1c735f9 100644 --- a/src/api/link-trusted-publisher.ts +++ b/src/api/link-trusted-publisher.ts @@ -1,13 +1,13 @@ -import type { NpmContext, TrustedPublisher } from '../types.ts'; +import type { NpmInternalClient, TrustedPublisher } from '../types.ts'; import { submitWithOtp } from '../utils/submit-with-otp.ts'; import { getAccessPageWithCsrf } from './get-package-access.ts'; export const linkTrustedPublisher = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, publisher: TrustedPublisher, ) => { - const settings = await getAccessPageWithCsrf(context, packageName); + const settings = await getAccessPageWithCsrf(client, packageName); const accessPath = `package/${packageName}/access`; const body = publisher.type === 'github' @@ -32,7 +32,7 @@ export const linkTrustedPublisher = async ( csrftoken: settings.csrfToken, }); - const result = await submitWithOtp(context, accessPath, body); + const result = await submitWithOtp(client, accessPath, body); if (result.status < 300 || result.status >= 400) { throw new Error(`Failed to link trusted publisher for ${packageName} (status ${result.status})`); diff --git a/src/api/list-packages.ts b/src/api/list-packages.ts index aa37bd6..fa5183f 100644 --- a/src/api/list-packages.ts +++ b/src/api/list-packages.ts @@ -1,15 +1,15 @@ -import type { NpmContext, PackageListItem } from '../types.ts'; +import type { NpmInternalClient, PackageListItem } from '../types.ts'; import { getUsername } from './get-username.ts'; -export const listPackages = async (context: NpmContext): Promise => { - const username = await getUsername(context); +export const listPackages = async (client: NpmInternalClient): Promise => { + const username = await getUsername(client); const perPage = 100; const allPackages: PackageListItem[] = []; let page = 0; let total = Infinity; while (allPackages.length < total) { - const response = await context.fetch( + const response = await client.fetch( `settings/${username}/packages?page=${page}&perPage=${perPage}`, { headers: { 'x-spiferack': '1' } }, ); diff --git a/src/api/set-publishing-access.ts b/src/api/set-publishing-access.ts index 153a2c8..29385ff 100644 --- a/src/api/set-publishing-access.ts +++ b/src/api/set-publishing-access.ts @@ -1,16 +1,16 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { submitWithOtp } from '../utils/submit-with-otp.ts'; import { getAccessPageWithCsrf } from './get-package-access.ts'; export const setPublishingAccess = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, access: string, ) => { - const settings = await getAccessPageWithCsrf(context, packageName); + const settings = await getAccessPageWithCsrf(client, packageName); const accessPath = `package/${packageName}/access`; - const result = await submitWithOtp(context, accessPath, new URLSearchParams({ + const result = await submitWithOtp(client, accessPath, new URLSearchParams({ publishingAccess: access, csrftoken: settings.csrfToken, })); diff --git a/src/context/login.ts b/src/context/login.ts index f39bbd0..36e0b9b 100644 --- a/src/context/login.ts +++ b/src/context/login.ts @@ -1,10 +1,10 @@ import { parseLoginPage } from '../parsers/login.ts'; import { parseOtpPage } from '../parsers/otp.ts'; -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { generateOtp } from '../utils/generate-otp.ts'; -const isLoggedIn = async (context: NpmContext): Promise => { - const response = await context.fetch('', { +const isLoggedIn = async (client: NpmInternalClient): Promise => { + const response = await client.fetch('', { headers: { 'x-spiferack': '1' }, }); if (response.status !== 200) { @@ -12,23 +12,23 @@ const isLoggedIn = async (context: NpmContext): Promise => { } const data = await response.json() as { user?: { name?: string } }; if (data.user?.name) { - context.cachedUsername = data.user.name; + client.cachedUsername = data.user.name; return true; } return false; }; export const performLogin = async ( - context: NpmContext, + client: NpmInternalClient, credentials: { username: string; password: string; }, ): Promise<{ skipped: boolean }> => { - if (await isLoggedIn(context)) { + if (await isLoggedIn(client)) { return { skipped: true }; } // GET /login to get CSRF token - const loginPage = await context.fetch('login'); + const loginPage = await client.fetch('login'); if (loginPage.status !== 200) { throw new Error(`Failed to fetch login page (status ${loginPage.status})`); } @@ -36,7 +36,7 @@ export const performLogin = async ( const { csrfToken } = parseLoginPage(await loginPage.text()); // POST /login with credentials - const loginResponse = await context.fetch('login', { + const loginResponse = await client.fetch('login', { method: 'POST', body: new URLSearchParams({ username: credentials.username, @@ -49,13 +49,13 @@ export const performLogin = async ( if (loginResponse.status >= 300 && loginResponse.status < 400) { const location = loginResponse.headers.get('location'); if (location) { - const otpPage = await context.fetch(location); + const otpPage = await client.fetch(location); const otpPageBody = await otpPage.text(); if (otpPage.status === 200 && otpPageBody.includes('One-time Password')) { const { action, csrfToken: otpCsrf, formName } = parseOtpPage(otpPageBody); - const otp = await generateOtp(context); + const otp = await generateOtp(client); - const otpResponse = await context.fetch(action, { + const otpResponse = await client.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, diff --git a/src/create-client.ts b/src/create-client.ts index a1afdc6..21cc29b 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -22,7 +22,7 @@ export const createClient = (options: CreateClientOptions): NpmClient => { followRedirects: false, }); - const context = { + const client = { fetch: (path: string, init?: Record) => impit.fetch( new URL(path, npmBaseUrl).href, init, @@ -37,18 +37,18 @@ export const createClient = (options: CreateClientOptions): NpmClient => { if (!options.username || !options.password) { throw new Error('login() requires username and password in createClient options'); } - return performLogin(context, { + return performLogin(client, { username: options.username, password: options.password, }); }, - listPackages: () => listPackages(context), - getPackageAccess: packageName => getPackageAccess(context, packageName), - setPublishingAccess: (packageName, access) => setPublishingAccess(context, packageName, access), + listPackages: () => listPackages(client), + getPackageAccess: packageName => getPackageAccess(client, packageName), + setPublishingAccess: (packageName, access) => setPublishingAccess(client, packageName, access), linkTrustedPublisher: (packageName, publisher) => ( - linkTrustedPublisher(context, packageName, publisher) + linkTrustedPublisher(client, packageName, publisher) ), - addMaintainer: (packageName, npmUsername) => addMaintainer(context, packageName, npmUsername), - getUsername: () => getUsername(context), + addMaintainer: (packageName, npmUsername) => addMaintainer(client, packageName, npmUsername), + getUsername: () => getUsername(client), }; }; diff --git a/src/index.ts b/src/index.ts index bab22af..8eae85b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export { addMaintainer } from './api/add-maintainer.ts'; export { validateTrustedPublisher } from './api/validate-trusted-publisher.ts'; export type { NpmClient, - NpmContext, + NpmInternalClient, CreateClientOptions, PackageListItem, PackageSettings, diff --git a/src/types.ts b/src/types.ts index 392037c..8ff973c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ export type OtpGenerator = (secret: string) => Promise; // Internal session state -export type NpmContext = { +export type NpmInternalClient = { fetch: ( path: string, init?: Record, diff --git a/src/utils/authenticated-get.ts b/src/utils/authenticated-get.ts index be00cf6..543cfeb 100644 --- a/src/utils/authenticated-get.ts +++ b/src/utils/authenticated-get.ts @@ -1,10 +1,10 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { parseOtpPage } from '../parsers/otp.ts'; import { generateOtp } from './generate-otp.ts'; const handleOtpEscalation = async ( - context: NpmContext, + client: NpmInternalClient, body: string, ): Promise<{ status: number; body: string; } | undefined> => { @@ -13,9 +13,9 @@ const handleOtpEscalation = async ( } const { action, csrfToken, formName } = parseOtpPage(body); - const otp = await generateOtp(context); + const otp = await generateOtp(client); - const otpResponse = await context.fetch(action, { + const otpResponse = await client.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, @@ -27,7 +27,7 @@ const handleOtpEscalation = async ( if (otpResponse.status >= 300 && otpResponse.status < 400) { const location = otpResponse.headers.get('location'); if (location) { - const finalResponse = await context.fetch(location); + const finalResponse = await client.fetch(location); return { status: finalResponse.status, body: await finalResponse.text(), @@ -42,17 +42,17 @@ const handleOtpEscalation = async ( }; export const authenticatedGet = async ( - context: NpmContext, + client: NpmInternalClient, path: string, ): Promise<{ status: number; body: string; }> => { - let response = await context.fetch(path); + let response = await client.fetch(path); // Follow redirect (escalation or login) if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); if (location) { - response = await context.fetch(location); + response = await client.fetch(location); } } @@ -66,7 +66,7 @@ export const authenticatedGet = async ( } // Handle OTP escalation page - const otpResult = await handleOtpEscalation(context, body); + const otpResult = await handleOtpEscalation(client, body); if (otpResult) { return otpResult; } diff --git a/src/utils/generate-otp.ts b/src/utils/generate-otp.ts index 96fddcd..f787aa3 100644 --- a/src/utils/generate-otp.ts +++ b/src/utils/generate-otp.ts @@ -1,3 +1,3 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; -export const generateOtp = (context: NpmContext) => context.otpGenerator(context.otpSecret); +export const generateOtp = (client: NpmInternalClient) => client.otpGenerator(client.otpSecret); diff --git a/src/utils/submit-with-otp.ts b/src/utils/submit-with-otp.ts index d11e6d0..de92809 100644 --- a/src/utils/submit-with-otp.ts +++ b/src/utils/submit-with-otp.ts @@ -1,14 +1,14 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { parseOtpPage } from '../parsers/otp.ts'; import { generateOtp } from './generate-otp.ts'; export const submitWithOtp = async ( - context: NpmContext, + client: NpmInternalClient, path: string, body: URLSearchParams, ) => { - const response = await context.fetch(path, { + const response = await client.fetch(path, { method: 'POST', body, }); @@ -16,9 +16,9 @@ export const submitWithOtp = async ( if (response.status === 200 && responseBody.includes('One-time Password')) { const { action, csrfToken, formName } = parseOtpPage(responseBody); - const otp = await generateOtp(context); + const otp = await generateOtp(client); - const otpResponse = await context.fetch(action, { + const otpResponse = await client.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, diff --git a/tests/specs/api/add-maintainer.ts b/tests/specs/api/add-maintainer.ts index aa59fb7..38b79ad 100644 --- a/tests/specs/api/add-maintainer.ts +++ b/tests/specs/api/add-maintainer.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'manten'; import { addMaintainer } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -17,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = () => { - const context = { + const client = { csrftoken: 'csrf-token', package: 'my-pkg', formData: { @@ -30,13 +30,13 @@ const makeAccessHtml = () => { oidcConnections: [], maintainers: [], }; - return ``; + return ``; }; describe('addMaintainer', () => { test('submits add field with username', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -44,20 +44,20 @@ describe('addMaintainer', () => { return mockResponse(200, makeAccessHtml()); }); - await addMaintainer(context, 'my-pkg', 'newuser'); + await addMaintainer(client, 'my-pkg', 'newuser'); expect(postBody).toContain('add=newuser'); expect(postBody).toContain('csrftoken=csrf-token'); }); test('throws on failure', async () => { - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { return mockResponse(500, 'error'); } return mockResponse(200, makeAccessHtml()); }); - await expect(addMaintainer(context, 'my-pkg', 'newuser')) + await expect(addMaintainer(client, 'my-pkg', 'newuser')) .rejects.toThrow('Failed to add maintainer'); }); }); diff --git a/tests/specs/api/get-package-access.ts b/tests/specs/api/get-package-access.ts index 13f2d5f..8574764 100644 --- a/tests/specs/api/get-package-access.ts +++ b/tests/specs/api/get-package-access.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'manten'; import { getPackageAccess } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -17,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = (overrides: Record = {}) => { - const context = { + const client = { csrftoken: 'csrf-123', package: 'my-pkg', packageVersion: { repository: 'https://github.com/user/repo' }, @@ -35,7 +35,7 @@ const makeAccessHtml = (overrides: Record = {}) => { }], ...overrides, }; - return ``; + return ``; }; const otpPageHtml = ` @@ -48,9 +48,9 @@ const otpPageHtml = ` describe('getPackageAccess', () => { test('returns settings for direct 200 response', async () => { - const context = mockContext(async () => mockResponse(200, makeAccessHtml())); + const client = mockClient(async () => mockResponse(200, makeAccessHtml())); - const settings = await getPackageAccess(context, 'my-pkg'); + const settings = await getPackageAccess(client, 'my-pkg'); expect(settings.packageName).toBe('my-pkg'); expect(settings.repository).toBe('https://github.com/user/repo'); expect(settings.publishingAccess).toBe('tfa-required-unless-automation'); @@ -61,15 +61,15 @@ describe('getPackageAccess', () => { }); test('strips csrfToken from public result', async () => { - const context = mockContext(async () => mockResponse(200, makeAccessHtml())); + const client = mockClient(async () => mockResponse(200, makeAccessHtml())); - const settings = await getPackageAccess(context, 'my-pkg'); + const settings = await getPackageAccess(client, 'my-pkg'); expect('csrfToken' in settings).toBe(false); }); test('follows redirect and handles OTP escalation', async () => { let callIndex = 0; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: GET access page → redirect to escalation if (callIndex === 1) { @@ -87,14 +87,14 @@ describe('getPackageAccess', () => { return mockResponse(200, makeAccessHtml()); }); - const settings = await getPackageAccess(context, 'my-pkg'); + const settings = await getPackageAccess(client, 'my-pkg'); expect(settings.packageName).toBe('my-pkg'); expect(callIndex).toBe(4); }); test('throws on non-200 after all retries', async () => { - const context = mockContext(async () => mockResponse(429, 'rate limited')); + const client = mockClient(async () => mockResponse(429, 'rate limited')); - await expect(getPackageAccess(context, 'my-pkg')).rejects.toThrow('Failed to fetch access page'); + await expect(getPackageAccess(client, 'my-pkg')).rejects.toThrow('Failed to fetch access page'); }); }); diff --git a/tests/specs/api/get-username.ts b/tests/specs/api/get-username.ts index af86350..13e74eb 100644 --- a/tests/specs/api/get-username.ts +++ b/tests/specs/api/get-username.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'manten'; import { getUsername } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -17,40 +17,40 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('getUsername', () => { test('fetches from session when not cached', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: { name: 'sessionuser' } }), )); - const username = await getUsername(context); + const username = await getUsername(client); expect(username).toBe('sessionuser'); }); test('caches after first fetch', async () => { let callCount = 0; - const context = mockContext(async () => { + const client = mockClient(async () => { callCount += 1; return mockResponse(200, JSON.stringify({ user: { name: 'cached' } })); }); - await getUsername(context); - await getUsername(context); + await getUsername(client); + await getUsername(client); expect(callCount).toBe(1); - expect(context.cachedUsername).toBe('cached'); + expect(client.cachedUsername).toBe('cached'); }); test('throws when not logged in', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: null }), )); - await expect(getUsername(context)).rejects.toThrow('Not logged in'); + await expect(getUsername(client)).rejects.toThrow('Not logged in'); }); test('throws on non-200 response', async () => { - const context = mockContext(async () => mockResponse(500, '')); + const client = mockClient(async () => mockResponse(500, '')); - await expect(getUsername(context)).rejects.toThrow('Failed to fetch homepage'); + await expect(getUsername(client)).rejects.toThrow('Failed to fetch homepage'); }); }); diff --git a/tests/specs/api/link-trusted-publisher.ts b/tests/specs/api/link-trusted-publisher.ts index 5266862..4fdc59e 100644 --- a/tests/specs/api/link-trusted-publisher.ts +++ b/tests/specs/api/link-trusted-publisher.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'manten'; import { linkTrustedPublisher } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -17,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = () => { - const context = { + const client = { csrftoken: 'csrf-token', package: 'my-pkg', formData: { @@ -30,13 +30,13 @@ const makeAccessHtml = () => { oidcConnections: [], maintainers: [], }; - return ``; + return ``; }; describe('linkTrustedPublisher', () => { test('builds correct form body for GitHub publisher', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -44,7 +44,7 @@ describe('linkTrustedPublisher', () => { return mockResponse(200, makeAccessHtml()); }); - await linkTrustedPublisher(context, 'my-pkg', { + await linkTrustedPublisher(client, 'my-pkg', { type: 'github', owner: 'myorg', repository: 'myrepo', @@ -62,7 +62,7 @@ describe('linkTrustedPublisher', () => { test('builds correct form body for GitLab publisher', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -70,7 +70,7 @@ describe('linkTrustedPublisher', () => { return mockResponse(200, makeAccessHtml()); }); - await linkTrustedPublisher(context, 'my-pkg', { + await linkTrustedPublisher(client, 'my-pkg', { type: 'gitlab', namespace: 'my-group', project: 'my-project', @@ -87,7 +87,7 @@ describe('linkTrustedPublisher', () => { test('includes environment when provided', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -95,7 +95,7 @@ describe('linkTrustedPublisher', () => { return mockResponse(200, makeAccessHtml()); }); - await linkTrustedPublisher(context, 'my-pkg', { + await linkTrustedPublisher(client, 'my-pkg', { type: 'github', owner: 'org', repository: 'repo', @@ -107,14 +107,14 @@ describe('linkTrustedPublisher', () => { }); test('throws on failure', async () => { - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { return mockResponse(500, 'error'); } return mockResponse(200, makeAccessHtml()); }); - await expect(linkTrustedPublisher(context, 'my-pkg', { + await expect(linkTrustedPublisher(client, 'my-pkg', { type: 'github', owner: 'org', repository: 'repo', diff --git a/tests/specs/api/list-packages.ts b/tests/specs/api/list-packages.ts index fae13a7..345d5c8 100644 --- a/tests/specs/api/list-packages.ts +++ b/tests/specs/api/list-packages.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'manten'; import { listPackages } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -34,7 +34,7 @@ const makePackage = (name: string, overrides: Record = {}) => ( describe('listPackages', () => { test('fetches single page of packages', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ packagesCounts: { all: 2 }, @@ -42,7 +42,7 @@ describe('listPackages', () => { }), )); - const packages = await listPackages(context); + const packages = await listPackages(client); expect(packages).toHaveLength(2); expect(packages[0].name).toBe('pkg-a'); expect(packages[0].isPrivate).toBe(false); @@ -52,7 +52,7 @@ describe('listPackages', () => { test('paginates across multiple pages', async () => { let callCount = 0; - const context = mockContext(async (url) => { + const client = mockClient(async (url) => { callCount += 1; const objects = url.includes('page=0') ? [makePackage('a')] @@ -66,13 +66,13 @@ describe('listPackages', () => { ); }); - const packages = await listPackages(context); + const packages = await listPackages(client); expect(packages).toHaveLength(2); expect(callCount).toBe(2); }); test('maps all fields correctly', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ packagesCounts: { all: 1 }, @@ -93,7 +93,7 @@ describe('listPackages', () => { }), )); - const [package_] = await listPackages(context); + const [package_] = await listPackages(client); expect(package_.name).toBe('my-pkg'); expect(package_.version).toBe('3.2.1'); expect(package_.isPrivate).toBe(true); @@ -106,8 +106,8 @@ describe('listPackages', () => { }); test('throws on non-200 response', async () => { - const context = mockContext(async () => mockResponse(500, 'error')); + const client = mockClient(async () => mockResponse(500, 'error')); - await expect(listPackages(context)).rejects.toThrow('Failed to fetch packages'); + await expect(listPackages(client)).rejects.toThrow('Failed to fetch packages'); }); }); diff --git a/tests/specs/api/set-publishing-access.ts b/tests/specs/api/set-publishing-access.ts index 4727d82..270a59b 100644 --- a/tests/specs/api/set-publishing-access.ts +++ b/tests/specs/api/set-publishing-access.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'manten'; import { setPublishingAccess } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -17,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = (csrftoken = 'csrf-123') => { - const context = { + const client = { csrftoken, package: 'my-pkg', packageVersion: { repository: 'https://github.com/user/repo' }, @@ -31,13 +31,13 @@ const makeAccessHtml = (csrftoken = 'csrf-123') => { oidcConnections: [], maintainers: [], }; - return ``; + return ``; }; describe('setPublishingAccess', () => { test('submits publishingAccess with CSRF token', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -45,14 +45,14 @@ describe('setPublishingAccess', () => { return mockResponse(200, makeAccessHtml('my-csrf')); }); - await setPublishingAccess(context, 'my-pkg', 'tfa-always-required'); + await setPublishingAccess(client, 'my-pkg', 'tfa-always-required'); expect(postBody).toContain('publishingAccess=tfa-always-required'); expect(postBody).toContain('csrftoken=my-csrf'); }); test('handles OTP escalation on POST', async () => { let callIndex = 0; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: GET access page (from authenticatedGet via getAccessPageWithCsrf) if (callIndex === 1) { @@ -66,19 +66,19 @@ describe('setPublishingAccess', () => { return mockResponse(302, ''); }); - await setPublishingAccess(context, 'my-pkg', 'tfa-always-required'); + await setPublishingAccess(client, 'my-pkg', 'tfa-always-required'); expect(callIndex).toBe(3); }); test('throws on failure status', async () => { - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { return mockResponse(500, 'error'); } return mockResponse(200, makeAccessHtml()); }); - await expect(setPublishingAccess(context, 'my-pkg', 'tfa-always-required')) + await expect(setPublishingAccess(client, 'my-pkg', 'tfa-always-required')) .rejects.toThrow('Failed to set publishing access'); }); }); diff --git a/tests/specs/context/perform-login.ts b/tests/specs/context/perform-login.ts index f24d704..bdbe7e3 100644 --- a/tests/specs/context/perform-login.ts +++ b/tests/specs/context/perform-login.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'manten'; import { performLogin } from '../../../src/context/login.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -17,12 +17,12 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('performLogin', () => { test('returns skipped when already logged in', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: { name: 'alice' } }), )); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -34,7 +34,7 @@ describe('performLogin', () => { let callIndex = 0; const loginHtml = '
'; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: isLoggedIn check if (callIndex === 1) { @@ -51,7 +51,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -63,7 +63,7 @@ describe('performLogin', () => { let callIndex = 0; const loginHtml = '
'; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: isLoggedIn check — non-200 if (callIndex === 1) { @@ -80,7 +80,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -90,7 +90,7 @@ describe('performLogin', () => { test('throws when login page fetch fails', async () => { let callIndex = 0; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, JSON.stringify({ user: null })); @@ -98,7 +98,7 @@ describe('performLogin', () => { return mockResponse(500, 'error'); }); - await expect(performLogin(context, { + await expect(performLogin(client, { username: 'alice', password: 'pass', })).rejects.toThrow('Failed to fetch login page'); @@ -115,7 +115,7 @@ describe('performLogin', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: isLoggedIn check if (callIndex === 1) { @@ -140,7 +140,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -160,7 +160,7 @@ describe('performLogin', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, JSON.stringify({ user: null })); @@ -181,7 +181,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - await expect(performLogin(context, { + await expect(performLogin(client, { username: 'alice', password: 'pass', })).rejects.toThrow('OTP submission failed'); @@ -191,7 +191,7 @@ describe('performLogin', () => { let callIndex = 0; const loginHtml = '
'; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, JSON.stringify({ user: null })); @@ -203,22 +203,22 @@ describe('performLogin', () => { return mockResponse(401, 'unauthorized'); }); - await expect(performLogin(context, { + await expect(performLogin(client, { username: 'alice', password: 'wrong', })).rejects.toThrow('Login failed'); }); test('caches username when already logged in', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: { name: 'alice' } }), )); - await performLogin(context, { + await performLogin(client, { username: 'alice', password: 'pass', }); - expect(context.cachedUsername).toBe('alice'); + expect(client.cachedUsername).toBe('alice'); }); }); diff --git a/tests/specs/utils/authenticated-get.ts b/tests/specs/utils/authenticated-get.ts index 564bff8..ab8c3b9 100644 --- a/tests/specs/utils/authenticated-get.ts +++ b/tests/specs/utils/authenticated-get.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; import { authenticatedGet } from '../../../src/utils/authenticated-get.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -18,17 +18,17 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('authenticatedGet', () => { test('returns body directly when no escalation', async () => { const validPage = ''; - const context = mockContext(async () => mockResponse(200, validPage)); + const client = mockClient(async () => mockResponse(200, validPage)); - const result = await authenticatedGet(context, 'some/path'); + const result = await authenticatedGet(client, 'some/path'); expect(result.status).toBe(200); expect(result.body).toBe(validPage); }); test('returns non-200 status without escalation', async () => { - const context = mockContext(async () => mockResponse(429, 'rate limited')); + const client = mockClient(async () => mockResponse(429, 'rate limited')); - const result = await authenticatedGet(context, 'some/path'); + const result = await authenticatedGet(client, 'some/path'); expect(result.status).toBe(429); expect(result.body).toBe('rate limited'); }); @@ -36,7 +36,7 @@ describe('authenticatedGet', () => { test('follows 302 redirect', async () => { let callIndex = 0; const redirectedPage = ''; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(302, '', { location: '/redirected' }); @@ -44,7 +44,7 @@ describe('authenticatedGet', () => { return mockResponse(200, redirectedPage); }); - const result = await authenticatedGet(context, 'original'); + const result = await authenticatedGet(client, 'original'); expect(result.status).toBe(200); expect(result.body).toBe(redirectedPage); }); @@ -59,7 +59,7 @@ describe('authenticatedGet', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: GET page → OTP escalation if (callIndex === 1) { @@ -73,7 +73,7 @@ describe('authenticatedGet', () => { return mockResponse(200, 'actual content'); }); - const result = await authenticatedGet(context, 'protected/path'); + const result = await authenticatedGet(client, 'protected/path'); expect(result.status).toBe(200); expect(result.body).toBe('actual content'); expect(callIndex).toBe(3); @@ -89,7 +89,7 @@ describe('authenticatedGet', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { capturedBody = init.body?.toString(); return mockResponse(200, 'done'); @@ -97,7 +97,7 @@ describe('authenticatedGet', () => { return mockResponse(200, otpPage); }); - await authenticatedGet(context, 'path'); + await authenticatedGet(client, 'path'); expect(capturedBody).toContain('otp=123456'); expect(capturedBody).toContain('csrftoken=my-csrf'); expect(capturedBody).toContain('formName=totp'); @@ -113,7 +113,7 @@ describe('authenticatedGet', () => { One-time Password `; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, otpPage); @@ -122,7 +122,7 @@ describe('authenticatedGet', () => { return mockResponse(200, 'otp result'); }); - const result = await authenticatedGet(context, 'path'); + const result = await authenticatedGet(client, 'path'); expect(result.status).toBe(200); expect(result.body).toBe('otp result'); }); @@ -131,7 +131,7 @@ describe('authenticatedGet', () => { let callIndex = 0; const loginPage = 'npm | Sign In
Sign In
'; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(302, '', { location: '/login?next=%2Fpackage%2Ftsx%2Faccess' }); @@ -139,7 +139,7 @@ describe('authenticatedGet', () => { return mockResponse(200, loginPage); }); - await expect(authenticatedGet(context, 'package/tsx/access')) + await expect(authenticatedGet(client, 'package/tsx/access')) .rejects.toThrow('Session expired'); }); }); diff --git a/tests/specs/utils/generate-otp.ts b/tests/specs/utils/generate-otp.ts index 54f5ddb..f1fd0a4 100644 --- a/tests/specs/utils/generate-otp.ts +++ b/tests/specs/utils/generate-otp.ts @@ -1,11 +1,11 @@ import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; import { generateOtp } from '../../../src/utils/generate-otp.ts'; describe('generateOtp', () => { test('calls otpGenerator with otpSecret', async () => { let capturedSecret = ''; - const context: NpmContext = { + const client: NpmInternalClient = { fetch: async () => { throw new Error('should not fetch'); }, otpSecret: 'MY_SECRET', otpGenerator: async (secret) => { @@ -14,7 +14,7 @@ describe('generateOtp', () => { }, }; - const otp = await generateOtp(context); + const otp = await generateOtp(client); expect(otp).toBe('123456'); expect(capturedSecret).toBe('MY_SECRET'); }); diff --git a/tests/specs/utils/submit-with-otp.ts b/tests/specs/utils/submit-with-otp.ts index c20cee3..0e91cb3 100644 --- a/tests/specs/utils/submit-with-otp.ts +++ b/tests/specs/utils/submit-with-otp.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; import { submitWithOtp } from '../../../src/utils/submit-with-otp.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ @@ -9,7 +9,7 @@ const mockResponse = (status: number, body: string, headers: Record JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '654321', @@ -18,13 +18,13 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('submitWithOtp', () => { test('posts body and returns response when no escalation', async () => { let capturedBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { capturedBody = init?.body?.toString(); return mockResponse(302, ''); }); const result = await submitWithOtp( - context, + client, 'some/path', new URLSearchParams({ key: 'value', @@ -46,7 +46,7 @@ describe('submitWithOtp', () => { One-time Password `; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; // 1: POST form → escalation page if (callIndex === 1) { @@ -57,7 +57,7 @@ describe('submitWithOtp', () => { }); const result = await submitWithOtp( - context, + client, 'path', new URLSearchParams({ data: 'test' }), ); @@ -77,7 +77,7 @@ describe('submitWithOtp', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, otpPage); @@ -86,17 +86,17 @@ describe('submitWithOtp', () => { return mockResponse(302, ''); }); - await submitWithOtp(context, 'path', new URLSearchParams({ x: '1' })); + await submitWithOtp(client, 'path', new URLSearchParams({ x: '1' })); expect(otpBody).toContain('otp=654321'); expect(otpBody).toContain('csrftoken=esc-csrf'); expect(otpBody).toContain('formName=totp'); }); test('returns error status without escalation', async () => { - const context = mockContext(async () => mockResponse(500, 'server error')); + const client = mockClient(async () => mockResponse(500, 'server error')); const result = await submitWithOtp( - context, + client, 'path', new URLSearchParams({ x: '1' }), ); @@ -107,13 +107,13 @@ describe('submitWithOtp', () => { test('does not escalate on non-200 with OTP text', async () => { let callCount = 0; - const context = mockContext(async () => { + const client = mockClient(async () => { callCount += 1; return mockResponse(403, 'One-time Password forbidden'); }); const result = await submitWithOtp( - context, + client, 'path', new URLSearchParams({ x: '1' }), ); From dbf5347d909aba0999d7a33ef3b0f09ac5f36f89 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 04:30:49 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test:=20improve=20coverage=20=E2=80=94=20sm?= =?UTF-8?q?oke=20through=20createClient,=20null-guard=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Smoke test now goes through createClient (not bare Impit) to catch misconfiguration. Tests both GET and POST requests. - Add createClient fetch integration test for URL resolution - Add list-packages test for missing field fallback defaults - 87 tests total (was 82) --- tests/index.ts | 1 + tests/specs/api/list-packages.ts | 27 +++++++++++ tests/specs/create-client-fetch.ts | 36 ++++++++++++++ tests/specs/smoke/cloudflare-bypass.ts | 66 ++++++++++++++++++++------ 4 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 tests/specs/create-client-fetch.ts diff --git a/tests/index.ts b/tests/index.ts index dc442d1..979afe7 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -29,6 +29,7 @@ describe('npm-pkg-settings', () => { describe('create-client', () => { import('./specs/create-client.ts'); + import('./specs/create-client-fetch.ts'); }); describe('smoke', () => { diff --git a/tests/specs/api/list-packages.ts b/tests/specs/api/list-packages.ts index 345d5c8..5cad441 100644 --- a/tests/specs/api/list-packages.ts +++ b/tests/specs/api/list-packages.ts @@ -105,6 +105,33 @@ describe('listPackages', () => { expect(package_.updatedRel).toBe('today'); }); + test('handles missing fields with fallback defaults', async () => { + const client = mockClient(async () => mockResponse( + 200, + JSON.stringify({ + packagesCounts: { all: 1 }, + packages: { + objects: [{ + name: 'empty-pkg', + // All other fields missing — simulates placeholder/unpublished package + }], + }, + }), + )); + + const [package_] = await listPackages(client); + expect(package_.name).toBe('empty-pkg'); + expect(package_.version).toBe(''); + expect(package_.description).toBe(''); + expect(package_.isPrivate).toBe(false); + expect(package_.isHighImpact).toBe(false); + expect(package_.lastPublishTs).toBe(0); + expect(package_.lastPublishRel).toBe(''); + expect(package_.publisher).toBe(''); + expect(package_.createdRel).toBe(''); + expect(package_.updatedRel).toBe(''); + }); + test('throws on non-200 response', async () => { const client = mockClient(async () => mockResponse(500, 'error')); diff --git a/tests/specs/create-client-fetch.ts b/tests/specs/create-client-fetch.ts new file mode 100644 index 0000000..cf03c87 --- /dev/null +++ b/tests/specs/create-client-fetch.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { describe, test, expect } from 'manten'; +import { createClient } from '../../src/index.ts'; + +/** + * Tests that createClient's internal fetch resolves URLs correctly + * and produces a working fetch pipeline. + * + * These tests create a real client and make real requests to npm + * (unauthenticated GETs only — no credentials needed). + */ +describe('createClient fetch', () => { + const sessionFile = path.join(os.tmpdir(), `.npm-pkg-settings-url-test-${Date.now()}.json`); + + test('resolves relative path to npm URL', async () => { + const client = createClient({ + otpSecret: '', + sessionFile, + }); + + // getUsername fetches '' (empty path) → should resolve to https://www.npmjs.com/ + // The response should be valid JSON (npm homepage data), not a 404 or redirect + try { + await client.getUsername(); + } catch (error) { + // "Not logged in" means the fetch resolved to the right URL and got a valid response + expect((error as Error).message).toContain('Not logged in'); + } + }); + + test('cleanup', async () => { + await fs.rm(sessionFile, { force: true }); + }); +}); diff --git a/tests/specs/smoke/cloudflare-bypass.ts b/tests/specs/smoke/cloudflare-bypass.ts index e44ebef..b281be1 100644 --- a/tests/specs/smoke/cloudflare-bypass.ts +++ b/tests/specs/smoke/cloudflare-bypass.ts @@ -1,27 +1,63 @@ -import { Impit } from 'impit'; -import { test, expect } from 'manten'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { describe, test, expect } from 'manten'; +import { createClient } from '../../../src/index.ts'; /** - * Smoke test: verify impit's Chrome TLS fingerprint bypasses + * Smoke tests: verify the real createClient pipeline bypasses * Cloudflare's bot detection on npm. * * Cloudflare fingerprints TLS handshakes (JA3/JA4) and HTTP/2 framing - * to distinguish real browsers from automated clients. If the TLS - * fingerprint doesn't match a known browser, requests are challenged - * with a 403 "Just a moment..." page. + * to distinguish real browsers from automated clients. POST requests + * to sensitive endpoints (e.g. /login) face stricter checks than GETs. * - * This test catches upgrades or config changes that break the bypass. + * These tests go through createClient (not bare Impit) to catch + * misconfiguration of the browser fingerprint or cookie jar. */ -test('impit chrome142 is not blocked by Cloudflare', async () => { - const impit = new Impit({ - browser: 'chrome142', - followRedirects: false, +describe('Cloudflare bypass', () => { + const sessionFile = path.join(os.tmpdir(), `.npm-pkg-settings-test-${Date.now()}.json`); + + test('GET request is not blocked', async () => { + const client = createClient({ + otpSecret: '', + sessionFile, + }); + + // getUsername fetches GET / with x-spiferack header + // Without a session it won't find a user, but the request should reach npm (not be challenged) + try { + await client.getUsername(); + } catch (error) { + // "Not logged in" = npm responded with real data (not blocked) + // "Failed to fetch homepage" with status 403 = Cloudflare blocked us + expect((error as Error).message).toContain('Not logged in'); + } }); - const response = await impit.fetch('https://www.npmjs.com/', { - headers: { 'x-spiferack': '1' }, + test('POST request is not blocked', async () => { + const client = createClient({ + otpSecret: '', + username: 'cloudflare-bypass-test', + password: 'not-a-real-password', + sessionFile, + }); + + // login() POSTs to /login — with fake credentials it should fail with + // a login error, NOT a Cloudflare 403 challenge + try { + await client.login(); + } catch (error) { + const { message } = (error as Error); + // Any of these mean npm processed the request (not blocked by Cloudflare) + const reachedNpm = message.includes('Login failed') + || message.includes('Failed to fetch login page') + || message.includes('OTP'); + expect(reachedNpm).toBe(true); + } }); - expect(response.headers.get('cf-mitigated')).not.toBe('challenge'); - expect(response.status).not.toBe(403); + test('cleanup', async () => { + await fs.rm(sessionFile, { force: true }); + }); }); From 6089b724123af3aee6ba6f704f5e6db679187c5b Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Mon, 23 Mar 2026 15:14:35 +0900 Subject: [PATCH 8/8] docs: remove curl-impersonate prerequisite, update session file name --- README.md | 22 +++++++--------------- skills/npm-pkg-settings/SKILL.md | 2 +- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8caa63e..4447c2a 100644 --- a/README.md +++ b/README.md @@ -15,20 +15,12 @@ This package automates all of it — trusted publishers, publishing access, and ## Prerequisites -1. **`curl-impersonate`** — Bypasses Cloudflare bot protection on npmjs.com. Install the [`curl-impersonate`](https://github.com/lexiforest/curl-impersonate) binary: +**TOTP secret** — The base32 secret key used to generate your npm 2FA codes. If you already know your secret, you can use it directly. Otherwise, this package includes a helper command to extract it from a Google Authenticator QR code screenshot or migration URL: - ```sh - # macOS (Apple Silicon) - curl -L https://github.com/lexiforest/curl-impersonate/releases/latest/download/curl-impersonate-v1.5.1.arm64-macos.tar.gz | tar xz - cp curl-impersonate curl_chrome145 /usr/local/bin/ - ``` - -2. **TOTP secret** — The base32 secret key used to generate your npm 2FA codes. If you already know your secret, you can use it directly. Otherwise, this package includes a helper command to extract it from a Google Authenticator QR code screenshot or migration URL: - - ```sh - npx npm-pkg-settings decode-secret ./qr-code.png - npx npm-pkg-settings decode-secret 'otpauth-migration://offline?data=...' - ``` +```sh +npx npm-pkg-settings decode-secret ./qr-code.png +npx npm-pkg-settings decode-secret 'otpauth-migration://offline?data=...' +``` ## Install @@ -153,7 +145,7 @@ const npm = createClient({ await npm.login() ``` -The session is saved to `.npm-pkg-settings.session.txt`, so subsequent runs reuse it without re-authenticating. +The session is saved to `.npm-pkg-settings.cookies.json`, so subsequent runs reuse it without re-authenticating. Store credentials in a `.env` file: @@ -284,7 +276,7 @@ Returns an `NpmClient`. | `otpSecret` | `string` | TOTP secret (base32) for 2FA | | `username` | `string?` | npm username (required for `login()`) | | `password` | `string?` | npm password (required for `login()`) | -| `sessionFile` | `string?` | Session file path (default: `.npm-pkg-settings.session.txt`) | +| `sessionFile` | `string?` | Session file path (default: `.npm-pkg-settings.cookies.json`) | ### `NpmClient` diff --git a/skills/npm-pkg-settings/SKILL.md b/skills/npm-pkg-settings/SKILL.md index 236ca84..d0f7e58 100644 --- a/skills/npm-pkg-settings/SKILL.md +++ b/skills/npm-pkg-settings/SKILL.md @@ -5,7 +5,7 @@ description: Manages npm package publish settings — listing packages, viewing/ # npm-pkg-settings -CLI and programmatic API for managing npm package publish settings. Works by automating the npm website (using ky + ky-cookies for HTTP and session management), since npm has no public API for package settings. Automatically handles OTP generation and submission during all operations. +CLI and programmatic API for managing npm package publish settings. Works by automating the npm website, since npm has no public API for package settings. Automatically handles OTP generation and submission during all operations. ## Setup