@preventive/upstream 1.0.0-alpha.4 → 1.0.0-alpha.6

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.
package/advisories.d.ts CHANGED
@@ -89,5 +89,5 @@ export interface Advisory {
89
89
  // renamed or blocked, add nothing. Any other failure, a malformed answer,
90
90
  // or one about something not asked, throws: nothing is left out quietly.
91
91
  // Sorted by ecosystem and name. Versions are matched by npm's semver, from
92
- // the npm beside node.
92
+ // the npm beside node, or by the semver peer where there is no npm.
93
93
  export function advisories(packages: Iterable<Package>, options?: AdvisoryOptions): Promise<Advisory[]>
package/npm.d.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  // cache path, otherwise. Responses are read up to a size limit and within
7
7
  // a timeout, as github.d.ts describes, and lookups for many names go to
8
8
  // the registry eight at a time.
9
+ //
10
+ // Where NPM_TOKEN is set, it is sent with the requests for a scoped
11
+ // package, so a private one can be read.
9
12
 
10
13
  // Where cached answers live, resolved when set. Unset by default, and
11
14
  // unset means no cache: every read misses and every write is skipped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.6",
4
4
  "description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
5
5
  "type": "module",
6
6
  "exports": {
@@ -78,11 +78,13 @@
78
78
  "src/github/read.js",
79
79
  "src/github/write.js",
80
80
  "src/http.js",
81
+ "src/npm/registry.js",
81
82
  "src/npm/repos.js",
82
83
  "src/npm/tarball.js",
83
84
  "src/package.js",
84
85
  "src/pool.js",
85
86
  "src/remote.js",
87
+ "src/semver-peer.cjs",
86
88
  "src/semver.js",
87
89
  "src/tree.js",
88
90
  "src/soldeer.js"
@@ -110,5 +112,13 @@
110
112
  "homepage": "https://github.com/PreventiveMeasures/libraries/tree/main/upstream",
111
113
  "publishConfig": {
112
114
  "access": "public"
115
+ },
116
+ "peerDependencies": {
117
+ "semver": "^7.5.2"
118
+ },
119
+ "peerDependenciesMeta": {
120
+ "semver": {
121
+ "optional": true
122
+ }
113
123
  }
114
124
  }
package/semver.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // Hand-written against semver.js; a change to either belongs with the other.
2
2
 
3
3
  // npm's own semver, borrowed from the npm beside node and passed through
4
- // as it is. Without it, every call throws, except valid() on a plain
4
+ // as it is; where there is no npm, the `semver` package, an optional peer
5
+ // dependency. Without either, every call throws, except valid() on a plain
5
6
  // MAJOR.MINOR.PATCH with no options.
6
7
  export function satisfies(version: string, range: string, options?: { includePrerelease?: boolean; loose?: boolean }): boolean
7
8
  export function compareVersions(a: string, b: string, options?: { loose?: boolean }): -1 | 0 | 1
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertArgs, assertBoolean, assertRepo, assertion, optional, sameName } from '../args.js'
3
+ import { assertArgs, assertBoolean, assertRepo, assertion, isIterable, optional, sameName } from '../args.js'
4
4
  import { order } from './common.js'
5
5
  import { GITHUB, SOLDEER, assertClient } from './github.js'
6
6
  import { NPM } from './npm.js'
