diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 4548dd857..ced620909 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -62,10 +62,8 @@ } }, "dependencies": { - "@bomb.sh/tab": "^0.0.22", "@clack/prompts": "^1.7.0", "args-tokenizer": "^0.3.0", - "citty": "^0.2.2", "clickable-path": "^0.0.1", "confbox": "^0.2.4", "consola": "^3.4.2", @@ -73,7 +71,6 @@ "exsolve": "^1.1.1", "fuzzysort": "^4.0.1", "get-port-please": "^3.2.0", - "nypm": "^0.6.9", "obug": "^2.1.4", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", @@ -88,16 +85,19 @@ "verkit": "^0.3.2" }, "devDependencies": { + "@bomb.sh/tab": "^0.0.22", "@nuxt/kit": "^4.5.2", "@nuxt/schema": "^4.5.2", "@nuxt/test-utils": "^4.1.0", "@speed-highlight/core": "^2.0.0", "@types/node": "^24.13.3", + "citty": "^0.2.2", "giget": "^3.3.1", "h3": "^1.15.11", "jiti": "^2.7.0", "nitro": "^3.0.1-alpha.2", "nitropack": "^2.13.4", + "nypm": "^0.6.9", "rolldown": "^1.2.4", "rollup": "^4.62.4", "tsdown": "^0.22.14", diff --git a/packages/nuxt-cli/src/commands/_shared.ts b/packages/nuxt-cli/src/commands/_shared.ts index aac1537a8..a26a9dfd6 100644 --- a/packages/nuxt-cli/src/commands/_shared.ts +++ b/packages/nuxt-cli/src/commands/_shared.ts @@ -9,6 +9,22 @@ export const cwdArgs = { }, } as const satisfies Record +/** + * The root command's `--cwd`, forwarded to every subcommand so it can be passed + * before the command name. + * + * No default, unlike {@link cwdArgs}: commands taking a ROOTDIR positional treat an + * explicit `--cwd` as an override of it, and cannot tell the two apart if it is + * always set. + */ +export const globalCwdArgs = { + cwd: { + ...cwdArgs.cwd, + default: undefined as string | undefined, + inherit: true, + }, +} as const satisfies Record + export const logLevelArgs = { logLevel: { type: 'string', @@ -27,7 +43,8 @@ export const envNameArgs = { export const dotEnvArgs = { dotenv: { type: 'string', - description: 'Path to `.env` file to load, relative to the root directory', + description: 'Path to `.env` file to load, relative to the root directory. Can be repeated, with later files taking precedence.', + multiple: true, }, } as const satisfies Record @@ -37,6 +54,7 @@ export const extendsArgs = { description: 'Extend from a Nuxt layer', valueHint: 'layer-name', alias: ['e'], + multiple: true, }, } as const satisfies Record @@ -50,9 +68,9 @@ export const profileArgs = { } as const satisfies Record /** - * `--cwd` is deliberately not declared here: it is an undocumented alias for ROOTDIR, - * normalised out of `rawArgs` by `normaliseCwdArg` and read back off `args.cwd`. - * No default, so `resolveRootDir` can tell an explicit ROOTDIR from an absent one. + * `--cwd` is deliberately not declared here: commands taking a ROOTDIR positional + * inherit it from the root command (see {@link globalCwdArgs}) rather than listing + * it twice in their own help. */ export const rootDirArgs = { rootDir: { diff --git a/packages/nuxt-cli/src/commands/analyze.ts b/packages/nuxt-cli/src/commands/analyze.ts index b96336a0d..c5703e175 100644 --- a/packages/nuxt-cli/src/commands/analyze.ts +++ b/packages/nuxt-cli/src/commands/analyze.ts @@ -10,6 +10,7 @@ import { defu } from 'defu' import { join, relative, resolve } from 'pathe' import { serve } from 'srvx' +import { resolveDotenvFileNames } from '../utils/args' import { overrideEnv } from '../utils/env' import { ActionableError } from '../utils/errors' import { clearDir } from '../utils/fs' @@ -86,10 +87,10 @@ export default defineCommand({ ready: false, dotenv: { cwd, - fileName: ctx.args.dotenv, + fileName: resolveDotenvFileNames(ctx.args.dotenv), }, overrides: defu(ctx.data?.overrides, { - ...(ctx.args.extends && { extends: ctx.args.extends }), + ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), build: { analyze: { enabled: true, diff --git a/packages/nuxt-cli/src/commands/build.ts b/packages/nuxt-cli/src/commands/build.ts index 1c2c7d480..555584a2b 100644 --- a/packages/nuxt-cli/src/commands/build.ts +++ b/packages/nuxt-cli/src/commands/build.ts @@ -5,6 +5,7 @@ import { intro, outro } from '@clack/prompts' import { defineCommand } from 'citty' import { relative } from 'pathe' +import { resolveDotenvFileNames } from '../utils/args' import { showBanner } from '../utils/banner' import { overrideEnv } from '../utils/env' @@ -63,7 +64,7 @@ export default defineCommand({ ready: false, dotenv: { cwd, - fileName: ctx.args.dotenv, + fileName: resolveDotenvFileNames(ctx.args.dotenv), }, envName: ctx.args.envName, // nuxt will fall back to NODE_ENV overrides: { @@ -73,7 +74,7 @@ export default defineCommand({ static: ctx.args.prerender, preset: ctx.args.preset || process.env.NITRO_PRESET || process.env.SERVER_PRESET, }, - ...(ctx.args.extends && { extends: ctx.args.extends }), + ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), ...ctx.data?.overrides, ...((perfValue || ctx.data?.overrides?.debug) && { debug: { diff --git a/packages/nuxt-cli/src/commands/curl.ts b/packages/nuxt-cli/src/commands/curl.ts index 97e0685e8..1ffdcdc88 100644 --- a/packages/nuxt-cli/src/commands/curl.ts +++ b/packages/nuxt-cli/src/commands/curl.ts @@ -68,6 +68,7 @@ export default defineCommand({ alias: 'H', description: 'Request header in `Name: Value` form. Can be repeated.', valueHint: 'header', + multiple: true, }, data: { type: 'string', @@ -106,7 +107,7 @@ export default defineCommand({ const url = await resolveRequestUrl(input, cwd) const headers = new Headers() - for (const header of collectRepeated(ctx.rawArgs, 'header', 'H')) { + for (const header of ctx.args.header) { const separator = header.indexOf(':') if (separator <= 0) { logger.error(`Invalid header ${styleText('cyan', header)}. Expected ${styleText('cyan', 'Name: Value')}.`) @@ -181,35 +182,6 @@ async function resolveRequestUrl(input: string, cwd: string): Promise { return new URL(input.startsWith('/') ? input : `/${input}`, server.url) } -/** - * citty keeps only the last value of a repeated string flag, so repeatable - * options are read back off the raw argv instead of `ctx.args`. - */ -function collectRepeated(rawArgs: string[], name: string, alias: string): string[] { - const values: string[] = [] - const end = rawArgs.indexOf('--') - const argv = end === -1 ? rawArgs : rawArgs.slice(0, end) - - for (let index = 0; index < argv.length; index++) { - const arg = argv[index]! - if (arg === `--${name}` || arg === `-${alias}`) { - const value = argv[++index] - if (value !== undefined) { - values.push(value) - } - continue - } - if (arg.startsWith(`--${name}=`)) { - values.push(arg.slice(name.length + 3)) - } - else if (arg.startsWith(`-${alias}=`)) { - values.push(arg.slice(alias.length + 2)) - } - } - - return values -} - async function readRequestBody(data: string | undefined): Promise { if (data === undefined) { return undefined diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index e6acdf7a7..d8913ae35 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -141,16 +141,19 @@ const command = defineCommand({ }, 'https.domains': { type: 'string', - description: 'Comma-separated domains for a generated certificate', + description: 'Domain for a generated certificate. Can be repeated, or given as a comma-separated list.', + multiple: true, }, ...profileArgs, 'sslCert': { type: 'string', description: '(DEPRECATED) Use `--https.cert` instead.', + hidden: true, }, 'sslKey': { type: 'string', description: '(DEPRECATED) Use `--https.key` instead.', + hidden: true, }, }, async run(ctx) { @@ -441,9 +444,7 @@ export function resolveListenOverrides(args: ParsedArgs): DevListenOverri pfx: args['https.pfx'] || undefined, passphrase: args['https.passphrase'] || undefined, validityDays: parsePositiveInteger(args['https.validityDays']), - domains: args['https.domains'] - ? args['https.domains'].split(',').map(domain => domain.trim()).filter(Boolean) - : undefined, + domains: args['https.domains']?.flatMap(value => value.split(',')).map(domain => domain.trim()).filter(Boolean), } const host = (args.host as string | boolean | undefined) diff --git a/packages/nuxt-cli/src/commands/module/add.ts b/packages/nuxt-cli/src/commands/module/add.ts index 3d924b02a..0b03a48a4 100644 --- a/packages/nuxt-cli/src/commands/module/add.ts +++ b/packages/nuxt-cli/src/commands/module/add.ts @@ -60,6 +60,7 @@ export function defineAddCommand({ layers = false }: { layers?: boolean } = {}) moduleName: { type: 'positional', description: `Specify one or more modules${layers ? ' or layers' : ''} to install by name, separated by spaces`, + multiple: true, }, skipInstall: { type: 'boolean', @@ -80,7 +81,7 @@ export function defineAddCommand({ layers = false }: { layers?: boolean } = {}) }, async setup(ctx) { const cwd = resolve(ctx.args.cwd) - let modules = ctx.args._.map(e => e.trim()).filter(Boolean) + let modules = ctx.args.moduleName.map(e => e.trim()).filter(Boolean) const projectPkg = await readPackageJSON(cwd).catch(() => ({} as PackageJson)) if (!await ensureNuxtDependency(cwd, projectPkg)) { diff --git a/packages/nuxt-cli/src/commands/prepare.ts b/packages/nuxt-cli/src/commands/prepare.ts index dab624b16..ca50f51a3 100644 --- a/packages/nuxt-cli/src/commands/prepare.ts +++ b/packages/nuxt-cli/src/commands/prepare.ts @@ -3,6 +3,7 @@ import process from 'node:process' import { styleText } from 'node:util' import { defineCommand } from 'citty' +import { resolveDotenvFileNames } from '../utils/args' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { readActiveLock } from '../utils/lockfile' @@ -32,13 +33,13 @@ export default defineCommand({ cwd, dotenv: { cwd, - fileName: ctx.args.dotenv, + fileName: resolveDotenvFileNames(ctx.args.dotenv), }, envName: ctx.args.envName, // nuxt will fall back to NODE_ENV overrides: { _prepare: true, logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose', - ...(ctx.args.extends && { extends: ctx.args.extends }), + ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), ...ctx.data?.overrides, }, }) diff --git a/packages/nuxt-cli/src/commands/preview.ts b/packages/nuxt-cli/src/commands/preview.ts index 1b338641b..9ac3438b4 100644 --- a/packages/nuxt-cli/src/commands/preview.ts +++ b/packages/nuxt-cli/src/commands/preview.ts @@ -9,6 +9,7 @@ import { defineCommand } from 'citty' import { resolve } from 'pathe' import { x } from 'tinyexec' +import { resolveDotenvFileNames } from '../utils/args' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { withPrependedPath } from '../utils/path-env' @@ -51,12 +52,12 @@ const command = defineCommand({ cwd, dotenv: { cwd, - fileName: ctx.args.dotenv, + fileName: resolveDotenvFileNames(ctx.args.dotenv), }, envName: ctx.args.envName, ready: true, overrides: { - ...(ctx.args.extends && { extends: ctx.args.extends }), + ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), modules: [ function (_, nuxt) { envLoaded = true @@ -124,24 +125,22 @@ const command = defineCommand({ }, ) - const envFileName = ctx.args.dotenv || '.env' + const envFileNames = resolveDotenvFileNames(ctx.args.dotenv) ?? ['.env'] + const existing = envFileNames.filter(fileName => existsSync(resolve(cwd, fileName))) + const missing = envFileNames.filter(fileName => !existing.includes(fileName)) - const envExists = existsSync(resolve(cwd, envFileName)) - - if (envExists) { + if (existing.length > 0) { + const list = existing.map(fileName => styleText('cyan', fileName)).join(', ') if (envLoaded) { - logger.info( - `Loaded ${styleText('cyan', envFileName)}. This will not be loaded when running the server in production.`, - ) + logger.info(`Loaded ${list}. This will not be loaded when running the server in production.`) } else { - logger.warn( - `Could not load Nuxt, so ${styleText('cyan', envFileName)} may not be fully applied to the preview server.`, - ) + logger.warn(`Could not load Nuxt, so ${list} may not be fully applied to the preview server.`) } } - else if (ctx.args.dotenv) { - logger.error(`Cannot find ${styleText('cyan', envFileName)}.`) + + if (ctx.args.dotenv.length > 0 && missing.length > 0) { + logger.error(`Cannot find ${missing.map(fileName => styleText('cyan', fileName)).join(', ')}.`) } const port = ctx.args.port diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 751805dcd..b7a13707e 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -13,6 +13,7 @@ import { readPackageJSON, readTSConfig } from 'pkg-types' import { hasTTY } from 'std-env' import { x } from 'tinyexec' +import { resolveDotenvFileNames } from '../utils/args' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { resolveRootDir, withNodePath } from '../utils/paths' @@ -145,7 +146,7 @@ export default defineCommand({ readTSConfig(cwd).catch(() => ({} as TSConfig)), writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', { ...ctx.data?.overrides, - ...(ctx.args.extends && { extends: ctx.args.extends }), + ...(ctx.args.extends.length > 0 && { extends: ctx.args.extends }), }), ]) @@ -366,11 +367,11 @@ async function installMissingPackages(options: { } } -async function writeTypes(cwd: string, dotenv?: string, logLevel?: 'silent' | 'info' | 'verbose', overrides?: Record) { +async function writeTypes(cwd: string, dotenv?: string[], logLevel?: 'silent' | 'info' | 'verbose', overrides?: Record) { const { loadNuxt, buildNuxt, writeTypes } = await loadKit(cwd) const nuxt = await loadNuxt({ cwd, - dotenv: { cwd, fileName: dotenv }, + dotenv: { cwd, fileName: resolveDotenvFileNames(dotenv) }, overrides: { _prepare: true, logLevel, diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts index 57fcf880b..2d0e285a8 100644 --- a/packages/nuxt-cli/src/dev/index.ts +++ b/packages/nuxt-cli/src/dev/index.ts @@ -5,6 +5,7 @@ import type { NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './ import process from 'node:process' import defu from 'defu' +import { resolveDotenvFileNames } from '../utils/args' import { overrideEnv } from '../utils/env.ts' import { isRemotePeerError } from '../utils/errors' import { debug } from '../utils/logger' @@ -149,7 +150,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti ), logLevel: devContext.args.logLevel as 'silent' | 'info' | 'verbose', clear: devContext.args.clear, - dotenv: { cwd: devContext.cwd, fileName: devContext.args.dotenv }, + dotenv: { cwd: devContext.cwd, fileName: resolveDotenvFileNames(devContext.args.dotenv) }, envName: devContext.args.envName, showBanner: ctx.showBanner !== false && !ipc.enabled, listenOverrides: ctx.listenOverrides, diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 149f6bab5..1a3d7ed42 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -55,9 +55,9 @@ export interface NuxtDevContext { args: { clear?: boolean logLevel?: string - dotenv?: string + dotenv?: string[] envName?: string - extends?: string + extends?: string[] profile?: string | boolean } } diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index 09875fe2d..d4de2dad7 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -10,9 +10,8 @@ import { provider } from 'std-env' import { description, name, version } from '../package.json' import { commands } from './commands' -import { cwdArgs } from './commands/_shared' +import { cwdArgs, globalCwdArgs } from './commands/_shared' import { runCommand, setCurrentCommand } from './run' -import { normaliseCwdArg } from './utils/args' import { setupGlobalConsole } from './utils/console' import { debug, logger } from './utils/logger' import { setupProxySupport } from './utils/network' @@ -33,7 +32,7 @@ const _main = defineCommand({ description, }, args: { - ...cwdArgs, + ...globalCwdArgs, command: { type: 'positional', required: false, @@ -41,8 +40,6 @@ const _main = defineCommand({ }, subCommands: commands, async setup(ctx) { - normaliseCwdArg(ctx.rawArgs) - const command = ctx.args._[0] setCurrentCommand(command) setupGlobalConsole({ dev: command === 'dev' }) @@ -74,7 +71,7 @@ const _main = defineCommand({ // allow running arbitrary commands if there's a locally registered binary with `nuxt-` prefix if (ctx.args.command && !Object.hasOwn(commands, ctx.args.command)) { - const cwd = resolve(ctx.args.cwd) + const cwd = resolve(ctx.args.cwd || '.') const env = withLocalBinPath(cwd) // Resolved before spawning rather than after failing: Windows runs a bare // name through `cmd.exe`, which reports its own error instead of `ENOENT`, diff --git a/packages/nuxt-cli/src/run-command.ts b/packages/nuxt-cli/src/run-command.ts index 4e46e8f65..540c3d658 100644 --- a/packages/nuxt-cli/src/run-command.ts +++ b/packages/nuxt-cli/src/run-command.ts @@ -4,8 +4,8 @@ import process from 'node:process' import { runCommand as _runCommand } from 'citty' +import { globalCwdArgs } from './commands/_shared' import { isNuxiCommand } from './commands/_utils' -import { normaliseCwdArg } from './utils/args' // To provide subcommands call it as `runCommandDef(, [, ...])` export async function runCommandDef( @@ -23,11 +23,9 @@ export async function runCommandDef( throw new Error(`Invalid command, must be named`) } - const rawArgs = [...argv] - normaliseCwdArg(rawArgs) - return await _runCommand(command, { - rawArgs, + rawArgs: [...argv], + inheritedArgs: globalCwdArgs, data: { overrides: data.overrides || {}, }, diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index c8e6bcc1a..e60e397c4 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -4,8 +4,8 @@ import { fileURLToPath } from 'node:url' import { runCommand as _runCommand, runMain as _runMain } from 'citty' import { commands } from './commands' +import { globalCwdArgs } from './commands/_shared' import { main } from './main' -import { normaliseCwdArg } from './utils/args' import { warnOnHang } from './utils/hang' globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { @@ -50,11 +50,9 @@ export async function runCommand( throw new Error(`Invalid command ${name}`) } - const rawArgs = [...argv] - normaliseCwdArg(rawArgs) - return await _runCommand(await commands[name as keyof typeof commands](), { - rawArgs, + rawArgs: [...argv], + inheritedArgs: globalCwdArgs, data: { overrides: data.overrides || {}, }, diff --git a/packages/nuxt-cli/src/utils/args.ts b/packages/nuxt-cli/src/utils/args.ts index 197e8b412..414e40988 100644 --- a/packages/nuxt-cli/src/utils/args.ts +++ b/packages/nuxt-cli/src/utils/args.ts @@ -1,39 +1,8 @@ import { resolve } from 'pathe' -function cwdArgIndex(rawArgs: string[]): number { - const end = rawArgs.indexOf('--') - const index = rawArgs.findIndex(arg => arg === '--cwd' || arg.startsWith('--cwd=')) - return end !== -1 && index > end ? -1 : index -} - /** - * Commands accept `--cwd` as an undeclared alias for their ROOTDIR positional, so it stays out - * of `--help`. Undeclared, only the `--cwd=` form is safe: mri treats a bare `--cwd` as - * boolean and its value would be consumed as a positional. Rewrite to that form, keeping the - * last occurrence, and move it after a command name that citty would otherwise slice the - * preceding arguments off, but ahead of any `--` separator so it is still parsed as a flag. - * @see https://github.com/nuxt/cli/issues/365 - */ -export function normaliseCwdArg(rawArgs: string[]): void { - let cwd: string | undefined - for (let index = cwdArgIndex(rawArgs); index !== -1; index = cwdArgIndex(rawArgs)) { - const arg = rawArgs[index]! - const inline = arg.includes('=') - const [, value] = rawArgs.splice(index, inline ? 1 : 2) - cwd = inline ? arg.slice(arg.indexOf('=') + 1) : value - } - - if (cwd === undefined) { - return - } - - const separator = rawArgs.indexOf('--') - rawArgs.splice(separator === -1 ? rawArgs.length : separator, 0, `--cwd=${cwd}`) -} - -/** - * Point already-normalised `rawArgs` at `cwd`, so a process launched with them - * (a dev fork) runs against the same directory as this one. + * Point `rawArgs` at `cwd`, so a process launched with them (a dev fork) runs + * against the same directory as this one. * * A positional resolving to `previousCwd` is dropped along with any existing * `--cwd`: it named the directory being moved away from, and leaving it would @@ -43,13 +12,27 @@ export function replaceCwdArg(rawArgs: string[], cwd: string, previousCwd: strin const separator = rawArgs.indexOf('--') const end = separator === -1 ? rawArgs.length : separator - for (let index = end - 1; index >= 0; index--) { + const kept: string[] = [] + for (let index = 0; index < end; index++) { const arg = rawArgs[index]! + if (arg === '--cwd') { + index++ + continue + } if (arg.startsWith('--cwd=') || (!arg.startsWith('-') && resolve(arg) === previousCwd)) { - rawArgs.splice(index, 1) + continue } + kept.push(arg) } - const remaining = rawArgs.indexOf('--') - rawArgs.splice(remaining === -1 ? rawArgs.length : remaining, 0, `--cwd=${cwd}`) + rawArgs.splice(0, end, ...kept, `--cwd=${cwd}`) +} + +/** + * The `.env` files a command was asked to load, or `undefined` when it was not + * asked for any: an empty list would tell `c12` to load nothing at all, rather + * than to fall back to `.env`. + */ +export function resolveDotenvFileNames(dotenv: string[] | undefined): string[] | undefined { + return dotenv?.length ? dotenv : undefined } diff --git a/packages/nuxt-cli/src/utils/paths.ts b/packages/nuxt-cli/src/utils/paths.ts index c2357b265..9bc96f791 100644 --- a/packages/nuxt-cli/src/utils/paths.ts +++ b/packages/nuxt-cli/src/utils/paths.ts @@ -39,15 +39,15 @@ export function resolveRootDir(args: { cwd?: string, rootDir?: string }): string * `args` are the root command's, so `_[0]` is the subcommand and `_[1]` is the * first argument to it. */ -export function resolveProjectDir(args: { cwd: string, _: string[] }): string { +export function resolveProjectDir(args: { cwd?: string, _: string[] }): string { const [, rootDir] = args._ - if (args.cwd === '.' && rootDir && !rootDir.startsWith('-')) { + if ((args.cwd ?? '.') === '.' && rootDir && !rootDir.startsWith('-')) { const candidate = resolve(rootDir) if (existsSync(candidate) && statSync(candidate).isDirectory()) { return candidate } } - return resolve(args.cwd) + return resolve(args.cwd ?? '.') } export function relativeToProcess(path: string) { diff --git a/packages/nuxt-cli/test/unit/commands/add.spec.ts b/packages/nuxt-cli/test/unit/commands/add.spec.ts index d3c1c69a1..966bf7861 100644 --- a/packages/nuxt-cli/test/unit/commands/add.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/add.spec.ts @@ -153,7 +153,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], }, }) @@ -176,7 +176,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['@nuxt/icon'], + moduleName: ['@nuxt/icon'], }, }) @@ -197,7 +197,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui', 'icon'], + moduleName: ['ui', 'icon'], }, }) @@ -216,7 +216,7 @@ describe('nuxt add command', () => { it('should resolve duplicate modules once', async () => { const addCommand = await (commands as CommandsType).subCommands.add() - await addCommand.setup({ args: { cwd: '/fake-dir', _: ['ui', 'ui'] } }) + await addCommand.setup({ args: { cwd: '/fake-dir', moduleName: ['ui', 'ui'] } }) expect(mock$fetch).toHaveBeenCalledTimes(1) expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ @@ -230,7 +230,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], skipInstall: true, }, }) @@ -245,7 +245,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], skipConfig: true, }, }) @@ -260,7 +260,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], dev: true, }, }) @@ -301,7 +301,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui@2.5.0'], + moduleName: ['ui@2.5.0'], }, }) @@ -319,7 +319,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], }, }) @@ -336,7 +336,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], }, }) @@ -352,7 +352,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], }, }) @@ -371,7 +371,7 @@ describe('nuxt add command', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['ui'], + moduleName: ['ui'], skipInstall: true, }, }) diff --git a/packages/nuxt-cli/test/unit/commands/build.spec.ts b/packages/nuxt-cli/test/unit/commands/build.spec.ts index 85ada862c..e9227918a 100644 --- a/packages/nuxt-cli/test/unit/commands/build.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/build.spec.ts @@ -80,7 +80,7 @@ describe('build', () => { logLevel: undefined, _generate: true, nitro: { static: true, preset: 'cloudflare' }, - extends: 'base', + extends: ['base'], debug: { templates: true, perf: true }, }, }) @@ -106,6 +106,14 @@ describe('build', () => { expect(calls).toEqual([...calls].sort((a, b) => a! - b!)) }) + it('should load every requested `.env` file, in the order given', async () => { + await run(['--dotenv', '.env.development', '--dotenv', '.env.local']) + + expect(mocks.loadNuxt).toHaveBeenCalledWith(expect.objectContaining({ + dotenv: { cwd, fileName: ['.env.development', '.env.local'] }, + })) + }) + it('propagates build errors without terminating programmatic callers', async () => { const error = new Error('build failed') mocks.buildNuxt.mockRejectedValue(error) diff --git a/packages/nuxt-cli/test/unit/commands/dev-args.spec.ts b/packages/nuxt-cli/test/unit/commands/dev-args.spec.ts index 1e708830d..75a4864cb 100644 --- a/packages/nuxt-cli/test/unit/commands/dev-args.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/dev-args.spec.ts @@ -51,8 +51,9 @@ describe('resolveListenOverrides', () => { expect(overrides().open).toBeFalsy() }) - it('should split https domains and drop empty entries', () => { - expect(overrides({ 'https.domains': 'a.test, b.test ,' }).https).toMatchObject({ domains: ['a.test', 'b.test'] }) + it('should collect https domains from repeated flags and comma-separated lists', () => { + expect(overrides({ 'https.domains': ['a.test, b.test ,'] }).https).toMatchObject({ domains: ['a.test', 'b.test'] }) + expect(overrides({ 'https.domains': ['a.test', 'b.test'] }).https).toMatchObject({ domains: ['a.test', 'b.test'] }) }) it('should leave https domains unset when the flag is absent', () => { diff --git a/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts b/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts index b742fe400..c320cb16f 100644 --- a/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts @@ -55,7 +55,7 @@ async function addModule(name: string, pkg: Record) { manifest = { devDependencies: { nuxt: '3.0.0' }, ...pkg } const addCommand = await (commands as CommandsType).subCommands.add() - await addCommand.setup({ args: { cwd: '/fake-dir', _: [name] } }) + await addCommand.setup({ args: { cwd: '/fake-dir', moduleName: [name] } }) return addNuxtConfigEntries.mock.calls.at(-1)![1] } @@ -106,7 +106,7 @@ describe('module add config', () => { manifest = { devDependencies: { nuxt: '3.0.0' }, exports: { '.': './dist/index.mjs', './nuxt': './dist/nuxt.mjs' } } const addCommand = await (commands as CommandsType).subCommands.add() - await addCommand.setup({ args: { cwd: '/fake-dir', _: ['maz-ui/nuxt'] } }) + await addCommand.setup({ args: { cwd: '/fake-dir', moduleName: ['maz-ui/nuxt'] } }) expect(installUtils.runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: ['maz-ui@1.0.0'], 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 7f0934aa6..e231ebeb7 100644 --- a/packages/nuxt-cli/test/unit/commands/module/add.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/add.spec.ts @@ -122,7 +122,7 @@ describe('module add', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['content'], + moduleName: ['content'], }, }) @@ -140,7 +140,7 @@ describe('module add', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['content@2.9.0'], + moduleName: ['content@2.9.0'], }, }) @@ -158,7 +158,7 @@ describe('module add', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['content@2'], + moduleName: ['content@2'], }, }) @@ -176,7 +176,7 @@ describe('module add', () => { await addCommand.setup({ args: { cwd: '/fake-dir', - _: ['content@3.1'], + moduleName: ['content@3.1'], }, }) diff --git a/packages/nuxt-cli/test/unit/global-args.spec.ts b/packages/nuxt-cli/test/unit/global-args.spec.ts index 1e9a6af35..d6345fa33 100644 --- a/packages/nuxt-cli/test/unit/global-args.spec.ts +++ b/packages/nuxt-cli/test/unit/global-args.spec.ts @@ -33,6 +33,23 @@ describe('global args', () => { expect(await run(['--cwd', 'apps/docs', 'info', '--cwd', 'apps/web'])).toMatchObject({ cwd: 'apps/web' }) }) + it('should forward a leading --cwd to a nested subcommand', async () => { + const run = vi.fn() + vi.doMock('../../src/commands', () => ({ + commands: { module: () => ({ meta: { name: 'module' }, subCommands: { add: { meta: { name: 'add' }, args: {}, run } } }) }, + })) + vi.resetModules() + + const { main: freshMain } = await import('../../src/main') as { main: typeof main } + await runCommand(freshMain, { rawArgs: ['--cwd', 'apps/web', 'module', 'add', 'nuxt-og-image'] }) + + expect(run.mock.calls[0]![0].args).toMatchObject({ cwd: 'apps/web', _: ['nuxt-og-image'] }) + }) + + it('should not treat a --cwd after a -- separator as its own', async () => { + expect((await run(['info', '--', '--cwd', 'apps/web'])).cwd).toBeUndefined() + }) + it('should preserve the ROOTDIR positional', async () => { expect(await run(['--cwd', 'apps/docs', 'info', 'apps/web'])).toMatchObject({ cwd: 'apps/docs', rootDir: 'apps/web' }) const args = await run(['info', 'apps/web']) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index e85d9dd7c..643a08b17 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -34,7 +34,7 @@ describe('help', () => { OPTIONS - --cwd= Specify the root directory of your Nuxt project (Default: .) + --cwd= Specify the root directory of your Nuxt project COMMANDS @@ -65,11 +65,11 @@ describe('help', () => { expect(await usage(commands.add, main)).toMatchInlineSnapshot(` "Add Nuxt modules and layers (nuxt add v0.0.0) - USAGE nuxt add [OPTIONS] + USAGE nuxt add [OPTIONS] ARGUMENTS - MODULENAME Specify one or more modules or layers to install by name, separated by spaces (Required) + MODULENAME... Specify one or more modules or layers to install by name, separated by spaces (Required) OPTIONS @@ -120,13 +120,13 @@ describe('help', () => { OPTIONS - --logLevel= Specify build-time log level - --dotenv= Path to \`.env\` file to load, relative to the root directory - -e, --extends= Extend from a Nuxt layer - --name= Name of the analysis (Default: default) - --serve Serve the analysis results (Default: true) - --no-serve Skip serving the analysis results - --prerender Prerender routes while analyzing (Default: false) + --logLevel= Specify build-time log level + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. + -e, --extends=... Extend from a Nuxt layer + --name= Name of the analysis (Default: default) + --serve Serve the analysis results (Default: true) + --no-serve Skip serving the analysis results + --prerender Prerender routes while analyzing (Default: false) " `) }) @@ -146,9 +146,9 @@ describe('help', () => { --logLevel= Specify build-time log level --prerender Build Nuxt and prerender static routes --preset= Nitro server preset - --dotenv= Path to \`.env\` file to load, relative to the root directory + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) - -e, --extends= Extend from a Nuxt layer + -e, --extends=... Extend from a Nuxt layer --profile= Profile performance. Use --profile for CPU only, --profile=verbose for full report. " `) @@ -181,7 +181,7 @@ describe('help', () => { --logLevel= Specify build-time log level --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) - --dotenv= Path to \`.env\` file to load, relative to the root directory + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. --clear Clear console on restart --no-clear Disable clear console on restart " @@ -201,9 +201,9 @@ describe('help', () => { OPTIONS --logLevel= Specify build-time log level - --dotenv= Path to \`.env\` file to load, relative to the root directory + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) - -e, --extends= Extend from a Nuxt layer + -e, --extends=... Extend from a Nuxt layer --inspect Enable the Node.js inspector for the process serving your app (\`--inspect=[host:]port\`) --inspect-brk Enable the Node.js inspector and wait for a debugger to attach (\`--inspect-brk=[host:]port\`) --clear Clear console on restart (Default: false) @@ -227,10 +227,8 @@ describe('help', () => { --https.pfx= Path to PKCS#12 (.p12/.pfx) keystore --https.passphrase= Passphrase for the TLS key or keystore --https.validityDays= Validity in days for a generated self-signed certificate - --https.domains= Comma-separated domains for a generated certificate + --https.domains=... Domain for a generated certificate. Can be repeated, or given as a comma-separated list. --profile= Profile performance. Use --profile for CPU only, --profile=verbose for full report. - --sslCert= (DEPRECATED) Use \`--https.cert\` instead. - --sslKey= (DEPRECATED) Use \`--https.key\` instead. " `) }) @@ -282,9 +280,9 @@ describe('help', () => { --logLevel= Specify build-time log level --preset= Nitro server preset - --dotenv= Path to \`.env\` file to load, relative to the root directory + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) - -e, --extends= Extend from a Nuxt layer + -e, --extends=... Extend from a Nuxt layer --profile= Profile performance. Use --profile for CPU only, --profile=verbose for full report. " `) @@ -344,10 +342,10 @@ describe('help', () => { OPTIONS - --dotenv= Path to \`.env\` file to load, relative to the root directory + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. --logLevel= Specify build-time log level --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) - -e, --extends= Extend from a Nuxt layer + -e, --extends=... Extend from a Nuxt layer " `) }) @@ -366,10 +364,10 @@ describe('help', () => { --logLevel= Specify build-time log level --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) - -e, --extends= Extend from a Nuxt layer + -e, --extends=... Extend from a Nuxt layer -p, --port= Port to listen on (default: \`NUXT_PORT || NITRO_PORT || PORT\`) -h, --host= Host to listen on (default: \`NUXT_HOST || NITRO_HOST || HOST\`) - --dotenv= Path to \`.env\` file to load, relative to the root directory + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. " `) }) @@ -388,10 +386,10 @@ describe('help', () => { --logLevel= Specify build-time log level --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) - -e, --extends= Extend from a Nuxt layer + -e, --extends=... Extend from a Nuxt layer -p, --port= Port to listen on (default: \`NUXT_PORT || NITRO_PORT || PORT\`) -h, --host= Host to listen on (default: \`NUXT_HOST || NITRO_HOST || HOST\`) - --dotenv= Path to \`.env\` file to load, relative to the root directory + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. " `) }) @@ -426,11 +424,11 @@ describe('help', () => { OPTIONS - --logLevel= Specify build-time log level - --dotenv= Path to \`.env\` file to load, relative to the root directory - -e, --extends= Extend from a Nuxt layer - --checker= Type checker to use (\`vue-tsc\` or \`golar\`) - -b, --build Type-check in build mode, using TypeScript project references (detected automatically by default) + --logLevel= Specify build-time log level + --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. + -e, --extends=... Extend from a Nuxt layer + --checker= Type checker to use (\`vue-tsc\` or \`golar\`) + -b, --build Type-check in build mode, using TypeScript project references (detected automatically by default) " `) }) @@ -459,11 +457,11 @@ describe('help', () => { expect(await usage(await subCommand(commands.module, 'add'), commands.module)).toMatchInlineSnapshot(` "Add Nuxt modules (module add) - USAGE module add [OPTIONS] + USAGE module add [OPTIONS] ARGUMENTS - MODULENAME Specify one or more modules to install by name, separated by spaces (Required) + MODULENAME... Specify one or more modules to install by name, separated by spaces (Required) OPTIONS diff --git a/packages/nuxt-cli/test/unit/run.spec.ts b/packages/nuxt-cli/test/unit/run.spec.ts index f15bd24b4..54ef37f92 100644 --- a/packages/nuxt-cli/test/unit/run.spec.ts +++ b/packages/nuxt-cli/test/unit/run.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { runCommandDef } from '../../src/run-command' let clear: boolean | undefined +let cwd: string | undefined const devCommand = defineCommand({ meta: { name: 'dev' }, @@ -16,6 +17,7 @@ const devCommand = defineCommand({ }, run(ctx) { clear = ctx.args.clear + cwd = ctx.args.cwd as string | undefined }, }) @@ -56,14 +58,17 @@ describe('runCommand', () => { expect(argv).toEqual(['--clear']) }) - it('should not modify a `--cwd` it normalises', async () => { + it('should parse a `--cwd` the command does not declare', async () => { const { runCommand } = await import('../../src/run') - const argv = ['--clear', '--cwd', '.'] + const argv = ['--clear', '--cwd', 'apps/web'] await runCommand('dev', argv) + expect(cwd).toBe('apps/web') + await runCommandDef(devCommand, argv) + expect(cwd).toBe('apps/web') - expect(argv).toEqual(['--clear', '--cwd', '.']) + expect(argv).toEqual(['--clear', '--cwd', 'apps/web']) }) it('should reject inherited command properties', async () => { diff --git a/packages/nuxt-cli/test/unit/utils/args.spec.ts b/packages/nuxt-cli/test/unit/utils/args.spec.ts index 232d1dc76..3583093fb 100644 --- a/packages/nuxt-cli/test/unit/utils/args.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/args.spec.ts @@ -1,42 +1,7 @@ import { resolve } from 'pathe' import { describe, expect, it } from 'vitest' -import { normaliseCwdArg, replaceCwdArg } from '../../../src/utils/args' - -function normalise(rawArgs: string[]): string[] { - normaliseCwdArg(rawArgs) - return rawArgs -} - -describe('normaliseCwdArg', () => { - it('should rewrite a bare --cwd to its inline form', () => { - expect(normalise(['build', '--cwd', 'apps/web'])).toEqual(['build', '--cwd=apps/web']) - expect(normalise(['--cwd', 'apps/web'])).toEqual(['--cwd=apps/web']) - }) - - it('should move --cwd after the command name', () => { - expect(normalise(['--cwd', 'apps/web', 'build'])).toEqual(['build', '--cwd=apps/web']) - expect(normalise(['--cwd=apps/web', 'build', '--prerender'])).toEqual(['build', '--prerender', '--cwd=apps/web']) - }) - - it('should keep the last of several occurrences', () => { - expect(normalise(['--cwd', 'apps/docs', 'build', '--cwd=apps/web'])).toEqual(['build', '--cwd=apps/web']) - }) - - it('should leave the ROOTDIR positional in place', () => { - expect(normalise(['build', 'apps/web'])).toEqual(['build', 'apps/web']) - expect(normalise(['--cwd', 'apps/docs', 'build', 'apps/web'])).toEqual(['build', 'apps/web', '--cwd=apps/docs']) - }) - - it('should ignore --cwd after a -- separator', () => { - expect(normalise(['test', '--', '--cwd', 'apps/web'])).toEqual(['test', '--', '--cwd', 'apps/web']) - }) - - it('should keep --cwd ahead of a -- separator', () => { - expect(normalise(['--cwd', 'apps/web', 'test', '--', '--watch'])).toEqual(['test', '--cwd=apps/web', '--', '--watch']) - expect(normalise(['test', '--cwd=apps/web', '--', '--watch'])).toEqual(['test', '--cwd=apps/web', '--', '--watch']) - }) -}) +import { replaceCwdArg } from '../../../src/utils/args' describe('replaceCwdArg', () => { const previous = resolve('apps/web') @@ -48,6 +13,7 @@ describe('replaceCwdArg', () => { it('should replace an existing --cwd', () => { expect(replace(['dev', '--cwd=apps/web'])).toEqual(['dev', '--cwd=/projects/site']) + expect(replace(['dev', '--cwd', 'apps/web'])).toEqual(['dev', '--cwd=/projects/site']) }) it('should drop a ROOTDIR positional naming the previous directory', () => { diff --git a/packages/nuxt-cli/tsdown.config.ts b/packages/nuxt-cli/tsdown.config.ts index f71a2f9a7..a5b791ece 100644 --- a/packages/nuxt-cli/tsdown.config.ts +++ b/packages/nuxt-cli/tsdown.config.ts @@ -12,6 +12,6 @@ export const packaging: PackagingContract = { export default defineCliConfig({ entry: ['src/index.ts', 'src/dev/index.ts'], - deps: { onlyBundle: ['h3', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES }, + deps: { onlyBundle: ['@bomb.sh/tab', 'citty', 'h3', 'nypm', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES }, ...packaging, }) diff --git a/patches/citty@0.2.2.patch b/patches/citty@0.2.2.patch new file mode 100644 index 000000000..7966c6c4f --- /dev/null +++ b/patches/citty@0.2.2.patch @@ -0,0 +1,288 @@ +diff --git a/dist/index.d.mts b/dist/index.d.mts +index a1366a146c3e54139fd82fd9d562a4f4329fa733..de79b9a2be12feb563d9e5b572a7f0ec0bb8cced 100644 +--- a/dist/index.d.mts ++++ b/dist/index.d.mts +@@ -4,27 +4,34 @@ type _ArgDef = { + type?: T; + description?: string; + valueHint?: string; ++ hidden?: boolean; + alias?: string | string[]; + default?: VT; + required?: boolean; ++ multiple?: boolean; ++ inherit?: boolean; + options?: string[]; + }; +-type BooleanArgDef = Omit<_ArgDef<"boolean", boolean>, "options"> & { ++type BooleanArgDef = Omit<_ArgDef<"boolean", boolean>, "options" | "multiple"> & { + negativeDescription?: string; + }; + type StringArgDef = Omit<_ArgDef<"string", string>, "options">; + type EnumArgDef = _ArgDef<"enum", string>; +-type PositionalArgDef = Omit<_ArgDef<"positional", string>, "alias" | "options">; ++type PositionalArgDef = Omit<_ArgDef<"positional", string>, "alias" | "options" | "hidden" | "inherit">; + type ArgDef = BooleanArgDef | StringArgDef | PositionalArgDef | EnumArgDef; + type ArgsDef = Record; + type Arg = ArgDef & { + name: string; + alias: string[]; ++ multiple?: boolean; + }; +-type ResolveParsedArgType = T extends { ++type ResolveScalarArgType = T extends { + default?: any; + required?: boolean; + } ? T["default"] extends NonNullable ? VT : T["required"] extends true ? VT : VT | undefined : VT | undefined; ++type ResolveParsedArgType = T extends { ++ multiple: true; ++} ? VT[] : ResolveScalarArgType; + type ParsedPositionalArg = T extends { + type: "positional"; + } ? ResolveParsedArgType : never; +@@ -86,6 +93,7 @@ interface RunCommandOptions { + rawArgs: string[]; + data?: any; + showUsage?: boolean; ++ inheritedArgs?: ArgsDef; + } + declare function runCommand(cmd: CommandDef, opts: RunCommandOptions): Promise<{ + result: unknown; +diff --git a/dist/index.mjs b/dist/index.mjs +index d0af470ebf46490d35a130436b67f287c46bdb0f..59d8d72cae4bb091a660cfb576e066333af64857 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -26,6 +26,7 @@ var CLIError = class extends Error { + function parseRawArgs(args = [], opts = {}) { + const booleans = new Set(opts.boolean || []); + const strings = new Set(opts.string || []); ++ const multiples = new Set(opts.multiple || []); + const aliasMap = opts.alias || {}; + const defaults = opts.default || {}; + const aliasToMain = /* @__PURE__ */ new Map(); +@@ -42,17 +43,20 @@ function parseRawArgs(args = [], opts = {}) { + } + } + const options = {}; +- function getType(name) { +- if (booleans.has(name)) return "boolean"; ++ function isInSet(name, set) { ++ if (set.has(name)) return true; + const aliases = mainToAliases.get(name) || []; +- for (const alias of aliases) if (booleans.has(alias)) return "boolean"; +- return "string"; ++ for (const alias of aliases) if (set.has(alias)) return true; ++ return false; ++ } ++ function getType(name) { ++ return isInSet(name, booleans) ? "boolean" : "string"; + } + function isStringType(name) { +- if (strings.has(name)) return true; +- const aliases = mainToAliases.get(name) || []; +- for (const alias of aliases) if (strings.has(alias)) return true; +- return false; ++ return isInSet(name, strings); ++ } ++ function isMultiple(name) { ++ return isInSet(name, multiples); + } + const allOptions = new Set([ + ...booleans, +@@ -63,7 +67,8 @@ function parseRawArgs(args = [], opts = {}) { + ]); + for (const name of allOptions) if (!options[name]) options[name] = { + type: getType(name), +- default: defaults[name] ++ default: defaults[name], ++ multiple: isMultiple(name) + }; + for (const [alias, main] of aliasToMain.entries()) if (alias.length === 1 && options[main] && !options[main].short) options[main].short = alias; + const processedArgs = []; +@@ -97,12 +102,13 @@ function parseRawArgs(args = [], opts = {}) { + } + const out = { _: [] }; + out._ = parsed.positionals; +- for (const [key, value] of Object.entries(parsed.values)) { +- let coerced = value; +- if (getType(key) === "boolean" && typeof value === "string") coerced = value !== "false"; +- else if (isStringType(key) && typeof value === "boolean") coerced = ""; +- out[key] = coerced; ++ function coerceValue(key, value) { ++ if (Array.isArray(value)) return value.map((item) => coerceValue(key, item)); ++ if (getType(key) === "boolean" && typeof value === "string") return value !== "false"; ++ if (isStringType(key) && typeof value === "boolean") return ""; ++ return value; + } ++ for (const [key, value] of Object.entries(parsed.values)) out[key] = coerceValue(key, value); + for (const [name] of Object.entries(negatedFlags)) { + out[name] = false; + const mainName = aliasToMain.get(name); +@@ -130,19 +136,28 @@ const gray = /* @__PURE__ */ _c(90); + const underline = /* @__PURE__ */ _c(4, 24); + //#endregion + //#region src/args.ts ++function assertEnumValue(arg, value) { ++ if (arg.type !== "enum" || value === void 0) return; ++ const options = arg.options || []; ++ if (options.length > 0 && !options.includes(value)) throw new CLIError(`Invalid value for argument: ${cyan(`--${arg.name}`)} (${cyan(value)}). Expected one of: ${options.map((o) => cyan(o)).join(", ")}.`, "EARG"); ++} + function parseArgs(rawArgs, argsDef) { + const parseOptions = { + boolean: [], + string: [], ++ multiple: [], + alias: {}, + default: {} + }; + const args = resolveArgs(argsDef); ++ const positionals = args.filter(({ type }) => type === "positional"); ++ for (const [index, positional] of positionals.entries()) if (positional.multiple && index < positionals.length - 1) throw new CLIError(`A "multiple" positional argument must be the last positional argument, but "${positional.name}" is not.`, "EARG"); + for (const arg of args) { + if (arg.type === "positional") continue; + if (arg.type === "string" || arg.type === "enum") parseOptions.string.push(arg.name); + else if (arg.type === "boolean") parseOptions.boolean.push(arg.name); +- if (arg.default !== void 0) parseOptions.default[arg.name] = arg.default; ++ if (arg.multiple) parseOptions.multiple.push(arg.name); ++ if (arg.default !== void 0 && !arg.multiple) parseOptions.default[arg.name] = arg.default; + if (arg.alias) parseOptions.alias[arg.name] = arg.alias; + const camelName = camelCase(arg.name); + const kebabName = kebabCase(arg.name); +@@ -158,16 +173,21 @@ function parseArgs(rawArgs, argsDef) { + const parsedArgsProxy = new Proxy(parsed, { get(target, prop) { + return target[prop] ?? target[camelCase(prop)] ?? target[kebabCase(prop)]; + } }); +- for (const [, arg] of args.entries()) if (arg.type === "positional") { ++ for (const [, arg] of args.entries()) if (arg.type === "positional" && arg.multiple) { ++ if (positionalArguments.length === 0 && arg.required !== false) throw new CLIError(`Missing required positional argument: ${arg.name.toUpperCase()}`, "EARG"); ++ parsedArgsProxy[arg.name] = positionalArguments; ++ } else if (arg.type === "positional") { + const nextPositionalArgument = positionalArguments.shift(); + if (nextPositionalArgument !== void 0) parsedArgsProxy[arg.name] = nextPositionalArgument; + else if (arg.default === void 0 && arg.required !== false) throw new CLIError(`Missing required positional argument: ${arg.name.toUpperCase()}`, "EARG"); + else parsedArgsProxy[arg.name] = arg.default; +- } else if (arg.type === "enum") { +- const argument = parsedArgsProxy[arg.name]; +- const options = arg.options || []; +- if (argument !== void 0 && options.length > 0 && !options.includes(argument)) throw new CLIError(`Invalid value for argument: ${cyan(`--${arg.name}`)} (${cyan(argument)}). Expected one of: ${options.map((o) => cyan(o)).join(", ")}.`, "EARG"); +- } else if (arg.required && parsedArgsProxy[arg.name] === void 0) throw new CLIError(`Missing required argument: --${arg.name}`, "EARG"); ++ } else if (arg.multiple) { ++ const values = parsedArgsProxy[arg.name] ?? []; ++ for (const value of values) assertEnumValue(arg, value); ++ if (arg.required && values.length === 0) throw new CLIError(`Missing required argument: --${arg.name}`, "EARG"); ++ parsedArgsProxy[arg.name] = values; ++ } else if (arg.type === "enum") assertEnumValue(arg, parsedArgsProxy[arg.name]); ++ else if (arg.required && parsedArgsProxy[arg.name] === void 0) throw new CLIError(`Missing required argument: --${arg.name}`, "EARG"); + return parsedArgsProxy; + } + function resolveArgs(argsDef) { +@@ -193,7 +213,10 @@ function defineCommand(def) { + return def; + } + async function runCommand(cmd, opts) { +- const cmdArgs = await resolveValue(cmd.args || {}); ++ const cmdArgs = { ++ ...opts.inheritedArgs, ++ ...await resolveValue(cmd.args || {}) ++ }; + const parsedArgs = parseArgs(opts.rawArgs, cmdArgs); + const context = { + rawArgs: opts.rawArgs, +@@ -214,14 +237,21 @@ async function runCommand(cmd, opts) { + if (explicitName) { + const subCommand = await _findSubCommand(subCommands, explicitName); + if (!subCommand) throw new CLIError(`Unknown command ${cyan(explicitName)}`, "E_UNKNOWN_COMMAND"); +- await runCommand(subCommand, { rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1) }); ++ const inheritedArgs = _inheritedArgs(cmdArgs); ++ await runCommand(subCommand, { ++ rawArgs: [..._collectInheritedRawArgs(opts.rawArgs.slice(0, subCommandArgIndex), cmdArgs, inheritedArgs), ...opts.rawArgs.slice(subCommandArgIndex + 1)], ++ inheritedArgs ++ }); + } else { + const defaultSubCommand = await resolveValue(cmd.default); + if (defaultSubCommand) { + if (cmd.run) throw new CLIError(`Cannot specify both 'run' and 'default' on the same command.`, "E_DEFAULT_CONFLICT"); + const subCommand = await _findSubCommand(subCommands, defaultSubCommand); + if (!subCommand) throw new CLIError(`Default sub command ${cyan(defaultSubCommand)} not found in subCommands.`, "E_UNKNOWN_COMMAND"); +- await runCommand(subCommand, { rawArgs: opts.rawArgs }); ++ await runCommand(subCommand, { ++ rawArgs: opts.rawArgs, ++ inheritedArgs: _inheritedArgs(cmdArgs) ++ }); + } else if (!cmd.run) throw new CLIError(`No command specified.`, "E_NO_COMMAND"); + } + } +@@ -278,14 +308,42 @@ function findSubCommandIndex(rawArgs, argsDef) { + return -1; + } + function _isValueFlag(flag, argsDef) { ++ return _matchArg(flag, argsDef, (def) => def.type === "string" || def.type === "enum") !== void 0; ++} ++function _matchArg(flag, argsDef, filter) { + const name = flag.replace(/^-{1,2}/, ""); ++ const direct = _findArg(name, argsDef, filter); ++ if (direct || !name.startsWith("no-")) return direct; ++ return _findArg(name.slice(3), argsDef, (def) => def.type === "boolean" && filter(def)); ++} ++function _findArg(name, argsDef, filter) { + const normalized = camelCase(name); + for (const [key, def] of Object.entries(argsDef)) { +- if (def.type !== "string" && def.type !== "enum") continue; +- if (normalized === camelCase(key)) return true; +- if ((Array.isArray(def.alias) ? def.alias : def.alias ? [def.alias] : []).includes(name)) return true; ++ if (!filter(def)) continue; ++ if (normalized === camelCase(key)) return def; ++ if (toArray(def.alias).includes(name)) return def; ++ } ++} ++function _inheritedArgs(argsDef) { ++ const inherited = {}; ++ for (const [name, def] of Object.entries(argsDef)) if (def.type !== "positional" && def.inherit) inherited[name] = def; ++ return inherited; ++} ++function _collectInheritedRawArgs(rawArgs, argsDef, inherited) { ++ if (Object.keys(inherited).length === 0) return []; ++ const collected = []; ++ for (let i = 0; i < rawArgs.length; i++) { ++ const arg = rawArgs[i]; ++ if (!arg.startsWith("-")) continue; ++ const [flag] = arg.split("=", 1); ++ const takesValue = !arg.includes("=") && _isValueFlag(flag, argsDef); ++ if (_matchArg(flag, inherited, () => true)) { ++ collected.push(arg); ++ if (takesValue && i + 1 < rawArgs.length) collected.push(rawArgs[i + 1]); ++ } ++ if (takesValue) i++; + } +- return false; ++ return collected; + } + //#endregion + //#region src/usage.ts +@@ -307,11 +365,12 @@ async function renderUsage(cmd, parent) { + const commandsLines = []; + const usageLine = []; + for (const arg of cmdArgs) if (arg.type === "positional") { +- const name = arg.name.toUpperCase(); ++ const name = arg.name.toUpperCase() + (arg.multiple ? "..." : ""); + const isRequired = arg.required !== false && arg.default === void 0; + posLines.push([cyan(name + renderValueHint(arg)), renderDescription(arg, isRequired)]); + usageLine.push(isRequired ? `<${name}>` : `[${name}]`); + } else { ++ if (arg.hidden) continue; + const isRequired = arg.required === true && arg.default === void 0; + const argStr = [...(arg.alias || []).map((a) => `-${a}`), `--${arg.name}`].join(", ") + renderValueHint(arg); + argLines.push([cyan(argStr), renderDescription(arg, isRequired)]); +@@ -364,9 +423,10 @@ async function renderUsage(cmd, parent) { + function renderValueHint(arg) { + const valueHint = arg.valueHint ? `=<${arg.valueHint}>` : ""; + const fallbackValueHint = valueHint || `=<${snakeCase(arg.name)}>`; ++ const repeat = arg.multiple ? "..." : ""; + if (!arg.type || arg.type === "positional" || arg.type === "boolean") return valueHint; +- if (arg.type === "enum" && arg.options?.length) return `=<${arg.options.join("|")}>`; +- return fallbackValueHint; ++ if (arg.type === "enum" && arg.options?.length) return `=<${arg.options.join("|")}>${repeat}`; ++ return fallbackValueHint + repeat; + } + function renderDescription(arg, required) { + const requiredHint = required ? gray("(Required)") : ""; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 058cd985c..e6da4705c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ overrides: nuxi: workspace:* rolldown: 1.2.4 +patchedDependencies: + citty@0.2.2: 24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac + importers: .: @@ -101,7 +104,7 @@ importers: devDependencies: '@bomb.sh/tab': specifier: ^0.0.22 - version: 0.0.22(cac@7.0.0)(citty@0.2.2) + version: 0.0.22(cac@7.0.0)(citty@0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac)) '@clack/prompts': specifier: ^1.7.0 version: 1.7.0 @@ -110,7 +113,7 @@ importers: version: 24.13.3 citty: specifier: ^0.2.2 - version: 0.2.2 + version: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) giget: specifier: ^3.3.1 version: 3.3.1 @@ -153,7 +156,7 @@ importers: version: 24.13.3 citty: specifier: ^0.2.2 - version: 0.2.2 + version: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) jiti: specifier: ^2.7.0 version: 2.7.0 @@ -172,9 +175,6 @@ importers: packages/nuxt-cli: dependencies: - '@bomb.sh/tab': - specifier: ^0.0.22 - version: 0.0.22(cac@7.0.0)(citty@0.2.2) '@clack/prompts': specifier: ^1.7.0 version: 1.7.0 @@ -184,9 +184,6 @@ importers: args-tokenizer: specifier: ^0.3.0 version: 0.3.0 - citty: - specifier: ^0.2.2 - version: 0.2.2 clickable-path: specifier: ^0.0.1 version: 0.0.1 @@ -208,9 +205,6 @@ importers: get-port-please: specifier: ^3.2.0 version: 3.2.0 - nypm: - specifier: ^0.6.9 - version: 0.6.9 obug: specifier: ^2.1.4 version: 2.1.4 @@ -254,6 +248,9 @@ importers: specifier: ^0.3.2 version: 0.3.2 devDependencies: + '@bomb.sh/tab': + specifier: ^0.0.22 + version: 0.0.22(cac@7.0.0)(citty@0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac)) '@nuxt/kit': specifier: ^4.5.2 version: 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(yaml@2.9.0))) @@ -269,6 +266,9 @@ importers: '@types/node': specifier: ^24.13.3 version: 24.13.3 + citty: + specifier: ^0.2.2 + version: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) giget: specifier: ^3.3.1 version: 3.3.1 @@ -284,6 +284,9 @@ importers: nitropack: specifier: 2.13.4 version: 2.13.4(oxc-parser@0.143.0)(rolldown@1.2.4)(srvx@0.12.5)(supports-color@10.2.2)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.46.2)(yaml@2.9.0)) + nypm: + specifier: ^0.6.9 + version: 0.6.9 rolldown: specifier: 1.2.4 version: 1.2.4 @@ -6826,10 +6829,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@bomb.sh/tab@0.0.22(cac@7.0.0)(citty@0.2.2)': + '@bomb.sh/tab@0.0.22(cac@7.0.0)(citty@0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac))': optionalDependencies: cac: 7.0.0 - citty: 0.2.2 + citty: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) '@clack/core@1.4.3': dependencies: @@ -7833,7 +7836,7 @@ snapshots: '@nuxt/telemetry@2.8.0(@nuxt/kit@4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@7.3.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.2)(yaml@2.9.0))))': dependencies: '@nuxt/kit': 4.5.2(magic-string@1.1.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@7.3.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.2)(yaml@2.9.0))) - citty: 0.2.2 + citty: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) consola: 3.4.2 ofetch: 2.0.0-alpha.3 rc9: 3.0.1 @@ -9646,7 +9649,7 @@ snapshots: dependencies: consola: 3.4.2 - citty@0.2.2: {} + citty@0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac): {} cli-boxes@3.0.0: {} @@ -10970,7 +10973,7 @@ snapshots: dependencies: '@parcel/watcher': 2.5.6 '@parcel/watcher-wasm': 2.5.6 - citty: 0.2.2 + citty: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) consola: 3.4.2 crossws: 0.4.10(srvx@0.12.5) defu: 6.1.7 @@ -11549,7 +11552,7 @@ snapshots: archiver: 7.0.1 c12: 3.3.4(magicast@0.5.4) chokidar: 5.0.0 - citty: 0.2.2 + citty: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) compatx: 0.2.0 confbox: 0.2.4 consola: 3.4.2 @@ -11660,7 +11663,7 @@ snapshots: archiver: 7.0.1 c12: 3.3.4(magicast@0.5.4) chokidar: 5.0.0 - citty: 0.2.2 + citty: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) compatx: 0.2.0 confbox: 0.2.4 consola: 3.4.2 @@ -12080,7 +12083,7 @@ snapshots: nypm@0.6.9: dependencies: - citty: 0.2.2 + citty: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) pathe: 2.0.3 tinyexec: 1.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fde7eb845..4bcf5a794 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,3 +32,6 @@ allowBuilds: '@parcel/watcher': false esbuild: false unrs-resolver: false + +patchedDependencies: + citty@0.2.2: patches/citty@0.2.2.patch