@@ -68,6 +68,8 @@ export interface NpmExecResult {
6868 requiresOtp ?: boolean
6969 /** True if the operation failed due to authentication failure (not logged in or token expired) */
7070 authFailure ?: boolean
71+ /** URLs detected in the command output (stdout + stderr) */
72+ urls ?: string [ ]
7173}
7274
7375function detectOtpRequired ( stderr : string ) : boolean {
@@ -116,10 +118,192 @@ function filterNpmWarnings(stderr: string): string {
116118 . trim ( )
117119}
118120
119- async function execNpm (
121+ const URL_RE = / h t t p s ? : \/ \/ [ ^ \s < > " { } | \\ ^ ` [ \] ] + / g
122+
123+ export 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
132+ const OTP_PROMPT_RE = / E n t e r O T P : / i
133+ // Patterns to detect npm's web auth URL prompt in pty output
134+ const AUTH_URL_PROMPT_RE = / P r e s s E N T E R t o o p e n i n t h e b r o w s e r / i
135+ // npm prints "Authenticate your account at:\n<url>" — capture the URL on the next line
136+ const AUTH_URL_TITLE_RE = / A u t h e n t i c a t e y o u r a c c o u n t a t : \s * ( h t t p s ? : \/ \/ \S + ) /
137+
138+ function 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+
144+ const AUTH_URL_TIMEOUT_MS = 90_000
145+
146+ export 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 opend 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+ */
167+ async function execNpmInteractive (
120168 args : string [ ] ,
121- options : { otp ?: string ; silent ?: boolean } = { } ,
169+ options : ExecNpmOptions = { } ,
122170) : Promise < NpmExecResult > {
171+ const openUrls = options . openUrls !== false
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 authUrlTimeout : ReturnType < typeof setTimeout > | null = null
191+
192+ const env : Record < string , string > = {
193+ ...( process . env as Record < string , string > ) ,
194+ FORCE_COLOR : '0' ,
195+ }
196+
197+ // When openUrls is false, tell npm not to open the browser.
198+ // npm still prints the auth URL and polls doneUrl
199+ if ( ! openUrls ) {
200+ env . npm_config_browser = 'false'
201+ }
202+
203+ const child = pty . spawn ( 'npm' , npmArgs , {
204+ name : 'xterm-256color' ,
205+ cols : 120 ,
206+ rows : 30 ,
207+ env,
208+ } )
209+
210+ // General timeout: 5 minutes (covers non-auth interactive commands)
211+ const timeout = setTimeout ( ( ) => {
212+ if ( resolved ) return
213+ logDebug ( 'Interactive command timed out' , { output } )
214+ child . kill ( )
215+ } , 300000 )
216+
217+ child . onData ( ( data : string ) => {
218+ output += data
219+ const clean = stripAnsi ( data )
220+ logDebug ( 'pty data:' , { text : clean . trim ( ) } )
221+
222+ const cleanAll = stripAnsi ( output )
223+
224+ // Detect auth URL in output and notify the caller.
225+ if ( ! authUrlSeen ) {
226+ const urlMatch = cleanAll . match ( AUTH_URL_TITLE_RE )
227+
228+ if ( urlMatch && urlMatch [ 1 ] ) {
229+ authUrlSeen = true
230+ const authUrl = urlMatch [ 1 ] . replace ( / [ . , ; : ! ? ) ] + $ / , '' )
231+ logDebug ( 'Auth URL detected:' , { authUrl, openUrls } )
232+ options . onAuthUrl ?.( authUrl )
233+
234+ authUrlTimeout = setTimeout ( ( ) => {
235+ if ( resolved ) return
236+ logDebug ( 'Auth URL timeout (90s) — killing process' )
237+ logError ( 'Authentication timed out after 90 seconds' )
238+ child . kill ( )
239+ } , AUTH_URL_TIMEOUT_MS )
240+ }
241+ }
242+
243+ if ( authUrlSeen && openUrls && AUTH_URL_PROMPT_RE . test ( cleanAll ) ) {
244+ logDebug ( 'Web auth prompt detected, pressing ENTER' )
245+ child . write ( '\r' )
246+ }
247+
248+ if ( ! otpPromptSeen && OTP_PROMPT_RE . test ( cleanAll ) ) {
249+ otpPromptSeen = true
250+ if ( options . otp ) {
251+ logDebug ( 'OTP prompt detected, writing OTP' )
252+ child . write ( options . otp + '\r' )
253+ } else {
254+ logDebug ( 'OTP prompt detected but no OTP provided, killing process' )
255+ child . kill ( )
256+ }
257+ }
258+ } )
259+
260+ child . onExit ( ( { exitCode } ) => {
261+ if ( resolved ) return
262+ resolved = true
263+ clearTimeout ( timeout )
264+ if ( authUrlTimeout ) clearTimeout ( authUrlTimeout )
265+
266+ const cleanOutput = stripAnsi ( output )
267+ logDebug ( 'Interactive command exited:' , { exitCode, output : cleanOutput } )
268+
269+ const requiresOtp = ( otpPromptSeen && ! options . otp ) || detectOtpRequired ( cleanOutput )
270+ const authFailure = detectAuthFailure ( cleanOutput )
271+ const urls = extractUrls ( cleanOutput )
272+
273+ if ( ! options . silent ) {
274+ if ( exitCode === 0 ) {
275+ logSuccess ( 'Done' )
276+ } else if ( requiresOtp ) {
277+ logError ( 'OTP required' )
278+ } else if ( authFailure ) {
279+ logError ( 'Authentication required - please run "npm login" and restart the connector' )
280+ } else {
281+ const firstLine = filterNpmWarnings ( cleanOutput ) . split ( '\n' ) [ 0 ] || 'Command failed'
282+ logError ( firstLine )
283+ }
284+ }
285+
286+ resolve ( {
287+ stdout : cleanOutput . trim ( ) ,
288+ stderr : requiresOtp
289+ ? 'This operation requires a one-time password (OTP).'
290+ : authFailure
291+ ? 'Authentication failed. Please run "npm login" and restart the connector.'
292+ : filterNpmWarnings ( cleanOutput ) ,
293+ exitCode,
294+ requiresOtp,
295+ authFailure,
296+ urls : urls . length > 0 ? urls : undefined ,
297+ } )
298+ } )
299+ } )
300+ }
301+
302+ async function execNpm ( args : string [ ] , options : ExecNpmOptions = { } ) : Promise < NpmExecResult > {
303+ if ( options . interactive ) {
304+ return execNpmInteractive ( args , options )
305+ }
306+
123307 // Build the full args array including OTP if provided
124308 const npmArgs = options . otp ? [ ...args , '--otp' , options . otp ] : args
125309
@@ -230,84 +414,98 @@ export async function orgAddUser(
230414 org : string ,
231415 user : string ,
232416 role : 'developer' | 'admin' | 'owner' ,
233- otp ?: string ,
417+ options ?: ExecNpmOptions ,
234418) : Promise < NpmExecResult > {
235419 validateOrgName ( org )
236420 validateUsername ( user )
237- return execNpm ( [ 'org' , 'set' , org , user , role ] , { otp } )
421+ return execNpm ( [ 'org' , 'set' , org , user , role ] , options )
238422}
239423
240424export async function orgRemoveUser (
241425 org : string ,
242426 user : string ,
243- otp ?: string ,
427+ options ?: ExecNpmOptions ,
244428) : Promise < NpmExecResult > {
245429 validateOrgName ( org )
246430 validateUsername ( user )
247- return execNpm ( [ 'org' , 'rm' , org , user ] , { otp } )
431+ return execNpm ( [ 'org' , 'rm' , org , user ] , options )
248432}
249433
250- export async function teamCreate ( scopeTeam : string , otp ?: string ) : Promise < NpmExecResult > {
434+ export async function teamCreate (
435+ scopeTeam : string ,
436+ options ?: ExecNpmOptions ,
437+ ) : Promise < NpmExecResult > {
251438 validateScopeTeam ( scopeTeam )
252- return execNpm ( [ 'team' , 'create' , scopeTeam ] , { otp } )
439+ return execNpm ( [ 'team' , 'create' , scopeTeam ] , options )
253440}
254441
255- export async function teamDestroy ( scopeTeam : string , otp ?: string ) : Promise < NpmExecResult > {
442+ export async function teamDestroy (
443+ scopeTeam : string ,
444+ options ?: ExecNpmOptions ,
445+ ) : Promise < NpmExecResult > {
256446 validateScopeTeam ( scopeTeam )
257- return execNpm ( [ 'team' , 'destroy' , scopeTeam ] , { otp } )
447+ return execNpm ( [ 'team' , 'destroy' , scopeTeam ] , options )
258448}
259449
260450export async function teamAddUser (
261451 scopeTeam : string ,
262452 user : string ,
263- otp ?: string ,
453+ options ?: ExecNpmOptions ,
264454) : Promise < NpmExecResult > {
265455 validateScopeTeam ( scopeTeam )
266456 validateUsername ( user )
267- return execNpm ( [ 'team' , 'add' , scopeTeam , user ] , { otp } )
457+ return execNpm ( [ 'team' , 'add' , scopeTeam , user ] , options )
268458}
269459
270460export async function teamRemoveUser (
271461 scopeTeam : string ,
272462 user : string ,
273- otp ?: string ,
463+ options ?: ExecNpmOptions ,
274464) : Promise < NpmExecResult > {
275465 validateScopeTeam ( scopeTeam )
276466 validateUsername ( user )
277- return execNpm ( [ 'team' , 'rm' , scopeTeam , user ] , { otp } )
467+ return execNpm ( [ 'team' , 'rm' , scopeTeam , user ] , options )
278468}
279469
280470export async function accessGrant (
281471 permission : 'read-only' | 'read-write' ,
282472 scopeTeam : string ,
283473 pkg : string ,
284- otp ?: string ,
474+ options ?: ExecNpmOptions ,
285475) : Promise < NpmExecResult > {
286476 validateScopeTeam ( scopeTeam )
287477 validatePackageName ( pkg )
288- return execNpm ( [ 'access' , 'grant' , permission , scopeTeam , pkg ] , { otp } )
478+ return execNpm ( [ 'access' , 'grant' , permission , scopeTeam , pkg ] , options )
289479}
290480
291481export async function accessRevoke (
292482 scopeTeam : string ,
293483 pkg : string ,
294- otp ?: string ,
484+ options ?: ExecNpmOptions ,
295485) : Promise < NpmExecResult > {
296486 validateScopeTeam ( scopeTeam )
297487 validatePackageName ( pkg )
298- return execNpm ( [ 'access' , 'revoke' , scopeTeam , pkg ] , { otp } )
488+ return execNpm ( [ 'access' , 'revoke' , scopeTeam , pkg ] , options )
299489}
300490
301- export async function ownerAdd ( user : string , pkg : string , otp ?: string ) : Promise < NpmExecResult > {
491+ export async function ownerAdd (
492+ user : string ,
493+ pkg : string ,
494+ options ?: ExecNpmOptions ,
495+ ) : Promise < NpmExecResult > {
302496 validateUsername ( user )
303497 validatePackageName ( pkg )
304- return execNpm ( [ 'owner' , 'add' , user , pkg ] , { otp } )
498+ return execNpm ( [ 'owner' , 'add' , user , pkg ] , options )
305499}
306500
307- export async function ownerRemove ( user : string , pkg : string , otp ?: string ) : Promise < NpmExecResult > {
501+ export async function ownerRemove (
502+ user : string ,
503+ pkg : string ,
504+ options ?: ExecNpmOptions ,
505+ ) : Promise < NpmExecResult > {
308506 validateUsername ( user )
309507 validatePackageName ( pkg )
310- return execNpm ( [ 'owner' , 'rm' , user , pkg ] , { otp } )
508+ return execNpm ( [ 'owner' , 'rm' , user , pkg ] , options )
311509}
312510
313511// List functions (for reading data) - silent since they're not user-triggered operations
0 commit comments