@@ -14,7 +14,7 @@ const assertVersions = assertion('a non-empty array', (value) => Array.isArray(v
14
14
  // Ecosystem → name → { versions, github? }, a name given twice merged.
15
15
  // Every package is checked before any is used.
16
16
  function collect(packages) {
17
- assert.ok(typeof packages?.[Symbol.iterator] === 'function' && typeof packages !== 'string', 'advisories: packages must be an iterable of { ecosystem, name, github?, versions }')
17
+ assert.ok(isIterable(packages), 'advisories: packages must be an iterable of { ecosystem, name, github?, versions }')
18
18
  const byEcosystem = new Map()
19
19
  for (const pkg of packages) {
20
20
  assertArgs('advisories', pkg, { ecosystem: assertEcosystem, name: null, github: optional(assertRepo), versions: assertVersions }, 'package')
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { isRepo, show } from '../args.js'
4
- import { readRecord, writeRecord } from '../cache.js'
3
+ import { show } from '../args.js'
4
+ import { addRepos, readRepos } from '../cache.js'
5
5
  import { lookUpCrateRepos } from '../cargo/repos.js'
6
6
  import { PACKAGIST_REPO, SOLDEER_API, buildUrl, isNotFound, recover, request } from '../http.js'
7
7
  import { lookUpPackageRepo } from '../npm/repos.js'
@@ -14,16 +14,8 @@ const PACKAGES_AT_ONCE = 8
14
14
  // or it names no GitHub repo; any other failure throws. Only a repo found
15
15
  // is cached, for a month.
16
16
  async function cachedRepos(dir, names, fetchMissing) {
17
- const repos = new Map()
18
- for (const name of names) {
19
- const entry = await readRecord(dir, name)
20
- if (isRepo(entry?.github)) repos.set(name, entry.github)
21
- }
22
- const missing = names.filter((name) => !repos.has(name))
23
- for (const [name, github] of await fetchMissing(missing)) {
24
- repos.set(name, github)
25
- if (github) await writeRecord(dir, name, { github })
26
- }
17
+ const repos = await readRepos(dir, names)
18
+ await addRepos(dir, repos, await fetchMissing(names.filter((name) => !repos.has(name))))
27
19
  return repos
28
20
  }
29
21
 
package/src/args.js CHANGED
@@ -39,6 +39,7 @@ export function isRepo(value) {
39
39
  }
40
40
 
41
41
  export const isStrings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string')
42
+ export const isIterable = (value) => typeof value?.[Symbol.iterator] === 'function' && typeof value !== 'string'
42
43
  export const sameName = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase()
43
44
  export const isTagName = (value) => isString(value, 255) && !BAD_REF.test(value)
44
45
  export const isRefName = (value) => isTagName(value) && !/^(?:@$|-)/u.test(value)
@@ -55,6 +56,16 @@ export function assertArgs(method, options, spec, name) {
55
56
  for (const [key, check] of Object.entries(spec)) check?.(method, label(key), options[key])
56
57
  }
57
58
 
59
+ // The names a resolve*Repos call is given, each checked and given once,
60
+ // and its options.
61
+ export function assertNames(method, what, names, options, assertName) {
62
+ assert.ok(isIterable(names), `${method}: ${what} must be an iterable of names`)
63
+ assertArgs(method, options, { cachedOnly: optional(assertBoolean) })
64
+ const unique = [...new Set(names)]
65
+ for (const name of unique) assertName(method, 'name', name)
66
+ return unique
67
+ }
68
+
58
69
  export const assertBoolean = assertion('a boolean', (value) => typeof value === 'boolean')
59
70
  export const assertNumber = assertion('a positive integer', (value) => Number.isSafeInteger(value) && value > 0)
60
71
  export const assertLine = assertion('a non-empty single line', (value) => isString(value, 1024) && value.trim() !== '')
@@ -84,5 +95,13 @@ export const assertSoldeerName = assertion('a Soldeer package name', matches(/^(
84
95
  // Soldeer takes any version but an empty one. Its registry has semver
85
96
  // (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
86
97
  export const assertSoldeerVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
98
+ // A package's name and version, as a registry's getters take them.
99
+ const assertNameVersion = (assertName, assertVersion) => (method, name, version) => {
100
+ assertName(method, 'name', name)
101
+ assertVersion(method, 'version', version)
102
+ }
103
+ export const assertCrate = assertNameVersion(assertCrateName, assertCrateVersion)
104
+ export const assertPackage = assertNameVersion(assertPackageName, assertPackageVersion)
105
+ export const assertSoldeerPackage = assertNameVersion(assertSoldeerName, assertSoldeerVersion)
87
106
  // As Cargo.lock and soldeer.lock write a checksum.
88
107
  export const assertSha256 = assertion('a sha256 in lowercase hex', matches(/^[\da-f]{64}$/u))
package/src/cache.js CHANGED
@@ -3,7 +3,7 @@ import { constants } from 'node:fs'
3
3
  import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
4
4
  import { dirname, join, resolve } from 'node:path'
5
5
 
6
- import { assertDirectoryPath } from './args.js'
6
+ import { assertDirectoryPath, isRepo } from './args.js'
7
7
  import { MAX_BYTES, decode } from './http.js'
8
8
 
9
9
  const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'cargo/crates', 'composer/repos', 'soldeer/repos', 'soldeer/zips', 'github/trees'])
@@ -93,3 +93,21 @@ export async function readRecord(dir, name) {
93
93
  }
94
94
 
95
95
  export const writeRecord = (dir, name, value) => writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, ...value })
96
+
97
+ // Name → the GitHub repo cached for it, for those of `names` that have one.
98
+ export async function readRepos(dir, names) {
99
+ const repos = new Map()
100
+ for (const name of names) {
101
+ const entry = await readRecord(dir, name)
102
+ if (isRepo(entry?.github)) repos.set(name, entry.github)
103
+ }
104
+ return repos
105
+ }
106
+
107
+ // An answer into `repos`, and into the cache where it found a repo.
108
+ export async function addRepos(dir, repos, answer) {
109
+ for (const [name, github] of answer) {
110
+ repos.set(name, github)
111
+ if (github) await writeRecord(dir, name, { github })
112
+ }
113
+ }
@@ -3,7 +3,7 @@ import { readdir } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
 
6
- import { assertCrateName, assertCrateVersion, assertSha256, printable, show } from '../args.js'
6
+ import { assertCrate, assertSha256, printable, show } from '../args.js'
7
7
  import { verifiedDownload } from '../download.js'
8
8
  import { CRATES_INDEX, CRATES_STATIC, buildUrl, encodeSegment, request } from '../http.js'
9
9
 
@@ -37,11 +37,6 @@ async function localPaths(name, version) {
37
37
  return (await readdir(root).catch(() => [])).map((registry) => join(root, registry, `${name}-${version}.crate`))
38
38
  }
39
39
 
40
- function assertCrate(method, name, version) {
41
- assertCrateName(method, 'name', name)
42
- assertCrateVersion(method, 'version', version)
43
- }
44
-
45
40
  export async function verifyChecksum(name, version, checksum) {
46
41
  assertCrate('verifyChecksum', name, version)
47
42
  assertSha256('verifyChecksum', 'checksum', checksum)
@@ -1,8 +1,8 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { setTimeout as sleep } from 'node:timers/promises'
3
3
 
4
- import { assertArgs, assertBoolean, assertCrateName, isRepo, optional, show } from '../args.js'
5
- import { readRecord, writeRecord } from '../cache.js'
4
+ import { assertCrateName, assertNames, show } from '../args.js'
5
+ import { addRepos, readRepos } from '../cache.js'
6
6
  import { CRATES_API, buildUrl, request } from '../http.js'
7
7
  import { chunks } from '../pool.js'
8
8
  import { githubRepoOfUrl } from '../remote.js'
@@ -32,11 +32,7 @@ async function askCrates(method, names) {
32
32
  // for a month. A failed request throws, or with `soft` leaves its names
33
33
  // out; `cachedOnly` asks nothing.
34
34
  export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly = false } = {}) {
35
- const repos = new Map()
36
- for (const name of names) {
37
- const entry = await readRecord(DIR, name)
38
- if (isRepo(entry?.github)) repos.set(name, entry.github)
39
- }
35
+ const repos = await readRepos(DIR, names)
40
36
  if (cachedOnly) return repos
41
37
  for (const [i, chunk] of chunks(names.filter((name) => !repos.has(name)), PER_REQUEST).entries()) {
42
38
  if (i > 0) await sleep(PACE_MS)
@@ -44,19 +40,13 @@ export async function lookUpCrateRepos(method, names, { soft = false, cachedOnly
44
40
  if (soft) return new Map()
45
41
  throw error
46
42
  })
47
- for (const [name, github] of answer) {
48
- repos.set(name, github)
49
- if (github) await writeRecord(DIR, name, { github })
50
- }
43
+ await addRepos(DIR, repos, answer)
51
44
  }
52
45
  return repos
53
46
  }
54
47
 
55
48
  export async function resolveCrateRepos(crateNames, options = {}) {
56
- assert.ok(typeof crateNames?.[Symbol.iterator] === 'function' && typeof crateNames !== 'string', 'resolveCrateRepos: crateNames must be an iterable of names')
57
- assertArgs('resolveCrateRepos', options, { cachedOnly: optional(assertBoolean) })
58
- const names = [...new Set(crateNames)]
59
- for (const name of names) assertCrateName('resolveCrateRepos', 'name', name)
49
+ const names = assertNames('resolveCrateRepos', 'crateNames', crateNames, options, assertCrateName)
60
50
  const repos = await lookUpCrateRepos('resolveCrateRepos', names, { soft: true, cachedOnly: options.cachedOnly })
61
51
  return new Map(names.flatMap((name) => (repos.get(name) ? [[name, { github: repos.get(name) }]] : [])))
62
52
  }
@@ -1,12 +1,11 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
3
  import { assertArgs, assertToken, assertTokenOrNull, assertUserAgent, optional } from '../args.js'
4
- import { GITHUB_API, HttpError, buildUrl, request, requestWithHeaders } from '../http.js'
4
+ import { GITHUB_API, HttpError, buildUrl, request } from '../http.js'
5
5
 
6
6
  export const api = (segments, query) => buildUrl(GITHUB_API, segments, query)
7
7
  export const repoApi = (repo, segments = [], query) => api(['repos', ...repo.split('/'), ...segments], query)
8
8
  export const call = (headers, url, options) => request(url, { as: 'json', headers, ...options })
9
- export const callWithHeaders = (headers, url, options) => requestWithHeaders(url, { as: 'json', headers, ...options })
10
9
  // A repository renamed (a redirect, refused), deleted or blocked, not a
11
10
  // failure that could pass.
12
11
  export const isGone = (err) => err instanceof HttpError && [301, 404, 410, 451].includes(err.status)
@@ -1,12 +1,11 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { Buffer } from 'node:buffer'
3
- import { createHash } from 'node:crypto'
4
3
 
5
4
  import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, assertTagName, assertTreeId, isSha, isSha1, isTagName, optional, sameName, show } from '../args.js'
6
5
  import { verifiedDownload } from '../download.js'
7
- import { decode, encodeSegment } from '../http.js'
8
- import { gitTreeOfListing } from '../tree.js'
9
- import { api, bindMethods, call, callWithHeaders, clientHeaders, isGone, repoApi } from './client.js'
6
+ import { decode, encodeSegment, requestWithHeaders } from '../http.js'
7
+ import { gitTreeOfListing, objectId } from '../tree.js'
8
+ import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
10
9
 
11
10
  const PER_PAGE = 100
12
11
  const MAX_PAGES = 100
@@ -29,7 +28,7 @@ async function* pages(method, headers, pageUrl, maxPages = MAX_PAGES) {
29
28
  async function* cursorPages(method, headers, pageUrl, maxPages = MAX_PAGES) {
30
29
  const paging = { per_page: PER_PAGE }
31
30
  for (let page = 1; ; page++) {
32
- const answer = await callWithHeaders(headers, pageUrl(paging))
31
+ const answer = await requestWithHeaders(pageUrl(paging), { as: 'json', headers })
33
32
  assert.ok(Array.isArray(answer.body), `${method}: expected an array for page ${page}`)
34
33
  yield answer.body
35
34
  const next = /<([^>]*)>\s*;\s*rel="next"/u.exec(answer.headers.get('link') ?? '')?.[1]
@@ -107,7 +106,7 @@ async function getRepoFile(headers, options) {
107
106
  const bytes = file.encoding === 'none'
108
107
  ? await call({ ...headers, Accept: 'application/vnd.github.raw' }, repoApi(repo, ['git', 'blobs', file.sha]), { as: 'bytes' })
109
108
  : Buffer.from(file.content, 'base64')
110
- const blob = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
109
+ const blob = objectId('blob', bytes).toString('hex')
111
110
  assert.ok(bytes.length === file.size && blob === file.sha, `getRepoFile: ${show(path)} came back as blob ${blob}, not ${file.sha}`)
112
111
  return decode(bytes, url)
113
112
  }
@@ -0,0 +1,19 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { show } from '../args.js'
4
+ import { NPM_REGISTRY, buildUrl, request } from '../http.js'
5
+
6
+ // Private packages are always scoped.
7
+ export function withNpmToken(name, url, options = {}) {
8
+ const method = options.method ?? 'GET'
9
+ assert.ok(method === 'GET' && `${new URL(url)}` === url && url.startsWith(`${NPM_REGISTRY}/${name}/`), `Unexpected request for ${name}: ${method} ${url}`)
10
+ const token = process.env.NPM_TOKEN
11
+ return token && name.startsWith('@') ? { ...options, method, headers: { ...options.headers, Authorization: `Bearer ${token}` } } : options
12
+ }
13
+
14
+ export async function getDocument(method, name, spec) {
15
+ const url = buildUrl(NPM_REGISTRY, [...name.split('/'), spec])
16
+ const json = await request(url, withNpmToken(name, url, { as: 'json' }))
17
+ assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
18
+ return json
19
+ }
package/src/npm/repos.js CHANGED
@@ -1,20 +1,17 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertArgs, assertBoolean, assertPackageName, assertRepo, isRepo, optional, show } from '../args.js'
3
+ import { assertNames, assertPackageName, assertRepo, isRepo } from '../args.js'
4
4
  import { readRecord, writeRecord } from '../cache.js'
5
- import { NPM_REGISTRY, buildUrl, isNotFound, recover, request } from '../http.js'
5
+ import { isNotFound, recover } from '../http.js'
6
6
  import { assertRepoDirectory, getRepo, isRepoDirectory } from '../package.js'
7
7
  import { pool } from '../pool.js'
8
+ import { getDocument } from './registry.js'
8
9
 
9
10
  const DIR = 'npm/repos'
10
11
  const CONCURRENCY = 8
11
12
 
12
13
  // `latest`, not the full packument, which is megabytes of version history.
13
- async function fetchRepo(method, name) {
14
- const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), 'latest']), { as: 'json' })
15
- assert.ok(json?.name === name, `${method}: the registry answered for ${show(json?.name)}, not ${name}`)
16
- return getRepo(json)
17
- }
14
+ const fetchRepo = async (method, name) => getRepo(await getDocument(method, name, 'latest'))
18
15
 
