registry-sync 7.1.0 → 8.0.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.
package/README.md CHANGED
@@ -8,7 +8,7 @@ The local copy can then be used as a simple private NPM registry without publish
8
8
 
9
9
  ## Pre-requisites
10
10
 
11
- - Node.js v20.17.0 or newer
11
+ - Node.js v22.18.0 or newer
12
12
 
13
13
  ## Installation
14
14
 
@@ -118,5 +118,5 @@ See [releases](https://github.com/heikkipora/registry-sync/releases).
118
118
 
119
119
  ## Contributing
120
120
 
121
- Pull requests are welcome. Kindly check that your code passes ESLint checks by running `npm run eslint:check` first.
121
+ Pull requests are welcome. Kindly check that your code passes ESLint checks by running `npm run eslint` first.
122
122
  Integration tests can be run with `npm test`. Both are anyway run automatically by GitHub Actions.
package/bin/sync CHANGED
@@ -1,4 +1,3 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const path = require('path')
4
- require(path.join(__dirname, '..', 'src'))
3
+ await import('../src/index.ts')
package/package.json CHANGED
@@ -1,19 +1,18 @@
1
1
  {
2
2
  "name": "registry-sync",
3
- "version": "7.1.0",
3
+ "version": "8.0.0",
4
4
  "description": "synchronize a remote npm registry for private use",
5
5
  "repository": "https://github.com/heikkipora/registry-sync",
6
+ "type": "module",
6
7
  "bin": {
7
8
  "registry-sync": "bin/sync"
8
9
  },
9
10
  "scripts": {
10
11
  "build": "./build-npm",
11
- "prettier": "prettier --write .",
12
- "prettier:check": "prettier --check --loglevel warn .",
13
- "eslint": "eslint --fix --format=codeframe",
14
- "eslint:check": "eslint --max-warnings=0 --format=codeframe",
15
- "lint-staged": "lint-staged --verbose",
16
- "test": "mocha -r ts-node/register --config test/.mocharc.js --timeout 120000 test/*.ts",
12
+ "eslint": "eslint --max-warnings=0 src test release-test/server/src",
13
+ "fix-eslint": "eslint --fix src test release-test/server/src",
14
+ "test": "mocha --timeout 120000 test/*.ts",
15
+ "typecheck": "tsc",
17
16
  "release-test": "cd release-test && ./run-sync-install-cycle.sh"
18
17
  },
19
18
  "author": "Heikki Pora",
@@ -22,37 +21,28 @@
22
21
  "@yarnpkg/lockfile": "1.1.0",
23
22
  "axios": "1.13.2",
24
23
  "commander": "14.0.2",
25
- "lru-cache": "11.2.2",
24
+ "lru-cache": "11.2.4",
26
25
  "semver": "7.7.3",
27
26
  "ssri": "13.0.0",
28
27
  "tar-fs": "3.1.1"
29
28
  },
30
29
  "devDependencies": {
31
- "@arkweid/lefthook": "0.7.7",
32
- "@eslint/eslintrc": "3.3.1",
33
- "@eslint/js": "9.39.1",
34
30
  "@types/chai": "5.2.3",
35
- "@types/lodash": "4.17.20",
31
+ "@types/lodash": "4.17.21",
36
32
  "@types/mocha": "10.0.10",
37
33
  "@types/node": "20.17.32",
38
34
  "@types/semver": "7.7.1",
39
35
  "@types/ssri": "7.1.5",
40
36
  "@types/tar-fs": "2.0.4",
41
37
  "@types/yarnpkg__lockfile": "1.1.9",
42
- "@typescript-eslint/eslint-plugin": "8.46.4",
43
- "@typescript-eslint/parser": "8.46.4",
44
- "chai": "6.2.1",
45
- "eslint": "9.39.1",
46
- "eslint-config-prettier": "10.1.8",
47
- "eslint-formatter-codeframe": "7.32.2",
38
+ "chai": "6.2.2",
39
+ "eslint": "9.39.2",
48
40
  "eslint-plugin-mocha": "11.2.0",
49
- "express": "5.1.0",
50
- "globals": "16.5.0",
51
- "lint-staged": "16.2.6",
41
+ "express": "5.2.1",
42
+ "globals": "17.0.0",
52
43
  "mocha": "11.7.5",
53
- "prettier": "3.6.2",
54
- "ts-node": "10.9.2",
55
- "typescript": "5.9.3"
44
+ "typescript": "5.9.3",
45
+ "typescript-eslint": "8.51.0"
56
46
  },
57
47
  "keywords": [
58
48
  "registry",
@@ -62,6 +52,6 @@
62
52
  "offline"
63
53
  ],
64
54
  "engines": {
65
- "node": ">=20.17.0"
55
+ "node": ">=22.18.0"
66
56
  }
67
57
  }
