[READ-ONLY] a fast, modern browser for the npm registry
at main 833 lines 24 kB view raw
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 }