UNPKG

sync-npm-packages

Version:

Sync released npm packages to mirror registries automatically.

487 lines (486 loc) 17.3 kB
import process from "node:process"; import { cac } from "cac"; import c from "tinyrainbow"; import { createConfigLoader } from "unconfig"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { glob } from "tinyglobby"; import { request } from "node:https"; //#region package.json var name = "sync-npm-packages"; var version = "0.3.0"; //#endregion //#region src/config.ts /** * Resolve user definedConfig based on cli config and config file * * @param cliConfig - cli config * @returns merged config */ async function resolveConfig(cliConfig = {}) { const { config = {} } = await createConfigLoader({ sources: [{ files: ["sync.config"], extensions: [ "mts", "cts", "ts", "mjs", "cjs", "js", "json" ] }, { files: [".syncrc"], extensions: ["json"] }], cwd: process.cwd(), merge: false }).load(); return { ...config, ...cliConfig }; } //#endregion //#region node_modules/.pnpm/@ntnyq+utils@0.13.3/node_modules/@ntnyq/utils/dist/index.js /** * Returns a new array with unique values. * @param array - The array to process. * @returns The new array. * @example * * ```typescript * import { unique } from '@ntnyq/utils' * * const result = unique([1, 1, 2, 3, 3]) * console.log(result) // => [1, 2, 3] * ``` * */ function unique(array) { return Array.from(new Set(array)); } /** * Converts a value to an array. * @param array - The value to convert. * @returns The array. * @example * * ```typescript * import { toArray } from '@ntnyq/utils' * * const result = toArray('hello') * console.log(result) // => ['hello'] * ``` * */ function toArray(array) { array ??= []; return Array.isArray(array) ? array : [array]; } //#endregion //#region src/transport.ts const DEFAULT_REQUEST_TIMEOUT = 1e4; const DEFAULT_REGISTRY_HOST = "registry.npmmirror.com"; /** * Check whether an IPv4 hostname belongs to private/local ranges. * @param hostname - hostname in IPv4 format * @returns true when host is private or local IPv4 */ function isPrivateIpv4(hostname) { const parts = hostname.split(".").map(Number); if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return false; const [a, b] = parts; return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168; } /** * Check whether an IPv6 hostname belongs to private/local ranges. * @param hostname - hostname in IPv6 format * @returns true when host is private or local IPv6 */ function isPrivateIpv6(hostname) { if (!hostname.includes(":")) return false; const normalized = hostname.toLowerCase(); return normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd"); } /** * Validate whether a host is unsafe for outbound registry requests. * @param hostname - normalized hostname * @returns true when hostname points to localhost/private network */ function isUnsafeHost(hostname) { const normalized = hostname.toLowerCase(); return normalized === "localhost" || normalized.endsWith(".localhost") || isPrivateIpv4(normalized) || isPrivateIpv6(normalized); } /** * Normalize and validate registry host for outbound requests. * @param registry - registry host or HTTPS URL */ function normalizeRegistryHost(registry) { const rawRegistry = (registry ?? DEFAULT_REGISTRY_HOST).trim(); if (!rawRegistry) throw new Error("Registry host cannot be empty"); const url = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//u.test(rawRegistry) ? new URL(rawRegistry) : new URL(`https://${rawRegistry}`); if (url.protocol !== "https:") throw new Error("Registry must use https protocol"); if (url.username || url.password) throw new Error("Registry must not include credentials"); if (url.pathname && url.pathname !== "/") throw new Error("Registry must not include path"); if (url.search || url.hash) throw new Error("Registry must not include query or hash"); const host = url.hostname.toLowerCase(); if (!host) throw new Error("Registry host cannot be empty"); if (isUnsafeHost(host)) throw new Error("Registry host must be a public host"); return host; } /** * Build the npmmirror sync path and safely encode package names. * @param packageName - npm package name */ function buildSyncRequestPath(packageName) { return `/-/package/${encodeURIComponent(packageName)}/syncs`; } /** * Build sync path for custom target. * @param packageName - npm package name * @param syncPathTemplate - custom sync path template * @returns sync path for custom registry */ function buildCustomSyncRequestPath(packageName, syncPathTemplate) { if (!syncPathTemplate) throw new Error("syncPathTemplate is required when target is custom and must contain {packageName}"); if (!syncPathTemplate.includes("{packageName}")) throw new Error("syncPathTemplate must include {packageName} placeholder"); if (!syncPathTemplate.startsWith("/")) throw new Error("syncPathTemplate must start with /"); const encodedPackageName = encodeURIComponent(packageName); return syncPathTemplate.replaceAll("{packageName}", encodedPackageName); } /** * Resolve request method and path for the configured target. * @param packageName - npm package name * @param options - sync options * @returns request method and path */ function resolveSyncRequest(packageName, options) { const method = options.syncMethod ?? "PUT"; if (options.target === "custom") return { method, path: buildCustomSyncRequestPath(packageName, options.syncPathTemplate) }; return { method, path: buildSyncRequestPath(packageName) }; } /** * Sync package to npm mirror-compatible registry. * @param packageName - npm package name * @param options - sync options */ async function syncPackageToRegistry(packageName, options) { const timeout = options.timeout ?? DEFAULT_REQUEST_TIMEOUT; const registryHost = normalizeRegistryHost(options.registry); const { method, path } = resolveSyncRequest(packageName, options); return new Promise((resolve, reject) => { const req = request({ method, path, host: registryHost, protocol: "https:", headers: { "content-length": 0 }, timeout }, (res) => { let responseBody = ""; res.on("data", (chunk) => { responseBody += chunk; }); res.on("end", () => { if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) resolve(); else { const errorMsg = responseBody ? `HTTP ${res.statusCode}: ${responseBody}` : `HTTP ${res.statusCode}`; reject(/* @__PURE__ */ new Error(`Failed to sync ${packageName}: ${errorMsg}`)); } }); }); req.on("error", (err) => { reject(/* @__PURE__ */ new Error(`Failed to sync ${packageName}: ${err.message}`)); }); req.on("timeout", () => { req.destroy(); reject(/* @__PURE__ */ new Error(`Failed to sync ${packageName}: Request timeout after ${timeout}ms`)); }); req.end(); }); } //#endregion //#region src/utils.ts /** * Check if given package json object is valid * * @param packageJson - package json object * @returns true if is a valid package json, false otherwise */ function isValidPublicPackage(packageJson) { if (packageJson.private) return false; return Boolean(packageJson.name && packageJson.version); } const SUPPORTED_TARGETS = ["npmmirror", "custom"]; /** * Assert given target is a valid sync target * * @param target - target site */ function assertSyncTarget(target) { if (!target || !SUPPORTED_TARGETS.includes(target)) throw new Error(`Required option target to be one of ${SUPPORTED_TARGETS.join(", ")}`); } //#endregion //#region src/core.ts /** * node_modules must be ignored */ const IGNORE_NODE_MODULES = "**/node_modules/**"; /** * default patterns to be ignored */ const DEFAULT_IGNORE = [ IGNORE_NODE_MODULES, "**/.git/**", "**/docs/**", "**/tests/**", "**/examples/**", "**/fixtures/**", "**/playground/**" ]; /** * glob pattern to match package.json files */ const GLOB_PACKAGE_JSON = "**/package.json"; /** * Default retry count */ const DEFAULT_RETRY = 3; /** * Default retry delay in milliseconds */ const DEFAULT_RETRY_DELAY = 1e3; /** * Default concurrency limit */ const DEFAULT_CONCURRENCY = 5; /** * Delay for a specified amount of time * @param ms - milliseconds to delay */ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Load cached package names * @param cacheDir - cache directory */ async function loadSyncCache(cacheDir) { try { const content = await readFile(join(cacheDir, "synced-packages.json"), "utf8"); const data = JSON.parse(content); return new Set(data.packages); } catch { return /* @__PURE__ */ new Set(); } } /** * Save cached package names * @param cacheDir - cache directory * @param packages - package names to cache */ async function saveSyncCache(cacheDir, packages) { try { await mkdir(cacheDir, { recursive: true }); const cachePath = join(cacheDir, "synced-packages.json"); const data = { packages: Array.from(packages), timestamp: (/* @__PURE__ */ new Date()).toISOString() }; await writeFile(cachePath, JSON.stringify(data, null, 2), "utf8"); } catch (error) { if (error instanceof Error) console.warn(c.yellow(`Warning: Failed to save sync cache: ${error.message}`)); } } /** * Sync package with retry mechanism * @param packageName - package name * @param options - sync options */ async function syncPackageWithRetry(packageName, options) { const maxRetries = options.retry ?? DEFAULT_RETRY; const retryDelay = options.retryDelay ?? DEFAULT_RETRY_DELAY; const { verbose, silent, debug, beforeSync, afterSync } = options; try { if (beforeSync) await beforeSync(packageName); } catch (error) { if (afterSync) await afterSync(packageName, error instanceof Error ? error : new Error(String(error))); throw error; } for (let attempt = 0; attempt <= maxRetries; attempt++) try { if (debug && !silent) console.log(c.dim(` Attempting ${packageName} (${attempt + 1}/${maxRetries + 1})...`)); await syncPackageToRegistry(packageName, options); if (verbose && !silent) console.log(c.green(` ✓ ${packageName}`)); if (afterSync) await afterSync(packageName); return; } catch (error) { const isLastAttempt = attempt === maxRetries; if (debug && !silent) console.log(c.yellow(` Attempt ${attempt + 1} failed for ${packageName}: ${error instanceof Error ? error.message : String(error)}`)); if (isLastAttempt) { if (verbose && !silent) console.log(c.red(` ✗ ${packageName}: ${error instanceof Error ? error.message : String(error)}`)); if (afterSync) await afterSync(packageName, error instanceof Error ? error : new Error(String(error))); throw error; } const waitTime = retryDelay * 2 ** attempt; if (debug && !silent) console.log(c.dim(` Waiting ${waitTime}ms before retry...`)); await delay(waitTime); } } /** * Sync npm packages release to a mirror site * * @param input - package names * @param options - sync options {@link SyncOptions} * @returns a Promise that resolves when syncing is complete * * @example * * ```ts * import { syncNpmPackages } from 'sync-npm-packages' * * // single package * await syncNpmPackages('package-foobar', { target: 'npmmirror' }) * * // multiple packages * await syncNpmPackages(['package-foo', 'package-bar'], { target: 'npmmirror' }) * ``` */ async function syncNpmPackages(input, options) { let packages = unique(toArray(input)); const { silent, verbose, concurrency = DEFAULT_CONCURRENCY, cache, cacheDir = ".sync-cache" } = options; assertSyncTarget(options.target); let syncedPackages = /* @__PURE__ */ new Set(); if (cache) { syncedPackages = await loadSyncCache(cacheDir); packages = packages.filter((pkg) => !syncedPackages.has(pkg)); if (!silent && verbose && syncedPackages.size > 0) console.log(c.dim(`\nSkipping ${syncedPackages.size} already synced package(s)\n`)); } if (packages.length === 0) { if (!silent && verbose && cache) console.log(c.green("All packages have been synced already!")); return; } if (!silent && verbose) console.log(c.dim(`\nSyncing ${packages.length} package(s) with concurrency ${concurrency}...\n`)); const errors = []; const executing = []; let completed = 0; for (const pkg of packages) { const promise = syncPackageWithRetry(pkg, options).then(() => { completed++; if (cache) syncedPackages.add(pkg); if (!silent && !verbose) process.stdout.write(`\r${c.dim(`Progress: ${completed}/${packages.length}`)} ${c.green("✓".repeat(Math.floor(completed / packages.length * 20)))}`); }).catch((error) => { completed++; errors.push({ package: pkg, error }); if (!silent && !verbose) process.stdout.write(`\r${c.dim(`Progress: ${completed}/${packages.length}`)} ${c.green("✓".repeat(Math.floor(completed / packages.length * 20)))}`); }).finally(() => { executing.splice(executing.indexOf(promise), 1); }); executing.push(promise); if (executing.length >= concurrency) await Promise.race(executing); } await Promise.all(executing); if (cache && syncedPackages.size > 0) await saveSyncCache(cacheDir, syncedPackages); if (!silent && !verbose && packages.length > 0) process.stdout.write("\n"); if (errors.length > 0) { if (!silent) { console.log(c.red(`\n${errors.length} package(s) failed to sync:`)); for (const { package: pkg, error } of errors) console.log(c.red(` - ${pkg}: ${error.message}`)); } throw new Error(`Failed to sync ${errors.length} package(s)`); } } /** * Get valid package names from all package.json files * * @param options - detect options {@link DetectOptions} * @returns a promise resolves valid package names */ async function getValidPackageNames(options = {}) { const { cwd = process.cwd(), defaultIgnore: useDefaultIgnore = true, ignore: userIgnore = [], include = [], exclude = [], withOptional = false } = options; const ignore = toArray(userIgnore); if (useDefaultIgnore) ignore.push(...DEFAULT_IGNORE); else ignore.push(IGNORE_NODE_MODULES); const files = await glob(GLOB_PACKAGE_JSON, { cwd, ignore, absolute: true, onlyFiles: true }); const packages = [...toArray(include)]; const fileContents = await Promise.all(files.map(async (file) => { try { const content = await readFile(file, "utf8"); return JSON.parse(content); } catch (error) { if (error instanceof Error) console.warn(c.yellow(`Warning: Failed to parse ${file}: ${error.message}`)); return null; } })); const excludeSet = new Set(toArray(exclude)); for (const packageJson of fileContents) { if (!packageJson) continue; if (isValidPublicPackage(packageJson)) { packages.push(packageJson.name); if (withOptional) packages.push(...Object.keys(packageJson.optionalDependencies || {})); } } return unique(packages.filter((pkg) => !excludeSet.has(pkg))); } //#endregion //#region src/cli.ts const cli = cac(name); cli.version(version).option("--debug", "Enable debug mode").option("--cwd [cwd]", "Current working directory").option("--target [target]", "The mirror site preset").option("--sync-method [syncMethod]", "HTTP method for sync request").option("--sync-path-template [syncPathTemplate]", "Request path template for custom target, supports {packageName}").option("--ignore [ignore]", "Ignore package.json pattern").option("--include [include]", "Additional packages to sync").option("--exclude [exclude]", "Exclude packages from being synced").option("--with-optional", "With optionalDependencies in package.json").option("--no-default-ignore", "Disable default ignore patterns").option("--dry", "Dry run").option("--retry [retry]", "Number of retry attempts on failure", { default: 3 }).option("--retry-delay [retryDelay]", "Delay between retries in milliseconds", { default: 1e3 }).option("--concurrency [concurrency]", "Maximum concurrent sync requests", { default: 5 }).option("--timeout [timeout]", "Request timeout in milliseconds", { default: 1e4 }).option("--verbose", "Enable verbose output").option("--silent", "Silent mode, suppress all output").help(); cli.command("").action(async (options) => { let silent = Boolean(options.silent); try { const resolvedConfig = await resolveConfig(options); silent = Boolean(resolvedConfig.silent); assertSyncTarget(resolvedConfig.target); if (!resolvedConfig.silent) { console.log(`\n${c.bold(c.magenta(name))} ${c.dim(`v${version}`)}`); console.log(c.dim("--------------")); } const packages = await getValidPackageNames(resolvedConfig); if (packages.length === 0) { if (!resolvedConfig.silent) console.log(c.red("No packages detected.")); return; } /** * Print all detected packages in a user-friendly list. */ function printPackages() { console.log(c.dim("\nDetected packages to sync:")); for (const pkg of packages) console.log(`- ${c.cyan(c.bold(pkg))}`); console.log(); } if (resolvedConfig.dry) { if (!resolvedConfig.silent) { console.log(c.yellow("\nDry run, sync is skipped.")); printPackages(); } return; } await syncNpmPackages(packages, resolvedConfig); if (!resolvedConfig.silent) console.log(c.green("\nSync successfully!")); } catch (error) { if (!silent) { console.error(c.red(String(error))); if (error instanceof Error && error.stack) console.error(c.dim(error.stack?.split("\n").slice(1).join("\n"))); } process.exit(1); } }); cli.parse(); //#endregion export {};