19
16
  export async function getGitHub(name) {
20
17
  assertPackageName('getGitHub', 'name', name)
@@ -42,10 +39,7 @@ export async function writePackageRepoCache(name, github, directory = '') {
42
39
  }
43
40
 
44
41
  export async function resolvePackageRepos(packageNames, options = {}) {
45
- assert.ok(typeof packageNames?.[Symbol.iterator] === 'function' && typeof packageNames !== 'string', 'resolvePackageRepos: packageNames must be an iterable of names')
46
- assertArgs('resolvePackageRepos', options, { cachedOnly: optional(assertBoolean) })
47
- const names = [...new Set(packageNames)]
48
- for (const name of names) assertPackageName('resolvePackageRepos', 'name', name)
42
+ const names = assertNames('resolvePackageRepos', 'packageNames', packageNames, options, assertPackageName)
49
43
  const found = await pool(names, CONCURRENCY, (name) => (options.cachedOnly ? readPackageRepoCache(name) : lookUpPackageRepo(name)).catch(() => null))
50
44
  return new Map(names.flatMap((name, i) => (found[i] ? [[name, found[i]]] : [])))
51
45
  }
@@ -3,9 +3,10 @@ import { Buffer } from 'node:buffer'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
 
6
- import { assertArgs, assertPackageName, assertPackageVersion, assertion, matches, show } from '../args.js'
6
+ import { assertArgs, assertPackage, assertion, matches, show } from '../args.js'
7
7
  import { verifiedDownload } from '../download.js'
8
- import { NPM_REGISTRY, buildUrl, request } from '../http.js'
8
+ import { NPM_REGISTRY, buildUrl } from '../http.js'
9
+ import { getDocument, withNpmToken } from './registry.js'
9
10
 
10
11
  const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twice.
11
12
  // One sha512 and nothing else, as the registry writes it: a sha1, a second
@@ -13,11 +14,6 @@ const DIR = 'npm/tarballs' // No expiry: the registry never takes a version twic
13
14
  // last character before `==` two bits, so only A, Q, g or w is canonical.
14
15
  const assertIntegrity = assertion('"sha512-" and a base64 sha512', matches(/^sha512-[\dA-Za-z+/]{85}[AQgw]==$/u))
15
16
 
16
- function assertPackage(method, name, version) {
17
- assertPackageName(method, 'name', name)
18
- assertPackageVersion(method, 'version', version)
19
- }
20
-
21
17
  // `{ tarball, integrity }` and nothing else, the tarball exactly the
22
18
  // registry's own URL for that version. Each field is read once, and only
23
19
  // as the object's own, into a copy that is checked and used from then on:
@@ -34,8 +30,8 @@ function checkedDist(method, name, version, dist) {
34
30
  }
35
31
 
36
32
  async function getDist(method, name, version) {
37
- const json = await request(buildUrl(NPM_REGISTRY, [...name.split('/'), version]), { as: 'json' })
38
- assert.ok(json?.name === name && json.version === version, `${method}: the registry answered for ${show(json?.name)}@${show(json?.version)}, not ${name}@${version}`)
33
+ const json = await getDocument(method, name, version)
34
+ assert.ok(json.version === version, `${method}: the registry answered for ${name}@${show(json.version)}, not ${name}@${version}`)
39
35
  return checkedDist(method, name, version, { tarball: json.dist?.tarball, integrity: json.dist?.integrity })
40
36
  }
41
37
 
@@ -75,5 +71,5 @@ export async function verifyDist(name, version, dist) {
75
71
  export async function getTarball(name, version, dist) {
76
72
  assertPackage('getTarball', name, version)
77
73
  const { tarball, integrity } = dist === undefined ? await getDist('getTarball', name, version) : checkedDist('getTarball', name, version, dist)
78
- return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), locate: () => tarball })
74
+ return await verifiedDownload({ method: 'getTarball', dir: DIR, what: `${name}@${version}`, ext: 'tgz', algorithm: 'sha512', expected: integrity, local: localPaths(name, version, integrity), locate: () => tarball, options: withNpmToken(name, tarball) })
79
75
  }
