Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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-*
Expand Down
22 changes: 7 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,12 @@

## 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

Expand Down Expand Up @@ -153,7 +145,7 @@
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:

Expand Down Expand Up @@ -211,7 +203,7 @@
// Optionally validate before linking (GitHub only) — uses the public GitHub API
// to check that the repo and workflow file exist. Throws if the repo is public but
// the workflow is missing. Silently passes for private/inaccessible repos.
const { repoVerified, workflowVerified } = await validateTrustedPublisher(publisher)

Check warning on line 206 in README.md

View workflow job for this annotation

GitHub Actions / Test

'workflowVerified' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 206 in README.md

View workflow job for this annotation

GitHub Actions / Test

'repoVerified' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 206 in README.md

View workflow job for this annotation

GitHub Actions / Test

'workflowVerified' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 206 in README.md

View workflow job for this annotation

GitHub Actions / Test

'repoVerified' is assigned a value but never used. Allowed unused vars must match /^_/u

await npm.linkTrustedPublisher('my-package', publisher)

Expand Down Expand Up @@ -284,7 +276,7 @@
| `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`

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@
"dependencies": {
"ansis": "^4.2.0",
"cleye": "^2.2.1",
"impit": "^0.11.0",
"jsqr": "^1.4.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",
Expand Down
128 changes: 125 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions skills/npm-pkg-settings/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, since npm has no public API for package settings. Automatically handles OTP generation and submission during all operations.

## Setup

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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
})
```

Expand Down
8 changes: 4 additions & 4 deletions src/api/add-maintainer.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
Expand Down
10 changes: 5 additions & 5 deletions src/api/get-package-access.ts
Original file line number Diff line number Diff line change
@@ -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<PackageSettings> => {
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})`);
}
Expand Down
13 changes: 6 additions & 7 deletions src/api/get-username.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { NpmContext } from '../types.ts';
import { npmFetch } from '../utils/npm-fetch.ts';
import type { NpmInternalClient } from '../types.ts';

export const getUsername = async (context: NpmContext): Promise<string> => {
if (context.cachedUsername) {
return context.cachedUsername;
export const getUsername = async (client: NpmInternalClient): Promise<string> => {
if (client.cachedUsername) {
return client.cachedUsername;
}

const response = await npmFetch(context, '', {
const response = await client.fetch('', {
headers: { 'x-spiferack': '1' },
});
if (response.status !== 200) {
Expand All @@ -19,6 +18,6 @@ export const getUsername = async (context: NpmContext): Promise<string> => {
throw new Error('Not logged in — no user found in session');
}

context.cachedUsername = username;
client.cachedUsername = username;
return username;
};
8 changes: 4 additions & 4 deletions src/api/link-trusted-publisher.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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})`);
Expand Down
Loading
Loading