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') }}
-
- - - + +
+ + + +
+

+ {{ otpError }} +

+
+
+ {{ $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', () => {