From 3523a322647f35b3aef7e814e65302e28ab43b91 Mon Sep 17 00:00:00 2001 From: cpojer Date: Wed, 18 Mar 2026 16:33:26 +0900 Subject: [PATCH] fix: Detect Vite+. --- docs/guide/app-types.md | 16 ++--- packages/void/src/cli/deploy.ts | 51 ++++++++++------ packages/void/src/cli/init.ts | 16 +++-- packages/void/src/cli/preset.ts | 58 +++++++++++++++---- .../void/test/unit/deploy-project.test.ts | 20 +++++++ packages/void/test/unit/preset.test.ts | 46 ++++++++++++++- 6 files changed, 165 insertions(+), 42 deletions(-) diff --git a/docs/guide/app-types.md b/docs/guide/app-types.md index 7a22e728d..3eedc6ef6 100644 --- a/docs/guide/app-types.md +++ b/docs/guide/app-types.md @@ -76,14 +76,14 @@ At the edge, the resolution order is: When running `void deploy` and no `inference.appType` is set in `void.json`, the detection logic runs in this order: -| Priority | Condition | Type | -| -------- | ----------------------------------------------------------------------------------------- | ----------------------------- | -| 1 | `--dir` flag | Static (or SPA with `--spa`) | -| 2 | Known SSG in dependencies (`vitepress`, `@docusaurus/core`) | Static, builds with SSG CLI | -| 3 | `@tanstack/react-start`, `@react-router/dev`, `@sveltejs/kit`, `nuxt`, or `astro` in deps | Framework | -| 4 | Backend files exist (`routes/`, `pages/`, `middleware/`, `crons/`, `queues/`, SSR entry) | Void app | -| 5 | `vite` in dependencies, no backend files | SPA, builds with `vite build` | -| 6 | `dist/index.html` or `./index.html` exists | Static (no build step) | +| Priority | Condition | Type | +| -------- | ----------------------------------------------------------------------------------------- | ------------------------------------------- | +| 1 | `--dir` flag | Static (or SPA with `--spa`) | +| 2 | Known SSG in dependencies (`vitepress`, `@docusaurus/core`) | Static, builds with SSG CLI | +| 3 | `@tanstack/react-start`, `@react-router/dev`, `@sveltejs/kit`, `nuxt`, or `astro` in deps | Framework | +| 4 | Backend files exist (`routes/`, `pages/`, `middleware/`, `crons/`, `queues/`, SSR entry) | Void app | +| 5 | `vite` or `vite-plus` in dependencies, no backend files | SPA, builds with `vite build` or `vp build` | +| 6 | `dist/index.html` or `./index.html` exists | Static (no build step) | ## Explicit Configuration diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index d4778a959..4d7156651 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -39,7 +39,14 @@ import { scanWebSocketRoutesSync } from "../router/websocket"; import { validateSsrEntry } from "../ssr/entry"; import { scanPages } from "../pages/scan"; import { collectPrerenderPaths, collectPrerenderPathsNode } from "../prerender"; -import { detectPreset, FRAMEWORK_PRESETS, type StaticPreset, type FrameworkPreset } from "./preset"; +import { + detectPreset, + FRAMEWORK_PRESETS, + formatProjectCommand, + resolveProjectCommand, + type StaticPreset, + type FrameworkPreset, +} from "./preset"; import { readConfig, resolveBindingNames, @@ -341,10 +348,13 @@ async function runFrameworkDeploy( const dialect = getDatabaseDialect(voidConfig); // 1. Build + const buildCmd = voidConfig.inference?.build ?? formatProjectCommand(root, fwPreset.buildCommand); if (!skipBuild) { - const buildCmd = voidConfig.inference?.build ?? fwPreset.buildCommand; log.step(`Building (${buildCmd})...`); - execSync(buildCmd, { cwd: root, stdio: "inherit" }); + execSync(voidConfig.inference?.build ?? resolveProjectCommand(root, fwPreset.buildCommand), { + cwd: root, + stdio: "inherit", + }); } // 2. Verify output paths @@ -362,9 +372,7 @@ async function runFrameworkDeploy( const redirectRules: RedirectRule[] = [...(fwRedirectRules ?? []), ...(cfgRedirectRules ?? [])]; if (!existsSync(workerDir)) { - log.error( - `Worker output not found at "${fwPreset.workerDir}". Run "${fwPreset.buildCommand}" first.`, - ); + log.error(`Worker output not found at "${fwPreset.workerDir}". Run "${buildCmd}" first.`); process.exit(1); } @@ -573,12 +581,10 @@ async function runFullDeploy( ): Promise { // 3. Build (child process to avoid Vite API side effects on bundle output) if (!skipBuild) { - log.step("Building..."); - const viteBin = fileURLToPath( - new URL("bin/vite.js", import.meta.resolve("vite/package.json")).href, - ); + const buildCmd = formatProjectCommand(root, "vite build"); + log.step(`Building (${buildCmd})...`); try { - execFileSync(process.execPath, [viteBin, "build"], { cwd: root, stdio: "inherit" }); + execSync(resolveProjectCommand(root, "vite build"), { cwd: root, stdio: "inherit" }); } catch (err: any) { process.exit(err.status ?? 1); } @@ -586,7 +592,10 @@ async function runFullDeploy( // 4. Resolve build output paths from build artifacts (avoids re-resolving // Vite config in the main process — plugins only run once inside the build) - const distDir = resolveDistDir(root, fwPreset); + const frameworkBuildCmd = fwPreset + ? formatProjectCommand(root, fwPreset.buildCommand) + : undefined; + const distDir = resolveDistDir(root, fwPreset, frameworkBuildCmd); // Detect non-default assetsDir from Vite client manifest for _headers generation let assetsPrefix = "assets"; @@ -679,7 +688,7 @@ async function runFullDeploy( process.exit(1); } } - const workerDirName = resolveWorkerDirName(distDir, fwPreset); + const workerDirName = resolveWorkerDirName(distDir, fwPreset, frameworkBuildCmd); const schedules = scanJobsSync(root).flatMap((job) => job.crons.map((cron) => ({ @@ -1137,7 +1146,11 @@ function loadDeployEnv(root: string): Record { * * For non-framework (Void app) deploys, defaults to "dist". */ -export function resolveDistDir(root: string, fwPreset?: FrameworkPreset): string { +export function resolveDistDir( + root: string, + fwPreset?: FrameworkPreset, + buildCommand?: string, +): string { if (fwPreset) { // Derive the top-level build dir from the preset's workerDir // e.g. "build/server" → "build", "dist/ssr" → "dist" @@ -1145,7 +1158,7 @@ export function resolveDistDir(root: string, fwPreset?: FrameworkPreset): string const distDir = join(root, topDir); if (!existsSync(distDir)) { throw new Error( - `Build output directory "${topDir}" not found. Run "${fwPreset.buildCommand}" first.`, + `Build output directory "${topDir}" not found. Run "${buildCommand ?? fwPreset.buildCommand}" first.`, ); } return distDir; @@ -1161,7 +1174,11 @@ export function resolveDistDir(root: string, fwPreset?: FrameworkPreset): string * * For non-framework (Void app) deploys, probes for known subdirectories. */ -export function resolveWorkerDirName(distDir: string, fwPreset?: FrameworkPreset): string { +export function resolveWorkerDirName( + distDir: string, + fwPreset?: FrameworkPreset, + buildCommand?: string, +): string { if (fwPreset) { // Derive the subdirectory from the preset's workerDir // e.g. "build/server" → "server", "dist/ssr" → "ssr" @@ -1169,7 +1186,7 @@ export function resolveWorkerDirName(distDir: string, fwPreset?: FrameworkPreset const subDir = parts.slice(1).join("/"); if (!existsSync(join(distDir, subDir))) { throw new Error( - `Worker output not found at "${fwPreset.workerDir}". Run "${fwPreset.buildCommand}" first.`, + `Worker output not found at "${fwPreset.workerDir}". Run "${buildCommand ?? fwPreset.buildCommand}" first.`, ); } return subDir; diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 77867427f..96cb92c8c 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -20,6 +20,7 @@ import { SCAFFOLD_GITIGNORE_LINES, type ScaffoldFramework, } from "./template"; +import { formatProjectCommand } from "./preset"; import { detectAgents, getAgentById, @@ -482,15 +483,15 @@ async function promptScaffoldFramework(): Promise { } /** Get the dev command for a detected framework. */ -function getDevCommand(framework?: DetectedFramework): string { - if (!framework) return "vite dev"; +function getDevCommand(root: string, framework?: DetectedFramework): string { + if (!framework) return formatProjectCommand(root, "vite dev"); switch (framework.name) { case "nuxt": return "nuxt dev"; case "astro": return "astro dev"; default: - return "vite dev"; + return formatProjectCommand(root, "vite dev"); } } @@ -732,8 +733,9 @@ function scaffoldPagesApp( applyDatabaseDialect(root, dialect); if (dialect === "postgresql") { + const devCommand = getDevCommand(root); note( - "Add DATABASE_URL to .env.local before running `vite dev`.\n" + + `Add DATABASE_URL to .env.local before running \`${devCommand}\`.\n` + "Void uses that direct connection for local PostgreSQL development.", "PostgreSQL local dev", ); @@ -745,6 +747,8 @@ function scaffoldPagesApp( } function scaffoldDemo(root: string): void { + const devCommand = getDevCommand(root); + // Migration mkdirSync(join(root, "db", "migrations"), { recursive: true }); writeFileSync( @@ -812,7 +816,7 @@ console.log(posts); note( "SQL migration → DB types → Route handler → Route types → Typed fetch\n\n" + - "Run `vite dev` and open src/demo.ts to see the types in action.\n" + + `Run \`${devCommand}\` and open src/demo.ts to see the types in action.\n` + "Try changing the migration or handler — types update automatically.", "Type Safety Demo", ); @@ -1118,5 +1122,5 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise } } - outro(`Run \`${getDevCommand(framework)}\` to start developing`); + outro(`Run \`${getDevCommand(root, framework)}\` to start developing`); } diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index 4cf9c88c5..c22355573 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -63,8 +63,9 @@ export interface FrameworkPreset { * Known output conventions for meta-frameworks. * Keyed by the framework name from `detectFramework()`. * - * Class A frameworks (tanstack-start, react-router, vinext) use `vite build` - * and have their output structure determined by the framework's Vite plugin. + * Class A frameworks (tanstack-start, react-router, vinext) use the project's + * Vite-compatible build command (`vite build` or `vp build`) and have their + * output structure determined by the framework's Vite plugin. * * Class B/C frameworks (sveltekit, nuxt, astro) use their own build CLI * and have framework-specific output conventions. @@ -126,6 +127,26 @@ export interface StaticPreset { appType: "spa" | "static"; } +/** + * Resolve a project-local command, rewriting `vite` to `vp` when the project + * depends on Vite+ and preferring node_modules/.bin when available. + */ +export function resolveProjectCommand(root: string, command: string): string { + const [bin, ...args] = command.trim().split(/\s+/); + const resolvedBin = localBin(root, resolveCommandBin(root, bin)); + return args.length > 0 ? `${resolvedBin} ${args.join(" ")}` : resolvedBin; +} + +/** + * Format a user-facing command string, rewriting `vite` to `vp` when the + * project depends on Vite+ without expanding to an absolute local bin path. + */ +export function formatProjectCommand(root: string, command: string): string { + const [bin, ...args] = command.trim().split(/\s+/); + const formattedBin = resolveCommandBin(root, bin); + return args.length > 0 ? `${formattedBin} ${args.join(" ")}` : formattedBin; +} + /** * Detect a static deploy preset based on the project root. * Returns null when the project requires a full worker deploy. @@ -152,10 +173,8 @@ export function detectPreset( const overrides = preset.resolve?.(root); const cmd = overrides?.buildCommand ?? preset.buildCommand; const out = overrides?.outputDir ?? preset.outputDir; - const bin = cmd.split(" ")[0]; - const args = cmd.slice(bin.length); return { - buildCommand: `${localBin(root, bin)}${args}`, + buildCommand: resolveProjectCommand(root, cmd), outputDir: resolve(root, out), appType: preset.appType, }; @@ -168,10 +187,10 @@ export function detectPreset( return null; } - // 4. Vite in deps → static SPA - if (pkg && hasVite(pkg)) { + // 4. Vite/Vite+ in deps → static SPA + if (pkg && hasViteLike(pkg)) { return { - buildCommand: `${localBin(root, "vite")} build`, + buildCommand: resolveProjectCommand(root, "vite build"), outputDir: resolve(root, "dist"), appType: "spa", }; @@ -206,6 +225,13 @@ function localBin(root: string, name: string): string { return existsSync(bin) ? bin : name; } +function resolveCommandBin(root: string, name: string): string { + if (name === "vite") { + return detectViteBinary(root); + } + return name; +} + function readPackageJson(root: string): Record | null { try { return JSON.parse(readFileSync(join(root, "package.json"), "utf-8")); @@ -221,8 +247,20 @@ function getAllDeps(pkg: Record): Record { }; } -function hasVite(pkg: Record): boolean { - return "vite" in getAllDeps(pkg); +function hasViteLike(pkg: Record): boolean { + const deps = getAllDeps(pkg); + return "vite" in deps || "vite-plus" in deps; +} + +function detectViteBinary(root: string): "vite" | "vp" { + const pkg = readPackageJson(root); + if (pkg) { + const deps = getAllDeps(pkg); + if ("vite-plus" in deps) { + return "vp"; + } + } + return "vite"; } /** diff --git a/packages/void/test/unit/deploy-project.test.ts b/packages/void/test/unit/deploy-project.test.ts index ea8cdffd3..2b8ea54f6 100644 --- a/packages/void/test/unit/deploy-project.test.ts +++ b/packages/void/test/unit/deploy-project.test.ts @@ -133,6 +133,15 @@ describe("resolveDistDir", () => { 'Build output directory "build" not found', ); }); + + it("uses the resolved build command in framework error messages", () => { + const root = mkdtempSync(join(tmpdir(), "void-test-")); + tempDirs.push(root); + + expect(() => resolveDistDir(root, FRAMEWORK_PRESETS.sveltekit, "vp build")).toThrow( + 'Run "vp build" first.', + ); + }); }); describe("resolveWorkerDirName", () => { @@ -181,6 +190,17 @@ describe("resolveWorkerDirName", () => { ); }); + it("uses the resolved build command when worker output is missing", () => { + const root = mkdtempSync(join(tmpdir(), "void-test-")); + tempDirs.push(root); + const dist = join(root, "dist"); + mkdirSync(dist, { recursive: true }); + + expect(() => resolveWorkerDirName(dist, FRAMEWORK_PRESETS.sveltekit, "vp build")).toThrow( + 'Run "vp build" first.', + ); + }); + it("throws when no worker dir exists without preset", () => { const root = mkdtempSync(join(tmpdir(), "void-test-")); tempDirs.push(root); diff --git a/packages/void/test/unit/preset.test.ts b/packages/void/test/unit/preset.test.ts index 3d3b45521..a3caa355f 100644 --- a/packages/void/test/unit/preset.test.ts +++ b/packages/void/test/unit/preset.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; -import { detectPreset } from "../../src/cli/preset"; +import { detectPreset, formatProjectCommand, resolveProjectCommand } from "../../src/cli/preset"; const TMP = join(import.meta.dirname, ".tmp-preset"); @@ -148,6 +148,18 @@ describe("detectPreset", () => { }); }); + it("detects Vite+ SPA (no backend files)", () => { + const root = setupProject({ + pkg: { devDependencies: { "vite-plus": "^1.0.0" } }, + }); + const result = detectPreset(root); + expect(result).toEqual({ + buildCommand: "vp build", + outputDir: resolve(root, "dist"), + appType: "spa", + }); + }); + it("detects pre-built dist/index.html as static", () => { const root = setupProject({ files: { "dist/index.html": "" }, @@ -180,3 +192,35 @@ describe("detectPreset", () => { expect(result).toBeNull(); }); }); + +describe("project Vite commands", () => { + it("formats Vite commands with vp when Vite+ is installed", () => { + const root = setupProject({ + pkg: { devDependencies: { "vite-plus": "^1.0.0" } }, + }); + + expect(formatProjectCommand(root, "vite dev")).toBe("vp dev"); + expect(formatProjectCommand(root, "vite build")).toBe("vp build"); + expect(formatProjectCommand(root, "vite preview")).toBe("vp preview"); + }); + + it("resolves Vite+ commands to the local vp binary", () => { + const root = setupProject({ + pkg: { devDependencies: { "vite-plus": "^1.0.0" } }, + files: { "node_modules/.bin/vp": "" }, + }); + + expect(resolveProjectCommand(root, "vite build")).toBe( + `${join(root, "node_modules", ".bin", "vp")} build`, + ); + }); + + it("keeps plain Vite commands unchanged", () => { + const root = setupProject({ + pkg: { devDependencies: { vite: "^6.0.0" } }, + }); + + expect(formatProjectCommand(root, "vite dev")).toBe("vite dev"); + expect(resolveProjectCommand(root, "vite build")).toBe("vite build"); + }); +});