[READ-ONLY] a fast, modern browser for the npm registry
at main 652 lines 20 kB view raw
1import crypto from 'node:crypto' 2import process from 'node:process' 3import { execFile } from 'node:child_process' 4import { promisify } from 'node:util' 5import { mkdtemp, writeFile, rm } from 'node:fs/promises' 6import { tmpdir } from 'node:os' 7import { join } from 'node:path' 8import * as v from 'valibot' 9import { PackageNameSchema, UsernameSchema, OrgNameSchema, ScopeTeamSchema } from './schemas.ts' 10import { logCommand, logSuccess, logError, logDebug } from './logger.ts' 11 12const execFileAsync = promisify(execFile) 13 14/** 15 * Validates an npm package name using the official npm validation package 16 * @throws Error if the name is invalid 17 * @internal 18 */ 19export function validatePackageName(name: string): void { 20 const result = v.safeParse(PackageNameSchema, name) 21 if (!result.success) { 22 const message = result.issues[0]?.message || 'Invalid package name' 23 throw new Error(`Invalid package name "${name}": ${message}`) 24 } 25} 26 27/** 28 * Validates an npm username 29 * @throws Error if the username is invalid 30 * @internal 31 */ 32export function validateUsername(name: string): void { 33 const result = v.safeParse(UsernameSchema, name) 34 if (!result.success) { 35 throw new Error(`Invalid username: ${name}`) 36 } 37} 38 39/** 40 * Validates an npm org name (without the @ prefix) 41 * @throws Error if the org name is invalid 42 * @internal 43 */ 44export function validateOrgName(name: string): void { 45 const result = v.safeParse(OrgNameSchema, name) 46 if (!result.success) { 47 throw new Error(`Invalid org name: ${name}`) 48 } 49} 50 51/** 52 * Validates a scope:team format (e.g., @myorg:developers) 53 * @throws Error if the scope:team is invalid 54 * @internal 55 */ 56export function validateScopeTeam(scopeTeam: string): void { 57 const result = v.safeParse(ScopeTeamSchema, scopeTeam) 58 if (!result.success) { 59 throw new Error(`Invalid scope:team format: ${scopeTeam}. Expected @scope:team`) 60 } 61} 62 63export interface NpmExecResult { 64 stdout: string 65 stderr: string 66 exitCode: number 67 /** True if the operation failed due to missing/invalid OTP */ 68 requiresOtp?: boolean 69 /** True if the operation failed due to authentication failure (not logged in or token expired) */ 70 authFailure?: boolean 71 /** URLs detected in the command output (stdout + stderr) */ 72 urls?: string[] 73} 74 75function detectOtpRequired(stderr: string): boolean { 76 const otpPatterns = [ 77 'EOTP', 78 'one-time password', 79 'This operation requires a one-time password', 80 'OTP required for authentication', 81 '--otp=<code>', 82 ] 83 const lowerStderr = stderr.toLowerCase() 84 logDebug('Checking for OTP requirement in stderr:', stderr) 85 logDebug('OTP patterns:', otpPatterns) 86 const result = otpPatterns.some(pattern => lowerStderr.includes(pattern.toLowerCase())) 87 logDebug('OTP required:', result) 88 return result 89} 90 91function detectAuthFailure(stderr: string): boolean { 92 const authPatterns = [ 93 'ENEEDAUTH', 94 'You must be logged in', 95 'authentication error', 96 'Unable to authenticate', 97 'code E401', 98 'code E403', 99 '401 Unauthorized', 100 '403 Forbidden', 101 'not logged in', 102 'npm login', 103 'npm adduser', 104 ] 105 const lowerStderr = stderr.toLowerCase() 106 logDebug('Checking for auth failure in stderr:', stderr) 107 logDebug('Auth patterns:', authPatterns) 108 const result = authPatterns.some(pattern => lowerStderr.includes(pattern.toLowerCase())) 109 logDebug('Auth failure:', result) 110 return result 111} 112 113function filterNpmWarnings(stderr: string): string { 114 return stderr 115 .split('\n') 116 .filter(line => !line.startsWith('npm warn')) 117 .join('\n') 118 .trim() 119} 120 121const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g 122 123export function extractUrls(text: string): string[] { 124 const matches = text.match(URL_RE) 125 if (!matches) return [] 126 127 const cleaned = matches.map(url => url.replace(/[.,;:!?)]+$/, '')) 128 return [...new Set(cleaned)] 129} 130 131// Patterns to detect npm's OTP prompt in pty output 132const OTP_PROMPT_RE = /Enter OTP:/i 133// Patterns to detect npm's web auth URL prompt in pty output 134const AUTH_URL_PROMPT_RE = /Press ENTER to open in the browser/i 135// npm prints "Authenticate your account at:\n<url>" — capture the URL on the next line 136const AUTH_URL_TITLE_RE = /Authenticate your account at:\s*(https?:\/\/\S+)/ 137 138function stripAnsi(text: string): string { 139 // eslint disabled because we need escape characters in regex 140 // eslint-disable-next-line no-control-regex, regexp/no-obscure-range 141 return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') 142} 143 144const AUTH_URL_TIMEOUT_MS = 90_000 145 146export interface ExecNpmOptions { 147 otp?: string 148 silent?: boolean 149 /** When true, use PTY-based interactive execution instead of execFile. */ 150 interactive?: boolean 151 /** When true, npm opens auth URLs in the user's browser. 152 * When false, browser opening is suppressed via npm_config_browser=false. 153 * Only relevant when `interactive` is true. */ 154 openUrls?: boolean 155 /** 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. 156 * Only relevant when `interactive` is true. */ 157 onAuthUrl?: (url: string) => void 158} 159 160/** 161 * PTY-based npm execution for interactive commands (uses node-pty). 162 * 163 * - 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. 164 * 165 * - 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 166 */ 167async function execNpmInteractive( 168 args: string[], 169 options: ExecNpmOptions = {}, 170): Promise<NpmExecResult> { 171 const openUrls = options.openUrls === true 172 173 // Lazy-load node-pty so the native addon is only required when interactive mode is actually used. 174 const pty = await import('@lydell/node-pty') 175 176 return new Promise(resolve => { 177 const npmArgs = options.otp ? [...args, '--otp', options.otp] : args 178 179 if (!options.silent) { 180 const displayCmd = options.otp 181 ? ['npm', ...args, '--otp', '******'].join(' ') 182 : ['npm', ...args].join(' ') 183 logCommand(`${displayCmd} (interactive/pty)`) 184 } 185 186 let output = '' 187 let resolved = false 188 let otpPromptSeen = false 189 let authUrlSeen = false 190 let enterSent = false 191 let authUrlTimeout: ReturnType<typeof setTimeout> | null = null 192 let authUrlTimedOut = false 193 194 const env: Record<string, string> = { 195 ...(process.env as Record<string, string>), 196 FORCE_COLOR: '0', 197 } 198 199 // When openUrls is false, tell npm not to open the browser. 200 // npm still prints the auth URL and polls doneUrl 201 if (!openUrls) { 202 env.npm_config_browser = 'false' 203 } 204 205 const child = pty.spawn('npm', npmArgs, { 206 name: 'xterm-256color', 207 cols: 120, 208 rows: 30, 209 env, 210 }) 211 212 // General timeout: 5 minutes (covers non-auth interactive commands) 213 const timeout = setTimeout(() => { 214 if (resolved) return 215 logDebug('Interactive command timed out', { output }) 216 child.kill() 217 }, 300000) 218 219 child.onData((data: string) => { 220 output += data 221 const clean = stripAnsi(data) 222 logDebug('pty data:', { text: clean.trim() }) 223 224 const cleanAll = stripAnsi(output) 225 226 // Detect auth URL in output and notify the caller. 227 if (!authUrlSeen) { 228 const urlMatch = cleanAll.match(AUTH_URL_TITLE_RE) 229 230 if (urlMatch && urlMatch[1]) { 231 authUrlSeen = true 232 const authUrl = urlMatch[1].replace(/[.,;:!?)]+$/, '') 233 logDebug('Auth URL detected:', { authUrl, openUrls }) 234 options.onAuthUrl?.(authUrl) 235 236 authUrlTimeout = setTimeout(() => { 237 if (resolved) return 238 authUrlTimedOut = true 239 logDebug('Auth URL timeout (90s) — killing process') 240 logError('Authentication timed out after 90 seconds') 241 child.kill() 242 }, AUTH_URL_TIMEOUT_MS) 243 } 244 } 245 246 if (authUrlSeen && openUrls && !enterSent && AUTH_URL_PROMPT_RE.test(cleanAll)) { 247 enterSent = true 248 logDebug('Web auth prompt detected, pressing ENTER') 249 child.write('\r') 250 } 251 252 if (!otpPromptSeen && OTP_PROMPT_RE.test(cleanAll)) { 253 otpPromptSeen = true 254 if (options.otp) { 255 logDebug('OTP prompt detected, writing OTP') 256 child.write(options.otp + '\r') 257 } else { 258 logDebug('OTP prompt detected but no OTP provided, killing process') 259 child.kill() 260 } 261 } 262 }) 263 264 child.onExit(({ exitCode }) => { 265 if (resolved) return 266 resolved = true 267 clearTimeout(timeout) 268 if (authUrlTimeout) clearTimeout(authUrlTimeout) 269 270 const cleanOutput = stripAnsi(output) 271 logDebug('Interactive command exited:', { exitCode, output: cleanOutput }) 272 273 const requiresOtp = 274 authUrlTimedOut || (otpPromptSeen && !options.otp) || detectOtpRequired(cleanOutput) 275 const authFailure = detectAuthFailure(cleanOutput) 276 const urls = extractUrls(cleanOutput) 277 278 if (!options.silent) { 279 if (exitCode === 0) { 280 logSuccess('Done') 281 } else if (requiresOtp) { 282 logError('OTP required') 283 } else if (authFailure) { 284 logError('Authentication required - please run "npm login" and restart the connector') 285 } else { 286 const firstLine = filterNpmWarnings(cleanOutput).split('\n')[0] || 'Command failed' 287 logError(firstLine) 288 } 289 } 290 291 // If auth URL timed out, force a non-zero exit code so it's marked as failed 292 const finalExitCode = authUrlTimedOut ? 1 : exitCode 293 294 resolve({ 295 stdout: cleanOutput.trim(), 296 stderr: requiresOtp 297 ? 'This operation requires a one-time password (OTP).' 298 : authFailure 299 ? 'Authentication failed. Please run "npm login" and restart the connector.' 300 : filterNpmWarnings(cleanOutput), 301 exitCode: finalExitCode, 302 requiresOtp, 303 authFailure, 304 urls: urls.length > 0 ? urls : undefined, 305 }) 306 }) 307 }) 308} 309 310async function execNpm(args: string[], options: ExecNpmOptions = {}): Promise<NpmExecResult> { 311 if (options.interactive) { 312 return execNpmInteractive(args, options) 313 } 314 315 // Build the full args array including OTP if provided 316 const npmArgs = options.otp ? [...args, '--otp', options.otp] : args 317 318 // Log the command being run (hide OTP value for security) 319 if (!options.silent) { 320 const displayCmd = options.otp 321 ? ['npm', ...args, '--otp', '******'].join(' ') 322 : ['npm', ...args].join(' ') 323 logCommand(displayCmd) 324 } 325 326 try { 327 logDebug('Executing npm command:', { command: 'npm', args: npmArgs }) 328 // Use execFile instead of exec to avoid shell injection vulnerabilities 329 // On Windows, shell: true is required to execute .cmd files (like npm.cmd) 330 // On Unix, we keep it false for better security and performance 331 const { stdout, stderr } = await execFileAsync('npm', npmArgs, { 332 timeout: 60000, 333 env: { ...process.env, FORCE_COLOR: '0' }, 334 shell: process.platform === 'win32', 335 }) 336 337 logDebug('Command succeeded:', { stdout, stderr }) 338 339 if (!options.silent) { 340 logSuccess('Done') 341 } 342 343 return { 344 stdout: stdout.trim(), 345 stderr: filterNpmWarnings(stderr), 346 exitCode: 0, 347 } 348 } catch (error) { 349 const err = error as { stdout?: string; stderr?: string; code?: number } 350 const stderr = err.stderr?.trim() ?? String(error) 351 logDebug('Command failed:', { error, stdout: err.stdout, stderr: err.stderr, code: err.code }) 352 const requiresOtp = detectOtpRequired(stderr) 353 const authFailure = detectAuthFailure(stderr) 354 355 if (!options.silent) { 356 if (requiresOtp) { 357 logError('OTP required') 358 } else if (authFailure) { 359 logError('Authentication required - please run "npm login" and restart the connector') 360 } else { 361 logError(filterNpmWarnings(stderr).split('\n')[0] || 'Command failed') 362 } 363 } 364 365 return { 366 stdout: err.stdout?.trim() ?? '', 367 stderr: requiresOtp 368 ? 'This operation requires a one-time password (OTP).' 369 : authFailure 370 ? 'Authentication failed. Please run "npm login" and restart the connector.' 371 : filterNpmWarnings(stderr), 372 exitCode: err.code ?? 1, 373 requiresOtp, 374 authFailure, 375 } 376 } 377} 378 379export async function getNpmUser(): Promise<string | null> { 380 const result = await execNpm(['whoami'], { silent: true }) 381 if (result.exitCode === 0 && result.stdout) { 382 return result.stdout 383 } 384 return null 385} 386 387/** 388 * Gets the user's avatar as a base64 data URL from Gravatar. 389 * Returns null if the user's email cannot be retrieved or avatar fetch fails. 390 */ 391export async function getNpmAvatar(): Promise<string | null> { 392 const result = await execNpm(['profile', 'get', 'email', '--json'], { silent: true }) 393 if (result.exitCode !== 0 || !result.stdout) { 394 return null 395 } 396 397 try { 398 const parsed = JSON.parse(result.stdout) as { email?: string } 399 if (!parsed.email) { 400 return null 401 } 402 403 const email = parsed.email.trim().toLowerCase() 404 const hash = crypto.createHash('md5').update(email).digest('hex') 405 const gravatarUrl = `https://www.gravatar.com/avatar/${hash}?s=64&d=retro` 406 407 const response = await fetch(gravatarUrl) 408 if (!response.ok) { 409 return null 410 } 411 412 const contentType = response.headers.get('content-type') || 'image/png' 413 const buffer = await response.arrayBuffer() 414 const base64 = Buffer.from(buffer).toString('base64') 415 return `data:${contentType};base64,${base64}` 416 } catch { 417 return null 418 } 419} 420 421export async function orgAddUser( 422 org: string, 423 user: string, 424 role: 'developer' | 'admin' | 'owner', 425 options?: ExecNpmOptions, 426): Promise<NpmExecResult> { 427 validateOrgName(org) 428 validateUsername(user) 429 return execNpm(['org', 'set', org, user, role], options) 430} 431 432export async function orgRemoveUser( 433 org: string, 434 user: string, 435 options?: ExecNpmOptions, 436): Promise<NpmExecResult> { 437 validateOrgName(org) 438 validateUsername(user) 439 return execNpm(['org', 'rm', org, user], options) 440} 441 442export async function teamCreate( 443 scopeTeam: string, 444 options?: ExecNpmOptions, 445): Promise<NpmExecResult> { 446 validateScopeTeam(scopeTeam) 447 return execNpm(['team', 'create', scopeTeam], options) 448} 449 450export async function teamDestroy( 451 scopeTeam: string, 452 options?: ExecNpmOptions, 453): Promise<NpmExecResult> { 454 validateScopeTeam(scopeTeam) 455 return execNpm(['team', 'destroy', scopeTeam], options) 456} 457 458export async function teamAddUser( 459 scopeTeam: string, 460 user: string, 461 options?: ExecNpmOptions, 462): Promise<NpmExecResult> { 463 validateScopeTeam(scopeTeam) 464 validateUsername(user) 465 return execNpm(['team', 'add', scopeTeam, user], options) 466} 467 468export async function teamRemoveUser( 469 scopeTeam: string, 470 user: string, 471 options?: ExecNpmOptions, 472): Promise<NpmExecResult> { 473 validateScopeTeam(scopeTeam) 474 validateUsername(user) 475 return execNpm(['team', 'rm', scopeTeam, user], options) 476} 477 478export async function accessGrant( 479 permission: 'read-only' | 'read-write', 480 scopeTeam: string, 481 pkg: string, 482 options?: ExecNpmOptions, 483): Promise<NpmExecResult> { 484 validateScopeTeam(scopeTeam) 485 validatePackageName(pkg) 486 return execNpm(['access', 'grant', permission, scopeTeam, pkg], options) 487} 488 489export async function accessRevoke( 490 scopeTeam: string, 491 pkg: string, 492 options?: ExecNpmOptions, 493): Promise<NpmExecResult> { 494 validateScopeTeam(scopeTeam) 495 validatePackageName(pkg) 496 return execNpm(['access', 'revoke', scopeTeam, pkg], options) 497} 498 499export async function ownerAdd( 500 user: string, 501 pkg: string, 502 options?: ExecNpmOptions, 503): Promise<NpmExecResult> { 504 validateUsername(user) 505 validatePackageName(pkg) 506 return execNpm(['owner', 'add', user, pkg], options) 507} 508 509export async function ownerRemove( 510 user: string, 511 pkg: string, 512 options?: ExecNpmOptions, 513): Promise<NpmExecResult> { 514 validateUsername(user) 515 validatePackageName(pkg) 516 return execNpm(['owner', 'rm', user, pkg], options) 517} 518 519// List functions (for reading data) - silent since they're not user-triggered operations 520 521export async function orgListUsers(org: string): Promise<NpmExecResult> { 522 validateOrgName(org) 523 return execNpm(['org', 'ls', org, '--json'], { silent: true }) 524} 525 526export async function teamListTeams(org: string): Promise<NpmExecResult> { 527 validateOrgName(org) 528 return execNpm(['team', 'ls', org, '--json'], { silent: true }) 529} 530 531export async function teamListUsers(scopeTeam: string): Promise<NpmExecResult> { 532 validateScopeTeam(scopeTeam) 533 return execNpm(['team', 'ls', scopeTeam, '--json'], { silent: true }) 534} 535 536export async function accessListCollaborators(pkg: string): Promise<NpmExecResult> { 537 validatePackageName(pkg) 538 return execNpm(['access', 'list', 'collaborators', pkg, '--json'], { silent: true }) 539} 540 541/** 542 * Lists all packages that a user has access to publish. 543 * Uses `npm access list packages @{user} --json` 544 * Returns a map of package name to permission level 545 */ 546export async function listUserPackages(user: string): Promise<NpmExecResult> { 547 validateUsername(user) 548 return execNpm(['access', 'list', 'packages', `@${user}`, '--json'], { silent: true }) 549} 550 551/** 552 * Initialize and publish a new package to claim the name. 553 * Creates a minimal package.json in a temp directory and publishes it. 554 * @param name Package name to claim 555 * @param author npm username of the publisher (for author field) 556 * @param otp Optional OTP for 2FA 557 */ 558export async function packageInit( 559 name: string, 560 author?: string, 561 otp?: string, 562): Promise<NpmExecResult> { 563 validatePackageName(name) 564 565 // Create a temporary directory 566 const tempDir = await mkdtemp(join(tmpdir(), 'npmx-init-')) 567 568 try { 569 // Determine access type based on whether it's a scoped package 570 const isScoped = name.startsWith('@') 571 const access = isScoped ? 'public' : undefined 572 573 // Create minimal package.json 574 const packageJson = { 575 name, 576 version: '0.0.0', 577 description: `Placeholder for ${name}`, 578 main: 'index.js', 579 scripts: {}, 580 keywords: [], 581 author: author ? `${author} (https://www.npmjs.com/~${author})` : '', 582 license: 'UNLICENSED', 583 private: false, 584 ...(access && { publishConfig: { access } }), 585 } 586 587 await writeFile(join(tempDir, 'package.json'), JSON.stringify(packageJson, null, 2)) 588 589 // Create empty index.js 590 await writeFile(join(tempDir, 'index.js'), '// Placeholder\n') 591 592 // Build npm publish args 593 const args = ['publish'] 594 if (access) { 595 args.push('--access', access) 596 } 597 598 // Run npm publish from the temp directory 599 const npmArgs = otp ? [...args, '--otp', otp] : args 600 601 // Log the command being run (hide OTP value for security) 602 const displayCmd = otp ? `npm ${args.join(' ')} --otp ******` : `npm ${args.join(' ')}` 603 logCommand(`${displayCmd} (in temp dir for ${name})`) 604 605 try { 606 const { stdout, stderr } = await execFileAsync('npm', npmArgs, { 607 timeout: 60000, 608 cwd: tempDir, 609 env: { ...process.env, FORCE_COLOR: '0' }, 610 shell: process.platform === 'win32', 611 }) 612 613 logSuccess(`Published ${name}@0.0.0`) 614 615 return { 616 stdout: stdout.trim(), 617 stderr: filterNpmWarnings(stderr), 618 exitCode: 0, 619 } 620 } catch (error) { 621 const err = error as { stdout?: string; stderr?: string; code?: number } 622 const stderr = err.stderr?.trim() ?? String(error) 623 const requiresOtp = detectOtpRequired(stderr) 624 const authFailure = detectAuthFailure(stderr) 625 626 if (requiresOtp) { 627 logError('OTP required') 628 } else if (authFailure) { 629 logError('Authentication required - please run "npm login" and restart the connector') 630 } else { 631 logError(filterNpmWarnings(stderr).split('\n')[0] || 'Command failed') 632 } 633 634 return { 635 stdout: err.stdout?.trim() ?? '', 636 stderr: requiresOtp 637 ? 'This operation requires a one-time password (OTP).' 638 : authFailure 639 ? 'Authentication failed. Please run "npm login" and restart the connector.' 640 : filterNpmWarnings(stderr), 641 exitCode: err.code ?? 1, 642 requiresOtp, 643 authFailure, 644 } 645 } 646 } finally { 647 // Clean up temp directory 648 await rm(tempDir, { recursive: true, force: true }).catch(() => { 649 // Ignore cleanup errors 650 }) 651 } 652}