Something went wrong. Try again.
[READ-ONLY]
a fast, modern browser for the npm registry forked from npmx.dev/npmx.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653import crypto from 'node:crypto'import process from 'node:process'import { execFile } from 'node:child_process'import { promisify } from 'node:util'import { mkdtemp, writeFile, rm } from 'node:fs/promises'import { tmpdir } from 'node:os'import { join } from 'node:path'import * as v from 'valibot'import { PackageNameSchema, UsernameSchema, OrgNameSchema, ScopeTeamSchema } from './schemas.ts'import { logCommand, logSuccess, logError, logDebug } from './logger.ts'
const execFileAsync = promisify(execFile)
/** * Validates an npm package name using the official npm validation package * @throws Error if the name is invalid * @internal */export function validatePackageName(name: string): void { const result = v.safeParse(PackageNameSchema, name) if (!result.success) { const message = result.issues[0]?.message || 'Invalid package name' throw new Error(`Invalid package name "${name}": ${message}`) }}
/** * Validates an npm username * @throws Error if the username is invalid * @internal */export function validateUsername(name: string): void { const result = v.safeParse(UsernameSchema, name) if (!result.success) { throw new Error(`Invalid username: ${name}`) }}
/** * Validates an npm org name (without the @ prefix) * @throws Error if the org name is invalid * @internal */export function validateOrgName(name: string): void { const result = v.safeParse(OrgNameSchema, name) if (!result.success) { throw new Error(`Invalid org name: ${name}`) }}
/** * Validates a scope:team format (e.g., @myorg:developers) * @throws Error if the scope:team is invalid * @internal */export function validateScopeTeam(scopeTeam: string): void { const result = v.safeParse(ScopeTeamSchema, scopeTeam) if (!result.success) { throw new Error(`Invalid scope:team format: ${scopeTeam}. Expected @scope:team`) }}
export interface NpmExecResult { stdout: string stderr: string exitCode: number /** True if the operation failed due to missing/invalid OTP */ requiresOtp?: boolean /** True if the operation failed due to authentication failure (not logged in or token expired) */ authFailure?: boolean /** URLs detected in the command output (stdout + stderr) */ urls?: string[]}
function detectOtpRequired(stderr: string): boolean { const otpPatterns = [ 'EOTP', 'one-time password', 'This operation requires a one-time password', 'OTP required for authentication', '--otp=<code>', ] const lowerStderr = stderr.toLowerCase() logDebug('Checking for OTP requirement in stderr:', stderr) logDebug('OTP patterns:', otpPatterns) const result = otpPatterns.some(pattern => lowerStderr.includes(pattern.toLowerCase())) logDebug('OTP required:', result) return result}
function detectAuthFailure(stderr: string): boolean { const authPatterns = [ 'ENEEDAUTH', 'You must be logged in', 'authentication error', 'Unable to authenticate', 'code E401', 'code E403', '401 Unauthorized', '403 Forbidden', 'not logged in', 'npm login', 'npm adduser', ] const lowerStderr = stderr.toLowerCase() logDebug('Checking for auth failure in stderr:', stderr) logDebug('Auth patterns:', authPatterns) const result = authPatterns.some(pattern => lowerStderr.includes(pattern.toLowerCase())) logDebug('Auth failure:', result) return result}
function filterNpmWarnings(stderr: string): string { return stderr .split('\n') .filter(line => !line.startsWith('npm warn')) .join('\n') .trim()}
const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g
export function extractUrls(text: string): string[] { const matches = text.match(URL_RE) if (!matches) return []
const cleaned = matches.map(url => url.replace(/[.,;:!?)]+$/, '')) return [...new Set(cleaned)]}
// Patterns to detect npm's OTP prompt in pty outputconst OTP_PROMPT_RE = /Enter OTP:/i// Patterns to detect npm's web auth URL prompt in pty outputconst AUTH_URL_PROMPT_RE = /Press ENTER to open in the browser/i// npm prints "Authenticate your account at:\n<url>" — capture the URL on the next lineconst AUTH_URL_TITLE_RE = /Authenticate your account at:\s*(https?:\/\/\S+)/
function stripAnsi(text: string): string { // eslint disabled because we need escape characters in regex // eslint-disable-next-line no-control-regex, regexp/no-obscure-range return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')}
const AUTH_URL_TIMEOUT_MS = 90_000
export interface ExecNpmOptions { otp?: string silent?: boolean /** When true, use PTY-based interactive execution instead of execFile. */ interactive?: boolean /** When true, npm opens auth URLs in the user's browser. * When false, browser opening is suppressed via npm_config_browser=false. * Only relevant when `interactive` is true. */ openUrls?: boolean /** Called when an auth URL is detected in the pty output, while npm is still running (polling doneUrl). Lets the caller expose the URL to the frontend via /state before the execute response comes back. * Only relevant when `interactive` is true. */ onAuthUrl?: (url: string) => void}
/** * PTY-based npm execution for interactive commands (uses node-pty). * * - Web OTP - either open URL in browser if openUrls is true or passes the URL to frontend. If no auth happend within AUTH_URL_TIMEOUT_MS kills the process to unlock the connector. * * - CLI OTP - if we get a classic OTP prompt will either return OTP request to the frontend or will pass sent OTP if its provided */async function execNpmInteractive( args: string[], options: ExecNpmOptions = {},): Promise<NpmExecResult> { const openUrls = options.openUrls === true
// Lazy-load node-pty so the native addon is only required when interactive mode is actually used. const pty = await import('@lydell/node-pty')
return new Promise(resolve => { const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
if (!options.silent) { const displayCmd = options.otp ? ['npm', ...args, '--otp', '******'].join(' ') : ['npm', ...args].join(' ') logCommand(`${displayCmd} (interactive/pty)`) }
let output = '' let resolved = false let otpPromptSeen = false let authUrlSeen = false let enterSent = false let authUrlTimeout: ReturnType<typeof setTimeout> | null = null let authUrlTimedOut = false
const env: Record<string, string> = { ...(process.env as Record<string, string>), FORCE_COLOR: '0', }
// When openUrls is false, tell npm not to open the browser. // npm still prints the auth URL and polls doneUrl if (!openUrls) { env.npm_config_browser = 'false' }
const child = pty.spawn('npm', npmArgs, { name: 'xterm-256color', cols: 120, rows: 30, env, })
// General timeout: 5 minutes (covers non-auth interactive commands) const timeout = setTimeout(() => { if (resolved) return logDebug('Interactive command timed out', { output }) child.kill() }, 300000)
child.onData((data: string) => { output += data const clean = stripAnsi(data) logDebug('pty data:', { text: clean.trim() })
const cleanAll = stripAnsi(output)
// Detect auth URL in output and notify the caller. if (!authUrlSeen) { const urlMatch = cleanAll.match(AUTH_URL_TITLE_RE)
if (urlMatch && urlMatch[1]) { authUrlSeen = true const authUrl = urlMatch[1].replace(/[.,;:!?)]+$/, '') logDebug('Auth URL detected:', { authUrl, openUrls }) options.onAuthUrl?.(authUrl)
authUrlTimeout = setTimeout(() => { if (resolved) return authUrlTimedOut = true logDebug('Auth URL timeout (90s) — killing process') logError('Authentication timed out after 90 seconds') child.kill() }, AUTH_URL_TIMEOUT_MS) } }
if (authUrlSeen && openUrls && !enterSent && AUTH_URL_PROMPT_RE.test(cleanAll)) { enterSent = true logDebug('Web auth prompt detected, pressing ENTER') child.write('\r') }
if (!otpPromptSeen && OTP_PROMPT_RE.test(cleanAll)) { otpPromptSeen = true if (options.otp) { logDebug('OTP prompt detected, writing OTP') child.write(options.otp + '\r') } else { logDebug('OTP prompt detected but no OTP provided, killing process') child.kill() } } })
child.onExit(({ exitCode }) => { if (resolved) return resolved = true clearTimeout(timeout) if (authUrlTimeout) clearTimeout(authUrlTimeout)
const cleanOutput = stripAnsi(output) logDebug('Interactive command exited:', { exitCode, output: cleanOutput })
const requiresOtp = authUrlTimedOut || (otpPromptSeen && !options.otp) || detectOtpRequired(cleanOutput) const authFailure = detectAuthFailure(cleanOutput) const urls = extractUrls(cleanOutput)
if (!options.silent) { if (exitCode === 0) { logSuccess('Done') } else if (requiresOtp) { logError('OTP required') } else if (authFailure) { logError('Authentication required - please run "npm login" and restart the connector') } else { const firstLine = filterNpmWarnings(cleanOutput).split('\n')[0] || 'Command failed' logError(firstLine) } }
// If auth URL timed out, force a non-zero exit code so it's marked as failed const finalExitCode = authUrlTimedOut ? 1 : exitCode
resolve({ stdout: cleanOutput.trim(), stderr: requiresOtp ? 'This operation requires a one-time password (OTP).' : authFailure ? 'Authentication failed. Please run "npm login" and restart the connector.' : filterNpmWarnings(cleanOutput), exitCode: finalExitCode, requiresOtp, authFailure, urls: urls.length > 0 ? urls : undefined, }) }) })}
async function execNpm(args: string[], options: ExecNpmOptions = {}): Promise<NpmExecResult> { if (options.interactive) { return execNpmInteractive(args, options) }
// Build the full args array including OTP if provided const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
// Log the command being run (hide OTP value for security) if (!options.silent) { const displayCmd = options.otp ? ['npm', ...args, '--otp', '******'].join(' ') : ['npm', ...args].join(' ') logCommand(displayCmd) }
try { logDebug('Executing npm command:', { command: 'npm', args: npmArgs }) // Use execFile instead of exec to avoid shell injection vulnerabilities // On Windows, shell: true is required to execute .cmd files (like npm.cmd) // On Unix, we keep it false for better security and performance const { stdout, stderr } = await execFileAsync('npm', npmArgs, { timeout: 60000, env: { ...process.env, FORCE_COLOR: '0' }, shell: process.platform === 'win32', })
logDebug('Command succeeded:', { stdout, stderr })
if (!options.silent) { logSuccess('Done') }
return { stdout: stdout.trim(), stderr: filterNpmWarnings(stderr), exitCode: 0, } } catch (error) { const err = error as { stdout?: string; stderr?: string; code?: number } const stderr = err.stderr?.trim() ?? String(error) logDebug('Command failed:', { error, stdout: err.stdout, stderr: err.stderr, code: err.code }) const requiresOtp = detectOtpRequired(stderr) const authFailure = detectAuthFailure(stderr)
if (!options.silent) { if (requiresOtp) { logError('OTP required') } else if (authFailure) { logError('Authentication required - please run "npm login" and restart the connector') } else { logError(filterNpmWarnings(stderr).split('\n')[0] || 'Command failed') } }
return { stdout: err.stdout?.trim() ?? '', stderr: requiresOtp ? 'This operation requires a one-time password (OTP).' : authFailure ? 'Authentication failed. Please run "npm login" and restart the connector.' : filterNpmWarnings(stderr), exitCode: err.code ?? 1, requiresOtp, authFailure, } }}
export async function getNpmUser(): Promise<string | null> { const result = await execNpm(['whoami'], { silent: true }) if (result.exitCode === 0 && result.stdout) { return result.stdout } return null}
/** * Gets the user's avatar as a base64 data URL from Gravatar. * Returns null if the user's email cannot be retrieved or avatar fetch fails. */export async function getNpmAvatar(): Promise<string | null> { const result = await execNpm(['profile', 'get', 'email', '--json'], { silent: true }) if (result.exitCode !== 0 || !result.stdout) { return null }
try { const parsed = JSON.parse(result.stdout) as { email?: string } if (!parsed.email) { return null }
const email = parsed.email.trim().toLowerCase() const hash = crypto.createHash('md5').update(email).digest('hex') const gravatarUrl = `https://www.gravatar.com/avatar/${hash}?s=64&d=retro`
const response = await fetch(gravatarUrl) if (!response.ok) { return null }
const contentType = response.headers.get('content-type') || 'image/png' const buffer = await response.arrayBuffer() const base64 = Buffer.from(buffer).toString('base64') return `data:${contentType};base64,${base64}` } catch { return null }}
export async function orgAddUser( org: string, user: string, role: 'developer' | 'admin' | 'owner', options?: ExecNpmOptions,): Promise<NpmExecResult> { validateOrgName(org) validateUsername(user) return execNpm(['org', 'set', org, user, role], options)}
export async function orgRemoveUser( org: string, user: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateOrgName(org) validateUsername(user) return execNpm(['org', 'rm', org, user], options)}
export async function teamCreate( scopeTeam: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateScopeTeam(scopeTeam) return execNpm(['team', 'create', scopeTeam], options)}
export async function teamDestroy( scopeTeam: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateScopeTeam(scopeTeam) return execNpm(['team', 'destroy', scopeTeam], options)}
export async function teamAddUser( scopeTeam: string, user: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateScopeTeam(scopeTeam) validateUsername(user) return execNpm(['team', 'add', scopeTeam, user], options)}
export async function teamRemoveUser( scopeTeam: string, user: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateScopeTeam(scopeTeam) validateUsername(user) return execNpm(['team', 'rm', scopeTeam, user], options)}
export async function accessGrant( permission: 'read-only' | 'read-write', scopeTeam: string, pkg: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateScopeTeam(scopeTeam) validatePackageName(pkg) return execNpm(['access', 'grant', permission, scopeTeam, pkg], options)}
export async function accessRevoke( scopeTeam: string, pkg: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateScopeTeam(scopeTeam) validatePackageName(pkg) return execNpm(['access', 'revoke', scopeTeam, pkg], options)}
export async function ownerAdd( user: string, pkg: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateUsername(user) validatePackageName(pkg) return execNpm(['owner', 'add', user, pkg], options)}
export async function ownerRemove( user: string, pkg: string, options?: ExecNpmOptions,): Promise<NpmExecResult> { validateUsername(user) validatePackageName(pkg) return execNpm(['owner', 'rm', user, pkg], options)}
// List functions (for reading data) - silent since they're not user-triggered operations
export async function orgListUsers(org: string): Promise<NpmExecResult> { validateOrgName(org) return execNpm(['org', 'ls', org, '--json'], { silent: true })}
export async function teamListTeams(org: string): Promise<NpmExecResult> { validateOrgName(org) return execNpm(['team', 'ls', org, '--json'], { silent: true })}
export async function teamListUsers(scopeTeam: string): Promise<NpmExecResult> { validateScopeTeam(scopeTeam) return execNpm(['team', 'ls', scopeTeam, '--json'], { silent: true })}
export async function accessListCollaborators(pkg: string): Promise<NpmExecResult> { validatePackageName(pkg) return execNpm(['access', 'list', 'collaborators', pkg, '--json'], { silent: true })}
/** * Lists all packages that a user has access to publish. * Uses `npm access list packages @{user} --json` * Returns a map of package name to permission level */export async function listUserPackages(user: string): Promise<NpmExecResult> { validateUsername(user) return execNpm(['access', 'list', 'packages', `@${user}`, '--json'], { silent: true })}
/** * Initialize and publish a new package to claim the name. * Creates a minimal package.json in a temp directory and publishes it. * @param name Package name to claim * @param author npm username of the publisher (for author field) * @param otp Optional OTP for 2FA */export async function packageInit( name: string, author?: string, otp?: string,): Promise<NpmExecResult> { validatePackageName(name)
// Create a temporary directory const tempDir = await mkdtemp(join(tmpdir(), 'npmx-init-'))
try { // Determine access type based on whether it's a scoped package const isScoped = name.startsWith('@') const access = isScoped ? 'public' : undefined
// Create minimal package.json const packageJson = { name, version: '0.0.0', description: `Placeholder for ${name}`, main: 'index.js', scripts: {}, keywords: [], author: author ? `${author} (https://www.npmjs.com/~${author})` : '', license: 'UNLICENSED', private: false, ...(access && { publishConfig: { access } }), }
await writeFile(join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2))
// Create empty index.js await writeFile(join(tempDir, 'index.js'), '// Placeholder\n')
// Build npm publish args const args = ['publish'] if (access) { args.push('--access', access) }
// Run npm publish from the temp directory const npmArgs = otp ? [...args, '--otp', otp] : args
// Log the command being run (hide OTP value for security) const displayCmd = otp ? `npm ${args.join(' ')} --otp ******` : `npm ${args.join(' ')}` logCommand(`${displayCmd} (in temp dir for ${name})`)
try { const { stdout, stderr } = await execFileAsync('npm', npmArgs, { timeout: 60000, cwd: tempDir, env: { ...process.env, FORCE_COLOR: '0' }, shell: process.platform === 'win32', })
logSuccess(`Published ${name}@0.0.0`)
return { stdout: stdout.trim(), stderr: filterNpmWarnings(stderr), exitCode: 0, } } catch (error) { const err = error as { stdout?: string; stderr?: string; code?: number } const stderr = err.stderr?.trim() ?? String(error) const requiresOtp = detectOtpRequired(stderr) const authFailure = detectAuthFailure(stderr)
if (requiresOtp) { logError('OTP required') } else if (authFailure) { logError('Authentication required - please run "npm login" and restart the connector') } else { logError(filterNpmWarnings(stderr).split('\n')[0] || 'Command failed') }
return { stdout: err.stdout?.trim() ?? '', stderr: requiresOtp ? 'This operation requires a one-time password (OTP).' : authFailure ? 'Authentication failed. Please run "npm login" and restart the connector.' : filterNpmWarnings(stderr), exitCode: err.code ?? 1, requiresOtp, authFailure, } } } finally { // Clean up temp directory await rm(tempDir, { recursive: true, force: true }).catch(() => { // Ignore cleanup errors }) }}