@@ -0,0 +1,8 @@
1
+ 'use strict'
2
+
3
+ // The semver peer dependency by a plain require(), which a bundler follows
4
+ // and bundles: CommonJS for that alone. In an ESM bundle that leaves semver
5
+ // out, this require() is the bundler's stub, which throws.
6
+ module.exports = function requirePeer() {
7
+ return require('semver')
8
+ }
package/src/semver.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { createRequire } from 'node:module'
3
3
  import { basename, dirname, resolve } from 'node:path'
4
+ import requirePeer from './semver-peer.cjs'
4
5
 
5
6
  // 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
+ // as a dependency: the same implementation `npm audit` uses. Where there is
8
+ // no npm, as in a bundled serverless function, the `semver` package itself,
9
+ // an optional peer dependency.
7
10
 
8
11
  // Plain releases that semver.valid answers unchanged, so it need not be
9
12
  // loaded for them: no leading zeros, and at most 15 digits a part, under
@@ -11,26 +14,37 @@ import { basename, dirname, resolve } from 'node:path'
11
14
  // to semver.
12
15
  const PLAIN_RELEASE = /^(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})\.(?:0|[1-9]\d{0,14})$/u
13
16
 
14
- let found
17
+ // What load() returns if it is semver, or null if it is not or throws.
18
+ function attempt(load) {
19
+ try {
20
+ const lib = load()
21
+ if (['compare', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
22
+ } catch {}
23
+ return null
24
+ }
15
25
 
16
26
  function find() {
17
27
  const argv0 = process.argv[0]
18
28
  // 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', 'intersects', 'satisfies', 'valid', 'validRange'].every((name) => typeof lib?.[name] === 'function')) return lib
26
- } catch {}
29
+ if (['node', 'node.exe'].includes(basename(argv0 ?? ''))) {
30
+ // POSIX keeps npm in <prefix>/lib beside <prefix>/bin/node; Windows, beside node.exe.
31
+ for (const prefix of ['../lib', '.']) {
32
+ // Required from itself, which a bundle of any format leaves alone.
33
+ const path = resolve(dirname(argv0), prefix, 'node_modules/npm/node_modules/semver')
34
+ const lib = attempt(() => createRequire(path)(path))
35
+ if (lib) return lib
36
+ }
27
37
  }
28
- return null
38
+ // The peer, by the plain require() a bundler follows; or, where an ESM
39
+ // bundle leaves it out, by node's own require() from beside the bundle.
40
+ return attempt(requirePeer) ?? attempt(() => createRequire(import.meta.url)('semver'))
29
41
  }
30
42
 
43
+ let found
44
+
31
45
  function semver() {
32
46
  if (found === undefined) found = find()
33
- assert.ok(found, 'semver: no npm beside node to borrow it from')
47
+ assert.ok(found, 'semver: no npm beside node to borrow it from, and the semver peer dependency is not installed')
34
48
  return found
35
49
  }
36
50
 
package/src/soldeer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertSha256, assertSoldeerName, assertSoldeerVersion, show } from './args.js'
3
+ import { assertSha256, assertSoldeerPackage, show } from './args.js'
4
4
  import { verifiedDownload } from './download.js'
