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
-
## 📦 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', () => {