diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue
index 0210462ac..edcfdf8c9 100644
--- a/app/components/Header/ConnectorModal.vue
+++ b/app/components/Header/ConnectorModal.vue
@@ -2,6 +2,8 @@
const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } =
useConnector()
+const { settings } = useSettings()
+
const tokenInput = shallowRef('')
const portInput = shallowRef('31415')
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
@@ -61,6 +63,16 @@ function handleDisconnect() {
+
+
+
+
+
+
+
@@ -194,6 +206,14 @@ function handleDisconnect() {
class="w-full"
size="medium"
/>
+
+
+
+
+
diff --git a/app/components/Org/OperationsQueue.vue b/app/components/Org/OperationsQueue.vue
index 92eff1722..60341b253 100644
--- a/app/components/Org/OperationsQueue.vue
+++ b/app/components/Org/OperationsQueue.vue
@@ -7,6 +7,7 @@ const {
approvedOperations,
completedOperations,
activeOperations,
+ operations,
hasOperations,
hasPendingOperations,
hasApprovedOperations,
@@ -23,11 +24,53 @@ const {
const isExecuting = shallowRef(false)
const otpInput = shallowRef('')
+const otpError = shallowRef('')
-/** Check if any active operation needs OTP */
+const authUrl = computed(() => {
+ const op = operations.value.find(o => o.status === 'running' && o.authUrl)
+ return op?.authUrl ?? null
+})
+
+const authPollTimer = shallowRef | null>(null)
+
+function startAuthPolling() {
+ stopAuthPolling()
+ let remaining = 3
+ authPollTimer.value = setInterval(async () => {
+ try {
+ await refreshState()
+ } catch {
+ stopAuthPolling()
+ return
+ }
+ remaining--
+ if (remaining <= 0) {
+ stopAuthPolling()
+ }
+ }, 20000)
+}
+
+function stopAuthPolling() {
+ if (authPollTimer.value) {
+ clearInterval(authPollTimer.value)
+ authPollTimer.value = null
+ }
+}
+
+onUnmounted(stopAuthPolling)
+
+function handleOpenAuthUrl() {
+ if (authUrl.value) {
+ window.open(authUrl.value, '_blank', 'noopener,noreferrer')
+ startAuthPolling()
+ }
+}
+
+/** Check if any active operation needs OTP (fallback for web auth failures) */
const hasOtpFailures = computed(() =>
activeOperations.value.some(
- (op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp,
+ (op: PendingOperation) =>
+ op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
),
)
@@ -46,14 +89,25 @@ async function handleExecute(otp?: string) {
/** Retry all OTP-failed operations with the provided OTP */
async function handleRetryWithOtp() {
- if (!otpInput.value.trim()) return
-
const otp = otpInput.value.trim()
+
+ if (!otp) {
+ otpError.value = 'OTP required'
+ return
+ }
+
+ if (!/^\d{6}$/.test(otp)) {
+ otpError.value = 'OTP must be a 6-digit code'
+ return
+ }
+
+ otpError.value = ''
otpInput.value = ''
- // First, re-approve all OTP-failed operations
+ // First, re-approve all OTP/auth-failed operations
const otpFailedOps = activeOperations.value.filter(
- (op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp,
+ (op: PendingOperation) =>
+ op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
)
for (const op of otpFailedOps) {
await retryOperation(op.id)
@@ -63,9 +117,25 @@ async function handleRetryWithOtp() {
await handleExecute(otp)
}
+/** Retry failed operations with web auth (no OTP) */
+async function handleRetryWebAuth() {
+ // Find all failed operations that need auth retry
+ const failedOps = activeOperations.value.filter(
+ (op: PendingOperation) =>
+ op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
+ )
+
+ for (const op of failedOps) {
+ await retryOperation(op.id)
+ }
+
+ await handleExecute()
+}
+
async function handleClearAll() {
await clearOperations()
otpInput.value = ''
+ otpError.value = ''
}
function getStatusColor(status: string): string {
@@ -228,7 +298,7 @@ watch(isExecuting, executing => {
-
+
{
{{ $t('operations.queue.otp_prompt') }}
-
+
+
+
{{ $t('common.or') }}
+
+
+
@@ -288,6 +378,15 @@ watch(isExecuting, executing => {
: `${$t('operations.queue.execute')} (${approvedOperations.length})`
}}
+
diff --git a/app/composables/useConnector.ts b/app/composables/useConnector.ts
index 00c3e998d..d1ef4296e 100644
--- a/app/composables/useConnector.ts
+++ b/app/composables/useConnector.ts
@@ -57,6 +57,8 @@ const STORAGE_KEY = 'npmx-connector'
const DEFAULT_PORT = 31415
export const useConnector = createSharedComposable(function useConnector() {
+ const { settings } = useSettings()
+
// Persisted connection config
const config = useState<{ token: string; port: number } | null>('connector-config', () => null)
@@ -303,7 +305,11 @@ export const useConnector = createSharedComposable(function useConnector() {
ApiResponse<{ results: unknown[]; otpRequired?: boolean }>
>('/execute', {
method: 'POST',
- body: otp ? { otp } : undefined,
+ body: {
+ otp,
+ interactive: !otp,
+ openUrls: settings.value.connector.autoOpenURL,
+ },
})
if (response?.success) {
await refreshState()
@@ -371,20 +377,22 @@ export const useConnector = createSharedComposable(function useConnector() {
const approvedOperations = computed(() =>
state.value.operations.filter(op => op.status === 'approved'),
)
- /** Operations that are done (completed, or failed without needing OTP retry) */
+ /** Operations that are done (completed, or failed without needing OTP/auth retry) */
const completedOperations = computed(() =>
state.value.operations.filter(
- op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp),
+ op =>
+ op.status === 'completed' ||
+ (op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure),
),
)
- /** Operations that are still active (pending, approved, running, or failed needing OTP retry) */
+ /** Operations that are still active (pending, approved, running, or failed needing OTP/auth retry) */
const activeOperations = computed(() =>
state.value.operations.filter(
op =>
op.status === 'pending' ||
op.status === 'approved' ||
op.status === 'running' ||
- (op.status === 'failed' && op.result?.requiresOtp),
+ (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)),
),
)
const hasOperations = computed(() => state.value.operations.length > 0)
diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts
index 31476415e..8502bfda7 100644
--- a/app/composables/useSettings.ts
+++ b/app/composables/useSettings.ts
@@ -29,6 +29,11 @@ export interface AppSettings {
selectedLocale: LocaleObject['code'] | null
/** Search provider for package search */
searchProvider: SearchProvider
+ /** Connector preferences */
+ connector: {
+ /** Automatically open the web auth page in the browser */
+ autoOpenURL: boolean
+ }
sidebar: {
collapsed: string[]
}
@@ -42,6 +47,9 @@ const DEFAULT_SETTINGS: AppSettings = {
selectedLocale: null,
preferredBackgroundTheme: null,
searchProvider: import.meta.test ? 'npm' : 'algolia',
+ connector: {
+ autoOpenURL: false,
+ },
sidebar: {
collapsed: [],
},
diff --git a/cli/package.json b/cli/package.json
index 0f6276443..0818dfbaf 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -31,6 +31,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
+ "@lydell/node-pty": "1.2.0-beta.3",
"citty": "^0.2.0",
"h3-next": "npm:h3@^2.0.1-rc.11",
"obug": "^2.1.1",
diff --git a/cli/src/mock-app.ts b/cli/src/mock-app.ts
index e3bdc9052..24ebe6b88 100644
--- a/cli/src/mock-app.ts
+++ b/cli/src/mock-app.ts
@@ -230,13 +230,18 @@ function createMockConnectorApp(stateManager: MockConnectorStateManager) {
requireAuth(event)
const body = await event.req.json().catch(() => ({}))
- const otp = (body as { otp?: string })?.otp
+ const { otp } = body as { otp?: string; interactive?: boolean; openUrls?: boolean }
- const { results, otpRequired } = stateManager.executeOperations({ otp })
+ const { results, otpRequired, authFailure, urls } = stateManager.executeOperations({ otp })
return {
success: true,
- data: { results, otpRequired },
+ data: {
+ results,
+ otpRequired,
+ authFailure,
+ urls,
+ },
} satisfies ApiResponse
})
diff --git a/cli/src/mock-state.ts b/cli/src/mock-state.ts
index b2182fab5..829e0ff3c 100644
--- a/cli/src/mock-state.ts
+++ b/cli/src/mock-state.ts
@@ -58,6 +58,8 @@ export interface ExecuteOptions {
export interface ExecuteResult {
results: Array<{ id: string; result: OperationResult }>
otpRequired?: boolean
+ authFailure?: boolean
+ urls?: string[]
}
export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData {
@@ -281,6 +283,7 @@ export class MockConnectorStateManager {
exitCode: configuredResult.exitCode ?? 1,
requiresOtp: configuredResult.requiresOtp,
authFailure: configuredResult.authFailure,
+ urls: configuredResult.urls,
}
op.result = result
op.status = result.exitCode === 0 ? 'completed' : 'failed'
@@ -305,7 +308,15 @@ export class MockConnectorStateManager {
}
}
- return { results }
+ const authFailure = results.some(r => r.result.authFailure)
+ const allUrls = results.flatMap(r => r.result.urls ?? [])
+ const urls = [...new Set(allUrls)]
+
+ return {
+ results,
+ authFailure: authFailure || undefined,
+ urls: urls.length > 0 ? urls : undefined,
+ }
}
/** Apply side effects of a completed operation. Param keys match schemas.ts. */
diff --git a/cli/src/node-pty.d.ts b/cli/src/node-pty.d.ts
new file mode 100644
index 000000000..9fb264416
--- /dev/null
+++ b/cli/src/node-pty.d.ts
@@ -0,0 +1,53 @@
+// @lydell/node-pty package.json does not export its types so for nodenext target we need to add them
+declare module '@lydell/node-pty' {
+ export function spawn(
+ file: string,
+ args: string[] | string,
+ options: IPtyForkOptions | IWindowsPtyForkOptions,
+ ): IPty
+ export interface IBasePtyForkOptions {
+ name?: string
+ cols?: number
+ rows?: number
+ cwd?: string
+ env?: { [key: string]: string | undefined }
+ encoding?: string | null
+ handleFlowControl?: boolean
+ flowControlPause?: string
+ flowControlResume?: string
+ }
+
+ export interface IPtyForkOptions extends IBasePtyForkOptions {
+ uid?: number
+ gid?: number
+ }
+
+ export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {
+ useConpty?: boolean
+ useConptyDll?: boolean
+ conptyInheritCursor?: boolean
+ }
+
+ export interface IPty {
+ readonly pid: number
+ readonly cols: number
+ readonly rows: number
+ readonly process: string
+ handleFlowControl: boolean
+ readonly onData: IEvent
+ readonly onExit: IEvent<{ exitCode: number; signal?: number }>
+ resize(columns: number, rows: number): void
+ clear(): void
+ write(data: string | Buffer): void
+ kill(signal?: string): void
+ pause(): void
+ resume(): void
+ }
+
+ export interface IDisposable {
+ dispose(): void
+ }
+ export interface IEvent {
+ (listener: (e: T) => any): IDisposable
+ }
+}
diff --git a/cli/src/npm-client.ts b/cli/src/npm-client.ts
index b709e77fa..a3f61a5c1 100644
--- a/cli/src/npm-client.ts
+++ b/cli/src/npm-client.ts
@@ -68,6 +68,8 @@ export interface NpmExecResult {
requiresOtp?: boolean
/** True if the operation failed due to authentication failure (not logged in or token expired) */
authFailure?: boolean
+ /** URLs detected in the command output (stdout + stderr) */
+ urls?: string[]
}
function detectOtpRequired(stderr: string): boolean {
@@ -116,10 +118,200 @@ function filterNpmWarnings(stderr: string): string {
.trim()
}
-async function execNpm(
+const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g
+
+export function extractUrls(text: string): string[] {
+ const matches = text.match(URL_RE)
+ if (!matches) return []
+
+ const cleaned = matches.map(url => url.replace(/[.,;:!?)]+$/, ''))
+ return [...new Set(cleaned)]
+}
+
+// Patterns to detect npm's OTP prompt in pty output
+const OTP_PROMPT_RE = /Enter OTP:/i
+// Patterns to detect npm's web auth URL prompt in pty output
+const AUTH_URL_PROMPT_RE = /Press ENTER to open in the browser/i
+// npm prints "Authenticate your account at:\n" — capture the URL on the next line
+const AUTH_URL_TITLE_RE = /Authenticate your account at:\s*(https?:\/\/\S+)/
+
+function stripAnsi(text: string): string {
+ // eslint disabled because we need escape characters in regex
+ // eslint-disable-next-line no-control-regex, regexp/no-obscure-range
+ return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
+}
+
+const AUTH_URL_TIMEOUT_MS = 90_000
+
+export interface ExecNpmOptions {
+ otp?: string
+ silent?: boolean
+ /** When true, use PTY-based interactive execution instead of execFile. */
+ interactive?: boolean
+ /** When true, npm opens auth URLs in the user's browser.
+ * When false, browser opening is suppressed via npm_config_browser=false.
+ * Only relevant when `interactive` is true. */
+ openUrls?: boolean
+ /** 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.
+ * Only relevant when `interactive` is true. */
+ onAuthUrl?: (url: string) => void
+}
+
+/**
+ * PTY-based npm execution for interactive commands (uses node-pty).
+ *
+ * - 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.
+ *
+ * - 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
+ */
+async function execNpmInteractive(
args: string[],
- options: { otp?: string; silent?: boolean } = {},
+ options: ExecNpmOptions = {},
): Promise {
+ const openUrls = options.openUrls === true
+
+ // Lazy-load node-pty so the native addon is only required when interactive mode is actually used.
+ const pty = await import('@lydell/node-pty')
+
+ return new Promise(resolve => {
+ const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
+
+ if (!options.silent) {
+ const displayCmd = options.otp
+ ? ['npm', ...args, '--otp', '******'].join(' ')
+ : ['npm', ...args].join(' ')
+ logCommand(`${displayCmd} (interactive/pty)`)
+ }
+
+ let output = ''
+ let resolved = false
+ let otpPromptSeen = false
+ let authUrlSeen = false
+ let enterSent = false
+ let authUrlTimeout: ReturnType | null = null
+ let authUrlTimedOut = false
+
+ const env: Record = {
+ ...(process.env as Record),
+ FORCE_COLOR: '0',
+ }
+
+ // When openUrls is false, tell npm not to open the browser.
+ // npm still prints the auth URL and polls doneUrl
+ if (!openUrls) {
+ env.npm_config_browser = 'false'
+ }
+
+ const child = pty.spawn('npm', npmArgs, {
+ name: 'xterm-256color',
+ cols: 120,
+ rows: 30,
+ env,
+ })
+
+ // General timeout: 5 minutes (covers non-auth interactive commands)
+ const timeout = setTimeout(() => {
+ if (resolved) return
+ logDebug('Interactive command timed out', { output })
+ child.kill()
+ }, 300000)
+
+ child.onData((data: string) => {
+ output += data
+ const clean = stripAnsi(data)
+ logDebug('pty data:', { text: clean.trim() })
+
+ const cleanAll = stripAnsi(output)
+
+ // Detect auth URL in output and notify the caller.
+ if (!authUrlSeen) {
+ const urlMatch = cleanAll.match(AUTH_URL_TITLE_RE)
+
+ if (urlMatch && urlMatch[1]) {
+ authUrlSeen = true
+ const authUrl = urlMatch[1].replace(/[.,;:!?)]+$/, '')
+ logDebug('Auth URL detected:', { authUrl, openUrls })
+ options.onAuthUrl?.(authUrl)
+
+ authUrlTimeout = setTimeout(() => {
+ if (resolved) return
+ authUrlTimedOut = true
+ logDebug('Auth URL timeout (90s) — killing process')
+ logError('Authentication timed out after 90 seconds')
+ child.kill()
+ }, AUTH_URL_TIMEOUT_MS)
+ }
+ }
+
+ if (authUrlSeen && openUrls && !enterSent && AUTH_URL_PROMPT_RE.test(cleanAll)) {
+ enterSent = true
+ logDebug('Web auth prompt detected, pressing ENTER')
+ child.write('\r')
+ }
+
+ if (!otpPromptSeen && OTP_PROMPT_RE.test(cleanAll)) {
+ otpPromptSeen = true
+ if (options.otp) {
+ logDebug('OTP prompt detected, writing OTP')
+ child.write(options.otp + '\r')
+ } else {
+ logDebug('OTP prompt detected but no OTP provided, killing process')
+ child.kill()
+ }
+ }
+ })
+
+ child.onExit(({ exitCode }) => {
+ if (resolved) return
+ resolved = true
+ clearTimeout(timeout)
+ if (authUrlTimeout) clearTimeout(authUrlTimeout)
+
+ const cleanOutput = stripAnsi(output)
+ logDebug('Interactive command exited:', { exitCode, output: cleanOutput })
+
+ const requiresOtp =
+ authUrlTimedOut || (otpPromptSeen && !options.otp) || detectOtpRequired(cleanOutput)
+ const authFailure = detectAuthFailure(cleanOutput)
+ const urls = extractUrls(cleanOutput)
+
+ if (!options.silent) {
+ if (exitCode === 0) {
+ logSuccess('Done')
+ } else if (requiresOtp) {
+ logError('OTP required')
+ } else if (authFailure) {
+ logError('Authentication required - please run "npm login" and restart the connector')
+ } else {
+ const firstLine = filterNpmWarnings(cleanOutput).split('\n')[0] || 'Command failed'
+ logError(firstLine)
+ }
+ }
+
+ // If auth URL timed out, force a non-zero exit code so it's marked as failed
+ const finalExitCode = authUrlTimedOut ? 1 : exitCode
+
+ resolve({
+ stdout: cleanOutput.trim(),
+ stderr: requiresOtp
+ ? 'This operation requires a one-time password (OTP).'
+ : authFailure
+ ? 'Authentication failed. Please run "npm login" and restart the connector.'
+ : filterNpmWarnings(cleanOutput),
+ exitCode: finalExitCode,
+ requiresOtp,
+ authFailure,
+ urls: urls.length > 0 ? urls : undefined,
+ })
+ })
+ })
+}
+
+async function execNpm(args: string[], options: ExecNpmOptions = {}): Promise {
+ if (options.interactive) {
+ return execNpmInteractive(args, options)
+ }
+
// Build the full args array including OTP if provided
const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
@@ -230,84 +422,98 @@ export async function orgAddUser(
org: string,
user: string,
role: 'developer' | 'admin' | 'owner',
- otp?: string,
+ options?: ExecNpmOptions,
): Promise {
validateOrgName(org)
validateUsername(user)
- return execNpm(['org', 'set', org, user, role], { otp })
+ return execNpm(['org', 'set', org, user, role], options)
}
export async function orgRemoveUser(
org: string,
user: string,
- otp?: string,
+ options?: ExecNpmOptions,
): Promise {
validateOrgName(org)
validateUsername(user)
- return execNpm(['org', 'rm', org, user], { otp })
+ return execNpm(['org', 'rm', org, user], options)
}
-export async function teamCreate(scopeTeam: string, otp?: string): Promise {
+export async function teamCreate(
+ scopeTeam: string,
+ options?: ExecNpmOptions,
+): Promise {
validateScopeTeam(scopeTeam)
- return execNpm(['team', 'create', scopeTeam], { otp })
+ return execNpm(['team', 'create', scopeTeam], options)
}
-export async function teamDestroy(scopeTeam: string, otp?: string): Promise {
+export async function teamDestroy(
+ scopeTeam: string,
+ options?: ExecNpmOptions,
+): Promise {
validateScopeTeam(scopeTeam)
- return execNpm(['team', 'destroy', scopeTeam], { otp })
+ return execNpm(['team', 'destroy', scopeTeam], options)
}
export async function teamAddUser(
scopeTeam: string,
user: string,
- otp?: string,
+ options?: ExecNpmOptions,
): Promise {
validateScopeTeam(scopeTeam)
validateUsername(user)
- return execNpm(['team', 'add', scopeTeam, user], { otp })
+ return execNpm(['team', 'add', scopeTeam, user], options)
}
export async function teamRemoveUser(
scopeTeam: string,
user: string,
- otp?: string,
+ options?: ExecNpmOptions,
): Promise {
validateScopeTeam(scopeTeam)
validateUsername(user)
- return execNpm(['team', 'rm', scopeTeam, user], { otp })
+ return execNpm(['team', 'rm', scopeTeam, user], options)
}
export async function accessGrant(
permission: 'read-only' | 'read-write',
scopeTeam: string,
pkg: string,
- otp?: string,
+ options?: ExecNpmOptions,
): Promise {
validateScopeTeam(scopeTeam)
validatePackageName(pkg)
- return execNpm(['access', 'grant', permission, scopeTeam, pkg], { otp })
+ return execNpm(['access', 'grant', permission, scopeTeam, pkg], options)
}
export async function accessRevoke(
scopeTeam: string,
pkg: string,
- otp?: string,
+ options?: ExecNpmOptions,
): Promise {
validateScopeTeam(scopeTeam)
validatePackageName(pkg)
- return execNpm(['access', 'revoke', scopeTeam, pkg], { otp })
+ return execNpm(['access', 'revoke', scopeTeam, pkg], options)
}
-export async function ownerAdd(user: string, pkg: string, otp?: string): Promise {
+export async function ownerAdd(
+ user: string,
+ pkg: string,
+ options?: ExecNpmOptions,
+): Promise {
validateUsername(user)
validatePackageName(pkg)
- return execNpm(['owner', 'add', user, pkg], { otp })
+ return execNpm(['owner', 'add', user, pkg], options)
}
-export async function ownerRemove(user: string, pkg: string, otp?: string): Promise {
+export async function ownerRemove(
+ user: string,
+ pkg: string,
+ options?: ExecNpmOptions,
+): Promise {
validateUsername(user)
validatePackageName(pkg)
- return execNpm(['owner', 'rm', user, pkg], { otp })
+ return execNpm(['owner', 'rm', user, pkg], options)
}
// List functions (for reading data) - silent since they're not user-triggered operations
diff --git a/cli/src/schemas.ts b/cli/src/schemas.ts
index 6d4fd3883..de2cb627b 100644
--- a/cli/src/schemas.ts
+++ b/cli/src/schemas.ts
@@ -151,10 +151,15 @@ export const ConnectBodySchema = v.object({
})
/**
- * Schema for /execute request body
+ * Schema for /execute request body.
+ * - `otp`: optional 6-digit OTP code for 2FA
+ * - `interactive`: when true, commands run via a real PTY (node-pty) instead of execFile, so npm's OTP handler can activate.
+ * - `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
*/
export const ExecuteBodySchema = v.object({
otp: OtpSchema,
+ interactive: v.optional(v.boolean()),
+ openUrls: v.optional(v.boolean()),
})
/**
diff --git a/cli/src/server.ts b/cli/src/server.ts
index d750f7f61..ee798c7a9 100644
--- a/cli/src/server.ts
+++ b/cli/src/server.ts
@@ -51,6 +51,8 @@ import {
ownerRemove,
packageInit,
listUserPackages,
+ extractUrls,
+ type ExecNpmOptions,
type NpmExecResult,
} from './npm-client.ts'
import {
@@ -335,8 +337,10 @@ export function createConnectorApp(expectedToken: string) {
throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
}
- // OTP can be passed directly in the request body for this execution
+ // OTP, interactive flag, and openUrls can be passed in the request body
let otp: string | undefined
+ let interactive = false
+ let openUrls = false
try {
const rawBody = await event.req.json()
if (rawBody) {
@@ -345,6 +349,8 @@ export function createConnectorApp(expectedToken: string) {
throw new HTTPError({ statusCode: 400, message: parsed.error })
}
otp = parsed.data.otp
+ interactive = parsed.data.interactive ?? false
+ openUrls = parsed.data.openUrls ?? false
}
} catch (err) {
// Re-throw HTTPError, ignore JSON parse errors (empty body is fine)
@@ -357,6 +363,9 @@ export function createConnectorApp(expectedToken: string) {
const completedIds = new Set()
const failedIds = new Set()
+ // Collect all URLs across all operations in this execution batch
+ const allUrls: string[] = []
+
// Execute operations in waves, respecting dependencies
// Each wave contains operations whose dependencies are satisfied
while (true) {
@@ -393,8 +402,9 @@ export function createConnectorApp(expectedToken: string) {
// Execute ready operations in parallel
const runningOps = readyOps.map(async op => {
op.status = 'running'
- const result = await executeOperation(op, otp)
+ const result = await executeOperation(op, { otp, interactive, openUrls })
op.result = result
+ op.authUrl = undefined
op.status = result.exitCode === 0 ? 'completed' : 'failed'
if (result.exitCode === 0) {
@@ -408,6 +418,11 @@ export function createConnectorApp(expectedToken: string) {
otpRequired = true
}
+ // Collect URLs from this operation's output
+ if (result.urls && result.urls.length > 0) {
+ allUrls.push(...result.urls)
+ }
+
results.push({ id: op.id, result })
})
@@ -417,12 +432,15 @@ export function createConnectorApp(expectedToken: string) {
// Check if any operation had an auth failure
const authFailure = results.some(r => r.result.authFailure)
+ const urls = [...new Set(allUrls)]
+
return {
success: true,
data: {
results,
otpRequired,
authFailure,
+ urls: urls.length > 0 ? urls : undefined,
},
} satisfies ApiResponse
})
@@ -725,42 +743,76 @@ export function createConnectorApp(expectedToken: string) {
return app
}
-async function executeOperation(op: PendingOperation, otp?: string): Promise {
+async function executeOperation(
+ op: PendingOperation,
+ options: { otp?: string; interactive?: boolean; openUrls?: boolean } = {},
+): Promise {
const { type, params } = op
+ // Build exec options that get passed through to execNpm, which
+ // internally routes to either execFile or PTY-based execution.
+ const execOptions: ExecNpmOptions = {
+ otp: options.otp,
+ interactive: options.interactive,
+ openUrls: options.openUrls,
+ onAuthUrl: options.interactive
+ ? url => {
+ // Set authUrl on the operation so /state exposes it to the
+ // frontend while npm is still polling for authentication.
+ op.authUrl = url
+ }
+ : undefined,
+ }
+
+ let result: NpmExecResult
+
switch (type) {
case 'org:add-user':
- return orgAddUser(
+ case 'org:set-role':
+ result = await orgAddUser(
params.org,
params.user,
params.role as 'developer' | 'admin' | 'owner',
- otp,
+ execOptions,
)
+ break
case 'org:rm-user':
- return orgRemoveUser(params.org, params.user, otp)
+ result = await orgRemoveUser(params.org, params.user, execOptions)
+ break
case 'team:create':
- return teamCreate(params.scopeTeam, otp)
+ result = await teamCreate(params.scopeTeam, execOptions)
+ break
case 'team:destroy':
- return teamDestroy(params.scopeTeam, otp)
+ result = await teamDestroy(params.scopeTeam, execOptions)
+ break
case 'team:add-user':
- return teamAddUser(params.scopeTeam, params.user, otp)
+ result = await teamAddUser(params.scopeTeam, params.user, execOptions)
+ break
case 'team:rm-user':
- return teamRemoveUser(params.scopeTeam, params.user, otp)
+ result = await teamRemoveUser(params.scopeTeam, params.user, execOptions)
+ break
case 'access:grant':
- return accessGrant(
+ result = await accessGrant(
params.permission as 'read-only' | 'read-write',
params.scopeTeam,
params.pkg,
- otp,
+ execOptions,
)
+ break
case 'access:revoke':
- return accessRevoke(params.scopeTeam, params.pkg, otp)
+ result = await accessRevoke(params.scopeTeam, params.pkg, execOptions)
+ break
case 'owner:add':
- return ownerAdd(params.user, params.pkg, otp)
+ result = await ownerAdd(params.user, params.pkg, execOptions)
+ break
case 'owner:rm':
- return ownerRemove(params.user, params.pkg, otp)
+ result = await ownerRemove(params.user, params.pkg, execOptions)
+ break
case 'package:init':
- return packageInit(params.name, params.author, otp)
+ // package:init has its own special execution path (temp dir + publish)
+ // and does not support interactive mode
+ result = await packageInit(params.name, params.author, options.otp)
+ break
default:
return {
stdout: '',
@@ -768,6 +820,14 @@ async function executeOperation(op: PendingOperation, otp?: string): Promise 0) result.urls = urls
+ }
+
+ return result
}
export { generateToken }
diff --git a/cli/src/types.ts b/cli/src/types.ts
index f9efcbc8b..fcd0db2a4 100644
--- a/cli/src/types.ts
+++ b/cli/src/types.ts
@@ -1,3 +1,5 @@
+import './node-pty.d.ts'
+
export interface ConnectorConfig {
port: number
host: string
@@ -41,6 +43,8 @@ export interface OperationResult {
requiresOtp?: boolean
/** True if the operation failed due to authentication failure (not logged in or token expired) */
authFailure?: boolean
+ /** URLs detected in the command output (stdout + stderr) */
+ urls?: string[]
}
export interface PendingOperation {
@@ -54,6 +58,8 @@ export interface PendingOperation {
result?: OperationResult
/** ID of operation this depends on (must complete successfully first) */
dependsOn?: string
+ /** Auth URL detected during interactive execution (set while operation is still running) */
+ authUrl?: string
}
export interface ConnectorState {
@@ -92,6 +98,7 @@ export interface ExecuteResponseData {
results: Array<{ id: string; result: OperationResult }>
otpRequired?: boolean
authFailure?: boolean
+ urls?: string[]
}
/** POST /approve-all response data */
@@ -127,7 +134,10 @@ export interface ConnectorEndpoints {
'POST /approve': { body: never; data: PendingOperation }
'POST /approve-all': { body: never; data: ApproveAllResponseData }
'POST /retry': { body: never; data: PendingOperation }
- 'POST /execute': { body: { otp?: string }; data: ExecuteResponseData }
+ 'POST /execute': {
+ body: { otp?: string; interactive?: boolean; openUrls?: boolean }
+ data: ExecuteResponseData
+ }
'GET /org/:org/users': { body: never; data: Record }
'GET /org/:org/teams': { body: never; data: string[] }
'GET /team/:scopeTeam/users': { body: never; data: string[] }
diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts
index f9e4897ba..b0bc548f3 100644
--- a/cli/tsdown.config.ts
+++ b/cli/tsdown.config.ts
@@ -6,4 +6,5 @@ export default defineConfig({
clean: true,
dts: true,
outDir: 'dist',
+ external: ['@lydell/node-pty'],
})
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index e3f07511e..e3e88014e 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -125,6 +125,7 @@
"end_of_results": "End of results",
"try_again": "Try again",
"close": "Close",
+ "or": "or",
"retry": "Retry",
"copy": "copy",
"copied": "copied!",
@@ -471,7 +472,8 @@
"warning": "WARNING",
"warning_text": "This allows npmx to access your npm CLI. Only connect to sites you trust.",
"connect": "Connect",
- "connecting": "Connecting..."
+ "connecting": "Connecting...",
+ "auto_open_url": "Automatically open auth page"
}
},
"operations": {
@@ -487,7 +489,9 @@
"otp_placeholder": "Enter OTP code...",
"otp_label": "One-time password",
"retry_otp": "Retry with OTP",
+ "retry_web_auth": "Retry with web auth",
"retrying": "Retrying...",
+ "open_web_auth": "Open web auth link",
"approve_operation": "Approve operation",
"remove_operation": "Remove operation",
"approve_all": "Approve All",
diff --git a/i18n/locales/pl-PL.json b/i18n/locales/pl-PL.json
index f6bc009ed..e0bce7e3b 100644
--- a/i18n/locales/pl-PL.json
+++ b/i18n/locales/pl-PL.json
@@ -125,6 +125,7 @@
"end_of_results": "Koniec wyników",
"try_again": "Spróbuj ponownie",
"close": "Zamknij",
+ "or": "lub",
"retry": "Ponów",
"copy": "kopiuj",
"copied": "skopiowano!",
@@ -461,7 +462,8 @@
"warning": "OSTRZEŻENIE",
"warning_text": "To pozwala npmx uzyskać dostęp do twojego npm CLI. Łącz się tylko ze stronami, którym ufasz.",
"connect": "Połącz",
- "connecting": "Łączenie..."
+ "connecting": "Łączenie...",
+ "auto_open_url": "Automatycznie otwórz stronę z autoryzacją"
}
},
"operations": {
@@ -477,7 +479,9 @@
"otp_placeholder": "Wprowadź kod OTP...",
"otp_label": "Hasło jednorazowe",
"retry_otp": "Ponów z OTP",
+ "retry_web_auth": "Ponów z autoryzacją w przeglądarce",
"retrying": "Ponawianie...",
+ "open_web_auth": "Otwórz stronę z autoryzacją",
"approve_operation": "Zatwierdź operację",
"remove_operation": "Usuń operację",
"approve_all": "Zatwierdź wszystkie",
diff --git a/i18n/schema.json b/i18n/schema.json
index 84cbbd10c..e2789b11f 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -379,6 +379,9 @@
"close": {
"type": "string"
},
+ "or": {
+ "type": "string"
+ },
"retry": {
"type": "string"
},
@@ -1419,6 +1422,9 @@
},
"connecting": {
"type": "string"
+ },
+ "auto_open_url": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -1465,9 +1471,15 @@
"retry_otp": {
"type": "string"
},
+ "retry_web_auth": {
+ "type": "string"
+ },
"retrying": {
"type": "string"
},
+ "open_web_auth": {
+ "type": "string"
+ },
"approve_operation": {
"type": "string"
},
diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json
index 458a5f8f7..23e381f04 100644
--- a/lunaria/files/en-GB.json
+++ b/lunaria/files/en-GB.json
@@ -124,6 +124,7 @@
"end_of_results": "End of results",
"try_again": "Try again",
"close": "Close",
+ "or": "or",
"retry": "Retry",
"copy": "copy",
"copied": "copied!",
@@ -470,7 +471,8 @@
"warning": "WARNING",
"warning_text": "This allows npmx to access your npm CLI. Only connect to sites you trust.",
"connect": "Connect",
- "connecting": "Connecting..."
+ "connecting": "Connecting...",
+ "auto_open_url": "Automatically open auth page"
}
},
"operations": {
@@ -486,7 +488,9 @@
"otp_placeholder": "Enter OTP code...",
"otp_label": "One-time password",
"retry_otp": "Retry with OTP",
+ "retry_web_auth": "Retry with web auth",
"retrying": "Retrying...",
+ "open_web_auth": "Open web auth link",
"approve_operation": "Approve operation",
"remove_operation": "Remove operation",
"approve_all": "Approve All",
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index a4424e083..9de51c67d 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -124,6 +124,7 @@
"end_of_results": "End of results",
"try_again": "Try again",
"close": "Close",
+ "or": "or",
"retry": "Retry",
"copy": "copy",
"copied": "copied!",
@@ -470,7 +471,8 @@
"warning": "WARNING",
"warning_text": "This allows npmx to access your npm CLI. Only connect to sites you trust.",
"connect": "Connect",
- "connecting": "Connecting..."
+ "connecting": "Connecting...",
+ "auto_open_url": "Automatically open auth page"
}
},
"operations": {
@@ -486,7 +488,9 @@
"otp_placeholder": "Enter OTP code...",
"otp_label": "One-time password",
"retry_otp": "Retry with OTP",
+ "retry_web_auth": "Retry with web auth",
"retrying": "Retrying...",
+ "open_web_auth": "Open web auth link",
"approve_operation": "Approve operation",
"remove_operation": "Remove operation",
"approve_all": "Approve All",
diff --git a/lunaria/files/pl-PL.json b/lunaria/files/pl-PL.json
index 22e273461..059cb4534 100644
--- a/lunaria/files/pl-PL.json
+++ b/lunaria/files/pl-PL.json
@@ -124,6 +124,7 @@
"end_of_results": "Koniec wyników",
"try_again": "Spróbuj ponownie",
"close": "Zamknij",
+ "or": "lub",
"retry": "Ponów",
"copy": "kopiuj",
"copied": "skopiowano!",
@@ -460,7 +461,8 @@
"warning": "OSTRZEŻENIE",
"warning_text": "To pozwala npmx uzyskać dostęp do twojego npm CLI. Łącz się tylko ze stronami, którym ufasz.",
"connect": "Połącz",
- "connecting": "Łączenie..."
+ "connecting": "Łączenie...",
+ "auto_open_url": "Automatycznie otwórz stronę z autoryzacją"
}
},
"operations": {
@@ -476,7 +478,9 @@
"otp_placeholder": "Wprowadź kod OTP...",
"otp_label": "Hasło jednorazowe",
"retry_otp": "Ponów z OTP",
+ "retry_web_auth": "Ponów z autoryzacją w przeglądarce",
"retrying": "Ponawianie...",
+ "open_web_auth": "Otwórz stronę z autoryzacją",
"approve_operation": "Zatwierdź operację",
"remove_operation": "Usuń operację",
"approve_all": "Zatwierdź wszystkie",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 175172ddb..52a4afe68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -291,6 +291,9 @@ importers:
'@clack/prompts':
specifier: ^1.0.0
version: 1.0.0
+ '@lydell/node-pty':
+ specifier: 1.2.0-beta.3
+ version: 1.2.0-beta.3
citty:
specifier: ^0.2.0
version: 0.2.0
@@ -1883,6 +1886,39 @@ packages:
version: 0.1.1
engines: {node: '>=18.17.0'}
+ '@lydell/node-pty-darwin-arm64@1.2.0-beta.3':
+ resolution: {integrity: sha512-owcv+e1/OSu3bf9ZBdUQqJsQF888KyuSIiPYFNn0fLhgkhm9F3Pvha76Kj5mCPnodf7hh3suDe7upw7GPRXftQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@lydell/node-pty-darwin-x64@1.2.0-beta.3':
+ resolution: {integrity: sha512-k38O+UviWrWdxtqZBBc/D8NJU11Rey8Y2YMwSWNxLv3eXZZdF5IVpbBkI/2RmLsV5nCcciqLPbukxeZnEfPlwA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@lydell/node-pty-linux-arm64@1.2.0-beta.3':
+ resolution: {integrity: sha512-HUwRpGu3O+4sv9DAQFKnyW5LYhyYu2SDUa/bdFO/t4dIFCM4uDJEq47wfRM7+aYtJTi1b3lakN8SlWeuFQqJQQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@lydell/node-pty-linux-x64@1.2.0-beta.3':
+ resolution: {integrity: sha512-+RRY0PoCUeQaCvPR7/UnkGbxulwbFtoTWJfe+o4T1RcNtngrgaI55I9nl8CD8uqhGrB3smKuyvPM5UtwGhASUw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@lydell/node-pty-win32-arm64@1.2.0-beta.3':
+ resolution: {integrity: sha512-UEDd9ASp2M3iIYpIzfmfBlpyn4+K1G4CAjYcHWStptCkefoSVXWTiUBIa1KjBjZi3/xmsHIDpBEYTkGWuvLt2Q==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@lydell/node-pty-win32-x64@1.2.0-beta.3':
+ resolution: {integrity: sha512-TpdqSFYx7/Rj+68tuP6F/lkRYrHCYAIJgaS1bx3SctTkb5QAQCFwOKHd4xlsivmEOMT2LdhkJggPxwX9PAO5pQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@lydell/node-pty@1.2.0-beta.3':
+ resolution: {integrity: sha512-ngGAItlRhmJXrhspxt8kX13n1dVFqzETOq0m/+gqSkO8NJBvNMwP7FZckMwps2UFySdr4yxCXNGu/bumg5at6A==}
+
'@mapbox/node-pre-gyp@2.0.3':
resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==}
engines: {node: '>=18'}
@@ -11432,6 +11468,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@lydell/node-pty-darwin-arm64@1.2.0-beta.3':
+ optional: true
+
+ '@lydell/node-pty-darwin-x64@1.2.0-beta.3':
+ optional: true
+
+ '@lydell/node-pty-linux-arm64@1.2.0-beta.3':
+ optional: true
+
+ '@lydell/node-pty-linux-x64@1.2.0-beta.3':
+ optional: true
+
+ '@lydell/node-pty-win32-arm64@1.2.0-beta.3':
+ optional: true
+
+ '@lydell/node-pty-win32-x64@1.2.0-beta.3':
+ optional: true
+
+ '@lydell/node-pty@1.2.0-beta.3':
+ optionalDependencies:
+ '@lydell/node-pty-darwin-arm64': 1.2.0-beta.3
+ '@lydell/node-pty-darwin-x64': 1.2.0-beta.3
+ '@lydell/node-pty-linux-arm64': 1.2.0-beta.3
+ '@lydell/node-pty-linux-x64': 1.2.0-beta.3
+ '@lydell/node-pty-win32-arm64': 1.2.0-beta.3
+ '@lydell/node-pty-win32-x64': 1.2.0-beta.3
+
'@mapbox/node-pre-gyp@2.0.3':
dependencies:
consola: 3.4.2
diff --git a/test/nuxt/components/HeaderConnectorModal.spec.ts b/test/nuxt/components/HeaderConnectorModal.spec.ts
index 1ee999af5..bf1450998 100644
--- a/test/nuxt/components/HeaderConnectorModal.spec.ts
+++ b/test/nuxt/components/HeaderConnectorModal.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { ref, computed, readonly, nextTick } from 'vue'
import type { VueWrapper } from '@vue/test-utils'
import type { PendingOperation } from '../../../cli/src/types'
@@ -44,7 +44,7 @@ function createMockUseConnector() {
op.status === 'pending' ||
op.status === 'approved' ||
op.status === 'running' ||
- (op.status === 'failed' && op.result?.requiresOtp),
+ (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)),
),
),
hasOperations: computed(() => mockState.value.operations.length > 0),
@@ -60,12 +60,14 @@ function createMockUseConnector() {
op.status === 'pending' ||
op.status === 'approved' ||
op.status === 'running' ||
- (op.status === 'failed' && op.result?.requiresOtp),
+ (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)),
),
),
hasCompletedOperations: computed(() =>
mockState.value.operations.some(
- op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp),
+ op =>
+ op.status === 'completed' ||
+ (op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure),
),
),
connect: vi.fn().mockResolvedValue(true),
@@ -99,6 +101,9 @@ function resetMockState() {
error: null,
lastExecutionTime: null,
}
+ mockSettings.value.connector = {
+ autoOpenURL: false,
+ }
}
function simulateConnect() {
@@ -107,14 +112,33 @@ function simulateConnect() {
mockState.value.avatar = 'https://example.com/avatar.png'
}
-// Mock the composables at module level (vi.mock is hoisted)
-vi.mock('~/composables/useConnector', () => ({
- useConnector: createMockUseConnector,
-}))
+const mockSettings = ref({
+ relativeDates: false,
+ includeTypesInInstall: true,
+ accentColorId: null,
+ hidePlatformPackages: true,
+ selectedLocale: null,
+ preferredBackgroundTheme: null,
+ searchProvider: 'npm',
+ connector: {
+ autoOpenURL: false,
+ },
+ sidebar: {
+ collapsed: [],
+ },
+})
-vi.mock('~/composables/useSelectedPackageManager', () => ({
- useSelectedPackageManager: () => ref('npm'),
-}))
+mockNuxtImport('useConnector', () => {
+ return createMockUseConnector
+})
+
+mockNuxtImport('useSettings', () => {
+ return () => ({ settings: mockSettings })
+})
+
+mockNuxtImport('useSelectedPackageManager', () => {
+ return () => ref('npm')
+})
vi.mock('~/utils/npm', () => ({
getExecuteCommand: () => 'npx npmx-connector',
@@ -176,6 +200,157 @@ afterEach(() => {
})
describe('HeaderConnectorModal', () => {
+ describe('Connector preferences (connected)', () => {
+ it('shows auto-open URL toggle when connected', async () => {
+ const dialog = await mountAndOpen('connected')
+ const labels = Array.from(dialog?.querySelectorAll('label, span') ?? [])
+ const autoOpenLabel = labels.find(el => el.textContent?.includes('open auth page'))
+ expect(autoOpenLabel).toBeTruthy()
+ })
+
+ it('does not show a web auth toggle (web auth is now always on)', async () => {
+ const dialog = await mountAndOpen('connected')
+ const labels = Array.from(dialog?.querySelectorAll('label, span') ?? [])
+ const webAuthLabel = labels.find(el => el.textContent?.includes('web authentication'))
+ expect(webAuthLabel).toBeUndefined()
+ })
+ })
+
+ describe('Auth URL button', () => {
+ it('does not show auth URL button when no running operations have an authUrl', async () => {
+ const dialog = await mountAndOpen('connected')
+
+ const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
+ const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link'))
+ expect(authUrlBtn).toBeUndefined()
+ })
+
+ it('shows auth URL button when a running operation has an authUrl', async () => {
+ mockState.value.operations = [
+ {
+ id: '0000000000000001',
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ status: 'running',
+ createdAt: Date.now(),
+ authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123',
+ },
+ ]
+ const dialog = await mountAndOpen('connected')
+
+ const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
+ const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link'))
+ expect(authUrlBtn).toBeTruthy()
+ })
+
+ it('opens auth URL in new tab when button is clicked', async () => {
+ const mockOpen = vi.fn()
+ vi.stubGlobal('open', mockOpen)
+
+ mockState.value.operations = [
+ {
+ id: '0000000000000001',
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ status: 'running',
+ createdAt: Date.now(),
+ authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123',
+ },
+ ]
+ const dialog = await mountAndOpen('connected')
+
+ const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
+ const authUrlBtn = buttons.find(b =>
+ b.textContent?.includes('web auth link'),
+ ) as HTMLButtonElement
+ authUrlBtn?.click()
+ await nextTick()
+
+ expect(mockOpen).toHaveBeenCalledWith(
+ 'https://www.npmjs.com/login?next=/login/cli/abc123',
+ '_blank',
+ 'noopener,noreferrer',
+ )
+
+ vi.unstubAllGlobals()
+ // Re-stub navigator.clipboard which was unstubbed
+ vi.stubGlobal('navigator', {
+ ...navigator,
+ clipboard: {
+ writeText: mockWriteText,
+ readText: vi.fn().mockResolvedValue(''),
+ },
+ })
+ })
+ })
+
+ describe('Operations queue in connected state', () => {
+ it('renders OTP prompt when operations have OTP failures', async () => {
+ mockState.value.operations = [
+ {
+ id: '0000000000000001',
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ status: 'failed',
+ createdAt: Date.now(),
+ result: { stdout: '', stderr: 'otp required', exitCode: 1, requiresOtp: true },
+ },
+ ]
+ const dialog = await mountAndOpen('connected')
+
+ // The OrgOperationsQueue child should render with the OTP alert
+ const otpAlert = dialog?.querySelector('[role="alert"]')
+ expect(otpAlert).not.toBeNull()
+ expect(dialog?.innerHTML).toContain('otp-input')
+ })
+
+ it('does not show retry with web auth when there are no auth failures', async () => {
+ mockState.value.operations = [
+ {
+ id: '0000000000000001',
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ status: 'approved',
+ createdAt: Date.now(),
+ },
+ ]
+ const dialog = await mountAndOpen('connected')
+
+ const html = dialog?.innerHTML ?? ''
+ const hasWebAuthButton =
+ html.includes('Retry with web auth') || html.includes('retry_web_auth')
+ expect(hasWebAuthButton).toBe(false)
+ })
+
+ it('shows OTP alert section for operations with authFailure (not just requiresOtp)', async () => {
+ mockState.value.operations = [
+ {
+ id: '0000000000000001',
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ status: 'failed',
+ createdAt: Date.now(),
+ result: { stdout: '', stderr: 'auth failed', exitCode: 1, authFailure: true },
+ },
+ ]
+ const dialog = await mountAndOpen('connected')
+
+ // The OTP/auth failures section should render for authFailure too
+ const otpAlert = dialog?.querySelector('[role="alert"]')
+ expect(otpAlert).not.toBeNull()
+ })
+ })
+
describe('Disconnected state', () => {
it('shows connection form when not connected', async () => {
const dialog = await mountAndOpen()
diff --git a/test/unit/cli/mock-state.spec.ts b/test/unit/cli/mock-state.spec.ts
new file mode 100644
index 000000000..899883cab
--- /dev/null
+++ b/test/unit/cli/mock-state.spec.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it, beforeEach } from 'vitest'
+import { MockConnectorStateManager, createMockConnectorState } from '../../../cli/src/mock-state.ts'
+
+function createManager() {
+ const data = createMockConnectorState({ token: 'test-token', npmUser: 'testuser' })
+ return new MockConnectorStateManager(data)
+}
+
+describe('MockConnectorStateManager: executeOperations', () => {
+ let manager: MockConnectorStateManager
+
+ beforeEach(() => {
+ manager = createManager()
+ manager.connect('test-token')
+ })
+
+ it('returns authFailure when a configured result has authFailure', () => {
+ const op = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ })
+ manager.approveOperation(op.id)
+
+ const result = manager.executeOperations({
+ results: {
+ [op.id]: {
+ exitCode: 1,
+ stderr: 'auth failure',
+ authFailure: true,
+ },
+ },
+ })
+
+ expect(result.authFailure).toBe(true)
+ expect(result.results).toHaveLength(1)
+ expect(result.results[0]!.result.authFailure).toBe(true)
+ })
+
+ it('returns authFailure as undefined when no operations have auth failures', () => {
+ const op = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ })
+ manager.approveOperation(op.id)
+
+ const result = manager.executeOperations()
+
+ // Default success path -- no auth failure
+ expect(result.authFailure).toBeFalsy()
+ })
+
+ it('collects and deduplicates urls from operation results', () => {
+ const op1 = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ })
+ const op2 = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'bob', role: 'developer' },
+ description: 'Add bob',
+ command: 'npm org set myorg bob developer',
+ })
+ manager.approveOperation(op1.id)
+ manager.approveOperation(op2.id)
+
+ const result = manager.executeOperations({
+ results: {
+ [op1.id]: {
+ exitCode: 0,
+ stdout: 'ok',
+ urls: ['https://npmjs.com/auth/abc'],
+ },
+ [op2.id]: {
+ exitCode: 0,
+ stdout: 'ok',
+ urls: ['https://npmjs.com/auth/abc', 'https://npmjs.com/auth/def'],
+ },
+ },
+ })
+
+ expect(result.urls).toBeDefined()
+ // Should be deduplicated
+ expect(result.urls).toEqual(['https://npmjs.com/auth/abc', 'https://npmjs.com/auth/def'])
+ })
+
+ it('returns urls as undefined when no operations have urls', () => {
+ const op = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ })
+ manager.approveOperation(op.id)
+
+ const result = manager.executeOperations()
+
+ expect(result.urls).toBeUndefined()
+ })
+
+ it('returns otpRequired when a configured result requires OTP and none provided', () => {
+ const op = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ })
+ manager.approveOperation(op.id)
+
+ const result = manager.executeOperations({
+ results: {
+ [op.id]: {
+ exitCode: 1,
+ stderr: 'otp required',
+ requiresOtp: true,
+ },
+ },
+ })
+
+ expect(result.otpRequired).toBe(true)
+ })
+
+ it('returns both authFailure and urls together', () => {
+ const op1 = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'alice', role: 'developer' },
+ description: 'Add alice',
+ command: 'npm org set myorg alice developer',
+ })
+ const op2 = manager.addOperation({
+ type: 'org:add-user',
+ params: { org: 'myorg', user: 'bob', role: 'developer' },
+ description: 'Add bob',
+ command: 'npm org set myorg bob developer',
+ })
+ manager.approveOperation(op1.id)
+ manager.approveOperation(op2.id)
+
+ const result = manager.executeOperations({
+ results: {
+ [op1.id]: {
+ exitCode: 1,
+ stderr: 'auth failure',
+ authFailure: true,
+ urls: ['https://npmjs.com/login'],
+ },
+ [op2.id]: {
+ exitCode: 0,
+ stdout: 'ok',
+ },
+ },
+ })
+
+ expect(result.authFailure).toBe(true)
+ expect(result.urls).toEqual(['https://npmjs.com/login'])
+ })
+})
diff --git a/test/unit/cli/npm-client.spec.ts b/test/unit/cli/npm-client.spec.ts
index ae951a011..09aa47e74 100644
--- a/test/unit/cli/npm-client.spec.ts
+++ b/test/unit/cli/npm-client.spec.ts
@@ -4,6 +4,7 @@ import {
validateOrgName,
validateScopeTeam,
validatePackageName,
+ extractUrls,
} from '../../../cli/src/npm-client.ts'
describe('validateUsername', () => {
@@ -130,3 +131,56 @@ describe('validatePackageName', () => {
expect(() => validatePackageName('')).toThrow('Invalid package name')
})
})
+
+describe('extractUrls', () => {
+ it('extracts HTTP URLs from text', () => {
+ const text = 'Visit http://example.com for more info'
+ expect(extractUrls(text)).toEqual(['http://example.com'])
+ })
+
+ it('extracts HTTPS URLs from text', () => {
+ const text = 'Visit https://example.com/path for more info'
+ expect(extractUrls(text)).toEqual(['https://example.com/path'])
+ })
+
+ it('extracts multiple URLs from text', () => {
+ const text = 'See https://example.com and http://other.org/page'
+ expect(extractUrls(text)).toEqual(['https://example.com', 'http://other.org/page'])
+ })
+
+ it('strips trailing punctuation from URLs', () => {
+ expect(extractUrls('Go to https://example.com.')).toEqual(['https://example.com'])
+ expect(extractUrls('Go to https://example.com,')).toEqual(['https://example.com'])
+ expect(extractUrls('Go to https://example.com;')).toEqual(['https://example.com'])
+ expect(extractUrls('Go to https://example.com:')).toEqual(['https://example.com'])
+ expect(extractUrls('Go to https://example.com!')).toEqual(['https://example.com'])
+ expect(extractUrls('Go to https://example.com?')).toEqual(['https://example.com'])
+ expect(extractUrls('Go to https://example.com)')).toEqual(['https://example.com'])
+ })
+
+ it('strips multiple trailing punctuation characters', () => {
+ expect(extractUrls('See https://example.com/path).')).toEqual(['https://example.com/path'])
+ })
+
+ it('preserves query strings and fragments', () => {
+ expect(extractUrls('Go to https://example.com/path?q=1&b=2#anchor')).toEqual([
+ 'https://example.com/path?q=1&b=2#anchor',
+ ])
+ })
+
+ it('returns empty array when no URLs found', () => {
+ expect(extractUrls('No URLs here')).toEqual([])
+ expect(extractUrls('')).toEqual([])
+ })
+
+ it('deduplicates identical URLs', () => {
+ const text = 'Visit https://example.com and again https://example.com'
+ expect(extractUrls(text)).toEqual(['https://example.com'])
+ })
+
+ it('extracts URLs from npm auth output', () => {
+ const npmOutput =
+ 'Authenticate your account at:\nhttps://www.npmjs.com/login?next=/login/cli/abc123'
+ expect(extractUrls(npmOutput)).toEqual(['https://www.npmjs.com/login?next=/login/cli/abc123'])
+ })
+})
diff --git a/test/unit/cli/schemas.spec.ts b/test/unit/cli/schemas.spec.ts
index c5ea7bb23..fea1f4c98 100644
--- a/test/unit/cli/schemas.spec.ts
+++ b/test/unit/cli/schemas.spec.ts
@@ -245,6 +245,50 @@ describe('ExecuteBodySchema', () => {
it('rejects invalid OTP', () => {
expect(v.safeParse(ExecuteBodySchema, { otp: '12345' }).success).toBe(false)
})
+
+ it('accepts interactive flag', () => {
+ const result = v.safeParse(ExecuteBodySchema, { interactive: true })
+ expect(result.success).toBe(true)
+ expect((result as { output: { interactive: boolean } }).output.interactive).toBe(true)
+ })
+
+ it('accepts openUrls flag', () => {
+ const result = v.safeParse(ExecuteBodySchema, { openUrls: true })
+ expect(result.success).toBe(true)
+ expect((result as { output: { openUrls: boolean } }).output.openUrls).toBe(true)
+ })
+
+ it('accepts all fields together', () => {
+ const result = v.safeParse(ExecuteBodySchema, {
+ otp: '123456',
+ interactive: true,
+ openUrls: false,
+ })
+ expect(result.success).toBe(true)
+ const output = (result as { output: { otp: string; interactive: boolean; openUrls: boolean } })
+ .output
+ expect(output.otp).toBe('123456')
+ expect(output.interactive).toBe(true)
+ expect(output.openUrls).toBe(false)
+ })
+
+ it('interactive and openUrls are optional (undefined when omitted)', () => {
+ const result = v.safeParse(ExecuteBodySchema, { otp: '123456' })
+ expect(result.success).toBe(true)
+ const output = (result as { output: Record }).output
+ expect(output.interactive).toBeUndefined()
+ expect(output.openUrls).toBeUndefined()
+ })
+
+ it('rejects non-boolean values for interactive', () => {
+ expect(v.safeParse(ExecuteBodySchema, { interactive: 'true' }).success).toBe(false)
+ expect(v.safeParse(ExecuteBodySchema, { interactive: 1 }).success).toBe(false)
+ })
+
+ it('rejects non-boolean values for openUrls', () => {
+ expect(v.safeParse(ExecuteBodySchema, { openUrls: 'true' }).success).toBe(false)
+ expect(v.safeParse(ExecuteBodySchema, { openUrls: 1 }).success).toBe(false)
+ })
})
describe('CreateOperationBodySchema', () => {