diff --git a/CLAUDE.md b/CLAUDE.md index deff3bc..bd629e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,3 @@ - ## Comment Style - Write comments in English. diff --git a/README.md b/README.md index fbf6458..d5c3990 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ An interactive CLI that makes installing dependencies easy — with first-class Ghostty 2026-03-11 01 32 41 - ## 📦 Install ```bash @@ -17,19 +16,28 @@ npm i -g @rizumu/nai ```bash # Interactive mode — prompts for everything -nai +nai add -# Pass package names directly +# Pass package names directly (two ways) nai react vue@^3.5 lodash +nai add react vue@^3.5 lodash # Install as devDependencies -nai vitest -D +nai add vitest -D # Install as peerDependencies -nai react --peer +nai add react --peer # Specify a catalog -nai zod -C prod +nai add zod -C prod + +# Update packages to latest versions +nai update +nai up react lodash + +# Manage catalogs +nai catalog +nai catalog --list ``` Run `nai --help` for all available options. @@ -56,6 +64,44 @@ Too many flags. Too many files to touch. Too many things to remember. 6. ✅ **Review & confirm** — colorful summary before any file is changed 7. 🚀 **Install** — writes config files and runs install for you +## 📋 Commands + +| Command | Alias | Description | +|---------|-------|-------------| +| `nai [packages]` | `nai add` | Install packages interactively | +| `nai update [packages]` | `nai up` | Update packages to latest versions | +| `nai remove [packages]` | `nai rm` | Remove packages from dependencies | +| `nai catalog` | - | Browse and manage catalog versions | + +### 📦 Update + +Check and update outdated packages: + +```bash +# Check all packages for updates +nai update + +# Update specific packages +nai update react lodash + +# Interactive mode +nai update -i +``` + +### 🗂️ Catalog + +Browse catalogs and change dependency versions: + +```bash +# Interactive catalog browser +nai catalog + +# List all catalogs +nai catalog --list +``` + +Select packages within a catalog and choose new versions from npm. + ## 🗂️ What is a Catalog? Catalogs let you define dependency versions in one central place (e.g. `pnpm-workspace.yaml`) and reference them in `package.json` with `catalog:name`. This keeps versions consistent across a monorepo. diff --git a/src/cli.ts b/src/cli.ts index 346cf9c..2ced64f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,9 +10,13 @@ import { getDepField, resolvePackageVersions, } from './core.ts' +import { + checkOutdated, + getPackageVersions, +} from './npm.ts' import { providers } from './providers/index.ts' import { parsePackageSpec, type ParsedPackage } from './utils.ts' -import type { Provider } from './type.ts' +import type { Provider, ResolvedDep } from './type.ts' /** Auto-detect the first available provider */ async function detectProvider(): Promise< @@ -339,6 +343,695 @@ cli .option('-C, --catalog ', 'Specify catalog name') .action(run) +cli + .command('add [...names]', 'Install packages with catalog support') + .option('-D, --dev', 'Install as dev dependency') + .option('--peer', 'Install as peer dependency') + .option('--peer-optional', 'Mark peer dependencies as optional') + .option('-C, --catalog ', 'Specify catalog name') + .action(run) + +async function runRemove(names: string[], options: { cleanCatalog?: boolean }) { + p.intro(`${c.yellow`@rizumu/nai`} ${c.dim`v${version}`} ${c.red`remove`}`) + + // --- Detect or select package manager --- + let provider: Provider + let pmVersion: string | undefined + const detected = await detectProvider() + if (detected) { + provider = detected.provider + pmVersion = detected.version + const versionStr = pmVersion ? ` ${c.dim(`v${pmVersion}`)}` : '' + p.log.info(`Detected: ${c.bold(provider.name)}${versionStr}`) + } else if (providers.length > 0) { + const selectedName = guardCancel( + await p.select({ + message: 'No package manager detected. Select one:', + options: providers.map((prov) => ({ + value: prov.name, + label: prov.name, + })), + }), + ) + provider = providers.find((prov) => prov.name === selectedName)! + } else { + p.log.error('No package manager providers available.') + p.outro('Exiting') + process.exit(1) + } + + // --- Check catalog support --- + const catalogCheck = checkCatalogSupport(provider, pmVersion) + const catalogsEnabled = catalogCheck.supported + + // --- Get all installed packages across workspace --- + const { packages: repoPackages } = await provider.listPackages() + + // Build a map of all dependencies across all packages + const allDeps = new Map< + string, + { name: string; packages: string[]; types: string[] } + >() + + for (const pkg of repoPackages) { + for (const depField of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ] as const) { + const deps = pkg[depField] + if (!deps) continue + for (const depName of Object.keys(deps)) { + const existing = allDeps.get(depName) || { + name: depName, + packages: [], + types: [], + } + if (!existing.packages.includes(pkg.name)) { + existing.packages.push(pkg.name) + } + if (!existing.types.includes(depField)) { + existing.types.push(depField) + } + allDeps.set(depName, existing) + } + } + } + + if (allDeps.size === 0) { + p.log.warn('No dependencies found in any package.') + p.outro('Nothing to remove') + return + } + + // --- Select packages to remove --- + let packagesToRemove: string[] + + if (names.length > 0) { + // Validate provided names + const notFound = names.filter((n) => !allDeps.has(n)) + if (notFound.length > 0) { + p.log.warn(`Packages not found: ${notFound.join(', ')}`) + } + packagesToRemove = names.filter((n) => allDeps.has(n)) + if (packagesToRemove.length === 0) { + p.log.error('No valid packages to remove.') + p.outro('Exiting') + return + } + } else { + const sortedDeps = [...allDeps.values()].toSorted((a, b) => + a.name.localeCompare(b.name), + ) + + packagesToRemove = guardCancel( + await p.multiselect({ + message: 'Select packages to remove', + options: sortedDeps.map((dep) => ({ + value: dep.name, + label: c.cyan(dep.name), + hint: c.dim(`${dep.packages.length} pkg(s), ${dep.types.join(', ')}`), + })), + }), + ) + } + + // --- Show which packages will be affected --- + const affectedPackages = new Set() + for (const depName of packagesToRemove) { + const dep = allDeps.get(depName) + if (dep) { + for (const pkgName of dep.packages) { + affectedPackages.add(pkgName) + } + } + } + + // --- Select target packages to remove from --- + let targetDirs: string[] + + if (affectedPackages.size <= 1) { + const pkgName = [...affectedPackages][0] + const pkg = repoPackages.find((p) => p.name === pkgName) + targetDirs = pkg ? [pkg.directory] : ['.'] + } else { + targetDirs = guardCancel( + await p.multiselect({ + message: 'Select packages to remove from', + options: repoPackages + .filter((pkg) => affectedPackages.has(pkg.name)) + .map((pkg) => ({ + value: pkg.directory, + label: pkg.name, + hint: pkg.description || undefined, + })), + initialValues: repoPackages + .filter((pkg) => affectedPackages.has(pkg.name)) + .map((pkg) => pkg.directory), + }), + ) + } + + // --- Ask about catalog cleanup --- + let cleanCatalog = options.cleanCatalog ?? false + + if (catalogsEnabled && cleanCatalog === undefined) { + cleanCatalog = guardCancel( + await p.confirm({ + message: 'Also remove unused catalog entries?', + initialValue: true, + }), + ) + } + + // --- Build summary --- + const summaryLines = packagesToRemove.map((name) => { + const dep = allDeps.get(name) + const typeStr = dep ? c.gray(`(${dep.types.join(', ')})`) : '' + return ` ${c.red('-')} ${c.cyan(name)} ${typeStr}` + }) + + const targetNames = targetDirs.map((d) => { + const pkg = repoPackages.find((rp) => rp.directory === d) + return pkg?.name || d + }) + + const summaryContent = [ + `${c.dim('Package manager:')} ${c.bold(provider.name)}`, + `${c.dim('Remove from:')} ${c.cyan(targetNames.join(', '))}`, + `${c.dim('Clean catalog:')} ${cleanCatalog ? c.green('yes') : c.gray('no')}`, + '', + ...summaryLines, + ].join('\n') + + p.note(c.reset(summaryContent), 'Summary') + + const confirmed = guardCancel( + await p.confirm({ message: c.red('Remove selected packages?') }), + ) + if (!confirmed) { + p.cancel('Cancelled.') + process.exit(0) + } + + // --- Execute --- + try { + await provider.depRemoveExecutor({ + packageNames: packagesToRemove, + targetPackages: targetDirs, + cleanCatalog, + logger: (msg) => p.log.step(msg), + }) + p.outro(c.green('Done!')) + } catch (error) { + p.log.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +cli + .command('remove [...names]', 'Remove packages from dependencies') + .alias('rm') + .option('--clean-catalog', 'Remove unused catalog entries') + .action(runRemove) + +async function runUpdate( + names: string[], + options: { interactive?: boolean; catalog?: string }, +) { + p.intro(`${c.yellow`@rizumu/nai`} ${c.dim`v${version}`} ${c.green`update`}`) + + // --- Detect package manager --- + const detected = await detectProvider() + if (!detected) { + p.log.error('No package manager detected.') + p.outro('Exiting') + process.exit(1) + } + const { provider, version: pmVersion } = detected + const versionStr = pmVersion ? ` ${c.dim(`v${pmVersion}`)}` : '' + p.log.info(`Detected: ${c.bold(provider.name)}${versionStr}`) + + // --- Check catalog support --- + const catalogCheck = checkCatalogSupport(provider, pmVersion) + const catalogsEnabled = catalogCheck.supported + + // --- Get all packages and their dependencies --- + const { packages: repoPackages } = await provider.listPackages() + const { catalogs } = catalogsEnabled + ? await provider.listCatalogs() + : { catalogs: {} } + + // Build a map of all deps with their locations + interface DepInfo { + name: string + version: string + packages: { name: string; directory: string; type: string }[] + catalogName?: string + } + const allDeps = new Map() + + for (const pkg of repoPackages) { + for (const depField of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ] as const) { + const deps = pkg[depField] + if (!deps) continue + for (const [depName, depVersion] of Object.entries(deps)) { + const existing = allDeps.get(depName) + if (existing) { + existing.packages.push({ + name: pkg.name, + directory: pkg.directory, + type: depField, + }) + } else { + allDeps.set(depName, { + name: depName, + version: depVersion, + packages: [ + { name: pkg.name, directory: pkg.directory, type: depField }, + ], + }) + } + } + } + } + + // Resolve catalog references + for (const [depName, info] of allDeps) { + if (info.version.startsWith('catalog:')) { + const catalogRef = info.version.slice('catalog:'.length) || '' + const catalog = catalogs[catalogRef] + if (catalog && catalog[depName]) { + info.version = catalog[depName] + info.catalogName = catalogRef + } + } + } + + if (allDeps.size === 0) { + p.log.warn('No dependencies found.') + p.outro('Nothing to update') + return + } + + // --- Check for outdated packages --- + let outdatedDeps: DepInfo[] = [] + + if (names.length > 0) { + // Check specific packages + for (const name of names) { + const dep = allDeps.get(name) + if (dep) { + outdatedDeps.push(dep) + } else { + p.log.warn(`Package ${c.cyan(name)} not found in dependencies`) + } + } + } else { + // Check all packages for updates + const s = p.spinner() + s.start('Checking for outdated packages...') + + const depsToCheck: Record = {} + for (const [name, info] of allDeps) { + if (!info.version.startsWith('workspace:')) { + depsToCheck[name] = info.version + } + } + + const outdated = await checkOutdated(depsToCheck, { + logger: (msg) => s.message(msg), + }) + s.stop(`Found ${c.green(outdated.length)} outdated packages`) + + outdatedDeps = outdated + .map((o) => allDeps.get(o.name)!) + .filter(Boolean) + .map((dep) => { + const outdatedInfo = outdated.find((o) => o.name === dep.name) + if (outdatedInfo) { + return { ...dep, latestVersion: outdatedInfo.latest } + } + return dep + }) + } + + if (outdatedDeps.length === 0) { + p.log.success('All packages are up to date!') + p.outro('Done') + return + } + + // --- Select packages to update --- + let toUpdate: (DepInfo & { latestVersion?: string })[] + + if (options.interactive || names.length === 0) { + toUpdate = guardCancel( + await p.multiselect({ + message: 'Select packages to update', + options: outdatedDeps.map((dep) => ({ + value: dep.name, + label: dep.latestVersion + ? `${c.cyan(dep.name)} ${c.gray(dep.version)} → ${c.green(dep.latestVersion)}` + : c.cyan(dep.name), + hint: dep.catalogName + ? c.yellow(`catalog:${dep.catalogName}`) + : undefined, + })), + }), + ).map((name) => { + const dep = outdatedDeps.find((d) => d.name === name)! + return dep + }) + } else { + toUpdate = outdatedDeps + } + + if (toUpdate.length === 0) { + p.log.warn('No packages selected for update.') + p.outro('Done') + return + } + + // --- Resolve new versions --- + const resolved: ResolvedDep[] = [] + for (const dep of toUpdate) { + const s2 = p.spinner() + s2.start(`Resolving ${c.cyan(dep.name)}...`) + + try { + const meta = await getLatestVersion(dep.name) + if (meta.version) { + s2.stop(`Resolved ${c.cyan(dep.name)}@${c.green(`^${meta.version}`)}`) + resolved.push({ + name: dep.name, + version: `^${meta.version}`, + catalogName: dep.catalogName, + existsInCatalog: !!dep.catalogName, + }) + } + } catch { + s2.stop(`Failed to resolve ${c.cyan(dep.name)}`) + } + } + + if (resolved.length === 0) { + p.log.error('Could not resolve any packages.') + p.outro('Exiting') + return + } + + // --- Build summary --- + const summaryLines = resolved.map((dep) => { + const oldDep = toUpdate.find((d) => d.name === dep.name) + const oldVersion = oldDep?.version || 'unknown' + if (dep.catalogName != null) { + const ref = + dep.catalogName === '' ? 'catalog:' : `catalog:${dep.catalogName}` + return `${c.cyan(dep.name)} ${c.gray(oldVersion)} → ${c.green(dep.version)} ${c.yellow(ref)}` + } + return `${c.cyan(dep.name)} ${c.gray(oldVersion)} → ${c.green(dep.version)} ${c.gray('(direct)')}` + }) + + const summaryContent = [ + `${c.dim('Package manager:')} ${c.bold(provider.name)}`, + '', + ...summaryLines, + ].join('\n') + + p.note(c.reset(summaryContent), 'Summary') + + const confirmed = guardCancel( + await p.confirm({ message: c.green('Update selected packages?') }), + ) + if (!confirmed) { + p.cancel('Cancelled.') + process.exit(0) + } + + // --- Execute update --- + // Group packages by target directory + const targetDirs = [...new Set(toUpdate.flatMap((d) => d.packages.map((p) => p.directory)))] + + try { + // Update catalog entries if needed + if (catalogsEnabled) { + const catalogUpdates = resolved.filter( + (d) => d.catalogName != null && d.existsInCatalog, + ) + if (catalogUpdates.length > 0) { + // Provider will handle catalog updates through depInstallExecutor + } + } + + // Update package.json files + await provider.depInstallExecutor({ + deps: resolved, + targetPackages: targetDirs, + dev: false, + peer: false, + logger: (msg) => p.log.step(msg), + }) + p.outro(c.green('Done!')) + } catch (error) { + p.log.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +cli + .command('update [...names]', 'Update packages to latest versions') + .alias('up') + .option('-i, --interactive', 'Interactive mode (select packages to update)') + .option('-C, --catalog ', 'Update packages in a specific catalog') + .action(runUpdate) + +async function runCatalog(options: { list?: boolean }) { + p.intro(`${c.yellow`@rizumu/nai`} ${c.dim`v${version}`} ${c.yellow`catalog`}`) + + // --- Detect package manager --- + const detected = await detectProvider() + if (!detected) { + p.log.error('No package manager detected.') + p.outro('Exiting') + process.exit(1) + } + const { provider, version: pmVersion } = detected + const versionStr = pmVersion ? ` ${c.dim(`v${pmVersion}`)}` : '' + p.log.info(`Detected: ${c.bold(provider.name)}${versionStr}`) + + // --- Check catalog support --- + const catalogCheck = checkCatalogSupport(provider, pmVersion) + if (!catalogCheck.supported) { + if (catalogCheck.reason === 'unsupported') { + p.log.error(`${c.bold(provider.name)} does not support catalogs.`) + } else { + p.log.error( + `${c.bold(provider.name)} ${c.dim(`v${pmVersion}`)} does not support catalogs (requires ${c.green(`>= ${catalogCheck.minVersion}`)}).`, + ) + } + p.outro('Exiting') + process.exit(1) + } + + // --- List catalogs --- + const { catalogs } = await provider.listCatalogs() + const catalogNames = Object.keys(catalogs) + + if (catalogNames.length === 0) { + p.log.warn('No catalogs defined.') + p.outro('Done') + return + } + + if (options.list) { + // Just list all catalogs + for (const name of catalogNames) { + const deps = catalogs[name] + const displayName = name || '(default)' + p.log.info(`${c.yellow(displayName)}: ${c.dim(`${Object.keys(deps).length} deps`)}`) + } + p.outro('Done') + return + } + + // --- Interactive catalog browser --- + const selectedCatalog = guardCancel( + await p.select({ + message: 'Select a catalog', + options: catalogNames.map((name) => ({ + value: name, + label: c.yellow(name || '(default)'), + hint: c.dim(`${Object.keys(catalogs[name]).length} deps`), + })), + }), + ) + + const catalogDeps = catalogs[selectedCatalog] + const depEntries = Object.entries(catalogDeps).sort((a, b) => + a[0].localeCompare(b[0]), + ) + + if (depEntries.length === 0) { + p.log.warn('This catalog is empty.') + p.outro('Done') + return + } + + // Show packages in catalog with version selection option + const action = guardCancel( + await p.select({ + message: `Catalog ${c.yellow(selectedCatalog || '(default)')} - ${depEntries.length} packages`, + options: [ + { value: 'view', label: c.cyan('View all packages') }, + { value: 'select', label: c.green('Select packages to change version') }, + { value: 'back', label: c.dim('Back') }, + ], + }), + ) + + if (action === 'back') { + p.outro('Done') + return + } + + if (action === 'view') { + const content = depEntries + .map(([name, version]) => `${c.cyan(name)}: ${c.green(version)}`) + .join('\n') + p.note(c.reset(content), `Packages in ${selectedCatalog || '(default)'}`) + p.outro('Done') + return + } + + // Select packages to change version + const toChange = guardCancel( + await p.multiselect({ + message: 'Select packages to change version', + options: depEntries.map(([name, version]) => ({ + value: name, + label: `${c.cyan(name)} ${c.dim(`(${version})`)}`, + })), + }), + ) + + if (toChange.length === 0) { + p.log.warn('No packages selected.') + p.outro('Done') + return + } + + // For each selected package, get available versions and let user choose + const updated: { name: string; version: string }[] = [] + + for (const depName of toChange) { + const s = p.spinner() + s.start(`Fetching versions for ${c.cyan(depName)}...`) + + try { + const versions = await getPackageVersions(depName) + s.stop(`Found ${c.green(versions.length)} versions`) + + // Show recent versions (last 20) + const recentVersions = versions.slice(0, 20) + + const newVersion = guardCancel( + await p.select({ + message: `Select version for ${c.cyan(depName)}`, + options: recentVersions.map((v) => ({ + value: v, + label: c.green(v), + })), + }), + ) + + updated.push({ name: depName, version: `^${newVersion}` }) + } catch { + s.stop(`Failed to fetch versions for ${c.cyan(depName)}`) + } + } + + if (updated.length === 0) { + p.log.warn('No versions selected.') + p.outro('Done') + return + } + + // Show summary + const summaryLines = updated.map( + ({ name, version }) => { + const oldVersion = catalogDeps[name] + return `${c.cyan(name)} ${c.gray(oldVersion)} → ${c.green(version)}` + }, + ) + + p.note(c.reset(summaryLines.join('\n')), 'Summary') + + const confirmed = guardCancel( + await p.confirm({ message: c.green('Update catalog?') }), + ) + if (!confirmed) { + p.cancel('Cancelled.') + process.exit(0) + } + + // Build ResolvedDep array for the executor + const resolved: ResolvedDep[] = updated.map((u) => ({ + name: u.name, + version: u.version, + catalogName: selectedCatalog, + existsInCatalog: true, + })) + + // Execute update - we need to update catalog entries only + const { packages: repoPackages } = await provider.listPackages() + + // Find all packages that use these catalog references + const affectedPackages: string[] = [] + for (const pkg of repoPackages) { + for (const depField of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ] as const) { + const deps = pkg[depField] + if (!deps) continue + for (const depName of Object.keys(deps)) { + if ( + toChange.includes(depName) && + deps[depName].startsWith('catalog:') + ) { + affectedPackages.push(pkg.directory) + break + } + } + } + } + + try { + await provider.depInstallExecutor({ + deps: resolved, + targetPackages: [...new Set(affectedPackages)], + dev: false, + peer: false, + logger: (msg) => p.log.step(msg), + }) + p.outro(c.green('Done!')) + } catch (error) { + p.log.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +cli + .command('catalog', 'Manage catalogs') + .option('-l, --list', 'List all catalogs') + .action(runCatalog) + cli.help() cli.version(version) cli.parse() diff --git a/src/npm.ts b/src/npm.ts new file mode 100644 index 0000000..321be8c --- /dev/null +++ b/src/npm.ts @@ -0,0 +1,77 @@ +import { getLatestVersion } from 'fast-npm-meta' + +export interface OutdatedInfo { + name: string + current: string + latest: string + wanted: string + dependent: string +} + +/** + * Fetch all available versions for a package. + */ +export async function getPackageVersions( + packageName: string, +): Promise { + const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}` + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch versions: ${response.statusText}`) + } + + const data = (await response.json()) as { + versions?: Record + error?: string + } + + if (data.error) { + throw new Error(`Package not found: ${packageName}`) + } + + return Object.keys(data.versions ?? {}).sort().reverse() +} + +/** + * Check for outdated packages by comparing current versions with latest. + */ +export async function checkOutdated( + deps: Record, + options?: { logger?: (msg: string) => void }, +): Promise { + const log = options?.logger ?? (() => {}) + const outdated: OutdatedInfo[] = [] + + for (const [name, currentSpec] of Object.entries(deps)) { + // Skip catalog: references and workspace: references + if (currentSpec.startsWith('catalog:') || currentSpec.startsWith('workspace:')) { + continue + } + + // Extract version from spec (remove ^, ~, etc.) + const currentVersion = currentSpec.replace(/^[\^~>=<]+/, '') + + try { + const meta = await getLatestVersion(name) + if (!meta.version) continue + + const latestVersion = meta.version + // For simple version comparison, check if current is different from latest + if (currentVersion !== latestVersion && !currentVersion.includes('*')) { + outdated.push({ + name, + current: currentSpec, + latest: `^${latestVersion}`, + wanted: `^${latestVersion}`, + dependent: 'root', + }) + log(`Checking ${name}: ${currentSpec} -> ^${latestVersion}`) + } + } catch { + // Skip packages that can't be fetched + } + } + + return outdated +} diff --git a/src/providers/bun.ts b/src/providers/bun.ts index 98adf22..fef6201 100644 --- a/src/providers/bun.ts +++ b/src/providers/bun.ts @@ -9,7 +9,7 @@ import { sortObject, writePeerDependenciesMeta, } from './shared.ts' -import type { DepInstallOptions, Provider } from '../type.ts' +import type { DepInstallOptions, DepRemoveOptions, Provider } from '../type.ts' const LOCK_FILES = ['bun.lock', 'bun.lockb'] @@ -204,5 +204,100 @@ export function createBunProvider(cwd = process.cwd()): Provider { return Promise.resolve() }, + + depRemoveExecutor(options: DepRemoveOptions) { + const log = options.logger ?? (() => {}) + const rootPkgPath = join(cwd, 'package.json') + + // 1. Remove dependencies from each target package.json + for (const dir of options.targetPackages) { + const pkgPath = join(dir, 'package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + let modified = false + + for (const depName of options.packageNames) { + for (const depField of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ] as const) { + if (pkg[depField] && depName in pkg[depField]) { + delete pkg[depField][depName] + modified = true + } + } + } + + if (modified) { + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8') + log(`Updating ${pkgPath}`) + } + } + + // 2. Clean up unused catalog entries in root package.json if requested + if (options.cleanCatalog && existsSync(rootPkgPath)) { + const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')) + const source = readCatalogSource(rootPkg) + let modified = false + + if (source) { + for (const depName of options.packageNames) { + // Remove from default catalog + const defaultCatalog = source.catalog as + | Record + | undefined + if (defaultCatalog && depName in defaultCatalog) { + delete defaultCatalog[depName] + modified = true + } + // Remove from named catalogs + if (source.catalogs && typeof source.catalogs === 'object') { + const catalogs = source.catalogs as Record< + string, + Record + > + for (const catalogName of Object.keys(catalogs)) { + const catalog = catalogs[catalogName] + if (catalog && depName in catalog) { + delete catalog[depName] + modified = true + } + } + } + } + + // Sort catalog entries after removal + if (source.catalog) { + source.catalog = sortObject( + source.catalog as Record, + ) + } + if (source.catalogs) { + const catalogs = source.catalogs as Record< + string, + Record + > + for (const name of Object.keys(catalogs)) { + catalogs[name] = sortObject(catalogs[name]) + } + } + } + + if (modified) { + writeFileSync( + rootPkgPath, + `${JSON.stringify(rootPkg, null, 2)}\n`, + 'utf8', + ) + log('Cleaning catalog entries in package.json') + } + } + + // 3. Run bun install to update lockfile + log('Running bun install') + execFileSync('bun', ['install'], { cwd, stdio: 'inherit' }) + + return Promise.resolve() + }, } } diff --git a/src/providers/pnpm.ts b/src/providers/pnpm.ts index 7115f16..7d3dcae 100644 --- a/src/providers/pnpm.ts +++ b/src/providers/pnpm.ts @@ -10,7 +10,7 @@ import { sortObject, writePeerDependenciesMeta, } from './shared.ts' -import type { DepInstallOptions, Provider } from '../type.ts' +import type { DepInstallOptions, DepRemoveOptions, Provider } from '../type.ts' const LOCK_FILE = 'pnpm-lock.yaml' const WORKSPACE_FILE = 'pnpm-workspace.yaml' @@ -142,5 +142,71 @@ export function createPnpmProvider(cwd = process.cwd()): Provider { return Promise.resolve() }, + + depRemoveExecutor(options: DepRemoveOptions) { + const log = options.logger ?? (() => {}) + const workspacePath = join(cwd, WORKSPACE_FILE) + + // 1. Remove dependencies from each target package.json + for (const dir of options.targetPackages) { + const pkgPath = join(dir, 'package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + let modified = false + + for (const depName of options.packageNames) { + for (const depField of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ] as const) { + if (pkg[depField] && depName in pkg[depField]) { + delete pkg[depField][depName] + modified = true + } + } + } + + if (modified) { + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8') + log(`Updating ${pkgPath}`) + } + } + + // 2. Clean up unused catalog entries if requested + if (options.cleanCatalog && existsSync(workspacePath)) { + const content = readFileSync(workspacePath, 'utf8') + const doc = parseDocument(content) + let modified = false + + for (const depName of options.packageNames) { + // Remove from default catalog + if (doc.hasIn(['catalog', depName])) { + doc.deleteIn(['catalog', depName]) + modified = true + } + // Remove from named catalogs + const raw = doc.toJSON() + if (raw?.catalogs && typeof raw.catalogs === 'object') { + for (const catalogName of Object.keys(raw.catalogs)) { + if (doc.hasIn(['catalogs', catalogName, depName])) { + doc.deleteIn(['catalogs', catalogName, depName]) + modified = true + } + } + } + } + + if (modified) { + writeFileSync(workspacePath, doc.toString(), 'utf8') + log(`Cleaning catalog entries in ${WORKSPACE_FILE}`) + } + } + + // 3. Run pnpm install to update lockfile + log('Running pnpm install') + execFileSync('pnpm', ['install'], { cwd, stdio: 'inherit' }) + + return Promise.resolve() + }, } } diff --git a/src/providers/shared.ts b/src/providers/shared.ts index 71a1581..2055b84 100644 --- a/src/providers/shared.ts +++ b/src/providers/shared.ts @@ -13,6 +13,7 @@ export function readPackageItem( description: (pkg.description as string) || '', dependencies: (pkg.dependencies as Record) || {}, devDependencies: (pkg.devDependencies as Record) || {}, + peerDependencies: (pkg.peerDependencies as Record) || {}, } } diff --git a/src/providers/yarn.ts b/src/providers/yarn.ts index a1cb171..309dc79 100644 --- a/src/providers/yarn.ts +++ b/src/providers/yarn.ts @@ -10,7 +10,7 @@ import { sortObject, writePeerDependenciesMeta, } from './shared.ts' -import type { DepInstallOptions, Provider } from '../type.ts' +import type { DepInstallOptions, DepRemoveOptions, Provider } from '../type.ts' const LOCK_FILE = 'yarn.lock' const CONFIG_FILE = '.yarnrc.yml' @@ -137,5 +137,71 @@ export function createYarnProvider(cwd = process.cwd()): Provider { return Promise.resolve() }, + + depRemoveExecutor(options: DepRemoveOptions) { + const log = options.logger ?? (() => {}) + const configPath = join(cwd, CONFIG_FILE) + + // 1. Remove dependencies from each target package.json + for (const dir of options.targetPackages) { + const pkgPath = join(dir, 'package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + let modified = false + + for (const depName of options.packageNames) { + for (const depField of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ] as const) { + if (pkg[depField] && depName in pkg[depField]) { + delete pkg[depField][depName] + modified = true + } + } + } + + if (modified) { + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8') + log(`Updating ${pkgPath}`) + } + } + + // 2. Clean up unused catalog entries if requested + if (options.cleanCatalog && existsSync(configPath)) { + const content = readFileSync(configPath, 'utf8') + const doc = parseDocument(content) + let modified = false + + for (const depName of options.packageNames) { + // Remove from default catalog + if (doc.hasIn(['catalog', depName])) { + doc.deleteIn(['catalog', depName]) + modified = true + } + // Remove from named catalogs + const raw = doc.toJSON() + if (raw?.catalogs && typeof raw.catalogs === 'object') { + for (const catalogName of Object.keys(raw.catalogs)) { + if (doc.hasIn(['catalogs', catalogName, depName])) { + doc.deleteIn(['catalogs', catalogName, depName]) + modified = true + } + } + } + } + + if (modified) { + writeFileSync(configPath, doc.toString(), 'utf8') + log(`Cleaning catalog entries in ${CONFIG_FILE}`) + } + } + + // 3. Run yarn install to update lockfile + log('Running yarn install') + execFileSync('yarn', ['install'], { cwd, stdio: 'inherit' }) + + return Promise.resolve() + }, } } diff --git a/src/type.ts b/src/type.ts index 6fb0d58..d32d94b 100644 --- a/src/type.ts +++ b/src/type.ts @@ -30,6 +30,13 @@ export type Provider = { * and running install in its own way. */ depInstallExecutor: (options: DepInstallOptions) => Promise + + /** + * Execute the dependency removal flow. + * Each provider handles catalog cleanup, package.json updates, + * and running install in its own way. + */ + depRemoveExecutor: (options: DepRemoveOptions) => Promise } export type DepInstallOptions = { @@ -44,6 +51,17 @@ export type DepInstallOptions = { logger?: (message: string) => void } +export type DepRemoveOptions = { + /** Package names to remove */ + packageNames: string[] + /** Target package directories to remove dependencies from */ + targetPackages: string[] + /** Whether to also remove unused catalog entries */ + cleanCatalog?: boolean + /** Log progress messages during execution */ + logger?: (message: string) => void +} + export type ResolvedDep = { name: string version: string @@ -59,4 +77,5 @@ export type RepoPackageItem = { description: string dependencies: Record devDependencies: Record + peerDependencies: Record } diff --git a/tests/core.test.ts b/tests/core.test.ts index 7df2a28..41c272b 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -117,6 +117,7 @@ describe('checkCatalogSupport', () => { listCatalogs: vi.fn(), listPackages: vi.fn(), depInstallExecutor: vi.fn(), + depRemoveExecutor: vi.fn(), } satisfies Omit it('returns supported when catalogSupport is set and no version', () => {