295 lines
9.0 KiB
TypeScript
295 lines
9.0 KiB
TypeScript
/**
|
|
* Catalog Resolver
|
|
*
|
|
* Provides utilities for loading and resolving Bun workspace catalog references.
|
|
* Handles both default catalog (catalog:) and named catalogs (catalog:testing).
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const resolver = await CatalogResolver.create();
|
|
* const version = resolver.resolve('react', 'catalog:react');
|
|
* // Returns: '19.0.0'
|
|
* ```
|
|
*/
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { CONFIG } from "./config.js";
|
|
import { discoverPackageDirs } from "./discover-packages.js";
|
|
|
|
export type ExportsField =
|
|
| string
|
|
| { [key: string]: ExportsField }
|
|
| ExportsField[];
|
|
|
|
export interface PackageJson {
|
|
name?: string;
|
|
version?: string;
|
|
private?: boolean;
|
|
main?: string;
|
|
module?: string;
|
|
types?: string;
|
|
files?: string[];
|
|
exports?: ExportsField;
|
|
scripts?: Record<string, string>;
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
workspaces?: {
|
|
catalog?: Record<string, string>;
|
|
catalogs?: Record<string, Record<string, string>>;
|
|
};
|
|
[extra: string]: unknown;
|
|
}
|
|
|
|
export interface CatalogEntry {
|
|
catalogName: string;
|
|
version: string;
|
|
}
|
|
|
|
/**
|
|
* Resolves catalog references to actual versions
|
|
*/
|
|
export class CatalogResolver {
|
|
private catalogs: Map<string, Record<string, string>> = new Map();
|
|
private workspaceVersions: Map<string, string> = new Map();
|
|
|
|
private constructor() {}
|
|
|
|
/**
|
|
* Create a CatalogResolver by loading catalogs from root package.json
|
|
* and discovering workspace package versions.
|
|
*
|
|
* @returns Initialized CatalogResolver instance
|
|
* @throws Error if root package.json cannot be read
|
|
*/
|
|
static async create(): Promise<CatalogResolver> {
|
|
const resolver = new CatalogResolver();
|
|
await resolver.loadCatalogs();
|
|
await resolver.loadWorkspaceVersions();
|
|
return resolver;
|
|
}
|
|
|
|
/**
|
|
* Discover all @suaveplan/* workspace packages and their current versions.
|
|
* Walks `packages/<plugin>/<pkg>/` via `discoverPackageDirs`.
|
|
*/
|
|
private async loadWorkspaceVersions(): Promise<void> {
|
|
const pkgDirs = await discoverPackageDirs();
|
|
for (const pkgDir of pkgDirs) {
|
|
const pkgPath = join(pkgDir, "package.json");
|
|
try {
|
|
const content = await readFile(pkgPath, "utf-8");
|
|
const pkg: PackageJson = JSON.parse(content);
|
|
if (pkg.name && pkg.version) {
|
|
this.workspaceVersions.set(pkg.name, pkg.version);
|
|
}
|
|
} catch {
|
|
// Skip entries without a readable package.json
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load catalogs from root package.json
|
|
*
|
|
* Loads both the default catalog (workspaces.catalog) and named catalogs
|
|
* (workspaces.catalogs) into memory for fast lookups.
|
|
*/
|
|
private async loadCatalogs(): Promise<void> {
|
|
const rootPkgPath = join(CONFIG.paths.root, "package.json");
|
|
const content = await readFile(rootPkgPath, "utf-8");
|
|
const rootPkg: PackageJson = JSON.parse(content);
|
|
|
|
// Load default catalog
|
|
if (rootPkg.workspaces?.catalog) {
|
|
this.catalogs.set("", rootPkg.workspaces.catalog);
|
|
}
|
|
|
|
// Load named catalogs
|
|
if (rootPkg.workspaces?.catalogs) {
|
|
for (const [name, versions] of Object.entries(
|
|
rootPkg.workspaces.catalogs
|
|
)) {
|
|
this.catalogs.set(name, versions);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the number of loaded catalogs
|
|
*/
|
|
getCatalogCount(): number {
|
|
return this.catalogs.size;
|
|
}
|
|
|
|
/**
|
|
* Get all catalog names
|
|
*/
|
|
getCatalogNames(): string[] {
|
|
return Array.from(this.catalogs.keys());
|
|
}
|
|
|
|
/**
|
|
* Resolve a catalog reference to an actual version
|
|
*
|
|
* @param packageName - The package name to resolve (e.g., 'react')
|
|
* @param catalogRef - The catalog reference (e.g., 'catalog:react' or 'catalog:')
|
|
* @returns The resolved version string, or null if not found
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* resolver.resolve('react', 'catalog:react') // Returns: '19.0.0'
|
|
* resolver.resolve('vitest', 'catalog:testing') // Returns: '2.1.0'
|
|
* resolver.resolve('typescript', 'catalog:') // Returns: '5.9.2'
|
|
* ```
|
|
*/
|
|
resolve(packageName: string, catalogRef: string): string | null {
|
|
const match = catalogRef.match(/^catalog:(.*)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const catalogName = match[1] || "";
|
|
const catalog = this.catalogs.get(catalogName);
|
|
|
|
if (!catalog) {
|
|
return null;
|
|
}
|
|
|
|
return catalog[packageName] || null;
|
|
}
|
|
|
|
/**
|
|
* Find which catalog contains a package
|
|
*
|
|
* @param packageName - The package name to search for
|
|
* @returns CatalogEntry with catalog name and version, or null if not found
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* resolver.findInCatalogs('react')
|
|
* // Returns: { catalogName: 'react', version: '19.0.0' }
|
|
* ```
|
|
*/
|
|
findInCatalogs(packageName: string): CatalogEntry | null {
|
|
for (const [catalogName, catalog] of this.catalogs.entries()) {
|
|
const version = catalog[packageName];
|
|
if (version) {
|
|
return { catalogName, version };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve dependencies object, converting catalog refs to actual versions
|
|
*
|
|
* @param deps - Dependencies object from package.json
|
|
* @param currentVersion - Current package version (for workspace:* refs)
|
|
* @returns Resolved dependencies with actual versions
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const resolved = resolver.resolveDependencies({
|
|
* 'react': 'catalog:react',
|
|
* '@suaveplan/utils': 'workspace:*',
|
|
* 'lodash': '^4.17.21'
|
|
* }, '1.0.0');
|
|
* // Returns: {
|
|
* // 'react': '19.0.0',
|
|
* // '@suaveplan/utils': '^1.0.0',
|
|
* // 'lodash': '^4.17.21'
|
|
* // }
|
|
* ```
|
|
*/
|
|
resolveDependencies(
|
|
deps: Record<string, string> | undefined,
|
|
_currentVersion: string
|
|
): Record<string, string> | undefined {
|
|
if (!deps) return undefined;
|
|
|
|
const resolved: Record<string, string> = {};
|
|
|
|
for (const [name, version] of Object.entries(deps)) {
|
|
let resolvedVersion = version;
|
|
|
|
// First, resolve catalog references
|
|
if (version.startsWith("catalog:")) {
|
|
const catalogResolved = this.resolve(name, version);
|
|
if (catalogResolved) {
|
|
resolvedVersion = catalogResolved;
|
|
} else {
|
|
continue; // Skip unresolved catalog refs
|
|
}
|
|
}
|
|
|
|
// Then, resolve workspace references (including those from catalogs)
|
|
if (resolvedVersion.startsWith("workspace:")) {
|
|
if (name.startsWith("@suaveplan/")) {
|
|
// Use the actual workspace package version so consumers
|
|
// pick up the correct minimum (prevents bun from resolving
|
|
// to stale nested versions from the registry).
|
|
const wsVersion = this.workspaceVersions.get(name);
|
|
resolved[name] = wsVersion ? `^${wsVersion}` : "^1.0.0";
|
|
} else {
|
|
resolved[name] = "*";
|
|
}
|
|
} else {
|
|
resolved[name] = resolvedVersion;
|
|
}
|
|
}
|
|
|
|
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
}
|
|
|
|
/**
|
|
* Convert a regular version string to a catalog reference
|
|
*
|
|
* @param packageName - The package name
|
|
* @param version - The version string
|
|
* @returns Catalog reference string, or null if package not in any catalog
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* resolver.toCatalogRef('react', '19.0.0')
|
|
* // Returns: 'catalog:react'
|
|
* ```
|
|
*/
|
|
toCatalogRef(packageName: string, version: string): string | null {
|
|
const entry = this.findInCatalogs(packageName);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
// Only convert if versions match
|
|
if (entry.version === version) {
|
|
return entry.catalogName
|
|
? `catalog:${entry.catalogName}`
|
|
: "catalog:";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get all packages in a specific catalog
|
|
*
|
|
* @param catalogName - The catalog name (empty string for default catalog)
|
|
* @returns Record of package names to versions
|
|
*/
|
|
getCatalog(catalogName: string): Record<string, string> | undefined {
|
|
return this.catalogs.get(catalogName);
|
|
}
|
|
|
|
/**
|
|
* Check if a package exists in any catalog
|
|
*
|
|
* @param packageName - The package name to check
|
|
* @returns true if package exists in at least one catalog
|
|
*/
|
|
hasPackage(packageName: string): boolean {
|
|
return this.findInCatalogs(packageName) !== null;
|
|
}
|
|
}
|