From 54fb84a6946c85be8cdb932b0759199bfa1e280c Mon Sep 17 00:00:00 2001 From: dongguacute Date: Thu, 12 Mar 2026 02:46:18 +0800 Subject: [PATCH 1/4] feat: implement package removal functionality in CLI Added a new command to remove packages from dependencies, including options for catalog cleanup. Updated provider interfaces and implementations for bun, pnpm, and yarn to support the new removal feature. Enhanced type definitions and added tests for the new functionality. --- CLAUDE.md | 1 - README.md | 15 ++- src/cli.ts | 204 ++++++++++++++++++++++++++++++++++++++++ src/providers/bun.ts | 97 ++++++++++++++++++- src/providers/pnpm.ts | 68 +++++++++++++- src/providers/shared.ts | 1 + src/providers/yarn.ts | 68 +++++++++++++- src/type.ts | 19 ++++ tests/core.test.ts | 1 + 9 files changed, 462 insertions(+), 12 deletions(-) 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 e00a3cf..0d11481 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 @@ -81,13 +80,13 @@ catalogs: ## 🛠️ Supported Package Managers -| Package Manager | Status | -|---|---| -| pnpm | ✅ Supported | -| yarn | ✅ Supported | -| bun | ✅ Supported | -| vlt | 🚧 Planned | -| npm | 🚧 Planned | +| Package Manager | Status | +| --------------- | ------------ | +| pnpm | ✅ Supported | +| yarn | ✅ Supported | +| bun | ✅ Supported | +| vlt | 🚧 Planned | +| npm | 🚧 Planned | ## 📄 License diff --git a/src/cli.ts b/src/cli.ts index 390f8aa..6f6dbe0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -322,6 +322,210 @@ cli .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) + cli.help() cli.version(version) cli.parse() diff --git a/src/providers/bun.ts b/src/providers/bun.ts index 0255078..6cc5405 100644 --- a/src/providers/bun.ts +++ b/src/providers/bun.ts @@ -8,7 +8,7 @@ import { resolveWorkspacePackages, sortObject, } 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'] @@ -202,5 +202,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 a7efdaf..7d98fc0 100644 --- a/src/providers/pnpm.ts +++ b/src/providers/pnpm.ts @@ -9,7 +9,7 @@ import { resolveWorkspacePackages, sortObject, } 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' @@ -140,5 +140,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 85c0373..d2dba71 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 1b07cf9..57025e6 100644 --- a/src/providers/yarn.ts +++ b/src/providers/yarn.ts @@ -9,7 +9,7 @@ import { resolveWorkspacePackages, sortObject, } 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' @@ -135,5 +135,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 41a7a72..1b08b7b 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 = { @@ -42,6 +49,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 @@ -57,4 +75,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', () => { From f21841ce3cea3596fbe7e7b8416285440fdcdcca Mon Sep 17 00:00:00 2001 From: dongguacute Date: Thu, 12 Mar 2026 02:49:55 +0800 Subject: [PATCH 2/4] feat: enhance CLI with search and update commands Added new commands to the CLI for searching npm packages and updating dependencies. The `search` command allows users to find packages interactively, while the `update` command checks for outdated packages and facilitates their updates. Updated the README to reflect these new features and provided usage examples. --- README.md | 62 ++++++ src/cli.ts | 547 ++++++++++++++++++++++++++++++++++++++++++++++++++++- src/npm.ts | 127 +++++++++++++ 3 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 src/npm.ts diff --git a/README.md b/README.md index 0d11481..fb1fa28 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ nai react --peer # Specify a catalog nai zod -C prod + +# Search npm for packages +nai search react +nai s vue router + +# 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. @@ -55,6 +67,56 @@ 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]` | - | Install packages interactively | +| `nai search [query]` | `nai s` | Search npm for packages | +| `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 | + +### 🔍 Search + +Search npm registry interactively: + +```bash +nai search react +nai s "ui framework" +``` + +Results show package names, versions, and descriptions. Select a package to install it directly. + +### 📦 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 6f6dbe0..fc1617f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,9 +10,14 @@ import { getDepField, resolvePackageVersions, } from './core.ts' +import { + checkOutdated, + getPackageVersions, + searchNpm, +} 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< @@ -526,6 +531,546 @@ cli .option('--clean-catalog', 'Remove unused catalog entries') .action(runRemove) +async function runSearch(query: string[]) { + p.intro(`${c.yellow`@rizumu/nai`} ${c.dim`v${version}`} ${c.cyan`search`}`) + + const searchQuery = query.length > 0 ? query.join(' ') : guardCancel( + await p.text({ + message: 'Search packages', + placeholder: 'e.g. react framework', + validate: (v) => { + if (!v?.trim()) return 'Please enter a search query.' + }, + }), + ) + + const s = p.spinner() + s.start(`Searching npm for "${c.cyan(searchQuery)}"...`) + + try { + const results = await searchNpm(searchQuery, { size: 20 }) + s.stop(`Found ${c.green(results.length)} packages`) + + if (results.length === 0) { + p.log.warn('No packages found.') + p.outro('Try a different search term') + return + } + + const selected = guardCancel( + await p.select({ + message: 'Select a package to install', + options: results.map((pkg) => ({ + value: pkg.name, + label: c.cyan(pkg.name) + c.dim(` v${pkg.version}`), + hint: pkg.description?.slice(0, 60) || undefined, + })), + }), + ) + + // Ask if user wants to install + const shouldInstall = guardCancel( + await p.confirm({ + message: `Install ${c.cyan(selected)}?`, + initialValue: true, + }), + ) + + if (shouldInstall) { + // Run the install flow + await run([selected], {}) + } else { + p.outro('Done') + } + } catch (error) { + s.stop('Search failed') + p.log.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +cli + .command('search [...query]', 'Search npm for packages') + .alias('s') + .action(runSearch) + +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..cf4a954 --- /dev/null +++ b/src/npm.ts @@ -0,0 +1,127 @@ +import { getLatestVersion } from 'fast-npm-meta' + +export interface SearchResult { + name: string + version: string + description: string + author?: string + date?: string + keywords?: string[] +} + +export interface OutdatedInfo { + name: string + current: string + latest: string + wanted: string + dependent: string +} + +/** + * Search npm registry for packages matching a query. + */ +export async function searchNpm( + query: string, + options?: { size?: number }, +): Promise { + const size = options?.size ?? 20 + const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${size}` + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Search failed: ${response.statusText}`) + } + + const data = (await response.json()) as { + objects: Array<{ + package: { + name: string + version: string + description?: string + author?: { name?: string } | string + date?: string + keywords?: string[] + } + }> + } + + return data.objects.map((obj) => ({ + name: obj.package.name, + version: obj.package.version, + description: obj.package.description ?? '', + author: + typeof obj.package.author === 'string' + ? obj.package.author + : obj.package.author?.name, + date: obj.package.date, + keywords: obj.package.keywords, + })) +} + +/** + * 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 +} From acf4f586ec397c85f3626978fbb5298516312457 Mon Sep 17 00:00:00 2001 From: dongguacute Date: Fri, 13 Mar 2026 13:07:32 +0800 Subject: [PATCH 3/4] refactor: remove search functionality from CLI and related code The search command and its implementation have been removed from the CLI, along with associated types and functions in the npm module. This streamlines the codebase by eliminating unused features. --- README.md | 16 -------------- src/cli.ts | 64 ------------------------------------------------------ src/npm.ts | 50 ------------------------------------------ 3 files changed, 130 deletions(-) diff --git a/README.md b/README.md index d5e3b92..8ef9236 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,6 @@ nai react --peer # Specify a catalog nai zod -C prod -# Search npm for packages -nai search react -nai s vue router - # Update packages to latest versions nai update nai up react lodash @@ -72,22 +68,10 @@ Too many flags. Too many files to touch. Too many things to remember. | Command | Alias | Description | |---------|-------|-------------| | `nai [packages]` | - | Install packages interactively | -| `nai search [query]` | `nai s` | Search npm for packages | | `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 | -### 🔍 Search - -Search npm registry interactively: - -```bash -nai search react -nai s "ui framework" -``` - -Results show package names, versions, and descriptions. Select a package to install it directly. - ### 📦 Update Check and update outdated packages: diff --git a/src/cli.ts b/src/cli.ts index f0374c4..5c8fd0d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,6 @@ import { import { checkOutdated, getPackageVersions, - searchNpm, } from './npm.ts' import { providers } from './providers/index.ts' import { parsePackageSpec, type ParsedPackage } from './utils.ts' @@ -548,69 +547,6 @@ cli .option('--clean-catalog', 'Remove unused catalog entries') .action(runRemove) -async function runSearch(query: string[]) { - p.intro(`${c.yellow`@rizumu/nai`} ${c.dim`v${version}`} ${c.cyan`search`}`) - - const searchQuery = query.length > 0 ? query.join(' ') : guardCancel( - await p.text({ - message: 'Search packages', - placeholder: 'e.g. react framework', - validate: (v) => { - if (!v?.trim()) return 'Please enter a search query.' - }, - }), - ) - - const s = p.spinner() - s.start(`Searching npm for "${c.cyan(searchQuery)}"...`) - - try { - const results = await searchNpm(searchQuery, { size: 20 }) - s.stop(`Found ${c.green(results.length)} packages`) - - if (results.length === 0) { - p.log.warn('No packages found.') - p.outro('Try a different search term') - return - } - - const selected = guardCancel( - await p.select({ - message: 'Select a package to install', - options: results.map((pkg) => ({ - value: pkg.name, - label: c.cyan(pkg.name) + c.dim(` v${pkg.version}`), - hint: pkg.description?.slice(0, 60) || undefined, - })), - }), - ) - - // Ask if user wants to install - const shouldInstall = guardCancel( - await p.confirm({ - message: `Install ${c.cyan(selected)}?`, - initialValue: true, - }), - ) - - if (shouldInstall) { - // Run the install flow - await run([selected], {}) - } else { - p.outro('Done') - } - } catch (error) { - s.stop('Search failed') - p.log.error(error instanceof Error ? error.message : String(error)) - process.exit(1) - } -} - -cli - .command('search [...query]', 'Search npm for packages') - .alias('s') - .action(runSearch) - async function runUpdate( names: string[], options: { interactive?: boolean; catalog?: string }, diff --git a/src/npm.ts b/src/npm.ts index cf4a954..321be8c 100644 --- a/src/npm.ts +++ b/src/npm.ts @@ -1,14 +1,5 @@ import { getLatestVersion } from 'fast-npm-meta' -export interface SearchResult { - name: string - version: string - description: string - author?: string - date?: string - keywords?: string[] -} - export interface OutdatedInfo { name: string current: string @@ -17,47 +8,6 @@ export interface OutdatedInfo { dependent: string } -/** - * Search npm registry for packages matching a query. - */ -export async function searchNpm( - query: string, - options?: { size?: number }, -): Promise { - const size = options?.size ?? 20 - const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${size}` - - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Search failed: ${response.statusText}`) - } - - const data = (await response.json()) as { - objects: Array<{ - package: { - name: string - version: string - description?: string - author?: { name?: string } | string - date?: string - keywords?: string[] - } - }> - } - - return data.objects.map((obj) => ({ - name: obj.package.name, - version: obj.package.version, - description: obj.package.description ?? '', - author: - typeof obj.package.author === 'string' - ? obj.package.author - : obj.package.author?.name, - date: obj.package.date, - keywords: obj.package.keywords, - })) -} - /** * Fetch all available versions for a package. */ From 3155da2ca12b8a6dbe7ba4ce42b36d56405fa619 Mon Sep 17 00:00:00 2001 From: dongguacute Date: Fri, 13 Mar 2026 13:22:05 +0800 Subject: [PATCH 4/4] feat: add 'add' command for package installation with catalog support Introduced a new 'add' command in the CLI to facilitate package installation, allowing users to specify options for dev and peer dependencies, as well as catalog names. Updated README to reflect the new command usage and examples. --- README.md | 13 +++++++------ src/cli.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8ef9236..d5c3990 100644 --- a/README.md +++ b/README.md @@ -16,19 +16,20 @@ 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 @@ -67,7 +68,7 @@ Too many flags. Too many files to touch. Too many things to remember. | Command | Alias | Description | |---------|-------|-------------| -| `nai [packages]` | - | Install packages interactively | +| `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 | diff --git a/src/cli.ts b/src/cli.ts index 5c8fd0d..2ced64f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -343,6 +343,14 @@ 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`}`)