UNPKG

pncat

Version:

Enhanced pnpm catalogs management with advanced workspace dependency control.

1,195 lines (1,172 loc) 41.5 kB
import process from 'node:process'; import * as p from '@clack/prompts'; import c from 'ansis'; import { cac } from 'cac'; import { writeFile, readFile } from 'node:fs/promises'; import { execa } from 'execa'; import { dirname, join, resolve } from 'pathe'; import { readPackageJSON, writePackageJSON } from 'pkg-types'; import { findUp } from 'find-up'; import { parsePnpmWorkspaceYaml } from 'pnpm-workspace-yaml'; import { existsSync } from 'node:fs'; import { glob } from 'tinyglobby'; import { D as DEFAULT_CATALOG_RULES } from './shared/pncat.ZjPjRMjV.mjs'; import semver from 'semver'; import deepmerge from 'deepmerge'; import { createConfigLoader } from 'unconfig'; const version = "0.4.1"; async function findWorkspaceRoot() { const pnpmWorkspaceYamlPath = await findWorkspaceYaml(); if (pnpmWorkspaceYamlPath) return dirname(pnpmWorkspaceYamlPath); return process.cwd(); } async function findWorkspaceYaml() { return await findUp("pnpm-workspace.yaml", { cwd: process.cwd() }); } async function ensurePnpmWorkspaceYAML() { let pnpmWorkspaceYamlPath = await findWorkspaceYaml(); if (!pnpmWorkspaceYamlPath) { const root = await findUp([".git", "pnpm-lock.yaml"], { cwd: process.cwd() }).then((r) => r ? dirname(r) : process.cwd()); p.log.warn(c.yellow("No pnpm-workspace.yaml found")); const result = await p.confirm({ message: `do you want to create it under project root ${c.dim(root)} ?` }); if (!result) { p.outro(c.red("no pnpm-workspace.yaml found, aborting")); process.exit(1); } pnpmWorkspaceYamlPath = join(root, "pnpm-workspace.yaml"); await writeFile(pnpmWorkspaceYamlPath, "packages: []"); } const context = parsePnpmWorkspaceYaml(await readFile(pnpmWorkspaceYamlPath, "utf-8")); return { context, pnpmWorkspaceYamlPath }; } const MODE_CHOICES = ["detect", "migrate", "add", "remove", "clean", "revert"]; const DEPS_FIELDS = [ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", "packageManager", "pnpm.overrides", "resolutions", "overrides", "pnpm-workspace" ]; const DEFAULT_COMMON_OPTIONS = { cwd: "", recursive: true, force: false, ignorePaths: "", ignoreOtherWorkspaces: true, include: "", exclude: "", depFields: { packageManager: false }, allowedProtocols: ["workspace", "link", "file"], catalogRules: DEFAULT_CATALOG_RULES, specifierOptions: { skipComplexRanges: true, allowPreReleases: true, allowWildcards: false } }; const DEFAULT_CATALOG_OPTIONS = { ...DEFAULT_COMMON_OPTIONS, mode: "detect", yes: false, install: true }; const DEFAULT_IGNORE_PATHS = [ "**/node_modules/**", "**/dist/**", "**/public/**", "**/fixture/**", "**/fixtures/**" ]; const DEP_TYPE_GROUP_NAME_MAP = { dependencies: "prod", devDependencies: "dev", peerDependencies: "peer", optionalDependencies: "optional" }; function toArray(array) { array = array ?? []; return Array.isArray(array) ? array : [array]; } function escapeRegExp(str) { return str.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); } function filterToRegex(str) { if (str.startsWith("/")) { const endIndex = str.lastIndexOf("/"); const regexp = str.substring(1, endIndex); const flags = str.substring(endIndex + 1, str.length); return new RegExp(regexp, flags); } return new RegExp(`^${escapeRegExp(str).replace(/\*+/g, ".*?")}$`); } function parseFilter(str, defaultValue = true) { if (!str || str.length === 0) return () => defaultValue; const regex = toArray(str).flatMap((i) => i.split(",")).map(filterToRegex); return (name) => { for (const reg of regex) { if (reg.test(name)) return true; } return false; }; } function specifierFilter(str, options) { const { skipComplexRanges = true, skipRangeTypes = [], allowPreReleases = true, allowWildcards = false } = options ?? {}; if (!str.trim()) return false; if (str.startsWith("catalog:")) return true; if (skipRangeTypes.length > 0) { for (const type of skipRangeTypes) { if (type === "||" && str.includes("||")) return false; if (type === "-" && str.includes(" - ")) return false; if (type === ">=" && str.startsWith(">=")) return false; if (type === "<=" && str.startsWith("<=")) return false; if (type === ">" && str.startsWith(">")) return false; if (type === "<" && str.startsWith("<")) return false; if (type === "x" && str.includes("x")) return false; if (type === "*" && str === "*") return false; if (type === "pre-release" && str.includes("-")) return false; } return true; } if (skipComplexRanges) { const isComplex = str.includes("||") || str.includes(" - ") || /^[><=]/.test(str); if (isComplex) return false; } if (!allowPreReleases && str.includes("-")) { return false; } if (!allowWildcards && (str.includes("x") || str === "*")) { return false; } return true; } function createDependenciesFilter(include, exclude, options) { const i = parseFilter(include, true); const e = parseFilter(exclude, false); return (name, specifier) => !e(name) && i(name) && specifierFilter(specifier, options); } function flatten(obj, parents = []) { if (!obj) return obj; let flattenData = {}; for (const [key, value] of Object.entries(obj)) { if (typeof value === "object") flattenData = { ...flattenData, ...flatten(value, [...parents, key]) }; else if (typeof value === "string") flattenData[key] = { specifier: value, parents }; } return flattenData; } function getByPath(obj, path) { return flatten(path.split(".").reduce((o, i) => o?.[i], obj)); } function parseDependency(name, specifier, type, shouldCatalog, parents) { return { name, specifier, parents, source: type, // when `catalog` marked to `false`, it will be bypassed on resolving catalog: shouldCatalog(name, specifier) }; } function parseDependencies(pkg, type, shouldCatalog) { return Object.entries(getByPath(pkg, type) || {}).map(([name, { specifier, parents }]) => parseDependency(name, specifier, type, shouldCatalog, parents)); } async function loadPackageJSON(relative, options, shouldCatalog) { const filepath = resolve(options.cwd ?? "", relative); const raw = await readPackageJSON(filepath); const deps = []; for (const key of DEPS_FIELDS) { if (options.depFields?.[key] !== false) { if (key === "packageManager") { if (raw.packageManager) { const [name, specifier] = raw.packageManager.split("@"); deps.push(parseDependency(name, `^${specifier.split("+")[0]}`, "packageManager", shouldCatalog)); } } else { deps.push(...parseDependencies(raw, key, shouldCatalog)); } } } return [ { name: raw.name, private: !!raw.private, version: raw.version, type: "package.json", relative, filepath, raw, deps } ]; } async function loadPnpmWorkspace(relative, options, shouldCatalog) { const filepath = resolve(options.cwd || "", relative); const rawText = await readFile(filepath, "utf-8"); const context = parsePnpmWorkspaceYaml(rawText); const raw = context.getDocument().toJSON(); const catalogs = []; function createPnpmWorkspaceEntry(name, map) { const deps = Object.entries(map).map(([pkg, specifier]) => parseDependency(pkg, specifier, "pnpm-workspace", shouldCatalog)); return { name, relative, filepath, type: "pnpm-workspace.yaml", raw, context, deps }; } if (raw.catalog) { catalogs.push( createPnpmWorkspaceEntry("pnpm-catalog:default", raw.catalog) ); } if (raw.catalogs) { for (const key of Object.keys(raw.catalogs)) { catalogs.push( createPnpmWorkspaceEntry(`pnpm-catalog:${key}`, raw.catalogs[key]) ); } } if (raw.overrides) { catalogs.push( createPnpmWorkspaceEntry("pnpm-workspace:overrides", raw.overrides) ); } return catalogs; } async function loadPackage(relative, options, shouldCatalog) { if (relative.endsWith("pnpm-workspace.yaml")) return loadPnpmWorkspace(relative, options, shouldCatalog); return loadPackageJSON(relative, options, shouldCatalog); } async function loadPackages(options) { const cwd = resolve(options.cwd || process.cwd()); const filter = createDependenciesFilter(options.include, options.exclude, options.specifierOptions); const paths = await findPackagePaths(options); if (existsSync(join(cwd, "pnpm-workspace.yaml"))) { paths.unshift("pnpm-workspace.yaml"); } const packages = (await Promise.all( paths.map( (relative) => loadPackage(relative, options, filter) ) )).flat(); return packages; } async function findPackagePaths(options) { let paths = []; const cwd = resolve(options.cwd || process.cwd()); if (options.recursive) { paths = await glob("**/package.json", { ignore: DEFAULT_IGNORE_PATHS.concat(options.ignorePaths || []), cwd: options.cwd, onlyFiles: true, dot: false, expandDirectories: false }); paths.sort((a, b) => a.localeCompare(b)); } else { paths = ["package.json"]; } if (options.ignoreOtherWorkspaces) { paths = (await Promise.all( paths.map(async (packagePath) => { if (!packagePath.includes("/")) return [packagePath]; const absolute = join(cwd, packagePath); const gitDir = await findUp(".git", { cwd: absolute, stopAt: cwd }); if (gitDir && dirname(gitDir) !== cwd) return []; const pnpmWorkspace = await findUp("pnpm-workspace.yaml", { cwd: absolute, stopAt: cwd }); if (pnpmWorkspace && dirname(pnpmWorkspace) !== cwd) return []; return [packagePath]; }) )).flat(); } return paths; } function parseSpec(spec) { let name; let specifier; const parts = spec.split(/@/g); if (parts[0] === "") { name = parts.slice(0, 2).join("@"); specifier = parts[2]; } else { name = parts[0]; specifier = parts[1]; } return { name, specifier }; } function isDepMatched(depName, match) { if (Array.isArray(match)) { return match.some((m) => typeof m === "string" ? depName === m : m.test(depName)); } else if (typeof match === "string") { return depName === match; } else if (match instanceof RegExp) { return match.test(depName); } return false; } function extractVersionFromSpecifier(specifier, options) { if (options.allowedProtocols.some((p) => specifier.startsWith(p))) { return null; } const cleanSpec = cleanSpecifier(specifier); const version = semver.valid(cleanSpec); if (version) { return version; } const coerced = semver.coerce(cleanSpec); if (coerced) { return coerced.version; } return null; } function findMostSpecificRange(specifierRules, version) { let mostSpecific = specifierRules[0]; let bestScore = calculateRangeSpecificity(specifierRules[0].specifier); for (let i = 1; i < specifierRules.length; i++) { const currentScore = calculateRangeSpecificity(specifierRules[i].specifier); if (currentScore > bestScore) { mostSpecific = specifierRules[i]; bestScore = currentScore; } } return mostSpecific; } function calculateRangeSpecificity(range, _version) { const hasUpperBound = range.includes("<") || range.includes(" - "); const hasLowerBound = range.includes(">") || range.includes("^") || range.includes("~"); let score = 0; if (hasUpperBound && hasLowerBound) { score += 1e3; } else if (hasUpperBound) { score += 500; } else if (hasLowerBound) { score += 100; } const minVersionMatch = range.match(/>=?(\d+\.\d+\.\d+)/); if (minVersionMatch) { const minVersion = minVersionMatch[1]; const minVersionParts = minVersion.split(".").map(Number); score += minVersionParts[0] * 10 + minVersionParts[1] * 1 + minVersionParts[2] * 0.1; } return score; } function cleanSpecifier(specifier) { return specifier.replace(/^[\^~>=<]+/, ""); } function getDepCatalogName(dep, options) { for (const rule of options.catalogRules ?? []) { const { name, match, specifierRules } = rule; if (!isDepMatched(dep.name, match)) continue; if (!specifierRules?.length) return name; const version = extractVersionFromSpecifier(dep.specifier, options); if (!version) return name; const matchingRules = specifierRules.filter((specifierRule) => { if (specifierRule.match && !isDepMatched(dep.name, specifierRule.match)) { return false; } return semver.satisfies(version, specifierRule.specifier); }); if (matchingRules.length === 0) return name; if (matchingRules.length === 1) { const rule2 = matchingRules[0]; return rule2.name || `${name}-${rule2.suffix}`; } const mostSpecific = findMostSpecificRange(matchingRules); return mostSpecific.name || `${name}-${mostSpecific.suffix}`; } return DEP_TYPE_GROUP_NAME_MAP[dep.source] || "default"; } async function addCommand(options) { const args = process.argv.slice(3); if (args.length === 0) { p.outro(c.red("no arguments provided, aborting")); process.exit(1); } const targetPackageJSON = join(process.cwd(), "package.json"); if (!targetPackageJSON) { p.outro(c.red("no package.json found, aborting")); process.exit(1); } const pkgJson = await readPackageJSON(targetPackageJSON); const { context: workspaceYaml, pnpmWorkspaceYamlPath } = await ensurePnpmWorkspaceYAML(); const config = await resolveDependencies(workspaceYaml, args, options); const contents = []; for (const dep of config.dependencies) { const padEnd = Math.max(0, 20 - dep.name.length - (dep.specifier?.length || 0)); const padCatalog = Math.max(0, 20 - (dep.catalog?.length ? dep.catalog.length + " catalog:".length : 0)); contents.push([ `${c.cyan(dep.name)}@${c.green(dep.specifier)} ${" ".repeat(padEnd)}`, dep.catalog ? c.yellow` catalog:${dep.catalog}` : "", " ".repeat(padCatalog), dep.specifierSource ? c.gray(` (from ${dep.specifierSource})`) : "" ].join(" ")); } p.note(c.reset(contents.join("\n")), `install packages to ${c.dim(pnpmWorkspaceYamlPath)}`); if (!options.yes) { const result = await p.confirm({ message: c.green`looks good?` }); if (!result || p.isCancel(result)) { p.outro(c.red("aborting")); process.exit(1); } } for (const dep of config.dependencies) { if (dep.catalog) workspaceYaml.setPackage(dep.catalog, dep.name, dep.specifier || "^0.0.0"); } const depsName = config.isDev ? "devDependencies" : "dependencies"; const depNameOppsite = config.isDev ? "dependencies" : "devDependencies"; const deps = pkgJson[depsName] ||= {}; for (const pkg of config.dependencies) { deps[pkg.name] = pkg.catalog ? `catalog:${pkg.catalog}` : pkg.specifier || "^0.0.0"; if (pkgJson[depNameOppsite]?.[pkg.name]) delete pkgJson[depNameOppsite][pkg.name]; } p.log.info("writing pnpm-workspace.yaml"); await writeFile(pnpmWorkspaceYamlPath, workspaceYaml.toString(), "utf-8"); p.log.info("writing package.json"); await writePackageJSON(targetPackageJSON, pkgJson); p.log.info("done"); p.log.success("add complete"); if (options.install) { p.outro("running pnpm install"); await execa("pnpm", ["install"], { stdio: "inherit", cwd: options.cwd || process.cwd() }); } } async function resolveDependencies(workspaceYaml, args, options) { const isDev = ["--save-dev", "-D"].some((flag) => args.includes(flag)); const dependencies = args.filter((arg) => !arg.startsWith("-")); if (!dependencies.length) { p.outro(c.red("no dependency provided, aborting")); process.exit(1); } const workspaceJson = workspaceYaml.toJSON(); const parsed = dependencies.map((x) => x.trim()).filter(Boolean).map(parseSpec); const workspacePackages = []; if (options.recursive) { const paths = await findPackagePaths(options); await Promise.all( paths.map(async (relative) => { const filepath = resolve(options.cwd || "", relative); const pkg = await readPackageJSON(filepath); if (pkg.name) workspacePackages.push(pkg.name); }) ); } for (const dep of parsed) { if (dep.specifier) dep.specifierSource ||= "user"; if (!dep.specifier) { const catalogs = workspaceYaml.getPackageCatalogs(dep.name); if (catalogs[0]) { dep.catalog = catalogs[0]; dep.specifierSource ||= "catalog"; } } if (dep.catalog && !dep.specifier) { const spec = dep.catalog === "default" ? workspaceJson?.catalog?.[dep.name] : workspaceJson?.catalogs?.[dep.catalog]?.[dep.name]; if (spec) { dep.specifier = spec; dep.specifierSource ||= "catalog"; } } if (!dep.specifier) { if (workspacePackages.includes(dep.name)) { dep.specifier = "workspace:*"; dep.specifierSource ||= "workspace"; continue; } const spinner = p.spinner({ indicator: "dots" }); spinner.start(`resolving ${c.cyan(dep.name)} from npm...`); const { getLatestVersion } = await import('fast-npm-meta'); const version = await getLatestVersion(dep.name); if (version.version) { dep.specifier = `^${version.version}`; dep.specifierSource ||= "npm"; spinner.stop(c.gray`resolved ${c.cyan(dep.name)}@${c.green(dep.specifier)}`); } else { spinner.stop(`failed to resolve ${c.cyan(dep.name)} from npm`); p.outro(c.red("aborting")); process.exit(1); } } if (!dep.catalog) dep.catalog = determineCatalogName(dep.name, dep.specifier, isDev, options); } return { dependencies: parsed, isDev }; } function determineCatalogName(name, specifier, isDev, options) { if (specifier.startsWith("workspace:")) return; return getDepCatalogName({ name, specifier, source: isDev ? "devDependencies" : "dependencies"}, options); } async function Scanner(options, callbacks = {}) { const packages = await loadPackages(options); callbacks.afterPackagesLoaded?.(packages); for (const pkg of packages) { callbacks.beforePackageStart?.(pkg); await callbacks.onPackageResolved?.(pkg); callbacks.afterPackageEnd?.(pkg); } callbacks.afterPackagesEnd?.(packages); return { packages }; } function safeYAMLDeleteIn(doc, path) { if (doc.hasIn(path)) { doc.deleteIn(path); } } function highlightYAML(yamlContent) { const lines = yamlContent.split("\n"); let indentLevel = 0; const indentSize = 2; return lines.map((line) => { if (line.trim() === "") return line; const currentIndent = line.search(/\S/); const newIndentLevel = Math.floor(currentIndent / indentSize); const specifierMatch = line.match(/(:)\s*(['"])?([~^<>=]*\d[\w.\-]*)(['"])?/); if (specifierMatch) { const beforeSpecifier = line.substring(0, specifierMatch.index + 1); const openingQuote = specifierMatch[2] || ""; const specifier = specifierMatch[3]; const closingQuote = specifierMatch[4] || ""; return `${c.cyan(beforeSpecifier)} ${openingQuote}${c.green(specifier)}${closingQuote}`; } indentLevel = newIndentLevel; const colors = [c.magenta, c.yellow]; const color = colors[Math.min(indentLevel, colors.length - 1)] || c.reset; return color(line); }).join("\n"); } async function cleanCommand(options) { const pnpmWorkspaceYamlPath = await findWorkspaceYaml(); if (!pnpmWorkspaceYamlPath) { p.outro(c.red("no pnpm-workspace.yaml found, aborting")); process.exit(1); } await Scanner( options, { afterPackagesLoaded: async (pkgs) => { const depsRecord = {}; const deletableCatalogs = []; for (const pkg of pkgs) { if (pkg.type === "pnpm-workspace.yaml") continue; for (const dep of pkg.deps) { depsRecord[dep.name] ??= []; depsRecord[dep.name].push(dep); } } const pnpmWorkspacePackages = pkgs.filter((pkg) => pkg.type === "pnpm-workspace.yaml"); for (const pkg of pnpmWorkspacePackages) { for (const dep of pkg.deps) { const catalogSpecifier = pkg.name.replace("pnpm-catalog:", "catalog:"); if (!depsRecord[dep.name] || !depsRecord[dep.name].some((d) => d.specifier === catalogSpecifier)) { deletableCatalogs.push({ catalogName: pkg.name.replace("pnpm-catalog:", ""), name: dep.name, specifier: dep.specifier }); } } } if (!deletableCatalogs.length) { p.outro(c.yellow("No deletable catalog found")); return; } p.note( c.reset(deletableCatalogs.map((item) => { return `${c.yellow(item.catalogName)}: ${c.cyan(item.name)} (${c.green(item.specifier)})`; }).join("\n")), `\u{1F4E6} Found ${deletableCatalogs.length} deletable catalogs:` ); if (!options.yes) { const result = await p.confirm({ message: c.green("Do you want to continue?") }); if (!result || p.isCancel(result)) { p.outro(c.red("aborting")); process.exit(1); } } const { context } = await ensurePnpmWorkspaceYAML(); const document = context.getDocument(); deletableCatalogs.forEach((catalog) => { if (catalog.catalogName === "default") safeYAMLDeleteIn(document, ["catalog", catalog.name]); safeYAMLDeleteIn(document, ["catalogs", catalog.catalogName, catalog.name]); }); cleanupCatalogs(context); p.log.info("writing pnpm-workspace.yaml"); await writeFile(pnpmWorkspaceYamlPath, context.toString(), "utf-8"); p.log.success("clean complete"); if (options.install) { p.outro("running pnpm install"); await execa("pnpm", ["install"], { stdio: "inherit", cwd: options.cwd || process.cwd() }); } } } ); } function cleanupCatalogs(context) { const document = context.getDocument(); const workspaceJson = context.toJSON(); if (workspaceJson.catalog && !Object.keys(workspaceJson.catalog).length) safeYAMLDeleteIn(document, ["catalog"]); if (workspaceJson.catalogs) { const emptyCatalogs = []; for (const [catalogKey, catalogValue] of Object.entries(workspaceJson.catalogs)) { if (!catalogValue || Object.keys(catalogValue).length === 0) emptyCatalogs.push(catalogKey); } emptyCatalogs.forEach((key) => { safeYAMLDeleteIn(document, ["catalogs", key]); }); } const updatedWorkspaceJson = context.toJSON(); if (!updatedWorkspaceJson.catalogs || Object.keys(updatedWorkspaceJson.catalogs).length === 0) { safeYAMLDeleteIn(document, ["catalogs"]); } } async function detectCommand(options) { const catalogableDeps = []; await Scanner( options, { onPackageResolved: async (pkg) => { if (pkg.type === "pnpm-workspace.yaml") return; for (const dep of pkg.deps) { if (!dep.catalog) continue; if (options.allowedProtocols.some((p2) => dep.specifier.startsWith(p2))) continue; if (dep.specifier.startsWith("catalog:")) continue; catalogableDeps.push({ ...dep, packageName: pkg.name, relativePath: pkg.relative }); } }, afterPackagesEnd: () => { const catalogableDepsRecord = catalogableDeps.reduce((acc, dep) => { if (!acc[dep.name]) acc[dep.name] = []; acc[dep.name].push(dep); return acc; }, {}); if (!catalogableDeps.length) { p.outro(c.yellow("No catalogable dependencies found")); return; } const contents = []; Object.keys(catalogableDepsRecord).sort().forEach((name) => { catalogableDepsRecord[name].sort((a, b) => a.packageName.localeCompare(b.packageName)); }); for (const [name, deps] of Object.entries(catalogableDepsRecord)) { contents.push(c.cyan(`${name} (${deps.length}):`)); for (const dep of deps) { contents.push(` ${c.yellow(dep.packageName)} (${c.dim(dep.relativePath)}): ${c.green(dep.specifier)}`); } } p.note(c.reset(contents.join("\n")), `\u{1F4E6} Found ${catalogableDeps.length} catalogable dependencies:`); p.outro("detect complete"); } } ); } async function migrateCommand(options) { let pnpmWorkspacePackages = []; const resolvedCatalogs = {}; const resolvedPackageJson = {}; const conflictSpecifiers = /* @__PURE__ */ new Map(); const depToCatalog = /* @__PURE__ */ new Map(); function getCatalog(depName) { const target = Object.entries(resolvedCatalogs).find(([, deps]) => deps[depName]); if (target) { const [name, deps] = target; return { name, specifier: deps[depName] }; } const pnpmWorkspace = pnpmWorkspacePackages.find((ws) => ws.deps.some((dep2) => dep2.name === depName)); if (!pnpmWorkspace) return; const dep = pnpmWorkspace.deps.find((dep2) => dep2.name === depName); if (!dep) return; const catalogName = options.force ? getDepCatalogName(dep, options) : pnpmWorkspace.name.replace("pnpm-catalog:", ""); return { name: catalogName, specifier: dep.specifier }; } function traversePkgs(pkgs, source) { for (const pkg of pkgs) { if (pkg.type === "pnpm-workspace.yaml") continue; for (const dep of pkg.deps) { if (dep.source !== source) continue; if (!dep.catalog) continue; if (options.allowedProtocols.some((p2) => dep.specifier.startsWith(p2))) continue; const catalog = getCatalog(dep.name); const catalogName = getDepCatalogName(dep, options); resolvedCatalogs[catalogName] ??= {}; depToCatalog.set(dep.name, catalogName); if (catalog?.name === catalogName || catalog?.name === "default") { const existingSpecifier = resolvedCatalogs[catalogName][dep.name]; if (!existingSpecifier) { resolvedCatalogs[catalogName][dep.name] = catalog.specifier; continue; } if (dep.specifier.startsWith("catalog:")) continue; if (existingSpecifier === dep.specifier) continue; if (!conflictSpecifiers.has(dep.name)) conflictSpecifiers.set(dep.name, /* @__PURE__ */ new Set()); conflictSpecifiers.get(dep.name).add(existingSpecifier); conflictSpecifiers.get(dep.name).add(dep.specifier); try { const existSpec = cleanSpecifier(existingSpecifier); const depSpec = cleanSpecifier(dep.specifier); if (semver.valid(existSpec) && semver.valid(depSpec)) { if (semver.gt(depSpec, existSpec)) resolvedCatalogs[catalogName][dep.name] = dep.specifier; } else if (semver.coerce(existSpec) && semver.coerce(depSpec)) { const existVer = semver.coerce(existSpec).version; const depVer = semver.coerce(depSpec).version; if (semver.gt(depVer, existVer)) resolvedCatalogs[catalogName][dep.name] = dep.specifier; } } catch { p.log.warn(c.yellow(`${dep.name}: ${existingSpecifier} ${dep.specifier} (version comparison failed)`)); } } else { resolvedCatalogs[catalogName][dep.name] = dep.specifier; } } } } await Scanner( options, { afterPackagesLoaded: (pkgs) => { pnpmWorkspacePackages = pkgs.filter((pkg) => pkg.type === "pnpm-workspace.yaml"); DEPS_FIELDS.forEach((source) => { traversePkgs(pkgs, source); }); }, onPackageResolved: async (pkg) => { if (pkg.type === "pnpm-workspace.yaml") return; const content = pkg.raw; for (const dep of pkg.deps) { if (!dep.catalog) continue; if (options.allowedProtocols.some((p2) => dep.specifier.startsWith(p2))) continue; const catalog = getCatalog(dep.name); if (!catalog) continue; content[dep.source][dep.name] = `catalog:${catalog.name}`; } resolvedPackageJson[pkg.filepath] = content; }, afterPackagesEnd: async (_pkgs) => { if (!options.yes && conflictSpecifiers.size > 0) { p.log.warn(c.yellow(`\u{1F4E6} Found ${conflictSpecifiers.size} dependencies with version conflicts`)); for (const [depName, specifiers] of conflictSpecifiers) { const catalogName = depToCatalog.get(depName) || "default"; const specifierArray = Array.from(specifiers).sort(); const currentSpecifier = resolvedCatalogs[catalogName][depName]; const choices = specifierArray.map((spec) => { let label = spec; if (spec === currentSpecifier) { label += c.green(" (auto-selected)"); } return { label, value: spec }; }); const result = await p.select({ message: `${c.cyan(depName)} in catalog ${c.yellow(catalogName)}:`, options: choices, initialValue: currentSpecifier }); if (p.isCancel(result)) { p.outro(c.red("aborting")); process.exit(1); } const selected = choices.find((i) => i.value === result); if (!selected || !result || typeof result === "symbol") { p.outro(c.red("invalid specifier")); process.exit(1); } resolvedCatalogs[catalogName][depName] = result; } } const { context, pnpmWorkspaceYamlPath } = await ensurePnpmWorkspaceYAML(); const document = context.getDocument(); safeYAMLDeleteIn(document, ["catalog"]); safeYAMLDeleteIn(document, ["catalogs"]); Object.entries(resolvedCatalogs).sort((a, b) => a[0].localeCompare(b[0])).forEach(([catalogName, deps]) => { Object.entries(deps).forEach(([depName, specifier]) => { if (catalogName === "default") { context.setPath(["catalog", depName], specifier); return; } context.setPath(["catalogs", catalogName, depName], specifier); }); }); const content = context.toString(); p.note(c.reset(highlightYAML(content)), `${c.cyan("pnpm-workspace.yaml")} (${c.dim(pnpmWorkspaceYamlPath)})`); if (!options.yes) { const result = await p.confirm({ message: c.green("looks good?") }); if (!result || p.isCancel(result)) { p.outro(c.red("aborting")); process.exit(1); } } p.log.info("writing pnpm-workspace.yaml"); await writeFile(pnpmWorkspaceYamlPath, content, "utf-8"); p.log.info("writing package.json"); await Promise.all(Object.entries(resolvedPackageJson).map(([filepath, content2]) => { return writePackageJSON(filepath, content2); })); p.log.success("migrate complete"); if (options.install) { p.outro("running pnpm install"); await execa("pnpm", ["install"], { stdio: "inherit", cwd: options.cwd || process.cwd() }); } } } ); } async function removeCommand(options) { const names = process.argv.slice(3); const optionalCatalogs = {}; if (!names.length) { p.outro(c.red("no package name provided, aborting")); process.exit(1); } const pnpmWorkspaceYamlPath = await findWorkspaceYaml(); if (!pnpmWorkspaceYamlPath) { p.outro(c.red("no pnpm-workspace.yaml found, aborting")); process.exit(1); } const { context } = await ensurePnpmWorkspaceYAML(); const document = context.getDocument(); async function resolveCatalogSelect(name, catalog) { p.log.info(`${c.cyan(name)} found in ${c.yellow(catalog.catalogName)}`); if (!options.yes) { const result = await p.confirm({ message: c.green("remove from catalog and package.json?") }); if (!result || p.isCancel(result)) { p.outro(c.red("aborting")); process.exit(1); } } const catalogName = catalog.catalogName.replace("pnpm-catalog:", ""); if (catalogName === "default") safeYAMLDeleteIn(document, ["catalog", catalog.name]); else safeYAMLDeleteIn(document, ["catalogs", catalogName, catalog.name]); return { name, specifier: catalogName === "default" ? `catalog:` : `catalog:${catalogName}` }; } await Scanner( options, { afterPackagesLoaded: async (pkgs) => { const pnpmWorkspacePackages = pkgs.filter((pkg) => pkg.type === "pnpm-workspace.yaml"); for (const pkg of pnpmWorkspacePackages) { for (const dep of pkg.deps) { if (names.includes(dep.name)) { optionalCatalogs[dep.name] ??= []; optionalCatalogs[dep.name].push({ name: dep.name, catalogName: pkg.name, specifier: dep.specifier }); } } } const pendingDeps = []; await Promise.all(names.map(async (name) => { const catalogOptions = optionalCatalogs[name]; if (!catalogOptions) { p.outro(`${c.cyan(name)} not found in pnpm-workspace.yaml, running pnpm remove ${c.cyan(name)}`); await execa("pnpm", ["remove", name, options.recursive ? "--recursive" : ""], { stdio: "inherit", cwd: options.cwd || process.cwd() }); return; } if (catalogOptions.length === 1) { pendingDeps.push(await resolveCatalogSelect(name, catalogOptions[0])); return; } const result = await p.select({ message: `${c.cyan(name)} found in multiple catalogs, please select one`, options: catalogOptions.map((catalog) => ({ value: catalog.catalogName, label: catalog.catalogName })) }); const selected = catalogOptions.find((catalog) => catalog.catalogName === result); if (!selected || !result || typeof result === "symbol") { p.outro(c.red("invalid catalog")); process.exit(1); } pendingDeps.push(await resolveCatalogSelect(name, selected)); })); p.log.info("writing pnpm-workspace.yaml"); await writeFile(pnpmWorkspaceYamlPath, context.toString(), "utf-8"); p.log.info("writing package.json"); for (const pkg of pkgs) { if (pkg.type === "pnpm-workspace.yaml") continue; let changed = false; const content = pkg.raw; for (const dep of pkg.deps) { const pending = pendingDeps.find((pending2) => pending2.name === dep.name); if (pending && dep.specifier === pending?.specifier) { delete content[dep.source][dep.name]; changed = true; } } if (changed) { await writePackageJSON(pkg.filepath, content); } } p.log.success("remove complete"); if (options.install) { p.outro("running pnpm install"); await execa("pnpm", ["install"], { stdio: "inherit", cwd: options.cwd || process.cwd() }); } } } ); } async function revertCommand(options) { const catalogSpecifiedRecord = {}; const resolvedPackageJson = {}; await Scanner( options, { afterPackagesLoaded: (pkgs) => { const pnpmWorkspacePackages = pkgs.filter((pkg) => pkg.type === "pnpm-workspace.yaml"); for (const pkg of pnpmWorkspacePackages) { for (const dep of pkg.deps) { const catalogSpecifier = pkg.name.replace("pnpm-catalog:", "catalog:"); catalogSpecifiedRecord[dep.name] ??= {}; catalogSpecifiedRecord[dep.name][catalogSpecifier] = dep.specifier; } } }, onPackageResolved: (pkg) => { if (pkg.type === "pnpm-workspace.yaml") return; const content = pkg.raw; for (const dep of pkg.deps) { if (!dep.specifier.includes("catalog:")) continue; content[dep.source][dep.name] = catalogSpecifiedRecord[dep.name][dep.specifier]; } resolvedPackageJson[pkg.filepath] = content; }, afterPackagesEnd: async (_pkgs) => { const { context, pnpmWorkspaceYamlPath } = await ensurePnpmWorkspaceYAML(); const document = context.getDocument(); safeYAMLDeleteIn(document, ["catalog"]); safeYAMLDeleteIn(document, ["catalogs"]); if (!options.yes) { const result = await p.confirm({ message: c.green("All catalog dependencies will be removed from pnpm-workspace.yaml, are you sure?") }); if (!result || p.isCancel(result)) { p.outro(c.red("aborting")); process.exit(1); } } p.log.info("writing pnpm-workspace.yaml"); await writeFile(pnpmWorkspaceYamlPath, context.toString(), "utf-8"); p.log.info("writing package.json"); await Promise.all(Object.entries(resolvedPackageJson).map(([filepath, content]) => { return writePackageJSON(filepath, content); })); p.log.success("revert complete"); if (options.install) { p.outro("running pnpm install"); await execa("pnpm", ["install"], { stdio: "inherit", cwd: options.cwd || process.cwd() }); } } } ); } function sortCatalogRules(rules) { return rules.sort( (a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity) ); } function normalizeConfig(options) { if ("default" in options) options = options.default; return options; } async function resolveConfig(options) { const defaults = { ...DEFAULT_CATALOG_OPTIONS }; options = normalizeConfig(options); const loader = createConfigLoader({ sources: [ { files: [ "pncat.config" ] }, { files: [ ".pncatrc" ], extensions: ["json", ""] } ], cwd: options.cwd || process.cwd(), merge: false }); const config = await loader.load(); if (!config.sources.length) return deepmerge(defaults, options); const configOptions = normalizeConfig(config.config); if (!configOptions.cwd) configOptions.cwd = await findWorkspaceRoot(); const catalogRules = configOptions.catalogRules ?? defaults.catalogRules ?? []; delete configOptions.catalogRules; const merged = deepmerge(deepmerge(defaults, configOptions), options); merged.catalogRules = sortCatalogRules(catalogRules); return merged; } try { const cli = cac("pncat"); cli.command("[mode]", "Enhanced pnpm catalogs management with advanced workspace dependency control").option("--recursive, -r", "recursively search for package.json in subdirectories").option("--force, -f", "force cataloging according to rules, ignoring original configurations").option("--ignore-paths <paths>", "ignore paths for search package.json").option("--ignore-other-workspaces", "ignore package.json that in other workspaces (with their own .git,pnpm-workspace.yaml,etc.)", { default: true }).option("--include, -n <deps>", "only included dependencies will be checked for catalog").option("--exclude, -x <deps>", "exclude dependencies to be checked, will override --include options").option("--yes", "skip prompt confirmation").option("--install", "install dependencies after execution").allowUnknownOptions().action(async (mode, options) => { if (mode) { if (!MODE_CHOICES.includes(mode)) { console.error(`Invalid mode: ${mode}. Please use one of the following: ${MODE_CHOICES.join("|")}`); process.exit(1); } options.mode = mode; } p.intro(`${c.yellow`pncat `}${c.dim`v${version}`}`); const resolved = await resolveConfig(options); switch (resolved.mode) { case "detect": await detectCommand(resolved); break; case "migrate": await migrateCommand(resolved); break; case "add": await addCommand(resolved); break; case "remove": await removeCommand(resolved); break; case "clean": await cleanCommand(resolved); break; case "revert": await revertCommand(resolved); break; } }); cli.help(); cli.version(version); cli.parse(); } catch (error) { console.error(error); process.exit(1); }