From 904b06f083bc8054849553a3a02c123faa1efd8b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 25 Jul 2026 23:33:20 +0000 Subject: [PATCH] feat(nuxi): defer subcommands to the project's `@nuxt/cli` --- packages/nuxi/bin/nuxi.mjs | 2 +- packages/nuxi/src/cli.ts | 1 + packages/nuxi/src/index.ts | 3 +- packages/nuxi/src/launcher.ts | 192 ++++++++++++++++++ packages/nuxi/src/main.ts | 6 +- packages/nuxi/src/run.ts | 2 +- packages/nuxi/test/launcher.spec.ts | 124 +++++++++++ packages/nuxi/tsdown.config.ts | 5 +- packages/nuxt-cli/src/commands/init.ts | 2 +- packages/nuxt-cli/src/commands/module/add.ts | 2 +- .../nuxt-cli/src/commands/module/remove.ts | 2 +- packages/nuxt-cli/src/run-command.ts | 32 +++ packages/nuxt-cli/src/run.ts | 28 --- .../nuxt-cli/test/unit/commands/add.spec.ts | 2 +- .../test/unit/commands/module/add.spec.ts | 2 +- .../test/unit/commands/module/remove.spec.ts | 2 +- 16 files changed, 368 insertions(+), 39 deletions(-) create mode 100644 packages/nuxi/src/cli.ts create mode 100644 packages/nuxi/src/launcher.ts create mode 100644 packages/nuxi/test/launcher.spec.ts create mode 100644 packages/nuxt-cli/src/run-command.ts diff --git a/packages/nuxi/bin/nuxi.mjs b/packages/nuxi/bin/nuxi.mjs index 49df74bd9..cc035291a 100755 --- a/packages/nuxi/bin/nuxi.mjs +++ b/packages/nuxi/bin/nuxi.mjs @@ -43,6 +43,6 @@ if ( } // eslint-disable-next-line antfu/no-top-level-await -const { runMain } = await import('../dist/index.mjs') +const { runMain } = await import('../dist/cli.mjs') runMain() diff --git a/packages/nuxi/src/cli.ts b/packages/nuxi/src/cli.ts new file mode 100644 index 000000000..b3483f061 --- /dev/null +++ b/packages/nuxi/src/cli.ts @@ -0,0 +1 @@ +export { runMain } from './launcher' diff --git a/packages/nuxi/src/index.ts b/packages/nuxi/src/index.ts index 70f311c8f..b8f9b7ad7 100644 --- a/packages/nuxi/src/index.ts +++ b/packages/nuxi/src/index.ts @@ -1,2 +1,3 @@ -export { main, runMain } from './main' +export { runMain } from './launcher' +export { main } from './main' export { runCommand } from './run' diff --git a/packages/nuxi/src/launcher.ts b/packages/nuxi/src/launcher.ts new file mode 100644 index 000000000..48eac6d29 --- /dev/null +++ b/packages/nuxi/src/launcher.ts @@ -0,0 +1,192 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { resolveModulePath } from 'exsolve' + +import { isNuxiCommand } from '../../nuxt-cli/src/commands/_utils' +import { tryResolveNuxt } from '../../nuxt-cli/src/utils/kit' +import { withNodePath } from '../../nuxt-cli/src/utils/paths' + +const FLAG_RE = /^-/ + +const launcherRoot = fileURLToPath(new URL('../', import.meta.url)) + +/** + * Oldest project CLI worth handing off to. `@nuxt/cli` v2 is the (unrelated) + * Nuxt 2 CLI, and `nuxi` below v3 predates the `runMain` entry. + */ +const MIN_PROJECT_CLI: Record = { + '@nuxt/cli': [3, 20], + 'nuxi': [3, 0], +} + +/** + * Run the requested command with the `@nuxt/cli` installed in the user's + * project, so it matches the version of Nuxt it is installed alongside. + * + * Falls back to the commands bundled with `nuxi` whenever the project CLI + * cannot run the command itself: no project CLI, one too old to hand off to, or + * one that does not know this command (a project pinned to an older release + * still gets newer commands, rather than an error). + */ +export async function runMain(): Promise { + const rawArgs = process.argv.slice(2) + + const cli = loadProjectCli(rawArgs) + const delegate = cli && await loadDelegate(cli, commandName(rawArgs)) + if (delegate) { + return delegate() + } + + const { runFallbackMain } = await import('./main') + return runFallbackMain() +} + +interface ProjectCli { + name: string + version: string | undefined + entry: string + devEntry: string | undefined +} + +export function loadProjectCli(rawArgs: string[]): ProjectCli | null { + for (const dir of candidateDirs(rawArgs)) { + // trailing separators keep `exsolve` from treating the directories as files + const from = [tryResolveNuxt(dir), ...withNodePath(dir).map(path => join(path, '/'))].filter(Boolean) as string[] + // older versions of `nuxt` depend on `nuxi` rather than on `@nuxt/cli` + for (const name of ['@nuxt/cli', 'nuxi']) { + const entry = resolveModulePath(name, { from, try: true }) + // resolving to our own build (a global or `npx` install of `nuxi`) would recurse + if (!entry || entry.startsWith(join(launcherRoot, 'dist/'))) { + continue + } + const pkg = findPackage(entry, name) + const minimum = MIN_PROJECT_CLI[name] + if (pkg?.version && minimum && !isAtLeast(pkg.version, minimum)) { + continue + } + const devEntry = pkg && join(pkg.root, 'dist/dev/index.mjs') + return { + name, + version: pkg?.version, + entry, + devEntry: devEntry && existsSync(devEntry) ? devEntry : undefined, + } + } + } + return null +} + +async function loadDelegate(cli: ProjectCli, command: string | undefined) { + const mod = await import(pathToFileURL(cli.entry).href).catch(() => null) as { runMain?: () => Promise, main?: unknown } | null + if (typeof mod?.runMain !== 'function') { + return null + } + + if (command && !supportsCommand(mod.main, command)) { + return null + } + + // the dev server forks this entry, so it must come from the CLI running the command + if (command === 'dev' && !cli.devEntry) { + return null + } + if (globalThis.__nuxt_cli__ && cli.devEntry) { + globalThis.__nuxt_cli__.devEntry = cli.devEntry + } + + return mod.runMain +} + +/** + * Whether the project CLI can handle a command itself. Only known `nuxi` + * commands are checked against its subcommands; anything else (`complete`, or a + * locally registered `nuxt-` binary) is left for it to interpret. + */ +export function supportsCommand(main: unknown, command: string): boolean { + if (!isNuxiCommand(command)) { + return true + } + const subCommands = (main as { subCommands?: unknown } | undefined)?.subCommands + if (!subCommands || typeof subCommands !== 'object') { + return true + } + return command in subCommands +} + +function commandName(rawArgs: string[]) { + return rawArgs.find(arg => !FLAG_RE.test(arg)) +} + +/** + * Directories the project CLI might be installed relative to, in priority order: + * an explicit `--cwd`, a positional root directory (`nuxi info ../my-app`), then `process.cwd()`. + */ +function candidateDirs(rawArgs: string[]) { + const dirs: string[] = [] + const add = (dir: string | undefined) => { + if (dir && !dirs.includes(dir)) { + dirs.push(dir) + } + } + + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i]! + if (arg === '--cwd') { + add(resolveDir(rawArgs[i + 1])) + } + else if (arg.startsWith('--cwd=')) { + add(resolveDir(arg.slice('--cwd='.length))) + } + } + + // `init` takes a project name rather than an existing root directory + if (rawArgs[0] !== 'init') { + for (const arg of rawArgs.slice(1)) { + if (!FLAG_RE.test(arg)) { + add(resolveDir(arg)) + } + } + } + + add(process.cwd()) + + return dirs +} + +function resolveDir(value: string | undefined) { + if (!value || FLAG_RE.test(value)) { + return + } + const dir = resolve(process.cwd(), value) + return existsSync(join(dir, 'package.json')) ? dir : undefined +} + +function isAtLeast(version: string, [minMajor, minMinor]: [number, number]) { + const [major = 0, minor = 0] = version.split('.').map(part => Number.parseInt(part, 10) || 0) + return major > minMajor || (major === minMajor && minor >= minMinor) +} + +function findPackage(entry: string, name: string) { + let dir = dirname(entry) + for (let i = 0; i < 5; i++) { + const path = join(dir, 'package.json') + if (existsSync(path)) { + try { + const pkg = JSON.parse(readFileSync(path, 'utf8')) as { name?: string, version?: string } + if (pkg.name === name) { + return { root: dir, version: pkg.version } + } + } + catch {} + } + const next = dirname(dir) + if (next === dir) { + break + } + dir = next + } + return null +} diff --git a/packages/nuxi/src/main.ts b/packages/nuxi/src/main.ts index 584d38b56..4084edfda 100644 --- a/packages/nuxi/src/main.ts +++ b/packages/nuxi/src/main.ts @@ -102,7 +102,11 @@ const _main = defineCommand({ export const main = _main as CommandDef -export async function runMain(): Promise { +/** + * Run the commands bundled with `nuxi` itself, used when the project has no + * `@nuxt/cli` or when its `@nuxt/cli` does not know the requested command. + */ +export async function runFallbackMain(): Promise { if (process.argv[2] === 'complete') { const { initCompletions } = await import('../../nuxt-cli/src/completions') await initCompletions(main) diff --git a/packages/nuxi/src/run.ts b/packages/nuxi/src/run.ts index 39209d294..c4b8c4f4b 100644 --- a/packages/nuxi/src/run.ts +++ b/packages/nuxi/src/run.ts @@ -12,4 +12,4 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { } // To provide subcommands call it as `runCommand(, [, ...])` -export { runCommandDef as runCommand } from '../../nuxt-cli/src/run' +export { runCommandDef as runCommand } from '../../nuxt-cli/src/run-command' diff --git a/packages/nuxi/test/launcher.spec.ts b/packages/nuxi/test/launcher.spec.ts new file mode 100644 index 000000000..55e2ac76b --- /dev/null +++ b/packages/nuxi/test/launcher.spec.ts @@ -0,0 +1,124 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { loadProjectCli, supportsCommand } from '../src/launcher' + +const cwd = process.cwd() +let nodePath: string | undefined + +// `vitest` points `NODE_PATH` at the pnpm store, where a hoisted `@nuxt/cli` would be resolvable +beforeEach(() => { + nodePath = process.env.NODE_PATH + delete process.env.NODE_PATH +}) + +afterEach(() => { + process.env.NODE_PATH = nodePath + process.chdir(cwd) +}) + +function createProject(name: string, deps: Record, options: { devEntry?: boolean } = {}) { + const root = join(tmpdir(), `nuxi-launcher-${name}-${Date.now()}`) + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'package.json'), JSON.stringify({ name, private: true })) + for (const [dep, version] of Object.entries(deps)) { + const depRoot = join(root, 'node_modules', dep) + mkdirSync(join(depRoot, 'dist', 'dev'), { recursive: true }) + writeFileSync(join(depRoot, 'package.json'), JSON.stringify({ + name: dep, + version, + type: 'module', + exports: { '.': './dist/index.mjs' }, + })) + writeFileSync(join(depRoot, 'dist', 'index.mjs'), 'export function runMain() {}') + if (options.devEntry !== false) { + writeFileSync(join(depRoot, 'dist', 'dev', 'index.mjs'), 'export const initialize = () => {}') + } + } + return root +} + +describe('loadProjectCli', () => { + it('should resolve `@nuxt/cli` from the current directory', () => { + const root = createProject('with-cli', { '@nuxt/cli': '3.40.0' }) + process.chdir(root) + + const cli = loadProjectCli([]) + expect(cli?.name).toBe('@nuxt/cli') + expect(cli?.version).toBe('3.40.0') + expect(cli?.devEntry).toBe(join(root, 'node_modules', '@nuxt/cli', 'dist', 'dev', 'index.mjs')) + }) + + it('should resolve `@nuxt/cli` from an explicit `--cwd`', () => { + const root = createProject('with-cwd', { '@nuxt/cli': '3.40.0' }) + + expect(loadProjectCli(['dev', '--cwd', root])?.entry).toContain(root) + expect(loadProjectCli([`--cwd=${root}`, 'dev'])?.entry).toContain(root) + }) + + it('should resolve `@nuxt/cli` from a positional root directory', () => { + const root = createProject('with-positional', { '@nuxt/cli': '3.40.0' }) + + expect(loadProjectCli(['info', root])?.entry).toContain(root) + + process.chdir(createProject('without-cli', {})) + expect(loadProjectCli(['init', root])).toBeNull() + }) + + it('should fall back to a legacy `nuxi` dependency', () => { + const root = createProject('with-legacy', { nuxi: '3.20.0' }) + + const cli = loadProjectCli(['dev', '--cwd', root]) + expect(cli?.name).toBe('nuxi') + expect(cli?.version).toBe('3.20.0') + }) + + it('should ignore a project CLI that is too old to hand off to', () => { + process.chdir(createProject('without-cli', {})) + + const nuxt2Cli = createProject('with-nuxt-2-cli', { '@nuxt/cli': '2.16.0' }) + expect(loadProjectCli(['dev', '--cwd', nuxt2Cli])).toBeNull() + + const oldNuxi = createProject('with-old-nuxi', { nuxi: '0.10.0' }) + expect(loadProjectCli(['dev', '--cwd', oldNuxi])).toBeNull() + }) + + it('should report a missing dev entry', () => { + const root = createProject('without-dev-entry', { '@nuxt/cli': '3.40.0' }, { devEntry: false }) + + expect(loadProjectCli(['dev', '--cwd', root])?.devEntry).toBeUndefined() + }) + + it('should return `null` when there is no project CLI', () => { + process.chdir(createProject('without-cli', {})) + + expect(loadProjectCli(['dev'])).toBeNull() + }) +}) + +describe('supportsCommand', () => { + const main = { subCommands: { dev: () => {}, init: () => {} } } + + it('should defer commands the project CLI knows', () => { + expect(supportsCommand(main, 'dev')).toBe(true) + }) + + it('should not defer `nuxi` commands the project CLI is missing', () => { + expect(supportsCommand(main, 'add-template')).toBe(false) + }) + + it('should defer anything that is not a `nuxi` command', () => { + expect(supportsCommand(main, 'complete')).toBe(true) + expect(supportsCommand(main, 'frobnicate')).toBe(true) + }) + + it('should defer when the command list cannot be inspected', () => { + expect(supportsCommand({ subCommands: () => ({}) }, 'dev')).toBe(true) + expect(supportsCommand({}, 'dev')).toBe(true) + expect(supportsCommand(undefined, 'dev')).toBe(true) + }) +}) diff --git a/packages/nuxi/tsdown.config.ts b/packages/nuxi/tsdown.config.ts index cfa3584ec..ed8369ec2 100644 --- a/packages/nuxi/tsdown.config.ts +++ b/packages/nuxi/tsdown.config.ts @@ -7,12 +7,15 @@ import { purgePolyfills } from 'unplugin-purge-polyfills' const isAnalysingSize = process.env.BUNDLE_SIZE === 'true' export default defineConfig({ - entry: ['src/index.ts', 'src/dev/index.ts'], + entry: ['src/index.ts', 'src/cli.ts', 'src/dev/index.ts'], shims: true, fixedExtension: true, deps: { onlyBundle: false }, dts: !isAnalysingSize && { oxc: true, + // the dev entry is only a fork target and is not exposed to consumers, and + // declarations for it inline the whole of `@nuxt/schema` + entry: ['src/index.ts'], }, // disabled due to upstream DTS warnings from @nuxt/schema type imports failOnWarn: false, diff --git a/packages/nuxt-cli/src/commands/init.ts b/packages/nuxt-cli/src/commands/init.ts index b1b19f400..1537bec5e 100644 --- a/packages/nuxt-cli/src/commands/init.ts +++ b/packages/nuxt-cli/src/commands/init.ts @@ -20,7 +20,7 @@ import { findFile, readPackageJSON, writePackageJSON } from 'pkg-types' import { hasTTY } from 'std-env' import { x } from 'tinyexec' -import { runCommandDef as runCommand } from '../run' +import { runCommandDef as runCommand } from '../run-command' import { nuxtIcon, themeColor } from '../utils/ascii' import { createInstallLog, resolvePackageManagerDescriptor, runInstall, takeUnreportedIgnoredBuilds } from '../utils/install' import { debug, logger } from '../utils/logger' diff --git a/packages/nuxt-cli/src/commands/module/add.ts b/packages/nuxt-cli/src/commands/module/add.ts index bf893a7b1..7a9d39228 100644 --- a/packages/nuxt-cli/src/commands/module/add.ts +++ b/packages/nuxt-cli/src/commands/module/add.ts @@ -16,7 +16,7 @@ import { readPackageJSON } from 'pkg-types' import { joinURL } from 'ufo' import { satisfies } from 'verkit' -import { runCommandDef as runCommand } from '../../run' +import { runCommandDef as runCommand } from '../../run-command' import { createInstallLog, resolvePackageManagerDescriptor, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' diff --git a/packages/nuxt-cli/src/commands/module/remove.ts b/packages/nuxt-cli/src/commands/module/remove.ts index a13682b41..896e9c74f 100644 --- a/packages/nuxt-cli/src/commands/module/remove.ts +++ b/packages/nuxt-cli/src/commands/module/remove.ts @@ -12,7 +12,7 @@ import { resolve } from 'pathe' import colors from 'picocolors' import { readPackageJSON } from 'pkg-types' -import { runCommandDef as runCommand } from '../../run' +import { runCommandDef as runCommand } from '../../run-command' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' import { relativeToProcess } from '../../utils/paths' diff --git a/packages/nuxt-cli/src/run-command.ts b/packages/nuxt-cli/src/run-command.ts new file mode 100644 index 000000000..0f841069c --- /dev/null +++ b/packages/nuxt-cli/src/run-command.ts @@ -0,0 +1,32 @@ +import type { ArgsDef, CommandDef } from 'citty' + +import process from 'node:process' + +import { runCommand as _runCommand } from 'citty' + +import { isNuxiCommand } from './commands/_utils' + +// To provide subcommands call it as `runCommandDef(, [, ...])` +export async function runCommandDef( + command: CommandDef, + argv: string[] = process.argv.slice(2), + data: { overrides?: Record } = {}, +): Promise<{ result: unknown }> { + argv.push('--no-clear') // Dev + if (command.meta && 'name' in command.meta && typeof command.meta.name === 'string') { + const name = command.meta.name + if (!(isNuxiCommand(name))) { + throw new Error(`Invalid command ${name}`) + } + } + else { + throw new Error(`Invalid command, must be named`) + } + + return await _runCommand(command, { + rawArgs: argv, + data: { + overrides: data.overrides || {}, + }, + }) +} diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index 1e256138a..9f20e02ec 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -1,12 +1,9 @@ -import type { ArgsDef, CommandDef } from 'citty' - import process from 'node:process' import { fileURLToPath } from 'node:url' import { runCommand as _runCommand, runMain as _runMain } from 'citty' import { commands } from './commands' -import { isNuxiCommand } from './commands/_utils' import { main } from './main' globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { @@ -46,28 +43,3 @@ export async function runCommand( }, }) } - -// To provide subcommands call it as `runCommandDef(, [, ...])` -export async function runCommandDef( - command: CommandDef, - argv: string[] = process.argv.slice(2), - data: { overrides?: Record } = {}, -): Promise<{ result: unknown }> { - argv.push('--no-clear') // Dev - if (command.meta && 'name' in command.meta && typeof command.meta.name === 'string') { - const name = command.meta.name - if (!(isNuxiCommand(name))) { - throw new Error(`Invalid command ${name}`) - } - } - else { - throw new Error(`Invalid command, must be named`) - } - - return await _runCommand(command, { - rawArgs: argv, - data: { - overrides: data.overrides || {}, - }, - }) -} diff --git a/packages/nuxt-cli/test/unit/commands/add.spec.ts b/packages/nuxt-cli/test/unit/commands/add.spec.ts index 50dba8c91..dcaf22cf0 100644 --- a/packages/nuxt-cli/test/unit/commands/add.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/add.spec.ts @@ -3,7 +3,7 @@ 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' +import * as runCommands from '../../../src/run-command' import * as installUtils from '../../../src/utils/install' import * as versions from '../../../src/utils/versions' diff --git a/packages/nuxt-cli/test/unit/commands/module/add.spec.ts b/packages/nuxt-cli/test/unit/commands/module/add.spec.ts index d479b6be9..bb4e0d050 100644 --- a/packages/nuxt-cli/test/unit/commands/module/add.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/add.spec.ts @@ -2,7 +2,7 @@ import { beforeAll, 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' +import * as runCommands from '../../../../src/run-command' import * as installUtils from '../../../../src/utils/install' import * as versions from '../../../../src/utils/versions' diff --git a/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts b/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts index ef0e95919..03911262d 100644 --- a/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts @@ -2,7 +2,7 @@ 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' +import * as runCommands from '../../../../src/run-command' const updateConfig = vi.fn(() => Promise.resolve()) const removeDependency = vi.fn(() => Promise.resolve())