Skip to content
Merged
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 knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"defu",
"exsolve",
"fuse.js",
"fzf",
"giget",
"h3-next",
"jiti",
Expand Down
1 change: 1 addition & 0 deletions packages/nuxi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"defu": "^6.1.4",
"exsolve": "^1.0.8",
"fuse.js": "^7.1.0",
"fzf": "^0.5.2",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if fzf is imported in the mentioned file
cat packages/nuxi/src/commands/module/_autocomplete.ts | head -50

Repository: nuxt/cli

Length of output: 1582


🏁 Script executed:

# Check the build configuration and package.json structure
cat packages/nuxi/package.json | head -80

Repository: nuxt/cli

Length of output: 2002


🏁 Script executed:

# Search for fzf usage in the codebase
rg "fzf" packages/nuxi/src --type ts --type js

Repository: nuxt/cli

Length of output: 515


🏁 Script executed:

# Check for build configuration files
fd -e "tsconfig\|vite\|webpack\|rollup\|build" packages/nuxi/ --type f

Repository: nuxt/cli

Length of output: 413


🏁 Script executed:

# Search for tsdown configuration files
fd -e config -name "*tsdown*" packages/nuxi/
fd . packages/nuxi --maxdepth 2 --type f \( -name "tsdown*" -o -name "build*" -o -name ".tsdownrc*" \)

Repository: nuxt/cli

Length of output: 513


🏁 Script executed:

# Check if there's a dist folder and what it contains
ls -la packages/nuxi/dist 2>/dev/null | head -20 || echo "dist not found or not built"

Repository: nuxt/cli

Length of output: 34


🏁 Script executed:

# Search for external/externalize config in package.json or tsconfig
grep -i "external\|bundle" packages/nuxi/package.json

Repository: nuxt/cli

Length of output: 34


🏁 Script executed:

# Check tsdown documentation/behavior in context of how packages are typically bundled
rg "tsdown|bundle" packages/nuxi --type json --type ts -A 2 | head -30

Repository: nuxt/cli

Length of output: 1693


🏁 Script executed:

# Read the tsdown configuration
cat -n packages/nuxi/tsdown.config.ts

Repository: nuxt/cli

Length of output: 892


🏁 Script executed:

# Also check if there's any external configuration in the config
cat packages/nuxi/tsdown.config.ts | grep -i "external\|dependencies"

Repository: nuxt/cli

Length of output: 34


Move fzf from devDependencies to dependencies.

fzf is imported and used at runtime in the CLI (packages/nuxi/src/commands/module/_autocomplete.ts). The tsdown build configuration does not bundle dependencies by default, meaning consumers who install the package will not receive fzf since it's currently only a devDependency. This will cause the CLI to fail at runtime with a "module not found" error when the autocomplete functionality is invoked.

Proposed fix
{
  "name": "nuxi",
  "type": "module",
  "version": "3.32.0",
  "description": "Nuxt CLI",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/nuxt/cli.git",
    "directory": "packages/nuxi"
  },
  "exports": {
    ".": "./dist/index.mjs",
    "./cli": "./bin/nuxi.mjs"
  },
  "types": "./dist/index.d.ts",
  "bin": {
    "nuxi": "bin/nuxi.mjs",
    "nuxi-ng": "bin/nuxi.mjs",
    "nuxt": "bin/nuxi.mjs",
    "nuxt-cli": "bin/nuxi.mjs"
  },
  "files": [
    "bin",
    "dist"
  ],
  "engines": {
    "node": "^16.10.0 || >=18.0.0"
  },
  "scripts": {
    "build": "tsdown",
    "prepack": "pnpm build",
    "test:dist": "node ./bin/nuxi.mjs info ../../playground"
  },
