pncat
Version:
Enhanced pnpm catalogs management with advanced workspace dependency control.
1,195 lines (1,172 loc) • 41.5 kB
JavaScript
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);
}