@preventive/upstream 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict'
2
+ import { Buffer } from 'node:buffer'
3
+
4
+ import {
5
+ assertArgs, assertBoolean, assertLine, assertLogin, assertPath, assertRef, assertRepo, assertRepoName, assertSha, assertText,
6
+ optional, printable,
7
+ } from '../args.js'
8
+ import { HttpError, decode, readBody, send } from '../http.js'
9
+ import { api, bindMethods, call, clientHeaders, repoApi } from './client.js'
10
+ import { getRepoHead, readMethods } from './read.js'
11
+
12
+ const CREATE_COMMIT_MUTATION = `mutation($input: CreateCommitOnBranchInput!) {
13
+ createCommitOnBranch(input: $input) {
14
+ commit { oid url }
15
+ }
16
+ }`
17
+
18
+ function toBase64(value, what) {
19
+ // Buffer.from would put U+FFFD in for a lone surrogate, and commit other bytes than asked.
20
+ assert.ok((typeof value === 'string' && value.isWellFormed()) || value instanceof Uint8Array, `createCommit: ${what} must be a well-formed string or a Uint8Array, got ${typeof value === 'string' ? 'a malformed string' : typeof value}`)
21
+ return Buffer.from(value).toString('base64')
22
+ }
23
+
24
+ function commitMessage(message) {
25
+ if (typeof message === 'string') {
26
+ assertLine('createCommit', 'message', message)
27
+ return { headline: message }
28
+ }
29
+ assertArgs('createCommit', message, { headline: assertLine, body: optional(assertText) }, 'message')
30
+ return { headline: message.headline, ...(message.body && { body: message.body }) }
31
+ }
32
+
33
+ function fileChanges(additions = [], deletions = []) {
34
+ assert.ok(Array.isArray(additions), 'createCommit: additions must be an array')
35
+ assert.ok(Array.isArray(deletions), 'createCommit: deletions must be an array')
36
+ const seen = new Set()
37
+ const once = (path, what) => {
38
+ assertPath('createCommit', what, path)
39
+ assert.ok(!seen.has(path), `createCommit: ${what} names ${JSON.stringify(path)} a second time`)
40
+ seen.add(path)
41
+ return path
42
+ }
43
+ return {
44
+ additions: additions.map((addition, i) => {
45
+ assertArgs('createCommit', addition, { path: null, contents: null }, `additions[${i}]`)
46
+ return { path: once(addition.path, `additions[${i}].path`), contents: toBase64(addition.contents, `additions[${i}].contents`) }
47
+ }),
48
+ deletions: deletions.map((deletion, i) => {
49
+ if (typeof deletion === 'string') return { path: once(deletion, `deletions[${i}]`) }
50
+ assertArgs('createCommit', deletion, { path: null }, `deletions[${i}]`)
51
+ return { path: once(deletion.path, `deletions[${i}].path`) }
52
+ }),
53
+ }
54
+ }
55
+
56
+ function assertHead(method, what, head) {
57
+ const colon = typeof head === 'string' ? head.indexOf(':') : -1
58
+ if (colon === -1) return assertRef(method, what, head)
59
+ assertLogin(method, `${what} owner`, head.slice(0, colon))
60
+ assertRef(method, `${what} branch`, head.slice(colon + 1))
61
+ }
62
+
63
+ // Exported to test the error paths without a fetch mock.
64
+ export function parseGraphQLResponse(status, text) {
65
+ if (status < 200 || status >= 300) {
66
+ throw new HttpError(status, `GitHub GraphQL ${status}: ${printable(text.slice(0, 4096)) || '(empty body)'}`)
67
+ }
68
+ let json
69
+ try { json = JSON.parse(text) } catch (err) {
70
+ throw new Error(`GitHub GraphQL ${status}: malformed JSON response (${err.message}): ${printable(text.slice(0, 200))}`, { cause: err })
71
+ }
72
+ if (json.errors) throw new Error(`GitHub GraphQL: ${printable(JSON.stringify(json.errors))}`)
73
+ return json.data
74
+ }
75
+
76
+ // A signed commit; the branch must already exist.
77
+ async function createCommitOnBranch(headers, input) {
78
+ const res = await send(api(['graphql']), { method: 'POST', headers, body: { query: CREATE_COMMIT_MUTATION, variables: { input } }, as: 'json' })
79
+ return parseGraphQLResponse(res.status, decode(await readBody(res, 1024 * 1024), 'GitHub GraphQL')).createCommitOnBranch.commit
80
+ }
81
+
82
+ async function forkRepo(headers, options) {
83
+ assertArgs('forkRepo', options, { repo: assertRepo, name: optional(assertRepoName), organization: optional(assertLogin), defaultBranchOnly: optional(assertBoolean) })
84
+ const { repo, name, organization, defaultBranchOnly } = options
85
+ const body = { ...(name && { name }), ...(organization && { organization }), ...(defaultBranchOnly && { default_branch_only: true }) }
86
+ return await call(headers, repoApi(repo, ['forks']), { method: 'POST', body })
87
+ }
88
+
89
+ async function createBranch(headers, options) {
90
+ assertArgs('createBranch', options, { repo: assertRepo, branch: assertRef, oid: optional(assertSha) })
91
+ const { repo, branch } = options
92
+ const sha = options.oid ?? (await getRepoHead(headers, { repo })).oid
93
+ return await call(headers, repoApi(repo, ['git', 'refs']), { method: 'POST', body: { ref: `refs/heads/${branch}`, sha } })
94
+ }
95
+
96
+ async function createCommit(headers, options) {
97
+ assertArgs('createCommit', options, { repo: assertRepo, branch: assertRef, message: null, additions: null, deletions: null, expectedHeadOid: optional(assertSha) })
98
+ const { repo, branch } = options
99
+ const changes = fileChanges(options.additions, options.deletions)
100
+ const message = commitMessage(options.message)
101
+ const expectedHeadOid = options.expectedHeadOid ?? (await getRepoHead(headers, { repo, branch })).oid
102
+ return await createCommitOnBranch(headers, { branch: { repositoryNameWithOwner: repo, branchName: branch }, message, fileChanges: changes, expectedHeadOid })
103
+ }
104
+
105
+ async function createPR(headers, options) {
106
+ assertArgs('createPR', options, { repo: assertRepo, title: assertLine, body: optional(assertText), head: assertHead, base: assertRef, draft: optional(assertBoolean) })
107
+ const { repo, title, body, head, base, draft } = options
108
+ const payload = { title, head, base, ...(body && { body }), ...(draft && { draft: true }) }
109
+ return await call(headers, repoApi(repo, ['pulls']), { method: 'POST', body: payload })
110
+ }
111
+
112
+ export const createWriteClient = (options) => bindMethods(clientHeaders('createWriteClient', options, false), { ...readMethods, forkRepo, createBranch, createCommit, createPR })
package/src/http.js ADDED
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { matches, printable } from './args.js'
4
+
5
+ // Every request goes through here, to a URL from buildUrl, which has to
6
+ // come back out of `new URL()` unchanged: no segment can then be read as
7
+ // `..` (`%2e%2e` included), a query or a fragment.
8
+
9
+ export const NPM_REGISTRY = 'https://registry.npmjs.org'
10
+ export const GITHUB_API = 'https://api.github.com'
11
+ export const OSV_API = 'https://api.osv.dev'
12
+ export const CRATES_API = 'https://crates.io'
13
+ export const PACKAGIST_REPO = 'https://repo.packagist.org'
14
+ export const MAX_BYTES = 512 * 1024 * 1024
15
+
16
+ const ORIGINS = new Set([NPM_REGISTRY, GITHUB_API, OSV_API, CRATES_API, PACKAGIST_REPO])
17
+ const isSegment = matches(/^(?!\.\.?$)(?:[\w.~@-]|%[\dA-F]{2})+$/u)
18
+ const isQueryKey = matches(/^[a-z_]+(?:\[\])?$/u)
19
+ const isQueryValue = (value) => (typeof value === 'string' && value !== '') || (Number.isSafeInteger(value) && value >= 0)
20
+ // Only a `key[]` repeats, and it always does.
21
+ const isQuery = ([key, value]) => isQueryKey(key) && (key.endsWith('[]') ? Array.isArray(value) && value.length > 0 && value.every(isQueryValue) : isQueryValue(value))
22
+ // A line break in a value would start a header of its own.
23
+ const isHeader = ([name, value]) => /^[A-Za-z][\w-]*$/u.test(name) && matches(/^[ -~]*$/u)(value)
24
+ const LIMITS = {
25
+ json: { bytes: 64 * 1024 * 1024, ms: 30_000 },
26
+ text: { bytes: 128 * 1024 * 1024, ms: 30_000 },
27
+ bytes: { bytes: MAX_BYTES, ms: 300_000 },
28
+ }
29
+ const decoder = new TextDecoder('utf-8', { fatal: true })
30
+
31
+ export class HttpError extends Error {
32
+ constructor(status, message) {
33
+ super(message)
34
+ this.name = 'HttpError'
35
+ this.status = status
36
+ }
37
+ }
38
+
39
+ export function decode(bytes, from) {
40
+ try {
41
+ return decoder.decode(bytes)
42
+ } catch (err) {
43
+ throw new Error(`Malformed UTF-8 from ${from}`, { cause: err })
44
+ }
45
+ }
46
+
47
+ // encodeURIComponent leaves `!'()*` as they are.
48
+ export function encodeSegment(value) {
49
+ assert.equal(typeof value, 'string')
50
+ return encodeURIComponent(value).replace(/[!'()*]/gu, (char) => `%${char.codePointAt(0).toString(16).toUpperCase()}`)
51
+ }
52
+
53
+ export function buildUrl(origin, segments, query = {}) {
54
+ assert.ok(ORIGINS.has(origin), `Unexpected origin: ${origin}`)
55
+ assert.ok(segments.length > 0 && segments.every(isSegment), `Unexpected URL path segment in ${JSON.stringify(segments)}`)
56
+ assert.ok(Object.entries(query).every(isQuery), `Unexpected query parameter in ${JSON.stringify(query)}`)
57
+ const search = new URLSearchParams(Object.entries(query).flatMap(([key, value]) => [value].flat().map((item) => [key, item]))).toString()
58
+ const href = `${origin}/${segments.join('/')}${search && `?${search}`}`
59
+ assert.equal(new URL(href).href, href, `URL changed in parsing: ${href}`)
60
+ return href
61
+ }
62
+
63
+ // Redirects aren't followed unless asked: an answer about another repo is
64
+ // worse than an error.
65
+ export async function send(url, { method = 'GET', headers = {}, body, redirect = 'manual', as } = {}) {
66
+ const parsed = typeof url === 'string' ? URL.parse(url) : null
67
+ assert.ok(parsed?.href === url && ORIGINS.has(parsed.origin) && !parsed.username && !parsed.password && !parsed.hash, `Unexpected URL: ${printable(url)}`)
68
+ assert.ok(['GET', 'POST'].includes(method), `Unexpected method: ${method}`)
69
+ assert.ok(['manual', 'follow'].includes(redirect), `Unexpected redirect mode: ${redirect}`)
70
+ assert.ok(Object.hasOwn(LIMITS, as), `Unexpected response type: ${as}`)
71
+ assert.ok(Object.entries(headers).every(isHeader), 'Unexpected header')
72
+ const json = body === undefined ? {} : { headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
73
+ return await fetch(url, { method, headers, redirect, signal: AbortSignal.timeout(LIMITS[as].ms), ...json })
74
+ }
75
+
76
+ export async function readBody(res, limit, { truncate = false } = {}) {
77
+ const declared = Number(res.headers.get('content-length'))
78
+ if (!truncate && declared > limit) {
79
+ await res.body?.cancel()
80
+ throw new Error(`Response too large: ${declared} bytes, over ${limit}`)
81
+ }
82
+ const chunks = []
83
+ let size = 0
84
+ for await (const chunk of res.body ?? []) {
85
+ chunks.push(chunk)
86
+ size += chunk.byteLength
87
+ if (size <= limit) continue
88
+ if (!truncate) throw new Error(`Response too large: over ${limit} bytes`)
89
+ break
90
+ }
91
+ return new Uint8Array(await new Blob(chunks).arrayBuffer(), 0, Math.min(size, limit))
92
+ }
93
+
94
+ // Read as the caller says, not by content type, which proxies drop or
95
+ // rewrite.
96
+ export async function request(url, options) {
97
+ const res = await send(url, options)
98
+ if (!res.ok) {
99
+ // A stream decode holds back a character cut at the limit rather than refusing it.
100
+ const text = await readBody(res, 4096, { truncate: true }).then((bytes) => new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })).catch(() => '')
101
+ throw new HttpError(res.status, `${options.method ?? 'GET'} ${url} ${res.status}: ${printable(text)}`)
102
+ }
103
+ const bytes = await readBody(res, LIMITS[options.as].bytes)
104
+ if (options.as === 'bytes') return bytes
105
+ const text = decode(bytes, url)
106
+ if (options.as === 'text') return text
107
+ try {
108
+ return JSON.parse(text)
109
+ } catch (err) {
110
+ throw new Error(`Malformed JSON from ${url}: ${printable(text.slice(0, 200))}`, { cause: err })
111
+ }
112
+ }
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
4
+ import { readCacheJSON, writeCacheJSON } from '../cache.js'
5
+ import { HttpError, NPM_REGISTRY, buildUrl, request } from '../http.js'
6
+ import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
7
+ import { pool } from '../pool.js'
8
+
9
+ const DIR = 'npm/repos'
10
+ const TTL_MS = 30 * 24 * 60 * 60 * 1000 // A link only moves on a transfer or rename, and GitHub redirects those.
11
+ const CONCURRENCY = 8
12
+
13
+ // `latest`, not the full packument, which is megabytes of version history.
14
+ async function fetchRepo(method, name) {
15
+ const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), 'latest']), { as: 'json' })
16
+ assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
17
+ return getRepo(json)
18
+ }
19
+
20
+ export async function getGitHub(name) {
21
+ assertPackageName('getGitHub', 'name', name)
22
+ const link = await fetchRepo('getGitHub', name)
23
+ assert.ok(link.github, `getGitHub: no GitHub repo for ${name}`)
24
+ return link
25
+ }
26
+
27
+ // An entry without `directory` predates the field: a miss, not a package
28
+ // at the repo root.
29
+ export async function readPackageRepoCache(name) {
30
+ assertPackageName('readPackageRepoCache', 'name', name)
31
+ const entry = await readCacheJSON(DIR, `${name}.json`)
32
+ const age = typeof entry?.at === 'number' ? Date.now() - entry.at : Number.NaN
33
+ const fresh = age >= 0 && age <= TTL_MS // An entry from the future is not fresh forever.
34
+ if (!fresh || entry.name !== name || !isRepo(entry.github) || !isRepoDirectory(entry.directory)) return null
35
+ return { github: entry.github, ...(entry.directory && { directory: entry.directory }) }
36
+ }
37
+
38
+ // Only resolved repos are cached: a 404, a rate limit and a package with
39
+ // no repo link fail alike.
40
+ export async function writePackageRepoCache(name, github, directory = '') {
41
+ assertPackageName('writePackageRepoCache', 'name', name)
42
+ assertRepo('writePackageRepoCache', 'github', github)
43
+ assertRepoDirectory('writePackageRepoCache', 'directory', directory)
44
+ return await writeCacheJSON(DIR, `${name}.json`, { at: Date.now(), name, github, directory })
45
+ }
46
+
47
+ export async function resolvePackageRepos(packageNames, options = {}) {
48
+ assert.ok(typeof packageNames?.[Symbol.iterator] === 'function' && typeof packageNames !== 'string', 'resolvePackageRepos: packageNames must be an iterable of names')
49
+ assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
50
+ const names = [...new Set(packageNames)]
51
+ for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
52
+ const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
53
+ return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
54
+ }
55
+
56
+ // Through the cache. Null for a package the registry does not have, or
57
+ // one naming no GitHub repo; any other failure throws.
58
+ export async function lookUpPackageRepo(name) {
59
+ const stored = await readPackageRepoCache(name)
60
+ if (stored) return stored
61
+ const { github, directory } = await fetchRepo('lookUpPackageRepo', name).catch((err) => {
62
+ if (err instanceof HttpError && err.status === 404) return {}
63
+ throw err
64
+ })
65
+ if (!github) return null
66
+ await writePackageRepoCache(name, github, directory)
67
+ return { github, ...(directory && { directory }) }
68
+ }
@@ -0,0 +1,115 @@
1
+ import assert from 'node:assert/strict'
2
+ import { Buffer } from 'node:buffer'
3
+ import { createHash } from 'node:crypto'
4
+ import { readFile, stat } from 'node:fs/promises'
5
+ import { homedir } from 'node:os'
6
+ import { join, resolve } from 'node:path'
7
+
8
+ import { assertArgs, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
9
+ import { readCache, writeCache } from '../cache.js'
10
+ import { MAX_BYTES, NPM_REGISTRY, buildUrl, request } from '../http.js'
11
+
12
+ const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
13
+ // One sha512 and nothing else, as the registry writes it: a sha1, a second
14
+ // hash or an option is refused rather than trusted. 64 bytes leave the
15
+ // last character before `==` two bits, so only A, Q, g or w is canonical.
16
+ const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
17
+ const sha512 = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
18
+
19
+ function assertPackage(method, name, version) {
20
+ assertPackageName(method, 'name', name)
21
+ assertPackageVersion(method, 'version', version)
22
+ }
23
+
24
+ function tarballUrl(name, version) {
25
+ const segments = name.split('/')
26
+ return buildUrl(NPM_REGISTRY, [...segments, '-', `${segments.at(-1)}-${version}.tgz`])
27
+ }
28
+
29
+ // `{ tarball, integrity }` and nothing else, the tarball exactly the
30
+ // registry's own URL for that version. Each field is read once, and only
31
+ // as the object's own, into a copy that is checked and used from then on:
32
+ // the caller's object can change while a request is out, and a field on
33
+ // its prototype is not one it has.
34
+ function checkedDist(method, name, version, dist) {
35
+ assertArgs(method, dist, { tarball: null, integrity: null }, 'dist')
36
+ const [tarball, integrity] = ['tarball', 'integrity'].map((key) => (Object.hasOwn(dist, key) ? dist[key] : undefined))
37
+ assertIntegrity(method, 'dist.integrity', integrity)
38
+ const expected = tarballUrl(name, version)
39
+ assert.ok(tarball === expected, `${method}: dist.tarball must be ${expected}, got ${show(tarball)}`)
40
+ return { tarball, integrity }
41
+ }
42
+
43
+ async function getDist(method, name, version) {
44
+ const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), version]), { as: 'json' })
45
+ assert.ok(json?.name === name && json.version === version, `${method}: the registry answered for ${show(json?.name)}@${show(json?.version)}, not ${name}@${version}`)
46
+ return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
47
+ }
48
+
49
+ function assertBytes(bytes, integrity, what) {
50
+ const actual = sha512(bytes)
51
+ assert.ok(actual === integrity, `getTarball: integrity mismatch for ${what}: expected ${integrity}, got ${actual}`)
52
+ }
53
+
54
+ // Where npm keeps its cache, short of an .npmrc moving it: npm_config_cache,
55
+ // as npm sets it for what it runs, else npm's default. On Windows, npm 4
56
+ // and before defaulted to %APPDATA%, later ones to %LOCALAPPDATA%.
57
+ function npmCacheDirs() {
58
+ const configured = process.env.npm_config_cache || process.env.NPM_CONFIG_CACHE
59
+ if (configured) return [resolve(configured.replace(/^~(?=[/\\])/u, homedir()))]
60
+ if (process.platform !== 'win32') return [join(homedir(), '.npm')]
61
+ return [process.env.LOCALAPPDATA, process.env.APPDATA].map((dir) => join(dir || homedir(), 'npm-cache'))
62
+ }
63
+
64
+ // Only a regular file: a FIFO or a device would block or never end.
65
+ async function readRegularFile(path) {
66
+ const stats = await stat(path).catch(() => null)
67
+ if (!stats?.isFile() || stats.size > MAX_BYTES) return null
68
+ return await readFile(path).then((bytes) => new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), () => null)
69
+ }
70
+
71
+ // npm 5+ (cacache) files a tarball by its sha512, npm 4 and before as
72
+ // <name>/<version>/package.tgz; ~/.audit as <org>:<name>-<version>.tgz.
73
+ async function readLocalCaches(name, version, integrity) {
74
+ const hex = Buffer.from(integrity.slice('sha512-'.length), 'base64').toString('hex')
75
+ const npm = npmCacheDirs().flatMap((root) => [join(root, '_cacache/content-v2/sha512', hex.slice(0, 2), hex.slice(2, 4), hex.slice(4)), join(root, name, version, 'package.tgz')])
76
+ const audit = join(homedir(), '.audit/cache/tgz', `${name.replace(/^@/u, '').replace('/', ':')}-${version}.tgz`)
77
+ for (const path of [...npm, audit]) {
78
+ const bytes = await readRegularFile(path)
79
+ if (bytes && sha512(bytes) === integrity) return bytes
80
+ }
81
+ return null
82
+ }
83
+
84
+ export async function getMeta(name, version) {
85
+ assertPackage('getMeta', name, version)
86
+ return { name, version, dist: await getDist('getMeta', name, version) }
87
+ }
88
+
89
+ export async function verifyDist(name, version, dist) {
90
+ assertPackage('verifyDist', name, version)
91
+ const given = checkedDist('verifyDist', name, version, dist)
92
+ const { integrity } = await getDist('verifyDist', name, version)
93
+ assert.ok(given.integrity === integrity, `verifyDist: ${name}@${version} is ${integrity} on the registry, not ${given.integrity}`)
94
+ }
95
+
96
+ // Without `dist`, the version document is read every time, cache or not:
97
+ // bytes from a cache are checked against the registry's integrity, never
98
+ // against anything a cache itself holds. A mismatch in ours throws rather
99
+ // than fetching over it.
100
+ export async function getTarball(name, version, dist) {
101
+ assertPackage('getTarball', name, version)
102
+ dist = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
103
+ const local = await readLocalCaches(name, version, dist.integrity)
104
+ if (local) return local
105
+ const key = `${name}@${version}.tgz`
106
+ const cached = await readCache(DIR, key)
107
+ if (cached) {
108
+ assertBytes(cached, dist.integrity, `${name}@${version} from the cache`)
109
+ return cached
110
+ }
111
+ const bytes = await request(dist.tarball, { as: 'bytes' })
112
+ assertBytes(bytes, dist.integrity, `${name}@${version} from ${dist.tarball}`)
113
+ await writeCache(DIR, key, bytes)
114
+ return bytes
115
+ }
package/src/package.js ADDED
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { assertion, isRepo, isRepoPath, sameName } from './args.js'
4
+ import { githubRepoOfUrl } from './remote.js'
5
+
6
+ const bugsRegex = /^(?i:https?:\/\/github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+)\/issues\/?$/u
7
+ // npm's `owner/name` shorthand means GitHub. No dots in the owner, so a
8
+ // domain (`srvx.h3.dev/srvx`) isn't read as one; another forge's prefix
9
+ // (`gitlab:`) can't match.
10
+ const shorthandRegex = /^(?:github:)?(?<repo>[\w-]+\/[\w.-]+)$/u
11
+ // The ref is one segment: a branch with a `/` reads as part of the directory.
12
+ const homepageRegex = /^(?:https?:\/\/)?(?:www\.)?github\.com(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?(?:\/(?:tree\/[^/]+(?:\/(?<directory>.*))?)?)?$/iu
13
+ const str = (value) => (typeof value === 'string' ? value : '')
14
+ const urlOf = (field) => str(field?.url ?? field)
15
+ const homepageUrl = (homepage) => str(homepage).trim().split(/[?#]/u)[0] // npm appends `#readme`.
16
+ const repoIn = (regex, text) => regex.exec(text)?.groups.repo
17
+ // A URL's path spells a tree path percent-encoded; one that does not decode names none.
18
+ function decodePath(path = '') {
19
+ try {
20
+ return decodeURIComponent(path)
21
+ } catch {}
22
+ }
23
+
24
+ // npm lets a repository name a commit-ish after `#`; it is not part of the repo.
25
+ function repositoryRepo(url) {
26
+ const repo = url.replace(/#.*$/su, '')
27
+ return repoIn(shorthandRegex, repo) ?? githubRepoOfUrl(repo)
28
+ }
29
+
30
+ // Spaces are kept: git allows them at either end of a name.
31
+ function repoSubdirectory(value) {
32
+ const path = str(value).replace(/^(?:\.\/|\/)+/u, '').replace(/\/+$/u, '')
33
+ return isRepoPath(path) ? path : undefined // No empty, `.`, `..` or `.git` part: a path inside the repo.
34
+ }
35
+
36
+ export const isRepoDirectory = (value) => typeof value === 'string' && (value === '' || repoSubdirectory(value) === value)
37
+ export const assertRepoDirectory = assertion('a path inside the repository', isRepoDirectory)
38
+
39
+ export function getRepo(pkg) {
40
+ assert.ok(pkg && typeof pkg === 'object' && !Array.isArray(pkg), 'getRepo: expected a package.json object')
41
+ const { bugs, repository } = pkg
42
+ const declared = repositoryRepo(urlOf(repository))
43
+ const homepage = homepageRegex.exec(homepageUrl(pkg.homepage))?.groups
44
+ const github = [repoIn(bugsRegex, urlOf(bugs)), declared, homepage?.repo].find(isRepo)
45
+ if (github === undefined) return {}
46
+ const directory = (sameName(declared, github) && repoSubdirectory(repository?.directory))
47
+ || (sameName(homepage?.repo, github) && repoSubdirectory(decodePath(homepage.directory)))
48
+ return { github, ...(directory && { directory }), url: `https://github.com/${github}` }
49
+ }
package/src/pool.js ADDED
@@ -0,0 +1,12 @@
1
+ // `fn` over `items`, at most `limit` at a time; results in `items` order.
2
+ export async function pool(items, limit, fn) {
3
+ const results = []
4
+ let next = 0
5
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
6
+ while (next < items.length) {
7
+ const i = next++
8
+ results[i] = await fn(items[i])
9
+ }
10
+ }))
11
+ return results
12
+ }
package/src/remote.js ADDED
@@ -0,0 +1,15 @@
1
+ import { isRepo } from './args.js'
2
+
3
+ // `user:token@` is skipped, never returned. It is held to the characters
4
+ // RFC 3986 allows there — a `#`, `?` or `\` before the `@` ends the
5
+ // authority early, so `https://evil.example#@github.com/a/b` goes to
6
+ // evil.example — and URL parsing has to agree the host is github.com.
7
+ const gitUrlRegex = /^(?i:(?:git\+)?(?:https?|git|ssh)):\/\/(?:[\w.~%!$&'()*+,;=:-]*@)?(?i:github\.com)(?::\d{1,5})?\/(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?\/?$/u
8
+ const scpRegex = /^(?i:git\+ssh:\/\/)?git@(?i:github\.com):(?<repo>[\w-]+\/[\w.-]+?)(?:\.git)?$/u
9
+
10
+ export function githubRepoOfUrl(url) {
11
+ if (typeof url !== 'string') return undefined
12
+ const onGitHub = URL.parse(url.replace(/^git\+/iu, ''))?.hostname.toLowerCase() === 'github.com'
13
+ const repo = scpRegex.exec(url)?.groups.repo ?? (onGitHub ? gitUrlRegex.exec(url)?.groups.repo : undefined)
14
+ return isRepo(repo) ? repo : undefined
15
+ }
package/src/semver.js ADDED
@@ -0,0 +1,41 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createRequire } from 'node:module'
3
+ import { basename, dirname, resolve } from 'node:path'
4
+
5
+ // npm's own semver, borrowed from the npm next to node rather than added
6
+ // as a dependency: the same implementation `npm audit` uses.
7
+
8
+ // Plain releases that semver.valid answers unchanged, so it need not be
9
+ // loaded for them: no leading zeros, and at most 15 digits a part, under
10
+ // Number.MAX_SAFE_INTEGER. Anything else, or any call with options, goes
11
+ // to semver.
12
+ const PLAIN_RELEASE = /^(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})$/u
13
+
14
+ let found
15
+
16
+ function find() {
17
+ const argv0 = process.argv[0]
18
+ // Another host executable's `../lib` is not npm's.
19
+ if (!['node', 'node.exe'].includes(basename(argv0 ?? ''))) return null
20
+ const require = createRequire(import.meta.url)
21
+ // POSIX keeps npm in <prefix>/lib beside <prefix>/bin/node; Windows, beside node.exe.
22
+ for (const prefix of ['../lib', '.']) {
23
+ try {
24
+ const lib = require(resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver'))
25
+ if (['compare', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
26
+ } catch {}
27
+ }
28
+ return null
29
+ }
30
+
31
+ function semver() {
32
+ if (found === undefined) found = find()
33
+ assert.ok(found, 'semver: no npm beside node to borrow it from')
34
+ return found
35
+ }
36
+
37
+ export const satisfies = (...args) => semver().satisfies(...args)
38
+ export const validRange = (...args) => semver().validRange(...args)
39
+ export const compareVersions = (...args) => semver().compare(...args)
40
+ export const valid = (version, ...rest) => (rest.length === 0 && typeof version === 'string' && PLAIN_RELEASE.test(version) ? version : semver().valid(version, ...rest))
41
+ export const isExactVersion = (version) => typeof version === 'string' && valid(version) === version