├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── config ├── npm-organizations.yml └── npm-teams.yml ├── index.js ├── lib ├── index.js └── util │ ├── constants.js │ ├── find.js │ ├── interpolate.js │ └── types.js ├── license ├── npm.md ├── package.json ├── readme.md ├── script └── crawl.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: unifiedjs/beep-boop-beta@main 6 | with: 7 | repo-token: ${{secrets.GITHUB_TOKEN}} 8 | name: bb 9 | on: 10 | issues: 11 | types: [opened, reopened, edited, closed, labeled, unlabeled] 12 | pull_request_target: 13 | types: [opened, reopened, edited, closed, labeled, unlabeled] 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: actions/checkout@v4 6 | - uses: actions/setup-node@v4 7 | with: 8 | node-version: node 9 | - run: npm install 10 | - run: npm test 11 | - env: 12 | GITHUB_TOKEN: ${{secrets.GH_TOKEN}} 13 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} 14 | run: npm start 15 | name: main 16 | on: 17 | push: 18 | branches: 19 | - main 20 | schedule: 21 | - cron: '55 7 * * *' 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.d.ts.map 2 | *.d.ts 3 | *.tsbuildinfo 4 | .DS_Store 5 | config/unified*.yml 6 | node_modules/ 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | -------------------------------------------------------------------------------- /config/npm-organizations.yml: -------------------------------------------------------------------------------- 1 | # npm org config. 2 | admin: core/maintainer 3 | member: :orgTeam/maintainer 4 | orgs: 5 | - github: mdx-js 6 | npm: mdx-js 7 | unified: mdx 8 | - github: micromark 9 | npm: micromark 10 | unified: micromark 11 | - github: redotjs 12 | npm: redotjs 13 | unified: redot 14 | - github: rehypejs 15 | npm: rehypejs 16 | unified: rehype 17 | - github: remarkjs 18 | npm: remarkjs 19 | unified: remark 20 | - github: retextjs 21 | npm: retextjs 22 | unified: retext 23 | - github: syntax-tree 24 | npm: syntax-tree 25 | unified: syntax tree 26 | - github: unifiedjs 27 | npm: unifiedjs 28 | unified: unified 29 | - github: vfile 30 | npm: vfile 31 | unified: vfile 32 | owner: :orgTeam/lead 33 | packages: :org/* 34 | -------------------------------------------------------------------------------- /config/npm-teams.yml: -------------------------------------------------------------------------------- 1 | # List of npm teams. 2 | - description: '@:org members with read-only rights' 3 | member: :orgTeam/merger 4 | name: mergers 5 | permission: read-only 6 | - description: '@:org members with read-write rights' 7 | member: :orgTeam/releaser 8 | name: releasers 9 | permission: read-write 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {npmTools} from './lib/index.js' 2 | 3 | npmTools() 4 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {GraphqlResponseError, graphql as GraphQl} from '@octokit/graphql' 3 | * @import {Packument} from 'pacote' 4 | * @import {PackageJson} from 'type-fest' 5 | * @import {Context, Human, NpmOrgs, NpmOrg, NpmPermissionLong, NpmPermissionShort, NpmRole, NpmTeam, PackageManifest, Package, Repo, Team} from './util/types.js' 6 | */ 7 | 8 | /** 9 | * @typedef BranchRefData 10 | * @property {string | undefined} name 11 | * 12 | * @typedef DependencyGraphManifestsData 13 | * @property {Array} nodes 14 | * 15 | * @typedef EdgeData 16 | * @property {string} cursor 17 | * @property {NodeData} node 18 | * 19 | * @typedef NodeData 20 | * @property {boolean} isArchived 21 | * @property {string} name 22 | * @property {BranchRefData | undefined} defaultBranchRef 23 | * 24 | * @typedef NpmUserData 25 | * @property {unknown} cidr_whitelist 26 | * @property {string} created 27 | * @property {boolean} email_verified 28 | * @property {string} email 29 | * @property {string} freenode 30 | * @property {string} fullname 31 | * @property {string} github 32 | * @property {string} homepage 33 | * @property {string} name 34 | * @property {unknown} tfa 35 | * @property {string} twitter 36 | * @property {string} updated 37 | * 38 | * @typedef ObjectObject 39 | * @property {string} text 40 | * 41 | * @typedef ObjectRepository 42 | * @property {ObjectObject | undefined} object 43 | * 44 | * @typedef ObjectResponse 45 | * @property {ObjectRepository} repository 46 | * 47 | * @typedef OrganizationResponse 48 | * @property {OrganizationData} organization 49 | * 50 | * @typedef OrganizationData 51 | * @property {RepositoriesData} repositories 52 | * 53 | * @typedef PageInfoData 54 | * @property {boolean} hasNextPage 55 | * 56 | * @typedef RepositoriesData 57 | * @property {Array} edges 58 | * @property {PageInfoData} pageInfo 59 | * 60 | * @typedef RepositoryData 61 | * @property {DependencyGraphManifestsData} dependencyGraphManifests 62 | * 63 | * @typedef RepositoryResponse 64 | * @property {RepositoryData} repository 65 | */ 66 | 67 | import assert from 'node:assert/strict' 68 | import fs from 'node:fs/promises' 69 | import path from 'node:path' 70 | import process from 'node:process' 71 | import {graphql} from '@octokit/graphql' 72 | import chalk from 'chalk' 73 | import pSeries from 'p-series' 74 | import {fetch} from 'undici' 75 | import yaml from 'yaml' 76 | import {dependencyGraphAccept} from './util/constants.js' 77 | import {find} from './util/find.js' 78 | import {interpolate} from './util/interpolate.js' 79 | 80 | /** 81 | * @param {string} name 82 | * @returns {Promise} 83 | */ 84 | async function loadYaml(name) { 85 | const url = new URL('../config/' + name + '.yml', import.meta.url) 86 | const document = await fs.readFile(url, 'utf8') 87 | return yaml.parse(document) 88 | } 89 | 90 | /** 91 | * @returns {Promise} 92 | */ 93 | export async function npmTools() { 94 | // Note: ghToken needs `admin:org` and `repo` scopes. 95 | const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN 96 | // Note: npmToken must be granted by an owner of all orgs. 97 | const npmToken = process.env.NPM_TOKEN 98 | assert(ghToken, 'expected `ghToken` to be set') 99 | assert(npmToken, 'expected `npmToken` to be set') 100 | 101 | const npmUserResponse = await fetch( 102 | 'https://registry.npmjs.org/-/npm/v1/user', 103 | {headers: {Authorization: 'Bearer ' + npmToken}} 104 | ) 105 | 106 | const npmUserData = /** @type {NpmUserData} */ (await npmUserResponse.json()) 107 | const npmTokenOwner = npmUserData.name 108 | 109 | console.error(chalk.blue('ℹ') + ' authenticated as %s', npmTokenOwner) 110 | 111 | /** @type {Context} */ 112 | const context = { 113 | // Name of the whole collective. 114 | collective: 'unifiedjs', 115 | humans: /** @type {Array} */ (await loadYaml('unified-humans')), 116 | ghToken, 117 | npmOrgs: /** @type {NpmOrgs} */ (await loadYaml('npm-organizations')), 118 | npmTeams: /** @type {Array} */ (await loadYaml('npm-teams')), 119 | ghQuery: wrap( 120 | graphql.defaults({headers: {authorization: 'token ' + ghToken}}) 121 | ), 122 | npmTokenOwner, 123 | npmToken, 124 | teams: /** @type {Array} */ (await loadYaml('unified-teams')) 125 | } 126 | 127 | /** @type {Array<() => Promise>} */ 128 | const organizationTasks = [] 129 | 130 | for (const npmOrg of context.npmOrgs.orgs) { 131 | organizationTasks.push(async function () { 132 | await organizationRun(context, npmOrg) 133 | }) 134 | } 135 | 136 | await pSeries(organizationTasks) 137 | 138 | console.warn(chalk.green('✓') + ' done') 139 | } 140 | 141 | /** 142 | * @param {Context} context 143 | * @param {NpmOrg} npmOrg 144 | * @returns {Promise} 145 | */ 146 | async function organizationRun(context, npmOrg) { 147 | // Repos. 148 | 149 | /** @type {Array} */ 150 | const repositories = [] 151 | let done = false 152 | /** @type {string | undefined} */ 153 | let cursor 154 | 155 | console.warn(chalk.bold('repos') + ' for %s', npmOrg.github) 156 | 157 | while (!done) { 158 | const data = /** @type {OrganizationResponse} */ ( 159 | // eslint-disable-next-line no-await-in-loop 160 | await context.ghQuery( 161 | ` 162 | query($cursor: String, $org: String!) { 163 | organization(login: $org) { 164 | repositories(first: 100, after: $cursor) { 165 | edges { 166 | cursor 167 | node { 168 | defaultBranchRef { name } 169 | isArchived 170 | name 171 | } 172 | } 173 | pageInfo { hasNextPage } 174 | } 175 | } 176 | } 177 | `, 178 | {cursor, org: npmOrg.github} 179 | ) 180 | ) 181 | 182 | const repos = data.organization.repositories 183 | 184 | for (const edge of repos.edges) { 185 | repositories.push({ 186 | archived: edge.node.isArchived, 187 | defaultBranch: edge.node.defaultBranchRef?.name, 188 | name: edge.node.name 189 | }) 190 | 191 | cursor = edge.cursor 192 | } 193 | 194 | done = !repos.pageInfo.hasNextPage 195 | } 196 | 197 | /** @type {Array<() => Promise>} */ 198 | const repoTasks = [] 199 | /** @type {Array} */ 200 | const packages = [] 201 | 202 | for (const repo of repositories) { 203 | repoTasks.push(async function () { 204 | const results = await repoRun(context, repo, npmOrg) 205 | packages.push(...results) 206 | }) 207 | } 208 | 209 | await pSeries(repoTasks) 210 | 211 | const [orgPackageResponse, orgTeamResponse, orgUserResponse] = 212 | await Promise.all([ 213 | fetch( 214 | 'https://registry.npmjs.org/-/org/' + 215 | encodeURIComponent(npmOrg.npm) + 216 | '/package', 217 | {headers: {Authorization: 'Bearer ' + context.npmToken}} 218 | ), 219 | fetch( 220 | 'https://registry.npmjs.org/-/org/' + 221 | encodeURIComponent(npmOrg.npm) + 222 | '/team', 223 | {headers: {Authorization: 'Bearer ' + context.npmToken}} 224 | ), 225 | fetch( 226 | 'https://registry.npmjs.org/-/org/' + 227 | encodeURIComponent(npmOrg.npm) + 228 | '/user', 229 | {headers: {Authorization: 'Bearer ' + context.npmToken}} 230 | ) 231 | ]) 232 | 233 | if (!orgPackageResponse.ok || !orgUserResponse.ok) { 234 | console.warn( 235 | ' ' + 236 | chalk.blue('ℹ') + 237 | ' could not get packages or members, does the `%s` org exist?', 238 | npmOrg.npm 239 | ) 240 | return 241 | } 242 | 243 | if (!orgTeamResponse.ok) { 244 | console.warn( 245 | chalk.red('✖') + 246 | ' could not get teams for `%s`, make sure the current token’s user (`%s`) is an owner', 247 | npmOrg.npm, 248 | context.npmTokenOwner 249 | ) 250 | return 251 | } 252 | 253 | /** @type {Array<() => Promise>} */ 254 | const tasks = [] 255 | 256 | const orgPackage = /** @type {Record} */ ( 257 | await orgPackageResponse.json() 258 | ) 259 | const orgTeam = /** @type {Array} */ (await orgTeamResponse.json()) 260 | const orgUser = /** @type {Record} */ ( 261 | await orgUserResponse.json() 262 | ) 263 | 264 | // Packages. 265 | 266 | // Adding and permissions are based on teams. 267 | // See `team/index` for that. 268 | // But we can look for packages that need to be removed. 269 | for (const name of Object.keys(orgPackage)) { 270 | const info = packages.find(function (y) { 271 | return name === y.name 272 | }) 273 | 274 | if (!info) { 275 | tasks.push(async function () { 276 | const packageArchived = await deprecated(context, name) 277 | 278 | if (!packageArchived) { 279 | console.error( 280 | ' ' + 281 | chalk.red('✖') + 282 | ' npm package `%s` should not be in org `%s` (or it should be deprecated)', 283 | name, 284 | npmOrg.npm 285 | ) 286 | } 287 | }) 288 | } 289 | } 290 | 291 | // Members. 292 | const data = {npmOrg: npmOrg.npm, orgTeam: npmOrg.unified, org: npmOrg.github} 293 | const admins = find(context, interpolate(data, context.npmOrgs.admin)) 294 | const members = find(context, interpolate(data, context.npmOrgs.member)) 295 | const owners = find(context, interpolate(data, context.npmOrgs.owner)) 296 | const people = [...new Set([...admins, ...members, ...owners])] 297 | 298 | /** @type {Array} */ 299 | const expectedMembers = [] 300 | 301 | for (const github of people) { 302 | const human = context.humans.find(function (h) { 303 | return h.github === github 304 | }) 305 | assert(human, 'could not find human for `@' + github + '`') 306 | 307 | if (!human.npm) { 308 | console.warn( 309 | ' ' + chalk.red('✖') + ' member `@%s` is not on npm', 310 | human.github 311 | ) 312 | continue 313 | } 314 | 315 | expectedMembers.push(human.npm) 316 | 317 | const actualRole = Object.hasOwn(orgUser, human.npm) 318 | ? orgUser[human.npm] 319 | : undefined 320 | const expectedRole = 321 | owners.includes(github) || human.npm === context.npmTokenOwner 322 | ? 'owner' 323 | : admins.includes(github) 324 | ? 'admin' 325 | : 'developer' 326 | 327 | if (actualRole === expectedRole) { 328 | continue 329 | } 330 | 331 | if ( 332 | (actualRole === 'owner' && 333 | (expectedRole === 'admin' || expectedRole === 'developer')) || 334 | (actualRole === 'admin' && expectedRole === 'developer') 335 | ) { 336 | console.error( 337 | ' ' + 338 | chalk.red('✖') + 339 | ' unexpected user `%s` with more rights (`%s`) than expected (`%s`), ignoring', 340 | human.npm, 341 | actualRole, 342 | expectedRole 343 | ) 344 | continue 345 | } 346 | 347 | // Update or add: both the same request. 348 | tasks.push(async function () { 349 | try { 350 | await fetch( 351 | 'https://registry.npmjs.org/-/org/' + 352 | encodeURIComponent(npmOrg.npm) + 353 | '/user', 354 | { 355 | body: JSON.stringify({role: expectedRole, user: human.npm}), 356 | headers: {Authorization: 'Bearer ' + context.npmToken}, 357 | method: 'PUT' 358 | } 359 | ) 360 | 361 | console.warn( 362 | ' ' + chalk.green('✔') + ' add npm user `~%s` to `%s` as `%s`', 363 | human.npm, 364 | npmOrg.npm, 365 | expectedRole 366 | ) 367 | } catch { 368 | console.error( 369 | ' ' + 370 | chalk.red('✖') + 371 | ' could not add npm user `~%s` to `%s`, make sure the current token’s user (`%s`) is an owner of the org', 372 | human.npm, 373 | npmOrg.npm, 374 | context.npmTokenOwner 375 | ) 376 | } 377 | }) 378 | } 379 | 380 | // Remove. 381 | for (const login of Object.keys(orgUser)) { 382 | if (!expectedMembers.includes(login)) { 383 | console.error( 384 | ' ' + chalk.red('✖') + ' npm user `~%s` should not be in `%s`', 385 | login, 386 | npmOrg.npm 387 | ) 388 | } 389 | } 390 | 391 | // Teams. 392 | /** @type {Array} */ 393 | const existingTeams = [] 394 | 395 | for (const team of orgTeam) { 396 | const index = team.indexOf(':') 397 | assert(index !== -1) 398 | const orgName = team.slice(0, index) 399 | assert(orgName === npmOrg.npm) 400 | const teamName = team.slice(index + 1) 401 | existingTeams.push(teamName) 402 | } 403 | 404 | assert(context.npmTeams) 405 | 406 | for (const npmTeam of context.npmTeams) { 407 | tasks.push(async function () { 408 | // Create a team if it doesn’t already exist. 409 | if (!existingTeams.includes(npmTeam.name)) { 410 | await fetch( 411 | 'https://registry.npmjs.org/-/org/' + 412 | encodeURIComponent(npmOrg.npm) + 413 | '/team', 414 | { 415 | body: JSON.stringify({ 416 | // Note: there’s no way to get the description on the npm API, 417 | // so we can’t update that if it’s incorrect. 418 | description: interpolate(context, npmTeam.description), 419 | name: npmTeam.name 420 | }), 421 | headers: {Authorization: 'Bearer ' + context.npmToken}, 422 | method: 'PUT' 423 | } 424 | ) 425 | 426 | console.info( 427 | ' ' + chalk.green('✓') + ' team %s created', 428 | npmTeam.name 429 | ) 430 | } 431 | 432 | await teamRun(context, npmOrg, packages, npmTeam) 433 | }) 434 | } 435 | 436 | // Wait for all tasks. 437 | await pSeries(tasks) 438 | } 439 | 440 | /** 441 | * @param {Context} context 442 | * @param {NpmOrg} npmOrg 443 | * @param {Repo} repo 444 | * @param {string} manifestFilename 445 | * @returns {Promise} 446 | */ 447 | async function packageRun(context, npmOrg, repo, manifestFilename) { 448 | // Get `package.json`. 449 | 450 | const target = (repo.defaultBranch || 'master') + ':' + manifestFilename 451 | /** @type {string | undefined} */ 452 | let objectText 453 | 454 | try { 455 | const response = /** @type {ObjectResponse} */ ( 456 | await context.ghQuery( 457 | ` 458 | query($name: String!, $org: String!, $target: String!) { 459 | repository(name: $name, owner: $org) { 460 | object(expression: $target) { 461 | ... on Blob { text } 462 | } 463 | } 464 | } 465 | `, 466 | {name: repo.name, org: npmOrg.github, target} 467 | ) 468 | ) 469 | 470 | objectText = response.repository.object?.text 471 | } catch (error) { 472 | console.warn( 473 | ' ' + chalk.blue('ℹ') + ' could not request package at `%s`', 474 | target, 475 | error 476 | ) 477 | } 478 | 479 | /** @type {PackageJson | undefined} */ 480 | let packageData 481 | 482 | if (objectText) { 483 | try { 484 | packageData = JSON.parse(objectText) 485 | } catch { 486 | console.warn( 487 | ' ' + chalk.blue('ℹ') + ' package at `%s` is invalid', 488 | target 489 | ) 490 | } 491 | } 492 | 493 | if (!packageData || !packageData.name || packageData.private) { 494 | console.warn( 495 | ' ' + chalk.blue('ℹ') + ' package %s at `%s` is private', 496 | packageData?.name ? '`' + packageData.name + '`' : 'without name', 497 | target 498 | ) 499 | return 500 | } 501 | 502 | // Get collaborators. 503 | 504 | const response = await fetch( 505 | 'https://registry.npmjs.org/-/package/' + 506 | encodeURIComponent(packageData.name) + 507 | '/collaborators', 508 | {headers: {Authorization: 'Bearer ' + context.npmToken}} 509 | ) 510 | 511 | if (!response.ok) { 512 | console.warn( 513 | ' ' + 514 | chalk.red('✖') + 515 | ' could not get collaborators for `%s`. Is it published?', 516 | packageData.name 517 | ) 518 | return 519 | } 520 | 521 | const actualCollaborators = 522 | /** @type {Record} */ (await response.json()) 523 | 524 | /** @type {Record} */ 525 | const permissions = {} 526 | /** @type {Record} */ 527 | const score = {'read-only': 0, 'read-write': 1} 528 | 529 | for (const team of context.npmTeams) { 530 | const members = find( 531 | context, 532 | interpolate( 533 | { 534 | npmOrg: npmOrg.npm, 535 | orgTeam: npmOrg.unified, 536 | org: npmOrg.github 537 | }, 538 | team.member 539 | ) 540 | ) 541 | 542 | for (const login of members) { 543 | const permission = Object.hasOwn(permissions, login) 544 | ? permissions[login] 545 | : undefined 546 | 547 | if (permission) { 548 | if (score[team.permission] > score[permission]) { 549 | permissions[login] = team.permission 550 | } 551 | } else { 552 | permissions[login] = team.permission 553 | } 554 | } 555 | } 556 | 557 | /** @type {Array} */ 558 | const expectedCollaborators = [] 559 | 560 | for (const github of Object.keys(permissions)) { 561 | const permission = permissions[github] 562 | const human = context.humans.find(function (h) { 563 | return h.github === github 564 | }) 565 | 566 | // We don’t need to warn for this as it’ll be warned about on the org level. 567 | if (human && human.npm) { 568 | const actualPermission = Object.hasOwn(actualCollaborators, human.npm) 569 | ? actualCollaborators[human.npm] 570 | : undefined 571 | const expectedPermission = permission === 'read-write' ? 'write' : 'read' 572 | 573 | // Different permissions. 574 | if (actualPermission === undefined) { 575 | console.warn( 576 | ' wait what? Some collaborator is missing? %s, %s, %j', 577 | human.name, 578 | packageData.name, 579 | actualCollaborators 580 | ) 581 | } else if (actualPermission !== expectedPermission) { 582 | console.error( 583 | ' ' + chalk.red('✖') + ' ~%s should not have %s rights in `%s`', 584 | human.name, 585 | actualPermission, 586 | packageData.name 587 | ) 588 | } 589 | 590 | expectedCollaborators.push(human.npm) 591 | } 592 | } 593 | 594 | // Remove. 595 | for (const actual of Object.keys(actualCollaborators)) { 596 | if (!expectedCollaborators.includes(actual)) { 597 | console.error( 598 | ' ' + chalk.red('✖') + ' ~%s should not be a collaborator on `%s`', 599 | actual, 600 | packageData.name 601 | ) 602 | } 603 | } 604 | 605 | // We don’t do missing here: people are added to teams, so they’ll be warned 606 | // about in the team pipeline. 607 | const packageArchived = await deprecated(context, packageData.name) 608 | 609 | if (repo.archived) { 610 | if (!packageArchived) { 611 | console.error( 612 | ' ' + 613 | chalk.red('✖') + 614 | 'unexpected undeprecated package `%s` in archived repo `%s`', 615 | packageData.name, 616 | repo.name 617 | ) 618 | } 619 | } else if (packageArchived) { 620 | console.info( 621 | ' ' + 622 | chalk.blue('ℹ') + 623 | ' unexpected deprecated package `%s` in unarchived repo `%s`', 624 | packageData.name, 625 | repo.name 626 | ) 627 | } 628 | 629 | return {name: packageData.name, repo: repo.name} 630 | } 631 | 632 | /** 633 | * @param {Context} context 634 | * @param {Repo} repo 635 | * @param {NpmOrg} npmOrg 636 | * @returns {Promise>} 637 | */ 638 | async function repoRun(context, repo, npmOrg) { 639 | if (!repo.defaultBranch) { 640 | console.warn( 641 | ' ' + chalk.blue('ℹ') + ' repo %s has no branches yet', 642 | repo.name 643 | ) 644 | return [] 645 | } 646 | 647 | if (repo.archived) { 648 | console.warn(' ' + chalk.blue('ℹ') + ' repo %s is archived', repo.name) 649 | } 650 | 651 | /** @type {RepositoryResponse} */ 652 | let response 653 | 654 | try { 655 | response = /** @type {RepositoryResponse} */ ( 656 | await context.ghQuery( 657 | ` 658 | query($name: String!, $org: String!) { 659 | repository(name: $name, owner: $org) { 660 | dependencyGraphManifests { 661 | nodes { 662 | blobPath 663 | exceedsMaxSize 664 | filename 665 | parseable 666 | } 667 | } 668 | } 669 | } 670 | `, 671 | { 672 | headers: {Accept: dependencyGraphAccept}, 673 | name: repo.name, 674 | org: npmOrg.github 675 | } 676 | ) 677 | ) 678 | } catch { 679 | console.error( 680 | ' ' + 681 | chalk.red('✖') + 682 | ' could not get manifests for %s: maybe they are loading? (in which case, running this again will probably work)', 683 | repo.name 684 | ) 685 | 686 | return [] 687 | } 688 | 689 | const manifestNodes = response.repository.dependencyGraphManifests.nodes 690 | 691 | /** @type {Array} */ 692 | const packages = [] 693 | /** @type {Array<() => Promise>} */ 694 | const tasks = [] 695 | 696 | for (const manifest of manifestNodes) { 697 | // Only include `package.json`s, not locks. 698 | if (path.posix.basename(manifest.filename) !== 'package.json') { 699 | continue 700 | } 701 | 702 | if (manifest.exceedsMaxSize) { 703 | console.error( 704 | ' ' + chalk.red('✖') + ' manifest %s in %s is too big', 705 | manifest.filename, 706 | repo.name 707 | ) 708 | continue 709 | } 710 | 711 | if (!manifest.parseable) { 712 | console.error( 713 | ' ' + chalk.red('✖') + ' manifest %s in %s is not parseable', 714 | manifest.filename, 715 | repo.name 716 | ) 717 | continue 718 | } 719 | 720 | tasks.push(async function () { 721 | const result = await packageRun(context, npmOrg, repo, manifest.filename) 722 | 723 | if (result) { 724 | packages.push(result) 725 | } 726 | }) 727 | } 728 | 729 | console.warn(' ' + chalk.bold('packages') + ' for %s', repo.name) 730 | await pSeries(tasks) 731 | 732 | return packages 733 | } 734 | 735 | /** 736 | * @param {Context} context 737 | * @param {NpmOrg} npmOrg 738 | * @param {Array} npmOrgPackages 739 | * @param {NpmTeam} npmTeam 740 | * @returns {Promise} 741 | */ 742 | async function teamRun(context, npmOrg, npmOrgPackages, npmTeam) { 743 | console.info(' ' + chalk.bold('team') + ' %s', npmTeam.name) 744 | 745 | const [actualTeamResponse, actualPackagesResponse] = await Promise.all([ 746 | fetch( 747 | 'https://registry.npmjs.org/-/team/' + 748 | encodeURIComponent(npmOrg.npm) + 749 | '/' + 750 | encodeURIComponent(npmTeam.name) + 751 | '/user', 752 | {headers: {Authorization: 'Bearer ' + context.npmToken}} 753 | ), 754 | fetch( 755 | 'https://registry.npmjs.org/-/team/' + 756 | encodeURIComponent(npmOrg.npm) + 757 | '/' + 758 | encodeURIComponent(npmTeam.name) + 759 | '/package', 760 | {headers: {Authorization: 'Bearer ' + context.npmToken}} 761 | ) 762 | ]) 763 | 764 | // Team members. 765 | 766 | const actualTeam = /** @type {Array} */ ( 767 | await actualTeamResponse.json() 768 | ) 769 | 770 | /** @type {Array<() => Promise>} */ 771 | const tasks = [] 772 | 773 | const expectedTeamGithub = find( 774 | context, 775 | interpolate( 776 | { 777 | npmOrg: npmOrg.npm, 778 | orgTeam: npmOrg.unified, 779 | org: npmOrg.github 780 | }, 781 | npmTeam.member 782 | ) 783 | ) 784 | /** @type {Array} */ 785 | const expectedTeam = [] 786 | 787 | for (const github of expectedTeamGithub) { 788 | const human = context.humans.find(function (h) { 789 | return h.github === github 790 | }) 791 | 792 | // We don’t need to warn for this as it’ll be warned about on the org level. 793 | if (human && human.npm) { 794 | expectedTeam.push(human.npm) 795 | } 796 | } 797 | 798 | // Remove (manual). 799 | for (const login of actualTeam) { 800 | if (!expectedTeam.includes(login)) { 801 | console.info( 802 | ' ' + chalk.red('✖') + ' ~%s should not be in team %s', 803 | login, 804 | npmTeam.name 805 | ) 806 | } 807 | } 808 | 809 | // Add. 810 | for (const login of expectedTeam) { 811 | if (!actualTeam.includes(login)) { 812 | tasks.push(async function () { 813 | assert(npmOrg.npm) 814 | 815 | await fetch( 816 | 'https://registry.npmjs.org/-/team/' + 817 | encodeURIComponent(npmOrg.npm) + 818 | '/' + 819 | encodeURIComponent(npmTeam.name) + 820 | '/user', 821 | { 822 | body: JSON.stringify({user: login}), 823 | headers: {Authorization: 'Bearer ' + context.npmToken}, 824 | method: 'PUT' 825 | } 826 | ) 827 | 828 | console.info( 829 | ' ' + chalk.green('✔') + ' add ~%s to %s', 830 | login, 831 | npmTeam.name 832 | ) 833 | }) 834 | } 835 | } 836 | 837 | // Packages. 838 | 839 | const actualPackages = /** @type {Record} */ ( 840 | await actualPackagesResponse.json() 841 | ) 842 | 843 | // We don’t need to log for removing packages: each team manages all packages. 844 | // Packages that need to be removed are thus at the org level. 845 | // Logging for removal thus happens there. 846 | 847 | // Add packages / fix permissions. 848 | for (const packageInfo of npmOrgPackages) { 849 | const name = packageInfo.name 850 | /** @type {NpmPermissionLong | undefined} */ 851 | const actualPermission = Object.hasOwn(actualPackages, name) 852 | ? actualPackages[name] === 'write' 853 | ? 'read-write' 854 | : 'read-only' 855 | : undefined 856 | 857 | // Fine. 858 | if (actualPermission === npmTeam.permission) { 859 | continue 860 | } 861 | 862 | // Update or add: both the same request. 863 | tasks.push(async function () { 864 | assert(npmOrg.npm) 865 | 866 | try { 867 | await fetch( 868 | 'https://registry.npmjs.org/-/team/' + 869 | encodeURIComponent(npmOrg.npm) + 870 | '/' + 871 | encodeURIComponent(npmTeam.name) + 872 | '/package', 873 | { 874 | body: JSON.stringify({ 875 | package: name, 876 | permissions: npmTeam.permission 877 | }), 878 | headers: {Authorization: 'Bearer ' + context.npmToken}, 879 | method: 'PUT' 880 | } 881 | ) 882 | 883 | console.info( 884 | ' ' + chalk.green('✔') + ' add %s to %s with %s', 885 | name, 886 | npmTeam.name, 887 | npmTeam.permission 888 | ) 889 | } catch { 890 | console.info( 891 | ' ' + 892 | chalk.red('✖') + 893 | ' could not add %s to %s, make sure the current token’s user (%s) is an owner', 894 | name, 895 | npmTeam.name, 896 | context.npmTokenOwner 897 | ) 898 | } 899 | }) 900 | } 901 | 902 | // Wait for all tasks. 903 | await pSeries(tasks) 904 | } 905 | 906 | /** 907 | * @param {Context} context 908 | * @param {string} name 909 | * @returns {Promise} 910 | */ 911 | async function deprecated(context, name) { 912 | const manifestResponse = await fetch( 913 | 'https://registry.npmjs.org/' + encodeURIComponent(name), 914 | {headers: {Authorization: 'Bearer ' + context.npmToken}} 915 | ) 916 | const packument = /** @type {Packument} */ (await manifestResponse.json()) 917 | const version = packument.versions[packument['dist-tags'].latest] 918 | return 'deprecated' in version 919 | } 920 | 921 | /** 922 | * @param {GraphQl} internalFunction 923 | * @returns {GraphQl} 924 | */ 925 | function wrap(internalFunction) { 926 | // @ts-expect-error: fine. 927 | return wrappedFunction 928 | 929 | /** 930 | * @param {Parameters} parameters 931 | * @returns {ReturnType} 932 | */ 933 | function wrappedFunction(...parameters) { 934 | return attempt().catch(retry) 935 | 936 | /** 937 | * @returns {ReturnType} 938 | */ 939 | function attempt() { 940 | return internalFunction(...parameters) 941 | } 942 | 943 | /** 944 | * @param {unknown} error 945 | * @returns {Promise} 946 | */ 947 | function retry(error) { 948 | const exception = /** @type {GraphqlResponseError} */ (error) 949 | console.error('wrap err:', exception) 950 | console.error('to do:', 'does `status` really not exist?') 951 | const after = 952 | // @ts-expect-error: does `status` really not exist? 953 | exception && exception.status === 403 954 | ? exception.headers['retry-after'] 955 | : undefined 956 | 957 | if (!after) { 958 | throw exception 959 | } 960 | 961 | return new Promise(function (resolve, reject) { 962 | setTimeout(delayed, Number.parseInt(String(after), 10) * 1000) 963 | 964 | /** 965 | * @returns {undefined} 966 | */ 967 | function delayed() { 968 | attempt().then(resolve, reject) 969 | } 970 | }) 971 | } 972 | } 973 | } 974 | -------------------------------------------------------------------------------- /lib/util/constants.js: -------------------------------------------------------------------------------- 1 | export const dependencyGraphAccept = 2 | 'application/vnd.github.hawkgirl-preview+json' 3 | -------------------------------------------------------------------------------- /lib/util/find.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Context, Role} from './types.js' 3 | */ 4 | 5 | import assert from 'node:assert/strict' 6 | 7 | /** 8 | * @param {Context} context 9 | * Context. 10 | * @param {string} value 11 | * Value. 12 | * @returns {Array} 13 | * Names of humans. 14 | */ 15 | export function find(context, value) { 16 | let [name, role] = value.split('/') 17 | const team = context.teams.find(function (x) { 18 | return x.name === name 19 | }) 20 | /** @type {(x: unknown) => unknown} */ 21 | let modify = identity 22 | 23 | if (!team) { 24 | throw new Error('Could not find team `' + name + '`') 25 | } 26 | 27 | if (role.charAt(0) === '!') { 28 | modify = negate 29 | role = role.slice(1) 30 | } 31 | 32 | assert( 33 | role === 'contributor' || 34 | role === 'lead' || 35 | role === 'maintainer' || 36 | role === 'member' || 37 | role === 'merger' || 38 | role === 'releaser' 39 | ) 40 | 41 | const expanded = role === 'lead' ? undefined : expand(role) 42 | 43 | const {humans} = team 44 | 45 | return Object.keys(humans).filter(function (member) { 46 | return ( 47 | (expanded === undefined && modify(team.lead === member)) || 48 | (expanded !== undefined && modify(expanded.includes(humans[member]))) 49 | ) 50 | }) 51 | } 52 | 53 | /** 54 | * @param {Role} role 55 | * Role. 56 | * @returns {Array} 57 | * Expanded roles. 58 | */ 59 | function expand(role) { 60 | switch (role) { 61 | case 'contributor': { 62 | return [role, 'member'] 63 | } 64 | 65 | case 'merger': { 66 | return [role, 'maintainer', 'member'] 67 | } 68 | 69 | case 'releaser': { 70 | return [role, 'maintainer', 'member'] 71 | } 72 | 73 | case 'maintainer': { 74 | return ['merger', 'releaser', role, 'member'] 75 | } 76 | 77 | case 'member': { 78 | return ['contributor', 'merger', 'releaser', 'maintainer', role] 79 | } 80 | 81 | default: { 82 | throw new Error('Unknown role `' + role + '`') 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * @template Value 89 | * Value. 90 | * @param {Value} x 91 | * Value. 92 | * @returns {Value} 93 | * Value. 94 | */ 95 | function identity(x) { 96 | return x 97 | } 98 | 99 | /** 100 | * @param {unknown} x 101 | * Value. 102 | * @returns {boolean} 103 | * Negated value. 104 | */ 105 | function negate(x) { 106 | return !x 107 | } 108 | -------------------------------------------------------------------------------- /lib/util/interpolate.js: -------------------------------------------------------------------------------- 1 | import dlv from 'dlv' 2 | 3 | /** 4 | * @param {object} context 5 | * Object to interpolate values from. 6 | * @param {string} value 7 | * Value with interpolation patterns. 8 | * @returns {string} 9 | * Result. 10 | */ 11 | export function interpolate(context, value) { 12 | return value.replaceAll(/:([\w$.]+)/g, replace) 13 | 14 | /** 15 | * @param {string} _ 16 | * Whole. 17 | * @param {string} $1 18 | * Pattern. 19 | * @returns {string} 20 | * Replacement. 21 | */ 22 | function replace(_, $1) { 23 | return dlv(context, $1) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/util/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {graphql as GraphQl} from '@octokit/graphql' 3 | */ 4 | 5 | /** 6 | * @typedef Context 7 | * Context passed around. 8 | * @property {string} collective 9 | * Name of the collective (example: `unified`). 10 | * @property {GraphQl} ghQuery 11 | * Send a request to the GitHub GQL API. 12 | * @property {string} ghToken 13 | * GH token. 14 | * @property {Array} humans 15 | * All humans. 16 | * @property {NpmOrgs} npmOrgs 17 | * npm orgs. 18 | * @property {Array} npmTeams 19 | * Teams from `npm-teams.yml`. 20 | * @property {string} npmTokenOwner 21 | * Username of owner of `npmToken`. 22 | * @property {string} npmToken 23 | * npm token. 24 | * @property {Array} teams 25 | * All teams. 26 | * 27 | * @typedef Human 28 | * Person. 29 | * @property {string} email 30 | * Email address. 31 | * @property {string} github 32 | * GitHub handle. 33 | * @property {string} name 34 | * Name. 35 | * @property {string | undefined} [npm] 36 | * npm handle. 37 | * @property {string | undefined} [url] 38 | * URL. 39 | * 40 | * @typedef NpmOrgs 41 | * Tree structure representing npm access. 42 | * @property {string} admin 43 | * Glob-like pattern for admins. 44 | * @property {string} member 45 | * Glob-like pattern for members. 46 | * @property {Array} orgs 47 | * Orgs. 48 | * @property {string} owner 49 | * Glob-like pattern for owners. 50 | * @property {string} packages 51 | * Glob-like pattern for packages. 52 | * 53 | * @typedef NpmOrg 54 | * Organization. 55 | * @property {string} github 56 | * GitHub slug (example: `'unifiedjs'`). 57 | * @property {string} npm 58 | * npm slug (example: `'unifiedjs'`). 59 | * @property {string} unified 60 | * Name of org (example: `'unified'`). 61 | * 62 | * @typedef {'read-only' | 'read-write'} NpmPermissionLong 63 | * npm permissions. 64 | * 65 | * @typedef {'read' | 'write'} NpmPermissionShort 66 | * npm permissions. 67 | * 68 | * @typedef {'admin' | 'developer' | 'owner'} NpmRole 69 | * npm role. 70 | * 71 | * @typedef NpmTeam 72 | * npm team. 73 | * @property {string} description 74 | * Description. 75 | * @property {string} member 76 | * Glob-like pattern to match members. 77 | * @property {string} name 78 | * Name. 79 | * @property {NpmPermissionLong} permission 80 | * Permission. 81 | * 82 | * @typedef PackageManifest 83 | * @property {string} blobPath 84 | * Path to blob. 85 | * @property {boolean} exceedsMaxSize 86 | * Whether the manifest exeeds a certain size. 87 | * @property {string} filename 88 | * Filename. 89 | * @property {boolean} parseable 90 | * Whether the manifest is parseable. 91 | * 92 | * @typedef Package 93 | * Package. 94 | * @property {string} name 95 | * Name. 96 | * @property {string} repo 97 | * Repo slug. 98 | * 99 | * @typedef Repo 100 | * Repository. 101 | * @property {boolean} archived 102 | * Whether the repo is archived. 103 | * @property {string | undefined} defaultBranch 104 | * Default branch. 105 | * @property {string} name 106 | * Name. 107 | * 108 | * @typedef {'contributor' | 'maintainer' | 'member' | 'merger' | 'releaser'} Role 109 | * unified role. 110 | * 111 | * @typedef Team 112 | * unified team. 113 | * @property {boolean | undefined} [collective] 114 | * Whether this is a collective (overarching) team. 115 | * @property {Record} humans 116 | * Map of GH handles to roles (example: `{wooorm: 'maintainer'}`). 117 | * @property {string | undefined} [lead] 118 | * GH handle of team lead (when not a collective team) (example: `'wooorm'`). 119 | * @property {string} name 120 | * Name of unified team (example: `'remark'`, `'core'`). 121 | */ 122 | 123 | export {} 124 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /npm.md: -------------------------------------------------------------------------------- 1 | # npm registry docs 2 | 3 | The npm registry (api) isn’t documented very well. 4 | You can find some docs in [`npm/registry`][registry], but most of it can be best 5 | perused by reading code, such as [`libnpmaccess`][libnpmaccess]. 6 | 7 | …or read this document. 8 | 9 | ## Table of Contents 10 | 11 | * [Authentication](#authentication) 12 | * [Org](#org) 13 | * [List teams in an org](#list-teams-in-an-org) 14 | * [List packages in an org](#list-packages-in-an-org) 15 | * [List users in an org](#list-users-in-an-org) 16 | * [Add or change a user in an org](#add-or-change-a-user-in-an-org) 17 | * [Remove a user from an org](#remove-a-user-from-an-org) 18 | * [Team](#team) 19 | * [Add or change a team](#add-or-change-a-team) 20 | * [List users in a team](#list-users-in-a-team) 21 | * [Add a user to a team](#add-a-user-to-a-team) 22 | * [Remove a user from a team](#remove-a-user-from-a-team) 23 | * [List packages in a team](#list-packages-in-a-team) 24 | * [Add or change a package in a team](#add-or-change-a-package-in-a-team) 25 | * [Remove a package from a team](#remove-a-package-from-a-team) 26 | * [Packages](#packages) 27 | * [Get a package](#get-a-package) 28 | * [Get collaborators of a package](#get-collaborators-of-a-package) 29 | * [Set maintainers for a package](#set-maintainers-for-a-package) 30 | * [Set 2fa access of a package](#set-2fa-access-of-a-package) 31 | * [Users](#users) 32 | * [Get the current user (from the token)](#get-the-current-user-from-the-token) 33 | * [Get a user](#get-a-user) 34 | * [List packages for a user](#list-packages-for-a-user) 35 | 36 | ## Authentication 37 | 38 | First, make sure to set npm to use 2fa for **auth-only**. 39 | Proper 2fa doesn’t work well as you’d have to fill in OTPs all the time. 40 | 41 | ```sh 42 | npm profile enable-2fa auth-only 43 | ``` 44 | 45 | Then, create an npm token: 46 | 47 | ```sh 48 | npm token create 49 | ``` 50 | 51 | Store that somewhere in a dotenv. 52 | 53 | ## Org 54 | 55 | ### List teams in an org 56 | 57 | ```sh 58 | org=remarkjs 59 | 60 | curl "https://registry.npmjs.org/-/org/$org/team" \ 61 | -H "Authorization: Bearer $token" 62 | # ["remarkjs:developers","remarkjs:foo"] 63 | ``` 64 | 65 | ### List packages in an org 66 | 67 | ```sh 68 | org=remarkjs 69 | 70 | curl "https://registry.npmjs.org/-/org/$org/package" 71 | # {"remark":"write",…"remark-external-links":"write"} 72 | ``` 73 | 74 | ### List users in an org 75 | 76 | Only users the token can see are shown. 77 | 78 | ```sh 79 | org=remarkjs 80 | 81 | curl "https://registry.npmjs.org/-/org/$org/user" \ 82 | -H "Authorization: Bearer $token" 83 | # {"wooorm":"owner",…"murderlon":"admin"} 84 | ``` 85 | 86 | ### Add or change a user in an org 87 | 88 | ```sh 89 | org=remarkjs 90 | user="wooorm" 91 | role="owner" # "developer", "owner", or "admin" 92 | # See https://docs.npmjs.com/org-roles-and-permissions. 93 | 94 | curl "https://registry.npmjs.org/-/org/$org/user" \ 95 | -X PUT \ 96 | -H "Authorization: Bearer $token" \ 97 | -H "Content-Type: application/json" \ 98 | -d "{\"user\": \"$user\", \"role\": \"$role\"}" 99 | # {"org":{"name":"remarkjs","size":5},"user":"wooorm","role":"owner"} 100 | ``` 101 | 102 | ### Remove a user from an org 103 | 104 | ```sh 105 | org=remarkjs 106 | user="wooorm" 107 | 108 | curl "https://registry.npmjs.org/-/org/$org/user" \ 109 | -X DELETE \ 110 | -H "Authorization: Bearer $token" \ 111 | -H "Content-Type: application/json" \ 112 | -d "{\"user\": \"$user\"}" 113 | ``` 114 | 115 | ## Team 116 | 117 | ### Add or change a team 118 | 119 | ```sh 120 | org=remarkjs 121 | team=bar 122 | description=bravo 123 | 124 | curl "https://registry.npmjs.org/-/org/$org/team" \ 125 | -X PUT \ 126 | -H "Authorization: Bearer $token" \ 127 | -H "Content-Type: application/json" \ 128 | -d "{\"name\": \"$team\",\"description\": \"$description\"}" 129 | # {"name":"bar"} 130 | ``` 131 | 132 | ### List users in a team 133 | 134 | ```sh 135 | org=remarkjs 136 | team=developers 137 | 138 | curl "https://registry.npmjs.org/-/team/$org/$team/user" \ 139 | -H "Authorization: Bearer $token" 140 | # ["wooorm",…] 141 | ``` 142 | 143 | ### Add a user to a team 144 | 145 | ```sh 146 | org=remarkjs 147 | team=foo 148 | user=wooorm 149 | 150 | curl "https://registry.npmjs.org/-/team/$org/$team/user" \ 151 | -X PUT \ 152 | -H "Authorization: Bearer $token" \ 153 | -H "Content-Type: application/json" \ 154 | -d "{\"user\":\"$user\"}" 155 | # {} 156 | ``` 157 | 158 | ### Remove a user from a team 159 | 160 | ```sh 161 | org=remarkjs 162 | team=foo 163 | user=johno 164 | 165 | curl "https://registry.npmjs.org/-/team/$org/$team/user" \ 166 | -X DELETE \ 167 | -H "Authorization: Bearer $token" \ 168 | -H "Content-Type: application/json" \ 169 | -d "{\"user\":\"$user\"}" 170 | # empty 171 | ``` 172 | 173 | ### List packages in a team 174 | 175 | ```sh 176 | org=remarkjs 177 | team=developers 178 | 179 | curl "https://registry.npmjs.org/-/team/$org/$team/package" \ 180 | -H "Authorization: Bearer $token" 181 | # {"remark":"write",…"remark-external-links":"write"} 182 | ``` 183 | 184 | ### Add or change a package in a team 185 | 186 | ```sh 187 | org=remarkjs 188 | team=foo 189 | package=remark-parse 190 | permissions="read-write" # "read-only" or "read-write" 191 | 192 | curl "https://registry.npmjs.org/-/team/$org/$team/package" \ 193 | -X PUT \ 194 | -H "Authorization: Bearer $token" \ 195 | -H "Content-Type: application/json" \ 196 | -d "{\"package\": \"$package\", \"permissions\": \"$permissions\"}" 197 | # {} 198 | ``` 199 | 200 | ### Remove a package from a team 201 | 202 | ```sh 203 | org=remarkjs 204 | team=foo 205 | package=remark-parse 206 | 207 | curl "https://registry.npmjs.org/-/team/$org/$team/package" \ 208 | -X DELETE \ 209 | -H "Authorization: Bearer $token" \ 210 | -H "Content-Type: application/json" \ 211 | -d "{\"package\": \"$package\"}" 212 | # empty response 213 | ``` 214 | 215 | ## Packages 216 | 217 | ### Get a package 218 | 219 | ```sh 220 | package=remark-parse # Use "@foo%2bar" for scoped packages 221 | 222 | curl "https://registry.npmjs.org/$package" \ 223 | -H "Accept: application/vnd.npm.install-v1+json" # Remove for full metadata. 224 | # {"_id":"remark-parse","_rev":"35-c4b211558296c2be5fad20fd0a7b3b25","name":"remark-parse","maintainers":[…],…} 225 | ``` 226 | 227 | This can be used to find `maintainers`. 228 | 229 | ### Get collaborators of a package 230 | 231 | ```sh 232 | package=remark-parse 233 | 234 | curl "https://registry.npmjs.org/-/package/$package/collaborators" \ 235 | -H "Authorization: Bearer $token" 236 | # {"wooorm":"write",…} 237 | ``` 238 | 239 | ### Set maintainers for a package 240 | 241 | ```sh 242 | package=remark-parse 243 | rev=10-4193cf2ba92283e3e8fd605d75108054 244 | 245 | curl "https://registry.npmjs.org/$package/-rev/$rev" \ 246 | -X PUT \ 247 | -H "Authorization: Bearer $token" \ 248 | -H "Content-Type: application/json" \ 249 | -d "{ 250 | \"_id\": \"$package\", 251 | \"_rev\": \"$rev\", 252 | \"maintainers\": [ 253 | {\"email\":\"dev@vincentweevers.nl\",\"name\":\"vweevers\"}, 254 | {\"email\":\"tituswormer@gmail.com\",\"name\":\"wooorm\"} 255 | ] 256 | }" \ 257 | --verbose 258 | ``` 259 | 260 | ### Set 2fa access of a package 261 | 262 | ```sh 263 | package=remark-parse 264 | tfa=true # true or false 265 | 266 | curl "https://registry.npmjs.org/-/package/$package/access" \ 267 | -X POST \ 268 | -H "Authorization: Bearer $token" \ 269 | -H "Content-Type: application/json" \ 270 | -d "{\"publish_requires_tfa\": $tfa}" 271 | # empty 272 | ``` 273 | 274 | ## Users 275 | 276 | ### Get the current user (from the token) 277 | 278 | ```sh 279 | curl "https://registry.npmjs.org/-/npm/v1/user" \ 280 | -H "Authorization: Bearer $token" 281 | # {"tfa":{"pending":false,…"fullname":"Titus Wormer",…"twitter":"wooorm","github":"wooorm"} 282 | ``` 283 | 284 | ### Get a user 285 | 286 | ```sh 287 | user=wooorm 288 | 289 | curl "https://registry.npmjs.org/-/user/org.couchdb.user:$user" 290 | # {"_id":"org.couchdb.user:wooorm","email":"tituswormer@gmail.com","name":"wooorm"} 291 | ``` 292 | 293 | ### List packages for a user 294 | 295 | ```sh 296 | user=wooorm 297 | 298 | curl "https://registry.npmjs.org/-/user/$user/package" 299 | # {"retext-latin":"write",…"remark-bookmarks":"write"} 300 | ``` 301 | 302 | 303 | 304 | [libnpmaccess]: https://github.com/npm/libnpmaccess/blob/latest/index.js 305 | 306 | [registry]: https://github.com/npm/registry 307 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Titus Wormer (https://wooorm.com)", 3 | "bugs": "https://github.com/unifiedjs/npm-tools/issues", 4 | "contributors": [ 5 | "Titus Wormer (https://wooorm.com)" 6 | ], 7 | "description": "npm tools for unified", 8 | "dependencies": { 9 | "@octokit/graphql": "^8.0.0", 10 | "chalk": "^5.0.0", 11 | "dlv": "^1.0.0", 12 | "p-series": "^3.0.0", 13 | "undici": "^7.0.0", 14 | "yaml": "^2.0.0" 15 | }, 16 | "devDependencies": { 17 | "@types/dlv": "^1.0.0", 18 | "@types/node": "^22.0.0", 19 | "@types/pacote": "^11.0.0", 20 | "pacote": "^21.0.0", 21 | "prettier": "^3.0.0", 22 | "remark-cli": "^12.0.0", 23 | "remark-preset-wooorm": "^11.0.0", 24 | "type-coverage": "^2.0.0", 25 | "type-fest": "^4.0.0", 26 | "typescript": "^5.0.0", 27 | "xo": "^0.60.0" 28 | }, 29 | "files": [ 30 | "config/", 31 | "lib/", 32 | "index.js" 33 | ], 34 | "keywords": [], 35 | "license": "MIT", 36 | "main": "index.js", 37 | "name": "npm-tools", 38 | "prettier": { 39 | "bracketSpacing": false, 40 | "semi": false, 41 | "singleQuote": true, 42 | "tabWidth": 2, 43 | "trailingComma": "none", 44 | "useTabs": false 45 | }, 46 | "private": true, 47 | "remarkConfig": { 48 | "plugins": [ 49 | "remark-preset-wooorm" 50 | ] 51 | }, 52 | "repository": "unifiedjs/npm-tools", 53 | "scripts": { 54 | "build": "tsc --build --clean && tsc --build && type-coverage", 55 | "crawl": "node --conditions developments script/crawl.js", 56 | "format": "remark --frail --quiet --output -- . && prettier --log-level warn --write -- . && xo --fix", 57 | "start": "node --conditions developments index.js", 58 | "test": "npm run build && npm run crawl && npm run format" 59 | }, 60 | "typeCoverage": { 61 | "atLeast": 100, 62 | "strict": true 63 | }, 64 | "type": "module", 65 | "version": "0.0.0", 66 | "xo": { 67 | "prettier": true, 68 | "rules": { 69 | "complexity": "off" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # npm tools 2 | 3 | This projects manages the [unified][] collective organizations on npm: 4 | 5 | * check org members, admins, and owners: rights, missing, unexpected 6 | * check repos for the packages in them 7 | * check teams: missing 8 | * check team members: missing, unexpected 9 | * check team packages: missing, unexpected, rights 10 | * check package collaborators: unexpected, rights 11 | 12 | npm’s API is not really documented, see [`npm.md`][npm-md] for API details. 13 | 14 | These tools automatically add packages, teams, org members, and team members 15 | where needed, and warns about incorrectly configured entities. 16 | 17 | Most of this is hardcoded to work for unified. 18 | In the future we hope to allow other collectives to use this as well. 19 | 20 | These tools work well with our [`github-tools`][github-tools]. 21 | The plan is to merge them together in some pluggable way in the future. 22 | 23 | [github-tools]: https://github.com/unifiedjs/github-tools 24 | 25 | [npm-md]: npm.md 26 | 27 | [unified]: https://github.com/unifiedjs 28 | -------------------------------------------------------------------------------- /script/crawl.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import https from 'node:https' 4 | 5 | const base = 'https://raw.githubusercontent.com/unifiedjs/collective/HEAD/data/' 6 | 7 | get('humans.yml') 8 | get('teams.yml') 9 | 10 | /** 11 | * @param {string} filename 12 | * Name. 13 | * @returns {undefined} 14 | * Nothing. 15 | */ 16 | function get(filename) { 17 | https.get(base + filename, function (response) { 18 | response.pipe( 19 | fs.createWriteStream(path.join('config', 'unified-' + filename)) 20 | ) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declarationMap": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "lib": ["es2022"], 10 | "module": "node16", 11 | "strict": true, 12 | "target": "es2022" 13 | }, 14 | "exclude": ["coverage/", "node_modules/"], 15 | "include": ["**/*.js"] 16 | } 17 | --------------------------------------------------------------------------------