5
5
  import { SOLDEER_API, SOLDEER_REVISIONS, buildUrl, request } from './http.js'
6
6
 
@@ -20,8 +20,7 @@ async function zipUrl(name, version) {
20
20
  // The registry has no hash to check a zip against, so the caller brings
21
21
  // one, as soldeer.lock records it.
22
22
  export async function getZip(name, version, checksum) {
23
- assertSoldeerName('getZip', 'name', name)
24
- assertSoldeerVersion('getZip', 'version', version)
23
+ assertSoldeerPackage('getZip', name, version)
25
24
  assertSha256('getZip', 'checksum', checksum)
26
25
  return await verifiedDownload({ method: 'getZip', dir: DIR, what: `${name}@${version}`, ext: 'zip', algorithm: 'sha256', expected: checksum, locate: () => zipUrl(name, version) })
27
26
  }
package/src/tree.js CHANGED
@@ -28,7 +28,7 @@ const octal = (header, start, end) => {
28
28
  const text = field(header, start, end).trim()
29
29
  return /^[0-7]{1,12}$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
30
30
  }
31
- const objectId = (type, content) => createHash('sha1').update(`${type} ${content.length}\0`).update(content).digest()
31
+ export const objectId = (type, content) => createHash('sha1').update(`${type} ${content.length}\0`).update(content).digest()
32
32
  const checksum = (header) => header.reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0)
