From c5df00a1ef0d5543f8b010f1c08512d58ce80467 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Jun 2026 14:09:17 +0100 Subject: [PATCH 1/4] refactor(cli): add spec-driven command framework and migrate dev Introduce a zero-dependency CLI command framework under src/cli/ where a single CommandSpec is the source of truth for both argument parsing and help text, eliminating the drift between the hand-rolled parser and the separate inline help templates. Migrates the `dev` command onto the framework as the first slice; all other commands keep using the existing parseArgs/printHelp path for now. - cli/types.ts: CommandSpec/ArgSpec definitions + InferValues typing - cli/parse.ts: node:util parseArgs wrapper with typed coercion and the value-validation guards ported from cli-args.ts (CliUsageError) - cli/help.ts: help rendered from the same spec (TTY-aware styling) - cli/command.ts: defineCommand + runCommand (handles --help, prints clean usage errors with a help hint) - cli/runtime.ts: extracted shared Vite helpers (loadVite, getViteVersion, buildViteConfig, applyViteConfigCompatibility) so commands no longer depend on cli.ts - cli/commands/dev.ts: dev command spec + run, with heavy runtime imports lazy-loaded so the module stays cheap to import and unit test - tests/cli-framework.test.ts: parse + help-generation coverage --- packages/vinext/src/cli.ts | 282 +--------------------- packages/vinext/src/cli/command.ts | 57 +++++ packages/vinext/src/cli/commands/dev.ts | 165 +++++++++++++ packages/vinext/src/cli/help.ts | 110 +++++++++ packages/vinext/src/cli/parse.ts | 180 ++++++++++++++ packages/vinext/src/cli/runtime.ts | 138 +++++++++++ packages/vinext/src/cli/types.ts | 117 +++++++++ tests/cli-framework.test.ts | 303 ++++++++++++++++++++++++ 8 files changed, 1083 insertions(+), 269 deletions(-) create mode 100644 packages/vinext/src/cli/command.ts create mode 100644 packages/vinext/src/cli/commands/dev.ts create mode 100644 packages/vinext/src/cli/help.ts create mode 100644 packages/vinext/src/cli/parse.ts create mode 100644 packages/vinext/src/cli/runtime.ts create mode 100644 packages/vinext/src/cli/types.ts create mode 100644 tests/cli-framework.test.ts diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index be26be0ec..a70655a9a 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -18,16 +18,9 @@ import vinext from "./index.js"; import { runPrerender } from "./build/run-prerender.js"; import path from "node:path"; import fs from "node:fs"; -import { pathToFileURL } from "node:url"; -import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { - detectPackageManager, - ensureViteConfigCompatibility, - hasAppDir, - hasViteConfig, -} from "./utils/project.js"; +import { detectPackageManager, hasAppDir, hasViteConfig } from "./utils/project.js"; import { deploy as runDeploy, parseDeployArgs } from "./deploy.js"; import { runCheck, formatReport } from "./check.js"; import { init as runInit, getReactUpgradeDeps } from "./init.js"; @@ -43,66 +36,19 @@ import { cleanBuildOutput } from "./build/clean-output.js"; import { resolveVinextPackageRoot } from "./utils/vinext-root.js"; import { parseArgs } from "./cli-args.js"; import { - type DevLockfile, - formatAlreadyRunningError, - tryAcquireLockfile, -} from "./server/dev-lockfile.js"; + applyViteConfigCompatibility, + buildViteConfig, + getViteVersion, + loadVite, + type ViteModule, +} from "./cli/runtime.js"; +import { runCommand } from "./cli/command.js"; +import { devCommand } from "./cli/commands/dev.js"; import { generateRouteTypes } from "./typegen.js"; -// ─── Resolve Vite from the project root ──────────────────────────────────────── -// -// When vinext is installed via `bun link` or `npm link`, Node follows the -// symlink back to the monorepo and resolves `vite` from the monorepo's -// node_modules — not the project's. This causes dual Vite instances, dual -// React copies, and plugin resolution failures. -// -// To fix this, we resolve Vite dynamically from `process.cwd()` at runtime -// using `createRequire`. This ensures we always use the project's Vite. - -type ViteModule = { - createServer: typeof import("vite").createServer; - build: typeof import("vite").build; - createBuilder: typeof import("vite").createBuilder; - createLogger: typeof import("vite").createLogger; - loadConfigFromFile: typeof import("vite").loadConfigFromFile; - version: string; -}; - -let _viteModule: ViteModule | null = null; - -/** - * Dynamically load Vite from the project root. Falls back to the bundled - * copy if the project doesn't have its own Vite installation. - */ -async function loadVite(): Promise { - if (_viteModule) return _viteModule; - - const projectRoot = process.cwd(); - let vitePath: string; - - try { - // Resolve "vite" from the project root, not from vinext's location - const require = createRequire(path.join(projectRoot, "package.json")); - vitePath = require.resolve("vite"); - } catch { - // Fallback: use the Vite that ships with vinext (works for non-linked installs) - vitePath = "vite"; - } - - // On Windows, absolute paths must be file:// URLs for ESM import(). - // The fallback ("vite") is a bare specifier and works as-is. - const viteUrl = vitePath === "vite" ? vitePath : pathToFileURL(vitePath).href; - const vite = (await import(/* @vite-ignore */ viteUrl)) as ViteModule; - _viteModule = vite; - return vite; -} - -/** - * Get the Vite version string. Returns "unknown" before loadVite() is called. - */ -function getViteVersion(): string { - return _viteModule?.version ?? "unknown"; -} +// Vite is resolved from the project root at runtime — see ./cli/runtime.ts for +// the loadVite()/getViteVersion()/buildViteConfig()/applyViteConfigCompatibility() +// helpers shared across the dev, build, start, and deploy commands. const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8")) .version as string; @@ -229,195 +175,8 @@ async function loadBuildEmptyOutDir(vite: ViteModule, root: string): Promise = {}, logger?: import("vite").Logger) { - const hasConfig = hasViteConfig(process.cwd()); - - // If a vite.config exists, let Vite load it — only set root and overrides. - // The user's config already has vinext() + rsc() plugins configured. - // Adding them here too would duplicate the RSC transform (causes - // "Identifier has already been declared" errors in production builds). - if (hasConfig) { - return { - root: process.cwd(), - ...(logger ? { customLogger: logger } : {}), - ...overrides, - }; - } - - // No vite.config — auto-configure everything. - // vinext() auto-registers @vitejs/plugin-rsc when app/ is detected, - // so we only need vinext() in the plugins array. - const config: Record = { - root: process.cwd(), - configFile: false, - plugins: [vinext()], - // Deduplicate React packages to prevent "Invalid hook call" errors - // when vinext is symlinked (bun link / npm link) and both vinext's - // and the project's node_modules contain React. - resolve: { - dedupe: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], - }, - ...(logger ? { customLogger: logger } : {}), - ...overrides, - }; - - return config; -} - -/** - * Ensure the project's package.json has `"type": "module"` before Vite loads - * the vite.config.ts. This prevents the esbuild CJS-bundling path that Vite - * takes for projects without `"type": "module"`, which produces a `.mjs` temp - * file containing `require()` calls — calls that fail on Node 22 when - * targeting pure-ESM packages like `@cloudflare/vite-plugin`. - * - * This mirrors what `vinext init` does, but is applied lazily at dev/build - * time for projects that were set up before `vinext init` added the step, or - * that were migrated manually. - */ -function applyViteConfigCompatibility(root: string): void { - const result = ensureViteConfigCompatibility(root); - if (!result) return; - - for (const [oldName, newName] of result.renamed) { - console.warn(` [vinext] Renamed ${oldName} → ${newName} (required for "type": "module")`); - } - if (result.addedTypeModule) { - console.warn( - ` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` + - ` Run \`vinext init\` to review all project configuration.`, - ); - } -} - // ─── Commands ───────────────────────────────────────────────────────────────── -async function dev() { - const parsed = parseArgs(rawArgs); - if (parsed.help) return printHelp("dev"); - - loadDotenv({ - root: process.cwd(), - mode: "development", - }); - - // Ensure "type": "module" in package.json before Vite loads vite.config.ts. - // Without this, Vite bundles the config as CJS and tries require() on pure-ESM - // packages like @cloudflare/vite-plugin, which fails on Node 22. - applyViteConfigCompatibility(process.cwd()); - - const vite = await loadVite(); - - const port = parsed.port ?? 3000; - const host = parsed.hostname ?? "localhost"; - - // Acquire the dev lock file. If another live `vinext dev` is running in this - // directory, print an actionable error (PID + URL) and exit. This is - // especially useful for AI coding agents, which frequently attempt to start - // a dev server without knowing one is already running. - // - // Disabled when VINEXT_NO_DEV_LOCK is set (escape hatch for unusual setups). - let lockfile: DevLockfile | undefined; - // Capture the acquisition timestamp so we can preserve it across the - // post-listen update(). `startedAt` is meant to reflect when this process - // started, not when the URL was resolved. - const startedAt = Date.now(); - if (process.env.VINEXT_NO_DEV_LOCK !== "1") { - const root = process.cwd(); - // Substitute "localhost" for wildcard binds so the URL is actually - // clickable when surfaced in the lock file before server.listen() has - // had a chance to resolve the real URL. - const initialDisplayHost = host === "0.0.0.0" ? "localhost" : host; - const acquired = tryAcquireLockfile({ - root, - info: { - pid: process.pid, - port, - hostname: host, - appUrl: `http://${initialDisplayHost}:${port}`, - startedAt, - cwd: root, - }, - }); - if (!acquired.ok) { - console.error( - "\n " + - formatAlreadyRunningError({ - existing: acquired.existing, - cwd: root, - lockfilePath: acquired.lockfilePath, - }).replace(/\n/g, "\n ") + - "\n", - ); - process.exit(1); - } - lockfile = acquired.lockfile; - } - - console.log(`\n vinext dev (Vite ${getViteVersion()})\n`); - - const config = buildViteConfig({ - server: { port, host }, - }); - - // If anything between here and the first successful listen() throws (e.g. - // strictPort and the port is taken), release the lock immediately so we - // don't leave a misleading "server running" entry behind in the brief - // window before the exit handler runs. The exit handler still serves as - // a safety net for unexpected exit paths. - let server; - try { - server = await vite.createServer(config); - await server.listen(); - } catch (err) { - lockfile?.release(); - throw err; - } - server.printUrls(); - - // Once the server is actually listening, the port may have changed (e.g. - // Vite picked a free port if the requested one was in use). Update the - // lock file so other tools see the right port/URL. - // - // Prefer Vite's resolvedUrls.local[0] because it handles wildcard binds - // (e.g. host "0.0.0.0") by substituting "localhost" so the URL is - // actually clickable. Fall back to httpServer.address() if Vite didn't - // populate resolvedUrls for some reason. - if (lockfile) { - const resolved = server.resolvedUrls?.local[0]; - let actualPort = port; - let appUrl: string; - if (resolved) { - appUrl = resolved.replace(/\/$/, ""); - try { - const parsed = new URL(appUrl); - actualPort = parsed.port ? Number.parseInt(parsed.port, 10) : actualPort; - } catch { - // ignore — keep requested port - } - } else { - const address = server.httpServer?.address(); - actualPort = typeof address === "object" && address ? address.port : port; - appUrl = `http://${host === "0.0.0.0" ? "localhost" : host}:${actualPort}`; - } - lockfile.update({ - pid: process.pid, - port: actualPort, - hostname: host, - appUrl, - // Preserve the original acquire-time startedAt rather than resetting - // to "now". startedAt represents when the process started. - startedAt, - cwd: process.cwd(), - }); - } -} - async function buildApp() { const parsed = parseArgs(rawArgs); if (parsed.help) return printHelp("build"); @@ -820,21 +579,6 @@ async function initCommand() { // ─── Help ───────────────────────────────────────────────────────────────────── function printHelp(cmd?: string) { - if (cmd === "dev") { - console.log(` - vinext dev - Start development server - - Usage: vinext dev [options] - - Options: - -p, --port Port to listen on (default: 3000) - -H, --hostname Hostname to bind to (default: localhost) - --turbopack Accepted for compatibility (no-op, Vite is always used) - -h, --help Show this help -`); - return; - } - if (cmd === "build") { console.log(` vinext build - Build for production @@ -1046,7 +790,7 @@ if (command === "--help" || command === "-h" || !command) { switch (command) { case "dev": - dev().catch((e) => { + runCommand(devCommand, rawArgs).catch((e) => { console.error(e); process.exit(1); }); diff --git a/packages/vinext/src/cli/command.ts b/packages/vinext/src/cli/command.ts new file mode 100644 index 000000000..e194931b1 --- /dev/null +++ b/packages/vinext/src/cli/command.ts @@ -0,0 +1,57 @@ +/** + * Command runner for the vinext CLI framework. + * + * Ties together parsing ({@link parseCommand}), help rendering + * ({@link renderCommandHelp}), and execution (`spec.run`): + * + * 1. If `--help`/`-h` is present, print the generated help and return. + * 2. Otherwise parse argv into typed values and invoke `spec.run`. + * 3. {@link CliUsageError}s (bad flags) are printed cleanly without a stack + * trace, followed by a hint to run `--help`, and exit with code 1. + * + * `defineCommand` is an identity helper that preserves the precise `args` + * generic so `run`'s `ctx.values` is fully typed at the definition site. + */ + +import { CliUsageError, parseCommand } from "./parse.js"; +import { renderCommandHelp } from "./help.js"; +import type { ArgSpec, CommandSpec } from "./types.js"; + +/** Identity helper that infers and preserves a command's `args` generic. */ +export function defineCommand>( + spec: CommandSpec, +): CommandSpec { + return spec; +} + +/** + * Parse argv for `spec`, handle `--help`, and run the command. + * + * Parsing/usage errors are reported to stderr and exit the process with code 1. + * Errors thrown by `spec.run` propagate to the caller (the top-level CLI + * dispatcher already wraps command execution in a `.catch`). + */ +export async function runCommand>( + spec: CommandSpec, + argv: string[], +): Promise { + let parsed; + try { + parsed = parseCommand(spec, argv); + } catch (err) { + if (err instanceof CliUsageError) { + process.stderr.write( + `\n ${err.message}\n Run \`vinext ${spec.name} --help\` for usage.\n\n`, + ); + process.exit(1); + } + throw err; + } + + if (parsed.values.help) { + process.stdout.write(renderCommandHelp(spec) + "\n"); + return; + } + + await spec.run({ values: parsed.values, positionals: parsed.positionals }); +} diff --git a/packages/vinext/src/cli/commands/dev.ts b/packages/vinext/src/cli/commands/dev.ts new file mode 100644 index 000000000..8f7058b40 --- /dev/null +++ b/packages/vinext/src/cli/commands/dev.ts @@ -0,0 +1,165 @@ +/** + * `vinext dev` — start the development server (Vite). + * + * The command's flags and help text are both derived from the {@link CommandSpec} + * below; there is no separate help template to keep in sync. + */ + +import { defineCommand } from "../command.js"; +import type { DevLockfile } from "../../server/dev-lockfile.js"; + +export const devCommand = defineCommand({ + name: "dev", + summary: "Start development server", + description: "Start the Vite-powered development server for your Next.js app.", + args: { + port: { + type: "port", + short: "p", + valueHint: "port", + description: "Port to listen on", + default: 3000, + }, + hostname: { + type: "string", + short: "H", + valueHint: "host", + description: "Hostname to bind to", + default: "localhost", + }, + turbopack: { + type: "boolean", + description: "Accepted for compatibility (no-op, Vite is always used)", + }, + }, + examples: [ + { command: "vinext dev", description: "Start dev server on port 3000" }, + { command: "vinext dev -p 4000", description: "Start dev server on port 4000" }, + ], + async run({ values }) { + const port = values.port; + const host = values.hostname; + + // Lazy-load the heavy runtime helpers (which pull in the full vinext Vite + // plugin) so importing this command module — e.g. for unit-testing its + // spec and help output — stays cheap and side-effect-free. + const { applyViteConfigCompatibility, buildViteConfig, getViteVersion, loadVite } = + await import("../runtime.js"); + const { loadDotenv } = await import("../../config/dotenv.js"); + const { formatAlreadyRunningError, tryAcquireLockfile } = + await import("../../server/dev-lockfile.js"); + + loadDotenv({ + root: process.cwd(), + mode: "development", + }); + + // Ensure "type": "module" in package.json before Vite loads vite.config.ts. + // Without this, Vite bundles the config as CJS and tries require() on pure-ESM + // packages like @cloudflare/vite-plugin, which fails on Node 22. + applyViteConfigCompatibility(process.cwd()); + + const vite = await loadVite(); + + // Acquire the dev lock file. If another live `vinext dev` is running in this + // directory, print an actionable error (PID + URL) and exit. This is + // especially useful for AI coding agents, which frequently attempt to start + // a dev server without knowing one is already running. + // + // Disabled when VINEXT_NO_DEV_LOCK is set (escape hatch for unusual setups). + let lockfile: DevLockfile | undefined; + // Capture the acquisition timestamp so we can preserve it across the + // post-listen update(). `startedAt` is meant to reflect when this process + // started, not when the URL was resolved. + const startedAt = Date.now(); + if (process.env.VINEXT_NO_DEV_LOCK !== "1") { + const root = process.cwd(); + // Substitute "localhost" for wildcard binds so the URL is actually + // clickable when surfaced in the lock file before server.listen() has + // had a chance to resolve the real URL. + const initialDisplayHost = host === "0.0.0.0" ? "localhost" : host; + const acquired = tryAcquireLockfile({ + root, + info: { + pid: process.pid, + port, + hostname: host, + appUrl: `http://${initialDisplayHost}:${port}`, + startedAt, + cwd: root, + }, + }); + if (!acquired.ok) { + console.error( + "\n " + + formatAlreadyRunningError({ + existing: acquired.existing, + cwd: root, + lockfilePath: acquired.lockfilePath, + }).replace(/\n/g, "\n ") + + "\n", + ); + process.exit(1); + } + lockfile = acquired.lockfile; + } + + console.log(`\n vinext dev (Vite ${getViteVersion()})\n`); + + const config = buildViteConfig({ + server: { port, host }, + }); + + // If anything between here and the first successful listen() throws (e.g. + // strictPort and the port is taken), release the lock immediately so we + // don't leave a misleading "server running" entry behind in the brief + // window before the exit handler runs. The exit handler still serves as + // a safety net for unexpected exit paths. + let server; + try { + server = await vite.createServer(config); + await server.listen(); + } catch (err) { + lockfile?.release(); + throw err; + } + server.printUrls(); + + // Once the server is actually listening, the port may have changed (e.g. + // Vite picked a free port if the requested one was in use). Update the + // lock file so other tools see the right port/URL. + // + // Prefer Vite's resolvedUrls.local[0] because it handles wildcard binds + // (e.g. host "0.0.0.0") by substituting "localhost" so the URL is + // actually clickable. Fall back to httpServer.address() if Vite didn't + // populate resolvedUrls for some reason. + if (lockfile) { + const resolved = server.resolvedUrls?.local[0]; + let actualPort = port; + let appUrl: string; + if (resolved) { + appUrl = resolved.replace(/\/$/, ""); + try { + const parsedUrl = new URL(appUrl); + actualPort = parsedUrl.port ? Number.parseInt(parsedUrl.port, 10) : actualPort; + } catch { + // ignore — keep requested port + } + } else { + const address = server.httpServer?.address(); + actualPort = typeof address === "object" && address ? address.port : port; + appUrl = `http://${host === "0.0.0.0" ? "localhost" : host}:${actualPort}`; + } + lockfile.update({ + pid: process.pid, + port: actualPort, + hostname: host, + appUrl, + // Preserve the original acquire-time startedAt rather than resetting + // to "now". startedAt represents when the process started. + startedAt, + cwd: process.cwd(), + }); + } + }, +}); diff --git a/packages/vinext/src/cli/help.ts b/packages/vinext/src/cli/help.ts new file mode 100644 index 000000000..e6d613a7e --- /dev/null +++ b/packages/vinext/src/cli/help.ts @@ -0,0 +1,110 @@ +/** + * Help-text generation for the vinext CLI command framework. + * + * Help is rendered from the same {@link CommandSpec} that drives parsing, so + * the documented flags can never drift from the parsed ones. ANSI styling is + * applied only when stdout is a TTY, so piped/redirected output and test + * snapshots stay plain text. + */ + +import type { ArgSpec, CommandSpec } from "./types.js"; + +const INDENT = " "; +const ITEM_INDENT = " "; +/** Spaces between an item's label column and its description column. */ +const COLUMN_GAP = 2; + +const isTTY = () => Boolean(process.stdout.isTTY); +const bold = (s: string) => (isTTY() ? `\x1b[1m${s}\x1b[0m` : s); +const dim = (s: string) => (isTTY() ? `\x1b[2m${s}\x1b[0m` : s); + +/** Default angle-bracket placeholder for a value flag lacking an explicit hint. */ +function valuePlaceholder(arg: ArgSpec): string { + if (arg.type === "boolean") return ""; + const hint = + arg.valueHint ?? (arg.type === "port" ? "port" : arg.type === "string" ? "value" : "n"); + return ` <${hint}>`; +} + +/** The left-hand label for an option row, e.g. `-p, --port `. */ +function optionLabel(name: string, arg: ArgSpec): string { + const short = arg.short ? `-${arg.short}, ` : ""; + return `${short}--${name}${valuePlaceholder(arg)}`; +} + +/** The trailing `(default: …)` suffix for value flags that declare a default. */ +function defaultSuffix(arg: ArgSpec): string { + if (arg.type === "boolean" || arg.default === undefined) return ""; + return dim(` (default: ${arg.default})`); +} + +/** Render a block of aligned `label description` rows. */ +function renderRows(rows: Array<{ label: string; description: string }>): string[] { + const width = Math.max(...rows.map((r) => r.label.length)); + return rows.map((r) => `${ITEM_INDENT}${r.label.padEnd(width + COLUMN_GAP)}${r.description}`); +} + +/** + * Render the full `--help` output for a single command. + * + * The framework always documents a trailing `-h, --help` row, matching the + * `help` flag it injects during parsing. + */ +export function renderCommandHelp>(spec: CommandSpec): string { + const lines: string[] = []; + const args = (spec.args ?? {}) as Record; + + lines.push(""); + lines.push(`${INDENT}${bold(`vinext ${spec.name}`)} - ${spec.summary}`); + lines.push(""); + lines.push(`${INDENT}${bold("Usage:")} ${spec.usage ?? `vinext ${spec.name} [options]`}`); + + if (spec.description) { + lines.push(""); + for (const line of spec.description.split("\n")) { + lines.push(line ? `${INDENT}${line}` : ""); + } + } + + if (spec.positionals && spec.positionals.length > 0) { + lines.push(""); + lines.push(`${INDENT}${bold("Arguments:")}`); + lines.push( + ...renderRows( + spec.positionals.map((p) => ({ + label: p.variadic ? `[${p.name}...]` : `[${p.name}]`, + description: p.description, + })), + ), + ); + } + + lines.push(""); + lines.push(`${INDENT}${bold("Options:")}`); + const optionRows = Object.entries(args).map(([name, arg]) => ({ + label: optionLabel(name, arg), + description: `${arg.description}${defaultSuffix(arg)}`, + })); + optionRows.push({ label: "-h, --help", description: "Show this help" }); + lines.push(...renderRows(optionRows)); + + if (spec.examples && spec.examples.length > 0) { + lines.push(""); + lines.push(`${INDENT}${bold("Examples:")}`); + lines.push( + ...renderRows( + spec.examples.map((e) => ({ label: e.command, description: e.description ?? "" })), + ), + ); + } + + if (spec.notes) { + lines.push(""); + for (const line of spec.notes.split("\n")) { + lines.push(line ? `${INDENT}${line}` : ""); + } + } + + lines.push(""); + return lines.join("\n"); +} diff --git a/packages/vinext/src/cli/parse.ts b/packages/vinext/src/cli/parse.ts new file mode 100644 index 000000000..2050cc497 --- /dev/null +++ b/packages/vinext/src/cli/parse.ts @@ -0,0 +1,180 @@ +/** + * Argument parsing engine for the vinext CLI command framework. + * + * Wraps Node's built-in `node:util` `parseArgs` (the parser already used by + * `vinext deploy`) and layers on: + * + * - Typed coercion driven by each flag's {@link ArgType} (`port`, `int`, + * `positiveInt`, `string`, `boolean`). + * - The strict value validation previously hand-rolled in `cli-args.ts`: + * value-taking flags error on missing/empty values and reject a following + * token that "looks like another flag" (e.g. `--port --hostname`). + * - Per-command defaults applied to the returned values. + * - Graceful pass-through of unknown flags (drop-in `next` CLI friendliness). + * + * Parsing errors throw a {@link CliUsageError} so the caller can render them + * cleanly without a stack trace. + */ + +import { parseArgs as nodeParseArgs } from "node:util"; +import type { ArgSpec, CommandSpec, InferValues } from "./types.js"; + +/** + * Matches long flags (`--foo`) and single-letter short flags (`-x`). Digits and + * multi-char sequences (e.g. `-1`, `-abc`) are intentionally excluded so that + * negative numbers are not mistaken for flags. + */ +const FLAG_PATTERN = /^(?:--|-[a-zA-Z]$)/; + +/** Value-taking arg kinds (everything except `boolean`). */ +type ValueArgType = Exclude; + +/** Error raised for invalid CLI usage (bad flag value, missing value, etc.). */ +export class CliUsageError extends Error { + constructor(message: string) { + super(message); + this.name = "CliUsageError"; + } +} + +/** Result of parsing one command's argv. */ +export type ParseResult> = { + /** Parsed, coerced, defaulted flag values plus the framework `help` flag. */ + values: InferValues & { help: boolean }; + /** Positional arguments in order. */ + positionals: string[]; +}; + +function coerceInteger(raw: string, flag: string): number { + const parsed = Number(raw); + if (!Number.isInteger(parsed)) { + throw new CliUsageError(`${flag} expects an integer, but got "${raw}".`); + } + return parsed; +} + +/** + * Coerce a raw string value into the type declared by its {@link ArgSpec}. + * Mirrors the validation messages from the legacy `cli-args.ts` parser. + */ +function coerceValue(raw: string, type: ValueArgType, flag: string): string | number { + switch (type) { + case "string": + return raw; + case "int": + return coerceInteger(raw, flag); + case "positiveInt": { + // Match the legacy parsePositiveIntegerArg: a single "positive integer" + // message covers both non-integers (e.g. "4.5") and values <= 0. + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CliUsageError(`${flag} expects a positive integer, but got "${raw}".`); + } + return parsed; + } + case "port": { + const parsed = coerceInteger(raw, flag); + if (parsed < 0 || parsed > 65535) { + throw new CliUsageError(`${flag} expects a valid port (0-65535), but got "${raw}".`); + } + return parsed; + } + } +} + +/** + * Parse a command's argv into typed, validated values. + * + * A `help` boolean flag (with `-h` short alias) is always recognized, even if + * the command does not declare it, so `--help` works uniformly everywhere. + * + * @throws {CliUsageError} when a value-taking flag is missing its value, is + * given an empty value, is followed by something that looks like another + * flag, or fails type coercion (e.g. a non-integer `--port`). + */ +export function parseCommand>( + spec: Pick, "args">, + argv: string[], +): ParseResult { + const args = (spec.args ?? {}) as Record; + + // Build the node:util options map. A `help` flag is always available. + const options: Record< + string, + { type: "boolean" | "string"; short?: string; multiple?: boolean } + > = { + help: { type: "boolean", short: "h" }, + }; + for (const [name, arg] of Object.entries(args)) { + options[name] = { + type: arg.type === "boolean" ? "boolean" : "string", + ...(arg.short ? { short: arg.short } : {}), + ...(arg.multiple ? { multiple: true } : {}), + }; + } + + const { + values: rawValues, + positionals, + tokens, + } = nodeParseArgs({ + args: argv, + options, + allowPositionals: true, + // Lenient: unknown flags are ignored rather than throwing, matching the + // drop-in `next` CLI behavior (people pass flags vinext doesn't model). + strict: false, + tokens: true, + }); + + // Single pass over the token stream to (a) validate value-taking flags and + // (b) remember the exact form the user typed (`-p` vs `--port`) so coercion + // errors below reference the same spelling. Tokens preserve whether a value + // was inline (`--port=3000`) vs. consumed from the next token (`--port 3000`), + // which is what lets us reproduce the "looks like another flag" guard. + const typedAs: Record = {}; + for (const token of tokens) { + if (token.kind !== "option") continue; + const arg = args[token.name]; + if (!arg || arg.type === "boolean") continue; + + const label = token.rawName; + typedAs[token.name] = label; + + if (token.value === undefined || token.value === "") { + throw new CliUsageError(`${label} requires a value, but none was provided.`); + } + if (!token.inlineValue && FLAG_PATTERN.test(token.value)) { + throw new CliUsageError( + `${label} requires a value, but got "${token.value}" which looks like another flag.`, + ); + } + } + + // Build the typed result by iterating the spec (never the raw values) so + // unknown flags are dropped, defaults are applied, and types are coerced. + const result: Record = {}; + for (const [name, arg] of Object.entries(args)) { + const raw = rawValues[name]; + const label = typedAs[name] ?? `--${name}`; + + if (arg.type === "boolean") { + result[name] = raw === true ? true : (arg.default ?? false); + continue; + } + + if (arg.multiple) { + const list = Array.isArray(raw) ? raw : []; + result[name] = list.map((v) => coerceValue(String(v), arg.type as ValueArgType, label)); + continue; + } + + result[name] = + typeof raw === "string" ? coerceValue(raw, arg.type as ValueArgType, label) : arg.default; + } + + return { + values: { ...result, help: rawValues.help === true } as InferValues & { help: boolean }, + positionals, + }; +} diff --git a/packages/vinext/src/cli/runtime.ts b/packages/vinext/src/cli/runtime.ts new file mode 100644 index 000000000..bc297d58f --- /dev/null +++ b/packages/vinext/src/cli/runtime.ts @@ -0,0 +1,138 @@ +/** + * Shared Vite runtime helpers for the vinext CLI. + * + * These were extracted from `cli.ts` so individual command modules (e.g. + * `cli/commands/dev.ts`) can load Vite and build a Vite config without + * depending on the CLI entrypoint. `dev`, `build`, `start`, and `deploy` all + * share the same project-root Vite resolution and auto-configuration. + */ + +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import vinext from "../index.js"; +import { ensureViteConfigCompatibility, hasViteConfig } from "../utils/project.js"; + +// ─── Resolve Vite from the project root ──────────────────────────────────────── +// +// When vinext is installed via `bun link` or `npm link`, Node follows the +// symlink back to the monorepo and resolves `vite` from the monorepo's +// node_modules — not the project's. This causes dual Vite instances, dual +// React copies, and plugin resolution failures. +// +// To fix this, we resolve Vite dynamically from `process.cwd()` at runtime +// using `createRequire`. This ensures we always use the project's Vite. + +export type ViteModule = { + createServer: typeof import("vite").createServer; + build: typeof import("vite").build; + createBuilder: typeof import("vite").createBuilder; + createLogger: typeof import("vite").createLogger; + loadConfigFromFile: typeof import("vite").loadConfigFromFile; + version: string; +}; + +let _viteModule: ViteModule | null = null; + +/** + * Dynamically load Vite from the project root. Falls back to the bundled + * copy if the project doesn't have its own Vite installation. + */ +export async function loadVite(): Promise { + if (_viteModule) return _viteModule; + + const projectRoot = process.cwd(); + let vitePath: string; + + try { + // Resolve "vite" from the project root, not from vinext's location + const require = createRequire(path.join(projectRoot, "package.json")); + vitePath = require.resolve("vite"); + } catch { + // Fallback: use the Vite that ships with vinext (works for non-linked installs) + vitePath = "vite"; + } + + // On Windows, absolute paths must be file:// URLs for ESM import(). + // The fallback ("vite") is a bare specifier and works as-is. + const viteUrl = vitePath === "vite" ? vitePath : pathToFileURL(vitePath).href; + const vite = (await import(/* @vite-ignore */ viteUrl)) as ViteModule; + _viteModule = vite; + return vite; +} + +/** + * Get the Vite version string. Returns "unknown" before loadVite() is called. + */ +export function getViteVersion(): string { + return _viteModule?.version ?? "unknown"; +} + +/** + * Build the Vite config automatically. If a vite.config.ts exists in the + * project, Vite will merge our config with it (theirs takes precedence). + * If there's no vite.config, this provides everything needed. + */ +export function buildViteConfig( + overrides: Record = {}, + logger?: import("vite").Logger, +) { + const hasConfig = hasViteConfig(process.cwd()); + + // If a vite.config exists, let Vite load it — only set root and overrides. + // The user's config already has vinext() + rsc() plugins configured. + // Adding them here too would duplicate the RSC transform (causes + // "Identifier has already been declared" errors in production builds). + if (hasConfig) { + return { + root: process.cwd(), + ...(logger ? { customLogger: logger } : {}), + ...overrides, + }; + } + + // No vite.config — auto-configure everything. + // vinext() auto-registers @vitejs/plugin-rsc when app/ is detected, + // so we only need vinext() in the plugins array. + const config: Record = { + root: process.cwd(), + configFile: false, + plugins: [vinext()], + // Deduplicate React packages to prevent "Invalid hook call" errors + // when vinext is symlinked (bun link / npm link) and both vinext's + // and the project's node_modules contain React. + resolve: { + dedupe: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], + }, + ...(logger ? { customLogger: logger } : {}), + ...overrides, + }; + + return config; +} + +/** + * Ensure the project's package.json has `"type": "module"` before Vite loads + * the vite.config.ts. This prevents the esbuild CJS-bundling path that Vite + * takes for projects without `"type": "module"`, which produces a `.mjs` temp + * file containing `require()` calls — calls that fail on Node 22 when + * targeting pure-ESM packages like `@cloudflare/vite-plugin`. + * + * This mirrors what `vinext init` does, but is applied lazily at dev/build + * time for projects that were set up before `vinext init` added the step, or + * that were migrated manually. + */ +export function applyViteConfigCompatibility(root: string): void { + const result = ensureViteConfigCompatibility(root); + if (!result) return; + + for (const [oldName, newName] of result.renamed) { + console.warn(` [vinext] Renamed ${oldName} → ${newName} (required for "type": "module")`); + } + if (result.addedTypeModule) { + console.warn( + ` [vinext] Added "type": "module" to package.json (required for Vite ESM config loading).\n` + + ` Run \`vinext init\` to review all project configuration.`, + ); + } +} diff --git a/packages/vinext/src/cli/types.ts b/packages/vinext/src/cli/types.ts new file mode 100644 index 000000000..beedafe3b --- /dev/null +++ b/packages/vinext/src/cli/types.ts @@ -0,0 +1,117 @@ +/** + * Type definitions for the vinext CLI command framework. + * + * A command is described declaratively by a {@link CommandSpec}: its name, a + * one-line summary, an optional longer description, its flags ({@link ArgSpec}), + * positionals, and examples. This single spec is the source of truth for *both* + * argument parsing (see `./parse.ts`) and help text generation (see `./help.ts`), + * so the two can never drift out of sync. + * + * The `run` callback receives a fully parsed, typed {@link CommandContext}. + */ + +/** + * The supported argument value kinds. + * + * - `boolean` — a flag with no value (`--verbose`). Always defined (false when absent). + * - `string` — an arbitrary string value (`--hostname localhost`). + * - `int` — any integer, validated with strict `Number()` parsing. + * - `port` — an integer in the valid TCP port range (0–65535). + * - `positiveInt` — an integer greater than zero. + */ +export type ArgType = "boolean" | "string" | "int" | "port" | "positiveInt"; + +/** Declarative description of a single CLI flag. */ +export type ArgSpec = { + /** Value kind. Drives both parsing/validation and the help placeholder. */ + type: ArgType; + /** Single-letter short alias, without the dash (e.g. `"p"` for `-p`). */ + short?: string; + /** Human-readable description, shown in the command's `--help` output. */ + description: string; + /** + * Placeholder shown in help for value-taking flags. Rendered wrapped in + * angle brackets, e.g. `valueHint: "port"` → `--port `. Ignored for + * `boolean` args. Defaults to the arg's type name when omitted. + */ + valueHint?: string; + /** + * Default value applied at runtime when the flag is absent, and shown in + * help as `(default: …)`. For `boolean` args the default is always `false`. + */ + default?: string | number | boolean; + /** Allow the flag to be repeated; values are collected into an array. */ + multiple?: boolean; +}; + +/** A named positional argument, used only for help/usage rendering. */ +export type PositionalSpec = { + /** Display name, e.g. `"directory"`. Rendered as `[directory]` in usage. */ + name: string; + /** Description shown under the "Arguments" help section. */ + description: string; + /** Whether this positional accepts multiple values (`[directory...]`). */ + variadic?: boolean; +}; + +/** An example invocation shown under the "Examples" help section. */ +export type ExampleSpec = { + /** The full command line, e.g. `"vinext dev -p 4000"`. */ + command: string; + /** Optional explanation rendered alongside the command. */ + description?: string; +}; + +/** Maps a single {@link ArgSpec} to its parsed scalar value type. */ +type ScalarValue = S["type"] extends "string" ? string : number; + +/** + * Infers the shape of the parsed `values` object from an args spec. + * + * - `boolean` flags are always present (`false` when absent). + * - `multiple` flags become arrays. + * - flags with a `default` are always present. + * - all other value flags are `T | undefined`. + */ +export type InferValues> = { + [K in keyof A]: A[K]["type"] extends "boolean" + ? boolean + : A[K]["multiple"] extends true + ? ScalarValue[] + : A[K] extends { default: string | number } + ? ScalarValue + : ScalarValue | undefined; +}; + +/** The parsed, typed context handed to a command's `run` callback. */ +export type CommandContext> = { + /** Parsed and coerced flag values, keyed by arg name. */ + values: InferValues; + /** Positional arguments, in order, with consumed flag values removed. */ + positionals: string[]; +}; + +/** + * Declarative description of a CLI command. This is the single source of truth + * for argument parsing and help generation. + */ +export type CommandSpec = Record> = { + /** The subcommand name, e.g. `"dev"`. */ + name: string; + /** One-line summary shown in the top-level command list. */ + summary: string; + /** Longer description shown in the command's own `--help` output. */ + description?: string; + /** Overrides the default `vinext [options]` usage line. */ + usage?: string; + /** Flag definitions. A `--help`/`-h` flag is always injected automatically. */ + args?: A; + /** Positional argument definitions (help/usage only). */ + positionals?: PositionalSpec[]; + /** Example invocations shown under "Examples". */ + examples?: ExampleSpec[]; + /** Free-form trailing help text (e.g. notes about experimental flags). */ + notes?: string; + /** Command implementation. Receives the parsed, typed context. */ + run: (ctx: CommandContext) => void | Promise; +}; diff --git a/tests/cli-framework.test.ts b/tests/cli-framework.test.ts new file mode 100644 index 000000000..52392562c --- /dev/null +++ b/tests/cli-framework.test.ts @@ -0,0 +1,303 @@ +/** + * CLI command framework tests. + * + * Covers the spec-driven CLI framework under packages/vinext/src/cli/: + * - parseCommand: typed coercion (port/int/positiveInt/string/boolean), + * value validation (missing/empty/looks-like-a-flag), defaults, multiple, + * unknown-flag pass-through, positionals, and the auto-injected --help flag. + * - renderCommandHelp: help text generated from the same spec that drives + * parsing (single source of truth — no drift). + * - The real `dev` command spec: parsing + help, end to end. + * + * These mirror the legacy tests/cli-args.test.ts cases so the new engine keeps + * the same validation guarantees while sourcing help from the spec. + */ +import { describe, it, expect } from "vite-plus/test"; +import { parseCommand, CliUsageError } from "../packages/vinext/src/cli/parse.js"; +import { renderCommandHelp } from "../packages/vinext/src/cli/help.js"; +import type { ArgSpec, CommandSpec } from "../packages/vinext/src/cli/types.js"; +import { devCommand } from "../packages/vinext/src/cli/commands/dev.js"; + +// A representative spec exercising every value kind. +const spec = { + args: { + port: { type: "port", short: "p", description: "Port" }, + hostname: { type: "string", short: "H", description: "Host" }, + verbose: { type: "boolean", description: "Verbose" }, + concurrency: { type: "positiveInt", description: "Concurrency" }, + }, +} satisfies Pick; + +// ─── port: parsing ────────────────────────────────────────────────────────── + +describe("parseCommand — port", () => { + it("parses --port ", () => { + expect(parseCommand(spec, ["--port", "4000"]).values).toMatchObject({ port: 4000 }); + }); + + it("parses -p short form", () => { + expect(parseCommand(spec, ["-p", "4000"]).values).toMatchObject({ port: 4000 }); + }); + + it("parses --port=value", () => { + expect(parseCommand(spec, ["--port=8080"]).values).toMatchObject({ port: 8080 }); + }); + + it("parses port 0 and 65535 (bounds)", () => { + expect(parseCommand(spec, ["--port", "0"]).values).toMatchObject({ port: 0 }); + expect(parseCommand(spec, ["--port", "65535"]).values).toMatchObject({ port: 65535 }); + }); + + it("throws when --port has no value (end of args)", () => { + expect(() => parseCommand(spec, ["--port"])).toThrow( + "--port requires a value, but none was provided.", + ); + }); + + it("throws when -p has no value, using the typed short form", () => { + expect(() => parseCommand(spec, ["-p"])).toThrow("-p requires a value, but none was provided."); + }); + + it("throws when --port value looks like another flag", () => { + expect(() => parseCommand(spec, ["--port", "--hostname", "x"])).toThrow( + '--port requires a value, but got "--hostname" which looks like another flag.', + ); + }); + + it("throws when -p value looks like another short flag", () => { + expect(() => parseCommand(spec, ["-p", "-H", "x"])).toThrow( + '-p requires a value, but got "-H" which looks like another flag.', + ); + }); + + it("throws for non-numeric port (short form keeps -p in message)", () => { + expect(() => parseCommand(spec, ["-p", "abc"])).toThrow( + '-p expects an integer, but got "abc".', + ); + }); + + it("throws for trailing garbage (Number, not parseInt)", () => { + expect(() => parseCommand(spec, ["--port", "4000abc"])).toThrow( + '--port expects an integer, but got "4000abc".', + ); + }); + + it("throws for float port", () => { + expect(() => parseCommand(spec, ["--port", "4000.5"])).toThrow( + '--port expects an integer, but got "4000.5".', + ); + }); + + it("throws for out-of-range ports", () => { + expect(() => parseCommand(spec, ["--port", "65536"])).toThrow( + '--port expects a valid port (0-65535), but got "65536".', + ); + expect(() => parseCommand(spec, ["--port=-1"])).toThrow( + '--port expects a valid port (0-65535), but got "-1".', + ); + }); + + it("throws a CliUsageError instance", () => { + expect(() => parseCommand(spec, ["--port"])).toThrow(CliUsageError); + }); +}); + +// ─── hostname / string ──────────────────────────────────────────────────────── + +describe("parseCommand — string", () => { + it("parses a value (long, short, = forms)", () => { + expect(parseCommand(spec, ["--hostname", "0.0.0.0"]).values).toMatchObject({ + hostname: "0.0.0.0", + }); + expect(parseCommand(spec, ["-H", "localhost"]).values).toMatchObject({ hostname: "localhost" }); + expect(parseCommand(spec, ["--hostname=0.0.0.0"]).values).toMatchObject({ + hostname: "0.0.0.0", + }); + }); + + it("throws on missing/empty value", () => { + expect(() => parseCommand(spec, ["--hostname"])).toThrow( + "--hostname requires a value, but none was provided.", + ); + expect(() => parseCommand(spec, ["--hostname="])).toThrow( + "--hostname requires a value, but none was provided.", + ); + expect(() => parseCommand(spec, ["--hostname", ""])).toThrow( + "--hostname requires a value, but none was provided.", + ); + }); +}); + +// ─── positiveInt ────────────────────────────────────────────────────────────── + +describe("parseCommand — positiveInt", () => { + it("parses a positive integer", () => { + expect(parseCommand(spec, ["--concurrency", "4"]).values).toMatchObject({ concurrency: 4 }); + }); + + it("throws for zero and negatives", () => { + expect(() => parseCommand(spec, ["--concurrency", "0"])).toThrow( + '--concurrency expects a positive integer, but got "0".', + ); + expect(() => parseCommand(spec, ["--concurrency", "4.5"])).toThrow( + '--concurrency expects a positive integer, but got "4.5".', + ); + }); +}); + +// ─── booleans, defaults, multiple, unknowns, positionals ────────────────────── + +describe("parseCommand — booleans", () => { + it("is true when present, false when absent", () => { + expect(parseCommand(spec, ["--verbose"]).values).toMatchObject({ verbose: true }); + expect(parseCommand(spec, []).values).toMatchObject({ verbose: false }); + }); +}); + +describe("parseCommand — defaults", () => { + const withDefaults = { + args: { + port: { type: "port", description: "Port", default: 3000 }, + hostname: { type: "string", description: "Host", default: "localhost" }, + }, + } satisfies Pick; + + it("applies defaults when flags are absent", () => { + expect(parseCommand(withDefaults, []).values).toMatchObject({ + port: 3000, + hostname: "localhost", + }); + }); + + it("overrides defaults when flags are present", () => { + expect(parseCommand(withDefaults, ["--port", "5000"]).values).toMatchObject({ port: 5000 }); + }); +}); + +describe("parseCommand — multiple", () => { + const withMultiple = { + args: { tag: { type: "string", multiple: true, description: "Tags" } }, + } satisfies Pick; + + it("collects repeated flags into an array", () => { + expect(parseCommand(withMultiple, ["--tag", "a", "--tag", "b"]).values).toMatchObject({ + tag: ["a", "b"], + }); + }); + + it("defaults to an empty array when absent", () => { + expect(parseCommand(withMultiple, []).values.tag).toEqual([]); + }); +}); + +describe("parseCommand — unknowns & positionals", () => { + it("ignores unknown flags (drop-in next CLI friendliness)", () => { + // Unknown flags are dropped entirely; declared-but-absent flags are present + // as `undefined` (matching the InferValues contract), never mis-populated. + const result = parseCommand(spec, ["--unknown", "value"]); + expect(result.values).not.toHaveProperty("unknown"); + expect(result.values.port).toBeUndefined(); + }); + + it("collects positionals", () => { + expect(parseCommand(spec, ["apps/web"]).positionals).toEqual(["apps/web"]); + }); + + it("keeps positionals alongside flags without consuming flag values", () => { + const result = parseCommand(spec, ["--port", "4000", "apps/web", "--verbose"]); + expect(result.values).toMatchObject({ port: 4000, verbose: true }); + expect(result.positionals).toEqual(["apps/web"]); + }); +}); + +describe("parseCommand — help", () => { + it("recognizes --help / -h even when not declared", () => { + expect(parseCommand(spec, ["--help"]).values.help).toBe(true); + expect(parseCommand(spec, ["-h"]).values.help).toBe(true); + expect(parseCommand(spec, []).values.help).toBe(false); + }); +}); + +// ─── help rendering ─────────────────────────────────────────────────────────── + +describe("renderCommandHelp", () => { + const demo: CommandSpec = { + name: "demo", + summary: "Demo command", + description: "A longer description.", + args: { + port: { + type: "port", + short: "p", + valueHint: "port", + description: "Port to listen on", + default: 3000, + }, + flag: { type: "boolean", description: "A boolean flag" } satisfies ArgSpec, + }, + examples: [{ command: "vinext demo", description: "Run the demo" }], + run: () => {}, + }; + + const help = renderCommandHelp(demo); + + it("renders title, usage, and description", () => { + expect(help).toContain("vinext demo - Demo command"); + expect(help).toContain("Usage: vinext demo [options]"); + expect(help).toContain("A longer description."); + }); + + it("renders options with placeholders and defaults", () => { + expect(help).toContain("-p, --port "); + expect(help).toContain("Port to listen on"); + expect(help).toContain("(default: 3000)"); + expect(help).toContain("--flag"); + expect(help).toContain("A boolean flag"); + }); + + it("always documents the injected --help flag", () => { + expect(help).toContain("-h, --help"); + expect(help).toContain("Show this help"); + }); + + it("renders examples", () => { + expect(help).toContain("Examples:"); + expect(help).toContain("vinext demo"); + expect(help).toContain("Run the demo"); + }); + + it("omits a value placeholder for boolean flags", () => { + expect(help).not.toContain("--flag <"); + }); +}); + +// ─── real dev command ───────────────────────────────────────────────────────── + +describe("devCommand", () => { + it("parses port and hostname (long and short)", () => { + expect(parseCommand(devCommand, ["-p", "4000", "-H", "0.0.0.0"]).values).toMatchObject({ + port: 4000, + hostname: "0.0.0.0", + }); + }); + + it("applies dev defaults (port 3000, localhost, turbopack off)", () => { + expect(parseCommand(devCommand, []).values).toMatchObject({ + port: 3000, + hostname: "localhost", + turbopack: false, + }); + }); + + it("generates help documenting every flag (parse + help share one spec)", () => { + const help = renderCommandHelp(devCommand); + expect(help).toContain("vinext dev - Start development server"); + expect(help).toContain("-p, --port "); + expect(help).toContain("(default: 3000)"); + expect(help).toContain("-H, --hostname "); + expect(help).toContain("(default: localhost)"); + expect(help).toContain("--turbopack"); + expect(help).toContain("Accepted for compatibility"); + expect(help).toContain("-h, --help"); + }); +}); From a5d3a5201bc770e0524832ff1eb211a76646d773 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Jun 2026 15:48:26 +0100 Subject: [PATCH 2/4] refactor(cli): address review feedback and migrate build, start, init Review feedback from #1874: - Error on unknown flags by default instead of silently ignoring them, with a per-command `passthroughUnknown` opt-in for cases that need leniency. - Add a `hidden` flag option; hide the `--turbopack` no-op on `dev` and add a hidden `--experimental-https` no-op so `next dev` flags don't trip the new unknown-flag error. - Model ArgSpec as a discriminated union so the compiler rejects boolean+multiple and a truthy boolean default (both latent footguns flagged in review). - Hoist dev's lazy dynamic imports back to static module-level imports. Command migrations: - Migrate `build`, `start`, and `init` onto the framework (spec-driven parsing and help). Move createBuildLogger/hasPagesDir/loadBuildEmptyOutDir into the build command module and drop their printHelp templates + legacy parseArgs use. Tests: 100 total (48 framework + 52 legacy) covering unknown-flag errors, passthrough, hidden flags, dev compat flags, and build/start/init parse+help. --- packages/vinext/src/cli.ts | 499 +--------------------- packages/vinext/src/cli/commands/build.ts | 417 ++++++++++++++++++ packages/vinext/src/cli/commands/dev.ts | 28 +- packages/vinext/src/cli/commands/init.ts | 52 +++ packages/vinext/src/cli/commands/start.ts | 57 +++ packages/vinext/src/cli/help.ts | 10 +- packages/vinext/src/cli/parse.ts | 26 +- packages/vinext/src/cli/types.ts | 60 ++- tests/cli-framework.test.ts | 128 +++++- 9 files changed, 742 insertions(+), 535 deletions(-) create mode 100644 packages/vinext/src/cli/commands/build.ts create mode 100644 packages/vinext/src/cli/commands/init.ts create mode 100644 packages/vinext/src/cli/commands/start.ts diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index a70655a9a..006be67ac 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -14,36 +14,21 @@ * needed for most Next.js apps. */ -import vinext from "./index.js"; -import { runPrerender } from "./build/run-prerender.js"; import path from "node:path"; import fs from "node:fs"; import { execFileSync } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { detectPackageManager, hasAppDir, hasViteConfig } from "./utils/project.js"; +import { detectPackageManager } from "./utils/project.js"; import { deploy as runDeploy, parseDeployArgs } from "./deploy.js"; import { runCheck, formatReport } from "./check.js"; -import { init as runInit, getReactUpgradeDeps } from "./init.js"; import { loadDotenv } from "./config/dotenv.js"; -import { - createRscCompatibilityId, - loadNextConfig, - resolveNextConfig, - PHASE_PRODUCTION_BUILD, -} from "./config/next-config.js"; -import { emitStandaloneOutput } from "./build/standalone.js"; -import { cleanBuildOutput } from "./build/clean-output.js"; -import { resolveVinextPackageRoot } from "./utils/vinext-root.js"; +import { loadNextConfig, resolveNextConfig, PHASE_PRODUCTION_BUILD } from "./config/next-config.js"; import { parseArgs } from "./cli-args.js"; -import { - applyViteConfigCompatibility, - buildViteConfig, - getViteVersion, - loadVite, - type ViteModule, -} from "./cli/runtime.js"; +import { getViteVersion, loadVite } from "./cli/runtime.js"; import { runCommand } from "./cli/command.js"; import { devCommand } from "./cli/commands/dev.js"; +import { buildCommand } from "./cli/commands/build.js"; +import { startCommand } from "./cli/commands/start.js"; +import { initCommand } from "./cli/commands/init.js"; import { generateRouteTypes } from "./typegen.js"; // Vite is resolved from the project root at runtime — see ./cli/runtime.ts for @@ -58,390 +43,8 @@ const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.met const command = process.argv[2]; const rawArgs = process.argv.slice(3); -// ─── Build logger ───────────────────────────────────────────────────────────── - -/** - * Create a custom Vite logger for build output. - * - * By default Vite/Rollup emit a lot of build noise: version banners, progress - * lines, chunk size tables, minChunkSize diagnostics, and various internal - * warnings that are either not actionable or already handled by vinext at - * runtime. This logger suppresses all of that while keeping the things that - * actually matter: - * - * KEPT - * ✓ N modules transformed. — confirms the transform phase completed - * ✓ built in Xs — build timing (useful perf signal) - * Genuine warnings/errors — anything the user may need to act on - * - * SUPPRESSED (info) - * vite vX.Y.Z building... — Vite version banner - * transforming... / rendering chunks... / computing gzip size... - * Initially, there are N chunks... — Rollup minChunkSize diagnostics - * After merging chunks, there are... - * X are below minChunkSize. - * Blank lines - * Chunk/asset size table rows — e.g. " dist/client/assets/foo.js 42 kB" - * [rsc] / [ssr] / [client] / [worker] — RSC plugin env section headers - * - * SUPPRESSED (warn) - * "dynamic import will not move module into another chunk" — internal chunking note - * "X is not exported by virtual:vinext-*" — handled gracefully at runtime - */ -function createBuildLogger(vite: ViteModule): import("vite").Logger { - const logger = vite.createLogger("info", { allowClearScreen: false }); - const originalInfo = logger.info.bind(logger); - const originalWarn = logger.warn.bind(logger); - - // Strip ANSI escape codes for pattern matching (keep originals for output). - const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); // oxlint-disable-line no-control-regex - - logger.info = (msg: string, options?: import("vite").LogOptions) => { - const plain = strip(msg); - - // Always keep timing lines ("✓ built in 1.23s", "✓ 75 modules transformed."). - if (plain.trimStart().startsWith("✓")) { - originalInfo(msg, options); - return; - } - - // Vite version banner: "vite v6.x.x building for production..." - if (/^vite v\d/.test(plain.trim())) return; - - // Rollup progress noise: "transforming...", "rendering chunks...", "computing gzip size..." - if (/^(transforming|rendering chunks|computing gzip size)/.test(plain.trim())) return; - - // Rollup minChunkSize diagnostics: - // "Initially, there are\n36 chunks, of which\n..." - // "After merging chunks, there are\n..." - // "X are below minChunkSize." - if (/^(Initially,|After merging|are below minChunkSize)/.test(plain.trim())) return; - - // Blank / whitespace-only separator lines. - if (/^\s*$/.test(plain)) return; - - // Chunk/asset size table rows — e.g.: - // " dist/client/assets/foo.js 42.10 kB │ gzip: 6.74 kB" (TTY: indented) - // "dist/client/assets/foo.js 42.10 kB │ gzip: 6.74 kB" (non-TTY: at column 0) - // Both start with "dist/" (possibly preceded by whitespace). - if (/^\s*(dist\/|\.\/)/.test(plain)) return; - - // @vitejs/plugin-rsc environment section headers ("[rsc]", "[ssr]", "[client]", "[worker]"). - if (/^\s*\[(rsc|ssr|client|worker)\]/.test(plain)) return; - - originalInfo(msg, options); - }; - - logger.warn = (msg: string, options?: import("vite").LogOptions) => { - const plain = strip(msg); - - // Rollup: "dynamic import will not move module into another chunk" — this is - // emitted as a [plugin vite:reporter] warning with long absolute file paths. - // It's an internal chunking note, not actionable. - if (plain.includes("dynamic import will not move module into another chunk")) return; - - // Rollup: "X is not exported by Y" from virtual entry modules — these come from - // Rollup's static analysis of the generated virtual:vinext-server-entry when the - // user's middleware doesn't export the expected names. The vinext runtime handles - // missing exports gracefully, so this is noise. - if (plain.includes("is not exported by") && plain.includes("virtual:vinext")) return; - - originalWarn(msg, options); - }; - - return logger; -} - -// ─── Auto-configuration ─────────────────────────────────────────────────────── - -function hasPagesDir(): boolean { - return ( - fs.existsSync(path.join(process.cwd(), "pages")) || - fs.existsSync(path.join(process.cwd(), "src", "pages")) - ); -} - -async function loadBuildEmptyOutDir(vite: ViteModule, root: string): Promise { - if (!hasViteConfig(root)) return undefined; - - // Read the raw user config before the multi-environment build so - // `build.emptyOutDir: false` remains an escape hatch for vinext's upfront clean. - const loaded = await vite.loadConfigFromFile( - { command: "build", mode: "production" }, - undefined, - root, - ); - const emptyOutDir = loaded?.config.build?.emptyOutDir; - return typeof emptyOutDir === "boolean" ? emptyOutDir : undefined; -} - // ─── Commands ───────────────────────────────────────────────────────────────── -async function buildApp() { - const parsed = parseArgs(rawArgs); - if (parsed.help) return printHelp("build"); - - if (parsed.precompress) { - process.env.VINEXT_PRECOMPRESS = "1"; - } - - loadDotenv({ - root: process.cwd(), - mode: "production", - }); - - // Ensure "type": "module" in package.json before Vite loads vite.config.ts. - // Without this, Vite bundles the config as CJS and tries require() on pure-ESM - // packages like @cloudflare/vite-plugin, which fails on Node 22. - applyViteConfigCompatibility(process.cwd()); - - const vite = await loadVite(); - const viteMajorVersion = Number.parseInt(vite.version, 10) || 7; - - const withBuildBundlerOptions = (bundlerOptions: Record) => - viteMajorVersion >= 8 ? { rolldownOptions: bundlerOptions } : { rollupOptions: bundlerOptions }; - - console.log(`\n vinext build (Vite ${getViteVersion()})\n`); - - const root = process.cwd(); - const isApp = hasAppDir(process.cwd()); - const resolvedNextConfig = await resolveNextConfig( - await loadNextConfig(root, PHASE_PRODUCTION_BUILD), - root, - ); - - // Coordinate a single build ID across every vinext() plugin instance in this - // build. A hybrid app+pages build runs the App Router multi-environment build - // (buildApp) and a separate Pages Router SSR build (vite.build) as distinct - // plugin instances; without this, each resolves its own (potentially random) - // ID and the runtime, prerender manifest, and dist/server/BUILD_ID disagree. - // We resolve it once here — resolveNextConfig() already ran resolveBuildId() - // honoring the user's generateBuildId (including the null→UUID fallback) — and - // share that authoritative value via env so every plugin instance adopts it. - // - // Not cleaned up intentionally: `vinext build` runs once and the process - // exits, so there is no in-process reuse to leak into. The var is namespaced - // to vinext's build flow and is never read by dev or standalone resolveBuildId. - process.env.__VINEXT_SHARED_BUILD_ID = resolvedNextConfig.buildId; - - // Same coordination for the App Router RSC compatibility token. Without a - // pinned deploymentId, createRscCompatibilityId() mints a random UUID per - // plugin instance, so a hybrid app+pages build would bake two different - // compatibility tokens. Resolve it once and share it (see the plugin's - // adoption site). Reuses deploymentId when set (already stable across - // instances). - process.env.__VINEXT_SHARED_RSC_COMPATIBILITY_ID = createRscCompatibilityId(resolvedNextConfig); - - // On-demand ISR revalidation secret — the vinext analog of Next.js's - // prerender-manifest `previewModeId`. `res.revalidate()` loops back into the - // server via an internal `fetch()`; on Cloudflare Workers that loopback can - // land on a *different* isolate than the sender, so a per-process random - // secret would mismatch and false-reject legitimate revalidations. We instead - // generate one 256-bit secret here, once per build, and bake it (server-only) - // into every server bundle via Vite `define` so all isolates share the exact - // same value. Resolved once and shared across plugin instances exactly like - // __VINEXT_SHARED_BUILD_ID so a hybrid app+pages build bakes a single secret. - // A fresh secret per build means it rotates with every deployment. - if (!process.env.__VINEXT_SHARED_REVALIDATE_SECRET) { - process.env.__VINEXT_SHARED_REVALIDATE_SECRET = randomBytes(32).toString("hex"); - } - - const outputMode = resolvedNextConfig.output; - const distDir = path.resolve(root, "dist"); - - // Pre-flight check: verify vinext's own dist/ exists before starting the build. - // Without this, a missing dist/ (e.g. from a broken install) only surfaces after - // the full multi-minute Vite build completes, when emitStandaloneOutput runs. - if (outputMode === "standalone") { - const vinextDistDir = path.join(resolveVinextPackageRoot(), "dist"); - if (!fs.existsSync(vinextDistDir)) { - console.error( - ` Error: vinext dist/ not found at ${vinextDistDir}. Run \`pnpm run build\` in the vinext package first.`, - ); - process.exit(1); - } - } - - // In verbose mode, skip the custom logger so raw Vite/Rollup output is shown. - const logger = parsed.verbose - ? vite.createLogger("info", { allowClearScreen: false }) - : createBuildLogger(vite); - - // For App Router: upgrade React if needed for react-server-dom-webpack compatibility. - // Without this, builds with older React versions can produce a Worker that crashes at - // runtime with "Cannot read properties of undefined (reading 'moduleMap')". - if (isApp) { - const reactUpgrade = getReactUpgradeDeps(process.cwd()); - if (reactUpgrade.length > 0) { - const installCmd = detectPackageManager(process.cwd()).replace(/ -D$/, ""); - const [pm, ...pmArgs] = installCmd.split(" "); - console.log(" Upgrading React for RSC compatibility..."); - execFileSync(pm, [...pmArgs, ...reactUpgrade], { - cwd: process.cwd(), - stdio: "inherit", - shell: process.platform === "win32", - }); - } - } - - cleanBuildOutput({ - root, - outDir: distDir, - emptyOutDir: await loadBuildEmptyOutDir(vite, root), - }); - - // All paths (App Router, Pages Router + Cloudflare, Pages Router plain Node) - // use createBuilder + buildApp(). vinext() defines the appropriate environments - // in its config() hook for each case, so cloudflare() and the plain Node SSR - // build both work correctly. - const config = buildViteConfig({}, logger); - const builder = await vite.createBuilder(config); - await builder.buildApp(); - - if (isApp) { - // Hybrid app (both app/ and pages/ directories): also build the Pages Router - // SSR bundle so the prerender phase can render Pages Router routes. - // The App Router multi-env build (buildApp) doesn't include the Pages Router - // SSR entry, so we run it as a separate step here. - // We use configFile: false with vinext({ disableAppRouter: true }) to avoid - // loading the user's vite.config (which has vinext() without disableAppRouter) - // and to prevent the multi-env environments config from overriding our SSR - // input and entryFileNames. - if (hasPagesDir()) { - console.log(" Building Pages Router server (hybrid)..."); - // Inherit transform plugins from the user's vite.config (e.g. SVG loaders, - // CSS-in-JS) that vinext doesn't auto-register. We load the raw config via - // loadConfigFromFile — before any plugin config() hooks fire — so that - // cloudflare() hasn't yet injected its multi-env environments block. - // We then exclude the plugin families that vinext({ disableAppRouter: true }) - // will re-register itself, and cloudflare() which must not run here. - const root = process.cwd(); - let userTransformPlugins: import("vite").PluginOption[] = []; - if (hasViteConfig(process.cwd())) { - const loaded = await vite.loadConfigFromFile( - { command: "build", mode: "production", isSsrBuild: true }, - undefined, - root, - ); - if (loaded?.config.plugins) { - const flat = (loaded.config.plugins as unknown[]).flat(Infinity) as { - name?: string; - }[]; - userTransformPlugins = flat.filter( - (p): p is import("vite").Plugin => - !!p && - typeof p.name === "string" && - // vinext and its sub-plugins — re-registered below - !p.name.startsWith("vinext:") && - // @vitejs/plugin-react — auto-registered by vinext - !p.name.startsWith("vite:react") && - // @vitejs/plugin-rsc and its sub-plugins — App Router only - !p.name.startsWith("rsc:") && - p.name !== "vite-rsc-load-module-dev-proxy" && - // vite-tsconfig-paths — auto-registered by vinext - p.name !== "vite-tsconfig-paths" && - // cloudflare() — injects multi-env environments block which - // conflicts with the plain SSR build config below - !p.name.startsWith("vite-plugin-cloudflare"), - ); - } - } - await vite.build({ - root, - configFile: false, - plugins: [...userTransformPlugins, vinext({ disableAppRouter: true })], - resolve: { - dedupe: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], - }, - ...(logger ? { customLogger: logger } : {}), - build: { - outDir: "dist/server", - emptyOutDir: false, // preserve RSC artefacts from buildApp() - ssr: "virtual:vinext-server-entry", - ...withBuildBundlerOptions({ - output: { - entryFileNames: "entry.js", - }, - }), - }, - }); - } - } - - if (outputMode === "standalone") { - const standalone = emitStandaloneOutput({ - root: process.cwd(), - outDir: distDir, - }); - console.log( - ` Generated standalone output in ${path.relative(process.cwd(), standalone.standaloneDir)}/`, - ); - console.log(" Start it with: node dist/standalone/server.js\n"); - return process.exit(0); - } - - let prerenderResult; - const shouldPrerender = parsed.prerenderAll || resolvedNextConfig.output === "export"; - - if (shouldPrerender) { - // Enable Node.js built-in sourcemap support so prerender error stack - // traces resolve through the server bundle's sourcemaps to show original - // source files. Matches Next.js's enablePrerenderSourceMaps default. - if (resolvedNextConfig.enablePrerenderSourceMaps) { - process.setSourceMapsEnabled(true); - Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50); - } - const label = parsed.prerenderAll - ? "Pre-rendering all routes..." - : "Pre-rendering all routes (output: 'export')..."; - process.stdout.write("\x1b[0m"); - console.log(` ${label}`); - prerenderResult = await runPrerender({ - root: process.cwd(), - concurrency: parsed.prerenderConcurrency, - }); - } - - // Precompression runs as a Vite plugin writeBundle hook (vinext:precompress). - // Opt-in via --precompress CLI flag or `precompress: true` in plugin options. - - process.stdout.write("\x1b[0m"); - const { printBuildReport } = await import("./build/report.js"); - await printBuildReport({ - root: process.cwd(), - pageExtensions: resolvedNextConfig.pageExtensions, - prerenderResult: prerenderResult ?? undefined, - }); - - console.log("\n Build complete. Run `vinext start` to start the production server.\n"); - process.exit(0); -} - -async function start() { - const parsed = parseArgs(rawArgs); - if (parsed.help) return printHelp("start"); - - loadDotenv({ - root: process.cwd(), - mode: "production", - }); - - const port = parsed.port ?? parseInt(process.env.PORT ?? "3000", 10); - const host = parsed.hostname ?? "0.0.0.0"; - - console.log(`\n vinext start (port ${port})\n`); - - const { startProdServer } = (await import(/* @vite-ignore */ "./server/prod-server.js")) as { - startProdServer: (opts: { port: number; host: string; outDir: string }) => Promise; - }; - - await startProdServer({ - port, - host, - outDir: path.resolve(process.cwd(), "dist"), - }); -} - async function lint() { const parsed = parseArgs(rawArgs); if (parsed.help) return printHelp("lint"); @@ -557,68 +160,9 @@ async function typegen() { console.log(`\n Generated route types at ${path.relative(root, outputPath)}\n`); } -async function initCommand() { - const parsed = parseArgs(rawArgs); - if (parsed.help) return printHelp("init"); - - console.log(`\n vinext init\n`); - - // Parse init-specific flags - const port = parsed.port ?? 3001; - const skipCheck = rawArgs.includes("--skip-check"); - const force = rawArgs.includes("--force"); - - await runInit({ - root: process.cwd(), - port, - skipCheck, - force, - }); -} - // ─── Help ───────────────────────────────────────────────────────────────────── function printHelp(cmd?: string) { - if (cmd === "build") { - console.log(` - vinext build - Build for production - - Usage: vinext build [options] - - Automatically detects App Router (app/) or Pages Router (pages/) and - runs the appropriate multi-environment build via Vite. - If next.config sets output: "standalone", also emits dist/standalone/server.js. - - Options: - --verbose Show full Vite/Rollup build output (suppressed by default) - --prerender-all Pre-render discovered routes after building (future releases - will serve these files in vinext start) - --prerender-concurrency - Maximum number of routes to pre-render in parallel - --precompress Precompress static assets at build time (.br, .gz, .zst) - -h, --help Show this help -`); - return; - } - - if (cmd === "start") { - console.log(` - vinext start - Start production server - - Usage: vinext start [options] - - Serves the output from \`vinext build\`. Supports SSR, static files, - compression, and all middleware. - For output: "standalone", you can also run: node dist/standalone/server.js - - Options: - -p, --port Port to listen on (default: 3000, or PORT env) - -H, --hostname Hostname to bind to (default: 0.0.0.0) - -h, --help Show this help -`); - return; - } - if (cmd === "deploy") { console.log(` vinext deploy - Deploy to Cloudflare Workers @@ -685,31 +229,6 @@ function printHelp(cmd?: string) { return; } - if (cmd === "init") { - console.log(` - vinext init - Migrate a Next.js project to run under vinext - - Usage: vinext init [options] - - One-command migration: installs dependencies, configures ESM, - generates vite.config.ts, and adds npm scripts. Your Next.js - setup continues to work alongside vinext. - - Options: - -p, --port Dev server port for the vinext script (default: 3001) - --skip-check Skip the compatibility check step - --force Overwrite existing vite.config.ts - -h, --help Show this help - - Examples: - vinext init Migrate with defaults - vinext init -p 4000 Use port 4000 for dev:vinext - vinext init --force Overwrite existing vite.config.ts - vinext init --skip-check Skip the compatibility report -`); - return; - } - if (cmd === "typegen") { console.log(` vinext typegen - Generate App Router route helper types @@ -797,14 +316,14 @@ switch (command) { break; case "build": - buildApp().catch((e) => { + runCommand(buildCommand, rawArgs).catch((e) => { console.error(e); process.exit(1); }); break; case "start": - start().catch((e) => { + runCommand(startCommand, rawArgs).catch((e) => { console.error(e); process.exit(1); }); @@ -818,7 +337,7 @@ switch (command) { break; case "init": - initCommand().catch((e) => { + runCommand(initCommand, rawArgs).catch((e) => { console.error(e); process.exit(1); }); diff --git a/packages/vinext/src/cli/commands/build.ts b/packages/vinext/src/cli/commands/build.ts new file mode 100644 index 000000000..a92248264 --- /dev/null +++ b/packages/vinext/src/cli/commands/build.ts @@ -0,0 +1,417 @@ +/** + * `vinext build` — build for production. + * + * Detects App Router (`app/`) or Pages Router (`pages/`) and runs the + * appropriate multi-environment build via Vite. The command's flags and help + * text are both derived from the {@link CommandSpec} below. + */ + +import path from "node:path"; +import fs from "node:fs"; +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import vinext from "../../index.js"; +import { defineCommand } from "../command.js"; +import { + applyViteConfigCompatibility, + buildViteConfig, + getViteVersion, + loadVite, + type ViteModule, +} from "../runtime.js"; +import { detectPackageManager, hasAppDir, hasViteConfig } from "../../utils/project.js"; +import { getReactUpgradeDeps } from "../../init.js"; +import { loadDotenv } from "../../config/dotenv.js"; +import { + createRscCompatibilityId, + loadNextConfig, + resolveNextConfig, + PHASE_PRODUCTION_BUILD, +} from "../../config/next-config.js"; +import { emitStandaloneOutput } from "../../build/standalone.js"; +import { cleanBuildOutput } from "../../build/clean-output.js"; +import { runPrerender } from "../../build/run-prerender.js"; +import { resolveVinextPackageRoot } from "../../utils/vinext-root.js"; + +// ─── Build logger ─────────────────────────────────────────────────────────── + +/** + * Create a custom Vite logger for build output. + * + * By default Vite/Rollup emit a lot of build noise: version banners, progress + * lines, chunk size tables, minChunkSize diagnostics, and various internal + * warnings that are either not actionable or already handled by vinext at + * runtime. This logger suppresses all of that while keeping the things that + * actually matter: + * + * KEPT + * ✓ N modules transformed. — confirms the transform phase completed + * ✓ built in Xs — build timing (useful perf signal) + * Genuine warnings/errors — anything the user may need to act on + * + * SUPPRESSED (info) + * vite vX.Y.Z building... — Vite version banner + * transforming... / rendering chunks... / computing gzip size... + * Initially, there are N chunks... — Rollup minChunkSize diagnostics + * After merging chunks, there are... + * X are below minChunkSize. + * Blank lines + * Chunk/asset size table rows — e.g. " dist/client/assets/foo.js 42 kB" + * [rsc] / [ssr] / [client] / [worker] — RSC plugin env section headers + * + * SUPPRESSED (warn) + * "dynamic import will not move module into another chunk" — internal chunking note + * "X is not exported by virtual:vinext-*" — handled gracefully at runtime + */ +function createBuildLogger(vite: ViteModule): import("vite").Logger { + const logger = vite.createLogger("info", { allowClearScreen: false }); + const originalInfo = logger.info.bind(logger); + const originalWarn = logger.warn.bind(logger); + + // Strip ANSI escape codes for pattern matching (keep originals for output). + const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); // oxlint-disable-line no-control-regex + + logger.info = (msg: string, options?: import("vite").LogOptions) => { + const plain = strip(msg); + + // Always keep timing lines ("✓ built in 1.23s", "✓ 75 modules transformed."). + if (plain.trimStart().startsWith("✓")) { + originalInfo(msg, options); + return; + } + + // Vite version banner: "vite v6.x.x building for production..." + if (/^vite v\d/.test(plain.trim())) return; + + // Rollup progress noise: "transforming...", "rendering chunks...", "computing gzip size..." + if (/^(transforming|rendering chunks|computing gzip size)/.test(plain.trim())) return; + + // Rollup minChunkSize diagnostics: + // "Initially, there are\n36 chunks, of which\n..." + // "After merging chunks, there are\n..." + // "X are below minChunkSize." + if (/^(Initially,|After merging|are below minChunkSize)/.test(plain.trim())) return; + + // Blank / whitespace-only separator lines. + if (/^\s*$/.test(plain)) return; + + // Chunk/asset size table rows — e.g.: + // " dist/client/assets/foo.js 42.10 kB │ gzip: 6.74 kB" (TTY: indented) + // "dist/client/assets/foo.js 42.10 kB │ gzip: 6.74 kB" (non-TTY: at column 0) + // Both start with "dist/" (possibly preceded by whitespace). + if (/^\s*(dist\/|\.\/)/.test(plain)) return; + + // @vitejs/plugin-rsc environment section headers ("[rsc]", "[ssr]", "[client]", "[worker]"). + if (/^\s*\[(rsc|ssr|client|worker)\]/.test(plain)) return; + + originalInfo(msg, options); + }; + + logger.warn = (msg: string, options?: import("vite").LogOptions) => { + const plain = strip(msg); + + // Rollup: "dynamic import will not move module into another chunk" — this is + // emitted as a [plugin vite:reporter] warning with long absolute file paths. + // It's an internal chunking note, not actionable. + if (plain.includes("dynamic import will not move module into another chunk")) return; + + // Rollup: "X is not exported by Y" from virtual entry modules — these come from + // Rollup's static analysis of the generated virtual:vinext-server-entry when the + // user's middleware doesn't export the expected names. The vinext runtime handles + // missing exports gracefully, so this is noise. + if (plain.includes("is not exported by") && plain.includes("virtual:vinext")) return; + + originalWarn(msg, options); + }; + + return logger; +} + +function hasPagesDir(): boolean { + return ( + fs.existsSync(path.join(process.cwd(), "pages")) || + fs.existsSync(path.join(process.cwd(), "src", "pages")) + ); +} + +async function loadBuildEmptyOutDir(vite: ViteModule, root: string): Promise { + if (!hasViteConfig(root)) return undefined; + + // Read the raw user config before the multi-environment build so + // `build.emptyOutDir: false` remains an escape hatch for vinext's upfront clean. + const loaded = await vite.loadConfigFromFile( + { command: "build", mode: "production" }, + undefined, + root, + ); + const emptyOutDir = loaded?.config.build?.emptyOutDir; + return typeof emptyOutDir === "boolean" ? emptyOutDir : undefined; +} + +// ─── Command ────────────────────────────────────────────────────────────────── + +export const buildCommand = defineCommand({ + name: "build", + summary: "Build for production", + description: + "Automatically detects App Router (app/) or Pages Router (pages/) and runs the\n" + + "appropriate multi-environment build via Vite. If next.config sets\n" + + 'output: "standalone", also emits dist/standalone/server.js.', + args: { + verbose: { + type: "boolean", + description: "Show full Vite/Rollup build output (suppressed by default)", + }, + "prerender-all": { + type: "boolean", + description: "Pre-render discovered routes after building", + }, + "prerender-concurrency": { + type: "positiveInt", + valueHint: "count", + description: "Maximum number of routes to pre-render in parallel", + }, + precompress: { + type: "boolean", + description: "Precompress static assets at build time (.br, .gz, .zst)", + }, + }, + async run({ values }) { + if (values.precompress) { + process.env.VINEXT_PRECOMPRESS = "1"; + } + + loadDotenv({ + root: process.cwd(), + mode: "production", + }); + + // Ensure "type": "module" in package.json before Vite loads vite.config.ts. + // Without this, Vite bundles the config as CJS and tries require() on pure-ESM + // packages like @cloudflare/vite-plugin, which fails on Node 22. + applyViteConfigCompatibility(process.cwd()); + + const vite = await loadVite(); + const viteMajorVersion = Number.parseInt(vite.version, 10) || 7; + + const withBuildBundlerOptions = (bundlerOptions: Record) => + viteMajorVersion >= 8 + ? { rolldownOptions: bundlerOptions } + : { rollupOptions: bundlerOptions }; + + console.log(`\n vinext build (Vite ${getViteVersion()})\n`); + + const root = process.cwd(); + const isApp = hasAppDir(process.cwd()); + const resolvedNextConfig = await resolveNextConfig( + await loadNextConfig(root, PHASE_PRODUCTION_BUILD), + root, + ); + + // Coordinate a single build ID across every vinext() plugin instance in this + // build. A hybrid app+pages build runs the App Router multi-environment build + // (buildApp) and a separate Pages Router SSR build (vite.build) as distinct + // plugin instances; without this, each resolves its own (potentially random) + // ID and the runtime, prerender manifest, and dist/server/BUILD_ID disagree. + // We resolve it once here — resolveNextConfig() already ran resolveBuildId() + // honoring the user's generateBuildId (including the null→UUID fallback) — and + // share that authoritative value via env so every plugin instance adopts it. + // + // Not cleaned up intentionally: `vinext build` runs once and the process + // exits, so there is no in-process reuse to leak into. The var is namespaced + // to vinext's build flow and is never read by dev or standalone resolveBuildId. + process.env.__VINEXT_SHARED_BUILD_ID = resolvedNextConfig.buildId; + + // Same coordination for the App Router RSC compatibility token. Without a + // pinned deploymentId, createRscCompatibilityId() mints a random UUID per + // plugin instance, so a hybrid app+pages build would bake two different + // compatibility tokens. Resolve it once and share it (see the plugin's + // adoption site). Reuses deploymentId when set (already stable across + // instances). + process.env.__VINEXT_SHARED_RSC_COMPATIBILITY_ID = createRscCompatibilityId(resolvedNextConfig); + + // On-demand ISR revalidation secret — the vinext analog of Next.js's + // prerender-manifest `previewModeId`. `res.revalidate()` loops back into the + // server via an internal `fetch()`; on Cloudflare Workers that loopback can + // land on a *different* isolate than the sender, so a per-process random + // secret would mismatch and false-reject legitimate revalidations. We instead + // generate one 256-bit secret here, once per build, and bake it (server-only) + // into every server bundle via Vite `define` so all isolates share the exact + // same value. Resolved once and shared across plugin instances exactly like + // __VINEXT_SHARED_BUILD_ID so a hybrid app+pages build bakes a single secret. + // A fresh secret per build means it rotates with every deployment. + if (!process.env.__VINEXT_SHARED_REVALIDATE_SECRET) { + process.env.__VINEXT_SHARED_REVALIDATE_SECRET = randomBytes(32).toString("hex"); + } + + const outputMode = resolvedNextConfig.output; + const distDir = path.resolve(root, "dist"); + + // Pre-flight check: verify vinext's own dist/ exists before starting the build. + // Without this, a missing dist/ (e.g. from a broken install) only surfaces after + // the full multi-minute Vite build completes, when emitStandaloneOutput runs. + if (outputMode === "standalone") { + const vinextDistDir = path.join(resolveVinextPackageRoot(), "dist"); + if (!fs.existsSync(vinextDistDir)) { + console.error( + ` Error: vinext dist/ not found at ${vinextDistDir}. Run \`pnpm run build\` in the vinext package first.`, + ); + process.exit(1); + } + } + + // In verbose mode, skip the custom logger so raw Vite/Rollup output is shown. + const logger = values.verbose + ? vite.createLogger("info", { allowClearScreen: false }) + : createBuildLogger(vite); + + // For App Router: upgrade React if needed for react-server-dom-webpack compatibility. + // Without this, builds with older React versions can produce a Worker that crashes at + // runtime with "Cannot read properties of undefined (reading 'moduleMap')". + if (isApp) { + const reactUpgrade = getReactUpgradeDeps(process.cwd()); + if (reactUpgrade.length > 0) { + const installCmd = detectPackageManager(process.cwd()).replace(/ -D$/, ""); + const [pm, ...pmArgs] = installCmd.split(" "); + console.log(" Upgrading React for RSC compatibility..."); + execFileSync(pm, [...pmArgs, ...reactUpgrade], { + cwd: process.cwd(), + stdio: "inherit", + shell: process.platform === "win32", + }); + } + } + + cleanBuildOutput({ + root, + outDir: distDir, + emptyOutDir: await loadBuildEmptyOutDir(vite, root), + }); + + // All paths (App Router, Pages Router + Cloudflare, Pages Router plain Node) + // use createBuilder + buildApp(). vinext() defines the appropriate environments + // in its config() hook for each case, so cloudflare() and the plain Node SSR + // build both work correctly. + const config = buildViteConfig({}, logger); + const builder = await vite.createBuilder(config); + await builder.buildApp(); + + if (isApp) { + // Hybrid app (both app/ and pages/ directories): also build the Pages Router + // SSR bundle so the prerender phase can render Pages Router routes. + // The App Router multi-env build (buildApp) doesn't include the Pages Router + // SSR entry, so we run it as a separate step here. + // We use configFile: false with vinext({ disableAppRouter: true }) to avoid + // loading the user's vite.config (which has vinext() without disableAppRouter) + // and to prevent the multi-env environments config from overriding our SSR + // input and entryFileNames. + if (hasPagesDir()) { + console.log(" Building Pages Router server (hybrid)..."); + // Inherit transform plugins from the user's vite.config (e.g. SVG loaders, + // CSS-in-JS) that vinext doesn't auto-register. We load the raw config via + // loadConfigFromFile — before any plugin config() hooks fire — so that + // cloudflare() hasn't yet injected its multi-env environments block. + // We then exclude the plugin families that vinext({ disableAppRouter: true }) + // will re-register itself, and cloudflare() which must not run here. + const root = process.cwd(); + let userTransformPlugins: import("vite").PluginOption[] = []; + if (hasViteConfig(process.cwd())) { + const loaded = await vite.loadConfigFromFile( + { command: "build", mode: "production", isSsrBuild: true }, + undefined, + root, + ); + if (loaded?.config.plugins) { + const flat = (loaded.config.plugins as unknown[]).flat(Infinity) as { + name?: string; + }[]; + userTransformPlugins = flat.filter( + (p): p is import("vite").Plugin => + !!p && + typeof p.name === "string" && + // vinext and its sub-plugins — re-registered below + !p.name.startsWith("vinext:") && + // @vitejs/plugin-react — auto-registered by vinext + !p.name.startsWith("vite:react") && + // @vitejs/plugin-rsc and its sub-plugins — App Router only + !p.name.startsWith("rsc:") && + p.name !== "vite-rsc-load-module-dev-proxy" && + // vite-tsconfig-paths — auto-registered by vinext + p.name !== "vite-tsconfig-paths" && + // cloudflare() — injects multi-env environments block which + // conflicts with the plain SSR build config below + !p.name.startsWith("vite-plugin-cloudflare"), + ); + } + } + await vite.build({ + root, + configFile: false, + plugins: [...userTransformPlugins, vinext({ disableAppRouter: true })], + resolve: { + dedupe: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], + }, + ...(logger ? { customLogger: logger } : {}), + build: { + outDir: "dist/server", + emptyOutDir: false, // preserve RSC artefacts from buildApp() + ssr: "virtual:vinext-server-entry", + ...withBuildBundlerOptions({ + output: { + entryFileNames: "entry.js", + }, + }), + }, + }); + } + } + + if (outputMode === "standalone") { + const standalone = emitStandaloneOutput({ + root: process.cwd(), + outDir: distDir, + }); + console.log( + ` Generated standalone output in ${path.relative(process.cwd(), standalone.standaloneDir)}/`, + ); + console.log(" Start it with: node dist/standalone/server.js\n"); + return process.exit(0); + } + + let prerenderResult; + const shouldPrerender = values["prerender-all"] || resolvedNextConfig.output === "export"; + + if (shouldPrerender) { + // Enable Node.js built-in sourcemap support so prerender error stack + // traces resolve through the server bundle's sourcemaps to show original + // source files. Matches Next.js's enablePrerenderSourceMaps default. + if (resolvedNextConfig.enablePrerenderSourceMaps) { + process.setSourceMapsEnabled(true); + Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 50); + } + const label = values["prerender-all"] + ? "Pre-rendering all routes..." + : "Pre-rendering all routes (output: 'export')..."; + process.stdout.write("\x1b[0m"); + console.log(` ${label}`); + prerenderResult = await runPrerender({ + root: process.cwd(), + concurrency: values["prerender-concurrency"], + }); + } + + // Precompression runs as a Vite plugin writeBundle hook (vinext:precompress). + // Opt-in via --precompress CLI flag or `precompress: true` in plugin options. + + process.stdout.write("\x1b[0m"); + const { printBuildReport } = await import("../../build/report.js"); + await printBuildReport({ + root: process.cwd(), + pageExtensions: resolvedNextConfig.pageExtensions, + prerenderResult: prerenderResult ?? undefined, + }); + + console.log("\n Build complete. Run `vinext start` to start the production server.\n"); + process.exit(0); + }, +}); diff --git a/packages/vinext/src/cli/commands/dev.ts b/packages/vinext/src/cli/commands/dev.ts index 8f7058b40..5fc54bd2a 100644 --- a/packages/vinext/src/cli/commands/dev.ts +++ b/packages/vinext/src/cli/commands/dev.ts @@ -6,7 +6,18 @@ */ import { defineCommand } from "../command.js"; -import type { DevLockfile } from "../../server/dev-lockfile.js"; +import { + applyViteConfigCompatibility, + buildViteConfig, + getViteVersion, + loadVite, +} from "../runtime.js"; +import { loadDotenv } from "../../config/dotenv.js"; +import { + type DevLockfile, + formatAlreadyRunningError, + tryAcquireLockfile, +} from "../../server/dev-lockfile.js"; export const devCommand = defineCommand({ name: "dev", @@ -29,8 +40,14 @@ export const devCommand = defineCommand({ }, turbopack: { type: "boolean", + hidden: true, description: "Accepted for compatibility (no-op, Vite is always used)", }, + "experimental-https": { + type: "boolean", + hidden: true, + description: "Accepted for `next dev` compatibility (no-op)", + }, }, examples: [ { command: "vinext dev", description: "Start dev server on port 3000" }, @@ -40,15 +57,6 @@ export const devCommand = defineCommand({ const port = values.port; const host = values.hostname; - // Lazy-load the heavy runtime helpers (which pull in the full vinext Vite - // plugin) so importing this command module — e.g. for unit-testing its - // spec and help output — stays cheap and side-effect-free. - const { applyViteConfigCompatibility, buildViteConfig, getViteVersion, loadVite } = - await import("../runtime.js"); - const { loadDotenv } = await import("../../config/dotenv.js"); - const { formatAlreadyRunningError, tryAcquireLockfile } = - await import("../../server/dev-lockfile.js"); - loadDotenv({ root: process.cwd(), mode: "development", diff --git a/packages/vinext/src/cli/commands/init.ts b/packages/vinext/src/cli/commands/init.ts new file mode 100644 index 000000000..1f54b70a1 --- /dev/null +++ b/packages/vinext/src/cli/commands/init.ts @@ -0,0 +1,52 @@ +/** + * `vinext init` — migrate a Next.js project to run under vinext. + * + * One-command migration: installs dependencies, configures ESM, generates + * vite.config.ts, and adds npm scripts. The command's flags and help text are + * both derived from the {@link CommandSpec} below. + */ + +import { defineCommand } from "../command.js"; +import { init as runInit } from "../../init.js"; + +export const initCommand = defineCommand({ + name: "init", + summary: "Migrate a Next.js project to vinext", + description: + "One-command migration: installs dependencies, configures ESM, generates\n" + + "vite.config.ts, and adds npm scripts. Your Next.js setup continues to work\n" + + "alongside vinext.", + args: { + port: { + type: "port", + short: "p", + valueHint: "port", + description: "Dev server port for the vinext script", + default: 3001, + }, + "skip-check": { + type: "boolean", + description: "Skip the compatibility check step", + }, + force: { + type: "boolean", + description: "Overwrite existing vite.config.ts", + }, + }, + examples: [ + { command: "vinext init", description: "Migrate with defaults" }, + { command: "vinext init -p 4000", description: "Use port 4000 for dev:vinext" }, + { command: "vinext init --force", description: "Overwrite existing vite.config.ts" }, + { command: "vinext init --skip-check", description: "Skip the compatibility report" }, + ], + async run({ values }) { + console.log(`\n vinext init\n`); + + await runInit({ + root: process.cwd(), + port: values.port, + skipCheck: values["skip-check"], + force: values.force, + }); + }, +}); diff --git a/packages/vinext/src/cli/commands/start.ts b/packages/vinext/src/cli/commands/start.ts new file mode 100644 index 000000000..671156d70 --- /dev/null +++ b/packages/vinext/src/cli/commands/start.ts @@ -0,0 +1,57 @@ +/** + * `vinext start` — start the production server. + * + * Serves the output of `vinext build`. The command's flags and help text are + * both derived from the {@link CommandSpec} below. + */ + +import path from "node:path"; +import { defineCommand } from "../command.js"; +import { loadDotenv } from "../../config/dotenv.js"; + +export const startCommand = defineCommand({ + name: "start", + summary: "Start production server", + description: + "Serves the output from `vinext build`. Supports SSR, static files,\n" + + "compression, and all middleware.\n" + + 'For output: "standalone", you can also run: node dist/standalone/server.js', + args: { + port: { + type: "port", + short: "p", + valueHint: "port", + description: "Port to listen on (default: 3000, or PORT env)", + }, + hostname: { + type: "string", + short: "H", + valueHint: "host", + description: "Hostname to bind to", + default: "0.0.0.0", + }, + }, + async run({ values }) { + loadDotenv({ + root: process.cwd(), + mode: "production", + }); + + const port = values.port ?? parseInt(process.env.PORT ?? "3000", 10); + const host = values.hostname; + + console.log(`\n vinext start (port ${port})\n`); + + const { startProdServer } = (await import( + /* @vite-ignore */ "../../server/prod-server.js" + )) as { + startProdServer: (opts: { port: number; host: string; outDir: string }) => Promise; + }; + + await startProdServer({ + port, + host, + outDir: path.resolve(process.cwd(), "dist"), + }); + }, +}); diff --git a/packages/vinext/src/cli/help.ts b/packages/vinext/src/cli/help.ts index e6d613a7e..13a15f5f0 100644 --- a/packages/vinext/src/cli/help.ts +++ b/packages/vinext/src/cli/help.ts @@ -81,10 +81,12 @@ export function renderCommandHelp>(spec: Comma lines.push(""); lines.push(`${INDENT}${bold("Options:")}`); - const optionRows = Object.entries(args).map(([name, arg]) => ({ - label: optionLabel(name, arg), - description: `${arg.description}${defaultSuffix(arg)}`, - })); + const optionRows = Object.entries(args) + .filter(([, arg]) => !arg.hidden) + .map(([name, arg]) => ({ + label: optionLabel(name, arg), + description: `${arg.description}${defaultSuffix(arg)}`, + })); optionRows.push({ label: "-h, --help", description: "Show this help" }); lines.push(...renderRows(optionRows)); diff --git a/packages/vinext/src/cli/parse.ts b/packages/vinext/src/cli/parse.ts index 2050cc497..8d87a17f5 100644 --- a/packages/vinext/src/cli/parse.ts +++ b/packages/vinext/src/cli/parse.ts @@ -93,7 +93,7 @@ function coerceValue(raw: string, type: ValueArgType, flag: string): string | nu * flag, or fails type coercion (e.g. a non-integer `--port`). */ export function parseCommand>( - spec: Pick, "args">, + spec: Pick, "args" | "passthroughUnknown">, argv: string[], ): ParseResult { const args = (spec.args ?? {}) as Record; @@ -109,7 +109,7 @@ export function parseCommand>( options[name] = { type: arg.type === "boolean" ? "boolean" : "string", ...(arg.short ? { short: arg.short } : {}), - ...(arg.multiple ? { multiple: true } : {}), + ...(arg.type !== "boolean" && arg.multiple ? { multiple: true } : {}), }; } @@ -121,8 +121,9 @@ export function parseCommand>( args: argv, options, allowPositionals: true, - // Lenient: unknown flags are ignored rather than throwing, matching the - // drop-in `next` CLI behavior (people pass flags vinext doesn't model). + // Keep node:util lenient and enforce our own unknown-flag policy below, so + // we can honor each command's `passthroughUnknown` opt-in and raise a + // CliUsageError consistent with the rest of the framework. strict: false, tokens: true, }); @@ -136,7 +137,13 @@ export function parseCommand>( for (const token of tokens) { if (token.kind !== "option") continue; const arg = args[token.name]; - if (!arg || arg.type === "boolean") continue; + if (!arg) { + // `help` is always injected and accepted. Any other undeclared flag is a + // hard error unless the command opts into pass-through. + if (token.name === "help" || spec.passthroughUnknown) continue; + throw new CliUsageError(`Unknown option "${token.rawName}".`); + } + if (arg.type === "boolean") continue; const label = token.rawName; typedAs[token.name] = label; @@ -159,18 +166,19 @@ export function parseCommand>( const label = typedAs[name] ?? `--${name}`; if (arg.type === "boolean") { - result[name] = raw === true ? true : (arg.default ?? false); + // Boolean defaults are always false; a repeated boolean (node returns an + // array) still resolves to true. + result[name] = raw === true || (Array.isArray(raw) && raw.length > 0); continue; } if (arg.multiple) { const list = Array.isArray(raw) ? raw : []; - result[name] = list.map((v) => coerceValue(String(v), arg.type as ValueArgType, label)); + result[name] = list.map((v) => coerceValue(String(v), arg.type, label)); continue; } - result[name] = - typeof raw === "string" ? coerceValue(raw, arg.type as ValueArgType, label) : arg.default; + result[name] = typeof raw === "string" ? coerceValue(raw, arg.type, label) : arg.default; } return { diff --git a/packages/vinext/src/cli/types.ts b/packages/vinext/src/cli/types.ts index beedafe3b..4ce04f713 100644 --- a/packages/vinext/src/cli/types.ts +++ b/packages/vinext/src/cli/types.ts @@ -21,29 +21,54 @@ */ export type ArgType = "boolean" | "string" | "int" | "port" | "positiveInt"; -/** Declarative description of a single CLI flag. */ -export type ArgSpec = { - /** Value kind. Drives both parsing/validation and the help placeholder. */ - type: ArgType; +/** Fields shared by every flag kind. */ +type BaseArgSpec = { /** Single-letter short alias, without the dash (e.g. `"p"` for `-p`). */ short?: string; /** Human-readable description, shown in the command's `--help` output. */ description: string; + /** Hide this flag from `--help` output. It is still parsed and accepted. */ + hidden?: boolean; +}; + +/** + * A boolean flag (`--verbose`). Always present in the parsed values (`false` + * when absent). A truthy default is intentionally not representable — there is + * no negation (`--no-foo`) path — so only `default: false` is permitted, which + * is also the implicit default. + */ +export type BooleanArgSpec = BaseArgSpec & { + type: "boolean"; + default?: false; +}; + +/** A value-taking flag (`--port 3000`, or `--tag a --tag b` when `multiple`). */ +export type ValueArgSpec = BaseArgSpec & { + type: "string" | "int" | "port" | "positiveInt"; /** - * Placeholder shown in help for value-taking flags. Rendered wrapped in - * angle brackets, e.g. `valueHint: "port"` → `--port `. Ignored for - * `boolean` args. Defaults to the arg's type name when omitted. + * Placeholder shown in help, rendered wrapped in angle brackets, e.g. + * `valueHint: "port"` → `--port `. Defaults to the type name when + * omitted (`port` → ``, `string` → ``, integers → ``). */ valueHint?: string; /** * Default value applied at runtime when the flag is absent, and shown in - * help as `(default: …)`. For `boolean` args the default is always `false`. + * help as `(default: …)`. */ - default?: string | number | boolean; + default?: string | number; /** Allow the flag to be repeated; values are collected into an array. */ multiple?: boolean; }; +/** + * Declarative description of a single CLI flag. + * + * Modeled as a discriminated union on `type` so the compiler enforces that + * only value flags can declare `multiple`/`valueHint`/a value `default`, and a + * boolean flag cannot declare a truthy default. + */ +export type ArgSpec = BooleanArgSpec | ValueArgSpec; + /** A named positional argument, used only for help/usage rendering. */ export type PositionalSpec = { /** Display name, e.g. `"directory"`. Rendered as `[directory]` in usage. */ @@ -62,8 +87,8 @@ export type ExampleSpec = { description?: string; }; -/** Maps a single {@link ArgSpec} to its parsed scalar value type. */ -type ScalarValue = S["type"] extends "string" ? string : number; +/** Maps a value {@link ArgSpec} to its parsed scalar value type. */ +type ScalarValue = S extends { type: "string" } ? string : number; /** * Infers the shape of the parsed `values` object from an args spec. @@ -72,11 +97,14 @@ type ScalarValue = S["type"] extends "string" ? string : numb * - `multiple` flags become arrays. * - flags with a `default` are always present. * - all other value flags are `T | undefined`. + * + * Uses `extends { … }` rather than indexed access so it distributes correctly + * over the {@link ArgSpec} union (boolean specs have no `multiple`/`default`). */ export type InferValues> = { - [K in keyof A]: A[K]["type"] extends "boolean" + [K in keyof A]: A[K] extends { type: "boolean" } ? boolean - : A[K]["multiple"] extends true + : A[K] extends { multiple: true } ? ScalarValue[] : A[K] extends { default: string | number } ? ScalarValue @@ -106,6 +134,12 @@ export type CommandSpec = Record { }); }); -describe("parseCommand — unknowns & positionals", () => { - it("ignores unknown flags (drop-in next CLI friendliness)", () => { - // Unknown flags are dropped entirely; declared-but-absent flags are present - // as `undefined` (matching the InferValues contract), never mis-populated. - const result = parseCommand(spec, ["--unknown", "value"]); +describe("parseCommand — unknown flags", () => { + it("errors on an unknown long flag", () => { + expect(() => parseCommand(spec, ["--unknown", "value"])).toThrow('Unknown option "--unknown".'); + }); + + it("errors on an unknown short flag", () => { + expect(() => parseCommand(spec, ["-z"])).toThrow('Unknown option "-z".'); + }); + + it("throws a CliUsageError for unknown flags", () => { + expect(() => parseCommand(spec, ["--nope"])).toThrow(CliUsageError); + }); + + it("ignores unknown flags when passthroughUnknown is set", () => { + const lenient = { ...spec, passthroughUnknown: true }; + const result = parseCommand(lenient, ["--unknown", "value", "--port", "4000"]); expect(result.values).not.toHaveProperty("unknown"); - expect(result.values.port).toBeUndefined(); + expect(result.values).toMatchObject({ port: 4000 }); }); +}); +describe("parseCommand — positionals", () => { it("collects positionals", () => { expect(parseCommand(spec, ["apps/web"]).positionals).toEqual(["apps/web"]); }); @@ -269,6 +285,22 @@ describe("renderCommandHelp", () => { it("omits a value placeholder for boolean flags", () => { expect(help).not.toContain("--flag <"); }); + + it("hides flags marked hidden from the options list", () => { + const withHidden: CommandSpec = { + name: "demo", + summary: "Demo", + args: { + shown: { type: "boolean", description: "Visible flag" }, + secret: { type: "boolean", hidden: true, description: "Hidden flag" }, + }, + run: () => {}, + }; + const rendered = renderCommandHelp(withHidden); + expect(rendered).toContain("--shown"); + expect(rendered).not.toContain("--secret"); + expect(rendered).not.toContain("Hidden flag"); + }); }); // ─── real dev command ───────────────────────────────────────────────────────── @@ -289,15 +321,93 @@ describe("devCommand", () => { }); }); - it("generates help documenting every flag (parse + help share one spec)", () => { + it("accepts the hidden next-compat flags without erroring", () => { + expect(() => parseCommand(devCommand, ["--turbopack", "--experimental-https"])).not.toThrow(); + }); + + it("generates help documenting visible flags but hiding compat ones", () => { const help = renderCommandHelp(devCommand); expect(help).toContain("vinext dev - Start development server"); expect(help).toContain("-p, --port "); expect(help).toContain("(default: 3000)"); expect(help).toContain("-H, --hostname "); expect(help).toContain("(default: localhost)"); - expect(help).toContain("--turbopack"); - expect(help).toContain("Accepted for compatibility"); expect(help).toContain("-h, --help"); + // turbopack / experimental-https are hidden no-ops — accepted but not shown. + expect(help).not.toContain("--turbopack"); + expect(help).not.toContain("--experimental-https"); + }); +}); + +// ─── build / start / init commands ──────────────────────────────────────────── + +describe("buildCommand", () => { + it("parses build flags", () => { + expect( + parseCommand(buildCommand, ["--verbose", "--prerender-all", "--prerender-concurrency", "4"]) + .values, + ).toMatchObject({ + verbose: true, + "prerender-all": true, + "prerender-concurrency": 4, + }); + }); + + it("defaults boolean flags to false", () => { + expect(parseCommand(buildCommand, []).values).toMatchObject({ + verbose: false, + "prerender-all": false, + precompress: false, + }); + }); + + it("validates --prerender-concurrency as a positive integer", () => { + expect(() => parseCommand(buildCommand, ["--prerender-concurrency", "0"])).toThrow( + "positive integer", + ); + }); + + it("generates help from the spec", () => { + const help = renderCommandHelp(buildCommand); + expect(help).toContain("vinext build - Build for production"); + expect(help).toContain("--prerender-concurrency "); + expect(help).toContain("--precompress"); + }); +}); + +describe("startCommand", () => { + it("parses port/hostname and defaults hostname to 0.0.0.0", () => { + expect(parseCommand(startCommand, ["-p", "8080"]).values).toMatchObject({ + port: 8080, + hostname: "0.0.0.0", + }); + }); + + it("leaves port undefined when absent (run() applies the PORT-env fallback)", () => { + expect(parseCommand(startCommand, []).values.port).toBeUndefined(); + }); + + it("generates help from the spec", () => { + const help = renderCommandHelp(startCommand); + expect(help).toContain("vinext start - Start production server"); + expect(help).toContain("-p, --port "); + expect(help).toContain("(default: 0.0.0.0)"); + }); +}); + +describe("initCommand", () => { + it("parses flags and defaults port to 3001", () => { + expect(parseCommand(initCommand, ["--force", "--skip-check"]).values).toMatchObject({ + port: 3001, + force: true, + "skip-check": true, + }); + }); + + it("generates help with examples", () => { + const help = renderCommandHelp(initCommand); + expect(help).toContain("vinext init - Migrate a Next.js project to vinext"); + expect(help).toContain("--skip-check"); + expect(help).toContain("Examples:"); }); }); From fa8667828a9cde3c8fb0d3ccbf4454b2242cb476 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Jun 2026 11:21:00 +0100 Subject: [PATCH 3/4] refactor(cli): satisfy knip by un-exporting internal spec types; keep `next build --turbopack` compat - types.ts: ArgType, BooleanArgSpec, ValueArgSpec, PositionalSpec, ExampleSpec, and CommandContext are only referenced within the module, so stop exporting them (knip failed CI on the unused exports). ArgSpec, CommandSpec, and InferValues remain the public surface. - build: declare --turbopack as a hidden no-op (parity with dev). The legacy parser accepted it on every command and `next build --turbopack` is a real Next.js flag, so erroring on it would break drop-in usage. - tests: cover the hidden build flag; refresh the stale header comment. --- packages/vinext/src/cli/commands/build.ts | 5 +++++ packages/vinext/src/cli/types.ts | 14 +++++++------- tests/cli-framework.test.ts | 12 ++++++++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/cli/commands/build.ts b/packages/vinext/src/cli/commands/build.ts index a92248264..61496ec7e 100644 --- a/packages/vinext/src/cli/commands/build.ts +++ b/packages/vinext/src/cli/commands/build.ts @@ -175,6 +175,11 @@ export const buildCommand = defineCommand({ type: "boolean", description: "Precompress static assets at build time (.br, .gz, .zst)", }, + turbopack: { + type: "boolean", + hidden: true, + description: "Accepted for `next build` compatibility (no-op, Vite is always used)", + }, }, async run({ values }) { if (values.precompress) { diff --git a/packages/vinext/src/cli/types.ts b/packages/vinext/src/cli/types.ts index 4ce04f713..a493101bc 100644 --- a/packages/vinext/src/cli/types.ts +++ b/packages/vinext/src/cli/types.ts @@ -19,7 +19,7 @@ * - `port` — an integer in the valid TCP port range (0–65535). * - `positiveInt` — an integer greater than zero. */ -export type ArgType = "boolean" | "string" | "int" | "port" | "positiveInt"; +type ArgType = "boolean" | "string" | "int" | "port" | "positiveInt"; /** Fields shared by every flag kind. */ type BaseArgSpec = { @@ -37,14 +37,14 @@ type BaseArgSpec = { * no negation (`--no-foo`) path — so only `default: false` is permitted, which * is also the implicit default. */ -export type BooleanArgSpec = BaseArgSpec & { +type BooleanArgSpec = BaseArgSpec & { type: "boolean"; default?: false; }; /** A value-taking flag (`--port 3000`, or `--tag a --tag b` when `multiple`). */ -export type ValueArgSpec = BaseArgSpec & { - type: "string" | "int" | "port" | "positiveInt"; +type ValueArgSpec = BaseArgSpec & { + type: Exclude; /** * Placeholder shown in help, rendered wrapped in angle brackets, e.g. * `valueHint: "port"` → `--port `. Defaults to the type name when @@ -70,7 +70,7 @@ export type ValueArgSpec = BaseArgSpec & { export type ArgSpec = BooleanArgSpec | ValueArgSpec; /** A named positional argument, used only for help/usage rendering. */ -export type PositionalSpec = { +type PositionalSpec = { /** Display name, e.g. `"directory"`. Rendered as `[directory]` in usage. */ name: string; /** Description shown under the "Arguments" help section. */ @@ -80,7 +80,7 @@ export type PositionalSpec = { }; /** An example invocation shown under the "Examples" help section. */ -export type ExampleSpec = { +type ExampleSpec = { /** The full command line, e.g. `"vinext dev -p 4000"`. */ command: string; /** Optional explanation rendered alongside the command. */ @@ -112,7 +112,7 @@ export type InferValues> = { }; /** The parsed, typed context handed to a command's `run` callback. */ -export type CommandContext> = { +type CommandContext> = { /** Parsed and coerced flag values, keyed by arg name. */ values: InferValues; /** Positional arguments, in order, with consumed flag values removed. */ diff --git a/tests/cli-framework.test.ts b/tests/cli-framework.test.ts index f7d55014f..16f1abdf2 100644 --- a/tests/cli-framework.test.ts +++ b/tests/cli-framework.test.ts @@ -4,10 +4,12 @@ * Covers the spec-driven CLI framework under packages/vinext/src/cli/: * - parseCommand: typed coercion (port/int/positiveInt/string/boolean), * value validation (missing/empty/looks-like-a-flag), defaults, multiple, - * unknown-flag pass-through, positionals, and the auto-injected --help flag. + * unknown-flag errors (and the per-command passthroughUnknown opt-in), + * positionals, and the auto-injected --help flag. * - renderCommandHelp: help text generated from the same spec that drives * parsing (single source of truth — no drift). - * - The real `dev` command spec: parsing + help, end to end. + * - The real `dev`, `build`, `start`, and `init` command specs: parsing + + * help, end to end. * * These mirror the legacy tests/cli-args.test.ts cases so the new engine keeps * the same validation guarantees while sourcing help from the spec. @@ -367,11 +369,17 @@ describe("buildCommand", () => { ); }); + it("accepts the hidden --turbopack next-compat flag without erroring", () => { + expect(() => parseCommand(buildCommand, ["--turbopack"])).not.toThrow(); + }); + it("generates help from the spec", () => { const help = renderCommandHelp(buildCommand); expect(help).toContain("vinext build - Build for production"); expect(help).toContain("--prerender-concurrency "); expect(help).toContain("--precompress"); + // turbopack is a hidden no-op — accepted but not shown. + expect(help).not.toContain("--turbopack"); }); }); From 0664f8c63b69cb3d54e6396a75267c6bec02c123 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 10 Jun 2026 11:28:09 +0100 Subject: [PATCH 4/4] fix(cli): reject inline values on boolean flags; fix stale parse.ts docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse.ts: with strict: false, node:util stores `--verbose=true` as the string "true", which failed the `raw === true` check and silently resolved the flag to false — the opposite of what the user typed. Raise a CliUsageError instead, consistent with the strict-by-default policy. - parse.ts: the module docstring still described the pre-review "graceful pass-through of unknown flags" design; document the strict-by-default + passthroughUnknown opt-in policy instead. --- packages/vinext/src/cli/parse.ts | 18 ++++++++++++++++-- tests/cli-framework.test.ts | 10 ++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/cli/parse.ts b/packages/vinext/src/cli/parse.ts index 8d87a17f5..3214ffdfe 100644 --- a/packages/vinext/src/cli/parse.ts +++ b/packages/vinext/src/cli/parse.ts @@ -10,7 +10,9 @@ * value-taking flags error on missing/empty values and reject a following * token that "looks like another flag" (e.g. `--port --hostname`). * - Per-command defaults applied to the returned values. - * - Graceful pass-through of unknown flags (drop-in `next` CLI friendliness). + * - A strict-by-default unknown-flag policy: undeclared flags raise a + * {@link CliUsageError}, with a per-command `passthroughUnknown` opt-in + * for commands that must tolerate arbitrary pass-through flags. * * Parsing errors throw a {@link CliUsageError} so the caller can render them * cleanly without a stack trace. @@ -143,7 +145,19 @@ export function parseCommand>( if (token.name === "help" || spec.passthroughUnknown) continue; throw new CliUsageError(`Unknown option "${token.rawName}".`); } - if (arg.type === "boolean") continue; + if (arg.type === "boolean") { + // With `strict: false`, node:util accepts an inline value on a boolean + // flag and stores it as a *string* (`--verbose=true` → "true"), which + // would otherwise silently resolve to `false` below. Reject it instead — + // silently flipping an explicitly-passed value is exactly the kind of + // trap the strict-by-default policy is meant to prevent. + if (token.value !== undefined) { + throw new CliUsageError( + `${token.rawName} does not take a value, but got "${token.value}".`, + ); + } + continue; + } const label = token.rawName; typedAs[token.name] = label; diff --git a/tests/cli-framework.test.ts b/tests/cli-framework.test.ts index 16f1abdf2..80fac1962 100644 --- a/tests/cli-framework.test.ts +++ b/tests/cli-framework.test.ts @@ -157,6 +157,16 @@ describe("parseCommand — booleans", () => { expect(parseCommand(spec, ["--verbose"]).values).toMatchObject({ verbose: true }); expect(parseCommand(spec, []).values).toMatchObject({ verbose: false }); }); + + it("rejects an inline value on a boolean flag instead of silently dropping it", () => { + // node:util (strict: false) stores `--verbose=true` as the *string* "true", + // which would otherwise resolve to `false` — the opposite of what was typed. + expect(() => parseCommand(spec, ["--verbose=true"])).toThrow( + '--verbose does not take a value, but got "true".', + ); + expect(() => parseCommand(spec, ["--verbose=false"])).toThrow("does not take a value"); + expect(() => parseCommand(spec, ["--verbose="])).toThrow("does not take a value"); + }); }); describe("parseCommand — defaults", () => {