Files
suaveplan/scripts/lib/catalog-resolver.ts
2026-05-27 23:47:53 +01:00

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;
}
}