diff --git a/packages/nuxi/src/commands/module/_utils.ts b/packages/nuxi/src/commands/module/_utils.ts index 7e4b05d3..a461ee54 100644 --- a/packages/nuxi/src/commands/module/_utils.ts +++ b/packages/nuxi/src/commands/module/_utils.ts @@ -1,7 +1,19 @@ +import type { PackageManager } from 'nypm' +import type { PackageJson } from 'pkg-types' + +import { existsSync } from 'node:fs' + +import { confirm, isCancel } from '@clack/prompts' import { parseINI } from 'confbox' +import { colors } from 'consola/utils' import { $fetch } from 'ofetch' +import { resolve } from 'pathe' import { satisfies } from 'semver' +import { logger } from '../../utils/logger' +import { relativeToProcess } from '../../utils/paths' +import { cwdArgs, logLevelArgs } from '../_shared' + export const categories = [ 'Analytics', 'CMS', @@ -136,3 +148,40 @@ export function getRegistryFromContent(content: string, scope: string | null) { return null } } + +export function getProjectDependencies(projectPkg: PackageJson): Set { + return new Set([ + ...Object.keys(projectPkg.dependencies || {}), + ...Object.keys(projectPkg.devDependencies || {}), + ]) +} + +/** + * Warn and prompt to continue when the project has no `nuxt` dependency. + * Returns `false` if the user declines or cancels. + */ +export async function ensureNuxtDependency(cwd: string, projectPkg: PackageJson): Promise { + if (projectPkg.dependencies?.nuxt || projectPkg.devDependencies?.nuxt) { + return true + } + + logger.warn(`No ${colors.cyan('nuxt')} dependency detected in ${colors.cyan(relativeToProcess(cwd))}.`) + + const shouldContinue = await confirm({ + message: `Do you want to continue anyway?`, + initialValue: false, + }) + + return !isCancel(shouldContinue) && shouldContinue === true +} + +export function isPnpmWorkspace(packageManager: PackageManager | undefined, cwd: string): boolean { + return packageManager?.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')) +} + +/** Forward `cwd` and log-level args to a chained command invocation. */ +export function forwardCommandArgs(args: Record): string[] { + return Object.entries(args) + .filter(([k]) => k in cwdArgs || k in logLevelArgs) + .map(([k, v]) => `--${k}=${v}`) +} diff --git a/packages/nuxi/src/commands/module/add.ts b/packages/nuxi/src/commands/module/add.ts index 0620e5db..7ca5251a 100644 --- a/packages/nuxi/src/commands/module/add.ts +++ b/packages/nuxi/src/commands/module/add.ts @@ -3,7 +3,6 @@ import type { PackageJson } from 'pkg-types' import type { NuxtModule } from './_utils' import * as fs from 'node:fs' -import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -21,12 +20,11 @@ import { joinURL } from 'ufo' import { runCommand } from '../../run' import { logger } from '../../utils/logger' -import { relativeToProcess } from '../../utils/paths' import { getNuxtVersion } from '../../utils/versions' import { cwdArgs, logLevelArgs } from '../_shared' import prepareCommand from '../prepare' import { selectModulesAutocomplete } from './_autocomplete' -import { checkNuxtCompatibility, fetchModules, getRegistryFromContent } from './_utils' +import { checkNuxtCompatibility, ensureNuxtDependency, fetchModules, forwardCommandArgs, getProjectDependencies, getRegistryFromContent, isPnpmWorkspace } from './_utils' const PROTOCOL_RE = /^https?:\/\// const TRAILING_SLASH_RE = /\/$/ @@ -76,17 +74,8 @@ export default defineCommand({ let modules = ctx.args._.map(e => e.trim()).filter(Boolean) const projectPkg = await readPackageJSON(cwd).catch(() => ({} as PackageJson)) - if (!projectPkg.dependencies?.nuxt && !projectPkg.devDependencies?.nuxt) { - logger.warn(`No ${colors.cyan('nuxt')} dependency detected in ${colors.cyan(relativeToProcess(cwd))}.`) - - const shouldContinue = await confirm({ - message: `Do you want to continue anyway?`, - initialValue: false, - }) - - if (isCancel(shouldContinue) || shouldContinue !== true) { - process.exit(1) - } + if (!await ensureNuxtDependency(cwd, projectPkg)) { + process.exit(1) } // If no modules specified, show interactive search @@ -137,9 +126,7 @@ export default defineCommand({ // Run prepare command if install is not skipped if (!ctx.args.skipInstall) { - const args = Object.entries(ctx.args).filter(([k]) => k in cwdArgs || k in logLevelArgs).map(([k, v]) => `--${k}=${v}`) - - await runCommand(prepareCommand, args) + await runCommand(prepareCommand, forwardCommandArgs(ctx.args)) } }, }) @@ -151,10 +138,7 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip const installedModules: ResolvedModule[] = [] const notInstalledModules: ResolvedModule[] = [] - const dependencies = new Set([ - ...Object.keys(projectPkg.dependencies || {}), - ...Object.keys(projectPkg.devDependencies || {}), - ]) + const dependencies = getProjectDependencies(projectPkg) for (const module of modules) { if (dependencies.has(module.pkgName)) { @@ -186,7 +170,7 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip dev: isDev, installPeerDependencies: true, packageManager, - workspace: packageManager?.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')), + workspace: isPnpmWorkspace(packageManager, cwd), }).then(() => true).catch( async (error) => { logger.error(String(error)) diff --git a/packages/nuxi/src/commands/module/index.ts b/packages/nuxi/src/commands/module/index.ts index a7da58d2..01407b02 100644 --- a/packages/nuxi/src/commands/module/index.ts +++ b/packages/nuxi/src/commands/module/index.ts @@ -8,6 +8,7 @@ export default defineCommand({ args: {}, subCommands: { add: () => import('./add').then(r => r.default || r), + remove: () => import('./remove').then(r => r.default || r), search: () => import('./search').then(r => r.default || r), }, }) diff --git a/packages/nuxi/src/commands/module/remove.ts b/packages/nuxi/src/commands/module/remove.ts new file mode 100644 index 00000000..034d17e0 --- /dev/null +++ b/packages/nuxi/src/commands/module/remove.ts @@ -0,0 +1,326 @@ +import type { PackageJson } from 'pkg-types' + +import type { NuxtModule } from './_utils' + +import process from 'node:process' + +import { cancel, confirm, isCancel, multiselect } from '@clack/prompts' +import { updateConfig } from 'c12/update' +import { defineCommand } from 'citty' +import { colors } from 'consola/utils' +import { detectPackageManager, removeDependency } from 'nypm' +import { resolve } from 'pathe' +import { readPackageJSON } from 'pkg-types' + +import { runCommand } from '../../run' +import { logger } from '../../utils/logger' +import { relativeToProcess } from '../../utils/paths' +import { cwdArgs, logLevelArgs } from '../_shared' +import prepareCommand from '../prepare' +import { ensureNuxtDependency, fetchModules, forwardCommandArgs, getProjectDependencies, isPnpmWorkspace } from './_utils' + +interface OrphanedPeer { + peer: string + source: string +} + +export default defineCommand({ + meta: { + name: 'remove', + description: 'Remove Nuxt modules', + }, + args: { + ...cwdArgs, + ...logLevelArgs, + moduleName: { + type: 'positional', + description: 'Specify one or more modules to remove by name, separated by spaces', + required: false, + }, + skipInstall: { + type: 'boolean', + description: 'Skip dependency uninstall', + }, + skipConfig: { + type: 'boolean', + description: 'Skip nuxt.config.ts update', + }, + }, + async setup(ctx) { + const cwd = resolve(ctx.args.cwd) + const modules = ctx.args._.map(e => e.trim()).filter(Boolean) + const projectPkg = await readPackageJSON(cwd).catch(() => ({} as PackageJson)) + + if (!await ensureNuxtDependency(cwd, projectPkg)) { + process.exit(1) + } + + if (ctx.args.skipConfig && modules.length === 0) { + cancel(`Specify one or more modules to remove when ${colors.cyan('--skipConfig')} is set.`) + process.exit(1) + } + + // With no inputs, the multiselect picker runs inside `removeModules` against the + // configured modules. Otherwise resolve aliases/names to canonical npm package names. + const installedNames = getProjectDependencies(projectPkg) + + const needsDB = modules.some(m => !installedNames.has(m)) + const modulesDB: NuxtModule[] = needsDB + ? await fetchModules().catch((err) => { + logger.warn(`Cannot search in the Nuxt Modules database: ${err}`) + return [] + }) + : [] + + const resolvedModules = modules.map(m => resolveModuleName(m, modulesDB, installedNames)) + + if (resolvedModules.length > 0) { + logger.info(`Resolved ${resolvedModules.map(x => colors.cyan(x)).join(', ')}, removing module${resolvedModules.length > 1 ? 's' : ''}...`) + } + + const proceed = await removeModules(resolvedModules, { ...ctx.args, cwd }, projectPkg) + + if (!proceed) { + process.exit(0) + } + + // Run prepare command if uninstall is not skipped + if (!ctx.args.skipInstall) { + await runCommand(prepareCommand, forwardCommandArgs(ctx.args)) + } + }, +}) + +// -- Internal Utils -- +async function removeModules(modules: string[], { skipInstall = false, skipConfig = false, cwd }: { skipInstall?: boolean, skipConfig?: boolean, cwd: string }, projectPkg: PackageJson): Promise { + const removedFromConfig: string[] = [] + + if (!skipConfig) { + let configMissing = false + let cancelled = false + + await updateConfig({ + cwd, + configFile: 'nuxt.config', + onCreate() { + configMissing = true + return false + }, + async onUpdate(config) { + if (!Array.isArray(config.modules)) { + return + } + + const present: string[] = [] + for (const item of config.modules) { + const name = readModuleName(item) + if (name) { + present.push(name) + } + } + + let toRemove: Set + if (modules.length === 0) { + if (present.length === 0) { + return + } + + const picked = await multiselect({ + message: 'Select modules to remove:', + options: present.map(m => ({ value: m, label: m })), + required: true, + }) + + if (isCancel(picked)) { + cancelled = true + return + } + + toRemove = new Set(picked as string[]) + } + else { + toRemove = new Set(modules) + } + + for (let i = config.modules.length - 1; i >= 0; i--) { + const name = readModuleName(config.modules[i]) + if (name && toRemove.has(name)) { + logger.info(`Removing ${colors.cyan(name)} from the ${colors.cyan('modules')}`) + config.modules.splice(i, 1) + removedFromConfig.push(name) + } + } + }, + }).catch((error) => { + if (configMissing) { + return + } + logger.error(`Failed to update ${colors.cyan('nuxt.config')}: ${error.message}`) + logger.error(`Please manually remove ${colors.cyan(modules.join(', ') || 'the relevant modules')} from the ${colors.cyan('modules')} array in ${colors.cyan('nuxt.config.ts')}`) + }) + + if (cancelled) { + cancel('No modules selected.') + return false + } + + if (modules.length === 0 && removedFromConfig.length === 0) { + cancel(configMissing + ? `No ${colors.cyan('nuxt.config')} found in ${colors.cyan(relativeToProcess(cwd))}.` + : `No modules configured in ${colors.cyan('nuxt.config')}.`) + return false + } + } + + if (!skipInstall) { + const installedModules: string[] = [] + const notInstalledModules: string[] = [] + + const dependencies = getProjectDependencies(projectPkg) + + const targets = Array.from(new Set([...modules, ...removedFromConfig])) + + for (const module of targets) { + if (dependencies.has(module)) { + installedModules.push(module) + } + else { + notInstalledModules.push(module) + } + } + + if (notInstalledModules.length > 0) { + const notInstalledList = notInstalledModules.map(m => colors.cyan(m)).join(', ') + const are = notInstalledModules.length > 1 ? 'are' : 'is' + logger.info(`${notInstalledList} ${are} not installed as a dependency`) + } + + if (installedModules.length === 0) { + return true + } + + const toRemove = [...installedModules] + + const orphanedPeers = await findOrphanedPeers(installedModules, projectPkg, cwd) + if (orphanedPeers.length > 0) { + const peersList = orphanedPeers.map(({ peer, source }) => + `${colors.cyan(peer)} (peer of ${colors.cyan(source)})`).join(', ') + const peerDep = orphanedPeers.length > 1 ? 'dependencies' : 'dependency' + const them = orphanedPeers.length > 1 ? 'them' : 'it' + + logger.info(`The following peer ${peerDep} ${orphanedPeers.length > 1 ? 'are' : 'is'} no longer used by any other dependency: ${peersList}`) + + const alsoRemove = await confirm({ + message: `Do you also want to remove ${them}?`, + initialValue: false, + }) + + if (isCancel(alsoRemove)) { + cancel('Aborted.') + return false + } + + if (alsoRemove) { + toRemove.push(...orphanedPeers.map(o => o.peer)) + } + } + + const removeList = toRemove.map(m => colors.cyan(m)).join(', ') + const dependency = toRemove.length > 1 ? 'dependencies' : 'dependency' + logger.info(`Uninstalling ${removeList} ${dependency}`) + + const packageManager = await detectPackageManager(cwd) + + const removed = await removeDependency(toRemove, { + cwd, + packageManager, + workspace: isPnpmWorkspace(packageManager, cwd), + }).then(() => true).catch((error) => { + logger.error(String(error)) + return false + }) + + if (!removed) { + return false + } + } + + return true +} + +function readModuleName(item: unknown): string | null { + if (typeof item === 'string') { + return item + } + if (Array.isArray(item) && typeof item[0] === 'string') { + return item[0] + } + return null +} + +function resolveModuleName(input: string, modulesDB: NuxtModule[], installed: Set): string { + if (installed.has(input)) { + return input + } + + const matched = modulesDB.find(m => + m.name === input + || m.npm === input + || m.aliases?.includes(input), + ) + + return matched?.npm || input +} + +async function findOrphanedPeers(removing: string[], projectPkg: PackageJson, cwd: string): Promise { + const projectDeps = getProjectDependencies(projectPkg) + const removingSet = new Set(removing) + + // peer name -> first removed module that declares it + const candidates = new Map() + for (const m of removing) { + const pkg = await readPackageJSON(m, { from: cwd }).catch(() => null) + if (!pkg?.peerDependencies) { + continue + } + for (const peer of Object.keys(pkg.peerDependencies)) { + if (!projectDeps.has(peer) || removingSet.has(peer) || candidates.has(peer)) { + continue + } + candidates.set(peer, m) + } + } + + if (candidates.size === 0) { + return [] + } + + // Strike out peers that another retained dep still needs + const stillNeeded = new Set() + for (const dep of projectDeps) { + if (removingSet.has(dep) || candidates.has(dep)) { + continue + } + const depPkg = await readPackageJSON(dep, { from: cwd }).catch(() => null) + if (!depPkg) { + continue + } + const depDeps = new Set([ + ...Object.keys(depPkg.dependencies || {}), + ...Object.keys(depPkg.peerDependencies || {}), + ]) + for (const peer of candidates.keys()) { + if (depDeps.has(peer)) { + stillNeeded.add(peer) + } + } + } + + const orphans: OrphanedPeer[] = [] + for (const [peer, source] of candidates) { + if (!stillNeeded.has(peer)) { + orphans.push({ peer, source }) + } + } + return orphans +} diff --git a/packages/nuxi/test/unit/commands/module/remove.spec.ts b/packages/nuxi/test/unit/commands/module/remove.spec.ts new file mode 100644 index 00000000..22209d3f --- /dev/null +++ b/packages/nuxi/test/unit/commands/module/remove.spec.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import commands from '../../../../src/commands/module' +import * as utils from '../../../../src/commands/module/_utils' +import * as runCommands from '../../../../src/run' + +const updateConfig = vi.fn(() => Promise.resolve()) +const removeDependency = vi.fn(() => Promise.resolve()) +const detectPackageManager = vi.fn(() => Promise.resolve({ name: 'npm' })) +const confirm = vi.fn((): Promise => Promise.resolve(false)) +const multiselect = vi.fn((): Promise => Promise.resolve([])) + +const defaultProjectPkg = { + devDependencies: { nuxt: '3.0.0' }, + dependencies: { '@nuxt/content': '^3.0.0' }, +} + +const readPackageJSON = vi.fn(() => Promise.resolve(defaultProjectPkg)) + +interface CommandsType { + subCommands: { + remove: () => Promise<{ setup: (args: any) => Promise }> + } +} + +vi.mock('c12/update', () => ({ updateConfig })) +vi.mock('nypm', () => ({ removeDependency, detectPackageManager })) +vi.mock('pkg-types', () => ({ readPackageJSON })) +vi.mock('@clack/prompts', async importOriginal => ({ + ...await importOriginal(), + confirm: (...args: unknown[]) => confirm(...(args as [])), + multiselect: (...args: unknown[]) => multiselect(...(args as [])), +})) + +describe('module remove', () => { + vi.spyOn(runCommands, 'runCommand').mockImplementation(vi.fn()) + vi.spyOn(utils, 'fetchModules').mockResolvedValue([ + { + name: 'content', + npm: '@nuxt/content', + compatibility: { + nuxt: '3.0.0', + requires: {}, + versionMap: {}, + }, + description: '', + repo: '', + github: '', + website: '', + learn_more: '', + category: '', + type: 'community', + maintainers: [], + stats: { + downloads: 0, + stars: 0, + maintainers: 0, + contributors: 0, + modules: 0, + }, + }, + ]) + + beforeEach(() => { + updateConfig.mockClear() + removeDependency.mockClear() + confirm.mockReset().mockResolvedValue(false) + multiselect.mockReset().mockResolvedValue([]) + readPackageJSON.mockReset().mockImplementation(() => Promise.resolve(defaultProjectPkg)) + }) + + it('should remove a Nuxt module by alias', async () => { + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['content'], + }, + }) + + expect(removeDependency).toHaveBeenCalledWith(['@nuxt/content'], { + cwd: '/fake-dir', + packageManager: { name: 'npm' }, + workspace: false, + }) + }) + + it('should remove a Nuxt module by npm name', async () => { + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['@nuxt/content'], + }, + }) + + expect(removeDependency).toHaveBeenCalledWith(['@nuxt/content'], { + cwd: '/fake-dir', + packageManager: { name: 'npm' }, + workspace: false, + }) + }) + + it('should remove modules selected from the picker when none are given', async () => { + updateConfig.mockImplementationOnce((async (config: any) => { + await config.onUpdate({ modules: ['@nuxt/content'] }) + }) as typeof updateConfig) + multiselect.mockResolvedValueOnce(['@nuxt/content']) + + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + _: [], + }, + }) + + expect(multiselect).toHaveBeenCalled() + expect(removeDependency).toHaveBeenCalledWith(['@nuxt/content'], expect.objectContaining({ cwd: '/fake-dir' })) + }) + + it('should skip uninstall when --skipInstall is set', async () => { + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + skipInstall: true, + _: ['@nuxt/content'], + }, + }) + + expect(removeDependency).not.toHaveBeenCalled() + }) + + it('should skip config update when --skipConfig is set', async () => { + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + skipConfig: true, + _: ['@nuxt/content'], + }, + }) + + expect(updateConfig).not.toHaveBeenCalled() + }) + + it('should not uninstall a module that is not in dependencies', async () => { + readPackageJSON.mockImplementation((() => Promise.resolve({ + devDependencies: { nuxt: '3.0.0' }, + dependencies: {}, + })) as typeof readPackageJSON) + + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['@nuxt/content'], + }, + }) + + expect(removeDependency).not.toHaveBeenCalled() + }) + + it('should remove orphaned peer dependencies when confirmed', async () => { + confirm.mockResolvedValueOnce(true) + readPackageJSON.mockImplementation(((id?: string) => { + if (id === '@vee-validate/nuxt') { + return Promise.resolve({ peerDependencies: { 'vee-validate': '^4.0.0' } }) + } + if (id === 'vee-validate' || id === 'nuxt') { + return Promise.resolve({}) + } + return Promise.resolve({ + devDependencies: { nuxt: '3.0.0' }, + dependencies: { + '@vee-validate/nuxt': '1.0.0', + 'vee-validate': '4.0.0', + }, + }) + }) as typeof readPackageJSON) + + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['@vee-validate/nuxt'], + }, + }) + + expect(confirm).toHaveBeenCalled() + expect(removeDependency).toHaveBeenCalledWith( + ['@vee-validate/nuxt', 'vee-validate'], + expect.objectContaining({ cwd: '/fake-dir' }), + ) + }) + + it('should keep orphaned peer dependencies when declined', async () => { + confirm.mockResolvedValueOnce(false) + readPackageJSON.mockImplementation(((id?: string) => { + if (id === '@vee-validate/nuxt') { + return Promise.resolve({ peerDependencies: { 'vee-validate': '^4.0.0' } }) + } + if (id === 'vee-validate' || id === 'nuxt') { + return Promise.resolve({}) + } + return Promise.resolve({ + devDependencies: { nuxt: '3.0.0' }, + dependencies: { + '@vee-validate/nuxt': '1.0.0', + 'vee-validate': '4.0.0', + }, + }) + }) as typeof readPackageJSON) + + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['@vee-validate/nuxt'], + }, + }) + + expect(confirm).toHaveBeenCalled() + expect(removeDependency).toHaveBeenCalledWith( + ['@vee-validate/nuxt'], + expect.objectContaining({ cwd: '/fake-dir' }), + ) + }) + + it('should not treat a peer still required by another dependency as orphaned', async () => { + readPackageJSON.mockImplementation(((id?: string) => { + if (id === '@vee-validate/nuxt') { + return Promise.resolve({ peerDependencies: { 'vee-validate': '^4.0.0' } }) + } + if (id === 'some-other-dep') { + return Promise.resolve({ dependencies: { 'vee-validate': '^4.0.0' } }) + } + if (id === 'vee-validate' || id === 'nuxt') { + return Promise.resolve({}) + } + return Promise.resolve({ + devDependencies: { nuxt: '3.0.0' }, + dependencies: { + '@vee-validate/nuxt': '1.0.0', + 'some-other-dep': '1.0.0', + 'vee-validate': '4.0.0', + }, + }) + }) as typeof readPackageJSON) + + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + _: ['@vee-validate/nuxt'], + }, + }) + + expect(confirm).not.toHaveBeenCalled() + expect(removeDependency).toHaveBeenCalledWith( + ['@vee-validate/nuxt'], + expect.objectContaining({ cwd: '/fake-dir' }), + ) + }) +})