From 7e5e224852ddf5b7ec51732cf5fd36caf12042b4 Mon Sep 17 00:00:00 2001 From: mikouaji Date: Tue, 10 Feb 2026 23:13:38 +0100 Subject: [PATCH 1/8] feat(connector): add ability to run commands in interactive mode and catch urls with possible auto opening --- cli/package.json | 1 + cli/src/node-pty.d.ts | 23 ++++ cli/src/npm-client.ts | 242 ++++++++++++++++++++++++++++++++++++++---- cli/src/schemas.ts | 7 +- cli/src/server.ts | 92 +++++++++++++--- cli/src/types.ts | 4 + cli/tsdown.config.ts | 1 + 7 files changed, 331 insertions(+), 39 deletions(-) create mode 100644 cli/src/node-pty.d.ts diff --git a/cli/package.json b/cli/package.json index f95122382..224f9f3b8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -33,6 +33,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/node-pty.d.ts b/cli/src/node-pty.d.ts new file mode 100644 index 000000000..bf77febbb --- /dev/null +++ b/cli/src/node-pty.d.ts @@ -0,0 +1,23 @@ +// @lydell/node-pty package.json does not export its types so for nodenext target we need to add them (very minimal version) +declare module '@lydell/node-pty' { + interface IPty { + readonly pid: number + readonly onData: (listener: (data: string) => void) => { dispose(): void } + readonly onExit: (listener: (e: { exitCode: number; signal?: number }) => void) => { + dispose(): void + } + write(data: string): void + kill(signal?: string): void + } + + export function spawn( + file: string, + args: string[], + options: { + name?: string + cols?: number + rows?: number + env?: Record + }, + ): IPty +} diff --git a/cli/src/npm-client.ts b/cli/src/npm-client.ts index b709e77fa..d7354cbd4 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,192 @@ 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 opend URL in browser if openUrls is true or passes the URL to frontend. If no auth happend within AUTH_URL_TIMEOUT_MS kills the process to unlock the connector. + * + * - 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 !== false + + // 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 authUrlTimeout: ReturnType | null = null + + 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 + logDebug('Auth URL timeout (90s) — killing process') + logError('Authentication timed out after 90 seconds') + child.kill() + }, AUTH_URL_TIMEOUT_MS) + } + } + + if (authUrlSeen && openUrls && AUTH_URL_PROMPT_RE.test(cleanAll)) { + 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 = (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) + } + } + + 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, + 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 +414,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 fc609e06f..40878f463 100644 --- a/cli/src/server.ts +++ b/cli/src/server.ts @@ -24,6 +24,8 @@ import { ownerRemove, packageInit, listUserPackages, + extractUrls, + type ExecNpmOptions, type NpmExecResult, } from './npm-client.ts' import { @@ -308,8 +310,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) { @@ -318,6 +322,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) @@ -330,6 +336,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) { @@ -366,8 +375,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) { @@ -381,6 +391,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 }) }) @@ -390,12 +405,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, }, } as ApiResponse }) @@ -698,42 +716,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: '', @@ -741,6 +793,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 a83e9f3b7..ad0efbcab 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -41,6 +41,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 +56,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 { 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'], }) From 1121c4a8e996a13a787cf76fed0f30793a0a3deb Mon Sep 17 00:00:00 2001 From: mikouaji Date: Tue, 10 Feb 2026 23:14:11 +0100 Subject: [PATCH 2/8] feat: add new connector settings for web auth --- app/composables/useConnector.ts | 8 +++++++- app/composables/useSettings.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/composables/useConnector.ts b/app/composables/useConnector.ts index 8efe83114..528f024e8 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: settings.value.connector.webAuth, + openUrls: settings.value.connector.autoOpenURL, + }, }) if (response?.success) { await refreshState() diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 31476415e..bfeafdde1 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -29,6 +29,13 @@ export interface AppSettings { selectedLocale: LocaleObject['code'] | null /** Search provider for package search */ searchProvider: SearchProvider + /** Connector preferences */ + connector: { + /** Use web-based authentication instead of CLI token */ + webAuth: boolean + /** Automatically open the web auth page in the browser */ + autoOpenURL: boolean + } sidebar: { collapsed: string[] } @@ -42,6 +49,10 @@ const DEFAULT_SETTINGS: AppSettings = { selectedLocale: null, preferredBackgroundTheme: null, searchProvider: import.meta.test ? 'npm' : 'algolia', + connector: { + webAuth: false, + autoOpenURL: false, + }, sidebar: { collapsed: [], }, From a0904611872df95fe007587aea795ff3325df82b Mon Sep 17 00:00:00 2001 From: mikouaji Date: Tue, 10 Feb 2026 23:24:15 +0100 Subject: [PATCH 3/8] feat: add web auth settings and redirect/retry buttons to ConnectionModal and operations component --- app/components/Header/ConnectorModal.vue | 102 ++++++++++++++++++++++- app/components/Org/OperationsQueue.vue | 23 +++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue index 0210462ac..c249e936f 100644 --- a/app/components/Header/ConnectorModal.vue +++ b/app/components/Header/ConnectorModal.vue @@ -1,6 +1,54 @@