···6868 requiresOtp?: boolean
6969 /** True if the operation failed due to authentication failure (not logged in or token expired) */
7070 authFailure?: boolean
7171+ /** URLs detected in the command output (stdout + stderr) */
7272+ urls?: string[]
7173}
72747375function detectOtpRequired(stderr: string): boolean {
···116118 .trim()
117119}
118120119119-async function execNpm(
121121+const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g
122122+123123+export function extractUrls(text: string): string[] {
124124+ const matches = text.match(URL_RE)
125125+ if (!matches) return []
126126+127127+ const cleaned = matches.map(url => url.replace(/[.,;:!?)]+$/, ''))
128128+ return [...new Set(cleaned)]
129129+}
130130+131131+// Patterns to detect npm's OTP prompt in pty output
132132+const OTP_PROMPT_RE = /Enter OTP:/i
133133+// Patterns to detect npm's web auth URL prompt in pty output
134134+const AUTH_URL_PROMPT_RE = /Press ENTER to open in the browser/i
135135+// npm prints "Authenticate your account at:\n<url>" — capture the URL on the next line
136136+const AUTH_URL_TITLE_RE = /Authenticate your account at:\s*(https?:\/\/\S+)/
137137+138138+function stripAnsi(text: string): string {
139139+ // eslint disabled because we need escape characters in regex
140140+ // eslint-disable-next-line no-control-regex, regexp/no-obscure-range
141141+ return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
142142+}
143143+144144+const AUTH_URL_TIMEOUT_MS = 90_000
145145+146146+export interface ExecNpmOptions {
147147+ otp?: string
148148+ silent?: boolean
149149+ /** When true, use PTY-based interactive execution instead of execFile. */
150150+ interactive?: boolean
151151+ /** When true, npm opens auth URLs in the user's browser.
152152+ * When false, browser opening is suppressed via npm_config_browser=false.
153153+ * Only relevant when `interactive` is true. */
154154+ openUrls?: boolean
155155+ /** 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.
156156+ * Only relevant when `interactive` is true. */
157157+ onAuthUrl?: (url: string) => void
158158+}
159159+160160+/**
161161+ * PTY-based npm execution for interactive commands (uses node-pty).
162162+ *
163163+ * - 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.
164164+ *
165165+ * - 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
166166+ */
167167+async function execNpmInteractive(
120168 args: string[],
121121- options: { otp?: string; silent?: boolean } = {},
169169+ options: ExecNpmOptions = {},
122170): Promise<NpmExecResult> {
171171+ const openUrls = options.openUrls === true
172172+173173+ // Lazy-load node-pty so the native addon is only required when interactive mode is actually used.
174174+ const pty = await import('@lydell/node-pty')
175175+176176+ return new Promise(resolve => {
177177+ const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
178178+179179+ if (!options.silent) {
180180+ const displayCmd = options.otp
181181+ ? ['npm', ...args, '--otp', '******'].join(' ')
182182+ : ['npm', ...args].join(' ')
183183+ logCommand(`${displayCmd} (interactive/pty)`)
184184+ }
185185+186186+ let output = ''
187187+ let resolved = false
188188+ let otpPromptSeen = false
189189+ let authUrlSeen = false
190190+ let enterSent = false
191191+ let authUrlTimeout: ReturnType<typeof setTimeout> | null = null
192192+ let authUrlTimedOut = false
193193+194194+ const env: Record<string, string> = {
195195+ ...(process.env as Record<string, string>),
196196+ FORCE_COLOR: '0',
197197+ }
198198+199199+ // When openUrls is false, tell npm not to open the browser.
200200+ // npm still prints the auth URL and polls doneUrl
201201+ if (!openUrls) {
202202+ env.npm_config_browser = 'false'
203203+ }
204204+205205+ const child = pty.spawn('npm', npmArgs, {
206206+ name: 'xterm-256color',
207207+ cols: 120,
208208+ rows: 30,
209209+ env,
210210+ })
211211+212212+ // General timeout: 5 minutes (covers non-auth interactive commands)
213213+ const timeout = setTimeout(() => {
214214+ if (resolved) return
215215+ logDebug('Interactive command timed out', { output })
216216+ child.kill()
217217+ }, 300000)
218218+219219+ child.onData((data: string) => {
220220+ output += data
221221+ const clean = stripAnsi(data)
222222+ logDebug('pty data:', { text: clean.trim() })
223223+224224+ const cleanAll = stripAnsi(output)
225225+226226+ // Detect auth URL in output and notify the caller.
227227+ if (!authUrlSeen) {
228228+ const urlMatch = cleanAll.match(AUTH_URL_TITLE_RE)
229229+230230+ if (urlMatch && urlMatch[1]) {
231231+ authUrlSeen = true
232232+ const authUrl = urlMatch[1].replace(/[.,;:!?)]+$/, '')
233233+ logDebug('Auth URL detected:', { authUrl, openUrls })
234234+ options.onAuthUrl?.(authUrl)
235235+236236+ authUrlTimeout = setTimeout(() => {
237237+ if (resolved) return
238238+ authUrlTimedOut = true
239239+ logDebug('Auth URL timeout (90s) — killing process')
240240+ logError('Authentication timed out after 90 seconds')
241241+ child.kill()
242242+ }, AUTH_URL_TIMEOUT_MS)
243243+ }
244244+ }
245245+246246+ if (authUrlSeen && openUrls && !enterSent && AUTH_URL_PROMPT_RE.test(cleanAll)) {
247247+ enterSent = true
248248+ logDebug('Web auth prompt detected, pressing ENTER')
249249+ child.write('\r')
250250+ }
251251+252252+ if (!otpPromptSeen && OTP_PROMPT_RE.test(cleanAll)) {
253253+ otpPromptSeen = true
254254+ if (options.otp) {
255255+ logDebug('OTP prompt detected, writing OTP')
256256+ child.write(options.otp + '\r')
257257+ } else {
258258+ logDebug('OTP prompt detected but no OTP provided, killing process')
259259+ child.kill()
260260+ }
261261+ }
262262+ })
263263+264264+ child.onExit(({ exitCode }) => {
265265+ if (resolved) return
266266+ resolved = true
267267+ clearTimeout(timeout)
268268+ if (authUrlTimeout) clearTimeout(authUrlTimeout)
269269+270270+ const cleanOutput = stripAnsi(output)
271271+ logDebug('Interactive command exited:', { exitCode, output: cleanOutput })
272272+273273+ const requiresOtp =
274274+ authUrlTimedOut || (otpPromptSeen && !options.otp) || detectOtpRequired(cleanOutput)
275275+ const authFailure = detectAuthFailure(cleanOutput)
276276+ const urls = extractUrls(cleanOutput)
277277+278278+ if (!options.silent) {
279279+ if (exitCode === 0) {
280280+ logSuccess('Done')
281281+ } else if (requiresOtp) {
282282+ logError('OTP required')
283283+ } else if (authFailure) {
284284+ logError('Authentication required - please run "npm login" and restart the connector')
285285+ } else {
286286+ const firstLine = filterNpmWarnings(cleanOutput).split('\n')[0] || 'Command failed'
287287+ logError(firstLine)
288288+ }
289289+ }
290290+291291+ // If auth URL timed out, force a non-zero exit code so it's marked as failed
292292+ const finalExitCode = authUrlTimedOut ? 1 : exitCode
293293+294294+ resolve({
295295+ stdout: cleanOutput.trim(),
296296+ stderr: requiresOtp
297297+ ? 'This operation requires a one-time password (OTP).'
298298+ : authFailure
299299+ ? 'Authentication failed. Please run "npm login" and restart the connector.'
300300+ : filterNpmWarnings(cleanOutput),
301301+ exitCode: finalExitCode,
302302+ requiresOtp,
303303+ authFailure,
304304+ urls: urls.length > 0 ? urls : undefined,
305305+ })
306306+ })
307307+ })
308308+}
309309+310310+async function execNpm(args: string[], options: ExecNpmOptions = {}): Promise<NpmExecResult> {
311311+ if (options.interactive) {
312312+ return execNpmInteractive(args, options)
313313+ }
314314+123315 // Build the full args array including OTP if provided
124316 const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
125317···230422 org: string,
231423 user: string,
232424 role: 'developer' | 'admin' | 'owner',
233233- otp?: string,
425425+ options?: ExecNpmOptions,
234426): Promise<NpmExecResult> {
235427 validateOrgName(org)
236428 validateUsername(user)
237237- return execNpm(['org', 'set', org, user, role], { otp })
429429+ return execNpm(['org', 'set', org, user, role], options)
238430}
239431240432export async function orgRemoveUser(
241433 org: string,
242434 user: string,
243243- otp?: string,
435435+ options?: ExecNpmOptions,
244436): Promise<NpmExecResult> {
245437 validateOrgName(org)
246438 validateUsername(user)
247247- return execNpm(['org', 'rm', org, user], { otp })
439439+ return execNpm(['org', 'rm', org, user], options)
248440}
249441250250-export async function teamCreate(scopeTeam: string, otp?: string): Promise<NpmExecResult> {
442442+export async function teamCreate(
443443+ scopeTeam: string,
444444+ options?: ExecNpmOptions,
445445+): Promise<NpmExecResult> {
251446 validateScopeTeam(scopeTeam)
252252- return execNpm(['team', 'create', scopeTeam], { otp })
447447+ return execNpm(['team', 'create', scopeTeam], options)
253448}
254449255255-export async function teamDestroy(scopeTeam: string, otp?: string): Promise<NpmExecResult> {
450450+export async function teamDestroy(
451451+ scopeTeam: string,
452452+ options?: ExecNpmOptions,
453453+): Promise<NpmExecResult> {
256454 validateScopeTeam(scopeTeam)
257257- return execNpm(['team', 'destroy', scopeTeam], { otp })
455455+ return execNpm(['team', 'destroy', scopeTeam], options)
258456}
259457260458export async function teamAddUser(
261459 scopeTeam: string,
262460 user: string,
263263- otp?: string,
461461+ options?: ExecNpmOptions,
264462): Promise<NpmExecResult> {
265463 validateScopeTeam(scopeTeam)
266464 validateUsername(user)
267267- return execNpm(['team', 'add', scopeTeam, user], { otp })
465465+ return execNpm(['team', 'add', scopeTeam, user], options)
268466}
269467270468export async function teamRemoveUser(
271469 scopeTeam: string,
272470 user: string,
273273- otp?: string,
471471+ options?: ExecNpmOptions,
274472): Promise<NpmExecResult> {
275473 validateScopeTeam(scopeTeam)
276474 validateUsername(user)
277277- return execNpm(['team', 'rm', scopeTeam, user], { otp })
475475+ return execNpm(['team', 'rm', scopeTeam, user], options)
278476}
279477280478export async function accessGrant(
281479 permission: 'read-only' | 'read-write',
282480 scopeTeam: string,
283481 pkg: string,
284284- otp?: string,
482482+ options?: ExecNpmOptions,
285483): Promise<NpmExecResult> {
286484 validateScopeTeam(scopeTeam)
287485 validatePackageName(pkg)
288288- return execNpm(['access', 'grant', permission, scopeTeam, pkg], { otp })
486486+ return execNpm(['access', 'grant', permission, scopeTeam, pkg], options)
289487}
290488291489export async function accessRevoke(
292490 scopeTeam: string,
293491 pkg: string,
294294- otp?: string,
492492+ options?: ExecNpmOptions,
295493): Promise<NpmExecResult> {
296494 validateScopeTeam(scopeTeam)
297495 validatePackageName(pkg)
298298- return execNpm(['access', 'revoke', scopeTeam, pkg], { otp })
496496+ return execNpm(['access', 'revoke', scopeTeam, pkg], options)
299497}
300498301301-export async function ownerAdd(user: string, pkg: string, otp?: string): Promise<NpmExecResult> {
499499+export async function ownerAdd(
500500+ user: string,
501501+ pkg: string,
502502+ options?: ExecNpmOptions,
503503+): Promise<NpmExecResult> {
302504 validateUsername(user)
303505 validatePackageName(pkg)
304304- return execNpm(['owner', 'add', user, pkg], { otp })
506506+ return execNpm(['owner', 'add', user, pkg], options)
305507}
306508307307-export async function ownerRemove(user: string, pkg: string, otp?: string): Promise<NpmExecResult> {
509509+export async function ownerRemove(
510510+ user: string,
511511+ pkg: string,
512512+ options?: ExecNpmOptions,
513513+): Promise<NpmExecResult> {
308514 validateUsername(user)
309515 validatePackageName(pkg)
310310- return execNpm(['owner', 'rm', user, pkg], { otp })
516516+ return execNpm(['owner', 'rm', user, pkg], options)
311517}
312518313519// List functions (for reading data) - silent since they're not user-triggered operations
+6-1
cli/src/schemas.ts
···151151})
152152153153/**
154154- * Schema for /execute request body
154154+ * Schema for /execute request body.
155155+ * - `otp`: optional 6-digit OTP code for 2FA
156156+ * - `interactive`: when true, commands run via a real PTY (node-pty) instead of execFile, so npm's OTP handler can activate.
157157+ * - `openUrls`: when true (default), npm opens auth URLs in the user's browser automatically. When false, URLs are suppressed on the connector side and only returned in the response / exposed in /state
155158 */
156159export const ExecuteBodySchema = v.object({
157160 otp: OtpSchema,
161161+ interactive: v.optional(v.boolean()),
162162+ openUrls: v.optional(v.boolean()),
158163})
159164160165/**
+76-16
cli/src/server.ts
···5151 ownerRemove,
5252 packageInit,
5353 listUserPackages,
5454+ extractUrls,
5555+ type ExecNpmOptions,
5456 type NpmExecResult,
5557} from './npm-client.ts'
5658import {
···335337 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
336338 }
337339338338- // OTP can be passed directly in the request body for this execution
340340+ // OTP, interactive flag, and openUrls can be passed in the request body
339341 let otp: string | undefined
342342+ let interactive = false
343343+ let openUrls = false
340344 try {
341345 const rawBody = await event.req.json()
342346 if (rawBody) {
···345349 throw new HTTPError({ statusCode: 400, message: parsed.error })
346350 }
347351 otp = parsed.data.otp
352352+ interactive = parsed.data.interactive ?? false
353353+ openUrls = parsed.data.openUrls ?? false
348354 }
349355 } catch (err) {
350356 // Re-throw HTTPError, ignore JSON parse errors (empty body is fine)
···356362 let otpRequired = false
357363 const completedIds = new Set<string>()
358364 const failedIds = new Set<string>()
365365+366366+ // Collect all URLs across all operations in this execution batch
367367+ const allUrls: string[] = []
359368360369 // Execute operations in waves, respecting dependencies
361370 // Each wave contains operations whose dependencies are satisfied
···393402 // Execute ready operations in parallel
394403 const runningOps = readyOps.map(async op => {
395404 op.status = 'running'
396396- const result = await executeOperation(op, otp)
405405+ const result = await executeOperation(op, { otp, interactive, openUrls })
397406 op.result = result
407407+ op.authUrl = undefined
398408 op.status = result.exitCode === 0 ? 'completed' : 'failed'
399409400410 if (result.exitCode === 0) {
···408418 otpRequired = true
409419 }
410420421421+ // Collect URLs from this operation's output
422422+ if (result.urls && result.urls.length > 0) {
423423+ allUrls.push(...result.urls)
424424+ }
425425+411426 results.push({ id: op.id, result })
412427 })
413428···417432 // Check if any operation had an auth failure
418433 const authFailure = results.some(r => r.result.authFailure)
419434435435+ const urls = [...new Set(allUrls)]
436436+420437 return {
421438 success: true,
422439 data: {
423440 results,
424441 otpRequired,
425442 authFailure,
443443+ urls: urls.length > 0 ? urls : undefined,
426444 },
427445 } satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']>
428446 })
···725743 return app
726744}
727745728728-async function executeOperation(op: PendingOperation, otp?: string): Promise<NpmExecResult> {
746746+async function executeOperation(
747747+ op: PendingOperation,
748748+ options: { otp?: string; interactive?: boolean; openUrls?: boolean } = {},
749749+): Promise<NpmExecResult> {
729750 const { type, params } = op
730751752752+ // Build exec options that get passed through to execNpm, which
753753+ // internally routes to either execFile or PTY-based execution.
754754+ const execOptions: ExecNpmOptions = {
755755+ otp: options.otp,
756756+ interactive: options.interactive,
757757+ openUrls: options.openUrls,
758758+ onAuthUrl: options.interactive
759759+ ? url => {
760760+ // Set authUrl on the operation so /state exposes it to the
761761+ // frontend while npm is still polling for authentication.
762762+ op.authUrl = url
763763+ }
764764+ : undefined,
765765+ }
766766+767767+ let result: NpmExecResult
768768+731769 switch (type) {
732770 case 'org:add-user':
733733- return orgAddUser(
771771+ case 'org:set-role':
772772+ result = await orgAddUser(
734773 params.org,
735774 params.user,
736775 params.role as 'developer' | 'admin' | 'owner',
737737- otp,
776776+ execOptions,
738777 )
778778+ break
739779 case 'org:rm-user':
740740- return orgRemoveUser(params.org, params.user, otp)
780780+ result = await orgRemoveUser(params.org, params.user, execOptions)
781781+ break
741782 case 'team:create':
742742- return teamCreate(params.scopeTeam, otp)
783783+ result = await teamCreate(params.scopeTeam, execOptions)
784784+ break
743785 case 'team:destroy':
744744- return teamDestroy(params.scopeTeam, otp)
786786+ result = await teamDestroy(params.scopeTeam, execOptions)
787787+ break
745788 case 'team:add-user':
746746- return teamAddUser(params.scopeTeam, params.user, otp)
789789+ result = await teamAddUser(params.scopeTeam, params.user, execOptions)
790790+ break
747791 case 'team:rm-user':
748748- return teamRemoveUser(params.scopeTeam, params.user, otp)
792792+ result = await teamRemoveUser(params.scopeTeam, params.user, execOptions)
793793+ break
749794 case 'access:grant':
750750- return accessGrant(
795795+ result = await accessGrant(
751796 params.permission as 'read-only' | 'read-write',
752797 params.scopeTeam,
753798 params.pkg,
754754- otp,
799799+ execOptions,
755800 )
801801+ break
756802 case 'access:revoke':
757757- return accessRevoke(params.scopeTeam, params.pkg, otp)
803803+ result = await accessRevoke(params.scopeTeam, params.pkg, execOptions)
804804+ break
758805 case 'owner:add':
759759- return ownerAdd(params.user, params.pkg, otp)
806806+ result = await ownerAdd(params.user, params.pkg, execOptions)
807807+ break
760808 case 'owner:rm':
761761- return ownerRemove(params.user, params.pkg, otp)
809809+ result = await ownerRemove(params.user, params.pkg, execOptions)
810810+ break
762811 case 'package:init':
763763- return packageInit(params.name, params.author, otp)
812812+ // package:init has its own special execution path (temp dir + publish)
813813+ // and does not support interactive mode
814814+ result = await packageInit(params.name, params.author, options.otp)
815815+ break
764816 default:
765817 return {
766818 stdout: '',
···768820 exitCode: 1,
769821 }
770822 }
823823+824824+ // Extract URLs from output if not already populated
825825+ if (!result.urls) {
826826+ const urls = extractUrls((result.stdout || '') + '\n' + (result.stderr || ''))
827827+ if (urls.length > 0) result.urls = urls
828828+ }
829829+830830+ return result
771831}
772832773833export { generateToken }