33
33
  const isZero = (bytes) => bytes.every((byte) => byte === 0)
34
34
 
@@ -64,9 +64,9 @@ function emptyDirs(dir, prefix = '') {
64
64
  // Git sorts a subtree as if its name ended in `/`.
65
65
  function treeId(dir) {
66
66
  const entries = [...dir].map(([name, entry]) => (entry instanceof Map ? { mode: '40000', name, id: treeId(entry) } : { ...entry, name }))
67
- const key = ({ mode, name }) => (mode === '40000' ? `${name}/` : name)
68
- entries.sort((a, b) => (key(a) < key(b) ? -1 : Number(key(a) > key(b))))
69
- return objectId('tree', Buffer.concat(entries.flatMap(({ mode, name, id }) => [Buffer.from(`${mode} ${name}\0`, 'latin1'), id])))
67
+ const keyed = entries.map((entry) => [entry.mode === '40000' ? `${entry.name}/` : entry.name, entry])
68
+ keyed.sort(([a], [b]) => (a < b ? -1 : Number(a > b)))
69
+ return objectId('tree', Buffer.concat(keyed.flatMap(([, { mode, name, id }]) => [Buffer.from(`${mode} ${name}\0`, 'latin1'), id])))
70
70
  }
71
71
 
72
72
  const EMPTY_TREE = objectId('tree', Buffer.alloc(0)).toString('hex')
@@ -90,11 +90,10 @@ async function holdsNothing(sha, listed) {
90
90
  // names any, and one is put back only once its own listings show it holds
91
91
  // nothing.
92
92
  async function putBackEmptyTrees(dir, sha, listed) {
93
- if (treeId(dir).toString('hex') === sha) return
94
93
  for (const entry of await listed(sha)) {
95
94
  if (entry.type !== 'tree') continue
96
95
  const here = dir.get(entry.name)
97
- if (here instanceof Map) await putBackEmptyTrees(here, entry.sha, listed)
96
+ if (here instanceof Map && treeId(here).toString('hex') !== entry.sha) await putBackEmptyTrees(here, entry.sha, listed)
98
97
  else if (here === undefined && await holdsNothing(entry.sha, listed)) dir.set(entry.name, subtree(entry.sha))
99
98
  }
100
99
  }
@@ -177,6 +176,8 @@ export async function gitTreeOfTarball(gzipped, { expected, list } = {}) {
177
176
  if (!isSha1(sha)) return `no tree: an empty directory, ${JSON.stringify(path)}, and no submodule there`
178
177
  dir.set(name, { mode: '160000', id: Buffer.from(sha, 'hex') })
179
178
  }
179
+ const id = treeId(root).toString('hex')
180
+ if (id === expected) return id
180
181
  await putBackEmptyTrees(root, expected, listed)
181
182
  return treeId(root).toString('hex')
182
183
  }