Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nuxi/src/commands/module/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
})
328 changes: 328 additions & 0 deletions packages/nuxi/src/commands/module/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import type { PackageJson } from 'pkg-types'

import type { NuxtModule } from './_utils'

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'

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 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 (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 installedNames = new Set([
...Object.keys(projectPkg.dependencies || {}),
...Object.keys(projectPkg.devDependencies || {}),
])

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' : ''}...`)
}

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) {
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): Promise<boolean> {
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) {
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<string>
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)
}
}
Comment on lines +155 to +168
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolved npm name in toRemove won't match alias-form config entries — leaves the project in a broken state

modules passed to removeModules are already resolved npm package names (e.g., @nuxt/content), so toRemove at line 156 contains only npm names. readModuleName reads config entries as literal strings. If a user's nuxt.config.ts contains the short alias ('content' instead of '@nuxt/content'), the comparison fails silently: the package gets uninstalled but the config entry is left intact, referencing a package that no longer exists.

The fix is to resolve config entry names through the same resolveModule logic before comparing, or to pass both original inputs and their resolved forms through to toRemove. One clean approach: pass modulesDB and installedNames into removeModules and resolve the config entry name inline:

- if (name && toRemove.has(name)) {
+ if (name && (toRemove.has(name) || toRemove.has(resolveModule(name, modulesDB, installed)))) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxi/src/commands/module/remove.ts` around lines 153 - 166, The
removal logic compares resolved npm names in toRemove to raw config entries from
readModuleName, so alias entries like 'content' won't match and remain in
nuxt.config after uninstall; update removeModules to compare resolved names by
either (A) resolving each config entry with resolveModule (or using
modulesDB/installedNames) before checking (i.e., call resolveModule on the value
returned by readModuleName and compare that resolved name to toRemove) or (B)
populate toRemove with both the original and resolved package names so the
existing loop that uses readModuleName finds matches; update references to
toRemove, readModuleName, resolveModule, removeModules, and
modulesDB/installedNames accordingly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not valid.. Nuxt does not resolve aliases inside config.modules

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Flo0806, you're right — since Nuxt requires full npm package names (or file paths) in config.modules and does not resolve short aliases there, readModuleName will always return the canonical npm name. The comparison with toRemove is therefore correct, and my concern was a false positive. Apologies for the noise!


✏️ Learnings added
Learnt from: Flo0806
Repo: nuxt/cli PR: 1306
File: packages/nuxi/src/commands/module/remove.ts:153-166
Timestamp: 2026-05-07T12:55:55.836Z
Learning: In Nuxt's `nuxt.config.ts`, the `modules` array does not support short aliases (e.g., `'content'`). Only full npm package names (e.g., `'nuxt/content'`) or file paths are valid entries. Therefore, when reading module names from the config via `readModuleName` in `packages/nuxi/src/commands/module/remove.ts`, the values will always be canonical npm names and can be directly compared to resolved npm package names without any additional alias resolution.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

},
}).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
}
}

// 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 true
}

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))
process.exit(1)
})
}

return true
Comment on lines +237 to +247
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check nypm's removeDependency type signature for array support
# Expected: function signature should show string | string[] as first param

# Find the nypm type declaration file
fd -i 'index.d.ts' --search-path node_modules/nypm | head -5 | xargs grep -A 5 'removeDependency' 2>/dev/null || \
fd -i 'nypm' --type f --extension ts | xargs grep -A 5 'removeDependency' 2>/dev/null | head -30

Repository: nuxt/cli

Length of output: 157


🏁 Script executed:

wc -l packages/nuxi/src/commands/module/remove.ts

Repository: nuxt/cli

Length of output: 100


🏁 Script executed:

cat -n packages/nuxi/src/commands/module/remove.ts | sed -n '90,250p'

Repository: nuxt/cli

Length of output: 6359


🌐 Web query:

nypm removeDependency function signature documentation

💡 Result:

nypm removeDependency function signature (documented) The nypm package documents the API method as: removeDependency(name, options) — “Removes dependency from the project.” [1] Notes on how to use it (from the same docs page): - CLI form: npx nypm remove [1] - API usage section lists the “removeDependency(name, options)” signature under “API Usage”. [1] Sources: [1] https://www.npmjs.com/package/nypm

Citations:


removeDependency failure is silently swallowed; prepare still executes

When removeDependency rejects (line 239), the error is only logged and the function returns true (line 244). The caller then unconditionally runs prepare (lines 101-105) if !ctx.args.skipUninstall, which may fail or produce confusing results if node_modules is in an inconsistent state. Return false or re-throw after logging so the caller can skip prepare.

Additionally, verify that nypm's removeDependency accepts string[] as its first argument — the public docs only document removeDependency(name, options) with singular examples, not array support.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxi/src/commands/module/remove.ts` around lines 235 - 244, The
removeDependency rejection is currently only logged and the function returns
true, causing the caller to still run prepare; change the error handling in the
removeDependency call so that after logging you either re-throw the error or
return false to propagate failure to the caller (so caller honor prepare skip
when ctx.args.skipUninstall is false), and update the calling code that checks
the boolean result to skip calling prepare when false; also confirm
nypm.removeDependency's signature — if it does not accept string[] then iterate
over allToRemove and call removeDependency(name, options) for each entry (or use
Promise.all on per-name calls) instead of passing the array. Ensure references
to removeDependency, prepare, ctx.args.skipUninstall and allToRemove are updated
accordingly.

}

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 resolveModule(input: string, modulesDB: NuxtModule[], installed: Set<string>): 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<OrphanedPeer[]> {
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<string, string>()
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<string>()
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
}
Loading
Loading