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
2 changes: 1 addition & 1 deletion packages/nuxi/bin/nuxi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
1 change: 1 addition & 0 deletions packages/nuxi/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { runMain } from './launcher'
3 changes: 2 additions & 1 deletion packages/nuxi/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { main, runMain } from './main'
export { runMain } from './launcher'
export { main } from './main'
export { runCommand } from './run'
192 changes: 192 additions & 0 deletions packages/nuxi/src/launcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, [major: number, minor: number]> = {
'@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<void> {
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<void>, 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-<command>` 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
}
6 changes: 5 additions & 1 deletion packages/nuxi/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ const _main = defineCommand({

export const main = _main as CommandDef<any>

export async function runMain(): Promise<void> {
/**
* 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<void> {
if (process.argv[2] === 'complete') {
const { initCompletions } = await import('../../nuxt-cli/src/completions')
await initCompletions(main)
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxi/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || {
}

// To provide subcommands call it as `runCommand(<command>, [<subcommand>, ...])`
export { runCommandDef as runCommand } from '../../nuxt-cli/src/run'
export { runCommandDef as runCommand } from '../../nuxt-cli/src/run-command'
124 changes: 124 additions & 0 deletions packages/nuxi/test/launcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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)
})
})
5 changes: 4 additions & 1 deletion packages/nuxi/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/commands/module/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/commands/module/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading