import 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=', ] 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 output const OTP_PROMPT_RE = /Enter OTP:/i // Patterns to detect npm's web auth URL prompt in pty output const AUTH_URL_PROMPT_RE = /Press ENTER to open in the browser/i // npm prints "Authenticate your account at:\n" — capture the URL on the next line const 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 { 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 | null = null let authUrlTimedOut = false const env: Record = { ...(process.env as Record), 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 { 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 { 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 { 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 { validateOrgName(org) validateUsername(user) return execNpm(['org', 'set', org, user, role], options) } export async function orgRemoveUser( org: string, user: string, options?: ExecNpmOptions, ): Promise { validateOrgName(org) validateUsername(user) return execNpm(['org', 'rm', org, user], options) } export async function teamCreate( scopeTeam: string, options?: ExecNpmOptions, ): Promise { validateScopeTeam(scopeTeam) return execNpm(['team', 'create', scopeTeam], options) } export async function teamDestroy( scopeTeam: string, options?: ExecNpmOptions, ): Promise { validateScopeTeam(scopeTeam) return execNpm(['team', 'destroy', scopeTeam], options) } export async function teamAddUser( scopeTeam: string, user: string, options?: ExecNpmOptions, ): Promise { validateScopeTeam(scopeTeam) validateUsername(user) return execNpm(['team', 'add', scopeTeam, user], options) } export async function teamRemoveUser( scopeTeam: string, user: string, options?: ExecNpmOptions, ): Promise { 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 { validateScopeTeam(scopeTeam) validatePackageName(pkg) return execNpm(['access', 'grant', permission, scopeTeam, pkg], options) } export async function accessRevoke( scopeTeam: string, pkg: string, options?: ExecNpmOptions, ): Promise { validateScopeTeam(scopeTeam) validatePackageName(pkg) return execNpm(['access', 'revoke', scopeTeam, pkg], options) } export async function ownerAdd( user: string, pkg: string, options?: ExecNpmOptions, ): Promise { validateUsername(user) validatePackageName(pkg) return execNpm(['owner', 'add', user, pkg], options) } export async function ownerRemove( user: string, pkg: string, options?: ExecNpmOptions, ): Promise { 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 { validateOrgName(org) return execNpm(['org', 'ls', org, '--json'], { silent: true }) } export async function teamListTeams(org: string): Promise { validateOrgName(org) return execNpm(['team', 'ls', org, '--json'], { silent: true }) } export async function teamListUsers(scopeTeam: string): Promise { validateScopeTeam(scopeTeam) return execNpm(['team', 'ls', scopeTeam, '--json'], { silent: true }) } export async function accessListCollaborators(pkg: string): Promise { 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 { 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 { 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 }) } }