+  "dependencies": {
+    "fzf": "^0.5.2"
+  },
  "devDependencies": {
    "@bomb.sh/tab": "^0.0.12",
    "@clack/prompts": "1.0.0",
    "@nuxt/kit": "^4.3.0",
    "@nuxt/schema": "^4.3.0",
    "@nuxt/test-utils": "^3.23.0",
    "@types/copy-paste": "^2.1.0",
    "@types/debug": "^4.1.12",
    "@types/node": "^24.10.10",
    "@types/semver": "^7.7.1",
    "c12": "^3.3.3",
    "citty": "^0.2.0",
    "confbox": "^0.2.2",
    "consola": "^3.4.2",
    "copy-paste": "^2.2.0",
    "debug": "^4.4.3",
    "defu": "^6.1.4",
    "exsolve": "^1.0.8",
    "fuse.js": "^7.1.0",
-    "fzf": "^0.5.2",
    "giget": "^3.1.1",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"fzf": "^0.5.2",
"scripts": {
"build": "tsdown",
"prepack": "pnpm build",
"test:dist": "node ./bin/nuxi.mjs info ../../playground"
},
"dependencies": {
"fzf": "^0.5.2"
},
"devDependencies": {
"@bomb.sh/tab": "^0.0.12",
"@clack/prompts": "1.0.0",
"@nuxt/kit": "^4.3.0",
"@nuxt/schema": "^4.3.0",
"@nuxt/test-utils": "^3.23.0",
"@types/copy-paste": "^2.1.0",
"@types/debug": "^4.1.12",
"@types/node": "^24.10.10",
"@types/semver": "^7.7.1",
"c12": "^3.3.3",
"citty": "^0.2.0",
"confbox": "^0.2.2",
"consola": "^3.4.2",
"copy-paste": "^2.2.0",
"debug": "^4.4.3",
"defu": "^6.1.4",
"exsolve": "^1.0.8",
"fuse.js": "^7.1.0",
"giget": "^3.1.1",
🤖 Prompt for AI Agents
In `@packages/nuxi/package.json` at line 54, The package.json currently lists
"fzf" under devDependencies but it is imported/used at runtime in
packages/nuxi/src/commands/module/_autocomplete.ts, so move the "fzf" entry from
devDependencies to dependencies in packages/nuxi/package.json (i.e., remove it
from devDependencies and add the same version under dependencies) and then
reinstall/build so the runtime bundle includes it.

"giget": "^3.1.1",
"h3": "^1.15.5",
"h3-next": "npm:h3@^2.0.1-rc.11",
Expand Down
33 changes: 11 additions & 22 deletions packages/nuxi/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { TemplateData } from '../utils/starter-templates'
import { existsSync } from 'node:fs'
import process from 'node:process'

import { box, cancel, confirm, intro, isCancel, multiselect, outro, select, spinner, tasks, text } from '@clack/prompts'
import { box, cancel, confirm, intro, isCancel, outro, select, spinner, tasks, text } from '@clack/prompts'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
import { downloadTemplate, startShell } from 'giget'
Expand All @@ -23,6 +23,7 @@ import { relativeToProcess } from '../utils/paths'
import { getTemplates } from '../utils/starter-templates'
import { getNuxtVersion } from '../utils/versions'
import { cwdArgs, logLevelArgs } from './_shared'
import { selectModulesAutocomplete } from './module/_autocomplete'
import { checkNuxtCompatibility, fetchModules } from './module/_utils'
import addModuleCommand from './module/add'

Expand Down Expand Up @@ -426,11 +427,11 @@ export default defineCommand({
}
}

// ...or offer to install official modules (if not offline)
// ...or offer to browse and install modules (if not offline)
else if (!ctx.args.offline && !ctx.args.preferOffline) {
const modulesPromise = fetchModules()
const wantsUserModules = await confirm({
message: `Would you like to install any of the official modules?`,
message: `Would you like to browse and install modules?`,
initialValue: false,
})

Expand All @@ -451,33 +452,21 @@ export default defineCommand({

modulesSpinner.stop('Modules loaded')

const officialModules = response
const allModules = response
.filter(module =>
module.type === 'official'
&& module.npm !== '@nuxt/devtools'
module.npm !== '@nuxt/devtools'
&& !templateDeps.includes(module.npm)
&& (!module.compatibility.nuxt || checkNuxtCompatibility(module, nuxtVersion)),
)

if (officialModules.length === 0) {
logger.info('All official modules are already included in this template.')
if (allModules.length === 0) {
logger.info('All modules are already included in this template.')
}
else {
const selectedOfficialModules = await multiselect({
message: 'Pick the modules to install:',
options: officialModules.map(module => ({
label: `${colors.bold(colors.greenBright(module.npm))} – ${module.description.replace(/\.$/, '')}`,
value: module.npm,
})),
required: false,
})

if (isCancel(selectedOfficialModules)) {
process.exit(1)
}
const result = await selectModulesAutocomplete({ modules: allModules })

if (selectedOfficialModules.length > 0) {
const modules = selectedOfficialModules as unknown as string[]
if (result.selected.length > 0) {
const modules = result.selected

const allDependencies = Object.fromEntries(
await Promise.all(modules.map(async module =>
Expand Down
75 changes: 75 additions & 0 deletions packages/nuxi/src/commands/module/_autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Option } from '@clack/prompts'
import type { NuxtModule } from './_utils'

import { autocompleteMultiselect, isCancel } from '@clack/prompts'
import { byLengthAsc, Fzf } from 'fzf'
import { hasTTY } from 'std-env'

import { logger } from '../../utils/logger'

export interface AutocompleteOptions {
modules: NuxtModule[]
message?: string
}

export interface AutocompleteResult {
selected: string[]
cancelled: boolean
}

/**
* Interactive fuzzy search for selecting Nuxt modules
* Returns object with selected module npm package names and cancellation status
*/
export async function selectModulesAutocomplete(options: AutocompleteOptions): Promise<AutocompleteResult> {
const { modules, message = 'Search and select modules:' } = options

if (!hasTTY) {
logger.warn('Interactive module selection requires a TTY. Skipping.')
return { selected: [], cancelled: false }
}

// Sort: official modules first, then alphabetically
const sortedModules = [...modules].sort((a, b) => {
if (a.type === 'official' && b.type !== 'official')
return -1
if (a.type !== 'official' && b.type === 'official')
return 1
return a.npm.localeCompare(b.npm)
})

// Setup fzf for fast fuzzy search
const fzf = new Fzf(sortedModules, {
selector: m => `${m.npm} ${m.name} ${m.category}`,
casing: 'case-insensitive',
tiebreakers: [byLengthAsc],
})

// Build options for clack multiselect
const clackOptions: Option<string>[] = sortedModules.map(m => ({
value: m.npm,
label: m.npm,
hint: m.description.replace(/\.$/, ''),
}))

// Custom filter function using fzf for fuzzy matching
const filter = (search: string, option: Option<string>): boolean => {
if (!search)
return true
const results = fzf.find(search)
return results.some(r => r.item.npm === option.value)
}

const result = await autocompleteMultiselect({
message,
options: clackOptions,
filter,
required: false,
})

if (isCancel(result)) {
return { selected: [], cancelled: true }
}

return { selected: result, cancelled: false }
}
34 changes: 32 additions & 2 deletions packages/nuxi/src/commands/module/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { homedir } from 'node:os'
import { join } from 'node:path'

import process from 'node:process'
import { cancel, confirm, isCancel, select } from '@clack/prompts'
import { cancel, confirm, isCancel, select, spinner } from '@clack/prompts'
import { updateConfig } from 'c12/update'
import { defineCommand } from 'citty'
import { colors } from 'consola/utils'
Expand All @@ -25,6 +25,7 @@ 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'

interface RegistryMeta {
Expand Down Expand Up @@ -68,7 +69,7 @@ export default defineCommand({
},
async setup(ctx) {
const cwd = resolve(ctx.args.cwd)
const modules = ctx.args._.map(e => e.trim()).filter(Boolean)
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) {
Expand All @@ -84,6 +85,35 @@ export default defineCommand({
}
}

// If no modules specified, show interactive search
if (modules.length === 0) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')

const [allModules, nuxtVersion] = await Promise.all([
fetchModules(),
getNuxtVersion(cwd),
])

const compatibleModules = allModules.filter(m =>
!m.compatibility.nuxt || checkNuxtCompatibility(m, nuxtVersion),
)

modulesSpinner.stop('Modules loaded')

const result = await selectModulesAutocomplete({
modules: compatibleModules,
message: 'Search modules to add (Esc to finish):',
})

if (result.selected.length === 0) {
cancel('No modules selected.')
process.exit(0)
}

modules = result.selected
}
Comment on lines +88 to +115
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle module fetch failures and explicit cancellation in the interactive flow.

A failed fetch will currently throw and leave the spinner running; also explicit cancellation isn’t handled separately from empty selection. Consider adding a guarded fetch and a result.cancelled branch.

🐛 Suggested hardening
     if (modules.length === 0) {
       const modulesSpinner = spinner()
       modulesSpinner.start('Fetching available modules')

-      const [allModules, nuxtVersion] = await Promise.all([
-        fetchModules(),
-        getNuxtVersion(cwd),
-      ])
+      let allModules: NuxtModule[]
+      let nuxtVersion: string
+      try {
+        [allModules, nuxtVersion] = await Promise.all([
+          fetchModules(),
+          getNuxtVersion(cwd),
+        ])
+      }
+      catch (err) {
+        modulesSpinner.stop('Failed to load modules')
+        logger.error(err instanceof Error ? err.message : String(err))
+        process.exit(1)
+      }

       const compatibleModules = allModules.filter(m =>
         !m.compatibility.nuxt || checkNuxtCompatibility(m, nuxtVersion),
       )

       modulesSpinner.stop('Modules loaded')

       const result = await selectModulesAutocomplete({
         modules: compatibleModules,
         message: 'Search modules to add (Esc to finish):',
       })
+      if (result.cancelled) {
+        cancel('Operation cancelled.')
+        process.exit(1)
+      }

       if (result.selected.length === 0) {
         cancel('No modules selected.')
         process.exit(0)
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If no modules specified, show interactive search
if (modules.length === 0) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')
const [allModules, nuxtVersion] = await Promise.all([
fetchModules(),
getNuxtVersion(cwd),
])
const compatibleModules = allModules.filter(m =>
!m.compatibility.nuxt || checkNuxtCompatibility(m, nuxtVersion),
)
modulesSpinner.stop('Modules loaded')
const result = await selectModulesAutocomplete({
modules: compatibleModules,
message: 'Search modules to add (Esc to finish):',
})
if (result.selected.length === 0) {
cancel('No modules selected.')
process.exit(0)
}
modules = result.selected
}
// If no modules specified, show interactive search
if (modules.length === 0) {
const modulesSpinner = spinner()
modulesSpinner.start('Fetching available modules')
let allModules: NuxtModule[]
let nuxtVersion: string
try {
[allModules, nuxtVersion] = await Promise.all([
fetchModules(),
getNuxtVersion(cwd),
])
}
catch (err) {
modulesSpinner.stop('Failed to load modules')
logger.error(err instanceof Error ? err.message : String(err))
process.exit(1)
}
const compatibleModules = allModules.filter(m =>
!m.compatibility.nuxt || checkNuxtCompatibility(m, nuxtVersion),
)
modulesSpinner.stop('Modules loaded')
const result = await selectModulesAutocomplete({
modules: compatibleModules,
message: 'Search modules to add (Esc to finish):',
})
if (result.cancelled) {
cancel('Operation cancelled.')
process.exit(1)
}
if (result.selected.length === 0) {
cancel('No modules selected.')
process.exit(0)
}
modules = result.selected
}
🤖 Prompt for AI Agents
In `@packages/nuxi/src/commands/module/add.ts` around lines 88 - 115, The
interactive branch can leave the spinner running on fetch failures and doesn't
handle explicit cancellation from selectModulesAutocomplete; wrap the
Promise.all call that uses fetchModules() and getNuxtVersion(cwd) in a try/catch
(or use .catch) and ensure spinner().stop() is called in a finally block so the
spinner always stops on error, logging or calling cancel() with the caught error
and exiting; after calling selectModulesAutocomplete({modules:
compatibleModules,...}) check result.cancelled first and call cancel('Selection
cancelled.') and exit, then handle result.selected.length === 0 as the
empty-selection case, and keep references to spinner, fetchModules,
getNuxtVersion, selectModulesAutocomplete, cancel and process.exit when
implementing these guards.


const resolvedModules: ResolvedModule[] = []
for (const moduleName of modules) {
const resolvedModule = await resolveModule(moduleName, cwd)
Expand Down
Loading
Loading