[READ-ONLY] a fast, modern browser for the npm registry

feat: npmx connector allow web auth (#1355)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Mikołaj Misztal
Daniel Roe
and committed by
GitHub
4db33e93 e43b8cc3

+1111 -94
+20
app/components/Header/ConnectorModal.vue
··· 2 2 const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } = 3 3 useConnector() 4 4 5 + const { settings } = useSettings() 6 + 5 7 const tokenInput = shallowRef('') 6 8 const portInput = shallowRef('31415') 7 9 const { copied, copy } = useClipboard({ copiedDuring: 2000 }) ··· 60 62 </p> 61 63 </div> 62 64 </div> 65 + 66 + <!-- Connector preferences --> 67 + <div class="flex flex-col gap-2"> 68 + <SettingsToggle 69 + :label="$t('connector.modal.auto_open_url')" 70 + v-model="settings.connector.autoOpenURL" 71 + /> 72 + </div> 73 + 74 + <div class="border-t border-border my-3" /> 63 75 64 76 <!-- Operations Queue --> 65 77 <OrgOperationsQueue /> ··· 194 206 class="w-full" 195 207 size="medium" 196 208 /> 209 + 210 + <div class="border-t border-border my-3" /> 211 + <div class="flex flex-col gap-2"> 212 + <SettingsToggle 213 + :label="$t('connector.modal.auto_open_url')" 214 + v-model="settings.connector.autoOpenURL" 215 + /> 216 + </div> 197 217 </div> 198 218 </details> 199 219 </div>
+128 -29
app/components/Org/OperationsQueue.vue
··· 7 7 approvedOperations, 8 8 completedOperations, 9 9 activeOperations, 10 + operations, 10 11 hasOperations, 11 12 hasPendingOperations, 12 13 hasApprovedOperations, ··· 23 24 24 25 const isExecuting = shallowRef(false) 25 26 const otpInput = shallowRef('') 27 + const otpError = shallowRef('') 26 28 27 - /** Check if any active operation needs OTP */ 29 + const authUrl = computed(() => { 30 + const op = operations.value.find(o => o.status === 'running' && o.authUrl) 31 + return op?.authUrl ?? null 32 + }) 33 + 34 + const authPollTimer = shallowRef<ReturnType<typeof setInterval> | null>(null) 35 + 36 + function startAuthPolling() { 37 + stopAuthPolling() 38 + let remaining = 3 39 + authPollTimer.value = setInterval(async () => { 40 + try { 41 + await refreshState() 42 + } catch { 43 + stopAuthPolling() 44 + return 45 + } 46 + remaining-- 47 + if (remaining <= 0) { 48 + stopAuthPolling() 49 + } 50 + }, 20000) 51 + } 52 + 53 + function stopAuthPolling() { 54 + if (authPollTimer.value) { 55 + clearInterval(authPollTimer.value) 56 + authPollTimer.value = null 57 + } 58 + } 59 + 60 + onUnmounted(stopAuthPolling) 61 + 62 + function handleOpenAuthUrl() { 63 + if (authUrl.value) { 64 + window.open(authUrl.value, '_blank', 'noopener,noreferrer') 65 + startAuthPolling() 66 + } 67 + } 68 + 69 + /** Check if any active operation needs OTP (fallback for web auth failures) */ 28 70 const hasOtpFailures = computed(() => 29 71 activeOperations.value.some( 30 - (op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp, 72 + (op: PendingOperation) => 73 + op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure), 31 74 ), 32 75 ) 33 76 ··· 46 89 47 90 /** Retry all OTP-failed operations with the provided OTP */ 48 91 async function handleRetryWithOtp() { 49 - if (!otpInput.value.trim()) return 92 + const otp = otpInput.value.trim() 50 93 51 - const otp = otpInput.value.trim() 94 + if (!otp) { 95 + otpError.value = 'OTP required' 96 + return 97 + } 98 + 99 + if (!/^\d{6}$/.test(otp)) { 100 + otpError.value = 'OTP must be a 6-digit code' 101 + return 102 + } 103 + 104 + otpError.value = '' 52 105 otpInput.value = '' 53 106 54 - // First, re-approve all OTP-failed operations 107 + // First, re-approve all OTP/auth-failed operations 55 108 const otpFailedOps = activeOperations.value.filter( 56 - (op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp, 109 + (op: PendingOperation) => 110 + op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure), 57 111 ) 58 112 for (const op of otpFailedOps) { 59 113 await retryOperation(op.id) ··· 63 117 await handleExecute(otp) 64 118 } 65 119 120 + /** Retry failed operations with web auth (no OTP) */ 121 + async function handleRetryWebAuth() { 122 + // Find all failed operations that need auth retry 123 + const failedOps = activeOperations.value.filter( 124 + (op: PendingOperation) => 125 + op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure), 126 + ) 127 + 128 + for (const op of failedOps) { 129 + await retryOperation(op.id) 130 + } 131 + 132 + await handleExecute() 133 + } 134 + 66 135 async function handleClearAll() { 67 136 await clearOperations() 68 137 otpInput.value = '' 138 + otpError.value = '' 69 139 } 70 140 71 141 function getStatusColor(status: string): string { ··· 228 298 </li> 229 299 </ul> 230 300 231 - <!-- Inline OTP prompt (appears when operations need OTP) --> 301 + <!-- Inline OTP prompt (appears when web auth fails and OTP is needed as fallback) --> 232 302 <div 233 303 v-if="hasOtpFailures" 234 304 class="p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg" ··· 240 310 {{ $t('operations.queue.otp_prompt') }} 241 311 </span> 242 312 </div> 243 - <form class="flex items-center gap-2" @submit.prevent="handleRetryWithOtp"> 244 - <label for="otp-input" class="sr-only">{{ $t('operations.queue.otp_label') }}</label> 245 - <InputBase 246 - id="otp-input" 247 - v-model="otpInput" 248 - type="text" 249 - name="otp-code" 250 - inputmode="numeric" 251 - pattern="[0-9]*" 252 - :placeholder="$t('operations.queue.otp_placeholder')" 253 - autocomplete="one-time-code" 254 - spellcheck="false" 255 - class="flex-1 min-w-25" 256 - size="small" 257 - /> 258 - <button 259 - type="submit" 260 - :disabled="!otpInput.trim() || isExecuting" 261 - class="px-3 py-2 font-mono text-xs text-bg bg-amber-500 rounded transition-all duration-200 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50" 262 - > 263 - {{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }} 264 - </button> 313 + <form class="flex flex-col gap-1" @submit.prevent="handleRetryWithOtp"> 314 + <div class="flex items-center gap-2"> 315 + <label for="otp-input" class="sr-only">{{ $t('operations.queue.otp_label') }}</label> 316 + <InputBase 317 + id="otp-input" 318 + v-model="otpInput" 319 + type="text" 320 + name="otp-code" 321 + inputmode="numeric" 322 + pattern="[0-9]*" 323 + maxlength="6" 324 + :placeholder="$t('operations.queue.otp_placeholder')" 325 + autocomplete="one-time-code" 326 + spellcheck="false" 327 + :class="['flex-1 min-w-25', otpError ? 'border-red-500 focus:outline-red-500' : '']" 328 + size="small" 329 + @input="otpError = ''" 330 + /> 331 + <button 332 + type="submit" 333 + :disabled="isExecuting" 334 + class="px-3 py-2 font-mono text-xs text-bg bg-amber-500 rounded transition-all duration-200 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50" 335 + > 336 + {{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }} 337 + </button> 338 + </div> 339 + <p v-if="otpError" class="text-xs text-red-400 font-mono"> 340 + {{ otpError }} 341 + </p> 265 342 </form> 343 + <div class="flex items-center gap-2 my-3"> 344 + <div class="flex-1 h-px bg-amber-500/30" /> 345 + <span class="text-xs text-amber-400 font-mono uppercase">{{ $t('common.or') }}</span> 346 + <div class="flex-1 h-px bg-amber-500/30" /> 347 + </div> 348 + <button 349 + type="button" 350 + :disabled="isExecuting" 351 + class="w-full px-3 py-2 font-mono text-xs text-fg bg-bg-subtle border border-border rounded transition-all duration-200 hover:text-fg hover:border-border-hover disabled:opacity-50 disabled:cursor-not-allowed" 352 + @click="handleRetryWebAuth" 353 + > 354 + {{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_web_auth') }} 355 + </button> 266 356 </div> 267 357 268 358 <!-- Action buttons --> ··· 287 377 ? $t('operations.queue.executing') 288 378 : `${$t('operations.queue.execute')} (${approvedOperations.length})` 289 379 }} 380 + </button> 381 + <button 382 + v-if="authUrl" 383 + type="button" 384 + class="flex-1 px-4 py-2 font-mono text-sm text-accent bg-accent/10 border border-accent/30 rounded-md transition-colors duration-200 hover:bg-accent/20" 385 + @click="handleOpenAuthUrl" 386 + > 387 + <span class="i-carbon:launch w-4 h-4 inline-block me-1" aria-hidden="true" /> 388 + {{ $t('operations.queue.open_web_auth') }} 290 389 </button> 291 390 </div> 292 391
+13 -5
app/composables/useConnector.ts
··· 57 57 const DEFAULT_PORT = 31415 58 58 59 59 export const useConnector = createSharedComposable(function useConnector() { 60 + const { settings } = useSettings() 61 + 60 62 // Persisted connection config 61 63 const config = useState<{ token: string; port: number } | null>('connector-config', () => null) 62 64 ··· 303 305 ApiResponse<{ results: unknown[]; otpRequired?: boolean }> 304 306 >('/execute', { 305 307 method: 'POST', 306 - body: otp ? { otp } : undefined, 308 + body: { 309 + otp, 310 + interactive: !otp, 311 + openUrls: settings.value.connector.autoOpenURL, 312 + }, 307 313 }) 308 314 if (response?.success) { 309 315 await refreshState() ··· 371 377 const approvedOperations = computed(() => 372 378 state.value.operations.filter(op => op.status === 'approved'), 373 379 ) 374 - /** Operations that are done (completed, or failed without needing OTP retry) */ 380 + /** Operations that are done (completed, or failed without needing OTP/auth retry) */ 375 381 const completedOperations = computed(() => 376 382 state.value.operations.filter( 377 - op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp), 383 + op => 384 + op.status === 'completed' || 385 + (op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure), 378 386 ), 379 387 ) 380 - /** Operations that are still active (pending, approved, running, or failed needing OTP retry) */ 388 + /** Operations that are still active (pending, approved, running, or failed needing OTP/auth retry) */ 381 389 const activeOperations = computed(() => 382 390 state.value.operations.filter( 383 391 op => 384 392 op.status === 'pending' || 385 393 op.status === 'approved' || 386 394 op.status === 'running' || 387 - (op.status === 'failed' && op.result?.requiresOtp), 395 + (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)), 388 396 ), 389 397 ) 390 398 const hasOperations = computed(() => state.value.operations.length > 0)
+8
app/composables/useSettings.ts
··· 29 29 selectedLocale: LocaleObject['code'] | null 30 30 /** Search provider for package search */ 31 31 searchProvider: SearchProvider 32 + /** Connector preferences */ 33 + connector: { 34 + /** Automatically open the web auth page in the browser */ 35 + autoOpenURL: boolean 36 + } 32 37 sidebar: { 33 38 collapsed: string[] 34 39 } ··· 42 47 selectedLocale: null, 43 48 preferredBackgroundTheme: null, 44 49 searchProvider: import.meta.test ? 'npm' : 'algolia', 50 + connector: { 51 + autoOpenURL: false, 52 + }, 45 53 sidebar: { 46 54 collapsed: [], 47 55 },
+1
cli/package.json
··· 31 31 }, 32 32 "dependencies": { 33 33 "@clack/prompts": "^1.0.0", 34 + "@lydell/node-pty": "1.2.0-beta.3", 34 35 "citty": "^0.2.0", 35 36 "h3-next": "npm:h3@^2.0.1-rc.11", 36 37 "obug": "^2.1.1",
+8 -3
cli/src/mock-app.ts
··· 230 230 requireAuth(event) 231 231 232 232 const body = await event.req.json().catch(() => ({})) 233 - const otp = (body as { otp?: string })?.otp 233 + const { otp } = body as { otp?: string; interactive?: boolean; openUrls?: boolean } 234 234 235 - const { results, otpRequired } = stateManager.executeOperations({ otp }) 235 + const { results, otpRequired, authFailure, urls } = stateManager.executeOperations({ otp }) 236 236 237 237 return { 238 238 success: true, 239 - data: { results, otpRequired }, 239 + data: { 240 + results, 241 + otpRequired, 242 + authFailure, 243 + urls, 244 + }, 240 245 } satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']> 241 246 }) 242 247
+12 -1
cli/src/mock-state.ts
··· 58 58 export interface ExecuteResult { 59 59 results: Array<{ id: string; result: OperationResult }> 60 60 otpRequired?: boolean 61 + authFailure?: boolean 62 + urls?: string[] 61 63 } 62 64 63 65 export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData { ··· 281 283 exitCode: configuredResult.exitCode ?? 1, 282 284 requiresOtp: configuredResult.requiresOtp, 283 285 authFailure: configuredResult.authFailure, 286 + urls: configuredResult.urls, 284 287 } 285 288 op.result = result 286 289 op.status = result.exitCode === 0 ? 'completed' : 'failed' ··· 305 308 } 306 309 } 307 310 308 - return { results } 311 + const authFailure = results.some(r => r.result.authFailure) 312 + const allUrls = results.flatMap(r => r.result.urls ?? []) 313 + const urls = [...new Set(allUrls)] 314 + 315 + return { 316 + results, 317 + authFailure: authFailure || undefined, 318 + urls: urls.length > 0 ? urls : undefined, 319 + } 309 320 } 310 321 311 322 /** Apply side effects of a completed operation. Param keys match schemas.ts. */
+53
cli/src/node-pty.d.ts
··· 1 + // @lydell/node-pty package.json does not export its types so for nodenext target we need to add them 2 + declare module '@lydell/node-pty' { 3 + export function spawn( 4 + file: string, 5 + args: string[] | string, 6 + options: IPtyForkOptions | IWindowsPtyForkOptions, 7 + ): IPty 8 + export interface IBasePtyForkOptions { 9 + name?: string 10 + cols?: number 11 + rows?: number 12 + cwd?: string 13 + env?: { [key: string]: string | undefined } 14 + encoding?: string | null 15 + handleFlowControl?: boolean 16 + flowControlPause?: string 17 + flowControlResume?: string 18 + } 19 + 20 + export interface IPtyForkOptions extends IBasePtyForkOptions { 21 + uid?: number 22 + gid?: number 23 + } 24 + 25 + export interface IWindowsPtyForkOptions extends IBasePtyForkOptions { 26 + useConpty?: boolean 27 + useConptyDll?: boolean 28 + conptyInheritCursor?: boolean 29 + } 30 + 31 + export interface IPty { 32 + readonly pid: number 33 + readonly cols: number 34 + readonly rows: number 35 + readonly process: string 36 + handleFlowControl: boolean 37 + readonly onData: IEvent<string> 38 + readonly onExit: IEvent<{ exitCode: number; signal?: number }> 39 + resize(columns: number, rows: number): void 40 + clear(): void 41 + write(data: string | Buffer): void 42 + kill(signal?: string): void 43 + pause(): void 44 + resume(): void 45 + } 46 + 47 + export interface IDisposable { 48 + dispose(): void 49 + } 50 + export interface IEvent<T> { 51 + (listener: (e: T) => any): IDisposable 52 + } 53 + }
+228 -22
cli/src/npm-client.ts
··· 68 68 requiresOtp?: boolean 69 69 /** True if the operation failed due to authentication failure (not logged in or token expired) */ 70 70 authFailure?: boolean 71 + /** URLs detected in the command output (stdout + stderr) */ 72 + urls?: string[] 71 73 } 72 74 73 75 function detectOtpRequired(stderr: string): boolean { ··· 116 118 .trim() 117 119 } 118 120 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 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 + */ 167 + async function execNpmInteractive( 120 168 args: string[], 121 - options: { otp?: string; silent?: boolean } = {}, 169 + options: ExecNpmOptions = {}, 122 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 + 310 + async function execNpm(args: string[], options: ExecNpmOptions = {}): Promise<NpmExecResult> { 311 + if (options.interactive) { 312 + return execNpmInteractive(args, options) 313 + } 314 + 123 315 // Build the full args array including OTP if provided 124 316 const npmArgs = options.otp ? [...args, '--otp', options.otp] : args 125 317 ··· 230 422 org: string, 231 423 user: string, 232 424 role: 'developer' | 'admin' | 'owner', 233 - otp?: string, 425 + options?: ExecNpmOptions, 234 426 ): Promise<NpmExecResult> { 235 427 validateOrgName(org) 236 428 validateUsername(user) 237 - return execNpm(['org', 'set', org, user, role], { otp }) 429 + return execNpm(['org', 'set', org, user, role], options) 238 430 } 239 431 240 432 export async function orgRemoveUser( 241 433 org: string, 242 434 user: string, 243 - otp?: string, 435 + options?: ExecNpmOptions, 244 436 ): Promise<NpmExecResult> { 245 437 validateOrgName(org) 246 438 validateUsername(user) 247 - return execNpm(['org', 'rm', org, user], { otp }) 439 + return execNpm(['org', 'rm', org, user], options) 248 440 } 249 441 250 - export async function teamCreate(scopeTeam: string, otp?: string): Promise<NpmExecResult> { 442 + export async function teamCreate( 443 + scopeTeam: string, 444 + options?: ExecNpmOptions, 445 + ): Promise<NpmExecResult> { 251 446 validateScopeTeam(scopeTeam) 252 - return execNpm(['team', 'create', scopeTeam], { otp }) 447 + return execNpm(['team', 'create', scopeTeam], options) 253 448 } 254 449 255 - export async function teamDestroy(scopeTeam: string, otp?: string): Promise<NpmExecResult> { 450 + export async function teamDestroy( 451 + scopeTeam: string, 452 + options?: ExecNpmOptions, 453 + ): Promise<NpmExecResult> { 256 454 validateScopeTeam(scopeTeam) 257 - return execNpm(['team', 'destroy', scopeTeam], { otp }) 455 + return execNpm(['team', 'destroy', scopeTeam], options) 258 456 } 259 457 260 458 export async function teamAddUser( 261 459 scopeTeam: string, 262 460 user: string, 263 - otp?: string, 461 + options?: ExecNpmOptions, 264 462 ): Promise<NpmExecResult> { 265 463 validateScopeTeam(scopeTeam) 266 464 validateUsername(user) 267 - return execNpm(['team', 'add', scopeTeam, user], { otp }) 465 + return execNpm(['team', 'add', scopeTeam, user], options) 268 466 } 269 467 270 468 export async function teamRemoveUser( 271 469 scopeTeam: string, 272 470 user: string, 273 - otp?: string, 471 + options?: ExecNpmOptions, 274 472 ): Promise<NpmExecResult> { 275 473 validateScopeTeam(scopeTeam) 276 474 validateUsername(user) 277 - return execNpm(['team', 'rm', scopeTeam, user], { otp }) 475 + return execNpm(['team', 'rm', scopeTeam, user], options) 278 476 } 279 477 280 478 export async function accessGrant( 281 479 permission: 'read-only' | 'read-write', 282 480 scopeTeam: string, 283 481 pkg: string, 284 - otp?: string, 482 + options?: ExecNpmOptions, 285 483 ): Promise<NpmExecResult> { 286 484 validateScopeTeam(scopeTeam) 287 485 validatePackageName(pkg) 288 - return execNpm(['access', 'grant', permission, scopeTeam, pkg], { otp }) 486 + return execNpm(['access', 'grant', permission, scopeTeam, pkg], options) 289 487 } 290 488 291 489 export async function accessRevoke( 292 490 scopeTeam: string, 293 491 pkg: string, 294 - otp?: string, 492 + options?: ExecNpmOptions, 295 493 ): Promise<NpmExecResult> { 296 494 validateScopeTeam(scopeTeam) 297 495 validatePackageName(pkg) 298 - return execNpm(['access', 'revoke', scopeTeam, pkg], { otp }) 496 + return execNpm(['access', 'revoke', scopeTeam, pkg], options) 299 497 } 300 498 301 - export async function ownerAdd(user: string, pkg: string, otp?: string): Promise<NpmExecResult> { 499 + export async function ownerAdd( 500 + user: string, 501 + pkg: string, 502 + options?: ExecNpmOptions, 503 + ): Promise<NpmExecResult> { 302 504 validateUsername(user) 303 505 validatePackageName(pkg) 304 - return execNpm(['owner', 'add', user, pkg], { otp }) 506 + return execNpm(['owner', 'add', user, pkg], options) 305 507 } 306 508 307 - export async function ownerRemove(user: string, pkg: string, otp?: string): Promise<NpmExecResult> { 509 + export async function ownerRemove( 510 + user: string, 511 + pkg: string, 512 + options?: ExecNpmOptions, 513 + ): Promise<NpmExecResult> { 308 514 validateUsername(user) 309 515 validatePackageName(pkg) 310 - return execNpm(['owner', 'rm', user, pkg], { otp }) 516 + return execNpm(['owner', 'rm', user, pkg], options) 311 517 } 312 518 313 519 // List functions (for reading data) - silent since they're not user-triggered operations
+6 -1
cli/src/schemas.ts
··· 151 151 }) 152 152 153 153 /** 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 155 158 */ 156 159 export const ExecuteBodySchema = v.object({ 157 160 otp: OtpSchema, 161 + interactive: v.optional(v.boolean()), 162 + openUrls: v.optional(v.boolean()), 158 163 }) 159 164 160 165 /**
+76 -16
cli/src/server.ts
··· 51 51 ownerRemove, 52 52 packageInit, 53 53 listUserPackages, 54 + extractUrls, 55 + type ExecNpmOptions, 54 56 type NpmExecResult, 55 57 } from './npm-client.ts' 56 58 import { ··· 335 337 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) 336 338 } 337 339 338 - // OTP can be passed directly in the request body for this execution 340 + // OTP, interactive flag, and openUrls can be passed in the request body 339 341 let otp: string | undefined 342 + let interactive = false 343 + let openUrls = false 340 344 try { 341 345 const rawBody = await event.req.json() 342 346 if (rawBody) { ··· 345 349 throw new HTTPError({ statusCode: 400, message: parsed.error }) 346 350 } 347 351 otp = parsed.data.otp 352 + interactive = parsed.data.interactive ?? false 353 + openUrls = parsed.data.openUrls ?? false 348 354 } 349 355 } catch (err) { 350 356 // Re-throw HTTPError, ignore JSON parse errors (empty body is fine) ··· 356 362 let otpRequired = false 357 363 const completedIds = new Set<string>() 358 364 const failedIds = new Set<string>() 365 + 366 + // Collect all URLs across all operations in this execution batch 367 + const allUrls: string[] = [] 359 368 360 369 // Execute operations in waves, respecting dependencies 361 370 // Each wave contains operations whose dependencies are satisfied ··· 393 402 // Execute ready operations in parallel 394 403 const runningOps = readyOps.map(async op => { 395 404 op.status = 'running' 396 - const result = await executeOperation(op, otp) 405 + const result = await executeOperation(op, { otp, interactive, openUrls }) 397 406 op.result = result 407 + op.authUrl = undefined 398 408 op.status = result.exitCode === 0 ? 'completed' : 'failed' 399 409 400 410 if (result.exitCode === 0) { ··· 408 418 otpRequired = true 409 419 } 410 420 421 + // Collect URLs from this operation's output 422 + if (result.urls && result.urls.length > 0) { 423 + allUrls.push(...result.urls) 424 + } 425 + 411 426 results.push({ id: op.id, result }) 412 427 }) 413 428 ··· 417 432 // Check if any operation had an auth failure 418 433 const authFailure = results.some(r => r.result.authFailure) 419 434 435 + const urls = [...new Set(allUrls)] 436 + 420 437 return { 421 438 success: true, 422 439 data: { 423 440 results, 424 441 otpRequired, 425 442 authFailure, 443 + urls: urls.length > 0 ? urls : undefined, 426 444 }, 427 445 } satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']> 428 446 }) ··· 725 743 return app 726 744 } 727 745 728 - async function executeOperation(op: PendingOperation, otp?: string): Promise<NpmExecResult> { 746 + async function executeOperation( 747 + op: PendingOperation, 748 + options: { otp?: string; interactive?: boolean; openUrls?: boolean } = {}, 749 + ): Promise<NpmExecResult> { 729 750 const { type, params } = op 730 751 752 + // Build exec options that get passed through to execNpm, which 753 + // internally routes to either execFile or PTY-based execution. 754 + const execOptions: ExecNpmOptions = { 755 + otp: options.otp, 756 + interactive: options.interactive, 757 + openUrls: options.openUrls, 758 + onAuthUrl: options.interactive 759 + ? url => { 760 + // Set authUrl on the operation so /state exposes it to the 761 + // frontend while npm is still polling for authentication. 762 + op.authUrl = url 763 + } 764 + : undefined, 765 + } 766 + 767 + let result: NpmExecResult 768 + 731 769 switch (type) { 732 770 case 'org:add-user': 733 - return orgAddUser( 771 + case 'org:set-role': 772 + result = await orgAddUser( 734 773 params.org, 735 774 params.user, 736 775 params.role as 'developer' | 'admin' | 'owner', 737 - otp, 776 + execOptions, 738 777 ) 778 + break 739 779 case 'org:rm-user': 740 - return orgRemoveUser(params.org, params.user, otp) 780 + result = await orgRemoveUser(params.org, params.user, execOptions) 781 + break 741 782 case 'team:create': 742 - return teamCreate(params.scopeTeam, otp) 783 + result = await teamCreate(params.scopeTeam, execOptions) 784 + break 743 785 case 'team:destroy': 744 - return teamDestroy(params.scopeTeam, otp) 786 + result = await teamDestroy(params.scopeTeam, execOptions) 787 + break 745 788 case 'team:add-user': 746 - return teamAddUser(params.scopeTeam, params.user, otp) 789 + result = await teamAddUser(params.scopeTeam, params.user, execOptions) 790 + break 747 791 case 'team:rm-user': 748 - return teamRemoveUser(params.scopeTeam, params.user, otp) 792 + result = await teamRemoveUser(params.scopeTeam, params.user, execOptions) 793 + break 749 794 case 'access:grant': 750 - return accessGrant( 795 + result = await accessGrant( 751 796 params.permission as 'read-only' | 'read-write', 752 797 params.scopeTeam, 753 798 params.pkg, 754 - otp, 799 + execOptions, 755 800 ) 801 + break 756 802 case 'access:revoke': 757 - return accessRevoke(params.scopeTeam, params.pkg, otp) 803 + result = await accessRevoke(params.scopeTeam, params.pkg, execOptions) 804 + break 758 805 case 'owner:add': 759 - return ownerAdd(params.user, params.pkg, otp) 806 + result = await ownerAdd(params.user, params.pkg, execOptions) 807 + break 760 808 case 'owner:rm': 761 - return ownerRemove(params.user, params.pkg, otp) 809 + result = await ownerRemove(params.user, params.pkg, execOptions) 810 + break 762 811 case 'package:init': 763 - return packageInit(params.name, params.author, otp) 812 + // package:init has its own special execution path (temp dir + publish) 813 + // and does not support interactive mode 814 + result = await packageInit(params.name, params.author, options.otp) 815 + break 764 816 default: 765 817 return { 766 818 stdout: '', ··· 768 820 exitCode: 1, 769 821 } 770 822 } 823 + 824 + // Extract URLs from output if not already populated 825 + if (!result.urls) { 826 + const urls = extractUrls((result.stdout || '') + '\n' + (result.stderr || '')) 827 + if (urls.length > 0) result.urls = urls 828 + } 829 + 830 + return result 771 831 } 772 832 773 833 export { generateToken }
+11 -1
cli/src/types.ts
··· 1 + import './node-pty.d.ts' 2 + 1 3 export interface ConnectorConfig { 2 4 port: number 3 5 host: string ··· 41 43 requiresOtp?: boolean 42 44 /** True if the operation failed due to authentication failure (not logged in or token expired) */ 43 45 authFailure?: boolean 46 + /** URLs detected in the command output (stdout + stderr) */ 47 + urls?: string[] 44 48 } 45 49 46 50 export interface PendingOperation { ··· 54 58 result?: OperationResult 55 59 /** ID of operation this depends on (must complete successfully first) */ 56 60 dependsOn?: string 61 + /** Auth URL detected during interactive execution (set while operation is still running) */ 62 + authUrl?: string 57 63 } 58 64 59 65 export interface ConnectorState { ··· 92 98 results: Array<{ id: string; result: OperationResult }> 93 99 otpRequired?: boolean 94 100 authFailure?: boolean 101 + urls?: string[] 95 102 } 96 103 97 104 /** POST /approve-all response data */ ··· 127 134 'POST /approve': { body: never; data: PendingOperation } 128 135 'POST /approve-all': { body: never; data: ApproveAllResponseData } 129 136 'POST /retry': { body: never; data: PendingOperation } 130 - 'POST /execute': { body: { otp?: string }; data: ExecuteResponseData } 137 + 'POST /execute': { 138 + body: { otp?: string; interactive?: boolean; openUrls?: boolean } 139 + data: ExecuteResponseData 140 + } 131 141 'GET /org/:org/users': { body: never; data: Record<string, OrgRole> } 132 142 'GET /org/:org/teams': { body: never; data: string[] } 133 143 'GET /team/:scopeTeam/users': { body: never; data: string[] }
+1
cli/tsdown.config.ts
··· 6 6 clean: true, 7 7 dts: true, 8 8 outDir: 'dist', 9 + external: ['@lydell/node-pty'], 9 10 })
+5 -1
i18n/locales/en.json
··· 125 125 "end_of_results": "End of results", 126 126 "try_again": "Try again", 127 127 "close": "Close", 128 + "or": "or", 128 129 "retry": "Retry", 129 130 "copy": "copy", 130 131 "copied": "copied!", ··· 471 472 "warning": "WARNING", 472 473 "warning_text": "This allows npmx to access your npm CLI. Only connect to sites you trust.", 473 474 "connect": "Connect", 474 - "connecting": "Connecting..." 475 + "connecting": "Connecting...", 476 + "auto_open_url": "Automatically open auth page" 475 477 } 476 478 }, 477 479 "operations": { ··· 487 489 "otp_placeholder": "Enter OTP code...", 488 490 "otp_label": "One-time password", 489 491 "retry_otp": "Retry with OTP", 492 + "retry_web_auth": "Retry with web auth", 490 493 "retrying": "Retrying...", 494 + "open_web_auth": "Open web auth link", 491 495 "approve_operation": "Approve operation", 492 496 "remove_operation": "Remove operation", 493 497 "approve_all": "Approve All",
+5 -1
i18n/locales/pl-PL.json
··· 125 125 "end_of_results": "Koniec wyników", 126 126 "try_again": "Spróbuj ponownie", 127 127 "close": "Zamknij", 128 + "or": "lub", 128 129 "retry": "Ponów", 129 130 "copy": "kopiuj", 130 131 "copied": "skopiowano!", ··· 461 462 "warning": "OSTRZEŻENIE", 462 463 "warning_text": "To pozwala npmx uzyskać dostęp do twojego npm CLI. Łącz się tylko ze stronami, którym ufasz.", 463 464 "connect": "Połącz", 464 - "connecting": "Łączenie..." 465 + "connecting": "Łączenie...", 466 + "auto_open_url": "Automatycznie otwórz stronę z autoryzacją" 465 467 } 466 468 }, 467 469 "operations": { ··· 477 479 "otp_placeholder": "Wprowadź kod OTP...", 478 480 "otp_label": "Hasło jednorazowe", 479 481 "retry_otp": "Ponów z OTP", 482 + "retry_web_auth": "Ponów z autoryzacją w przeglądarce", 480 483 "retrying": "Ponawianie...", 484 + "open_web_auth": "Otwórz stronę z autoryzacją", 481 485 "approve_operation": "Zatwierdź operację", 482 486 "remove_operation": "Usuń operację", 483 487 "approve_all": "Zatwierdź wszystkie",
+12
i18n/schema.json
··· 379 379 "close": { 380 380 "type": "string" 381 381 }, 382 + "or": { 383 + "type": "string" 384 + }, 382 385 "retry": { 383 386 "type": "string" 384 387 }, ··· 1419 1422 }, 1420 1423 "connecting": { 1421 1424 "type": "string" 1425 + }, 1426 + "auto_open_url": { 1427 + "type": "string" 1422 1428 } 1423 1429 }, 1424 1430 "additionalProperties": false ··· 1465 1471 "retry_otp": { 1466 1472 "type": "string" 1467 1473 }, 1474 + "retry_web_auth": { 1475 + "type": "string" 1476 + }, 1468 1477 "retrying": { 1478 + "type": "string" 1479 + }, 1480 + "open_web_auth": { 1469 1481 "type": "string" 1470 1482 }, 1471 1483 "approve_operation": {
+5 -1
lunaria/files/en-GB.json
··· 124 124 "end_of_results": "End of results", 125 125 "try_again": "Try again", 126 126 "close": "Close", 127 + "or": "or", 127 128 "retry": "Retry", 128 129 "copy": "copy", 129 130 "copied": "copied!", ··· 470 471 "warning": "WARNING", 471 472 "warning_text": "This allows npmx to access your npm CLI. Only connect to sites you trust.", 472 473 "connect": "Connect", 473 - "connecting": "Connecting..." 474 + "connecting": "Connecting...", 475 + "auto_open_url": "Automatically open auth page" 474 476 } 475 477 }, 476 478 "operations": { ··· 486 488 "otp_placeholder": "Enter OTP code...", 487 489 "otp_label": "One-time password", 488 490 "retry_otp": "Retry with OTP", 491 + "retry_web_auth": "Retry with web auth", 489 492 "retrying": "Retrying...", 493 + "open_web_auth": "Open web auth link", 490 494 "approve_operation": "Approve operation", 491 495 "remove_operation": "Remove operation", 492 496 "approve_all": "Approve All",
+5 -1
lunaria/files/en-US.json
··· 124 124 "end_of_results": "End of results", 125 125 "try_again": "Try again", 126 126 "close": "Close", 127 + "or": "or", 127 128 "retry": "Retry", 128 129 "copy": "copy", 129 130 "copied": "copied!", ··· 470 471 "warning": "WARNING", 471 472 "warning_text": "This allows npmx to access your npm CLI. Only connect to sites you trust.", 472 473 "connect": "Connect", 473 - "connecting": "Connecting..." 474 + "connecting": "Connecting...", 475 + "auto_open_url": "Automatically open auth page" 474 476 } 475 477 }, 476 478 "operations": { ··· 486 488 "otp_placeholder": "Enter OTP code...", 487 489 "otp_label": "One-time password", 488 490 "retry_otp": "Retry with OTP", 491 + "retry_web_auth": "Retry with web auth", 489 492 "retrying": "Retrying...", 493 + "open_web_auth": "Open web auth link", 490 494 "approve_operation": "Approve operation", 491 495 "remove_operation": "Remove operation", 492 496 "approve_all": "Approve All",
+5 -1
lunaria/files/pl-PL.json
··· 124 124 "end_of_results": "Koniec wyników", 125 125 "try_again": "Spróbuj ponownie", 126 126 "close": "Zamknij", 127 + "or": "lub", 127 128 "retry": "Ponów", 128 129 "copy": "kopiuj", 129 130 "copied": "skopiowano!", ··· 460 461 "warning": "OSTRZEŻENIE", 461 462 "warning_text": "To pozwala npmx uzyskać dostęp do twojego npm CLI. Łącz się tylko ze stronami, którym ufasz.", 462 463 "connect": "Połącz", 463 - "connecting": "Łączenie..." 464 + "connecting": "Łączenie...", 465 + "auto_open_url": "Automatycznie otwórz stronę z autoryzacją" 464 466 } 465 467 }, 466 468 "operations": { ··· 476 478 "otp_placeholder": "Wprowadź kod OTP...", 477 479 "otp_label": "Hasło jednorazowe", 478 480 "retry_otp": "Ponów z OTP", 481 + "retry_web_auth": "Ponów z autoryzacją w przeglądarce", 479 482 "retrying": "Ponawianie...", 483 + "open_web_auth": "Otwórz stronę z autoryzacją", 480 484 "approve_operation": "Zatwierdź operację", 481 485 "remove_operation": "Usuń operację", 482 486 "approve_all": "Zatwierdź wszystkie",
+63
pnpm-lock.yaml
··· 291 291 '@clack/prompts': 292 292 specifier: ^1.0.0 293 293 version: 1.0.0 294 + '@lydell/node-pty': 295 + specifier: 1.2.0-beta.3 296 + version: 1.2.0-beta.3 294 297 citty: 295 298 specifier: ^0.2.0 296 299 version: 0.2.0 ··· 1882 1885 resolution: {tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3} 1883 1886 version: 0.1.1 1884 1887 engines: {node: '>=18.17.0'} 1888 + 1889 + '@lydell/node-pty-darwin-arm64@1.2.0-beta.3': 1890 + resolution: {integrity: sha512-owcv+e1/OSu3bf9ZBdUQqJsQF888KyuSIiPYFNn0fLhgkhm9F3Pvha76Kj5mCPnodf7hh3suDe7upw7GPRXftQ==} 1891 + cpu: [arm64] 1892 + os: [darwin] 1893 + 1894 + '@lydell/node-pty-darwin-x64@1.2.0-beta.3': 1895 + resolution: {integrity: sha512-k38O+UviWrWdxtqZBBc/D8NJU11Rey8Y2YMwSWNxLv3eXZZdF5IVpbBkI/2RmLsV5nCcciqLPbukxeZnEfPlwA==} 1896 + cpu: [x64] 1897 + os: [darwin] 1898 + 1899 + '@lydell/node-pty-linux-arm64@1.2.0-beta.3': 1900 + resolution: {integrity: sha512-HUwRpGu3O+4sv9DAQFKnyW5LYhyYu2SDUa/bdFO/t4dIFCM4uDJEq47wfRM7+aYtJTi1b3lakN8SlWeuFQqJQQ==} 1901 + cpu: [arm64] 1902 + os: [linux] 1903 + 1904 + '@lydell/node-pty-linux-x64@1.2.0-beta.3': 1905 + resolution: {integrity: sha512-+RRY0PoCUeQaCvPR7/UnkGbxulwbFtoTWJfe+o4T1RcNtngrgaI55I9nl8CD8uqhGrB3smKuyvPM5UtwGhASUw==} 1906 + cpu: [x64] 1907 + os: [linux] 1908 + 1909 + '@lydell/node-pty-win32-arm64@1.2.0-beta.3': 1910 + resolution: {integrity: sha512-UEDd9ASp2M3iIYpIzfmfBlpyn4+K1G4CAjYcHWStptCkefoSVXWTiUBIa1KjBjZi3/xmsHIDpBEYTkGWuvLt2Q==} 1911 + cpu: [arm64] 1912 + os: [win32] 1913 + 1914 + '@lydell/node-pty-win32-x64@1.2.0-beta.3': 1915 + resolution: {integrity: sha512-TpdqSFYx7/Rj+68tuP6F/lkRYrHCYAIJgaS1bx3SctTkb5QAQCFwOKHd4xlsivmEOMT2LdhkJggPxwX9PAO5pQ==} 1916 + cpu: [x64] 1917 + os: [win32] 1918 + 1919 + '@lydell/node-pty@1.2.0-beta.3': 1920 + resolution: {integrity: sha512-ngGAItlRhmJXrhspxt8kX13n1dVFqzETOq0m/+gqSkO8NJBvNMwP7FZckMwps2UFySdr4yxCXNGu/bumg5at6A==} 1885 1921 1886 1922 '@mapbox/node-pre-gyp@2.0.3': 1887 1923 resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} ··· 11431 11467 zod: 3.25.76 11432 11468 transitivePeerDependencies: 11433 11469 - supports-color 11470 + 11471 + '@lydell/node-pty-darwin-arm64@1.2.0-beta.3': 11472 + optional: true 11473 + 11474 + '@lydell/node-pty-darwin-x64@1.2.0-beta.3': 11475 + optional: true 11476 + 11477 + '@lydell/node-pty-linux-arm64@1.2.0-beta.3': 11478 + optional: true 11479 + 11480 + '@lydell/node-pty-linux-x64@1.2.0-beta.3': 11481 + optional: true 11482 + 11483 + '@lydell/node-pty-win32-arm64@1.2.0-beta.3': 11484 + optional: true 11485 + 11486 + '@lydell/node-pty-win32-x64@1.2.0-beta.3': 11487 + optional: true 11488 + 11489 + '@lydell/node-pty@1.2.0-beta.3': 11490 + optionalDependencies: 11491 + '@lydell/node-pty-darwin-arm64': 1.2.0-beta.3 11492 + '@lydell/node-pty-darwin-x64': 1.2.0-beta.3 11493 + '@lydell/node-pty-linux-arm64': 1.2.0-beta.3 11494 + '@lydell/node-pty-linux-x64': 1.2.0-beta.3 11495 + '@lydell/node-pty-win32-arm64': 1.2.0-beta.3 11496 + '@lydell/node-pty-win32-x64': 1.2.0-beta.3 11434 11497 11435 11498 '@mapbox/node-pre-gyp@2.0.3': 11436 11499 dependencies:
+186 -11
test/nuxt/components/HeaderConnectorModal.spec.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 2 - import { mountSuspended } from '@nuxt/test-utils/runtime' 2 + import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' 3 3 import { ref, computed, readonly, nextTick } from 'vue' 4 4 import type { VueWrapper } from '@vue/test-utils' 5 5 import type { PendingOperation } from '../../../cli/src/types' ··· 44 44 op.status === 'pending' || 45 45 op.status === 'approved' || 46 46 op.status === 'running' || 47 - (op.status === 'failed' && op.result?.requiresOtp), 47 + (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)), 48 48 ), 49 49 ), 50 50 hasOperations: computed(() => mockState.value.operations.length > 0), ··· 60 60 op.status === 'pending' || 61 61 op.status === 'approved' || 62 62 op.status === 'running' || 63 - (op.status === 'failed' && op.result?.requiresOtp), 63 + (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)), 64 64 ), 65 65 ), 66 66 hasCompletedOperations: computed(() => 67 67 mockState.value.operations.some( 68 - op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp), 68 + op => 69 + op.status === 'completed' || 70 + (op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure), 69 71 ), 70 72 ), 71 73 connect: vi.fn().mockResolvedValue(true), ··· 98 100 operations: [], 99 101 error: null, 100 102 lastExecutionTime: null, 103 + } 104 + mockSettings.value.connector = { 105 + autoOpenURL: false, 101 106 } 102 107 } 103 108 ··· 107 112 mockState.value.avatar = 'https://example.com/avatar.png' 108 113 } 109 114 110 - // Mock the composables at module level (vi.mock is hoisted) 111 - vi.mock('~/composables/useConnector', () => ({ 112 - useConnector: createMockUseConnector, 113 - })) 115 + const mockSettings = ref({ 116 + relativeDates: false, 117 + includeTypesInInstall: true, 118 + accentColorId: null, 119 + hidePlatformPackages: true, 120 + selectedLocale: null, 121 + preferredBackgroundTheme: null, 122 + searchProvider: 'npm', 123 + connector: { 124 + autoOpenURL: false, 125 + }, 126 + sidebar: { 127 + collapsed: [], 128 + }, 129 + }) 130 + 131 + mockNuxtImport('useConnector', () => { 132 + return createMockUseConnector 133 + }) 114 134 115 - vi.mock('~/composables/useSelectedPackageManager', () => ({ 116 - useSelectedPackageManager: () => ref('npm'), 117 - })) 135 + mockNuxtImport('useSettings', () => { 136 + return () => ({ settings: mockSettings }) 137 + }) 138 + 139 + mockNuxtImport('useSelectedPackageManager', () => { 140 + return () => ref('npm') 141 + }) 118 142 119 143 vi.mock('~/utils/npm', () => ({ 120 144 getExecuteCommand: () => 'npx npmx-connector', ··· 176 200 }) 177 201 178 202 describe('HeaderConnectorModal', () => { 203 + describe('Connector preferences (connected)', () => { 204 + it('shows auto-open URL toggle when connected', async () => { 205 + const dialog = await mountAndOpen('connected') 206 + const labels = Array.from(dialog?.querySelectorAll('label, span') ?? []) 207 + const autoOpenLabel = labels.find(el => el.textContent?.includes('open auth page')) 208 + expect(autoOpenLabel).toBeTruthy() 209 + }) 210 + 211 + it('does not show a web auth toggle (web auth is now always on)', async () => { 212 + const dialog = await mountAndOpen('connected') 213 + const labels = Array.from(dialog?.querySelectorAll('label, span') ?? []) 214 + const webAuthLabel = labels.find(el => el.textContent?.includes('web authentication')) 215 + expect(webAuthLabel).toBeUndefined() 216 + }) 217 + }) 218 + 219 + describe('Auth URL button', () => { 220 + it('does not show auth URL button when no running operations have an authUrl', async () => { 221 + const dialog = await mountAndOpen('connected') 222 + 223 + const buttons = Array.from(dialog?.querySelectorAll('button') ?? []) 224 + const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link')) 225 + expect(authUrlBtn).toBeUndefined() 226 + }) 227 + 228 + it('shows auth URL button when a running operation has an authUrl', async () => { 229 + mockState.value.operations = [ 230 + { 231 + id: '0000000000000001', 232 + type: 'org:add-user', 233 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 234 + description: 'Add alice', 235 + command: 'npm org set myorg alice developer', 236 + status: 'running', 237 + createdAt: Date.now(), 238 + authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123', 239 + }, 240 + ] 241 + const dialog = await mountAndOpen('connected') 242 + 243 + const buttons = Array.from(dialog?.querySelectorAll('button') ?? []) 244 + const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link')) 245 + expect(authUrlBtn).toBeTruthy() 246 + }) 247 + 248 + it('opens auth URL in new tab when button is clicked', async () => { 249 + const mockOpen = vi.fn() 250 + vi.stubGlobal('open', mockOpen) 251 + 252 + mockState.value.operations = [ 253 + { 254 + id: '0000000000000001', 255 + type: 'org:add-user', 256 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 257 + description: 'Add alice', 258 + command: 'npm org set myorg alice developer', 259 + status: 'running', 260 + createdAt: Date.now(), 261 + authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123', 262 + }, 263 + ] 264 + const dialog = await mountAndOpen('connected') 265 + 266 + const buttons = Array.from(dialog?.querySelectorAll('button') ?? []) 267 + const authUrlBtn = buttons.find(b => 268 + b.textContent?.includes('web auth link'), 269 + ) as HTMLButtonElement 270 + authUrlBtn?.click() 271 + await nextTick() 272 + 273 + expect(mockOpen).toHaveBeenCalledWith( 274 + 'https://www.npmjs.com/login?next=/login/cli/abc123', 275 + '_blank', 276 + 'noopener,noreferrer', 277 + ) 278 + 279 + vi.unstubAllGlobals() 280 + // Re-stub navigator.clipboard which was unstubbed 281 + vi.stubGlobal('navigator', { 282 + ...navigator, 283 + clipboard: { 284 + writeText: mockWriteText, 285 + readText: vi.fn().mockResolvedValue(''), 286 + }, 287 + }) 288 + }) 289 + }) 290 + 291 + describe('Operations queue in connected state', () => { 292 + it('renders OTP prompt when operations have OTP failures', async () => { 293 + mockState.value.operations = [ 294 + { 295 + id: '0000000000000001', 296 + type: 'org:add-user', 297 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 298 + description: 'Add alice', 299 + command: 'npm org set myorg alice developer', 300 + status: 'failed', 301 + createdAt: Date.now(), 302 + result: { stdout: '', stderr: 'otp required', exitCode: 1, requiresOtp: true }, 303 + }, 304 + ] 305 + const dialog = await mountAndOpen('connected') 306 + 307 + // The OrgOperationsQueue child should render with the OTP alert 308 + const otpAlert = dialog?.querySelector('[role="alert"]') 309 + expect(otpAlert).not.toBeNull() 310 + expect(dialog?.innerHTML).toContain('otp-input') 311 + }) 312 + 313 + it('does not show retry with web auth when there are no auth failures', async () => { 314 + mockState.value.operations = [ 315 + { 316 + id: '0000000000000001', 317 + type: 'org:add-user', 318 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 319 + description: 'Add alice', 320 + command: 'npm org set myorg alice developer', 321 + status: 'approved', 322 + createdAt: Date.now(), 323 + }, 324 + ] 325 + const dialog = await mountAndOpen('connected') 326 + 327 + const html = dialog?.innerHTML ?? '' 328 + const hasWebAuthButton = 329 + html.includes('Retry with web auth') || html.includes('retry_web_auth') 330 + expect(hasWebAuthButton).toBe(false) 331 + }) 332 + 333 + it('shows OTP alert section for operations with authFailure (not just requiresOtp)', async () => { 334 + mockState.value.operations = [ 335 + { 336 + id: '0000000000000001', 337 + type: 'org:add-user', 338 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 339 + description: 'Add alice', 340 + command: 'npm org set myorg alice developer', 341 + status: 'failed', 342 + createdAt: Date.now(), 343 + result: { stdout: '', stderr: 'auth failed', exitCode: 1, authFailure: true }, 344 + }, 345 + ] 346 + const dialog = await mountAndOpen('connected') 347 + 348 + // The OTP/auth failures section should render for authFailure too 349 + const otpAlert = dialog?.querySelector('[role="alert"]') 350 + expect(otpAlert).not.toBeNull() 351 + }) 352 + }) 353 + 179 354 describe('Disconnected state', () => { 180 355 it('shows connection form when not connected', async () => { 181 356 const dialog = await mountAndOpen()
+162
test/unit/cli/mock-state.spec.ts
··· 1 + import { describe, expect, it, beforeEach } from 'vitest' 2 + import { MockConnectorStateManager, createMockConnectorState } from '../../../cli/src/mock-state.ts' 3 + 4 + function createManager() { 5 + const data = createMockConnectorState({ token: 'test-token', npmUser: 'testuser' }) 6 + return new MockConnectorStateManager(data) 7 + } 8 + 9 + describe('MockConnectorStateManager: executeOperations', () => { 10 + let manager: MockConnectorStateManager 11 + 12 + beforeEach(() => { 13 + manager = createManager() 14 + manager.connect('test-token') 15 + }) 16 + 17 + it('returns authFailure when a configured result has authFailure', () => { 18 + const op = manager.addOperation({ 19 + type: 'org:add-user', 20 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 21 + description: 'Add alice', 22 + command: 'npm org set myorg alice developer', 23 + }) 24 + manager.approveOperation(op.id) 25 + 26 + const result = manager.executeOperations({ 27 + results: { 28 + [op.id]: { 29 + exitCode: 1, 30 + stderr: 'auth failure', 31 + authFailure: true, 32 + }, 33 + }, 34 + }) 35 + 36 + expect(result.authFailure).toBe(true) 37 + expect(result.results).toHaveLength(1) 38 + expect(result.results[0]!.result.authFailure).toBe(true) 39 + }) 40 + 41 + it('returns authFailure as undefined when no operations have auth failures', () => { 42 + const op = manager.addOperation({ 43 + type: 'org:add-user', 44 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 45 + description: 'Add alice', 46 + command: 'npm org set myorg alice developer', 47 + }) 48 + manager.approveOperation(op.id) 49 + 50 + const result = manager.executeOperations() 51 + 52 + // Default success path -- no auth failure 53 + expect(result.authFailure).toBeFalsy() 54 + }) 55 + 56 + it('collects and deduplicates urls from operation results', () => { 57 + const op1 = manager.addOperation({ 58 + type: 'org:add-user', 59 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 60 + description: 'Add alice', 61 + command: 'npm org set myorg alice developer', 62 + }) 63 + const op2 = manager.addOperation({ 64 + type: 'org:add-user', 65 + params: { org: 'myorg', user: 'bob', role: 'developer' }, 66 + description: 'Add bob', 67 + command: 'npm org set myorg bob developer', 68 + }) 69 + manager.approveOperation(op1.id) 70 + manager.approveOperation(op2.id) 71 + 72 + const result = manager.executeOperations({ 73 + results: { 74 + [op1.id]: { 75 + exitCode: 0, 76 + stdout: 'ok', 77 + urls: ['https://npmjs.com/auth/abc'], 78 + }, 79 + [op2.id]: { 80 + exitCode: 0, 81 + stdout: 'ok', 82 + urls: ['https://npmjs.com/auth/abc', 'https://npmjs.com/auth/def'], 83 + }, 84 + }, 85 + }) 86 + 87 + expect(result.urls).toBeDefined() 88 + // Should be deduplicated 89 + expect(result.urls).toEqual(['https://npmjs.com/auth/abc', 'https://npmjs.com/auth/def']) 90 + }) 91 + 92 + it('returns urls as undefined when no operations have urls', () => { 93 + const op = manager.addOperation({ 94 + type: 'org:add-user', 95 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 96 + description: 'Add alice', 97 + command: 'npm org set myorg alice developer', 98 + }) 99 + manager.approveOperation(op.id) 100 + 101 + const result = manager.executeOperations() 102 + 103 + expect(result.urls).toBeUndefined() 104 + }) 105 + 106 + it('returns otpRequired when a configured result requires OTP and none provided', () => { 107 + const op = manager.addOperation({ 108 + type: 'org:add-user', 109 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 110 + description: 'Add alice', 111 + command: 'npm org set myorg alice developer', 112 + }) 113 + manager.approveOperation(op.id) 114 + 115 + const result = manager.executeOperations({ 116 + results: { 117 + [op.id]: { 118 + exitCode: 1, 119 + stderr: 'otp required', 120 + requiresOtp: true, 121 + }, 122 + }, 123 + }) 124 + 125 + expect(result.otpRequired).toBe(true) 126 + }) 127 + 128 + it('returns both authFailure and urls together', () => { 129 + const op1 = manager.addOperation({ 130 + type: 'org:add-user', 131 + params: { org: 'myorg', user: 'alice', role: 'developer' }, 132 + description: 'Add alice', 133 + command: 'npm org set myorg alice developer', 134 + }) 135 + const op2 = manager.addOperation({ 136 + type: 'org:add-user', 137 + params: { org: 'myorg', user: 'bob', role: 'developer' }, 138 + description: 'Add bob', 139 + command: 'npm org set myorg bob developer', 140 + }) 141 + manager.approveOperation(op1.id) 142 + manager.approveOperation(op2.id) 143 + 144 + const result = manager.executeOperations({ 145 + results: { 146 + [op1.id]: { 147 + exitCode: 1, 148 + stderr: 'auth failure', 149 + authFailure: true, 150 + urls: ['https://npmjs.com/login'], 151 + }, 152 + [op2.id]: { 153 + exitCode: 0, 154 + stdout: 'ok', 155 + }, 156 + }, 157 + }) 158 + 159 + expect(result.authFailure).toBe(true) 160 + expect(result.urls).toEqual(['https://npmjs.com/login']) 161 + }) 162 + })
+54
test/unit/cli/npm-client.spec.ts
··· 4 4 validateOrgName, 5 5 validateScopeTeam, 6 6 validatePackageName, 7 + extractUrls, 7 8 } from '../../../cli/src/npm-client.ts' 8 9 9 10 describe('validateUsername', () => { ··· 130 131 expect(() => validatePackageName('')).toThrow('Invalid package name') 131 132 }) 132 133 }) 134 + 135 + describe('extractUrls', () => { 136 + it('extracts HTTP URLs from text', () => { 137 + const text = 'Visit http://example.com for more info' 138 + expect(extractUrls(text)).toEqual(['http://example.com']) 139 + }) 140 + 141 + it('extracts HTTPS URLs from text', () => { 142 + const text = 'Visit https://example.com/path for more info' 143 + expect(extractUrls(text)).toEqual(['https://example.com/path']) 144 + }) 145 + 146 + it('extracts multiple URLs from text', () => { 147 + const text = 'See https://example.com and http://other.org/page' 148 + expect(extractUrls(text)).toEqual(['https://example.com', 'http://other.org/page']) 149 + }) 150 + 151 + it('strips trailing punctuation from URLs', () => { 152 + expect(extractUrls('Go to https://example.com.')).toEqual(['https://example.com']) 153 + expect(extractUrls('Go to https://example.com,')).toEqual(['https://example.com']) 154 + expect(extractUrls('Go to https://example.com;')).toEqual(['https://example.com']) 155 + expect(extractUrls('Go to https://example.com:')).toEqual(['https://example.com']) 156 + expect(extractUrls('Go to https://example.com!')).toEqual(['https://example.com']) 157 + expect(extractUrls('Go to https://example.com?')).toEqual(['https://example.com']) 158 + expect(extractUrls('Go to https://example.com)')).toEqual(['https://example.com']) 159 + }) 160 + 161 + it('strips multiple trailing punctuation characters', () => { 162 + expect(extractUrls('See https://example.com/path).')).toEqual(['https://example.com/path']) 163 + }) 164 + 165 + it('preserves query strings and fragments', () => { 166 + expect(extractUrls('Go to https://example.com/path?q=1&b=2#anchor')).toEqual([ 167 + 'https://example.com/path?q=1&b=2#anchor', 168 + ]) 169 + }) 170 + 171 + it('returns empty array when no URLs found', () => { 172 + expect(extractUrls('No URLs here')).toEqual([]) 173 + expect(extractUrls('')).toEqual([]) 174 + }) 175 + 176 + it('deduplicates identical URLs', () => { 177 + const text = 'Visit https://example.com and again https://example.com' 178 + expect(extractUrls(text)).toEqual(['https://example.com']) 179 + }) 180 + 181 + it('extracts URLs from npm auth output', () => { 182 + const npmOutput = 183 + 'Authenticate your account at:\nhttps://www.npmjs.com/login?next=/login/cli/abc123' 184 + expect(extractUrls(npmOutput)).toEqual(['https://www.npmjs.com/login?next=/login/cli/abc123']) 185 + }) 186 + })
+44
test/unit/cli/schemas.spec.ts
··· 245 245 it('rejects invalid OTP', () => { 246 246 expect(v.safeParse(ExecuteBodySchema, { otp: '12345' }).success).toBe(false) 247 247 }) 248 + 249 + it('accepts interactive flag', () => { 250 + const result = v.safeParse(ExecuteBodySchema, { interactive: true }) 251 + expect(result.success).toBe(true) 252 + expect((result as { output: { interactive: boolean } }).output.interactive).toBe(true) 253 + }) 254 + 255 + it('accepts openUrls flag', () => { 256 + const result = v.safeParse(ExecuteBodySchema, { openUrls: true }) 257 + expect(result.success).toBe(true) 258 + expect((result as { output: { openUrls: boolean } }).output.openUrls).toBe(true) 259 + }) 260 + 261 + it('accepts all fields together', () => { 262 + const result = v.safeParse(ExecuteBodySchema, { 263 + otp: '123456', 264 + interactive: true, 265 + openUrls: false, 266 + }) 267 + expect(result.success).toBe(true) 268 + const output = (result as { output: { otp: string; interactive: boolean; openUrls: boolean } }) 269 + .output 270 + expect(output.otp).toBe('123456') 271 + expect(output.interactive).toBe(true) 272 + expect(output.openUrls).toBe(false) 273 + }) 274 + 275 + it('interactive and openUrls are optional (undefined when omitted)', () => { 276 + const result = v.safeParse(ExecuteBodySchema, { otp: '123456' }) 277 + expect(result.success).toBe(true) 278 + const output = (result as { output: Record<string, unknown> }).output 279 + expect(output.interactive).toBeUndefined() 280 + expect(output.openUrls).toBeUndefined() 281 + }) 282 + 283 + it('rejects non-boolean values for interactive', () => { 284 + expect(v.safeParse(ExecuteBodySchema, { interactive: 'true' }).success).toBe(false) 285 + expect(v.safeParse(ExecuteBodySchema, { interactive: 1 }).success).toBe(false) 286 + }) 287 + 288 + it('rejects non-boolean values for openUrls', () => { 289 + expect(v.safeParse(ExecuteBodySchema, { openUrls: 'true' }).success).toBe(false) 290 + expect(v.safeParse(ExecuteBodySchema, { openUrls: 1 }).success).toBe(false) 291 + }) 248 292 }) 249 293 250 294 describe('CreateOperationBodySchema', () => {