package/src/client.ts ADDED
@@ -0,0 +1,35 @@
1
+ import * as https from 'https'
2
+ import axios from 'axios'
3
+ import {LRUCache} from 'lru-cache'
4
+ import type {AxiosRequestConfig, ResponseType} from 'axios'
5
+ import type {RegistryMetadata} from './types.d.ts'
6
+
7
+ const metadataCache = new LRUCache<string, RegistryMetadata>({max: 100})
8
+
9
+ const client = axios.create({
10
+ httpsAgent: new https.Agent({keepAlive: true}),
11
+ timeout: 30 * 1000
12
+ })
13
+
14
+ export async function fetchJsonWithCacheCloned(url: string, token: string): Promise<RegistryMetadata> {
15
+ const cached = metadataCache.get(url)
16
+ if (cached) {
17
+ return structuredClone(cached)
18
+ }
19
+
20
+ const value = await fetch<RegistryMetadata>(url, 'json', token)
21
+ metadataCache.set(url, value)
22
+ return structuredClone(value)
23
+ }
24
+
25
+ export function fetchBinaryData(url: string, token: string): Promise<Buffer> {
26
+ return fetch<Buffer>(url, 'arraybuffer', token)
27
+ }
28
+
29
+ async function fetch<T>(url: string, responseType: ResponseType, token: string): Promise<T> {
30
+ const config: AxiosRequestConfig = {responseType}
31
+ if (token !== '') {
32
+ config.headers = {authorization: 'Bearer ' + token}
33
+ }
34
+ return (await client.get<T>(url, config)).data
35
+ }
@@ -0,0 +1,141 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import * as semver from 'semver'
4
+ import * as url from 'url'
5
+ import assert from 'assert'
6
+ import {downloadPrebuiltBinaries, hasPrebuiltBinaries} from './pregyp.ts'
7
+ import {fetchBinaryData, fetchJsonWithCacheCloned} from './client.ts'
8
+ import {rewriteMetadataInTarball, rewriteVersionMetadata, tarballFilename} from './metadata.ts'
9
+ import {verifyIntegrity} from './integrity.ts'
10
+ import type {CommandLineOptions, PackageWithId, PlatformVariant, RegistryMetadata, VersionMetadata} from './types.d.ts'
11
+
12
+ export async function downloadAll(
13
+ packages: PackageWithId[],
14
+ {
15
+ localUrl,
16
+ prebuiltBinaryProperties,
17
+ registryUrl,
18
+ registryToken,
19
+ rootFolder,
20
+ enforceTarballsOverHttps
21
+ }: Omit<CommandLineOptions, 'manifest' | 'includeDevDependencies'>
22
+ ): Promise<void> {
23
+ const downloadFromRegistry = download.bind(
24
+ null,
25
+ registryUrl,
26
+ registryToken,
27
+ localUrl,
28
+ rootFolder,
29
+ prebuiltBinaryProperties,
30
+ enforceTarballsOverHttps
31
+ )
32
+ for (const pkg of packages) {
33
+ await downloadFromRegistry(pkg)
34
+ }
35
+ }
36
+
37
+ async function download(
38
+ registryUrl: string,
39
+ registryToken: string,
40
+ localUrl: url.URL,
41
+ rootFolder: string,
42
+ prebuiltBinaryProperties: PlatformVariant[],
43
+ enforceTarballsOverHttps: boolean,
44
+ {name, version}: PackageWithId
45
+ ): Promise<void> {
46
+ const registryMetadata = await fetchMetadataCloned(name, registryUrl, registryToken)
47
+ const versionMetadata: VersionMetadata | undefined = registryMetadata.versions[version]
48
+ if (!versionMetadata) {
49
+ throw new Error(`Unknown package version ${name}@${version}`)
50
+ }
51
+
52
+ const localFolder = await ensureLocalFolderExists(name, rootFolder)
53
+ let data = await downloadTarball(versionMetadata, enforceTarballsOverHttps, registryToken)
54
+ if (hasPrebuiltBinaries(versionMetadata)) {
55
+ const localPregypFolder = await ensureLocalFolderExists(version, localFolder)
56
+ await downloadPrebuiltBinaries(versionMetadata, localPregypFolder, prebuiltBinaryProperties)
57
+ data = await rewriteMetadataInTarball(data, versionMetadata, localUrl, localFolder)
58
+ }
59
+ await saveTarball(versionMetadata, data, localFolder)
60
+
61
+ rewriteVersionMetadata(versionMetadata, data, localUrl)
62
+ await updateMetadata(versionMetadata, registryMetadata, registryUrl, localFolder)
63
+ }
64
+
65
+ async function downloadTarball(
66
+ {_id: id, dist}: VersionMetadata,
67
+ enforceTarballsOverHttps: boolean,
68
+ registryToken: string
69
+ ): Promise<Buffer> {
70
+ const tarballUrl = enforceTarballsOverHttps ? dist.tarball.replace('http://', 'https://') : dist.tarball
71
+ const data = await fetchBinaryData(tarballUrl, registryToken)
72
+ verifyIntegrity(data, id, dist)
73
+ return data
74
+ }
75
+
76
+ function saveTarball({name, version}: VersionMetadata, data: Buffer, localFolder: string) {
77
+ return fs.promises.writeFile(tarballPath(name, version, localFolder), data)
78
+ }
79
+
80
+ async function updateMetadata(
81
+ versionMetadata: VersionMetadata,
82
+ defaultMetadata: RegistryMetadata,
83
+ registryUrl: string,
84
+ localFolder: string
85
+ ) {
86
+ const {version} = versionMetadata
87
+ const localMetadataPath = path.join(localFolder, 'index.json')
88
+ const localMetadata = await loadMetadata(localMetadataPath, defaultMetadata)
89
+ localMetadata.versions[version] = versionMetadata
90
+ localMetadata.time[version] = defaultMetadata.time[version]
91
+ localMetadata['dist-tags'] = collectDistTags(localMetadata, defaultMetadata)
92
+ await saveMetadata(localMetadataPath, localMetadata)
93
+ }
94
+
95
+ // Collect dist-tags entries (name -> version) from registry metadata,
96
+ // which point to versions we have locally available.
97
+ // Override 'latest' tag to ensure its validity as we might not have the version
98
+ // that is tagged latest in registry
99
+ function collectDistTags(localMetadata: RegistryMetadata, defaultMetadata: RegistryMetadata): Record<string, string> {
100
+ const availableVersions = Object.keys(localMetadata.versions)
101
+ const validDistTags = Object.entries(defaultMetadata['dist-tags']).filter(([, version]) =>
102
+ availableVersions.includes(version)
103
+ )
104
+
105
+ const latest = availableVersions.sort(semver.compare).pop()
106
+ assert(latest, 'At least one version should be locally available to determine "latest" dist-tag')
107
+
108
+ return {
109
+ ...Object.fromEntries(validDistTags),
110
+ latest
111
+ }
112
+ }
113
+
114
+ async function loadMetadata(path: string, defaultMetadata: RegistryMetadata): Promise<RegistryMetadata> {
115
+ try {
116
+ const json = await fs.promises.readFile(path, 'utf8')
117
+ return JSON.parse(json)
118
+ } catch {
119
+ return {...defaultMetadata, 'dist-tags': {}, time: {}, versions: {}}
120
+ }
121
+ }
122
+
123
+ function saveMetadata(path: string, metadata: RegistryMetadata): Promise<void> {
124
+ const json = JSON.stringify(metadata, null, 2)
125
+ return fs.promises.writeFile(path, json, 'utf8')
126
+ }
127
+
128
+ function tarballPath(name: string, version: string, localFolder: string) {
129
+ return path.join(localFolder, tarballFilename(name, version))
130
+ }
131
+
132
+ async function ensureLocalFolderExists(name: string, rootFolder: string): Promise<string> {
133
+ const localFolder = path.resolve(rootFolder, name)
134
+ await fs.promises.mkdir(localFolder, {recursive: true})
135
+ return localFolder
136
+ }
137
+
138
+ function fetchMetadataCloned(name: string, registryUrl: string, registryToken: string): Promise<RegistryMetadata> {
139
+ const urlSafeName = name.replace(/\//g, '%2f')
140
+ return fetchJsonWithCacheCloned(url.resolve(registryUrl, urlSafeName), registryToken)
141
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import {Command} from 'commander'
4
+ import {synchronize} from './sync.ts'
5
+ import {URL} from 'url'
6
+ import type {CommandLineOptions, PlatformVariant} from './types.d.ts'
7
+
8
+ const {version} = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf-8'))
9
+
10
+ const program = new Command()
11
+ program
12
+ .version(version)
13
+ .requiredOption('--root <path>', 'Path to save NPM package tarballs and metadata to')
14
+ .requiredOption(
15
+ '--manifest <file>',
16
+ 'Path to a package-lock.json or yarn.lock file to use as catalog for mirrored NPM packages.'
17
+ )
18
+ .requiredOption(
19
+ '--localUrl <url>',
20
+ 'URL to use as root in stored package metadata (i.e. where folder defined as --root will be exposed at)'
21
+ )
22
+ .option(
23
+ '--binaryAbi <list>',
24
+ 'Comma-separated list of node C++ ABI numbers to download pre-built binaries for. See NODE_MODULE_VERSION column in https://nodejs.org/en/download/releases/'
25
+ )
26
+ .option(
27
+ '--binaryArch <list>',
28
+ 'Comma-separated list of CPU architectures to download pre-built binaries for. Valid values: arm, ia32, and x64'
29
+ )
30
+ .option(
31
+ '--binaryPlatform <list>',
32
+ 'Comma-separated list of OS platforms to download pre-built binaries for. Valid values: linux, darwin, win32, sunos, freebsd, openbsd, and aix'
33
+ )
34
+ .option(
35
+ '--registryUrl [url]',
36
+ 'Optional URL to use as NPM registry when fetching packages. Default value is https://registry.npmjs.org'
37
+ )
38
+ .option('--registryToken [string]', 'Optional Bearer token for the registry.')
39
+ .option(
40
+ '--dontEnforceHttps',
41
+ 'Disable the default behavior of downloading tarballs over HTTPS (will use whichever protocol is defined in the registry metadata)'
42
+ )
43
+ .option('--includeDev', 'Include also packages found from devDependencies section of the --manifest')
44
+ .option('--dryRun', 'Print packages that would be downloaded but do not download them')
45
+ .parse(process.argv)
46
+
47
+ const rawOptions = program.opts()
48
+
49
+ // use current (abi,arch,platform) triplet as default if none is specified
50
+ // so the user doesn't have to look them up if build is always done on the
51
+ // same kind of machine
52
+ const binaryAbi: string = rawOptions.binaryAbi || process.versions.modules
53
+ const binaryArch: string = rawOptions.binaryArch || process.arch
54
+ const binaryPlatform: string = rawOptions.binaryPlatform || process.platform
55
+
56
+ const abis: number[] = binaryAbi.split(',').map(Number)
57
+ const architectures: string[] = binaryArch.split(',')
58
+ const platforms: string[] = binaryPlatform.split(',')
59
+ const prebuiltBinaryProperties: PlatformVariant[] = abis
60
+ .map(abi => architectures.map(arch => platforms.map(platform => ({abi, arch, platform}))).flat())
61
+ .flat()
62
+
63
+ const options: CommandLineOptions = {
64
+ localUrl: new URL(rawOptions.localUrl),
65
+ manifest: rawOptions.manifest,
66
+ prebuiltBinaryProperties,
67
+ registryUrl: rawOptions.registryUrl || 'https://registry.npmjs.org',
68
+ registryToken: rawOptions.registryToken || '',
69
+ rootFolder: rawOptions.root,
70
+ enforceTarballsOverHttps: Boolean(!rawOptions.dontEnforceHttps),
71
+ includeDevDependencies: Boolean(rawOptions.includeDev),
72
+ dryRun: Boolean(rawOptions.dryRun)
73
+ }
74
+
75
+ synchronize(options)
@@ -0,0 +1,27 @@
1
+ import * as ssri from 'ssri'
2
+
3
+ export function verifyIntegrity(
4
+ data: Buffer,
5
+ id: string,
6
+ {integrity, shasum}: {integrity?: string; shasum?: string}
7
+ ): void {
8
+ if (!integrity && !shasum) {
9
+ throw new Error(`Integrity values not present in metadata for ${id}`)
10
+ }
11
+
12
+ if (integrity) {
13
+ if (!ssri.checkData(data, integrity)) {
14
+ throw new Error(`Integrity check failed for ${id}`)
15
+ }
16
+ } else if (sha1(data) != shasum) {
17
+ throw new Error(`Integrity check with SHA1 failed for failed for ${id}`)
18
+ }
19
+ }
20
+
21
+ export function sha1(data: Buffer): string {
22
+ return ssri.fromData(data, {algorithms: ['sha1']}).hexDigest()
23
+ }
24
+
25
+ export function sha512(data: Buffer): string {
26
+ return ssri.fromData(data, {algorithms: ['sha512']}).toString()
27
+ }
@@ -0,0 +1,78 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import * as tar from 'tar-fs'
4
+ import * as zlib from 'zlib'
5
+ import {hasPrebuiltBinaries} from './pregyp.ts'
6
+ import {Readable} from 'stream'
7
+ import {sha1, sha512} from './integrity.ts'
8
+ import type {URL} from 'url'
9
+ import type {VersionMetadata} from './types.d.ts'
10
+
11
+ export function rewriteVersionMetadata(versionMetadata: VersionMetadata, data: Buffer, localUrl: URL): void {
12
+ versionMetadata.dist.tarball = localTarballUrl(versionMetadata, localUrl)
13
+
14
+ if (hasPrebuiltBinaries(versionMetadata)) {
15
+ versionMetadata.binary.host = localUrl.origin
16
+ versionMetadata.binary.remote_path = createPrebuiltBinaryRemotePath(localUrl, versionMetadata)
17
+ versionMetadata.dist.integrity = sha512(data)
18
+ versionMetadata.dist.shasum = sha1(data)
19
+ }
20
+ }
21
+
22
+ export async function rewriteMetadataInTarball(
23
+ data: Buffer,
24
+ versionMetadata: VersionMetadata,
25
+ localUrl: URL,
26
+ localFolder: string
27
+ ): Promise<Buffer> {
28
+ const tmpFolder = path.join(localFolder, '.tmp')
29
+ await fs.promises.mkdir(tmpFolder, {recursive: true})
30
+ await extractTgz(data, tmpFolder)
31
+
32
+ const manifestPath = path.join(tmpFolder, 'package', 'package.json')
33
+ const json = await fs.promises.readFile(manifestPath, 'utf8')
34
+ const metadata = JSON.parse(json)
35
+ metadata.binary.host = localUrl.origin
36
+ metadata.binary.remote_path = createPrebuiltBinaryRemotePath(localUrl, versionMetadata)
37
+ await fs.promises.writeFile(manifestPath, JSON.stringify(metadata, null, 2))
38
+
39
+ const updatedData = await compressTgz(tmpFolder)
40
+ await fs.promises.rm(tmpFolder, {recursive: true})
41
+ return updatedData
42
+ }
43
+
44
+ function createPrebuiltBinaryRemotePath(url: URL, versionMetadata: VersionMetadata): string {
45
+ return `${removeTrailingSlash(url.pathname)}/${versionMetadata.name}/${versionMetadata.version}/`
46
+ }
47
+
48
+ export function extractTgz(data: Buffer, folder: string): Promise<void> {
49
+ return new Promise((resolve, reject) => {
50
+ const tgz = Readable.from(data).pipe(zlib.createGunzip()).pipe(tar.extract(folder))
51
+
52
+ tgz.on('finish', resolve)
53
+ tgz.on('error', reject)
54
+ })
55
+ }
56
+
57
+ function compressTgz(folder: string): Promise<Buffer> {
58
+ return new Promise((resolve, reject) => {
59
+ const chunks: Buffer[] = []
60
+ const tgz = tar.pack(folder).pipe(zlib.createGzip())
61
+ tgz.on('data', (chunk: Buffer) => chunks.push(chunk))
62
+ tgz.on('end', () => resolve(Buffer.concat(chunks)))
63
+ tgz.on('error', reject)
64
+ })
65
+ }
66
+
67
+ function localTarballUrl({name, version}: {name: string; version: string}, localUrl: URL) {
68
+ return `${localUrl.origin}${removeTrailingSlash(localUrl.pathname)}/${name}/${tarballFilename(name, version)}`
69
+ }
70
+
71
+ export function tarballFilename(name: string, version: string): string {
72
+ const normalized = name.replace(/\//g, '-')
73
+ return `${normalized}-${version}.tgz`
74
+ }
75
+
76
+ function removeTrailingSlash(str: string): string {
77
+ return str.replace(/\/$/, '')
78
+ }
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /*
3
2
  BSD 2-Clause License
4
3
 
@@ -28,33 +27,40 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28
27
  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
28
  */
30
29
  // From https://github.com/yarnpkg/yarn/blob/953c8b6a20e360b097625d64189e6e56ed813e0f/src/util/normalize-pattern.js#L2
31
- Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.normalizeYarnPackagePattern = normalizeYarnPackagePattern;
33
- function normalizeYarnPackagePattern(pattern) {
34
- let hasVersion = false;
35
- let range = 'latest';
36
- let name = pattern;
37
- // if we're a scope then remove the @ and add it back later
38
- let isScoped = false;
39
- if (name[0] === '@') {
40
- isScoped = true;
41
- name = name.slice(1);
42
- }
43
- // take first part as the name
44
- const parts = name.split('@');
45
- if (parts.length > 1) {
46
- name = parts.shift();
47
- range = parts.join('@');
48
- if (range) {
49
- hasVersion = true;
50
- }
51
- else {
52
- range = '*';
53
- }
54
- }
55
- // add back @ scope suffix
56
- if (isScoped) {
57
- name = `@${name}`;
30
+
31
+ export function normalizeYarnPackagePattern(pattern: string): {
32
+ hasVersion: boolean
33
+ name: string
34
+ range: string
35
+ } {
36
+ let hasVersion = false
37
+ let range = 'latest'
38
+ let name = pattern
39
+
40
+ // if we're a scope then remove the @ and add it back later
41
+ let isScoped = false
42
+ if (name[0] === '@') {
43
+ isScoped = true
44
+ name = name.slice(1)
45
+ }
46
+
47
+ // take first part as the name
48
+ const parts = name.split('@')
49
+ if (parts.length > 1) {
50
+ name = parts.shift()!
51
+ range = parts.join('@')
52
+
53
+ if (range) {
54
+ hasVersion = true
55
+ } else {
56
+ range = '*'
58
57
  }
59
- return { name, range, hasVersion };
58
+ }
59
+
60
+ // add back @ scope suffix
61
+ if (isScoped) {
62
+ name = `@${name}`
63
+ }
64
+
65
+ return {name, range, hasVersion}
60
66
  }