From e7a7bf421167bd289bf84fd6d368e5c311dce25d Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 7 May 2026 14:11:06 +0200 Subject: [PATCH 1/5] feat: add remove command --- packages/nuxi/src/commands/module/index.ts | 1 + packages/nuxi/src/commands/module/remove.ts | 336 ++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 packages/nuxi/src/commands/module/remove.ts diff --git a/packages/nuxi/src/commands/module/index.ts b/packages/nuxi/src/commands/module/index.ts index a7da58d23..01407b028 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 000000000..d596f2bdf --- /dev/null +++ b/packages/nuxi/src/commands/module/remove.ts @@ -0,0 +1,336 @@ +import type { PackageJson } from 'pkg-types' + +import { existsSync } from 'node:fs' +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 { fetchModules } from './_utils' + +// Mirrors `packageRegex` in add.ts (non-capturing - we only validate, never extract). +const packageRegex + = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(?:@[^@]+)?$/ + +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, + }, + skipUninstall: { + 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 inputs = 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 (ctx.args.skipConfig && inputs.length === 0) { + cancel(`Specify one or more modules to remove when ${colors.cyan('--skipConfig')} is set.`) + process.exit(1) + } + + // Resolve positional inputs to canonical npm package names. With no inputs, the + // multiselect picker runs inside `removeModules` against the configured modules. + const resolvedModules: string[] = [] + for (const moduleName of inputs) { + const resolved = await resolveModule(moduleName, projectPkg) + if (resolved) { + resolvedModules.push(resolved) + } + } + + if (inputs.length > 0 && resolvedModules.length === 0) { + cancel('No modules to remove.') + process.exit(1) + } + + if (resolvedModules.length > 0) { + logger.info(`Resolved ${resolvedModules.map(x => colors.cyan(x)).join(', ')}, removing module${resolvedModules.length > 1 ? 's' : ''}...`) + } + + await removeModules(resolvedModules, { ...ctx.args, cwd }, projectPkg) + + // Run prepare command if uninstall is not skipped + if (!ctx.args.skipUninstall) { + const args = Object.entries(ctx.args).filter(([k]) => k in cwdArgs || k in logLevelArgs).map(([k, v]) => `--${k}=${v}`) + + await runCommand(prepareCommand, args) + } + }, +}) + +// -- Internal Utils -- +async function removeModules(modules: string[], { skipUninstall = false, skipConfig = false, cwd }: { skipUninstall?: boolean, skipConfig?: boolean, cwd: string }, projectPkg: PackageJson) { + const removedFromConfig: string[] = [] + + // Update nuxt.config.ts (with picker if no modules were given upfront) + if (!skipConfig) { + let configMissing = false + let cancelled = false + + await updateConfig({ + cwd, + configFile: 'nuxt.config', + onCreate() { + configMissing = true + return false + }, + async onUpdate(config) { + const present: string[] = [] + if (Array.isArray(config.modules)) { + 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.') + process.exit(0) + } + + 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')}.`) + process.exit(0) + } + } + + // Remove dependencies + if (!skipUninstall) { + const installedModules: string[] = [] + const notInstalledModules: string[] = [] + + const dependencies = new Set([ + ...Object.keys(projectPkg.dependencies || {}), + ...Object.keys(projectPkg.devDependencies || {}), + ]) + + 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 + } + + 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' + logger.info(`Also removing orphaned peer ${peerDep}: ${peersList}`) + } + + const allToRemove = [...installedModules, ...orphanedPeers.map(o => o.peer)] + const removeList = allToRemove.map(m => colors.cyan(m)).join(', ') + const dependency = allToRemove.length > 1 ? 'dependencies' : 'dependency' + logger.info(`Uninstalling ${removeList} ${dependency}`) + + const packageManager = await detectPackageManager(cwd) + + await removeDependency(allToRemove, { + cwd, + packageManager, + workspace: packageManager?.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')), + }).catch((error) => { + logger.error(String(error)) + }) + } +} + +async function resolveModule(moduleName: string, projectPkg: PackageJson): Promise { + if (!packageRegex.test(moduleName)) { + logger.error(`Invalid package name ${colors.cyan(moduleName)}.`) + return false + } + + const installedNames = new Set([ + ...Object.keys(projectPkg.dependencies || {}), + ...Object.keys(projectPkg.devDependencies || {}), + ]) + + // Already a known package in this project - skip the network round-trip + if (installedNames.has(moduleName)) { + return moduleName + } + + // Map slug/alias → npm package name via the modules database + const modulesDB = await fetchModules().catch((err) => { + logger.warn(`Cannot search in the Nuxt Modules database: ${err}`) + return [] + }) + + const matched = modulesDB.find(m => + m.name === moduleName + || m.npm === moduleName + || m.aliases?.includes(moduleName), + ) + + return matched?.npm || moduleName +} + +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 +} + +async function findOrphanedPeers(removing: string[], projectPkg: PackageJson, cwd: string): Promise { + const projectDeps = new Set([ + ...Object.keys(projectPkg.dependencies || {}), + ...Object.keys(projectPkg.devDependencies || {}), + ]) + 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 +} From 1294c1bf9eb4bdaf8da2e4ed9bb2f6892fda2701 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 7 May 2026 14:28:30 +0200 Subject: [PATCH 2/5] chore: tests --- .../test/unit/commands/module/remove.spec.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/nuxi/test/unit/commands/module/remove.spec.ts 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 000000000..2dd74d0ba --- /dev/null +++ b/packages/nuxi/test/unit/commands/module/remove.spec.ts @@ -0,0 +1,117 @@ +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' })) + +interface CommandsType { + subCommands: { + remove: () => Promise<{ setup: (args: any) => Promise }> + } +} + +vi.mock('c12/update', () => ({ updateConfig })) +vi.mock('nypm', () => ({ removeDependency, detectPackageManager })) +vi.mock('pkg-types', () => ({ + readPackageJSON: () => Promise.resolve({ + devDependencies: { nuxt: '3.0.0' }, + dependencies: { '@nuxt/content': '^3.0.0' }, + }), +})) + +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() + }) + + 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 skip uninstall when --skipUninstall is set', async () => { + const removeCommand = await (commands as CommandsType).subCommands.remove() + await removeCommand.setup({ + args: { + cwd: '/fake-dir', + skipUninstall: 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() + }) +}) From e1f383d79c1a508631c66eca8e7d5e50fba31dd2 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 7 May 2026 14:32:46 +0200 Subject: [PATCH 3/5] chore: refactor --- packages/nuxi/src/commands/module/remove.ts | 93 +++++++++------------ 1 file changed, 41 insertions(+), 52 deletions(-) diff --git a/packages/nuxi/src/commands/module/remove.ts b/packages/nuxi/src/commands/module/remove.ts index d596f2bdf..335462e3d 100644 --- a/packages/nuxi/src/commands/module/remove.ts +++ b/packages/nuxi/src/commands/module/remove.ts @@ -1,5 +1,7 @@ import type { PackageJson } from 'pkg-types' +import type { NuxtModule } from './_utils' + import { existsSync } from 'node:fs' import process from 'node:process' @@ -18,10 +20,6 @@ import { cwdArgs, logLevelArgs } from '../_shared' import prepareCommand from '../prepare' import { fetchModules } from './_utils' -// Mirrors `packageRegex` in add.ts (non-capturing - we only validate, never extract). -const packageRegex - = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(?:@[^@]+)?$/ - interface OrphanedPeer { peer: string source: string @@ -51,7 +49,7 @@ export default defineCommand({ }, async setup(ctx) { const cwd = resolve(ctx.args.cwd) - const inputs = ctx.args._.map(e => e.trim()).filter(Boolean) + const modules = ctx.args._.map(e => e.trim()).filter(Boolean) const projectPkg = await readPackageJSON(cwd).catch(() => ({} as PackageJson)) if (!projectPkg.dependencies?.nuxt && !projectPkg.devDependencies?.nuxt) { @@ -67,31 +65,37 @@ export default defineCommand({ } } - if (ctx.args.skipConfig && inputs.length === 0) { + if (ctx.args.skipConfig && modules.length === 0) { cancel(`Specify one or more modules to remove when ${colors.cyan('--skipConfig')} is set.`) process.exit(1) } // Resolve positional inputs to canonical npm package names. With no inputs, the // multiselect picker runs inside `removeModules` against the configured modules. - const resolvedModules: string[] = [] - for (const moduleName of inputs) { - const resolved = await resolveModule(moduleName, projectPkg) - if (resolved) { - resolvedModules.push(resolved) - } - } + const installedNames = new Set([ + ...Object.keys(projectPkg.dependencies || {}), + ...Object.keys(projectPkg.devDependencies || {}), + ]) - if (inputs.length > 0 && resolvedModules.length === 0) { - cancel('No modules to remove.') - process.exit(1) - } + 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 => resolveModule(m, modulesDB, installedNames)) if (resolvedModules.length > 0) { logger.info(`Resolved ${resolvedModules.map(x => colors.cyan(x)).join(', ')}, removing module${resolvedModules.length > 1 ? 's' : ''}...`) } - await removeModules(resolvedModules, { ...ctx.args, cwd }, projectPkg) + 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.skipUninstall) { @@ -103,7 +107,7 @@ export default defineCommand({ }) // -- Internal Utils -- -async function removeModules(modules: string[], { skipUninstall = false, skipConfig = false, cwd }: { skipUninstall?: boolean, skipConfig?: boolean, cwd: string }, projectPkg: PackageJson) { +async function removeModules(modules: string[], { skipUninstall = false, skipConfig = false, cwd }: { skipUninstall?: boolean, skipConfig?: boolean, cwd: string }, projectPkg: PackageJson): Promise { const removedFromConfig: string[] = [] // Update nuxt.config.ts (with picker if no modules were given upfront) @@ -171,14 +175,14 @@ async function removeModules(modules: string[], { skipUninstall = false, skipCon if (cancelled) { cancel('No modules selected.') - process.exit(0) + 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')}.`) - process.exit(0) + return false } } @@ -210,7 +214,7 @@ async function removeModules(modules: string[], { skipUninstall = false, skipCon } if (installedModules.length === 0) { - return + return true } const orphanedPeers = await findOrphanedPeers(installedModules, projectPkg, cwd) @@ -236,37 +240,8 @@ async function removeModules(modules: string[], { skipUninstall = false, skipCon logger.error(String(error)) }) } -} - -async function resolveModule(moduleName: string, projectPkg: PackageJson): Promise { - if (!packageRegex.test(moduleName)) { - logger.error(`Invalid package name ${colors.cyan(moduleName)}.`) - return false - } - const installedNames = new Set([ - ...Object.keys(projectPkg.dependencies || {}), - ...Object.keys(projectPkg.devDependencies || {}), - ]) - - // Already a known package in this project - skip the network round-trip - if (installedNames.has(moduleName)) { - return moduleName - } - - // Map slug/alias → npm package name via the modules database - const modulesDB = await fetchModules().catch((err) => { - logger.warn(`Cannot search in the Nuxt Modules database: ${err}`) - return [] - }) - - const matched = modulesDB.find(m => - m.name === moduleName - || m.npm === moduleName - || m.aliases?.includes(moduleName), - ) - - return matched?.npm || moduleName + return true } function readModuleName(item: unknown): string | null { @@ -279,6 +254,20 @@ function readModuleName(item: unknown): string | null { return null } +function resolveModule(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 = new Set([ ...Object.keys(projectPkg.dependencies || {}), From 1e4f0334c02c2e5f0dfab041fc1f147917738a2b Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 7 May 2026 14:58:55 +0200 Subject: [PATCH 4/5] fix: coderabbit --- packages/nuxi/src/commands/module/remove.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/nuxi/src/commands/module/remove.ts b/packages/nuxi/src/commands/module/remove.ts index 335462e3d..bf582ba9f 100644 --- a/packages/nuxi/src/commands/module/remove.ts +++ b/packages/nuxi/src/commands/module/remove.ts @@ -123,13 +123,15 @@ async function removeModules(modules: string[], { skipUninstall = false, skipCon return false }, async onUpdate(config) { + if (!Array.isArray(config.modules)) { + return + } + const present: string[] = [] - if (Array.isArray(config.modules)) { - for (const item of config.modules) { - const name = readModuleName(item) - if (name) { - present.push(name) - } + for (const item of config.modules) { + const name = readModuleName(item) + if (name) { + present.push(name) } } @@ -238,6 +240,7 @@ async function removeModules(modules: string[], { skipUninstall = false, skipCon workspace: packageManager?.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')), }).catch((error) => { logger.error(String(error)) + process.exit(1) }) } From 592109e7803386953003d6cdcef71bd7befc7d14 Mon Sep 17 00:00:00 2001 From: Florian Heuberger Date: Thu, 7 May 2026 15:03:18 +0200 Subject: [PATCH 5/5] chore: add more tests --- .../test/unit/commands/module/remove.spec.ts | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/nuxi/test/unit/commands/module/remove.spec.ts b/packages/nuxi/test/unit/commands/module/remove.spec.ts index 2dd74d0ba..99d36322e 100644 --- a/packages/nuxi/test/unit/commands/module/remove.spec.ts +++ b/packages/nuxi/test/unit/commands/module/remove.spec.ts @@ -8,6 +8,13 @@ const updateConfig = vi.fn(() => Promise.resolve()) const removeDependency = vi.fn(() => Promise.resolve()) const detectPackageManager = vi.fn(() => Promise.resolve({ name: 'npm' })) +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 }> @@ -16,12 +23,7 @@ interface CommandsType { vi.mock('c12/update', () => ({ updateConfig })) vi.mock('nypm', () => ({ removeDependency, detectPackageManager })) -vi.mock('pkg-types', () => ({ - readPackageJSON: () => Promise.resolve({ - devDependencies: { nuxt: '3.0.0' }, - dependencies: { '@nuxt/content': '^3.0.0' }, - }), -})) +vi.mock('pkg-types', () => ({ readPackageJSON })) describe('module remove', () => { vi.spyOn(runCommands, 'runCommand').mockImplementation(vi.fn()) @@ -55,6 +57,7 @@ describe('module remove', () => { beforeEach(() => { updateConfig.mockClear() removeDependency.mockClear() + readPackageJSON.mockReset().mockImplementation(() => Promise.resolve(defaultProjectPkg)) }) it('should remove a Nuxt module by alias', async () => { @@ -114,4 +117,52 @@ describe('module remove', () => { 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 also remove orphaned peer dependencies', async () => { + 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(removeDependency).toHaveBeenCalledWith( + ['@vee-validate/nuxt', 'vee-validate'], + expect.objectContaining({ cwd: '/fake-dir' }), + ) + }) })