diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bb95cec..5108bd17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: fail-fast: false max-parallel: 3 matrix: - node: [20, 22, 24] + node: [22, 24] platform: [ubuntu-latest, macos-latest, windows-latest] name: "${{matrix.platform}} w/ Node.js ${{matrix.node}}.x" runs-on: ${{matrix.platform}} diff --git a/README.md b/README.md index e81d2ec6..117ba944 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ USAGE * [`hd auth logout`](#hd-auth-logout) * [`hd auth provision-ci-token`](#hd-auth-provision-ci-token) * [`hd help [COMMAND]`](#hd-help-command) +* [`hd install`](#hd-install) * [`hd report committers`](#hd-report-committers) * [`hd scan eol`](#hd-scan-eol) * [`hd tracker init`](#hd-tracker-init) @@ -165,6 +166,20 @@ DESCRIPTION _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.44/src/commands/help.ts)_ +### `hd install` + +Install dependencies through the HeroDevs NES npm proxy + +``` +USAGE + $ hd install + +DESCRIPTION + Install dependencies through the HeroDevs NES npm proxy +``` + +_See code: [src/commands/install.ts](https://github.com/herodevs/cli/blob/v2.0.6/src/commands/install.ts)_ + ### `hd report committers` Generate report of committers to a git repository @@ -177,10 +192,10 @@ USAGE FLAGS -c, --csv Output in CSV format -d, --directory= Directory to search - -e, --afterDate= [default: 2025-04-23] Start date (format: yyyy-MM-dd) + -e, --afterDate= [default: 2025-05-27] Start date (format: yyyy-MM-dd) -m, --months= [default: 12] The number of months of git history to review. Cannot be used along beforeDate and afterDate - -s, --beforeDate= [default: 2026-04-23] End date (format: yyyy-MM-dd) + -s, --beforeDate= [default: 2026-05-27] End date (format: yyyy-MM-dd) -s, --save Save the committers report as herodevs.committers. -x, --exclude=... Path Exclusions (eg -x="./src/bin" -x="./dist") --json Output to JSON format diff --git a/biome.json b/biome.json index 9c4ff943..9dff99bf 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, diff --git a/e2e/commands/install/install.test.ts b/e2e/commands/install/install.test.ts new file mode 100644 index 00000000..06dc1e67 --- /dev/null +++ b/e2e/commands/install/install.test.ts @@ -0,0 +1,227 @@ +import { equal, match } from 'node:assert/strict'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { gzipSync } from 'node:zlib'; +import { runCommand } from '@oclif/test'; + +const fixturesDir = path.resolve(import.meta.dirname, '../../fixtures/install'); +const projectFixtureDir = path.join(fixturesDir, 'simple-project'); +const packageFixtureDir = path.join(fixturesDir, 'hd-demo-dep'); + +describe('install e2e', () => { + const originalCwd = process.cwd(); + const originalCatalogUrl = process.env.HD_INSTALL_CATALOG_URL; + const originalRegistryOverride = process.env.HD_INSTALL_NPM_REGISTRY_URL; + const originalNpmCache = process.env.NPM_CONFIG_CACHE; + let tempDir: string; + let projectDir: string; + let registry: MockRegistry | undefined; + + beforeEach(async () => { + tempDir = mkdtempSync(path.join(tmpdir(), 'hd-install-e2e-')); + projectDir = path.join(tempDir, 'project'); + cpSync(projectFixtureDir, projectDir, { recursive: true }); + + const tarballPath = createFixtureTarball(tempDir); + registry = await startMockRegistry(tarballPath); + process.env.HD_INSTALL_CATALOG_URL = `${registry.url}/catalog`; + process.env.HD_INSTALL_NPM_REGISTRY_URL = registry.url; + process.env.NPM_CONFIG_CACHE = path.join(tempDir, '.npm-cache'); + process.chdir(projectDir); + }); + + afterEach(() => { + registry?.close(); + process.chdir(originalCwd); + restoreEnv('HD_INSTALL_CATALOG_URL', originalCatalogUrl); + restoreEnv('HD_INSTALL_NPM_REGISTRY_URL', originalRegistryOverride); + restoreEnv('NPM_CONFIG_CACHE', originalNpmCache); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('runs npm install through the local proxy against a real fixture project', async () => { + if (!registry) { + throw new Error('Mock registry was not started'); + } + + const output = await runCommand('install'); + equal(output.error, undefined); + + const stdout = output.stdout; + match(stdout, /Install completed\./); + + const installedPackagePath = path.join(projectDir, 'node_modules', 'hd-demo-dep', 'index.js'); + equal(existsSync(installedPackagePath), true); + equal(readFileSync(installedPackagePath, 'utf8'), "module.exports = 'installed through hd install';\n"); + + const lockfile = JSON.parse(readFileSync(path.join(projectDir, 'package-lock.json'), 'utf8')); + equal(lockfile.packages['node_modules/hd-demo-dep'].version, '1.0.0'); + match(lockfile.packages['node_modules/hd-demo-dep'].resolved, /\/hd-demo-dep-1\.0\.0-hd-demo-dep-1\.0\.1\.tgz$/); + }); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +function createFixtureTarball(destination: string): string { + const entries = [ + createTarEntry('package/package.json', readFileSync(path.join(packageFixtureDir, 'package.json'))), + createTarEntry('package/index.js', readFileSync(path.join(packageFixtureDir, 'index.js'))), + ]; + const tarballPath = path.join(destination, 'hd-demo-dep-1.0.0.tgz'); + writeFileSync(tarballPath, gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]))); + return tarballPath; +} + +function createTarEntry(name: string, content: Buffer): Buffer { + const header = Buffer.alloc(512); + writeTarString(header, name, 0, 100); + writeTarOctal(header, 0o644, 100, 8); + writeTarOctal(header, 0, 108, 8); + writeTarOctal(header, 0, 116, 8); + writeTarOctal(header, content.length, 124, 12); + writeTarOctal(header, 0, 136, 12); + header.fill(' ', 148, 156); + header[156] = '0'.charCodeAt(0); + writeTarString(header, 'ustar', 257, 6); + writeTarString(header, '00', 263, 2); + + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + writeTarOctal(header, checksum, 148, 8); + + return Buffer.concat([header, content, Buffer.alloc(padToTarBlock(content.length))]); +} + +function writeTarString(header: Buffer, value: string, offset: number, length: number): void { + header.write(value, offset, Math.min(Buffer.byteLength(value), length), 'utf8'); +} + +function writeTarOctal(header: Buffer, value: number, offset: number, length: number): void { + const encoded = value.toString(8).padStart(length - 1, '0'); + header.write(`${encoded}\0`, offset, length, 'ascii'); +} + +function padToTarBlock(size: number): number { + const remainder = size % 512; + return remainder === 0 ? 0 : 512 - remainder; +} + +interface MockRegistry { + url: string; + close: () => void; +} + +async function startMockRegistry(tarballPath: string): Promise { + let registryUrl = ''; + const server = createServer((req, res) => { + handleRegistryRequest(req, res, registryUrl, tarballPath); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Mock registry did not bind to a TCP port'); + } + + registryUrl = `http://127.0.0.1:${address.port}`; + + return { + url: registryUrl, + close: () => { + server.closeAllConnections(); + server.close(); + }, + }; +} + +function handleRegistryRequest( + req: IncomingMessage, + res: ServerResponse, + registryUrl: string, + tarballPath: string, +): void { + const url = new URL(req.url ?? '/', registryUrl); + const decodedPathname = decodeURIComponent(url.pathname); + + if ( + req.method === 'GET' && + (decodedPathname === '/hd-demo-dep' || decodedPathname === '/@neverendingsupport/hd-demo-dep') + ) { + const isNesPackage = decodedPathname === '/@neverendingsupport/hd-demo-dep'; + const packageName = isNesPackage ? '@neverendingsupport/hd-demo-dep' : 'hd-demo-dep'; + const packageVersion = isNesPackage ? '1.0.0-hd-demo-dep-1.0.1' : '1.0.0'; + const tarballUrl = isNesPackage + ? `${registryUrl}/%40neverendingsupport/hd-demo-dep/-/hd-demo-dep-1.0.0-hd-demo-dep-1.0.1.tgz` + : `${registryUrl}/hd-demo-dep/-/hd-demo-dep-1.0.0.tgz`; + + sendJson(res, { + name: packageName, + 'dist-tags': { latest: packageVersion }, + versions: { + [packageVersion]: { + name: packageName, + version: packageVersion, + main: 'index.js', + dist: { + tarball: tarballUrl, + }, + }, + }, + }); + return; + } + + if (req.method === 'GET' && decodedPathname === '/catalog') { + sendJson(res, { + results: [ + { + component: 'pkg:npm/hd-demo-dep', + versions: [ + { + version: '1.0.0', + nes: { + latest: '1.0.0-hd-demo-dep-1.0.1', + purl: 'pkg:npm/%40neverendingsupport/hd-demo-dep', + }, + }, + ], + }, + ], + totalPages: 1, + }); + return; + } + + if ( + req.method === 'GET' && + (decodedPathname === '/hd-demo-dep/-/hd-demo-dep-1.0.0.tgz' || + decodedPathname === '/@neverendingsupport/hd-demo-dep/-/hd-demo-dep-1.0.0-hd-demo-dep-1.0.1.tgz') + ) { + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + res.end(readFileSync(tarballPath)); + return; + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +} + +function sendJson(res: ServerResponse, body: unknown): void { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} diff --git a/e2e/fixtures/install/hd-demo-dep/index.js b/e2e/fixtures/install/hd-demo-dep/index.js new file mode 100644 index 00000000..7543c3a7 --- /dev/null +++ b/e2e/fixtures/install/hd-demo-dep/index.js @@ -0,0 +1 @@ +module.exports = 'installed through hd install'; diff --git a/e2e/fixtures/install/hd-demo-dep/package.json b/e2e/fixtures/install/hd-demo-dep/package.json new file mode 100644 index 00000000..5557ae65 --- /dev/null +++ b/e2e/fixtures/install/hd-demo-dep/package.json @@ -0,0 +1,5 @@ +{ + "name": "hd-demo-dep", + "version": "1.0.0", + "main": "index.js" +} diff --git a/e2e/fixtures/install/simple-project/package.json b/e2e/fixtures/install/simple-project/package.json new file mode 100644 index 00000000..95c8349b --- /dev/null +++ b/e2e/fixtures/install/simple-project/package.json @@ -0,0 +1,8 @@ +{ + "name": "hd-install-simple-project", + "version": "1.0.0", + "private": true, + "dependencies": { + "hd-demo-dep": "1.0.0" + } +} diff --git a/e2e/setup/mock-auth-hooks.mjs b/e2e/setup/mock-auth-hooks.mjs index 35b20858..348e4eb0 100644 --- a/e2e/setup/mock-auth-hooks.mjs +++ b/e2e/setup/mock-auth-hooks.mjs @@ -1,6 +1,6 @@ /** - * ESM loader hooks that replace auth.svc.ts with a mock during E2E tests. - * This avoids writing encrypted token files during E2E tests. + * ESM loader hooks that replace auth services with mocks during E2E tests. + * This avoids writing encrypted token files or calling HeroDevs auth APIs during E2E tests. */ export async function load(url, context, nextLoad) { if (url.endsWith('/service/auth.svc.ts') || url.endsWith('/service/auth.svc.js')) { @@ -33,5 +33,15 @@ export async function load(url, context, nextLoad) { }; } + if (url.endsWith('/service/install/registry-auth.svc.ts') || url.endsWith('/service/install/registry-auth.svc.js')) { + return { + format: 'module', + shortCircuit: true, + source: ` + export function getNesRegistryAuthToken() { return Promise.resolve('test-registry-token'); } + `, + }; + } + return nextLoad(url, context); } diff --git a/package-lock.json b/package-lock.json index 02583630..6ff93c29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "cli-progress": "^3.12.0", "conf": "^15.1.0", "date-fns": "^4.1.0", + "fastify": "^5.8.5", "glob": "^13.0.0", "graphql": "^16.13.2", "node-machine-id": "^1.1.12", "ora": "^9.3.0", "packageurl-js": "^2.0.1", + "semver": "^7.8.1", "sloc": "^0.3.2", "terminal-link": "^5.0.0", "update-notifier": "^7.3.1" @@ -42,6 +44,7 @@ "@types/mock-fs": "^4.13.4", "@types/node": "^24.10.1", "@types/ora": "^3.2.0", + "@types/semver": "^7.7.1", "@types/sinon": "^21.0.1", "@types/sloc": "^0.2.3", "@types/terminal-link": "^1.1.0", @@ -1823,6 +1826,18 @@ "integrity": "sha512-fWC4ZPxo80qlh3xN5FxfIoQD3phVY4+EyzTIqyksjhKNDmaicdpxSvkWwIrYTtv9C1/RcUN6pxaTwGmj2NzS6A==", "license": "MIT" }, + "node_modules/@cyclonedx/cdxgen/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@cyclonedx/cyclonedx-library": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-library/-/cyclonedx-library-9.4.1.tgz", @@ -1866,29 +1881,6 @@ } } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2342,6 +2334,117 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@graphql-typed-document-node/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", @@ -2920,6 +3023,7 @@ "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.10.5.tgz", "integrity": "sha512-qcdCF7NrdWPfme6Kr34wwljRCXbCVpL1WVxiNy0Ep6vbWKjxAjFQwuhqkoyL0yjI+KdwtLcOCGn5z2yzdijc8w==", "license": "MIT", + "peer": true, "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", @@ -3807,6 +3911,12 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -4071,6 +4181,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", @@ -5116,6 +5249,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5142,11 +5276,19 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sinon": { "version": "21.0.1", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.1.tgz", @@ -5372,6 +5514,12 @@ "node": ">=8" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5599,6 +5747,15 @@ "retry": "0.13.1" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/atomically": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", @@ -5631,6 +5788,26 @@ "gulp-header": "^1.7.1" } }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6596,6 +6773,19 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6816,6 +7006,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-indent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", @@ -7420,6 +7619,12 @@ "node": ">=0.10.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7443,6 +7648,30 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, "node_modules/fast-levenshtein": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", @@ -7453,6 +7682,15 @@ "fastest-levenshtein": "^1.0.7" } }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -7514,11 +7752,43 @@ "node": ">= 4.9.1" } }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -7616,6 +7886,20 @@ "node": ">= 0.8" } }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", @@ -8133,6 +8417,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -8698,6 +8983,15 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -9030,6 +9324,25 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/json-schema-to-typescript": { "version": "15.0.4", "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", @@ -9181,6 +9494,43 @@ "node": ">=0.10.0" } }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -10644,6 +10994,15 @@ "node": ">=8" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11094,6 +11453,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -11154,6 +11550,22 @@ "dev": true, "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -11295,6 +11707,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -11384,6 +11802,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11464,6 +11883,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -11658,6 +12086,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -11672,13 +12109,18 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", @@ -11742,6 +12184,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11773,6 +12216,37 @@ "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", "license": "MIT" }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -11797,10 +12271,26 @@ "loose-envify": "^1.1.0" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11821,6 +12311,12 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-getter": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz", @@ -12092,6 +12588,15 @@ "node": ">= 14" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sort-object-keys": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz", @@ -12183,6 +12688,15 @@ "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", "license": "CC0-1.0" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -12642,6 +13156,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -12715,6 +13247,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12758,6 +13291,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -12850,6 +13392,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -12927,6 +13470,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13208,6 +13752,7 @@ "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/package.json b/package.json index 8e926c85..8ee1fc1d 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,13 @@ "cli-progress": "^3.12.0", "conf": "^15.1.0", "date-fns": "^4.1.0", + "fastify": "^5.8.5", "glob": "^13.0.0", "graphql": "^16.13.2", "node-machine-id": "^1.1.12", "ora": "^9.3.0", "packageurl-js": "^2.0.1", + "semver": "^7.8.1", "sloc": "^0.3.2", "terminal-link": "^5.0.0", "update-notifier": "^7.3.1" @@ -72,6 +74,7 @@ "@types/mock-fs": "^4.13.4", "@types/node": "^24.10.1", "@types/ora": "^3.2.0", + "@types/semver": "^7.7.1", "@types/sinon": "^21.0.1", "@types/sloc": "^0.2.3", "@types/terminal-link": "^1.1.0", diff --git a/src/commands/install.ts b/src/commands/install.ts new file mode 100644 index 00000000..49eb56a7 --- /dev/null +++ b/src/commands/install.ts @@ -0,0 +1,123 @@ +import { Command, ux } from '@oclif/core'; +import { track } from '../service/analytics.svc.ts'; +import { requireAccessToken } from '../service/auth.svc.ts'; +import { loadInstallCatalog } from '../service/install/catalog.svc.ts'; +import { + createInstallSummary, + formatInstallSummary, + toInstallAnalyticsProperties, +} from '../service/install/install-summary.svc.ts'; +import { runNpmInstall } from '../service/install/npm-runner.svc.ts'; +import { startInstallProxy } from '../service/install/proxy-server.svc.ts'; +import { getNesRegistryAuthToken } from '../service/install/registry-auth.svc.ts'; +import { getErrorMessage } from '../service/log.svc.ts'; + +const INSTALL_CATALOG_URL_ENV = 'HD_INSTALL_CATALOG_URL'; +const INSTALL_NES_REGISTRY_AUTH_TOKEN_ENV = 'HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN'; +const INSTALL_NES_REGISTRY_URL_ENV = 'HD_INSTALL_NPM_REGISTRY_URL'; +const DEFAULT_NES_REGISTRY_URL = 'https://registry.nes.herodevs.com/npm/pkg'; + +/** + * Orchestrates the first `hd install` flow. + * + * This command intentionally stays thin: auth, proxy lifecycle, a plain npm install invocation, + * and aggregate analytics. Package mapping, NES entitlement decisions, and reporting belong in + * install services so each step can be reviewed independently. + */ +export default class Install extends Command { + static override description = 'Install dependencies through the HeroDevs NES npm proxy'; + + async run() { + await this.parse(Install); + + let authToken: string; + try { + ux.action.start('Checking HeroDevs authentication'); + authToken = await requireAccessToken(); + ux.action.stop('ready'); + } catch (error) { + ux.action.stop('failed'); + this.error(`Must be logged in to install NES packages. Run 'hd auth login' first. ${getErrorMessage(error)}`); + } + + track('CLI Install Started', (context) => ({ + command: 'install', + app_used: context.app_used, + ci_provider: context.ci_provider, + cli_version: context.cli_version, + started_at: context.started_at, + })); + + ux.action.start('Loading NES catalog'); + const catalog = await loadInstallCatalog({ + authToken, + catalogUrl: process.env[INSTALL_CATALOG_URL_ENV], + onProgress: (message) => { + ux.action.status = message; + }, + }); + ux.action.stop(`${catalog.size} npm package mappings`); + const summary = createInstallSummary(); + + const nesRegistryUrl = process.env[INSTALL_NES_REGISTRY_URL_ENV] ?? DEFAULT_NES_REGISTRY_URL; + const registryAuthTokenOverride = process.env[INSTALL_NES_REGISTRY_AUTH_TOKEN_ENV]?.trim(); + let registryAuthToken: string; + if (registryAuthTokenOverride) { + registryAuthToken = registryAuthTokenOverride; + } else { + try { + ux.action.start('Preparing NES registry authentication'); + registryAuthToken = await getNesRegistryAuthToken(); + ux.action.stop('ready'); + } catch (error) { + ux.action.stop('failed'); + this.error(`Unable to authenticate with the NES registry. ${getErrorMessage(error)}`); + } + } + + ux.action.start('Starting local npm proxy'); + const proxy = await startInstallProxy({ + authToken, + catalog, + registryAuthToken, + summary, + nesRegistryUrl, + }); + ux.action.stop(proxy.registryUrl); + + let npmExitCode = 1; + try { + const result = await runNpmInstall({ + authToken, + nesRegistryUrl, + registryAuthToken, + registryUrl: proxy.registryUrl, + }); + npmExitCode = result.exitCode; + } catch (error) { + track('CLI Install Failed', () => ({ + command: 'install', + error: getErrorMessage(error), + })); + this.error(`Install failed. ${getErrorMessage(error)}`); + } finally { + await proxy.close(); + } + + if (npmExitCode !== 0) { + track('CLI Install Failed', () => ({ + command: 'install', + error: `npm_exit_code:${npmExitCode}`, + ...toInstallAnalyticsProperties(summary), + })); + this.exit(npmExitCode); + } + + track('CLI Install Succeeded', () => ({ + command: 'install', + ...toInstallAnalyticsProperties(summary), + })); + this.log(formatInstallSummary(summary)); + this.log('Install completed.'); + } +} diff --git a/src/service/analytics.svc.ts b/src/service/analytics.svc.ts index babf56dc..27aabbd7 100644 --- a/src/service/analytics.svc.ts +++ b/src/service/analytics.svc.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import { track as _track, Identify, identify, init, setOptOut, Types } from '@amplitude/analytics-node'; import NodeMachineId from 'node-machine-id'; import { config } from '../config/constants.ts'; +import type { InstallEolPackageSummary, InstallNesPackageSummary } from '../types/install.ts'; import { getStoredTokens, type StoredTokens } from './auth-token.svc.ts'; import { decodeJwtPayload } from './jwt.svc.ts'; import { debugLogger, getErrorMessage } from './log.svc.ts'; @@ -34,6 +35,12 @@ interface AnalyticsContext { command?: string; command_flags?: string; error?: string; + eol_no_nes_count?: number; + eol_no_nes_packages?: InstallEolPackageSummary[]; + nes_available_not_entitled_count?: number; + nes_available_not_entitled_packages?: InstallNesPackageSummary[]; + nes_matched_package_count?: number; + nes_matched_packages?: InstallNesPackageSummary[]; // Scan Results eol_true_count?: number; diff --git a/src/service/install/PLAN.md b/src/service/install/PLAN.md new file mode 100644 index 00000000..b3c87766 --- /dev/null +++ b/src/service/install/PLAN.md @@ -0,0 +1,121 @@ +# `hd install` plan + +`hd install` should automate NES package installation for customers who already have package +registry access. The command runs a short-lived local npm registry proxy, executes a plain +`npm install`, and lets npm write `node_modules` and `package-lock.json` from the metadata it +receives. + +Keep this file as the durable implementation guide. Detailed behavior belongs in code and tests. + +## Goals + +- Install entitled NES npm packages when npm resolves a catalog-matched OSS package. +- Pass through packages that cannot be safely replaced. +- Report exact catalog-matched NES packages that were not installed because registry access failed. +- Preserve npm behavior: do not rewrite `package.json`, `package-lock.json`, or `.npmrc`. +- Keep tokens out of config files, logs, generated docs, and test fixtures. + +## Current slice + +- `src/commands/install.ts` owns command orchestration, auth checks, proxy lifecycle, npm execution, analytics, and the final summary. +- `src/service/install/catalog.svc.ts` loads `https://api.nes.herodevs.com/api/catalog/packages?type=npm` once and indexes OSS npm package versions to catalog-provided NES package versions. +- `src/service/install/proxy-server.svc.ts` starts a local Fastify proxy. Ordinary requests pass through to public npm. Catalog-matched metadata requests fetch NES manifests, synthesize OSS-looking metadata, and expose stable NES tarball URLs. +- `src/service/install/npm-runner.svc.ts` runs exactly `npm install` with `NPM_CONFIG_REGISTRY` pointed at the local proxy. +- `src/service/install/registry-auth.svc.ts` documents and enforces the current registry-auth limitation: `hd auth login` proves API identity but does not expose a usable npm registry credential. +- `src/service/install/install-summary.svc.ts` aggregates matched NES packages, not-entitled opportunities, and EOL/no-NES opportunities. + +Do not add npm argument forwarding yet. For now `hd install` means exactly `npm install`. + +## Command flow + +1. Parse `hd install`; there are no install-specific flags and no forwarded npm args. +2. Require the existing CLI-managed HeroDevs OAuth session. +3. Fetch the npm catalog and build an in-memory package index. +4. Resolve NES registry auth: + - Prefer `HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN` when present. + - Without the override, fail with a clear message because the API cannot currently derive a registry-valid secret from `hd auth login`. +5. Start the proxy on `127.0.0.1` with port `0`. +6. Spawn `npm install` with only the child process environment changed. +7. Let the proxy classify package metadata requests while npm runs. +8. Close the proxy in `finally`. +9. Print a concise install summary, emit aggregate analytics, and exit with npm's exit code when npm fails. + +## Registry auth facts + +Research on 2026-05-27 established: + +- `hd auth login` stores a JWT for HeroDevs APIs. That JWT is not accepted by the npm registry. +- `iamV2.access.getOrgAccessTokens` returns JWT access/refresh tokens. Those tokens are not accepted by the npm registry. +- The npm registry accepts opaque/PAT-style licensing access-group tokens, for example tokens associated with the `NES Access Token Provider` integration. +- Licensing access groups are associated with the IAM principal tenant organization, not necessarily the EOL setup organization returned by `ensureUserSetup()`. +- The licensing API can list access groups and token masks, but it does not expose existing token secrets through normal queries. +- `licensing.groups.issueToken` was tested against the current registry flow and the newly issued credential was not accepted for `@neverendingsupport/lodash`. +- A known valid dev registry token successfully fetched `https://registry.dev.nes.herodevs.com/npm/pkg/%40neverendingsupport/lodash`. + +Current consequence: `hd install` cannot derive registry access from `hd auth login` alone. Use +`HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN` for real registry tests until there is a supported API for +obtaining an npm-registry-valid credential. + +## Environment overrides + +- `HD_INSTALL_NPM_REGISTRY_URL` overrides the NES registry base URL. Use it for local/e2e/dev registry tests only. +- `HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN` supplies the registry-valid token for NES manifest and tarball requests. Use it for local/e2e/dev registry tests and current manual real-registry validation. +- `HD_INSTALL_CATALOG_URL` overrides the catalog API URL. Use it for local/e2e tests only. + +These are intentionally not user-facing command flags in this slice. + +## Package decisions + +For each package metadata request, classify the package into one outcome: + +- `install-nes`: the requested OSS package/version matches catalog data, a compatible NES package exists, and the registry token can fetch its NES manifest. Return OSS-looking metadata whose `dist.tarball` points at the stable NES registry tarball URL. +- `available-not-entitled`: a compatible NES package exists, but the registry returns `401` or `403`. Pass through to public npm and include the exact NES package in the post-install summary. +- `eol-no-nes`: the package is EOL and no compatible NES remediation exists. Pass through and include it in the post-install remediation opportunity summary. +- `pass-through`: the package is outside catalog/EOL scope, belongs to a custom registry we should not intercept, is already a NES package request, or cannot be safely mapped. + +Prefer a successful package installation over perfect reporting. If enrichment fails temporarily, pass through and report what is known. + +## Registry and metadata behavior + +- Never attach HeroDevs credentials to a request just because npm called the local proxy. Match the requested package against the catalog first. +- Preserve the NES registry base path; `https://registry.nes.herodevs.com/npm/pkg` is not just a host. +- Treat NES registry `401` and `403` responses as not entitled. +- Use the catalog's latest NES version for each OSS version; do not request NES metadata using the raw OSS version. +- Preserve NES manifest fields such as dependencies, peer dependencies, engines, dist integrity, and tarball URLs. Override only the public package name/version needed for npm resolution. +- Return final stable tarball URLs in metadata. npm should write the lockfile by itself. +- Existing OSS lockfiles with absolute npmjs `resolved` URLs may not re-resolve through the proxy during plain `npm install`; replacing those safely requires a separate design, not ad hoc lockfile editing. +- Keep tarball responses streaming. Do not buffer large tarballs unless a future feature truly requires rewriting them. +- Do not intercept packages already configured for a private/custom registry once detection exists. + +## Known registry and catalog facts + +- The private npm registry is mounted under `/npm`; package metadata is served from `/npm/pkg/:pkg` and `/npm/pkg/:org/:pkg`. +- Tarballs are served from `/npm/pkg/:org/:pkg/-/:version.tgz`. +- The catalog source of truth is `https://api.nes.herodevs.com/api/catalog/packages`; `?type=npm` filters it to npm packages. +- As of 2026-05-27, the npm catalog exposes OSS version, NES purl/version, products, CVEs, and compatibility ranges, but not full npm manifest metadata such as dependencies. +- Existing catalog tooling maps OSS versions to NES versions using `version.oss.compatibility` ranges shaped like `{ lower, upper }`, interpreted as `>= lower < upper`. +- Do not correct catalog package-name mistakes in the CLI. If npm asks for a package name that is not an exact catalog match, pass it through and fix bad OSS identities in the catalog. + +## Performance constraints + +- Fetch npm catalog data once per install run where possible, using bounded parallel page loading. +- Cache NES manifests and package decisions for the duration of the command. +- Avoid NES registry calls for packages outside catalog scope. +- Preserve npm's concurrency; the proxy must handle concurrent metadata and tarball requests. +- Defer non-critical analytics and opportunity reporting until after npm exits, or keep it bounded. + +## Testing strategy + +- Unit test catalog matching, registry-auth failure modes, manifest synthesis, summary aggregation, and npm runner behavior. +- Use Fastify `app.inject()` for proxy route tests that do not need a real port. +- Keep command tests mocked so they verify lifecycle and exit behavior without running real npm. +- Keep E2E tests customer-shaped: copy a fixture project, run the real `hd install`, serve package metadata/tarballs from a local mock registry, and assert `node_modules` plus `package-lock.json`. + +## Deferred + +- Supported API or OAuth flow for retrieving an npm-registry-valid credential. +- Forwarding npm args or supporting package-manager flags. +- Persisting package manager configuration or editing `package.json`. +- Yarn and pnpm support. +- Custom registry detection. +- Sending full gathered install data to a dedicated analytics API. diff --git a/src/service/install/catalog.svc.ts b/src/service/install/catalog.svc.ts new file mode 100644 index 00000000..71a774bf --- /dev/null +++ b/src/service/install/catalog.svc.ts @@ -0,0 +1,186 @@ +import { PackageURL } from 'packageurl-js'; +import semver from 'semver'; +import type { InstallCatalogEntry, InstallCatalogIndex } from '../../types/install.ts'; + +const DEFAULT_CATALOG_URL = 'https://api.nes.herodevs.com/api/catalog/packages?type=npm'; +const CATALOG_FETCH_TIMEOUT_MS = 10_000; +const CATALOG_PAGE_CONCURRENCY = 5; + +interface LoadInstallCatalogOptions { + authToken: string; + catalogUrl?: string; + onProgress?: (message: string) => void; +} + +interface CatalogPage { + results: CatalogPackage[]; + totalPages: number; +} + +interface CatalogPackage { + component?: unknown; + versions?: CatalogPackageVersion[]; +} + +interface CatalogPackageVersion { + version?: unknown; + nes?: { + latest?: unknown; + purl?: unknown; + versions?: Array<{ purl?: unknown }>; + }; +} + +/** Fetches every catalog page once at startup and indexes npm OSS package names to NES packages. */ +export async function loadInstallCatalog(options: LoadInstallCatalogOptions): Promise { + const catalogUrl = options.catalogUrl ?? DEFAULT_CATALOG_URL; + options.onProgress?.('Loading NES catalog page 1'); + const firstPage = await fetchCatalogPage(catalogUrl, options.authToken); + const pages = [firstPage]; + options.onProgress?.(`Loaded NES catalog page 1 of ${firstPage.totalPages}`); + + const remainingPages: number[] = []; + for (let page = 2; page <= firstPage.totalPages; page++) { + remainingPages.push(page); + } + + for (let index = 0; index < remainingPages.length; index += CATALOG_PAGE_CONCURRENCY) { + const batch = remainingPages.slice(index, index + CATALOG_PAGE_CONCURRENCY); + options.onProgress?.(`Loading NES catalog pages ${batch[0]}-${batch[batch.length - 1]} of ${firstPage.totalPages}`); + const loadedPages = await Promise.all(batch.map((page) => fetchCatalogPage(catalogUrl, options.authToken, page))); + pages.push(...loadedPages); + } + + const catalog = createInstallCatalogIndex(pages); + options.onProgress?.(`Loaded NES catalog with ${catalog.size} npm package mappings`); + return catalog; +} + +/** + * Builds the proxy routing index from catalog response pages. + * + * Each OSS package may have several installable catalog entries because npm resolves a concrete + * OSS version first, while NES publishes package-specific replacement versions. + */ +export function createInstallCatalogIndex(pages: CatalogPage | CatalogPage[]): InstallCatalogIndex { + const catalogPages = Array.isArray(pages) ? pages : [pages]; + const index: InstallCatalogIndex = new Map(); + + for (const page of catalogPages) { + for (const item of page.results) { + const ossPackageName = getNpmPackageName(item.component); + if (!ossPackageName) { + continue; + } + + const entries = getNpmNesEntries(item); + for (const entry of entries) { + addCatalogEntry(index, ossPackageName, entry); + } + } + } + + return index; +} + +async function fetchCatalogPage(catalogUrl: string, authToken: string, page?: number): Promise { + const url = new URL(catalogUrl); + if (page !== undefined) { + url.searchParams.set('page', String(page)); + } + + const response = await fetch(url, { + headers: { + authorization: `Bearer ${authToken}`, + }, + signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Failed to load NES catalog: ${response.status}`); + } + + return normalizeCatalogPage(await response.json()); +} + +function normalizeCatalogPage(data: unknown): CatalogPage { + if (!data || typeof data !== 'object') { + throw new Error('Failed to load NES catalog: unexpected response shape'); + } + + const results = Array.isArray((data as { results?: unknown }).results) + ? (data as { results: CatalogPackage[] }).results + : []; + const totalPages = Number((data as { totalPages?: unknown }).totalPages); + + return { + results, + totalPages: Number.isInteger(totalPages) && totalPages > 0 ? totalPages : 1, + }; +} + +function getNpmNesEntries(item: CatalogPackage): InstallCatalogEntry[] { + const entries: InstallCatalogEntry[] = []; + if (!Array.isArray(item.versions)) { + return entries; + } + + for (const version of item.versions) { + const ossVersion = typeof version.version === 'string' ? semver.valid(version.version) : undefined; + const nesPackageName = getNpmPackageName(version.nes?.purl); + const nesVersion = typeof version.nes?.latest === 'string' ? version.nes.latest : undefined; + if (ossVersion && nesPackageName && nesVersion) { + entries.push({ nesPackageName, nesVersion, ossVersion }); + } + } + + return entries; +} + +function addCatalogEntry(index: InstallCatalogIndex, ossPackageName: string, entry: InstallCatalogEntry): void { + const entries = index.get(ossPackageName) ?? []; + const duplicateIndex = entries.findIndex((candidate) => candidate.ossVersion === entry.ossVersion); + if (duplicateIndex === -1) { + entries.push(entry); + } else if (isPreferredNesPackage(ossPackageName, entry.nesPackageName, entries[duplicateIndex].nesPackageName)) { + entries[duplicateIndex] = entry; + } + + entries.sort((left, right) => semver.compare(left.ossVersion, right.ossVersion)); + index.set(ossPackageName, entries); +} + +function isPreferredNesPackage(ossPackageName: string, candidate: string, current: string): boolean { + return ( + getPackageBaseName(candidate) === getPackageBaseName(ossPackageName) && + getPackageBaseName(current) !== getPackageBaseName(ossPackageName) + ); +} + +function getPackageBaseName(packageName: string): string { + return packageName.split('/').at(-1) ?? packageName; +} + +function getNpmPackageName(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length === 0) { + return; + } + + let purl: PackageURL; + try { + purl = PackageURL.fromString(value); + } catch { + return; + } + + if (purl.type !== 'npm') { + return; + } + + if (purl.namespace) { + const namespace = purl.namespace.startsWith('@') ? purl.namespace : `@${purl.namespace}`; + return `${namespace}/${purl.name}`; + } + + return purl.name; +} diff --git a/src/service/install/install-summary.svc.ts b/src/service/install/install-summary.svc.ts new file mode 100644 index 00000000..345469b9 --- /dev/null +++ b/src/service/install/install-summary.svc.ts @@ -0,0 +1,115 @@ +import semver from 'semver'; +import type { + InstallCatalogEntry, + InstallEolPackageSummary, + InstallNesPackageSummary, + InstallSummary, +} from '../../types/install.ts'; + +/** Creates the per-run install summary populated by the local proxy. */ +export function createInstallSummary(): InstallSummary { + return { + availableNotEntitled: new Map(), + matchedNesPackages: new Map(), + eolNoNesPackages: new Map(), + }; +} + +/** Records a NES package the customer could install if they had entitlement. */ +export function recordAvailableNotEntitled( + summary: InstallSummary, + ossPackageName: string, + entry: InstallCatalogEntry, +): void { + const item = toNesPackageSummary(ossPackageName, entry); + summary.availableNotEntitled.set(getNesPackageSummaryKey(item), item); +} + +/** Records a NES package successfully selected for this install run. */ +export function recordMatchedNesPackage( + summary: InstallSummary, + ossPackageName: string, + entry: InstallCatalogEntry, +): void { + const item = toNesPackageSummary(ossPackageName, entry); + summary.matchedNesPackages.set(getNesPackageSummaryKey(item), item); +} + +/** Serializes install summary maps into analytics-friendly arrays while preserving counts. */ +export function toInstallAnalyticsProperties(summary: InstallSummary): { + eol_no_nes_count: number; + eol_no_nes_packages: InstallEolPackageSummary[]; + nes_available_not_entitled_count: number; + nes_available_not_entitled_packages: InstallNesPackageSummary[]; + nes_matched_package_count: number; + nes_matched_packages: InstallNesPackageSummary[]; +} { + return { + eol_no_nes_count: summary.eolNoNesPackages.size, + eol_no_nes_packages: Array.from(summary.eolNoNesPackages.values()), + nes_available_not_entitled_count: summary.availableNotEntitled.size, + nes_available_not_entitled_packages: Array.from(summary.availableNotEntitled.values()), + nes_matched_package_count: summary.matchedNesPackages.size, + nes_matched_packages: Array.from(summary.matchedNesPackages.values()), + }; +} + +/** Formats a concise user-facing summary of the proxy decisions made during install. */ +export function formatInstallSummary(summary: InstallSummary): string { + const lines: string[] = []; + + if (summary.matchedNesPackages.size > 0) { + lines.push('Installed NES packages:'); + for (const item of getLatestPackageSummaries(summary.matchedNesPackages)) { + lines.push(formatNesPackageSummaryItem(item)); + } + } + + if (summary.availableNotEntitled.size > 0) { + lines.push('NES packages available, but not included in your entitlement:'); + for (const item of getLatestPackageSummaries(summary.availableNotEntitled)) { + lines.push(formatNesPackageSummaryItem(item)); + } + } + + if (summary.eolNoNesPackages.size > 0) { + lines.push('EOL packages without an available NES replacement:'); + for (const item of summary.eolNoNesPackages.values()) { + lines.push(`- ${item.packageName}${item.version ? `@${item.version}` : ''}`); + } + } + + return lines.length > 0 ? lines.join('\n') : 'No NES package changes were made.'; +} + +function toNesPackageSummary(ossPackageName: string, entry: InstallCatalogEntry): InstallNesPackageSummary { + return { + ossPackageName, + ossVersion: entry.ossVersion, + nesPackageName: entry.nesPackageName, + nesVersion: entry.nesVersion, + }; +} + +function getNesPackageSummaryKey(item: InstallNesPackageSummary): string { + return `${item.ossPackageName}@${item.ossVersion}->${item.nesPackageName}@${item.nesVersion}`; +} + +function formatNesPackageSummaryItem(item: InstallNesPackageSummary): string { + return `- ${item.ossPackageName}@${item.ossVersion} -> ${item.nesPackageName}@${item.nesVersion}`; +} + +function getLatestPackageSummaries(packages: Map): InstallNesPackageSummary[] { + const latestByPackageName = new Map(); + + for (const item of packages.values()) { + const current = latestByPackageName.get(item.ossPackageName); + if (!current || semver.gt(item.ossVersion, current.ossVersion)) { + latestByPackageName.set(item.ossPackageName, item); + } + } + + return Array.from(latestByPackageName.values()).sort((left, right) => + left.ossPackageName.localeCompare(right.ossPackageName), + ); +} diff --git a/src/service/install/npm-runner.svc.ts b/src/service/install/npm-runner.svc.ts new file mode 100644 index 00000000..e58de306 --- /dev/null +++ b/src/service/install/npm-runner.svc.ts @@ -0,0 +1,42 @@ +import { spawn } from 'node:child_process'; +import type { NpmInstallOptions, NpmInstallResult } from '../../types/install.ts'; + +/** + * Runs the first-iteration install command through npm. + * + * The local registry and NES tarball auth are injected only through this child process + * environment. We do not write to `.npmrc`, which keeps the behavior reversible. + */ +export function runNpmInstall(options: NpmInstallOptions): Promise { + return new Promise((resolve, reject) => { + const child = spawn('npm', ['install'], { + env: buildNpmInstallEnv(options), + stdio: 'inherit', + }); + + child.on('error', reject); + child.on('close', (exitCode) => { + resolve({ exitCode: exitCode ?? 1 }); + }); + }); +} + +function buildNpmInstallEnv(options: NpmInstallOptions): NodeJS.ProcessEnv { + return { + ...process.env, + // Point only this npm invocation at the local proxy; do not mutate the user's npm config files. + NPM_CONFIG_REGISTRY: options.registryUrl, + ...buildNesRegistryAuthEnv(options.nesRegistryUrl, options.registryAuthToken ?? options.authToken), + }; +} + +function buildNesRegistryAuthEnv(nesRegistryUrl: string, authToken: string): NodeJS.ProcessEnv { + const registryUrl = new URL(nesRegistryUrl); + const registryPath = registryUrl.pathname.endsWith('/') ? registryUrl.pathname : `${registryUrl.pathname}/`; + + // npm downloads stable NES tarball URLs directly from manifest metadata, outside our proxy. + // Registry auth has to use npm's config key shape so only this child process can read the token. + return { + [`NPM_CONFIG_//${registryUrl.host}${registryPath}:_authToken`]: authToken, + }; +} diff --git a/src/service/install/proxy-server.svc.ts b/src/service/install/proxy-server.svc.ts new file mode 100644 index 00000000..2d352f5d --- /dev/null +++ b/src/service/install/proxy-server.svc.ts @@ -0,0 +1,452 @@ +import { Readable } from 'node:stream'; +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'; +import semver from 'semver'; +import type { InstallCatalogEntry, InstallNesPackageSummary, InstallProxyOptions } from '../../types/install.ts'; +import { debugLogger } from '../log.svc.ts'; +import { recordAvailableNotEntitled, recordMatchedNesPackage } from './install-summary.svc.ts'; + +const DEFAULT_NES_REGISTRY_URL = 'https://registry.nes.herodevs.com/npm/pkg'; +const DEFAULT_PUBLIC_REGISTRY_URL = 'https://registry.npmjs.org'; +const UPSTREAM_FETCH_TIMEOUT_MS = 300_000; +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'content-encoding', + 'content-length', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); +const ENTITLEMENT_FAILURE_STATUSES = new Set([401, 403]); + +interface RegistryRequest { + isMetadataRequest: boolean; + packageName?: string; + withPackageName(packageName: string): string; +} + +/** + * Short-lived local npm registry proxy used by `hd install`. + * + * Requests pass through to public npm unless the package is present in the NES catalog loaded at + * startup. Only catalog-matched requests are sent to the NES registry with HeroDevs auth attached. + */ +export function createInstallProxy(options: InstallProxyOptions): FastifyInstance { + const nesRegistryUrl = options.nesRegistryUrl ?? DEFAULT_NES_REGISTRY_URL; + const publicRegistryUrl = options.publicRegistryUrl ?? DEFAULT_PUBLIC_REGISTRY_URL; + const nesManifestCache = new Map>>(); + const app = Fastify({ logger: false }); + + app.all('/*', async (request, reply) => { + const registryRequest = parseRegistryRequest(request.url); + const catalogEntries = registryRequest.packageName ? options.catalog.get(registryRequest.packageName) : undefined; + if (registryRequest.packageName && catalogEntries && registryRequest.isMetadataRequest) { + const response = await getSynthesizedManifest( + request, + options, + registryRequest.packageName, + catalogEntries, + nesRegistryUrl, + publicRegistryUrl, + nesManifestCache, + ); + reply.code(response.status); + copyResponseHeaders(reply, response.headers); + return reply.send(response.body); + } + + const catalogEntry = catalogEntries?.[0]; + const alreadyKnownNotEntitled = + registryRequest.packageName && catalogEntry + ? hasAvailableNotEntitledPackage( + options.summary.availableNotEntitled, + registryRequest.packageName, + catalogEntry, + ) + : false; + const shouldUseNesRegistry = Boolean(catalogEntry) && !alreadyKnownNotEntitled; + const upstreamPackageName = shouldUseNesRegistry + ? (catalogEntry?.nesPackageName ?? registryRequest.packageName) + : registryRequest.packageName; + const upstreamRegistryUrl = shouldUseNesRegistry ? nesRegistryUrl : publicRegistryUrl; + const upstreamPath = upstreamPackageName ? registryRequest.withPackageName(upstreamPackageName) : request.url; + debugLogger('install proxy request %o', { + packageName: registryRequest.packageName, + upstreamPackageName, + shouldUseNesRegistry, + upstreamRegistryUrl, + upstreamPath, + }); + + let upstreamResponse = await fetchUpstream( + request, + upstreamRegistryUrl, + upstreamPath, + shouldUseNesRegistry ? (options.registryAuthToken ?? options.authToken) : undefined, + ); + debugLogger('install proxy response %o', { + status: upstreamResponse.status, + upstreamRegistryUrl, + upstreamPath, + }); + + if ( + shouldUseNesRegistry && + registryRequest.packageName && + catalogEntry && + ENTITLEMENT_FAILURE_STATUSES.has(upstreamResponse.status) + ) { + recordAvailableNotEntitled(options.summary, registryRequest.packageName, catalogEntry); + debugLogger('install proxy fallback public npm %o', { + packageName: registryRequest.packageName, + status: upstreamResponse.status, + }); + upstreamResponse = await fetchUpstream(request, publicRegistryUrl, request.url); + } else if (shouldUseNesRegistry && registryRequest.packageName && catalogEntry) { + recordMatchedNesPackage(options.summary, registryRequest.packageName, catalogEntry); + } + + reply.code(upstreamResponse.status); + copyResponseHeaders(reply, upstreamResponse.headers); + + if (!upstreamResponse.body) { + return reply.send(); + } + + // Tarballs can be large, so passthrough responses must stream instead of buffering the body in memory. + return reply.send(Readable.fromWeb(upstreamResponse.body)); + }); + + return app; +} + +async function getSynthesizedManifest( + request: FastifyRequest, + options: InstallProxyOptions, + packageName: string, + catalogEntries: InstallCatalogEntry[], + nesRegistryUrl: string, + publicRegistryUrl: string, + manifestCache: Map>>, +): Promise<{ body: unknown; headers: Headers; status: number }> { + const manifests = new Map>(); + + for (const entry of catalogEntries) { + try { + manifests.set( + entry.nesPackageName, + await getNesManifest(request, options, entry.nesPackageName, nesRegistryUrl, manifestCache), + ); + } catch (error) { + if (error instanceof UpstreamResponseError && ENTITLEMENT_FAILURE_STATUSES.has(error.status)) { + recordAvailableNotEntitled(options.summary, packageName, entry); + continue; + } + + if (error instanceof UpstreamResponseError && error.status === 404) { + debugLogger('install proxy skipped missing NES manifest %o', { + packageName, + nesPackageName: entry.nesPackageName, + status: error.status, + }); + continue; + } + + throw error; + } + } + + const body = synthesizeManifest(packageName, catalogEntries, manifests); + if (Object.keys(body.versions).length === 0) { + debugLogger('install proxy fallback public npm empty synthesized manifest %o', { + packageName, + }); + return getPublicManifest(request, publicRegistryUrl); + } + + for (const entry of catalogEntries) { + if (Object.hasOwn(body.versions, entry.ossVersion)) { + recordMatchedNesPackage(options.summary, packageName, entry); + } + } + return { + body, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }; +} + +async function getNesManifest( + request: FastifyRequest, + options: InstallProxyOptions, + nesPackageName: string, + nesRegistryUrl: string, + manifestCache: Map>>, +): Promise> { + let manifest = manifestCache.get(nesPackageName); + if (!manifest) { + manifest = fetchNesManifest(request, options, nesPackageName, nesRegistryUrl); + manifestCache.set(nesPackageName, manifest); + } + + return manifest; +} + +async function fetchNesManifest( + request: FastifyRequest, + options: InstallProxyOptions, + nesPackageName: string, + nesRegistryUrl: string, +): Promise> { + const manifestPath = parseRegistryRequest(request.url).withPackageName(nesPackageName); + const response = await fetchUpstream( + request, + nesRegistryUrl, + manifestPath, + options.registryAuthToken ?? options.authToken, + // npm may request abbreviated install metadata, but this proxy needs the complete manifest to preserve package fields. + { accept: 'application/json' }, + ); + debugLogger('install proxy response %o', { + status: response.status, + upstreamRegistryUrl: nesRegistryUrl, + upstreamPath: manifestPath, + }); + + if (!response.ok) { + throw new UpstreamResponseError(response.status); + } + + return (await response.json()) as Record; +} + +async function getPublicManifest( + request: FastifyRequest, + publicRegistryUrl: string, +): Promise<{ body: unknown; headers: Headers; status: number }> { + const response = await fetchUpstream(request, publicRegistryUrl, request.url); + if (!response.ok) { + return { + body: await response.text(), + headers: response.headers, + status: response.status, + }; + } + + return { + body: await response.json(), + headers: response.headers, + status: response.status, + }; +} + +class UpstreamResponseError extends Error { + readonly status: number; + + constructor(status: number) { + super(`Upstream registry returned ${status}`); + this.status = status; + } +} + +function synthesizeManifest( + packageName: string, + catalogEntries: InstallCatalogEntry[], + manifests: Map>, +): { _id: string; name: string; versions: Record; 'dist-tags': { latest: string | undefined } } { + const versions: Record = {}; + for (const entry of catalogEntries) { + const nesVersion = getManifestVersion(manifests.get(entry.nesPackageName), entry.nesVersion); + if (!nesVersion) { + continue; + } + + versions[entry.ossVersion] = { + ...nesVersion, + name: packageName, + version: entry.ossVersion, + }; + } + + return { + _id: packageName, + name: packageName, + versions, + 'dist-tags': { + latest: resolveLatestVersion(Object.keys(versions)), + }, + }; +} + +function getManifestVersion( + manifest: Record | undefined, + version: string, +): Record | undefined { + const versions = manifest?.versions; + if (!versions || typeof versions !== 'object') { + return; + } + + const versionMetadata = (versions as Record)[version]; + if (!versionMetadata || typeof versionMetadata !== 'object') { + return; + } + + return versionMetadata as Record; +} + +function resolveLatestVersion(versions: string[]): string | undefined { + return semver.rsort(versions)[0]; +} + +function fetchUpstream( + request: FastifyRequest, + registryUrl: string, + requestPath: string, + authToken?: string, + headerOverrides: Record = {}, +): Promise { + const upstreamUrl = buildUpstreamUrl(registryUrl, requestPath); + return fetch(upstreamUrl, { + method: request.method, + headers: buildUpstreamHeaders(request, authToken, headerOverrides), + body: shouldForwardBody(request.method) ? request.raw : undefined, + duplex: shouldForwardBody(request.method) ? 'half' : undefined, + signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS), + } as RequestInit); +} + +/** Owns the lifecycle of a started local proxy server. */ +export class InstallProxyServer { + readonly app: FastifyInstance; + readonly registryUrl: string; + + constructor(app: FastifyInstance, registryUrl: string) { + this.app = app; + this.registryUrl = registryUrl; + } + + close(): Promise { + return this.app.close(); + } +} + +/** Starts the local proxy on an ephemeral localhost port for a single npm install run. */ +export async function startInstallProxy(options: InstallProxyOptions): Promise { + const app = createInstallProxy(options); + const registryUrl = await app.listen({ host: '127.0.0.1', port: 0 }); + + return new InstallProxyServer(app, registryUrl); +} + +function buildUpstreamHeaders( + request: FastifyRequest, + authToken?: string, + headerOverrides: Record = {}, +): Headers { + const headers = new Headers(); + for (const [key, value] of Object.entries(request.headers)) { + if (value === undefined || HOP_BY_HOP_HEADERS.has(key.toLowerCase()) || key.toLowerCase() === 'host') { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + headers.append(key, item); + } + } else { + headers.set(key, String(value)); + } + } + if (authToken) { + // npm does not know about the customer's HeroDevs CLI token, so catalog-matched NES requests authenticate here. + headers.set('authorization', `Bearer ${authToken}`); + } + for (const [key, value] of Object.entries(headerOverrides)) { + headers.set(key, value); + } + return headers; +} + +function shouldForwardBody(method: string): boolean { + return method !== 'GET' && method !== 'HEAD'; +} + +function hasAvailableNotEntitledPackage( + packages: Map, + ossPackageName: string, + entry: InstallCatalogEntry, +): boolean { + for (const item of packages.values()) { + if ( + item.ossPackageName === ossPackageName && + item.ossVersion === entry.ossVersion && + item.nesPackageName === entry.nesPackageName && + item.nesVersion === entry.nesVersion + ) { + return true; + } + } + return false; +} + +function buildUpstreamUrl(registryUrl: string, requestPath: string): URL { + const upstreamUrl = new URL(registryUrl); + const requestUrl = new URL(requestPath, 'http://localhost'); + const basePath = upstreamUrl.pathname.endsWith('/') ? upstreamUrl.pathname.slice(0, -1) : upstreamUrl.pathname; + const requestPathname = requestUrl.pathname.startsWith('/') ? requestUrl.pathname.slice(1) : requestUrl.pathname; + upstreamUrl.pathname = `${basePath}/${requestPathname}`; + upstreamUrl.search = requestUrl.search; + return upstreamUrl; +} + +function copyResponseHeaders(reply: FastifyReply, headers: Headers): void { + for (const [key, value] of headers) { + // Hop-by-hop and body framing headers cannot be copied safely after Node fetch has decoded the body. + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + reply.header(key, value); + } + } +} + +function parseRegistryRequest(path: string): RegistryRequest { + const url = new URL(path, 'http://localhost'); + const { pathname } = url; + const parts = pathname.split('/').filter(Boolean); + const [firstPart, secondPart] = parts; + let packageName: string | undefined; + let packagePartCount = 0; + + if (firstPart && !firstPart.startsWith('-')) { + const decodedFirstPart = decodeURIComponent(firstPart); + if (decodedFirstPart.startsWith('@')) { + if (decodedFirstPart.includes('/')) { + packageName = decodedFirstPart; + packagePartCount = 1; + } else if (secondPart && secondPart !== '-') { + packageName = `${decodedFirstPart}/${decodeURIComponent(secondPart)}`; + packagePartCount = 2; + } + } else { + packageName = decodedFirstPart; + packagePartCount = 1; + } + } + + return { + isMetadataRequest: Boolean(packageName) && parts.length === packagePartCount, + packageName, + withPackageName(nextPackageName: string): string { + if (!packageName) { + return path; + } + + const encodedPackageName = nextPackageName.split('/').map(encodeURIComponent).join('/'); + const suffix = parts.slice(packagePartCount).join('/'); + url.pathname = `/${encodedPackageName}${suffix ? `/${suffix}` : ''}`; + return `${url.pathname}${url.search}`; + }, + }; +} diff --git a/src/service/install/registry-auth.svc.ts b/src/service/install/registry-auth.svc.ts new file mode 100644 index 00000000..f33acccb --- /dev/null +++ b/src/service/install/registry-auth.svc.ts @@ -0,0 +1,154 @@ +import { config } from '../../config/constants.ts'; +import { requireAccessToken } from '../auth.svc.ts'; + +const graphqlUrl = `${config.graphqlHost}${config.graphqlPath}`; +const NPM_REGISTRY_TOKEN_PROVIDER = 'NES Access Token Provider'; +const REGISTRY_TOKEN_OVERRIDE_ENV = 'HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN'; + +type GraphQLResponse = { + data?: T; + errors?: Array<{ message?: string }>; +}; + +type AccessGroupsResponse = { + licensing?: { + groups?: { + search?: { + results?: Array<{ + access?: { + packages?: unknown[]; + subscriptions?: unknown[]; + }; + id?: number; + name?: string; + tokens?: Array<{ integrationName?: string }>; + }>; + }; + }; + }; +}; + +type PrincipalResponse = { + iam?: { + principal?: { + tenant?: number; + }; + }; +}; + +const principalQuery = ` +query InstallRegistryPrincipal { + iam { + principal { + tenant + } + } +} +`; + +const accessGroupsQuery = ` +query InstallRegistryAccessGroups($input: LicensingAccessGroupsSearchInput!) { + licensing { + groups { + search(input: $input) { + results { + id + name + tokens { + integrationName + } + access { + subscriptions { label } + packages { id } + } + } + } + } + } +} +`; + +/** + * Attempts to derive the credential the NES npm registry expects. + * + * `hd auth login` stores an OAuth token that is valid for HeroDevs APIs, but the package + * registry authorizes package downloads with an opaque licensing access-group credential. + * The current licensing API exposes existing registry credentials only as masks and newly issued + * access-group tokens are not accepted by the npm registry, so login alone cannot produce a usable + * registry secret. + */ +export async function getNesRegistryAuthToken(): Promise { + const apiToken = await requireAccessToken(); + const orgId = await getPrincipalTenantId(apiToken); + const hasAccessGroup = await hasRegistryAccessGroup(apiToken, orgId); + if (!hasAccessGroup) { + throw new Error( + `No NES registry access group was found for your account. Set ${REGISTRY_TOKEN_OVERRIDE_ENV} for dev/local registry testing, or ask your organization admin to grant NES package access.`, + ); + } + + throw new Error( + `Your account has NES registry access, but hd auth login does not expose the registry credential. Set ${REGISTRY_TOKEN_OVERRIDE_ENV} for this install run.`, + ); +} + +async function getPrincipalTenantId(authToken: string): Promise { + const data = await callGraphQL(authToken, principalQuery, {}); + const tenantId = data.iam?.principal?.tenant; + if (typeof tenantId !== 'number') { + throw new Error('NES registry auth principal did not include a tenant ID'); + } + return tenantId; +} + +async function hasRegistryAccessGroup(authToken: string, orgId: number): Promise { + const data = await callGraphQL(authToken, accessGroupsQuery, { + input: { orgIn: [orgId] }, + }); + + const groups = data.licensing?.groups?.search?.results ?? []; + for (const group of groups) { + if (typeof group.id !== 'number') { + continue; + } + + const packageCount = group.access?.packages?.length ?? 0; + const subscriptionCount = group.access?.subscriptions?.length ?? 0; + if (packageCount === 0 && subscriptionCount === 0) { + continue; + } + + if ((group.tokens ?? []).some((candidate) => candidate.integrationName === NPM_REGISTRY_TOKEN_PROVIDER)) { + return true; + } + } + + return false; +} + +async function callGraphQL(authToken: string, query: string, variables: Record): Promise { + const response = await fetch(graphqlUrl, { + method: 'POST', + headers: { + authorization: `Bearer ${authToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`NES registry auth request failed: ${response.status}`); + } + + const payload = (await response.json()) as GraphQLResponse; + if (payload.errors?.length) { + const message = payload.errors[0]?.message ?? 'NES registry auth request failed'; + throw new Error(message); + } + + if (!payload.data) { + throw new Error('NES registry auth request returned no data'); + } + + return payload.data; +} diff --git a/src/types/install.ts b/src/types/install.ts new file mode 100644 index 00000000..f854373e --- /dev/null +++ b/src/types/install.ts @@ -0,0 +1,53 @@ +/** Options shared by the local npm proxy implementation. */ +export interface InstallProxyOptions { + authToken: string; + catalog: InstallCatalogIndex; + registryAuthToken?: string; + summary: InstallSummary; + nesRegistryUrl?: string; + publicRegistryUrl?: string; +} + +/** Catalog entries keyed by the OSS npm package name npm will request. */ +export type InstallCatalogIndex = Map; + +/** One installable NES version exposed as an OSS npm version in synthesized metadata. */ +export interface InstallCatalogEntry { + nesPackageName: string; + nesVersion: string; + ossVersion: string; +} + +/** Aggregates install decisions so the command can report and emit analytics after npm exits. */ +export interface InstallSummary { + availableNotEntitled: Map; + matchedNesPackages: Map; + eolNoNesPackages: Map; +} + +/** A concrete OSS package/version and the NES package/version selected for it. */ +export interface InstallNesPackageSummary { + ossPackageName: string; + ossVersion: string; + nesPackageName: string; + nesVersion: string; +} + +/** Placeholder shape for future EOL opportunities that do not have a NES remediation yet. */ +export interface InstallEolPackageSummary { + packageName: string; + version?: string; +} + +/** Process result from the plain `npm install` child process. */ +export interface NpmInstallResult { + exitCode: number; +} + +/** Inputs for the npm child process used by `hd install`. */ +export interface NpmInstallOptions { + authToken: string; + nesRegistryUrl: string; + registryAuthToken?: string; + registryUrl: string; +} diff --git a/test/commands/install.test.ts b/test/commands/install.test.ts new file mode 100644 index 00000000..0041d285 --- /dev/null +++ b/test/commands/install.test.ts @@ -0,0 +1,255 @@ +import { type Mock, vi } from 'vitest'; + +const { + getNesRegistryAuthTokenMock, + loadInstallCatalogMock, + requireAccessTokenMock, + runNpmInstallMock, + startInstallProxyMock, + trackMock, +} = vi.hoisted(() => ({ + getNesRegistryAuthTokenMock: vi.fn(), + loadInstallCatalogMock: vi.fn(), + requireAccessTokenMock: vi.fn(), + runNpmInstallMock: vi.fn(), + startInstallProxyMock: vi.fn(), + trackMock: vi.fn(), +})); + +vi.mock('../../src/service/install/registry-auth.svc.ts', () => ({ + __esModule: true, + getNesRegistryAuthToken: getNesRegistryAuthTokenMock, +})); + +vi.mock('../../src/service/install/catalog.svc.ts', () => ({ + __esModule: true, + loadInstallCatalog: loadInstallCatalogMock, +})); + +vi.mock('../../src/service/auth.svc.ts', () => ({ + __esModule: true, + requireAccessToken: requireAccessTokenMock, +})); + +vi.mock('../../src/service/install/npm-runner.svc.ts', () => ({ + __esModule: true, + runNpmInstall: runNpmInstallMock, +})); + +vi.mock('../../src/service/install/proxy-server.svc.ts', () => ({ + __esModule: true, + startInstallProxy: startInstallProxyMock, +})); + +vi.mock('../../src/service/analytics.svc.ts', () => ({ + __esModule: true, + track: trackMock, +})); + +import { ux } from '@oclif/core'; +import Install from '../../src/commands/install.ts'; +import { track } from '../../src/service/analytics.svc.ts'; +import { requireAccessToken } from '../../src/service/auth.svc.ts'; +import { loadInstallCatalog } from '../../src/service/install/catalog.svc.ts'; +import { runNpmInstall } from '../../src/service/install/npm-runner.svc.ts'; +import { startInstallProxy } from '../../src/service/install/proxy-server.svc.ts'; +import { getNesRegistryAuthToken } from '../../src/service/install/registry-auth.svc.ts'; + +describe('Install command', () => { + const closeProxyMock = vi.fn(); + const originalRegistryAuthToken = process.env.HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN; + const catalog = new Map([ + ['lodash', [{ nesPackageName: '@neverendingsupport/lodash', nesVersion: '1.0.1', ossVersion: '1.0.0' }]], + ]); + + beforeEach(() => { + vi.resetAllMocks(); + delete process.env.HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN; + closeProxyMock.mockResolvedValue(undefined); + (requireAccessToken as Mock).mockResolvedValue('access-token'); + (getNesRegistryAuthToken as Mock).mockResolvedValue('registry-access-token'); + (loadInstallCatalog as Mock).mockResolvedValue(catalog); + (startInstallProxy as Mock).mockResolvedValue({ + registryUrl: 'http://127.0.0.1:12345', + close: closeProxyMock, + }); + (runNpmInstall as Mock).mockResolvedValue({ exitCode: 0 }); + vi.spyOn(ux.action, 'start').mockImplementation(() => {}); + vi.spyOn(ux.action, 'stop').mockImplementation(() => {}); + }); + + afterEach(() => { + if (originalRegistryAuthToken === undefined) { + delete process.env.HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN; + } else { + process.env.HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN = originalRegistryAuthToken; + } + }); + + it('starts the proxy, runs npm install, and closes the proxy', async () => { + const command = new Install([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}); + + await command.run(); + + expect(requireAccessToken).toHaveBeenCalledTimes(1); + expect(loadInstallCatalog).toHaveBeenCalledWith({ + authToken: 'access-token', + catalogUrl: undefined, + onProgress: expect.any(Function), + }); + expect(startInstallProxy).toHaveBeenCalledWith({ + authToken: 'access-token', + catalog, + registryAuthToken: 'registry-access-token', + summary: { + availableNotEntitled: new Map(), + matchedNesPackages: new Map(), + eolNoNesPackages: new Map(), + }, + nesRegistryUrl: 'https://registry.nes.herodevs.com/npm/pkg', + }); + expect(runNpmInstall).toHaveBeenCalledWith({ + authToken: 'access-token', + nesRegistryUrl: 'https://registry.nes.herodevs.com/npm/pkg', + registryAuthToken: 'registry-access-token', + registryUrl: 'http://127.0.0.1:12345', + }); + expect(closeProxyMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('Install completed.'); + expect(track).toHaveBeenNthCalledWith(1, 'CLI Install Started', expect.any(Function)); + expect(track).toHaveBeenNthCalledWith(2, 'CLI Install Succeeded', expect.any(Function)); + }); + + it('emits exact NES package decisions in install analytics', async () => { + (startInstallProxy as Mock).mockImplementation(async (options) => { + options.summary.availableNotEntitled.set('lodash@1.0.0->@neverendingsupport/lodash@1.0.1', { + ossPackageName: 'lodash', + ossVersion: '1.0.0', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '1.0.1', + }); + options.summary.matchedNesPackages.set('vue@2.7.14->@neverendingsupport/vue@2.7.14-vue-2.7.23', { + ossPackageName: 'vue', + ossVersion: '2.7.14', + nesPackageName: '@neverendingsupport/vue', + nesVersion: '2.7.14-vue-2.7.23', + }); + return { + registryUrl: 'http://127.0.0.1:12345', + close: closeProxyMock, + }; + }); + const command = new Install([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'log').mockImplementation(() => {}); + + await command.run(); + + const getProperties = (track as Mock).mock.calls[1][1]; + expect(getProperties({})).toMatchObject({ + nes_available_not_entitled_count: 1, + nes_available_not_entitled_packages: [ + { + ossPackageName: 'lodash', + ossVersion: '1.0.0', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '1.0.1', + }, + ], + nes_matched_package_count: 1, + nes_matched_packages: [ + { + ossPackageName: 'vue', + ossVersion: '2.7.14', + nesPackageName: '@neverendingsupport/vue', + nesVersion: '2.7.14-vue-2.7.23', + }, + ], + eol_no_nes_count: 0, + eol_no_nes_packages: [], + }); + }); + + it('uses the registry auth token override for NES npm registry access', async () => { + process.env.HD_INSTALL_NPM_REGISTRY_AUTH_TOKEN = 'registry-token'; + const command = new Install([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'log').mockImplementation(() => {}); + + await command.run(); + + expect(startInstallProxy).toHaveBeenCalledWith( + expect.objectContaining({ + authToken: 'access-token', + registryAuthToken: 'registry-token', + }), + ); + expect(getNesRegistryAuthToken).not.toHaveBeenCalled(); + expect(runNpmInstall).toHaveBeenCalledWith( + expect.objectContaining({ + authToken: 'access-token', + registryAuthToken: 'registry-token', + }), + ); + }); + + it('fails before starting the proxy when NES registry auth cannot be prepared', async () => { + (getNesRegistryAuthToken as Mock).mockRejectedValue(new Error('registry auth failed')); + const command = new Install([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'error').mockImplementation((message) => { + throw new Error(message as string); + }); + + await expect(command.run()).rejects.toThrow(/Unable to authenticate with the NES registry/); + + expect(startInstallProxy).not.toHaveBeenCalled(); + expect(runNpmInstall).not.toHaveBeenCalled(); + }); + + it('fails before starting the proxy when auth is missing', async () => { + (requireAccessToken as Mock).mockRejectedValue(new Error('not logged in')); + const command = new Install([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'error').mockImplementation((message) => { + throw new Error(message as string); + }); + + await expect(command.run()).rejects.toThrow(/Must be logged in/); + + expect(startInstallProxy).not.toHaveBeenCalled(); + expect(runNpmInstall).not.toHaveBeenCalled(); + }); + + it('closes the proxy when npm install fails', async () => { + (runNpmInstall as Mock).mockRejectedValue(new Error('spawn failed')); + const command = new Install([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'log').mockImplementation(() => {}); + vi.spyOn(command, 'error').mockImplementation((message) => { + throw new Error(message as string); + }); + + await expect(command.run()).rejects.toThrow(/spawn failed/); + + expect(closeProxyMock).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenNthCalledWith(2, 'CLI Install Failed', expect.any(Function)); + }); + + it('closes the proxy and exits with npm exit code when npm install exits non-zero', async () => { + (runNpmInstall as Mock).mockResolvedValue({ exitCode: 42 }); + const command = new Install([], {} as Record); + vi.spyOn(command, 'parse').mockResolvedValue({ flags: {}, args: {} } as never); + vi.spyOn(command, 'log').mockImplementation(() => {}); + vi.spyOn(command, 'exit').mockImplementation((code) => { + throw new Error(`exit:${code}`); + }); + + await expect(command.run()).rejects.toThrow('exit:42'); + + expect(closeProxyMock).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenNthCalledWith(2, 'CLI Install Failed', expect.any(Function)); + }); +}); diff --git a/test/service/install/catalog.svc.test.ts b/test/service/install/catalog.svc.test.ts new file mode 100644 index 00000000..c4e6fe17 --- /dev/null +++ b/test/service/install/catalog.svc.test.ts @@ -0,0 +1,266 @@ +import { createInstallCatalogIndex, loadInstallCatalog } from '../../../src/service/install/catalog.svc.ts'; + +describe('install catalog service', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('loads all catalog pages with the HeroDevs token', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [ + { + component: 'pkg:npm/lodash', + versions: [ + { + version: '4.17.21', + nes: { + latest: '4.17.21-lodash-4.17.22', + purl: 'pkg:npm/%40neverendingsupport/lodash', + }, + }, + ], + }, + ], + totalPages: 2, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [ + { + component: 'pkg:npm/%40angular/core', + versions: [ + { + version: '19.2.21', + nes: { + latest: '19.2.21-angular-19.2.22', + purl: 'pkg:npm/%40neverendingsupport/angular-core', + }, + }, + ], + }, + ], + totalPages: 2, + }), + { status: 200 }, + ), + ); + globalThis.fetch = fetchMock; + + const catalog = await loadInstallCatalog({ + authToken: 'access-token', + catalogUrl: 'https://catalog.example.test/packages', + }); + + expect(catalog).toEqual( + new Map([ + [ + 'lodash', + [ + { + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '4.17.21-lodash-4.17.22', + ossVersion: '4.17.21', + }, + ], + ], + [ + '@angular/core', + [ + { + nesPackageName: '@neverendingsupport/angular-core', + nesVersion: '19.2.21-angular-19.2.22', + ossVersion: '19.2.21', + }, + ], + ], + ]), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + new URL('https://catalog.example.test/packages'), + expect.objectContaining({ + headers: { authorization: 'Bearer access-token' }, + signal: expect.any(AbortSignal), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + new URL('https://catalog.example.test/packages?page=2'), + expect.objectContaining({ + headers: { authorization: 'Bearer access-token' }, + signal: expect.any(AbortSignal), + }), + ); + }); + + it('uses the npm catalog filter by default', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + results: [], + totalPages: 1, + }), + { status: 200 }, + ), + ); + globalThis.fetch = fetchMock; + + await loadInstallCatalog({ + authToken: 'access-token', + }); + + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://api.nes.herodevs.com/api/catalog/packages?type=npm'), + expect.any(Object), + ); + }); + + it('builds an OSS npm package to NES npm package index from catalog pages', () => { + const catalog = createInstallCatalogIndex({ + results: [ + { + component: 'pkg:npm/lodash', + versions: [ + { + version: '4.17.21', + nes: { + latest: '4.17.21-lodash-4.17.22', + purl: 'pkg:npm/%40neverendingsupport/lodash', + versions: [{ purl: 'pkg:npm/%40neverendingsupport/lodash@4.17.23-lodash-4.17.25' }], + }, + }, + ], + }, + { + component: 'pkg:npm/%40angular/core', + versions: [ + { + version: '19.2.21', + nes: { + latest: '19.2.21-angular-19.2.22', + purl: 'pkg:npm/%40neverendingsupport/angular-core', + }, + }, + ], + }, + ], + totalPages: 1, + }); + + expect(catalog).toEqual( + new Map([ + [ + 'lodash', + [ + { + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '4.17.21-lodash-4.17.22', + ossVersion: '4.17.21', + }, + ], + ], + [ + '@angular/core', + [ + { + nesPackageName: '@neverendingsupport/angular-core', + nesVersion: '19.2.21-angular-19.2.22', + ossVersion: '19.2.21', + }, + ], + ], + ]), + ); + }); + + it('prefers package-specific NES mappings over legacy shared packages for the same OSS version', () => { + const catalog = createInstallCatalogIndex({ + results: [ + { + component: 'pkg:npm/vue', + versions: [ + { + version: '2.7.14', + nes: { + latest: '2.7.22', + purl: 'pkg:npm/%40neverendingsupport/vue2', + }, + }, + { + version: '2.7.14', + nes: { + latest: '2.7.14-vue-2.7.23', + purl: 'pkg:npm/%40neverendingsupport/vue', + }, + }, + ], + }, + ], + totalPages: 1, + }); + + expect(catalog.get('vue')).toEqual([ + { + nesPackageName: '@neverendingsupport/vue', + nesVersion: '2.7.14-vue-2.7.23', + ossVersion: '2.7.14', + }, + ]); + }); + + it('indexes the OSS package identity emitted by the catalog component', () => { + const catalog = createInstallCatalogIndex({ + results: [ + { + component: 'pkg:npm/vue-compiler-sfc', + versions: [ + { + version: '2.7.14', + nes: { + latest: '2.7.14-vue-2.7.23', + purl: 'pkg:npm/%40neverendingsupport/vue-compiler-sfc', + }, + }, + ], + }, + ], + totalPages: 1, + }); + + expect(catalog.get('vue-compiler-sfc')).toEqual([ + { + nesPackageName: '@neverendingsupport/vue-compiler-sfc', + nesVersion: '2.7.14-vue-2.7.23', + ossVersion: '2.7.14', + }, + ]); + }); + + it('ignores non-npm catalog entries and incomplete NES mappings', () => { + const catalog = createInstallCatalogIndex({ + results: [ + { + component: 'pkg:composer/colorbox', + versions: [{ nes: { purl: 'pkg:composer/neverendingsupport/colorbox' } }], + }, + { + component: 'pkg:npm/bootstrap', + versions: [{ nes: { purl: '' } }], + }, + ], + totalPages: 1, + }); + + expect(catalog).toEqual(new Map()); + }); +}); diff --git a/test/service/install/install-summary.svc.test.ts b/test/service/install/install-summary.svc.test.ts new file mode 100644 index 00000000..0c35ef8c --- /dev/null +++ b/test/service/install/install-summary.svc.test.ts @@ -0,0 +1,47 @@ +import { + createInstallSummary, + formatInstallSummary, + recordAvailableNotEntitled, + recordMatchedNesPackage, +} from '../../../src/service/install/install-summary.svc.ts'; + +describe('install summary service', () => { + it('prints only the latest not-entitled NES candidate for each OSS package', () => { + const summary = createInstallSummary(); + recordAvailableNotEntitled(summary, 'lodash', { + ossVersion: '4.17.21', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '4.17.21-lodash-4.17.22', + }); + recordAvailableNotEntitled(summary, 'lodash', { + ossVersion: '4.17.23', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '4.17.23-lodash-4.17.25', + }); + + expect(formatInstallSummary(summary)).toBe( + [ + 'NES packages available, but not included in your entitlement:', + '- lodash@4.17.23 -> @neverendingsupport/lodash@4.17.23-lodash-4.17.25', + ].join('\n'), + ); + }); + + it('prints only the latest installed NES candidate for each OSS package', () => { + const summary = createInstallSummary(); + recordMatchedNesPackage(summary, 'lodash', { + ossVersion: '4.17.21', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '4.17.21-lodash-4.17.22', + }); + recordMatchedNesPackage(summary, 'lodash', { + ossVersion: '4.17.23', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '4.17.23-lodash-4.17.25', + }); + + expect(formatInstallSummary(summary)).toBe( + ['Installed NES packages:', '- lodash@4.17.23 -> @neverendingsupport/lodash@4.17.23-lodash-4.17.25'].join('\n'), + ); + }); +}); diff --git a/test/service/install/proxy-server.svc.test.ts b/test/service/install/proxy-server.svc.test.ts new file mode 100644 index 00000000..ed628a26 --- /dev/null +++ b/test/service/install/proxy-server.svc.test.ts @@ -0,0 +1,504 @@ +import { createInstallProxy } from '../../../src/service/install/proxy-server.svc.ts'; +import type { InstallNesPackageSummary, InstallSummary } from '../../../src/types/install.ts'; + +describe('install proxy server', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('passes requests outside the catalog through to the configured public registry', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ name: 'lodash' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock; + + const summary = createSummary(); + const app = createInstallProxy({ + authToken: 'access-token', + summary, + catalog: new Map(), + publicRegistryUrl: 'https://registry.example.test', + }); + const response = await app.inject({ + method: 'GET', + url: '/lodash', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ name: 'lodash' }); + expect(fetchMock).toHaveBeenCalledWith( + new URL('/lodash', 'https://registry.example.test'), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + + await app.close(); + }); + + it('routes catalog-matched requests to the mapped NES package path', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify( + createNesManifest('@neverendingsupport/lodash', '1.0.1', { dependencies: { leftpad: '^1.0.0' } }), + ), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + globalThis.fetch = fetchMock; + + const summary = createSummary(); + const app = createInstallProxy({ + authToken: 'access-token', + summary, + catalog: new Map([ + ['lodash', [{ nesPackageName: '@neverendingsupport/lodash', nesVersion: '1.0.1', ossVersion: '1.0.0' }]], + ]), + }); + const response = await app.inject({ + method: 'GET', + url: '/lodash', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().versions['1.0.0']).toMatchObject({ + name: 'lodash', + version: '1.0.0', + dependencies: { + leftpad: '^1.0.0', + }, + dist: { + tarball: 'https://registry.example.test/lodash.tgz', + }, + }); + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://registry.nes.herodevs.com/npm/pkg/%40neverendingsupport/lodash'), + expect.any(Object), + ); + + await app.close(); + }); + + it('uses the catalog NES version when synthesizing OSS metadata', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(createNesManifest('@neverendingsupport/vue', '2.7.14-vue-2.7.23')), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock; + + const summary = createSummary(); + const app = createInstallProxy({ + authToken: 'access-token', + summary, + catalog: new Map([ + ['vue', [{ nesPackageName: '@neverendingsupport/vue', nesVersion: '2.7.14-vue-2.7.23', ossVersion: '2.7.14' }]], + ]), + nesRegistryUrl: 'https://registry.dev.nes.herodevs.com/npm/pkg', + }); + const response = await app.inject({ + method: 'GET', + url: '/vue', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + name: 'vue', + versions: { + '2.7.14': { + name: 'vue', + version: '2.7.14', + dist: { + tarball: 'https://registry.example.test/lodash.tgz', + }, + }, + }, + 'dist-tags': { + latest: '2.7.14', + }, + }); + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://registry.dev.nes.herodevs.com/npm/pkg/%40neverendingsupport/vue'), + expect.any(Object), + ); + + await app.close(); + }); + + it('records catalog-matched metadata and caches NES manifests for the proxy run', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(createNesManifest('@neverendingsupport/lodash', '1.0.1')), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock; + + const summary = createSummary(); + const app = createInstallProxy({ + authToken: 'access-token', + summary, + catalog: new Map([ + ['lodash', [{ nesPackageName: '@neverendingsupport/lodash', nesVersion: '1.0.1', ossVersion: '1.0.0' }]], + ]), + publicRegistryUrl: 'https://registry.example.test', + }); + const response = await app.inject({ + method: 'GET', + url: '/lodash', + }); + const cachedResponse = await app.inject({ + method: 'GET', + url: '/lodash', + }); + + expect(response.statusCode).toBe(200); + expect(cachedResponse.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(Array.from(summary.matchedNesPackages.values())).toEqual([ + { + ossPackageName: 'lodash', + ossVersion: '1.0.0', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '1.0.1', + }, + ]); + + await app.close(); + }); + + it('falls back to public npm and records a not-entitled package when the NES manifest is unavailable', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('not entitled', { status: 403 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'lodash' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock; + + const summary = createSummary(); + const app = createInstallProxy({ + authToken: 'access-token', + summary, + catalog: new Map([ + ['lodash', [{ nesPackageName: '@neverendingsupport/lodash', nesVersion: '1.0.1', ossVersion: '1.0.0' }]], + ]), + publicRegistryUrl: 'https://registry.example.test', + }); + const response = await app.inject({ + method: 'GET', + url: '/lodash', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ name: 'lodash' }); + expect(Array.from(summary.availableNotEntitled.values())).toEqual([ + { + ossPackageName: 'lodash', + ossVersion: '1.0.0', + nesPackageName: '@neverendingsupport/lodash', + nesVersion: '1.0.1', + }, + ]); + + await app.close(); + }); + + it('skips inaccessible catalog candidates while keeping accessible NES versions', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('not entitled', { status: 403 })) + .mockResolvedValueOnce( + new Response(JSON.stringify(createNesManifest('@neverendingsupport/vue', '2.7.14-vue-2.7.23')), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock; + + const summary = createSummary(); + const app = createInstallProxy({ + authToken: 'access-token', + summary, + catalog: new Map([ + [ + 'vue', + [ + { nesPackageName: '@neverendingsupport/vue2', nesVersion: '2.7.22', ossVersion: '2.7.10' }, + { nesPackageName: '@neverendingsupport/vue', nesVersion: '2.7.14-vue-2.7.23', ossVersion: '2.7.14' }, + ], + ], + ]), + publicRegistryUrl: 'https://registry.example.test', + }); + const response = await app.inject({ + method: 'GET', + url: '/vue', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().versions).toEqual({ + '2.7.14': { + name: 'vue', + version: '2.7.14', + dist: { + tarball: 'https://registry.example.test/lodash.tgz', + }, + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(Array.from(summary.availableNotEntitled.values())).toEqual([ + { + ossPackageName: 'vue', + ossVersion: '2.7.10', + nesPackageName: '@neverendingsupport/vue2', + nesVersion: '2.7.22', + }, + ]); + + await app.close(); + }); + + it('strips decoded body framing headers before replying to npm', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ name: 'lodash' }), { + status: 200, + headers: { + 'content-encoding': 'gzip', + 'content-length': '999', + 'content-type': 'application/json', + }, + }), + ); + globalThis.fetch = fetchMock; + + const app = createInstallProxy({ + authToken: 'access-token', + summary: createSummary(), + catalog: new Map(), + publicRegistryUrl: 'https://registry.example.test', + }); + const response = await app.inject({ + method: 'GET', + url: '/lodash', + }); + + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.headers['content-length']).toBeUndefined(); + expect(response.headers['content-type']).toBe('application/json'); + + await app.close(); + }); + + it('preserves tarball request suffixes when routing to NES', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(createNesManifest('@neverendingsupport/lodash', '1.0.1')), { status: 200 }), + ); + globalThis.fetch = fetchMock; + + const app = createInstallProxy({ + authToken: 'access-token', + summary: createSummary(), + catalog: new Map([ + ['lodash', [{ nesPackageName: '@neverendingsupport/lodash', nesVersion: '1.0.1', ossVersion: '1.0.0' }]], + ]), + }); + await app.inject({ + method: 'GET', + url: '/lodash/-/lodash-4.17.25.tgz', + }); + + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://registry.nes.herodevs.com/npm/pkg/%40neverendingsupport/lodash/-/lodash-4.17.25.tgz'), + expect.any(Object), + ); + + await app.close(); + }); + + it('adds the HeroDevs token only when the request is catalog-matched', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(createNesManifest('@neverendingsupport/scope-package', '1.0.1')), { status: 200 }), + ); + globalThis.fetch = fetchMock; + + const app = createInstallProxy({ + authToken: 'access-token', + summary: createSummary(), + catalog: new Map([ + ['lodash', [{ nesPackageName: '@neverendingsupport/lodash', nesVersion: '1.0.1', ossVersion: '1.0.0' }]], + ]), + nesRegistryUrl: 'https://registry.dev.nes.herodevs.com/npm/pkg', + }); + await app.inject({ + method: 'GET', + url: '/lodash/-/lodash-1.0.1.tgz', + }); + + const [, requestInit] = fetchMock.mock.calls[0]; + expect((requestInit.headers as Headers).get('authorization')).toBe('Bearer access-token'); + + await app.close(); + }); + + it('does not add the HeroDevs token when the request is not catalog-matched', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + globalThis.fetch = fetchMock; + + const app = createInstallProxy({ + authToken: 'access-token', + summary: createSummary(), + catalog: new Map([ + ['bootstrap', [{ nesPackageName: '@neverendingsupport/bootstrap', nesVersion: '1.0.1', ossVersion: '1.0.0' }]], + ]), + nesRegistryUrl: 'https://registry.dev.nes.herodevs.com/npm/pkg', + publicRegistryUrl: 'https://registry.example.test', + }); + await app.inject({ + method: 'GET', + url: '/lodash', + }); + + const [, requestInit] = fetchMock.mock.calls[0]; + expect((requestInit.headers as Headers).get('authorization')).toBeNull(); + + await app.close(); + }); + + it('preserves scoped public tarball request suffixes without duplicating the package name', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + globalThis.fetch = fetchMock; + + const app = createInstallProxy({ + authToken: 'access-token', + summary: createSummary(), + catalog: new Map(), + publicRegistryUrl: 'https://registry.example.test', + }); + await app.inject({ + method: 'GET', + url: '/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz', + }); + + expect(fetchMock).toHaveBeenCalledWith( + new URL( + 'https://registry.example.test/%40babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz', + ), + expect.any(Object), + ); + + await app.close(); + }); + + it('keeps tarballs on public npm after metadata proves a catalog package is not entitled', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + globalThis.fetch = fetchMock; + + const app = createInstallProxy({ + authToken: 'access-token', + summary: createSummary({ + availableNotEntitled: [ + { + ossPackageName: 'vue', + ossVersion: '2.7.14', + nesPackageName: '@neverendingsupport/vue', + nesVersion: '2.7.14-vue-2.7.23', + }, + ], + }), + catalog: new Map([ + ['vue', [{ nesPackageName: '@neverendingsupport/vue', nesVersion: '2.7.14-vue-2.7.23', ossVersion: '2.7.14' }]], + ]), + publicRegistryUrl: 'https://registry.example.test', + }); + await app.inject({ + method: 'GET', + url: '/vue/-/vue-2.7.14.tgz', + }); + + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://registry.example.test/vue/-/vue-2.7.14.tgz'), + expect.any(Object), + ); + + await app.close(); + }); + + it('matches scoped package names when npm encodes the slash', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + globalThis.fetch = fetchMock; + + const app = createInstallProxy({ + authToken: 'access-token', + summary: createSummary(), + catalog: new Map([ + [ + '@scope/package', + [{ nesPackageName: '@neverendingsupport/scope-package', nesVersion: '1.0.1', ossVersion: '1.0.0' }], + ], + ]), + nesRegistryUrl: 'https://registry.dev.nes.herodevs.com/npm/pkg', + }); + await app.inject({ + method: 'GET', + url: '/@scope/package/-/scope-package-1.0.1.tgz', + }); + + expect(fetchMock).toHaveBeenCalledWith( + new URL( + 'https://registry.dev.nes.herodevs.com/npm/pkg/%40neverendingsupport/scope-package/-/scope-package-1.0.1.tgz', + ), + expect.any(Object), + ); + + await app.close(); + }); +}); + +function createNesManifest(name: string, version: string, metadata: Record = {}): unknown { + return { + name, + versions: { + [version]: { + name, + version, + ...metadata, + dist: { + tarball: 'https://registry.example.test/lodash.tgz', + }, + }, + }, + }; +} + +function createSummary(options: { availableNotEntitled?: InstallNesPackageSummary[] } = {}): InstallSummary { + const availableNotEntitled = new Map(); + for (const item of options.availableNotEntitled ?? []) { + availableNotEntitled.set( + `${item.ossPackageName}@${item.ossVersion}->${item.nesPackageName}@${item.nesVersion}`, + item, + ); + } + + return { + availableNotEntitled, + matchedNesPackages: new Map(), + eolNoNesPackages: new Map(), + }; +} diff --git a/test/service/install/registry-auth.svc.test.ts b/test/service/install/registry-auth.svc.test.ts new file mode 100644 index 00000000..79092314 --- /dev/null +++ b/test/service/install/registry-auth.svc.test.ts @@ -0,0 +1,124 @@ +import { type Mock, vi } from 'vitest'; + +vi.mock('../../../src/service/auth.svc.ts', () => ({ + __esModule: true, + requireAccessToken: vi.fn(), +})); + +import { requireAccessToken } from '../../../src/service/auth.svc.ts'; +import { getNesRegistryAuthToken } from '../../../src/service/install/registry-auth.svc.ts'; + +describe('install registry auth service', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + (requireAccessToken as Mock).mockResolvedValue('api-token'); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('throws when registry access exists but no registry credential can be derived from login', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(createPrincipalResponse(1000)) + .mockResolvedValueOnce( + createGraphQLResponse({ + licensing: { + groups: { + search: { + results: [ + { + id: 11, + name: 'wrong integration', + tokens: [{ integrationName: 'Other' }], + access: { packages: [{ id: 1 }], subscriptions: [] }, + }, + { + id: 12, + name: 'nes', + tokens: [{ integrationName: 'NES Access Token Provider' }], + access: { packages: [{ id: 1 }], subscriptions: [] }, + }, + ], + }, + }, + }, + }), + ); + globalThis.fetch = fetchMock; + + await expect(getNesRegistryAuthToken()).rejects.toThrow(/does not expose the registry credential/); + + expect(requireAccessToken).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + const searchBody = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(searchBody.variables).toEqual({ input: { orgIn: [1000] } }); + }); + + it('throws when no licensing access group is available', async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createPrincipalResponse(1000)) + .mockResolvedValueOnce( + createGraphQLResponse({ + licensing: { + groups: { + search: { + results: [], + }, + }, + }, + }), + ); + + await expect(getNesRegistryAuthToken()).rejects.toThrow(/No NES registry access group/); + }); + + it('throws when NES access groups do not have a token provider', async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createPrincipalResponse(1000)) + .mockResolvedValueOnce( + createGraphQLResponse({ + licensing: { + groups: { + search: { + results: [ + { + id: 12, + tokens: [{ integrationName: 'Other' }], + access: { packages: [{ id: 1 }], subscriptions: [] }, + }, + ], + }, + }, + }, + }), + ); + + await expect(getNesRegistryAuthToken()).rejects.toThrow(/No NES registry access group/); + }); +}); + +function createPrincipalResponse(tenant: number): Response { + return createGraphQLResponse({ + iam: { + principal: { + tenant, + }, + }, + }); +} + +function createGraphQLResponse(data: unknown): Response { + return { + ok: true, + status: 200, + async json() { + return { data }; + }, + } as Response; +}