forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}