diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index d2ceb0af..7cc1405b 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -13,6 +13,7 @@ A CLI tool to check the bundle size of npm packages, similar to [bundlephobia.co - Support for checking specific exports (tree-shaking) - Automatic externalization of React and React-DOM - Raw and gzip sizes with configurable compression level +- Custom npm registry support (for private registries) - Fast bundling using esbuild (with pnpm support) ## Installation @@ -101,6 +102,7 @@ bundlecheck lodash "debounce,throttle" | `--gzipLevel ` | `-g ` | Gzip compression level (1-9, default: 5) | | `--external ` | `-e ` | Comma-separated additional packages to mark as external | | `--noExternal` | `-n` | Do not mark any packages as external | +| `--registry ` | `-r ` | Custom npm registry URL (default: registry.npmjs.org) | ### Examples @@ -132,6 +134,9 @@ bundlecheck lodash --trend # Show bundle size trend for 3 versions bundlecheck lodash --trend 3 + +# Use a custom npm registry +bundlecheck @myorg/private-pkg --registry https://npm.mycompany.com ``` ## How It Works @@ -149,6 +154,21 @@ By default, `react` and `react-dom` are marked as external (not included in the To include React/React-DOM in the bundle size calculation, use the `--no-external` flag. +## Custom Registry + +Use the `--registry` flag to check packages from private or alternative npm registries: + +```bash +# Private corporate registry +bundlecheck @myorg/myorg-ui-library --registry https://npm.mycompany.com + +# Verdaccio local registry +bundlecheck my-local-pkg --registry http://localhost:4873 + +``` + +Note: If the registry requires authentication, ensure your npm/pnpm is configured with the appropriate credentials (via `.npmrc` or environment variables). + ## License MIT - see [LICENSE](./LICENSE) for details. diff --git a/packages/bundlecheck/src/__tests__/bundler.test.ts b/packages/bundlecheck/src/__tests__/bundler.test.ts index 216065a6..1c0556eb 100644 --- a/packages/bundlecheck/src/__tests__/bundler.test.ts +++ b/packages/bundlecheck/src/__tests__/bundler.test.ts @@ -294,6 +294,50 @@ describe("checkBundleSize", () => { expect(result.gzipLevel).toBe(5); }); + it("should pass valid registry URL to install command", async () => { + await checkBundleSize({ + packageName: "test-package", + registry: "https://registry.example.com", + }); + + // Verify execSync was called with the registry flag + const execCalls = vi.mocked(execSync).mock.calls; + const installCall = execCalls.find( + (call) => typeof call[0] === "string" && call[0].includes("--registry"), + ); + expect(installCall).toBeDefined(); + expect(installCall?.[0]).toContain( + '--registry "https://registry.example.com/"', + ); + }); + + it("should reject invalid registry URL", async () => { + await expect( + checkBundleSize({ + packageName: "test-package", + registry: "not-a-valid-url", + }), + ).rejects.toThrow("Invalid registry URL"); + }); + + it("should reject non-http/https registry URL", async () => { + await expect( + checkBundleSize({ + packageName: "test-package", + registry: "ftp://registry.example.com", + }), + ).rejects.toThrow("Invalid registry URL protocol"); + }); + + it("should reject registry URL with command injection attempt", async () => { + await expect( + checkBundleSize({ + packageName: "test-package", + registry: "https://evil.com; rm -rf /", + }), + ).rejects.toThrow("Invalid registry URL"); + }); + it("should handle package with subpath", async () => { const result = await checkBundleSize({ packageName: "@scope/package/subpath", diff --git a/packages/bundlecheck/src/__tests__/versions.test.ts b/packages/bundlecheck/src/__tests__/versions.test.ts index 8909e683..0748271c 100644 --- a/packages/bundlecheck/src/__tests__/versions.test.ts +++ b/packages/bundlecheck/src/__tests__/versions.test.ts @@ -135,6 +135,66 @@ describe("fetchPackageVersions", () => { expect(result.tags).toEqual({}); }); + + it("should use custom registry URL when provided", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + versions: { "1.0.0": {} }, + "dist-tags": { latest: "1.0.0" }, + }), + }); + + await fetchPackageVersions({ + packageName: "test-package", + registry: "https://custom.registry.com", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.registry.com/test-package", + ); + }); + + it("should strip trailing slash from custom registry URL", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + versions: { "1.0.0": {} }, + "dist-tags": { latest: "1.0.0" }, + }), + }); + + await fetchPackageVersions({ + packageName: "test-package", + registry: "https://custom.registry.com/", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.registry.com/test-package", + ); + }); + + it("should use options object format", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + versions: { "1.0.0": {} }, + "dist-tags": { latest: "1.0.0" }, + }), + }); + + const result = await fetchPackageVersions({ + packageName: "@scope/package", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/@scope/package", + ); + expect(result.versions).toEqual(["1.0.0"]); + }); }); describe("promptForVersion", () => { diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index fdff5d5e..36c2d539 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -73,7 +73,10 @@ async function main() { log.info(`\nFetching available versions for ${name}...`); - const { versions } = await fetchPackageVersions(packageName); + const { versions } = await fetchPackageVersions({ + packageName, + registry: flags?.registry, + }); if (versions.length === 0) { log.error("No versions found for this package"); @@ -96,6 +99,7 @@ async function main() { noExternal: flags?.noExternal, gzipLevel: flags?.gzipLevel, boring: flags?.boring, + registry: flags?.registry, }); if (results.length === 0) { @@ -128,7 +132,10 @@ async function main() { const { name, subpath } = parsePackageSpecifier(packageName); log.info(`\nFetching available versions for ${name}...`); - const { versions, tags } = await fetchPackageVersions(packageName); + const { versions, tags } = await fetchPackageVersions({ + packageName, + registry: flags?.registry, + }); if (versions.length === 0) { log.error("No versions found for this package"); @@ -162,6 +169,7 @@ async function main() { additionalExternals, noExternal: flags?.noExternal, gzipLevel: flags?.gzipLevel, + registry: flags?.registry, }); const blue = kleur.blue; diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 9bd4e039..44a272d5 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -80,6 +80,7 @@ export type BundleOptions = { additionalExternals?: string[]; noExternal?: boolean; gzipLevel?: number; + registry?: string; }; export type BundleResult = { @@ -141,18 +142,55 @@ function isPnpmAvailable(): boolean { // Cache the result of pnpm availability check let usePnpm: boolean | null = null; +/** + * Validate and sanitize a registry URL to prevent command injection + * @param registry - The registry URL to validate + * @returns The sanitized URL or undefined if invalid + * @throws Error if the URL is invalid or contains potentially malicious characters + */ +function validateRegistryUrl(registry: string): string { + // Parse as URL to validate format + let url: URL; + try { + url = new URL(registry); + } catch { + throw new Error( + `Invalid registry URL: ${registry}. Must be a valid URL (e.g., https://registry.example.com)`, + ); + } + + // Only allow http and https protocols + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error( + `Invalid registry URL protocol: ${url.protocol}. Only http: and https: are allowed`, + ); + } + + // Return the sanitized URL (URL constructor normalizes it) + return url.toString(); +} + /** * Get the install command (pnpm preferred, npm fallback) + * @param registry - Optional custom npm registry URL */ -function getInstallCommand(): string { +function getInstallCommand(registry?: string): string { if (usePnpm === null) { usePnpm = isPnpmAvailable(); } + let registryArg = ""; + if (registry) { + // Validate and sanitize the registry URL to prevent command injection + const sanitizedRegistry = validateRegistryUrl(registry); + // Quote the URL to handle any special characters safely + registryArg = ` --registry "${sanitizedRegistry}"`; + } + if (usePnpm) { - return "pnpm install --ignore-scripts --no-frozen-lockfile"; + return `pnpm install --ignore-scripts --no-frozen-lockfile${registryArg}`; } - return "npm install --legacy-peer-deps --ignore-scripts"; + return `npm install --legacy-peer-deps --ignore-scripts${registryArg}`; } export type EntryContentOptions = { @@ -446,6 +484,7 @@ export async function checkBundleSize( additionalExternals, noExternal, gzipLevel = 5, + registry, } = options; // Parse the package specifier to extract name, version, and subpath @@ -479,7 +518,7 @@ export async function checkBundleSize( ); // Install the main package (try pnpm first, fallback to npm) - const installCmd = getInstallCommand(); + const installCmd = getInstallCommand(registry); execSync(installCmd, { cwd: tmpDir, stdio: "pipe", diff --git a/packages/bundlecheck/src/defaults.ts b/packages/bundlecheck/src/defaults.ts index bfbf01a5..9a8fa5de 100644 --- a/packages/bundlecheck/src/defaults.ts +++ b/packages/bundlecheck/src/defaults.ts @@ -6,8 +6,11 @@ export const defaultFlags = { external: "", noExternal: false, versions: false, + registry: "", }; export const TREND_VERSION_COUNT = 5; export const DEFAULT_EXTERNALS = ["react", "react-dom"]; + +export const DEFAULT_REGISTRY = "https://registry.npmjs.org"; diff --git a/packages/bundlecheck/src/parse.ts b/packages/bundlecheck/src/parse.ts index 988000d4..f72d44e9 100644 --- a/packages/bundlecheck/src/parse.ts +++ b/packages/bundlecheck/src/parse.ts @@ -11,6 +11,7 @@ export type Flags = { gzipLevel?: number; external?: string; noExternal?: boolean; + registry?: string; }; export type Parameters = { @@ -70,6 +71,10 @@ export const config: Configuration = parser({ command: "bundlecheck lodash --trend 3", comment: "## Show bundle size trend for 3 versions", }, + { + command: "bundlecheck lodash --registry https://registry.example.com", + comment: "## Use a custom npm registry", + }, ], flags: { gzipLevel: { @@ -118,6 +123,13 @@ export const config: Configuration = parser({ description: "Show bundle size trend for N recent versions (default: 5)", type: "string", }, + registry: { + shortFlag: "r", + default: defaultFlags.registry, + description: + "Custom npm registry URL (default: https://registry.npmjs.org)", + type: "string", + }, }, parameters: { package: { diff --git a/packages/bundlecheck/src/trend.ts b/packages/bundlecheck/src/trend.ts index 67f4940a..3917919a 100644 --- a/packages/bundlecheck/src/trend.ts +++ b/packages/bundlecheck/src/trend.ts @@ -18,6 +18,7 @@ export type TrendOptions = { noExternal?: boolean; gzipLevel?: number; boring?: boolean; + registry?: string; }; /** @@ -48,6 +49,7 @@ export async function analyzeTrend( noExternal, gzipLevel, boring, + registry, } = options; const log = new Logger({ boring }); @@ -64,6 +66,7 @@ export async function analyzeTrend( additionalExternals, noExternal, gzipLevel, + registry, }); results.push({ diff --git a/packages/bundlecheck/src/versions.ts b/packages/bundlecheck/src/versions.ts index 1217d29d..fe43b271 100644 --- a/packages/bundlecheck/src/versions.ts +++ b/packages/bundlecheck/src/versions.ts @@ -1,6 +1,7 @@ import select from "@inquirer/select"; import { rsort } from "semver"; import { parsePackageSpecifier } from "./bundler.js"; +import { DEFAULT_REGISTRY } from "./defaults.js"; export type NpmPackageInfo = { versions: string[]; @@ -15,13 +16,28 @@ type NpmRegistryResponse = { "dist-tags"?: Record; }; +export type FetchVersionsOptions = { + packageName: string; + registry?: string; +}; + export async function fetchPackageVersions( - packageName: string, + packageNameOrOptions: string | FetchVersionsOptions, ): Promise { + // Support both string (legacy) and options object + const { packageName, registry } = + typeof packageNameOrOptions === "string" + ? { packageName: packageNameOrOptions, registry: undefined } + : packageNameOrOptions; + // Parse the package specifier to get just the name (without version) const { name } = parsePackageSpecifier(packageName); - const url = `https://registry.npmjs.org/${name}`; + // Use custom registry or default + const registryUrl = registry || DEFAULT_REGISTRY; + // Ensure no trailing slash + const baseUrl = registryUrl.replace(/\/$/, ""); + const url = `${baseUrl}/${name}`; const response = await fetch(url); if (!response.ok) {