Skip to content

Commit 7e5e224

Browse files
committed
feat(connector): add ability to run commands in interactive mode and catch urls with possible auto opening
1 parent e782462 commit 7e5e224

File tree

7 files changed

+331
-39
lines changed

7 files changed

+331
-39
lines changed

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
},
3434
"dependencies": {
3535
"@clack/prompts": "^1.0.0",
36+
"@lydell/node-pty": "1.2.0-beta.3",
3637
"citty": "^0.2.0",
3738
"h3-next": "npm:h3@^2.0.1-rc.11",
3839
"obug": "^2.1.1",

cli/src/node-pty.d.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// @lydell/node-pty package.json does not export its types so for nodenext target we need to add them (very minimal version)
2+
declare module '@lydell/node-pty' {
3+
interface IPty {
4+
readonly pid: number
5+
readonly onData: (listener: (data: string) => void) => { dispose(): void }
6+
readonly onExit: (listener: (e: { exitCode: number; signal?: number }) => void) => {
7+
dispose(): void
8+
}
9+
write(data: string): void
10+
kill(signal?: string): void
11+
}
12+
13+
export function spawn(
14+
file: string,
15+
args: string[],
16+
options: {
17+
name?: string
18+
cols?: number
19+
rows?: number
20+
env?: Record<string, string | undefined>
21+
},
22+
): IPty
23+
}

cli/src/npm-client.ts

Lines changed: 220 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

7375
function 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 = /https?:\/\/[^\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 = /Enter OTP:/i
133+
// Patterns to detect npm's web auth URL prompt in pty output
134+
const 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
136+
const AUTH_URL_TITLE_RE = /Authenticate your account at:\s*(https?:\/\/\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

240424
export 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

260450
export 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

270460
export 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

280470
export 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

291481
export 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

cli/src/schemas.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,15 @@ export const ConnectBodySchema = v.object({
151151
})
152152

153153
/**
154-
* Schema for /execute request body
154+
* Schema for /execute request body.
155+
* - `otp`: optional 6-digit OTP code for 2FA
156+
* - `interactive`: when true, commands run via a real PTY (node-pty) instead of execFile, so npm's OTP handler can activate.
157+
* - `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
*/
156159
export const ExecuteBodySchema = v.object({
157160
otp: OtpSchema,
161+
interactive: v.optional(v.boolean()),
162+
openUrls: v.optional(v.boolean()),
158163
})
159164

160165
/**

0 commit comments

Comments
 (0)