Something went wrong. Try again.
[READ-ONLY]
a fast, modern browser for the npm registry forked from npmx.dev/npmx.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834import crypto from 'node:crypto'import { H3, HTTPError, handleCors, type H3Event } from 'h3-next'import type { CorsOptions } from 'h3-next'import * as v from 'valibot'
import type { ConnectorState, PendingOperation, ApiResponse, ConnectorEndpoints, AssertEndpointsImplemented,} from './types.ts'
// Endpoint completeness check — errors if this list diverges from ConnectorEndpoints.const _endpointCheck: AssertEndpointsImplemented< | 'POST /connect' | 'GET /state' | 'POST /operations' | 'POST /operations/batch' | 'DELETE /operations' | 'DELETE /operations/all' | 'POST /approve' | 'POST /approve-all' | 'POST /retry' | 'POST /execute' | 'GET /org/:org/users' | 'GET /org/:org/teams' | 'GET /team/:scopeTeam/users' | 'GET /package/:pkg/collaborators' | 'GET /user/packages' | 'GET /user/orgs'> = truevoid _endpointCheckimport { logDebug, logError } from './logger.ts'import { getNpmUser, getNpmAvatar, orgAddUser, orgRemoveUser, orgListUsers, teamCreate, teamDestroy, teamAddUser, teamRemoveUser, teamListTeams, teamListUsers, accessGrant, accessRevoke, accessListCollaborators, ownerAdd, ownerRemove, packageInit, listUserPackages, extractUrls, type ExecNpmOptions, type NpmExecResult,} from './npm-client.ts'import { ConnectBodySchema, ExecuteBodySchema, CreateOperationBodySchema, BatchOperationsBodySchema, OrgNameSchema, ScopeTeamSchema, PackageNameSchema, OperationIdSchema, safeParse, validateOperationParams,} from './schemas.ts'
// Read version from package.jsonimport pkg from '../package.json' with { type: 'json' }
export const CONNECTOR_VERSION = pkg.version
function generateToken(): string { return crypto.randomBytes(16).toString('hex')}
function generateOperationId(): string { return crypto.randomBytes(8).toString('hex')}
const corsOptions: CorsOptions = { origin: ['https://npmx.dev', /^http:\/\/localhost:\d+$/, /^http:\/\/127.0.0.1:\d+$/], methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'],}
export function createConnectorApp(expectedToken: string) { const state: ConnectorState = { session: { token: expectedToken, connectedAt: 0, npmUser: null, avatar: null, }, operations: [], }
const app = new H3()
// Handle CORS for all requests (including preflight) app.use((event: H3Event) => { const corsResult = handleCors(event, corsOptions) if (corsResult !== false) { return corsResult } })
function validateToken(authHeader: string | null): boolean { if (!authHeader) return false const token = authHeader.replace('Bearer ', '') return token === expectedToken }
app.post('/connect', async (event: H3Event) => { const rawBody = await event.req.json() const parsed = safeParse(ConnectBodySchema, rawBody) if (!parsed.success) { throw new HTTPError({ statusCode: 400, message: parsed.error }) }
if (parsed.data.token !== expectedToken) { throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) }
const [npmUser, avatar] = await Promise.all([getNpmUser(), getNpmAvatar()]) state.session.connectedAt = Date.now() state.session.npmUser = npmUser state.session.avatar = avatar
return { success: true, data: { npmUser, avatar, connectedAt: state.session.connectedAt, }, } satisfies ApiResponse<ConnectorEndpoints['POST /connect']['data']> })
app.get('/state', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
return { success: true, data: { npmUser: state.session.npmUser, avatar: state.session.avatar, operations: state.operations, }, } satisfies ApiResponse<ConnectorEndpoints['GET /state']['data']> })
app.post('/operations', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const rawBody = await event.req.json() const parsed = safeParse(CreateOperationBodySchema, rawBody) if (!parsed.success) { throw new HTTPError({ statusCode: 400, message: parsed.error }) }
const { type, params, description, command } = parsed.data
// Validate params based on operation type try { validateOperationParams(type, params) } catch (err) { const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err) throw new HTTPError({ statusCode: 400, message: `Invalid params: ${message}` }) }
const operation: PendingOperation = { id: generateOperationId(), type, params, description, command, status: 'pending', createdAt: Date.now(), }
state.operations.push(operation)
return { success: true, data: operation, } satisfies ApiResponse<ConnectorEndpoints['POST /operations']['data']> })
app.post('/operations/batch', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const rawBody = await event.req.json() const parsed = safeParse(BatchOperationsBodySchema, rawBody) if (!parsed.success) { throw new HTTPError({ statusCode: 400, message: parsed.error }) }
// Validate each operation's params for (let i = 0; i < parsed.data.length; i++) { const op = parsed.data[i] if (!op) continue try { validateOperationParams(op.type, op.params) } catch (err) { const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err) throw new HTTPError({ statusCode: 400, message: `Operation ${i}: Invalid params: ${message}`, }) } }
const created: PendingOperation[] = [] for (const op of parsed.data) { const operation: PendingOperation = { id: generateOperationId(), type: op.type, params: op.params, description: op.description, command: op.command, status: 'pending', createdAt: Date.now(), } state.operations.push(operation) created.push(operation) }
return { success: true, data: created, } satisfies ApiResponse<ConnectorEndpoints['POST /operations/batch']['data']> })
app.post('/approve', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const url = new URL(event.req.url) const id = url.searchParams.get('id')
const idValidation = safeParse(OperationIdSchema, id) if (!idValidation.success) { throw new HTTPError({ statusCode: 400, message: idValidation.error }) }
const operation = state.operations.find(op => op.id === idValidation.data) if (!operation) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) }
if (operation.status !== 'pending') { throw new HTTPError({ statusCode: 400, message: 'Operation is not pending', }) }
operation.status = 'approved'
return { success: true, data: operation, } satisfies ApiResponse<ConnectorEndpoints['POST /approve']['data']> })
app.post('/approve-all', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const pendingOps = state.operations.filter(op => op.status === 'pending') for (const op of pendingOps) { op.status = 'approved' }
return { success: true, data: { approved: pendingOps.length }, } satisfies ApiResponse<ConnectorEndpoints['POST /approve-all']['data']> })
app.post('/retry', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const url = new URL(event.req.url) const id = url.searchParams.get('id')
const idValidation = safeParse(OperationIdSchema, id) if (!idValidation.success) { throw new HTTPError({ statusCode: 400, message: idValidation.error }) }
const operation = state.operations.find(op => op.id === idValidation.data) if (!operation) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) }
if (operation.status !== 'failed') { throw new HTTPError({ statusCode: 400, message: 'Only failed operations can be retried', }) }
// Reset the operation for retry operation.status = 'approved' operation.result = undefined
return { success: true, data: operation, } satisfies ApiResponse<ConnectorEndpoints['POST /retry']['data']> })
app.post('/execute', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
// 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) { const parsed = safeParse(ExecuteBodySchema, rawBody) if (!parsed.success) { 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) if (err instanceof HTTPError) throw err }
const approvedOps = state.operations.filter(op => op.status === 'approved') const results: Array<{ id: string; result: NpmExecResult }> = [] let otpRequired = false const completedIds = new Set<string>() const failedIds = new Set<string>()
// 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) { // Find operations ready to run (no pending dependencies) const readyOps = approvedOps.filter(op => { // Already processed if (completedIds.has(op.id) || failedIds.has(op.id)) return false // No dependency - ready if (!op.dependsOn) return true // Dependency completed successfully - ready if (completedIds.has(op.dependsOn)) return true // Dependency failed - skip this one too if (failedIds.has(op.dependsOn)) { op.status = 'failed' op.result = { stdout: '', stderr: 'Skipped: dependency failed', exitCode: 1, } failedIds.add(op.id) results.push({ id: op.id, result: op.result }) return false } // Dependency still pending - not ready return false })
// No more operations to run if (readyOps.length === 0) break
// If we've hit an OTP error and no OTP was provided, stop if (otpRequired && !otp) break
// Execute ready operations in parallel const runningOps = readyOps.map(async op => { op.status = 'running' 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) { completedIds.add(op.id) } else { failedIds.add(op.id) }
// Track if OTP is needed if (result.requiresOtp) { 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 }) })
await Promise.all(runningOps) }
// 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<ConnectorEndpoints['POST /execute']['data']> })
app.delete('/operations', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const url = new URL(event.req.url) const id = url.searchParams.get('id')
const idValidation = safeParse(OperationIdSchema, id) if (!idValidation.success) { throw new HTTPError({ statusCode: 400, message: idValidation.error }) }
const index = state.operations.findIndex(op => op.id === idValidation.data) if (index === -1) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) }
const operation = state.operations[index] if (!operation || operation.status === 'running') { throw new HTTPError({ statusCode: 400, message: 'Cannot cancel running operation', }) }
state.operations.splice(index, 1)
return { success: true } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations']['data']> })
app.delete('/operations/all', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const removed = state.operations.filter(op => op.status !== 'running').length state.operations = state.operations.filter(op => op.status === 'running')
return { success: true, data: { removed }, } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations/all']['data']> })
// List endpoints (read-only data fetching)
app.get('/org/:org/users', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const orgRaw = event.context.params?.org const orgValidation = safeParse(OrgNameSchema, orgRaw) if (!orgValidation.success) { throw new HTTPError({ statusCode: 400, message: orgValidation.error }) }
const result = await orgListUsers(orgValidation.data) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list org users', } as ApiResponse }
try { const users = JSON.parse(result.stdout) as Record<string, 'developer' | 'admin' | 'owner'> return { success: true, data: users, } satisfies ApiResponse<ConnectorEndpoints['GET /org/:org/users']['data']> } catch { return { success: false, error: 'Failed to parse org users', } as ApiResponse } })
app.get('/org/:org/teams', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const orgRaw = event.context.params?.org const orgValidation = safeParse(OrgNameSchema, orgRaw) if (!orgValidation.success) { throw new HTTPError({ statusCode: 400, message: orgValidation.error }) }
const result = await teamListTeams(orgValidation.data) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list teams', } as ApiResponse }
try { const teams = JSON.parse(result.stdout) as string[] return { success: true, data: teams, } satisfies ApiResponse<ConnectorEndpoints['GET /org/:org/teams']['data']> } catch { return { success: false, error: 'Failed to parse teams', } as ApiResponse } })
app.get('/team/:scopeTeam/users', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const scopeTeamRaw = event.context.params?.scopeTeam if (!scopeTeamRaw) { throw new HTTPError({ statusCode: 400, message: 'Team name required' }) }
// Decode the team name (handles encoded colons like nuxt%3Adevelopers) const scopeTeam = decodeURIComponent(scopeTeamRaw)
const validationResult = safeParse(ScopeTeamSchema, scopeTeam) if (!validationResult.success) { logError('scope:team validation failed') logDebug(validationResult.error, { scopeTeamRaw, scopeTeam }) throw new HTTPError({ statusCode: 400, message: `Invalid scope:team format: ${scopeTeam}. Expected @scope:team`, }) }
const result = await teamListUsers(scopeTeam) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list team users', } as ApiResponse }
try { const users = JSON.parse(result.stdout) as string[] return { success: true, data: users, } satisfies ApiResponse<ConnectorEndpoints['GET /team/:scopeTeam/users']['data']> } catch { return { success: false, error: 'Failed to parse team users', } as ApiResponse } })
app.get('/package/:pkg/collaborators', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const pkgRaw = event.context.params?.pkg if (!pkgRaw) { throw new HTTPError({ statusCode: 400, message: 'Package name required' }) }
// Decode the package name (handles scoped packages like @nuxt%2Fkit) const decodedPkg = decodeURIComponent(pkgRaw)
const pkgValidation = safeParse(PackageNameSchema, decodedPkg) if (!pkgValidation.success) { throw new HTTPError({ statusCode: 400, message: pkgValidation.error }) }
const result = await accessListCollaborators(pkgValidation.data) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list collaborators', } as ApiResponse }
try { const collaborators = JSON.parse(result.stdout) as Record<string, 'read-only' | 'read-write'> return { success: true, data: collaborators, } satisfies ApiResponse<ConnectorEndpoints['GET /package/:pkg/collaborators']['data']> } catch { return { success: false, error: 'Failed to parse collaborators', } as ApiResponse } })
// User-specific endpoints
app.get('/user/packages', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const npmUser = state.session.npmUser if (!npmUser) { return { success: false, error: 'Not logged in to npm', } as ApiResponse }
const result = await listUserPackages(npmUser) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list user packages', } as ApiResponse }
try { // npm access list packages returns { "packageName": "read-write" | "read-only" } const packages = JSON.parse(result.stdout) as Record<string, 'read-write' | 'read-only'> return { success: true, data: packages, } satisfies ApiResponse<ConnectorEndpoints['GET /user/packages']['data']> } catch { return { success: false, error: 'Failed to parse user packages', } as ApiResponse } })
app.get('/user/orgs', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) }
const npmUser = state.session.npmUser if (!npmUser) { return { success: false, error: 'Not logged in to npm', } as ApiResponse }
// Get user's packages and extract org names from scoped packages const result = await listUserPackages(npmUser) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list user packages', } as ApiResponse }
try { const packages = JSON.parse(result.stdout) as Record<string, string> const orgs = new Set<string>()
// Extract org names from scoped packages (e.g., @myorg/mypackage -> myorg) for (const pkgName of Object.keys(packages)) { if (pkgName.startsWith('@')) { const match = pkgName.match(/^@([^/]+)\//) if (match && match[1]) { // Exclude the user's own scope (personal packages) if (match[1].toLowerCase() !== npmUser.toLowerCase()) { orgs.add(match[1]) } } } }
return { success: true, data: Array.from(orgs).sort(), } satisfies ApiResponse<ConnectorEndpoints['GET /user/orgs']['data']> } catch { return { success: false, error: 'Failed to parse user orgs', } as ApiResponse } })
return app}
async function executeOperation( op: PendingOperation, options: { otp?: string; interactive?: boolean; openUrls?: boolean } = {},): Promise<NpmExecResult> { 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': case 'org:set-role': result = await orgAddUser( params.org, params.user, params.role as 'developer' | 'admin' | 'owner', execOptions, ) break case 'org:rm-user': result = await orgRemoveUser(params.org, params.user, execOptions) break case 'team:create': result = await teamCreate(params.scopeTeam, execOptions) break case 'team:destroy': result = await teamDestroy(params.scopeTeam, execOptions) break case 'team:add-user': result = await teamAddUser(params.scopeTeam, params.user, execOptions) break case 'team:rm-user': result = await teamRemoveUser(params.scopeTeam, params.user, execOptions) break case 'access:grant': result = await accessGrant( params.permission as 'read-only' | 'read-write', params.scopeTeam, params.pkg, execOptions, ) break case 'access:revoke': result = await accessRevoke(params.scopeTeam, params.pkg, execOptions) break case 'owner:add': result = await ownerAdd(params.user, params.pkg, execOptions) break case 'owner:rm': result = await ownerRemove(params.user, params.pkg, execOptions) break case 'package:init': // 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: '', stderr: `Unknown operation type: ${type}`, exitCode: 1, } }
// Extract URLs from output if not already populated if (!result.urls) { const urls = extractUrls((result.stdout || '') + '\n' + (result.stderr || '')) if (urls.length > 0) result.urls = urls }
return result}
export { generateToken }