forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import crypto from 'node:crypto'
2import { H3, HTTPError, handleCors, type H3Event } from 'h3-next'
3import type { CorsOptions } from 'h3-next'
4import * as v from 'valibot'
5
6import type {
7 ConnectorState,
8 PendingOperation,
9 ApiResponse,
10 ConnectorEndpoints,
11 AssertEndpointsImplemented,
12} from './types.ts'
13
14// Endpoint completeness check — errors if this list diverges from ConnectorEndpoints.
15const _endpointCheck: AssertEndpointsImplemented<
16 | 'POST /connect'
17 | 'GET /state'
18 | 'POST /operations'
19 | 'POST /operations/batch'
20 | 'DELETE /operations'
21 | 'DELETE /operations/all'
22 | 'POST /approve'
23 | 'POST /approve-all'
24 | 'POST /retry'
25 | 'POST /execute'
26 | 'GET /org/:org/users'
27 | 'GET /org/:org/teams'
28 | 'GET /team/:scopeTeam/users'
29 | 'GET /package/:pkg/collaborators'
30 | 'GET /user/packages'
31 | 'GET /user/orgs'
32> = true
33void _endpointCheck
34import { logDebug, logError } from './logger.ts'
35import {
36 getNpmUser,
37 getNpmAvatar,
38 orgAddUser,
39 orgRemoveUser,
40 orgListUsers,
41 teamCreate,
42 teamDestroy,
43 teamAddUser,
44 teamRemoveUser,
45 teamListTeams,
46 teamListUsers,
47 accessGrant,
48 accessRevoke,
49 accessListCollaborators,
50 ownerAdd,
51 ownerRemove,
52 packageInit,
53 listUserPackages,
54 extractUrls,
55 type ExecNpmOptions,
56 type NpmExecResult,
57} from './npm-client.ts'
58import {
59 ConnectBodySchema,
60 ExecuteBodySchema,
61 CreateOperationBodySchema,
62 BatchOperationsBodySchema,
63 OrgNameSchema,
64 ScopeTeamSchema,
65 PackageNameSchema,
66 OperationIdSchema,
67 safeParse,
68 validateOperationParams,
69} from './schemas.ts'
70
71// Read version from package.json
72import pkg from '../package.json' with { type: 'json' }
73
74export const CONNECTOR_VERSION = pkg.version
75
76function generateToken(): string {
77 return crypto.randomBytes(16).toString('hex')
78}
79
80function generateOperationId(): string {
81 return crypto.randomBytes(8).toString('hex')
82}
83
84const corsOptions: CorsOptions = {
85 origin: ['https://npmx.dev', /^http:\/\/localhost:\d+$/, /^http:\/\/127.0.0.1:\d+$/],
86 methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
87 allowHeaders: ['Content-Type', 'Authorization'],
88}
89
90export function createConnectorApp(expectedToken: string) {
91 const state: ConnectorState = {
92 session: {
93 token: expectedToken,
94 connectedAt: 0,
95 npmUser: null,
96 avatar: null,
97 },
98 operations: [],
99 }
100
101 const app = new H3()
102
103 // Handle CORS for all requests (including preflight)
104 app.use((event: H3Event) => {
105 const corsResult = handleCors(event, corsOptions)
106 if (corsResult !== false) {
107 return corsResult
108 }
109 })
110
111 function validateToken(authHeader: string | null): boolean {
112 if (!authHeader) return false
113 const token = authHeader.replace('Bearer ', '')
114 return token === expectedToken
115 }
116
117 app.post('/connect', async (event: H3Event) => {
118 const rawBody = await event.req.json()
119 const parsed = safeParse(ConnectBodySchema, rawBody)
120 if (!parsed.success) {
121 throw new HTTPError({ statusCode: 400, message: parsed.error })
122 }
123
124 if (parsed.data.token !== expectedToken) {
125 throw new HTTPError({ statusCode: 401, message: 'Invalid token' })
126 }
127
128 const [npmUser, avatar] = await Promise.all([getNpmUser(), getNpmAvatar()])
129 state.session.connectedAt = Date.now()
130 state.session.npmUser = npmUser
131 state.session.avatar = avatar
132
133 return {
134 success: true,
135 data: {
136 npmUser,
137 avatar,
138 connectedAt: state.session.connectedAt,
139 },
140 } satisfies ApiResponse<ConnectorEndpoints['POST /connect']['data']>
141 })
142
143 app.get('/state', event => {
144 const auth = event.req.headers.get('authorization')
145 if (!validateToken(auth)) {
146 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
147 }
148
149 return {
150 success: true,
151 data: {
152 npmUser: state.session.npmUser,
153 avatar: state.session.avatar,
154 operations: state.operations,
155 },
156 } satisfies ApiResponse<ConnectorEndpoints['GET /state']['data']>
157 })
158
159 app.post('/operations', async event => {
160 const auth = event.req.headers.get('authorization')
161 if (!validateToken(auth)) {
162 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
163 }
164
165 const rawBody = await event.req.json()
166 const parsed = safeParse(CreateOperationBodySchema, rawBody)
167 if (!parsed.success) {
168 throw new HTTPError({ statusCode: 400, message: parsed.error })
169 }
170
171 const { type, params, description, command } = parsed.data
172
173 // Validate params based on operation type
174 try {
175 validateOperationParams(type, params)
176 } catch (err) {
177 const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err)
178 throw new HTTPError({ statusCode: 400, message: `Invalid params: ${message}` })
179 }
180
181 const operation: PendingOperation = {
182 id: generateOperationId(),
183 type,
184 params,
185 description,
186 command,
187 status: 'pending',
188 createdAt: Date.now(),
189 }
190
191 state.operations.push(operation)
192
193 return {
194 success: true,
195 data: operation,
196 } satisfies ApiResponse<ConnectorEndpoints['POST /operations']['data']>
197 })
198
199 app.post('/operations/batch', async event => {
200 const auth = event.req.headers.get('authorization')
201 if (!validateToken(auth)) {
202 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
203 }
204
205 const rawBody = await event.req.json()
206 const parsed = safeParse(BatchOperationsBodySchema, rawBody)
207 if (!parsed.success) {
208 throw new HTTPError({ statusCode: 400, message: parsed.error })
209 }
210
211 // Validate each operation's params
212 for (let i = 0; i < parsed.data.length; i++) {
213 const op = parsed.data[i]
214 if (!op) continue
215 try {
216 validateOperationParams(op.type, op.params)
217 } catch (err) {
218 const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err)
219 throw new HTTPError({
220 statusCode: 400,
221 message: `Operation ${i}: Invalid params: ${message}`,
222 })
223 }
224 }
225
226 const created: PendingOperation[] = []
227 for (const op of parsed.data) {
228 const operation: PendingOperation = {
229 id: generateOperationId(),
230 type: op.type,
231 params: op.params,
232 description: op.description,
233 command: op.command,
234 status: 'pending',
235 createdAt: Date.now(),
236 }
237 state.operations.push(operation)
238 created.push(operation)
239 }
240
241 return {
242 success: true,
243 data: created,
244 } satisfies ApiResponse<ConnectorEndpoints['POST /operations/batch']['data']>
245 })
246
247 app.post('/approve', event => {
248 const auth = event.req.headers.get('authorization')
249 if (!validateToken(auth)) {
250 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
251 }
252
253 const url = new URL(event.req.url)
254 const id = url.searchParams.get('id')
255
256 const idValidation = safeParse(OperationIdSchema, id)
257 if (!idValidation.success) {
258 throw new HTTPError({ statusCode: 400, message: idValidation.error })
259 }
260
261 const operation = state.operations.find(op => op.id === idValidation.data)
262 if (!operation) {
263 throw new HTTPError({ statusCode: 404, message: 'Operation not found' })
264 }
265
266 if (operation.status !== 'pending') {
267 throw new HTTPError({
268 statusCode: 400,
269 message: 'Operation is not pending',
270 })
271 }
272
273 operation.status = 'approved'
274
275 return {
276 success: true,
277 data: operation,
278 } satisfies ApiResponse<ConnectorEndpoints['POST /approve']['data']>
279 })
280
281 app.post('/approve-all', event => {
282 const auth = event.req.headers.get('authorization')
283 if (!validateToken(auth)) {
284 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
285 }
286
287 const pendingOps = state.operations.filter(op => op.status === 'pending')
288 for (const op of pendingOps) {
289 op.status = 'approved'
290 }
291
292 return {
293 success: true,
294 data: { approved: pendingOps.length },
295 } satisfies ApiResponse<ConnectorEndpoints['POST /approve-all']['data']>
296 })
297
298 app.post('/retry', event => {
299 const auth = event.req.headers.get('authorization')
300 if (!validateToken(auth)) {
301 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
302 }
303
304 const url = new URL(event.req.url)
305 const id = url.searchParams.get('id')
306
307 const idValidation = safeParse(OperationIdSchema, id)
308 if (!idValidation.success) {
309 throw new HTTPError({ statusCode: 400, message: idValidation.error })
310 }
311
312 const operation = state.operations.find(op => op.id === idValidation.data)
313 if (!operation) {
314 throw new HTTPError({ statusCode: 404, message: 'Operation not found' })
315 }
316
317 if (operation.status !== 'failed') {
318 throw new HTTPError({
319 statusCode: 400,
320 message: 'Only failed operations can be retried',
321 })
322 }
323
324 // Reset the operation for retry
325 operation.status = 'approved'
326 operation.result = undefined
327
328 return {
329 success: true,
330 data: operation,
331 } satisfies ApiResponse<ConnectorEndpoints['POST /retry']['data']>
332 })
333
334 app.post('/execute', async event => {
335 const auth = event.req.headers.get('authorization')
336 if (!validateToken(auth)) {
337 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
338 }
339
340 // OTP, interactive flag, and openUrls can be passed in the request body
341 let otp: string | undefined
342 let interactive = false
343 let openUrls = false
344 try {
345 const rawBody = await event.req.json()
346 if (rawBody) {
347 const parsed = safeParse(ExecuteBodySchema, rawBody)
348 if (!parsed.success) {
349 throw new HTTPError({ statusCode: 400, message: parsed.error })
350 }
351 otp = parsed.data.otp
352 interactive = parsed.data.interactive ?? false
353 openUrls = parsed.data.openUrls ?? false
354 }
355 } catch (err) {
356 // Re-throw HTTPError, ignore JSON parse errors (empty body is fine)
357 if (err instanceof HTTPError) throw err
358 }
359
360 const approvedOps = state.operations.filter(op => op.status === 'approved')
361 const results: Array<{ id: string; result: NpmExecResult }> = []
362 let otpRequired = false
363 const completedIds = new Set<string>()
364 const failedIds = new Set<string>()
365
366 // Collect all URLs across all operations in this execution batch
367 const allUrls: string[] = []
368
369 // Execute operations in waves, respecting dependencies
370 // Each wave contains operations whose dependencies are satisfied
371 while (true) {
372 // Find operations ready to run (no pending dependencies)
373 const readyOps = approvedOps.filter(op => {
374 // Already processed
375 if (completedIds.has(op.id) || failedIds.has(op.id)) return false
376 // No dependency - ready
377 if (!op.dependsOn) return true
378 // Dependency completed successfully - ready
379 if (completedIds.has(op.dependsOn)) return true
380 // Dependency failed - skip this one too
381 if (failedIds.has(op.dependsOn)) {
382 op.status = 'failed'
383 op.result = {
384 stdout: '',
385 stderr: 'Skipped: dependency failed',
386 exitCode: 1,
387 }
388 failedIds.add(op.id)
389 results.push({ id: op.id, result: op.result })
390 return false
391 }
392 // Dependency still pending - not ready
393 return false
394 })
395
396 // No more operations to run
397 if (readyOps.length === 0) break
398
399 // If we've hit an OTP error and no OTP was provided, stop
400 if (otpRequired && !otp) break
401
402 // Execute ready operations in parallel
403 const runningOps = readyOps.map(async op => {
404 op.status = 'running'
405 const result = await executeOperation(op, { otp, interactive, openUrls })
406 op.result = result
407 op.authUrl = undefined
408 op.status = result.exitCode === 0 ? 'completed' : 'failed'
409
410 if (result.exitCode === 0) {
411 completedIds.add(op.id)
412 } else {
413 failedIds.add(op.id)
414 }
415
416 // Track if OTP is needed
417 if (result.requiresOtp) {
418 otpRequired = true
419 }
420
421 // Collect URLs from this operation's output
422 if (result.urls && result.urls.length > 0) {
423 allUrls.push(...result.urls)
424 }
425
426 results.push({ id: op.id, result })
427 })
428
429 await Promise.all(runningOps)
430 }
431
432 // Check if any operation had an auth failure
433 const authFailure = results.some(r => r.result.authFailure)
434
435 const urls = [...new Set(allUrls)]
436
437 return {
438 success: true,
439 data: {
440 results,
441 otpRequired,
442 authFailure,
443 urls: urls.length > 0 ? urls : undefined,
444 },
445 } satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']>
446 })
447
448 app.delete('/operations', event => {
449 const auth = event.req.headers.get('authorization')
450 if (!validateToken(auth)) {
451 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
452 }
453
454 const url = new URL(event.req.url)
455 const id = url.searchParams.get('id')
456
457 const idValidation = safeParse(OperationIdSchema, id)
458 if (!idValidation.success) {
459 throw new HTTPError({ statusCode: 400, message: idValidation.error })
460 }
461
462 const index = state.operations.findIndex(op => op.id === idValidation.data)
463 if (index === -1) {
464 throw new HTTPError({ statusCode: 404, message: 'Operation not found' })
465 }
466
467 const operation = state.operations[index]
468 if (!operation || operation.status === 'running') {
469 throw new HTTPError({
470 statusCode: 400,
471 message: 'Cannot cancel running operation',
472 })
473 }
474
475 state.operations.splice(index, 1)
476
477 return { success: true } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations']['data']>
478 })
479
480 app.delete('/operations/all', event => {
481 const auth = event.req.headers.get('authorization')
482 if (!validateToken(auth)) {
483 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
484 }
485
486 const removed = state.operations.filter(op => op.status !== 'running').length
487 state.operations = state.operations.filter(op => op.status === 'running')
488
489 return {
490 success: true,
491 data: { removed },
492 } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations/all']['data']>
493 })
494
495 // List endpoints (read-only data fetching)
496
497 app.get('/org/:org/users', async event => {
498 const auth = event.req.headers.get('authorization')
499 if (!validateToken(auth)) {
500 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
501 }
502
503 const orgRaw = event.context.params?.org
504 const orgValidation = safeParse(OrgNameSchema, orgRaw)
505 if (!orgValidation.success) {
506 throw new HTTPError({ statusCode: 400, message: orgValidation.error })
507 }
508
509 const result = await orgListUsers(orgValidation.data)
510 if (result.exitCode !== 0) {
511 return {
512 success: false,
513 error: result.stderr || 'Failed to list org users',
514 } as ApiResponse
515 }
516
517 try {
518 const users = JSON.parse(result.stdout) as Record<string, 'developer' | 'admin' | 'owner'>
519 return {
520 success: true,
521 data: users,
522 } satisfies ApiResponse<ConnectorEndpoints['GET /org/:org/users']['data']>
523 } catch {
524 return {
525 success: false,
526 error: 'Failed to parse org users',
527 } as ApiResponse
528 }
529 })
530
531 app.get('/org/:org/teams', async event => {
532 const auth = event.req.headers.get('authorization')
533 if (!validateToken(auth)) {
534 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
535 }
536
537 const orgRaw = event.context.params?.org
538 const orgValidation = safeParse(OrgNameSchema, orgRaw)
539 if (!orgValidation.success) {
540 throw new HTTPError({ statusCode: 400, message: orgValidation.error })
541 }
542
543 const result = await teamListTeams(orgValidation.data)
544 if (result.exitCode !== 0) {
545 return {
546 success: false,
547 error: result.stderr || 'Failed to list teams',
548 } as ApiResponse
549 }
550
551 try {
552 const teams = JSON.parse(result.stdout) as string[]
553 return {
554 success: true,
555 data: teams,
556 } satisfies ApiResponse<ConnectorEndpoints['GET /org/:org/teams']['data']>
557 } catch {
558 return {
559 success: false,
560 error: 'Failed to parse teams',
561 } as ApiResponse
562 }
563 })
564
565 app.get('/team/:scopeTeam/users', async event => {
566 const auth = event.req.headers.get('authorization')
567 if (!validateToken(auth)) {
568 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
569 }
570
571 const scopeTeamRaw = event.context.params?.scopeTeam
572 if (!scopeTeamRaw) {
573 throw new HTTPError({ statusCode: 400, message: 'Team name required' })
574 }
575
576 // Decode the team name (handles encoded colons like nuxt%3Adevelopers)
577 const scopeTeam = decodeURIComponent(scopeTeamRaw)
578
579 const validationResult = safeParse(ScopeTeamSchema, scopeTeam)
580 if (!validationResult.success) {
581 logError('scope:team validation failed')
582 logDebug(validationResult.error, { scopeTeamRaw, scopeTeam })
583 throw new HTTPError({
584 statusCode: 400,
585 message: `Invalid scope:team format: ${scopeTeam}. Expected @scope:team`,
586 })
587 }
588
589 const result = await teamListUsers(scopeTeam)
590 if (result.exitCode !== 0) {
591 return {
592 success: false,
593 error: result.stderr || 'Failed to list team users',
594 } as ApiResponse
595 }
596
597 try {
598 const users = JSON.parse(result.stdout) as string[]
599 return {
600 success: true,
601 data: users,
602 } satisfies ApiResponse<ConnectorEndpoints['GET /team/:scopeTeam/users']['data']>
603 } catch {
604 return {
605 success: false,
606 error: 'Failed to parse team users',
607 } as ApiResponse
608 }
609 })
610
611 app.get('/package/:pkg/collaborators', async event => {
612 const auth = event.req.headers.get('authorization')
613 if (!validateToken(auth)) {
614 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
615 }
616
617 const pkgRaw = event.context.params?.pkg
618 if (!pkgRaw) {
619 throw new HTTPError({ statusCode: 400, message: 'Package name required' })
620 }
621
622 // Decode the package name (handles scoped packages like @nuxt%2Fkit)
623 const decodedPkg = decodeURIComponent(pkgRaw)
624
625 const pkgValidation = safeParse(PackageNameSchema, decodedPkg)
626 if (!pkgValidation.success) {
627 throw new HTTPError({ statusCode: 400, message: pkgValidation.error })
628 }
629
630 const result = await accessListCollaborators(pkgValidation.data)
631 if (result.exitCode !== 0) {
632 return {
633 success: false,
634 error: result.stderr || 'Failed to list collaborators',
635 } as ApiResponse
636 }
637
638 try {
639 const collaborators = JSON.parse(result.stdout) as Record<string, 'read-only' | 'read-write'>
640 return {
641 success: true,
642 data: collaborators,
643 } satisfies ApiResponse<ConnectorEndpoints['GET /package/:pkg/collaborators']['data']>
644 } catch {
645 return {
646 success: false,
647 error: 'Failed to parse collaborators',
648 } as ApiResponse
649 }
650 })
651
652 // User-specific endpoints
653
654 app.get('/user/packages', async event => {
655 const auth = event.req.headers.get('authorization')
656 if (!validateToken(auth)) {
657 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
658 }
659
660 const npmUser = state.session.npmUser
661 if (!npmUser) {
662 return {
663 success: false,
664 error: 'Not logged in to npm',
665 } as ApiResponse
666 }
667
668 const result = await listUserPackages(npmUser)
669 if (result.exitCode !== 0) {
670 return {
671 success: false,
672 error: result.stderr || 'Failed to list user packages',
673 } as ApiResponse
674 }
675
676 try {
677 // npm access list packages returns { "packageName": "read-write" | "read-only" }
678 const packages = JSON.parse(result.stdout) as Record<string, 'read-write' | 'read-only'>
679 return {
680 success: true,
681 data: packages,
682 } satisfies ApiResponse<ConnectorEndpoints['GET /user/packages']['data']>
683 } catch {
684 return {
685 success: false,
686 error: 'Failed to parse user packages',
687 } as ApiResponse
688 }
689 })
690
691 app.get('/user/orgs', async event => {
692 const auth = event.req.headers.get('authorization')
693 if (!validateToken(auth)) {
694 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
695 }
696
697 const npmUser = state.session.npmUser
698 if (!npmUser) {
699 return {
700 success: false,
701 error: 'Not logged in to npm',
702 } as ApiResponse
703 }
704
705 // Get user's packages and extract org names from scoped packages
706 const result = await listUserPackages(npmUser)
707 if (result.exitCode !== 0) {
708 return {
709 success: false,
710 error: result.stderr || 'Failed to list user packages',
711 } as ApiResponse
712 }
713
714 try {
715 const packages = JSON.parse(result.stdout) as Record<string, string>
716 const orgs = new Set<string>()
717
718 // Extract org names from scoped packages (e.g., @myorg/mypackage -> myorg)
719 for (const pkgName of Object.keys(packages)) {
720 if (pkgName.startsWith('@')) {
721 const match = pkgName.match(/^@([^/]+)\//)
722 if (match && match[1]) {
723 // Exclude the user's own scope (personal packages)
724 if (match[1].toLowerCase() !== npmUser.toLowerCase()) {
725 orgs.add(match[1])
726 }
727 }
728 }
729 }
730
731 return {
732 success: true,
733 data: Array.from(orgs).sort(),
734 } satisfies ApiResponse<ConnectorEndpoints['GET /user/orgs']['data']>
735 } catch {
736 return {
737 success: false,
738 error: 'Failed to parse user orgs',
739 } as ApiResponse
740 }
741 })
742
743 return app
744}
745
746async function executeOperation(
747 op: PendingOperation,
748 options: { otp?: string; interactive?: boolean; openUrls?: boolean } = {},
749): Promise<NpmExecResult> {
750 const { type, params } = op
751
752 // Build exec options that get passed through to execNpm, which
753 // internally routes to either execFile or PTY-based execution.
754 const execOptions: ExecNpmOptions = {
755 otp: options.otp,
756 interactive: options.interactive,
757 openUrls: options.openUrls,
758 onAuthUrl: options.interactive
759 ? url => {
760 // Set authUrl on the operation so /state exposes it to the
761 // frontend while npm is still polling for authentication.
762 op.authUrl = url
763 }
764 : undefined,
765 }
766
767 let result: NpmExecResult
768
769 switch (type) {
770 case 'org:add-user':
771 case 'org:set-role':
772 result = await orgAddUser(
773 params.org,
774 params.user,
775 params.role as 'developer' | 'admin' | 'owner',
776 execOptions,
777 )
778 break
779 case 'org:rm-user':
780 result = await orgRemoveUser(params.org, params.user, execOptions)
781 break
782 case 'team:create':
783 result = await teamCreate(params.scopeTeam, execOptions)
784 break
785 case 'team:destroy':
786 result = await teamDestroy(params.scopeTeam, execOptions)
787 break
788 case 'team:add-user':
789 result = await teamAddUser(params.scopeTeam, params.user, execOptions)
790 break
791 case 'team:rm-user':
792 result = await teamRemoveUser(params.scopeTeam, params.user, execOptions)
793 break
794 case 'access:grant':
795 result = await accessGrant(
796 params.permission as 'read-only' | 'read-write',
797 params.scopeTeam,
798 params.pkg,
799 execOptions,
800 )
801 break
802 case 'access:revoke':
803 result = await accessRevoke(params.scopeTeam, params.pkg, execOptions)
804 break
805 case 'owner:add':
806 result = await ownerAdd(params.user, params.pkg, execOptions)
807 break
808 case 'owner:rm':
809 result = await ownerRemove(params.user, params.pkg, execOptions)
810 break
811 case 'package:init':
812 // package:init has its own special execution path (temp dir + publish)
813 // and does not support interactive mode
814 result = await packageInit(params.name, params.author, options.otp)
815 break
816 default:
817 return {
818 stdout: '',
819 stderr: `Unknown operation type: ${type}`,
820 exitCode: 1,
821 }
822 }
823
824 // Extract URLs from output if not already populated
825 if (!result.urls) {
826 const urls = extractUrls((result.stdout || '') + '\n' + (result.stderr || ''))
827 if (urls.length > 0) result.urls = urls
828 }
829
830 return result
831}
832
833export { generateToken }